create-cmp-cli 0.17.1 → 0.19.0
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/README.md +3 -3
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/packages/harness/package.json +1 -1
- package/packages/harness/src/approve.mjs +7 -0
- package/packages/harness/src/lib/approvals.mjs +44 -9
- package/packages/harness/src/lib/evidence-level.mjs +3 -1
- package/packages/harness/src/lib/feature-brief.mjs +88 -16
- package/packages/harness/src/lib/flight-recorder.mjs +47 -2
- package/packages/harness/src/lib/inputs-hash.mjs +9 -0
- package/packages/harness/src/lib/lane-narrator.mjs +97 -0
- package/packages/harness/src/lib/lane-runner.mjs +173 -0
- package/packages/harness/src/lib/plan.mjs +466 -0
- package/packages/harness/src/lib/receipt-validate.mjs +4 -1
- package/packages/harness/src/lib/spec-coverage.mjs +35 -2
- package/packages/harness/src/lib/step-cache.mjs +1 -1
- package/packages/harness/src/lib/step-outcomes.mjs +123 -0
- package/packages/harness/src/lib/steps-cmp.mjs +1275 -0
- package/packages/harness/src/lib/walk.mjs +262 -21
- package/packages/harness/src/plan.mjs +64 -0
- package/packages/harness/src/receipt-check.mjs +59 -1
- package/packages/harness/src/verify.mjs +115 -1197
- package/packages/harness/src/walk-status.mjs +37 -1
- package/packages/receipts/src/inputs-hash.mjs +9 -0
- package/packages/receipts/src/receipt-validate.mjs +4 -1
- package/src/commands/doctor.mjs +26 -0
- package/src/lib/project-doctor.mjs +37 -0
- package/template/CLAUDE.md +73 -9
- package/template/gitignore +10 -0
- package/template/qa/approve.mjs +7 -0
- package/template/qa/lib/approvals.mjs +44 -9
- package/template/qa/lib/evidence-level.mjs +3 -1
- package/template/qa/lib/feature-brief.mjs +88 -16
- package/template/qa/lib/flight-recorder.mjs +47 -2
- package/template/qa/lib/inputs-hash.mjs +9 -0
- package/template/qa/lib/lane-narrator.mjs +97 -0
- package/template/qa/lib/lane-runner.mjs +173 -0
- package/template/qa/lib/plan.mjs +466 -0
- package/template/qa/lib/receipt-validate.mjs +4 -1
- package/template/qa/lib/spec-coverage.mjs +35 -2
- package/template/qa/lib/step-cache.mjs +1 -1
- package/template/qa/lib/step-outcomes.mjs +123 -0
- package/template/qa/lib/steps-cmp.mjs +1275 -0
- package/template/qa/lib/walk.mjs +262 -21
- package/template/qa/plan.mjs +64 -0
- package/template/qa/receipt-check.mjs +59 -1
- package/template/qa/verify.mjs +115 -1197
- package/template/qa/walk-status.mjs +37 -1
- package/template/specs/README.md +26 -0
|
@@ -93,7 +93,14 @@ export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps,
|
|
|
93
93
|
verdict,
|
|
94
94
|
evidenceRung: evidenceLevel?.rung ?? null,
|
|
95
95
|
durationMs,
|
|
96
|
-
|
|
96
|
+
// durationMs per step (additive, schema id unchanged — old entries stay
|
|
97
|
+
// readable): the source for the lane's own "usually ~Ns" narration
|
|
98
|
+
// (drive-narration N4). Quoted from the journal, never from memory.
|
|
99
|
+
steps: stepList.map((s) => ({
|
|
100
|
+
name: s.name,
|
|
101
|
+
verdict: s.verdict,
|
|
102
|
+
...(typeof s.durationMs === "number" && s.durationMs >= 0 ? { durationMs: s.durationMs } : {}),
|
|
103
|
+
})),
|
|
97
104
|
// SKIP reasons verbatim — the journal's core signal (see file header).
|
|
98
105
|
skips: stepList.filter((s) => s.verdict === "SKIP").map((s) => ({ step: s.name, reason: s.reason ?? "" })),
|
|
99
106
|
deviceSteps: Array.isArray(onDeviceSteps) ? onDeviceSteps : [],
|
|
@@ -198,7 +205,15 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
|
|
|
198
205
|
// JSON-array key: reasons are arbitrary text, so a delimiter-joined
|
|
199
206
|
// string key would be ambiguous — and ambiguity here merges two
|
|
200
207
|
// different problems into one count.
|
|
201
|
-
|
|
208
|
+
//
|
|
209
|
+
// Grouped on the reason's FIRST LINE, which is exactly what the report
|
|
210
|
+
// prints. Several gates (approvals above all) end their reason with a
|
|
211
|
+
// variable list of artifact names, so keying on the whole string split
|
|
212
|
+
// ONE recurring reason into seven near-identical rows carrying the same
|
|
213
|
+
// visible text — a count the reader had to add up by eye. The detail is
|
|
214
|
+
// not lost: the verbatim reasons are still in the journal, which is the
|
|
215
|
+
// artifact that owes verbatim. The REPORT owes legibility.
|
|
216
|
+
const key = JSON.stringify([s.step ?? "?", (s.reason ?? "").split("\n")[0]]);
|
|
202
217
|
skipGroups.set(key, (skipGroups.get(key) ?? 0) + 1);
|
|
203
218
|
}
|
|
204
219
|
}
|
|
@@ -258,6 +273,36 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
|
|
|
258
273
|
};
|
|
259
274
|
}
|
|
260
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Steps that SKIPped in THIS run and have skipped in EVERY recorded full run —
|
|
278
|
+
* a tier that has never executed on this machine.
|
|
279
|
+
*
|
|
280
|
+
* A single SKIP is a fact; skipping every recorded run is a different fact,
|
|
281
|
+
* and only the journal can tell them apart. maestro was never installed on one
|
|
282
|
+
* machine, so e2eSmoke skipped on all 37 recorded runs while the lane said
|
|
283
|
+
* PASS each time — the end-to-end flow had never run once, and nothing said so.
|
|
284
|
+
*
|
|
285
|
+
* Needs a journal long enough to mean something: below `floor` recorded runs
|
|
286
|
+
* carrying the step, "every time" is a coincidence, not a pattern.
|
|
287
|
+
*
|
|
288
|
+
* @param {Array<{name: string, verdict: string, reason?: string}>} steps this run's results
|
|
289
|
+
* @param {object[]} entries parsed journal entries (any mode; fast runs are ignored)
|
|
290
|
+
* @param {{floor?: number}} [opts]
|
|
291
|
+
* @returns {Array<{name: string, runs: number, reason: string}>}
|
|
292
|
+
*/
|
|
293
|
+
export function neverRunTiers(steps, entries, { floor = 3 } = {}) {
|
|
294
|
+
const full = (Array.isArray(entries) ? entries : []).filter((e) => e && e.mode !== "fast" && Array.isArray(e.steps));
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const st of (Array.isArray(steps) ? steps : []).filter((x) => x && x.verdict === "SKIP")) {
|
|
297
|
+
const seen = full.filter((e) => e.steps.some((s) => s && s.name === st.name));
|
|
298
|
+
// "Ran" means produced a verdict about the tree: PASS or FAIL. An ERROR
|
|
299
|
+
// tried and could not; it is not evidence that the tier works here.
|
|
300
|
+
const ran = seen.filter((e) => e.steps.some((s) => s && s.name === st.name && (s.verdict === "PASS" || s.verdict === "FAIL")));
|
|
301
|
+
if (seen.length >= floor && ran.length === 0) out.push({ name: st.name, runs: seen.length, reason: st.reason ?? "" });
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
261
306
|
/**
|
|
262
307
|
* Render the summary as the plain-text report a human reads in ten seconds.
|
|
263
308
|
* Every line is a recorded fact; the honesty notes (short journal, single
|
|
@@ -48,7 +48,16 @@ export const VERIFIED_SURFACE = [
|
|
|
48
48
|
// bookkeeping about a commit that already happened, so appending a record
|
|
49
49
|
// must never invalidate a receipt for a tree whose code did not change
|
|
50
50
|
// (approvals.log.jsonl's principle, applied to audits).
|
|
51
|
+
// qa/.request.json and qa/.plan.json are the live chain's EPHEMERAL state
|
|
52
|
+
// (studio-drive-mode): the request file is rewritten on EVERY user prompt by
|
|
53
|
+
// the UserPromptSubmit hook, so hashing either would invalidate the receipt
|
|
54
|
+
// the moment the human speaks. They are also gitignored on fresh scaffolds,
|
|
55
|
+
// but the exclusion here is the load-bearing one — upgraded apps keep their
|
|
56
|
+
// own .gitignore, which never learns new entries.
|
|
51
57
|
const EXCLUDED_PREFIXES = [
|
|
58
|
+
"qa/.plan.json",
|
|
59
|
+
"qa/.request.json",
|
|
60
|
+
"qa/.plan-history.jsonl",
|
|
52
61
|
"qa/evidence",
|
|
53
62
|
"qa-artifacts",
|
|
54
63
|
"qa/comments.json",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lane-narrator.mjs — the lane's pulse while a step is running.
|
|
3
|
+
//
|
|
4
|
+
// node qa/lib/lane-narrator.mjs <projectRoot>
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS IS A SEPARATE PROCESS, and not a setInterval in verify.mjs. The lane's
|
|
7
|
+
// steps are SYNCHRONOUS: each one blocks on execSync/spawnSync while Gradle works.
|
|
8
|
+
// A timer inside that process cannot fire — the event loop is not running — so the
|
|
9
|
+
// lane could only ever print when a step FINISHED. Observed live: fourteen minutes
|
|
10
|
+
// without a single byte while the release build ran, with no way to tell a grinding
|
|
11
|
+
// step from a wedged one except checking the Gradle daemon's CPU by hand. A step
|
|
12
|
+
// that can take minutes must emit a heartbeat, or the operator's only signal is
|
|
13
|
+
// silence, and silence is exactly what a crash looks like.
|
|
14
|
+
//
|
|
15
|
+
// The marker verify.mjs already rewrites at each step start (.cmp-lane-in-progress,
|
|
16
|
+
// JSON: step, index, total, stepStartedAt, expectedStepMs, expectedLaneMs) carries
|
|
17
|
+
// everything a pulse needs, so this narrator INVENTS NOTHING — it reads what the
|
|
18
|
+
// lane declared about itself and says it out loud on a timer the lane cannot run.
|
|
19
|
+
//
|
|
20
|
+
// It writes to STDERR and never to stdout: --json consumers parse stdout, and a
|
|
21
|
+
// narrator that corrupted machine output would be worse than the silence it fixes.
|
|
22
|
+
// It is spawned only for human runs, and killed with the step loop.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
const ROOT = process.argv[2];
|
|
28
|
+
const MARKER = path.join(ROOT ?? ".", "composeApp", "build", ".cmp-lane-in-progress");
|
|
29
|
+
|
|
30
|
+
// A step under this is not a wait — saying anything about it is noise.
|
|
31
|
+
const FIRST_AFTER_MS = 20_000;
|
|
32
|
+
const EVERY_MS = 30_000;
|
|
33
|
+
const POLL_MS = 1_000;
|
|
34
|
+
|
|
35
|
+
/** "42s" / "4m12s" — short enough to sit inside one line without wrapping. */
|
|
36
|
+
export function shortDuration(ms) {
|
|
37
|
+
if (!(ms > 0)) return "0s";
|
|
38
|
+
const s = Math.round(ms / 1000);
|
|
39
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The line for one poll, or null when there is nothing worth saying yet.
|
|
44
|
+
* Pure, so the cadence and the wording are testable without a clock or a lane.
|
|
45
|
+
*
|
|
46
|
+
* @param {object|null} marker parsed marker content (null when absent/legacy)
|
|
47
|
+
* @param {number} elapsedMs how long the CURRENT step has been running
|
|
48
|
+
* @param {number|null} lastSaidAtMs elapsed value at the previous line, or null
|
|
49
|
+
* @returns {string|null}
|
|
50
|
+
*/
|
|
51
|
+
export function pulseLine(marker, elapsedMs, lastSaidAtMs) {
|
|
52
|
+
if (!marker || typeof marker.step !== "string") return null; // legacy marker: nothing to narrate
|
|
53
|
+
if (elapsedMs < FIRST_AFTER_MS) return null;
|
|
54
|
+
if (lastSaidAtMs !== null && elapsedMs - lastSaidAtMs < EVERY_MS) return null;
|
|
55
|
+
const where = marker.index && marker.total ? ` (${marker.index}/${marker.total})` : "";
|
|
56
|
+
// The expectation is quoted from the flight journal's last full run — measured,
|
|
57
|
+
// never estimated (walk-legibility L4). Absent until one such run exists.
|
|
58
|
+
const usually = marker.expectedStepMs > 0 ? `, usually ~${shortDuration(marker.expectedStepMs)}` : "";
|
|
59
|
+
// Past its usual time is the fact an operator actually wants: it is the
|
|
60
|
+
// difference between "grinding" and "possibly wedged", and it is derived, not
|
|
61
|
+
// guessed — so it is stated plainly rather than dressed up as a warning.
|
|
62
|
+
const over = marker.expectedStepMs > 0 && elapsedMs > marker.expectedStepMs * 1.5 ? " — longer than usual" : "";
|
|
63
|
+
return `⋯ ${marker.step}${where} — ${shortDuration(elapsedMs)} elapsed${usually}${over}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readMarker() {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(fs.readFileSync(MARKER, "utf8"));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (ROOT) {
|
|
75
|
+
let currentStep = null;
|
|
76
|
+
let lastSaidAtMs = null;
|
|
77
|
+
const timer = setInterval(() => {
|
|
78
|
+
const m = readMarker();
|
|
79
|
+
if (!m) return; // between steps, or the lane has finished and cleared it
|
|
80
|
+
if (m.step !== currentStep) {
|
|
81
|
+
currentStep = m.step;
|
|
82
|
+
lastSaidAtMs = null; // each step narrates on its own clock
|
|
83
|
+
}
|
|
84
|
+
const started = Date.parse(m.stepStartedAt ?? "");
|
|
85
|
+
if (Number.isNaN(started)) return;
|
|
86
|
+
const elapsed = Date.now() - started;
|
|
87
|
+
const line = pulseLine(m, elapsed, lastSaidAtMs);
|
|
88
|
+
if (line) {
|
|
89
|
+
lastSaidAtMs = elapsed;
|
|
90
|
+
process.stderr.write(`${line}\n`);
|
|
91
|
+
}
|
|
92
|
+
}, POLL_MS);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(0));
|
|
95
|
+
// Hold the process open against the unref'd timer: the parent kills us.
|
|
96
|
+
setInterval(() => {}, 1 << 30);
|
|
97
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// lane-runner.mjs — the lane's step loop, as a function (evidence-economics S8a).
|
|
2
|
+
//
|
|
3
|
+
// The SPINE, separated from the STEPS. Everything a lane does around its steps —
|
|
4
|
+
// stamp the in-flight marker with the step's own narration, set the step's
|
|
5
|
+
// deadline from its measured history, keep a pulse alive while a synchronous
|
|
6
|
+
// step blocks, turn a throw or a timeout into one ERROR row and keep going,
|
|
7
|
+
// print the mark, derive the verdict — is the same for a Compose app and for
|
|
8
|
+
// a Kotlin backend. Only the steps differ. payment-blueprint re-implemented
|
|
9
|
+
// all of this by hand (2,769 lines) because it lived inside verify.mjs next
|
|
10
|
+
// to Gradle calls it could not use; this file is what it should have been
|
|
11
|
+
// able to import.
|
|
12
|
+
//
|
|
13
|
+
// PURE OF PROJECT KNOWLEDGE. No ROOT, no Gradle, no composeApp path: every
|
|
14
|
+
// project-specific fact arrives through `ctx`. The runner never reads argv.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
import { stepDeadlineMs, stepErrorResult } from "./step-outcomes.mjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-step expected durations from the journal's LAST FULL run — the source
|
|
24
|
+
* of the marker's "usually ~Ns" and of each step's deadline. Measured, never
|
|
25
|
+
* estimated; empty until one new-format full run exists.
|
|
26
|
+
* @param {object[]} entries parsed flight-journal entries, oldest first
|
|
27
|
+
* @returns {{byName: Map<string, number>, laneMs: (number|null)}}
|
|
28
|
+
*/
|
|
29
|
+
export function expectedDurations(entries) {
|
|
30
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
31
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
32
|
+
const e = list[i];
|
|
33
|
+
if (!e || e.mode === "fast" || !Array.isArray(e.steps)) continue;
|
|
34
|
+
const byName = new Map();
|
|
35
|
+
for (const s of e.steps) {
|
|
36
|
+
if (s && typeof s.name === "string" && typeof s.durationMs === "number" && s.durationMs > 0) byName.set(s.name, s.durationMs);
|
|
37
|
+
}
|
|
38
|
+
return { byName, laneMs: typeof e.durationMs === "number" && e.durationMs > 0 ? e.durationMs : null };
|
|
39
|
+
}
|
|
40
|
+
return { byName: new Map(), laneMs: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* "stepUnitTests" → "unitTests", "stepSpecCoverageMemo" → "specCoverage" — the
|
|
45
|
+
* name the result will carry. An anonymous step narrates as null rather than
|
|
46
|
+
* guessing. Exported so a step pack can name its steps the same way.
|
|
47
|
+
* @param {Function} fn
|
|
48
|
+
* @returns {string|null}
|
|
49
|
+
*/
|
|
50
|
+
export function stepDisplayName(fn) {
|
|
51
|
+
const raw = typeof fn?.name === "string" ? fn.name.replace(/^step/, "").replace(/Memo$/, "") : "";
|
|
52
|
+
return raw === "" ? null : raw.charAt(0).toLowerCase() + raw.slice(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The lane's verdict over its rows: FAIL on any FAIL or ERROR, else PASS. CACHED counts as PASS (it IS a prior PASS). */
|
|
56
|
+
export function laneVerdict(steps) {
|
|
57
|
+
return steps.some((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR")) ? "FAIL" : "PASS";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The mark a row wears on the console: ✓ PASS · ⚡ CACHED · → SKIP · ⊘ ERROR (could not run) · ✗ FAIL. */
|
|
61
|
+
export function verdictMark(verdict) {
|
|
62
|
+
return verdict === "PASS" ? "✓" : verdict === "CACHED" ? "⚡" : verdict === "SKIP" ? "→" : verdict === "ERROR" ? "⊘" : "✗";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run the steps, in order, under the lane's own discipline.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} ctx
|
|
69
|
+
* @param {Function[]} ctx.steps the step functions, each returning a result row
|
|
70
|
+
* @param {string} ctx.markerPath the in-flight marker (.cmp-lane-in-progress) — stamped
|
|
71
|
+
* before every step with {pid, at, step, index, total, stepStartedAt, expectedStepMs,
|
|
72
|
+
* expectedLaneMs}, removed when the loop ends however it ends
|
|
73
|
+
* @param {{byName: Map<string, number>, laneMs: (number|null)}} [ctx.expected] from expectedDurations
|
|
74
|
+
* @param {(ms: number) => void} [ctx.setDeadline] receives each step's deadline before it
|
|
75
|
+
* runs — the project's subprocess helper reads it (verify.mjs's sh())
|
|
76
|
+
* @param {(line: string) => void|null} [ctx.print] one line per finished step; null = silent
|
|
77
|
+
* (--json). Also gates the narrator: no print, no pulse.
|
|
78
|
+
* @param {{entry: string, root: string}|null} [ctx.narrator] the pulse process to spawn
|
|
79
|
+
* beside the loop (lane-narrator.mjs) — a separate process because the steps are
|
|
80
|
+
* synchronous and no timer in this process can fire while one runs
|
|
81
|
+
* @param {(result: object) => boolean} [ctx.stopAfter] short-circuit predicate; default:
|
|
82
|
+
* stop after a FAILed "build" — nothing downstream is meaningful
|
|
83
|
+
* @param {() => void} [ctx.onFinally] runs in the finally (the project releases its device lease here)
|
|
84
|
+
* @param {number} [ctx.startedAt] the lane's start, for the marker's `at`
|
|
85
|
+
* @returns {{steps: object[], verdict: "PASS"|"FAIL", durationMs: number}}
|
|
86
|
+
*/
|
|
87
|
+
export function runLane(ctx) {
|
|
88
|
+
const {
|
|
89
|
+
steps: stepFns,
|
|
90
|
+
markerPath,
|
|
91
|
+
expected = { byName: new Map(), laneMs: null },
|
|
92
|
+
setDeadline = () => {},
|
|
93
|
+
print = null,
|
|
94
|
+
narrator = null,
|
|
95
|
+
stopAfter = (r) => r.name === "build" && r.verdict === "FAIL",
|
|
96
|
+
onFinally = () => {},
|
|
97
|
+
startedAt = Date.now(),
|
|
98
|
+
} = ctx;
|
|
99
|
+
|
|
100
|
+
const stamp = (stepFn, index, total) => {
|
|
101
|
+
try {
|
|
102
|
+
const name = stepFn ? stepDisplayName(stepFn) : null;
|
|
103
|
+
const narration = {
|
|
104
|
+
pid: process.pid,
|
|
105
|
+
at: new Date(startedAt).toISOString(),
|
|
106
|
+
step: name,
|
|
107
|
+
index,
|
|
108
|
+
total,
|
|
109
|
+
stepStartedAt: new Date().toISOString(),
|
|
110
|
+
expectedStepMs: name !== null ? (expected.byName.get(name) ?? null) : null,
|
|
111
|
+
expectedLaneMs: expected.laneMs,
|
|
112
|
+
};
|
|
113
|
+
fs.writeFileSync(markerPath, `${JSON.stringify(narration)}\n`);
|
|
114
|
+
} catch {
|
|
115
|
+
/* the narration must never break the lane it narrates */
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
120
|
+
stamp(null, 0, stepFns.length);
|
|
121
|
+
|
|
122
|
+
let pulse = null;
|
|
123
|
+
if (print && narrator) {
|
|
124
|
+
try {
|
|
125
|
+
pulse = spawn(process.execPath, [narrator.entry, narrator.root], { stdio: ["ignore", "ignore", "inherit"] });
|
|
126
|
+
pulse.on("error", () => {});
|
|
127
|
+
} catch {
|
|
128
|
+
/* a missing pulse is a quieter lane, never a failed one */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const results = [];
|
|
133
|
+
try {
|
|
134
|
+
for (const [i, step] of stepFns.entries()) {
|
|
135
|
+
stamp(step, i + 1, stepFns.length);
|
|
136
|
+
const name = stepDisplayName(step) ?? `step${i + 1}`;
|
|
137
|
+
// S4: every step under a deadline from its own history (×3, floor 5 min,
|
|
138
|
+
// ceiling 30). A deadline or a throw is ONE ERROR row — the lane keeps
|
|
139
|
+
// going, because the other verdicts are still worth having.
|
|
140
|
+
setDeadline(stepDeadlineMs(expected.byName.get(name)));
|
|
141
|
+
const stepStarted = Date.now();
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = step();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
result = stepErrorResult(name, err, Date.now() - stepStarted);
|
|
147
|
+
}
|
|
148
|
+
results.push(result);
|
|
149
|
+
if (print) {
|
|
150
|
+
print(
|
|
151
|
+
`${verdictMark(result.verdict)} ${result.name}: ${result.verdict}${result.note ? ` (${result.note})` : ""}${result.reason ? ` — ${String(result.reason).split("\n")[0]}` : ""}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (stopAfter(result)) break;
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
if (pulse) {
|
|
158
|
+
try {
|
|
159
|
+
pulse.kill();
|
|
160
|
+
} catch {
|
|
161
|
+
/* the narrator holds nothing; a failed kill must not fail the lane */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
fs.rmSync(markerPath, { force: true });
|
|
165
|
+
try {
|
|
166
|
+
onFinally();
|
|
167
|
+
} catch {
|
|
168
|
+
/* a finalizer that throws must not hide the rows already earned */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { steps: results, verdict: laneVerdict(results), durationMs: Date.now() - startedAt };
|
|
173
|
+
}
|