@gjsify/cli 0.36.0 → 0.37.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 (116) hide show
  1. package/dist/cli.gjs.mjs +3 -3
  2. package/lib/bundler-pick.js +61 -87
  3. package/lib/cli-fail.d.ts +4 -7
  4. package/lib/cli-fail.js +29 -55
  5. package/lib/commands/affected.js +161 -286
  6. package/lib/commands/install.d.ts +63 -66
  7. package/lib/commands/install.js +372 -536
  8. package/lib/templates/install.mjs.tmpl +38 -68
  9. package/lib/types/cli-build-options.d.ts +33 -67
  10. package/lib/types/command.d.ts +9 -12
  11. package/lib/types/config-data.d.ts +125 -221
  12. package/lib/utils/auth-npmrc.d.ts +5 -10
  13. package/lib/utils/auth-npmrc.js +11 -21
  14. package/lib/utils/clear-targets.d.ts +8 -11
  15. package/lib/utils/clear-targets.js +22 -29
  16. package/lib/utils/copy-targets.d.ts +13 -19
  17. package/lib/utils/copy-targets.js +31 -44
  18. package/lib/utils/dep-aggregation.d.ts +7 -15
  19. package/lib/utils/dep-aggregation.js +9 -22
  20. package/lib/utils/detect-native-packages.d.ts +83 -116
  21. package/lib/utils/detect-native-packages.js +111 -149
  22. package/lib/utils/dir-link.d.ts +29 -48
  23. package/lib/utils/dir-link.js +33 -56
  24. package/lib/utils/discover-showcases.d.ts +6 -7
  25. package/lib/utils/discover-showcases.js +11 -15
  26. package/lib/utils/dlx-cache.d.ts +29 -38
  27. package/lib/utils/dlx-cache.js +53 -63
  28. package/lib/utils/force-exit.d.ts +0 -3
  29. package/lib/utils/force-exit.js +13 -24
  30. package/lib/utils/gi-search-path.d.ts +16 -24
  31. package/lib/utils/gi-search-path.js +54 -82
  32. package/lib/utils/gjs-bundle-guard.d.ts +5 -13
  33. package/lib/utils/gjs-bundle-guard.js +35 -57
  34. package/lib/utils/gjs-source-escape.d.ts +2 -2
  35. package/lib/utils/gjs-source-escape.js +21 -39
  36. package/lib/utils/gjsify-shim.d.ts +17 -20
  37. package/lib/utils/gjsify-shim.js +80 -120
  38. package/lib/utils/html-entry.d.ts +8 -12
  39. package/lib/utils/html-entry.js +19 -28
  40. package/lib/utils/install-backend-native.d.ts +123 -161
  41. package/lib/utils/install-backend-native.js +536 -737
  42. package/lib/utils/install-backend.d.ts +42 -57
  43. package/lib/utils/install-backend.js +16 -21
  44. package/lib/utils/install-cache-fs.d.ts +10 -15
  45. package/lib/utils/install-cache-fs.js +18 -26
  46. package/lib/utils/install-lock.d.ts +5 -6
  47. package/lib/utils/install-lock.js +46 -65
  48. package/lib/utils/install-packument-cache.d.ts +27 -33
  49. package/lib/utils/install-packument-cache.js +44 -57
  50. package/lib/utils/install-progress.d.ts +2 -9
  51. package/lib/utils/install-progress.js +14 -39
  52. package/lib/utils/install-tarball-cache.d.ts +14 -20
  53. package/lib/utils/install-tarball-cache.js +41 -59
  54. package/lib/utils/library-output.d.ts +9 -22
  55. package/lib/utils/library-output.js +19 -37
  56. package/lib/utils/load-npmrc.d.ts +5 -6
  57. package/lib/utils/load-npmrc.js +18 -27
  58. package/lib/utils/node-bundle-guard.d.ts +4 -8
  59. package/lib/utils/node-bundle-guard.js +21 -35
  60. package/lib/utils/node-script.js +33 -54
  61. package/lib/utils/node-version.d.ts +5 -14
  62. package/lib/utils/node-version.js +15 -24
  63. package/lib/utils/normalize-bundler-options.d.ts +7 -11
  64. package/lib/utils/normalize-bundler-options.js +24 -42
  65. package/lib/utils/npm-oidc.d.ts +23 -37
  66. package/lib/utils/npm-oidc.js +28 -53
  67. package/lib/utils/npm-otp-cache.d.ts +4 -4
  68. package/lib/utils/npm-otp-cache.js +20 -25
  69. package/lib/utils/npm-otp.d.ts +20 -30
  70. package/lib/utils/npm-otp.js +32 -51
  71. package/lib/utils/onboard-probe.d.ts +7 -13
  72. package/lib/utils/onboard-probe.js +16 -27
  73. package/lib/utils/parse-spec.d.ts +3 -3
  74. package/lib/utils/parse-spec.js +4 -5
  75. package/lib/utils/pkg-json-edit.d.ts +7 -12
  76. package/lib/utils/pkg-json-edit.js +13 -26
  77. package/lib/utils/pkg-json.d.ts +11 -18
  78. package/lib/utils/pkg-json.js +19 -28
  79. package/lib/utils/prefixed-output.d.ts +3 -4
  80. package/lib/utils/prefixed-output.js +13 -19
  81. package/lib/utils/prompt.d.ts +3 -4
  82. package/lib/utils/prompt.js +14 -18
  83. package/lib/utils/publish-diagnose.d.ts +11 -16
  84. package/lib/utils/publish-diagnose.js +15 -28
  85. package/lib/utils/publish-headers.d.ts +7 -11
  86. package/lib/utils/publish-headers.js +36 -65
  87. package/lib/utils/publishable-packages.d.ts +4 -6
  88. package/lib/utils/publishable-packages.js +14 -18
  89. package/lib/utils/resolve-gjs-entry.d.ts +5 -8
  90. package/lib/utils/resolve-gjs-entry.js +10 -27
  91. package/lib/utils/resolve-npm-package.d.ts +6 -11
  92. package/lib/utils/resolve-npm-package.js +29 -63
  93. package/lib/utils/resolve-plugin-by-name.d.ts +11 -19
  94. package/lib/utils/resolve-plugin-by-name.js +9 -14
  95. package/lib/utils/run-gjs.d.ts +16 -21
  96. package/lib/utils/run-gjs.js +31 -56
  97. package/lib/utils/run-lifecycle-script.d.ts +5 -9
  98. package/lib/utils/run-lifecycle-script.js +23 -42
  99. package/lib/utils/run-node.d.ts +21 -33
  100. package/lib/utils/run-node.js +43 -64
  101. package/lib/utils/runtimes.d.ts +25 -35
  102. package/lib/utils/runtimes.js +34 -46
  103. package/lib/utils/spawn.d.ts +32 -48
  104. package/lib/utils/spawn.js +51 -84
  105. package/lib/utils/stdin.d.ts +2 -2
  106. package/lib/utils/stdin.js +10 -25
  107. package/lib/utils/suggest.d.ts +6 -7
  108. package/lib/utils/suggest.js +8 -10
  109. package/lib/utils/system-gi.d.ts +18 -27
  110. package/lib/utils/system-gi.js +60 -114
  111. package/lib/utils/trust-registry.d.ts +4 -8
  112. package/lib/utils/trust-registry.js +7 -15
  113. package/lib/utils/win32-command.d.ts +8 -14
  114. package/lib/utils/win32-command.js +29 -43
  115. package/lib/utils/workspace-root.js +7 -12
  116. package/package.json +18 -18
