@gjsify/cli 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/cli.gjs.mjs +169 -169
  2. package/lib/actions/build.d.ts +7 -0
  3. package/lib/actions/build.js +18 -3
  4. package/lib/bundler-pick.js +61 -87
  5. package/lib/cli-fail.d.ts +4 -7
  6. package/lib/cli-fail.js +29 -55
  7. package/lib/commands/affected.js +161 -286
  8. package/lib/commands/install.d.ts +63 -66
  9. package/lib/commands/install.js +372 -536
  10. package/lib/config.d.ts +14 -0
  11. package/lib/config.js +18 -1
  12. package/lib/templates/install.mjs.tmpl +164 -75
  13. package/lib/types/cli-build-options.d.ts +33 -67
  14. package/lib/types/command.d.ts +9 -12
  15. package/lib/types/config-data.d.ts +137 -221
  16. package/lib/utils/auth-npmrc.d.ts +5 -10
  17. package/lib/utils/auth-npmrc.js +11 -21
  18. package/lib/utils/bin-shim.d.ts +40 -25
  19. package/lib/utils/bin-shim.js +51 -26
  20. package/lib/utils/clear-targets.d.ts +8 -11
  21. package/lib/utils/clear-targets.js +22 -29
  22. package/lib/utils/copy-targets.d.ts +13 -19
  23. package/lib/utils/copy-targets.js +31 -44
  24. package/lib/utils/dep-aggregation.d.ts +7 -15
  25. package/lib/utils/dep-aggregation.js +9 -22
  26. package/lib/utils/detect-native-packages.d.ts +89 -122
  27. package/lib/utils/detect-native-packages.js +119 -169
  28. package/lib/utils/dir-link.d.ts +29 -48
  29. package/lib/utils/dir-link.js +33 -56
  30. package/lib/utils/discover-showcases.d.ts +6 -7
  31. package/lib/utils/discover-showcases.js +11 -15
  32. package/lib/utils/dlx-cache.d.ts +29 -38
  33. package/lib/utils/dlx-cache.js +53 -63
  34. package/lib/utils/force-exit.d.ts +0 -3
  35. package/lib/utils/force-exit.js +13 -24
  36. package/lib/utils/gi-search-path.d.ts +16 -24
  37. package/lib/utils/gi-search-path.js +54 -82
  38. package/lib/utils/gjs-bundle-guard.d.ts +5 -13
  39. package/lib/utils/gjs-bundle-guard.js +35 -57
  40. package/lib/utils/gjs-source-escape.d.ts +2 -2
  41. package/lib/utils/gjs-source-escape.js +21 -39
  42. package/lib/utils/gjsify-shim.d.ts +17 -20
  43. package/lib/utils/gjsify-shim.js +80 -120
  44. package/lib/utils/html-entry.d.ts +8 -12
  45. package/lib/utils/html-entry.js +19 -28
  46. package/lib/utils/install-backend-native.d.ts +123 -161
  47. package/lib/utils/install-backend-native.js +536 -737
  48. package/lib/utils/install-backend.d.ts +42 -57
  49. package/lib/utils/install-backend.js +16 -21
  50. package/lib/utils/install-cache-fs.d.ts +10 -15
  51. package/lib/utils/install-cache-fs.js +18 -26
  52. package/lib/utils/install-lock.d.ts +5 -6
  53. package/lib/utils/install-lock.js +46 -65
  54. package/lib/utils/install-packument-cache.d.ts +27 -33
  55. package/lib/utils/install-packument-cache.js +44 -57
  56. package/lib/utils/install-progress.d.ts +2 -9
  57. package/lib/utils/install-progress.js +14 -39
  58. package/lib/utils/install-tarball-cache.d.ts +14 -20
  59. package/lib/utils/install-tarball-cache.js +41 -59
  60. package/lib/utils/library-output.d.ts +9 -22
  61. package/lib/utils/library-output.js +19 -37
  62. package/lib/utils/load-npmrc.d.ts +5 -6
  63. package/lib/utils/load-npmrc.js +18 -27
  64. package/lib/utils/node-bundle-guard.d.ts +4 -8
  65. package/lib/utils/node-bundle-guard.js +21 -35
  66. package/lib/utils/node-script.js +40 -53
  67. package/lib/utils/node-version.d.ts +5 -14
  68. package/lib/utils/node-version.js +15 -24
  69. package/lib/utils/normalize-bundler-options.d.ts +7 -11
  70. package/lib/utils/normalize-bundler-options.js +24 -42
  71. package/lib/utils/npm-oidc.d.ts +23 -37
  72. package/lib/utils/npm-oidc.js +28 -53
  73. package/lib/utils/npm-otp-cache.d.ts +4 -4
  74. package/lib/utils/npm-otp-cache.js +20 -25
  75. package/lib/utils/npm-otp.d.ts +20 -30
  76. package/lib/utils/npm-otp.js +32 -51
  77. package/lib/utils/onboard-probe.d.ts +7 -13
  78. package/lib/utils/onboard-probe.js +16 -27
  79. package/lib/utils/parse-spec.d.ts +3 -3
  80. package/lib/utils/parse-spec.js +4 -5
  81. package/lib/utils/pkg-json-edit.d.ts +7 -12
  82. package/lib/utils/pkg-json-edit.js +13 -26
  83. package/lib/utils/pkg-json.d.ts +11 -18
  84. package/lib/utils/pkg-json.js +19 -28
  85. package/lib/utils/prefixed-output.d.ts +3 -4
  86. package/lib/utils/prefixed-output.js +13 -19
  87. package/lib/utils/prompt.d.ts +3 -4
  88. package/lib/utils/prompt.js +14 -18
  89. package/lib/utils/publish-diagnose.d.ts +11 -16
  90. package/lib/utils/publish-diagnose.js +15 -28
  91. package/lib/utils/publish-headers.d.ts +7 -11
  92. package/lib/utils/publish-headers.js +36 -65
  93. package/lib/utils/publishable-packages.d.ts +4 -6
  94. package/lib/utils/publishable-packages.js +14 -18
  95. package/lib/utils/resolve-gjs-entry.d.ts +5 -8
  96. package/lib/utils/resolve-gjs-entry.js +10 -27
  97. package/lib/utils/resolve-npm-package.d.ts +6 -11
  98. package/lib/utils/resolve-npm-package.js +29 -63
  99. package/lib/utils/resolve-plugin-by-name.d.ts +11 -19
  100. package/lib/utils/resolve-plugin-by-name.js +9 -14
  101. package/lib/utils/run-gjs.d.ts +16 -21
  102. package/lib/utils/run-gjs.js +31 -56
  103. package/lib/utils/run-lifecycle-script.d.ts +5 -9
  104. package/lib/utils/run-lifecycle-script.js +23 -42
  105. package/lib/utils/run-node.d.ts +21 -33
  106. package/lib/utils/run-node.js +43 -64
  107. package/lib/utils/runtimes.d.ts +25 -35
  108. package/lib/utils/runtimes.js +34 -46
  109. package/lib/utils/spawn.d.ts +32 -48
  110. package/lib/utils/spawn.js +51 -84
  111. package/lib/utils/stdin.d.ts +2 -2
  112. package/lib/utils/stdin.js +10 -25
  113. package/lib/utils/suggest.d.ts +6 -7
  114. package/lib/utils/suggest.js +8 -10
  115. package/lib/utils/system-gi.d.ts +36 -27
  116. package/lib/utils/system-gi.js +85 -114
  117. package/lib/utils/trust-registry.d.ts +4 -8
  118. package/lib/utils/trust-registry.js +7 -15
  119. package/lib/utils/win32-command.d.ts +8 -14
  120. package/lib/utils/win32-command.js +29 -43
  121. package/lib/utils/workspace-root.js +7 -12
  122. package/package.json +18 -18
@@ -1,80 +1,52 @@
1
1
  // Native install backend — GJS-runnable replacement for `npm install`.
2
2
  //
3
3
  // Pipeline: parse specs → resolve deps via @gjsify/npm-registry packuments and
4
- // @gjsify/semver → download tarballs in parallel → extract into a flat
5
- // node_modules/ via @gjsify/tar. Output layout matches `npm install` so the
6
- // existing `runGjsBundle()` prebuild detection works without branching.
4
+ // @gjsify/semver → download tarballs in parallel → extract into node_modules/
5
+ // via @gjsify/tar. Output layout matches `npm install` so `runGjsBundle()`'s
6
+ // prebuild detection needs no branching. Hoisting is npm v3+: root placement
7
+ // unless the root holds an incompatible version, then nested under the requester.
7
8
  //
8
- // Phase D.7b — version-conflict resolution via nested `node_modules`.
9
- // The resolver tracks per-package placement: a dep is hoisted to the
10
- // root when no conflict exists, nested under the requesting package
11
- // when its required version is incompatible with what's already at
12
- // the root. Mirrors npm v3+ behavior.
9
+ // PLATFORM FILTERING (os/cpu/libc) — the tree is RESOLVED for every platform and
10
+ // INSTALLED for one. A node the host cannot run is marked `inert` (npm's word):
11
+ // not downloaded, not extracted, still in `gjsify-lock.json` with its
12
+ // `os`/`cpu`/`libc`. A lockfile recording only what ONE host can install is a
13
+ // machine snapshot — commit it and a colleague on another OS gets a tree missing
14
+ // its binaries with no drift reported. So DECLARATIONS are locked and the VERDICT
15
+ // is recomputed per host (`applyPlatformFilter`).
13
16
  //
14
- // PLATFORM FILTERING (os/cpu/libc) — the tree is RESOLVED for every platform
15
- // and INSTALLED for one. A node whose declaration excludes the host is marked
16
- // `inert` (npm's word — arborist build-ideal-tree.js's `node.inert = true`)
17
- // which skips its download + extract, but it stays in `gjsify-lock.json` with
18
- // its `os`/`cpu`/`libc` recorded. That split is deliberate and is the whole
19
- // reason lockfiles are shareable: a lockfile containing only what ONE host can
20
- // install is not a lockfile, it is a machine snapshot — commit it and every
21
- // colleague on another OS gets a tree missing its binaries with no drift
22
- // reported. So the DECLARATIONS are locked and the VERDICT is recomputed per
23
- // host at install time (see applyPlatformFilter).
17
+ // Three invariants hold that up. Each is structural rather than documented,
18
+ // because the first draft broke the first two while reading as if it did not.
19
+ // The measured record is packages/infra/cli/AGENTS.md § `gjsify install`.
24
20
  //
25
- // THREE INVARIANTS MAKE THAT CLAIM TRUE, and each is structural rather than
26
- // documented, because the first draft of this feature broke the first two while
27
- // reading as if it did not — and shipped without the third:
28
- //
29
- // 1. THE RESOLVE IS TARGET-BLIND. `resolveDeps` is not given the platform
30
- // target at all — it cannot be, so it cannot record a target-dependent
31
- // declaration. The draft passed the target in to decide when to escalate to
32
- // a full packument for `libc`, gated on `target.os === 'linux'`; a lockfile
33
- // authored on macOS therefore carried NO `libc` for anything, and a Linux
34
- // colleague running `--immutable` off that file got no libc filtering at
35
- // all — precisely the direction this header promises is portable. The
36
- // resolve now reads the FULL document for every package (see resolveDeps),
37
- // which is also what arborist does for every dependency it resolves
38
- // (`#fetchManifest` sets `fullMetadata: true`).
39
- // 2. `optional` IS DERIVED, NEVER TRUSTED. It is recomputed as a FIXPOINT over
40
- // the placed graph on BOTH paths (computeOptionalFlags), before it is
41
- // persisted and before the platform verdict reads it. The draft promoted a
42
- // node out of the optional set at the reuse site, one shot, which made the
43
- // answer depend on BFS edge order: a genuinely required transitive dep
44
- // reached first through an optional edge stayed flagged optional, was
45
- // persisted that way, and was then silently skipped on every machine
46
- // instead of raising EBADPLATFORM. npm computes the same flags as a
21
+ // 1. THE RESOLVE IS TARGET-BLIND — `resolveDeps` takes no target, so it cannot
22
+ // record a target-dependent declaration. The draft gated `libc` escalation
23
+ // on `target.os === 'linux'`, so a macOS-authored lockfile carried no `libc`
24
+ // for anything and a Linux colleague's `--immutable` got no libc filtering
25
+ // at all. The resolve now reads the FULL document for every package, as
26
+ // arborist does (`#fetchManifest` sets `fullMetadata: true`).
27
+ // 2. `optional` IS DERIVED, NEVER TRUSTED — a fixpoint over the placed graph on
28
+ // BOTH paths (`computeOptionalFlags`), before it is persisted and before the
29
+ // platform verdict reads it. The draft promoted at the reuse site, one shot,
30
+ // making the answer depend on BFS edge order: a genuinely required
31
+ // transitive dep reached first through an optional edge stayed flagged
32
+ // optional, was persisted that way, and was then silently skipped on every
33
+ // machine instead of raising EBADPLATFORM. npm computes the same flags as a
47
34
  // fixpoint for the same reason (refs/npm-cli/workspaces/arborist/lib/
48
- // calc-dep-flags.js: "If a node is changed, we add to the queue and
49
- // continue until no more changes").
50
- // 3. OPTIONALITY BELONGS TO THE EDGE, NOT TO THE BLOCK THE NAME WAS READ FROM.
51
- // A name listed in BOTH `dependencies` and `optionalDependencies` is
52
- // OPTIONAL: "entries in optionalDependencies will override entries of the
53
- // same name in dependencies" (npm's package.json docs), which is why every
54
- // walk over a node's dep edges here goes through {@link requiredDepEntries}
55
- // instead of `Object.keys(node.dependencies)`. `@parcel/rust@2.16.4` is why
56
- // it is written down: it declares all eight of its per-platform napi
57
- // packages in both blocks, so a `dependencies`-only walk made
58
- // `@parcel/rust-darwin-x64` REQUIRED and every Linux install of any tree
59
- // containing parcel died with EBADPLATFORM — while `npm install` on the same
60
- // manifest exits 0, installs
61
- // `@parcel/rust-linux-x64-gnu`, and records the other seven as
62
- // `"optional": true` in its own lockfile. The rule was already applied to
63
- // swallowed resolve failures (`assertRequiredEdgesResolved`: "optionality
64
- // lives on the edge, not on its endpoints") and missing from the two walks
65
- // that decide fatal-vs-inert, which is the worst place to have half of it.
66
- // It costs a lockfile field (`optionalDependencies` per entry, v4): without
67
- // it the fresh-resolve path and the lockfile path would disagree about the
68
- // same tree, which is the split invariant 2 exists to close.
69
- // WHY THE DEFECT LOOKED LIKE IT DID NOT EXIST: the same napi shape is
70
- // overwhelmingly published in ONE block — measured on the latest of `oxlint`,
71
- // `esbuild` and `rollup`, all three list their per-platform bindings in
72
- // `optionalDependencies` only (zero overlap), so they installed correctly
73
- // throughout and every mock corpus reaches for that shape too. The block a
74
- // publisher chooses is not a property this installer may assume.
35
+ // calc-dep-flags.js).
36
+ // 3. OPTIONALITY BELONGS TO THE EDGE, not to the block the name came from. A
37
+ // name in BOTH `dependencies` and `optionalDependencies` is OPTIONAL (npm's
38
+ // documented override) — hence {@link requiredDepEntries} in every dep walk.
39
+ // `@parcel/rust@2.16.4` lists all eight of its napi platform packages in
40
+ // both blocks, so a `dependencies`-only walk made `@parcel/rust-darwin-x64`
41
+ // REQUIRED and every Linux install of a tree containing parcel died with
42
+ // EBADPLATFORM where `npm install` exits 0. It hid because that napi shape
43
+ // is overwhelmingly published in ONE block (`oxlint`, `esbuild`, `rollup`),
44
+ // which is what every mock corpus reaches for too. Costs a lockfile field
45
+ // (v4); without it the fresh-resolve and lockfile paths disagree about one
46
+ // tree.
75
47
  //
76
- // Out of scope (still deferred): peerDependencies validation,
77
- // lifecycle scripts, git/file specs.
48
+ // Out of scope (still deferred): peerDependencies validation, lifecycle scripts,
49
+ // git/file specs.
78
50
  import * as fs from 'node:fs';
