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.
Files changed (63) hide show
  1. package/bin/create-cmp.mjs +3 -0
  2. package/package.json +6 -2
  3. package/packages/harness/package.json +38 -0
  4. package/packages/harness/src/approve.mjs +247 -0
  5. package/packages/harness/src/arch-doc.mjs +69 -0
  6. package/packages/harness/src/comment.mjs +76 -0
  7. package/packages/harness/src/lib/a11y.mjs +113 -0
  8. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  9. package/packages/harness/src/lib/approvals.mjs +1403 -0
  10. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  11. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  12. package/packages/harness/src/lib/comments.mjs +252 -0
  13. package/packages/harness/src/lib/component-stories.mjs +183 -0
  14. package/packages/harness/src/lib/determinism.mjs +179 -0
  15. package/packages/harness/src/lib/device-lease.mjs +249 -0
  16. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  17. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  18. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  19. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  20. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  21. package/packages/harness/src/lib/harness-region.mjs +159 -0
  22. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  23. package/packages/harness/src/lib/reachability.mjs +211 -0
  24. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  25. package/packages/harness/src/lib/render.mjs +254 -0
  26. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  27. package/packages/harness/src/lib/step-cache.mjs +221 -0
  28. package/packages/harness/src/lib/token-drift.mjs +94 -0
  29. package/packages/harness/src/lib/tree.mjs +108 -0
  30. package/packages/harness/src/preview-gallery.mjs +122 -0
  31. package/packages/harness/src/receipt-check.mjs +96 -0
  32. package/packages/harness/src/record-audit.mjs +83 -0
  33. package/packages/harness/src/refusal-demo.mjs +498 -0
  34. package/packages/harness/src/retrospective.mjs +51 -0
  35. package/packages/harness/src/scaffold-feature.mjs +723 -0
  36. package/packages/harness/src/setup-hooks.mjs +33 -0
  37. package/packages/harness/src/verify.mjs +1709 -0
  38. package/packages/harness/src/walkthrough.mjs +499 -0
  39. package/packages/harness/src/watch.mjs +622 -0
  40. package/packages/receipts/package.json +36 -0
  41. package/packages/receipts/src/index.mjs +16 -0
  42. package/packages/receipts/src/inputs-hash.mjs +194 -0
  43. package/packages/receipts/src/receipt-validate.mjs +234 -0
  44. package/src/commands/upgrade.mjs +383 -0
  45. package/src/lib/harness-upgrade.mjs +521 -0
  46. package/src/scaffold.mjs +60 -1
  47. package/template/AGENTS.md +5 -0
  48. package/template/CLAUDE.md +34 -1
  49. package/template/README.md +4 -0
  50. package/template/gitignore +8 -0
  51. package/template/qa/lib/audit-cadence.mjs +290 -0
  52. package/template/qa/lib/determinism.mjs +179 -0
  53. package/template/qa/lib/evidence-badge.mjs +158 -0
  54. package/template/qa/lib/flight-recorder.mjs +332 -0
  55. package/template/qa/lib/harness-lock.mjs +147 -0
  56. package/template/qa/lib/harness-region.mjs +159 -0
  57. package/template/qa/lib/inputs-hash.mjs +17 -2
  58. package/template/qa/lib/receipt-validate.mjs +1 -1
  59. package/template/qa/preview-gallery.mjs +17 -2
  60. package/template/qa/record-audit.mjs +83 -0
  61. package/template/qa/retrospective.mjs +51 -0
  62. package/template/qa/verify.mjs +400 -10
  63. package/template/qa/watch.mjs +2 -2