@@ -1,117 +1,59 @@
1
- // `gjsify affected --base <sha> [--head <ref>] [--format=...]`
1
+ // `gjsify affected` — diff against `<base>` and emit the workspaces AFFECTED by
2
+ // the change set: seeds plus everything that transitively depends on them. CI
3
+ // feeds the output to `gjsify foreach test --include …`, so a single-package PR
4
+ // tests the touched workspace and its consumers instead of the whole monorepo.
2
5
  //
3
- // Diffs the working tree against `<base>` and emits the set of workspaces
4
- // that are AFFECTED by the change set — i.e. seeds plus everything that
5
- // transitively depends on them. CI uses the output as the `--include`
6
- // filter for `gjsify foreach test`, so a typical single-package PR ends
7
- // up running the touched workspace + its downstream consumers instead of
8
- // the whole monorepo.
6
+ // EVERY VERDICT HERE IS SCOPED TO ONE WORKFLOW: `main.yml`. That is the single
7
+ // most misread thing in this file. "Ignored" NEVER means "irrelevant to the
8
+ // repository" — it means "cannot change what `main.yml` builds or tests". Sibling
9
+ // workflows gate themselves with their own `paths:` filters, so `packages/node-gi/**`
10
+ // and `packages/napi/**` are load-bearing build inputs to THEIR workflows and still
11
+ // belong in IGNORE here.
9
12
  //
10
- // EVERY VERDICT THIS FILE EMITS IS SCOPED TO ONE WORKFLOW: `main.yml`. That
11
- // scoping is the single most misread thing here, so it is stated before the
12
- // tables rather than after. The classifier is booted only by `main.yml`'s
13
- // `changes` job, and `main.yml` is the only workflow whose jobs consume its
14
- // outputs. So "ignored" NEVER means "irrelevant to the repository" — it means
15
- // "cannot change what `main.yml` builds or tests". Sibling workflows
16
- // (`prebuilds.yml`, `audit-runtimes.yml`, `node-gi.yml`, `napi.yml`,
17
- // `deploy-docs.yml`, …) gate themselves with their OWN `paths:` filters and are
18
- // unaffected by anything decided here. `IGNORE` has always worked this way —
19
- // `packages/node-gi/**` and `packages/napi/**` are load-bearing build inputs to
20
- // their own workflows and ignored here — but the table read as a global claim,
21
- // which is how a directory that is genuinely irrelevant to `main.yml` still
22
- // looks like something that must not be ignored.
13
+ // Classifier order, first match wins: IGNORE → GLOBAL_TRIGGERS → SCRIPT_COUPLINGS
14
+ // → TEST_ONLY → CODE (map file→workspace, then walk reverse-dep edges). IGNORE runs
15
+ // first and therefore WINS OVER GLOBAL: `packages/infra/cli/README.md` must not
16
+ // force a full run just because `packages/infra/cli/` is a global trigger.
23
17
  //
24
- // Classifier table (first-match wins) handles the cases that aren't a
25
- // straightforward "file lives in workspace X":
18
+ // Integration suites run when a closure member is a dependency of an
19
+ // `@gjsify/integration-*` workspace, or on a global trigger; e2e runs on any
20
+ // global trigger, an explicit `tests/e2e/**` touch, or a coupling that declares it.
26
21
  //
27
- // 1. IGNORE — cannot change what `main.yml` builds or tests: pure-docs /
28
- // *.md / website / refs/ submodule / other workflows' files and their
29
- // inputs / LICENSE / .gitignore. Discarded FIRST, before every other
30
- // rule: an ignored file is never build-relevant to this workflow, not
31
- // even inside a GLOBAL_TRIGGERS directory (e.g.
32
- // `packages/infra/cli/README.md` must not force a full run just because
33
- // `packages/infra/cli/` is a global trigger). Ignore wins over global.
22
+ // `include-args` CONTRACT (github-actions format) — read before changing its
23
+ // shape. The value is a SPACE-SEPARATED list of ALREADY-SHELL-SAFE tokens
24
+ // (`--include @gjsify/fs --include @gjsify/stream`) whose only supported
25
+ // consumption is an UNQUOTED `$INCLUDE_ARGS` expansion, where word splitting is
26
+ // the intent. It carries NO quoting of its own, deliberately:
34
27
  //
