@gjsify/cli 0.28.0 → 0.30.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.
@@ -11,7 +11,7 @@
11
11
  // is in the project's dependency tree. A user with only @gjsify/fs in their
12
12
  // project never sees a warning about libmanette.
13
13
  import { execFileSync } from 'node:child_process';
14
- import { join, resolve } from 'node:path';
14
+ import { dirname, join, resolve } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
16
  import { pathToFileURL } from 'node:url';
17
17
  import { readFileSync, existsSync, readdirSync } from 'node:fs';
@@ -87,8 +87,8 @@ export function detectPackageManager() {
87
87
  }
88
88
  return 'unknown';
89
89
  }
90
- /** Optional system deps keyed by their DepCheck id. */
91
- const OPTIONAL_DEPS = {
90
+ /** Optional system deps keyed by their DepCheck id. Exported for the spec's declaration invariant. */
91
+ export const OPTIONAL_DEPS = {
92
92
  manette: { id: 'manette', name: 'libmanette', pkgName: 'manette-0.2' },
93
93
  gstreamer: { id: 'gstreamer', name: 'GStreamer', pkgName: 'gstreamer-1.0' },
94
94
  'gst-app': { id: 'gst-app', name: 'GStreamer App', pkgName: 'gstreamer-app-1.0' },
@@ -105,6 +105,21 @@ const OPTIONAL_DEPS = {
105
105
  // `Run-time dependency X found: NO` error mid-build.
106
106
  gnutls: { id: 'gnutls', name: 'GnuTLS', pkgName: 'gnutls' },
107
107
  nghttp2: { id: 'nghttp2', name: 'libnghttp2', pkgName: 'libnghttp2' },
108
+ // RUNTIME deps of the shipped prebuilds, not build-time ones — the distinction
109
+ // the two entries above make does not hold for these. Measured on fedora:43
110
+ // provisioned like a CONSUMER (gjs + libsoup3 only), `ldd` over every committed
111
+ // linux-x64 prebuild: three sonames do not resolve, and each makes its typelib
112
+ // UNLOADABLE, which surfaces as `Unsupported type void, deriving from
113
+ // fundamental void` at first construction — a message that names nothing.
114
+ // @gjsify/rolldown-native libjson-glib-1.0.so.0
115
+ // @gjsify/webgl libepoxy.so.0 (gdk-pixbuf already listed)
116
+ // @gjsify/webrtc-native libgstwebrtc-1.0.so.0 (gstreamer already listed)
117
+ // json-glib is the severe one: rolldown-native IS the GJS bundler engine, so a
118
+ // host without it cannot `gjsify build` at all, while `gjsify install` printed
119
+ // "System dependencies OK." — a check passing for the wrong reason.
120
+ 'json-glib': { id: 'json-glib', name: 'JSON-GLib', pkgName: 'json-glib-1.0' },
121
+ epoxy: { id: 'epoxy', name: 'libepoxy', pkgName: 'epoxy' },
122
+ 'gst-webrtc': { id: 'gst-webrtc', name: 'GStreamer WebRTC', pkgName: 'gstreamer-webrtc-1.0' },
108
123
  };
109
124
  /**
110
125
  * Map of @gjsify/* package name → ids of OPTIONAL_DEPS this package needs.
@@ -112,8 +127,11 @@ const OPTIONAL_DEPS = {
112
127
  * available trio GLib/GObject/Gio).
113
128
  *
114
129
  * Used to compute the set of optional deps to check for a given project.
130
+ *
131
+ * Exported for the spec's declaration invariant: every id named here must exist in
132
+ * OPTIONAL_DEPS, or the dep is silently never checked.
115
133
  */
116
- const PACKAGE_DEPS = {
134
+ export const PACKAGE_DEPS = {
117
135
  '@gjsify/gamepad': ['manette'],
118
136
  '@gjsify/webaudio': ['gstreamer', 'gst-app'],
119
137
  '@gjsify/iframe': ['webkitgtk'],
@@ -124,13 +142,17 @@ const PACKAGE_DEPS = {
124
142
  // as a special-case checkNpmPackage rather than checkPkgConfig in
125
143
  // runOptionalChecks. Mapping it here so its presence in the project's
126
144
  // dep tree triggers the check.
127
- '@gjsify/webgl': ['gwebgl'],
145
+ '@gjsify/webgl': ['gwebgl', 'gdk-pixbuf', 'epoxy'],
128
146
  // Native Vala bridges with `dependency('gnutls')` / `dependency('libnghttp2')`
129
147
  // in their meson.build. Optional because the shipped prebuild covers the
130
148
  // common-arch user path; only contributors rebuilding from source hit the
131
149
  // build-time dep.
132
150
  '@gjsify/tls-native': ['gnutls'],
133
151
  '@gjsify/http2-native': ['nghttp2'],
152
+ // Unlike the two above, these are LOAD-time: the prebuild is dynamically linked
153
+ // and GIRepository cannot open its backing library without them.
154
+ '@gjsify/rolldown-native': ['json-glib'],
155
+ '@gjsify/webrtc-native': ['gstreamer', 'gst-webrtc'],
134
156
  // @gjsify/event-bridge only needs gtk4/gdk which are already in the
135
157
  // required set, so it doesn't need an optional entry.
136
158
  };
@@ -367,6 +389,8 @@ const PM_PACKAGES = {
367
389
  cairo: 'libcairo2-dev',
368
390
  gnutls: 'libgnutls28-dev',
369
391
  nghttp2: 'libnghttp2-dev',
392
+ 'json-glib': 'libjson-glib-dev',
393
+ epoxy: 'libepoxy-dev',
370
394
  },
371
395
  dnf: {
372
396
  gjs: 'gjs',
@@ -390,6 +414,8 @@ const PM_PACKAGES = {
390
414
  cairo: 'cairo-devel',
391
415
  gnutls: 'gnutls-devel',
392
416
  nghttp2: 'libnghttp2-devel',
417
+ 'json-glib': 'json-glib-devel',
418
+ epoxy: 'libepoxy-devel',
393
419
  },
394
420
  pacman: {
395
421
  gjs: 'gjs',
@@ -413,6 +439,8 @@ const PM_PACKAGES = {
413
439
  cairo: 'cairo',
414
440
  gnutls: 'gnutls',
415
441
  nghttp2: 'libnghttp2',
442
+ 'json-glib': 'json-glib',
443
+ epoxy: 'libepoxy',
416
444
  },
417
445
  zypper: {
418
446
  gjs: 'gjs',
@@ -436,6 +464,8 @@ const PM_PACKAGES = {
436
464
  cairo: 'cairo-devel',
437
465
  gnutls: 'libgnutls-devel',
438
466
  nghttp2: 'libnghttp2-devel',
467
+ 'json-glib': 'json-glib-devel',
468
+ epoxy: 'libepoxy-devel',
439
469
  },
440
470
  apk: {
441
471
  gjs: 'gjs',
@@ -459,6 +489,8 @@ const PM_PACKAGES = {
459
489
  cairo: 'cairo-dev',
460
490
  gnutls: 'gnutls-dev',
461
491
  nghttp2: 'nghttp2-dev',
492
+ 'json-glib': 'json-glib-dev',
493
+ epoxy: 'libepoxy-dev',
462
494
  },
463
495
  // macOS. Names verified against homebrew/core rather than inferred from the
464
496
  // Linux rows — the mapping is NOT mechanical: Homebrew ships no `-dev`/`-devel`
@@ -502,6 +534,8 @@ const PM_PACKAGES = {
502
534
  cairo: 'cairo',
503
535
  gnutls: 'gnutls',
504
536
  nghttp2: 'nghttp2',
537
+ 'json-glib': 'json-glib',
538
+ epoxy: 'libepoxy',
505
539
  },
506
540
  unknown: {},
507
541
  };
@@ -546,3 +580,123 @@ export function buildInstallCommand(pm, missing) {
546
580
  }
547
581
  return lines.length > 0 ? lines.join('\n ') : null;
548
582
  }
583
+ /**
584
+ * Only libraries whose GIR carries a real `<package version>`, verified against
585
+ * the published packages rather than assumed. A binding whose value turns out
586
+ * degenerate is skipped at runtime by `isDegenerate`, so a wrong entry here
587
+ * cannot produce a false alarm — it can only produce silence.
588
+ */
589
+ const TYPE_PACKAGE_BINDINGS = [
590
+ { pkgConfig: 'gtk4', typePackage: '@girs/gtk-4.0', namespaceVersion: '4.0' },
591
+ { pkgConfig: 'libadwaita-1', typePackage: '@girs/adw-1', namespaceVersion: '1' },
592
+ { pkgConfig: 'libsoup-3.0', typePackage: '@girs/soup-3.0', namespaceVersion: '3.0' },
593
+ { pkgConfig: 'glib-2.0', typePackage: '@girs/glib-2.0', namespaceVersion: '2.0' },
594
+ { pkgConfig: 'gobject-2.0', typePackage: '@girs/gobject-2.0', namespaceVersion: '2.0' },
595
+ { pkgConfig: 'gio-2.0', typePackage: '@girs/gio-2.0', namespaceVersion: '2.0' },
596
+ { pkgConfig: 'gstreamer-1.0', typePackage: '@girs/gst-1.0', namespaceVersion: '1.0' },
597
+ { pkgConfig: 'webkitgtk-6.0', typePackage: '@girs/webkit-6.0', namespaceVersion: '6.0' },
598
+ { pkgConfig: 'manette-0.2', typePackage: '@girs/manette-0.2', namespaceVersion: '0.2' },
599
+ { pkgConfig: 'pango', typePackage: '@girs/pango-1.0', namespaceVersion: '1.0' },
600
+ { pkgConfig: 'harfbuzz', typePackage: '@girs/harfbuzz-0.0', namespaceVersion: '0.0' },
601
+ ];
602
+ /**
603
+ * Read `libraryVersion` from an installed `@girs/*` package.
604
+ *
605
+ * Resolves the package's MAIN entry and walks up to its root `package.json`
606
+ * rather than resolving the `package.json` subpath: `@girs/*` exports maps do not
607
+ * expose `./package.json` (verified on all 32 installed packages), so
608
+ * `require.resolve('@girs/gtk-4.0/package.json')` throws
609
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED`. The same trap silently disabled the
610
+ * `@nativescript/vite` major-version gate until it was found by measurement.
611
+ */
612
+ function readGirsLibraryVersion(cwd, typePackage) {
613
+ for (const from of [pathToFileURL(join(cwd, '_girs_.js')).href, import.meta.url]) {
614
+ let dir;
615
+ try {
616
+ dir = dirname(createRequire(from).resolve(typePackage));
617
+ }
618
+ catch {
619
+ continue;
620
+ }
621
+ // Walk up to the package root — the entry may sit in a subdirectory.
622
+ for (let i = 0; i < 5; i++) {
623
+ const manifest = join(dir, 'package.json');
624
+ if (existsSync(manifest)) {
625
+ try {
626
+ const parsed = JSON.parse(readFileSync(manifest, 'utf-8'));
627
+ if (parsed.name === typePackage)
628
+ return parsed.libraryVersion ?? null;
629
+ }
630
+ catch {
631
+ return null; // present but unreadable — nothing to compare against
632
+ }
633
+ }
634
+ const parent = dirname(dir);
635
+ if (parent === dir)
636
+ break;
637
+ dir = parent;
638
+ }
639
+ }
640
+ return null;
641
+ }
642
+ /** `[major, minor]`, or null when the string is not a comparable version. */
643
+ function majorMinor(version) {
644
+ const match = /^(\d+)\.(\d+)/.exec(version.trim());
645
+ if (!match)
646
+ return null;
647
+ return [Number(match[1]), Number(match[2])];
648
+ }
649
+ /**
650
+ * True when `libraryVersion` is ts-for-gir's namespace-version fallback rather
651
+ * than a real upstream release — `@girs/gdk-4.0`'s `4.0.0` against namespace `4.0`.
652
+ *
653
+ * Compares against every `.0`-padded spelling of the namespace version, not just
654
+ * the three-component one: `4.0` and `4.0.0` are the same claim, and matching only
655
+ * the padded form let the bare spelling through as a bogus 18-minor "behind"
656
+ * finding. Deliberately NOT a major.minor comparison — `@girs/adw-1` has namespace
657
+ * `1` and a real `libraryVersion` of `1.10.0`, which any major-only rule discards.
658
+ */
659
+ function isDegenerate(declared, namespaceVersion) {
660
+ const trimmed = declared.trim();
661
+ const parts = namespaceVersion.split('.');
662
+ for (let n = parts.length; n <= 3; n++) {
663
+ if (trimmed === [...parts, ...Array(n - parts.length).fill('0')].join('.'))
664
+ return true;
665
+ }
666
+ return false;
667
+ }
668
+ /**
669
+ * Compare each installed `@girs/*` package's declared upstream version against the
670
+ * library the host actually has. Only reports a `major.minor` difference: a micro
671
+ * difference does not change the API set, and reporting it would train people to
672
+ * ignore the output.
673
+ */
674
+ export function checkTypeSkew(cwd, readers = {}) {
675
+ const readDeclared = readers.readDeclared ?? readGirsLibraryVersion;
676
+ const readInstalled = readers.readInstalled ?? ((pkgConfig) => tryExecFile('pkg-config', ['--modversion', pkgConfig]));
677
+ const findings = [];
678
+ for (const binding of TYPE_PACKAGE_BINDINGS) {
679
+ const declared = readDeclared(cwd, binding.typePackage);
680
+ if (declared === null)
681
+ continue; // types not installed, or no declaration
682
+ if (isDegenerate(declared, binding.namespaceVersion))
683
+ continue;
684
+ const installed = readInstalled(binding.pkgConfig);
685
+ if (installed === null)
686
+ continue; // library absent — the presence check owns that
687
+ const want = majorMinor(declared);
688
+ const have = majorMinor(installed.split('\n')[0] ?? installed);
689
+ if (!want || !have)
690
+ continue;
691
+ if (want[0] === have[0] && want[1] === have[1])
692
+ continue;
693
+ findings.push({
694
+ typePackage: binding.typePackage,
695
+ pkgConfig: binding.pkgConfig,
696
+ declared,
697
+ installed: installed.split('\n')[0] ?? installed,
698
+ relation: want[0] > have[0] || (want[0] === have[0] && want[1] > have[1]) ? 'ahead' : 'behind',
699
+ });
700
+ }
701
+ return findings;
702
+ }
@@ -1,3 +1,4 @@
1
+ import { type SystemGiOptions } from './system-gi.js';
1
2
  export interface NativePackage {
2
3
  /** npm package name, e.g. "@gjsify/webgl" */
3
4
  name: string;
@@ -42,8 +43,15 @@ export interface NativeEnv {
42
43
  GI_TYPELIB_PATH: string;
43
44
  /** ELF loader (Linux and other ELF platforms). */
44
45
  LD_LIBRARY_PATH?: string;
45
- /** Mach-O loader (macOS). `dyld` ignores `LD_LIBRARY_PATH` entirely. */
46
+ /** Mach-O loader (macOS), OVERRIDING resolution by leaf. `dyld` ignores `LD_LIBRARY_PATH` entirely. */
46
47
  DYLD_LIBRARY_PATH?: string;
48
+ /**
49
+ * Mach-O loader (macOS), consulted only AFTER the normal search fails. This
50
+ * is where the HOST's GI libdirs go — never `DYLD_LIBRARY_PATH`, which would
51
+ * shadow correctly-resolved libraries process-wide. See
52
+ * {@link buildNativeEnv}.
53
+ */
54
+ DYLD_FALLBACK_LIBRARY_PATH?: string;
47
55
  [key: string]: string | undefined;
48
56
  }
49
57
  /**
@@ -287,8 +295,44 @@ export declare function libraryPathVar(platform: string): {
287
295
  * (a stock Windows env block uses `Path`) — Windows env names are
288
296
  * case-insensitive, but a plain JS object is not, and `{...process.env,
289
297
  * PATH: …}` would hand a child process two competing entries.
298
+ *
299
+ * ── DARWIN ALSO GETS THE *HOST'S* GI LIBDIRS ─────────────────────────────────
300
+ *
301
+ * Everything above is about the prebuilds gjsify itself ships. On macOS there is
302
+ * a second, independent gap that has nothing to do with gjsify: a typelib names
303
+ * its library by BARE LEAF (`libgtk-4.1.dylib`), and dyld carries neither
304
+ * `/usr/local/lib` nor `/opt/homebrew/lib` in its default fallback path.
305
+ * Homebrew's `gjs` has an rpath into GLIB's keg only, so it resolves no other
306
+ * keg's leaves — measured: bare `gjs -c "imports.gi.Gtk; Gtk.init()"`, no gjsify
307
+ * in the process, dies on that dlopen (full trace in `utils/system-gi.ts`). This
308
+ * is the ONE place `gjsify run`, `showcase`, `storybook`, `tsc` and `info` all
309
+ * pass through, so it is where the child's launch env is repaired.
310
+ *
311
+ * Two properties are load-bearing:
312
+ *
313
+ * 1. **`DYLD_FALLBACK_LIBRARY_PATH`, never `DYLD_LIBRARY_PATH`.** The override
314
+ * variable wins by LEAF for every dylib in the process, including ones the
315
+ * host already resolved correctly — pointing it at a whole system libdir is
316
+ * how you replace a linked-against library with a same-named different one.
317
+ * The fallback is consulted only after the normal search fails, which is
318
+ * exactly the "nothing else found it" case. Consequence, and it is the
319
+ * intended precedence: gjsify's own prebuilds keep priority through the
320
+ * override variable, the system stack fills in behind it.
321
+ * 2. **Our dirs go FIRST, an inherited fallback is kept BEHIND them, and
322
+ * `/usr/lib` is appended when there is none.** Setting the variable
323
+ * REPLACES dyld's own default fallback list, so writing only our dirs would
324
+ * silently remove `/usr/lib` from the search — a host that exported the
325
+ * variable itself already carries whatever tail it wants. Same composition
326
+ * as node-gi's re-exec.
327
+ *
328
+ * Emitted even when `packages` is empty: the gap is in the host's loader, not in
329
+ * a gjsify prebuild, so a plain `gjsify run script.gjs.js` that touches GTK needs
330
+ * it just as much. Empty off darwin — `ld.so`'s configured cache already resolves
331
+ * these leaves on Linux, and Windows re-reads `PATH` per `LoadLibrary`.
290
332
  */
291
333
  export declare function buildNativeEnv(packages: NativePackage[], opts?: {
292
334
  platform?: string;
293
335
  env?: Record<string, string | undefined>;
336
+ /** Injectable for tests — the darwin branch is asserted from Linux. */
337
+ systemGiDirs?: (opts: SystemGiOptions) => string[];
294
338
  }): NativeEnv;
@@ -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
  }
@@ -158,7 +158,7 @@ export function symlinkSwap(cacheDir, prepareDir, opts = {}) {
158
158
  }
159
159
  /** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
160
160
  export function cleanupStalePrepareDirs(cacheDir, _maxAgeMinutes = DEFAULT_TTL_MIN) {
161
- // Out of scope for Phase 1 — pnpm has the same TODO. Leaving a stub so
161
+ // TODO(open-todos: 10 small API gaps): out of scope for Phase 1 — pnpm has the same stub. Kept so
162
162
  // call sites already exist when we do implement it.
163
163
  void cacheDir;
164
164
  }
@@ -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
  /**
@@ -172,6 +250,14 @@ export declare function writeBinEntry(opts: {
172
250
  envPreamble: string;
173
251
  prebuildDirs: readonly string[];
174
252
  };
253
+ /**
254
+ * The package's npm `bin` target, when it declares one ALONGSIDE
255
+ * `gjsify.bin`. The launcher then picks a runtime per invocation instead of
256
+ * hard-coding `gjs` — see {@link pickBinMap}. Absent for a package that
257
+ * declares only one of the two, which is every package but `@gjsify/cli`
258
+ * today.
259
+ */
260
+ nodeFallbackAbs?: string;
175
261
  }): void;
176
262
  type Logger = (fmt: string, ...args: unknown[]) => void;
177
263
  export {};