@gjsify/cli 0.38.1 → 0.40.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 (71) hide show
  1. package/dist/cli.gjs.mjs +212 -195
  2. package/lib/cli-app.js +11 -25
  3. package/lib/commands/affected-classify.d.ts +16 -0
  4. package/lib/commands/affected-classify.js +304 -0
  5. package/lib/commands/affected.js +2 -291
  6. package/lib/commands/flatpak/check.js +34 -43
  7. package/lib/commands/flatpak/ci.js +1 -1
  8. package/lib/commands/flatpak/deps.js +10 -19
  9. package/lib/commands/flatpak/diff.js +1 -1
  10. package/lib/commands/flatpak/init.js +1 -1
  11. package/lib/commands/flatpak/release.js +7 -11
  12. package/lib/commands/flatpak/scaffold.d.ts +2 -4
  13. package/lib/commands/flatpak/scaffold.js +25 -298
  14. package/lib/commands/flatpak/sync-flathub.js +18 -36
  15. package/lib/commands/foreach.js +34 -20
  16. package/lib/commands/gettext.js +7 -4
  17. package/lib/commands/index.d.ts +1 -0
  18. package/lib/commands/index.js +1 -0
  19. package/lib/commands/ship.d.ts +11 -0
  20. package/lib/commands/ship.js +217 -0
  21. package/lib/config.d.ts +12 -0
  22. package/lib/config.js +15 -0
  23. package/lib/types/config-data.d.ts +136 -46
  24. package/lib/utils/app-metadata.d.ts +51 -0
  25. package/lib/utils/app-metadata.js +338 -0
  26. package/lib/utils/cli-runtime-closure.d.ts +23 -0
  27. package/lib/utils/cli-runtime-closure.js +274 -0
  28. package/lib/utils/publish-headers.d.ts +13 -0
  29. package/lib/utils/publish-headers.js +30 -4
  30. package/lib/utils/ship/ar.d.ts +21 -0
  31. package/lib/utils/ship/ar.js +57 -0
  32. package/lib/utils/ship/bytes.d.ts +4 -0
  33. package/lib/utils/ship/bytes.js +23 -0
  34. package/lib/utils/ship/cpio.d.ts +22 -0
  35. package/lib/utils/ship/cpio.js +93 -0
  36. package/lib/utils/ship/deb.d.ts +24 -0
  37. package/lib/utils/ship/deb.js +118 -0
  38. package/lib/utils/ship/depends.d.ts +62 -0
  39. package/lib/utils/ship/depends.js +202 -0
  40. package/lib/utils/ship/discover.d.ts +21 -0
  41. package/lib/utils/ship/discover.js +129 -0
  42. package/lib/utils/ship/formats.d.ts +5 -0
  43. package/lib/utils/ship/formats.js +83 -0
  44. package/lib/utils/ship/gi-namespaces.d.ts +7 -0
  45. package/lib/utils/ship/gi-namespaces.js +49 -0
  46. package/lib/utils/ship/gzip.d.ts +2 -0
  47. package/lib/utils/ship/gzip.js +21 -0
  48. package/lib/utils/ship/launcher.d.ts +7 -0
  49. package/lib/utils/ship/launcher.js +37 -0
  50. package/lib/utils/ship/payload.d.ts +30 -0
  51. package/lib/utils/ship/payload.js +59 -0
  52. package/lib/utils/ship/plan.d.ts +34 -0
  53. package/lib/utils/ship/plan.js +167 -0
  54. package/lib/utils/ship/rpm-header.d.ts +29 -0
  55. package/lib/utils/ship/rpm-header.js +170 -0
  56. package/lib/utils/ship/rpm.d.ts +12 -0
  57. package/lib/utils/ship/rpm.js +377 -0
  58. package/lib/utils/ship/scripts.d.ts +31 -0
  59. package/lib/utils/ship/scripts.js +76 -0
  60. package/lib/utils/ship/settings.d.ts +60 -0
  61. package/lib/utils/ship/settings.js +184 -0
  62. package/lib/utils/ship/stage-writer.d.ts +17 -0
  63. package/lib/utils/ship/stage-writer.js +49 -0
  64. package/lib/utils/ship/types.d.ts +114 -0
  65. package/lib/utils/ship/types.js +10 -0
  66. package/lib/utils/ship/version.d.ts +17 -0
  67. package/lib/utils/ship/version.js +55 -0
  68. package/lib/utils/spawn.d.ts +11 -1
  69. package/lib/utils/spawn.js +28 -4
  70. package/package.json +19 -18
  71. /package/lib/templates/{flatpak → app}/desktop.tmpl +0 -0