35
- // 2. GLOBAL_TRIGGERS — a non-ignored change touching infra the classifier
36
- // itself depends on (`@gjsify/workspace`, `@gjsify/cli`, the bundler
37
- // plugins, the lockfile, root tsconfig / package.json, this very
38
- // workflow file). Emits `global=true` → CI must run the full suite. We
39
- // can't trust the closure when the algorithm that computes it just
40
- // changed.
41
- //
42
- // 3. SCRIPT_COUPLINGS — a build input that reaches its consumer through a
43
- // BUILD SCRIPT instead of a dependency, so the closure walk has no edge
44
- // to follow. Contributes EXTRA seeds (and may force a test tier). See
45
- // the table for why this cannot be inferred and must be declared.
46
- //
47
- // 4. TEST_ONLY — every changed file under ONE workspace is a spec
48
- // file, e2e fixture, or integration test. Seed = that workspace
49
- // but SKIP the closure expansion (downstream consumers don't care
50
- // about test code changes).
51
- //
52
- // 5. CODE (default) — `workspacesForChangedFiles` maps file → ws,
53
- // then `affectedClosure` walks reverse-dep edges.
54
- //
55
- // Integration tests are gated separately: they run when any workspace
56
- // in the closure appears as a `dependencies` entry of any
57
- // `@gjsify/integration-*` workspace, OR on a `globalTrigger`. Same for
58
- // e2e: any infra change OR explicit `tests/e2e/**` touch turns the e2e
59
- // gate on.
60
- //
61
- // Output formats:
62
- //
63
- // --format=text (default) human-readable summary
64
- // --format=json { global, workspaces[], runIntegration, runE2E, skipAll, reason }
65
- // --format=globs one `@gjsify/<name>` per line
66
- // --format=github-actions $GITHUB_OUTPUT key=value lines
67
- //
68
- // `include-args` CONTRACT (github-actions format) — read this before changing
69
- // its shape. The value is a SPACE-SEPARATED list of ALREADY-SHELL-SAFE tokens:
70
- //
71
- // include-args=--include @gjsify/fs --include @gjsify/stream
72
- //
73
- // and its only supported consumption is an UNQUOTED `$INCLUDE_ARGS` expansion
74
- // in a POSIX shell, where word splitting is exactly the intent. It carries NO
75
- // quoting of its own, deliberately:
76
- //
77
- // * `main.yml` splices it into a `su testuser -c "… sh -c '…'"` nesting, and
78
- // the result of a parameter expansion is NEVER re-scanned for quotes. When
79
- // this emitted `--include '@gjsify/fs'`, the apostrophes became part of the
80
- // glob, every pattern matched zero workspaces, and `foreach` exited 0 —
81
- // CI "built" nothing on every selective PR run in 0.65 s (repo task #75).
82
- // Pre-quoting a value for an unknown number of nesting levels cannot work;
83
- // emitting tokens that need no quoting at any level can.
84
- // * There are FIVE consumers of this one output — the build / browser-bundle
85
- // / check / test steps splice it into a command line, and thirteen
28
+ // * `main.yml` splices it into a `su testuser -c "… sh -c '…'"` nesting, and the
29
+ // result of a parameter expansion is never re-scanned for quotes. When this
30
+ // emitted `--include '@gjsify/fs'` the apostrophes became part of the glob,
31
+ // every pattern matched zero workspaces, and `foreach` exited 0 — CI "built"
32
+ // nothing on every selective PR run, in 0.65 s. Pre-quoting for an unknown
33
+ // number of nesting levels cannot work; emitting tokens that need no quoting
34
+ // at any level can.
35
+ // * The output has two kinds of consumer: shell splices, and
86
36
  // `contains(needs.changes.outputs.include-args, '@gjsify/…')` GitHub
87
- // expressions substring-match it. A format that requires per-consumer
88
- // unquoting would have to be repeated five times in the very nesting that
89
- // produced the bug; `contains()` is unaffected either way (it matched the
90
- // quoted spelling too, which is why the gates kept working while the build
91
- // silently did nothing).
37
+ // expressions. Per-consumer unquoting would have to be repeated in the very
38
+ // nesting that produced the bug, and `contains()` is unaffected either way —
39
+ // it matched the quoted spelling too, which is why the gates kept working
40
+ // while the build silently did nothing.
92
41
  //
93
42
  // Shell-safety is ENFORCED, not assumed: `assertShellSafeWorkspaceName` rejects
94
- // any workspace name outside `[A-Za-z0-9@._/-]` before it is emitted. npm names
95
- // cannot contain those characters, so this never fires in practice — and if it
96
- // somehow does, the command exits non-zero, the classify step's
97
- // `continue-on-error` leaves the outputs unset, and CI falls back to a FULL
98
- // run. Failing towards "run everything" is the safe direction.
43
+ // any name outside `[A-Za-z0-9@._/-]`. npm names cannot contain those characters,
44
+ // so it never fires in practice; if it does, the non-zero exit leaves the classify
45
+ // step's outputs unset and CI falls back to a FULL run — failing towards "run
46
+ // everything" is the safe direction.
99
47
  //
100
- // `--changed-from-stdin` skips `git diff` entirely and reads a newline-
101
- // separated list of paths from stdin. Useful for local debugging and
102
- // for the spec suite. Stdin is slurped via `utils/stdin.ts`, NOT
103
- // `readFileSync(0)` — `@gjsify/fs` has no numeric-fd path, so the Node
104
- // idiom opens the relative file `./0` and the committed GJS classifier
105
- // bundle died with `ENOENT … read '0'` on every `--changed-from-stdin` run.
48
+ // `--changed-from-stdin` reads paths from stdin instead of running `git diff`.
49
+ // Slurped via `utils/stdin.ts`, NOT `readFileSync(0)`: `@gjsify/fs` has no
50
+ // numeric-fd path, so the Node idiom opens the relative file `./0` and the
51
+ // committed GJS bundle died with `ENOENT … read '0'` on every such run.
106
52
  import { spawnSync } from 'node:child_process';
