@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
package/cli.mjs
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { bothNames } from './env-name.mjs';
|
|
5
|
+
import { gateLock } from './lock.mjs';
|
|
6
|
+
import {
|
|
7
|
+
HISTORY,
|
|
8
|
+
LEDGER,
|
|
9
|
+
SPEC_LEDGER,
|
|
10
|
+
appendHistory,
|
|
11
|
+
appendLedger,
|
|
12
|
+
appendSpecLedger,
|
|
13
|
+
fullMedianLine,
|
|
14
|
+
ledgerLines,
|
|
15
|
+
readLedger,
|
|
16
|
+
seedHistory,
|
|
17
|
+
specLedgerLines,
|
|
18
|
+
} from './ledger.mjs';
|
|
19
|
+
import { RECEIPT, buildReceipt, receiptOk } from './receipt.mjs';
|
|
20
|
+
import { renderSummary } from './summary.mjs';
|
|
21
|
+
import {
|
|
22
|
+
reviewMap,
|
|
23
|
+
renderMarkdown,
|
|
24
|
+
renderSizeLine,
|
|
25
|
+
writeMap,
|
|
26
|
+
} from './review-map.mjs';
|
|
27
|
+
import { FAILURES_REL, writeFailures } from './failures.mjs';
|
|
28
|
+
import { moveProof as moveProofOf } from './move-proof.mjs';
|
|
29
|
+
import { inputState, specDigest, stepDigest } from './digest.mjs';
|
|
30
|
+
import { ROOT } from './root.mjs';
|
|
31
|
+
import { baseRef, changedPaths, treeHash } from './tree.mjs';
|
|
32
|
+
import { STEP_IDS, STEP_LIMITS, buildResults, runInShell } from './run.mjs';
|
|
33
|
+
import { commandsOf } from './config.mjs';
|
|
34
|
+
import { GATE } from './project.mjs';
|
|
35
|
+
import { nightlyStepOf, planCtx, planSteps, selectUnits } from './plan.mjs';
|
|
36
|
+
import { nightlyLine, recordE2eRun } from './nightly/gate.mjs';
|
|
37
|
+
import { registryHooks } from './mutations/gate.mjs';
|
|
38
|
+
import { runPrecheck } from './precheck.mjs';
|
|
39
|
+
|
|
40
|
+
const FAILURES = join(ROOT, FAILURES_REL);
|
|
41
|
+
|
|
42
|
+
/** `--timeout=<id>:<sec>` (repeatable). `limits` is STEP_LIMITS with the overrides applied. */
|
|
43
|
+
export function parseTimeouts(args) {
|
|
44
|
+
const limits = { ...STEP_LIMITS };
|
|
45
|
+
const overridden = new Set();
|
|
46
|
+
for (const arg of args.filter((a) => a.startsWith('--timeout='))) {
|
|
47
|
+
const value = arg.slice('--timeout='.length);
|
|
48
|
+
const at = value.indexOf(':');
|
|
49
|
+
const id = at < 0 ? value : value.slice(0, at);
|
|
50
|
+
const sec = at < 0 ? '' : value.slice(at + 1);
|
|
51
|
+
if (!(id in STEP_LIMITS))
|
|
52
|
+
return {
|
|
53
|
+
limits,
|
|
54
|
+
overridden,
|
|
55
|
+
error: `unknown step id "${id}" in ${arg}. Valid ids: ${Object.keys(STEP_LIMITS).join(', ')}.`,
|
|
56
|
+
};
|
|
57
|
+
if (!/^[1-9]\d*$/.test(sec))
|
|
58
|
+
return {
|
|
59
|
+
limits,
|
|
60
|
+
overridden,
|
|
61
|
+
error: `"${sec}" in ${arg} is not a whole number of seconds. Use --timeout=<id>:<sec>, for example --timeout=e2e:2400.`,
|
|
62
|
+
};
|
|
63
|
+
limits[id] = Number(sec);
|
|
64
|
+
overridden.add(id);
|
|
65
|
+
}
|
|
66
|
+
return { limits, overridden, error: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------- e2e
|
|
70
|
+
|
|
71
|
+
const PRECHECK_FLAGS = ['--precheck', '--base=', '--ticket='];
|
|
72
|
+
|
|
73
|
+
export async function main(args = process.argv.slice(2)) {
|
|
74
|
+
const dryRun = args.includes('--dry-run');
|
|
75
|
+
const skipIds = args
|
|
76
|
+
.filter((a) => a.startsWith('--skip='))
|
|
77
|
+
.flatMap((a) => a.slice('--skip='.length).split(','))
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
const timeouts = parseTimeouts(args);
|
|
80
|
+
if (timeouts.error) {
|
|
81
|
+
console.error(`verify:ticket: ${timeouts.error}`);
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
const mapArg = args
|
|
85
|
+
.find((a) => a.startsWith('--review-map='))
|
|
86
|
+
?.slice('--review-map='.length);
|
|
87
|
+
if (mapArg !== undefined && mapArg !== 'full') {
|
|
88
|
+
console.error(
|
|
89
|
+
`verify:ticket: unknown --review-map value "${mapArg}". Only --review-map=full is valid (omit the flag for the count line).`,
|
|
90
|
+
);
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
const machine = mapArg === 'full' ? 'full' : 'count';
|
|
94
|
+
// Pack hooks (pack-hooks.mjs): a missing one means no pack does that.
|
|
95
|
+
// The step that carries nightly units, found by its units, never by its id.
|
|
96
|
+
const nightlyStep = nightlyStepOf(GATE);
|
|
97
|
+
const nightlyId = nightlyStep?.id;
|
|
98
|
+
const e2eHook = nightlyStep?.units.nightly ?? {};
|
|
99
|
+
const registryHook = registryHooks(GATE) ?? {};
|
|
100
|
+
const e2eFlag = e2eHook.parseFlag?.(args) ?? {};
|
|
101
|
+
if (e2eFlag.error) {
|
|
102
|
+
console.error(`verify:ticket: ${e2eFlag.error}`);
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
const baseArg = args
|
|
106
|
+
.find((a) => a.startsWith('--base='))
|
|
107
|
+
?.slice('--base='.length);
|
|
108
|
+
const base = baseArg ?? baseRef(GATE.base);
|
|
109
|
+
const baseSha = execFileSync('git', ['rev-parse', base], {
|
|
110
|
+
cwd: ROOT,
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
}).trim();
|
|
113
|
+
|
|
114
|
+
const paths = changedPaths(base);
|
|
115
|
+
if (args.includes('--precheck')) {
|
|
116
|
+
const stray = args.find(
|
|
117
|
+
(a) => !PRECHECK_FLAGS.some((f) => a === f || a.startsWith(f)),
|
|
118
|
+
);
|
|
119
|
+
if (stray) {
|
|
120
|
+
console.error(
|
|
121
|
+
`verify:ticket: --precheck accepts only --base= and --ticket=, not ${stray}. Run the precheck alone, or drop --precheck.`,
|
|
122
|
+
);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// prettier-ignore
|
|
127
|
+
if (args.includes('--precheck')) process.exit(await runPrecheck({ base: baseSha }));
|
|
128
|
+
const map = reviewMap(base);
|
|
129
|
+
writeMap(map);
|
|
130
|
+
const state = inputState({ base: baseSha });
|
|
131
|
+
const fresh = args.includes('--fresh');
|
|
132
|
+
// The selector, the nightly and the spec ledger decide the e2e mode, and
|
|
133
|
+
// only when an e2e input changed (G1, R4): selectUnits runs a units step's selector
|
|
134
|
+
// when its `when` holds or its `reaches` says the diff gets there anyway.
|
|
135
|
+
const units = await selectUnits(GATE, {
|
|
136
|
+
...planCtx(paths, { base: baseSha }),
|
|
137
|
+
run: { e2eFlag: e2eFlag.flag, nowMs: Date.now(), fresh, state },
|
|
138
|
+
});
|
|
139
|
+
const e2ePlan = units[nightlyId]?.plan ?? null;
|
|
140
|
+
const registry = registryHook.plan?.(baseSha) ?? {
|
|
141
|
+
when: false,
|
|
142
|
+
keys: [],
|
|
143
|
+
deferred: [],
|
|
144
|
+
error: null,
|
|
145
|
+
};
|
|
146
|
+
const { steps, notRun, notSelected, unknown } = planSteps(paths, skipIds, {
|
|
147
|
+
base: baseSha,
|
|
148
|
+
registry: { when: registry.when, count: registry.keys.length },
|
|
149
|
+
digestOf: (id, cmd) => stepDigest(id, cmd, { state }),
|
|
150
|
+
ledger: readLedger(),
|
|
151
|
+
fresh,
|
|
152
|
+
units,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (unknown.length) {
|
|
156
|
+
console.error(
|
|
157
|
+
`verify:ticket: unknown --skip id(s): ${unknown.join(', ')}. Valid ids: ${STEP_IDS.join(', ')}.`,
|
|
158
|
+
);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// One informational line, never a gate; a read error prints and moves on.
|
|
163
|
+
const printNightly = () => {
|
|
164
|
+
try {
|
|
165
|
+
const line = nightlyLine(GATE, Date.now());
|
|
166
|
+
if (line !== null) console.log(`nightly: ${line}`);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.log(`nightly: unavailable (${e.message})`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const printNotSelected = () => {
|
|
173
|
+
for (const id of notSelected)
|
|
174
|
+
console.log(`NOT SELECTED --skip=${id} (the diff does not select it)`);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (dryRun) {
|
|
178
|
+
console.log(`Branch touches ${paths.length} path(s).\n`);
|
|
179
|
+
for (const s of steps) {
|
|
180
|
+
const mode =
|
|
181
|
+
{ human: 'HUMAN', skip: 'SKIP ', reuse: 'REUSE' }[s.mode] ?? 'RUN ';
|
|
182
|
+
const note = s.mode === 'reuse' ? `reused from ${s.reusedFrom}` : s.why;
|
|
183
|
+
console.log(` ${mode} ${s.cmd} (${note})`);
|
|
184
|
+
}
|
|
185
|
+
if (e2ePlan && e2eHook.dryLine && steps.some((s) => s.id === nightlyId))
|
|
186
|
+
console.log(e2eHook.dryLine(e2ePlan));
|
|
187
|
+
if (registryHook.dryLine) console.log(registryHook.dryLine(registry));
|
|
188
|
+
printNotSelected();
|
|
189
|
+
printNightly();
|
|
190
|
+
console.log(
|
|
191
|
+
`\n${renderMarkdown(map, { machine })}\n\n${renderSizeLine(map.size)}`,
|
|
192
|
+
);
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
printNotSelected();
|
|
197
|
+
printNightly();
|
|
198
|
+
// The lock covers the stack steps; a dry run and a no-stack diff never take it.
|
|
199
|
+
const lock = steps.some((s) => s.id === 'preflight' && s.mode === 'run')
|
|
200
|
+
? gateLock({ commands: commandsOf(GATE) })
|
|
201
|
+
: null;
|
|
202
|
+
// R1: the e2e step writes its JSON report next to the list output.
|
|
203
|
+
const e2eRuns = steps.some((s) => s.id === nightlyId && s.mode === 'run');
|
|
204
|
+
if (e2eRuns && e2eHook.reportPath) {
|
|
205
|
+
rmSync(e2eHook.reportPath, { force: true });
|
|
206
|
+
Object.assign(process.env, bothNames('E2E_JSON', e2eHook.reportPath));
|
|
207
|
+
}
|
|
208
|
+
let { results, ok, failures, stoppedBy } = buildResults(
|
|
209
|
+
{ steps, notSelected },
|
|
210
|
+
runInShell,
|
|
211
|
+
{ ...timeouts, lock, keepGoing: args.includes('--keep-going') },
|
|
212
|
+
);
|
|
213
|
+
// G2: the e2e result carries its mode, selection, per-spec results and knownRed.
|
|
214
|
+
let report = null;
|
|
215
|
+
const e2eAt = results.findIndex(
|
|
216
|
+
(r) => r.id === nightlyId && ['pass', 'fail'].includes(r.status),
|
|
217
|
+
);
|
|
218
|
+
if (e2ePlan && e2eRuns && e2eAt >= 0 && e2eHook.finish) {
|
|
219
|
+
try {
|
|
220
|
+
report = JSON.parse(readFileSync(e2eHook.reportPath, 'utf8'));
|
|
221
|
+
} catch {
|
|
222
|
+
report = null;
|
|
223
|
+
}
|
|
224
|
+
const done = e2eHook.finish(results[e2eAt], { ...e2ePlan, report });
|
|
225
|
+
if (done.status !== results[e2eAt].status)
|
|
226
|
+
failures.push({
|
|
227
|
+
id: nightlyId,
|
|
228
|
+
cmd: done.cmd,
|
|
229
|
+
why: 'a spec failed or never ran',
|
|
230
|
+
out: `${done.summary}\nRerun: ${commandsOf(GATE).verify} --e2e=full\n`,
|
|
231
|
+
});
|
|
232
|
+
results = results.map((r, i) => (i === e2eAt ? done : r));
|
|
233
|
+
ok = receiptOk(results);
|
|
234
|
+
}
|
|
235
|
+
const failed = results.some((r) => r.status === 'fail');
|
|
236
|
+
const hash = treeHash();
|
|
237
|
+
const ranAt = new Date().toISOString();
|
|
238
|
+
// R7: a partial run has no `missed`, so the shadow is kept for full runs.
|
|
239
|
+
const shadow = await e2eHook.finishShadow?.(
|
|
240
|
+
e2ePlan?.mode === 'full'
|
|
241
|
+
? e2ePlan.selectorError
|
|
242
|
+
? { error: e2ePlan.selectorError }
|
|
243
|
+
: { selection: e2ePlan.selection }
|
|
244
|
+
: null,
|
|
245
|
+
results,
|
|
246
|
+
{ id: nightlyId },
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// A failed proof is recorded as null and never changes `ok`.
|
|
250
|
+
let moveProof = null;
|
|
251
|
+
let moveProofError = null;
|
|
252
|
+
try {
|
|
253
|
+
const p = moveProofOf(baseSha);
|
|
254
|
+
moveProof = { unmatched: p.unmatched, base: baseSha };
|
|
255
|
+
} catch (e) {
|
|
256
|
+
moveProofError = e.message.split('\n')[0];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const receipt = buildReceipt({
|
|
260
|
+
treeHash: hash,
|
|
261
|
+
ranAt,
|
|
262
|
+
ok,
|
|
263
|
+
results,
|
|
264
|
+
size: map.size,
|
|
265
|
+
base: baseSha,
|
|
266
|
+
moveProof,
|
|
267
|
+
shadow,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
mkdirSync(dirname(RECEIPT), { recursive: true });
|
|
271
|
+
// Ledger: a pass or fail is filed only when the inputs are still the ones the
|
|
272
|
+
// plan digested. A ledger problem is printed and never fails the gate.
|
|
273
|
+
try {
|
|
274
|
+
const stateNow = inputState({ base: baseSha });
|
|
275
|
+
const lines = ledgerLines(results, {
|
|
276
|
+
ranAt,
|
|
277
|
+
digestNow: (id, cmd) => stepDigest(id, cmd, { state: stateNow }),
|
|
278
|
+
});
|
|
279
|
+
for (const r of results)
|
|
280
|
+
if (
|
|
281
|
+
['pass', 'fail'].includes(r.status) &&
|
|
282
|
+
r.digest &&
|
|
283
|
+
!lines.some((l) => l.id === r.id)
|
|
284
|
+
)
|
|
285
|
+
console.log(
|
|
286
|
+
`ledger: ${r.id} not recorded (inputs changed during the run)`,
|
|
287
|
+
);
|
|
288
|
+
appendLedger(LEDGER, lines);
|
|
289
|
+
const e2eRes = results.find(
|
|
290
|
+
(r) => r.id === nightlyId && Array.isArray(r.specs),
|
|
291
|
+
);
|
|
292
|
+
if (e2ePlan && e2eRes)
|
|
293
|
+
appendSpecLedger(
|
|
294
|
+
SPEC_LEDGER,
|
|
295
|
+
specLedgerLines(e2eRes.specs, {
|
|
296
|
+
ranAt,
|
|
297
|
+
mode: e2eRes.mode,
|
|
298
|
+
ms: e2eRes.ms,
|
|
299
|
+
digestNow: (file) =>
|
|
300
|
+
specDigest(file, e2ePlan.inputs.get(file) ?? [], {
|
|
301
|
+
state: stateNow,
|
|
302
|
+
}),
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
console.error(`verify:ticket: step ledger not written: ${e.message}`);
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
recordE2eRun({ gate: GATE, results, id: nightlyId, ranAt });
|
|
310
|
+
} catch (e) {
|
|
311
|
+
console.error(`verify:ticket: e2e run not recorded: ${e.message}`);
|
|
312
|
+
}
|
|
313
|
+
// The previous green receipt is still on disk here: seed before overwriting it.
|
|
314
|
+
try {
|
|
315
|
+
seedHistory(HISTORY, RECEIPT);
|
|
316
|
+
} catch (e) {
|
|
317
|
+
console.error(`verify:ticket: history not seeded: ${e.message}`);
|
|
318
|
+
}
|
|
319
|
+
writeFileSync(RECEIPT, JSON.stringify(receipt, null, 2));
|
|
320
|
+
try {
|
|
321
|
+
appendHistory(HISTORY, receipt);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
console.error(`verify:ticket: history not written: ${e.message}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The summary must never break the gate: the receipt above is already written.
|
|
327
|
+
let failuresPath = null;
|
|
328
|
+
try {
|
|
329
|
+
// prettier-ignore
|
|
330
|
+
failuresPath = writeFailures(FAILURES, { treeHash: hash, ranAt, failures, stoppedBy });
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error(`verify:ticket: failures summary not written: ${e.message}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(
|
|
336
|
+
'\n\n--- paste into the PR body -------------------------------\n',
|
|
337
|
+
);
|
|
338
|
+
console.log('```');
|
|
339
|
+
for (const line of renderSummary(results, notRun, {
|
|
340
|
+
failures: failuresPath && FAILURES_REL,
|
|
341
|
+
}))
|
|
342
|
+
console.log(line);
|
|
343
|
+
console.log('```\n');
|
|
344
|
+
if (shadow && e2eHook.shadowLine) console.log(e2eHook.shadowLine(shadow));
|
|
345
|
+
try {
|
|
346
|
+
const median = e2ePlan
|
|
347
|
+
? fullMedianLine(readLedger(), readLedger(SPEC_LEDGER))
|
|
348
|
+
: null;
|
|
349
|
+
if (median) console.log(median.line);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
console.log(`e2e full median: unavailable (${e.message})`);
|
|
352
|
+
}
|
|
353
|
+
console.log(renderMarkdown(map, { machine }));
|
|
354
|
+
console.log('');
|
|
355
|
+
console.log(renderSizeLine(map.size, { color: process.stdout.isTTY }));
|
|
356
|
+
console.log(
|
|
357
|
+
moveProof
|
|
358
|
+
? `move proof: unmatched ${moveProof.unmatched} (${commandsOf(GATE).moveCheck} lists them)`
|
|
359
|
+
: `move proof: unavailable (${moveProofError})`,
|
|
360
|
+
);
|
|
361
|
+
console.log('');
|
|
362
|
+
|
|
363
|
+
if (failed) {
|
|
364
|
+
console.error(
|
|
365
|
+
`verify:ticket FAILED (failures: ${FAILURES_REL}). Do not tick a DoD checkbox until this is green.`,
|
|
366
|
+
);
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
if (!ok) {
|
|
370
|
+
const skipped = results
|
|
371
|
+
.filter((r) => r.status === 'skipped')
|
|
372
|
+
.map((r) => r.id);
|
|
373
|
+
console.error(
|
|
374
|
+
`verify:ticket incomplete: skipped ${skipped.join(', ')}. The gate will not let a DoD checkbox be ticked until these run.`,
|
|
375
|
+
);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
console.log('verify:ticket OK.');
|
|
379
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Validation of the two config keys that add commands to the engine CLI: packs' packCommands
|
|
2
|
+
// (a name -> function map each pack contributes) and docs (the pages `docs-stale` reads). Imports
|
|
3
|
+
// the collection half of the pack hooks only, so config.mjs may import this file.
|
|
4
|
+
import { packHooksAll } from './pack-hooks-all.mjs';
|
|
5
|
+
|
|
6
|
+
/** Every command the engine CLI itself answers to; a pack may not give one of these names. */
|
|
7
|
+
export const ENGINE_COMMANDS = Object.freeze([
|
|
8
|
+
'verify',
|
|
9
|
+
'hook',
|
|
10
|
+
'review-map',
|
|
11
|
+
'move-check',
|
|
12
|
+
'fu',
|
|
13
|
+
'ratchet',
|
|
14
|
+
'mutate',
|
|
15
|
+
'mutations',
|
|
16
|
+
'select',
|
|
17
|
+
'score',
|
|
18
|
+
'cache-report',
|
|
19
|
+
'docs-stale',
|
|
20
|
+
'nightly',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
24
|
+
|
|
25
|
+
/** The pack commands of a config as `{ [name]: { by, run } }`; `{}` with no pack. */
|
|
26
|
+
export function packCommandsOf(config, fail) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const { by, value } of packHooksAll('packCommands', config)) {
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
30
|
+
fail('packs', `${by}: packCommands must be an object of name -> function`);
|
|
31
|
+
for (const [name, run] of Object.entries(value)) {
|
|
32
|
+
if (!NAME.test(name) || typeof run !== 'function')
|
|
33
|
+
fail('packs', `${by}: packCommands.${name} must be a function named /^[a-z][a-z0-9-]*$/`);
|
|
34
|
+
if (ENGINE_COMMANDS.includes(name)) fail('packs', `${by} names the engine command "${name}"`);
|
|
35
|
+
if (Object.hasOwn(out, name)) fail('packs', `command "${name}" is given by ${out[name].by} and ${by}`);
|
|
36
|
+
out[name] = { by, run };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The validated `docs` key: `{ stamped, ext?, exclude? }`. */
|
|
43
|
+
export function docsOf(docs, fail) {
|
|
44
|
+
if (!docs || typeof docs !== 'object' || Array.isArray(docs))
|
|
45
|
+
fail('docs', 'must be an object { stamped, ext?, exclude? }');
|
|
46
|
+
for (const key of Object.keys(docs))
|
|
47
|
+
if (!['stamped', 'ext', 'exclude'].includes(key))
|
|
48
|
+
fail('docs', `unknown key "${key}"; allowed: stamped, ext, exclude`);
|
|
49
|
+
if (!Array.isArray(docs.stamped) || docs.stamped.length === 0 || !docs.stamped.every((d) => typeof d === 'string' && d))
|
|
50
|
+
fail('docs.stamped', 'must be a non-empty array of directory paths');
|
|
51
|
+
if (docs.ext !== undefined && typeof docs.ext !== 'string') fail('docs.ext', 'must be a string');
|
|
52
|
+
if (docs.exclude !== undefined && !(Array.isArray(docs.exclude) && docs.exclude.every((d) => typeof d === 'string')))
|
|
53
|
+
fail('docs.exclude', 'must be an array of file names');
|
|
54
|
+
return { ...docs };
|
|
55
|
+
}
|