@@ -0,0 +1,254 @@
1
+ // render.mjs — deterministic SVG wireframe of a CMP inspector tree.
2
+ //
3
+ // The "structural twin" of a pixel preview: every node with a non-zero visual
4
+ // footprint becomes a rect; token-annotated nodes are visually distinct and carry
5
+ // a small chip with their resolved values ("radius 16 · pad 16"); clickable nodes
6
+ // get a distinct outline; testTags render as small mono labels; text nodes show
7
+ // their text. An optional a11y audit result overlays violations in a danger style.
8
+ //
9
+ // SVG is structured TEXT, not pixels — safe for model context, works for ANY
10
+ // source (file / live / uiautomator). Output is fully deterministic: no dates,
11
+ // no randomness; the same tree + opts always yields byte-identical SVG.
12
+ //
13
+ // Pure logic only — no fs, no MCP imports; the server wires file I/O around it.
14
+
15
+ import { walk } from "./tree.mjs";
16
+
17
+ const FIT_WIDTH = 740; // target drawing width when no explicit scale is given
18
+ const MARGIN = 16;
19
+ const LEGEND_H = 30;
20
+ const FOOTER_H = 24;
21
+
22
+ const STYLE = {
23
+ plain: { fill: "none", stroke: "#9CA3AF", strokeWidth: 1, dash: null },
24
+ tokenized: { fill: "rgba(0,185,107,0.10)", stroke: "#00B96B", strokeWidth: 1.5, dash: null },
25
+ clickableStroke: "#2563EB",
26
+ dangerStroke: "#DC2626",
27
+ chipFill: "#0A2540",
28
+ chipText: "#FFFFFF",
29
+ tagText: "#6B7280",
30
+ nodeText: "#1A1A1A",
31
+ footerText: "#6B7280",
32
+ };
33
+
34
+ /**
35
+ * Render a tree (full {root} document or bare node) as an SVG wireframe string.
36
+ *
37
+ * @param {object} tree
38
+ * @param {object} [opts]
39
+ * @param {object} [opts.a11y] an auditA11y() result — its violations are overlaid
40
+ * in the danger style (matched to nodes by path).
41
+ * @param {number} [opts.maxDepth] only draw nodes up to this depth (root = 0).
42
+ * @param {number} [opts.scale] explicit px scale; default fits root width to ~740.
43
+ * @returns {string} the SVG document.
44
+ */
45
+ export function renderTreeSvg(tree, opts = {}) {
46
+ const root = tree && tree.root ? tree.root : tree;
47
+ if (!root || typeof root !== "object") {
48
+ throw new Error("renderTreeSvg: tree has no root node.");
49
+ }
50
+ const schemaVersion = (tree && tree.schemaVersion) ?? 1;
51
+ const source = (tree && tree.source) ?? "unknown";
52
+
53
+ const rootW = boundsDim(root.bounds, "width") || 360;
54
+ const rootH = boundsDim(root.bounds, "height") || 640;
55
+ const scale = typeof opts.scale === "number" && opts.scale > 0 ? opts.scale : FIT_WIDTH / rootW;
56
+ const maxDepth =
57
+ typeof opts.maxDepth === "number" && opts.maxDepth >= 0 ? opts.maxDepth : Infinity;
58
+
59
+ // Violations by node path (danger overlay).
60
+ const violationsByPath = new Map();
61
+ if (opts.a11y && Array.isArray(opts.a11y.violations)) {
62
+ for (const v of opts.a11y.violations) {
63
+ if (!violationsByPath.has(v.path)) violationsByPath.set(v.path, []);
64
+ violationsByPath.get(v.path).push(v.rule);
65
+ }
66
+ }
67
+
68
+ const drawW = rootW * scale;
69
+ const drawH = rootH * scale;
70
+ const svgW = Math.ceil(drawW + MARGIN * 2);
71
+ const svgH = Math.ceil(LEGEND_H + drawH + FOOTER_H + MARGIN * 2);
72
+ const originX = MARGIN;
73
+ const originY = LEGEND_H + MARGIN / 2;
74
+
75
+ let nodeCount = 0;
76
+ const body = [];
77
+
78
+ for (const { node, path } of walk(root)) {
79
+ nodeCount++;
80
+ if (depthOf(path) > maxDepth) continue;
81
+ const b = node.bounds;
82
+ const w = boundsDim(b, "width");
83
+ const h = boundsDim(b, "height");
84
+ if (!(w > 0 && h > 0)) continue; // zero-footprint nodes have nothing to draw
85
+
86
+ const x = originX + (b.x || 0) * scale;
87
+ const y = originY + (b.y || 0) * scale;
88
+ const sw = w * scale;
89
+ const sh = h * scale;
90
+ const tokenized = node.designToken != null;
91
+ const clickable = node.clickable === true;
92
+ const rules = violationsByPath.get(path);
93
+
94
+ const base = tokenized ? STYLE.tokenized : STYLE.plain;
95
+ body.push(
96
+ `<rect x="${fmt(x)}" y="${fmt(y)}" width="${fmt(sw)}" height="${fmt(sh)}" ` +
97
+ `fill="${base.fill}" stroke="${base.stroke}" stroke-width="${base.strokeWidth}"` +
98
+ `${tokenized ? ` class="tokenized"` : ""} data-path="${esc(path)}"/>`
99
+ );
100
+ if (clickable) {
101
+ // Distinct clickable outline, drawn just inside the node rect.
102
+ body.push(
103
+ `<rect x="${fmt(x + 1.5)}" y="${fmt(y + 1.5)}" width="${fmt(Math.max(sw - 3, 1))}" ` +
104
+ `height="${fmt(Math.max(sh - 3, 1))}" fill="none" stroke="${STYLE.clickableStroke}" ` +
105
+ `stroke-width="2" stroke-dasharray="5 3" class="clickable"/>`
106
+ );
107
+ }
108
+ if (rules && rules.length > 0) {
109
+ // Danger overlay + rule label for a11y violations.
110
+ body.push(
111
+ `<rect x="${fmt(x - 2)}" y="${fmt(y - 2)}" width="${fmt(sw + 4)}" height="${fmt(sh + 4)}" ` +
112
+ `fill="rgba(220,38,38,0.08)" stroke="${STYLE.dangerStroke}" stroke-width="2" class="a11y-violation"/>`
113
+ );
114
+ body.push(
115
+ `<text x="${fmt(x)}" y="${fmt(y - 4)}" font-family="monospace" font-size="8" ` +
116
+ `fill="${STYLE.dangerStroke}" class="a11y-label">! ${esc([...rules].sort().join(", "))}</text>`
117
+ );
118
+ }
119
+ if (node.testTag) {
120
+ body.push(
121
+ `<text x="${fmt(x + 3)}" y="${fmt(y + 9)}" font-family="monospace" font-size="8" ` +
122
+ `fill="${STYLE.tagText}" class="test-tag">${esc(node.testTag)}</text>`
123
+ );
124
+ }
125
+ if (node.text) {
126
+ body.push(
127
+ `<text x="${fmt(x + 3)}" y="${fmt(y + sh / 2 + 3)}" font-family="sans-serif" font-size="10" ` +
128
+ `fill="${STYLE.nodeText}" class="node-text">${esc(truncate(node.text, 48))}</text>`
129
+ );
130
+ }
131
+ if (tokenized) {
132
+ const chip = tokenChip(node.designToken);
133
+ if (chip) {
134
+ const chipW = chip.length * 4.6 + 8;
135
+ const chipY = y + sh - 12;
136
+ body.push(
137
+ `<rect x="${fmt(x + 2)}" y="${fmt(chipY)}" width="${fmt(chipW)}" height="11" rx="5" ` +
138
+ `fill="${STYLE.chipFill}" opacity="0.85" class="token-chip"/>`
139
+ );
140
+ body.push(
141
+ `<text x="${fmt(x + 6)}" y="${fmt(chipY + 8.5)}" font-family="monospace" font-size="7.5" ` +
142
+ `fill="${STYLE.chipText}" class="token-chip-text">${esc(chip)}</text>`
143
+ );
144
+ }
145
+ }
146
+ }
147
+
148
+ const legend = legendRow(opts.a11y != null);
149
+ const footer =
150
+ `<text x="${MARGIN}" y="${svgH - 8}" font-family="monospace" font-size="10" ` +
151
+ `fill="${STYLE.footerText}" class="footer">${nodeCount} nodes · ${esc(source)} · schemaVersion ${schemaVersion}</text>`;
152
+
153
+ return [
154
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">`,
155
+ `<rect x="0" y="0" width="${svgW}" height="${svgH}" fill="#F7F9FC"/>`,
156
+ legend,
157
+ ...body,
158
+ footer,
159
+ `</svg>`,
160
+ ``,
161
+ ].join("\n");
162
+ }
163
+
164
+ // --- helpers ----------------------------------------------------------------
165
+
166
+ // Legend row across the top: what each visual style means.
167
+ function legendRow(withA11y) {
168
+ const items = [];
169
+ let x = MARGIN;
170
+ const y = 8;
171
+ const swatch = (fill, stroke, dash, label, cls) => {
172
+ const parts = [
173
+ `<rect x="${fmt(x)}" y="${y}" width="14" height="10" fill="${fill}" stroke="${stroke}" ` +
174
+ `stroke-width="1.5"${dash ? ` stroke-dasharray="${dash}"` : ""} class="legend-${cls}"/>`,
175
+ `<text x="${fmt(x + 18)}" y="${y + 9}" font-family="sans-serif" font-size="9" fill="#1A1A1A">${label}</text>`,
176
+ ];
177
+ x += 18 + label.length * 5.2 + 14;
178
+ items.push(...parts);
179
+ };
180
+ swatch("none", STYLE.plain.stroke, null, "node", "node");
181
+ swatch(STYLE.tokenized.fill, STYLE.tokenized.stroke, null, "tokenized", "tokenized");
182
+ swatch("none", STYLE.clickableStroke, "5 3", "clickable", "clickable");
183
+ if (withA11y) swatch("rgba(220,38,38,0.08)", STYLE.dangerStroke, null, "a11y violation", "a11y");
184
+ return `<g class="legend">${items.join("")}</g>`;
185
+ }
186
+
187
+ // "radius 16 · pad 16" — compact resolved-values chip, sorted keys for determinism.
188
+ function tokenChip(dt) {
189
+ if (!dt || !dt.resolved || typeof dt.resolved !== "object") return null;
190
+ const keys = Object.keys(dt.resolved).sort();
191
+ if (keys.length === 0) return null;
192
+ const parts = keys.map((k) => {
193
+ const v = String(dt.resolved[k]).replace(/(dp|sp)$/i, "");
194
+ return `${abbrev(k)} ${v}`.trim();
195
+ });
196
+ return truncate(parts.join(" · "), 64);
197
+ }
198
+
199
+ const ABBREV = {
200
+ padding: "pad",
201
+ elevation: "elev",
202
+ fontSize: "font",
203
+ height: "h",
204
+ width: "w",
205
+ statusBarPadding: "statusBar",
206
+ navBarPadding: "navBar",
207
+ };
208
+ function abbrev(key) {
209
+ return ABBREV[key] ?? key;
210
+ }
211
+
212
+ function depthOf(path) {
213
+ return (path.match(/\.children\[/g) || []).length;
214
+ }
215
+
216
+ function boundsDim(b, key) {
217
+ return b && typeof b[key] === "number" ? b[key] : 0;
218
+ }
219
+
220
+ function fmt(n) {
221
+ // Fixed one-decimal formatting: deterministic and diff-friendly.
222
+ return (Math.round(n * 10) / 10).toString();
223
+ }
224
+
225
+ function truncate(s, max) {
226
+ const str = String(s);
227
+ return str.length <= max ? str : str.slice(0, max - 1) + "…";
228
+ }
229
+
230
+ function esc(s) {
231
+ return String(s)
232
+ .replace(/&/g, "&amp;")
233
+ .replace(/</g, "&lt;")
234
+ .replace(/>/g, "&gt;")
235
+ .replace(/"/g, "&quot;");
236
+ }
237
+
238
+ /**
239
+ * Count the nodes renderTreeSvg would draw as rects (non-zero footprint within
240
+ * maxDepth) plus the total node count — used by the tool result.
241
+ */
242
+ export function countRenderable(tree, opts = {}) {
243
+ const root = tree && tree.root ? tree.root : tree;
244
+ const maxDepth =
245
+ typeof opts.maxDepth === "number" && opts.maxDepth >= 0 ? opts.maxDepth : Infinity;
246
+ let total = 0;
247
+ let drawn = 0;
248
+ for (const { node, path } of walk(root)) {
249
+ total++;
250
+ if (depthOf(path) > maxDepth) continue;
251
+ if (boundsDim(node.bounds, "width") > 0 && boundsDim(node.bounds, "height") > 0) drawn++;
252
+ }
253
+ return { total, drawn };
254
+ }
@@ -0,0 +1,131 @@
1
+ // spec-coverage.mjs — the clause ↔ citation scan, as a library.
2
+ //
3
+ // Extracted from qa/verify.mjs's stepSpecCoverage so there is exactly ONE
4
+ // definition of "what is a clause" and "what cites it". Two consumers:
5
+ // - the lane's specCoverage gate (orphans in either direction FAIL)
6
+ // - feature-brief.mjs's derived doneness (a feature is done when every live
7
+ // clause in ITS spec is cited and the receipt attests the tree)
8
+ // If these two scanned differently, the Features view and the lane could
9
+ // disagree about the same clause — the exact two-truths problem this file
10
+ // exists to prevent.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ /** `- **HOME-01** — …` (live) or `- ~~**HOME-01**~~ — …` (withdrawn). */
16
+ export const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
17
+
18
+ const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
19
+ const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
20
+ const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
21
+
22
+ /** Recursive walk returning files under `dir` ending in one of `exts`. */
23
+ export function walkFiles(dir, exts) {
24
+ const out = [];
25
+ if (!fs.existsSync(dir)) return out;
26
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
27
+ const p = path.join(dir, entry.name);
28
+ if (entry.isDirectory()) out.push(...walkFiles(p, exts));
29
+ else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(p);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /**
35
+ * Every clause in every specs/*.spec.md.
36
+ * @param {string} root
37
+ * @returns {Map<string, {file: string, withdrawn: boolean}>} id -> where/state
38
+ */
39
+ export function scanSpecClauses(root) {
40
+ const clauses = new Map();
41
+ const specsDir = path.join(root, "specs");
42
+ if (!fs.existsSync(specsDir)) return clauses;
43
+ for (const f of fs.readdirSync(specsDir).filter((n) => n.endsWith(".spec.md"))) {
44
+ const abs = path.join(specsDir, f);
45
+ for (const line of fs.readFileSync(abs, "utf8").split("\n")) {
46
+ const m = line.match(CLAUSE_LINE_RE);
47
+ if (!m) continue;
48
+ clauses.set(m[2], { file: path.relative(root, abs), withdrawn: Boolean(m[1]) });
49
+ }
50
+ }
51
+ return clauses;
52
+ }
53
+
54
+ /**
55
+ * Which test tier a citing file belongs to, derived from its path. Tiers are
56
+ * the source-set/harness boundaries that decide what a citation can actually
57
+ * SEE: commonTest and desktopTest run on the JVM (blind to androidMain code),
58
+ * androidInstrumentedTest runs on a device, e2e flows drive the installed app.
59
+ * @param {string} relFile path relative to the project root
60
+ * @returns {"commonTest"|"desktopTest"|"androidInstrumentedTest"|"e2e"|"other"}
61
+ */
62
+ export function tierForFile(relFile) {
63
+ const p = relFile.split(path.sep).join("/");
64
+ if (p.includes("/androidInstrumentedTest/")) return "androidInstrumentedTest";
65
+ if (p.includes("/commonTest/")) return "commonTest";
66
+ if (p.includes("/desktopTest/")) return "desktopTest";
67
+ if (p.startsWith("qa/e2e/")) return "e2e";
68
+ return "other";
69
+ }
70
+
71
+ /** Tiers whose tests run entirely on the host JVM — blind to platform source sets. */
72
+ export const DESKTOP_TIERS = Object.freeze(["commonTest", "desktopTest"]);
73
+
74
+ /**
75
+ * Every `// SPEC: ID[, ID…]` / `# SPEC: …` citation tag under composeApp/src
76
+ * and qa/e2e. Each entry carries the citing file's `tier` (see tierForFile).
77
+ * @param {string} root
78
+ * @returns {Array<{id: string, file: string, line: number, tier: string}>}
79
+ */
80
+ export function scanCitations(root) {
81
+ const tags = [];
82
+ const searchDirs = [path.join(root, "composeApp/src"), path.join(root, "qa/e2e")];
83
+ const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
84
+ for (const f of files) {
85
+ const rel = path.relative(root, f);
86
+ const tier = tierForFile(rel);
87
+ fs.readFileSync(f, "utf8")
88
+ .split("\n")
89
+ .forEach((line, i) => {
90
+ const trimmed = line.trim();
91
+ if (!TAG_LINE_RE.test(trimmed)) return;
92
+ const m = trimmed.match(TAG_IDS_RE);
93
+ if (!m) return;
94
+ const ids = m[1]
95
+ .split(/[,\s]+/)
96
+ .map((s) => s.trim())
97
+ .filter((s) => CLAUSE_ID_RE.test(s));
98
+ for (const id of ids) tags.push({ id, file: rel, line: i + 1, tier });
99
+ });
100
+ }
101
+ return tags;
102
+ }
103
+
104
+ /**
105
+ * Per-clause tier visibility — report data only, never a pass/fail input
106
+ * (instrument before you police). For each cited clause: which tiers cite it.
107
+ * `desktopOnly` lists live clauses whose every citation is desktop-tier
108
+ * (commonTest/desktopTest) — behavior claims no device-tier evidence backs.
109
+ * `summaryLine` is the one line the lane's specCoverage step (and any other
110
+ * consumer) can print verbatim; null when nothing is desktop-only.
111
+ * @param {Map<string, {file: string, withdrawn: boolean}>} clauses from scanSpecClauses
112
+ * @param {Array<{id: string, tier: string}>} tags from scanCitations
113
+ * @returns {{tiersByClause: Record<string, string[]>, desktopOnly: string[], summaryLine: string|null}}
114
+ */
115
+ export function clauseTierCoverage(clauses, tags) {
116
+ const tiersByClause = {};
117
+ for (const t of tags) {
118
+ (tiersByClause[t.id] ??= []).includes(t.tier) || tiersByClause[t.id].push(t.tier);
119
+ }
120
+ const desktopOnly = [...clauses.entries()]
121
+ .filter(([, c]) => !c.withdrawn)
122
+ .map(([id]) => id)
123
+ .filter((id) => {
124
+ const tiers = tiersByClause[id];
125
+ return tiers && tiers.every((t) => DESKTOP_TIERS.includes(t));
126
+ });
127
+ const summaryLine = desktopOnly.length
128
+ ? `${desktopOnly.length} clause${desktopOnly.length === 1 ? "" : "s"} cited only from desktop-tier tests (${desktopOnly.join(", ")})`
129
+ : null;
130
+ return { tiersByClause, desktopOnly, summaryLine };
131
+ }
@@ -0,0 +1,221 @@
1
+ // step-cache.mjs — fast-mode memoization for the lane's pure-Node steps.
2
+ //
3
+ // The steps this serves (specCoverage, approvals, componentStories,
4
+ // reachability, archDoc) are pure functions of files on disk: no Gradle, no
5
+ // git, no network, no clock in the verdict. For those — and ONLY those — a
6
+ // verdict can be safely reused when a content hash of the step's declared
7
+ // input set is unchanged since the last run that produced it.
8
+ //
9
+ // GROUND RULES, each load-bearing:
10
+ //
11
+ // - THE CACHE IS A CACHE, NEVER EVIDENCE. It lives in the gitignored build
12
+ // dir (composeApp/build/.cmp-step-cache.json), must never be committed,
13
+ // and must never be read by qa/receipt-check.mjs or any other receipt
14
+ // consumer. Deleting it can only cost time, never correctness.
15
+ //
16
+ // - THE FULL LANE NEVER READS IT. This is deliberate: it keeps the full
17
+ // lane's integrity property absolute rather than "absolute unless a cache
18
+ // says otherwise". A full run WRITES entries (so the next fast run
19
+ // benefits) but always executes every step itself.
20
+ //
21
+ // - ONLY A CACHED PASS IS EVER REUSED. A cached FAIL is always re-run so
22
+ // the failure detail is fresh; a cached SKIP is re-derived (these steps
23
+ // are cheap enough that only the PASS case is worth reusing, and a SKIP's
24
+ // reason — e.g. which approvals are pending — must stay current).
25
+ //
26
+ // - THE DECLARED INPUT SET IS THE WHOLE SAFETY ARGUMENT. A step's inputs
27
+ // must cover everything it reads; a wrong input set is a silently-stale
28
+ // gate — the worst possible bug this file could host. Callers over-declare
29
+ // on purpose (a too-broad set only costs cache misses; a too-narrow one
30
+ // costs truth). The declarations live next to the steps in qa/verify.mjs.
31
+ //
32
+ // - THE CACHE MUST NEVER BREAK THE LANE. Missing, corrupt, unreadable,
33
+ // unwritable — every failure mode degrades to "execute the step", never
34
+ // to an error and never to a reused verdict.
35
+
36
+ import { createHash } from "node:crypto";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+
40
+ export const STEP_CACHE_REL_PATH = "composeApp/build/.cmp-step-cache.json";
41
+ export const STEP_CACHE_SCHEMA = "cmp-step-cache/1";
42
+
43
+ function toPosix(p) {
44
+ return p.split(path.sep).join("/");
45
+ }
46
+
47
+ /** Every file under `dir` (recursive), absolute paths. Missing dir → []. */
48
+ function walkAllFiles(dir) {
49
+ const out = [];
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(dir, { withFileTypes: true });
53
+ } catch {
54
+ return out;
55
+ }
56
+ for (const e of entries) {
57
+ const p = path.join(dir, e.name);
58
+ if (e.isDirectory()) out.push(...walkAllFiles(p));
59
+ else if (e.isFile()) out.push(p);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Content-hash one step's declared input set: sha256 over the sorted list of
66
+ * `relpath\0bytes` entries. Deterministic: same paths + same bytes → same
67
+ * hash, independent of declaration order, walk order, and platform separators.
68
+ * A file appearing, disappearing, moving, or changing content all change the
69
+ * hash — which is exactly the set of events that can change a pure-Node
70
+ * step's verdict.
71
+ *
72
+ * @param {string} root project root (absolute)
73
+ * @param {string[]} inputs paths relative to root — each a file or a
74
+ * directory (walked recursively). Missing entries contribute nothing (their
75
+ * later appearance changes the hash).
76
+ * @returns {{hash: string, fileCount: number}}
77
+ */
78
+ export function computeStepInputsHash(root, inputs) {
79
+ const relFiles = new Set();
80
+ for (const rel of inputs ?? []) {
81
+ const abs = path.join(root, rel);
82
+ let stat;
83
+ try {
84
+ stat = fs.statSync(abs);
85
+ } catch {
86
+ continue; // absent input — contributes nothing until it exists
87
+ }
88
+ if (stat.isFile()) {
89
+ relFiles.add(toPosix(rel));
90
+ } else if (stat.isDirectory()) {
91
+ for (const f of walkAllFiles(abs)) {
92
+ relFiles.add(toPosix(path.relative(root, f)));
93
+ }
94
+ }
95
+ }
96
+ // Code-unit sort (default String sort), same stance as inputs-hash.mjs: the
97
+ // hash depends on iteration order and must be identical on every machine.
98
+ const sorted = [...relFiles].sort();
99
+ const overall = createHash("sha256");
100
+ for (const rel of sorted) {
101
+ overall.update(rel);
102
+ overall.update("\0");
103
+ overall.update(fs.readFileSync(path.join(root, rel)));
104
+ overall.update("\n");
105
+ }
106
+ return { hash: overall.digest("hex"), fileCount: sorted.length };
107
+ }
108
+
109
+ /**
110
+ * Load the cache file. Absent, corrupt, or wrong-schema is TOLERATED and
111
+ * returns an empty cache — a cache that cannot be read is a cache miss,
112
+ * never an error (see ground rules).
113
+ * @param {string} root
114
+ * @returns {{schema: string, steps: Record<string, {inputsHash: string, verdict: string, at: string}>}}
115
+ */
116
+ export function loadStepCache(root) {
117
+ try {
118
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, STEP_CACHE_REL_PATH), "utf8"));
119
+ if (!parsed || parsed.schema !== STEP_CACHE_SCHEMA || typeof parsed.steps !== "object" || parsed.steps === null || Array.isArray(parsed.steps)) {
120
+ return { schema: STEP_CACHE_SCHEMA, steps: {} };
121
+ }
122
+ return { schema: STEP_CACHE_SCHEMA, steps: parsed.steps };
123
+ } catch {
124
+ return { schema: STEP_CACHE_SCHEMA, steps: {} };
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The reuse decision: return the cached entry iff the step's last EXECUTED
130
+ * verdict was PASS and its inputs hash exactly matches `inputsHash`. A cached
131
+ * FAIL or SKIP is never reused (re-run so the detail is fresh); a hash
132
+ * mismatch is a miss; a malformed entry is a miss.
133
+ * @param {string} root
134
+ * @param {string} stepName
135
+ * @param {string} inputsHash
136
+ * @returns {{inputsHash: string, verdict: string, at: string}|null}
137
+ */
138
+ export function lookupCachedPass(root, stepName, inputsHash) {
139
+ const entry = loadStepCache(root).steps[stepName];
140
+ if (!entry || typeof entry !== "object") return null;
141
+ if (entry.verdict !== "PASS") return null; // FAIL/SKIP are never reused
142
+ if (typeof inputsHash !== "string" || entry.inputsHash !== inputsHash) return null;
143
+ if (typeof entry.at !== "string") return null;
144
+ return entry;
145
+ }
146
+
147
+ /**
148
+ * Record a step's EXECUTED result (any verdict — the entry always reflects
149
+ * the last real execution; only lookupCachedPass decides reusability).
150
+ * Write failures are swallowed: an unwritable cache costs the next run time,
151
+ * never correctness.
152
+ * @param {string} root
153
+ * @param {string} stepName
154
+ * @param {{inputsHash: string, verdict: string, at?: string}} entry
155
+ */
156
+ export function writeStepCacheEntry(root, stepName, { inputsHash, verdict, at = new Date().toISOString() }) {
157
+ try {
158
+ const cache = loadStepCache(root);
159
+ cache.steps[stepName] = { inputsHash, verdict, at };
160
+ const p = path.join(root, STEP_CACHE_REL_PATH);
161
+ fs.mkdirSync(path.dirname(p), { recursive: true });
162
+ fs.writeFileSync(p, `${JSON.stringify(cache, null, 2)}\n`);
163
+ } catch {
164
+ // never a lane failure — see ground rules
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The one memoization flow, shared by every memoized step so the mode rules
170
+ * cannot drift per step:
171
+ *
172
+ * fast mode: hash inputs → cached PASS with matching hash → return a
173
+ * CACHED result (verdict "CACHED", distinct from PASS so a fast
174
+ * receipt can never be mistaken for a fully-executed one);
175
+ * otherwise execute, record, return the real result.
176
+ * full mode: ALWAYS execute — the cache is never consulted (the full
177
+ * lane's integrity property stays absolute; see ground rules) —
178
+ * then record, so the next fast run benefits.
179
+ *
180
+ * Any cache-machinery error (hashing, read, write) degrades to plain
181
+ * execution.
182
+ *
183
+ * @param {object} args
184
+ * @param {boolean} args.fast whether this is a --fast run
185
+ * @param {string} args.root project root
186
+ * @param {string} args.stepName the lane step's name (the cache key)
187
+ * @param {string[]} args.inputs the step's declared input set (see ground rules)
188
+ * @param {() => object} args.run the real step function
189
+ * @returns {object} the step result — either `run()`'s verbatim, or a
190
+ * `{name, verdict: "CACHED", note, durationMs, details}` reuse marker
191
+ */
192
+ export function memoizeStep({ fast, root, stepName, inputs, run }) {
193
+ const started = Date.now();
194
+ let inputsHash = null;
195
+ try {
196
+ // Hashed BEFORE execution so the recorded entry binds the verdict to the
197
+ // tree the step actually saw, not to edits made while it ran.
198
+ inputsHash = computeStepInputsHash(root, inputs).hash;
199
+ } catch {
200
+ inputsHash = null;
201
+ }
202
+
203
+ if (fast && inputsHash) {
204
+ const hit = lookupCachedPass(root, stepName, inputsHash);
205
+ if (hit) {
206
+ return {
207
+ name: stepName,
208
+ verdict: "CACHED",
209
+ note: `unchanged since ${hit.at}`,
210
+ durationMs: Date.now() - started,
211
+ details: { inputsHash },
212
+ };
213
+ }
214
+ }
215
+
216
+ const result = run();
217
+ if (inputsHash && result && typeof result.verdict === "string") {
218
+ writeStepCacheEntry(root, stepName, { inputsHash, verdict: result.verdict });
219
+ }
220
+ return result;
221
+ }