@gjsify/cli 0.27.1 → 0.29.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.
@@ -29,6 +29,7 @@
29
29
  import { readdirSync, existsSync } from 'node:fs';
30
30
  import { join, resolve } from 'node:path';
31
31
  import { readPackageJson } from './pkg-json.js';
32
+ import { splitSearchPath, systemGiLibraryDirs } from './system-gi.js';
32
33
  /**
33
34
  * Map a Node `process.arch` value to the uname-style spelling the Vala/meson
34
35
  * bridges used to stage into (`prebuilds/linux-x86_64/`, the output of
@@ -558,6 +559,18 @@ export function libraryPathVar(platform) {
558
559
  return { name: 'PATH', separator: ';' };
559
560
  return { name: 'LD_LIBRARY_PATH', separator: ':' };
560
561
  }
562
+ /**
563
+ * dyld's *fallback* search path — consulted only AFTER the normal search
564
+ * (rpath / absolute name / dyld cache) has failed.
565
+ *
566
+ * Deliberately NOT part of {@link libraryPathVar}, which answers a different
567
+ * question: that one names the variable that OVERRIDES resolution for every
568
+ * dylib in the process (the right home for a prebuild we ship and want used),
569
+ * this one names the variable that fills in what nothing else could find (the
570
+ * right home for the host's own libdirs). Mixing the two roles is the mistake
571
+ * `buildNativeEnv` documents at length.
572
+ */
573
+ const DYLD_FALLBACK_VAR = 'DYLD_FALLBACK_LIBRARY_PATH';
561
574
  /**
562
575
  * Build the typelib + shared-library search-path environment for the detected
563
576
  * native packages. Prepends the new paths to any existing values.
@@ -569,6 +582,40 @@ export function libraryPathVar(platform) {
569
582
  * (a stock Windows env block uses `Path`) — Windows env names are
570
583
  * case-insensitive, but a plain JS object is not, and `{...process.env,
571
584
  * PATH: …}` would hand a child process two competing entries.
585
+ *
586
+ * ── DARWIN ALSO GETS THE *HOST'S* GI LIBDIRS ─────────────────────────────────
587
+ *
588
+ * Everything above is about the prebuilds gjsify itself ships. On macOS there is
589
+ * a second, independent gap that has nothing to do with gjsify: a typelib names
590
+ * its library by BARE LEAF (`libgtk-4.1.dylib`), and dyld carries neither
591
+ * `/usr/local/lib` nor `/opt/homebrew/lib` in its default fallback path.
592
+ * Homebrew's `gjs` has an rpath into GLIB's keg only, so it resolves no other
593
+ * keg's leaves — measured: bare `gjs -c "imports.gi.Gtk; Gtk.init()"`, no gjsify
594
+ * in the process, dies on that dlopen (full trace in `utils/system-gi.ts`). This
595
+ * is the ONE place `gjsify run`, `showcase`, `storybook`, `tsc` and `info` all
596
+ * pass through, so it is where the child's launch env is repaired.
597
+ *
598
+ * Two properties are load-bearing:
599
+ *
600
+ * 1. **`DYLD_FALLBACK_LIBRARY_PATH`, never `DYLD_LIBRARY_PATH`.** The override
601
+ * variable wins by LEAF for every dylib in the process, including ones the
602
+ * host already resolved correctly — pointing it at a whole system libdir is
603
+ * how you replace a linked-against library with a same-named different one.
604
+ * The fallback is consulted only after the normal search fails, which is
605
+ * exactly the "nothing else found it" case. Consequence, and it is the
606
+ * intended precedence: gjsify's own prebuilds keep priority through the
607
+ * override variable, the system stack fills in behind it.
608
+ * 2. **Our dirs go FIRST, an inherited fallback is kept BEHIND them, and
609
+ * `/usr/lib` is appended when there is none.** Setting the variable
610
+ * REPLACES dyld's own default fallback list, so writing only our dirs would
611
+ * silently remove `/usr/lib` from the search — a host that exported the
612
+ * variable itself already carries whatever tail it wants. Same composition
613
+ * as node-gi's re-exec.
614
+ *
615
+ * Emitted even when `packages` is empty: the gap is in the host's loader, not in
616
+ * a gjsify prebuild, so a plain `gjsify run script.gjs.js` that touches GTK needs
617
+ * it just as much. Empty off darwin — `ld.so`'s configured cache already resolves
618
+ * these leaves on Linux, and Windows re-reads `PATH` per `LoadLibrary`.
572
619
  */
