@ticket-gate/engine 0.0.0 → 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 +82 -0
  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 +21 -1
  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
@@ -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
+ }