107
- // STATIC import, never a lazy `require('node:fs')`. This package is ESM
108
- // (`"type": "module"`), so a bare `require` is a ReferenceError the moment the
109
- // line executes — and the only line that executes it sits behind
110
- // `if (process.env.GITHUB_OUTPUT)`, i.e. exclusively inside a GitHub Actions
111
- // step. That is why it survived from the command's first commit: every local
112
- // run and every spec took the stdout branch. There is no reason for laziness
113
- // here — the command already statically imports `node:child_process`, and the
114
- // classifier bundle resolves `node:fs` through `@gjsify/fs` like any other.
53
+ // STATIC, never a lazy `require('node:fs')`: this package is ESM, so a bare
54
+ // `require` is a ReferenceError — and the only line that would execute it sits
55
+ // behind `if (process.env.GITHUB_OUTPUT)`, so it survives every local run and
56
+ // every spec and fires only inside a GitHub Actions step.
115
57
  import { appendFileSync } from 'node:fs';
116
58
  import { readStdinLines } from '../utils/stdin.js';
117
59
  import { discoverWorkspaces, buildDependencyGraph, buildReverseDependencyGraph, affectedClosure, workspacesForChangedFiles, } from '@gjsify/workspace';
@@ -160,83 +102,61 @@ const GLOBAL_TRIGGERS = [
160
102
  /^packages\/infra\/cli\//,
161
103
  /^packages\/infra\/rolldown-plugin-gjsify\//,
162
104
  /^packages\/infra\/resolve-npm\//,
163
- // The test framework every spec imports. It is a *devDependency* of
164
- // virtually every workspace, so with the prod-deps-only closure below
165
- // (`includeDev: false`) a change to it would otherwise yield a near-empty
166
- // closure and silently skip every downstream test — yet a bug in a matcher
167
- // can break assertions anywhere. Treat it as a global trigger so a
168
- // `@gjsify/unit` change re-runs the full suite.
105
+ // The test framework every spec imports, as a *devDependency* — which the
106
+ // prod-deps-only closure below does not walk, so a change to it would yield a
107
+ // near-empty closure and silently skip every downstream test, while a bug in a
108
+ // matcher can break assertions anywhere.
169
109
  /^packages\/gjs\/unit\//,
170
- // The composite action every CI job runs to set itself up. A change here
171
- // alters the environment of the whole matrix, so it must force a full run
172
- // EXPLICITLY — it already did, but only by falling through to the
173
- // conservative "unmatched files" path, which is luck rather than intent.
110
+ // The composite action every CI job sets itself up with: a change alters the
111
+ // environment of the whole matrix. Listed EXPLICITLY — it previously forced a
112
+ // full run only by falling through to the "unmatched files" path, which is luck
113
+ // rather than intent.
174
114
  /^\.github\/actions\//,
175
115
  // Cross-cutting dep + lockfile + root config.
176
116
  /^gjsify-lock\.json$/,
177
117
  /^package\.json$/,
178
118
  /^tsconfig[^/]*\.json$/,
179
- // The workflow file itself — a job-shape change is invisible until
180
- // the workflow re-runs, so a path-filtered job can't safely apply
181
- // the new shape to an in-flight PR.
119
+ // The workflow file itself: a job-shape change is invisible until the workflow
120
+ // re-runs, so a path-filtered job cannot safely apply the new shape to an
121
+ // in-flight PR.
182
122
  /^\.github\/workflows\/main\.yml$/,
183
123
  /^scripts\/audit-runtimes\.mjs$/,
184
124
  ];
185
125
  /**
186
126
  * Inputs OWNED BY ANOTHER WORKFLOW — build-relevant, just not to `main.yml`.
187
127
  *
188
- * This is the sub-table where the workflow scoping stated at the top of the
189
- * file is load-bearing, so each entry NAMES the workflow that does gate it.
190
- * The test for membership is not "is this file unimportant" (none of these
191
- * are) but "can `main.yml` build or test differently because of it" — and for
192
- * every entry the answer is no, while the owning workflow has the path in its
193
- * own `paths:` filter and would go red on a break.
194
- *
195
- * Adding a regex here is therefore a claim with two halves, and BOTH must
196
- * hold: `main.yml` must not read the path, and some other workflow must.
128
+ * Adding a regex here is a claim with two halves and BOTH must hold: `main.yml`
129
+ * must not read the path, and some other workflow must have it in its own
130
+ * `paths:` filter so a break still reds a PR. The test is never "is this file
131
+ * unimportant" — none of these are.
197
132
  */