79
51
  import * as path from 'node:path';
80
52
  import * as os from 'node:os';
@@ -89,40 +61,38 @@ import { assertNativeBackendNodeVersion } from './node-version.js';
89
61
  import { buildCmdShim, buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, isGjsBundlePath, parseShebang, pickBinMap, } from './bin-shim.js';
90
62
  import { detectNativePackages } from './detect-native-packages.js';
91
63
  import { badPlatformError, checkPlatform, declaresPlatform, describePlatformTarget, readPlatformForce, resolveHostPlatform, } from './platform-check.js';
92
- // 16-wide download pool. Matched to the shared Soup.Session's lifted
93
- // `max-conns-per-host` (see @gjsify/fetch `getSharedSession`) — a higher pool
94
- // here only translates into real concurrency because that cap was raised from
95
- // libsoup's default of 2. npm (`maxsockets` 15) and pnpm (`network-concurrency`
96
- // 16) use the same order of magnitude. Override with GJSIFY_INSTALL_CONCURRENCY.
64
+ // 16-wide download pool, matched to the shared Soup.Session's lifted
65
+ // `max-conns-per-host` (@gjsify/fetch `getSharedSession`) — without that lift
66
+ // libsoup's default of 2 caps real concurrency however wide the pool is. Same
67
+ // order of magnitude as npm (`maxsockets` 15) and pnpm (`network-concurrency` 16).
97
68
  const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '16') || 16;
98
69
  const LOCKFILE_NAME = 'gjsify-lock.json';
99
70
  /**
100
- * v3 adds the per-entry platform declaration (`os`/`cpu`/`libc`) plus
101
- * `optional`, which together let a foreign-platform package be recorded without
102
- * being installed. v4 adds the per-entry `optionalDependencies` map, without
103
- * which the optionality fixpoint cannot see WHICH of an entry's edges are
104
- * optional and a name declared in both blocks comes out required (header note,
105
- * invariant 3). Older lockfiles are still READ (see {@link readLockfile}).
71
+ * v3 adds the per-entry platform declaration (`os`/`cpu`/`libc`) plus `optional`,
72
+ * which together let a foreign-platform package be recorded without being
73
+ * installed. v4 adds the per-entry `optionalDependencies` map, without which the
74
+ * optionality fixpoint cannot see WHICH of an entry's edges are optional and a
75
+ * name declared in both blocks comes out required (header note, invariant 3).
76
+ * Older lockfiles are still READ — see {@link readLockfile}.
106
77
  */
107
78
  const LOCKFILE_VERSION = 4;
108
79
  /**
109
- * Lockfile versions this CLI can read. A v2 lockfile is a valid pin set that
110
- * simply predates the platform fields; rejecting it (the old
111
- * `!== LOCKFILE_VERSION → null` behaviour) would discard the whole file and let
112
- * the following fresh resolve bump every `^`-range to the newest in-range
113
- * version — the exact silent churn lockfile preservation exists to prevent, on
114
- * every user's first install after upgrading the CLI.
80
+ * Lockfile versions this CLI can read. A v2 file is a valid pin set that merely
81
+ * predates the platform fields; rejecting it (the old `!== LOCKFILE_VERSION →
82
+ * null` behaviour) discards the whole file and lets the following fresh resolve
83
+ * bump every `^`-range to the newest in-range version — the silent churn lockfile
84
+ * preservation exists to prevent, on every user's first install after a CLI
85
+ * upgrade.
115
86
  *
116
- * READING an older file is not the same as TRUSTING it for everything: only the
117
- * current version short-circuits the resolve (see the branch below), so a v2/v3
118
- * file seeds version preservation and is then rewritten. `--immutable` is the one
119
- * path that must consume what it was handed verbatim, so there — and only there —
120
- * a pre-v4 file keeps its pre-v4 edge fidelity: a both-blocks optional dep is
121
- * judged required, exactly as before this fix. That is a strictly unchanged
122
- * outcome rather than a new failure, and one plain `gjsify install` upgrades the
123
- * file. Failing `--immutable` on an old-but-readable lockfile instead would break
124
- * every CI whose committed file predates this CLI, including this repo's own
125
- * (still v2).
87
+ * READING an older file is not TRUSTING it for everything: only the current
88
+ * version short-circuits the resolve (see the branch below), so a v2/v3 file
89
+ * seeds version preservation and is then rewritten. `--immutable` must consume
90
+ * what it was handed verbatim, so there alone a pre-v4 file keeps its pre-v4 edge
91
+ * fidelity — a both-blocks optional dep is judged required, exactly as before
92
+ * this fix, which is an unchanged outcome rather than a new failure, and one
93
+ * plain `gjsify install` upgrades the file. Failing `--immutable` on an
94
+ * old-but-readable lockfile would instead break every CI whose committed file
95
+ * predates this CLI.
126
96
  */
127
97
  const READABLE_LOCKFILE_VERSIONS = new Set([2, 3, LOCKFILE_VERSION]);