573
620
  export function buildNativeEnv(packages, opts = {}) {
574
621
  const platform = opts.platform ?? process.platform;
@@ -588,5 +635,14 @@ export function buildNativeEnv(packages, opts = {}) {
588
635
  GI_TYPELIB_PATH: prepend(env['GI_TYPELIB_PATH'], giSeparator),
589
636
  };
590
637
  out[libVarKey] = prepend(env[libVarKey], libSeparator);
638
+ // The host's own GI libdirs — see the block comment above. `systemGiLibraryDirs`
639
+ // is itself `[]` off darwin, so this is a no-op elsewhere without a second
640
+ // platform test; the variable is only WRITTEN when there is something to put in
641
+ // it, so a Mac with no GI stack installed is left byte-unchanged.
642
+ const systemDirs = (opts.systemGiDirs ?? systemGiLibraryDirs)({ platform, env });
643
+ if (systemDirs.length > 0) {
644
+ const inherited = splitSearchPath(env[DYLD_FALLBACK_VAR]);
645
+ out[DYLD_FALLBACK_VAR] = [...systemDirs, ...(inherited.length > 0 ? inherited : ['/usr/lib'])].join(':');
646
+ }
591
647
  return out;
592
648
  }
@@ -5,7 +5,13 @@ interface ParsedSpec {
5
5
  name: string;
6
6
  range: string;
7
7
  }