198
133
  const OTHER_WORKFLOW_INPUTS = [
199
- // Each sibling workflow's own definition. `main.yml` is deliberately
200
- // absent — it is a GLOBAL_TRIGGER, since a job-shape change there is
201
- // invisible until the workflow re-runs.
134
+ // Each sibling workflow's own definition. `main.yml` is deliberately absent: it
135
+ // is a GLOBAL_TRIGGER instead.
202
136
  /^\.github\/workflows\/(deploy-docs|commitlint|release|release-cut|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image|cancel-pr-runs)\.yml$/,
203
- // `prebuilds.yml`'s toolchain (#838): the QEMU-emulated build script and
204
- // the `changed-packages` classifier that decides which native packages a
205
- // prebuild run rebuilds. `prebuilds.yml` lists `.github/prebuild-toolchain/**`
206
- // in BOTH its `push` and `pull_request` `paths:` filters, so a break here
207
- // reds that workflow on the same PR. `main.yml` never executes any of it —
208
- // it builds no prebuild and runs no emulated leg.
137
+ // `prebuilds.yml`'s toolchain (#838): the QEMU-emulated build script and the
138
+ // classifier deciding which native packages a prebuild run rebuilds. It is in
139
+ // both of that workflow's `paths:` filters; `main.yml` builds no prebuild and
140
+ // runs no emulated leg.
209
141
  /^\.github\/prebuild-toolchain\//,
210
- // The REPO-SCOPED manifest-conformance rules (#847) — `tier`, `refs-pin`,
211
- // `platforms-ci` plus the `unchecked-fields` ledger. Their gate is
212
- // `audit-runtimes.yml`, which runs `node scripts/audit-runtimes.mjs
213
- // --check --strict` on EVERY pull_request with no `paths:` filter at all,
214
- // so this content is verified on every PR regardless of what is ignored
215
- // here; `prebuilds.yml` additionally declares it a shared input (#843) and
216
- // rebuilds every native package when it changes.
142
+ // The REPO-SCOPED manifest-conformance rules (#847), gated by
143
+ // `audit-runtimes.yml`, which runs on EVERY pull_request with no `paths:` filter;
144
+ // `prebuilds.yml` also treats it as a shared input (#843).
217
145
  //
218
- // The distinction that makes this safe is a real one, not a technicality:
219
- // `main.yml` DOES run manifest-conformance code, but it reaches it through
220
- // `scripts/verify-package-outputs.mjs` importing
221
- // `packages/infra/manifest-conformance/lib/**` — the PACKAGE, a normal
222
- // workspace under the `packages/infra/*` glob that maps and seeds like any
223
- // other. Only the `scripts/`-side repo-scoped half is ignored, and that
224
- // half is precisely the half `main.yml` does not load.
146
+ // The distinction is real, not a technicality: `main.yml` DOES run
147
+ // manifest-conformance code, but reaches it through
148
+ // `packages/infra/manifest-conformance/` — the PACKAGE, a normal workspace that
149
+ // maps and seeds like any other. Only the `scripts/`-side repo-scoped half is
150
+ // ignored, and that is precisely the half `main.yml` does not load.
225
151
  /^scripts\/manifest-conformance\//,
226
- // The authored status data (ADR 0016) and the generator that renders it.
227
- // Same two halves as the entry above, and both hold: `main.yml` reads
228
- // NEITHER — it never runs `npm run status:generate` and never opens
229
- // `status/`, which the grep for it confirms (the only hits are comments
230
- // citing `open-todos.md` as a ledger) — while `audit-runtimes.yml` runs the
231
- // `status-data` rule over exactly this data on EVERY pull_request with no
232
- // `paths:` filter.
152
+ // The authored status data (ADR 0016) + its generator. Same two halves:
153
+ // `main.yml` never runs `status:generate` and never opens `status/`, while
154
+ // `audit-runtimes.yml` runs the `status-data` rule over it on every PR.
233
155
  //
234
- // WHAT IT COSTS TODAY: `status/*.md` already fell into `IGNORE` through the
235
- // generic `/\.md$/i`, so the drift was invisible for the prose files and
236
- // showed up only on `status/status.json` — which matched nothing, landed in
237
- // `unmatched`, and forced a FULL CI run for a file `main.yml` cannot read.
238
- // Editing one package's status prose paid for the whole matrix. Naming the
239
- // directory makes the `.md` case intentional rather than incidental.
156
+ // Naming the directory makes the `.md` case intentional rather than incidental:
157
+ // `status/*.md` already fell into IGNORE via the generic `/\.md$/i`, so the gap
158
+ // showed up only on `status/status.json`, which matched nothing, landed in
159
+ // `unmatched`, and paid for a FULL matrix run over a file `main.yml` cannot read.
240
160
  /^status\//,
241
161
  /^scripts\/generate-status\.mjs$/,
242
162
  ];
