create-cmp-cli 0.12.0 → 0.14.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/bin/create-cmp.mjs +3 -0
- package/package.json +6 -2
- package/packages/harness/package.json +38 -0
- package/packages/harness/src/approve.mjs +247 -0
- package/packages/harness/src/arch-doc.mjs +69 -0
- package/packages/harness/src/comment.mjs +76 -0
- package/packages/harness/src/lib/a11y.mjs +113 -0
- package/packages/harness/src/lib/affected-tests.mjs +147 -0
- package/packages/harness/src/lib/approvals.mjs +1403 -0
- package/packages/harness/src/lib/arch-doc.mjs +451 -0
- package/packages/harness/src/lib/audit-cadence.mjs +290 -0
- package/packages/harness/src/lib/comments.mjs +252 -0
- package/packages/harness/src/lib/component-stories.mjs +183 -0
- package/packages/harness/src/lib/determinism.mjs +179 -0
- package/packages/harness/src/lib/device-lease.mjs +249 -0
- package/packages/harness/src/lib/evidence-badge.mjs +158 -0
- package/packages/harness/src/lib/evidence-level.mjs +117 -0
- package/packages/harness/src/lib/feature-brief.mjs +324 -0
- package/packages/harness/src/lib/flight-recorder.mjs +332 -0
- package/packages/harness/src/lib/harness-lock.mjs +147 -0
- package/packages/harness/src/lib/harness-region.mjs +159 -0
- package/packages/harness/src/lib/inputs-hash.mjs +194 -0
- package/packages/harness/src/lib/reachability.mjs +211 -0
- package/packages/harness/src/lib/receipt-validate.mjs +234 -0
- package/packages/harness/src/lib/render.mjs +254 -0
- package/packages/harness/src/lib/spec-coverage.mjs +131 -0
- package/packages/harness/src/lib/step-cache.mjs +221 -0
- package/packages/harness/src/lib/token-drift.mjs +94 -0
- package/packages/harness/src/lib/tree.mjs +108 -0
- package/packages/harness/src/preview-gallery.mjs +122 -0
- package/packages/harness/src/receipt-check.mjs +96 -0
- package/packages/harness/src/record-audit.mjs +83 -0
- package/packages/harness/src/refusal-demo.mjs +498 -0
- package/packages/harness/src/retrospective.mjs +51 -0
- package/packages/harness/src/scaffold-feature.mjs +723 -0
- package/packages/harness/src/setup-hooks.mjs +33 -0
- package/packages/harness/src/verify.mjs +1709 -0
- package/packages/harness/src/walkthrough.mjs +499 -0
- package/packages/harness/src/watch.mjs +622 -0
- package/packages/receipts/package.json +36 -0
- package/packages/receipts/src/index.mjs +16 -0
- package/packages/receipts/src/inputs-hash.mjs +194 -0
- package/packages/receipts/src/receipt-validate.mjs +234 -0
- package/src/commands/upgrade.mjs +383 -0
- package/src/lib/harness-upgrade.mjs +521 -0
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +34 -1
- package/template/README.md +4 -0
- package/template/gitignore +8 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/harness-lock.mjs +147 -0
- package/template/qa/lib/harness-region.mjs +159 -0
- package/template/qa/lib/inputs-hash.mjs +17 -2
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/verify.mjs +400 -10
- package/template/qa/watch.mjs +2 -2
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// qa/watch.mjs — resident watch mode: the inner verification loop, made free.
|
|
3
|
+
//
|
|
4
|
+
// node qa/watch.mjs [--once] [--json] [--help]
|
|
5
|
+
//
|
|
6
|
+
// A human developer's inner loop costs nothing because the IDE is always
|
|
7
|
+
// running — errors appear on save. An agent has no IDE, so it reaches for the
|
|
8
|
+
// heaviest thing labelled "done" (the full verify lane) after small edits.
|
|
9
|
+
// This process is the missing IDE: it watches the sources, and on every save
|
|
10
|
+
// runs the SANCTIONED fast tier — `node qa/verify.mjs --fast` — as a
|
|
11
|
+
// subprocess, then re-prints a compact status block. Same idea as the preview
|
|
12
|
+
// daemon (the single most-adopted surface in this harness), extended from
|
|
13
|
+
// rendering to verification.
|
|
14
|
+
//
|
|
15
|
+
// DELIBERATELY DECOUPLED: watch mode shells out to verify.mjs rather than
|
|
16
|
+
// importing its internals. The fast lane's step economics (what runs, what is
|
|
17
|
+
// memoized, what is scoped) evolve in verify.mjs; this file inherits every
|
|
18
|
+
// improvement for free and can never fork the step list.
|
|
19
|
+
//
|
|
20
|
+
// NOT A GATE — BY CONSTRUCTION. It runs `--fast` only, so every receipt it
|
|
21
|
+
// causes records `"mode": "fast"`, earns no evidence rung, and is refused by
|
|
22
|
+
// qa/receipt-check.mjs. This process never prints a completion claim; every
|
|
23
|
+
// run ends with the standing footer naming the real gate. Watch green is a
|
|
24
|
+
// signal; the done-gate is one deliberate full `node qa/verify.mjs` run.
|
|
25
|
+
//
|
|
26
|
+
// COORDINATION (non-negotiable): two concurrent Gradle invocations against one
|
|
27
|
+
// project corrupt each other's output (KSP cache collisions, half-written
|
|
28
|
+
// classes dirs — a real 20+ bogus-failure cascade). The lane and the preview
|
|
29
|
+
// daemon already coordinate via marker files under composeApp/build:
|
|
30
|
+
// .cmp-lane-in-progress — stamped by verify.mjs for a run's duration
|
|
31
|
+
// .cmp-render-in-progress — stamped by the preview daemon while its Gradle
|
|
32
|
+
// build is in flight
|
|
33
|
+
// Watch mode participates as a third citizen: it never launches a run while a
|
|
34
|
+
// FOREIGN lane or a render is fresh — it waits, coalescing every change that
|
|
35
|
+
// arrives into the one run that fires when the project frees up. Its own runs
|
|
36
|
+
// need no extra marker: the verify.mjs child stamps the lane marker itself, so
|
|
37
|
+
// a second watch instance (or a hand-run lane) sees this one's run and defers.
|
|
38
|
+
// Both mtime-staleness bounds mirror the existing consumers so a crashed
|
|
39
|
+
// process never wedges this watcher: the lane marker goes stale after 30
|
|
40
|
+
// minutes (preview-service.mjs's bound), the render marker after 5 minutes
|
|
41
|
+
// (verify.mjs's bound).
|
|
42
|
+
//
|
|
43
|
+
// OUTPUT is for BOTH audiences: plain, line-oriented, greppable — no cursor
|
|
44
|
+
// control, no spinners — so a human sees green/red at a glance and an agent
|
|
45
|
+
// can read the same stdout through a pipe. On failure the failing step's
|
|
46
|
+
// reason is surfaced VERBATIM (that is the thing the agent needs). `--json`
|
|
47
|
+
// switches to one JSON object per line (NDJSON) for programmatic consumption.
|
|
48
|
+
|
|
49
|
+
import { spawn } from "node:child_process";
|
|
50
|
+
import fs from "node:fs";
|
|
51
|
+
import path from "node:path";
|
|
52
|
+
import { fileURLToPath } from "node:url";
|
|
53
|
+
|
|
54
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
55
|
+
|
|
56
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export const USAGE = `node qa/watch.mjs [--once] [--json] [--help]
|
|
59
|
+
|
|
60
|
+
Resident watch mode — the inner verification loop. Watches composeApp/src,
|
|
61
|
+
specs/, and qa/ and runs \`node qa/verify.mjs --fast\` on every save (debounced;
|
|
62
|
+
a save storm triggers ONE run, changes during a run coalesce into one
|
|
63
|
+
follow-up). It defers while a verify lane or a preview-daemon render holds the
|
|
64
|
+
project (the .cmp-*-in-progress markers under composeApp/build), so two Gradle
|
|
65
|
+
invocations never collide.
|
|
66
|
+
|
|
67
|
+
THIS IS NOT A GATE. It runs the fast tier only: every receipt records
|
|
68
|
+
"mode": "fast", earns no evidence rung, and qa/receipt-check.mjs refuses it.
|
|
69
|
+
The done-gate is one deliberate full \`node qa/verify.mjs\` run.
|
|
70
|
+
|
|
71
|
+
Flags:
|
|
72
|
+
--once run a single coordinated fast pass and exit with its exit code
|
|
73
|
+
(0 = PASS, 1 = FAIL) — no watchers, useful for scripting
|
|
74
|
+
--json one JSON object per line (NDJSON) instead of the human block:
|
|
75
|
+
events "start", "deferred", "run", "watch-fallback", "shutdown"
|
|
76
|
+
--help, -h print this usage and exit 0 without watching anything
|
|
77
|
+
|
|
78
|
+
Ctrl-C / SIGTERM stop it cleanly (watchers closed, any in-flight verify child
|
|
79
|
+
terminated and its marker cleaned up) and exit 0.
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
// Strict parsing, same refusal-over-fabrication stance as verify.mjs: an
|
|
83
|
+
// unknown argument is refused by name, never swallowed into "watch anyway".
|
|
84
|
+
export function parseWatchArgs(rawArgs) {
|
|
85
|
+
const opts = { help: false, once: false, json: false };
|
|
86
|
+
for (const arg of rawArgs) {
|
|
87
|
+
if (arg === "--help" || arg === "-h") opts.help = true;
|
|
88
|
+
else if (arg === "--once") opts.once = true;
|
|
89
|
+
else if (arg === "--json") opts.json = true;
|
|
90
|
+
else return { error: `unknown argument "${arg}" — run node qa/watch.mjs --help` };
|
|
91
|
+
}
|
|
92
|
+
return opts;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── The watch set ───────────────────────────────────────────────────────────
|
|
96
|
+
// composeApp/src (the app), specs/ (the contract), qa/ (the harness itself —
|
|
97
|
+
// a golden-tree or e2e-flow edit should re-verify too). NOT watched: build
|
|
98
|
+
// output anywhere, qa/evidence/ (verify.mjs writes latest.json there on every
|
|
99
|
+
// run — watching your own output is an infinite loop), and dotfiles (VCS
|
|
100
|
+
// internals, editor droppings, and the .cmp-*-in-progress markers themselves).
|
|
101
|
+
|
|
102
|
+
export const WATCH_ROOTS = ["composeApp/src", "specs", "qa"];
|
|
103
|
+
|
|
104
|
+
/** Ignore predicate over a ROOT-relative path (forward slashes or backslashes). */
|
|
105
|
+
export function shouldIgnorePath(rel) {
|
|
106
|
+
const norm = String(rel).replace(/\\/g, "/");
|
|
107
|
+
if (!norm) return true;
|
|
108
|
+
const parts = norm.split("/");
|
|
109
|
+
// Any dotted segment: .git, .gradle, .DS_Store, .cmp-lane-in-progress, …
|
|
110
|
+
if (parts.some((s) => s.startsWith("."))) return true;
|
|
111
|
+
// Any build dir at any depth (composeApp/build, qa/**/build, …).
|
|
112
|
+
if (parts.includes("build")) return true;
|
|
113
|
+
// The lane's own output — the one path that would make watch feed itself.
|
|
114
|
+
if (norm === "qa/evidence" || norm.startsWith("qa/evidence/")) return true;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Debounce ────────────────────────────────────────────────────────────────
|
|
119
|
+
// 400ms, matching the preview daemon's proven DEBOUNCE_MS (inspector MCP
|
|
120
|
+
// preview-service) — the one number in this harness already field-tested
|
|
121
|
+
// against real editor save storms. One logical edit often lands as several
|
|
122
|
+
// fs events over a few hundred ms (multi-file agent turns, format-on-save,
|
|
123
|
+
// `git checkout`); below ~300ms those split into two runs, and anything above
|
|
124
|
+
// ~500ms only adds perceptible lag against a run that itself costs tens of
|
|
125
|
+
// seconds. Trailing debounce: the timer resets on every event, so the run
|
|
126
|
+
// fires once the storm goes quiet.
|
|
127
|
+
export const DEBOUNCE_MS = 400;
|
|
128
|
+
export const POLL_MS = 2000; // marker-wait poll AND the no-recursive-watch fallback scan cadence
|
|
129
|
+
|
|
130
|
+
// ── Marker coordination ─────────────────────────────────────────────────────
|
|
131
|
+
// Shapes and bounds mirror the existing participants (see the header):
|
|
132
|
+
// verify.mjs stamps LANE for its duration; the preview daemon stamps RENDER
|
|
133
|
+
// around its Gradle builds. Freshness is mtime-bounded so a crashed stamper
|
|
134
|
+
// never wedges us.
|
|
135
|
+
|
|
136
|
+
export const LANE_MARKER_REL = ["composeApp", "build", ".cmp-lane-in-progress"];
|
|
137
|
+
export const RENDER_MARKER_REL = ["composeApp", "build", ".cmp-render-in-progress"];
|
|
138
|
+
export const LANE_MARKER_STALE_MS = 30 * 60 * 1000; // preview-service.mjs's bound for this marker
|
|
139
|
+
export const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // verify.mjs's bound for this marker
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The launch decision, pure: given the two markers' mtimes (null = absent) and
|
|
143
|
+
* the clock, may a run start now? A fresh foreign marker means someone else's
|
|
144
|
+
* Gradle is (or may be) in flight — wait and coalesce, never collide.
|
|
145
|
+
*/
|
|
146
|
+
export function markerDecision({ laneMtimeMs = null, renderMtimeMs = null, nowMs = Date.now() } = {}) {
|
|
147
|
+
if (laneMtimeMs != null && nowMs - laneMtimeMs < LANE_MARKER_STALE_MS) {
|
|
148
|
+
return { launch: false, reason: "a verify lane is in progress (.cmp-lane-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
|
|
149
|
+
}
|
|
150
|
+
if (renderMtimeMs != null && nowMs - renderMtimeMs < RENDER_MARKER_FRESH_MS) {
|
|
151
|
+
return { launch: false, reason: "the preview daemon has a Gradle build in flight (.cmp-render-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
|
|
152
|
+
}
|
|
153
|
+
return { launch: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* If `markerPath` exists and its first token is `pid`, remove it. A verify
|
|
158
|
+
* child killed by a signal never runs its `finally`, so its lane marker would
|
|
159
|
+
* outlive it and make every consumer defer for the full staleness window —
|
|
160
|
+
* this is the cleanup for exactly (and only) the marker OUR child stamped.
|
|
161
|
+
* @returns {boolean} true when a marker owned by `pid` was removed
|
|
162
|
+
*/
|
|
163
|
+
export function clearMarkerIfOwnedBy(markerPath, pid) {
|
|
164
|
+
try {
|
|
165
|
+
const first = fs.readFileSync(markerPath, "utf8").trim().split(/\s+/)[0];
|
|
166
|
+
if (Number(first) !== pid) return false;
|
|
167
|
+
fs.rmSync(markerPath, { force: true });
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false; // absent or unreadable — nothing to clean
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── The run loop (pure of fs/child_process — unit-testable) ─────────────────
|
|
175
|
+
// State machine: idle → (change, debounce) → launch-check → running → idle.
|
|
176
|
+
// - A save storm resets the trailing debounce: ONE run per storm.
|
|
177
|
+
// - Changes while a run is in flight accumulate and queue EXACTLY ONE
|
|
178
|
+
// follow-up (coalesce, never stack).
|
|
179
|
+
// - When canLaunch says wait (foreign lane/render), the loop re-polls on
|
|
180
|
+
// pollMs, still coalescing; onDefer fires once per wait, not per poll.
|
|
181
|
+
|
|
182
|
+
export function createRunLoop({ debounceMs = DEBOUNCE_MS, pollMs = POLL_MS, canLaunch, runOnce, onDefer = () => {} }) {
|
|
183
|
+
let timer = null;
|
|
184
|
+
let running = false;
|
|
185
|
+
let queued = false; // a follow-up run is owed (set by changes arriving mid-run)
|
|
186
|
+
let stopped = false;
|
|
187
|
+
let deferNoticed = false;
|
|
188
|
+
const trigger = new Set(); // paths accumulated for the NEXT run
|
|
189
|
+
|
|
190
|
+
function change(relPath) {
|
|
191
|
+
if (stopped) return;
|
|
192
|
+
trigger.add(relPath);
|
|
193
|
+
if (running) {
|
|
194
|
+
queued = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (timer) clearTimeout(timer);
|
|
198
|
+
timer = setTimeout(fire, debounceMs);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function fire() {
|
|
202
|
+
timer = null;
|
|
203
|
+
if (stopped || running) return;
|
|
204
|
+
const decision = canLaunch();
|
|
205
|
+
if (!decision.launch) {
|
|
206
|
+
if (!deferNoticed) {
|
|
207
|
+
deferNoticed = true;
|
|
208
|
+
onDefer(decision.reason);
|
|
209
|
+
}
|
|
210
|
+
timer = setTimeout(fire, pollMs);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
deferNoticed = false;
|
|
214
|
+
const paths = [...trigger];
|
|
215
|
+
trigger.clear();
|
|
216
|
+
queued = false;
|
|
217
|
+
running = true;
|
|
218
|
+
try {
|
|
219
|
+
await runOnce(paths);
|
|
220
|
+
} finally {
|
|
221
|
+
running = false;
|
|
222
|
+
if (!stopped && queued) {
|
|
223
|
+
queued = false;
|
|
224
|
+
timer = setTimeout(fire, debounceMs);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function stop() {
|
|
230
|
+
stopped = true;
|
|
231
|
+
if (timer) clearTimeout(timer);
|
|
232
|
+
timer = null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
change,
|
|
237
|
+
stop,
|
|
238
|
+
get running() {
|
|
239
|
+
return running;
|
|
240
|
+
},
|
|
241
|
+
get queued() {
|
|
242
|
+
return queued;
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Output (pure formatters — unit-testable) ────────────────────────────────
|
|
248
|
+
|
|
249
|
+
// The standing contract line, printed after EVERY run and at startup. This
|
|
250
|
+
// process never claims completion; the real gate is named instead.
|
|
251
|
+
export const FOOTER =
|
|
252
|
+
"watch mode is the inner loop — the done-gate is one deliberate full `node qa/verify.mjs` run (a fast receipt earns no rung and satisfies no gate)";
|
|
253
|
+
|
|
254
|
+
export function formatTrigger(paths, cap = 3) {
|
|
255
|
+
if (!paths || paths.length === 0) return "(manual)";
|
|
256
|
+
const shown = paths.slice(0, cap).join(", ");
|
|
257
|
+
return paths.length > cap ? `${shown} (+${paths.length - cap} more)` : shown;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const secs = (ms) => `${(Math.max(0, ms) / 1000).toFixed(1)}s`;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* One run's status block: header (run number, wall-clock time, trigger), the
|
|
264
|
+
* fast lane's step table, a one-line verdict, and the standing footer. Plain
|
|
265
|
+
* lines only. FAILed steps get their reason VERBATIM, indented; SKIPs keep
|
|
266
|
+
* their first reason line as the fine print.
|
|
267
|
+
*/
|
|
268
|
+
export function formatRunBlock({ n, startedAtIso, trigger, receipt, exitCode, durationMs, rawTail }) {
|
|
269
|
+
const lines = [];
|
|
270
|
+
lines.push(`── watch run #${n} · ${startedAtIso} · trigger: ${formatTrigger(trigger)}`);
|
|
271
|
+
|
|
272
|
+
if (receipt && Array.isArray(receipt.steps)) {
|
|
273
|
+
for (const step of receipt.steps) {
|
|
274
|
+
const mark = step.verdict === "PASS" ? "✓" : step.verdict === "SKIP" ? "→" : "✗";
|
|
275
|
+
const first = step.reason ? ` — ${String(step.reason).split("\n")[0]}` : "";
|
|
276
|
+
lines.push(` ${mark} ${step.name} ${step.verdict} (${secs(step.durationMs ?? 0)})${step.verdict === "FAIL" ? "" : first}`);
|
|
277
|
+
if (step.verdict === "FAIL" && step.reason) {
|
|
278
|
+
for (const rl of String(step.reason).split("\n")) lines.push(` ${rl}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const failed = receipt.steps.filter((s) => s.verdict === "FAIL").map((s) => s.name);
|
|
282
|
+
const verdictNote =
|
|
283
|
+
failed.length > 0 ? `${failed.length} step${failed.length === 1 ? "" : "s"} failed: ${failed.join(", ")}` : "fast tier green";
|
|
284
|
+
lines.push(`── watch run #${n}: ${receipt.verdict} in ${secs(durationMs)} — ${verdictNote} (fast lane — a signal, not evidence)`);
|
|
285
|
+
} else {
|
|
286
|
+
// The child produced no parseable receipt (crashed, was killed, verify
|
|
287
|
+
// refused the invocation). Surface its tail verbatim — that IS the reason.
|
|
288
|
+
for (const rl of (rawTail ?? []).slice(-15)) lines.push(` ${rl}`);
|
|
289
|
+
lines.push(`── watch run #${n}: NO RECEIPT in ${secs(durationMs)} — verify exited ${exitCode ?? "by signal"} without a receipt (output above)`);
|
|
290
|
+
}
|
|
291
|
+
lines.push(FOOTER);
|
|
292
|
+
return lines.join("\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── main (everything below touches fs / child_process / signals) ────────────
|
|
296
|
+
|
|
297
|
+
function markerMtime(absPath) {
|
|
298
|
+
try {
|
|
299
|
+
return fs.statSync(absPath).mtimeMs;
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function launchDecisionNow() {
|
|
306
|
+
return markerDecision({
|
|
307
|
+
laneMtimeMs: markerMtime(path.join(ROOT, ...LANE_MARKER_REL)),
|
|
308
|
+
renderMtimeMs: markerMtime(path.join(ROOT, ...RENDER_MARKER_REL)),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Best-effort receipt extraction from `verify --json`'s stdout. */
|
|
313
|
+
export function parseReceipt(stdoutText) {
|
|
314
|
+
const t = String(stdoutText ?? "").trim();
|
|
315
|
+
try {
|
|
316
|
+
return JSON.parse(t);
|
|
317
|
+
} catch {
|
|
318
|
+
const i = t.indexOf("{");
|
|
319
|
+
if (i > 0) {
|
|
320
|
+
try {
|
|
321
|
+
return JSON.parse(t.slice(i));
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
331
|
+
|
|
332
|
+
function main() {
|
|
333
|
+
const opts = parseWatchArgs(process.argv.slice(2));
|
|
334
|
+
if (opts.error) {
|
|
335
|
+
console.error(opts.error);
|
|
336
|
+
process.exit(2);
|
|
337
|
+
}
|
|
338
|
+
if (opts.help) {
|
|
339
|
+
console.log(USAGE);
|
|
340
|
+
process.exit(0);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const asJson = opts.json;
|
|
344
|
+
const emit = (obj) => {
|
|
345
|
+
if (asJson) console.log(JSON.stringify(obj));
|
|
346
|
+
};
|
|
347
|
+
const say = (line) => {
|
|
348
|
+
if (!asJson) console.log(line);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
let runCounter = 0;
|
|
352
|
+
let currentChild = null;
|
|
353
|
+
|
|
354
|
+
/** Spawn one fast lane run and print/emit its outcome. Never rejects. */
|
|
355
|
+
function runVerify(triggerPaths) {
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
runCounter += 1;
|
|
358
|
+
const n = runCounter;
|
|
359
|
+
const startedAtIso = new Date().toISOString();
|
|
360
|
+
const started = Date.now();
|
|
361
|
+
say(`── watch run #${n} starting (node qa/verify.mjs --fast --no-journal) …`);
|
|
362
|
+
const child = spawn(process.execPath, [path.join(ROOT, "qa", "verify.mjs"), "--fast", "--json", "--no-journal"], {
|
|
363
|
+
cwd: ROOT,
|
|
364
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
365
|
+
// Its own process GROUP: verify spawns Gradle through a shell, and a
|
|
366
|
+
// shutdown signal to the child alone would strand those grandchildren
|
|
367
|
+
// (a live gradlew JVM survived exactly that in testing). With a group,
|
|
368
|
+
// shutdown signals -pid and the whole tree gets it.
|
|
369
|
+
detached: true,
|
|
370
|
+
});
|
|
371
|
+
currentChild = child;
|
|
372
|
+
let stdout = "";
|
|
373
|
+
let stderr = "";
|
|
374
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
375
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
376
|
+
child.on("error", (err) => {
|
|
377
|
+
currentChild = null;
|
|
378
|
+
say(`── watch run #${n}: could not spawn verify — ${err.message}`);
|
|
379
|
+
emit({ event: "run", n, startedAt: startedAtIso, trigger: triggerPaths, error: `spawn failed: ${err.message}` });
|
|
380
|
+
resolve();
|
|
381
|
+
});
|
|
382
|
+
child.on("exit", (code, signal) => {
|
|
383
|
+
currentChild = null;
|
|
384
|
+
// A signal-killed child never ran verify's `finally` — clean the lane
|
|
385
|
+
// marker it stamped so nothing defers on a ghost for 30 minutes.
|
|
386
|
+
if (signal) clearMarkerIfOwnedBy(path.join(ROOT, ...LANE_MARKER_REL), child.pid);
|
|
387
|
+
const durationMs = Date.now() - started;
|
|
388
|
+
const receipt = parseReceipt(stdout);
|
|
389
|
+
const rawTail = `${stdout}\n${stderr}`.split("\n").filter(Boolean);
|
|
390
|
+
say(
|
|
391
|
+
formatRunBlock({
|
|
392
|
+
n,
|
|
393
|
+
startedAtIso,
|
|
394
|
+
trigger: triggerPaths,
|
|
395
|
+
receipt,
|
|
396
|
+
exitCode: code,
|
|
397
|
+
durationMs,
|
|
398
|
+
rawTail,
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
emit({
|
|
402
|
+
event: "run",
|
|
403
|
+
n,
|
|
404
|
+
startedAt: startedAtIso,
|
|
405
|
+
trigger: triggerPaths,
|
|
406
|
+
verdict: receipt ? receipt.verdict : null,
|
|
407
|
+
mode: "fast",
|
|
408
|
+
innerLoopOnly: true,
|
|
409
|
+
exitCode: code,
|
|
410
|
+
signal: signal ?? undefined,
|
|
411
|
+
durationMs,
|
|
412
|
+
steps: receipt
|
|
413
|
+
? receipt.steps.map(({ name, verdict, durationMs: d, reason }) => ({ name, verdict, durationMs: d, ...(reason ? { reason } : {}) }))
|
|
414
|
+
: undefined,
|
|
415
|
+
note: FOOTER,
|
|
416
|
+
});
|
|
417
|
+
resolve(code);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── --once: one coordinated pass, exit with the child's code ──────────────
|
|
423
|
+
if (opts.once) {
|
|
424
|
+
(async () => {
|
|
425
|
+
let noticed = false;
|
|
426
|
+
for (;;) {
|
|
427
|
+
const d = launchDecisionNow();
|
|
428
|
+
if (d.launch) break;
|
|
429
|
+
if (!noticed) {
|
|
430
|
+
noticed = true;
|
|
431
|
+
say(`… ${d.reason}`);
|
|
432
|
+
emit({ event: "deferred", reason: d.reason });
|
|
433
|
+
}
|
|
434
|
+
await sleep(POLL_MS);
|
|
435
|
+
}
|
|
436
|
+
const code = await runVerify([]);
|
|
437
|
+
process.exit(typeof code === "number" ? code : 1);
|
|
438
|
+
})();
|
|
439
|
+
// Ctrl-C during --once still exits 0 cleanly.
|
|
440
|
+
installSignalHandlers(() => {}, () => currentChild, emit, say);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── resident mode ─────────────────────────────────────────────────────────
|
|
445
|
+
const loop = createRunLoop({
|
|
446
|
+
canLaunch: launchDecisionNow,
|
|
447
|
+
runOnce: runVerify,
|
|
448
|
+
onDefer: (reason) => {
|
|
449
|
+
say(`… ${reason}`);
|
|
450
|
+
emit({ event: "deferred", reason });
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const watchers = [];
|
|
455
|
+
const pollTimers = [];
|
|
456
|
+
const watchedRoots = WATCH_ROOTS.filter((rel) => fs.existsSync(path.join(ROOT, rel)));
|
|
457
|
+
|
|
458
|
+
// Poll fallback for platforms without recursive fs.watch: a full mtime scan
|
|
459
|
+
// per tick, diffed against the previous one so changed paths still get
|
|
460
|
+
// attributed. The trees here are small (sources + specs + qa scripts);
|
|
461
|
+
// build dirs and evidence are excluded by the same predicate as the watcher.
|
|
462
|
+
function scanTree(rootRel) {
|
|
463
|
+
const out = new Map(); // rel path -> mtimeMs
|
|
464
|
+
const walk = (dirAbs, dirRel) => {
|
|
465
|
+
let entries;
|
|
466
|
+
try {
|
|
467
|
+
entries = fs.readdirSync(dirAbs, { withFileTypes: true });
|
|
468
|
+
} catch {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
for (const e of entries) {
|
|
472
|
+
const rel = `${dirRel}/${e.name}`;
|
|
473
|
+
if (shouldIgnorePath(rel)) continue;
|
|
474
|
+
const abs = path.join(dirAbs, e.name);
|
|
475
|
+
if (e.isDirectory()) walk(abs, rel);
|
|
476
|
+
else {
|
|
477
|
+
try {
|
|
478
|
+
out.set(rel, fs.statSync(abs).mtimeMs);
|
|
479
|
+
} catch {
|
|
480
|
+
/* raced a delete */
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
walk(path.join(ROOT, rootRel), rootRel);
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function startPolling(rootRel) {
|
|
490
|
+
let prev = scanTree(rootRel);
|
|
491
|
+
const t = setInterval(() => {
|
|
492
|
+
const next = scanTree(rootRel);
|
|
493
|
+
for (const [rel, mtime] of next) {
|
|
494
|
+
if (!prev.has(rel) || prev.get(rel) !== mtime) loop.change(rel);
|
|
495
|
+
}
|
|
496
|
+
for (const rel of prev.keys()) {
|
|
497
|
+
if (!next.has(rel)) loop.change(rel);
|
|
498
|
+
}
|
|
499
|
+
prev = next;
|
|
500
|
+
}, POLL_MS);
|
|
501
|
+
pollTimers.push(t);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function noteFallback(rootRel, why) {
|
|
505
|
+
// Never silently watch nothing: the fallback is announced, per root.
|
|
506
|
+
say(`recursive watch unavailable for ${rootRel} (${why}) — falling back to a ${POLL_MS}ms poll`);
|
|
507
|
+
emit({ event: "watch-fallback", root: rootRel, reason: why, pollMs: POLL_MS });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const rootRel of watchedRoots) {
|
|
511
|
+
try {
|
|
512
|
+
const w = fs.watch(path.join(ROOT, rootRel), { recursive: true }, (_event, filename) => {
|
|
513
|
+
const rel = filename ? `${rootRel}/${String(filename).replace(/\\/g, "/")}` : rootRel;
|
|
514
|
+
if (shouldIgnorePath(rel)) return;
|
|
515
|
+
loop.change(rel);
|
|
516
|
+
});
|
|
517
|
+
w.on("error", () => {
|
|
518
|
+
// A watcher dying mid-flight (rare) degrades to polling, announced.
|
|
519
|
+
try {
|
|
520
|
+
w.close();
|
|
521
|
+
} catch {}
|
|
522
|
+
noteFallback(rootRel, "watcher error");
|
|
523
|
+
startPolling(rootRel);
|
|
524
|
+
});
|
|
525
|
+
watchers.push(w);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
// ENOSYS / ERR_FEATURE_UNAVAILABLE: recursive watch unsupported here.
|
|
528
|
+
noteFallback(rootRel, err.code || err.message);
|
|
529
|
+
startPolling(rootRel);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Startup: what is watched, what is respected, what this is NOT.
|
|
534
|
+
say("qa/watch.mjs — resident inner loop: runs `node qa/verify.mjs --fast` on save");
|
|
535
|
+
say(`watching: ${watchedRoots.join(", ")} (ignoring **/build/**, qa/evidence/**, dotfiles)`);
|
|
536
|
+
say("coordination: defers while composeApp/build/.cmp-lane-in-progress or .cmp-render-in-progress is fresh — never two Gradle invocations against this project");
|
|
537
|
+
say(`debounce: ${DEBOUNCE_MS}ms — a save storm triggers one run; changes during a run coalesce into one follow-up`);
|
|
538
|
+
say(FOOTER);
|
|
539
|
+
say("waiting for changes… (Ctrl-C to stop · --once for a single pass · --json for line-per-run output)");
|
|
540
|
+
emit({
|
|
541
|
+
event: "start",
|
|
542
|
+
pid: process.pid,
|
|
543
|
+
watching: watchedRoots,
|
|
544
|
+
ignoring: ["**/build/**", "qa/evidence/**", "dotfiles"],
|
|
545
|
+
debounceMs: DEBOUNCE_MS,
|
|
546
|
+
coordinates: [LANE_MARKER_REL.join("/"), RENDER_MARKER_REL.join("/")],
|
|
547
|
+
runs: "node qa/verify.mjs --fast",
|
|
548
|
+
note: FOOTER,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
installSignalHandlers(
|
|
552
|
+
() => {
|
|
553
|
+
loop.stop();
|
|
554
|
+
for (const w of watchers) {
|
|
555
|
+
try {
|
|
556
|
+
w.close();
|
|
557
|
+
} catch {}
|
|
558
|
+
}
|
|
559
|
+
for (const t of pollTimers) clearInterval(t);
|
|
560
|
+
},
|
|
561
|
+
() => currentChild,
|
|
562
|
+
emit,
|
|
563
|
+
say,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Clean shutdown on SIGINT/SIGTERM: stop producing work, terminate any
|
|
569
|
+
* in-flight verify child (TERM, then KILL after a short grace), clean up the
|
|
570
|
+
* lane marker that child stamped (its `finally` never ran), and exit 0 —
|
|
571
|
+
* Ctrl-C on a watcher is a normal end, not a failure.
|
|
572
|
+
*/
|
|
573
|
+
function installSignalHandlers(stopWork, getChild, emit, say) {
|
|
574
|
+
let shuttingDown = false;
|
|
575
|
+
const shutdown = async (sig) => {
|
|
576
|
+
if (shuttingDown) return;
|
|
577
|
+
shuttingDown = true;
|
|
578
|
+
stopWork();
|
|
579
|
+
const child = getChild();
|
|
580
|
+
if (child && child.exitCode === null) {
|
|
581
|
+
const exited = new Promise((r) => child.once("exit", r));
|
|
582
|
+
// Signal the child's whole process GROUP (it was spawned detached as a
|
|
583
|
+
// group leader): verify's Gradle grandchildren must get the signal too,
|
|
584
|
+
// or a gradlew JVM outlives the shutdown. Fall back to the single pid
|
|
585
|
+
// if the group is already gone.
|
|
586
|
+
const signalTree = (sig) => {
|
|
587
|
+
try {
|
|
588
|
+
process.kill(-child.pid, sig);
|
|
589
|
+
} catch {
|
|
590
|
+
try {
|
|
591
|
+
child.kill(sig);
|
|
592
|
+
} catch {}
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
signalTree("SIGTERM");
|
|
596
|
+
await Promise.race([exited, sleep(2500)]);
|
|
597
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
598
|
+
signalTree("SIGKILL");
|
|
599
|
+
await Promise.race([exited, sleep(1000)]);
|
|
600
|
+
}
|
|
601
|
+
clearMarkerIfOwnedBy(path.join(ROOT, ...LANE_MARKER_REL), child.pid);
|
|
602
|
+
}
|
|
603
|
+
say(`watch mode stopped (${sig}) — no receipt was made valid by watching; the done-gate is still one full \`node qa/verify.mjs\` run`);
|
|
604
|
+
emit({ event: "shutdown", reason: sig });
|
|
605
|
+
process.exit(0);
|
|
606
|
+
};
|
|
607
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
608
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Import-safe: tests import the pure pieces above without starting watchers.
|
|
612
|
+
// Realpath BOTH sides: the entry path may reach this file through a symlink
|
|
613
|
+
// (macOS's /var/folders → /private/var, npm links) and must still count.
|
|
614
|
+
const invokedDirectly = (() => {
|
|
615
|
+
try {
|
|
616
|
+
if (!process.argv[1]) return false;
|
|
617
|
+
return fs.realpathSync(path.resolve(process.argv[1])) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
618
|
+
} catch {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
})();
|
|
622
|
+
if (invokedDirectly) main();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cmp-receipts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Validate create-cmp evidence receipts: inputs-hash recompute, receipt-vs-tree binding, freshness, and execution-plausibility. Dependency-free ESM — the single source of truth vendored into every generated project's qa/lib/ and consumed by the hosted receipt check.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.mjs",
|
|
9
|
+
"./inputs-hash": "./src/inputs-hash.mjs",
|
|
10
|
+
"./receipt-validate": "./src/receipt-validate.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"create-cmp",
|
|
24
|
+
"evidence",
|
|
25
|
+
"receipt",
|
|
26
|
+
"verification",
|
|
27
|
+
"content-hash"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "Karel van der Merwe",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/kvdm-co-pilot/create-cmp.git",
|
|
34
|
+
"directory": "packages/receipts"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// cmp-receipts — validate create-cmp evidence receipts.
|
|
2
|
+
// Single source of truth for the inputs-hash algorithm and the receipt
|
|
3
|
+
// predicate; vendored byte-identical into every generated project's qa/lib/
|
|
4
|
+
// and consumed by hosted validators. Dependency-free ESM.
|
|
5
|
+
|
|
6
|
+
export { computeInputsHash, VERIFIED_SURFACE } from "./inputs-hash.mjs";
|
|
7
|
+
export {
|
|
8
|
+
RECEIPT_REL_PATH,
|
|
9
|
+
readReceipt,
|
|
10
|
+
evaluateReceipt,
|
|
11
|
+
DEFAULT_POLICY,
|
|
12
|
+
checkFreshness,
|
|
13
|
+
checkExecutionPlausibility,
|
|
14
|
+
listSkippedSteps,
|
|
15
|
+
validateReceiptForTree,
|
|
16
|
+
} from "./receipt-validate.mjs";
|