128
98
  export async function installPackagesNative(opts) {
@@ -136,12 +106,12 @@ export async function installPackagesNative(opts) {
136
106
  const npmrc = await loadNpmrc(opts);
137
107
  const log = makeLogger(opts.verbose ?? false);
138
108
  // Serialize every mutation of THIS prefix across processes (ADR 0001):
139
- // concurrent installs into the same node_modules used to interleave
140
- // `rmSync` + extract on shared destination dirs and tear the lockfile.
141
- // Re-entrant within the process (workspace installs already hold the
142
- // root-prefix lock — this just bumps a refcount). Installs into other
143
- // prefixes proceed concurrently; the shared XDG caches stay lock-free
144
- // because their tmp+rename writes are atomic (install-cache-fs.ts).
109
+ // concurrent installs into the same node_modules used to interleave `rmSync` +
110
+ // extract on shared destination dirs and tear the lockfile. Re-entrant within
111
+ // the process (a workspace install already holds the root-prefix lock, so this
112
+ // only bumps a refcount). Other prefixes proceed concurrently; the shared XDG
113
+ // caches stay lock-free because their tmp+rename writes are atomic
114
+ // (install-cache-fs.ts).
145
115
  const lock = await acquireInstallLock(opts.prefix, { signal: opts.signal });
146
116
  try {
147
117
  return await installPackagesNativeLocked(opts, npmrc, log);
@@ -156,9 +126,9 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
156
126
  const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
157
127
  const existingLock = readLockfile(lockfilePath);
158
128
  // The triple this install materialises FOR. Normally the running host;
159
- // `--os/--cpu/--libc` (npm's config keys `os`/`cpu`/`libc`, read from the
160
- // environment by resolveHostPlatform) override it, which is what makes the
161
- // darwin/win32/musl selection testable — and reviewable — from one machine.
129
+ // `--os/--cpu/--libc` (npm's config keys, read from the environment by
130
+ // `resolveHostPlatform`) override it, which is what makes the darwin/win32/musl
131
+ // branches reviewable from one machine.
162
132
  const target = resolveHostPlatform();
163
133
  const force = readPlatformForce(process.env);
164
134
  log('install: platform target %s%s', describePlatformTarget(target), force ? ' (--force: checks bypassed)' : '');
@@ -166,18 +136,16 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
166
136
  /** Did a resolve actually run? Only then is there something new to persist. */
167
137
  let resolved = false;
168
138
  /**
169
- * Edges whose resolve FAILED and was tolerated because the edge looked
170
- * optional at the time it was visited. Checked against the final (fixpoint)
171
- * optionality below — see {@link assertRequiredEdgesResolved}. Empty on the
172
- * lockfile paths, which resolve nothing.
139
+ * Edges whose resolve FAILED and was tolerated because the edge looked optional
140
+ * when visited; re-judged against the fixpoint optionality below (see
141
+ * {@link assertRequiredEdgesResolved}). Empty on the lockfile paths, which
142
+ * resolve nothing.
173
143
  */
174
144
  let skippedEdges = new Set();
175
145
  if (opts.frozen) {
176
- // --immutable / --frozen: lockfile is the authoritative source.
177
- // Reject if the file is missing, version-mismatched, or its
178
- // `requested` set has drifted from the live request — silently
179
- // honoring a stale lockfile would mask real dep churn (the original
180
- // bug --immutable exists to catch).
146
+ // --immutable / --frozen: the lockfile is authoritative. Reject a missing,
147
+ // version-mismatched or drifted file — silently honouring a stale lockfile
148
+ // masks the real dep churn `--immutable` exists to catch.
181
149
  if (!existingLock) {
182
150
  throw new Error(`install: --immutable requires ${LOCKFILE_NAME} at ${opts.prefix} — none found. ` +
183
151
  `Run \`gjsify install\` (without --immutable) to generate one and commit it.`);
@@ -200,43 +168,39 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
200
168
  else {
201
169
  // A resolve has to run (new/changed/removed dep, or no lockfile yet).
202
170
  //
203
- // A pre-v4 lockfile lands here too, even when it matches the request,
204
- // because each bump added something the verdict is recomputed FROM and a
205
- // file that lacks it cannot be consumed verbatim: pre-v3 entries carry no
206
- // platform declaration (so the foreign-platform tree would keep installing
207
- // forever), pre-v4 entries carry no `optionalDependencies` map (so a name
208
- // declared in both blocks looks required and its incompatible siblings
209
- // fail the install — the `@parcel/rust` shape, header note invariant 3).
210
- // One resolve — version-preserving, per the seeding below, so nothing
211
- // bumps — upgrades the file. That matters beyond tidiness here: the
212
- // lockfile is written BEFORE the platform filter runs, so the failing
213
- // install left a v3 file behind that would otherwise reproduce its own
214
- // EBADPLATFORM on every later run without ever resolving again.
171
+ // A pre-v4 lockfile lands here too even when it matches the request: each
172
+ // bump added something the verdict is recomputed FROM, so a file lacking it
173
+ // cannot be consumed verbatim — pre-v3 entries carry no platform
174
+ // declaration (the foreign-platform tree would keep installing forever),
175
+ // pre-v4 entries no `optionalDependencies` map (the `@parcel/rust` shape
176
+ // fails the install; header note, invariant 3). One version-preserving
177
+ // resolve upgrades the file, and that matters beyond tidiness: the lockfile
178
+ // is written BEFORE the platform filter runs, so a failing install left a
179
+ // v3 file behind that would otherwise reproduce its own EBADPLATFORM
180
+ // forever without ever resolving again.
215
181
  //
216
- // Unless --refresh-lockfile was passed, seed it with the versions
217
- // already pinned in the existing lockfile so unchanged deps keep their
218
- // resolved version and only the genuinely new/changed deps move — the
219
- // npm/yarn/pnpm `install` default. Without this, every `^`-range would
220
- // re-resolve to the newest registry version, churning the whole tree
221
- // (and silently bumping transitive deps) on a one-package add.
182
+ // Unless --refresh-lockfile was passed, seed from the versions already
183
+ // pinned so unchanged deps keep them and only genuinely new/changed deps
184
+ // move — the npm/yarn/pnpm `install` default. Without it every `^`-range
185
+ // re-resolves to the newest registry version, churning the whole tree (and
186
+ // silently bumping transitives) on a one-package add.
222
187
  const preferred = !opts.refreshLockfile && existingLock ? buildPreferredVersions(existingLock) : undefined;
223
188
  log('install: resolving %d top-level spec(s) → %s%s', opts.specs.length, opts.prefix, preferred ? ` (preserving ${preferred.size} locked name(s))` : '');
224
- // NOTE the absent argument: `target` is deliberately NOT passed. The
225
- // resolve must produce the same tree and the same recorded declarations
226
- // on every machine (header note, invariant 1), and the cheapest way to
227
- // keep that true is to give it nothing target-shaped to read.
189
+ // `target` is deliberately NOT passed: the resolve must produce the same
190
+ // tree and the same recorded declarations on every machine (header note,
191
+ // invariant 1), and giving it nothing target-shaped to read is what keeps
192
+ // that true.
228
193
  const resolveResult = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress, preferred, opts.workspaceNames, opts.specOrigins, opts.optionalSpecs);
229
194
  nodes = resolveResult.nodes;
230
195
  skippedEdges = resolveResult.skippedEdges;
231
196
  resolved = true;
232
197
  }
233
- // `optional` is DERIVED here, on BOTH paths, for the same reason
234
- // `applyPlatformFilter` is: the answer must not depend on which path the tree
235
- // arrived by, and a flag read back from a file is an INPUT nothing checked.
236
- // Runs BEFORE writeLockfile (so the persisted flag is the final one) and
237
- // before the platform verdict (which reads it to decide fatal vs. inert), and
238
- // before the workspace filter below (which removes nodes and would truncate
239
- // the walk).
198
+ // `optional` is DERIVED here on BOTH paths for the same reason
199
+ // `applyPlatformFilter` is: the answer must not depend on how the tree arrived,
200
+ // and a flag read back from a file is an INPUT nothing checked. Order matters —
201
+ // before `writeLockfile` (so the persisted flag is final), before the platform
202
+ // verdict (which reads it for fatal vs. inert), and before the workspace filter
203
+ // below (which removes nodes and would truncate the walk).
240
204
  computeOptionalFlags(nodes, requiredTopLevelNames(opts.specs, opts.optionalSpecs), log);
241
205
  assertRequiredEdgesResolved(nodes, skippedEdges);
242
206
  if (resolved && opts.lockfile) {
@@ -245,15 +209,13 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
245
209
  writeLockfile(lockfilePath, opts.specs, nodes);
246
210
  log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
247
211
  }
248
- // A package whose name is one of the monorepo's own workspaces is provided
249
- // by a workspace symlink (wired by `workspaceInstall`), NOT by a registry
250
- // tarball — even when the lockfile or a transitive edge pins a same-named
251
- // published version. Drop those nodes from the fetch/extract set so
252
- // `extractOne` never `rm`s + overwrites the workspace source symlink (its
253
- // data-loss guard would otherwise abort the whole install). This keeps
212
+ // A package named after one of the monorepo's own workspaces is provided by a
213
+ // workspace symlink (wired by `workspaceInstall`), NOT by a registry tarball —
214
+ // even when the lockfile or a transitive edge pins a same-named published
215
+ // version. Dropping those nodes keeps `extractOne` from `rm`ing the workspace
216
+ // source symlink (its data-loss guard would abort the whole install), and keeps
254
217
  // `--immutable` robust against a committed lockfile that still carries such
255
- // registry entries (it is built from the lockfile verbatim and never runs
256
- // `resolveDeps`); a fresh resolve additionally skips them at the source.
218
+ // entries, since that path never runs `resolveDeps`.
257
219
  if (opts.workspaceNames && opts.workspaceNames.size > 0) {
258
220
  const before = nodes.length;
259
221
  nodes = nodes.filter((n) => !opts.workspaceNames.has(n.name));
@@ -271,9 +233,8 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
271
233
  await linkBins(installable, opts.prefix, log);
272
234
  warnMissingNativeBuilds(installable, opts.prefix, log);
273
235
  log('install: done');
274
- // Surface the top-level requested packages so callers can update
275
- // package.json with the resolved version (mirrors `npm install --save`
276
- // behavior). Sub-deps are not included.
236
+ // Top-level requested packages only, so callers can write the resolved version
237
+ // back into package.json (`npm install --save`).
277
238
  return topLevelResolutions(opts.specs, nodes);
278
239
  }
279
240
  function errMsg(err) {
@@ -282,29 +243,26 @@ function errMsg(err) {
282
243
  return String(err);
283
244
  }
284
245
  /**
285
- * Split a resolved tree into what THIS target can install, marking the rest
286
- * inert. Mirrors arborist's post-resolve platform pass
287
- * (`refs/npm-cli/workspaces/arborist/lib/arborist/build-ideal-tree.js`, the
288
- * `checkPlatform` loop around line 210) rather than filtering during the walk,
289
- * for two reasons: the verdict must be identical whether the tree came from a
290
- * fresh resolve or from `gjsify-lock.json`, and a node's optionality is only
291
- * final once every edge that could reach it has been visited.
246
+ * Split a resolved tree into what THIS target can install, marking the rest inert.
247
+ * A post-pass rather than a filter during the walk (as in arborist's
248
+ * `refs/npm-cli/workspaces/arborist/lib/arborist/build-ideal-tree.js`) for two
249
+ * reasons: the verdict must be identical whether the tree came from a fresh
250
+ * resolve or from `gjsify-lock.json`, and a node's optionality is only final once
251
+ * every edge that could reach it has been visited.
292
252
  *
293
253
  * The two outcomes are npm's, including the asymmetry around `force`:
294
- * - REQUIRED + incompatible → EBADPLATFORM. A required dependency the host
295
- * cannot run is a broken install, not a smaller one; failing loudly with
296
- * `pkgid`/`current`/`required` is the only honest answer. `--force`
297
- * suppresses it (the user is asserting something about their own machine).
298
- * - OPTIONAL + incompatible → inert: not downloaded, not extracted, still in
299
- * the lockfile. `--force` does NOT lift this — npm says so in as many words
300
- * ("We ignore the --force and --engine-strict flags") and it is right:
301
- * forcing an optional binary that cannot load buys a download and nothing
302
- * else.
254
+ * - REQUIRED + incompatible → EBADPLATFORM. A required dep the host cannot run
255
+ * is a broken install, not a smaller one, so it fails loudly with
256
+ * `pkgid`/`current`/`required`. `--force` suppresses it (the user is asserting
257
+ * something about their own machine).
258
+ * - OPTIONAL + incompatible → inert: not downloaded, not extracted, still in the
259
+ * lockfile. `--force` does NOT lift this — npm says so in as many words ("We
260
+ * ignore the --force and --engine-strict flags"), and forcing an optional
261
+ * binary that cannot load buys a download and nothing else.
303
262
  *
304
- * The skip line goes through the debug logger with npm's `current`/`required`
305
- * payload, so `--verbose` recovers exactly WHY a package is absent. Silence
306
- * here is what made the original defect invisible in the other direction: 3.67
307
- * GB arrived with no line saying it should not have.
263
+ * The skip line carries npm's `current`/`required` payload so `--verbose` recovers
264
+ * WHY a package is absent. Silence here is what made the original defect invisible
265
+ * in the other direction: 3.67 GB arrived with no line saying it should not have.
308
266
  */
309
267
  export function applyPlatformFilter(nodes, target, force, log) {
310
268
  const installable = [];
@@ -343,15 +301,12 @@ export function applyPlatformFilter(nodes, target, force, log) {
343
301
  /**
344
302
  * Warn about a native package that installed WITHOUT a usable binary.
345
303
  *
346
- * gjsify install is node-free (it runs under GJS) and does NOT run a package's
347
- * `install`/`postinstall` lifecycle script — running e.g. `node-gyp rebuild` would
348
- * need Node + a C++ toolchain, breaking that property (see the header's "out of
349
- * scope"). Such a package works only if it SHIPS a prebuild for the platform (the
350
- * sanctioned path — `prebuilds/<platform>-<arch>/…`) or is later built by hand. When
351
- * neither is present the package silently loads no binary (the `@gjsify/node-gi
352
- * 0.21.0` case: installed, but `node_gi.node` absent → the consumer hit a runtime
353
- * failure with no hint). Surface it at install time with an actionable line. Pure
354
- * detection — no script is run, so node-free-ness is preserved.
304
+ * This install is node-free and does NOT run `install`/`postinstall` scripts —
305
+ * `node-gyp rebuild` would need Node plus a C++ toolchain. Such a package works
306
+ * only if it SHIPS a prebuild (`prebuilds/<platform>-<arch>/…`) or is built by
307
+ * hand; with neither it silently loads no binary, as `@gjsify/node-gi 0.21.0` did
308
+ * (installed, `node_gi.node` absent, consumer hit a runtime failure with no hint).
309
+ * Pure detection — no script is run, so node-free-ness is preserved.
355
310
  */
356
311
  export function warnMissingNativeBuilds(nodes, prefix, log) {
357
312
  const plat = process.platform;
@@ -404,8 +359,7 @@ export function warnMissingNativeBuilds(nodes, prefix, log) {
404
359
  }
405
360
  }
406
361
  function topLevelResolutions(specs, nodes) {
407
- // Top-level installs live at `node_modules/<name>` (no nesting). Build
408
- // a name → root-node lookup limited to the top-level set.
362
+ // Top-level installs live at `node_modules/<name>`, never nested.
409
363
  const byName = new Map();
410
364
  for (const n of nodes) {
411
365
  if (n.installPath === `node_modules/${n.name}`)
@@ -441,49 +395,39 @@ function parseSpecName(spec) {
441
395
  * the root has an incompatible version. Subsequent dependents of the
442
396
  * same conflicting version reuse the nested placement.
443
397
  *
444
- * The walk is BFS over (requester, depName, depRange) edges. Top-level
445
- * specs are seeded with a synthetic `null` requester so they hoist to
446
- * the root. Each placement returns a `ResolvedNode` whose `installPath`
447
- * captures where it lives in the tree.
398
+ * The walk is BFS over (requester, depName, depRange) edges. Top-level specs are
399
+ * seeded with a synthetic `null` requester so they hoist to the root.
448
400
  *
449
- * TAKES NO PLATFORM TARGET, and that is a load-bearing absence (header note,
450
- * invariant 1): the tree it produces and the `os`/`cpu`/`libc` it records must be
451
- * identical on every machine, so the platform verdict is a separate post-pass
452
- * (`applyPlatformFilter`) that this function cannot influence. An earlier draft
453
- * did take one — to decide when to escalate to a full packument for `libc`,
454
- * gated on `target.os === 'linux'` — and a macOS-authored lockfile ended up with
455
- * no `libc` recorded anywhere, i.e. unusable for filtering on the Linux hosts it
456
- * was committed for. Do not reintroduce the parameter: the resolve now reads the
457
- * full document unconditionally, which needs no target and no heuristic.
401
+ * TAKES NO PLATFORM TARGET, a load-bearing absence (header note, invariant 1): the
402
+ * tree and the `os`/`cpu`/`libc` it records must be identical on every machine, so the
403
+ * platform verdict is a separate post-pass this function cannot influence. Do not
404
+ * reintroduce the parameter — reading the full document unconditionally needs neither
405
+ * a target nor a heuristic.
458
406
  */
459
407
  async function resolveDeps(specs, npmrc, log, overrides, skipDeps, signal, progress,
460
408
  /**
461
- * Lockfile-preservation oracle: `name → versions already pinned in the
462
- * existing lockfile`. When an edge's range is satisfiable by a pinned
463
- * version, that version is reused instead of the newest registry match.
464
- * Undefined ⇒ a fresh resolve that always picks the newest in-range version
465
- * (first install, or an explicit `--refresh-lockfile`).
409
+ * Lockfile-preservation oracle: `name → versions already pinned`. A pinned
410
+ * version satisfying the edge's range is reused instead of the newest registry
411
+ * match. Undefined ⇒ always pick the newest in-range version (first install, or
412
+ * `--refresh-lockfile`).
466
413
  */
467
414
  preferredVersions,
468
415
  /**
469
- * Names of the monorepo's own workspace packages. An edge whose name is in
470
- * this set is satisfied by the workspace symlink, so it is skipped here —
471
- * the published version (and its subtree) never enters the resolved tree or
472
- * the lockfile.
416
+ * The monorepo's own workspace packages. Such an edge is satisfied by the
417
+ * workspace symlink, so it is skipped here — the published version and its
418
+ * subtree never enter the resolved tree or the lockfile.
473
419
  */
474
420
  workspaceNames,
475
421
  /**
476
- * Requester labels for top-level specs (`"<name>@<range>"` → workspace
477
- * names) — see `InstallOptions.specOrigins`. Used only to attribute the
478
- * version-conflict warning.
422
+ * Requester labels for top-level specs (`"<name>@<range>"` → workspace names),
423
+ * see `InstallOptions.specOrigins`. Only attributes the conflict warning.
479
424
  */
480
425
  specOrigins,
481
426
  /**
482
427
  * Names of top-level specs declared as `optionalDependencies` — see
483
- * {@link NativeInstallOptions.optionalSpecs}. Such an edge is seeded
484
- * `required: false`, so a resolve failure is skipped rather than fatal. It
485
- * also seeds the optional-flag walk, but the FINAL flag comes from
486
- * {@link computeOptionalFlags}, not from this walk.
428
+ * {@link NativeInstallOptions.optionalSpecs}. Seeds the edge `required: false`,
429
+ * so a resolve failure is skipped rather than fatal. The FINAL flag still comes
430
+ * from {@link computeOptionalFlags}, not from this walk.
487
431
  */
488
432
  optionalSpecs) {
489
433
  progress?.beginPhase('resolve', specs.length);
@@ -503,31 +447,26 @@ optionalSpecs) {
503
447
  const cached = packumentCache.get(name);
504
448
  if (cached)
505
449
  return cached;
506
- // FULL document (`accept: application/json`), not the abbreviated
507
- // "corgi" install document — the same choice arborist makes for every
508
- // dependency it resolves (`#fetchManifest` sets `fullMetadata: true`,
450
+ // FULL document (`accept: application/json`), not the abbreviated "corgi"
451
+ // install document — the same choice arborist makes for every dependency it
452
+ // resolves (`#fetchManifest` sets `fullMetadata: true`,
509
453
  // refs/npm-cli/workspaces/arborist/lib/arborist/build-ideal-tree.js).
510
454
  //
511
- // WHY, and why no cheaper trigger exists: the registry omits `libc` from
512
- // the abbreviated body (measured — `@rollup/rollup-linux-x64-musl` returns
455
+ // WHY, and why no cheaper trigger exists: the registry omits `libc` from the
456
+ // abbreviated body (measured — `@rollup/rollup-linux-x64-musl` returns
513
457
  // `{os,cpu}` under the corgi accept header and `{os,cpu,libc}` under
514
- // `application/json`), so the abbreviated body can never PROVE that a
515
- // version has no libc restriction. Every "escalate only where it can
516
- // matter" rule therefore has a hole, and the first draft's rule — escalate
517
- // when the corgi entry declares `os` or `cpu` — had exactly the hole that
518
- // matters here: the nine `@gjsify/*` bridges declare `libc: ["glibc"]`
519
- // with NO `os`/`cpu`, so on Alpine the installer would have handed a
520
- // glibc-only prebuild to a musl host and failed at `dlopen` instead of at
521
- // install time.
458
+ // `application/json`), so that body can never PROVE a version has no libc
459
+ // restriction, and every "escalate only where it can matter" rule has a
460
+ // hole. The draft's rule — escalate when the corgi entry declares `os` or
461
+ // `cpu` — had exactly the hole that matters: the nine `@gjsify/*` bridges
462
+ // declare `libc: ["glibc"]` with NO `os`/`cpu`, so on Alpine the installer
463
+ // would have handed a glibc-only prebuild to a musl host and failed at
464
+ // `dlopen` instead of at install time.
522
465
  //
523
- // Cost, measured over 15 representative deps of this workspace (transfer
524
- // bytes, largest packages dominating): the full document is 1.26× the
525
- // abbreviated one. Corgi-plus-escalate-everything would be 2.26× and
526
- // twice the requests; corgi-plus-escalate-the-declaring-~12% (the draft)
527
- // was ~1.15× and 1.12× the requests, i.e. this change costs ~10% more
528
- // metadata bytes and ~11% FEWER round-trips than the shape it replaces.
529
- // That is the whole trade: one authoritative document per package instead
530
- // of two documents and a heuristic that cannot be made sound.
466
+ // The trade is one authoritative document per package instead of two plus an
467
+ // unsound heuristic; measured over 15 representative deps of this workspace,
468
+ // the full document costs 1.26× the abbreviated one in transfer bytes and
469
+ // ~11% FEWER round-trips than corgi-plus-escalate.
531
470
  const fresh = fetchPackumentWithDiskCache(name, npmrc, log, signal, 'full');
532
471
  packumentCache.set(name, fresh);
533
472
  return fresh;
@@ -538,15 +477,14 @@ optionalSpecs) {
538
477
  const root = new Map();
539
478
  /** See {@link ResolveResult.skippedEdges}. Keyed by {@link edgeKey}. */
540
479
  const skippedEdges = new Set();
541
- // Top-level range bookkeeping for the version-conflict warning:
542
- // `name → (applied range → requester labels)`. Only TOP-LEVEL specs
543
- // participate: conflicting transitive edges are resolved correctly by
544
- // nesting (Phase D.7b), but conflicting top-level specs — the shape
545
- // `workspaceInstall` produces when two workspaces declare incompatible
546
- // ranges of the same external dep — all compete for the single root
547
- // slot, and today the resolver silently keeps one version for everyone
548
- // (per-workspace dedup is Phase D.8). Until that lands, the conflict is
549
- // surfaced loudly after the resolve (see emitTopLevelConflictWarnings).
480
+ // Bookkeeping for the version-conflict warning: `name → (applied range →
481
+ // requester labels)`. Only TOP-LEVEL specs participate — conflicting transitive
482
+ // edges are resolved correctly by nesting, but conflicting top-level specs (what
483
+ // `workspaceInstall` produces when two workspaces declare incompatible ranges of
484
+ // the same external dep) all compete for the single root slot and the resolver
485
+ // silently keeps one version for everyone. Per-workspace dedup is Phase D.8
486
+ // (status/open-todos.md); until it lands the conflict is surfaced loudly after
487
+ // the resolve (see emitTopLevelConflictWarnings).
550
488
  const topLevelRanges = new Map();
551
489
  const queue = specs.map(parseSpec).map((s) => {
552
490
  const range = applyOverride(s.name, s.range);
@@ -569,26 +507,19 @@ optionalSpecs) {
569
507
  required: !optionalSpecs?.has(s.name),
570
508
  };
571
509
  });
572
- // Wave-based BFS. Each iteration drains the current queue level, prefetches
573
- // every not-yet-cached packument in that level IN PARALLEL (bounded), then
574
- // applies placement SERIALLY in the same FIFO order the single-edge loop
575
- // used. Because newly-discovered children always append to the end of the
576
- // queue, "the current queue contents" is exactly one BFS level — so the
577
- // serial pass visits edges in the identical order, and `decidePlacement`'s
578
- // order-dependent hoist/nest decisions (hence the lockfile) are byte-for-
579
- // byte unchanged. Only the network moved: N sequential ~RTT packument
580
- // fetches per level collapse into one bounded-parallel batch. This is the
581
- // dominant cold-install cost — at libsoup's old `max-conns-per-host=2` it
582
- // would have throttled anyway, so it lands alongside the connection-cap
583
- // lift in `@gjsify/fetch`. `packumentCache` still guarantees ≤1 fetch per
584
- // unique name across the whole resolve, so prefetching every wave name
585
- // fetches exactly the same SET as before, just batched.
510
+ // Wave-based BFS: drain the current queue level, prefetch its not-yet-cached
511
+ // packuments in bounded parallel, then place SERIALLY in FIFO order. Newly
512
+ // discovered children always append, so "the current queue contents" is exactly
513
+ // one BFS level and the serial pass visits edges in the same order a single-edge
514
+ // loop would — which is what keeps `decidePlacement`'s order-dependent hoist/nest
515
+ // decisions, and hence the lockfile, unchanged. Only the network moved: N
516
+ // sequential ~RTT packument fetches per level collapse into one batch, the
517
+ // dominant cold-install cost. `packumentCache` still guarantees ≤1 fetch per
518
+ // unique name, so the same SET is fetched, just batched. Pointless before the
519
+ // `max-conns-per-host` lift in `@gjsify/fetch` — libsoup's default of 2 would
520
+ // have throttled it anyway.
586
521
  while (queue.length > 0) {
587
522
  const wave = queue.splice(0, queue.length);
588
- // Prefetch every not-yet-cached packument referenced in this level.
589
- // Names already in `packumentCache` (resolved in an earlier wave, or a
590
- // duplicate within this one) are skipped — the de-dup keeps the batch
591
- // to genuinely-new names.
592
523
  const toPrefetch = [];
593
524
  const queuedForFetch = new Set();
594
525
  for (const edge of wave) {
@@ -598,60 +529,47 @@ optionalSpecs) {
598
529
  toPrefetch.push(edge.name);
599
530
  }
600
531
  await prefetchPackuments(toPrefetch, fetchPkg, signal);
601
- // Serial placement pass — identical decisions and order to the original
602
- // single-edge loop, but every `fetchPkg` now resolves from the warmed
603
- // cache instead of blocking on a fresh round-trip.
532
+ // Serial placement pass; every `fetchPkg` here resolves from the warmed cache
533
+ // instead of blocking on a fresh round-trip.
604
534
  for (let wi = 0; wi < wave.length; wi++) {
605
535
  const edge = wave[wi];
606
- // A workspace member is satisfied by its workspace symlink, never by
607
- // a registry tarball — skip the edge so the published version (and
608
- // its subtree) never enters the resolved tree or the lockfile.
536
+ // A workspace member is satisfied by its symlink, never by a registry
537
+ // tarball — skip so the published version and its subtree never enter the
538
+ // resolved tree or the lockfile.
609
539
  if (workspaceNames?.has(edge.name))
610
540
  continue;
611
- // Walk the ancestor chain to see whether a satisfying placement is
612
- // already visible from the requester's `node_modules` lookup. npm's
613
- // resolver does this — each level of nesting acts as a fallback.
541
+ // Is a satisfying placement already visible from the requester's
542
+ // `node_modules` lookup? npm's resolver does the same — each level of
543
+ // nesting acts as a fallback.
614
544
  const visible = findVisible(edge.from, edge.name, byPath);
615
545
  if (visible && satisfiesRange(visible.version, edge.range)) {
616
- // Compatible placement reachable; reuse, no new install.
617
- //
618
- // NOTHING IS PROMOTED HERE. A `visible.optional = false` on a
619
- // required edge is the obvious move and it is wrong: the node's own
620
- // dep edges were already queued with the STALE flag, so its
621
- // children stayed optional and the one-shot promotion produced an
622
- // order-dependent answer (the reviewed defect). Optionality is
623
- // recomputed as a fixpoint over the finished graph instead —
624
- // {@link computeOptionalFlags}, which is order-independent by
625
- // construction and is the only writer of the final flag.
546
+ // Reuse; no new install. NOTHING IS PROMOTED HERE: setting
547
+ // `visible.optional = false` on a required edge is the obvious move
548
+ // and it is wrong, because the node's own dep edges were already
549
+ // queued with the STALE flag, so its children stay optional and the
550
+ // one-shot promotion answers by edge order (the reviewed defect).
551
+ // {@link computeOptionalFlags} is the only writer of the final flag.
626
552
  continue;
627
553
  }
628
- // No compatible existing placement. Resolve a version — preferring a
629
- // version already pinned in the lockfile when it satisfies the range
630
- // (so an add doesn't gratuitously bump unchanged deps).
554
+ // No compatible placement. Resolve a version, preferring one already
555
+ // pinned when it satisfies the range so an add does not bump unchanged
556
+ // deps.
631
557
  let version = null;
632
558
  try {
633
559
  const packument = await fetchPkg(edge.name);
634
560
  const preferred = preferredVersionFor(preferredVersions?.get(edge.name), edge.range);
635
561
  version = pickVersion(packument, edge.range, preferred);
636
562
  if (!version) {
637
- // Throw even for an optional edge and let the catch below
638
- // decide: ONE place that knows what a tolerated failure costs
639
- // (a log line and a `skippedEdges` entry). The `if
640
- // (!edge.required) continue` this replaces was a second,
641
- // silent copy of that decision — it skipped with no log line
642
- // at all and left nothing for the post-pass to re-judge.
563
+ // Throw even for an optional edge and let the catch below decide:
564
+ // ONE place knows what a tolerated failure costs (a log line and a
565
+ // `skippedEdges` entry). A bare `if (!edge.required) continue` here
566
+ // skipped silently and left nothing for the post-pass to re-judge.
643
567
  throw new Error(`No version of ${edge.name} satisfies ${edge.range}`);
644
568
  }
645
569
  const v = packument.versions[version];
646
570
  if (!v) {
647
571
  throw new Error(`Packument for ${edge.name} promised ${version} but no entry exists`);
648
572
  }
649
- // Decision: hoist to root, or nest under the requester?
650
- // - Hoist iff the root has no conflicting placement (i.e. the
651
- // root slot for `name` is empty OR holds the same version).
652
- // - Otherwise nest. Top-level specs (from === null) always
653
- // hoist; the resolver guarantees they never conflict with
654
- // each other because the input set is checked once.
655
573
  const installPath = decidePlacement(edge.from, edge.name, version, root);
656
574
  const node = {
657
575
  name: edge.name,
@@ -662,14 +580,13 @@ optionalSpecs) {
662
580
  dependencies: v.dependencies ?? {},
663
581
  optionalDependencies: v.optionalDependencies ?? {},
664
582
  bin: v.bin,
665
- // Complete as published — `fetchPkg` reads the FULL document,
666
- // so all three fields come from one authoritative body and no
667
- // second fetch or per-package heuristic is involved.
583
+ // Complete as published: `fetchPkg` reads the FULL document, so all
584
+ // three fields come from one body — no second fetch, no heuristic.
668
585
  platform: platformDeclarationOf(v),
669
- // Forward guess only; `computeOptionalFlags` overwrites it.
670
- // It is still worth setting: the child edges queued below read
671
- // it, which is what keeps a failure under a plainly-optional
672
- // subtree non-fatal at the point of failure.
586
+ // Forward guess only; `computeOptionalFlags` overwrites it. Still
587
+ // worth setting, because the child edges queued below read it, which
588
+ // is what keeps a failure under a plainly-optional subtree non-fatal
589
+ // at the point of failure.
673
590
  optional: !edge.required,
674
591
  };
675
592
  byPath.set(installPath, node);
@@ -677,9 +594,8 @@ optionalSpecs) {
677
594
  root.set(edge.name, node);
678
595
  }
679
596
  log('resolve: %s@%s ← %s (at %s)', edge.name, version, edge.range, installPath);
680
- // Moving soft-total: resolved so far + edges still to visit in
681
- // this wave + children already queued for the next wave. Same
682
- // converging-estimate pattern yarn/pnpm use.
597
+ // Moving soft-total: resolved so far + edges left in this wave +
598
+ // children queued for the next. The converging estimate yarn/pnpm use.
683
599
  progress?.update({
684
600
  phase: 'resolve',
685
601
  current: byPath.size,
@@ -688,25 +604,22 @@ optionalSpecs) {
688
604
  });
689
605
  if (!skipDeps) {
690
606
  // REQUIRED edges only — a name this package also lists in
691
- // `optionalDependencies` is queued by the loop below instead,
692
- // once, as optional (header note, invariant 3). Queuing it here
693
- // as well would place it with a `required: true` forward guess,
694
- // making a resolve failure fatal and (via the fixpoint) its
695
- // incompatible platform siblings fatal too.
607
+ // `optionalDependencies` is queued once by the loop below, as
608
+ // optional (header note, invariant 3). Queuing it here too would
609
+ // place it with a `required: true` forward guess, making a resolve
610
+ // failure fatal and, via the fixpoint, its incompatible platform
611
+ // siblings fatal too.
696
612
  for (const [depName, depRange] of requiredDepEntries(node)) {
697
613
  queue.push({
698
614
  from: installPath,
699
615
  name: depName,
700
- // A dependency of an OPTIONAL node inherits its
701
- // optionality — npm's `optionalSet`. Load-bearing for
702
- // the platform check, not a nicety: we resolve
703
- // foreign-platform optional packages on purpose (the
704
- // lockfile must stay portable), so their own required
705
- // deps get visited too. `fsevents` (darwin-only) is
706
- // the live shape — treating its subtree as required
707
- // would raise EBADPLATFORM for a package nothing on
708
- // this host will ever load, i.e. fail every Linux
709
- // install over a darwin-only optional dep.
616
+ // A dependency of an OPTIONAL node inherits its optionality
617
+ // (npm's `optionalSet`). Load-bearing for the platform check:
618
+ // foreign-platform optional packages are resolved on purpose
619
+ // to keep the lockfile portable, so their own required deps
620
+ // get visited too. Treating darwin-only `fsevents`' subtree as
621
+ // required would fail every Linux install with EBADPLATFORM
622
+ // over a package nothing on the host will ever load.
710
623
  range: applyOverride(depName, depRange),
711
624
  required: !node.optional,
712
625
  });
@@ -722,15 +635,14 @@ optionalSpecs) {
722
635
  }
723
636
  }
724
637
  catch (e) {
725
- // Optional deps that fail to resolve are skipped — yarn/npm
726
- // behavior. Required deps re-throw.
638
+ // Optional deps that fail to resolve are skipped (yarn/npm behaviour);
639
+ // required deps re-throw.
727
640
  if (!edge.required) {
728
- // RECORDED, not merely logged: `edge.required` is the walk's
729
- // forward guess, and a later required edge can still make this
730
- // node mandatory. `assertRequiredEdgesResolved` re-judges every
731
- // entry against the fixpoint flags, so a genuinely required
732
- // dependency cannot end up missing from the tree just because
733
- // BFS order happened to reach it through an optional edge first.
641
+ // RECORDED, not merely logged: `edge.required` is the walk's forward
642
+ // guess and a later required edge can still make this node mandatory.
643
+ // `assertRequiredEdgesResolved` re-judges every entry against the
644
+ // fixpoint flags, so a genuinely required dependency cannot go missing
645
+ // just because BFS reached it through an optional edge first.
734
646
  skippedEdges.add(edgeKey(edge.from, edge.name));
735
647
  log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, errMsg(e));
736
648
  continue;
@@ -741,24 +653,20 @@ optionalSpecs) {
741
653
  }
742
654
  progress?.endPhase('resolve');
743
655
  emitTopLevelConflictWarnings(topLevelRanges, root);
744
- // NO declaration post-pass here, deliberately. `fetchPkg` already read the
745
- // FULL document for every package, so each node's `platform` is complete as
746
- // published and there is nothing left to escalate. The draft DID have a
747
- // second `libc`-only fetch at exactly this line, and it took the platform
748
- // target to decide when to fire — the one argument this function must never
749
- // have (header note, invariant 1). Reintroducing the pass means
750
- // reintroducing the parameter, which is how a macOS-authored lockfile ended
751
- // up with no `libc` recorded for anyone.
656
+ // NO declaration post-pass here, deliberately: `fetchPkg` read the FULL document for
657
+ // every package, so each `platform` is complete as published. The draft's second
658
+ // `libc`-only fetch sat at exactly this line and needed the platform target to
659
+ // decide when to fire — reintroducing the pass reintroduces the one argument this
660
+ // function must never have (header note, invariant 1).
752
661
  //
753
- // `optional` is likewise NOT finalised here: the flags on these nodes are
754
- // still the walk's forward guess. `computeOptionalFlags` overwrites them at
755
- // the call site, on this path and on the lockfile path alike.
662
+ // `optional` is likewise NOT finalised here; these flags are still the walk's
663
+ // forward guess, overwritten by `computeOptionalFlags` at the call site.
756
664
  return { nodes: Array.from(byPath.values()), skippedEdges };
757
665
  }
758
666
  /**
759
667
  * The platform declaration a packument version carries, or undefined when it
760
- * declares none (the common case — keeping it undefined keeps the node and the
761
- * lockfile entry minimal). Pure read, no I/O.
668
+ * declares none (the common case — undefined keeps the node and the lockfile entry
669
+ * minimal).
762
670
  */
763
671
  function platformDeclarationOf(version) {
764
672
  const declaration = { os: version.os, cpu: version.cpu, libc: version.libc };
@@ -768,19 +676,17 @@ function platformDeclarationOf(version) {
768
676
  * Identity of one dependency EDGE: the requester's `installPath` (empty string
769
677
  * for the project root) plus the dependency name.
770
678
  *
771
- * The PAIR is the identity, never the name alone. The same unresolvable name can
772
- * be legitimately absent below an optional subtree and a hard error below a
773
- * required one — only the requester says which, so a `skippedEdges` keyed by
774
- * name would collapse the two cases and answer whichever it saw last.
679
+ * The PAIR is the identity, never the name alone: the same unresolvable name can be
680
+ * legitimately absent below an optional subtree and a hard error below a required
681
+ * one, and only the requester says which — a `skippedEdges` keyed by name would
682
+ * collapse the two and answer whichever it saw last.
775
683
  *
776
- * `\n` is the separator because no npm package name and no install path can
777
- * contain one (npm rejects control characters in names, and every path here is
778
- * built out of names), so the key round-trips through {@link parseEdgeKey}
684
+ * `\n` separates because no npm package name and no install path can contain one
685
+ * (npm rejects control characters in names, and every path here is built out of
686
+ * names), so the key round-trips through {@link parseEdgeKey}
779
687
  * unambiguously. Deliberately NOT `\u0000`, the other obvious choice: the GJS
780
- * bundle minifier rewrites that escape into a raw NUL byte and GJS then refuses
781
- * to parse the bundle at all ("template literal not terminated"). A separator
782
- * that only fails once this file is bundled for the runtime it ships on is not
783
- * worth the theoretical tidiness.
688
+ * bundle minifier rewrites that escape into a raw NUL byte and GJS then refuses to
689
+ * parse the bundle at all ("template literal not terminated").
784
690
  */
785
691
  function edgeKey(from, name) {
786
692
  return `${from ?? ''}\n${name}`;
@@ -792,19 +698,19 @@ function parseEdgeKey(key) {
792
698
  return { from: from === '' ? null : from, name: key.slice(sep + 1) };
793
699
  }
794
700
  /**
795
- * Seed set for {@link computeOptionalFlags}: the NAMES of the top-level specs
796
- * the project did not declare optional. Everything else in the tree has to EARN
797
- * its required status by being reachable from one of these.
701
+ * Seed set for {@link computeOptionalFlags}: the NAMES of the top-level specs the
702
+ * project did not declare optional. Everything else must EARN its required status
703
+ * by being reachable from one of these.
798
704
  *
799
705
  * The parse is load-bearing. `specs` are flat `"<name>@<range>"` strings while
800
706
  * `optionalSpecs` holds bare NAMES (the range differs per requester — see
801
- * {@link NativeInstallOptions.optionalSpecs}), so testing a spec string against
802
- * the set is a lookup that can never hit: every top-level optionalDependency
803
- * would be seeded as REQUIRED, and the `optionalDependencies: { fsevents }`
804
- * shape would fail every Linux install with EBADPLATFORM instead of thinning.
805
- * An earlier version of this walk compared the two directly and did exactly
806
- * that; the `(c) an incompatible OPTIONAL top-level dep is skipped, not fatal`
807
- * case in `tests/e2e/install-platform-filter` is where it is pinned.
707
+ * {@link NativeInstallOptions.optionalSpecs}), so testing a spec string against the
708
+ * set is a lookup that can never hit: every top-level optionalDependency would be
709
+ * seeded REQUIRED and the `optionalDependencies: { fsevents }` shape would fail
710
+ * every Linux install with EBADPLATFORM instead of thinning. An earlier version
711
+ * compared the two directly and did exactly that; pinned by the "incompatible
712
+ * OPTIONAL top-level dep is skipped, not fatal" case in
713
+ * `tests/e2e/install-platform-filter`.
808
714
  */
809
715
  function requiredTopLevelNames(specs, optionalSpecs) {
810
716
  const names = new Set();
@@ -817,27 +723,21 @@ function requiredTopLevelNames(specs, optionalSpecs) {
817
723
  return names;
818
724
  }
819
725
  /**
820
- * A node's REQUIRED dependency edges: its `dependencies`, minus every name its
821
- * `optionalDependencies` also lists. The one place that decides what kind an edge
822
- * is — both the resolve walk and the optionality fixpoint read it, so the two
823
- * cannot drift.
726
+ * A node's REQUIRED dependency edges: its `dependencies` minus every name its
727
+ * `optionalDependencies` also lists. The ONE place that decides an edge's kind —
728
+ * both the resolve walk and the optionality fixpoint read it, so they cannot drift.
824
729
  *
825
- * THE SUBTRACTION IS npm's RULE, not a policy of ours: "entries in
826
- * optionalDependencies will override entries of the same name in dependencies, so
827
- * it's usually best to only put in one place" (npm's package.json docs). Measured
828
- * on `@parcel/rust@2.16.4`, which lists all eight per-platform napi packages in
829
- * both blocks: `npm install` exits 0, installs `@parcel/rust-linux-x64-gnu` only,
830
- * and writes `"optional": true` for all eight in its `package-lock.json`.
730
+ * The subtraction is npm's rule, not ours: "entries in optionalDependencies will
731
+ * override entries of the same name in dependencies" (npm's package.json docs).
831
732
  *
832
- * WHY IT IS NOT MERELY A DEDUP: the kind decides what an incompatible
833
- * `os`/`cpu`/`libc` MEANS (`applyPlatformFilter` — fatal vs. inert) and whether a
834
- * failed resolve is tolerated. Reading `dependencies` alone therefore does not
835
- * just visit a name twice, it promotes a foreign-platform binary to a required
836
- * dependency and fails installs npm completes.
733
+ * NOT MERELY A DEDUP: the kind decides what an incompatible `os`/`cpu`/`libc` MEANS
734
+ * (fatal vs. inert in `applyPlatformFilter`) and whether a failed resolve is
735
+ * tolerated. Reading `dependencies` alone does not just visit a name twice, it
736
+ * promotes a foreign-platform binary to a required dependency and fails installs npm
737
+ * completes — the header note's invariant 3.
837
738
  *
838
- * The inverse direction is deliberately NOT symmetric: a name in
839
- * `optionalDependencies` alone is optional (no `dependencies` entry to override),
840
- * which is what {@link resolveDeps}'s second queue loop covers.
739
+ * Deliberately NOT symmetric: a name in `optionalDependencies` alone is optional (no
740
+ * `dependencies` entry to override), covered by {@link resolveDeps}'s second queue loop.
841
741
  */
842
742
  export function requiredDepEntries(node) {
843
743
  const entries = [];
@@ -849,51 +749,38 @@ export function requiredDepEntries(node) {
849
749
  return entries;
850
750
  }
851
751
  /**
852
- * Recompute every node's `optional` flag as a FIXPOINT over the placed graph.
853
- * The only writer of the final flag; whatever the nodes arrive carrying is an
854
- * input nothing checked (a BFS forward guess on the resolve path, a value read
855
- * back out of a file on the lockfile path) and is overwritten unconditionally.
752
+ * Recompute every node's `optional` flag as a FIXPOINT over the placed graph. The
753
+ * only writer of the final flag; whatever the nodes arrive carrying is an input
754
+ * nothing checked (a BFS forward guess on the resolve path, a value read out of a
755
+ * file on the lockfile path) and is overwritten unconditionally.
856
756
  *
857
- * DEFINITION: a node is REQUIRED iff it is reachable from `requiredNames`
858
- * through REQUIRED edges alone ({@link requiredDepEntries} — `dependencies` minus
859
- * the names `optionalDependencies` overrides); every other node is optional.
860
- * Optionality is therefore INHERITED — a plain dependency OF an optional package
861
- * is still optional — which is what npm's `optionalSet` computes.
757
+ * DEFINITION: a node is REQUIRED iff reachable from `requiredNames` through
758
+ * REQUIRED edges alone ({@link requiredDepEntries}); every other node is optional.
759
+ * Optionality is therefore INHERITED — a plain dependency OF an optional package is
760
+ * still optional, which is what npm's `optionalSet` computes.
862
761
  *
863
762
  * WHY A FIXPOINT AND NOT A ONE-SHOT PROMOTION AT THE REUSE SITE (the reviewed
864
- * defect): the walk queues a node's own dep edges at the moment it is placed,
865
- * carrying the optionality it had THEN. Promoting the node later leaves its
866
- * children behind, so the answer depends on which edge BFS happened to traverse
867
- * first — a genuinely required transitive dep reached first through an optional
868
- * edge stayed flagged optional, was persisted that way, and was then silently
869
- * skipped by the platform filter on every machine instead of raising
870
- * EBADPLATFORM. A monotone worklist over the FINISHED graph cannot have that
871
- * property: "required" only ever spreads, so the result is independent of visit
872
- * order. npm reaches for the same shape and says so —
873
- * refs/npm-cli/workspaces/arborist/lib/calc-dep-flags.js: "If a node is changed,
874
- * we add to the queue and continue until no more changes."
763
+ * defect): the walk queues a node's dep edges when it is placed, carrying the
764
+ * optionality it had THEN, so promoting the node later leaves its children behind and
765
+ * the answer depends on which edge BFS traversed first. A required transitive dep
766
+ * reached first through an optional edge stayed flagged optional, was persisted that
767
+ * way, and was then silently skipped by the platform filter on every machine instead
768
+ * of raising EBADPLATFORM. A monotone worklist over the FINISHED graph cannot have
769
+ * that property: "required" only ever spreads, so the result is order-independent.
770
+ * Same shape as refs/npm-cli/workspaces/arborist/lib/calc-dep-flags.js.
875
771
  *
876
- * PATH-INDEPENDENCE IS THE OTHER REQUIREMENT, and it is why the lockfile carries
877
- * an `optionalDependencies` map per entry (v4). The walk USED to read
878
- * `dependencies` only, for exactly that reason — a lockfile entry recorded nothing
879
- * else, so consulting `node.optionalDependencies` would have made the two paths
880
- * compute different flags for the same tree. The premise was fixable and the
881
- * consequence was not: a publisher listing the same name in BOTH blocks means
882
- * "optional" to npm, came out REQUIRED here, and the shape is not rare — it is
883
- * `@parcel/rust@2.16.4` (all eight per-platform napi packages in both blocks), so
884
- * every Linux install of any tree containing parcel failed with EBADPLATFORM on
885
- * `@parcel/rust-darwin-x64` while `npm install` thinned the same tree and exited
886
- * 0. "Erring toward REQUIRED errs toward the loud failure" is true and was the
887
- * wrong trade: the loud failure was not a real one, and no `--force` lifts it
888
- * (nor should it). So the declaration is persisted and both paths subtract it.
889
- * Reading a pre-v4 lockfile leaves the map empty, which reproduces the old
890
- * behaviour for that file alone — see READABLE_LOCKFILE_VERSIONS.
772
+ * PATH-INDEPENDENCE is the other requirement, and why the lockfile carries an
773
+ * `optionalDependencies` map per entry (v4). This walk USED to read `dependencies`
774
+ * only, because a lockfile entry recorded nothing else and consulting
775
+ * `node.optionalDependencies` would have made the two paths compute different flags
776
+ * for one tree. That premise was fixable and its consequence — a both-blocks name
777
+ * coming out REQUIRED — was not (header note, invariant 3). A pre-v4 lockfile leaves
778
+ * the map empty and reproduces the old behaviour for that file alone
779
+ * (READABLE_LOCKFILE_VERSIONS).
891
780
  *
892
- * Runs before `writeLockfile` so the persisted flag is the final one, and before
893
- * `applyPlatformFilter` so the fatal-vs-inert decision reads it rather than the
894
- * guess. Exported so the unit spec can drive it with INJECTED graphs: the whole
895
- * fatal-vs-inert question is decided by these two functions in composition, and
896
- * that is worth pinning without a registry.
781
+ * Exported so the unit spec can drive it with INJECTED graphs — the whole
782
+ * fatal-vs-inert question is decided by this and `applyPlatformFilter` in
783
+ * composition, worth pinning without a registry.
897
784
  */
898
785
  export function computeOptionalFlags(nodes, requiredNames, log) {
899
786
  const byPath = new Map();
@@ -909,9 +796,9 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
909
796
  required.add(node.installPath);
910
797
  worklist.push(node.installPath);
911
798
  };
912
- // Top-level specs always hoist (`decidePlacement` returns the root slot for
913
- // a null requester), so the seed lookup is exact. A name with no placement
914
- // is a workspace member satisfied by its symlink — nothing to flag.
799
+ // Top-level specs always hoist (`decidePlacement` returns the root slot for a
800
+ // null requester), so the seed lookup is exact. A name with no placement is a
801
+ // workspace member satisfied by its symlink — nothing to flag.
915
802
  for (const name of requiredNames) {
916
803
  const seed = byPath.get(`node_modules/${name}`);
917
804
  if (seed)
@@ -934,11 +821,10 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
934
821
  enter(resolvedTo);
935
822
  }
936
823
  }
937
- // Count the disagreements, don't just apply them. A nonzero `corrected` is
938
- // the signal that the incoming flags (the walk's guess, or a lockfile
939
- // written by an older CLI) were wrong for this tree — the class of bug this
940
- // pass exists for, otherwise invisible because the corrected result looks
941
- // exactly like a correct one.
824
+ // Count the disagreements, don't just apply them: a nonzero `corrected` is the
825
+ // signal that the incoming flags (the walk's guess, or a lockfile from an older
826
+ // CLI) were wrong for this tree. Otherwise invisible, because a corrected result
827
+ // looks exactly like a correct one.
942
828
  let corrected = 0;
943
829
  for (const node of nodes) {
944
830
  const optional = !required.has(node.installPath);
@@ -949,26 +835,22 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
949
835
  log('install: optionality fixpoint — %d of %d node(s) reachable only via optional edges%s', nodes.length - required.size, nodes.length, corrected > 0 ? `; corrected ${corrected} incoming flag(s)` : '');
950
836
  }
951
837
  /**
952
- * Re-judge every failure the resolve swallowed against the FIXPOINT flags, and
953
- * throw if one of them turned out to be required after all.
838
+ * Re-judge every failure the resolve swallowed against the FIXPOINT flags, and throw
839
+ * if one turned out to be required after all.
954
840
  *
955
- * `resolveDeps` tolerates a failed edge on the strength of `edge.required`,
956
- * which is a forward guess made while the graph was still incomplete. This is
957
- * the second half of that bargain: without it, a genuinely required dependency
958
- * disappears from the tree for no better reason than BFS reaching it through an
959
- * optional edge first — an install that "succeeded" and produced a tree that
960
- * cannot run, with the explanation confined to a `--verbose` line nobody read.
841
+ * `resolveDeps` tolerates a failed edge on the strength of `edge.required`, a
842
+ * forward guess made while the graph was incomplete. Without this second half of the
843
+ * bargain a genuinely required dependency disappears from the tree for no better
844
+ * reason than BFS reaching it through an optional edge first — an install that
845
+ * "succeeded" and produced a tree that cannot run, explained only by a `--verbose`
846
+ * line nobody read.
961
847
  *
962
- * Same shape as npm's `#pruneFailedOptional()` ("if (!node.optional) throw
963
- * node.errors[0]"), including its position AFTER the flag pass. The difference
964
- * is what we hold: npm placed a real (broken) Node and still has the original
965
- * error, we recorded only the edge — so the message has to name the edge and
966
- * point at the verbose line that carries the cause.
848
+ * Same shape and position as npm's `#pruneFailedOptional()` (after the flag pass).
849
+ * The difference is what we hold: npm placed a real broken Node and still has the
850
+ * original error, we recorded only the edge, so the message names the edge and points
851
+ * at the verbose line carrying the cause.
967
852
  *
968
- * Three tolerated cases, and each is a property of the EDGE, not of the tree:
969
- * a root edge the project itself declared optional, an edge under a subtree the
970
- * fixpoint says is optional-only, and a required node's own
971
- * `optionalDependencies` entry.
853
+ * The three tolerated cases below are each a property of the EDGE, not of the tree.
972
854
  */
973
855
  function assertRequiredEdgesResolved(nodes, skippedEdges) {
974
856
  if (skippedEdges.size === 0)
@@ -978,35 +860,28 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
978
860
  byPath.set(node.installPath, node);
979
861
  for (const key of skippedEdges) {
980
862
  const { from, name } = parseEdgeKey(key);
981
- // Top-level edge: tolerated only because the project's own manifest put
982
- // the spec in `optionalDependencies` (that is the ONLY way
983
- // `requiredTopLevelNames` leaves it out of the seed set). The manifest
984
- // is the whole truth for a root edge — no later edge can revise it.
863
+ // Top-level edge: tolerated only because the project's own manifest put the
864
+ // spec in `optionalDependencies` (the ONLY way `requiredTopLevelNames` leaves
865
+ // it out of the seed set), and no later edge can revise the manifest.
985
866
  if (from === null)
986
867
  continue;
987
868
  const requester = byPath.get(from);
988
- // Requester never made it into the tree. Nothing depends on this edge
989
- // any more, so nothing is missing.
869
+ // Requester never made it into the tree, so nothing depends on this edge.
990
870
  if (!requester)
991
871
  continue;
992
- // The whole subtree is reachable only through optional edges — npm's
993
- // `optionalSet`, skipped in silence.
872
+ // The whole subtree is optional-only — npm's `optionalSet`, skipped silently.
994
873
  if (requester.optional)
995
874
  continue;
996
- // A REQUIRED package's own optionalDependency stays optional whatever
997
- // the package's flag says: optionality lives on the edge, not on its
998
- // endpoints (header note, invariant 3 — the same rule
999
- // {@link requiredDepEntries} applies to the two graph walks). This branch
1000
- // is what keeps a darwin-only `fsevents` under a required `chokidar` from
1001
- // failing every Linux install.
1002
- //
1003
- // Membership, not `requiredDepEntries`: the failed edge may name something
1004
- // no longer in `dependencies` at all, and what is being asked here is
1005
- // whether the requester declared THIS name optional.
875
+ // A REQUIRED package's own optionalDependency stays optional whatever the
876
+ // package's flag says: optionality lives on the edge, not on its endpoints
877
+ // (header note, invariant 3). This branch is what keeps a darwin-only
878
+ // `fsevents` under a required `chokidar` from failing every Linux install.
1006
879
  //
1007
- // Safe on both paths because `skippedEdges` is only ever non-empty on
1008
- // the fresh-resolve path, where `optionalDependencies` is populated; the
1009
- // lockfile paths resolve nothing and reach here with an empty set.
880
+ // Membership, not `requiredDepEntries`: the failed edge may name something no
881
+ // longer in `dependencies` at all, and the question is whether the requester
882
+ // declared THIS name optional. Safe on both paths — `skippedEdges` is only
883
+ // ever non-empty on the fresh-resolve path, where `optionalDependencies` is
884
+ // populated.
1010
885
  if (name in requester.optionalDependencies)
1011
886
  continue;
1012
887
  throw new Error(`install: required dependency ${name} of ${requester.name}@${requester.version} ` +
@@ -1019,17 +894,15 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
1019
894
  }
1020
895
  }
1021
896
  /**
1022
- * Loud, single-line-per-package warning for top-level version-range
1023
- * conflicts (ADR 0001, step 3). Fires when two or more top-level specs
1024
- * requested DIFFERENT ranges of the same package and the version that ended
1025
- * up hoisted to the root does not satisfy all of them — i.e. some requester
1026
- * silently got a version outside its declared range. Compatible ranges
1027
- * (`^1.2` + `^1.4` both satisfied by `1.9.0`) stay silent; ranges that are
1028
- * not semver (dist-tags like `latest`) cannot be compared and are skipped.
897
+ * Loud one-line-per-package warning for top-level version-range conflicts (ADR
898
+ * 0001). Fires when two or more top-level specs requested DIFFERENT ranges of the
899
+ * same package and the version hoisted to the root does not satisfy all of them, so
900
+ * some requester silently got a version outside its declared range. Compatible
901
+ * ranges (`^1.2` + `^1.4` both satisfied by `1.9.0`) stay silent; non-semver ranges
902
+ * (dist-tags like `latest`) cannot be compared and are skipped.
1029
903
  *
1030
- * This makes the current single-root-slot behavior honest instead of silent;
1031
- * the real fix — a per-workspace dedup pass that gives each conflicting
1032
- * requester its own nested copy — is Phase D.8 (see status/open-todos.md).
904
+ * This makes the single-root-slot behaviour honest rather than silent; the real fix
905
+ * is a per-workspace dedup pass, Phase D.8 (status/open-todos.md).
1033
906
  */
1034
907
  function emitTopLevelConflictWarnings(topLevelRanges, root) {
1035
908
  for (const [name, ranges] of topLevelRanges) {
@@ -1060,27 +933,17 @@ function emitTopLevelConflictWarnings(topLevelRanges, root) {
1060
933
  }
1061
934
  }
1062
935
  /**
1063
- * Warm `packumentCache` for a batch of package names with bounded parallelism
1064
- * (same `DEFAULT_CONCURRENCY` width as the download pool). Rejections — e.g. a
1065
- * 404 on an optional dep — are swallowed here: the promise stays cached in its
1066
- * rejected state and the caller's per-edge `await fetchPkg(name)` re-surfaces
1067
- * it, so required deps still throw and optional ones are skipped exactly as in
1068
- * the single-edge path. Swallowing also stops one bad optional dependency from
1069
- * aborting the whole wave's batch.
1070
- */
1071
- /**
1072
- * Fetch a packument with on-disk ETag revalidation. Reads the cached
1073
- * `{ etag, packument }` for `(registry, name)`, sends it as `If-None-Match`,
1074
- * and on a `304 Not Modified` returns the cached body without re-downloading
1075
- * it; on a `200` it stores the fresh body + ETag and returns it. The cache is
1076
- * keyed by the registry the name resolves to, so scope-registry overrides never
1077
- * cross-contaminate. Falls back to a plain fetch when there's no cached entry
1078
- * or the registry doesn't send an ETag (the 304 fast-path simply never fires).
936
+ * Fetch a packument with on-disk ETag revalidation: the cached
937
+ * `{ etag, packument }` for `(registry, name)` goes out as `If-None-Match`, a `304`
938
+ * returns the cached body, a `200` stores the fresh body + ETag. Keyed by the
939
+ * registry the NAME resolves to, so scope-registry overrides never
940
+ * cross-contaminate. Falls back to a plain fetch with no cached entry or no ETag
941
+ * from the registry (the 304 fast-path then simply never fires).
1079
942
  *
1080
- * `shape` selects WHICH document: `'corgi'` (abbreviated — every resolve) or
1081
- * `'full'` (the escalation that carries `libc`). It is threaded all the way
1082
- * through — request `accept`, cache read AND cache write — because the two are
1083
- * different bodies with independently-versioned ETags for one URL. Mixing them
943
+ * `shape` selects WHICH document — `'corgi'` (abbreviated) or `'full'`, the only one
944
+ * this backend's resolve asks for because it is the only one carrying `libc`. It is
945
+ * threaded through request `accept`, cache read AND cache write, because the two are
946
+ * different bodies with independently-versioned ETags for one URL; mixing them
1084
947
  * anywhere in that chain produces a `libc`-less body that looks like a hit.
1085
948
  */
1086
949
  async function fetchPackumentWithDiskCache(name, npmrc, log, signal, shape = 'corgi') {
@@ -1106,10 +969,18 @@ async function fetchPackumentWithDiskCache(name, npmrc, log, signal, shape = 'co
1106
969
  putCachedPackument(registry, name, result.etag, result.packument, shape);
1107
970
  return result.packument;
1108
971
  }
1109
- // 304 with no cached body to satisfy it (a stale `If-None-Match` raced a
1110
- // cache eviction). Re-fetch unconditionally so we always return a body.
972
+ // 304 with no cached body to satisfy it — a stale `If-None-Match` raced a cache
973
+ // eviction. Re-fetch unconditionally so a body is always returned.
1111
974
  return fetchPackument(name, { npmrc, signal, fullMetadata, onRetry });
1112
975
  }
976
+ /**
977
+ * Warm `packumentCache` for a batch of names with bounded parallelism (the download
978
+ * pool's `DEFAULT_CONCURRENCY` width). Rejections — a 404 on an optional dep, say —
979
+ * are swallowed: the promise stays cached in its rejected state and the caller's
980
+ * per-edge `await fetchPkg(name)` re-surfaces it, so required deps still throw and
981
+ * optional ones are skipped exactly as on the single-edge path. Swallowing also stops
982
+ * one bad optional dependency from aborting the whole wave's batch.
983
+ */
1113
984
  async function prefetchPackuments(names, fetchPkg, signal) {
1114
985
  if (names.length === 0)
1115
986
  return;
@@ -1131,27 +1002,18 @@ async function prefetchPackuments(names, fetchPkg, signal) {
1131
1002
  await Promise.all(Array.from({ length: concurrency }, () => worker()));
1132
1003
  }
1133
1004
  /**
1134
- * Walk the ancestor `node_modules` chain from `requesterPath` upward,
1135
- * looking for a placement of `name` that the requester would resolve
1136
- * through Node's CommonJS lookup. Returns the first match — that's the
1137
- * one the requester actually sees at runtime.
1005
+ * The placement of `name` that `requesterPath` would resolve through Node's
1006
+ * `node_modules` lookup — its own nested copy first (nested deps shadow ancestor
1007
+ * ones), then each ancestor, then the root. The first match is what the requester
1008
+ * actually sees at runtime.
1138
1009
  */
1139
1010
  function findVisible(requesterPath, name, byPath) {
1140
- // From the requester's directory, Node walks up node_modules dirs
1141
- // looking for `<dir>/node_modules/<name>`. Translate that to lockfile
1142
- // paths: any prefix of the requester's `installPath` that ends in a
1143
- // package directory gives a candidate `<prefix>/node_modules/<name>`.
1144
- //
1145
- // The requester itself ALSO checks its OWN `node_modules` first
1146
- // (i.e. `<requesterPath>/node_modules/<name>` — nested deps shadow
1147
- // ancestor ones). Then it walks up.
1148
1011
  const candidates = [];
1149
1012
  if (requesterPath !== null) {
1150
1013
  candidates.push(`${requesterPath}/node_modules/${name}`);
1151
- // Walk up: strip the last `/node_modules/<pkg>` segment and try again.
1014
+ // Walk up by stripping the deepest `/node_modules/<pkg>` segment each time.
1152
1015
  let p = requesterPath;
1153
1016
  while (true) {
1154
- // Find the deepest `/node_modules/<pkg>` in `p`, strip it.
1155
1017
  const idx = p.lastIndexOf('/node_modules/');
1156
1018
  if (idx < 0)
1157
1019
  break;
@@ -1161,8 +1023,7 @@ function findVisible(requesterPath, name, byPath) {
1161
1023
  break;
1162
1024
  }
1163
1025
  }
1164
- // The root `node_modules/<name>` is the final candidate (covers the
1165
- // `requesterPath === null` case too).
1026
+ // Also covers `requesterPath === null`.
1166
1027
  candidates.push(`node_modules/${name}`);
1167
1028
  for (const candidate of candidates) {
1168
1029
  const hit = byPath.get(candidate);
@@ -1172,13 +1033,10 @@ function findVisible(requesterPath, name, byPath) {
1172
1033
  return null;
1173
1034
  }
1174
1035
  /**
1175
- * Decide where to install `name@version` for a request from `requesterPath`.
1176
- *
1177
- * - Root is empty for `name`: hoist (return `node_modules/<name>`).
1178
- * - Root has the SAME version: reuse the root placement.
1179
- * - Root has a DIFFERENT version: nest under the requester.
1180
- *
1181
- * Top-level requesters (requesterPath === null) always hoist.
1036
+ * Where to install `name@version` for a request from `requesterPath`: hoist to
1037
+ * `node_modules/<name>` when the root slot is empty or already holds this version,
1038
+ * nest under the requester when the root holds a different one. Top-level requesters
1039
+ * (`requesterPath === null`) always hoist.
1182
1040
  */
1183
1041
  function decidePlacement(requesterPath, name, version, root) {
1184
1042
  const rootSlot = root.get(name);
@@ -1187,16 +1045,15 @@ function decidePlacement(requesterPath, name, version, root) {
1187
1045
  if (rootSlot.version === version)
1188
1046
  return `node_modules/${name}`;
1189
1047
  if (requesterPath === null) {
1190
- // Top-level specs are deduplicated by the caller before reaching
1191
- // here; this branch is defensive (would only fire on a duplicate
1192
- // top-level spec with conflicting versions).
1048
+ // Defensive: the caller dedups top-level specs, so this only fires on a
1049
+ // duplicate top-level spec with conflicting versions.
1193
1050
  return `node_modules/${name}`;
1194
1051
  }
1195
1052
  return `${requesterPath}/node_modules/${name}`;
1196
1053
  }
1197
1054
  function satisfiesRange(version, range) {
1198
- // dist-tag (e.g. `latest`) cannot be matched here — caller passed a
1199
- // raw range. Dist-tags only meaningful at fresh-resolve time.
1055
+ // A dist-tag (`latest`) cannot be matched here; those are only meaningful at
1056
+ // fresh-resolve time.
1200
1057
  try {
1201
1058
  return satisfies(version, new Range(range));
1202
1059
  }
@@ -1209,11 +1066,8 @@ function readLockfile(lockfilePath) {
1209
1066
  return null;
1210
1067
  try {
1211
1068
  const parsed = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
1212
- // Accept every readable version, not just the current one — see
1213
- // READABLE_LOCKFILE_VERSIONS for why returning null on a v2 file is
1214
- // worse than reading it. The caller decides what it may be used FOR:
1215
- // a v2 file seeds version preservation but never short-circuits the
1216
- // resolve, because it carries no platform declarations.
1069
+ // Every readable version, not just the current one — READABLE_LOCKFILE_VERSIONS
1070
+ // has the reason. The caller decides what it may be used FOR.
1217
1071
  if (!READABLE_LOCKFILE_VERSIONS.has(parsed.lockfileVersion))
1218
1072
  return null;
1219
1073
  if (!parsed.packages || typeof parsed.packages !== 'object')
@@ -1234,16 +1088,14 @@ function writeLockfile(lockfilePath, specs, nodes) {
1234
1088
  resolved: node.tarballUrl,
1235
1089
  integrity: node.integrity,
1236
1090
  dependencies: Object.keys(node.dependencies).length > 0 ? node.dependencies : undefined,
1237
- // The EDGE KINDS, for the same reason the platform declaration is
1238
- // here: `computeOptionalFlags` runs on the lockfile path too and
1239
- // cannot recompute what the file does not carry (v4, header note
1240
- // invariant 3). Omitted when empty, which is most packages.
1091
+ // The EDGE KINDS, for the same reason the platform declaration is here:
1092
+ // `computeOptionalFlags` runs on the lockfile path too and cannot recompute
1093
+ // what the file does not carry (v4, header note invariant 3).
1241
1094
  optionalDependencies: Object.keys(node.optionalDependencies).length > 0 ? node.optionalDependencies : undefined,
1242
1095
  bin: node.bin,
1243
- // The DECLARATION, never the verdict: `inert` is per-host and
1244
- // deliberately absent from the file (a lockfile that recorded which
1245
- // packages the author's machine skipped would install a different
1246
- // tree for everyone else — the failure this design exists to avoid).
1096
+ // The DECLARATION, never the verdict: `inert` is per-host and deliberately
1097
+ // absent from the file, since a lockfile recording which packages the
1098
+ // author's machine skipped installs a different tree for everyone else.
1247
1099
  os: node.platform?.os,
1248
1100
  cpu: node.platform?.cpu,
1249
1101
  libc: node.platform?.libc,
@@ -1255,17 +1107,16 @@ function writeLockfile(lockfilePath, specs, nodes) {
1255
1107
  requested: [...specs],
1256
1108
  packages,
1257
1109
  };
1258
- // Atomic tmp+rename so a crash mid-write (or a reader racing the writer)
1259
- // can never observe a torn gjsify-lock.json — `--immutable` would
1260
- // otherwise hard-fail on the corrupt file with a misleading error.
1110
+ // Atomic tmp+rename so a crash mid-write, or a reader racing the writer, can never
1111
+ // observe a torn gjsify-lock.json — `--immutable` would otherwise hard-fail on the
1112
+ // corrupt file with a misleading error.
1261
1113
  atomicWriteStrict(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n');
1262
1114
  }
1263
1115
  /**
1264
- * Build the lockfile-preservation oracle: `name → every version currently
1265
- * pinned in the lockfile` (a name can appear at more than one install path /
1266
- * version when the tree nested a conflicting copy). Consulted during a resolve
1267
- * so unchanged deps keep their pinned version instead of bumping to the newest
1268
- * in-range match.
1116
+ * The lockfile-preservation oracle: `name → every version currently pinned` (a name
1117
+ * can appear at more than one path/version when the tree nested a conflicting copy).
1118
+ * Consulted during a resolve so unchanged deps keep their pinned version instead of
1119
+ * bumping to the newest in-range match.
1269
1120
  */
1270
1121
  function buildPreferredVersions(lockfile) {
1271
1122
  const byName = new Map();
@@ -1302,35 +1153,30 @@ function lockfileToNodes(lockfile) {
1302
1153
  return Object.entries(lockfile.packages).map(([installPath, entry]) => {
1303
1154
  const platform = { os: entry.os, cpu: entry.cpu, libc: entry.libc };
1304
1155
  return {
1305
- // Recover the package name from the path: the last segment is
1306
- // either `<name>` (unscoped) or `@scope/<name>` (scoped).
1307
1156
  name: nameFromInstallPath(installPath),
1308
1157
  version: entry.version,
1309
1158
  tarballUrl: entry.resolved,
1310
1159
  integrity: entry.integrity,
1311
1160
  installPath,
1312
1161
  dependencies: entry.dependencies ?? {},
1313
- // Empty for a pre-v4 entry, which is why such a file never
1314
- // short-circuits the resolve: with no edge kinds recorded, a name the
1315
- // publisher put in both blocks reads as required here (the pre-fix
1316
- // behaviour). `--immutable` is the one path that consumes it anyway —
1317
- // see READABLE_LOCKFILE_VERSIONS.
1162
+ // Empty for a pre-v4 entry, which is why such a file never short-circuits
1163
+ // the resolve: with no edge kinds recorded, a name the publisher put in both
1164
+ // blocks reads as required here. `--immutable` consumes it anyway — see
1165
+ // READABLE_LOCKFILE_VERSIONS.
1318
1166
  optionalDependencies: entry.optionalDependencies ?? {},
1319
1167
  bin: entry.bin,
1320
- // Rebuilt from the recorded declaration so `applyPlatformFilter`
1321
- // reaches the SAME verdict on this path as on the fresh-resolve
1322
- // path — for THIS host, which is generally not the host that wrote
1323
- // the file. A v2 entry has none of these fields, so nothing is
1324
- // filtered; that install behaves exactly as it did before the
1325
- // feature (see READABLE_LOCKFILE_VERSIONS).
1168
+ // Rebuilt from the recorded declaration so `applyPlatformFilter` reaches the
1169
+ // SAME verdict here as on the fresh-resolve path, for THIS host — generally
1170
+ // not the host that wrote the file. A v2 entry has none of these fields, so
1171
+ // nothing is filtered and the install behaves as it did pre-feature.
1326
1172
  platform: declaresPlatform(platform) ? platform : undefined,
1327
1173
  optional: entry.optional === true,
1328
1174
  };
1329
1175
  });
1330
1176
  }
1331
1177
  function nameFromInstallPath(installPath) {
1332
- // Last `node_modules/` boundary, then the rest is the package name
1333
- // (single segment unscoped, or `@scope/pkg` scoped).
1178
+ // Everything after the last `node_modules/` boundary is the package name — one
1179
+ // segment unscoped, `@scope/pkg` scoped.
1334
1180
  const idx = installPath.lastIndexOf('/node_modules/');
1335
1181
  const after = idx < 0 ? installPath.replace(/^node_modules\//, '') : installPath.slice(idx + '/node_modules/'.length);
1336
1182
  return after;
@@ -1343,10 +1189,9 @@ function lockfileMatchesRequest(lockfile, specs) {
1343
1189
  return a.every((v, i) => v === b[i]);
1344
1190
  }
1345
1191
  /**
1346
- * Human-readable diff between `lockfile.requested` and the live request.
1347
- * Returns null when the two sets are identical (the lockfile is in sync).
1348
- * Used by `--immutable` to surface exactly which deps drifted, so CI
1349
- * failures don't force the user to diff lockfile JSON by hand.
1192
+ * Human-readable diff between `lockfile.requested` and the live request, or null when
1193
+ * they are identical. `--immutable` uses it to name exactly which deps drifted, so a
1194
+ * CI failure does not force the user to diff lockfile JSON by hand.
1350
1195
  */
1351
1196
  function describeLockfileDrift(lockfile, specs) {
1352
1197
  const lockSet = new Set(lockfile.requested);
@@ -1368,20 +1213,14 @@ function describeLockfileDrift(lockfile, specs) {
1368
1213
  lines.push(` - ${removed.sort().join('\n - ')}`);
1369
1214
  return lines.join('\n');
1370
1215
  }
1371
- // Exported for unit-testing — keep the function name + signature
1372
- // stable, the install-backend itself still calls it via the local
1373
- // binding below. Internal API.
1216
+ // Internal API, exported for `install-backend-parse-spec.spec.ts`.
1374
1217
  export function parseSpec(raw) {
1375
- // Bare names without an explicit `@version` resolve to the `latest`
1376
- // dist-tag. This matches npm CLI behaviour (`npm install foo` →
1377
- // foo@latest) and — crucially — picks up prereleases when the
1378
- // publisher has tagged them as `latest`. Using semver `*` here
1379
- // would silently exclude any version with a `-` (rc, beta, alpha,
1380
- // …) suffix per semver §9 ("Pre-release versions have a lower
1381
- // precedence than the associated normal version"); ts-for-gir
1382
- // shipped only prereleases (4.0.0-rc.17 is the `latest` tag, no
1383
- // stable 4.x yet) and `*` was selecting the abandoned 3.3.0
1384
- // instead.
1218
+ // A bare name resolves to the `latest` DIST-TAG, matching `npm install foo`, and
1219
+ // that choice is what picks up prereleases the publisher tagged `latest`. Semver
1220
+ // `*` would silently exclude every version with a `-` suffix (semver §9: "Pre-
1221
+ // release versions have a lower precedence than the associated normal version");
1222
+ // ts-for-gir shipped only prereleases (`latest` = 4.0.0-rc.17, no stable 4.x) and
1223
+ // `*` selected the abandoned 3.3.0 instead.
1385
1224
  if (raw.startsWith('@')) {
1386
1225
  const slash = raw.indexOf('/');
1387
1226
  if (slash < 0)
@@ -1396,19 +1235,18 @@ export function parseSpec(raw) {
1396
1235
  return { name: raw, range: 'latest' };
1397
1236
  return { name: raw.slice(0, at), range: raw.slice(at + 1) || 'latest' };
1398
1237
  }
1399
- // Exported for unit-testing. Internal API.
1238
+ // Internal API, exported for `install-backend-parse-spec.spec.ts`.
1400
1239
  export function pickVersion(packument, range, preferred) {
1401
- // Lockfile preservation: if a version already pinned in the lockfile still
1402
- // satisfies the range and is still published, reuse it instead of bumping to
1403
- // the newest match. `preferred` is only ever a concrete semver (never a
1404
- // dist-tag), so this never hijacks a `latest`/`next` range.
1240
+ // Lockfile preservation: a pinned version that still satisfies the range and is
1241
+ // still published is reused rather than bumped. `preferred` is only ever a concrete
1242
+ // semver, never a dist-tag, so this cannot hijack a `latest`/`next` range.
1405
1243
  if (preferred && packument.versions[preferred] && satisfiesRange(preferred, range)) {
1406
1244
  return preferred;
1407
1245
  }
1408
1246
  // dist-tag fast path: `latest`, `next`, ...
1409
1247
  if (packument['dist-tags'][range])
1410
1248
  return packument['dist-tags'][range];
1411
- // Validate range early so a typo fails loudly.
1249
+ // Validate the range early so a typo fails loudly.
1412
1250
  let parsedRange;
1413
1251
  try {
1414
1252
  parsedRange = new Range(range);
@@ -1428,17 +1266,15 @@ export function pickVersion(packument, range, preferred) {
1428
1266
  return maxSatisfying(versions, parsedRange);
1429
1267
  }
1430
1268
  async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress) {
1431
- // Sort by install-path depth ascending so parents extract before
1432
- // children. Extracting a parent on top of an existing child would
1433
- // wipe out the child.
1269
+ // Depth ascending, so parents extract before children — extracting a parent on
1270
+ // top of an existing child wipes the child out.
1434
1271
  const queue = [...nodes].sort((a, b) => depth(a.installPath) - depth(b.installPath) || (a.installPath < b.installPath ? -1 : 1));
1435
1272
  const workers = [];
1436
1273
  const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
1437
1274
  progress?.beginPhase('download', queue.length);
1438
1275
  let completed = 0;
1439
- // Count how many nodes were already correctly materialised on disk and
1440
- // skipped (the npm "unchanged" set). Surfaced in the summary log so a warm
1441
- // install makes it obvious why the phase finished in seconds.
1276
+ // Nodes already correctly materialised on disk (npm's "unchanged" set). Surfaced in
1277
+ // the summary log so a warm install explains why the phase finished in seconds.
1442
1278
  let skipped = 0;
1443
1279
  const tickProgress = (node, wasSkipped) => {
1444
1280
  completed++;
@@ -1451,9 +1287,8 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
1451
1287
  name: `${node.name}@${node.version}`,
1452
1288
  });
1453
1289
  };
1454
- // Parents (depth 1) are extracted serially first to avoid concurrent
1455
- // `rm -rf` + extract races with their children. Once depth-1 is done,
1456
- // depths >=2 run with full concurrency.
1290
+ // Depth-1 parents extract serially first, to avoid `rm -rf` + extract racing their
1291
+ // own children; depths ≥2 then run at full concurrency.
1457
1292
  let cursor = 0;
1458
1293
  const depth1End = queue.findIndex((n) => depth(n.installPath) > 1);
1459
1294
  const splitAt = depth1End < 0 ? queue.length : depth1End;
@@ -1471,13 +1306,11 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
1471
1306
  for (let i = 0; i < concurrency; i++) {
1472
1307
  workers.push((async () => {
1473
1308
  while (true) {
1474
- // Honour an aborted overall-install budget inside the pool.
1475
- // Without this, a fired --timeout (or Ctrl-C) only aborts
1476
- // the NETWORK fetches; a tree that is mostly cache-hits /
1477
- // already-extracted keeps churning the extract loop to
1478
- // completion, so the install never actually stops when
1479
- // asked to (a contributor to the observed "it never
1480
- // completed; I killed it" hang).
1309
+ // Honour an aborted overall-install budget inside the pool. Without
1310
+ // this a fired --timeout (or Ctrl-C) aborts only the NETWORK fetches,
1311
+ // so a mostly-cached tree churns the extract loop to completion and
1312
+ // the install never stops when asked — one contributor to the observed
1313
+ // "it never completed; I killed it" hang.
1481
1314
  if (signal?.aborted)
1482
1315
  throw abortError(signal);
1483
1316
  const idx = cursor++;
@@ -1498,20 +1331,17 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress
1498
1331
  }
1499
1332
  }
1500
1333
  /**
1501
- * Is `name@version` already correctly materialised at `dest`? Reads
1502
- * `<dest>/package.json` and returns true iff its `name` AND `version` match the
1503
- * resolved node exactly. This is the npm/yarn/pnpm "unchanged node" check — an
1504
- * already-present, correct copy is skipped instead of being `rm`-ed and
1505
- * re-extracted.
1334
+ * Is `name@version` already correctly materialised at `dest`? The npm/yarn/pnpm
1335
+ * "unchanged node" check: an already-present, correct copy is skipped instead of being
1336
+ * `rm`-ed and re-extracted.
1506
1337
  *
1507
- * Conservative by design: a missing / unreadable / unparseable package.json, or
1508
- * ANY name/version mismatch, returns false so the caller falls through to the
1509
- * full rm + extract. The only thing this fast-paths is the exact-match case,
1510
- * which is the overwhelming majority of nodes on a warm re-install (the whole
1511
- * resolved tree minus the genuinely-new subtree). Skipping the re-extract is
1512
- * what turns a warm `gjsify install` on a 2000+-package workspace from a
1513
- * many-minute re-extract of every tarball (each gunzip routed through GJS's
1514
- * Gio.ZlibDecompressor on the single GLib main loop) into a near-instant no-op.
1338
+ * Conservative by design — a missing/unreadable/unparseable package.json or ANY
1339
+ * name/version mismatch returns false, so the caller falls through to the full rm +
1340
+ * extract. Only the exact-match case is fast-pathed, which is the overwhelming
1341
+ * majority of nodes on a warm re-install, and that is what turns a warm `gjsify
1342
+ * install` on a 2000+-package workspace from a many-minute re-extract of every tarball
1343
+ * (each gunzip routed through GJS's Gio.ZlibDecompressor on the single GLib main loop)
1344
+ * into a near-instant no-op.
1515
1345
  */
1516
1346
  function isAlreadyExtracted(dest, node) {
1517
1347
  const manifestPath = path.join(dest, 'package.json');
@@ -1524,11 +1354,9 @@ function isAlreadyExtracted(dest, node) {
1524
1354
  }
1525
1355
  try {
1526
1356
  const parsed = JSON.parse(raw);
1527
- // Match version exactly. Match name too when present — a stale dir from
1528
- // a previous resolve could hold a different package at the same path
1529
- // (e.g. a nested placement that moved), in which case the version alone
1530
- // could coincidentally collide. A missing `name` (rare, malformed) is
1531
- // tolerated as long as the version matches.
1357
+ // Name too, when present: a stale dir from a previous resolve can hold a
1358
+ // different package at the same path (a nested placement that moved), where the
1359
+ // version alone could coincidentally collide. A missing `name` is tolerated.
1532
1360
  if (parsed.version !== node.version)
1533
1361
  return false;
1534
1362
  if (typeof parsed.name === 'string' && parsed.name !== node.name)
@@ -1546,38 +1374,30 @@ function isAlreadyExtracted(dest, node) {
1546
1374
  */
1547
1375
  async function extractOne(node, prefix, npmrc, log, signal) {
1548
1376
  const dest = path.join(prefix, node.installPath);
1549
- // Defense-in-depth against the workspace-source-wipe data-loss bug:
1550
- // every extractable node MUST land inside a `node_modules/` directory.
1551
- // The resolver only ever produces `installPath`s of that shape, so this
1552
- // can only fail if a workspace package leaked into the fetch/extract
1553
- // queue (the root cause fixed in `workspaceInstall`). Refusing here means
1554
- // a regression in the resolver can never again `rmSync` a working-tree
1555
- // source dir — the realpath check additionally rejects a `dest` that
1556
- // resolves THROUGH a symlink into a directory outside node_modules.
1377
+ // Defence-in-depth against the workspace-source-wipe data-loss bug: this can only
1378
+ // fail if a workspace package leaked into the fetch/extract queue (the root cause
1379
+ // fixed in `workspaceInstall`), and refusing here means a resolver regression can
1380
+ // never again `rmSync` a working-tree source dir.
1557
1381
  assertNodeModulesDest(dest, node);
1558
- // Idempotent fast-path: the package is already extracted at the resolved
1559
- // version. Skip the cache read + rm + re-extract entirely — this is the
1560
- // npm/yarn/pnpm default (only added/changed nodes are written), and it is
1561
- // the dominant cost on a warm re-install. Force a full re-extract with
1562
- // GJSIFY_INSTALL_FORCE_EXTRACT=1 (debug / corrupted-tree recovery).
1382
+ // Idempotent fast-path — skip the cache read + rm + re-extract for a node already
1383
+ // extracted at the resolved version. The npm/yarn/pnpm default (only added/changed
1384
+ // nodes are written) and the dominant cost on a warm re-install.
1385
+ // GJSIFY_INSTALL_FORCE_EXTRACT=1 forces a full re-extract for corrupted-tree recovery.
1563
1386
  if (process.env.GJSIFY_INSTALL_FORCE_EXTRACT !== '1' && isAlreadyExtracted(dest, node)) {
1564
1387
  log('up-to-date: %s@%s (already extracted at %s)', node.name, node.version, node.installPath);
1565
1388
  return true;
1566
1389
  }
1567
- // Hit the content-addressable cache before touching the network.
1568
- // Tarballs are immutable per SRI integrity, so a hash hit means the
1569
- // cached bytes are byte-identical to whatever the registry would
1570
- // return — no need to verify by re-download.
1390
+ // Content-addressable cache before the network: tarballs are immutable per SRI
1391
+ // integrity, so a hash hit is byte-identical to what the registry would return and
1392
+ // needs no verifying re-download.
1571
1393
  let bytes = getCachedTarball(node.integrity);
1572
1394
  if (bytes) {
1573
1395
  log('cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
1574
1396
  }
1575
1397
  else if ((bytes = getForeignCachedTarball(node.integrity))) {
1576
- // Second-chance: npm's cacache content store (same SRI key). A user
1577
- // who has run `npm install` before already has the tarball on disk —
1578
- // read it instead of the network. Write it through to OUR store so
1579
- // the next `gjsify install` is a first-class hit even if npm later
1580
- // prunes its cache.
1398
+ // Second chance: npm's cacache content store, same SRI key — anyone who has run
1399
+ // `npm install` already has the tarball on disk. Written through to OUR store so
1400
+ // the next `gjsify install` hits first-class even if npm prunes its cache.
1581
1401
  log('npm-cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
1582
1402
  putCachedTarball(node.integrity, bytes);
1583
1403
  }
@@ -1591,35 +1411,31 @@ async function extractOne(node, prefix, npmrc, log, signal) {
1591
1411
  log('tarball %s@%s: retry %d after %dms (%s)', node.name, node.version, attempt, delayMs, errMsg(error));
1592
1412
  },
1593
1413
  });
1594
- // Best-effort cache write — failures are swallowed by `putCachedTarball`
1595
- // so a read-only HOME / out-of-disk cache volume doesn't break the install.
1414
+ // Best-effort: `putCachedTarball` swallows failures so a read-only HOME or an
1415
+ // out-of-disk cache volume does not break the install.
1596
1416
  putCachedTarball(node.integrity, bytes);
1597
1417
  }
1598
- // `rmWithRetry` (not a bare rmSync): on Windows the delete of a freshly
1599
- // extracted tree fails with EBUSY/EPERM while any handle is still open, and
1600
- // stays pending afterwards — so the mkdirSync on the next line would hit
1601
- // EPERM/ENOTEMPTY. No-op cost on POSIX. This runs for EVERY package.
1418
+ // `rmWithRetry`, not a bare rmSync: on Windows deleting a freshly extracted tree
1419
+ // fails EBUSY/EPERM while any handle is open and stays pending afterwards, so the
1420
+ // mkdirSync below would hit EPERM/ENOTEMPTY. No-op cost on POSIX.
1602
1421
  rmWithRetry(dest);
1603
1422
  fs.mkdirSync(dest, { recursive: true });
1604
1423
  await extractWithStallGuard(bytes, dest, node, signal);
1605
1424
  return false;
1606
1425
  }
1607
1426
  /**
1608
- * Cap over `extractTarball`, which takes no AbortSignal and no timeout of its
1609
- * own. A single tarball extract completes in well under a second even for
1610
- * large packages; anything past `EXTRACT_STALL_MS` means the decompress/write
1611
- * wedged — the classic dropped Gio stream close-event under GJS, a
1612
- * never-settling await that nothing downstream can break. Unlike a network
1613
- * fetch (30s per-request budget in @gjsify/npm-registry), extraction has no
1614
- * self-healing path, so one stuck package used to hang the whole install at
1615
- * 0% CPU indefinitely. We race the extract against (a) a stall timer and (b)
1616
- * the overall-install abort signal, so a wedge surfaces as an actionable
1617
- * error and the install fails fast instead of hanging.
1427
+ * Cap over `extractTarball`, which takes no AbortSignal and no timeout of its own. One
1428
+ * extract completes in well under a second even for large packages, so anything past
1429
+ * `EXTRACT_STALL_MS` means the decompress/write wedged — the classic dropped Gio stream
1430
+ * close-event under GJS, a never-settling await nothing downstream can break. Unlike a
1431
+ * network fetch (30s per-request budget in @gjsify/npm-registry) extraction has no
1432
+ * self-healing path, so one stuck package used to hang the whole install at 0% CPU
1433
+ * indefinitely. Racing the extract against a stall timer AND the overall-install abort
1434
+ * signal turns that into an actionable error.
1618
1435
  *
1619
- * Rejecting the race does not tear down the underlying (leaked) extract
1620
- * promise — the process exit that follows reclaims its resources. Override /
1621
- * disable the cap via `GJSIFY_EXTRACT_STALL_MS` (0 disables the timer; the
1622
- * signal race still applies).
1436
+ * Rejecting the race does not tear down the leaked extract promise; the process exit
1437
+ * that follows reclaims it. `GJSIFY_EXTRACT_STALL_MS=0` disables the timer, leaving the
1438
+ * signal race in place.
1623
1439
  */
1624
1440
  const EXTRACT_STALL_MS = (() => {
1625
1441
  const raw = process.env.GJSIFY_EXTRACT_STALL_MS;
@@ -1629,7 +1445,7 @@ const EXTRACT_STALL_MS = (() => {
1629
1445
  return Number.isFinite(n) && n >= 0 ? n : 120_000;
1630
1446
  })();
1631
1447
  async function extractWithStallGuard(bytes, dest, node, signal) {
1632
- // Fast path: nothing to guard against (timer disabled + no signal).
1448
+ // Nothing to guard against: timer disabled and no signal.
1633
1449
  if (EXTRACT_STALL_MS === 0 && !signal) {
1634
1450
  await extractTarball(bytes, dest);
1635
1451
  return;
@@ -1638,13 +1454,11 @@ async function extractWithStallGuard(bytes, dest, node, signal) {
1638
1454
  let onAbort;
1639
1455
  try {
1640
1456
  await new Promise((resolve, reject) => {
1641
- // Test seam (matches GJSIFY_INSTALL_FORCE_EXTRACT precedent): simulate
1642
- // an extract that never settles, so a suite can prove the stall timer
1643
- // + abort-signal race actually break the wedge. Never call
1644
- // extractTarball — leave the inner promise pending forever.
1457
+ // Test seam: simulate an extract that never settles, so a suite can prove
1458
+ // the stall timer + abort-signal race actually break the wedge.
1645
1459
  if (process.env.GJSIFY_TEST_HANG_EXTRACT !== '1') {
1646
- // extractTarball keeps running if we lose the race; its settlement
1647
- // (resolve or reject) is a no-op on the already-settled promise.
1460
+ // extractTarball keeps running if it loses the race; its settlement is
1461
+ // then a no-op on the already-settled promise.
1648
1462
  extractTarball(bytes, dest).then(() => resolve(), reject);
1649
1463
  }
1650
1464
  if (EXTRACT_STALL_MS > 0) {
@@ -1689,19 +1503,16 @@ function abortError(signal) {
1689
1503
  return err;
1690
1504
  }
1691
1505
  /**
1692
- * Guard: a tarball may only be extracted into a `node_modules/` directory.
1506
+ * Guard: a tarball may only be extracted into a `node_modules/` directory. Two checks,
1507
+ * both against ever wiping a working-tree source dir (the
1508
+ * install-deletes-workspace-sources data-loss bug):
1693
1509
  *
1694
- * Two checks, both belt-and-suspenders against ever wiping a working-tree
1695
- * source dir (the install-deletes-workspace-sources data-loss bug):
1696
- *
1697
- * 1. The logical `installPath` must contain a `node_modules` path segment.
1698
- * The resolver always produces such paths; a workspace package that
1699
- * slipped into the queue would not.
1700
- * 2. If `dest` already exists and resolves (via symlink) to a directory
1701
- * whose REAL path is not under a `node_modules/` segment, refuse. This
1702
- * catches the case where `node_modules/<name>` is a symlink to a
1703
- * workspace's source tree — `rmSync(dest, { recursive: true })` would
1704
- * then delete the link's target contents.
1510
+ * 1. The logical `installPath` must contain a `node_modules` segment. The resolver
1511
+ * always produces such paths; a workspace package that slipped in would not.
1512
+ * 2. An existing `dest` whose REAL path (after symlinks) is not under a
1513
+ * `node_modules/` segment is refused — that is `node_modules/<name>` symlinked to
1514
+ * a workspace source tree, where `rmSync(dest, { recursive: true })` deletes the
1515
+ * link's target contents.
1705
1516
  */
1706
1517
  function assertNodeModulesDest(dest, node) {
1707
1518
  const segments = dest.split(path.sep);
@@ -1715,8 +1526,7 @@ function assertNodeModulesDest(dest, node) {
1715
1526
  real = fs.realpathSync(dest);
1716
1527
  }
1717
1528
  catch {
1718
- // `dest` doesn't exist yet (fresh install) — nothing to resolve, the
1719
- // logical-path check above is sufficient.
1529
+ // `dest` does not exist yet (fresh install); the logical-path check suffices.
1720
1530
  return;
1721
1531
  }
1722
1532
  const realSegments = real.split(path.sep);
@@ -1727,8 +1537,7 @@ function assertNodeModulesDest(dest, node) {
1727
1537
  }
1728
1538
  }
1729
1539
  function depth(installPath) {
1730
- // Count `node_modules/` segments to know nesting depth.
1731
- // `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, etc.
1540
+ // `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, …
1732
1541
  return installPath.split('/node_modules/').length;
1733
1542
  }
1734
1543
  /**
@@ -1750,20 +1559,18 @@ function readShebang(file) {
1750
1559
  /**
1751
1560
  * Materialise ONE `node_modules/.bin/<name>` entry for `targetAbs`.
1752
1561
  *
1753
- * Exported (and platform-injectable) so the Windows branch is unit-tested from
1754
- * a Linux host — the file layout and shim contents are asserted there; only the
1755
- * `symlinkSync`/`CreateSymbolicLink` syscall behaviour itself is OS-bound.
1562
+ * Exported and platform-injectable so the Windows branch is unit-tested from a Linux
1563
+ * host; only the `symlinkSync`/`CreateSymbolicLink` syscall itself is OS-bound.
1756
1564
  *
1757
- * POSIX: a relative symlink, with a copy as the fallback (some filesystems and
1758
- * container overlays reject symlinks).
1565
+ * POSIX: a relative symlink, falling back to a copy (some filesystems and container
1566
+ * overlays reject symlinks).
1759
1567
  *
1760
- * Windows: `.bin/<name>` is not on `PATHEXT` and Windows has no shebang
1761
- * handling, so a symlink OR a copy of a `#!/usr/bin/env node` script is
1762
- * unrunnable — and a *file* symlink additionally needs elevation or Developer
1763
- * Mode, so the previous code reliably fell into its copy fallback and produced
1764
- * a dead entry. npm solves this with three sibling files (`<name>` for
1765
- * git-bash/MSYS/WSL, `<name>.cmd` for cmd.exe, `<name>.ps1` for pwsh); we write
1766
- * the same set from the same templates. See `./bin-shim.ts`.
1568
+ * Windows: `.bin/<name>` is not on `PATHEXT` and Windows has no shebang handling, so a
1569
+ * symlink OR a copy of a `#!/usr/bin/env node` script is unrunnable — and a *file*
1570
+ * symlink additionally needs elevation or Developer Mode, so the previous code reliably
1571
+ * fell into its copy fallback and produced a dead entry. npm's answer is three sibling
1572
+ * files (`<name>` for git-bash/MSYS/WSL, `<name>.cmd` for cmd.exe, `<name>.ps1` for
1573
+ * pwsh); the same set is written from the same templates (`./bin-shim.ts`).
1767
1574
  */
1768
1575
  export function writeBinEntry(opts) {
1769
1576
  const { binDir, binName, targetAbs, gjs, nodeFallbackAbs } = opts;
@@ -1771,8 +1578,8 @@ export function writeBinEntry(opts) {
1771
1578
  const linkPath = path.join(binDir, binName);
1772
1579
  if (gjs) {
1773
1580
  const isBundle = isGjsBundlePath(targetAbs);
1774
- // Replace the whole sibling set: a previous install may have written a
1775
- // plain symlink (or npm's three-file shim) under this very name.
1581
+ // Replace the whole sibling set — a previous install may have written a plain
1582
+ // symlink, or npm's three-file shim, under this very name.
1776
1583
  for (const stale of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`])
1777
1584
  rmWithRetry(stale);
1778
1585
  fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble: gjs.envPreamble, isGjsBundle: isBundle, nodeFallbackAbs }));
@@ -1783,9 +1590,9 @@ export function writeBinEntry(opts) {
1783
1590
  /* inert on Windows; harmless under WSL/MSYS */
1784
1591
  }
1785
1592
  if (platform === 'win32' && isBundle) {
1786
- // No dedicated DLL-search variable on Windows: LoadLibrary searches
1787
- // PATH, and GLib's search-path separator is `;` there too — so both
1788
- // variables take the same `;`-joined value.
1593
+ // No dedicated DLL-search variable on Windows: LoadLibrary searches PATH, and
1594
+ // GLib's search-path separator is `;` there too, so both variables take the
1595
+ // same `;`-joined value.
1789
1596
  const joined = gjs.prebuildDirs.join(';');
1790
1597
  const prependEnv = gjs.prebuildDirs.length > 0 ? { GI_TYPELIB_PATH: joined, PATH: joined } : {};
1791
1598
  const shims = buildLauncherShims({
@@ -1830,15 +1637,14 @@ export function writeBinEntry(opts) {
1830
1637
  }
1831
1638
  }
1832
1639
  async function linkBins(nodes, prefix, log) {
1833
- // Only root-level packages publish bins into the top-level
1834
- // `node_modules/.bin/`. Nested-package bins are addressable by their
1835
- // direct dependents through the nested .bin (npm matches this) — we
1836
- // omit nested-bin linking for now since no consumer of the install
1837
- // backend depends on it (gjsify's own use cases all hit root bins).
1640
+ // Only root-level packages publish bins into the top-level `node_modules/.bin/`.
1641
+ // Nested-bin linking is omitted because no consumer of this backend needs it — a
1642
+ // nested package's bins are addressable by its direct dependents through the nested
1643
+ // .bin, which is what npm does too.
1838
1644
  const binDir = path.join(prefix, 'node_modules', '.bin');
1839
1645
  let created = 0;
1840
- // Computed on first use only: the scan walks the whole prefix, and most
1841
- // installs contain no package declaring `gjsify.bin` at all.
1646
+ // Computed on first use: the scan walks the whole prefix, and most installs contain
1647
+ // no package declaring `gjsify.bin` at all.
1842
1648
  let gjsEnv;
1843
1649
  const gjsEnvForPrefix = () => {
1844
1650
  if (gjsEnv === undefined) {
@@ -1851,11 +1657,11 @@ async function linkBins(nodes, prefix, log) {
1851
1657
  if (depth(node.installPath) !== 1)
1852
1658
  continue;
1853
1659
  const pkgDir = path.join(prefix, node.installPath);
1854
- // The INSTALLED manifest is the source of truth here, not the packument
1855
- // the resolver carried: `gjsify.bin` is a gjsify-specific field npm's
1856
- // abbreviated packument does not include, so a packument-only read can
1857
- // never see it. Falls back to the resolved npm `bin` when the extracted
1858
- // manifest is unreadable, which keeps the previous behaviour intact.
1660
+ // The INSTALLED manifest is the source of truth here, not the packument the
1661
+ // resolver carried: `gjsify.bin` is a gjsify-specific field npm's abbreviated
1662
+ // packument does not include, so a packument-only read structurally cannot see
1663
+ // it. Falls back to the resolved npm `bin` when the extracted manifest is
1664
+ // unreadable.
1859
1665
  const manifest = readInstalledManifest(pkgDir) ?? (node.bin === undefined ? null : { bin: node.bin });
1860
1666
  if (manifest === null)
1861
1667
  continue;
@@ -1873,8 +1679,8 @@ async function linkBins(nodes, prefix, log) {
1873
1679
  catch {
1874
1680
  /* best effort */
1875
1681
  }
1876
- // Only a fallback that EXISTS is worth writing: a launcher naming a
1877
- // missing Node entry would trade exit 127 for a different exit 127.
1682
+ // Only a fallback that EXISTS is worth writing: a launcher naming a missing
1683
+ // Node entry trades exit 127 for a different exit 127.
1878
1684
  const fallbackRel = picked.nodeFallback?.get(binName);
1879
1685
  const fallbackAbs = fallbackRel === undefined ? undefined : path.join(pkgDir, fallbackRel);
1880
1686
  writeBinEntry({
@@ -1891,12 +1697,10 @@ async function linkBins(nodes, prefix, log) {
1891
1697
  log('bin: linked %d entry(ies) under .bin/', created);
1892
1698
  }
1893
1699
  /**
1894
- * Read an extracted package's `package.json`.
1895
- *
1896
- * Returns `null` rather than throwing: a node can legitimately have no manifest
1897
- * on disk yet (an optional dependency skipped for this platform), and bin
1898
- * linking is not the place to fail an otherwise-complete install. The caller
1899
- * falls back to the resolved npm `bin`.
1700
+ * Read an extracted package's `package.json`, returning `null` rather than throwing: a
1701
+ * node can legitimately have no manifest on disk (an optional dependency skipped for
1702
+ * this platform), and bin linking is not the place to fail an otherwise-complete
1703
+ * install. The caller falls back to the resolved npm `bin`.
1900
1704
  */
1901
1705
  function readInstalledManifest(pkgDir) {
1902
1706
  try {
@@ -1908,14 +1712,11 @@ function readInstalledManifest(pkgDir) {
1908
1712
  }
1909
1713
  }
1910
1714
  /**
1911
- * `fs.rmSync(..., { force: true })` with the Windows retry npm/rimraf apply.
1912
- *
1913
- * On Windows a delete fails with `EBUSY`/`EPERM` while any process holds a
1914
- * handle (antivirus scan, an editor, a still-terminating child), and the
1915
- * deletion stays *pending* until the last handle closes — so an immediately
1916
- * following create can hit `EPERM`/`ENOTEMPTY`. Node implements the retry loop
1917
- * for us behind `maxRetries`; it is a no-op on POSIX, where the first attempt
1918
- * always succeeds.
1715
+ * `fs.rmSync(..., { force: true })` with the Windows retry npm/rimraf apply: there a
1716
+ * delete fails `EBUSY`/`EPERM` while any process holds a handle (antivirus scan, an
1717
+ * editor, a still-terminating child) and stays *pending* until the last handle closes,
1718
+ * so an immediately following create can hit `EPERM`/`ENOTEMPTY`. Node implements the
1719
+ * retry loop behind `maxRetries`; a no-op on POSIX, where the first attempt succeeds.
1919
1720
  */
1920
1721
  function rmWithRetry(target) {
1921
1722
  fs.rmSync(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
@@ -1928,11 +1729,9 @@ async function loadNpmrc(opts) {
1928
1729
  authTokens: {},
1929
1730
  basicAuth: {},
1930
1731
  };
1931
- // Layered .npmrc lookup (most-specific wins): home → project (cwd's
1932
- // prefix). npm itself merges through `XDG_CONFIG_HOME/npm/npmrc` and a
1933
- // workspace-root one too; the gjsify project-local case is what users
1934
- // hit most often (mock-registry tests, scoped-registry overrides), so
1935
- // we cover that explicitly.
1732
+ // Layered .npmrc lookup, most-specific wins: home → project (the prefix). npm also
1733
+ // merges `XDG_CONFIG_HOME/npm/npmrc` and a workspace-root file; the project-local
1734
+ // case is the one users hit (mock-registry tests, scoped-registry overrides).
1936
1735
  for (const candidate of [path.join(home, '.npmrc'), path.join(opts.prefix, '.npmrc')]) {
1937
1736
  if (!fs.existsSync(candidate))
1938
1737
  continue;
@@ -1944,11 +1743,11 @@ async function loadNpmrc(opts) {
1944
1743
  console.warn(`gjsify install: ignoring malformed ${candidate}: ${e.message}`);
1945
1744
  }
1946
1745
  }
1947
- // env-var override (npm convention: `npm_config_registry`).
1746
+ // npm convention.
1948
1747
  const envRegistry = process.env.npm_config_registry;
1949
1748
  if (envRegistry)
1950
1749
  parsed.registry = envRegistry;
1951
- // Explicit caller-provided registry trumps everything else.
1750
+ // An explicit caller-provided registry trumps everything else.
1952
1751
  if (opts.registry)
1953
1752
  parsed.registry = opts.registry;
1954
1753
  return parsed;