@@ -247,33 +167,19 @@ const IGNORE = [
247
167
  /^website\//,
248
168
  /^docs\//,
249
169
  ...OTHER_WORKFLOW_INPUTS,
250
- // `.githooks/` WAS here, and that is what let #1095 break its own guard. IGNORE
251
- // is applied FIRST and wins over everything, so a hook change selected nothing,
252
- // the e2e tier stayed off, the PR went green and `main` went red on the push
253
- // run. It is a SCRIPT_COUPLING now instead: no seeds (there is no workspace to
254
- // rebuild) but `runE2E`, because tests/e2e/git-hooks-cli-bundle-staleness is the
255
- // only thing that exercises the hook at all.
256
- // @gjsify/node-gi (the Node-native GObject-Introspection engine, Axis 5) is
257
- // NOT a gjsify workspace member — the GJS-first install/foreach tooling can't
258
- // build its node-gyp addon, so the main workflow neither builds nor tests it.
259
- // Its own `.github/workflows/node-gi.yml` is the source of truth (built +
260
- // tested on Node). Without this carve-out its files map to no workspace and
261
- // land in `unmatched`, forcing a conservative full run on every node-gi PR.
170
+ // Neither `@gjsify/node-gi` (the Node-native GI engine) nor `@gjsify/napi` (the
171
+ // N-API host over SpiderMonkey) is a gjsify workspace member: the GJS-first
172
+ // install/foreach tooling cannot build their node-gyp addons or Vala+C++/meson
173
+ // shim, so `main.yml` neither builds nor tests them and their own workflows are
174
+ // the source of truth. Without the carve-outs their files map to no workspace,
175
+ // land in `unmatched`, and force a full run on every node-gi/napi PR.
262
176
  /^packages\/node-gi\//,
263
- // @gjsify/napi (the N-API host over GJS's SpiderMonkey, the forward mirror of
264
- // node-gi) is likewise NOT a gjsify workspace member — the GJS-first
265
- // install/foreach tooling can't build its Vala+C++/meson shim or its node-gyp
266
- // test addons, so the main workflow neither builds nor tests it. Its own
267
- // `.github/workflows/napi.yml` is the source of truth. Without this carve-out
268
- // its files map to no workspace and force a conservative full run on every
269
- // napi PR.
270
177
  /^packages\/napi\//,
271
178
  // Flatpak build/distribution tooling (SDK-extension manifest + metainfo). No
272
179
  // package-test consumers; its own `tests/e2e/flatpak-sdk-extension` runs on
273
- // `tests/e2e/**` / global triggers. This used to cite `.githooks/` as the
274
- // precedent — that one has since moved to SCRIPT_COUPLINGS because ignoring it
275
- // let a change walk past the suite guarding it, so the precedent is now the
276
- // counter-example. Worth revisiting here for the same reason.
180
+ // `tests/e2e/**` and global triggers. Worth revisiting: ignoring `.githooks/`
181
+ // for the same reason is what let a change walk past the suite guarding it (see
182
+ // SCRIPT_COUPLINGS), and IGNORE wins over every other rule.
277
183
  /^flatpak\//,
278
184
  /^LICENSE/,
279
185
  /^\.gitignore$/,
@@ -289,43 +195,35 @@ const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
289
195
  const SCRIPT_COUPLINGS = [
290
196
  {
291
197
  // `.githooks/pre-commit` is guarded by an e2e suite and reachable from no
292
- // workspace at all — it is not source, not a dependency, and its consumer is
293
- // git. So a change to it selected NOTHING, the e2e tier stayed off (it turns
294
- // on for a `tests/e2e/**` touch), and the PR went green while breaking the
295
- // very test written to hold the hook: #1095 edited the hook's command line,
296
- // merged clean, and turned `main` red on the push run.
297
- //
298
- // The same shape #1028 left a note about — whoever changes the GUARDED thing
299
- // walks past the guard — and the reason it repeats is that the guard's
198
+ // workspace at all — not source, not a dependency, its consumer is git. So a
199
+ // change to it selected NOTHING and the e2e tier stayed off (that turns on
200
+ // for a `tests/e2e/**` touch): #1095 edited the hook's command line, merged
201
+ // green, and turned `main` red on the push run. Same shape #1028 noted —
202
+ // whoever changes the GUARDED thing walks past the guard, because the guard's
300
203
  // trigger names the TEST's path instead of the thing under test.
301
204
  //
302
- // No extra seeds: there is no workspace to rebuild. The whole point is the
303
- // tier.
205
+ // No extra seeds: there is no workspace to rebuild. The tier is the point.
304
206
  match: /^\.githooks\//,
305
207
  seeds: [],
306
208
  runE2E: true,
307
209
  why: 'tests/e2e/git-hooks-cli-bundle-staleness drives .githooks/pre-commit',
308
210
  },
309
211
  {
310
- // `templates/*` ARE workspaces (root `package.json#workspaces` carries
311
- // a `templates/*` glob), so a template change already seeds its own
312
- // `@gjsify/template-<name>`. That is not the consumer that matters.
313
- //
314
- // The consumer is `@gjsify/create-app`, and it is unreachable twice
315
- // over: it lives at `packages/infra/create-gjsify/`, so no
316
- // directory-name search finds it, and it takes the templates in
317
- // through `node scripts/process-template.mjs` in its `build` script
318
- // (which resolves each template's `workspace:^` specifiers and copies
319
- // the result into `dist-templates/`), so there is no dependency edge
320
- // either. `dist-templates/` is a build-output cache candidate, so once
321
- // it is stale it stays stale.
212
+ // `templates/*` ARE workspaces, so a template change already seeds its own
213
+ // `@gjsify/template-<name>` — not the consumer that matters. That consumer is
214
+ // `@gjsify/create-app`, unreachable twice over: it lives at
215
+ // `packages/infra/create-gjsify/`, so no directory-name search finds it, and
216
+ // it pulls the templates in through its own `scripts/process-template.mjs`
217
+ // build step (which resolves each `workspace:^` specifier and copies the
218
+ // result into `dist-templates/`), so there is no dependency edge either.
219
+ // `dist-templates/` is a build-output cache candidate, so once stale it
220
+ // stays stale.
322
221
  match: /^templates\//,
323
222
  seeds: ['@gjsify/create-app'],
324
- // …and `tests/e2e/create-app` is the ONLY thing that would notice: it
325
- // scaffolds a project out of `dist-templates/` and builds it. Without
326
- // this a templates-only PR got neither the regenerated templates nor
327
- // the suite that exercises them. PR #853 passed only by accident of
328
- // also touching root `package.json` and tripping a global trigger.
223
+ // `tests/e2e/create-app` is the ONLY thing that would notice — it scaffolds a
224
+ // project out of `dist-templates/` and builds it. Without this a
225
+ // templates-only PR got neither the regenerated templates nor that suite;
226
+ // #853 passed only by also touching root `package.json`.
329
227
  runE2E: true,
330
228
  why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
331
229
  },
@@ -342,11 +240,8 @@ function classifyAndExpand(workspaces, changedFiles) {
342
240
  skipAll: true,
343
241
  };
344
242
  }
345
- // Drop ignored files FIRST — before the global-trigger check. An ignored
346
- // file (docs, *.md, LICENSE, .gitignore, …) is never build-relevant, not
347
- // even inside a GLOBAL_TRIGGERS directory: `packages/infra/cli/README.md`
348
- // must NOT force a full run just because `packages/infra/cli/` is a global
349
- // trigger. Ignore wins over global.
243
+ // Ignored files go FIRST, before the global-trigger check: ignore wins over
244
+ // global, so `packages/infra/cli/README.md` must not force a full run.
350
245
  const remaining = [];
351
246
  for (const f of files) {
352
247
  if (IGNORE.some((re) => re.test(f)))
@@ -378,12 +273,10 @@ function classifyAndExpand(workspaces, changedFiles) {
378
273
  }
379
274
  }
380
275
  }
381
- // Map files → workspaces. Files outside any workspace stay in `unmatched`.
382
276
  const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
383
- // SCRIPT_COUPLINGS: add the seeds no graph edge can reach. Applied BEFORE
384
- // the `unmatched` bail-out so the table works for a coupled directory that
385
- // is NOT itself a workspace — such a file would otherwise force a full run
386
- // and the declared seeds would never be consulted.
277
+ // Seeds no graph edge can reach. BEFORE the `unmatched` bail-out, so the table
278
+ // also works for a coupled directory that is not itself a workspace — such a
279
+ // file would otherwise force a full run and never consult the declared seeds.
387
280
  const knownNames = new Set(workspaces.map((w) => w.name));
388
281
  const couplingSeeds = new Set();
389
282
  const couplingAccounted = new Set();