8
- interface ResolvedNode {
8
+ /**
9
+ * One placed package in the resolved tree. Exported as a TYPE only: it is the
10
+ * graph shape {@link computeOptionalFlags} and {@link applyPlatformFilter} take,
11
+ * so a spec can inject a tree by hand instead of driving a whole install to reach
12
+ * them. Nothing outside this module constructs one for real.
13
+ */
14
+ export interface ResolvedNode {
9
15
  /** Package name (e.g. `@gjsify/cli`, `lodash`). */
10
16
  name: string;
11
17
  version: string;
@@ -133,6 +139,78 @@ export declare function applyPlatformFilter(nodes: ResolvedNode[], target: Platf
133
139
  * detection — no script is run, so node-free-ness is preserved.
134
140
  */
135
141
  export declare function warnMissingNativeBuilds(nodes: ResolvedNode[], prefix: string, log: Logger): void;
142
+ /**
143
+ * A node's REQUIRED dependency edges: its `dependencies`, minus every name its
144
+ * `optionalDependencies` also lists. The one place that decides what kind an edge
145
+ * is — both the resolve walk and the optionality fixpoint read it, so the two
146
+ * cannot drift.
147
+ *
148
+ * THE SUBTRACTION IS npm's RULE, not a policy of ours: "entries in
149
+ * optionalDependencies will override entries of the same name in dependencies, so
150
+ * it's usually best to only put in one place" (npm's package.json docs). Measured
151
+ * on `@parcel/rust@2.16.4`, which lists all eight per-platform napi packages in
152
+ * both blocks: `npm install` exits 0, installs `@parcel/rust-linux-x64-gnu` only,
153
+ * and writes `"optional": true` for all eight in its `package-lock.json`.
154
+ *
155
+ * WHY IT IS NOT MERELY A DEDUP: the kind decides what an incompatible
156
+ * `os`/`cpu`/`libc` MEANS (`applyPlatformFilter` — fatal vs. inert) and whether a
157
+ * failed resolve is tolerated. Reading `dependencies` alone therefore does not
158
+ * just visit a name twice, it promotes a foreign-platform binary to a required
159
+ * dependency and fails installs npm completes.
160
+ *
161
+ * The inverse direction is deliberately NOT symmetric: a name in
162
+ * `optionalDependencies` alone is optional (no `dependencies` entry to override),
163
+ * which is what {@link resolveDeps}'s second queue loop covers.
164
+ */
165
+ export declare function requiredDepEntries(node: Pick<ResolvedNode, 'dependencies' | 'optionalDependencies'>): [string, string][];
166
+ /**
167
+ * Recompute every node's `optional` flag as a FIXPOINT over the placed graph.
168
+ * The only writer of the final flag; whatever the nodes arrive carrying is an
169
+ * input nothing checked (a BFS forward guess on the resolve path, a value read
170
+ * back out of a file on the lockfile path) and is overwritten unconditionally.
171
+ *
172
+ * DEFINITION: a node is REQUIRED iff it is reachable from `requiredNames`
173
+ * through REQUIRED edges alone ({@link requiredDepEntries} — `dependencies` minus
174
+ * the names `optionalDependencies` overrides); every other node is optional.
175
+ * Optionality is therefore INHERITED — a plain dependency OF an optional package
176
+ * is still optional — which is what npm's `optionalSet` computes.
177
+ *
178
+ * WHY A FIXPOINT AND NOT A ONE-SHOT PROMOTION AT THE REUSE SITE (the reviewed
179
+ * defect): the walk queues a node's own dep edges at the moment it is placed,
180
+ * carrying the optionality it had THEN. Promoting the node later leaves its
181
+ * children behind, so the answer depends on which edge BFS happened to traverse
182
+ * first — a genuinely required transitive dep reached first through an optional
183
+ * edge stayed flagged optional, was persisted that way, and was then silently
184
+ * skipped by the platform filter on every machine instead of raising
185
+ * EBADPLATFORM. A monotone worklist over the FINISHED graph cannot have that
186
+ * property: "required" only ever spreads, so the result is independent of visit
187
+ * order. npm reaches for the same shape and says so —
188
+ * refs/npm-cli/workspaces/arborist/lib/calc-dep-flags.js: "If a node is changed,
189
+ * we add to the queue and continue until no more changes."
190
+ *
191
+ * PATH-INDEPENDENCE IS THE OTHER REQUIREMENT, and it is why the lockfile carries
192
+ * an `optionalDependencies` map per entry (v4). The walk USED to read
193
+ * `dependencies` only, for exactly that reason — a lockfile entry recorded nothing
194
+ * else, so consulting `node.optionalDependencies` would have made the two paths
195
+ * compute different flags for the same tree. The premise was fixable and the
196
+ * consequence was not: a publisher listing the same name in BOTH blocks means
197
+ * "optional" to npm, came out REQUIRED here, and the shape is not rare — it is
198
+ * `@parcel/rust@2.16.4` (all eight per-platform napi packages in both blocks), so
199
+ * every Linux install of any tree containing parcel failed with EBADPLATFORM on
200
+ * `@parcel/rust-darwin-x64` while `npm install` thinned the same tree and exited
201
+ * 0. "Erring toward REQUIRED errs toward the loud failure" is true and was the
202
+ * wrong trade: the loud failure was not a real one, and no `--force` lifts it
203
+ * (nor should it). So the declaration is persisted and both paths subtract it.
204
+ * Reading a pre-v4 lockfile leaves the map empty, which reproduces the old
205
+ * behaviour for that file alone — see READABLE_LOCKFILE_VERSIONS.
206
+ *
207
+ * Runs before `writeLockfile` so the persisted flag is the final one, and before
208
+ * `applyPlatformFilter` so the fatal-vs-inert decision reads it rather than the
209
+ * guess. Exported so the unit spec can drive it with INJECTED graphs: the whole
210
+ * fatal-vs-inert question is decided by these two functions in composition, and
211
+ * that is worth pinning without a registry.
212
+ */
213
+ export declare function computeOptionalFlags(nodes: ResolvedNode[], requiredNames: Set<string>, log: Logger): void;
136
214
  export declare function parseSpec(raw: string): ParsedSpec;
137
215
  export declare function pickVersion(packument: Packument, range: string, preferred?: string): string | null;
138
216
  /**
@@ -22,9 +22,9 @@
22
22
  // reported. So the DECLARATIONS are locked and the VERDICT is recomputed per
23
23
  // host at install time (see applyPlatformFilter).
24
24
  //
25
- // TWO INVARIANTS MAKE THAT CLAIM TRUE, and both are structural rather than
26
- // documented, because the first draft of this feature broke both while reading
27
- // as if it did not:
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
28
  //
29
29
  // 1. THE RESOLVE IS TARGET-BLIND. `resolveDeps` is not given the platform
30
30
  // target at all — it cannot be, so it cannot record a target-dependent
@@ -47,6 +47,31 @@
47
47
  // fixpoint for the same reason (refs/npm-cli/workspaces/arborist/lib/
48
48
  // calc-dep-flags.js: "If a node is changed, we add to the queue and
49
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.
50
75
  //
51
76
  // Out of scope (still deferred): peerDependencies validation,
52
77
  // lifecycle scripts, git/file specs.
@@ -74,9 +99,12 @@ const LOCKFILE_NAME = 'gjsify-lock.json';
74
99
  /**
75
100
  * v3 adds the per-entry platform declaration (`os`/`cpu`/`libc`) plus
76
101
  * `optional`, which together let a foreign-platform package be recorded without
77
- * being installed. v2 lockfiles are still READ (see {@link readLockfile}).
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}).
78
106
  */
79
- const LOCKFILE_VERSION = 3;
107
+ const LOCKFILE_VERSION = 4;
80
108
  /**
81
109
  * Lockfile versions this CLI can read. A v2 lockfile is a valid pin set that
82
110
  * simply predates the platform fields; rejecting it (the old
@@ -84,8 +112,19 @@ const LOCKFILE_VERSION = 3;
84
112
  * the following fresh resolve bump every `^`-range to the newest in-range
85
113
  * version — the exact silent churn lockfile preservation exists to prevent, on
86
114
  * every user's first install after upgrading the CLI.
115
+ *
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
126
  */
88
- const READABLE_LOCKFILE_VERSIONS = new Set([2, LOCKFILE_VERSION]);
127
+ const READABLE_LOCKFILE_VERSIONS = new Set([2, 3, LOCKFILE_VERSION]);
89
128
  export async function installPackagesNative(opts) {
90
129
  // Fail clearly on an unsupported Node major BEFORE touching the ABI-locked
91
130
  // native deps — otherwise they SIGSEGV mid-extract with no actionable message.
@@ -161,11 +200,18 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
161
200
  else {
162
201
  // A resolve has to run (new/changed/removed dep, or no lockfile yet).
163
202
  //
164
- // A pre-v3 lockfile lands here too, even when it matches the request:
165
- // its entries carry no platform declarations, so consuming it verbatim
166
- // would keep installing the foreign-platform tree forever with nothing
167
- // on disk to recompute the verdict from. One resolve — version-
168
- // preserving, per the seeding below, so nothing bumps — upgrades it.
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.
169
215
  //
170
216
  // Unless --refresh-lockfile was passed, seed it with the versions
171
217
  // already pinned in the existing lockfile so unchanged deps keep their
@@ -641,7 +687,13 @@ optionalSpecs) {
641
687
  name: `${edge.name}@${version}`,
642
688
  });
643
689
  if (!skipDeps) {
644
- for (const [depName, depRange] of Object.entries(node.dependencies)) {
690
+ // 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.
696
+ for (const [depName, depRange] of requiredDepEntries(node)) {
645
697
  queue.push({
646
698
  from: installPath,
647
699
  name: depName,
@@ -764,6 +816,38 @@ function requiredTopLevelNames(specs, optionalSpecs) {
764
816
  }
765
817
  return names;
766
818
  }
819
+ /**
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.
824
+ *
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`.
831
+ *
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.
837
+ *
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.
841
+ */
842
+ export function requiredDepEntries(node) {
843
+ const entries = [];
844
+ for (const [name, range] of Object.entries(node.dependencies)) {
845
+ if (name in node.optionalDependencies)
846
+ continue;
847
+ entries.push([name, range]);
848
+ }
849
+ return entries;
850
+ }
767
851
  /**
768
852
  * Recompute every node's `optional` flag as a FIXPOINT over the placed graph.
769
853
  * The only writer of the final flag; whatever the nodes arrive carrying is an
@@ -771,9 +855,10 @@ function requiredTopLevelNames(specs, optionalSpecs) {
771
855
  * back out of a file on the lockfile path) and is overwritten unconditionally.
772
856
  *
773
857
  * DEFINITION: a node is REQUIRED iff it is reachable from `requiredNames`
774
- * through `dependencies` edges alone; every other node is optional. Optionality
775
- * is therefore INHERITED — a plain dependency OF an optional package is still
776
- * optional — which is what npm's `optionalSet` computes.
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.
777
862
  *
778
863
  * WHY A FIXPOINT AND NOT A ONE-SHOT PROMOTION AT THE REUSE SITE (the reviewed
779
864
  * defect): the walk queues a node's own dep edges at the moment it is placed,
@@ -788,23 +873,29 @@ function requiredTopLevelNames(specs, optionalSpecs) {
788
873
  * refs/npm-cli/workspaces/arborist/lib/calc-dep-flags.js: "If a node is changed,
789
874
  * we add to the queue and continue until no more changes."
790
875
  *
791
- * PATH-INDEPENDENCE IS THE OTHER REQUIREMENT, and it is why the walk reads
792
- * `dependencies` ONLY. A lockfile entry records `dependencies` and nothing else
793
- * (an `optionalDependencies` map is not persisted), so consulting
794
- * `node.optionalDependencies` here would make the fresh-resolve path and the
795
- * lockfile path compute different flags for the same tree — precisely the split
796
- * this pass exists to close. The cost is one npm quirk we do not reproduce: a
797
- * publisher listing the same name in BOTH blocks means "optional" to npm, and
798
- * comes out required here. That shape is vanishingly rare in practice (the
799
- * platform-binary packages this feature is about — rollup, lightningcss,
800
- * esbuild, fsevents — all list their optional deps in one block only), and
801
- * erring toward REQUIRED errs toward the loud failure, not the silent one.
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.
802
891
  *
803
892
  * Runs before `writeLockfile` so the persisted flag is the final one, and before
804
893
  * `applyPlatformFilter` so the fatal-vs-inert decision reads it rather than the
805
- * guess.
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.
806
897
  */
807
- function computeOptionalFlags(nodes, requiredNames, log) {
898
+ export function computeOptionalFlags(nodes, requiredNames, log) {
808
899
  const byPath = new Map();
809
900
  for (const node of nodes)
810
901
  byPath.set(node.installPath, node);
@@ -833,7 +924,7 @@ function computeOptionalFlags(nodes, requiredNames, log) {
833
924
  const node = byPath.get(installPath);
834
925
  if (!node)
835
926
  continue;
836
- for (const depName of Object.keys(node.dependencies)) {
927
+ for (const [depName] of requiredDepEntries(node)) {
837
928
  // Resolve the edge the way the REQUESTER will at runtime — through
838
929
  // the ancestor `node_modules` chain — so a nested copy is credited
839
930
  // to the requester that nested it and the hoisted one is not
@@ -904,8 +995,14 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
904
995
  continue;
905
996
  // A REQUIRED package's own optionalDependency stays optional whatever
906
997
  // the package's flag says: optionality lives on the edge, not on its
907
- // endpoints. This branch is what keeps a darwin-only `fsevents` under a
908
- // required `chokidar` from failing every Linux install.
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.
909
1006
  //
910
1007
  // Safe on both paths because `skippedEdges` is only ever non-empty on
911
1008
  // the fresh-resolve path, where `optionalDependencies` is populated; the
@@ -1137,6 +1234,11 @@ function writeLockfile(lockfilePath, specs, nodes) {
1137
1234
  resolved: node.tarballUrl,
1138
1235
  integrity: node.integrity,
1139
1236
  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.
1241
+ optionalDependencies: Object.keys(node.optionalDependencies).length > 0 ? node.optionalDependencies : undefined,
1140
1242
  bin: node.bin,
1141
1243
  // The DECLARATION, never the verdict: `inert` is per-host and
1142
1244
  // deliberately absent from the file (a lockfile that recorded which
@@ -1208,7 +1310,12 @@ function lockfileToNodes(lockfile) {
1208
1310
  integrity: entry.integrity,
1209
1311
  installPath,
1210
1312
  dependencies: entry.dependencies ?? {},
1211
- optionalDependencies: {},
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.
1318
+ optionalDependencies: entry.optionalDependencies ?? {},
1212
1319
  bin: entry.bin,
1213
1320
  // Rebuilt from the recorded declaration so `applyPlatformFilter`
1214
1321
  // reaches the SAME verdict on this path as on the fresh-resolve
@@ -0,0 +1,71 @@
1
+ /** Injectable host facts — the darwin branch is testable from Linux. */
2
+ export interface SystemGiOptions {
3
+ /** `process.platform` value; defaults to the running process. */
4
+ platform?: string;
5
+ /** Environment to read `GI_TYPELIB_PATH` / `PKG_CONFIG_PATH` from; defaults to `process.env`. */
6
+ env?: Record<string, string | undefined>;
7
+ /** Directory-existence predicate; defaults to a `statSync` probe. */
8
+ existsDir?: (dir: string) => boolean;
9
+ /** `pkg-config`'s `.pc` search directories; defaults to {@link pkgConfigSearchDirs}. */
10
+ searchDirs?: (env: Record<string, string | undefined>) => string[];
11
+ }
12
+ /**
13
+ * Split an OS search-path variable, dropping empty segments.
14
+ */
15
+ export declare function splitSearchPath(value: string | undefined, separator?: string): string[];
16
+ /**
17
+ * `pkg-config`'s OWN configured `.pc` search path plus `$PKG_CONFIG_PATH`. Every
18
+ * entry is a `<libdir>/pkgconfig` (or `<datadir>/pkgconfig`) directory, so its
19
+ * parent is a candidate libdir — which is how a prefix nobody hardcoded is
20
+ * found. Measured on the reported host: `pc_path` =
21
+ * `/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:…`,
22
+ * whose first parent is exactly the `/usr/local/lib` that resolves every leaf.
23
+ *
24
+ * `spawnSync` reports a missing executable as `result.error`, not by throwing —
25
+ * an absent `pkg-config` is expected on a runtime-only GTK install, so it is a
26
+ * checked return value here and not an exception path. (`@gjsify/child_process`
27
+ * matches Node here deliberately; see its `spawnSync` note.)
28
+ */
29
+ export declare function pkgConfigSearchDirs(env: Record<string, string | undefined>): string[];
30
+ /**
31
+ * Directories holding the host's system GObject-Introspection shared libraries —
32
+ * most specific first, deduplicated, each verified on disk.
33
+ *
34
+ * Three sources, in precedence order:
35
+ * 1. `GI_TYPELIB_PATH` — the host stating outright where typelibs live; the
36
+ * libraries are the sibling `dirname()`. Accepted on directory existence
37
+ * alone: an explicit statement is not second-guessed.
38
+ * 2. `pkg-config`'s `.pc` search path → each `<dir>/pkgconfig`'s parent. The
39
+ * general mechanism (any prefix, including a custom one).
40
+ * 3. {@link PROBED_GI_LIBDIRS} — the standard macOS prefixes, for the common
41
+ * case where GTK is installed and pkg-config is not.
42
+ * Sources 2 and 3 are GUESSES at a prefix, so each must show the
43
+ * `girepository-1.0/` marker before it is believed.
44
+ *
45
+ * Empty off darwin, and that is a statement about LOADERS, not about GI. Linux
46
+ * resolves these leaves through `ld.so`'s system-wide configured cache
47
+ * (`/etc/ld.so.conf.d`), which a package install populates — nothing per-process
48
+ * is needed. dyld is the one loader that neither consults a system-wide config
49
+ * NOR re-reads its search path after launch, which is why darwin needs the
50
+ * variable set at launch and nobody else does.
51
+ *
52
+ * win32 is deliberately absent rather than proven unnecessary: `PATH` is the DLL
53
+ * search path, a Windows GTK distribution puts its `bin` there itself, and
54
+ * Windows re-reads that path at every `LoadLibrary` — so if a system GTK ever
55
+ * turns out not to be on it, the repair is a `PATH` prepend (which
56
+ * {@link buildNativeEnv} already does for the prebuilds), never a second
57
+ * variable. Untested there.
58
+ *
59
+ * @returns absolute directories, or `[]` when this platform's loader needs none
60
+ */
61
+ export declare function systemGiLibraryDirs({ platform, env, existsDir, searchDirs, }?: SystemGiOptions): string[];
62
+ /**
63
+ * Whether `current` (a split `DYLD_FALLBACK_LIBRARY_PATH`) already covers every
64
+ * directory in `wanted` — the "nothing to do" test.
65
+ *
66
+ * Kept in the port even though this CLI's consumer does not branch on it: the
67
+ * mirror's value is that the two files are the SAME file, and a surface that
68
+ * drifts by one export is a mirror nobody can check. `system-gi.spec.ts` pins
69
+ * both.
70
+ */
71
+ export declare function pathCovers(wanted: readonly string[], current: readonly string[]): boolean;