@gjsify/cli 0.38.1 → 0.39.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.
@@ -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
+ }
@@ -56,7 +56,8 @@ import { spawnSync } from 'node:child_process';
56
56
  // every spec and fires only inside a GitHub Actions step.
57
57
  import { appendFileSync } from 'node:fs';
58
58
  import { readStdinLines } from '../utils/stdin.js';
59
- import { discoverWorkspaces, buildDependencyGraph, buildReverseDependencyGraph, affectedClosure, workspacesForChangedFiles, } from '@gjsify/workspace';
59
+ import { discoverWorkspaces } from '@gjsify/workspace';
60
+ import { classifyAndExpand } from './affected-classify.js';
60
61
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
61
62
  export const affectedCommand = {
62
63
  command: 'affected',
@@ -95,296 +96,6 @@ export const affectedCommand = {
95
96
  emit(args.format, result);
96
97
  },
97
98
  };
98
- /** Patterns that force a full run. First-match wins; order is intentional. */
99
- const GLOBAL_TRIGGERS = [
100
- // The classifier itself + everything in its plumbing.
101
- /^packages\/infra\/workspace\//,
102
- /^packages\/infra\/cli\//,
103
- /^packages\/infra\/rolldown-plugin-gjsify\//,
104
- /^packages\/infra\/resolve-npm\//,
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.
109
- /^packages\/gjs\/unit\//,
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.
114
- /^\.github\/actions\//,
115
- // Cross-cutting dep + lockfile + root config.
116
- /^gjsify-lock\.json$/,
117
- /^package\.json$/,
118
- /^tsconfig[^/]*\.json$/,
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.
122
- /^\.github\/workflows\/main\.yml$/,
123
- /^scripts\/audit-runtimes\.mjs$/,
124
- ];
125
- /**
126
- * Inputs OWNED BY ANOTHER WORKFLOW — build-relevant, just not to `main.yml`.
127
- *
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.
132
- */
133
- const OTHER_WORKFLOW_INPUTS = [
134
- // Each sibling workflow's own definition. `main.yml` is deliberately absent: it
135
- // is a GLOBAL_TRIGGER instead.
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$/,
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.
141
- /^\.github\/prebuild-toolchain\//,
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).
145
- //
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.
151
- /^scripts\/manifest-conformance\//,
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.
155
- //
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.
160
- /^status\//,
161
- /^scripts\/generate-status\.mjs$/,
162
- ];
163
- /** Patterns that contribute no seed and don't force a full run. */
164
- const IGNORE = [
165
- /\.md$/i,
166
- /^refs\//,
167
- /^website\//,
168
- /^docs\//,
169
- ...OTHER_WORKFLOW_INPUTS,
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.
176
- /^packages\/node-gi\//,
177
- /^packages\/napi\//,
178
- // Flatpak build/distribution tooling (SDK-extension manifest + metainfo). No
179
- // package-test consumers; its own `tests/e2e/flatpak-sdk-extension` runs on
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.
183
- /^flatpak\//,
184
- /^LICENSE/,
185
- /^\.gitignore$/,
186
- /^\.gjsify-[^/]*\.md$/,
187
- /^STATUS\.md$/,
188
- /^CHANGELOG\.md$/,
189
- /^AGENTS\.md$/,
190
- /^CLAUDE\.md$/,
191
- /^README\.md$/,
192
- ];
193
- /** Patterns that suggest a test-only change. */
194
- const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
195
- const SCRIPT_COUPLINGS = [
196
- {
197
- // `.githooks/pre-commit` is guarded by an e2e suite and reachable from no
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
203
- // trigger names the TEST's path instead of the thing under test.
204
- //
205
- // No extra seeds: there is no workspace to rebuild. The tier is the point.
206
- match: /^\.githooks\//,
207
- seeds: [],
208
- runE2E: true,
209
- why: 'tests/e2e/git-hooks-cli-bundle-staleness drives .githooks/pre-commit',
210
- },
211
- {
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.
221
- match: /^templates\//,
222
- seeds: ['@gjsify/create-app'],
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`.
227
- runE2E: true,
228
- why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
229
- },
230
- ];
231
- function classifyAndExpand(workspaces, changedFiles) {
232
- const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
233
- if (files.length === 0) {
234
- return {
235
- global: false,
236
- reason: 'empty-diff',
237
- workspaces: [],
238
- runE2E: false,
239
- runIntegration: false,
240
- skipAll: true,
241
- };
242
- }
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.
245
- const remaining = [];
246
- for (const f of files) {
247
- if (IGNORE.some((re) => re.test(f)))
248
- continue;
249
- remaining.push(f);
250
- }
251
- if (remaining.length === 0) {
252
- return {
253
- global: false,
254
- reason: 'ignored-only',
255
- workspaces: [],
256
- runE2E: false,
257
- runIntegration: false,
258
- skipAll: true,
259
- };
260
- }
261
- // Global triggers short-circuit — checked on the non-ignored remainder.
262
- for (const f of remaining) {
263
- for (const re of GLOBAL_TRIGGERS) {
264
- if (re.test(f)) {
265
- return {
266
- global: true,
267
- reason: `global-trigger ${re.source} matched ${f}`,
268
- workspaces: workspaces.map((w) => w.name),
269
- runE2E: true,
270
- runIntegration: true,
271
- skipAll: false,
272
- };
273
- }
274
- }
275
- }
276
- const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
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.
280
- const knownNames = new Set(workspaces.map((w) => w.name));
281
- const couplingSeeds = new Set();
282
- const couplingAccounted = new Set();
283
- let couplingRunE2E = false;
284
- for (const c of SCRIPT_COUPLINGS) {
285
- const hits = remaining.filter((f) => c.match.test(f));
286
- if (hits.length === 0)
287
- continue;
288
- for (const h of hits)
289
- couplingAccounted.add(h);
290
- for (const s of c.seeds) {
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.
295
- if (!knownNames.has(s)) {
296
- return {
297
- global: true,
298
- reason: `script-coupling seed ${s} is not a workspace (${c.match.source} → ${c.why}); ` +
299
- `SCRIPT_COUPLINGS in commands/affected.ts is stale`,
300
- workspaces: workspaces.map((w) => w.name),
301
- runE2E: true,
302
- runIntegration: true,
303
- skipAll: false,
304
- };
305
- }
306
- couplingSeeds.add(s);
307
- }
308
- if (c.runE2E)
309
- couplingRunE2E = true;
310
- }
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.
313
- const stillUnmatched = unmatched.filter((f) => !couplingAccounted.has(f));
314
- if (stillUnmatched.length > 0) {
315
- return {
316
- global: true,
317
- reason: `unmatched files (${stillUnmatched.length}): ${stillUnmatched.slice(0, 3).join(', ')}${stillUnmatched.length > 3 ? '…' : ''}`,
318
- workspaces: workspaces.map((w) => w.name),
319
- runE2E: true,
320
- runIntegration: true,
321
- skipAll: false,
322
- };
323
- }
324
- for (const s of couplingSeeds)
325
- matched.add(s);
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.
329
- const testOnly = couplingSeeds.size === 0 && remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
330
- if (testOnly && matched.size === 1) {
331
- const only = [...matched][0];
332
- // An e2e- or integration-only change still needs its own job.
333
- const touchedE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
334
- const touchedIntegration = remaining.some((f) => f.startsWith('tests/integration/'));
335
- return {
336
- global: false,
337
- reason: `test-only (${remaining.length} file(s) in ${only})`,
338
- workspaces: [only],
339
- runE2E: touchedE2E,
340
- runIntegration: touchedIntegration || isIntegrationWorkspace(only),
341
- skipAll: false,
342
- };
343
- }
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.
355
- const reverse = buildReverseDependencyGraph(workspaces, { includeDev: false });
356
- const closure = affectedClosure(reverse, [...matched]);
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.
361
- const forward = buildDependencyGraph(workspaces, { includeDev: true });
362
- let runIntegration = false;
363
- for (const [from, deps] of forward.edges) {
364
- if (!isIntegrationWorkspace(from))
365
- continue;
366
- for (const dep of deps) {
367
- if (closure.has(dep)) {
368
- closure.add(from);
369
- runIntegration = true;
370
- break;
371
- }
372
- }
373
- }
374
- const runE2E = remaining.some((f) => f.startsWith('tests/e2e/')) || couplingRunE2E;
375
- return {
376
- global: false,
377
- reason: `closure (${closure.size} ws from ${matched.size} seed(s))` +
378
- (couplingSeeds.size > 0 ? `, ${couplingSeeds.size} via script-coupling` : ''),
379
- workspaces: [...closure].sort(),
380
- runE2E,
381
- runIntegration,
382
- skipAll: false,
383
- };
384
- }
385
- function isIntegrationWorkspace(name) {
386
- return name.startsWith('@gjsify/integration-');
387
- }
388
99
  function runGitDiff(cwd, base, head) {
389
100
  // `base...head` lists changed paths on `head` relative to the MERGE-BASE, which
390
101
  // matches what a GitHub PR diff shows and survives stacked PRs without picking up