@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,419 @@
|
|
|
1
|
+
// Mutation rotation: run the registry's guards in batches, oldest proof first, inside a time
|
|
2
|
+
// budget, and keep a ledger of what each run did. The caller supplies `io` (clock, registry,
|
|
3
|
+
// process runner, files) so a project's own runner and `ticket-gate mutate --rotate` share this core.
|
|
4
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
import { safeKillGroup } from '../kill-group.mjs';
|
|
16
|
+
import { takeOrExit } from '../lock.mjs';
|
|
17
|
+
import { ROOT } from '../root.mjs';
|
|
18
|
+
import { compareKeys, loadRegistry } from './registry.mjs';
|
|
19
|
+
|
|
20
|
+
export const ROTATE_DEFAULTS = Object.freeze({
|
|
21
|
+
budgetMs: 3_600_000,
|
|
22
|
+
batch: 10,
|
|
23
|
+
graceS: 120,
|
|
24
|
+
receipt: '.turbo/mutation-registry.json',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const DAY_MS = 86_400_000;
|
|
28
|
+
const LEDGER_STATUS = new Set(['killed', 'survived', 'error']);
|
|
29
|
+
const ledgerFile = (dir) => join(dir, 'mutations.jsonl');
|
|
30
|
+
const ts = (l) => Date.parse(l.ranAt);
|
|
31
|
+
|
|
32
|
+
function parseLedgerLine(text) {
|
|
33
|
+
try {
|
|
34
|
+
const l = JSON.parse(text);
|
|
35
|
+
const ok =
|
|
36
|
+
typeof l?.key === 'string' &&
|
|
37
|
+
typeof l.sha === 'string' &&
|
|
38
|
+
typeof l.ranAt === 'string' &&
|
|
39
|
+
LEDGER_STATUS.has(l.status);
|
|
40
|
+
return ok ? [l] : [];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** <dir>/mutations.jsonl as [{ key, sha, status, ranAt }]; bad lines skipped, no file -> []. */
|
|
47
|
+
export function readLedger(dir) {
|
|
48
|
+
const f = ledgerFile(dir);
|
|
49
|
+
if (!existsSync(f)) return [];
|
|
50
|
+
return readFileSync(f, 'utf8')
|
|
51
|
+
.split('\n')
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.flatMap(parseLedgerLine);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per key its newest line and its newest killed line, sorted by key then ranAt. */
|
|
57
|
+
export function compactLedger(lines) {
|
|
58
|
+
const per = new Map();
|
|
59
|
+
for (const l of lines) {
|
|
60
|
+
const e = per.get(l.key) ?? { newest: null, killed: null };
|
|
61
|
+
if (e.newest === null || ts(l) >= ts(e.newest)) e.newest = l;
|
|
62
|
+
if (l.status === 'killed' && (e.killed === null || ts(l) >= ts(e.killed)))
|
|
63
|
+
e.killed = l;
|
|
64
|
+
per.set(l.key, e);
|
|
65
|
+
}
|
|
66
|
+
return [...per.values()]
|
|
67
|
+
.flatMap((e) =>
|
|
68
|
+
e.killed === null || e.killed === e.newest
|
|
69
|
+
? [e.newest]
|
|
70
|
+
: [e.newest, e.killed],
|
|
71
|
+
)
|
|
72
|
+
.sort((a, b) => compareKeys(a.key, b.key) || ts(a) - ts(b));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Live entries ([{ key, ... }]) -> keys: never run first, then oldest last attempt, ties by key. */
|
|
76
|
+
export function rotationOrder(live, ledger) {
|
|
77
|
+
const newest = new Map();
|
|
78
|
+
for (const l of ledger)
|
|
79
|
+
if (ts(l) > (newest.get(l.key) ?? -Infinity)) newest.set(l.key, ts(l));
|
|
80
|
+
const last = (k) => newest.get(k) ?? -Infinity;
|
|
81
|
+
return live
|
|
82
|
+
.map((e) => e.key)
|
|
83
|
+
.sort((a, b) => {
|
|
84
|
+
const d = last(a) < last(b) ? -1 : last(a) > last(b) ? 1 : 0;
|
|
85
|
+
return d || compareKeys(a, b);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function survivorOf(key, byKey, ledger) {
|
|
90
|
+
const e = byKey.get(key);
|
|
91
|
+
const kill = ledger
|
|
92
|
+
.filter((l) => l.key === key && l.status === 'killed')
|
|
93
|
+
.sort((a, b) => ts(b) - ts(a))[0];
|
|
94
|
+
return {
|
|
95
|
+
key,
|
|
96
|
+
file: e?.file ?? null,
|
|
97
|
+
testFile: e?.testFile ?? null,
|
|
98
|
+
lastKillSha: kill?.sha ?? null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Runs registry keys in rotation order in batches until the budget (soft: a batch in flight
|
|
104
|
+
* finishes) or the deadline (hard: the batch limit is the time left, SIGTERM then SIGKILL) is
|
|
105
|
+
* reached. Without a finite `deadlineMs` there is no hard deadline and a batch has no limit.
|
|
106
|
+
* Appends to <dir>/mutations.jsonl after every batch. Throws on a registry error; the caller
|
|
107
|
+
* records it. `command` is the batch runner as [cmd, ...args]; `--registry --ids=<keys>` is
|
|
108
|
+
* appended to it.
|
|
109
|
+
*/
|
|
110
|
+
export async function runRotation(
|
|
111
|
+
io,
|
|
112
|
+
{
|
|
113
|
+
dir,
|
|
114
|
+
cwd,
|
|
115
|
+
sha,
|
|
116
|
+
startMs,
|
|
117
|
+
deadlineMs,
|
|
118
|
+
budgetMs = ROTATE_DEFAULTS.budgetMs,
|
|
119
|
+
batch = ROTATE_DEFAULTS.batch,
|
|
120
|
+
graceS = ROTATE_DEFAULTS.graceS,
|
|
121
|
+
command,
|
|
122
|
+
env = {},
|
|
123
|
+
receipt: receiptRel = ROTATE_DEFAULTS.receipt,
|
|
124
|
+
},
|
|
125
|
+
) {
|
|
126
|
+
const reg = io.registry(cwd);
|
|
127
|
+
if (reg.errors.length > 0)
|
|
128
|
+
throw new Error(`registry is invalid: ${reg.errors[0]}`);
|
|
129
|
+
const entries = [...reg.entries].flatMap(([key, v]) =>
|
|
130
|
+
v.entry.kind === 'unguarded'
|
|
131
|
+
? []
|
|
132
|
+
: [
|
|
133
|
+
{
|
|
134
|
+
key,
|
|
135
|
+
file: v.entry.file ?? null,
|
|
136
|
+
testFile: v.entry.testFile ?? null,
|
|
137
|
+
retired: v.entry.retired,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
);
|
|
141
|
+
const live = entries.filter((e) => !e.retired);
|
|
142
|
+
const byKey = new Map(live.map((e) => [e.key, e]));
|
|
143
|
+
let ledger = readLedger(dir);
|
|
144
|
+
const order = rotationOrder(live, ledger);
|
|
145
|
+
const hard = Number.isFinite(deadlineMs);
|
|
146
|
+
const budgetEnd = startMs + budgetMs;
|
|
147
|
+
const stopAt = hard ? Math.min(budgetEnd, deadlineMs) : budgetEnd;
|
|
148
|
+
const receipt = join(cwd, receiptRel);
|
|
149
|
+
const n = { ran: 0, killed: 0, survived: 0, error: 0 };
|
|
150
|
+
const survivors = [];
|
|
151
|
+
let status = 'ran';
|
|
152
|
+
let reason = null;
|
|
153
|
+
let stoppedBy = 'done';
|
|
154
|
+
for (let i = 0; i < order.length; ) {
|
|
155
|
+
const now = io.now();
|
|
156
|
+
if (now >= stopAt) {
|
|
157
|
+
stoppedBy = !hard || budgetEnd <= deadlineMs ? 'budget' : 'deadline';
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const keys = order.slice(i, i + batch);
|
|
161
|
+
i += keys.length;
|
|
162
|
+
io.rm(receipt);
|
|
163
|
+
const r = await io.run(
|
|
164
|
+
command[0],
|
|
165
|
+
[...command.slice(1), '--registry', `--ids=${keys.join(',')}`],
|
|
166
|
+
{
|
|
167
|
+
cwd,
|
|
168
|
+
env,
|
|
169
|
+
limitS: hard
|
|
170
|
+
? Math.max(1, Math.ceil((deadlineMs - now) / 1000))
|
|
171
|
+
: undefined,
|
|
172
|
+
killSignal: 'SIGTERM',
|
|
173
|
+
graceS,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
if (r.timedOut) {
|
|
177
|
+
[status, reason, stoppedBy] = ['error', 'cut-short', null];
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
let results = null;
|
|
181
|
+
if (io.exists(receipt))
|
|
182
|
+
try {
|
|
183
|
+
const got = JSON.parse(io.readFile(receipt)).results;
|
|
184
|
+
results = Array.isArray(got) ? got : null;
|
|
185
|
+
} catch {
|
|
186
|
+
results = null;
|
|
187
|
+
}
|
|
188
|
+
if (results === null) {
|
|
189
|
+
[status, reason, stoppedBy] = ['error', 'no-receipt', null];
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
const fresh = [];
|
|
193
|
+
for (const x of results) {
|
|
194
|
+
if (!keys.includes(x?.id) || !LEDGER_STATUS.has(x.status)) continue;
|
|
195
|
+
if (x.status === 'survived')
|
|
196
|
+
survivors.push(survivorOf(x.id, byKey, ledger));
|
|
197
|
+
n[x.status] += 1;
|
|
198
|
+
n.ran += 1;
|
|
199
|
+
fresh.push({
|
|
200
|
+
key: x.id,
|
|
201
|
+
sha,
|
|
202
|
+
status: x.status,
|
|
203
|
+
ranAt: new Date(io.now()).toISOString(),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
ledger = compactLedger([...ledger, ...fresh]);
|
|
207
|
+
io.writeFile(
|
|
208
|
+
ledgerFile(dir),
|
|
209
|
+
`${ledger.map((l) => JSON.stringify(l)).join('\n')}\n`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const kills = live
|
|
213
|
+
.map(
|
|
214
|
+
(e) =>
|
|
215
|
+
ledger
|
|
216
|
+
.filter((l) => l.key === e.key && l.status === 'killed')
|
|
217
|
+
.map(ts)
|
|
218
|
+
.sort((a, b) => b - a)[0],
|
|
219
|
+
)
|
|
220
|
+
.filter((t) => t !== undefined);
|
|
221
|
+
return {
|
|
222
|
+
status,
|
|
223
|
+
reason,
|
|
224
|
+
stoppedBy,
|
|
225
|
+
ms: io.now() - startMs,
|
|
226
|
+
live: live.length,
|
|
227
|
+
...n,
|
|
228
|
+
oldestProofDays:
|
|
229
|
+
kills.length === 0
|
|
230
|
+
? null
|
|
231
|
+
: Math.floor((io.now() - Math.min(...kills)) / DAY_MS),
|
|
232
|
+
unproven: live.length - kills.length,
|
|
233
|
+
survivors,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The batch runner `ticket-gate mutate --rotate` uses: the command in its own process group,
|
|
239
|
+
* killed past `limitS` (`killSignal`, then SIGKILL after `graceS`), stragglers killed on exit.
|
|
240
|
+
* Resolves { code, timedOut, out }; never rejects.
|
|
241
|
+
*/
|
|
242
|
+
export function runGroup(
|
|
243
|
+
cmd,
|
|
244
|
+
args,
|
|
245
|
+
{ cwd, env = {}, limitS, killSignal = 'SIGTERM', graceS = 120 } = {},
|
|
246
|
+
) {
|
|
247
|
+
return new Promise((done) => {
|
|
248
|
+
let out = '';
|
|
249
|
+
let timedOut = false;
|
|
250
|
+
let settled = false;
|
|
251
|
+
const timers = [];
|
|
252
|
+
const child = spawn(cmd, args, {
|
|
253
|
+
cwd,
|
|
254
|
+
env: { ...process.env, ...env },
|
|
255
|
+
detached: true,
|
|
256
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
257
|
+
});
|
|
258
|
+
const finish = (code) => {
|
|
259
|
+
if (settled) return;
|
|
260
|
+
settled = true;
|
|
261
|
+
timers.forEach(clearTimeout);
|
|
262
|
+
safeKillGroup(child.pid, 'SIGKILL');
|
|
263
|
+
done({ code, timedOut, out });
|
|
264
|
+
};
|
|
265
|
+
if (limitS !== undefined)
|
|
266
|
+
timers.push(
|
|
267
|
+
setTimeout(() => {
|
|
268
|
+
timedOut = true;
|
|
269
|
+
safeKillGroup(child.pid, killSignal);
|
|
270
|
+
timers.push(
|
|
271
|
+
setTimeout(() => {
|
|
272
|
+
safeKillGroup(child.pid, 'SIGKILL');
|
|
273
|
+
finish(124);
|
|
274
|
+
}, graceS * 1000),
|
|
275
|
+
);
|
|
276
|
+
}, limitS * 1000),
|
|
277
|
+
);
|
|
278
|
+
child.stdout.on('data', (d) => (out += d));
|
|
279
|
+
child.stderr.on('data', (d) => (out += d));
|
|
280
|
+
child.on('error', (err) => {
|
|
281
|
+
out += String(err);
|
|
282
|
+
finish(1);
|
|
283
|
+
});
|
|
284
|
+
child.on('close', (code) => finish(code ?? 1));
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const ROTATE_FLAGS = [
|
|
289
|
+
'rotate',
|
|
290
|
+
'ledger',
|
|
291
|
+
'budget-min',
|
|
292
|
+
'batch',
|
|
293
|
+
'deadline',
|
|
294
|
+
'sha',
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
function rotateUsage(msg, setup) {
|
|
298
|
+
console.error(
|
|
299
|
+
`${msg}\nusage: ${setup.commands.mutate} --rotate --ledger=<dir> [--budget-min=<n>] [--batch=<n>] [--deadline=<ISO>] [--sha=<sha>]`,
|
|
300
|
+
);
|
|
301
|
+
return 2;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const positive = (text) => {
|
|
305
|
+
const n = Number(text);
|
|
306
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* `ticket-gate mutate --rotate`: one budgeted rotation over the live registry, ledger under
|
|
311
|
+
* `--ledger`. Returns the exit code: 0 ran with no survivor, 1 a survivor or an error, 2 usage
|
|
312
|
+
* or an invalid registry. The summary is printed either way.
|
|
313
|
+
*/
|
|
314
|
+
export async function rotateMain(args, setup) {
|
|
315
|
+
const code = await rotate(args, setup);
|
|
316
|
+
process.exitCode = code;
|
|
317
|
+
return code;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Turn the `--rotate` flags into the run's settings, or `{ error }` with the usage message.
|
|
322
|
+
* `now` is the start time in ms. Without `--deadline` there is no hard deadline: the budget is
|
|
323
|
+
* soft and the batch in flight at its end finishes.
|
|
324
|
+
*/
|
|
325
|
+
export function resolveRotateFlags(args, now) {
|
|
326
|
+
const flags = new Map();
|
|
327
|
+
for (const a of args) {
|
|
328
|
+
const m = /^--([a-z-]+)(?:=(.*))?$/.exec(a);
|
|
329
|
+
if (!m || !ROTATE_FLAGS.includes(m[1]))
|
|
330
|
+
return { error: `unexpected argument ${a}` };
|
|
331
|
+
flags.set(m[1], m[2]);
|
|
332
|
+
}
|
|
333
|
+
if (!flags.get('ledger')) return { error: '--rotate needs --ledger=<dir>' };
|
|
334
|
+
const budgetMin = flags.has('budget-min')
|
|
335
|
+
? positive(flags.get('budget-min'))
|
|
336
|
+
: 60;
|
|
337
|
+
if (budgetMin === null)
|
|
338
|
+
return { error: '--budget-min must be a positive number' };
|
|
339
|
+
const batch = flags.has('batch')
|
|
340
|
+
? positive(flags.get('batch'))
|
|
341
|
+
: ROTATE_DEFAULTS.batch;
|
|
342
|
+
if (batch === null || !Number.isInteger(batch))
|
|
343
|
+
return { error: '--batch must be a positive integer' };
|
|
344
|
+
const deadlineMs = flags.has('deadline')
|
|
345
|
+
? Date.parse(flags.get('deadline'))
|
|
346
|
+
: undefined;
|
|
347
|
+
if (flags.has('deadline') && !Number.isFinite(deadlineMs))
|
|
348
|
+
return { error: '--deadline must be an ISO date-time' };
|
|
349
|
+
return {
|
|
350
|
+
ledger: flags.get('ledger'),
|
|
351
|
+
sha: flags.get('sha'),
|
|
352
|
+
startMs: now,
|
|
353
|
+
budgetMs: budgetMin * 60_000,
|
|
354
|
+
batch,
|
|
355
|
+
deadlineMs,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function rotate(args, setup) {
|
|
360
|
+
const f = resolveRotateFlags(args, Date.now());
|
|
361
|
+
if (f.error) return rotateUsage(f.error, setup);
|
|
362
|
+
const { startMs, deadlineMs, batch } = f;
|
|
363
|
+
const sha =
|
|
364
|
+
f.sha ||
|
|
365
|
+
execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
366
|
+
cwd: ROOT,
|
|
367
|
+
encoding: 'utf8',
|
|
368
|
+
}).trim();
|
|
369
|
+
const dir = resolve(ROOT, f.ledger);
|
|
370
|
+
// One run on the local stack at a time; the batch children inherit the owner id.
|
|
371
|
+
takeOrExit('mutate:check');
|
|
372
|
+
mkdirSync(dir, { recursive: true });
|
|
373
|
+
const io = {
|
|
374
|
+
now: Date.now,
|
|
375
|
+
registry: (root) => loadRegistry(root, setup),
|
|
376
|
+
run: runGroup,
|
|
377
|
+
rm: (p) => rmSync(p, { force: true }),
|
|
378
|
+
exists: existsSync,
|
|
379
|
+
readFile: (p) => readFileSync(p, 'utf8'),
|
|
380
|
+
writeFile: (p, text) => writeFileSync(p, text),
|
|
381
|
+
};
|
|
382
|
+
let summary;
|
|
383
|
+
try {
|
|
384
|
+
summary = await runRotation(io, {
|
|
385
|
+
dir,
|
|
386
|
+
cwd: ROOT,
|
|
387
|
+
sha,
|
|
388
|
+
startMs,
|
|
389
|
+
deadlineMs,
|
|
390
|
+
budgetMs: f.budgetMs,
|
|
391
|
+
batch,
|
|
392
|
+
command: [
|
|
393
|
+
process.execPath,
|
|
394
|
+
join(
|
|
395
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
396
|
+
'..',
|
|
397
|
+
'bin',
|
|
398
|
+
'ticket-gate.mjs',
|
|
399
|
+
),
|
|
400
|
+
'mutate',
|
|
401
|
+
],
|
|
402
|
+
receipt: setup.receipts.registry,
|
|
403
|
+
});
|
|
404
|
+
} catch (err) {
|
|
405
|
+
console.error(
|
|
406
|
+
`${err.message}\nRun ${setup.commands.checkMutations} for the full report.`,
|
|
407
|
+
);
|
|
408
|
+
return err.message.startsWith('registry is invalid') ? 2 : 1;
|
|
409
|
+
}
|
|
410
|
+
const s = summary;
|
|
411
|
+
console.log(
|
|
412
|
+
`rotate: ${s.ran} ran (${s.killed} killed, ${s.survived} survived, ${s.error} error) of ${s.live} live; ${s.stoppedBy ?? `error ${s.reason}`}; oldest proof ${s.oldestProofDays ?? 'none'} days; ${s.unproven} unproven`,
|
|
413
|
+
);
|
|
414
|
+
for (const v of s.survivors)
|
|
415
|
+
console.log(
|
|
416
|
+
` ${v.key} survived ${v.file ?? '-'} ${v.testFile ?? '-'} last kill ${v.lastKillSha ?? 'none'}`,
|
|
417
|
+
);
|
|
418
|
+
return s.status === 'ran' && s.survivors.length === 0 ? 0 : 1;
|
|
419
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { WINDOW_MS, byRanAtDesc } from '../nightly/record.mjs';
|
|
2
|
+
import { SEED_CAP, gitText, realGit, short, suspectsFirst } from '../nightly/seed.mjs';
|
|
3
|
+
|
|
4
|
+
export const ROTATION_WARN_DAYS = 14;
|
|
5
|
+
|
|
6
|
+
// ------------------------------------------------ mutation rotation
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Days for the registry to rotate once: ceil(live / perNight), perNight = the
|
|
10
|
+
* mean `ran` over the last 7 days of nights whose phase ran (or errored after
|
|
11
|
+
* running some). null when no such night, or no live count. `live` defaults to
|
|
12
|
+
* the newest history line that carries a mutations record.
|
|
13
|
+
*/
|
|
14
|
+
export function rotationDays(history, nowMs, live = null) {
|
|
15
|
+
const withPhase = history.filter((r) => r.mutations);
|
|
16
|
+
const total = live ?? withPhase.sort(byRanAtDesc)[0]?.mutations?.live ?? null;
|
|
17
|
+
const nights = withPhase.filter(
|
|
18
|
+
(r) =>
|
|
19
|
+
nowMs - Date.parse(r.ranAt) <= WINDOW_MS &&
|
|
20
|
+
(r.mutations.status === 'ran' ||
|
|
21
|
+
(r.mutations.status === 'error' && r.mutations.ran > 0)),
|
|
22
|
+
);
|
|
23
|
+
if (nights.length === 0 || total === null) return null;
|
|
24
|
+
const perNight =
|
|
25
|
+
nights.reduce((n, r) => n + (r.mutations.ran ?? 0), 0) / nights.length;
|
|
26
|
+
return perNight > 0 ? Math.ceil(total / perNight) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const touches = (files, survivor) =>
|
|
30
|
+
files.some((f) => f === survivor.file || f === survivor.testFile);
|
|
31
|
+
|
|
32
|
+
/** Merges since the survivor's last kill (or the newest 20), suspects first. */
|
|
33
|
+
export function mutationSeed({ survivor, sha, git }) {
|
|
34
|
+
const lastKillSha = survivor.lastKillSha ?? null;
|
|
35
|
+
const logArgs = ['log', '--first-parent', '--merges', '--format=%H%x09%s'];
|
|
36
|
+
const out = lastKillSha
|
|
37
|
+
? gitText(git, ...logArgs, `${lastKillSha}..${sha}`)
|
|
38
|
+
: gitText(git, ...logArgs, '-n', String(SEED_CAP), sha);
|
|
39
|
+
const merges = out
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.slice(0, lastKillSha ? undefined : SEED_CAP)
|
|
43
|
+
.map((row) => {
|
|
44
|
+
const [msha, ...rest] = row.split('\t');
|
|
45
|
+
const files = gitText(git, 'diff', '--name-only', `${msha}^1`, msha)
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
return {
|
|
49
|
+
sha: msha,
|
|
50
|
+
subject: rest.join('\t'),
|
|
51
|
+
suspect: touches(files, survivor),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
return { key: survivor.key, lastKillSha, merges: suspectsFirst(merges) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The mutation lines of the status output; empty when the night has no phase record. */
|
|
58
|
+
export function mutationLines(latest, history, nowMs, git = realGit) {
|
|
59
|
+
const m = latest?.mutations;
|
|
60
|
+
if (!m) return [];
|
|
61
|
+
const rot = rotationDays(history, nowMs, m.live ?? null);
|
|
62
|
+
const proof =
|
|
63
|
+
m.oldestProofDays === null || m.oldestProofDays === undefined
|
|
64
|
+
? 'never'
|
|
65
|
+
: `${m.oldestProofDays} days`;
|
|
66
|
+
let text = `mutations: ${m.killed} killed, ${m.survived} survived, ${m.error} error; oldest proof ${proof}; rotation ${rot === null ? 'n/a' : `${rot} days`}`;
|
|
67
|
+
if (m.unproven > 0) text += ` (${m.unproven} unproven)`;
|
|
68
|
+
if (m.status !== 'ran') text += ` [phase ${m.status}: ${m.reason}]`;
|
|
69
|
+
if (rot !== null && rot > ROTATION_WARN_DAYS)
|
|
70
|
+
text += ` WARN: rotation over ${ROTATION_WARN_DAYS} days; raise TICKET_GATE_NIGHTLY_MUTATION_MIN or take the Move-to-CI trigger`;
|
|
71
|
+
const lines = [text];
|
|
72
|
+
const day = new Date(nowMs).toLocaleDateString('en-CA');
|
|
73
|
+
for (const survivor of m.survivors ?? []) {
|
|
74
|
+
lines.push(`mutation survivor: ${survivor.key} (${survivor.testFile})`);
|
|
75
|
+
try {
|
|
76
|
+
const seed = mutationSeed({ survivor, sha: latest.sha, git });
|
|
77
|
+
lines.push(
|
|
78
|
+
seed.lastKillSha
|
|
79
|
+
? `fix seed: merges since last kill ${short(seed.lastKillSha)} (suspects first)`
|
|
80
|
+
: `fix seed: no kill on record; the newest ${SEED_CAP} merges (suspects first)`,
|
|
81
|
+
);
|
|
82
|
+
for (const g of seed.merges)
|
|
83
|
+
lines.push(` ${g.suspect ? '*' : ' '} ${short(g.sha)} ${g.subject}`);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
lines.push(`mutation fix seed unavailable: ${e.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if ((m.survivors ?? []).length > 0)
|
|
89
|
+
lines.push(
|
|
90
|
+
` next: branch fix/mutations-${day} seeded from the lines above`,
|
|
91
|
+
);
|
|
92
|
+
return lines;
|
|
93
|
+
}
|