@@ -395,11 +288,10 @@ function classifyAndExpand(workspaces, changedFiles) {
395
288
  for (const h of hits)
396
289
  couplingAccounted.add(h);
397
290
  for (const s of c.seeds) {
398
- // A declared seed that is not a workspace means the table has
399
- // drifted from the tree (package renamed, moved, removed). Fail
400
- // towards the full run and SAY SO — the whole point of this table
401
- // is that a missing rebuild here is otherwise invisible, so it must
402
- // never degrade quietly back into the bug it exists to prevent.
291
+ // A declared seed that is not a workspace means the table drifted from the
292
+ // tree (renamed, moved, removed). Fail towards the full run and SAY SO: a
293
+ // missing rebuild here is invisible, so this must never degrade quietly
294
+ // back into the bug the table exists to prevent.
403
295
  if (!knownNames.has(s)) {
404
296
  return {
405
297
  global: true,
@@ -416,10 +308,8 @@ function classifyAndExpand(workspaces, changedFiles) {
416
308
  if (c.runE2E)
417
309
  couplingRunE2E = true;
418
310
  }
419
- // Unmatched-but-not-ignored files are suspicious enough to fall back to
420
- // the conservative "full run" path. Examples: a new top-level dotfile,
421
- // a script in `scripts/` we haven't carved out, a refs/-adjacent file.
422
- // A file a coupling claimed is accounted for and does not count here.
311
+ // Unmatched-but-not-ignored (a new top-level dotfile, an uncarved `scripts/`
312
+ // file) falls back to the full run. A file a coupling claimed does not count.
423
313
  const stillUnmatched = unmatched.filter((f) => !couplingAccounted.has(f));
424
314
  if (stillUnmatched.length > 0) {
425
315
  return {
@@ -433,14 +323,13 @@ function classifyAndExpand(workspaces, changedFiles) {
433
323
  }
434
324
  for (const s of couplingSeeds)
435
325
  matched.add(s);
436
- // TEST_ONLY shortcut: every remaining file is a spec / e2e / integration
437
- // path, all under ONE workspace. Skip the closure expansion — test code
438
- // has no downstream consumers. A coupling deliberately disables it: the
439
- // extra seeds exist precisely because a downstream consumer DOES care.
326
+ // Every remaining file is a spec / e2e / integration path under ONE workspace, so
327
+ // skip the closure: test code has no downstream consumers. A coupling disables
328
+ // this — its extra seeds exist precisely because a consumer DOES care.
440
329
  const testOnly = couplingSeeds.size === 0 && remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
441
330
  if (testOnly && matched.size === 1) {
442
331
  const only = [...matched][0];
443
- // E2E or integration test-only changes still need their own job.
332
+ // An e2e- or integration-only change still needs its own job.
444
333
  const touchedE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
445
334
  const touchedIntegration = remaining.some((f) => f.startsWith('tests/integration/'));
446
335
  return {
@@ -452,29 +341,23 @@ function classifyAndExpand(workspaces, changedFiles) {
452
341
  skipAll: false,
453
342
  };
454
343
  }
455
- // Default: closure walk over PRODUCTION dependencies only (`includeDev:
456
- // false`). The closure answers "whose tests must re-run because a package
457
- // they depend on changed" — and that is a RUNTIME relationship. Walking
458
- // devDependency edges instead conflated it with the build/test toolchain:
459
- // every workspace devDepends `@gjsify/cli`/`@gjsify/tsc`/`@gjsify/unit`,
460
- // and the umbrella `@gjsify/node-polyfills` prod-depends every polyfill, so
461
- // ANY single-package change fanned out to ~210 of 221 workspaces — the
462
- // closure was the whole monorepo and selective CI bought almost nothing.
463
- // Prod-only collapses a typical seed to a handful (e.g. sqlite 210→4,
464
- // fetch 210→34) while keeping every real runtime dependent. Safe against
465
- // under-selection: no `*.spec.ts` imports a *cross-package* sibling via a
466
- // devDependency (the only such spec imports are self-imports, which are
467
- // covered by the package being its own seed); the universal test framework
468
- // `@gjsify/unit` is handled by GLOBAL_TRIGGERS above; and every push-to-
469
- // main + the nightly cron still run the FULL suite as the backstop.
344
+ // PRODUCTION dependencies only. The closure answers "whose tests must re-run
345
+ // because a package they depend on changed", which is a RUNTIME relationship;
346
+ // walking devDependency edges conflated it with the build/test toolchain, so ANY
347
+ // single-package change fanned out to ~210 of 221 workspaces and selective CI
348
+ // bought almost nothing. Prod-only collapses a typical seed to a handful (sqlite
349
+ // 210→4, fetch 210→34) while keeping every real runtime dependent.
350
+ //
351
+ // Safe against under-selection: no `*.spec.ts` imports a CROSS-PACKAGE sibling
352
+ // via a devDependency (the only such imports are self-imports, covered by the
353
+ // package being its own seed), `@gjsify/unit` is a GLOBAL_TRIGGER above, and
354
+ // every push-to-main plus the nightly cron still run the FULL suite.
470
355
  const reverse = buildReverseDependencyGraph(workspaces, { includeDev: false });
471
356
  const closure = affectedClosure(reverse, [...matched]);
472
- // Integration suites whose forward deps overlap with the closure also
473
- // need to run. We walk the forward graph with `includeDev: true` here
474
- // (deliberately broader than the reverse closure) so an integration suite
475
- // is never missed: integration packages declare the pillars they exercise
476
- // as `dependencies`, but the wider walk also tolerates a devDep-declared
477
- // edge. Over-running an integration suite is cheap; missing one is not.
357
+ // `includeDev: true`, deliberately broader than the reverse closure: integration
358
+ // packages declare the pillars they exercise as `dependencies`, but the wider
359
+ // walk tolerates a devDep-declared edge too. Over-running an integration suite is
360
+ // cheap; missing one is not.
478
361
  const forward = buildDependencyGraph(workspaces, { includeDev: true });
479
362
  let runIntegration = false;
480
363
  for (const [from, deps] of forward.edges) {
@@ -488,8 +371,6 @@ function classifyAndExpand(workspaces, changedFiles) {
488
371
  }
489
372
  }
490
373
  }
491
- // A `tests/e2e/**` touch turns the tier on, and so does any coupling that
492
- // declares e2e as its only real coverage (see SCRIPT_COUPLINGS).
493
374
  const runE2E = remaining.some((f) => f.startsWith('tests/e2e/')) || couplingRunE2E;
494
375
  return {
495
376
  global: false,
@@ -504,33 +385,30 @@ function classifyAndExpand(workspaces, changedFiles) {
504
385
  function isIntegrationWorkspace(name) {
505
386
  return name.startsWith('@gjsify/integration-');
506
387
  }
507
- // ─── git diff + stdin ──────────────────────────────────────────────────────
508
388
  function runGitDiff(cwd, base, head) {
509
- // `git diff --name-only base...head` lists changed paths on `head`
510
- // relative to the merge-base. That matches what GitHub PR diffs show
511
- // and survives stacked PRs without picking up commits from base.
389
+ // `base...head` lists changed paths on `head` relative to the MERGE-BASE, which
390
+ // matches what a GitHub PR diff shows and survives stacked PRs without picking up
391
+ // commits from base.
512
392
  const r = spawnSync('git', ['diff', '--name-only', `${base}...${head}`], {
513
393
  cwd,
514
394
  encoding: 'utf8',
515
395
  });
516
396
  if (r.status !== 0) {
517
- // Surface a clear error — caller (CI) will fall back to full run
518
- // via `continue-on-error: true` on the classify step.
397
+ // CI falls back to a full run via `continue-on-error: true` on the classify
398
+ // step.
519
399
  process.stderr.write(`gjsify affected: git diff failed (${r.status}): ${r.stderr.trim()}\n`);
520
- // Under GJS `process.exit()` is DEFERRED (no atexit — the call
521
- // returns), so in this synchronous, value-returning helper a bare
522
- // exit fell through to the `return` below and handed the caller a
523
- // diff parsed from a failed git run. `return process.exit(…)` cannot
524
- // help here either — it would hand the caller the pending exit
525
- // promise as a `string[]`. The throw is what actually stops the
526
- // caller; on Node the exit(2) halts first and the throw is dead.
400
+ // Under GJS `process.exit()` is DEFERRED (no atexit — the call returns), so in
401
+ // this synchronous, value-returning helper a bare exit fell through to the
402
+ // `return` below and handed the caller a diff parsed from a failed git run.
403
+ // `return process.exit(…)` cannot help either: it would hand the caller the
404
+ // pending exit promise as a `string[]`. The throw is what stops the caller; on
405
+ // Node the exit(2) halts first and the throw is dead.
527
406
  // oxlint-disable-next-line gjsify/deferred-process-exit -- the throw below IS the halt for the GJS path; see the comment above.
528
407
  process.exit(2);
529
408
  throw new Error(`gjsify affected: git diff failed (${r.status})`);
530
409
  }
531
410
  return r.stdout.split('\n').filter(Boolean);
532
411
  }
533
- // ─── Output ────────────────────────────────────────────────────────────────
534
412
  function emit(format, r) {
535
413
  if (format === 'json') {
536
414
  process.stdout.write(JSON.stringify(r) + '\n');
@@ -542,8 +420,8 @@ function emit(format, r) {
542
420
  return;
543
421
  }
544
422
  if (format === 'github-actions') {
545
- // Unquoted, space-separated tokens — see the `include-args` CONTRACT
546
- // block at the top of this file. Do NOT re-add quoting here.
423
+ // Unquoted, space-separated tokens — see the `include-args` CONTRACT at the
424
+ // top of this file. Do NOT re-add quoting here.
547
425
  const includeArgs = r.global
548
426
  ? ''
549
427
  : r.workspaces.map((n) => `--include ${assertShellSafeWorkspaceName(n)}`).join(' ');
@@ -557,7 +435,6 @@ function emit(format, r) {
557
435
  `reason=${r.reason}`,
558
436
  ];
559
437
  if (out) {
560
- // GitHub Actions: append to $GITHUB_OUTPUT.
561
438
  for (const l of lines)
562
439
  appendFileSync(out, `${l}\n`);
563
440
  }
@@ -578,12 +455,10 @@ function emit(format, r) {
578
455
  for (const name of r.workspaces)
579
456
  process.stdout.write(` - ${name}\n`);
580
457
  }
581
- // A workspace name that survives an UNQUOTED shell expansion unchanged: no
582
- // whitespace, no quote/escape characters, no glob metacharacters, no `$`, and
583
- // nothing the shell would treat as an operator. npm package names are a strict
584
- // subset of this, so the guard is a machine-checked restatement of the
585
- // `include-args` contract rather than a real runtime branch. Returns the name
586
- // so it can be used inline at the emit site.
458
+ // A name that survives an UNQUOTED shell expansion unchanged: no whitespace, no
459
+ // quote/escape characters, no glob metacharacters, no `$`, nothing the shell reads
460
+ // as an operator. npm names are a strict subset, so this is a machine-checked
461
+ // restatement of the `include-args` contract rather than a real runtime branch.
587
462
  const SHELL_SAFE_WORKSPACE_NAME = /^[A-Za-z0-9@][A-Za-z0-9@._/-]*$/;
588
463
  function assertShellSafeWorkspaceName(name) {
589
464
  if (SHELL_SAFE_WORKSPACE_NAME.test(name))
@@ -592,10 +467,10 @@ function assertShellSafeWorkspaceName(name) {
592
467
  ` \`include-args\` token (the value is consumed by an UNQUOTED $INCLUDE_ARGS expansion — see the\n` +
593
468
  ` contract at the top of commands/affected.ts). Rename the workspace, or teach this command a\n` +
594
469
  ` transport the consumers can unquote. Refusing to emit a filter that would silently mis-match.\n`);
595
- // NOT unreachable under GJS: `process.exit()` is DEFERRED there (no atexit
596
- // — the call returns), so the throw below is what actually stops an unsafe
597
- // name from reaching the emit site; the exit code rides the scheduled
598
- // teardown. On Node the exit(2) halts first and the throw is dead code.
470
+ // NOT unreachable under GJS: `process.exit()` is DEFERRED there (no atexit — the
471
+ // call returns), so the throw below is what stops an unsafe name from reaching the
472
+ // emit site, and the exit code rides the scheduled teardown. On Node the exit(2)
473
+ // halts first and the throw is dead code.
599
474
  // oxlint-disable-next-line gjsify/deferred-process-exit -- the throw below IS the halt for the GJS path; see the comment above.
600
475
  process.exit(2);
601
476
  throw new Error(`gjsify affected: unsafe workspace name ${name}`);