@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.
- package/LICENSE +202 -0
- package/README.md +82 -0
- 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 +21 -1
- 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/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
|
+
}
|