From 6d24bdd8efd885e3fb124cd48e2d2543790274aa Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Thu, 29 Jan 2026 10:55:06 -0700 Subject: [PATCH 01/18] JOB-148890 Add `pnpm` support --- ...dd_pnpm_workspace_support_70e2a7a6.plan.md | 251 +++++++++ lib/library_version_analysis.rb | 1 + .../check_version_status.rb | 11 + lib/library_version_analysis/github.rb | 1 + lib/library_version_analysis/pnpm.rb | 432 ++++++++++++++++ spec/pnpm_spec.rb | 479 ++++++++++++++++++ 6 files changed, 1175 insertions(+) create mode 100644 .cursor/plans/add_pnpm_workspace_support_70e2a7a6.plan.md create mode 100644 lib/library_version_analysis/pnpm.rb create mode 100644 spec/pnpm_spec.rb diff --git a/.cursor/plans/add_pnpm_workspace_support_70e2a7a6.plan.md b/.cursor/plans/add_pnpm_workspace_support_70e2a7a6.plan.md new file mode 100644 index 0000000..51435dd --- /dev/null +++ b/.cursor/plans/add_pnpm_workspace_support_70e2a7a6.plan.md @@ -0,0 +1,251 @@ +--- +name: Add pnpm Workspace Support +overview: Add support for analyzing library versions in pnpm workspace repositories (like GetJobber/jobber-frontend) by creating a new Pnpm class that follows the existing pattern used by Npm and Gemfile classes. +todos: + - id: create-pnpm-class + content: Create lib/library_version_analysis/pnpm.rb with Pnpm class implementing get_versions, add_all_libraries, run_libyear, parse_libyear, add_dependency_graph, break_cycles, and add_ownerships with cascading workspace ownership support + status: pending + - id: register-pnpm + content: Add require statement to lib/library_version_analysis.rb and add case handler + go_pnpm method to check_version_status.rb + status: pending + - id: update-github-sources + content: Add pnpm to SOURCES hash in github.rb mapping to NPM ecosystem for Dependabot + status: pending + - id: create-pnpm-tests + content: Create spec/pnpm_spec.rb with tests for parsing, dependency graph building, and cascading ownership assignment (root defaults, workspace overrides, inheritance) + status: pending +isProject: false +--- + +# Add pnpm Workspace Support + +## Overview + +Extend the library version analysis tool to support pnpm workspaces. The existing codebase follows a strategy pattern where each package manager (npm, gemfile) implements a `get_versions(source)` method. Adding pnpm support follows the same pattern. + +## Key Discovery: libyear pnpm Support + +The `libyear` npm package already supports pnpm natively: + +- `--package-manager pnpm` - explicitly specify pnpm +- `--all` - include dependencies from the whole project (workspaces) +- `--json` - output JSON format + +Command: `pnpx libyear --package-manager pnpm --all --json` + +## Architecture + +```mermaid +flowchart TD + Entry[exe/analyze] --> CheckVersion[CheckVersionStatus.run] + CheckVersion --> Router{source param} + Router -->|npm| NpmClass[Npm.get_versions] + Router -->|gemfile| GemfileClass[Gemfile.get_versions] + Router -->|pnpm| PnpmClass[Pnpm.get_versions] + + PnpmClass --> AddLibs[add_all_libraries via pnpm list] + AddLibs --> RunLibyear[run_libyear via pnpx libyear] + RunLibyear --> ParseResults[parse_libyear] + ParseResults --> Dependabot[add_dependabot_findings] + Dependabot --> DepGraph[add_dependency_graph via pnpm list --json] + DepGraph --> Cycles[break_cycles] + Cycles --> Ownership[add_ownerships] +``` + +## Files to Modify/Create + +### 1. Create [lib/library_version_analysis/pnpm.rb](lib/library_version_analysis/pnpm.rb) (new file) + +Create a new class mirroring the structure of `Npm` class: + +```ruby +module LibraryVersionAnalysis + class Pnpm + include LibraryVersionAnalysis::Ownership + + def get_versions(source) + # Same flow as Npm: + # 1. add_all_libraries (via pnpm list --depth=Infinity) + # 2. run_libyear (via pnpx libyear --package-manager pnpm --all --json) + # 3. parse_libyear + # 4. add_dependabot_findings + # 5. add_dependency_graph (via pnpm list --json --depth=Infinity) + # 6. break_cycles + # 7. add_ownerships + end + end +end +``` + +Key differences from npm.rb: + +- Use `pnpm list --depth=Infinity` instead of `npm list --all` +- Use `pnpx libyear --package-manager pnpm --all --json` for libyear +- Use `pnpm list --json --depth=Infinity` for dependency graph +- Handle workspace structure in JSON parsing (pnpm has different JSON output format) + +### 2. Modify [lib/library_version_analysis.rb](lib/library_version_analysis.rb) + +Add require statement: + +```ruby +require "library_version_analysis/pnpm" +``` + +### 3. Modify [lib/library_version_analysis/check_version_status.rb](lib/library_version_analysis/check_version_status.rb) + +Add case handler and dispatcher method: + +```ruby +# In go method (around line 111): +case source +when "npm" + meta_data, mode = go_npm(spreadsheet_id, repository, source) +when "gemfile" + meta_data, mode = go_gemfile(spreadsheet_id, repository, source) +when "pnpm" # NEW + meta_data, mode = go_pnpm(spreadsheet_id, repository, source) +else + # ... +end + +# New dispatcher method: +def go_pnpm(spreadsheet_id, repository, source) + puts " pnpm" if LibraryVersionAnalysis.dev_output? + pnpm = Pnpm.new(repository) + meta_data, mode = get_version_summary(pnpm, "PnpmVersionData!A:Q", spreadsheet_id, repository, source) + return meta_data, mode +end +``` + +### 4. Modify [lib/library_version_analysis/github.rb](lib/library_version_analysis/github.rb) + +Add ecosystem mapping for Dependabot: + +```ruby +SOURCES = { + "npm": "NPM", + "gemfile": "RUBYGEMS", + "pnpm": "NPM", # pnpm uses npm registry +}.freeze +``` + +### 5. Create [spec/pnpm_spec.rb](spec/pnpm_spec.rb) (new file) + +Add tests following the pattern in `spec/npm_spec.rb`: + +- Test parsing of pnpm list output +- Test dependency graph building +- Test ownership assignment +- Test cycle breaking + +## Implementation Notes + +### pnpm list JSON format + +pnpm's JSON output differs from npm. Example structure: + +```json +[ + { + "name": "workspace-root", + "dependencies": { ... } + }, + { + "name": "@scope/package-a", + "path": "/path/to/packages/a", + "dependencies": { ... } + } +] +``` + +The parser needs to handle the array-of-packages format for workspaces. + +### Cascading Ownership from package.json Files + +Ownership follows a **cascading model** where workspace-level definitions override root-level defaults: + +```mermaid +flowchart TD + subgraph discovery [1. Discovery Phase] + DiscoverWorkspaces[pnpm list -r --depth=-1 --json] --> GetPaths[Get all workspace paths] + end + + subgraph collection [2. Ownership Collection] + ReadRoot[Read root package.json ownerships] --> RootMap[Root ownership map - defaults] + GetPaths --> ReadWorkspaces[Read each workspace package.json] + ReadWorkspaces --> WorkspaceMap[Workspace ownership maps] + end + + subgraph assignment [3. Assignment with Precedence] + WorkspaceMap --> CheckWorkspace{Dependency in workspace ownerships?} + CheckWorkspace -->|Yes| UseWorkspace[Use workspace owner] + CheckWorkspace -->|No| CheckRoot{Dependency in root ownerships?} + RootMap --> CheckRoot + CheckRoot -->|Yes| UseRoot[Use root owner as fallback] + CheckRoot -->|No| UseDefault[Use default_owner_name] + end +``` + +**Precedence rules:** + +1. **Workspace package.json wins** - If a workspace explicitly defines an owner for a dependency, that takes precedence +2. **Root package.json is fallback** - If workspace doesn't specify, fall back to root's ownership definition +3. **Default owner** - If neither defines it, use `Configuration.get(:default_owner_name)` + +**Example structure:** + +``` +monorepo/ +├── package.json # Root ownerships (defaults) +│ └── ownerships: { "lodash": "platform-team", "react": "frontend-team" } +├── pnpm-workspace.yaml +├── packages/ +│ ├── app-a/ +│ │ └── package.json # Can override: { "ownerships": { "lodash": "app-a-team" } } +│ └── app-b/ +│ └── package.json # No ownerships defined → inherits from root +``` + +In this example: + +- `lodash` in app-a → owned by `app-a-team` (workspace override) +- `lodash` in app-b → owned by `platform-team` (root fallback) +- `react` everywhere → owned by `frontend-team` (root default) + +**Implementation in pnpm.rb:** + +```ruby +def add_ownerships(parsed_results) + # 1. Collect all ownership definitions + root_ownerships = read_package_json_ownerships("package.json") + workspace_ownerships = collect_workspace_ownerships # Hash of { workspace_name => { dep => owner } } + + # 2. Apply ownerships with precedence + apply_cascading_ownerships(parsed_results, root_ownerships, workspace_ownerships) + + # 3. Transitive ownership (existing logic) + add_transitive_ownerships(parsed_results) + + # 4. Attention needed for vulnerabilities + add_attention_needed(parsed_results) +end + +def collect_workspace_ownerships + workspaces = discover_workspaces # via pnpm list -r --depth=-1 --json + ownerships = {} + + workspaces.each do |ws| + pkg_json_path = File.join(ws["path"], "package.json") + next unless File.exist?(pkg_json_path) + + ownerships[ws["name"]] = read_package_json_ownerships(pkg_json_path) + end + + ownerships +end +``` + +### CI/CD Considerations + +Similar to the npm implementation, the libyear command may need to be run separately before analysis to avoid memory issues. The output file would be `libyear_report.txt` (same as npm) or a pnpm-specific name like `pnpm_libyear_report.txt`. \ No newline at end of file diff --git a/lib/library_version_analysis.rb b/lib/library_version_analysis.rb index 6150ecf..b649726 100644 --- a/lib/library_version_analysis.rb +++ b/lib/library_version_analysis.rb @@ -3,6 +3,7 @@ require "library_version_analysis/github" require "library_version_analysis/gemfile" require "library_version_analysis/npm" +require "library_version_analysis/pnpm" require "library_version_analysis/version" require "library_version_analysis/slack_notify" require "pry-byebug" diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index cfa6f3b..88e7548 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -113,6 +113,8 @@ def go(spreadsheet_id:, repository:, source:) # rubocop:disable Metrics/AbcSize, meta_data, mode = go_npm(spreadsheet_id, repository, source) when "gemfile" meta_data, mode = go_gemfile(spreadsheet_id, repository, source) + when "pnpm" + meta_data, mode = go_pnpm(spreadsheet_id, repository, source) else puts "Don't recognize source #{source}" exit(-1) @@ -151,6 +153,15 @@ def go_npm(spreadsheet_id, repository, source) return meta_data, mode end + def go_pnpm(spreadsheet_id, repository, source) + puts " pnpm" if LibraryVersionAnalysis.dev_output? + pnpm = Pnpm.new(repository) + + meta_data, mode = get_version_summary(pnpm, "PnpmVersionData!A:Q", spreadsheet_id, repository, source) + + return meta_data, mode + end + def get_version_summary(parser, range, spreadsheet_id, repository, source) parsed_results, meta_data = parser.get_versions(source) mode = get_mode_summary(parsed_results, meta_data) diff --git a/lib/library_version_analysis/github.rb b/lib/library_version_analysis/github.rb index 9743edb..2d0a309 100644 --- a/lib/library_version_analysis/github.rb +++ b/lib/library_version_analysis/github.rb @@ -10,6 +10,7 @@ class Github SOURCES = { "npm": "NPM", "gemfile": "RUBYGEMS", + "pnpm": "NPM", }.freeze HTTP_ADAPTER = GraphQL::Client::HTTP.new(URL) do diff --git a/lib/library_version_analysis/pnpm.rb b/lib/library_version_analysis/pnpm.rb new file mode 100644 index 0000000..d5f7b72 --- /dev/null +++ b/lib/library_version_analysis/pnpm.rb @@ -0,0 +1,432 @@ +require "library_version_analysis/ownership" +require "library_version_analysis/configuration" + +module LibraryVersionAnalysis + class Pnpm + include LibraryVersionAnalysis::Ownership + + def initialize(github_repo) + @github_repo = github_repo + end + + def get_versions(source) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + all_libraries = {} + puts("\tPNPM adding all libraries") if LibraryVersionAnalysis.dev_output? + all_libraries = add_all_libraries + + puts("\tPNPM running libyear") if LibraryVersionAnalysis.dev_output? + + libyear_results = run_libyear + if libyear_results.nil? + warn "Running libyear produced no results. Exiting" + exit(-1) + end + + puts("\tPNPM parsing libyear") if LibraryVersionAnalysis.dev_output? + parsed_results, meta_data = parse_libyear(libyear_results, all_libraries) + + puts("\tPNPM dependabot") if LibraryVersionAnalysis.dev_output? + add_dependabot_findings(parsed_results, meta_data, @github_repo, source) + + puts("\tPNPM building dependency graph") if LibraryVersionAnalysis.dev_output? + add_dependency_graph(parsed_results) + + puts("\tPNPM breaking cycles") if LibraryVersionAnalysis.dev_output? + break_cycles(parsed_results) + + puts("\tPNPM adding ownerships") if LibraryVersionAnalysis.dev_output? + add_ownerships(parsed_results) + + puts("PNPM done") if LibraryVersionAnalysis.dev_output? + + return parsed_results, meta_data + end + + def add_dependabot_findings(parsed_results, meta_data, github_repo, source) + LibraryVersionAnalysis::Github.new.get_dependabot_findings(parsed_results, meta_data, github_repo, source) + end + + # used when building dependency graphs for upload + def add_dependency_graph(parsed_results) # rubocop:disable Metrics/MethodLength + results = run_pnpm_list + json = JSON.parse(results) + + @visited_nodes = [] + @parent_count = 0 + all_nodes = {} + + # pnpm list --json returns an array for workspaces + packages = json.is_a?(Array) ? json : [json] + + packages.each do |package| + all_nodes = build_dependency_graph(all_nodes, package["dependencies"], nil) + end + + missing_keys = {} # TODO: handle missing keys + all_nodes.each do |key, graph| + if parsed_results.has_key?(key) + parsed_results[key]["dependency_graph"] = graph + else + missing_keys[key] = graph + end + end + + puts "Created dependency graph for #{@parent_count} libraries" if LibraryVersionAnalysis.dev_output? + + return all_nodes + end + + private + + def run_libyear + # Ideally, we'd run the "pnpx libyear --package-manager pnpm --all --json" command from here. + # Works great in dev. On Circle, it gets sigkilled with a 137 error (out-of-memory). + # As a work-around, run libyear before analyze and then just read the output. + + results_file = "libyear_report.txt" + results = read_file(results_file, true) + + return results + end + + def read_file(path, check_time) + # With this file-read approach, we could be using old data. protect against that. + if !File.exist?(path) || (check_time && Time.now.utc - File.mtime(path) > 3600) # 1 hour + warn "Either could not find #{File.expand_path(path)} or it is more than 1 hour old. Run \"pnpx libyear --package-manager pnpm --all --json > libyear_report.txt\"" + exit(-1) + end + + return File.read(path) + end + + def run_libyear_open3 + cmd = "pnpx libyear --package-manager pnpm --all --json" + results, captured_err, status = Open3.capture3(cmd) + + if status.exitstatus != 0 + warn "pnpm status: #{status}" + warn "pnpm captured_err: #{captured_err}" + + return nil + end + + return results + end + + def add_all_libraries # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + all_libraries = {} + cmd = "pnpm list --depth=Infinity --silent" + + # ignore errors for this. It may fail, but we hopefully don't care + results, _captured_err, _status = Open3.capture3(cmd) + + results.each_line do |line| + next if line.include?("UNMET OPTIONAL DEPENDENCY") + + # pnpm list output format is slightly different from npm + # Example: ├── lodash 4.17.21 + scan_result = line.scan(/^.*?\s([@\w][^\s]+)\s([.\d]+)/) + + if scan_result.nil? || scan_result.empty? + # Try alternative format: ├── @scope/package@version + scan_result = line.scan(/^.*?\s([@\w].+)@([.\d]+)/) + end + + unless scan_result.nil? || scan_result.empty? + name = scan_result[0][0] + + vv = all_libraries[name] + if vv.nil? + vv = new_version_line(scan_result[0][1]) + all_libraries[name] = vv + else + vv.current_version = calculate_version(vv.current_version, scan_result[0][1]) + end + end + end + + return all_libraries + end + + def new_version_line(current_version) + Versionline.new( + owner: LibraryVersionAnalysis::Configuration.get(:default_owner_name), + current_version: current_version, + current_version_date: "", + latest_version_date: "" + ) + end + + def parse_libyear(results, all_libraries) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + data = JSON.parse(results) + + meta_data = create_blank_metadata + + data.each do |line| + drift = find_drift(line).round(1) + meta_data.total_age += drift + meta_data.total_major += line["major"] + meta_data.total_minor += line["minor"] + meta_data.total_patch += line["patch"] + + vv = all_libraries[line["dependency"]] + if vv.nil? + vv = new_version_line("") + all_libraries[line["dependency"]] = vv + end + + vv.latest_version = line["available"] + vv.major = line["major"] + vv.minor = line["minor"] + vv.patch = line["patch"] + vv.age = drift + end + + meta_data.total_age = meta_data.total_age.round(1) + meta_data.total_releases = data.count + + return all_libraries, meta_data + end + + def create_blank_metadata + meta_data = MetaData.new + meta_data.total_age = 0 + meta_data.total_major = 0 + meta_data.total_minor = 0 + meta_data.total_patch = 0 + meta_data + end + + def find_drift(line) + drift = line["drift"] + if drift.nil? + drift = 0 + else + drift = drift.round(2) + end + drift + end + + def add_ownerships(parsed_results) + # 1. Collect all ownership definitions with cascading support + root_ownerships = read_package_json_ownerships("package.json") + workspace_ownerships = collect_workspace_ownerships + + # 2. Apply ownerships with precedence (workspace > root > default) + apply_cascading_ownerships(parsed_results, root_ownerships, workspace_ownerships) + + # 3. Second pass for transitive ownership + add_transitive_ownerships(parsed_results) + + # 4. Third pass for attention needed + add_attention_needed(parsed_results) + end + + def read_package_json_ownerships(path) + return {} unless File.exist?(path) + + file_contents = File.read(path) + package_data = JSON.parse(file_contents) + package_data["ownerships"] || {} + rescue JSON::ParserError + {} + end + + def collect_workspace_ownerships + workspaces = discover_workspaces + ownerships = {} + + workspaces.each do |ws| + pkg_json_path = File.join(ws["path"], "package.json") + next unless File.exist?(pkg_json_path) + + ws_ownerships = read_package_json_ownerships(pkg_json_path) + ownerships[ws["name"]] = ws_ownerships unless ws_ownerships.empty? + end + + ownerships + end + + def discover_workspaces + # Use pnpm list to discover all workspaces + cmd = "pnpm list -r --depth=-1 --json" + results, _captured_err, status = Open3.capture3(cmd) + + return [] unless status.exitstatus.zero? + + json = JSON.parse(results) + # pnpm returns an array of workspace packages + json.is_a?(Array) ? json : [json] + rescue JSON::ParserError + [] + end + + def apply_cascading_ownerships(parsed_results, root_ownerships, workspace_ownerships) + # Build a map of which workspace depends on which library + library_to_workspace = build_library_workspace_map + + parsed_results.each do |name, line_data| + owner = nil + + # First check workspace-level ownerships (highest precedence) + workspace_name = library_to_workspace[name] + if workspace_name && workspace_ownerships[workspace_name] + owner = workspace_ownerships[workspace_name][name] + end + + # Fall back to root ownerships if no workspace-level ownership + owner ||= root_ownerships[name] + + # Apply owner if found + if owner + line_data.owner = owner + line_data.owner_reason = LibraryVersionAnalysis::Ownership::OWNER_REASON_ASSIGNED + end + end + end + + def build_library_workspace_map + # Map library names to their primary workspace + # This helps determine which workspace's ownerships to check first + library_map = {} + + results = run_pnpm_list_recursive + return library_map if results.nil? + + begin + json = JSON.parse(results) + packages = json.is_a?(Array) ? json : [json] + + packages.each do |package| + workspace_name = package["name"] + next if workspace_name.nil? + + collect_dependencies_for_workspace(package["dependencies"], workspace_name, library_map) + end + rescue JSON::ParserError + # Return empty map on parse error + end + + library_map + end + + def collect_dependencies_for_workspace(dependencies, workspace_name, library_map) + return if dependencies.nil? + + dependencies.each do |name, _dep_info| + # Only assign if not already assigned (first workspace wins) + library_map[name] ||= workspace_name + end + end + + def run_pnpm_list_recursive + cmd = "pnpm list -r --json --depth=0" + results, _captured_err, status = Open3.capture3(cmd) + + return nil unless status.exitstatus.zero? + + results + end + + def calculate_version(current_version, new_version) + return "" if current_version.nil? || current_version.empty? + + left, right = current_version.split("..") + if right.nil? + if left == new_version # rubocop:disable Style/GuardClause + return current_version + else + right = left + end + end + + if new_version < left # rubocop:disable Style/GuardClause + return "#{new_version}..#{right}" + elsif new_version > right + return "#{left}..#{new_version}" + else + return current_version + end + end + + # Recursive method used when building dependency graph for upload + def build_dependency_graph(all_nodes, new_nodes, parents, depth = 0) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + return all_nodes if new_nodes.nil? + + new_nodes.keys.each do |name| + parent = all_nodes[name] + if parent.nil? + @parent_count += 1 + parent = LibNode.new(name: name, parents: parents.nil? ? nil : [parents]) + all_nodes[name] = parent + end + + existing_parents = parent.parents + if existing_parents.nil? && !parents.nil? + existing_parents = [] + parent.parents = existing_parents + end + + existing_parents.push(parents) if !parents.nil? && !existing_parents.include?(parents) + + all_nodes = build_dependency_graph(all_nodes, new_nodes[name]["dependencies"], parent, depth + 1) + + @visited_nodes.pop + end + + return all_nodes + end + + def push_unique(node) + return nil if @visited_nodes.include?(node) + + @visited_nodes.push(node) + end + + def break_cycles(parsed_results) + # Do a depth first pre-order traversal of the dependency graphs. Keep nodes on stack, if a node + # is already on the stack, then we have a cycle. Remove the cycle by removing the parent from the + # dependency graph. + parsed_results.each do |_, line_data| + next if line_data["dependency_graph"].nil? + + @visited_nodes = [] + break_cycles_for_graph(line_data["dependency_graph"]) + end + end + + def break_cycles_for_graph(node) + return if node.nil? + + if push_unique(node.name).nil? + puts "\t\tCycle detected: #{node.name}" if LibraryVersionAnalysis.dev_output? + return true + end + + new_parents = [] + node.parents&.each do |parent| + cycle_found = break_cycles_for_graph(parent) + new_parents.append(parent) unless cycle_found + end + node.parents = new_parents + + @visited_nodes.pop + return false + end + + def run_pnpm_list + cmd = "pnpm list --json --depth=Infinity --silent" + results, _, status = Open3.capture3(cmd) + + if status.exitstatus != 0 + begin + parsed = JSON.parse(results) + warn "error while running pnpm list: #{parsed['error']}" + rescue JSON::ParserError + warn "error while running pnpm list" + end + end + results + end + end +end diff --git a/spec/pnpm_spec.rb b/spec/pnpm_spec.rb new file mode 100644 index 0000000..849004b --- /dev/null +++ b/spec/pnpm_spec.rb @@ -0,0 +1,479 @@ +Status = Struct.new(:exitstatus) unless defined?(Status) + +RSpec.describe LibraryVersionAnalysis::Pnpm do + let(:libyear_file) do + <<~DOC + [ + {"dependency": "@apollo/client","drift":0.8213552361396304,"pulse":0.02737850787132101,"releases":34,"major":0,"minor":2,"patch":32,"available":"3.5.10"}, + {"dependency":"@babel/polyfill","drift":1.8179329226557153,"pulse":1.3880903490759753,"releases":12,"major":0,"minor":50,"patch":5,"available":"7.12.1"}, + {"dependency":"@ctrl/ts-base32","drift":0.9965776865160849,"pulse":0.6078028747433265,"releases":7,"major":1,"minor":1,"patch":5,"available":"2.1.1"}, + {"dependency":"@cubejs-client/core","drift":1.2019164955509924,"pulse":0.008213552361396304,"releases":58,"major":0,"minor":5,"patch":53,"available":"0.29.29"}, + {"dependency":"@flatfile/adapter","drift":0.9609856262833676,"pulse":0.2600958247775496,"releases":26,"major":2,"minor":7,"patch":19,"available":"2.9.6"}, + {"dependency":"@flatfile/react","drift":0.8350444900752909,"pulse":0.2655715263518138,"releases":16,"major":2,"minor":3,"patch":12,"available":"3.0.1"}, + {"dependency":"@fullcalendar/core","drift":1.7248459958932238,"pulse":0.3394934976043806,"releases":18,"major":1,"minor":10,"patch":7,"available":"5.10.1"}, + {"dependency":"@fullcalendar/daygrid","drift":1.7248459958932238,"pulse":0.3394934976043806,"releases":18,"major":1,"minor":10,"patch":7,"available":"5.10.1"}, + {"dependency":"lodash","drift":1.7248459958932238,"pulse":0.3394934976043806,"releases":18,"major":6,"minor":10,"patch":7,"available":"5.10.1"} + ] + DOC + end + + let(:root_package_file) do + <<~DOC + { + "name": "workspace-root", + "ownerships": { + "@apollo/client": ":api_platform", + "@formatjs/intl-displaynames": ":core", + "@ctrl/ts-base32": ":core", + "@cubejs-client/core": ":core", + "@flatfile/react": ":api_platform", + "@fullcalendar/core": ":api_platform", + "@fullcalendar/daygrid": ":api_platform", + "lodash": ":platform_team" + } + } + DOC + end + + def do_compare(result:, owner:, current_version:, latest_version:, major:, minor:, patch:, age:) # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists + expect(result[:owner]).to eq(owner) + expect(result[:current_version]).to eq(current_version) + expect(result[:latest_version]).to eq(latest_version) + expect(result[:major]).to eq(major) + expect(result[:minor]).to eq(minor) + expect(result[:patch]).to eq(patch) + expect(result[:age]).to eq(age) + end + + context "with pnpm workspace" do + subject do + analyzer = LibraryVersionAnalysis::Pnpm.new("test") + allow(analyzer).to receive(:read_file).with("libyear_report.txt", true).and_return(libyear_file) + allow(analyzer).to receive(:read_package_json_ownerships).with("package.json").and_return(JSON.parse(root_package_file)["ownerships"]) + allow(analyzer).to receive(:run_pnpm_list).and_return(pnpm_list) + allow(analyzer).to receive(:run_pnpm_list_recursive).and_return(pnpm_list_recursive) + allow(analyzer).to receive(:discover_workspaces).and_return([]) + allow(analyzer).to receive(:add_dependabot_findings).and_return(nil) + allow(analyzer).to receive(:add_all_libraries).and_return({}) + + analyzer.get_versions("test") + end + + let(:pnpm_list) do + <<~DOC + [ + { + "name": "workspace-root", + "version": "1.0.0", + "dependencies": { + "@apollo/client": { + "version": "3.3.16", + "dependencies": { + "@babel/polyfill": { + "version": "2.2.0" + } + } + }, + "@cubejs-client/core": { + "version": "0.4.0", + "dependencies": { + "@flatfile/adapter": { + "version": "2.2.0" + } + } + } + } + } + ] + DOC + end + + let(:pnpm_list_recursive) do + <<~DOC + [ + { + "name": "workspace-root", + "dependencies": { + "@apollo/client": {}, + "@babel/polyfill": {}, + "lodash": {} + } + } + ] + DOC + end + + it "should get expected data for owned library" do + do_compare( + result: subject[0]["@apollo/client"], + owner: ":api_platform", + current_version: "", + latest_version: "3.5.10", + major: 0, + minor: 2, + patch: 32, + age: 0.8 + ) + end + + it "should return expected data for transitive" do + do_compare( + result: subject[0]["@babel/polyfill"], + owner: ":api_platform", + current_version: "", + latest_version: "7.12.1", + major: 0, + minor: 50, + patch: 5, + age: 1.8 + ) + end + + it "should calculate expected meta_data" do + expect(subject[1].total_age).to eq(11.7) + expect(subject[1].total_releases).to eq(9) + expect(subject[1].total_major).to eq(13) + expect(subject[1].total_minor).to eq(98) + expect(subject[1].total_patch).to eq(147) + end + end + + describe "#add_dependency_graph" do + let(:pnpm_short_list) do + <<~DOC + [ + { + "name": "workspace-root", + "version": "1.0.0", + "dependencies": { + "a": { + "version": "1.1.35", + "dependencies": { + "b": { + "version": "1.16.2", + "dependencies": { + "c": { + "version": "1.16.2" + } + } + } + } + } + } + } + ] + DOC + end + + let(:pnpm_multi_parent) do + <<~DOC + [ + { + "name": "workspace-root", + "version": "1.0.0", + "dependencies": { + "a": { + "version": "1.1.35", + "dependencies": { + "b": { + "version": "1.16.2" + } + } + }, + "c": { + "version": "1.1.36", + "dependencies": { + "b": { + "version": "1.16.2" + } + } + } + } + } + ] + DOC + end + + let(:pnpm_multi_workspace) do + <<~DOC + [ + { + "name": "workspace-a", + "dependencies": { + "a": { + "version": "1.1.35", + "dependencies": { + "b": { + "version": "1.16.2" + } + } + } + } + }, + { + "name": "workspace-b", + "dependencies": { + "c": { + "version": "1.1.36", + "dependencies": { + "d": { + "version": "1.16.2" + } + } + } + } + } + ] + DOC + end + + it "should reverse simple chain" do + parsed_results = { "a" => {}, "b" => {}, "c" => {} } + + analyzer = LibraryVersionAnalysis::Pnpm.new("test") + allow(analyzer).to receive(:run_pnpm_list).and_return(pnpm_short_list) + + result = analyzer.add_dependency_graph(parsed_results) + + expect(result.count).to eq(3) + c = result["c"] + expect(c.parents[0].name).to eq("b") + b = result["c"].parents[0] + expect(b.parents[0].name).to eq("a") + a = result["c"].parents[0].parents[0] + expect(a.parents).to be_nil + end + + it "should handle multiple parents" do + parsed_results = { "a" => {}, "b" => {}, "c" => {} } + + analyzer = LibraryVersionAnalysis::Pnpm.new("test") + allow(analyzer).to receive(:run_pnpm_list).and_return(pnpm_multi_parent) + result = analyzer.add_dependency_graph(parsed_results) + + b = result["b"] + expect(b.parents.count).to eq(2) + end + + it "should handle multiple workspaces" do + parsed_results = { "a" => {}, "b" => {}, "c" => {}, "d" => {} } + + analyzer = LibraryVersionAnalysis::Pnpm.new("test") + allow(analyzer).to receive(:run_pnpm_list).and_return(pnpm_multi_workspace) + result = analyzer.add_dependency_graph(parsed_results) + + expect(result.count).to eq(4) + expect(result["a"]).not_to be_nil + expect(result["b"]).not_to be_nil + expect(result["c"]).not_to be_nil + expect(result["d"]).not_to be_nil + end + end + + describe "#break_cycles" do + let(:pnpm_cycle) do + <<~DOC + [ + { + "name": "workspace-root", + "version": "1.0.0", + "dependencies": { + "a": { + "version": "1.1.35" + }, + "browserslist": { + "version": "4.21.5", + "dependencies": { + "node-releases": { + "version": "2.0.10" + }, + "update-browserslist-db": { + "version": "1.0.10", + "dependencies": { + "browserslist": { + "version": "4.21.5" + }, + "escalade": { + "version": "3.1.1" + } + } + } + } + } + } + } + ] + DOC + end + + it "should nil out parents of library with cycle" do + parsed_results = { "a" => {}, "browserslist" => {}, "node-releases" => {}, "update-browserslist-db" => {}, "escalade" => {} } + analyzer = LibraryVersionAnalysis::Pnpm.new("test") + allow(analyzer).to receive(:run_pnpm_list).and_return(pnpm_cycle) + + analyzer.add_dependency_graph(parsed_results) + expect(parsed_results["browserslist"]["dependency_graph"].parents.find { |x| x.name == "update-browserslist-db" }.parents.length).to eq(1) + + analyzer.send(:break_cycles, parsed_results) + expect(parsed_results["browserslist"]["dependency_graph"].parents.find { |x| x.name == "update-browserslist-db" }.parents.length).to eq(0) + end + end + + describe "#calculate_version" do + let(:analyzer) { LibraryVersionAnalysis::Pnpm.new("test") } + + it("should return simple version if both match") do + expect(analyzer.send(:calculate_version, "1.2.3", "1.2.3")).to eq("1.2.3") + end + + it("should return correct order if new is greater than simple old") do + expect(analyzer.send(:calculate_version, "1.2.3", "2.1.3")).to eq("1.2.3..2.1.3") + end + + it("should return correct order if new is less than simple old") do + expect(analyzer.send(:calculate_version, "2.1.4", "2.1.3")).to eq("2.1.3..2.1.4") + end + + it("should replace left if new is less than left") do + expect(analyzer.send(:calculate_version, "1.2.4..2.1.3", "1.2.3")).to eq("1.2.3..2.1.3") + end + + it("should replace right if new is greater than right") do + expect(analyzer.send(:calculate_version, "1.2.3..2.1.3", "2.2.3")).to eq("1.2.3..2.2.3") + end + + it("should make no change if new is between left and right") do + expect(analyzer.send(:calculate_version, "1.2.3..2.1.3", "1.3.3")).to eq("1.2.3..2.1.3") + end + end + + describe "#cascading_ownerships" do + let(:analyzer) { LibraryVersionAnalysis::Pnpm.new("test") } + + let(:root_ownerships) do + { + "lodash" => ":platform_team", + "react" => ":frontend_team", + "shared-lib" => ":core_team" + } + end + + let(:workspace_ownerships) do + { + "app-a" => { + "lodash" => ":app_a_team", + "custom-lib" => ":app_a_team" + }, + "app-b" => { + "another-lib" => ":app_b_team" + } + } + end + + let(:library_workspace_map) do + { + "lodash" => "app-a", + "custom-lib" => "app-a", + "react" => "app-b", + "another-lib" => "app-b", + "shared-lib" => nil + } + end + + before do + allow(analyzer).to receive(:build_library_workspace_map).and_return(library_workspace_map) + end + + it "should use workspace ownership when defined" do + parsed_results = { + "lodash" => LibraryVersionAnalysis::Versionline.new(owner: ":unknown") + } + + analyzer.send(:apply_cascading_ownerships, parsed_results, root_ownerships, workspace_ownerships) + + expect(parsed_results["lodash"].owner).to eq(":app_a_team") + end + + it "should fall back to root ownership when workspace does not define" do + parsed_results = { + "react" => LibraryVersionAnalysis::Versionline.new(owner: ":unknown") + } + + analyzer.send(:apply_cascading_ownerships, parsed_results, root_ownerships, workspace_ownerships) + + expect(parsed_results["react"].owner).to eq(":frontend_team") + end + + it "should use root ownership when library has no workspace" do + parsed_results = { + "shared-lib" => LibraryVersionAnalysis::Versionline.new(owner: ":unknown") + } + + analyzer.send(:apply_cascading_ownerships, parsed_results, root_ownerships, workspace_ownerships) + + expect(parsed_results["shared-lib"].owner).to eq(":core_team") + end + + it "should not change owner when no ownership is defined" do + parsed_results = { + "undefined-lib" => LibraryVersionAnalysis::Versionline.new(owner: ":unknown") + } + + analyzer.send(:apply_cascading_ownerships, parsed_results, root_ownerships, workspace_ownerships) + + expect(parsed_results["undefined-lib"].owner).to eq(":unknown") + end + + it "should set owner_reason to ASSIGNED when owner is found" do + parsed_results = { + "lodash" => LibraryVersionAnalysis::Versionline.new(owner: ":unknown") + } + + analyzer.send(:apply_cascading_ownerships, parsed_results, root_ownerships, workspace_ownerships) + + expect(parsed_results["lodash"].owner_reason).to eq(LibraryVersionAnalysis::Ownership::OWNER_REASON_ASSIGNED) + end + end + + describe "#discover_workspaces" do + let(:analyzer) { LibraryVersionAnalysis::Pnpm.new("test") } + + let(:pnpm_workspace_list) do + <<~DOC + [ + { + "name": "workspace-root", + "path": "/project" + }, + { + "name": "@scope/app-a", + "path": "/project/packages/app-a" + }, + { + "name": "@scope/app-b", + "path": "/project/packages/app-b" + } + ] + DOC + end + + it "should return workspace packages" do + allow(Open3).to receive(:capture3).with("pnpm list -r --depth=-1 --json").and_return([pnpm_workspace_list, "", Status.new(0)]) + + result = analyzer.send(:discover_workspaces) + + expect(result.length).to eq(3) + expect(result[0]["name"]).to eq("workspace-root") + expect(result[1]["name"]).to eq("@scope/app-a") + expect(result[1]["path"]).to eq("/project/packages/app-a") + end + + it "should return empty array on failure" do + allow(Open3).to receive(:capture3).with("pnpm list -r --depth=-1 --json").and_return(["", "error", Status.new(1)]) + + result = analyzer.send(:discover_workspaces) + + expect(result).to eq([]) + end + end +end From 621a39349a454fa66ecff8dc538131291485ef9b Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 30 Jan 2026 14:35:21 -0700 Subject: [PATCH 02/18] better error handling. --- lib/library_version_analysis/pnpm.rb | 6 ++++++ spec/pnpm_spec.rb | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lib/library_version_analysis/pnpm.rb b/lib/library_version_analysis/pnpm.rb index d5f7b72..bb2fa36 100644 --- a/lib/library_version_analysis/pnpm.rb +++ b/lib/library_version_analysis/pnpm.rb @@ -49,6 +49,11 @@ def add_dependabot_findings(parsed_results, meta_data, github_repo, source) # used when building dependency graphs for upload def add_dependency_graph(parsed_results) # rubocop:disable Metrics/MethodLength results = run_pnpm_list + if results.nil? + warn "Skipping dependency graph: pnpm list failed" + return {} + end + json = JSON.parse(results) @visited_nodes = [] @@ -425,6 +430,7 @@ def run_pnpm_list rescue JSON::ParserError warn "error while running pnpm list" end + return nil end results end diff --git a/spec/pnpm_spec.rb b/spec/pnpm_spec.rb index 849004b..0cc09ea 100644 --- a/spec/pnpm_spec.rb +++ b/spec/pnpm_spec.rb @@ -268,6 +268,21 @@ def do_compare(result:, owner:, current_version:, latest_version:, major:, minor expect(result["c"]).not_to be_nil expect(result["d"]).not_to be_nil end + + it "should gracefully handle pnpm list failure" do + parsed_results = { "a" => {}, "b" => {} } + + analyzer = LibraryVersionAnalysis::Pnpm.new("test") + allow(analyzer).to receive(:run_pnpm_list).and_return(nil) + + expect { analyzer.add_dependency_graph(parsed_results) }.not_to raise_error + result = analyzer.add_dependency_graph(parsed_results) + + expect(result).to eq({}) + # Verify parsed_results is unchanged (no dependency_graph added) + expect(parsed_results["a"]["dependency_graph"]).to be_nil + expect(parsed_results["b"]["dependency_graph"]).to be_nil + end end describe "#break_cycles" do From fa70375439c4a70715a820c9969aecd23027a35d Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 30 Jan 2026 14:35:58 -0700 Subject: [PATCH 03/18] add debugging information so I can see whats failing in CI. --- lib/library_version_analysis/pnpm.rb | 62 +++++++++++++++++++++------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/lib/library_version_analysis/pnpm.rb b/lib/library_version_analysis/pnpm.rb index bb2fa36..456978f 100644 --- a/lib/library_version_analysis/pnpm.rb +++ b/lib/library_version_analysis/pnpm.rb @@ -122,8 +122,11 @@ def add_all_libraries # rubocop:disable Metrics/AbcSize, Metrics/MethodLength all_libraries = {} cmd = "pnpm list --depth=Infinity --silent" - # ignore errors for this. It may fail, but we hopefully don't care - results, _captured_err, _status = Open3.capture3(cmd) + warn "[pnpm] add_all_libraries: Running '#{cmd}'" + results, stderr, status = Open3.capture3(cmd) + warn "[pnpm] add_all_libraries: exit_code=#{status.exitstatus}" + warn "[pnpm] add_all_libraries: stderr=#{stderr.strip}" unless stderr.nil? || stderr.strip.empty? + warn "[pnpm] add_all_libraries: result_lines=#{results.lines.count}" results.each_line do |line| next if line.include?("UNMET OPTIONAL DEPENDENCY") @@ -255,14 +258,18 @@ def collect_workspace_ownerships def discover_workspaces # Use pnpm list to discover all workspaces cmd = "pnpm list -r --depth=-1 --json" - results, _captured_err, status = Open3.capture3(cmd) + warn "[pnpm] discover_workspaces: Running '#{cmd}'" + results, stderr, status = Open3.capture3(cmd) + warn "[pnpm] discover_workspaces: exit_code=#{status.exitstatus}" + warn "[pnpm] discover_workspaces: stderr=#{stderr.strip}" unless stderr.nil? || stderr.strip.empty? return [] unless status.exitstatus.zero? json = JSON.parse(results) # pnpm returns an array of workspace packages json.is_a?(Array) ? json : [json] - rescue JSON::ParserError + rescue JSON::ParserError => e + warn "[pnpm] discover_workspaces: JSON parse error: #{e.message}" [] end @@ -326,7 +333,10 @@ def collect_dependencies_for_workspace(dependencies, workspace_name, library_map def run_pnpm_list_recursive cmd = "pnpm list -r --json --depth=0" - results, _captured_err, status = Open3.capture3(cmd) + warn "[pnpm] run_pnpm_list_recursive: Running '#{cmd}'" + results, stderr, status = Open3.capture3(cmd) + warn "[pnpm] run_pnpm_list_recursive: exit_code=#{status.exitstatus}" + warn "[pnpm] run_pnpm_list_recursive: stderr=#{stderr.strip}" unless stderr.nil? || stderr.strip.empty? return nil unless status.exitstatus.zero? @@ -421,18 +431,40 @@ def break_cycles_for_graph(node) def run_pnpm_list cmd = "pnpm list --json --depth=Infinity --silent" - results, _, status = Open3.capture3(cmd) - - if status.exitstatus != 0 - begin - parsed = JSON.parse(results) - warn "error while running pnpm list: #{parsed['error']}" - rescue JSON::ParserError - warn "error while running pnpm list" + log_pnpm_debug("run_pnpm_list", cmd) do + results, stderr, status = Open3.capture3(cmd) + log_pnpm_result("run_pnpm_list", cmd, status, results, stderr) + + if status.exitstatus != 0 + begin + parsed = JSON.parse(results) + warn "[pnpm] error while running pnpm list: #{parsed['error']}" + rescue JSON::ParserError + warn "[pnpm] error while running pnpm list (non-JSON response)" + end + return nil end - return nil + results + end + end + + def log_pnpm_debug(method_name, cmd) + warn "[pnpm] #{method_name}: Running '#{cmd}'" + warn "[pnpm] #{method_name}: cwd=#{Dir.pwd}" + warn "[pnpm] #{method_name}: pnpm version=#{`pnpm --version 2>&1`.strip}" + yield + end + + def log_pnpm_result(method_name, cmd, status, stdout, stderr) + warn "[pnpm] #{method_name}: exit_code=#{status.exitstatus}" + warn "[pnpm] #{method_name}: stderr=#{stderr.strip}" unless stderr.nil? || stderr.strip.empty? + if stdout.nil? || stdout.strip.empty? + warn "[pnpm] #{method_name}: stdout=(empty)" + elsif stdout.length > 500 + warn "[pnpm] #{method_name}: stdout (first 500 chars)=#{stdout[0..500]}" + else + warn "[pnpm] #{method_name}: stdout=#{stdout.strip}" end - results end end end From 45fecf5ea5efb93e5e6e941f7b9066218c0a09b3 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 30 Jan 2026 14:54:53 -0700 Subject: [PATCH 04/18] Add logging for pnpm list failures and upload payload --- .../check_version_status.rb | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index 88e7548..400e071 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -174,7 +174,9 @@ def get_version_summary(parser, range, spreadsheet_id, repository, source) if @update_server puts " updating server" if LibraryVersionAnalysis.dev_output? - data = server_data(parsed_results, repository, source).to_json + server_payload = server_data(parsed_results, repository, source) + log_server_payload(server_payload) + data = server_payload.to_json LibraryTracking.upload(data) end @@ -361,5 +363,39 @@ def mode_results_specific(mode_results, source) def print_summary(source, meta_data, mode_data) puts "#{source}: #{meta_data}, #{mode_data}" if LibraryVersionAnalysis.dev_output? end + + def log_server_payload(payload) + warn "[upload] Preparing to upload data for #{payload[:repository]}/#{payload[:source]}" + warn "[upload] Libraries: #{payload[:libraries]&.count || 0}" + warn "[upload] New versions: #{payload[:new_versions]&.count || 0}" + warn "[upload] Vulnerabilities: #{payload[:vulnerabilities]&.count || 0}" + warn "[upload] Dependencies: #{payload[:dependencies]&.count || 0}" + + # Log sample of libraries (first 10) + if payload[:libraries]&.any? + warn "[upload] Sample libraries (first 10):" + payload[:libraries].first(10).each do |lib| + warn "[upload] - #{lib[:name]} @ #{lib[:version]} (owner: #{lib[:owner]})" + end + warn "[upload] ... and #{payload[:libraries].count - 10} more" if payload[:libraries].count > 10 + end + + # Log libraries with version updates + if payload[:new_versions]&.any? + outdated = payload[:new_versions].select { |v| v[:major]&.positive? } + warn "[upload] Libraries with major updates: #{outdated.count}" + outdated.first(5).each do |lib| + warn "[upload] - #{lib[:name]}: #{lib[:major]} major, #{lib[:minor]} minor, #{lib[:patch]} patch behind" + end + end + + # Log vulnerabilities + if payload[:vulnerabilities]&.any? + warn "[upload] Vulnerabilities found:" + payload[:vulnerabilities].first(10).each do |vuln| + warn "[upload] - #{vuln[:library]}: #{vuln[:assigned_severity]} (#{vuln[:state]})" + end + end + end end end From 4edc6d86e498e1deb65dbaae5f414993bea00c30 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 30 Jan 2026 16:52:10 -0700 Subject: [PATCH 05/18] handle monorepos in pnpm better. --- README.md | 57 +++++ .../check_version_status.rb | 41 +++- lib/library_version_analysis/pnpm.rb | 140 ++++++++++-- spec/pnpm_spec.rb | 200 ++++++++++++++++++ 4 files changed, 422 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index fc63dba..4873c68 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,63 @@ ln -s ../library_version_analysis . source library_version_anaysis/version.sh library_version_analysis/run.sh +## CI Pipeline Requirements for pnpm Workspaces + +For pnpm workspace repositories (monorepos), the CI pipeline must generate per-workspace libyear files before running the analysis. Each workspace gets its own libyear file with an underscore-based naming convention. + +### File Naming Convention + +| Workspace Path | Libyear Filename | +|---------------|------------------| +| Root (.) | `libyear_root.txt` | +| apps/client | `libyear_apps_client.txt` | +| apps/server | `libyear_apps_server.txt` | +| packages/ui | `libyear_packages_ui.txt` | +| Non-workspace repo | `libyear_report.txt` | + +### Example CI Script + +```bash +#!/bin/bash +# Generate libyear reports for each workspace + +# Root workspace +pnpx libyear --package-manager pnpm --json > libyear_root.txt + +# Each workspace (skip root at index 0) +for workspace in $(pnpm list -r --depth=-1 --json | jq -r '.[1:] | .[].path'); do + relative_path="${workspace#$(pwd)/}" + filename="libyear_${relative_path//\//_}.txt" + pnpx libyear --package-manager pnpm --json --cwd "$workspace" > "$filename" || true +done +``` + +### Example CI Configuration (CircleCI/GitHub Actions) + +```yaml +- name: Generate libyear reports + run: | + # Root workspace + pnpx libyear --package-manager pnpm --json > libyear_root.txt + + # Each workspace (skip root at index 0) + for workspace in $(pnpm list -r --depth=-1 --json | jq -r '.[1:] | .[].path'); do + relative_path="${workspace#$(pwd)/}" + filename="libyear_${relative_path//\//_}.txt" + pnpx libyear --package-manager pnpm --json --cwd "$workspace" > "$filename" || true + done + +- name: Run library analysis + run: ./exe/analyze $REPO_NAME pnpm +``` + +### Non-workspace Repositories + +For non-workspace pnpm repositories (single package.json), continue using the existing single file approach: + +```bash +pnpx libyear --package-manager pnpm --all --json > libyear_report.txt +``` ## Contributing diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index 400e071..940bd1f 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -153,13 +153,48 @@ def go_npm(spreadsheet_id, repository, source) return meta_data, mode end - def go_pnpm(spreadsheet_id, repository, source) + def go_pnpm(spreadsheet_id, repository, source) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength puts " pnpm" if LibraryVersionAnalysis.dev_output? pnpm = Pnpm.new(repository) - meta_data, mode = get_version_summary(pnpm, "PnpmVersionData!A:Q", spreadsheet_id, repository, source) + # Get results for ALL workspaces (or single repo) + results_by_workspace = pnpm.get_versions_for_all_workspaces - return meta_data, mode + all_modes = {} + first_meta_data = nil + first_mode = nil + + # Upload each workspace separately + results_by_workspace.each do |workspace_source, data| + parsed_results = data[:results] + meta_data = data[:meta_data] + mode = get_mode_summary(parsed_results, meta_data) + + # Store first workspace's data for backwards-compatible return value + if first_meta_data.nil? + first_meta_data = meta_data + first_mode = mode + end + + all_modes[workspace_source] = mode + + if @update_spreadsheet + puts " updating spreadsheet #{workspace_source}" if LibraryVersionAnalysis.dev_output? + data = spreadsheet_data(parsed_results, repository, workspace_source) + update_spreadsheet(spreadsheet_id, "PnpmVersionData!A:Q", data) + end + + if @update_server + puts " updating server for #{workspace_source}" if LibraryVersionAnalysis.dev_output? + server_payload = server_data(parsed_results, repository, workspace_source) + log_server_payload(server_payload) + LibraryTracking.upload(server_payload.to_json) + end + end + + puts "All Done! Uploaded #{results_by_workspace.keys.count} workspace(s)" if LibraryVersionAnalysis.dev_output? + + return first_meta_data, first_mode end def get_version_summary(parser, range, spreadsheet_id, repository, source) diff --git a/lib/library_version_analysis/pnpm.rb b/lib/library_version_analysis/pnpm.rb index 456978f..226989b 100644 --- a/lib/library_version_analysis/pnpm.rb +++ b/lib/library_version_analysis/pnpm.rb @@ -1,5 +1,6 @@ require "library_version_analysis/ownership" require "library_version_analysis/configuration" +require "pathname" module LibraryVersionAnalysis class Pnpm @@ -9,6 +10,75 @@ def initialize(github_repo) @github_repo = github_repo end + # Main entry point for per-workspace analysis + def get_versions_for_all_workspaces # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + workspaces = discover_workspaces + + if single_package_repo?(workspaces) + # Backwards compatible: non-workspace repos use "pnpm" + parsed_results, meta_data = get_versions("pnpm") + return { "pnpm" => { results: parsed_results, meta_data: meta_data } } + end + + # Workspace repo: iterate ALL packages including root + results_by_workspace = {} + workspaces.each do |workspace| + workspace_path = workspace["path"] + workspace_source = source_name_for_workspace(workspace_path) + + puts("\tPNPM analyzing workspace: #{workspace_source}") if LibraryVersionAnalysis.dev_output? + parsed_results, meta_data = get_versions_for_workspace(workspace_path, workspace_source) + results_by_workspace[workspace_source] = { results: parsed_results, meta_data: meta_data } + end + + results_by_workspace + end + + # Check if this is a non-workspace repo (only root package, no workspaces) + def single_package_repo?(workspaces) + workspaces.length == 1 && workspaces[0]["path"] == Dir.pwd + end + + # Convert workspace path to a meaningful source name + def source_name_for_workspace(workspace_path) + relative = Pathname.new(workspace_path).relative_path_from(Dir.pwd).to_s + relative == "." || relative.empty? ? "root" : relative + end + + # Analyze a single workspace + def get_versions_for_workspace(workspace_path, source) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + all_libraries = {} + puts("\tPNPM [#{source}] adding all libraries") if LibraryVersionAnalysis.dev_output? + all_libraries = add_all_libraries(workspace_path) + + puts("\tPNPM [#{source}] running libyear") if LibraryVersionAnalysis.dev_output? + + libyear_results = run_libyear_for_workspace(source) + if libyear_results.nil? + warn "Running libyear for #{source} produced no results. Exiting" + exit(-1) + end + + puts("\tPNPM [#{source}] parsing libyear") if LibraryVersionAnalysis.dev_output? + parsed_results, meta_data = parse_libyear(libyear_results, all_libraries) + + puts("\tPNPM [#{source}] dependabot") if LibraryVersionAnalysis.dev_output? + add_dependabot_findings(parsed_results, meta_data, @github_repo, source) + + puts("\tPNPM [#{source}] building dependency graph") if LibraryVersionAnalysis.dev_output? + add_dependency_graph(parsed_results, workspace_path) + + puts("\tPNPM [#{source}] breaking cycles") if LibraryVersionAnalysis.dev_output? + break_cycles(parsed_results) + + puts("\tPNPM [#{source}] adding ownerships") if LibraryVersionAnalysis.dev_output? + add_ownerships(parsed_results, workspace_path) + + puts("PNPM [#{source}] done") if LibraryVersionAnalysis.dev_output? + + return parsed_results, meta_data + end + def get_versions(source) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength all_libraries = {} puts("\tPNPM adding all libraries") if LibraryVersionAnalysis.dev_output? @@ -47,8 +117,8 @@ def add_dependabot_findings(parsed_results, meta_data, github_repo, source) end # used when building dependency graphs for upload - def add_dependency_graph(parsed_results) # rubocop:disable Metrics/MethodLength - results = run_pnpm_list + def add_dependency_graph(parsed_results, workspace_path = nil) # rubocop:disable Metrics/MethodLength + results = run_pnpm_list(workspace_path) if results.nil? warn "Skipping dependency graph: pnpm list failed" return {} @@ -94,10 +164,30 @@ def run_libyear return results end + # Read per-workspace libyear file generated by CI + def run_libyear_for_workspace(workspace_source) + filename = libyear_filename_for_source(workspace_source) + read_file(filename, true) + end + + # Convert source name to libyear filename + # "pnpm" -> "libyear_report.txt" (backwards compatible for non-workspace repos) + # "root" -> "libyear_root.txt" + # "apps/anchor" -> "libyear_apps_anchor.txt" + def libyear_filename_for_source(source) + case source + when "pnpm" + "libyear_report.txt" + else + "libyear_#{source.gsub('/', '_')}.txt" + end + end + def read_file(path, check_time) # With this file-read approach, we could be using old data. protect against that. if !File.exist?(path) || (check_time && Time.now.utc - File.mtime(path) > 3600) # 1 hour - warn "Either could not find #{File.expand_path(path)} or it is more than 1 hour old. Run \"pnpx libyear --package-manager pnpm --all --json > libyear_report.txt\"" + warn "Either could not find #{File.expand_path(path)} or it is more than 1 hour old." + warn "Ensure libyear files are generated in CI before running analysis." exit(-1) end @@ -118,9 +208,13 @@ def run_libyear_open3 return results end - def add_all_libraries # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def add_all_libraries(workspace_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength all_libraries = {} - cmd = "pnpm list --depth=Infinity --silent" + cmd = if workspace_path + "pnpm list --dir #{workspace_path} --depth=Infinity --silent" + else + "pnpm list --depth=Infinity --silent" + end warn "[pnpm] add_all_libraries: Running '#{cmd}'" results, stderr, status = Open3.capture3(cmd) @@ -215,13 +309,29 @@ def find_drift(line) drift end - def add_ownerships(parsed_results) - # 1. Collect all ownership definitions with cascading support - root_ownerships = read_package_json_ownerships("package.json") - workspace_ownerships = collect_workspace_ownerships + def add_ownerships(parsed_results, workspace_path = nil) + if workspace_path + # Per-workspace mode: only read ownerships from that workspace's package.json + pkg_json_path = File.join(workspace_path, "package.json") + workspace_ownerships = read_package_json_ownerships(pkg_json_path) + + # Apply workspace ownerships directly + parsed_results.each do |name, line_data| + owner = workspace_ownerships[name] + if owner + line_data.owner = owner + line_data.owner_reason = LibraryVersionAnalysis::Ownership::OWNER_REASON_ASSIGNED + end + end + else + # Legacy mode: cascading ownership across all workspaces + # 1. Collect all ownership definitions with cascading support + root_ownerships = read_package_json_ownerships("package.json") + workspace_ownerships = collect_workspace_ownerships - # 2. Apply ownerships with precedence (workspace > root > default) - apply_cascading_ownerships(parsed_results, root_ownerships, workspace_ownerships) + # 2. Apply ownerships with precedence (workspace > root > default) + apply_cascading_ownerships(parsed_results, root_ownerships, workspace_ownerships) + end # 3. Second pass for transitive ownership add_transitive_ownerships(parsed_results) @@ -429,8 +539,12 @@ def break_cycles_for_graph(node) return false end - def run_pnpm_list - cmd = "pnpm list --json --depth=Infinity --silent" + def run_pnpm_list(workspace_path = nil) + cmd = if workspace_path + "pnpm list --dir #{workspace_path} --json --depth=Infinity --silent" + else + "pnpm list --json --depth=Infinity --silent" + end log_pnpm_debug("run_pnpm_list", cmd) do results, stderr, status = Open3.capture3(cmd) log_pnpm_result("run_pnpm_list", cmd, status, results, stderr) diff --git a/spec/pnpm_spec.rb b/spec/pnpm_spec.rb index 0cc09ea..3e07533 100644 --- a/spec/pnpm_spec.rb +++ b/spec/pnpm_spec.rb @@ -491,4 +491,204 @@ def do_compare(result:, owner:, current_version:, latest_version:, major:, minor expect(result).to eq([]) end end + + describe "#source_name_for_workspace" do + let(:analyzer) { LibraryVersionAnalysis::Pnpm.new("test") } + + it "should return 'root' for the root workspace path" do + allow(Dir).to receive(:pwd).and_return("/project") + expect(analyzer.source_name_for_workspace("/project")).to eq("root") + end + + it "should return 'root' for '.' path" do + allow(Dir).to receive(:pwd).and_return("/project") + expect(analyzer.source_name_for_workspace("/project")).to eq("root") + end + + it "should return relative path for nested workspace" do + allow(Dir).to receive(:pwd).and_return("/project") + expect(analyzer.source_name_for_workspace("/project/apps/anchor")).to eq("apps/anchor") + end + + it "should return relative path for packages workspace" do + allow(Dir).to receive(:pwd).and_return("/project") + expect(analyzer.source_name_for_workspace("/project/packages/bits")).to eq("packages/bits") + end + end + + describe "#single_package_repo?" do + let(:analyzer) { LibraryVersionAnalysis::Pnpm.new("test") } + + before do + allow(Dir).to receive(:pwd).and_return("/project") + end + + it "should return true for single package at root" do + workspaces = [{ "name" => "my-app", "path" => "/project" }] + expect(analyzer.single_package_repo?(workspaces)).to be true + end + + it "should return false for multiple workspaces" do + workspaces = [ + { "name" => "root", "path" => "/project" }, + { "name" => "app-a", "path" => "/project/apps/app-a" } + ] + expect(analyzer.single_package_repo?(workspaces)).to be false + end + + it "should return false for single workspace not at root" do + workspaces = [{ "name" => "app-a", "path" => "/project/apps/app-a" }] + expect(analyzer.single_package_repo?(workspaces)).to be false + end + end + + describe "#libyear_filename_for_source" do + let(:analyzer) { LibraryVersionAnalysis::Pnpm.new("test") } + + it "should return 'libyear_report.txt' for 'pnpm' source (backwards compatible)" do + expect(analyzer.send(:libyear_filename_for_source, "pnpm")).to eq("libyear_report.txt") + end + + it "should return 'libyear_root.txt' for 'root' source" do + expect(analyzer.send(:libyear_filename_for_source, "root")).to eq("libyear_root.txt") + end + + it "should convert slashes to underscores for nested paths" do + expect(analyzer.send(:libyear_filename_for_source, "apps/anchor")).to eq("libyear_apps_anchor.txt") + end + + it "should handle deeply nested paths" do + expect(analyzer.send(:libyear_filename_for_source, "packages/ui/components")).to eq("libyear_packages_ui_components.txt") + end + end + + describe "#get_versions_for_all_workspaces" do + let(:analyzer) { LibraryVersionAnalysis::Pnpm.new("test") } + + let(:libyear_root) do + '[{"dependency": "typescript", "drift": 0.5, "major": 0, "minor": 1, "patch": 2, "available": "5.0.0"}]' + end + + let(:libyear_apps_anchor) do + '[{"dependency": "react", "drift": 0.3, "major": 0, "minor": 0, "patch": 1, "available": "18.2.0"}]' + end + + context "with workspace repo (multiple workspaces)" do + let(:workspaces) do + [ + { "name" => "root", "path" => "/project" }, + { "name" => "@jobber/anchor", "path" => "/project/apps/anchor" } + ] + end + + before do + allow(Dir).to receive(:pwd).and_return("/project") + allow(analyzer).to receive(:discover_workspaces).and_return(workspaces) + allow(analyzer).to receive(:get_versions_for_workspace).with("/project", "root").and_return([{ "typescript" => {} }, double(total_age: 0.5)]) + allow(analyzer).to receive(:get_versions_for_workspace).with("/project/apps/anchor", "apps/anchor").and_return([{ "react" => {} }, double(total_age: 0.3)]) + end + + it "should return hash with multiple workspace sources" do + result = analyzer.get_versions_for_all_workspaces + + expect(result.keys).to contain_exactly("root", "apps/anchor") + end + + it "should return results for root workspace" do + result = analyzer.get_versions_for_all_workspaces + + expect(result["root"][:results]).to have_key("typescript") + end + + it "should return results for nested workspace" do + result = analyzer.get_versions_for_all_workspaces + + expect(result["apps/anchor"][:results]).to have_key("react") + end + end + + context "with non-workspace repo (single package at root)" do + let(:workspaces) do + [{ "name" => "my-simple-app", "path" => "/project" }] + end + + before do + allow(Dir).to receive(:pwd).and_return("/project") + allow(analyzer).to receive(:discover_workspaces).and_return(workspaces) + allow(analyzer).to receive(:get_versions).with("pnpm").and_return([{ "lodash" => {} }, double(total_age: 1.0)]) + end + + it "should return hash with single 'pnpm' source for backwards compatibility" do + result = analyzer.get_versions_for_all_workspaces + + expect(result.keys).to eq(["pnpm"]) + end + + it "should call get_versions with 'pnpm' source" do + expect(analyzer).to receive(:get_versions).with("pnpm") + analyzer.get_versions_for_all_workspaces + end + + it "should return results under 'pnpm' key" do + result = analyzer.get_versions_for_all_workspaces + + expect(result["pnpm"][:results]).to have_key("lodash") + end + + it "should return meta_data under 'pnpm' key" do + result = analyzer.get_versions_for_all_workspaces + + expect(result["pnpm"][:meta_data].total_age).to eq(1.0) + end + end + end + + describe "#add_ownerships with workspace_path" do + let(:analyzer) { LibraryVersionAnalysis::Pnpm.new("test") } + + let(:workspace_package_json) do + { + "lodash" => ":anchor_team", + "react" => ":anchor_team" + } + end + + before do + allow(analyzer).to receive(:read_package_json_ownerships).with("/project/apps/anchor/package.json").and_return(workspace_package_json) + allow(analyzer).to receive(:add_transitive_ownerships) + allow(analyzer).to receive(:add_attention_needed) + end + + it "should apply ownership from workspace package.json when workspace_path provided" do + parsed_results = { + "lodash" => LibraryVersionAnalysis::Versionline.new(owner: ":unknown"), + "react" => LibraryVersionAnalysis::Versionline.new(owner: ":unknown") + } + + analyzer.send(:add_ownerships, parsed_results, "/project/apps/anchor") + + expect(parsed_results["lodash"].owner).to eq(":anchor_team") + expect(parsed_results["react"].owner).to eq(":anchor_team") + end + + it "should set owner_reason to ASSIGNED for workspace ownerships" do + parsed_results = { + "lodash" => LibraryVersionAnalysis::Versionline.new(owner: ":unknown") + } + + analyzer.send(:add_ownerships, parsed_results, "/project/apps/anchor") + + expect(parsed_results["lodash"].owner_reason).to eq(LibraryVersionAnalysis::Ownership::OWNER_REASON_ASSIGNED) + end + + it "should not change owner for libraries not in workspace package.json" do + parsed_results = { + "unknown-lib" => LibraryVersionAnalysis::Versionline.new(owner: ":default") + } + + analyzer.send(:add_ownerships, parsed_results, "/project/apps/anchor") + + expect(parsed_results["unknown-lib"].owner).to eq(":default") + end + end end From 7a1e2267ae9c470f40428e11a078612af32ca57f Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 30 Jan 2026 18:47:00 -0700 Subject: [PATCH 06/18] allow dry runs for debgugging in CI purposes --- .../check_version_status.rb | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index 940bd1f..b7597f4 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -44,13 +44,18 @@ def self.dev_output? ENV["DEV_OUTPUT"]&.downcase == "true" end + def self.dry_run? + ENV["DRY_RUN"]&.downcase == "true" + end + OBFUSCATE_WORDS = false # This is to ensure we don't store actual spicy data except in secure prod DB class CheckVersionStatus # TODO: joint - Need to change Jobbers https://github.com/GetJobber/Jobber/blob/dea12cebf8e6c65b2cafb5318bd42c1f3bf7d7a3/lib/code_analysis/code_analyzer/online_version_analysis.rb#L6 to run three times. One for each. def self.run(spreadsheet_id: "", repository: "", source: "") # check for env vars before we do anything - keys = %w(WORD_LIST_RANDOM_SEED GITHUB_READ_API_TOKEN LIBRARY_UPLOAD_URL UPLOAD_KEY) + keys = %w(WORD_LIST_RANDOM_SEED GITHUB_READ_API_TOKEN) + keys += %w(LIBRARY_UPLOAD_URL UPLOAD_KEY) unless LibraryVersionAnalysis.dry_run? missing_keys = keys.reject { |key| !ENV[key].nil? && !ENV[key].empty? } raise "Missing ENV vars: #{missing_keys}" if missing_keys.any? @@ -188,7 +193,11 @@ def go_pnpm(spreadsheet_id, repository, source) # rubocop:disable Metrics/AbcSiz puts " updating server for #{workspace_source}" if LibraryVersionAnalysis.dev_output? server_payload = server_data(parsed_results, repository, workspace_source) log_server_payload(server_payload) - LibraryTracking.upload(server_payload.to_json) + if LibraryVersionAnalysis.dry_run? + warn "[DRY_RUN] Skipping upload for #{workspace_source}" + else + LibraryTracking.upload(server_payload.to_json) + end end end @@ -211,8 +220,12 @@ def get_version_summary(parser, range, spreadsheet_id, repository, source) puts " updating server" if LibraryVersionAnalysis.dev_output? server_payload = server_data(parsed_results, repository, source) log_server_payload(server_payload) - data = server_payload.to_json - LibraryTracking.upload(data) + if LibraryVersionAnalysis.dry_run? + warn "[DRY_RUN] Skipping upload for #{source}" + else + data = server_payload.to_json + LibraryTracking.upload(data) + end end puts "All Done!" if LibraryVersionAnalysis.dev_output? From 854bbf01478b7e89b073d1a5fe22affb7a47c953 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Thu, 5 Feb 2026 13:49:24 -0700 Subject: [PATCH 07/18] remove output that seems to affect how we can interact with the library. --- lib/library_version_analysis/pnpm.rb | 66 +++++----------------------- 1 file changed, 11 insertions(+), 55 deletions(-) diff --git a/lib/library_version_analysis/pnpm.rb b/lib/library_version_analysis/pnpm.rb index 226989b..daab3ec 100644 --- a/lib/library_version_analysis/pnpm.rb +++ b/lib/library_version_analysis/pnpm.rb @@ -196,16 +196,11 @@ def read_file(path, check_time) def run_libyear_open3 cmd = "pnpx libyear --package-manager pnpm --all --json" - results, captured_err, status = Open3.capture3(cmd) + results, _captured_err, status = Open3.capture3(cmd) - if status.exitstatus != 0 - warn "pnpm status: #{status}" - warn "pnpm captured_err: #{captured_err}" + return nil if status.exitstatus != 0 - return nil - end - - return results + results end def add_all_libraries(workspace_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength @@ -216,11 +211,7 @@ def add_all_libraries(workspace_path = nil) # rubocop:disable Metrics/AbcSize, M "pnpm list --depth=Infinity --silent" end - warn "[pnpm] add_all_libraries: Running '#{cmd}'" - results, stderr, status = Open3.capture3(cmd) - warn "[pnpm] add_all_libraries: exit_code=#{status.exitstatus}" - warn "[pnpm] add_all_libraries: stderr=#{stderr.strip}" unless stderr.nil? || stderr.strip.empty? - warn "[pnpm] add_all_libraries: result_lines=#{results.lines.count}" + results, _stderr, _status = Open3.capture3(cmd) results.each_line do |line| next if line.include?("UNMET OPTIONAL DEPENDENCY") @@ -368,18 +359,14 @@ def collect_workspace_ownerships def discover_workspaces # Use pnpm list to discover all workspaces cmd = "pnpm list -r --depth=-1 --json" - warn "[pnpm] discover_workspaces: Running '#{cmd}'" - results, stderr, status = Open3.capture3(cmd) - warn "[pnpm] discover_workspaces: exit_code=#{status.exitstatus}" - warn "[pnpm] discover_workspaces: stderr=#{stderr.strip}" unless stderr.nil? || stderr.strip.empty? + results, _stderr, status = Open3.capture3(cmd) return [] unless status.exitstatus.zero? json = JSON.parse(results) # pnpm returns an array of workspace packages json.is_a?(Array) ? json : [json] - rescue JSON::ParserError => e - warn "[pnpm] discover_workspaces: JSON parse error: #{e.message}" + rescue JSON::ParserError [] end @@ -443,10 +430,7 @@ def collect_dependencies_for_workspace(dependencies, workspace_name, library_map def run_pnpm_list_recursive cmd = "pnpm list -r --json --depth=0" - warn "[pnpm] run_pnpm_list_recursive: Running '#{cmd}'" - results, stderr, status = Open3.capture3(cmd) - warn "[pnpm] run_pnpm_list_recursive: exit_code=#{status.exitstatus}" - warn "[pnpm] run_pnpm_list_recursive: stderr=#{stderr.strip}" unless stderr.nil? || stderr.strip.empty? + results, _stderr, status = Open3.capture3(cmd) return nil unless status.exitstatus.zero? @@ -545,40 +529,12 @@ def run_pnpm_list(workspace_path = nil) else "pnpm list --json --depth=Infinity --silent" end - log_pnpm_debug("run_pnpm_list", cmd) do - results, stderr, status = Open3.capture3(cmd) - log_pnpm_result("run_pnpm_list", cmd, status, results, stderr) - - if status.exitstatus != 0 - begin - parsed = JSON.parse(results) - warn "[pnpm] error while running pnpm list: #{parsed['error']}" - rescue JSON::ParserError - warn "[pnpm] error while running pnpm list (non-JSON response)" - end - return nil - end - results - end - end - def log_pnpm_debug(method_name, cmd) - warn "[pnpm] #{method_name}: Running '#{cmd}'" - warn "[pnpm] #{method_name}: cwd=#{Dir.pwd}" - warn "[pnpm] #{method_name}: pnpm version=#{`pnpm --version 2>&1`.strip}" - yield - end + results, _stderr, status = Open3.capture3(cmd) - def log_pnpm_result(method_name, cmd, status, stdout, stderr) - warn "[pnpm] #{method_name}: exit_code=#{status.exitstatus}" - warn "[pnpm] #{method_name}: stderr=#{stderr.strip}" unless stderr.nil? || stderr.strip.empty? - if stdout.nil? || stdout.strip.empty? - warn "[pnpm] #{method_name}: stdout=(empty)" - elsif stdout.length > 500 - warn "[pnpm] #{method_name}: stdout (first 500 chars)=#{stdout[0..500]}" - else - warn "[pnpm] #{method_name}: stdout=#{stdout.strip}" - end + return nil if status.exitstatus != 0 + + results end end end From db9b12296f532df915619c7f3d114c2e31d2aabe Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Thu, 5 Feb 2026 15:24:11 -0700 Subject: [PATCH 08/18] allow passing in the pnpm monorepo package names. --- exe/analyze | 21 ++++++++- .../check_version_status.rb | 44 +++++++++++++------ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/exe/analyze b/exe/analyze index ea97379..d9fc048 100755 --- a/exe/analyze +++ b/exe/analyze @@ -6,16 +6,33 @@ if ARGV.count == 1 spreadsheet_id = ARGV[0] repository = "jobber-mobile" source = "npm" + context = nil elsif ARGV.count == 2 # this supports legacy calls spreadsheet_id = "" repository = ARGV[0] source = ARGV[1] + context = nil +elsif ARGV.count == 3 + # pnpm workspace support: analyze pnpm + spreadsheet_id = "" + repository = ARGV[0] + source = ARGV[1] + context = ARGV[2] else - puts "Usage: analyze " + puts "Usage: analyze [context]" + puts " Examples:" + puts " analyze my-repo pnpm # analyze all pnpm workspaces" + puts " analyze my-repo pnpm root # analyze only root workspace" + puts " analyze my-repo pnpm apps/jobber-online # analyze specific workspace" Kernel.exit(1) end -results = LibraryVersionAnalysis::CheckVersionStatus.run(spreadsheet_id: spreadsheet_id, repository: repository, source: source) +results = LibraryVersionAnalysis::CheckVersionStatus.run( + spreadsheet_id: spreadsheet_id, + repository: repository, + source: source, + context: context +) puts JSON.generate(results) diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index b7597f4..ea1a88c 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -52,7 +52,7 @@ def self.dry_run? class CheckVersionStatus # TODO: joint - Need to change Jobbers https://github.com/GetJobber/Jobber/blob/dea12cebf8e6c65b2cafb5318bd42c1f3bf7d7a3/lib/code_analysis/code_analyzer/online_version_analysis.rb#L6 to run three times. One for each. - def self.run(spreadsheet_id: "", repository: "", source: "") + def self.run(spreadsheet_id: "", repository: "", source: "", context: nil) # check for env vars before we do anything keys = %w(WORD_LIST_RANDOM_SEED GITHUB_READ_API_TOKEN) keys += %w(LIBRARY_UPLOAD_URL UPLOAD_KEY) unless LibraryVersionAnalysis.dry_run? @@ -61,7 +61,7 @@ def self.run(spreadsheet_id: "", repository: "", source: "") raise "Missing ENV vars: #{missing_keys}" if missing_keys.any? c = CheckVersionStatus.new - mode_results = c.go(spreadsheet_id: spreadsheet_id, repository: repository, source: source) + mode_results = c.go(spreadsheet_id: spreadsheet_id, repository: repository, source: source, context: context) mode_key = "#{repository}/#{source}" @@ -99,7 +99,7 @@ def obfuscate(data) return ":#{@word_list[idx]}" # note: the colon is required in the dependency graph obfuscation end - def go(spreadsheet_id:, repository:, source:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def go(spreadsheet_id:, repository:, source:, context: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength if spreadsheet_id.nil? || spreadsheet_id.empty? @update_server = true @@ -119,7 +119,7 @@ def go(spreadsheet_id:, repository:, source:) # rubocop:disable Metrics/AbcSize, when "gemfile" meta_data, mode = go_gemfile(spreadsheet_id, repository, source) when "pnpm" - meta_data, mode = go_pnpm(spreadsheet_id, repository, source) + meta_data, mode = go_pnpm(spreadsheet_id, repository, context) else puts "Don't recognize source #{source}" exit(-1) @@ -158,19 +158,30 @@ def go_npm(spreadsheet_id, repository, source) return meta_data, mode end - def go_pnpm(spreadsheet_id, repository, source) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def go_pnpm(spreadsheet_id, repository, context = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength puts " pnpm" if LibraryVersionAnalysis.dev_output? pnpm = Pnpm.new(repository) # Get results for ALL workspaces (or single repo) results_by_workspace = pnpm.get_versions_for_all_workspaces + # Filter to specific workspace if context provided + if context + if results_by_workspace.key?(context) + results_by_workspace = { context => results_by_workspace[context] } + else + available = results_by_workspace.keys.join(", ") + puts "Workspace '#{context}' not found. Available workspaces: #{available}" + exit(-1) + end + end + all_modes = {} first_meta_data = nil first_mode = nil # Upload each workspace separately - results_by_workspace.each do |workspace_source, data| + results_by_workspace.each do |workspace_name, data| parsed_results = data[:results] meta_data = data[:meta_data] mode = get_mode_summary(parsed_results, meta_data) @@ -181,20 +192,20 @@ def go_pnpm(spreadsheet_id, repository, source) # rubocop:disable Metrics/AbcSiz first_mode = mode end - all_modes[workspace_source] = mode + all_modes[workspace_name] = mode if @update_spreadsheet - puts " updating spreadsheet #{workspace_source}" if LibraryVersionAnalysis.dev_output? - data = spreadsheet_data(parsed_results, repository, workspace_source) + puts " updating spreadsheet #{workspace_name}" if LibraryVersionAnalysis.dev_output? + data = spreadsheet_data(parsed_results, repository, workspace_name) update_spreadsheet(spreadsheet_id, "PnpmVersionData!A:Q", data) end if @update_server - puts " updating server for #{workspace_source}" if LibraryVersionAnalysis.dev_output? - server_payload = server_data(parsed_results, repository, workspace_source) + puts " updating server for #{workspace_name}" if LibraryVersionAnalysis.dev_output? + server_payload = server_data(parsed_results, repository, "pnpm", workspace_name) log_server_payload(server_payload) if LibraryVersionAnalysis.dry_run? - warn "[DRY_RUN] Skipping upload for #{workspace_source}" + warn "[DRY_RUN] Skipping upload for #{workspace_name}" else LibraryTracking.upload(server_payload.to_json) end @@ -238,7 +249,7 @@ def one_number(mode_summary) return mode_summary.three_plus_major * 50 + mode_summary.two_major * 20 + mode_summary.one_major * 10 + mode_summary.minor + mode_summary.patch * 0.5 end - def server_data(results, repository, source) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def server_data(results, repository, source, package = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength libraries = [] new_versions = [] vulns = [] @@ -267,7 +278,7 @@ def server_data(results, repository, source) # rubocop:disable Metrics/AbcSize, end end - { + payload = { source: source.downcase, repository: repository, libraries: libraries, @@ -275,6 +286,11 @@ def server_data(results, repository, source) # rubocop:disable Metrics/AbcSize, vulnerabilities: vulns, dependencies: dependencies, } + + # Add package field for pnpm workspaces + payload[:package] = package if package + + payload end def obfuscate_dependency_graph(dependency_graph) From 2899b1fa8d9f27828ae0b9fe5e7d60d6c9cfbf76 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Thu, 5 Feb 2026 16:12:21 -0700 Subject: [PATCH 09/18] change format of output for all libraries. Eliminate the need to call the analyze method for each package. --- .../check_version_status.rb | 39 +++++++++--- lib/library_version_analysis/pnpm.rb | 59 +++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index ea1a88c..8e5f2e1 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -77,7 +77,12 @@ def self.run(spreadsheet_id: "", repository: "", source: "", context: nil) result_key = mode_key end - results = {result_key => c.mode_results_specific(mode_results, mode_key.to_sym)} + # For pnpm, mode_results contains all_modes hash with each workspace's metrics + if source == "pnpm" + results = { result_key => c.pnpm_results_all_workspaces(mode_results, mode_key.to_sym) } + else + results = { result_key => c.mode_results_specific(mode_results, mode_key.to_sym) } + end return results end @@ -177,8 +182,7 @@ def go_pnpm(spreadsheet_id, repository, context = nil) # rubocop:disable Metrics end all_modes = {} - first_meta_data = nil - first_mode = nil + combined_meta_data = nil # Upload each workspace separately results_by_workspace.each do |workspace_name, data| @@ -186,11 +190,8 @@ def go_pnpm(spreadsheet_id, repository, context = nil) # rubocop:disable Metrics meta_data = data[:meta_data] mode = get_mode_summary(parsed_results, meta_data) - # Store first workspace's data for backwards-compatible return value - if first_meta_data.nil? - first_meta_data = meta_data - first_mode = mode - end + # Store first workspace's meta_data for print_summary (backwards compatible) + combined_meta_data ||= meta_data all_modes[workspace_name] = mode @@ -214,7 +215,8 @@ def go_pnpm(spreadsheet_id, repository, context = nil) # rubocop:disable Metrics puts "All Done! Uploaded #{results_by_workspace.keys.count} workspace(s)" if LibraryVersionAnalysis.dev_output? - return first_meta_data, first_mode + # Return all_modes hash for pnpm (contains all workspace metrics) + return combined_meta_data, all_modes end def get_version_summary(parser, range, spreadsheet_id, repository, source) @@ -424,6 +426,25 @@ def mode_results_specific(mode_results, source) } end + # Format pnpm results with all workspaces included + def pnpm_results_all_workspaces(mode_results, source) + all_modes = mode_results[source] + return {} if all_modes.nil? + + result = {} + all_modes.each do |workspace_name, mode| + result[workspace_name] = { + one_major: mode[:one_major], + two_major: mode[:two_major], + three_plus_major: mode[:three_plus_major], + minor: mode[:minor], + unowned_issues: mode[:unowned_issues], + one_number: mode[:one_number], + } + end + result + end + def print_summary(source, meta_data, mode_data) puts "#{source}: #{meta_data}, #{mode_data}" if LibraryVersionAnalysis.dev_output? end diff --git a/lib/library_version_analysis/pnpm.rb b/lib/library_version_analysis/pnpm.rb index daab3ec..e1a548f 100644 --- a/lib/library_version_analysis/pnpm.rb +++ b/lib/library_version_analysis/pnpm.rb @@ -14,6 +14,13 @@ def initialize(github_repo) def get_versions_for_all_workspaces # rubocop:disable Metrics/AbcSize, Metrics/MethodLength workspaces = discover_workspaces + # Handle case where pnpm list returns nothing (e.g., not a pnpm project) + if workspaces.empty? + warn "No pnpm workspaces discovered via 'pnpm list'." + warn "Falling back to discovering workspaces from libyear_*.txt files." + return get_versions_from_libyear_files + end + if single_package_repo?(workspaces) # Backwards compatible: non-workspace repos use "pnpm" parsed_results, meta_data = get_versions("pnpm") @@ -39,6 +46,58 @@ def single_package_repo?(workspaces) workspaces.length == 1 && workspaces[0]["path"] == Dir.pwd end + # Fallback: discover workspaces from libyear_*.txt files when pnpm list doesn't work + def get_versions_from_libyear_files # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + libyear_files = Dir.glob("libyear_*.txt") + + if libyear_files.empty? + warn "No libyear_*.txt files found. Cannot proceed." + exit(-1) + end + + results_by_workspace = {} + libyear_files.each do |filename| + workspace_source = source_name_from_libyear_filename(filename) + puts("\tPNPM analyzing workspace from file: #{workspace_source} (#{filename})") if LibraryVersionAnalysis.dev_output? + + # Read and parse libyear data + libyear_results = read_file(filename, true) + if libyear_results.nil? + warn "Running libyear for #{workspace_source} produced no results. Exiting" + exit(-1) + end + + # For file-based discovery, we can't get pnpm list data, so use empty library set + all_libraries = {} + parsed_results, meta_data = parse_libyear(libyear_results, all_libraries) + + # Skip dependabot findings, dependency graph, and ownerships when running in file-fallback mode + # (we don't have access to the actual pnpm project structure or GitHub repo) + puts("\tPNPM [#{workspace_source}] skipping dependabot/graph/ownerships in file-fallback mode") if LibraryVersionAnalysis.dev_output? + + results_by_workspace[workspace_source] = { results: parsed_results, meta_data: meta_data } + end + + results_by_workspace + end + + # Convert libyear filename back to source name + # "libyear_report.txt" -> "pnpm" + # "libyear_root.txt" -> "root" + # "libyear_apps_anchor.txt" -> "apps/anchor" + def source_name_from_libyear_filename(filename) + basename = File.basename(filename, ".txt") + + case basename + when "libyear_report" + "pnpm" + else + # "libyear_root" -> "root" + # "libyear_apps_anchor" -> "apps/anchor" + basename.sub(/^libyear_/, "").gsub("_", "/") + end + end + # Convert workspace path to a meaningful source name def source_name_for_workspace(workspace_path) relative = Pathname.new(workspace_path).relative_path_from(Dir.pwd).to_s From 27a883dfec85642a6f8bfd9909ec42d8a5a0f1de Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Thu, 5 Feb 2026 16:49:02 -0700 Subject: [PATCH 10/18] fix format of uploaded payload to library tracking --- lib/library_version_analysis/check_version_status.rb | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index 8e5f2e1..b1f1a95 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -203,7 +203,8 @@ def go_pnpm(spreadsheet_id, repository, context = nil) # rubocop:disable Metrics if @update_server puts " updating server for #{workspace_name}" if LibraryVersionAnalysis.dev_output? - server_payload = server_data(parsed_results, repository, "pnpm", workspace_name) + # Use workspace_name as the source for pnpm workspaces + server_payload = server_data(parsed_results, repository, workspace_name) log_server_payload(server_payload) if LibraryVersionAnalysis.dry_run? warn "[DRY_RUN] Skipping upload for #{workspace_name}" @@ -251,7 +252,7 @@ def one_number(mode_summary) return mode_summary.three_plus_major * 50 + mode_summary.two_major * 20 + mode_summary.one_major * 10 + mode_summary.minor + mode_summary.patch * 0.5 end - def server_data(results, repository, source, package = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def server_data(results, repository, source) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength libraries = [] new_versions = [] vulns = [] @@ -280,7 +281,7 @@ def server_data(results, repository, source, package = nil) # rubocop:disable Me end end - payload = { + { source: source.downcase, repository: repository, libraries: libraries, @@ -288,11 +289,6 @@ def server_data(results, repository, source, package = nil) # rubocop:disable Me vulnerabilities: vulns, dependencies: dependencies, } - - # Add package field for pnpm workspaces - payload[:package] = package if package - - payload end def obfuscate_dependency_graph(dependency_graph) From a106a77d5ccf5e6d05f735824202fd4fb5a22400 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 6 Feb 2026 14:57:25 -0700 Subject: [PATCH 11/18] self-review. --- README.md | 32 ++++++++++++++++++- exe/analyze | 3 +- .../check_version_status.rb | 27 +++++----------- lib/library_version_analysis/pnpm.rb | 8 ++--- spec/library_version_analysis/github_spec.rb | 11 +++++++ 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 4873c68..0464591 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,37 @@ Or install it yourself as: ## Usage -TODO: Write usage instructions here +```bash +analyze [context] +``` + +| Argument | Description | +|----------|-------------| +| `repository` | The repository name (e.g., `jobber`) | +| `source` | The package manager: `gemfile`, `npm`, or `pnpm` | +| `context` | *(Optional, pnpm only)* A specific pnpm workspace to analyze | + +### Examples + +```bash +# Analyze all gems +analyze my-repo gemfile + +# Analyze all npm packages +analyze my-repo npm + +# Analyze all pnpm workspaces +analyze my-repo pnpm + +# Analyze a specific pnpm workspace +analyze my-repo pnpm packages/ui +``` + +### The `context` parameter + +When using the `pnpm` source, the tool analyzes all workspaces by default. The optional `context` argument filters analysis to a single workspace by name. If the provided workspace name doesn't match any discovered workspace, the tool prints the available workspaces and exits. + +This parameter is ignored for `gemfile` and `npm` sources. ## Development diff --git a/exe/analyze b/exe/analyze index d9fc048..44599c7 100755 --- a/exe/analyze +++ b/exe/analyze @@ -23,8 +23,7 @@ else puts "Usage: analyze [context]" puts " Examples:" puts " analyze my-repo pnpm # analyze all pnpm workspaces" - puts " analyze my-repo pnpm root # analyze only root workspace" - puts " analyze my-repo pnpm apps/jobber-online # analyze specific workspace" + puts " analyze my-repo pnpm packages/ui # analyze specific workspace" Kernel.exit(1) end diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index b1f1a95..cb1eadc 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -44,18 +44,13 @@ def self.dev_output? ENV["DEV_OUTPUT"]&.downcase == "true" end - def self.dry_run? - ENV["DRY_RUN"]&.downcase == "true" - end - OBFUSCATE_WORDS = false # This is to ensure we don't store actual spicy data except in secure prod DB class CheckVersionStatus # TODO: joint - Need to change Jobbers https://github.com/GetJobber/Jobber/blob/dea12cebf8e6c65b2cafb5318bd42c1f3bf7d7a3/lib/code_analysis/code_analyzer/online_version_analysis.rb#L6 to run three times. One for each. def self.run(spreadsheet_id: "", repository: "", source: "", context: nil) # check for env vars before we do anything - keys = %w(WORD_LIST_RANDOM_SEED GITHUB_READ_API_TOKEN) - keys += %w(LIBRARY_UPLOAD_URL UPLOAD_KEY) unless LibraryVersionAnalysis.dry_run? + keys = %w(WORD_LIST_RANDOM_SEED GITHUB_READ_API_TOKEN LIBRARY_UPLOAD_URL UPLOAD_KEY) missing_keys = keys.reject { |key| !ENV[key].nil? && !ENV[key].empty? } raise "Missing ENV vars: #{missing_keys}" if missing_keys.any? @@ -206,11 +201,7 @@ def go_pnpm(spreadsheet_id, repository, context = nil) # rubocop:disable Metrics # Use workspace_name as the source for pnpm workspaces server_payload = server_data(parsed_results, repository, workspace_name) log_server_payload(server_payload) - if LibraryVersionAnalysis.dry_run? - warn "[DRY_RUN] Skipping upload for #{workspace_name}" - else - LibraryTracking.upload(server_payload.to_json) - end + LibraryTracking.upload(server_payload.to_json) end end @@ -234,12 +225,8 @@ def get_version_summary(parser, range, spreadsheet_id, repository, source) puts " updating server" if LibraryVersionAnalysis.dev_output? server_payload = server_data(parsed_results, repository, source) log_server_payload(server_payload) - if LibraryVersionAnalysis.dry_run? - warn "[DRY_RUN] Skipping upload for #{source}" - else - data = server_payload.to_json - LibraryTracking.upload(data) - end + data = server_payload.to_json + LibraryTracking.upload(data) end puts "All Done!" if LibraryVersionAnalysis.dev_output? @@ -313,9 +300,11 @@ def spreadsheet_data(results, repository, source) legacy_source= "MOBILE" end when "gemfile" - legacy_source= "ONLINE" + legacy_source = "ONLINE" + when "pnpm", "root", /^apps\//, /^packages\// + legacy_source = source else - legacy_source= "UNKNOWN" + legacy_source = "UNKNOWN" end data << ["Updated: #{Time.now.utc}"] diff --git a/lib/library_version_analysis/pnpm.rb b/lib/library_version_analysis/pnpm.rb index e1a548f..9ff8e97 100644 --- a/lib/library_version_analysis/pnpm.rb +++ b/lib/library_version_analysis/pnpm.rb @@ -84,7 +84,7 @@ def get_versions_from_libyear_files # rubocop:disable Metrics/AbcSize, Metrics/M # Convert libyear filename back to source name # "libyear_report.txt" -> "pnpm" # "libyear_root.txt" -> "root" - # "libyear_apps_anchor.txt" -> "apps/anchor" + # "libyear_apps-anchor.txt" -> "apps/anchor" def source_name_from_libyear_filename(filename) basename = File.basename(filename, ".txt") @@ -93,7 +93,7 @@ def source_name_from_libyear_filename(filename) "pnpm" else # "libyear_root" -> "root" - # "libyear_apps_anchor" -> "apps/anchor" + # "libyear_apps-anchor" -> "apps/anchor" basename.sub(/^libyear_/, "").gsub("_", "/") end end @@ -122,7 +122,7 @@ def get_versions_for_workspace(workspace_path, source) # rubocop:disable Metrics parsed_results, meta_data = parse_libyear(libyear_results, all_libraries) puts("\tPNPM [#{source}] dependabot") if LibraryVersionAnalysis.dev_output? - add_dependabot_findings(parsed_results, meta_data, @github_repo, source) + add_dependabot_findings(parsed_results, meta_data, @github_repo, "pnpm") puts("\tPNPM [#{source}] building dependency graph") if LibraryVersionAnalysis.dev_output? add_dependency_graph(parsed_results, workspace_path) @@ -238,7 +238,7 @@ def libyear_filename_for_source(source) when "pnpm" "libyear_report.txt" else - "libyear_#{source.gsub('/', '_')}.txt" + "libyear_#{source.gsub('/', '-')}.txt" end end diff --git a/spec/library_version_analysis/github_spec.rb b/spec/library_version_analysis/github_spec.rb index 48d057e..fa485fa 100644 --- a/spec/library_version_analysis/github_spec.rb +++ b/spec/library_version_analysis/github_spec.rb @@ -1,5 +1,6 @@ require "library_version_analysis/github" require "date" +require "ostruct" RSpec.describe LibraryVersionAnalysis::Github do let(:mock_alert_node) do @@ -109,6 +110,10 @@ subject.get_dependabot_findings(parsed_results, meta_data, "test-repo", "npm") expect(meta_data.total_cvss).to eq(1) end + + it "maps pnpm to NPM ecosystem (pnpm uses npm registry)" do + expect(described_class::SOURCES[:pnpm]).to eq("NPM") + end end describe "#get_closed_findings" do @@ -131,6 +136,12 @@ .with("test-repo", false, "NPM") subject.get_closed_findings(parsed_results, "test-repo", "npm") end + + it "maps pnpm to NPM ecosystem for find_alerts" do + expect_any_instance_of(described_class).to receive(:find_alerts) + .with("test-repo", false, "NPM") + subject.get_closed_findings(parsed_results, "test-repo", "pnpm") + end end describe "#add_alerts_working_set" do From 27211764cb6058e273461e2edb319be8bdbdef03 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 6 Feb 2026 15:09:48 -0700 Subject: [PATCH 12/18] fix some tests. --- lib/library_version_analysis/check_version_status.rb | 2 +- spec/gemfile_spec.rb | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/library_version_analysis/check_version_status.rb b/lib/library_version_analysis/check_version_status.rb index cb1eadc..6a79192 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -380,7 +380,7 @@ def get_mode_summary(results, meta_data) # rubocop:disable Metrics/AbcSize, Metr mode_summary.patch = mode_summary.patch + 1 end - mode_summary.unowned_issues = mode_summary.unowned_issues + 1 if line.owner == :attention_needed + mode_summary.unowned_issues = mode_summary.unowned_issues + 1 if line.owner == :attention_needed || line.owner == :unspecified end mode_summary.one_number = one_number(mode_summary) diff --git a/spec/gemfile_spec.rb b/spec/gemfile_spec.rb index 0d66e04..8ea9d7d 100644 --- a/spec/gemfile_spec.rb +++ b/spec/gemfile_spec.rb @@ -75,6 +75,9 @@ def do_compare(result:, owner:, current_version:, latest_version:, major:, minor before(:each) do allow(LibraryVersionAnalysis::CheckVersionStatus).to receive(:legacy?).and_return(true) + allow(LibraryVersionAnalysis::Configuration).to receive(:get).and_call_original + allow(LibraryVersionAnalysis::Configuration).to receive(:get).with(:special_case_ownerships) + .and_return("actioncable" => { "owner" => ":api_platform", "parent" => nil }) end it "should get expected data for owned gem" do From 6221f6d0e744d2d8b52b7fbde3787917cdab3779 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 6 Feb 2026 15:10:22 -0700 Subject: [PATCH 13/18] cleanup. --- ...dd_pnpm_workspace_support_70e2a7a6.plan.md | 251 ------------------ 1 file changed, 251 deletions(-) delete mode 100644 .cursor/plans/add_pnpm_workspace_support_70e2a7a6.plan.md diff --git a/.cursor/plans/add_pnpm_workspace_support_70e2a7a6.plan.md b/.cursor/plans/add_pnpm_workspace_support_70e2a7a6.plan.md deleted file mode 100644 index 51435dd..0000000 --- a/.cursor/plans/add_pnpm_workspace_support_70e2a7a6.plan.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -name: Add pnpm Workspace Support -overview: Add support for analyzing library versions in pnpm workspace repositories (like GetJobber/jobber-frontend) by creating a new Pnpm class that follows the existing pattern used by Npm and Gemfile classes. -todos: - - id: create-pnpm-class - content: Create lib/library_version_analysis/pnpm.rb with Pnpm class implementing get_versions, add_all_libraries, run_libyear, parse_libyear, add_dependency_graph, break_cycles, and add_ownerships with cascading workspace ownership support - status: pending - - id: register-pnpm - content: Add require statement to lib/library_version_analysis.rb and add case handler + go_pnpm method to check_version_status.rb - status: pending - - id: update-github-sources - content: Add pnpm to SOURCES hash in github.rb mapping to NPM ecosystem for Dependabot - status: pending - - id: create-pnpm-tests - content: Create spec/pnpm_spec.rb with tests for parsing, dependency graph building, and cascading ownership assignment (root defaults, workspace overrides, inheritance) - status: pending -isProject: false ---- - -# Add pnpm Workspace Support - -## Overview - -Extend the library version analysis tool to support pnpm workspaces. The existing codebase follows a strategy pattern where each package manager (npm, gemfile) implements a `get_versions(source)` method. Adding pnpm support follows the same pattern. - -## Key Discovery: libyear pnpm Support - -The `libyear` npm package already supports pnpm natively: - -- `--package-manager pnpm` - explicitly specify pnpm -- `--all` - include dependencies from the whole project (workspaces) -- `--json` - output JSON format - -Command: `pnpx libyear --package-manager pnpm --all --json` - -## Architecture - -```mermaid -flowchart TD - Entry[exe/analyze] --> CheckVersion[CheckVersionStatus.run] - CheckVersion --> Router{source param} - Router -->|npm| NpmClass[Npm.get_versions] - Router -->|gemfile| GemfileClass[Gemfile.get_versions] - Router -->|pnpm| PnpmClass[Pnpm.get_versions] - - PnpmClass --> AddLibs[add_all_libraries via pnpm list] - AddLibs --> RunLibyear[run_libyear via pnpx libyear] - RunLibyear --> ParseResults[parse_libyear] - ParseResults --> Dependabot[add_dependabot_findings] - Dependabot --> DepGraph[add_dependency_graph via pnpm list --json] - DepGraph --> Cycles[break_cycles] - Cycles --> Ownership[add_ownerships] -``` - -## Files to Modify/Create - -### 1. Create [lib/library_version_analysis/pnpm.rb](lib/library_version_analysis/pnpm.rb) (new file) - -Create a new class mirroring the structure of `Npm` class: - -```ruby -module LibraryVersionAnalysis - class Pnpm - include LibraryVersionAnalysis::Ownership - - def get_versions(source) - # Same flow as Npm: - # 1. add_all_libraries (via pnpm list --depth=Infinity) - # 2. run_libyear (via pnpx libyear --package-manager pnpm --all --json) - # 3. parse_libyear - # 4. add_dependabot_findings - # 5. add_dependency_graph (via pnpm list --json --depth=Infinity) - # 6. break_cycles - # 7. add_ownerships - end - end -end -``` - -Key differences from npm.rb: - -- Use `pnpm list --depth=Infinity` instead of `npm list --all` -- Use `pnpx libyear --package-manager pnpm --all --json` for libyear -- Use `pnpm list --json --depth=Infinity` for dependency graph -- Handle workspace structure in JSON parsing (pnpm has different JSON output format) - -### 2. Modify [lib/library_version_analysis.rb](lib/library_version_analysis.rb) - -Add require statement: - -```ruby -require "library_version_analysis/pnpm" -``` - -### 3. Modify [lib/library_version_analysis/check_version_status.rb](lib/library_version_analysis/check_version_status.rb) - -Add case handler and dispatcher method: - -```ruby -# In go method (around line 111): -case source -when "npm" - meta_data, mode = go_npm(spreadsheet_id, repository, source) -when "gemfile" - meta_data, mode = go_gemfile(spreadsheet_id, repository, source) -when "pnpm" # NEW - meta_data, mode = go_pnpm(spreadsheet_id, repository, source) -else - # ... -end - -# New dispatcher method: -def go_pnpm(spreadsheet_id, repository, source) - puts " pnpm" if LibraryVersionAnalysis.dev_output? - pnpm = Pnpm.new(repository) - meta_data, mode = get_version_summary(pnpm, "PnpmVersionData!A:Q", spreadsheet_id, repository, source) - return meta_data, mode -end -``` - -### 4. Modify [lib/library_version_analysis/github.rb](lib/library_version_analysis/github.rb) - -Add ecosystem mapping for Dependabot: - -```ruby -SOURCES = { - "npm": "NPM", - "gemfile": "RUBYGEMS", - "pnpm": "NPM", # pnpm uses npm registry -}.freeze -``` - -### 5. Create [spec/pnpm_spec.rb](spec/pnpm_spec.rb) (new file) - -Add tests following the pattern in `spec/npm_spec.rb`: - -- Test parsing of pnpm list output -- Test dependency graph building -- Test ownership assignment -- Test cycle breaking - -## Implementation Notes - -### pnpm list JSON format - -pnpm's JSON output differs from npm. Example structure: - -```json -[ - { - "name": "workspace-root", - "dependencies": { ... } - }, - { - "name": "@scope/package-a", - "path": "/path/to/packages/a", - "dependencies": { ... } - } -] -``` - -The parser needs to handle the array-of-packages format for workspaces. - -### Cascading Ownership from package.json Files - -Ownership follows a **cascading model** where workspace-level definitions override root-level defaults: - -```mermaid -flowchart TD - subgraph discovery [1. Discovery Phase] - DiscoverWorkspaces[pnpm list -r --depth=-1 --json] --> GetPaths[Get all workspace paths] - end - - subgraph collection [2. Ownership Collection] - ReadRoot[Read root package.json ownerships] --> RootMap[Root ownership map - defaults] - GetPaths --> ReadWorkspaces[Read each workspace package.json] - ReadWorkspaces --> WorkspaceMap[Workspace ownership maps] - end - - subgraph assignment [3. Assignment with Precedence] - WorkspaceMap --> CheckWorkspace{Dependency in workspace ownerships?} - CheckWorkspace -->|Yes| UseWorkspace[Use workspace owner] - CheckWorkspace -->|No| CheckRoot{Dependency in root ownerships?} - RootMap --> CheckRoot - CheckRoot -->|Yes| UseRoot[Use root owner as fallback] - CheckRoot -->|No| UseDefault[Use default_owner_name] - end -``` - -**Precedence rules:** - -1. **Workspace package.json wins** - If a workspace explicitly defines an owner for a dependency, that takes precedence -2. **Root package.json is fallback** - If workspace doesn't specify, fall back to root's ownership definition -3. **Default owner** - If neither defines it, use `Configuration.get(:default_owner_name)` - -**Example structure:** - -``` -monorepo/ -├── package.json # Root ownerships (defaults) -│ └── ownerships: { "lodash": "platform-team", "react": "frontend-team" } -├── pnpm-workspace.yaml -├── packages/ -│ ├── app-a/ -│ │ └── package.json # Can override: { "ownerships": { "lodash": "app-a-team" } } -│ └── app-b/ -│ └── package.json # No ownerships defined → inherits from root -``` - -In this example: - -- `lodash` in app-a → owned by `app-a-team` (workspace override) -- `lodash` in app-b → owned by `platform-team` (root fallback) -- `react` everywhere → owned by `frontend-team` (root default) - -**Implementation in pnpm.rb:** - -```ruby -def add_ownerships(parsed_results) - # 1. Collect all ownership definitions - root_ownerships = read_package_json_ownerships("package.json") - workspace_ownerships = collect_workspace_ownerships # Hash of { workspace_name => { dep => owner } } - - # 2. Apply ownerships with precedence - apply_cascading_ownerships(parsed_results, root_ownerships, workspace_ownerships) - - # 3. Transitive ownership (existing logic) - add_transitive_ownerships(parsed_results) - - # 4. Attention needed for vulnerabilities - add_attention_needed(parsed_results) -end - -def collect_workspace_ownerships - workspaces = discover_workspaces # via pnpm list -r --depth=-1 --json - ownerships = {} - - workspaces.each do |ws| - pkg_json_path = File.join(ws["path"], "package.json") - next unless File.exist?(pkg_json_path) - - ownerships[ws["name"]] = read_package_json_ownerships(pkg_json_path) - end - - ownerships -end -``` - -### CI/CD Considerations - -Similar to the npm implementation, the libyear command may need to be run separately before analysis to avoid memory issues. The output file would be `libyear_report.txt` (same as npm) or a pnpm-specific name like `pnpm_libyear_report.txt`. \ No newline at end of file From 15ba8bc3fed336bd1624c33cc10a83beb477d05a Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 6 Feb 2026 15:11:49 -0700 Subject: [PATCH 14/18] run tests in CI. --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a180b59 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.3.6'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Run tests + run: bundle exec rspec From b23ed4ffa2e81aa711691b38f33c235d36335948 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 6 Feb 2026 15:13:59 -0700 Subject: [PATCH 15/18] fix tests. --- spec/npm_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/npm_spec.rb b/spec/npm_spec.rb index aa87565..106dcae 100644 --- a/spec/npm_spec.rb +++ b/spec/npm_spec.rb @@ -47,8 +47,8 @@ def do_compare(result:, owner:, current_version:, latest_version:, major:, minor context "with legacy app" do subject do analyzer = LibraryVersionAnalysis::Npm.new("test") - allow(analyzer).to receive(:read_gemfile).with("libyear_report.txt", true).and_return(npxfile) - allow(analyzer).to receive(:read_gemfile).with("package.json", false).and_return(packagefile) + allow(analyzer).to receive(:read_file).with("libyear_report.txt", true).and_return(npxfile) + allow(analyzer).to receive(:read_file).with("package.json", false).and_return(packagefile) allow(analyzer).to receive(:run_npm_list).and_return(npmlist) allow(analyzer).to receive(:add_dependabot_findings).and_return(nil) # TODO: will need to retest this allow(analyzer).to receive(:add_ownership_from_transitive).and_return(nil) @@ -358,8 +358,8 @@ def do_compare(result:, owner:, current_version:, latest_version:, major:, minor describe "#add_ownerships" do subject do analyzer = LibraryVersionAnalysis::Npm.new("test") - allow(analyzer).to receive(:read_gemfile).with("libyear_report.txt", true).and_return(npx_file) - allow(analyzer).to receive(:read_gemfile).with("package.json", false).and_return(package_file) + allow(analyzer).to receive(:read_file).with("libyear_report.txt", true).and_return(npx_file) + allow(analyzer).to receive(:read_file).with("package.json", false).and_return(package_file) allow(analyzer).to receive(:run_npm_list).and_return(npm_list) allow(analyzer).to receive(:add_dependabot_findings).and_return(nil) # TODO: will need to retest this From 3582871b19515c372bc25f6e80f9445105379738 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 6 Feb 2026 15:23:09 -0700 Subject: [PATCH 16/18] fix tests 2. --- README.md | 12 ++++++------ lib/library_version_analysis/pnpm.rb | 6 +++--- spec/npm_spec.rb | 21 ++++++++++++++++++++- spec/pnpm_spec.rb | 6 +++--- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0464591..a6d3d53 100644 --- a/README.md +++ b/README.md @@ -97,16 +97,16 @@ library_version_analysis/run.sh ## CI Pipeline Requirements for pnpm Workspaces -For pnpm workspace repositories (monorepos), the CI pipeline must generate per-workspace libyear files before running the analysis. Each workspace gets its own libyear file with an underscore-based naming convention. +For pnpm workspace repositories (monorepos), the CI pipeline must generate per-workspace libyear files before running the analysis. Each workspace gets its own libyear file with a hyphen-based naming convention. ### File Naming Convention | Workspace Path | Libyear Filename | |---------------|------------------| | Root (.) | `libyear_root.txt` | -| apps/client | `libyear_apps_client.txt` | -| apps/server | `libyear_apps_server.txt` | -| packages/ui | `libyear_packages_ui.txt` | +| apps/client | `libyear_apps-client.txt` | +| apps/server | `libyear_apps-server.txt` | +| packages/ui | `libyear_packages-ui.txt` | | Non-workspace repo | `libyear_report.txt` | ### Example CI Script @@ -121,7 +121,7 @@ pnpx libyear --package-manager pnpm --json > libyear_root.txt # Each workspace (skip root at index 0) for workspace in $(pnpm list -r --depth=-1 --json | jq -r '.[1:] | .[].path'); do relative_path="${workspace#$(pwd)/}" - filename="libyear_${relative_path//\//_}.txt" + filename="libyear_${relative_path//\//-}.txt" pnpx libyear --package-manager pnpm --json --cwd "$workspace" > "$filename" || true done ``` @@ -137,7 +137,7 @@ done # Each workspace (skip root at index 0) for workspace in $(pnpm list -r --depth=-1 --json | jq -r '.[1:] | .[].path'); do relative_path="${workspace#$(pwd)/}" - filename="libyear_${relative_path//\//_}.txt" + filename="libyear_${relative_path//\//-}.txt" pnpx libyear --package-manager pnpm --json --cwd "$workspace" > "$filename" || true done diff --git a/lib/library_version_analysis/pnpm.rb b/lib/library_version_analysis/pnpm.rb index 9ff8e97..911b5a9 100644 --- a/lib/library_version_analysis/pnpm.rb +++ b/lib/library_version_analysis/pnpm.rb @@ -84,7 +84,7 @@ def get_versions_from_libyear_files # rubocop:disable Metrics/AbcSize, Metrics/M # Convert libyear filename back to source name # "libyear_report.txt" -> "pnpm" # "libyear_root.txt" -> "root" - # "libyear_apps-anchor.txt" -> "apps/anchor" + # "libyear_apps-anchor.txt" -> "apps/anchor" def source_name_from_libyear_filename(filename) basename = File.basename(filename, ".txt") @@ -94,7 +94,7 @@ def source_name_from_libyear_filename(filename) else # "libyear_root" -> "root" # "libyear_apps-anchor" -> "apps/anchor" - basename.sub(/^libyear_/, "").gsub("_", "/") + basename.sub(/^libyear_/, "").gsub("-", "/") end end @@ -232,7 +232,7 @@ def run_libyear_for_workspace(workspace_source) # Convert source name to libyear filename # "pnpm" -> "libyear_report.txt" (backwards compatible for non-workspace repos) # "root" -> "libyear_root.txt" - # "apps/anchor" -> "libyear_apps_anchor.txt" + # "apps/anchor" -> "libyear_apps-anchor.txt" def libyear_filename_for_source(source) case source when "pnpm" diff --git a/spec/npm_spec.rb b/spec/npm_spec.rb index 106dcae..0af3eac 100644 --- a/spec/npm_spec.rb +++ b/spec/npm_spec.rb @@ -49,9 +49,10 @@ def do_compare(result:, owner:, current_version:, latest_version:, major:, minor analyzer = LibraryVersionAnalysis::Npm.new("test") allow(analyzer).to receive(:read_file).with("libyear_report.txt", true).and_return(npxfile) allow(analyzer).to receive(:read_file).with("package.json", false).and_return(packagefile) - allow(analyzer).to receive(:run_npm_list).and_return(npmlist) + allow(analyzer).to receive(:run_npm_list).and_return(npm_list_json) allow(analyzer).to receive(:add_dependabot_findings).and_return(nil) # TODO: will need to retest this allow(analyzer).to receive(:add_ownership_from_transitive).and_return(nil) + allow(Open3).to receive(:capture3).with("npm list --all --silent").and_return([npmlist, "", Status.new(0)]) analyzer.get_versions("test") end @@ -72,6 +73,24 @@ def do_compare(result:, owner:, current_version:, latest_version:, major:, minor DOC end + let(:npm_list_json) do + <<~JSON + { + "dependencies": { + "@apollo/client": { + "dependencies": { + "@wry/context": { + "dependencies": { + "@babel/polyfill": {} + } + } + } + } + } + } + JSON + end + before(:each) do allow(LibraryVersionAnalysis::CheckVersionStatus).to receive(:legacy?).and_return(true) end diff --git a/spec/pnpm_spec.rb b/spec/pnpm_spec.rb index 3e07533..677084c 100644 --- a/spec/pnpm_spec.rb +++ b/spec/pnpm_spec.rb @@ -553,12 +553,12 @@ def do_compare(result:, owner:, current_version:, latest_version:, major:, minor expect(analyzer.send(:libyear_filename_for_source, "root")).to eq("libyear_root.txt") end - it "should convert slashes to underscores for nested paths" do - expect(analyzer.send(:libyear_filename_for_source, "apps/anchor")).to eq("libyear_apps_anchor.txt") + it "should convert slashes to hyphens for nested paths" do + expect(analyzer.send(:libyear_filename_for_source, "apps/anchor")).to eq("libyear_apps-anchor.txt") end it "should handle deeply nested paths" do - expect(analyzer.send(:libyear_filename_for_source, "packages/ui/components")).to eq("libyear_packages_ui_components.txt") + expect(analyzer.send(:libyear_filename_for_source, "packages/ui/components")).to eq("libyear_packages-ui-components.txt") end end From 8e91c675badb1933ad8d1008acde36bf3b9cb691 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Fri, 6 Feb 2026 15:29:21 -0700 Subject: [PATCH 17/18] update README. --- README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/README.md b/README.md index a6d3d53..acae951 100644 --- a/README.md +++ b/README.md @@ -109,23 +109,6 @@ For pnpm workspace repositories (monorepos), the CI pipeline must generate per-w | packages/ui | `libyear_packages-ui.txt` | | Non-workspace repo | `libyear_report.txt` | -### Example CI Script - -```bash -#!/bin/bash -# Generate libyear reports for each workspace - -# Root workspace -pnpx libyear --package-manager pnpm --json > libyear_root.txt - -# Each workspace (skip root at index 0) -for workspace in $(pnpm list -r --depth=-1 --json | jq -r '.[1:] | .[].path'); do - relative_path="${workspace#$(pwd)/}" - filename="libyear_${relative_path//\//-}.txt" - pnpx libyear --package-manager pnpm --json --cwd "$workspace" > "$filename" || true -done -``` - ### Example CI Configuration (CircleCI/GitHub Actions) ```yaml From 8de67fac3792e74b0793f27243747920c1254be0 Mon Sep 17 00:00:00 2001 From: Timotei Albu Date: Thu, 12 Feb 2026 13:05:06 -0700 Subject: [PATCH 18/18] update readme to explain how uploading to the spreadsheet works for pnpm workspaces. --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index acae951..b7404e3 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,38 @@ For non-workspace pnpm repositories (single package.json), continue using the ex pnpx libyear --package-manager pnpm --all --json > libyear_report.txt ``` +## Library Tracking Server Integration + +This gem uploads per-workspace data to the [library_tracking](https://github.com/GetJobber/library_tracking) Rails app via `POST /api/libraries/upload`. + +### Upload Payload + +For each pnpm workspace, the gem sends a separate upload with: + +```json +{ + "source": "", + "repository": "", + "libraries": [...], + "new_versions": [...], + "vulnerabilities": [...], + "dependencies": [...] +} +``` + +| Field | Value | Example | +|-------|-------|---------| +| `repository` | The first CLI argument, passed through | `"jobber-frontend"` | +| `source` | The workspace name (root workspace becomes `"root"`, nested workspaces use their relative path) | `"root"`, `"packages/ui"` | + +For non-workspace pnpm repos, `source` is `"pnpm"`. For other package managers, `source` matches the CLI argument (`"gemfile"`, `"npm"`). + +### Database Disambiguation + +The library_tracking database uniquely identifies a library by the composite index `(name, source, repository_id)`. This means the same library (e.g., `react`) can exist independently in multiple workspaces within the same repository, each tracked with its own version history, vulnerabilities, and dependency graph. + +> **Note:** The [DB diagram](https://dbdiagram.io/d/versions-63dfe88e296d97641d7e9064) is outdated and does not show the `source` column on the `libraries` table. Refer to `db/schema.rb` in the [library_tracking repo](https://github.com/GetJobber/library_tracking) for the current schema. + ## Contributing Not supported