Skip to content

JS: native package-lock.json regeneration without executing npm - #8344

Closed
timtebeek wants to merge 4 commits into
mainfrom
tim/node-lockfile-reimplementation
Closed

JS: native package-lock.json regeneration without executing npm#8344
timtebeek wants to merge 4 commits into
mainfrom
tim/node-lockfile-reimplementation

Conversation

@timtebeek

@timtebeek timtebeek commented Jul 29, 2026

Copy link
Copy Markdown
Member

Motivation

Why npm is tractable (and where it is not)

  • One registry request per moving package. The full packument carries each version's dependencies, engines, license, funding, bin and dist.integrity — npm itself resolves with fullMetadata: true (verified in arborist). No tarball is ever downloaded; Python's metadata ladder has no analogue here.
  • The lock records the requested state. The root "" entry mirrors the manifest's dependency blocks, so the edit set falls out of a diff with no external oracle — and diffing against the lock (not the pre-edit manifest) means an earlier recipe's edit in the same run is reconciled too.
  • Emission is deterministic. npm writes the lock via json-stringify-nice(data, swKeyOrder, indent) — a full-document re-serialization. The writer ports it exactly, including an Intl-collator-equivalent key comparator pinned by Node-generated vectors, so untouched entries are preserved byte-for-byte by construction.
  • The hard part is placement. package-lock.json records a physical node_modules tree; whether a package lands hoisted or nested is arborist's placeDep decision. Everything that would require placing or displacing packages fails loud.

Accuracy contract

Ported from ADR 0009 and held by construction:

  1. Byte-identical to npm for every edit the engine performs — asserted against goldens recorded from real npm 11.17.0.
  2. Minimal change — only the edited package and what its requirements force actually move.
  3. Fail loud, never guess — anything unprovable emits no lock and a structured failure; the manifest edit still applies, and CI's npm ci catches the stale pin (the defer-to-CI composition from ADR 0010).

