Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ gem "rspec", "~> 3.0"
gem "graphql", "~> 2.4.8"
gem "graphql-client", "~> 0.18"

# representable (pulled in via google-api-client) requires "multi_json" at
# runtime but does not declare it as a dependency, so bundler omits it and
# loading google/apis/sheets_v4 fails with "multi_json is not part of the bundle".
gem "multi_json"

plugin "bundler-why"
98 changes: 72 additions & 26 deletions lib/library_version_analysis/pnpm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -279,44 +279,90 @@ def run_libyear_open3
results
end

def add_all_libraries(workspace_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
# Resolve the installed ("current") version of each dependency for the
# analyzed workspace using the structured `pnpm list --json` output.
#
# We deliberately read the resolved `version` field instead of scraping the
# rendered tree output: the text rendering depends on the terminal (e.g. the
# `├── ` prefix is absent in non-TTY/CI runs), which previously caused every
# current_version to come back blank.
def add_all_libraries(workspace_path = nil)
all_libraries = {}
cmd = if workspace_path
"pnpm list --dir #{workspace_path} --depth=0 --silent"
else
"pnpm list --depth=0 --silent"
end

results, _stderr, _status = Open3.capture3(cmd)

results.each_line do |line|
next if line.include?("UNMET OPTIONAL DEPENDENCY")
results = run_pnpm_list_depth0
return all_libraries if results.nil?

# pnpm list output format is slightly different from npm
# Example: ├── lodash 4.17.21
scan_result = line.scan(/^.*?\s([@\w][^\s]+)\s([.\d]+)/)
begin
json = JSON.parse(results)
rescue JSON::ParserError
return all_libraries
end

if scan_result.nil? || scan_result.empty?
# Try alternative format: ├── @scope/package@version
scan_result = line.scan(/^.*?\s([@\w].+)@([.\d]+)/)
end
packages = json.is_a?(Array) ? json : [json]
package = select_workspace_package(packages, workspace_path)
return all_libraries if package.nil?

unless scan_result.nil? || scan_result.empty?
name = scan_result[0][0]
%w(dependencies devDependencies).each do |group|
(package[group] || {}).each do |name, info|
version = current_version_from_info(info)
next if version.nil? # link:/workspace: specifiers have no installed version

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
add_library_version(all_libraries, name, version)
end
end

return all_libraries
end

# `pnpm list --json` returns an array of project objects (one per workspace).
# Pick the entry whose resolved path matches the workspace being analyzed so
# per-workspace results are distinct. When no workspace_path is given (a
# single-package repo) there is only one entry to use.
def select_workspace_package(packages, workspace_path)
return packages.first if workspace_path.nil?

normalized = File.expand_path(workspace_path)
match = packages.find { |p| p["path"] && File.expand_path(p["path"]) == normalized }

if match.nil?
warn "Could not find pnpm list entry for workspace #{workspace_path}; skipping current versions for this workspace."
end

match
end

# Returns the resolved semver string, or nil when there is no installed
# version (missing, or a non-semver specifier such as link:/workspace:).
def current_version_from_info(info)
return nil unless info.is_a?(Hash)

version = info["version"]
return nil if version.nil? || version.empty?
return nil if version.include?(":") # e.g. "link:packages/x", "workspace:*"

version
end

def add_library_version(all_libraries, name, version)
existing = all_libraries[name]
if existing.nil?
all_libraries[name] = new_version_line(version)
else
existing.current_version = calculate_version(existing.current_version, version)
end
end

def run_pnpm_list_depth0
# No --dir: from a workspace root pnpm lists every workspace project, and
# we select the relevant one in select_workspace_package. (--dir collapses
# to the workspace root, which would make every workspace identical.)
results, _stderr, status = Open3.capture3("pnpm list --depth=0 --json")

return nil if status.exitstatus != 0

results
end

def new_version_line(current_version)
Versionline.new(
owner: LibraryVersionAnalysis::Configuration.get(:default_owner_name),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-09
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
## Context

`Pnpm#add_all_libraries` populates `Versionline#current_version` for every dependency before `parse_libyear` merges in latest-version/drift data and `server_data` uploads `libraries[].version` to Library Tracking. Today it shells out to `pnpm list --depth=0 --silent` and parses the **rendered text** with two regexes:

```ruby
scan_result = line.scan(/^.*?\s([@\w][^\s]+)\s([.\d]+)/) # "<prefix> name version"
scan_result = line.scan(/^.*?\s([@\w].+)@([.\d]+)/) if empty # "<prefix> name@version"
```

Both begin with `^.*?\s`, so they only match when a package line has a leading prefix (the `├── ` tree connector) before the name. In CI (non-TTY) pnpm emits bare `name@version` lines without that prefix, so both regexes fail and `current_version` is left blank for ~all libraries — the symptom in LIBTRACK-136. The nightly upload log confirms every library uploads as `name @ <blank>`.

Two further problems live in the same path:
- `add_all_libraries(workspace_path)` runs `pnpm list --dir <workspace_path> ...`, but pnpm resolves `--dir` to the workspace **root**, so all 24 workspaces are analyzed against identical root data.
- The repo already has a structured alternative: `run_pnpm_list` calls `pnpm list ... --json` and `add_dependency_graph` walks the parsed object's `dependencies` / `devDependencies`.

## Goals / Non-Goals

**Goals:**
- Populate `current_version` reliably for pnpm repos, independent of terminal rendering, so CI and local runs agree.
- Make per-workspace current-version resolution actually reflect the analyzed workspace.
- Preserve the existing contract: libyear merge (`parse_libyear`), the `a..b` range form (`calculate_version`), and the uploaded `version` field shape.

**Non-Goals:**
- Changing libyear sourcing, dependency-graph construction, ownership, or the upload payload schema.
- Fixing `jobber-frontend`'s TS analyzer JSON-extraction failure (separate, jobber-frontend-owned).
- Changing the npm or gemfile analyzers.

## Decisions

**1. Resolve current versions from `pnpm list --json` instead of rendered text.**
Parse the JSON and read each package's `version` field from the project's `dependencies` and `devDependencies` maps. Rationale: the JSON `version` is the resolved semver string with no tree connectors, peer-suffix decoration, or TTY dependence — eliminating the entire regex-format failure class. Alternative considered: harden the regex (e.g. tolerate missing prefix). Rejected — it chases pnpm's rendering choices and remains brittle; structured output is already used elsewhere in this file.

**2. Reuse the existing JSON invocation.**
`run_pnpm_list(workspace_path)` already returns `pnpm list ... --json`. Source current versions from the same call rather than introducing a second `pnpm list` shape. This also unifies how the workspace is selected.

**3. Select the correct workspace from the JSON.**
`pnpm list --json` returns an array of project objects. Choose the entry corresponding to the analyzed `workspace_path` (match on its `path`/`name`) and read that entry's `dependencies`/`devDependencies`, rather than relying on `--dir` (which collapses to root). Rationale: fixes the per-workspace duplication with the data already in hand.

**4. Keep merge and range semantics.**
Continue returning a `{ name => Versionline }` map so `parse_libyear` is unchanged. When a name appears with multiple resolved versions, keep combining via `calculate_version` to produce the `a..b` range. Packages with non-semver specifiers (`link:`, `workspace:`) resolve to an empty current version, matching prior intent for unversioned local links.

## Risks / Trade-offs

- **JSON field shape differs from assumptions** → Validate against real `pnpm list --json` for `jobber-frontend` (root + a workspace) during implementation; cover scoped names, `link:` deps, and multi-version packages in specs.
- **Workspace-selection ambiguity (root vs. a package sharing a path prefix)** → Match the workspace entry exactly by its resolved path/name; fall back explicitly and log when no entry matches rather than silently using root.
- **Behavior change in `current_version` values** (previously blank, now populated; ranges may appear) → This is the intended fix; note it so Library Tracking consumers expect populated versions and occasional `a..b` ranges.
- **`add_all_libraries` is stubbed in current tests** → Add focused unit coverage so the JSON path is actually exercised, not mocked away.

## Migration Plan

No data migration. The next nightly `static_analysis` run uploads populated versions, overwriting the blank values currently stored. Rollback = revert the change; the prior (blank-version) behavior returns. No schema or config changes required.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Why

For pnpm repositories (e.g. `jobber-frontend`), the analyzer uploads a blank `current_version` for essentially every tracked library, so Library Tracking shows no installed version (LIBTRACK-136). The current-version lookup parses `pnpm list --depth=0 --silent` **text** with regexes that require a leading tree prefix (`├── `) before each package name. In the non-interactive CI environment pnpm emits bare `name@version` lines with no prefix, so the regexes match nothing and `current_version` is left empty. A related defect in the same code path makes per-workspace analysis non-functional: `pnpm list --dir <subdir>` resolves to the workspace root, so every workspace is analyzed against identical root-level data.

## What Changes

- Replace the text + regex current-version resolution in `Pnpm#add_all_libraries` with the structured `pnpm list ... --json` output (the repo already consumes `pnpm list --json` for the dependency graph via `run_pnpm_list`). Read each package's `version` field directly instead of scraping rendered tree output.
- Resolve current versions from the correct workspace's `dependencies` and `devDependencies` so per-workspace analysis reflects that workspace, not the repo root.
- Preserve existing downstream behavior: the merge with libyear data (`parse_libyear`), the multi-occurrence version range (`a..b` via `calculate_version`), and the uploaded `libraries[].version` field shape.
- Remove the dependence on terminal/TTY-specific rendering so results are identical in local and CI runs.

## Capabilities

### New Capabilities
- `pnpm-version-analysis`: Resolving the installed ("current") version of each dependency in a pnpm project/workspace and exposing it for upload, using structured pnpm output rather than rendered text.

### Modified Capabilities
<!-- None: no existing specs in this repository. -->

## Impact

- **Code**: `lib/library_version_analysis/pnpm.rb` — `add_all_libraries` (rewritten to parse JSON), and its callers `get_versions` / `get_versions_for_workspace`. Touches how `Versionline#current_version` is populated before `parse_libyear` merges libyear data.
- **Behavior**: `current_version` becomes populated for pnpm repos; the uploaded Library Tracking payload (`server_data`) carries real versions. Per-workspace uploads become genuinely distinct.
- **Tests**: `spec/pnpm_spec.rb` — `add_all_libraries` is currently stubbed; add coverage for JSON-based resolution and workspace scoping.
- **Out of scope (related, separately owned)**: `jobber-frontend`'s `scripts/codeAnalysis/analyzers/libraryVersionAnalysis.ts` greedily `JSON.parse`-ing the gem's stdout and failing on Ruby hash-inspect output (the `static_analysis` CI job error). Tracked as a separate jobber-frontend-owned change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
## ADDED Requirements

### Requirement: Resolve current versions from structured pnpm output

The system SHALL determine each dependency's installed ("current") version by reading the structured `version` field from `pnpm list`'s JSON output, and SHALL NOT depend on the rendered text/tree formatting of `pnpm list`.

#### Scenario: Plain dependency version

- **WHEN** a pnpm project has a dependency `@apollo/client` resolved to `3.13.8`
- **THEN** the resolved current version for `@apollo/client` SHALL be `3.13.8`

#### Scenario: Scoped and unscoped names

- **WHEN** the project includes both a scoped package (e.g. `@datadog/datadog-api-client`) and an unscoped package (e.g. `wrangler`)
- **THEN** the current version SHALL be resolved for both, keyed by their full package names

#### Scenario: Output rendering does not affect results

- **WHEN** pnpm is invoked in a non-interactive (non-TTY) environment such as CI
- **THEN** the resolved current versions SHALL be identical to those produced in an interactive environment for the same installed dependency tree

### Requirement: Current versions reflect the analyzed workspace

The system SHALL resolve current versions from the specific workspace being analyzed, using that workspace's `dependencies` and `devDependencies`, so that per-workspace results are distinct.

#### Scenario: Per-workspace direct dependencies

- **WHEN** workspace `apps/jobber-online` declares `storybook` and workspace `packages/core` does not
- **THEN** the current version for `storybook` SHALL be present in the `apps/jobber-online` results and absent from the `packages/core` results

#### Scenario: Workspaces are not collapsed to the repository root

- **WHEN** analyzing a multi-workspace pnpm monorepo
- **THEN** each workspace's resolved current-version set SHALL be derived from that workspace and SHALL NOT be uniformly replaced by the repository root's dependency set

### Requirement: Preserve downstream version handling

The system SHALL continue to merge resolved current versions with libyear data and SHALL preserve the existing representation of a current version, including the range form used when a dependency resolves to more than one version.

#### Scenario: Merge with libyear-tracked library

- **WHEN** a library appears in both the resolved current versions and the libyear report
- **THEN** the uploaded record SHALL carry the resolved current version alongside the libyear-provided latest version

#### Scenario: Multiple resolved versions for one package

- **WHEN** a package resolves to more than one version across the analyzed set (e.g. `4.54.0` and `4.76.0`)
- **THEN** the current version SHALL be expressed as a range (`4.54.0..4.76.0`) consistent with the existing version-combining behavior

#### Scenario: Library with no resolvable current version

- **WHEN** a tracked library has no resolvable installed version (e.g. a workspace `link:` dependency)
- **THEN** the system SHALL record an empty current version for that library without aborting analysis of the remaining libraries
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## 1. Confirm JSON shape

- [x] 1.1 Capture real `pnpm list --depth=0 --json` for `jobber-frontend` root and for one app workspace (e.g. `apps/jobber-online`); record the array/object structure and the `dependencies`/`devDependencies` `version` field shape
- [x] 1.2 Note how `link:`/`workspace:` deps and multi-version packages appear in the JSON, to confirm empty-version and range handling

## 2. Rewrite current-version resolution

- [x] 2.1 Replace the text+regex parsing in `Pnpm#add_all_libraries` with JSON parsing of `pnpm list ... --json` (reuse `run_pnpm_list`), reading each package's `version` from `dependencies` and `devDependencies`
- [x] 2.2 Select the JSON entry matching the analyzed `workspace_path` (by resolved path/name) instead of relying on `--dir`; when no entry matches, log and skip rather than silently falling back to root
- [x] 2.3 Build the `{ name => Versionline }` map with `new_version_line`, combining duplicate names via `calculate_version` to preserve the `a..b` range form
- [x] 2.4 Resolve non-semver specifiers (`link:`, `workspace:`) to an empty current version without aborting the rest of the analysis
- [x] 2.5 Confirm `get_versions` (single-package repo) and `get_versions_for_workspace` (monorepo) both route through the new resolution and still feed `parse_libyear` unchanged

## 3. Tests

- [x] 3.1 Add a dedicated `#add_all_libraries` context in `spec/pnpm_spec.rb` (the method was stubbed everywhere) with fixtures based on the real JSON from task 1
- [x] 3.2 Cover: scoped + unscoped names resolve; per-workspace results differ; multi-version → range; `link:`/`workspace:` dep → skipped; single-package repo; pnpm-list failure → empty
- [x] 3.3 `bundle exec rspec` passes (116 examples, 0 failures), including the new `#add_all_libraries` specs. Also fixed a pre-existing suite-load failure by declaring `multi_json` in the Gemfile (representable pulls it in via google-api-client but does not declare it, so bundler omitted it and `require "google/apis/sheets_v4"` failed).

## 4. Verify against jobber-frontend

- [x] 4.1 Ran the real `add_all_libraries` against live `pnpm list --depth=0 --json` from the `jobber-frontend` worktree: 0 blank versions across root/apps/jobber-online/packages/core; `wrangler`/`storybook` now populated
- [x] 4.2 Confirmed two workspaces produce different current-version sets (jobber-online=119 libs vs core=12) — per-workspace duplication is gone

## 5. Release

- [ ] 5.1 (Release-time follow-up) Bump the gem version + tag a new release, then advance `jobber-frontend` `bin/Gemfile`'s git tag to consume the fix. Deferred: requires a tag on the merged commit and a separate jobber-frontend-owned change.
Loading
Loading