package/lib/cli-app.js CHANGED
@@ -15,12 +15,10 @@
15
15
  // This module MUST NOT execute anything at load time (no top-level
16
16
  // `parseAsync`) — `run.ts` imports it, and a top-level run would re-dispatch
17
17
  // the original argv on import.
18
- import { readFileSync } from 'node:fs';
19
- import { dirname, join } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
21
18
  import yargs from 'yargs';
22
19
  import { classifyCliFailure } from './cli-fail.js';
23
- import { buildCommand as build, clearCommand as clear, copyCommand as copy, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, onboardCommand as onboard, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
20
+ import { cliVersion } from './utils/publish-headers.js';
21
+ import { buildCommand as build, clearCommand as clear, copyCommand as copy, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, shipCommand as ship, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, onboardCommand as onboard, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
24
22
  import { APP_NAME } from './constants.js';
25
23
  import { isBun, isDeno, isNode, gjsSystemVersion } from '@gjsify/rolldown-plugin-gjsify/runtime';
26
24
  import { ensureGjsifyShimOnPath } from './utils/gjsify-shim.js';
@@ -49,22 +47,6 @@ function runtimeLabel() {
49
47
  }
50
48
  return 'unknown runtime';
51
49
  }
52
- // Read the version from package.json adjacent to the bundle. yargs's
53
- // auto-version-discovery (its `pkg-up`-driven default) doesn't reach
54
- // through the bundled `dist/cli.gjs.mjs` path on GJS — falls back to
55
- // "unknown". Both layouts are covered:
56
- // - dev (tsx, `yarn workspace`): src/index.ts → ../package.json
57
- // - bundled (install -g): dist/cli.gjs.mjs → ../package.json
58
- function readBundleVersion() {
59
- try {
60
- const here = dirname(fileURLToPath(import.meta.url));
61
- const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
62
- return typeof pkg.version === 'string' ? pkg.version : 'unknown';
63
- }
64
- catch {
65
- return 'unknown';
66
- }
67
- }
68
50
  /**
69
51
  * Build + run the gjsify yargs command surface for the given argv (already
70
52
  * stripped of the `node`/`gjs` + script prefix — i.e. what `hideBin` returns).
@@ -75,15 +57,18 @@ function readBundleVersion() {
75
57
  * exit before any async work runs.
76
58
  */
77
59
  export async function runCli(argv) {
78
- // Under GJS, make a GJS-runnable `gjsify` available on PATH for child
79
- // processes (workspace/foreach orchestration + compound `gjsify run`
80
- // scripts) so node-free builds don't fall back to the Node bin. No-op on
81
- // Node.
60
+ // Make a runnable `gjsify` available on PATH for child processes
61
+ // (workspace/foreach orchestration + compound `gjsify run` scripts). TWO
62
+ // cases, not one — the "No-op on Node" this comment used to claim stopped
63
+ // being true when the bootstrap case landed: under GJS, so node-free builds
64
+ // don't fall back to the Node bin; and a BOOTSTRAP CLI under Node, where the
65
+ // tree's own `node_modules/.bin/gjsify` dangles until `build:infra` — which
66
+ // is what a cold macOS/Windows leg runs — has produced what it dispatches to.
82
67
  ensureGjsifyShimOnPath();
83
68
  const cli = yargs(argv);
84
69
  await cli
85
70
  .scriptName(APP_NAME)
86
- .version(readBundleVersion())
71
+ .version(cliVersion())
87
72
  .strict()
88
73
  // Sits beside `.strict()` because that is what produces most of what it
89
74
  // classifies. See `cli-fail.ts` for the four yargs measurements this
@@ -122,6 +107,7 @@ export async function runCli(argv) {
122
107
  .command(gettext.command, gettext.description, gettext.builder, gettext.handler)
123
108
  .command(gsettings.command, gsettings.description, gsettings.builder, gsettings.handler)
124
109
  .command(flatpak.command, flatpak.description, flatpak.builder, flatpak.handler)
110
+ .command(ship.command, ship.description, ship.builder, ship.handler)
125
111
  .command(foreach.command, foreach.description, foreach.builder, foreach.handler)
126
112
  .command(workspace.command, workspace.description, workspace.builder, workspace.handler)
127
113
  .command(pack.command, pack.description, pack.builder, pack.handler)
@@ -0,0 +1,16 @@
1
+ import { type Workspace } from '@gjsify/workspace';
2
+ export interface ClassifyResult {
3
+ /** Cannot decide precisely — caller should run full suite. */
4
+ global: boolean;
5
+ /** Reason string surfaced in logs / debugging. */
6
+ reason: string;
7
+ /** Sorted workspace names. Includes every test-targeted workspace. */
8
+ workspaces: string[];
9
+ /** Should `tests/e2e/**` run? */
10
+ runE2E: boolean;
11
+ /** Should `@gjsify/integration-*` workspaces run? */
12
+ runIntegration: boolean;
13
+ /** Diff was empty AND no triggers fired → CI can skip the whole job. */
14
+ skipAll: boolean;
15
+ }
16
+ export declare function classifyAndExpand(workspaces: readonly Workspace[], changedFiles: readonly string[]): ClassifyResult;
@@ -0,0 +1,304 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The `gjsify affected` CLASSIFIER — the pure decision, separated from the command.
3
+ //
4
+ // WHY THIS MODULE EXISTS (#1161). The spec used to reach `classifyAndExpand` by spawning
5
+ // the built CLI for all 40 cases, deliberately: the spawns kept the helper unexported.
6
+ // Measured, they cost 12.81 s against a 30 s per-`describe()` budget (11.68 s / 12.30 s on
7
+ // two other hosts, so it is the suite's property, not a host's). A runner 2.6x slower per
8
+ // spawn blows it, which CI macOS ordinarily is — so the failure was a BUDGET, never an
9
+ // assertion, on the legs that run on `main` rather than on PRs: a red `main` naming no test.
10
+ //
11
+ // The privacy survives without them: this module is NOT in the package's `exports` map, so
12
+ // the spec's relative import is the only way in. The seven cases that genuinely cross a
13
+ // process boundary — both emit branches, a real `sh` word-split — still spawn.
14
+ import { affectedClosure, buildDependencyGraph, buildReverseDependencyGraph, workspacesForChangedFiles, } from '@gjsify/workspace';
15
+ /** Patterns that force a full run. First-match wins; order is intentional. */
16
+ const GLOBAL_TRIGGERS = [
17
+ // The classifier itself + everything in its plumbing.
18
+ /^packages\/infra\/workspace\//,
19
+ /^packages\/infra\/cli\//,
20
+ /^packages\/infra\/rolldown-plugin-gjsify\//,
21
+ /^packages\/infra\/resolve-npm\//,
22
+ // The test framework every spec imports, as a *devDependency* — which the
23
+ // prod-deps-only closure below does not walk, so a change to it would yield a
24
+ // near-empty closure and silently skip every downstream test, while a bug in a
25
+ // matcher can break assertions anywhere.
26
+ /^packages\/gjs\/unit\//,
27
+ // The composite action every CI job sets itself up with: a change alters the
28
+ // environment of the whole matrix. Listed EXPLICITLY — it previously forced a
29
+ // full run only by falling through to the "unmatched files" path, which is luck
30
+ // rather than intent.
31
+ /^\.github\/actions\//,
32
+ // Cross-cutting dep + lockfile + root config.
33
+ /^gjsify-lock\.json$/,
34
+ /^package\.json$/,
35
+ /^tsconfig[^/]*\.json$/,
36
+ // The workflow file itself: a job-shape change is invisible until the workflow
37
+ // re-runs, so a path-filtered job cannot safely apply the new shape to an
38
+ // in-flight PR.
39
+ /^\.github\/workflows\/main\.yml$/,
40
+ /^scripts\/audit-runtimes\.mjs$/,
41
+ ];
42
+ /**
43
+ * Inputs OWNED BY ANOTHER WORKFLOW — build-relevant, just not to `main.yml`.
44
+ *
45
+ * Adding a regex here is a claim with two halves and BOTH must hold: `main.yml`
46
+ * must not read the path, and some other workflow must have it in its own
47
+ * `paths:` filter so a break still reds a PR. The test is never "is this file
48
+ * unimportant" — none of these are.
49
+ */
50
+ const OTHER_WORKFLOW_INPUTS = [
51
+ // Each sibling workflow's own definition. `main.yml` is deliberately absent: it
52
+ // is a GLOBAL_TRIGGER instead.
53
+ /^\.github\/workflows\/(deploy-docs|commitlint|release|release-cut|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image|cancel-pr-runs)\.yml$/,
54
+ // `prebuilds.yml`'s toolchain (#838): the QEMU-emulated build script and the
55
+ // classifier deciding which native packages a prebuild run rebuilds. It is in
56
+ // both of that workflow's `paths:` filters; `main.yml` builds no prebuild and
57
+ // runs no emulated leg.
58
+ /^\.github\/prebuild-toolchain\//,
59
+ // The REPO-SCOPED manifest-conformance rules (#847), gated by
60
+ // `audit-runtimes.yml`, which runs on EVERY pull_request with no `paths:` filter;
61
+ // `prebuilds.yml` also treats it as a shared input (#843).
62
+ //
63
+ // The distinction is real, not a technicality: `main.yml` DOES run
64
+ // manifest-conformance code, but reaches it through
65
+ // `packages/infra/manifest-conformance/` — the PACKAGE, a normal workspace that
66
+ // maps and seeds like any other. Only the `scripts/`-side repo-scoped half is
67
+ // ignored, and that is precisely the half `main.yml` does not load.
68
+ /^scripts\/manifest-conformance\//,
69
+ // The authored status data (ADR 0016) + its generator. Same two halves:
70
+ // `main.yml` never runs `status:generate` and never opens `status/`, while
71
+ // `audit-runtimes.yml` runs the `status-data` rule over it on every PR.
72
+ //
73
+ // Naming the directory makes the `.md` case intentional rather than incidental:
74
+ // `status/*.md` already fell into IGNORE via the generic `/\.md$/i`, so the gap
75
+ // showed up only on `status/status.json`, which matched nothing, landed in
76
+ // `unmatched`, and paid for a FULL matrix run over a file `main.yml` cannot read.
77
+ /^status\//,
78
+ /^scripts\/generate-status\.mjs$/,
79
+ ];
80
+ /** Patterns that contribute no seed and don't force a full run. */
81
+ const IGNORE = [
82
+ /\.md$/i,
83
+ /^refs\//,
84
+ /^website\//,
85
+ /^docs\//,
86
+ ...OTHER_WORKFLOW_INPUTS,
87
+ // Neither `@gjsify/node-gi` (the Node-native GI engine) nor `@gjsify/napi` (the
88
+ // N-API host over SpiderMonkey) is a gjsify workspace member: the GJS-first
89
+ // install/foreach tooling cannot build their node-gyp addons or Vala+C++/meson
90
+ // shim, so `main.yml` neither builds nor tests them and their own workflows are
91
+ // the source of truth. Without the carve-outs their files map to no workspace,
92
+ // land in `unmatched`, and force a full run on every node-gi/napi PR.
93
+ /^packages\/node-gi\//,
94
+ /^packages\/napi\//,
95
+ // Flatpak build/distribution tooling (SDK-extension manifest + metainfo). No
96
+ // package-test consumers; its own `tests/e2e/flatpak-sdk-extension` runs on
97
+ // `tests/e2e/**` and global triggers. Worth revisiting: ignoring `.githooks/`
98
+ // for the same reason is what let a change walk past the suite guarding it (see
99
+ // SCRIPT_COUPLINGS), and IGNORE wins over every other rule.
100
+ /^flatpak\//,
101
+ /^LICENSE/,
102
+ /^\.gitignore$/,
103
+ /^\.gjsify-[^/]*\.md$/,
104
+ /^STATUS\.md$/,
105
+ /^CHANGELOG\.md$/,
106
+ /^AGENTS\.md$/,
107
+ /^CLAUDE\.md$/,
108
+ /^README\.md$/,
109
+ ];
110
+ /** Patterns that suggest a test-only change. */
111
+ const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
112
+ const SCRIPT_COUPLINGS = [
113
+ {
114
+ // `.githooks/pre-commit` is guarded by an e2e suite and reachable from no
115
+ // workspace at all — not source, not a dependency, its consumer is git. So a
116
+ // change to it selected NOTHING and the e2e tier stayed off (that turns on
117
+ // for a `tests/e2e/**` touch): #1095 edited the hook's command line, merged
118
+ // green, and turned `main` red on the push run. Same shape #1028 noted —
119
+ // whoever changes the GUARDED thing walks past the guard, because the guard's
120
+ // trigger names the TEST's path instead of the thing under test.
121
+ //
122
+ // No extra seeds: there is no workspace to rebuild. The tier is the point.
123
+ match: /^\.githooks\//,
124
+ seeds: [],
125
+ runE2E: true,
126
+ why: 'tests/e2e/git-hooks-cli-bundle-staleness drives .githooks/pre-commit',
127
+ },
128
+ {
129
+ // `templates/*` ARE workspaces, so a template change already seeds its own
130
+ // `@gjsify/template-<name>` — not the consumer that matters. That consumer is
131
+ // `@gjsify/create-app`, unreachable twice over: it lives at
132
+ // `packages/infra/create-gjsify/`, so no directory-name search finds it, and
133
+ // it pulls the templates in through its own `scripts/process-template.mjs`
134
+ // build step (which resolves each `workspace:^` specifier and copies the
135
+ // result into `dist-templates/`), so there is no dependency edge either.
136
+ // `dist-templates/` is a build-output cache candidate, so once stale it
137
+ // stays stale.
138
+ match: /^templates\//,
139
+ seeds: ['@gjsify/create-app'],
140
+ // `tests/e2e/create-app` is the ONLY thing that would notice — it scaffolds a
141
+ // project out of `dist-templates/` and builds it. Without this a
142
+ // templates-only PR got neither the regenerated templates nor that suite;
143
+ // #853 passed only by also touching root `package.json`.
144
+ runE2E: true,
145
+ why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
146
+ },
147
+ ];
148
+ export function classifyAndExpand(workspaces, changedFiles) {
149
+ const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
150
+ if (files.length === 0) {
151
+ return {
152
+ global: false,
153
+ reason: 'empty-diff',
154
+ workspaces: [],
155
+ runE2E: false,
156
+ runIntegration: false,
157
+ skipAll: true,
158
+ };
159
+ }
160
+ // Ignored files go FIRST, before the global-trigger check: ignore wins over
161
+ // global, so `packages/infra/cli/README.md` must not force a full run.
162
+ const remaining = [];
163
+ for (const f of files) {
164
+ if (IGNORE.some((re) => re.test(f)))
165
+ continue;
166
+ remaining.push(f);
167
+ }
168
+ if (remaining.length === 0) {
169
+ return {
170
+ global: false,
171
+ reason: 'ignored-only',
172
+ workspaces: [],
173
+ runE2E: false,
174
+ runIntegration: false,
175
+ skipAll: true,
176
+ };
177
+ }
178
+ // Global triggers short-circuit — checked on the non-ignored remainder.
179
+ for (const f of remaining) {
180
+ for (const re of GLOBAL_TRIGGERS) {
181
+ if (re.test(f)) {
182
+ return {
183
+ global: true,
184
+ reason: `global-trigger ${re.source} matched ${f}`,
185
+ workspaces: workspaces.map((w) => w.name),
186
+ runE2E: true,
187
+ runIntegration: true,
188
+ skipAll: false,
189
+ };
190
+ }
191
+ }
192
+ }
193
+ const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
194
+ // Seeds no graph edge can reach. BEFORE the `unmatched` bail-out, so the table
195
+ // also works for a coupled directory that is not itself a workspace — such a
196
+ // file would otherwise force a full run and never consult the declared seeds.
197
+ const knownNames = new Set(workspaces.map((w) => w.name));
198
+ const couplingSeeds = new Set();
199
+ const couplingAccounted = new Set();
200
+ let couplingRunE2E = false;
201
+ for (const c of SCRIPT_COUPLINGS) {
202
+ const hits = remaining.filter((f) => c.match.test(f));
203
+ if (hits.length === 0)
204
+ continue;
205
+ for (const h of hits)
206
+ couplingAccounted.add(h);
207
+ for (const s of c.seeds) {
208
+ // A declared seed that is not a workspace means the table drifted from the
209
+ // tree (renamed, moved, removed). Fail towards the full run and SAY SO: a
210
+ // missing rebuild here is invisible, so this must never degrade quietly
211
+ // back into the bug the table exists to prevent.
212
+ if (!knownNames.has(s)) {
213
+ return {
214
+ global: true,
215
+ reason: `script-coupling seed ${s} is not a workspace (${c.match.source} → ${c.why}); ` +
216
+ `SCRIPT_COUPLINGS in commands/affected.ts is stale`,
217
+ workspaces: workspaces.map((w) => w.name),
218
+ runE2E: true,
219
+ runIntegration: true,
220
+ skipAll: false,
221
+ };
222
+ }
223
+ couplingSeeds.add(s);
224
+ }
225
+ if (c.runE2E)
226
+ couplingRunE2E = true;
227
+ }
228
+ // Unmatched-but-not-ignored (a new top-level dotfile, an uncarved `scripts/`
229
+ // file) falls back to the full run. A file a coupling claimed does not count.
230
+ const stillUnmatched = unmatched.filter((f) => !couplingAccounted.has(f));
231
+ if (stillUnmatched.length > 0) {
232
+ return {
233
+ global: true,
234
+ reason: `unmatched files (${stillUnmatched.length}): ${stillUnmatched.slice(0, 3).join(', ')}${stillUnmatched.length > 3 ? '…' : ''}`,
235
+ workspaces: workspaces.map((w) => w.name),
236
+ runE2E: true,
237
+ runIntegration: true,
238
+ skipAll: false,
239
+ };
240
+ }
241
+ for (const s of couplingSeeds)
242
+ matched.add(s);
243
+ // Every remaining file is a spec / e2e / integration path under ONE workspace, so
244
+ // skip the closure: test code has no downstream consumers. A coupling disables
245
+ // this — its extra seeds exist precisely because a consumer DOES care.
246
+ const testOnly = couplingSeeds.size === 0 && remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
247
+ if (testOnly && matched.size === 1) {
248
+ const only = [...matched][0];
249
+ // An e2e- or integration-only change still needs its own job.
250
+ const touchedE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
251
+ const touchedIntegration = remaining.some((f) => f.startsWith('tests/integration/'));
252
+ return {
253
+ global: false,
254
+ reason: `test-only (${remaining.length} file(s) in ${only})`,
255
+ workspaces: [only],
256
+ runE2E: touchedE2E,
257
+ runIntegration: touchedIntegration || isIntegrationWorkspace(only),
258
+ skipAll: false,
259
+ };
260
+ }
261
+ // PRODUCTION dependencies only. The closure answers "whose tests must re-run
262
+ // because a package they depend on changed", which is a RUNTIME relationship;
263
+ // walking devDependency edges conflated it with the build/test toolchain, so ANY
264
+ // single-package change fanned out to ~210 of 221 workspaces and selective CI
265
+ // bought almost nothing. Prod-only collapses a typical seed to a handful (sqlite
266
+ // 210→4, fetch 210→34) while keeping every real runtime dependent.
267
+ //
268
+ // Safe against under-selection: no `*.spec.ts` imports a CROSS-PACKAGE sibling
269
+ // via a devDependency (the only such imports are self-imports, covered by the
270
+ // package being its own seed), `@gjsify/unit` is a GLOBAL_TRIGGER above, and
271
+ // every push-to-main plus the nightly cron still run the FULL suite.
272
+ const reverse = buildReverseDependencyGraph(workspaces, { includeDev: false });
273
+ const closure = affectedClosure(reverse, [...matched]);
274
+ // `includeDev: true`, deliberately broader than the reverse closure: integration
275
+ // packages declare the pillars they exercise as `dependencies`, but the wider
276
+ // walk tolerates a devDep-declared edge too. Over-running an integration suite is
277
+ // cheap; missing one is not.
278
+ const forward = buildDependencyGraph(workspaces, { includeDev: true });
279
+ let runIntegration = false;
280
+ for (const [from, deps] of forward.edges) {
281
+ if (!isIntegrationWorkspace(from))
282
+ continue;
283
+ for (const dep of deps) {
284
+ if (closure.has(dep)) {
285
+ closure.add(from);
286
+ runIntegration = true;
287
+ break;
288
+ }
289
+ }
290
+ }
291
+ const runE2E = remaining.some((f) => f.startsWith('tests/e2e/')) || couplingRunE2E;
292
+ return {
293
+ global: false,
294
+ reason: `closure (${closure.size} ws from ${matched.size} seed(s))` +
295
+ (couplingSeeds.size > 0 ? `, ${couplingSeeds.size} via script-coupling` : ''),
296
+ workspaces: [...closure].sort(),
297
+ runE2E,
298
+ runIntegration,
299
+ skipAll: false,
300
+ };
301
+ }
302
+ function isIntegrationWorkspace(name) {
303
+ return name.startsWith('@gjsify/integration-');
304
+ }