Skip to content

fix(lockfile): drop the redundant npm-alias target left by the pnpm reader - #609

Merged
colinhacks merged 3 commits into
mainfrom
dedupe-alias-578
Jul 29, 2026
Merged

fix(lockfile): drop the redundant npm-alias target left by the pnpm reader#609
colinhacks merged 3 commits into
mainfrom
dedupe-alias-578

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

Closes #578

The pnpm reader kept the canonical real-name entry alongside the alias-keyed clone it synthesizes for an npm: alias. A fresh resolve emits only the clone, so consumers walking packages raw disagreed with a re-resolved graph: dedupe reported a phantom removal every run (--check red forever on a byte-identical lockfile), fetch over-counted, rebuild <real-name> matched a non-installed dep. Usually hit transitively, via glob@10 reaching @isaacs/cliui.

The reader now drops an alias-clone source nothing else reaches; shared targets survive, file: targets are excluded.

Also fixed, each blind to aliased packages: ignored-builds/approve-builds (a prerequisite here), audit, outdated.

Conformance green against real pnpm 10.15.1 and npm 11.17.0.

…eader

Reading a pnpm-lock.yaml with an `npm:` alias synthesized an alias-keyed
package AND kept the canonical real-name-keyed entry it was cloned from.
A fresh resolve produces only the alias-keyed one, so the two graphs
disagreed and every consumer that walks `packages` raw disagreed with
them: dedupe reported a phantom removal on every run and `--check`
exited 1 forever even though the lockfile was byte-identical; fetch
over-counted and re-imported the tarball; `rebuild <real-name>` matched
a package that is not an installed dependency. install and the linker
were unaffected because they run filter_graph's reachability GC first.

The reader now drops each alias-clone source that no importer and no
surviving package edge reaches. It cannot drop unconditionally: an
aliased package is frequently also a genuine dependency of something
else, as in @isaacs/cliui, which reaches string-width@4.2.3 both through
its string-width-cjs alias and through wrap-ansi's own edge.

Dropping it is safe for serialization because the pnpm writer
regenerates the canonical packages:/snapshots: key from the alias clone
via alias_of. A `file:` target is excluded, since the writer keys locals
by name rather than alias_of and could not regenerate one from the
clone.

Three related `pkg.name` vs `registry_name()` misses are fixed alongside,
each of which made a command blind to npm-aliased packages:

- ignored-builds/approve-builds looked the store index up by the alias,
  so an aliased package's unreviewed install script silently dropped out
  of the report and could never be approved. This is a prerequisite for
  the reader change: the phantom entry was what kept the pnpm-lock path
  reporting correctly, so removing it without this fix would have turned
  pnpm-lock mode into the same silent miss every other format had.
  approve-builds reports and writes allowBuilds under the real name but
  hands the follow-up rebuild its graph names, which stay the alias --
  a second divergence class alongside the source-backed one already
  threaded there, and plural because one build can back several graph
  nodes.
- audit queried advisories by the alias, which the endpoint has never
  heard of, so an aliased package came back clean no matter how many
  advisories it had. The response readback already keyed on
  registry_name(), so the alias spelling could never have matched.
- outdated fetched the packument by the alias, warned that it could not
  be found, and then reported the dependency as up to date forever. Rows
  still display the alias the user wrote in package.json.
Copilot AI review requested due to automatic review settings July 29, 2026 07:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review any files in this pull request.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview, Comment Jul 29, 2026 9:08am

Request Review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No correctness issues found in the sweep or the companion fixes — two test-coverage rough edges worth a look.

Reviewed changes — the pnpm reader's npm:-alias handling, which left both the canonical real-name entry and the synthesized alias clone in the parsed graph while a fresh resolve emits only the clone, plus the four commands that were independently blind to aliased packages.

  • Drop the unreferenced alias-clone source in the pnpm readerread.rs records the canonical key each clone came from (gated on local_source.is_none(), so a file: target survives) and sweeps the ones no importer DirectDep and no surviving package edge reaches, resolving edges through resolve_dep_edge exactly as filter_graph does.
  • Re-key the audit advisory query on registry_name() — required by the sweep, not optional: the canonical entry was accidentally supplying the real name to the bulk endpoint, and resolved_versions_by_name already read the response back by registry_name().
  • Identify ignored-builds entries by registry_name()name, approval_key, and the store read move together so is_source_backed()'s approval_key != name test stays intact, and seen becomes a BTreeMap<_, Option<usize>> so a collapsed second graph node still contributes to the new IgnoredEntry.graph_names.
  • Thread graph names through the approve-builds rebuild handoffrun_project now returns the spelling rebuild matches on (p.name) rather than the registry name it was approved under.
  • Key outdated's packument fetches on the registry name — deduped by registry name, row lookup switched from remove to get since several deps can share one, warnings deduped; rows still display the alias the user wrote.
  • Update the lockfile tests to assert the drop — three existing alias tests flip to asserting absence, and two new tests pin the counterparts that must survive (an alias target shared with wrap-ansi, and a file: target the writer cannot regenerate).

The sweep holds up under the checks that matter. A single pass is sufficient because the clone is real_pkg.clone() and so carries the canonical's dep edges verbatim, keeping the dropped entry's children reachable. The pnpm writer regenerates the canonical packages: / snapshots: key and the importer version: field from alias_of, so the byte-identical round-trip survives — workspace_lockfile_round_trips_byte_identical_to_native_pnpm and the other 149 pnpm:: tests pass locally. Peer-context suffixes embedded in sibling keys are not a reference channel anywhere in the crate, and filter_graph already ignores them, so the sweep is no less conservative than the GC install has always run. Running before the git/tarball re-keying pass is safe because every local_source-bearing target is excluded from the recorded set.

ℹ️ The symptom the issue reports has no regression test

The parser invariant is well covered by the new and updated unit tests, but nothing pins the user-visible contract that broke. The reporter's failure was nub dedupe --check exiting 1 forever on a byte-identical lockfile, and vendor/aube/test/dedupe.bats still has no npm: alias case — its closest test uses a plain dependency. A future change to the sweep's retain predicate, to filter_graph, or to diff_graphs could reopen #578 with every unit test still green.

The alias-merge accumulation in collect_ignored is in the same position: the BTreeMap<_, Option<usize>> bookkeeping that folds two graph nodes into one entry and grows graph_names is reached only by an aliased or duplicated build, and no test constructs that graph.

Technical details
# The symptom the issue reports has no regression test

## Affected sites
- `vendor/aube/test/dedupe.bats` — has `aube dedupe --check exits 0 when lockfile is already deduped` and `aube dedupe removes orphan entries from the lockfile`, but no `npm:` alias fixture. The #578 repro (`"number-alias": "npm:is-number@7.0.0"` → install → `dedupe --check`) is not exercised at the command level anywhere.
- `vendor/aube/crates/aube/src/commands/ignored_builds.rs:211-287` — the `seen` merge path (`graph_names.push` for a second graph node behind the same build) has no test. The file has no `#[cfg(test)] mod tests` at all, and the new unit test in `approve_builds.rs` constructs an `IgnoredEntry` by hand rather than driving `collect_ignored`.

## Required outcome
- A test that fails if the phantom removal returns: install (or hand-write) a pnpm v9 lockfile with an `npm:` alias, then assert `dedupe --check` exits 0 and the lockfile hash is unchanged across a `dedupe` run.
- A test that exercises `collect_ignored`'s alias-merge on a graph where an alias and its real package (or two aliases of one package) both declare a lifecycle hook, asserting one entry with both graph names.

## Suggested approach
The `dedupe.bats` case is the cheaper and higher-value of the two — the existing `aube dedupe --check exits 0 when lockfile is already deduped` test is the template, and the alias fixture is a two-line `package.json` change. For `collect_ignored`, the blocker is that it reads a real lockfile and store; if factoring out a pure `(graph, policy) -> Vec<IgnoredEntry>` seam is more churn than it is worth, a bats case over `ignored-builds` on an aliased build-script package covers the same behavior end to end.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread vendor/aube/crates/aube/src/commands/approve_builds.rs Outdated
The parser invariant was covered but the user-visible contract from #578
was not, so a change to the reader's sweep or to diff_graphs could
reopen it with every unit test green.

diff_graphs gains three cases: an aliased dep reads as no change across
a re-resolve, a lingering canonical entry WOULD report a phantom removal
(pinning the shape the reader must never hand back), and a shared alias
target is absent from the diff. These are pure graph fixtures, so they
run in the ordinary aube cargo test job rather than needing a registry.

pm_verbs gains the reporter's end-to-end repro -- install an npm: alias
under pnpm-lock.yaml, then assert dedupe leaves the lockfile
byte-identical and --check exits 0. It resolves from the registry, so it
follows the file's existing #[ignore = "network"] convention.

The approve-builds handoff assertion re-implemented the expression it
was meant to pin, so it could not have failed. The handoff is now a
named rebuild_handoff_names called by both run_project and the test.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The fix itself is unchanged and still holds up — two rough edges in the new test coverage.

Reviewed changese28508a, which is test-only: it answers both coverage gaps the previous review raised, adding a command-level npm:-alias dedupe test and a diff_graphs unit-test module, and extracting the approve-builds rebuild handoff so its test drives the production expression.

  • Add a command-level npm:-alias dedupe regression testdedupe_ignores_npm_alias_targets_and_check_passes installs a number-alias: npm:is-number@7.0.0 fixture, then asserts dedupe reports "already deduped", leaves the lockfile byte-identical, and --check exits 0. Mirrors the adjacent workspace-link test exactly.
  • Extract rebuild_handoff_names — the graph-name handoff is now a named function over &[&IgnoredEntry] called from both run_project and its unit test, so the test no longer re-implements the expression it pins.
  • Add a diff_graphs unit-test module in dedupe.rs — three tests over hand-built LockfileGraphs covering the deduped shape, the pre-fix lingering-canonical shape, and a shared alias target.

I traced the new integration test against the pre-fix reader and it genuinely reproduces the reported failure, so the guard is real where it runs. The fixture's packageManager: pnpm@11.17.0 selects pnpm-lock.yaml through detect.rs; the native_pnpm_aliases gate at write.rs:68-71 makes the writer emit only the canonical is-number@7.0.0; the pre-fix reader hands back both keys, so diff_graphs returns removed = ["is-number@7.0.0"] and all three of the test's assertions fail. The extraction also matches the previous review's suggested shape, and that thread is resolved.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-cli/tests/pm_verbs.rs
Comment thread vendor/aube/crates/aube/src/commands/dedupe.rs Outdated
Two of the three diff_graphs cases compared a graph against an identical
copy of itself. diff_graphs never reads alias_of -- it filters on
local_source and compares keys -- so both fixtures' alias fields were
inert and the assertions reduced to reflexivity of a set difference.
They passed whether or not the reader's sweep existed.

Both now parse a real pnpm-lock.yaml fixture and diff the reader's own
output against the shape a fresh resolve emits, which is the only way
the sweep can be observed from this layer. Mutation-checked in both
directions: disabling the sweep fails the orphan case, and making it
unconditional fails the shared-target case.

a_lingering_alias_target_would_report_a_phantom_removal stays a pure
graph fixture on purpose -- it pins that #578 was fixed in the reader
rather than papered over inside this filter, which is a constraint on a
future change rather than an observation about the current one.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changesbd04143, which is test-only: it rewires two of the three diff_graphs unit tests to drive the real pnpm reader instead of hand-built graphs, closing both coverage gaps the previous review raised.

  • Parse the fixtures through the reader — a new parse_pnpm helper writes an inline pnpm v9 lockfile to a temp dir and calls aube_lockfile::pnpm::parse, so parsed is the reader's own output rather than a literal whose alias_of field diff_graphs never consults.
  • Rename both tests to the behavior they now pinan_orphan_alias_target_is_swept_so_dedupe_sees_no_change covers the drop, and an_alias_target_a_second_consumer_needs_survives_the_sweep covers the counterpart on the real @isaacs/cliui shape, where wrap-ansi@7.0.0's own edge keeps string-width@4.2.3 live.
  • Print the parsed key set on failure — both assertions now carry the parsed keys in the message, so a CI failure names the offending entries without a local rerun.
  • Keep the hand-built lingering-canonical testa_lingering_alias_target_would_report_a_phantom_removal stays a literal graph on purpose, pinning that the fix lives in the reader rather than inside diff_graphs' filter.

I confirmed each rewritten test fails on its own regression direction by mutating read.rs locally: short-circuiting the sweep makes the orphan test report removed = ["is-number@7.0.0"], and dropping the referenced.contains(key) term from the retain predicate makes the shared-target test report added = ["string-width@4.2.3"]. Both run in automation — vendor/aube is excluded from nub's cargo workspace so ci.yml's cargo test never sees them, but aube-parity.yml runs cargo test --workspace with working-directory: vendor/aube behind a vendor/aube/** path filter this PR matches. Green locally on bd04143: 3 dedupe tests, 150 pnpm:: tests, and cargo clippy -p aube --all-targets with no warnings.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks
colinhacks merged commit 152a0c0 into main Jul 29, 2026
53 checks passed
colinhacks added a commit that referenced this pull request Jul 29, 2026
Picks up #612, #609 and #613, which landed while this bump was in flight and
which the PR body flagged as overlapping.

One conflict, in commands/outdated.rs: #609 added a HashSet import and still
called max_satisfying_version, which aube v1.35.0 renames to wanted_version.
Unions the import and keeps the new name. The argument stays unreferenced —
packument binds to &Packument out of the Some(Ok(p)) arm.
@colinhacks

Copy link
Copy Markdown
Contributor Author

Shipped in v0.7.0: https://github.com/nubjs/nub/releases/tag/v0.7.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dedupe repeatedly reports npm alias targets removed without changing pnpm-lock.yaml

2 participants