@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,240 @@
|
|
|
1
|
+
// Sessions and harnesses (entry kinds and setup): which session runs an entry, and
|
|
2
|
+
// how a patch is put on disk and taken off again. The pure grouping and
|
|
3
|
+
// attribution functions disjointGroups/attribute join this
|
|
4
|
+
// file; none names a platform.
|
|
5
|
+
import { patchedSource } from './locate.mjs';
|
|
6
|
+
import { tail, verdict } from './verdict.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Applies each patch in order on the progressively patched text of its file.
|
|
10
|
+
* Each file's original is recorded once in `state.live` and `state.restore`
|
|
11
|
+
* puts every live file back. A `find` that no longer occurs once throws and
|
|
12
|
+
* leaves the tree as it was.
|
|
13
|
+
*/
|
|
14
|
+
export function applyPatch(patches, io, state) {
|
|
15
|
+
const live = (state.live ??= new Map());
|
|
16
|
+
const texts = new Map();
|
|
17
|
+
const fresh = [];
|
|
18
|
+
try {
|
|
19
|
+
for (const m of patches) {
|
|
20
|
+
if (!texts.has(m.file)) {
|
|
21
|
+
const original = io.readFile(m.file);
|
|
22
|
+
texts.set(m.file, original);
|
|
23
|
+
if (!live.has(m.file)) {
|
|
24
|
+
live.set(m.file, original);
|
|
25
|
+
fresh.push(m.file);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
texts.set(m.file, patchedSource(texts.get(m.file), m));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (patches.length === 1) throw err;
|
|
32
|
+
throw new Error(
|
|
33
|
+
`${m.id}: find no longer occurs once with the other patches applied`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
for (const file of fresh) live.delete(file);
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
state.restore = () => restorePatch(state, io);
|
|
42
|
+
try {
|
|
43
|
+
for (const [file, text] of texts) io.writeFile(file, text);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
try {
|
|
46
|
+
restorePatch(state, io);
|
|
47
|
+
} catch {
|
|
48
|
+
/* the write error is the one to report */
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Writes every live file back and checks it; a no-op when nothing is live. */
|
|
55
|
+
export function restorePatch(state, io) {
|
|
56
|
+
if (!state.live) {
|
|
57
|
+
state.restore = null;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let failed = null;
|
|
61
|
+
for (const [file, original] of state.live) {
|
|
62
|
+
try {
|
|
63
|
+
io.writeFile(file, original);
|
|
64
|
+
if (io.readFile(file) !== original)
|
|
65
|
+
throw new Error(`restore of ${file} is not byte-identical`);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
failed ??= err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
state.live.clear();
|
|
71
|
+
state.restore = null;
|
|
72
|
+
if (failed) throw failed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The inline fallback harness: runs every patch no other harness claims, one at
|
|
77
|
+
* a time. Rebuild rules (`ctx.rulesFor`) bracket the run: the flag is set while
|
|
78
|
+
* the patch is live, and the build runs again once the file is back.
|
|
79
|
+
*/
|
|
80
|
+
export const localHarness = {
|
|
81
|
+
name: 'local',
|
|
82
|
+
order: 0,
|
|
83
|
+
inline: true,
|
|
84
|
+
claims: () => true,
|
|
85
|
+
async runOne(m, io, state, ctx) {
|
|
86
|
+
const rules = ctx.rulesFor(m);
|
|
87
|
+
let result;
|
|
88
|
+
try {
|
|
89
|
+
for (const rule of rules) state[rule.flag] = true;
|
|
90
|
+
applyPatch([m], io, state);
|
|
91
|
+
for (const rule of rules) {
|
|
92
|
+
const b = await io[rule.io]();
|
|
93
|
+
if (b.code !== 0)
|
|
94
|
+
throw new Error(`${rule.cmd} failed after apply: ${tail(b.out)}`);
|
|
95
|
+
}
|
|
96
|
+
result = verdict(m, await io.run(m.test));
|
|
97
|
+
} catch (err) {
|
|
98
|
+
result = {
|
|
99
|
+
id: m.id,
|
|
100
|
+
status: 'error',
|
|
101
|
+
detail: String(err?.message ?? err),
|
|
102
|
+
};
|
|
103
|
+
} finally {
|
|
104
|
+
try {
|
|
105
|
+
restorePatch(state, io);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const detail = `RESTORE FAILED: ${err.message}. Run ${ctx.setup.commands.restoreHint}`;
|
|
108
|
+
if (result) result = { ...result, status: 'error', detail };
|
|
109
|
+
else result = { id: m.id, status: 'error', detail };
|
|
110
|
+
}
|
|
111
|
+
state.restore = null;
|
|
112
|
+
}
|
|
113
|
+
for (const rule of rules) {
|
|
114
|
+
// The built output must match the source again before the next mutation.
|
|
115
|
+
const b = await io[rule.io]();
|
|
116
|
+
if (b.code === 0) state[rule.flag] = false;
|
|
117
|
+
else {
|
|
118
|
+
ctx.fatal = `${rule.cmd} failed after restoring ${m.file}: ${rule.drift}. Run ${rule.cmd}`;
|
|
119
|
+
result = { id: m.id, status: 'error', detail: ctx.fatal };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const claimedTwice = (entry, claimants) =>
|
|
127
|
+
new Error(
|
|
128
|
+
`mutations config: ${entry.id} is claimed by both ${claimants[0].name} and ${claimants[1].name}`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The session that runs an entry: a non-patch kind's own `session`, or the
|
|
133
|
+
* harness claiming a patch (localHarness claims every patch nobody else does).
|
|
134
|
+
* Throws when two harnesses claim one entry.
|
|
135
|
+
*/
|
|
136
|
+
export function sessionOf(entry, setup) {
|
|
137
|
+
if (entry.kind !== 'patch') return setup.kinds.get(entry.kind)?.session;
|
|
138
|
+
const claimants = setup.harnesses.filter(
|
|
139
|
+
(h) => h !== localHarness && h.claims(entry),
|
|
140
|
+
);
|
|
141
|
+
if (claimants.length > 1) throw claimedTwice(entry, claimants);
|
|
142
|
+
return claimants[0] ?? localHarness;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- grouping and attribution (pure; no io, no platform name) -------------
|
|
146
|
+
|
|
147
|
+
const shares = (a, b) => a.some((x) => b.includes(x));
|
|
148
|
+
const overlaps = (a, b) => a.start <= b.end && b.start <= a.end;
|
|
149
|
+
const soloGroup = (entry, why) => ({
|
|
150
|
+
key: entry.id,
|
|
151
|
+
entries: [entry],
|
|
152
|
+
solo: why,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// prettier-ignore
|
|
156
|
+
function compatible(a, b) {
|
|
157
|
+
if (shares(a.tests, b.tests)) return false;
|
|
158
|
+
if (shares(a.cover, b.tests) || shares(b.cover, a.tests)) return false;
|
|
159
|
+
if (a.span.file === b.span.file && overlaps(a.span, b.span)) return false;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Greedy first-fit grouping in input order. A member joins a group only when
|
|
165
|
+
* it is compatible with every member: no shared test, no test of one covering
|
|
166
|
+
* the other's site, no overlapping lines in one file. `testsOf(entry)` is the
|
|
167
|
+
* tests that own the entry (null: unparsable); `coverOf(entry)` is
|
|
168
|
+
* `{ cover, span }` (null: no coverage). Both fall back to a solo group.
|
|
169
|
+
*/
|
|
170
|
+
export function disjointGroups(entries, testsOf, coverOf) {
|
|
171
|
+
const out = [];
|
|
172
|
+
const open = [];
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
const tests = testsOf(entry);
|
|
175
|
+
if (tests == null) {
|
|
176
|
+
out.push(soloGroup(entry, 'tests unparsable'));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const facts = coverOf(entry);
|
|
180
|
+
if (facts == null) {
|
|
181
|
+
out.push(soloGroup(entry, 'no coverage'));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const m = { entry, tests, cover: facts.cover, span: facts.span };
|
|
185
|
+
const home = open.find((g) => g.members.every((o) => compatible(o, m)));
|
|
186
|
+
if (home) {
|
|
187
|
+
home.members.push(m);
|
|
188
|
+
home.entries.push(entry);
|
|
189
|
+
} else {
|
|
190
|
+
const g = { key: entry.id, entries: [entry], members: [m] };
|
|
191
|
+
open.push(g);
|
|
192
|
+
out.push(g);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const holds = (span, c) =>
|
|
199
|
+
span.file === c.file && span.start <= c.line && c.line <= span.end;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Maps one run of a group back to its members. `{ solo }` means the run cannot
|
|
203
|
+
* be attributed and each member must be run alone. A compile error that lands
|
|
204
|
+
* in exactly one member's span marks that member an error and returns the rest
|
|
205
|
+
* to be deployed again.
|
|
206
|
+
*/
|
|
207
|
+
// prettier-ignore
|
|
208
|
+
export function attribute(group, run) {
|
|
209
|
+
if (!run.complete) return { solo: 'run cut short' };
|
|
210
|
+
const members = group.members;
|
|
211
|
+
if (run.compile) {
|
|
212
|
+
const owners = members.filter((m) => holds(m.span, run.compile));
|
|
213
|
+
if (owners.length !== 1) return { solo: 'compile error not attributable' };
|
|
214
|
+
return {
|
|
215
|
+
results: [
|
|
216
|
+
{
|
|
217
|
+
id: owners[0].entry.id,
|
|
218
|
+
status: 'error',
|
|
219
|
+
detail: `compile: ${run.compile.message}`,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
redeploy: members.filter((m) => m !== owners[0]),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
for (const row of run.rows) {
|
|
226
|
+
if (row.outcome !== 'fail') continue;
|
|
227
|
+
const owner = members.find((m) => m.tests.includes(row.test));
|
|
228
|
+
if (owner === undefined) return { solo: `row owned by no member: ${row.test}` };
|
|
229
|
+
}
|
|
230
|
+
const results = members.map((m) => {
|
|
231
|
+
const own = run.rows.filter((r) => m.tests.includes(r.test));
|
|
232
|
+
const id = m.entry.id;
|
|
233
|
+
if (own.some((r) => r.outcome === 'fail'))
|
|
234
|
+
return { id, status: 'killed', detail: 'a covering test failed' };
|
|
235
|
+
if (own.length > 0)
|
|
236
|
+
return { id, status: 'survived', detail: 'every covering test passed' };
|
|
237
|
+
return { id, status: 'error', detail: 'no rows' };
|
|
238
|
+
});
|
|
239
|
+
return { results };
|
|
240
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Entry kinds and the setup a project hands the engine.
|
|
2
|
+
//
|
|
3
|
+
// @typedef {object} Kind
|
|
4
|
+
// name, runs: boolean, fields: string[], shape(entry, readFile) -> string[],
|
|
5
|
+
// stale?(entry, readFile) -> string[], area?(entry, dir) -> string,
|
|
6
|
+
// runFields?: string[], selectRules?: [name, (ctx, key, rec) -> boolean][],
|
|
7
|
+
// repair?(entry, diff) -> { entry, notes? } | { left: string },
|
|
8
|
+
// planNote?(entry) -> string | null, session?: Session,
|
|
9
|
+
// validate?(entry, readFile) -> string[] (what validateSpec adds for a runnable entry),
|
|
10
|
+
// paths?(entry) -> string[] (files the entry guards, for `affected`),
|
|
11
|
+
// strays?(entry) -> string[] (a field this kind owns, checked on an entry of any kind)
|
|
12
|
+
// readFile(rel) returns the text of a repo file and throws when it is missing.
|
|
13
|
+
// @typedef {object} Setup
|
|
14
|
+
// dir, receipts: { ticket, registry }, area(entry), fieldOrder, legacy | null,
|
|
15
|
+
// rebuild: Rule[], childEnv: { drop }, commands, kinds: Map<name, Kind & { by }>,
|
|
16
|
+
// harnesses: Harness[], reuseEnv
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { localHarness } from './harness.mjs';
|
|
20
|
+
|
|
21
|
+
const isStr = (v) => typeof v === 'string' && v.trim() !== '';
|
|
22
|
+
|
|
23
|
+
const defaultArea = (entry, dir) => {
|
|
24
|
+
const d = dirname(entry.file);
|
|
25
|
+
return d === '.' ? `${dir}/_root.json` : `${dir}/${d}.json`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const ENGINE_DEFAULTS = Object.freeze({
|
|
29
|
+
dir: 'mutation-registry',
|
|
30
|
+
receipts: Object.freeze({
|
|
31
|
+
ticket: '.turbo/mutation-check.json',
|
|
32
|
+
registry: '.turbo/mutation-registry.json',
|
|
33
|
+
}),
|
|
34
|
+
area: (entry) => defaultArea(entry, 'mutation-registry'),
|
|
35
|
+
fieldOrder: Object.freeze([
|
|
36
|
+
'kind',
|
|
37
|
+
'protects',
|
|
38
|
+
'file',
|
|
39
|
+
'within',
|
|
40
|
+
'find',
|
|
41
|
+
'replace',
|
|
42
|
+
'test',
|
|
43
|
+
'testFile',
|
|
44
|
+
'testName',
|
|
45
|
+
'why',
|
|
46
|
+
'retired',
|
|
47
|
+
'reaimed',
|
|
48
|
+
]),
|
|
49
|
+
legacy: null,
|
|
50
|
+
rebuild: Object.freeze([]),
|
|
51
|
+
childEnv: Object.freeze({ drop: Object.freeze([]) }),
|
|
52
|
+
commands: Object.freeze({
|
|
53
|
+
mutate: 'ticket-gate mutate',
|
|
54
|
+
checkMutations: 'ticket-gate mutations check',
|
|
55
|
+
checkMutationsScript: 'ticket-gate mutations check',
|
|
56
|
+
afterFatal: 'restore the tree, then re-run',
|
|
57
|
+
restoreHint: 'git checkout on the file',
|
|
58
|
+
}),
|
|
59
|
+
reuseEnv: Object.freeze({}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** A patch: replace `find` by `replace` in `file`; the harness that claims it runs `test`. */
|
|
63
|
+
export const patchKind = {
|
|
64
|
+
name: 'patch',
|
|
65
|
+
runs: true,
|
|
66
|
+
fields: [
|
|
67
|
+
'protects',
|
|
68
|
+
'file',
|
|
69
|
+
'within',
|
|
70
|
+
'find',
|
|
71
|
+
'replace',
|
|
72
|
+
'test',
|
|
73
|
+
'testFile',
|
|
74
|
+
'testName',
|
|
75
|
+
],
|
|
76
|
+
runFields: [
|
|
77
|
+
'file',
|
|
78
|
+
'within',
|
|
79
|
+
'find',
|
|
80
|
+
'replace',
|
|
81
|
+
'apply',
|
|
82
|
+
'restore',
|
|
83
|
+
'test',
|
|
84
|
+
'object',
|
|
85
|
+
'also',
|
|
86
|
+
],
|
|
87
|
+
shape(entry) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const f of ['protects', 'file', 'find', 'test', 'testFile'])
|
|
90
|
+
if (!isStr(entry[f])) out.push(`${f} must be a non-empty string`);
|
|
91
|
+
if (typeof entry.replace !== 'string') out.push('replace must be a string');
|
|
92
|
+
if (entry.within !== undefined && !isStr(entry.within))
|
|
93
|
+
out.push('within must be a non-empty string');
|
|
94
|
+
return out;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const kindTwice = (name, a, b) =>
|
|
99
|
+
new Error(`mutations config: kind "${name}" is named by both ${a} and ${b}`);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Merges a project config over the engine defaults. Kinds resolve in order
|
|
103
|
+
* engine (`patch`), each pack's `mutationKinds`, then `config.kinds`; harnesses
|
|
104
|
+
* are every pack's `mutationHarnesses` in pack order, then `localHarness` last.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveSetup(config = {}) {
|
|
107
|
+
const packs = config.packs ?? [];
|
|
108
|
+
const kinds = new Map();
|
|
109
|
+
const add = (name, kind, by) => {
|
|
110
|
+
if (kinds.has(name)) throw kindTwice(name, kinds.get(name).by, by);
|
|
111
|
+
kinds.set(name, { ...kind, by });
|
|
112
|
+
};
|
|
113
|
+
add('patch', patchKind, 'engine');
|
|
114
|
+
for (const p of packs)
|
|
115
|
+
for (const [name, kind] of Object.entries(p.mutationKinds ?? {}))
|
|
116
|
+
add(name, kind, p.name);
|
|
117
|
+
for (const [name, kind] of Object.entries(config.kinds ?? {}))
|
|
118
|
+
add(name, kind, 'config');
|
|
119
|
+
const dir = config.dir ?? ENGINE_DEFAULTS.dir;
|
|
120
|
+
return {
|
|
121
|
+
dir,
|
|
122
|
+
receipts: config.receipts ?? ENGINE_DEFAULTS.receipts,
|
|
123
|
+
area: config.area ?? ((entry) => defaultArea(entry, dir)),
|
|
124
|
+
fieldOrder: config.fieldOrder ?? ENGINE_DEFAULTS.fieldOrder,
|
|
125
|
+
legacy: config.legacy ?? ENGINE_DEFAULTS.legacy,
|
|
126
|
+
rebuild: config.rebuild ?? ENGINE_DEFAULTS.rebuild,
|
|
127
|
+
childEnv: config.childEnv ?? ENGINE_DEFAULTS.childEnv,
|
|
128
|
+
commands: { ...ENGINE_DEFAULTS.commands, ...config.commands },
|
|
129
|
+
reuseEnv: config.reuseEnv ?? ENGINE_DEFAULTS.reuseEnv,
|
|
130
|
+
kinds,
|
|
131
|
+
harnesses: [
|
|
132
|
+
...packs.flatMap((p) => p.mutationHarnesses ?? []),
|
|
133
|
+
localHarness,
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const isRef = (w) => /\d/.test(w) || /\.(?:spec|test)\.[cm]?[jt]sx?$/.test(w);
|
|
139
|
+
|
|
140
|
+
/** Why a `protects` is too thin (null when fine): 4+ words, at least one a row or spec id. */
|
|
141
|
+
export function checkProtects(protects) {
|
|
142
|
+
if (typeof protects !== 'string') return 'protects must be a string';
|
|
143
|
+
const words = protects.split(/\s+/).filter(Boolean);
|
|
144
|
+
if (words.length < 4)
|
|
145
|
+
return `protects has ${words.length} words, needs 4 or more`;
|
|
146
|
+
if (!words.some(isRef)) return 'protects names no contract row or spec id';
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Where a patch's `find` sits in a source (moved out of registry.mjs so a kind
|
|
2
|
+
// module can use it without reaching the project setup).
|
|
3
|
+
const WS_RUN = /\s+/g;
|
|
4
|
+
|
|
5
|
+
/** A global RegExp for `find`: every whitespace run in it matches `\s+`. */
|
|
6
|
+
export function findPattern(find) {
|
|
7
|
+
const escaped = find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
return new RegExp(escaped.replace(WS_RUN, '\\s+'), 'g');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const lineStart = (src, i) => src.lastIndexOf('\n', i - 1) + 1;
|
|
12
|
+
const indentOf = (src, ls) => /^[ \t]*/.exec(src.slice(ls))[0].length;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The block an anchor opens: from the anchor's line up to, not including, the
|
|
16
|
+
* first later non-blank line indented <= the anchor's whose first character is
|
|
17
|
+
* not `}`, `)` or `]` (end of file when none). { count } when the anchor does
|
|
18
|
+
* not occur exactly once.
|
|
19
|
+
*/
|
|
20
|
+
export function blockSpan(src, anchor) {
|
|
21
|
+
const hits = [...src.matchAll(findPattern(anchor))];
|
|
22
|
+
if (hits.length !== 1) return { count: hits.length };
|
|
23
|
+
const start = lineStart(src, hits[0].index);
|
|
24
|
+
const indent = indentOf(src, start);
|
|
25
|
+
const from = hits[0].index + hits[0][0].length;
|
|
26
|
+
let pos = src.indexOf('\n', from);
|
|
27
|
+
while (pos !== -1) {
|
|
28
|
+
const ls = pos + 1;
|
|
29
|
+
const line = src.slice(
|
|
30
|
+
ls,
|
|
31
|
+
src.indexOf('\n', ls) === -1 ? src.length : src.indexOf('\n', ls),
|
|
32
|
+
);
|
|
33
|
+
if (
|
|
34
|
+
line.trim() !== '' &&
|
|
35
|
+
indentOf(src, ls) <= indent &&
|
|
36
|
+
!'})]'.includes(line.trim()[0])
|
|
37
|
+
)
|
|
38
|
+
return { start, end: ls };
|
|
39
|
+
pos = src.indexOf('\n', ls);
|
|
40
|
+
}
|
|
41
|
+
return { start, end: src.length };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where `find` sits: { count, index, length }; index and length only when count is 1. */
|
|
45
|
+
export function locate(src, find, within) {
|
|
46
|
+
if (typeof find !== 'string' || find === '') return { count: 0 };
|
|
47
|
+
let base = 0;
|
|
48
|
+
let hay = src;
|
|
49
|
+
if (within !== undefined) {
|
|
50
|
+
const span = blockSpan(src, within);
|
|
51
|
+
if (span.count !== undefined) return { count: 0, anchorCount: span.count };
|
|
52
|
+
base = span.start;
|
|
53
|
+
hay = src.slice(span.start, span.end);
|
|
54
|
+
}
|
|
55
|
+
const hits = [...hay.matchAll(findPattern(find))];
|
|
56
|
+
if (hits.length !== 1) return { count: hits.length };
|
|
57
|
+
return { count: 1, index: base + hits[0].index, length: hits[0][0].length };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function patchedSource(original, m) {
|
|
61
|
+
const hit = locate(original, m.find, m.within);
|
|
62
|
+
if (hit.count !== 1)
|
|
63
|
+
throw new Error(
|
|
64
|
+
`"find" occurs ${hit.count} times in ${m.file}, must be exactly 1`,
|
|
65
|
+
);
|
|
66
|
+
return (
|
|
67
|
+
original.slice(0, hit.index) +
|
|
68
|
+
m.replace +
|
|
69
|
+
original.slice(hit.index + hit.length)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The mutation engine's injection point. The engine modules read the project's Setup through
|
|
2
|
+
// `projectSetup()`; whoever drives them (the bin, a script, a test) binds one first with
|
|
3
|
+
// `bindSetup(setupOf(gate))`. This module imports nothing outside `node:`: the pack is in the
|
|
4
|
+
// gate config's import graph, so importing the config from here would deadlock.
|
|
5
|
+
let bound = null;
|
|
6
|
+
|
|
7
|
+
/** Bind the Setup the engine modules will read; returns it. */
|
|
8
|
+
export function bindSetup(setup) {
|
|
9
|
+
bound = setup;
|
|
10
|
+
return setup;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function notBound() {
|
|
14
|
+
return new Error(
|
|
15
|
+
'mutations: no setup bound. Run ticket-gate mutate or ticket-gate mutations, or call bindSetup(setupOf(gate)) first',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The bound Setup. Throws when nothing was bound. */
|
|
20
|
+
export const projectSetup = () => {
|
|
21
|
+
if (bound === null) throw notBound();
|
|
22
|
+
return bound;
|
|
23
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { ROOT } from '../root.mjs';
|
|
7
|
+
import { treeHash } from '../tree.mjs';
|
|
8
|
+
import { projectSetup } from './project.mjs';
|
|
9
|
+
|
|
10
|
+
// jest, vitest and playwright match -t / -g against the FULL name (describe
|
|
11
|
+
// titles + test title), so a leading ^ selects nothing. node's pattern matches
|
|
12
|
+
// each level's own name and is exempt.
|
|
13
|
+
export const CARET_FILTER =
|
|
14
|
+
/(?:^|\s)(?:-t|--testNamePattern|-g|--grep)(?:=|\s+)['"]?\^/;
|
|
15
|
+
export const CARET_HINT =
|
|
16
|
+
'"test" filter starts with ^: jest and vitest -t (and playwright -g) match the full name (describe titles + test title), so a leading ^ selects no test; drop the ^';
|
|
17
|
+
|
|
18
|
+
export const KEY_RE = /^[A-Z][A-Za-z0-9-]*\/[A-Z]{1,2}\d+$/;
|
|
19
|
+
export const FILTER_RE =
|
|
20
|
+
/(?:^|\s)(?:-t|--testNamePattern|-g|--grep|--test-name-pattern)(?:=|\s)|(?<=\bdeno test\b.*)\s--filter(?:=|\s)/;
|
|
21
|
+
|
|
22
|
+
/** Orders keys `<ID>/<Mn>`: digit runs numerically, everything else by code unit. */
|
|
23
|
+
export function compareKeys(a, b) {
|
|
24
|
+
const parts = (k) => k.match(/\d+|\D+/g) ?? [];
|
|
25
|
+
const pa = parts(a);
|
|
26
|
+
const pb = parts(b);
|
|
27
|
+
for (let i = 0; i < Math.min(pa.length, pb.length); i++) {
|
|
28
|
+
const x = pa[i];
|
|
29
|
+
const y = pb[i];
|
|
30
|
+
if (x === y) continue;
|
|
31
|
+
const dx = /^\d/.test(x);
|
|
32
|
+
const dy = /^\d/.test(y);
|
|
33
|
+
if (dx && dy) {
|
|
34
|
+
const d = Number(x) - Number(y);
|
|
35
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
return x < y ? -1 : 1;
|
|
39
|
+
}
|
|
40
|
+
return pa.length === pb.length ? 0 : pa.length < pb.length ? -1 : 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The repo-relative area file an entry belongs in: its kind's `area`, else the setup's. */
|
|
44
|
+
export function areaFor(entry, setup = projectSetup()) {
|
|
45
|
+
const kind = setup.kinds.get(entry.kind);
|
|
46
|
+
return kind?.area ? kind.area(entry, setup.dir) : setup.area(entry);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lineOf = (text, needle) => {
|
|
50
|
+
const i = text.indexOf(needle);
|
|
51
|
+
return i === -1 ? 1 : text.slice(0, i).split('\n').length;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function walkJson(dir, out = []) {
|
|
55
|
+
let names;
|
|
56
|
+
try {
|
|
57
|
+
names = readdirSync(dir).sort();
|
|
58
|
+
} catch {
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
for (const n of names) {
|
|
62
|
+
const p = join(dir, n);
|
|
63
|
+
if (statSync(p).isDirectory()) walkJson(p, out);
|
|
64
|
+
else if (n.endsWith('.json')) out.push(p);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function addArea(entries, errors, rel, text) {
|
|
70
|
+
let obj;
|
|
71
|
+
try {
|
|
72
|
+
obj = JSON.parse(text);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
errors.push(`${rel}: not valid JSON (${err.message})`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
78
|
+
errors.push(`${rel}: must be an object of { "<ID>/<Mn>": entry }`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const [key, entry] of Object.entries(obj)) {
|
|
82
|
+
if (entries.has(key)) {
|
|
83
|
+
errors.push(
|
|
84
|
+
`${rel}: duplicate key ${key} (also in ${entries.get(key).path})`,
|
|
85
|
+
);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
entries.set(key, { path: rel, line: lineOf(text, `"${key}"`), entry });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The registry in the working tree: { entries: Map<key, { path, line, entry }>, errors }. */
|
|
93
|
+
export function loadRegistry(root = ROOT, setup = projectSetup()) {
|
|
94
|
+
const entries = new Map();
|
|
95
|
+
const errors = [];
|
|
96
|
+
for (const abs of walkJson(join(root, setup.dir))) {
|
|
97
|
+
const rel = relative(root, abs);
|
|
98
|
+
addArea(entries, errors, rel, readFileSync(abs, 'utf8'));
|
|
99
|
+
}
|
|
100
|
+
return { entries, errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const gitAt = (root, args) =>
|
|
104
|
+
execFileSync('git', args, {
|
|
105
|
+
cwd: root,
|
|
106
|
+
encoding: 'utf8',
|
|
107
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
108
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The registry at a git ref. A ref without the registry folder is read by the
|
|
113
|
+
* setup's `legacy` reader (when it has one).
|
|
114
|
+
*/
|
|
115
|
+
export function registryAt(ref, root = ROOT, setup = projectSetup()) {
|
|
116
|
+
const entries = new Map();
|
|
117
|
+
const errors = [];
|
|
118
|
+
const ls = (pathspec) =>
|
|
119
|
+
gitAt(root, ['ls-tree', '-r', '-z', '--name-only', ref, '--', pathspec])
|
|
120
|
+
.split('\0')
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
const show = (path) => gitAt(root, ['show', `${ref}:${path}`]);
|
|
123
|
+
const areas = ls(setup.dir).filter((p) => p.endsWith('.json'));
|
|
124
|
+
if (areas.length) {
|
|
125
|
+
for (const p of areas) addArea(entries, errors, p, show(p));
|
|
126
|
+
return { entries, errors };
|
|
127
|
+
}
|
|
128
|
+
return setup.legacy ? setup.legacy.atRef(ref, root) : { entries, errors };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** [key, entry][] of `<ticket>/*`, in compareKeys order, retired entries included. */
|
|
132
|
+
export function entriesFor(registry, ticket) {
|
|
133
|
+
return [...registry.entries]
|
|
134
|
+
.filter(([k]) => k.startsWith(`${ticket}/`))
|
|
135
|
+
.sort(([a], [b]) => compareKeys(a, b))
|
|
136
|
+
.map(([k, v]) => [k, v.entry]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The runnable spec for runMutations: live entries whose kind runs (no retired). */
|
|
140
|
+
export function toSpec(pairs, ticket, setup = projectSetup()) {
|
|
141
|
+
return {
|
|
142
|
+
ticket,
|
|
143
|
+
mutations: pairs
|
|
144
|
+
.filter(([, e]) => !e.retired && setup.kinds.get(e.kind)?.runs !== false)
|
|
145
|
+
.map(([key, e]) => ({ id: key, ...e })),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sortKeysDeep = (v) =>
|
|
150
|
+
Array.isArray(v)
|
|
151
|
+
? v.map(sortKeysDeep)
|
|
152
|
+
: v && typeof v === 'object'
|
|
153
|
+
? Object.fromEntries(
|
|
154
|
+
Object.keys(v)
|
|
155
|
+
.sort()
|
|
156
|
+
.map((k) => [k, sortKeysDeep(v[k])]),
|
|
157
|
+
)
|
|
158
|
+
: v;
|
|
159
|
+
|
|
160
|
+
/** sha256 of the pairs with every object's keys sorted. */
|
|
161
|
+
export function entriesDigest(pairs) {
|
|
162
|
+
return createHash('sha256')
|
|
163
|
+
.update(JSON.stringify(sortKeysDeep(pairs)))
|
|
164
|
+
.digest('hex');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The tree hash a ticket receipt binds: the registry folder is data, left out. */
|
|
168
|
+
export function ticketTreeHash(setup = projectSetup()) {
|
|
169
|
+
return treeHash({ exclude: [`${setup.dir}/`] });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { blockSpan, findPattern, locate, patchedSource } from './locate.mjs';
|
|
173
|
+
export { addArea };
|