@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
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { safeKillGroup } from '../kill-group.mjs';
|
|
5
|
+
import { takeOrExit } from '../lock.mjs';
|
|
6
|
+
import { ROOT } from '../root.mjs';
|
|
7
|
+
import { baseRef, treeEntries, treeHash } from '../tree.mjs';
|
|
8
|
+
import { inFlight, realIo } from './exec.mjs';
|
|
9
|
+
import { projectSetup } from './project.mjs';
|
|
10
|
+
import { rotateMain } from './rotate.mjs';
|
|
11
|
+
import {
|
|
12
|
+
addArea,
|
|
13
|
+
compareKeys,
|
|
14
|
+
entriesDigest,
|
|
15
|
+
entriesFor,
|
|
16
|
+
loadRegistry,
|
|
17
|
+
ticketTreeHash,
|
|
18
|
+
toSpec,
|
|
19
|
+
} from './registry.mjs';
|
|
20
|
+
import {
|
|
21
|
+
deferredLine,
|
|
22
|
+
finalOutcome,
|
|
23
|
+
interruptHandler,
|
|
24
|
+
ruleMatches,
|
|
25
|
+
runMutations,
|
|
26
|
+
summaryLine,
|
|
27
|
+
} from './run.mjs';
|
|
28
|
+
import { selectChanged, splitServed } from './select.mjs';
|
|
29
|
+
import { validateSpec } from './validate.mjs';
|
|
30
|
+
|
|
31
|
+
// How long a child gets to act on a forwarded signal before its group is KILLed.
|
|
32
|
+
const SIGNAL_GRACE_MS = 5000;
|
|
33
|
+
|
|
34
|
+
function usage(msg, setup = projectSetup()) {
|
|
35
|
+
const cmd = setup.commands.mutate;
|
|
36
|
+
console.error(
|
|
37
|
+
`${msg}\nusage: ${cmd} <ID> [--only=M1,M2] [--dry-run] [--file=PATH] [--digest] [--typecheck]\n ${cmd} --registry (--ids=<ID>/<Mn>,... | --all | --changed[=<base>]) [--dry-run] [--typecheck]\n ${cmd} --rotate --ledger=<dir> [--budget-min=<n>] [--batch=<n>] [--deadline=<ISO>] [--sha=<sha>]`,
|
|
38
|
+
);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Entries to run, as [key, entry][]; exits 2 on any usage or registry problem.
|
|
43
|
+
function selectEntries(args, flag, setup) {
|
|
44
|
+
const registryMode = args.includes('--registry');
|
|
45
|
+
const changed = args
|
|
46
|
+
.find((a) => a === '--changed' || a.startsWith('--changed='))
|
|
47
|
+
?.slice('--changed='.length);
|
|
48
|
+
if (changed !== undefined && !registryMode)
|
|
49
|
+
usage('--changed needs --registry');
|
|
50
|
+
const reg = loadRegistry(ROOT, setup);
|
|
51
|
+
if (reg.errors.length) {
|
|
52
|
+
console.error(
|
|
53
|
+
`${setup.dir}/ is invalid:\n - ${reg.errors.join('\n - ')}\nRun ${setup.commands.checkMutations} for the full report.`,
|
|
54
|
+
);
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
if (registryMode) {
|
|
58
|
+
for (const f of ['only', 'file', 'digest'])
|
|
59
|
+
if (args.some((a) => a === `--${f}` || a.startsWith(`--${f}=`)))
|
|
60
|
+
usage(`--${f} does not combine with --registry`);
|
|
61
|
+
let ids = flag('ids')?.split(',').filter(Boolean);
|
|
62
|
+
let deferred = [];
|
|
63
|
+
const defer = args.includes('--defer-playwright');
|
|
64
|
+
if (defer && changed === undefined)
|
|
65
|
+
usage('--defer-playwright needs --changed');
|
|
66
|
+
if (changed !== undefined) {
|
|
67
|
+
if (ids || args.includes('--all'))
|
|
68
|
+
usage('--changed does not combine with --ids or --all');
|
|
69
|
+
try {
|
|
70
|
+
ids = selectChanged({ base: changed || baseRef() }, setup).map(
|
|
71
|
+
(s) => s.key,
|
|
72
|
+
);
|
|
73
|
+
if (defer) {
|
|
74
|
+
const split = splitServed(
|
|
75
|
+
ids.map((key) => ({ key })),
|
|
76
|
+
reg.entries,
|
|
77
|
+
setup,
|
|
78
|
+
);
|
|
79
|
+
deferred = split.deferred.map((s) => s.key);
|
|
80
|
+
ids = split.run.map((s) => s.key);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(
|
|
84
|
+
`mutate:check: the changed-entry selection failed: ${err.message}\nRun ${setup.commands.checkMutations}, fix what it reports, then re-run.`,
|
|
85
|
+
);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
if (ids.length === 0)
|
|
89
|
+
return {
|
|
90
|
+
registryMode,
|
|
91
|
+
pairs: [],
|
|
92
|
+
id: 'registry',
|
|
93
|
+
mode: 'changed',
|
|
94
|
+
empty: true,
|
|
95
|
+
deferred,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (!ids && !args.includes('--all'))
|
|
99
|
+
usage('--registry needs --ids=<ID>/<Mn>,..., --all or --changed');
|
|
100
|
+
if (ids && args.includes('--all')) usage('--ids and --all are exclusive');
|
|
101
|
+
let pairs;
|
|
102
|
+
if (ids) {
|
|
103
|
+
const bad = ids.filter((k) => !reg.entries.has(k));
|
|
104
|
+
if (bad.length) usage(`--ids names unknown key(s): ${bad.join(', ')}`);
|
|
105
|
+
const retired = ids.filter((k) => reg.entries.get(k).entry.retired);
|
|
106
|
+
if (retired.length)
|
|
107
|
+
usage(`--ids names retired key(s): ${retired.join(', ')}`);
|
|
108
|
+
pairs = [...new Set(ids)]
|
|
109
|
+
.sort(compareKeys)
|
|
110
|
+
.map((k) => [k, reg.entries.get(k).entry]);
|
|
111
|
+
} else
|
|
112
|
+
pairs = [...reg.entries.keys()]
|
|
113
|
+
.sort(compareKeys)
|
|
114
|
+
.map((k) => [k, reg.entries.get(k).entry]);
|
|
115
|
+
const spec = toSpec(pairs, 'registry', setup);
|
|
116
|
+
if (spec.mutations.length === 0) usage(`no live entries in ${setup.dir}/`);
|
|
117
|
+
return {
|
|
118
|
+
registryMode,
|
|
119
|
+
pairs,
|
|
120
|
+
spec,
|
|
121
|
+
id: 'registry',
|
|
122
|
+
mode: changed !== undefined ? 'changed' : ids ? 'ids' : 'all',
|
|
123
|
+
deferred,
|
|
124
|
+
...(changed !== undefined ? { base: changed || baseRef() } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const id = args.find((a) => !a.startsWith('--'));
|
|
128
|
+
if (!id) usage('missing ticket id');
|
|
129
|
+
const file = flag('file');
|
|
130
|
+
let pairs;
|
|
131
|
+
let source = 'registry';
|
|
132
|
+
if (file) {
|
|
133
|
+
const abs = resolve(ROOT, file);
|
|
134
|
+
if (!existsSync(abs)) usage(`no entries file at ${abs}`);
|
|
135
|
+
const tmp = { entries: new Map(), errors: [] };
|
|
136
|
+
addArea(tmp.entries, tmp.errors, file, readFileSync(abs, 'utf8'));
|
|
137
|
+
const foreign = [...tmp.entries.keys()].filter(
|
|
138
|
+
(k) => !k.startsWith(`${id}/`),
|
|
139
|
+
);
|
|
140
|
+
if (tmp.errors.length || foreign.length) {
|
|
141
|
+
console.error(
|
|
142
|
+
`${file} is invalid:\n - ${[...tmp.errors, ...foreign.map((k) => `key ${k} does not start with ${id}/`)].join('\n - ')}`,
|
|
143
|
+
);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
pairs = entriesFor(tmp, id);
|
|
147
|
+
source = `file:${relative(ROOT, abs)}`;
|
|
148
|
+
} else pairs = entriesFor(reg, id);
|
|
149
|
+
if (pairs.length === 0) usage(`no live entries for ${id} in ${setup.dir}/`);
|
|
150
|
+
return { registryMode, pairs, source, id };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function main() {
|
|
154
|
+
const setup = projectSetup();
|
|
155
|
+
const args = process.argv.slice(2);
|
|
156
|
+
if (args.includes('--rotate')) return rotateMain(args, setup);
|
|
157
|
+
const flag = (name) =>
|
|
158
|
+
args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
159
|
+
const dry = args.includes('--dry-run');
|
|
160
|
+
const typecheck = args.includes('--typecheck');
|
|
161
|
+
const typechecker = setup.harnesses.find((h) => h.typecheck)?.typecheck;
|
|
162
|
+
if (typecheck && !typechecker)
|
|
163
|
+
usage('--typecheck: no harness of this setup type-checks patches', setup);
|
|
164
|
+
if (typecheck)
|
|
165
|
+
for (const f of ['only', 'digest', 'defer-playwright'])
|
|
166
|
+
if (args.some((a) => a === `--${f}` || a.startsWith(`--${f}=`)))
|
|
167
|
+
usage(`--${f} does not combine with --typecheck`);
|
|
168
|
+
const sel = selectEntries(args, flag, setup);
|
|
169
|
+
const { registryMode, pairs, id } = sel;
|
|
170
|
+
if (sel.deferred?.length) console.log(deferredLine(sel.deferred));
|
|
171
|
+
if (sel.empty && typecheck) {
|
|
172
|
+
console.log(typechecker.summary(id, []));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (sel.empty) {
|
|
176
|
+
console.log(
|
|
177
|
+
`${summaryLine('registry', [])} (${sel.deferred?.length ? 'nothing left to run in the gate' : 'the diff touches no live registry entry'})`,
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const onlyKeys = flag('only')
|
|
182
|
+
?.split(',')
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.map((o) => (o.includes('/') ? o : `${id}/${o}`));
|
|
185
|
+
if (!registryMode) {
|
|
186
|
+
for (const [key, e] of pairs)
|
|
187
|
+
if (e.retired) console.log(`${key} skipped (retired)`);
|
|
188
|
+
}
|
|
189
|
+
if (args.includes('--digest')) {
|
|
190
|
+
console.log(`entriesDigest: ${entriesDigest(pairs)}`);
|
|
191
|
+
console.log(`treeHash: ${ticketTreeHash(setup)}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const spec = registryMode ? sel.spec : toSpec(pairs, id, setup);
|
|
195
|
+
if (spec.mutations.length === 0)
|
|
196
|
+
usage(`no live entries for ${id} in ${setup.dir}/`);
|
|
197
|
+
const errors = validateSpec(
|
|
198
|
+
spec,
|
|
199
|
+
(p) => readFileSync(join(ROOT, p), 'utf8'),
|
|
200
|
+
setup,
|
|
201
|
+
);
|
|
202
|
+
if (errors.length) {
|
|
203
|
+
console.error(
|
|
204
|
+
`${id} is invalid:\n - ${errors.join('\n - ')}\nFix the entries; nothing was run.`,
|
|
205
|
+
);
|
|
206
|
+
process.exit(2);
|
|
207
|
+
}
|
|
208
|
+
if (onlyKeys) {
|
|
209
|
+
const unknown = onlyKeys.filter(
|
|
210
|
+
(o) => !spec.mutations.some((m) => m.id === o),
|
|
211
|
+
);
|
|
212
|
+
if (unknown.length)
|
|
213
|
+
usage(`--only names unknown id(s): ${unknown.join(', ')}`);
|
|
214
|
+
}
|
|
215
|
+
const todo = onlyKeys
|
|
216
|
+
? spec.mutations.filter((m) => onlyKeys.includes(m.id))
|
|
217
|
+
: spec.mutations;
|
|
218
|
+
const tests = new Set(todo.map((m) => m.test));
|
|
219
|
+
console.log(
|
|
220
|
+
`plan: ${todo.length} mutation(s), ${tests.size} distinct test command(s) (each run once unmutated first)`,
|
|
221
|
+
);
|
|
222
|
+
for (const m of todo) {
|
|
223
|
+
const note = setup.kinds.get(m.kind)?.planNote?.(m);
|
|
224
|
+
const rebuilds = setup.rebuild
|
|
225
|
+
.filter((rule) => ruleMatches(rule, m))
|
|
226
|
+
.map((rule) => ` ${rule.note}`)
|
|
227
|
+
.join('');
|
|
228
|
+
console.log(
|
|
229
|
+
` ${m.id} ${m.kind} ${m.protects ?? ''} ${m.test}${note ? ` ${note}` : ''}${rebuilds}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (dry) return;
|
|
233
|
+
if (typecheck) {
|
|
234
|
+
// Exits 130/143 on a signal; nothing is written, so nothing needs restoring.
|
|
235
|
+
process.on('SIGINT', () => process.exit(130));
|
|
236
|
+
process.on('SIGTERM', () => process.exit(143));
|
|
237
|
+
process.exit(await typechecker.main(spec, todo, id));
|
|
238
|
+
}
|
|
239
|
+
// One run on the local stack at a time; children inherit the owner id.
|
|
240
|
+
takeOrExit('mutate:check');
|
|
241
|
+
|
|
242
|
+
const before = treeHash();
|
|
243
|
+
const beforeEntries = new Map(treeEntries());
|
|
244
|
+
const state = {};
|
|
245
|
+
let io;
|
|
246
|
+
let interrupted = false;
|
|
247
|
+
// Runs once: restore, lean reset if sql is applied, exit 130 (SIGINT) or 143.
|
|
248
|
+
const onInterrupt = async (sig) => {
|
|
249
|
+
if (interrupted) return;
|
|
250
|
+
interrupted = true;
|
|
251
|
+
await interruptHandler(state, io, process.exit, sig, setup);
|
|
252
|
+
};
|
|
253
|
+
// Children run in their own process groups, so a signal (a terminal Ctrl-C,
|
|
254
|
+
// or kill <pid>) reaches only the tool. Forward it to every child group; the
|
|
255
|
+
// child's close then reaches onInterrupt. With no child running, stop now.
|
|
256
|
+
const onSignal = (sig) => {
|
|
257
|
+
if (state.interrupted) return;
|
|
258
|
+
state.interrupted = sig;
|
|
259
|
+
for (const c of inFlight) {
|
|
260
|
+
try {
|
|
261
|
+
process.kill(-c.pid, sig);
|
|
262
|
+
} catch {
|
|
263
|
+
/* already gone */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (inFlight.size === 0) void onInterrupt(sig);
|
|
267
|
+
// A child can swallow the forwarded signal (a shell takes a SIGINT that lands
|
|
268
|
+
// while it forks and then waits on nothing). Give it the grace the time-limit
|
|
269
|
+
// path gives, then KILL its group; the child's exit settles the run.
|
|
270
|
+
else
|
|
271
|
+
setTimeout(() => {
|
|
272
|
+
for (const c of inFlight) safeKillGroup(c.pid, 'SIGKILL');
|
|
273
|
+
}, SIGNAL_GRACE_MS).unref();
|
|
274
|
+
};
|
|
275
|
+
process.on('SIGTERM', onSignal);
|
|
276
|
+
process.on('SIGINT', onSignal);
|
|
277
|
+
io = realIo(onlyKeys, state, onInterrupt, setup);
|
|
278
|
+
const report = await runMutations(spec, io, setup);
|
|
279
|
+
if (report.refused) {
|
|
280
|
+
console.error(report.refused);
|
|
281
|
+
process.exit(2);
|
|
282
|
+
}
|
|
283
|
+
for (const r of report.results) {
|
|
284
|
+
const m = spec.mutations.find((x) => x.id === r.id);
|
|
285
|
+
console.log(`${r.id} ${r.status.padEnd(8)} ${m.protects ?? ''} ${m.test}`);
|
|
286
|
+
if (r.status !== 'killed') {
|
|
287
|
+
console.log(` ${r.detail.split('\n').join('\n ')}`);
|
|
288
|
+
} else {
|
|
289
|
+
const last = r.detail.split('\n').at(-1);
|
|
290
|
+
if (last.startsWith('restored by reset ')) console.log(` ${last}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (report.fatal) {
|
|
294
|
+
console.error(
|
|
295
|
+
report.serveFatal
|
|
296
|
+
? `\n${report.fatal}\nNothing was mutated; fix the build or start, then re-run.`
|
|
297
|
+
: `\n${report.fatal}\n${setup.commands.afterFatal}`,
|
|
298
|
+
);
|
|
299
|
+
} else {
|
|
300
|
+
console.log(
|
|
301
|
+
`\n${summaryLine(spec.ticket ?? id, report.results, report.rerun, report.builds)}${report.partial ? ' (partial: ok is false)' : ''}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const after = treeHash();
|
|
306
|
+
let names = [];
|
|
307
|
+
if (after !== before) {
|
|
308
|
+
const afterEntries = new Map(treeEntries());
|
|
309
|
+
names = [
|
|
310
|
+
...new Set([...beforeEntries.keys(), ...afterEntries.keys()]),
|
|
311
|
+
].filter((p) => beforeEntries.get(p) !== afterEntries.get(p));
|
|
312
|
+
if (!report.fatal)
|
|
313
|
+
console.error(
|
|
314
|
+
`restore left the tree changed: ${names.join(', ')}. Run git diff on them and restore by hand; no receipt written.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const outcome = finalOutcome(report, names);
|
|
318
|
+
if (!outcome.writeReceipt) process.exit(outcome.code);
|
|
319
|
+
const receiptPath = join(
|
|
320
|
+
ROOT,
|
|
321
|
+
registryMode ? setup.receipts.registry : setup.receipts.ticket,
|
|
322
|
+
);
|
|
323
|
+
mkdirSync(dirname(receiptPath), { recursive: true });
|
|
324
|
+
const common = {
|
|
325
|
+
at: new Date().toISOString(),
|
|
326
|
+
results: report.results,
|
|
327
|
+
ok: report.ok,
|
|
328
|
+
...(report.groups ? { groups: report.groups } : {}),
|
|
329
|
+
};
|
|
330
|
+
const receipt = registryMode
|
|
331
|
+
? {
|
|
332
|
+
mode: sel.mode,
|
|
333
|
+
...(sel.base ? { base: sel.base } : {}),
|
|
334
|
+
keys: pairs.map(([k]) => k).sort(compareKeys),
|
|
335
|
+
treeHash: after,
|
|
336
|
+
at: common.at,
|
|
337
|
+
partial: false,
|
|
338
|
+
results: common.results,
|
|
339
|
+
ok: common.ok,
|
|
340
|
+
...(common.groups ? { groups: common.groups } : {}),
|
|
341
|
+
}
|
|
342
|
+
: {
|
|
343
|
+
ticket: id,
|
|
344
|
+
source: sel.source,
|
|
345
|
+
entriesDigest: entriesDigest(pairs),
|
|
346
|
+
treeHash: ticketTreeHash(setup),
|
|
347
|
+
at: common.at,
|
|
348
|
+
partial: report.partial,
|
|
349
|
+
results: common.results,
|
|
350
|
+
ok: common.ok,
|
|
351
|
+
...(common.groups ? { groups: common.groups } : {}),
|
|
352
|
+
};
|
|
353
|
+
writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
354
|
+
if (outcome.code !== 0)
|
|
355
|
+
console.error(
|
|
356
|
+
'mutate:check not ok: a survivor means the test does not bite yet. Strengthen it and re-run.',
|
|
357
|
+
);
|
|
358
|
+
process.exit(outcome.code);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export { main };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { safeKillGroup } from '../kill-group.mjs';
|
|
6
|
+
import { ROOT } from '../root.mjs';
|
|
7
|
+
import { projectSetup } from './project.mjs';
|
|
8
|
+
import { LIMITS } from './verdict.mjs';
|
|
9
|
+
|
|
10
|
+
// Every child runs here. detached: its own process group and session, so a
|
|
11
|
+
// terminal Ctrl-C reaches only the tool, which forwards it (onSignal in main).
|
|
12
|
+
// The loop is async because a listener runs only when the event loop turns.
|
|
13
|
+
const inFlight = new Set();
|
|
14
|
+
let signalCtx = { state: {}, onInterrupt: async () => {}, drop: undefined };
|
|
15
|
+
|
|
16
|
+
/** Which signal ended the run, if any: the tool's own, else the child's. */
|
|
17
|
+
export const interruptSignal = (state, r) =>
|
|
18
|
+
state.interrupted ??
|
|
19
|
+
(r.signal === 'SIGTERM'
|
|
20
|
+
? 'SIGTERM'
|
|
21
|
+
: r.signal === 'SIGINT' || r.code === 130
|
|
22
|
+
? 'SIGINT'
|
|
23
|
+
: null);
|
|
24
|
+
|
|
25
|
+
// The setup's drop list (the e2e step's report path, for this repo)
|
|
26
|
+
// must never reach a child: a served Playwright run here would overwrite
|
|
27
|
+
// .turbo/e2e-report.json (an earlier incident).
|
|
28
|
+
export function childEnv(
|
|
29
|
+
extra,
|
|
30
|
+
base = process.env,
|
|
31
|
+
drop = projectSetup().childEnv.drop,
|
|
32
|
+
) {
|
|
33
|
+
const rest = Object.fromEntries(
|
|
34
|
+
Object.entries(base).filter(([k]) => !drop.includes(k)),
|
|
35
|
+
);
|
|
36
|
+
return { ...rest, ...extra };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function exec(file, args, { input, limitS, env } = {}) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const c = spawn(file, args, {
|
|
42
|
+
cwd: ROOT,
|
|
43
|
+
env: childEnv(env, process.env, signalCtx.drop),
|
|
44
|
+
detached: true,
|
|
45
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
inFlight.add(c);
|
|
48
|
+
let out = '';
|
|
49
|
+
let settled = false;
|
|
50
|
+
let timer;
|
|
51
|
+
const done = async (r) => {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
settled = true;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
inFlight.delete(c);
|
|
56
|
+
const { state, onInterrupt } = signalCtx;
|
|
57
|
+
const sig = interruptSignal(state, r);
|
|
58
|
+
if (sig) await onInterrupt(sig);
|
|
59
|
+
resolve(r);
|
|
60
|
+
};
|
|
61
|
+
// Past the limit: TERM the whole group, KILL it 5 s later, resolve at once
|
|
62
|
+
// (a grandchild holding the pipes open would otherwise delay 'close').
|
|
63
|
+
const timeOut = (child) => {
|
|
64
|
+
safeKillGroup(child.pid, 'SIGTERM');
|
|
65
|
+
setTimeout(() => safeKillGroup(child.pid, 'SIGKILL'), 5000).unref();
|
|
66
|
+
done({ code: 124, out, signal: null, timedOut: true, limitS });
|
|
67
|
+
};
|
|
68
|
+
// prettier-ignore
|
|
69
|
+
if (limitS !== undefined) timer = setTimeout(() => timeOut(c), limitS * 1000);
|
|
70
|
+
c.stdout.on('data', (d) => (out += d));
|
|
71
|
+
c.stderr.on('data', (d) => (out += d));
|
|
72
|
+
c.on('error', (err) => done({ code: 1, out: String(err), signal: null }));
|
|
73
|
+
// The forwarded signal can be lost by a group member still starting up
|
|
74
|
+
// (forked, not yet exec'd), which then outlives its leader and holds the
|
|
75
|
+
// pipes open, so 'close' never fires. When the leader exits on an
|
|
76
|
+
// interrupt, KILL what is left of its group (orphans; the run exits right
|
|
77
|
+
// after, so a delayed KILL would never fire) and settle at once.
|
|
78
|
+
c.on('exit', (code, signal) => {
|
|
79
|
+
if (!signalCtx.state.interrupted) return;
|
|
80
|
+
safeKillGroup(c.pid, 'SIGKILL');
|
|
81
|
+
done({ code: code ?? 1, out, signal });
|
|
82
|
+
});
|
|
83
|
+
c.on('close', (code, signal) => done({ code: code ?? 1, out, signal }));
|
|
84
|
+
if (input !== undefined) {
|
|
85
|
+
c.stdin.on('error', () => {});
|
|
86
|
+
c.stdin.end(input);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The io runMutations drives: the generic file and command methods, one build
|
|
93
|
+
* method per rebuild rule, and every session's `ioMethods` (sql channel, served
|
|
94
|
+
* build and start, type check).
|
|
95
|
+
*/
|
|
96
|
+
function realIo(only, state, onInterrupt, setup = projectSetup()) {
|
|
97
|
+
signalCtx = { state, onInterrupt, drop: setup.childEnv.drop };
|
|
98
|
+
const sh = (cmd, limitS) => exec('bash', ['-lc', cmd], { limitS });
|
|
99
|
+
const io = {
|
|
100
|
+
only,
|
|
101
|
+
state,
|
|
102
|
+
run: (cmd, { reuse } = {}) =>
|
|
103
|
+
exec('bash', ['-lc', cmd], {
|
|
104
|
+
limitS: LIMITS.run,
|
|
105
|
+
env: reuse ? setup.reuseEnv : undefined,
|
|
106
|
+
}),
|
|
107
|
+
readFile: (p) => readFileSync(join(ROOT, p), 'utf8'),
|
|
108
|
+
writeFile: (p, s) => writeFileSync(join(ROOT, p), s),
|
|
109
|
+
};
|
|
110
|
+
for (const rule of setup.rebuild)
|
|
111
|
+
io[rule.io] = () => sh(rule.cmd, rule.limitS ?? LIMITS.build);
|
|
112
|
+
const sessions = [
|
|
113
|
+
...[...setup.kinds.values()].map((k) => k.session),
|
|
114
|
+
...setup.harnesses,
|
|
115
|
+
];
|
|
116
|
+
for (const s of sessions) Object.assign(io, s?.ioMethods?.({ exec, sh }));
|
|
117
|
+
return io;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { inFlight, realIo };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The wiring between a project's gate config and the mutation engine: the Setup a gate implies,
|
|
2
|
+
// the hooks the verify plan, precheck and review map read, and the runner behind
|
|
3
|
+
// `ticket-gate mutate` and `ticket-gate mutations`. Imported by the bin, cli, precheck, review-map
|
|
4
|
+
// and a project's scripts; never by a pack (a pack is in the gate config's import graph, and
|
|
5
|
+
// the config is loaded by ../project.mjs).
|
|
6
|
+
import { packHooksAll } from '../pack-hooks.mjs';
|
|
7
|
+
import { bindSetup } from './project.mjs';
|
|
8
|
+
import { resolveSetup } from './kinds.mjs';
|
|
9
|
+
import { sessionOf } from './harness.mjs';
|
|
10
|
+
import {
|
|
11
|
+
loadRegistry,
|
|
12
|
+
registryAt as registryAtRef,
|
|
13
|
+
} from './registry.mjs';
|
|
14
|
+
import {
|
|
15
|
+
registryEntries,
|
|
16
|
+
registryEntriesAt,
|
|
17
|
+
selectChanged,
|
|
18
|
+
splitServed,
|
|
19
|
+
} from './select.mjs';
|
|
20
|
+
import { ticketFromBranch } from './check.mjs';
|
|
21
|
+
|
|
22
|
+
/** The Setup a gate implies: `gate.mutations`, `gate.commands` and every pack's kinds and harnesses. */
|
|
23
|
+
export function setupOf(gate) {
|
|
24
|
+
const kinds = packHooksAll('mutationKinds', gate).map(({ by, value }) => ({
|
|
25
|
+
name: by,
|
|
26
|
+
mutationKinds: value,
|
|
27
|
+
}));
|
|
28
|
+
const harnesses = packHooksAll('mutationHarnesses', gate).map(
|
|
29
|
+
({ by, value }) => ({ name: by, mutationHarnesses: value }),
|
|
30
|
+
);
|
|
31
|
+
return resolveSetup({
|
|
32
|
+
...gate.mutations,
|
|
33
|
+
commands: gate.commands,
|
|
34
|
+
packs: [...kinds, ...harnesses],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The registry entries this diff touches. `select` is injectable. A
|
|
40
|
+
* selector that throws selects the step (fail closed, like toolsReachE2e): the
|
|
41
|
+
* step then fails through mutate:check with the same error.
|
|
42
|
+
*/
|
|
43
|
+
export function registryPlan(
|
|
44
|
+
base,
|
|
45
|
+
select = (opts) => selectChanged(opts),
|
|
46
|
+
entries = () => registryEntries(),
|
|
47
|
+
) {
|
|
48
|
+
try {
|
|
49
|
+
// Playwright-backed entries are the nightly rotation's, not the gate's.
|
|
50
|
+
const split = splitServed(select({ base }), entries());
|
|
51
|
+
const keys = split.run.map((s) => s.key);
|
|
52
|
+
const deferred = split.deferred.map((s) => s.key);
|
|
53
|
+
return { when: keys.length > 0, keys, deferred, error: null };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { when: true, keys: [], error: err.message };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const registryDryLine = (p) =>
|
|
60
|
+
p.error
|
|
61
|
+
? `mutation-registry: selection failed (${p.error}); the step runs`
|
|
62
|
+
: `mutation-registry: ${p.keys.length} selected (${p.keys.slice(0, 10).join(', ')}${p.keys.length > 10 ? ', ...' : ''}), ${p.deferred?.length ?? 0} Playwright-backed deferred to the nightly`;
|
|
63
|
+
|
|
64
|
+
const hooksOf = new WeakMap();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The hooks the engine's plan, dry-run line, precheck and review map read, or null when the gate
|
|
68
|
+
* has no `mutations`. The gate's Setup is built and bound once per gate.
|
|
69
|
+
*/
|
|
70
|
+
export function registryHooks(gate) {
|
|
71
|
+
if (gate.mutations === undefined) return null;
|
|
72
|
+
if (hooksOf.has(gate)) return hooksOf.get(gate);
|
|
73
|
+
const setup = bindSetup(setupOf(gate));
|
|
74
|
+
const hooks = {
|
|
75
|
+
plan: (base) =>
|
|
76
|
+
registryPlan(
|
|
77
|
+
base,
|
|
78
|
+
(opts) => selectChanged(opts, setup),
|
|
79
|
+
() => registryEntries(undefined, setup),
|
|
80
|
+
),
|
|
81
|
+
dryLine: registryDryLine,
|
|
82
|
+
entries: () => registryEntries(undefined, setup),
|
|
83
|
+
entriesAt: (base) => registryEntriesAt(base, undefined, setup),
|
|
84
|
+
load: (root) => loadRegistry(root, setup),
|
|
85
|
+
at: (base, root) => registryAtRef(base, root, setup),
|
|
86
|
+
ticketFromBranch,
|
|
87
|
+
};
|
|
88
|
+
hooksOf.set(gate, hooks);
|
|
89
|
+
return hooks;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const notConfigured = (cmd, err) => {
|
|
93
|
+
err.call(console, `${cmd}: mutations is not configured; set mutations in gate.config.mjs`);
|
|
94
|
+
return 2;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Every non-retired entry of the registry, as the harness sees it (`{ id: key, ...entry }`). */
|
|
98
|
+
function liveEntries(setup) {
|
|
99
|
+
const { entries } = loadRegistry(undefined, setup);
|
|
100
|
+
return [...entries]
|
|
101
|
+
.filter(([, v]) => !v.entry?.retired)
|
|
102
|
+
.map(([key, v]) => ({ id: key, ...v.entry }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run `mutate`, `mutations check` or `mutations select` for a gate. Binds the gate's Setup,
|
|
107
|
+
* rejects a doubly claimed entry before any test runs, and returns the exit code.
|
|
108
|
+
*/
|
|
109
|
+
export async function runMutationCommand(
|
|
110
|
+
cmd,
|
|
111
|
+
argv,
|
|
112
|
+
gate,
|
|
113
|
+
{ err } = console,
|
|
114
|
+
) {
|
|
115
|
+
if (gate.mutations === undefined) return notConfigured(cmd, err);
|
|
116
|
+
let setup;
|
|
117
|
+
try {
|
|
118
|
+
setup = setupOf(gate);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
err.call(console, e.message);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
bindSetup(setup);
|
|
124
|
+
try {
|
|
125
|
+
for (const entry of liveEntries(setup)) sessionOf(entry, setup);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
err.call(console, e.message);
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
const args = argv.map((a) => (a === '--defer' ? '--defer-playwright' : a));
|
|
131
|
+
process.argv = [process.argv[0], process.argv[1], ...args];
|
|
132
|
+
if (cmd === 'mutate') {
|
|
133
|
+
const cli = await import('./cli.mjs');
|
|
134
|
+
await cli.main();
|
|
135
|
+
return Number(process.exitCode ?? 0);
|
|
136
|
+
}
|
|
137
|
+
if (cmd === 'mutations check') {
|
|
138
|
+
const check = await import('./check.mjs');
|
|
139
|
+
return check.main(args, setup);
|
|
140
|
+
}
|
|
141
|
+
if (cmd === 'mutations select') {
|
|
142
|
+
const select = await import('./select.mjs');
|
|
143
|
+
await select.main();
|
|
144
|
+
return Number(process.exitCode ?? 0);
|
|
145
|
+
}
|
|
146
|
+
err.call(console, `ticket-gate: unknown mutation command ${cmd}`);
|
|
147
|
+
return 2;
|
|
148
|
+
}
|