JS: native package-lock.json regeneration without executing npm - #8344
Closed
timtebeek wants to merge 4 commits into
Closed
JS: native package-lock.json regeneration without executing npm#8344timtebeek wants to merge 4 commits into
timtebeek wants to merge 4 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
When a dependency recipe edits a
package.json, the sibling lock file must be regenerated to match. That previously rannpm install --package-lock-only --ignore-scripts --legacy-peer-depsin a temp directory — which fails at fleet scale for environmental reasons unrelated to the edit (npm missing from the worker, registry credentials not reaching the sandbox), leaving the manifest changed and the lock stale with only truncated subprocess stderr to act on (moderneinc/customer-requests#2893).This PR replaces the npm shell-out with a native Java engine — no node or npm execution anywhere in the regeneration path — applying the approach shipped for Python in Python: native Pipfile.lock and uv.lock regeneration without executing package managers #8283/Python: native poetry.lock and pdm.lock regeneration without executing package managers #8285/Python: native uv.lock delta resolution (greedy-forward resolver) #8292. Design and constraints are in ADR 0011 (submitted as
Proposed; acceptance is part of this review). Yarn Classic/Berry, pnpm and Bun keep the shell-out until they go native; the npm shell-out is deleted, not kept as a fallback.Why npm is tractable (and where it is not)
engines,license,funding,binanddist.integrity— npm itself resolves withfullMetadata: true(verified in arborist). No tarball is ever downloaded; Python's metadata ladder has no analogue here.""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.json-stringify-nice(data, swKeyOrder, indent)— a full-document re-serialization. The writer ports it exactly, including anIntl-collator-equivalent key comparator pinned by Node-generated vectors, so untouched entries are preserved byte-for-byte by construction.package-lock.jsonrecords a physicalnode_modulestree; whether a package lands hoisted or nested is arborist'splaceDepdecision. Everything that would require placing or displacing packages fails loud.Accuracy contract
Ported from ADR 0009 and held by construction:
npm cicatches 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
overridessubstituted, 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 owncalc-dep-flagsalgorithm (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 recordedoverridefixture 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'sHttpSender;.npmrcresolution mirroring npm (scoped registries, the npm-registry-fetch auth-key walk,${VAR}expansion failing only when used); host overrides viaJavaScriptExecutionContextView+NpmRegistryCredentials(theMavenExecutionContextViewpattern).internal/npmlock—NpmLockWriter(byte-exact emission),NpmLockTree(visibility-walk edge resolution,calc-dep-flagsport doubling as the reachability sweep),NpmLockEngine(orchestration), all behind the existingLockFileRegenerationseam.Resultgains a structuredFailure{reason, package, registry, detail}; failures warn both the manifest and the unchanged lock file and land in a newNodeLockRegenerationFailuresdata 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:
src/test/resources/npmlock/, regenerable viarecord.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 routedHttpSenderasserts exactly the expected requests and nothing more.Intl-sorted vectors; round-trip byte identity over every recorded lock._password, env expansion, view overrides.Known follow-ups
placeDep/canPlacesemantics) — the data table will size demand first.UpgradeTransitiveDependencyVersion(overrides) currently always defers to CI for npm, since override moves are placement.