Handled natively: version moves proven in both directions (the new version's requirements resolve satisfied from its location with nested copies shadowing and overrides substituted, and every dependent that resolved the old copy still accepts the new version); removals with an orphan sweep and dep-flag recoloring via arborist's own calc-dep-flags algorithm (applied only where the edit changed an entry's color — pre-existing drift is never rewritten as a side effect); top-level additions that fit without displacement; range edits the current pin already satisfies (zero network); version selection matching npm-pick-manifest's latest-dist-tag preference.

Fail-loud: cascading placement, peer conflicts, workspace and link: locks, git/file/alias/tarball specs, bundled dependencies, lockfileVersion < 3, override edits that move a pin (the recorded override fixture shows npm re-places overridden copies — nesting them under dependents — rather than substituting in place), and any lock failing the parse→emit round-trip check.

Summary

  • internal/semver — node-semver versions/ranges ported from npm/node-semver's desugaring pipeline, driven by its own fixture corpus (200 transposed rows) plus the prerelease-exclusion rule.
  • internal/registry — packument client on the run's HttpSender; .npmrc resolution mirroring npm (scoped registries, the npm-registry-fetch auth-key walk, ${VAR} expansion failing only when used); host overrides via JavaScriptExecutionContextView + NpmRegistryCredentials (the MavenExecutionContextView pattern).
  • internal/npmlockNpmLockWriter (byte-exact emission), NpmLockTree (visibility-walk edge resolution, calc-dep-flags port doubling as the reachability sweep), NpmLockEngine (orchestration), all behind the existing LockFileRegeneration seam.
  • Failure surfaceResult gains a structured Failure{reason, package, registry, detail}; failures warn both the manifest and the unchanged lock file and land in a new NodeLockRegenerationFailures data table, so fleet-scale runs can aggregate why locks did not regenerate — and size the next phase (cascade/placement) on data, per ADR 0010's sizing-before-building sequence.

Two deliberate, environment-decoupling divergences from a local npm run (documented in the ADR): engines-based version deprioritization is ignored (npm's depends on the locally running node version), and v1/v2 locks fail loud instead of being wholesale-rewritten to v3 as modern npm would.

Tests

All offline, pure JVM — no node, npm, or network at test time:

  • Scenario goldens recorded from real npm 11.17.0 (src/test/resources/npmlock/, regenerable via record.sh): before/after manifests, before/after locks, and full packuments captured together. The engine's output is asserted byte-identical to npm's for upgrade, range-edit, removal-with-orphans, upgrade-with-orphans, leaf-add, dev-recoloring, and scoped-package scenarios; a routed HttpSender asserts exactly the expected requests and nothing more.
  • node-semver conformance via the upstream fixture corpus; collation pinned by Node-Intl-sorted vectors; round-trip byte identity over every recorded lock.
  • Recipe-level: full scan → edit → regenerate → marker-overlay flow, including the failure path asserting warnings on both files plus the data-table row.
  • Registry config: scoped registries, auth-key walk, base64 _password, env expansion, view overrides.

Known follow-ups

  • Cascading upgrades and additions requiring placement (the arborist placeDep/canPlace semantics) — the data table will size demand first.
  • pnpm / Yarn Classic native engines on the same layers; Yarn Berry is constrained by its zip-repack checksum (see ADR 0011).
  • UpgradeTransitiveDependencyVersion (overrides) currently always defers to CI for npm, since override moves are placement.

…e corpus

Strict-mode NpmVersion/NpmRange/NpmComparator ported from npm/node-semver's
desugaring pipeline (hyphen, caret, tilde, x-range, star, GTE0) including the
prerelease-exclusion rule and npm-pick-manifest's latest-dist-tag preference.
Driven by node-semver's own test fixtures transposed to TSV (178 satisfies +
19 comparison + 3 equality strict rows).
…ntextView

Full packuments over the run's HttpSender (npm itself resolves with
fullMetadata: true — lock entries need license/funding/bin, which the
abbreviated document omits). Registry selection and credentials mirror npm:
@scope:registry then registry then npmjs default, with the
npm-registry-fetch auth-key walk over //host/path:_authToken-style keys and
${VAR} expansion that fails only when the value is actually used. Hosts
override registries and credentials via JavaScriptExecutionContextView, the
MavenExecutionContextView pattern.
A dependency edit to package.json now updates the sibling package-lock.json
in pure Java, consulting the registry only for the packages the edit moves —
no npm execution anywhere in the path. The other package managers keep the
shell-out until they go native; the npm shell-out is deleted, not kept as a
fallback.

The engine handles the provable subset and fails loud with a structured
Failure on everything else, per ADR 0011:
- version moves proven in both directions (the new version's requirements
  resolve satisfied from its location, and every dependent still accepts it)
- removals with an orphan sweep and arborist's calc-dep-flags recoloring,
  applied only where the edit changed an entry's color
- top-level additions that fit without displacing anything
- range edits the current pin already satisfies (zero network)
- fail loud: cascades, peer conflicts, workspaces, link:/git/file/alias
  specs, bundled deps, lockfileVersion < 3, override edits that move a pin
  (the recorded fixture shows npm re-places overridden copies), and any lock
  that fails the parse -> emit round-trip

The writer reproduces npm's emission exactly: json-stringify-nice semantics
(scalars before objects, swKeyOrder priority, localeCompare('en') pinned by
Node-generated collation vectors), detected indent/EOL, trailing newline.
Untouched entries are preserved byte-for-byte by construction.

Failures surface as Markup.warn on both the manifest and the unchanged lock
file, plus a NodeLockRegenerationFailures data table for fleet aggregation.
On failure the manifest edit still applies and CI's npm ci catches the stale
lock — the defer-to-CI composition.

Offline tests replay scenarios recorded from real npm 11.17.0 (before/after
locks + packuments captured together, regenerable via record.sh) and assert
the engine's output byte-identical to npm's for the same edit, with a routed
HttpSender proving nothing escaped to the network.
Correctness, each verified against npm's own sources or a fresh golden:
- Edge precedence matched to arborist (peer loaded first, dev last, later
  scopes replace earlier): a root declaring a package in both devDependencies
  and peerDependencies gets a dev edge, so npm records "dev": true. New
  dev-peer-overlap fixture recorded from real npm proves the flag survives an
  upgrade. declaredRange uses the same priority.
- bin normalization ported from npm-normalize-package-bin: string bins key on
  the package name, keys collapse to their basename (dropping scopes), targets
  resolve as rooted paths.
- Version components above Number.MAX_SAFE_INTEGER are invalid (null), as in
  node-semver, instead of throwing NumberFormatException out of the engine;
  malformed registry shasums fail loud with INTEGRITY_UNAVAILABLE instead of
  escaping as runtime exceptions.
- Registry configuration failures (unset ${VAR}, invalid registry URL) get
  their own REGISTRY_CONFIG reason instead of masquerading as
  MALFORMED_MANIFEST in the failures table.
- Failure rows fall back to the recipe's target package name, so shell-out and
  unreachable-registry failures stay attributable; Result.errorMessage is now
  derived from the structured Failure rather than stored alongside it.
- Scanners capture the lock first-wins, so a cycle-1 failure warning can no
  longer leak markup into a later cycle's captured lock content.
- Fixed AddDependency's garbled description and regenerated recipes.csv.

Also: NpmRange parse memoization and a rationale note versus
org.openrewrite.semver, moveEntry/addEntry collapsed onto one placement
pipeline, dead accessors and banner comments removed, ADR addendum recording
the --legacy-peer-deps and npm_config_* env divergences.
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Jul 30, 2026
@timtebeek
timtebeek deleted the tim/node-lockfile-reimplementation branch July 30, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants