@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
package/hooks/stop.mjs ADDED
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env node
2
+ // Stop / SubagentStop hook.
3
+ //
4
+ // Blocks exactly one failure mode, the one that cost a full dev round on
5
+ // a past ticket: an agent ticks a Definition-of-Done checkbox in a phase file
6
+ // while the configured verify command's steps are red, were skipped, or were never
7
+ // run.
8
+ //
9
+ // It is deliberately cheap. It runs no test suite; it only reads the receipt
10
+ // that the verify command wrote and checks it still describes this exact
11
+ // branch. If no DoD checkbox was newly ticked, it approves silently, so
12
+ // ordinary work in this repo never pays for it.
13
+ //
14
+ // "Newly ticked" is measured against the merge-base with main, so a tick that
15
+ // was committed is still a tick. A line is new only if the same text was not
16
+ // already ticked at the base, so moving a ticked bullet does not count.
17
+ // A committed tick is settled when the tree of the commit that last changed it
18
+ // (treeHashAt) is in .turbo/ticket-verify-history.jsonl; settled ticks skip the
19
+ // checks below except `(human)`, which every newly ticked line still gets.
20
+ //
21
+ // When a ticket's DoD is being ticked it also checks the follow-up files:
22
+ // every FU that the ticket's contract section 8.0 marks `closes` must have
23
+ // `status: done` in its follow-up file, and every `PARTIAL` one
24
+ // must stay open and carry a `PARTIAL:` note naming the ticket. Without this
25
+ // the register only stays accurate when a prompt lists the FUs by hand.
26
+ //
27
+ // A receipt with `oversize: true` (the diff outside tests, docs and generated
28
+ // files is over 1500 lines) blocks like a failing step, unless section 1 of
29
+ // each ticked ticket's contract has an `oversize-accepted: <reason>` line,
30
+ // which the tech-lead adds up front. A reason starting `move-only` additionally
31
+ // needs `moveProof.unmatched === 0` in the receipt (the configured move check).
32
+ //
33
+ // A `reused` result (a pass recorded earlier on an identical input
34
+ // digest) counts as verified, but only if its digest still matches today's
35
+ // inputs. Each one is recomputed with stepDigest against the receipt's `base`;
36
+ // a missing digest or base, or a mismatch (an env file or a tool version moved
37
+ // since the run), blocks. Nothing is written. A receipt with no `reused` result
38
+ // is judged exactly as before.
39
+
40
+ import { readFileSync } from 'node:fs';
41
+ import { execSync } from 'node:child_process';
42
+ import { join } from 'node:path';
43
+
44
+ import { commandsOf } from '../config.mjs';
45
+ import { ROOT } from '../root.mjs';
46
+ import { fileIdOf, parseFile, toRow } from '../fu.mjs';
47
+ import {
48
+ baseRef,
49
+ lineCommit,
50
+ readHistory,
51
+ treeExclude,
52
+ treeHash,
53
+ treeHashAt,
54
+ } from '../tree.mjs';
55
+
56
+ const RECEIPT = join(ROOT, '.turbo', 'ticket-verify.json');
57
+
58
+ const approve = () => process.exit(0);
59
+ const block = (reason) => {
60
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
61
+ process.exit(0);
62
+ };
63
+
64
+ let input = {};
65
+ try {
66
+ input = JSON.parse(readFileSync(0, 'utf8') || '{}');
67
+ } catch {
68
+ /* invoked outside a hook, or no stdin: fall through and check anyway */
69
+ }
70
+
71
+ // A blocked stop re-invokes the agent, which stops again. Never block twice.
72
+ if (input.stop_hook_active) approve();
73
+
74
+ const sh = (cmd) =>
75
+ execSync(cmd, {
76
+ cwd: ROOT,
77
+ encoding: 'utf8',
78
+ stdio: ['ignore', 'pipe', 'ignore'],
79
+ });
80
+
81
+ // The config is loaded dynamically, inside a try, so a missing, throwing or invalid
82
+ // gate.config.mjs (or an import of it that throws) can never crash the hook to exit 1, which
83
+ // Claude Code reads as "proceed". Without a usable config the hook cannot know which files are
84
+ // the DoD, so it falls back to a conservative scan: any newly added `- [x]` line in a markdown
85
+ // file (diff against the base, or an untracked file) blocks with exit 2; none approves.
86
+ const TICK_ADDED = /^\+\s*- \[x\]/i;
87
+ const TICK_ANY = /^\s*- \[x\]/im;
88
+ function failClosed(err) {
89
+ let ticked = [];
90
+ try {
91
+ const diff = sh(`git diff ${baseRef()} -- '*.md'`)
92
+ .split('\n')
93
+ .filter((l) => TICK_ADDED.test(l));
94
+ const fresh = sh("git ls-files --others --exclude-standard -- '*.md'")
95
+ .split('\n')
96
+ .filter(Boolean)
97
+ .filter((f) => {
98
+ try {
99
+ return TICK_ANY.test(readFileSync(join(ROOT, f), 'utf8'));
100
+ } catch {
101
+ return false;
102
+ }
103
+ });
104
+ ticked = [...diff.map((l) => l.slice(1).trim()), ...fresh];
105
+ } catch {
106
+ approve(); // git unavailable: never block on infrastructure
107
+ }
108
+ if (ticked.length === 0) approve();
109
+ process.stderr.write(
110
+ `ticket-gate: gate.config.mjs is unusable (${err.message}), so the DoD cannot be checked, ` +
111
+ `and ${ticked.length} DoD checkbox(es) were ticked on this branch.\n` +
112
+ 'Fix gate.config.mjs (the error above names the problem) and run `ticket-gate verify`, or untick the box(es).\n',
113
+ );
114
+ process.exit(2);
115
+ }
116
+
117
+ let GATE;
118
+ let step;
119
+ try {
120
+ const { loadGateConfig } = await import('../config.mjs');
121
+ GATE = await loadGateConfig();
122
+ step = await import('../digest.mjs');
123
+ } catch (err) {
124
+ failClosed(err);
125
+ }
126
+ const {
127
+ AFFECTED_NIGHTLY,
128
+ NIGHTLY_MAX_AGE_MS,
129
+ VERIFIED_STATUSES,
130
+ inputState,
131
+ specDigest,
132
+ stepDigest,
133
+ } = step;
134
+ const { dod } = GATE;
135
+ const CMD = commandsOf(GATE); // the commands a message tells the agent to run (gate.config.mjs `commands`)
136
+ const FU = GATE.followUps; // { dir, pointer? }; absent: the project keeps no register
137
+ const FU_DIR = FU ? join(ROOT, FU.dir) : null;
138
+ // "outside <dirs>": the paths `.ticket-gate/tree.json` leaves out of the tree hash.
139
+ const OUTSIDE = treeExclude().length
140
+ ? ` outside ${treeExclude().join(', ')}`
141
+ : '';
142
+ const escapeRegExp = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
143
+ const HUMAN = new RegExp(escapeRegExp(dod.human), 'i');
144
+ const dodContract = (id) => join(ROOT, dod.contract.replace('{id}', id));
145
+ const dodGlobs = dod.files.map((g) => `':(glob)${g}'`).join(' ');
146
+
147
+ // Each ticked line, with the ticket id of the `## <ID> <title>` heading above it.
148
+ const tickedLines = (text) => {
149
+ const out = [];
150
+ let id = null;
151
+ const rows = text.split('\n');
152
+ for (let i = 0; i < rows.length; i++) {
153
+ const l = rows[i].trim();
154
+ const heading = /^## (\S+)/.exec(l);
155
+ if (heading) id = heading[1];
156
+ else if (/^- \[x\]/i.test(l))
157
+ out.push({ id, line: i + 1, text: l.replace(/^- \[x\]\s*/i, '') });
158
+ }
159
+ return out;
160
+ };
161
+
162
+ const ticks = []; // { file, line, id, text }; id is null under no `## <ID>` heading
163
+ let base = 'HEAD';
164
+ try {
165
+ base = baseRef(GATE.base);
166
+ const files = sh(`git ls-files -- ${dodGlobs}`).split('\n').filter(Boolean);
167
+ const untracked = sh(
168
+ `git ls-files --others --exclude-standard -- ${dodGlobs}`,
169
+ )
170
+ .split('\n')
171
+ .filter(Boolean);
172
+ const dodFiles = [...files, ...untracked];
173
+ // A glob that matches no file would read no DoD at all: a silent open (3-10).
174
+ if (dodFiles.length === 0) failClosed(new Error('dod.files matches no file'));
175
+ for (const f of dodFiles) {
176
+ let now = '';
177
+ try {
178
+ now = readFileSync(join(ROOT, f), 'utf8');
179
+ } catch {
180
+ continue; // deleted in the working tree
181
+ }
182
+ let before = '';
183
+ try {
184
+ before = sh(`git show ${base}:${f}`);
185
+ } catch {
186
+ /* file is new on this branch: every tick in it is new */
187
+ }
188
+ const wasTicked = new Set(tickedLines(before).map((t) => t.text));
189
+ for (const { id, line, text } of tickedLines(now)) {
190
+ if (wasTicked.has(text)) continue;
191
+ ticks.push({ file: f, line, id, text });
192
+ }
193
+ }
194
+ } catch {
195
+ approve(); // not a git repo, or git unavailable: never block on infrastructure
196
+ }
197
+
198
+ if (ticks.length === 0) approve();
199
+
200
+ const tickedHeader = (list) =>
201
+ `${list.length} DoD checkbox(es) were ticked on this branch:\n` +
202
+ list.map((t) => ` ${t.file}: ${t.text}`).join('\n');
203
+
204
+ // A bullet marked `(human)` (device runs, Maestro flows, staging, tags) is never an agent's to tick,
205
+ // settled or not.
206
+ // prettier-ignore
207
+ const humanOnly = ticks.filter((t) => HUMAN.test(t.text));
208
+ if (humanOnly.length) {
209
+ block(
210
+ `${tickedHeader(ticks)}\n\n` +
211
+ 'These bullets are human steps and may not be ticked by an agent:\n' +
212
+ humanOnly.map((t) => ` ${t.file}: ${t.text}`).join('\n') +
213
+ '\n\nUntick them and list them under "Human steps" in the PR body.',
214
+ );
215
+ }
216
+
217
+ // `- **FU<n>** closes: ...` in a contract's section 8.0; the FU itself is one file in the follow-ups folder.
218
+ // A tick committed on a tree that an earlier green verify run hashed is settled: that run
219
+ // already verified it. Every error here leaves the tick unsettled (never approve on a failure).
220
+ function settle(ticks, base) {
221
+ const hashes = new Set(readHistory().map((e) => e.treeHash));
222
+ if (hashes.size === 0 || base === 'HEAD') return ticks;
223
+ const hashAt = new Map();
224
+ return ticks.filter((t) => {
225
+ const commit = lineCommit(t.file, t.line);
226
+ if (commit === null) return true;
227
+ if (!hashAt.has(commit)) hashAt.set(commit, treeHashAt(commit, base));
228
+ // prettier-ignore
229
+ return !hashes.has(hashAt.get(commit));
230
+ });
231
+ }
232
+ let unsettled;
233
+ try {
234
+ unsettled = settle(ticks, base);
235
+ } catch {
236
+ unsettled = ticks;
237
+ }
238
+ // prettier-ignore
239
+ if (unsettled.length === 0) approve();
240
+ const ticked = tickedHeader(unsettled);
241
+ // prettier-ignore
242
+ const tickedTickets = new Set(unsettled.filter((t) => t.id).map((t) => t.id));
243
+ // prettier-ignore
244
+ const headlessTick = unsettled.some((t) => !t.id);
245
+
246
+ const FU_ID = 'FU\\d+[a-z]?(?: \\([^)]+\\))?';
247
+ const CONTRACT_FU = new RegExp(`^- \\*\\*(${FU_ID})\\*\\* (closes|PARTIAL)\\b`);
248
+
249
+ // The FU's file in the follow-ups folder, or null when it does not exist.
250
+ function followUpText(fu) {
251
+ try {
252
+ return readFileSync(join(FU_DIR, `${fileIdOf(fu)}.md`), 'utf8');
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ const followUpProblems = [];
259
+ for (const id of FU ? tickedTickets : []) {
260
+ let contract;
261
+ try {
262
+ contract = readFileSync(dodContract(id), 'utf8');
263
+ } catch {
264
+ continue; // a ticket that skipped phase A has no contract to check
265
+ }
266
+ let in80 = false;
267
+ for (const raw of contract.split('\n')) {
268
+ if (/^#{1,4} /.test(raw)) in80 = /^#{3,4} 8\.0\b/.test(raw);
269
+ const m = in80 && CONTRACT_FU.exec(raw.trim());
270
+ if (!m) continue;
271
+ const [, fu, verdict] = m;
272
+ const where = `${dod.contract.replace('{id}', id)} section 8.0 says ${fu} ${verdict}`;
273
+ const file = `${FU.dir}/${fileIdOf(fu)}.md`;
274
+ const text = followUpText(fu);
275
+ if (text === null) {
276
+ followUpProblems.push(`${where}, but ${file} does not exist.`);
277
+ continue;
278
+ }
279
+ let status;
280
+ try {
281
+ status = parseFile(text).data.status;
282
+ } catch (e) {
283
+ followUpProblems.push(
284
+ `${where}, but its file does not parse: ${e.message}.`,
285
+ );
286
+ continue;
287
+ }
288
+ if (verdict === 'closes') {
289
+ if (status !== 'done')
290
+ followUpProblems.push(`${where}, but its file says status: ${status}.`);
291
+ } else if (status !== 'open') {
292
+ followUpProblems.push(
293
+ `${where}, but its file says status: ${status} (PARTIAL items stay open).`,
294
+ );
295
+ } else if (!new RegExp(`PARTIAL: ${id}\\b`).test(toRow(text))) {
296
+ followUpProblems.push(
297
+ `${where}, but its file has no "PARTIAL: ${id} ..." note.`,
298
+ );
299
+ }
300
+ }
301
+ }
302
+
303
+ if (followUpProblems.length) {
304
+ block(
305
+ `${ticked}\n\n` +
306
+ 'The follow-up files do not match what the contract says this ticket closes:\n' +
307
+ followUpProblems.map((p) => ` ${p}`).join('\n') +
308
+ `\n\nUpdate ${FU.dir}/: run \`${CMD.fu} close <FU> --by <ID> --sha <sha>\` for each \`closes\` FU, and add a ` +
309
+ "`PARTIAL: <ID> ...` note to each `PARTIAL` one's body. If a fix was not made, mark it " +
310
+ '`declined` in section 8.0 with the reason instead.',
311
+ );
312
+ }
313
+
314
+ let receipt;
315
+ try {
316
+ receipt = JSON.parse(readFileSync(RECEIPT, 'utf8'));
317
+ } catch {
318
+ block(
319
+ `${ticked}\n\n` +
320
+ 'No verification receipt exists. The verify command must pass before a ticket is ' +
321
+ 'declared done.\n\n' +
322
+ `Run \`${CMD.verify}\`, paste its output into the PR body, and only then leave the ` +
323
+ 'checkboxes ticked. If a box depends on a human-only step, untick it and list it under ' +
324
+ '"Human steps" instead.',
325
+ );
326
+ }
327
+
328
+ if (receipt.treeHash !== treeHash()) {
329
+ block(
330
+ `${ticked}\n\n` +
331
+ `The verification receipt is stale: it was written at ${receipt.ranAt}, and the branch ` +
332
+ `has changed since: a changed, added, deleted or re-moded file${OUTSIDE}. ` +
333
+ 'Committing or staging alone does not do this. Whatever changed last was never verified.\n\n' +
334
+ `Re-run \`${CMD.verify}\` and update the summary lines in the PR body.`,
335
+ );
336
+ }
337
+
338
+ const notVerified = (receipt.results ?? []).filter(
339
+ (r) => !VERIFIED_STATUSES.includes(r.status),
340
+ );
341
+ if (!receipt.ok || notVerified.length) {
342
+ block(
343
+ `${ticked}\n\n` +
344
+ `The last \`${CMD.verify}\` run did not verify every required step:\n` +
345
+ notVerified
346
+ .map((r) => ` [${r.status.toUpperCase()}] ${r.cmd}: ${r.summary}`)
347
+ .join('\n') +
348
+ '\n\nA skipped step is not a passed step. Run it (install the stack or tool it needs) ' +
349
+ 'before declaring the ticket done. A turbo task cancelled by an upstream failure is not ' +
350
+ 'a pass: check every task, not just the first summary line.',
351
+ );
352
+ }
353
+
354
+ // A reused result is a pass on identical inputs: prove they are still identical.
355
+ const sameInputs = (r) =>
356
+ stepDigest(r.id, r.cmd, { base: receipt.base }) === r.digest;
357
+ for (const r of (receipt.results ?? []).filter((x) => x.status === 'reused')) {
358
+ let match = false;
359
+ try {
360
+ match =
361
+ typeof r.digest === 'string' &&
362
+ typeof receipt.base === 'string' &&
363
+ sameInputs(r);
364
+ } catch {
365
+ /* git or digest failure: never approve on it */
366
+ }
367
+ if (!match) {
368
+ block(
369
+ `${ticked}\n\n` +
370
+ `reused step ${r.id} no longer matches its inputs (an env file or a tool version changed): re-run \`${CMD.verify}\``,
371
+ );
372
+ }
373
+ }
374
+
375
+ // Any step's result may come from a partial (affected) run, whatever the step's id: a nightly-backed
376
+ // step records a mode, selected specs and a knownRed list. A result with no mode is a full run,
377
+ // judged as above.
378
+ for (const r of (receipt.results ?? []).filter((x) => typeof x.id === 'string')) {
379
+ const mode = r.mode ?? 'full';
380
+ if (mode !== 'full' && mode !== 'affected') {
381
+ block(`${ticked}\n\nunknown ${r.id} mode ${mode}: re-run \`${CMD.verify}\``);
382
+ }
383
+ const inside = (r.knownRed ?? []).filter((f) =>
384
+ (r.selected ?? []).includes(f),
385
+ );
386
+ if (inside.length) {
387
+ block(
388
+ `${ticked}\n\n` +
389
+ inside
390
+ .map(
391
+ (f) =>
392
+ `knownRed spec ${f} is inside this branch's selection: it must run and pass`,
393
+ )
394
+ .join('\n'),
395
+ );
396
+ }
397
+ if (mode === 'affected') {
398
+ if (
399
+ !AFFECTED_NIGHTLY.includes(r.nightly?.status) ||
400
+ !(
401
+ Date.parse(r.nightly?.checkedAt) - Date.parse(r.nightly?.ranAt) <=
402
+ NIGHTLY_MAX_AGE_MS
403
+ )
404
+ ) {
405
+ block(
406
+ `${ticked}\n\n${r.id} ran in affected mode without a fresh green or amber nightly: re-run \`${CMD.verify}\` (it falls back to full)`,
407
+ );
408
+ }
409
+ const specs = r.specs ?? [];
410
+ const uncovered = (r.selected ?? []).filter(
411
+ (f) =>
412
+ !specs.some(
413
+ (s) => s.file === f && ['pass', 'reused'].includes(s.status),
414
+ ),
415
+ );
416
+ if (uncovered.length) {
417
+ block(
418
+ `${ticked}\n\n` +
419
+ uncovered
420
+ .map((f) => `affected ${r.id} did not pass selected spec ${f}`)
421
+ .join('\n') +
422
+ `\n\nRe-run \`${CMD.verify}\`.`,
423
+ );
424
+ }
425
+ let state = null;
426
+ try {
427
+ state = inputState({ base: receipt.base });
428
+ } catch {
429
+ /* git failure: every reused spec then fails to match */
430
+ }
431
+ for (const s of specs.filter((x) => x.status === 'reused')) {
432
+ let match = false;
433
+ try {
434
+ match =
435
+ state !== null &&
436
+ typeof s.digest === 'string' &&
437
+ Array.isArray(s.inputs) &&
438
+ specDigest(s.file, s.inputs, { state }) === s.digest;
439
+ } catch {
440
+ /* a throw counts as no match */
441
+ }
442
+ if (!match) {
443
+ block(
444
+ `${ticked}\n\nreused ${r.id} spec ${s.file} no longer matches its inputs: re-run \`${CMD.verify}\``,
445
+ );
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ // The diff is too big to have been reviewed as one ticket (the review map).
452
+ const OVERSIZE_ACCEPTED = dod.oversize;
453
+ const MOVE_ONLY = /^move-only/;
454
+ const reasonsOf = (id) => {
455
+ if (!id) return [];
456
+ try {
457
+ const lines = readFileSync(dodContract(id), 'utf8').split('\n');
458
+ const start = lines.findIndex((l) => /^## 1\.\s/.test(l));
459
+ if (start < 0) return [];
460
+ const end = lines.findIndex((l, i) => i > start && /^## /.test(l));
461
+ return lines
462
+ .slice(start, end < 0 ? undefined : end)
463
+ .map((l) => l.trim())
464
+ .filter((l) => OVERSIZE_ACCEPTED.test(l))
465
+ .map((l) => l.replace(/^(?:- )?oversize-accepted:\s*/, ''));
466
+ } catch {
467
+ return [];
468
+ }
469
+ };
470
+ // A `move-only` waiver also needs the receipt's move proof at 0.
471
+ const accepted = (id) => {
472
+ const reasons = reasonsOf(id);
473
+ if (reasons.length === 0) return false;
474
+ if (reasons.some((r) => MOVE_ONLY.test(r)))
475
+ return receipt.moveProof?.unmatched === 0;
476
+ return true;
477
+ };
478
+ if (receipt.oversize === true) {
479
+ const unaccepted = [...tickedTickets].filter((id) => !accepted(id));
480
+ if (unaccepted.length || headlessTick) {
481
+ block(
482
+ `${ticked}\n\n` +
483
+ `The diff is ${receipt.size?.lines ?? 'over 1500'} changed lines outside tests, docs and generated files, over the 1500 cap. ` +
484
+ `Ticket(s) without a waiver: ${[...unaccepted, ...(headlessTick ? ['(a ticked line with no ticket heading)'] : [])].join(', ')}.\n\n` +
485
+ `Split the ticket (${GATE.reviewMap.splitHint}). If a split is impossible, the ` +
486
+ 'tech-lead (not a dev) adds the line `oversize-accepted: <reason>` to section 1 of ' +
487
+ `${dod.contract.replace('{id}', '<ID>')}, then re-run \`${CMD.verify}\`.` +
488
+ ([...unaccepted].some((id) => reasonsOf(id).length)
489
+ ? `\n\nA move-only waiver needs moveProof.unmatched === 0 (receipt has ${receipt.moveProof?.unmatched ?? 'none'}): run ${CMD.moveCheck}`
490
+ : ''),
491
+ );
492
+ }
493
+ }
494
+
495
+ approve();
package/is-main.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // Entry-point guard that survives a symlinked install. pnpm and `.bin` run a module through a
2
+ // symlink: process.argv[1] is the link, import.meta.url is the real path, so comparing the two as
3
+ // URLs never matches and the script silently does nothing. Compare real paths instead.
4
+ import { realpathSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ /** True when `url` (an import.meta.url) is the script this process is running. */
8
+ export function isMain(url, argv1 = process.argv[1]) {
9
+ if (!argv1) return false;
10
+ try {
11
+ return realpathSync(argv1) === realpathSync(fileURLToPath(url));
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
package/kill-group.mjs ADDED
@@ -0,0 +1,13 @@
1
+ // Process-group kill helpers: signal a spawned child's whole group, not only the child. A leaf
2
+ // module with no imports of its own, so any script that spawns a stack, a test run or a server
3
+ // can share it without pulling in a config or another script.
4
+ export const killGroup = (pid, sig) => process.kill(-pid, sig);
5
+
6
+ /** killGroup that ignores ESRCH: the group may already be gone. */
7
+ export function safeKillGroup(pid, sig) {
8
+ try {
9
+ killGroup(pid, sig);
10
+ } catch {
11
+ // ESRCH: the group is already gone
12
+ }
13
+ }