@ticket-gate/engine 0.0.0-stage → 0.1.0-rc.1

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 (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +81 -2
  3. package/bin/ticket-gate.mjs +261 -0
  4. package/checks/assertions-unguarded.mjs +31 -0
  5. package/checks/assertions.mjs +319 -0
  6. package/checks/cache-report.mjs +313 -0
  7. package/checks/docs-stale.mjs +69 -0
  8. package/checks/duplication.mjs +258 -0
  9. package/checks/ratchet-options.mjs +51 -0
  10. package/checks/surface.mjs +397 -0
  11. package/cli.mjs +379 -0
  12. package/commands-config.mjs +55 -0
  13. package/config.mjs +597 -0
  14. package/digest.mjs +195 -0
  15. package/env-name.mjs +24 -0
  16. package/failures.mjs +256 -0
  17. package/fu.mjs +458 -0
  18. package/graph/js.mjs +517 -0
  19. package/graph/load-time.mjs +309 -0
  20. package/graph/typescript.mjs +28 -0
  21. package/hooks/pre-tool-use.mjs +213 -0
  22. package/hooks/stop.mjs +495 -0
  23. package/is-main.mjs +15 -0
  24. package/kill-group.mjs +13 -0
  25. package/ledger.mjs +186 -0
  26. package/lock.d.mts +72 -0
  27. package/lock.mjs +329 -0
  28. package/move-proof.mjs +206 -0
  29. package/mutations/check.mjs +506 -0
  30. package/mutations/cli.mjs +361 -0
  31. package/mutations/exec.mjs +120 -0
  32. package/mutations/gate.mjs +148 -0
  33. package/mutations/harness.mjs +240 -0
  34. package/mutations/kinds.mjs +148 -0
  35. package/mutations/locate.mjs +71 -0
  36. package/mutations/project.mjs +23 -0
  37. package/mutations/registry.mjs +173 -0
  38. package/mutations/rotate.mjs +419 -0
  39. package/mutations/rotation-status.mjs +93 -0
  40. package/mutations/run.mjs +252 -0
  41. package/mutations/select.mjs +333 -0
  42. package/mutations/served.mjs +216 -0
  43. package/mutations/validate.mjs +86 -0
  44. package/mutations/verdict.mjs +159 -0
  45. package/nightly/cli.mjs +30 -0
  46. package/nightly/define.mjs +111 -0
  47. package/nightly/e2e-runs.mjs +73 -0
  48. package/nightly/gate.mjs +25 -0
  49. package/nightly/io.mjs +109 -0
  50. package/nightly/launchd.mjs +42 -0
  51. package/nightly/record.mjs +231 -0
  52. package/nightly/run.mjs +470 -0
  53. package/nightly/schedule.mjs +76 -0
  54. package/nightly/seed.mjs +78 -0
  55. package/nightly/status.mjs +189 -0
  56. package/nightly/suites.mjs +43 -0
  57. package/nightly-config.mjs +38 -0
  58. package/nightly-window.mjs +6 -0
  59. package/pack-hooks-all.mjs +15 -0
  60. package/pack-hooks.mjs +26 -0
  61. package/package.json +19 -4
  62. package/plan.mjs +272 -0
  63. package/precheck.mjs +90 -0
  64. package/project.mjs +21 -0
  65. package/ratchet.mjs +431 -0
  66. package/receipt.mjs +34 -0
  67. package/review-map.mjs +432 -0
  68. package/root.mjs +33 -0
  69. package/run.mjs +291 -0
  70. package/runners/playwright.mjs +132 -0
  71. package/score/adapters/stryker.mjs +111 -0
  72. package/score/base.mjs +82 -0
  73. package/score/cli.mjs +182 -0
  74. package/score/judge.mjs +107 -0
  75. package/score/lines.mjs +40 -0
  76. package/score/schema.mjs +68 -0
  77. package/score/survivors.mjs +103 -0
  78. package/score/targets.mjs +34 -0
  79. package/score-config.mjs +120 -0
  80. package/selection/check.mjs +45 -0
  81. package/selection/cli.mjs +267 -0
  82. package/selection/define.mjs +55 -0
  83. package/selection/for.mjs +235 -0
  84. package/selection/graphs.mjs +38 -0
  85. package/selection/project.mjs +11 -0
  86. package/selection/select.mjs +159 -0
  87. package/selection/units.mjs +646 -0
  88. package/selection/value-rules.mjs +142 -0
  89. package/selection-config.mjs +76 -0
  90. package/serve.mjs +142 -0
  91. package/summary.mjs +34 -0
  92. package/tree.mjs +211 -0
package/run.mjs ADDED
@@ -0,0 +1,291 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { GATE } from './project.mjs';
11
+ import { pick } from './config.mjs';
12
+ import { stopsOf } from './plan.mjs';
13
+ import { receiptOk } from './receipt.mjs';
14
+ import { oneLine } from './summary.mjs';
15
+ import { ROOT } from './root.mjs';
16
+
17
+ export const STEP_IDS = GATE.steps.map((s) => s.id);
18
+
19
+ // Seconds a step may run before the watchdog kills its process group. A step
20
+ // that needs longer gets `--timeout=<id>:<sec>`; the limit is never removed.
21
+ export const STEP_LIMITS = Object.fromEntries(
22
+ GATE.steps.filter((s) => !s.human).map((s) => [s.id, s.limitS]),
23
+ );
24
+
25
+ const READY_LIMIT_S = 120;
26
+
27
+ const STACK_STEPS = GATE.steps.filter((s) => s.stack).map((s) => s.id);
28
+
29
+ // Steps before any GATE.stopOnRedBefore id in step order; a red one stops the stack steps.
30
+ const preStack = (id) =>
31
+ stopsOf(GATE).some((s) => STEP_IDS.indexOf(id) < STEP_IDS.indexOf(s));
32
+
33
+ const digestOn = (step) => (step.digest ? { digest: step.digest } : {});
34
+
35
+ /**
36
+ * Turns a plan into receipt results. `runStep(step, { limitS })` executes one
37
+ * `run` step and returns `{ status, out, timedOut?, streamed? }`. Steps the
38
+ * diff did not select (`notSelected`) produce no result. Each run result gets
39
+ * `ms`, and `limitS` when `--timeout` overrode that step's limit. A red
40
+ * `preflight` stops the gate (D1): nothing after it runs, the receipt is red.
41
+ */
42
+ export function buildResults(
43
+ plan,
44
+ runStep,
45
+ {
46
+ limits = STEP_LIMITS,
47
+ overridden = new Set(),
48
+ lock = null,
49
+ keepGoing = false,
50
+ } = {},
51
+ ) {
52
+ const results = [];
53
+ const failures = [];
54
+ // The id of the first red step before `seed`; once set, no stack
55
+ // step (and no preflight) that has not started is run.
56
+ let stoppedBy = null;
57
+ try {
58
+ for (const step of plan.steps) {
59
+ if (step.mode === 'human') {
60
+ console.log(`\n=== HUMAN STEP (not run): ${step.cmd}\n`);
61
+ results.push({
62
+ id: step.id,
63
+ cmd: step.cmd,
64
+ status: 'human',
65
+ summary: step.summary(''),
66
+ });
67
+ continue;
68
+ }
69
+ if (step.mode === 'reuse') {
70
+ console.log(`\n=== REUSED (${step.reusedFrom}): ${step.cmd}\n`);
71
+ results.push({
72
+ id: step.id,
73
+ cmd: step.cmd,
74
+ status: 'reused',
75
+ summary: `${step.reusedSummary} (reused from ${step.reusedFrom})`,
76
+ digest: step.digest,
77
+ reusedFrom: step.reusedFrom,
78
+ });
79
+ continue;
80
+ }
81
+ if (step.mode === 'skip') {
82
+ console.log(`\n=== SKIPPED (--skip=${step.id}): ${step.cmd}\n`);
83
+ results.push({
84
+ id: step.id,
85
+ cmd: step.cmd,
86
+ status: 'skipped',
87
+ summary: 'skipped by request: NOT verified',
88
+ ...digestOn(step),
89
+ });
90
+ continue;
91
+ }
92
+ if (
93
+ stoppedBy !== null &&
94
+ (STACK_STEPS.includes(step.id) ||
95
+ step.id === 'preflight' ||
96
+ step.health)
97
+ ) {
98
+ console.log(`\n=== STOPPED (${stoppedBy} red): ${step.cmd}\n`);
99
+ results.push({
100
+ id: step.id,
101
+ cmd: step.cmd,
102
+ status: 'stopped',
103
+ summary: `not run (stopped: ${stoppedBy} red)`,
104
+ ...digestOn(step),
105
+ });
106
+ continue;
107
+ }
108
+ console.log(`\n=== ${step.cmd} (${step.why})\n`);
109
+ const started = Date.now();
110
+ if (step.id === 'preflight') {
111
+ const got = lock ? lock.acquire() : { ok: true };
112
+ if (!got.ok) {
113
+ results.push({
114
+ id: 'preflight',
115
+ cmd: step.cmd,
116
+ status: 'fail',
117
+ summary: got.summary,
118
+ ms: Date.now() - started,
119
+ });
120
+ failures.push({
121
+ id: 'preflight',
122
+ cmd: step.cmd,
123
+ why: step.why,
124
+ out: `${got.summary}\n`,
125
+ });
126
+ break;
127
+ }
128
+ }
129
+ if (typeof step.prepare === 'function') {
130
+ try {
131
+ step.prepare();
132
+ } catch (e) {
133
+ const summary = `e2e list: ${e.message}`;
134
+ results.push({
135
+ id: step.id,
136
+ cmd: step.cmd,
137
+ status: 'fail',
138
+ summary,
139
+ ms: Date.now() - started,
140
+ ...digestOn(step),
141
+ });
142
+ failures.push({
143
+ id: step.id,
144
+ cmd: step.cmd,
145
+ why: step.why,
146
+ out: `${summary}\n`,
147
+ });
148
+ continue;
149
+ }
150
+ }
151
+ const limitS = limits[step.id] ?? step.limitS;
152
+ const first = runStep(step, { limitS });
153
+ if (first.streamed !== true) process.stdout.write(first.out);
154
+ let ok = first.status === 0 && first.timedOut !== true;
155
+ let { out } = first;
156
+ let timedOut = first.timedOut === true;
157
+ let timedOutLimit = limitS;
158
+ let readyFailed = false;
159
+ if (ok && step.ready) {
160
+ console.log(
161
+ `\n=== ${step.ready} (${step.id} is up; waiting for the services later steps need)\n`,
162
+ );
163
+ const r = runStep(
164
+ { id: step.id, cmd: step.ready, append: true },
165
+ { limitS: READY_LIMIT_S },
166
+ );
167
+ if (r.streamed !== true) process.stdout.write(r.out);
168
+ if (r.status !== 0 || r.timedOut === true) {
169
+ ok = false;
170
+ readyFailed = true;
171
+ out = r.out;
172
+ timedOut = r.timedOut === true;
173
+ timedOutLimit = READY_LIMIT_S;
174
+ }
175
+ }
176
+ if (!ok) {
177
+ const hint = STACK_STEPS.includes(step.id)
178
+ ? ` Check the stack with: ${GATE.stackHint(step.id)}`
179
+ : ` Rerun this step alone: ${step.cmd}`;
180
+ failures.push({
181
+ id: step.id,
182
+ cmd: step.cmd,
183
+ why: step.why,
184
+ out: timedOut
185
+ ? `${out}\nTIMEOUT after ${timedOutLimit} s (limit ${step.id}: ${timedOutLimit} s).${hint}\n`
186
+ : out,
187
+ });
188
+ }
189
+ let summary;
190
+ if (timedOut)
191
+ summary = `TIMEOUT after ${timedOutLimit} s (limit ${step.id}: ${timedOutLimit} s)`;
192
+ else if (readyFailed) summary = oneLine(out) || 'stack not ready';
193
+ else if (ok) summary = step.summary(out) ?? 'passed';
194
+ else
195
+ summary =
196
+ step.failSummary?.(out) ??
197
+ pick(out, [/Code style issues/, /error/i, /failed/i]) ??
198
+ 'failed';
199
+ results.push({
200
+ id: step.id,
201
+ cmd: step.cmd,
202
+ status: ok ? 'pass' : 'fail',
203
+ summary,
204
+ ms: Date.now() - started,
205
+ ...digestOn(step),
206
+ ...(overridden.has(step.id) ? { limitS } : {}),
207
+ });
208
+ // prettier-ignore
209
+ if (!ok && !keepGoing && stoppedBy === null && preStack(step.id)) stoppedBy = step.id;
210
+ if (step.id === 'preflight' && !ok) break;
211
+ }
212
+ } finally {
213
+ lock?.release();
214
+ }
215
+ // `ok` means every required step actually ran and passed. A skipped step is
216
+ // not ok: the gate reads `results` too, but `ok` is what a human reads first.
217
+ return { results, ok: receiptOk(results), failures, stoppedBy };
218
+ }
219
+
220
+ // Each step runs in its own process group under a watchdog that kills the
221
+ // group once this process is gone. Without it, a gate stopped by a signal to
222
+ // its own pid (pkill, a closed agent shell) left `supabase db reset` running
223
+ // at full CPU for over an hour, and the next gate timed out on load.
224
+ // The same watchdog enforces the step's time limit: past it the group gets
225
+ // TERM, then KILL 5 s later, and the marker file says the limit (not the step)
226
+ // ended it. $1 cmd, $2 limit in seconds (0 = none), $3 log (appended), $4 marker.
227
+ // Output reaches the terminal live through tee and is kept in the log.
228
+ const STEP_WATCHDOG = String.raw`
229
+ set -m
230
+ ( set -o pipefail; bash -lc "$1" 2>&1 | tee -a "$3" ) &
231
+ child=$!
232
+ set +m
233
+ ( while kill -0 "$VERIFY_TICKET_PID" 2>/dev/null; do sleep 2; done
234
+ kill -TERM -"$child" 2>/dev/null; sleep 5; kill -KILL -"$child" 2>/dev/null ) >/dev/null 2>&1 &
235
+ watch=$!
236
+ timer=
237
+ if [ "$2" -gt 0 ]; then
238
+ ( sleep "$2"; echo timeout > "$4"; kill -TERM -"$child" 2>/dev/null; sleep 5; kill -KILL -"$child" 2>/dev/null ) >/dev/null 2>&1 &
239
+ timer=$!
240
+ fi
241
+ wait "$child"; status=$?
242
+ # The timer arm's sleep is its child and outlives a kill of the subshell: stop the
243
+ # subshell (it can fork no more), find its sleep, then kill both.
244
+ kids=
245
+ if [ -n "$timer" ]; then kill -STOP "$timer" 2>/dev/null; kids=$(pgrep -P "$timer" 2>/dev/null); fi
246
+ kill "$watch" 2>/dev/null
247
+ [ -n "$timer" ] && kill -KILL "$timer" $kids 2>/dev/null
248
+ exit "$status"`;
249
+
250
+ /**
251
+ * Runs one step under the watchdog. Logs under `logDir/<id>.log` (only .turbo/
252
+ * is ever written); `step.append` keeps the existing log and returns just the
253
+ * new part. `timedOut` is true only when the watchdog's timer fired: a step
254
+ * that itself exits 124 is an ordinary failure.
255
+ */
256
+ export const runInShell = (
257
+ step,
258
+ { limitS, logDir = join(ROOT, '.turbo', 'verify-steps') } = {},
259
+ ) => {
260
+ mkdirSync(logDir, { recursive: true });
261
+ const log = join(logDir, `${step.id ?? 'step'}.log`);
262
+ const marker = `${log}.timeout`;
263
+ rmSync(marker, { force: true });
264
+ if (!step.append) writeFileSync(log, '');
265
+ const before = readFileSync(log, 'utf8').length;
266
+ const r = spawnSync(
267
+ 'bash',
268
+ [
269
+ '-c',
270
+ STEP_WATCHDOG,
271
+ 'verify-step',
272
+ step.cmd,
273
+ String(limitS ?? 0),
274
+ log,
275
+ marker,
276
+ ],
277
+ {
278
+ cwd: ROOT,
279
+ stdio: ['ignore', 'inherit', 'inherit'],
280
+ env: { ...process.env, VERIFY_TICKET_PID: String(process.pid) },
281
+ },
282
+ );
283
+ const timedOut = existsSync(marker);
284
+ rmSync(marker, { force: true });
285
+ return {
286
+ status: r.status ?? (timedOut ? 124 : 1),
287
+ out: readFileSync(log, 'utf8').slice(before),
288
+ timedOut,
289
+ streamed: true,
290
+ };
291
+ };
@@ -0,0 +1,132 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import { projectSelection } from '../selection/project.mjs';
4
+
5
+ const ERROR_CAP = 2000;
6
+
7
+ /** Walks Playwright's nested suites, calling visit(spec, test) for each test. */
8
+ export function walkReport(suites, visit) {
9
+ for (const s of suites ?? []) {
10
+ for (const spec of s.specs ?? [])
11
+ for (const t of spec.tests ?? []) visit(spec, t, s);
12
+ walkReport(s.suites, visit);
13
+ }
14
+ }
15
+
16
+ /** Playwright JSON -> { stats, failedSpecs, flakySpecs, redTests }; null stats = interrupted. */
17
+ export function summarizeReport(playwrightJson) {
18
+ const report = playwrightJson ?? {};
19
+ const stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
20
+ const failed = new Set();
21
+ const flaky = new Set();
22
+ const redTests = [];
23
+ const navigation = {};
24
+ walkReport(report.suites, (spec, t) => {
25
+ if (t.status in stats) stats[t.status] += 1;
26
+ const file = spec.file ?? '';
27
+ const notes = [
28
+ ...(t.annotations ?? []),
29
+ ...(t.results ?? []).flatMap((r) => r.annotations ?? []),
30
+ ];
31
+ for (const a of notes) {
32
+ if (a?.type !== 'e2e-nav') continue;
33
+ const seen = (navigation[file] ??= []);
34
+ for (const p of String(a.description ?? '').split(' '))
35
+ if (p !== '' && !seen.includes(p)) seen.push(p);
36
+ }
37
+ if (t.status === 'unexpected') {
38
+ failed.add(file);
39
+ const last = (t.results ?? []).at(-1);
40
+ const msg = last?.error?.message ?? last?.errors?.[0]?.message ?? '';
41
+ redTests.push({
42
+ file,
43
+ title: spec.title,
44
+ project: t.projectName,
45
+ error: String(msg).slice(0, ERROR_CAP),
46
+ });
47
+ }
48
+ if (t.status === 'flaky') flaky.add(file);
49
+ });
50
+ return {
51
+ stats: report.stats ? stats : null,
52
+ failedSpecs: [...failed].sort(),
53
+ flakySpecs: [...flaky].sort(),
54
+ redTests,
55
+ navigation,
56
+ };
57
+ }
58
+
59
+ // ---------------------------------------------------------------- shadow and test list
60
+
61
+ const isSmoke = (spec, t) =>
62
+ [...(spec.tags ?? []), ...(t.tags ?? [])].some(
63
+ (x) => String(x).replace(/^@/, '') === 'smoke',
64
+ ) || /@smoke\b/.test(spec.title ?? '');
65
+
66
+ /** What a partial run would have missed (R6). Never changes the gate's result. */
67
+ export function shadowOf(selection, report, cfg = projectSelection()) {
68
+ const selected = new Set(selection.specs);
69
+ const base = {
70
+ mode: selection.mode,
71
+ why: selection.why,
72
+ selected: [...selection.specs],
73
+ };
74
+ if (selection.mode === 'full') return { ...base, missed: [] };
75
+ if (report == null) return { ...base, missed: null, error: 'no-report' };
76
+ const failed = new Set();
77
+ walkReport(report.suites, (spec, t) => {
78
+ if (t.status !== 'unexpected') return;
79
+ const file = spec.file ?? '';
80
+ if (cfg.specs.setup.has(file) || isSmoke(spec, t)) return;
81
+ failed.add(file);
82
+ });
83
+ const failedFiles = [...failed];
84
+ const missed = failedFiles.filter((f) => !selected.has(f));
85
+ return { ...base, missed: missed.sort() };
86
+ }
87
+
88
+ /** Lines for `playwright test --test-list`: every test of a selected file plus smoke tests. */
89
+ export function testListLines(listJson, selection) {
90
+ if (selection.mode === 'full') return null;
91
+ const selected = new Set(selection.specs);
92
+ const lines = new Set();
93
+ const visit = (suites, titles) => {
94
+ for (const s of suites ?? []) {
95
+ const own = titles.length === 0 && s.title === s.file ? [] : [s.title];
96
+ const path = [...titles, ...own.filter(Boolean)];
97
+ for (const spec of s.specs ?? []) {
98
+ const file = spec.file ?? s.file ?? '';
99
+ for (const t of spec.tests ?? [])
100
+ if (selected.has(file) || isSmoke(spec, t))
101
+ lines.add(`${file} › ${[...path, spec.title].join(' › ')}`);
102
+ }
103
+ visit(s.suites, path);
104
+ }
105
+ };
106
+ visit(listJson?.suites, []);
107
+ return [...lines];
108
+ }
109
+
110
+ // ---------------------------------------------------------------- runner
111
+
112
+ /**
113
+ * The Playwright runner adapter. `cmd` is the command that runs `playwright test` from the repo
114
+ * root; `specPrefix` is the spec path prefix that command takes.
115
+ */
116
+ export function playwright({ cmd, specPrefix }) {
117
+ return {
118
+ name: 'playwright',
119
+ command: cmd.join(' '),
120
+ specPrefix,
121
+ list: (cwd) =>
122
+ JSON.parse(
123
+ execFileSync(cmd[0], [...cmd.slice(1), '--list', '--reporter=json'], {
124
+ cwd,
125
+ encoding: 'utf8',
126
+ maxBuffer: 1 << 28,
127
+ }),
128
+ ),
129
+ summarize: summarizeReport,
130
+ invertFlag: '--test-list-invert',
131
+ };
132
+ }
@@ -0,0 +1,111 @@
1
+ // The Stryker adapter of the mutation score gate: how to run the mutation
2
+ // tool on the branch and on a base worktree, and which lines disable it.
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readFileSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ import { fail2, git } from '../base.mjs';
8
+
9
+ export const DISABLE_WITH_REASON =
10
+ /Stryker\s+disable(?:\s+next-line)?\s+[\w,]+\s*:\s*\S/;
11
+
12
+ /** Lines of `source` that disable Stryker without a stated reason. */
13
+ export function disablesWithoutReason(source) {
14
+ const bad = [];
15
+ source.split('\n').forEach((text, i) => {
16
+ if (!/Stryker\s+disable/.test(text)) return;
17
+ const line = text;
18
+ if (!DISABLE_WITH_REASON.test(line)) bad.push({ line: i + 1 });
19
+ });
20
+ return bad;
21
+ }
22
+
23
+ function runStryker(cmd, args, cwd, label) {
24
+ const r = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
25
+ if (r.status !== 0)
26
+ fail2(
27
+ `${label} Stryker run exited ${r.status ?? r.signal}. Run it by hand: cd ${cwd} && ${cmd} ${args.join(' ')}`,
28
+ );
29
+ }
30
+
31
+ function addedDisables(root, base, path, isNew) {
32
+ const found = [];
33
+ if (isNew) {
34
+ readFileSync(join(root, path), 'utf8')
35
+ .split('\n')
36
+ .forEach((text, i) => {
37
+ if (/Stryker\s+disable/.test(text))
38
+ found.push({ path, line: i + 1, text });
39
+ });
40
+ return found;
41
+ }
42
+ const diff = git(root, [
43
+ 'diff',
44
+ '-U0',
45
+ '--no-renames',
46
+ '--no-color',
47
+ base,
48
+ '--',
49
+ path,
50
+ ]);
51
+ let line = 0;
52
+ for (const l of diff.split('\n')) {
53
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(l);
54
+ if (h) line = Number(h[1]);
55
+ else if (l.startsWith('+') && !l.startsWith('+++')) {
56
+ if (/Stryker\s+disable/.test(l))
57
+ found.push({ path, line, text: l.slice(1) });
58
+ line++;
59
+ }
60
+ }
61
+ return found;
62
+ }
63
+
64
+ /**
65
+ * `branchCmd`: the command (argv) that runs Stryker for the branch, given
66
+ * `--mutate <files>` appended. `packageDir`: the package the files live in.
67
+ * `config`: Stryker's config file inside it. `reportPath`: the JSON report,
68
+ * relative to the repo root (and to a base worktree).
69
+ */
70
+ export function stryker({ branchCmd, packageDir, config, reportPath }) {
71
+ const rel = (t) =>
72
+ packageDir === '.' ? t : t.slice(`${packageDir}/`.length);
73
+ const forceArg = (force) => (force ? ['--force'] : []);
74
+ return {
75
+ name: 'stryker',
76
+ reportPath,
77
+ runBranch({ root, files, force }) {
78
+ rmSync(join(root, reportPath), { force: true });
79
+ runStryker(
80
+ branchCmd[0],
81
+ [
82
+ ...branchCmd.slice(1),
83
+ '--mutate',
84
+ files.map(rel).join(','),
85
+ ...forceArg(force),
86
+ ],
87
+ root,
88
+ 'branch',
89
+ );
90
+ },
91
+ runBase({ root, dir, files, force }) {
92
+ const out = join(dir, reportPath);
93
+ rmSync(out, { force: true });
94
+ runStryker(
95
+ join(root, packageDir, 'node_modules', '.bin', 'stryker'),
96
+ [
97
+ 'run',
98
+ join(root, packageDir, config),
99
+ '--mutate',
100
+ files.map(rel).join(','),
101
+ ...forceArg(force),
102
+ ],
103
+ join(dir, packageDir),
104
+ 'base',
105
+ );
106
+ return out;
107
+ },
108
+ disablesWithoutReason,
109
+ addedDisables,
110
+ };
111
+ }
package/score/base.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // Git, report and base-worktree helpers of the mutation score gate. Every
2
+ // project value (install command, marker, worktree folder) arrives as `cfg`.
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ import { schemaProblem } from './schema.mjs';
9
+
10
+ export class GateError extends Error {}
11
+
12
+ export const fail2 = (msg) => {
13
+ throw new GateError(`mutation: FAIL ${msg}`);
14
+ };
15
+
16
+ export function git(cwd, args) {
17
+ return execFileSync('git', args, {
18
+ cwd,
19
+ encoding: 'utf8',
20
+ maxBuffer: 256 * 1024 * 1024,
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ });
23
+ }
24
+
25
+ export const gitOk = (cwd, args) => {
26
+ try {
27
+ git(cwd, args);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ };
33
+
34
+ export function readReport(path, label) {
35
+ if (!existsSync(path)) fail2(`${label} report missing: ${path}`);
36
+ let report;
37
+ try {
38
+ report = JSON.parse(readFileSync(path, 'utf8'));
39
+ } catch (e) {
40
+ return fail2(`${label} report unreadable: ${path} (${e.message})`);
41
+ }
42
+ const problem = schemaProblem(report);
43
+ if (problem) fail2(`${label} report ${path}: ${problem}`);
44
+ return report;
45
+ }
46
+
47
+ /** The install runner for a `baseInstall` config: `(dir) => spawnResult`. */
48
+ export const installWith = (baseInstall) => (dir) =>
49
+ spawnSync(baseInstall.cmd, baseInstall.args, { cwd: dir, stdio: 'inherit' });
50
+
51
+ export function ensureBaseInstall(dir, sha, install, cfg) {
52
+ const { baseInstall } = cfg;
53
+ if (!existsSync(join(dir, baseInstall.marker))) {
54
+ const r = install(dir);
55
+ if (r.status !== 0)
56
+ fail2(
57
+ `${baseInstall.label} in the base worktree ${dir} exited ${r.status}. Run it by hand: cd ${dir} && ${baseInstall.hint}`,
58
+ );
59
+ writeFileSync(join(dir, baseInstall.marker), sha);
60
+ }
61
+ }
62
+
63
+ export function baseWorktree(root, sha, cfg) {
64
+ const dir = join(tmpdir(), cfg.worktreeDir, sha.slice(0, 12));
65
+ try {
66
+ git(root, ['worktree', 'prune']);
67
+ const head = existsSync(dir)
68
+ ? git(dir, ['rev-parse', 'HEAD']).trim()
69
+ : null;
70
+ if (head !== sha) {
71
+ if (existsSync(dir)) {
72
+ gitOk(root, ['worktree', 'remove', '--force', dir]);
73
+ rmSync(dir, { recursive: true, force: true });
74
+ }
75
+ git(root, ['worktree', 'add', '--detach', dir, sha]);
76
+ }
77
+ } catch (e) {
78
+ fail2(`base worktree ${dir} failed: ${e.stderr ?? e.message}`);
79
+ }
80
+ ensureBaseInstall(dir, sha, installWith(cfg.baseInstall), cfg);
81
+ return dir;
82
+ }