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 diff --git a/README.md b/README.md index fc63dba..b7404e3 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 @@ -65,6 +95,78 @@ 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 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` | +| Non-workspace repo | `libyear_report.txt` | + +### 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 +``` + +## 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 diff --git a/exe/analyze b/exe/analyze index ea97379..44599c7 100755 --- a/exe/analyze +++ b/exe/analyze @@ -6,16 +6,32 @@ 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 packages/ui # 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.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..6a79192 100755 --- a/lib/library_version_analysis/check_version_status.rb +++ b/lib/library_version_analysis/check_version_status.rb @@ -48,7 +48,7 @@ def self.dev_output? 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 LIBRARY_UPLOAD_URL UPLOAD_KEY) missing_keys = keys.reject { |key| !ENV[key].nil? && !ENV[key].empty? } @@ -56,7 +56,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}" @@ -72,7 +72,12 @@ def self.run(spreadsheet_id: "", repository: "", source: "") 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 @@ -94,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 @@ -113,6 +118,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, context) else puts "Don't recognize source #{source}" exit(-1) @@ -151,6 +158,59 @@ def go_npm(spreadsheet_id, repository, source) return meta_data, mode end + 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 = {} + combined_meta_data = nil + + # Upload each workspace separately + 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) + + # Store first workspace's meta_data for print_summary (backwards compatible) + combined_meta_data ||= meta_data + + all_modes[workspace_name] = mode + + if @update_spreadsheet + 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_name}" if LibraryVersionAnalysis.dev_output? + # Use workspace_name as the source for pnpm workspaces + server_payload = server_data(parsed_results, repository, workspace_name) + 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 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) parsed_results, meta_data = parser.get_versions(source) mode = get_mode_summary(parsed_results, meta_data) @@ -163,7 +223,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 @@ -238,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}"] @@ -316,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) @@ -347,8 +411,61 @@ 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 + + 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 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..911b5a9 --- /dev/null +++ b/lib/library_version_analysis/pnpm.rb @@ -0,0 +1,599 @@ +require "library_version_analysis/ownership" +require "library_version_analysis/configuration" +require "pathname" + +module LibraryVersionAnalysis + class Pnpm + include LibraryVersionAnalysis::Ownership + + 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 + + # 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") + 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 + + # 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 + 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, "pnpm") + + 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? + 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, workspace_path = nil) # rubocop:disable Metrics/MethodLength + results = run_pnpm_list(workspace_path) + if results.nil? + warn "Skipping dependency graph: pnpm list failed" + return {} + end + + 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 + + # 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." + warn "Ensure libyear files are generated in CI before running analysis." + 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) + + return nil if status.exitstatus != 0 + + results + end + + def add_all_libraries(workspace_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + all_libraries = {} + cmd = if workspace_path + "pnpm list --dir #{workspace_path} --depth=Infinity --silent" + else + "pnpm list --depth=Infinity --silent" + end + + results, _stderr, _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, 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) + end + + # 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, _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 + [] + 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, _stderr, 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(workspace_path = nil) + cmd = if workspace_path + "pnpm list --dir #{workspace_path} --json --depth=Infinity --silent" + else + "pnpm list --json --depth=Infinity --silent" + end + + results, _stderr, status = Open3.capture3(cmd) + + return nil if status.exitstatus != 0 + + results + end + end +end 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 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 diff --git a/spec/npm_spec.rb b/spec/npm_spec.rb index aa87565..0af3eac 100644 --- a/spec/npm_spec.rb +++ b/spec/npm_spec.rb @@ -47,11 +47,12 @@ 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(:run_npm_list).and_return(npmlist) + 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(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 @@ -358,8 +377,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 diff --git a/spec/pnpm_spec.rb b/spec/pnpm_spec.rb new file mode 100644 index 0000000..677084c --- /dev/null +++ b/spec/pnpm_spec.rb @@ -0,0 +1,694 @@ +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 + + 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 + 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 + + 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 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") + 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