@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.
- package/LICENSE +202 -0
- package/README.md +81 -2
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +19 -4
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- 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
|
+
}
|