@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,252 @@
|
|
|
1
|
+
import { patchKey } from './served.mjs';
|
|
2
|
+
import { localHarness, sessionOf } from './harness.mjs';
|
|
3
|
+
import { projectSetup } from './project.mjs';
|
|
4
|
+
import { tail, zeroResult, zeroTestRun } from './verdict.mjs';
|
|
5
|
+
|
|
6
|
+
// A rebuild rule matches a patch under its files whose test does not already build.
|
|
7
|
+
export const ruleMatches = (rule, m) =>
|
|
8
|
+
m.kind === 'patch' &&
|
|
9
|
+
m.file.startsWith(rule.files) &&
|
|
10
|
+
!rule.unlessTest.test(m.test);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A patch a rebuild rule covers (today: packages/core/src whose test is not a
|
|
14
|
+
* core-package command). Other packages resolve it through its build output,
|
|
15
|
+
* so they never see the patched source until it is rebuilt.
|
|
16
|
+
*/
|
|
17
|
+
export const needsCoreBuild = (m, setup = projectSetup()) =>
|
|
18
|
+
setup.rebuild.some((rule) => ruleMatches(rule, m));
|
|
19
|
+
|
|
20
|
+
/** The first deferral the setup's harnesses declare. */
|
|
21
|
+
const deferOf = (setup) => setup.harnesses.find((h) => h.defer)?.defer;
|
|
22
|
+
|
|
23
|
+
const redText = (base) =>
|
|
24
|
+
`test red before mutation: fix the test or its command first (${tail(base.out)})`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs the mutations. `io` is injected so tests need no shell, pnpm or stack.
|
|
28
|
+
* `io.only` (array of ids) restricts the run; `io.state` (optional object) is
|
|
29
|
+
* kept current so a signal handler can restore the in-flight mutation.
|
|
30
|
+
* Every entry runs in the session of the kind or harness that claims it:
|
|
31
|
+
* refuse -> inline open -> rebuild -> inline baselines and loop -> settle ->
|
|
32
|
+
* close -> grouped sessions by order (precheck, open, group, run, close). A
|
|
33
|
+
* reset that cannot reproduce the baseline is fatal (`report.fatal`).
|
|
34
|
+
*/
|
|
35
|
+
export async function runMutations(spec, io, setup = projectSetup()) {
|
|
36
|
+
const state = io.state ?? {};
|
|
37
|
+
const todo = io.only
|
|
38
|
+
? spec.mutations.filter((m) => io.only.includes(m.id))
|
|
39
|
+
: spec.mutations;
|
|
40
|
+
const partial = todo.length !== spec.mutations.length;
|
|
41
|
+
const sessionFor = new Map(todo.map((m) => [m, sessionOf(m, setup)]));
|
|
42
|
+
const entriesOf = new Map();
|
|
43
|
+
for (const m of todo) {
|
|
44
|
+
const s = sessionFor.get(m);
|
|
45
|
+
if (!s) throw new Error(`mutations config: ${m.id} has no session`);
|
|
46
|
+
if (!entriesOf.has(s)) entriesOf.set(s, []);
|
|
47
|
+
entriesOf.get(s).push(m);
|
|
48
|
+
}
|
|
49
|
+
const sessions = [...entriesOf.keys()].sort((a, b) => a.order - b.order);
|
|
50
|
+
const stop = (extra) => ({
|
|
51
|
+
results: [],
|
|
52
|
+
ok: false,
|
|
53
|
+
partial,
|
|
54
|
+
rerun: 0,
|
|
55
|
+
...extra,
|
|
56
|
+
});
|
|
57
|
+
for (const s of sessions) {
|
|
58
|
+
const refused = await s.refuse?.(entriesOf.get(s), io);
|
|
59
|
+
if (refused) return stop({ refused });
|
|
60
|
+
}
|
|
61
|
+
const inline = sessions.filter((s) => s.inline);
|
|
62
|
+
for (const s of inline) {
|
|
63
|
+
const o = await s.open?.(entriesOf.get(s), io, state);
|
|
64
|
+
if (o?.fatal) return stop({ fatal: o.fatal });
|
|
65
|
+
}
|
|
66
|
+
for (const rule of setup.rebuild) {
|
|
67
|
+
if (!todo.some((m) => ruleMatches(rule, m))) continue;
|
|
68
|
+
// A signal mid-build leaves the output half-written; interruptHandler rebuilds while set.
|
|
69
|
+
state[rule.flag] = true;
|
|
70
|
+
const b = await io[rule.io]();
|
|
71
|
+
if (b.code !== 0)
|
|
72
|
+
return stop({
|
|
73
|
+
fatal: `${rule.cmd} failed before the baselines: ${tail(b.out)}`,
|
|
74
|
+
});
|
|
75
|
+
state[rule.flag] = false;
|
|
76
|
+
}
|
|
77
|
+
// The engine baselines the inline sessions it owns (local, and kind sessions such as
|
|
78
|
+
// sql); an inline harness a pack adds measures its own baseline inside runOne.
|
|
79
|
+
const ownBaseline = (s) => s === localHarness || !setup.harnesses.includes(s);
|
|
80
|
+
const plain = todo.filter((m) => sessionFor.get(m).inline);
|
|
81
|
+
const extra = inline.flatMap((s) => (s.zeroTests ? [s.zeroTests] : []));
|
|
82
|
+
const baseline = new Map();
|
|
83
|
+
for (const m of plain) {
|
|
84
|
+
if (!ownBaseline(sessionFor.get(m)) || baseline.has(m.test)) continue;
|
|
85
|
+
let r;
|
|
86
|
+
try {
|
|
87
|
+
r = await io.run(m.test);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
r = { code: 1, out: String(err) };
|
|
90
|
+
}
|
|
91
|
+
baseline.set(m.test, { ...r, zero: zeroTestRun(m.test, r.out, extra) });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ctx = {
|
|
95
|
+
fatal: null,
|
|
96
|
+
setup,
|
|
97
|
+
// Re-runs one entry through its own session (the pollution re-run of a killed patch).
|
|
98
|
+
runOne: (m) => sessionFor.get(m).runOne(m, io, state, ctx),
|
|
99
|
+
rulesFor: (m) => setup.rebuild.filter((rule) => ruleMatches(rule, m)),
|
|
100
|
+
};
|
|
101
|
+
let results = [];
|
|
102
|
+
for (const m of plain) {
|
|
103
|
+
const base = baseline.get(m.test);
|
|
104
|
+
if (!ownBaseline(sessionFor.get(m))) {
|
|
105
|
+
results.push(await sessionFor.get(m).runOne(m, io, state, ctx));
|
|
106
|
+
if (ctx.fatal) break;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (base.zero) {
|
|
110
|
+
results.push(zeroResult(m, base.zero, 'before the mutation'));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (base.code !== 0) {
|
|
114
|
+
results.push({ id: m.id, status: 'error', detail: redText(base) });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
results.push(await sessionFor.get(m).runOne(m, io, state, ctx));
|
|
118
|
+
if (ctx.fatal) break;
|
|
119
|
+
}
|
|
120
|
+
let rerun = 0;
|
|
121
|
+
const inlineEntries = inline.flatMap((x) => entriesOf.get(x));
|
|
122
|
+
for (const s of inline) {
|
|
123
|
+
if (ctx.fatal) break;
|
|
124
|
+
if (s.settle) {
|
|
125
|
+
const r = await s.settle(results, inlineEntries, io, state, ctx);
|
|
126
|
+
results = r.results;
|
|
127
|
+
rerun += r.rerun;
|
|
128
|
+
}
|
|
129
|
+
if (!ctx.fatal && s.close)
|
|
130
|
+
ctx.fatal = (await s.close(io, state, ctx)).fatal ?? null;
|
|
131
|
+
}
|
|
132
|
+
let groups;
|
|
133
|
+
let builds;
|
|
134
|
+
let serveFatal;
|
|
135
|
+
const tally = (r) => {
|
|
136
|
+
if (r.groups) groups = [...(groups ?? []), ...r.groups];
|
|
137
|
+
if (r.builds !== undefined) builds = (builds ?? 0) + r.builds;
|
|
138
|
+
};
|
|
139
|
+
const emit = (r, group) =>
|
|
140
|
+
results.push(group === undefined ? r : { ...r, group });
|
|
141
|
+
for (const session of sessions.filter((x) => !x.inline)) {
|
|
142
|
+
if (ctx.fatal || serveFatal) break;
|
|
143
|
+
let entries = entriesOf.get(session);
|
|
144
|
+
// A patch that cannot compile can never pass the build: report it before the clean build.
|
|
145
|
+
if (session.precheck) {
|
|
146
|
+
const tc = await session.precheck(entries, io);
|
|
147
|
+
const compiles = (m) => tc.get(patchKey(m))?.ok !== false;
|
|
148
|
+
for (const m of entries.filter((x) => !compiles(x)))
|
|
149
|
+
results.push({
|
|
150
|
+
id: m.id,
|
|
151
|
+
status: 'error',
|
|
152
|
+
detail: `typecheck: ${tc.get(patchKey(m)).line}`,
|
|
153
|
+
});
|
|
154
|
+
entries = entries.filter(compiles);
|
|
155
|
+
}
|
|
156
|
+
const opened = (await session.open?.(entries, io, state)) ?? {};
|
|
157
|
+
tally(opened);
|
|
158
|
+
if (opened.fatal) {
|
|
159
|
+
serveFatal = opened.fatal;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const zeros = session.zeroTests ? [session.zeroTests] : [];
|
|
163
|
+
const ready = [];
|
|
164
|
+
for (const m of entries) {
|
|
165
|
+
const base = opened.baselines?.get(m.test);
|
|
166
|
+
const zero = base && zeroTestRun(m.test, base.out, zeros);
|
|
167
|
+
if (zero) results.push(zeroResult(m, zero, 'before the mutation'));
|
|
168
|
+
else if (base && base.code !== 0)
|
|
169
|
+
results.push({ id: m.id, status: 'error', detail: redText(base) });
|
|
170
|
+
else ready.push(m);
|
|
171
|
+
}
|
|
172
|
+
if (ready.length > 0) {
|
|
173
|
+
const grouped = session.group(ready, opened);
|
|
174
|
+
const ran = await session.run(grouped, io, emit, state, ctx);
|
|
175
|
+
tally(ran);
|
|
176
|
+
if (ran.fatal) ctx.fatal = ran.fatal;
|
|
177
|
+
}
|
|
178
|
+
if (session.close) {
|
|
179
|
+
const closed = await session.close(io, state, ctx);
|
|
180
|
+
if (closed?.fatal) ctx.fatal = closed.fatal;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
state.restore = null;
|
|
184
|
+
state.sqlApplied = false;
|
|
185
|
+
const ok =
|
|
186
|
+
!ctx.fatal &&
|
|
187
|
+
!serveFatal &&
|
|
188
|
+
!partial &&
|
|
189
|
+
results.length > 0 &&
|
|
190
|
+
results.every((r) => r.status === 'killed');
|
|
191
|
+
const report = { results, ok, partial, rerun };
|
|
192
|
+
if (groups) Object.assign(report, { groups, builds });
|
|
193
|
+
if (serveFatal)
|
|
194
|
+
Object.assign(report, { fatal: serveFatal, ok: false, serveFatal: true });
|
|
195
|
+
if (ctx.fatal) report.fatal = ctx.fatal;
|
|
196
|
+
return report;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** SIGINT or SIGTERM: stop a server, put back every patched file, reset what a sql mutation left, rebuild what a patch built. */
|
|
200
|
+
export async function interruptHandler(
|
|
201
|
+
state,
|
|
202
|
+
io,
|
|
203
|
+
exit = process.exit,
|
|
204
|
+
signal = 'SIGINT',
|
|
205
|
+
setup = projectSetup(),
|
|
206
|
+
) {
|
|
207
|
+
for (const h of setup.harnesses) {
|
|
208
|
+
try {
|
|
209
|
+
await h.interrupt?.(state, io, setup);
|
|
210
|
+
} catch {
|
|
211
|
+
/* best effort */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
state.restore?.();
|
|
216
|
+
} catch {
|
|
217
|
+
/* best effort */
|
|
218
|
+
}
|
|
219
|
+
for (const kind of setup.kinds.values())
|
|
220
|
+
await kind.session?.interrupt?.(state, io, setup);
|
|
221
|
+
// A patch built into its output: rebuild so the output matches the restored source.
|
|
222
|
+
for (const rule of setup.rebuild)
|
|
223
|
+
if (state[rule.flag] && (await io[rule.io]()).code !== 0)
|
|
224
|
+
console.error(`run ${rule.cmd}`);
|
|
225
|
+
exit(signal === 'SIGTERM' ? 143 : 130);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Exit code for a finished run (tree check excluded). */
|
|
229
|
+
export const exitCodeFor = (report) =>
|
|
230
|
+
report.results.length > 0 &&
|
|
231
|
+
report.results.every((r) => r.status === 'killed') &&
|
|
232
|
+
!report.partial
|
|
233
|
+
? 0
|
|
234
|
+
: 1;
|
|
235
|
+
|
|
236
|
+
/** What main does after the run: exit code, and whether a receipt is written. */
|
|
237
|
+
export function finalOutcome(report, changedPaths) {
|
|
238
|
+
if (report.fatal) return { code: 1, writeReceipt: false };
|
|
239
|
+
if (changedPaths.length > 0) return { code: 1, writeReceipt: false };
|
|
240
|
+
return { code: exitCodeFor(report), writeReceipt: true };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The line the ticket gate prints for the entries it left to the nightly rotation. */
|
|
244
|
+
export const deferredLine = (keys, defer = deferOf(projectSetup())) =>
|
|
245
|
+
`mutate:check deferred: ${keys.length} ${defer.label} ${keys.length === 1 ? 'entry' : 'entries'} left to ${defer.to}: ${keys.join(', ')}`;
|
|
246
|
+
|
|
247
|
+
export function summaryLine(ticket, results, rerun = 0, builds) {
|
|
248
|
+
const n = (s) => results.filter((r) => r.status === s).length;
|
|
249
|
+
const base = `mutate:check ${ticket}: ${n('killed')}/${results.length} killed, ${n('survived')} survived, ${n('error')} error`;
|
|
250
|
+
const line = rerun > 0 ? `${base}, ${rerun} re-run after pollution` : base;
|
|
251
|
+
return builds === undefined ? line : `${line}, ${builds} builds`;
|
|
252
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { isServed } from './served.mjs';
|
|
6
|
+
import { ROOT } from '../root.mjs';
|
|
7
|
+
import { baseRef } from '../tree.mjs';
|
|
8
|
+
import { patchKind } from './kinds.mjs';
|
|
9
|
+
import { projectSetup } from './project.mjs';
|
|
10
|
+
import { compareKeys, loadRegistry, locate, registryAt } from './registry.mjs';
|
|
11
|
+
|
|
12
|
+
/** Rule `guard`: a hunk this many lines (or fewer) from the `find` span selects. */
|
|
13
|
+
export const NEAR = 3;
|
|
14
|
+
|
|
15
|
+
const TITLE_RE = /^\s*(?:Deno\.)?(?:test|it|describe)(?:\.\w+)*\(/;
|
|
16
|
+
const indent = (line) => line.length - line.trimStart().length;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `git diff -U0 --no-color` text -> Map<newPath, Array<[start, end]>> (1-based,
|
|
20
|
+
* inclusive, new side). `+++ /dev/null` is skipped; a pure deletion `+c,0`
|
|
21
|
+
* becomes [c, c+1] so both neighbours count.
|
|
22
|
+
*/
|
|
23
|
+
export function parseHunks(diffU0) {
|
|
24
|
+
const out = new Map();
|
|
25
|
+
let path = null;
|
|
26
|
+
for (const line of String(diffU0).split('\n')) {
|
|
27
|
+
if (line.startsWith('+++ ')) {
|
|
28
|
+
const p = line.slice(4).split('\t')[0];
|
|
29
|
+
path = p === '/dev/null' ? null : p.replace(/^b\//, '');
|
|
30
|
+
if (path && !out.has(path)) out.set(path, []);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (line.startsWith('--- ') || path === null) continue;
|
|
34
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
35
|
+
if (!m) continue;
|
|
36
|
+
const c = Number(m[1]);
|
|
37
|
+
const d = m[2] === undefined ? 1 : Number(m[2]);
|
|
38
|
+
out.get(path).push(d > 0 ? [c, c + d - 1] : [c, c + 1]);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `git diff -M --name-status` text -> [{ status: 'A'|'M'|'D'|'R', path, from? }]. */
|
|
44
|
+
export function parseNameStatus(text) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const line of String(text).split('\n')) {
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
const cols = line.split('\t');
|
|
49
|
+
const s = cols[0][0];
|
|
50
|
+
if (s === 'R' || s === 'C')
|
|
51
|
+
out.push({ status: s === 'R' ? 'R' : 'A', path: cols[2], from: cols[1] });
|
|
52
|
+
else out.push({ status: s, path: cols[1] });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 1-based inclusive line ranges of every block whose title line contains
|
|
59
|
+
* `testName` literally: from that line to the line before the next line at the
|
|
60
|
+
* same or lower indentation that opens a test, else to EOF.
|
|
61
|
+
*/
|
|
62
|
+
export function testBlocks(source, testName) {
|
|
63
|
+
const lines = String(source).split('\n');
|
|
64
|
+
const out = [];
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
if (!lines[i].includes(testName)) continue;
|
|
67
|
+
// prettier may put the title on the line after `test(`
|
|
68
|
+
const s =
|
|
69
|
+
!TITLE_RE.test(lines[i]) && TITLE_RE.test(lines[i - 1] ?? '') ? i - 1 : i;
|
|
70
|
+
const own = indent(lines[s]);
|
|
71
|
+
let end = lines.length;
|
|
72
|
+
for (let j = s + 1; j < lines.length; j++) {
|
|
73
|
+
if (indent(lines[j]) <= own && TITLE_RE.test(lines[j])) break;
|
|
74
|
+
end = j + 1;
|
|
75
|
+
}
|
|
76
|
+
out.push([s + 1, end]);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const overlaps = (hunks, path, lo, hi) =>
|
|
82
|
+
(hunks.get(path) ?? []).some(([hs, he]) => hs <= hi && he >= lo);
|
|
83
|
+
|
|
84
|
+
const lineAt = (src, offset) => src.slice(0, offset).split('\n').length;
|
|
85
|
+
|
|
86
|
+
function nearGuard(ctx, key, rec) {
|
|
87
|
+
const e = rec.entry;
|
|
88
|
+
if (e.kind !== 'patch' || !ctx.hunks.has(e.file)) return false;
|
|
89
|
+
const src = ctx.readFile(e.file);
|
|
90
|
+
if (src === null) return false;
|
|
91
|
+
const hit = locate(src, e.find, e.within);
|
|
92
|
+
if (hit.count !== 1) return false;
|
|
93
|
+
const start = lineAt(src, hit.index);
|
|
94
|
+
const end = lineAt(src, hit.index + Math.max(hit.length - 1, 0));
|
|
95
|
+
return overlaps(ctx.hunks, e.file, start - NEAR, end + NEAR);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function inTestBlock(ctx, key, rec) {
|
|
99
|
+
const e = rec.entry;
|
|
100
|
+
if (!e.testFile || !ctx.hunks.has(e.testFile)) return false;
|
|
101
|
+
if (!e.testName) return true;
|
|
102
|
+
const src = ctx.readFile(e.testFile);
|
|
103
|
+
if (src === null) return false;
|
|
104
|
+
return testBlocks(src, e.testName).some(([lo, hi]) =>
|
|
105
|
+
overlaps(ctx.hunks, e.testFile, lo, hi),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sortKeysDeep = (v) =>
|
|
110
|
+
Array.isArray(v)
|
|
111
|
+
? v.map(sortKeysDeep)
|
|
112
|
+
: v && typeof v === 'object'
|
|
113
|
+
? Object.fromEntries(
|
|
114
|
+
Object.keys(v)
|
|
115
|
+
.sort()
|
|
116
|
+
.map((k) => [k, sortKeysDeep(v[k])]),
|
|
117
|
+
)
|
|
118
|
+
: v;
|
|
119
|
+
// The selection compares only what changes the run: an area move, a new `protects`,
|
|
120
|
+
// `testName` or `reaimed` item leaves the mutation and its test as they were.
|
|
121
|
+
// The fields are the kind's `runFields`, behind `kind` itself.
|
|
122
|
+
export const RUN_FIELDS = Object.freeze(['kind', ...patchKind.runFields]);
|
|
123
|
+
const canon = (entry, setup) => {
|
|
124
|
+
const fields = setup.kinds.get(entry.kind)?.runFields ?? patchKind.runFields;
|
|
125
|
+
return JSON.stringify(
|
|
126
|
+
sortKeysDeep(
|
|
127
|
+
Object.fromEntries(['kind', ...fields].map((f) => [f, entry[f] ?? null])),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function entryChanged(ctx, key, rec) {
|
|
133
|
+
return (
|
|
134
|
+
canon(rec.entry, ctx.setup) !==
|
|
135
|
+
canon(ctx.baseEntries.get(key).entry, ctx.setup)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pathMoved(ctx, key, rec) {
|
|
140
|
+
const now = [rec.entry.file, rec.entry.testFile];
|
|
141
|
+
const was = ctx.baseEntries.get(key).entry;
|
|
142
|
+
return (
|
|
143
|
+
now.some((p) => p && ctx.added.has(p)) ||
|
|
144
|
+
[was.file, was.testFile].some((p) => p && ctx.gone.has(p))
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The selection rules in order: guard, test, entry, each kind's `selectRules`, moved. */
|
|
149
|
+
export const rulesOf = (setup) => [
|
|
150
|
+
['guard', nearGuard],
|
|
151
|
+
['test', inTestBlock],
|
|
152
|
+
['entry', entryChanged],
|
|
153
|
+
...[...setup.kinds.values()].flatMap((k) => k.selectRules ?? []),
|
|
154
|
+
['moved', pathMoved],
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pure. entries/baseEntries: loadRegistry shape Map<key, { path, line, entry }>;
|
|
159
|
+
* hunks: parseHunks output; statuses: parseNameStatus output plus untracked
|
|
160
|
+
* files as 'A'; readFile(path) -> string | null (the working tree).
|
|
161
|
+
* Returns [{ key, rules }] sorted by key; a key is in it iff >= 1 rule holds.
|
|
162
|
+
*/
|
|
163
|
+
export function selectEntries(
|
|
164
|
+
{ entries, baseEntries, hunks, statuses, readFile },
|
|
165
|
+
setup = projectSetup(),
|
|
166
|
+
) {
|
|
167
|
+
const rules = rulesOf(setup);
|
|
168
|
+
const ctx = {
|
|
169
|
+
setup,
|
|
170
|
+
baseEntries,
|
|
171
|
+
hunks,
|
|
172
|
+
readFile,
|
|
173
|
+
changed: new Set([...hunks.keys(), ...statuses.map((s) => s.path)]),
|
|
174
|
+
added: new Set(
|
|
175
|
+
statuses
|
|
176
|
+
.filter((s) => s.status === 'A' || s.status === 'R')
|
|
177
|
+
.map((s) => s.path),
|
|
178
|
+
),
|
|
179
|
+
gone: new Set(
|
|
180
|
+
statuses.flatMap((s) =>
|
|
181
|
+
s.status === 'D' ? [s.path] : s.status === 'R' ? [s.from] : [],
|
|
182
|
+
),
|
|
183
|
+
),
|
|
184
|
+
};
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const key of [...entries.keys()].sort(compareKeys)) {
|
|
187
|
+
const rec = entries.get(key);
|
|
188
|
+
if (!baseEntries.has(key)) continue;
|
|
189
|
+
if (rec.entry.retired) continue;
|
|
190
|
+
if (setup.kinds.get(rec.entry.kind)?.runs === false) continue;
|
|
191
|
+
const held = rules.filter(([, fn]) => fn(ctx, key, rec)).map(([n]) => n);
|
|
192
|
+
if (held.length) out.push({ key, rules: held });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The ticket gate runs only the entries that need no `next build`: a selected
|
|
199
|
+
* Playwright-backed entry (`isServed`) is left to the nightly rotation (tools/nightly-e2e.mjs
|
|
200
|
+
* `runMutationPhase` runs every live entry, `rotationOrder`). Pure.
|
|
201
|
+
* `selection`: selectChanged output; `entries`: loadRegistry shape.
|
|
202
|
+
* Returns { run, deferred }, both [{ key, rules }] in selection order.
|
|
203
|
+
*/
|
|
204
|
+
/** The live registry entries (loadRegistry shape), for callers that must not read the registry themselves (step-inputs SI-R2). */
|
|
205
|
+
export const registryEntries = (root = ROOT, setup = projectSetup()) =>
|
|
206
|
+
loadRegistry(root, setup).entries;
|
|
207
|
+
|
|
208
|
+
/** The registry entries at a git ref (same reason as registryEntries; used by the precheck). */
|
|
209
|
+
export const registryEntriesAt = (ref, root = ROOT, setup = projectSetup()) =>
|
|
210
|
+
registryAt(ref, root, setup).entries;
|
|
211
|
+
|
|
212
|
+
export function splitServed(selection, entries, setup = projectSetup()) {
|
|
213
|
+
const run = [];
|
|
214
|
+
const deferred = [];
|
|
215
|
+
for (const s of selection) {
|
|
216
|
+
const e = entries.get(s.key)?.entry;
|
|
217
|
+
(e && isServed(e, setup) ? deferred : run).push(s);
|
|
218
|
+
}
|
|
219
|
+
return { run, deferred };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const git = (root, args) =>
|
|
223
|
+
execFileSync('git', ['-c', 'core.quotepath=off', ...args], {
|
|
224
|
+
cwd: root,
|
|
225
|
+
encoding: 'utf8',
|
|
226
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
227
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The I/O edge: diffs the WORKING TREE against `base` (tracked changes plus
|
|
232
|
+
* untracked files, as tree-hash.changedPaths does) and selects against the
|
|
233
|
+
* registry in the tree and at `base`. Throws on an invalid registry.
|
|
234
|
+
*/
|
|
235
|
+
export function selectChanged(
|
|
236
|
+
{ base, root = ROOT } = {},
|
|
237
|
+
setup = projectSetup(),
|
|
238
|
+
) {
|
|
239
|
+
const ref = base || baseRef();
|
|
240
|
+
const reg = loadRegistry(root, setup);
|
|
241
|
+
if (reg.errors.length)
|
|
242
|
+
throw new Error(`registry invalid: ${reg.errors.join('; ')}`);
|
|
243
|
+
const baseReg = registryAt(ref, root, setup);
|
|
244
|
+
const hunks = parseHunks(
|
|
245
|
+
git(root, ['diff', '-U0', '--no-color', '-M', ref, '--']),
|
|
246
|
+
);
|
|
247
|
+
const statuses = parseNameStatus(
|
|
248
|
+
git(root, ['diff', '-M', '--name-status', ref, '--']),
|
|
249
|
+
);
|
|
250
|
+
const readFile = (p) => {
|
|
251
|
+
try {
|
|
252
|
+
return readFileSync(join(root, p), 'utf8');
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const untracked = git(root, [
|
|
258
|
+
'ls-files',
|
|
259
|
+
'-z',
|
|
260
|
+
'--others',
|
|
261
|
+
'--exclude-standard',
|
|
262
|
+
])
|
|
263
|
+
.split('\0')
|
|
264
|
+
.filter(Boolean);
|
|
265
|
+
for (const p of untracked) {
|
|
266
|
+
statuses.push({ status: 'A', path: p });
|
|
267
|
+
const src = readFile(p);
|
|
268
|
+
if (src !== null) hunks.set(p, [[1, Math.max(src.split('\n').length, 1)]]);
|
|
269
|
+
}
|
|
270
|
+
return selectEntries(
|
|
271
|
+
{
|
|
272
|
+
entries: reg.entries,
|
|
273
|
+
baseEntries: baseReg.entries,
|
|
274
|
+
hunks,
|
|
275
|
+
statuses,
|
|
276
|
+
readFile,
|
|
277
|
+
},
|
|
278
|
+
setup,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* For tech-lead: live entries whose `file` or `testFile` equals a listed path or
|
|
284
|
+
* lies under a listed path ending `/`; sql entries whose snapshot is a listed path.
|
|
285
|
+
* [{ key, path, protects }] sorted by key.
|
|
286
|
+
*/
|
|
287
|
+
export function affected(paths, entries, setup = projectSetup()) {
|
|
288
|
+
const hit = (p) =>
|
|
289
|
+
paths.some((q) => (q.endsWith('/') ? p.startsWith(q) : p === q));
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const key of [...entries.keys()].sort(compareKeys)) {
|
|
292
|
+
const { entry: e } = entries.get(key);
|
|
293
|
+
const kind = setup.kinds.get(e.kind);
|
|
294
|
+
if (e.retired || kind?.runs === false) continue;
|
|
295
|
+
const path = [e.file, e.testFile, ...(kind?.paths?.(e) ?? [])].find(
|
|
296
|
+
(p) => p && hit(p),
|
|
297
|
+
);
|
|
298
|
+
if (path) out.push({ key, path, protects: e.protects ?? '' });
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function main() {
|
|
304
|
+
const args = process.argv.slice(2);
|
|
305
|
+
const flag = (n) =>
|
|
306
|
+
args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3);
|
|
307
|
+
if (args.includes('--affected')) {
|
|
308
|
+
const paths = flag('paths')?.split(',').filter(Boolean);
|
|
309
|
+
if (!paths) {
|
|
310
|
+
console.error(
|
|
311
|
+
'mutation-select: --affected needs --paths=<a,b,...> (the files your ticket changes; end a folder with /)',
|
|
312
|
+
);
|
|
313
|
+
process.exit(2);
|
|
314
|
+
}
|
|
315
|
+
const rows = affected(paths, loadRegistry().entries);
|
|
316
|
+
for (const r of rows) console.log(`${r.key} ${r.path} ${r.protects}`);
|
|
317
|
+
console.log(`mutations:affected: ${rows.length} entries`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const all = selectChanged({ base: flag('base') });
|
|
321
|
+
const reg = loadRegistry().entries;
|
|
322
|
+
const defer = args.includes('--defer-playwright');
|
|
323
|
+
const { run, deferred } = splitServed(all, reg);
|
|
324
|
+
const sel = defer ? run : all;
|
|
325
|
+
for (const s of sel) console.log(`${s.key} ${s.rules.join(',')}`);
|
|
326
|
+
console.log(
|
|
327
|
+
defer
|
|
328
|
+
? `mutation-select: ${sel.length} selected (${deferred.length} playwright deferred to the nightly)`
|
|
329
|
+
: `mutation-select: ${sel.length} selected (${deferred.length} playwright)`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export { main };
|