create-cmp-cli 0.13.0 → 0.14.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/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 +1723 -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 +115 -1
- package/src/lib/harness-upgrade.mjs +193 -5
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +30 -0
- package/template/gitignore +8 -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 +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/verify.mjs +110 -2
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +0 -2
- package/template/.gradle/vcs-1/gc.properties +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// token-drift.mjs — pure, dependency-free comparison of a LIVE inspector tree's
|
|
2
|
+
// resolved design-token payloads against the declared design-system catalog.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors the comparison semantics of diffAgainstDesignSystem() in the cmp-inspector
|
|
5
|
+
// MCP server (inspector/mcp/src/lib/drift.mjs): for each node in the tree that carries
|
|
6
|
+
// a designToken payload ({tokens:[names], resolved:{facet:value}}), and for each
|
|
7
|
+
// declared token name on that node, resolve the expected value from the catalog
|
|
8
|
+
// (dimens checked before colors — same lookup order as the MCP) and compare it
|
|
9
|
+
// against every one of the node's resolved facet values. If NONE of them match the
|
|
10
|
+
// declared value, the node has drifted from what it claims to use.
|
|
11
|
+
//
|
|
12
|
+
// Matching is case/whitespace-normalized string equality (trim + lowercase) — the
|
|
13
|
+
// exact normalization the MCP's `normalize()` applies (so "#0A2540" == "#0a2540",
|
|
14
|
+
// but "72dp" would NOT equal "72.0dp" — the MCP does not do numeric-format
|
|
15
|
+
// normalization, so neither do we, to keep the comparison rule identical).
|
|
16
|
+
//
|
|
17
|
+
// No imports, no Node built-ins beyond what the runtime provides for free — pure
|
|
18
|
+
// object-in/object-out, unit-testable with plain objects.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{colors?:Record<string,string>, dimens?:Record<string,string>}} declaredCatalog
|
|
22
|
+
* @param {{root:object}|object} tree the parsed /inspect/tree document (or a bare node)
|
|
23
|
+
* @returns {{checked:number, drifted:Array<{node:string, token:string, facet:string, expected:string, actual:string}>}}
|
|
24
|
+
*/
|
|
25
|
+
export function compareTokenDrift(declaredCatalog, tree) {
|
|
26
|
+
const colors = (declaredCatalog && declaredCatalog.colors) || {};
|
|
27
|
+
const dimens = (declaredCatalog && declaredCatalog.dimens) || {};
|
|
28
|
+
|
|
29
|
+
let checked = 0;
|
|
30
|
+
const drifted = [];
|
|
31
|
+
|
|
32
|
+
for (const { node, path } of walk(tree)) {
|
|
33
|
+
const dt = node && node.designToken;
|
|
34
|
+
if (!dt || !Array.isArray(dt.tokens) || dt.tokens.length === 0) continue;
|
|
35
|
+
|
|
36
|
+
const resolved = dt.resolved && typeof dt.resolved === "object" ? dt.resolved : {};
|
|
37
|
+
const resolvedEntries = Object.entries(resolved);
|
|
38
|
+
|
|
39
|
+
for (const token of dt.tokens) {
|
|
40
|
+
let declared;
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(dimens, token)) declared = dimens[token];
|
|
42
|
+
else if (Object.prototype.hasOwnProperty.call(colors, token)) declared = colors[token];
|
|
43
|
+
else continue; // token not in the declared catalog — nothing to diff against
|
|
44
|
+
|
|
45
|
+
checked += 1;
|
|
46
|
+
|
|
47
|
+
const declaredNorm = normalize(declared);
|
|
48
|
+
const matches = resolvedEntries.some(([, v]) => normalize(v) === declaredNorm);
|
|
49
|
+
if (matches) continue;
|
|
50
|
+
|
|
51
|
+
const [facet, actual] = pickFacetForReport(resolvedEntries);
|
|
52
|
+
drifted.push({
|
|
53
|
+
node: node.testTag || path,
|
|
54
|
+
token,
|
|
55
|
+
facet,
|
|
56
|
+
expected: declared,
|
|
57
|
+
actual,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { checked, drifted };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Depth-first walk yielding every node with a stable, dotted path. Accepts either a
|
|
66
|
+
// full tree ({schemaVersion, source, root}) or a bare node — same contract as the
|
|
67
|
+
// MCP's tree.mjs walk().
|
|
68
|
+
function* walk(tree) {
|
|
69
|
+
const root = tree && tree.root ? tree.root : tree;
|
|
70
|
+
if (!root || typeof root !== "object") return;
|
|
71
|
+
yield* walkNode(root, "root");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function* walkNode(node, path) {
|
|
75
|
+
yield { node, path };
|
|
76
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
77
|
+
for (let i = 0; i < children.length; i++) {
|
|
78
|
+
yield* walkNode(children[i], `${path}.children[${i}]`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Case-insensitive, trimmed comparison — identical to the MCP's normalize().
|
|
83
|
+
function normalize(v) {
|
|
84
|
+
return String(v == null ? "" : v).trim().toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Best-effort single (facet, value) to blame in the drift report. If the node
|
|
88
|
+
// resolved exactly one facet, name it directly; otherwise join every facet/value
|
|
89
|
+
// so the reader sees everything the node actually resolved.
|
|
90
|
+
function pickFacetForReport(entries) {
|
|
91
|
+
if (entries.length === 1) return [entries[0][0], String(entries[0][1])];
|
|
92
|
+
if (entries.length === 0) return ["(none)", "(no resolved values)"];
|
|
93
|
+
return [entries.map(([k]) => k).join(","), entries.map(([, v]) => String(v)).join(", ")];
|
|
94
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// tree.mjs — pure helpers for loading and walking a CMP inspector tree.
|
|
2
|
+
// No MCP imports here: everything is unit-testable in isolation.
|
|
3
|
+
//
|
|
4
|
+
// The JSON tree contract (schemaVersion 1):
|
|
5
|
+
// { schemaVersion, source, root: <Node> }
|
|
6
|
+
// Node = { testTag, text, contentDescription, bounds:{x,y,width,height},
|
|
7
|
+
// designToken: { tokens:string[], resolved:{[k]:string} } | null,
|
|
8
|
+
// children: Node[] }
|
|
9
|
+
//
|
|
10
|
+
// Additive optional fields (still schemaVersion 1 — absent on old trees, so every
|
|
11
|
+
// consumer must treat them as optional):
|
|
12
|
+
// role: string|null — semantics Role (e.g. "Button", "Checkbox")
|
|
13
|
+
// clickable: boolean — presence of the OnClick semantics action
|
|
14
|
+
// disabled: boolean — presence of the Disabled semantics property
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load a tree from a filesystem path, a JSON string, or an already-parsed object.
|
|
20
|
+
* Validates the minimal shape (schemaVersion + root) and throws a clear,
|
|
21
|
+
* caller-facing Error (never a raw fs/JSON stack) on failure.
|
|
22
|
+
*
|
|
23
|
+
* @param {string|object} pathOrObj
|
|
24
|
+
* @returns {object} the parsed tree ({ schemaVersion, source, root })
|
|
25
|
+
*/
|
|
26
|
+
export function loadTree(pathOrObj) {
|
|
27
|
+
if (pathOrObj == null) {
|
|
28
|
+
throw new Error("loadTree: no tree provided (path or object is null/undefined).");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let tree;
|
|
32
|
+
if (typeof pathOrObj === "object") {
|
|
33
|
+
tree = pathOrObj;
|
|
34
|
+
} else if (typeof pathOrObj === "string") {
|
|
35
|
+
const raw = readOrParse(pathOrObj);
|
|
36
|
+
tree = raw;
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error(`loadTree: unsupported input type '${typeof pathOrObj}'.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!tree || typeof tree !== "object") {
|
|
42
|
+
throw new Error("loadTree: tree is not an object.");
|
|
43
|
+
}
|
|
44
|
+
if (!tree.root || typeof tree.root !== "object") {
|
|
45
|
+
throw new Error("loadTree: tree has no 'root' node (expected { schemaVersion, source, root }).");
|
|
46
|
+
}
|
|
47
|
+
return tree;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// If the string looks like a JSON document, parse it directly; otherwise treat
|
|
51
|
+
// it as a filesystem path and read+parse. This lets callers pass either.
|
|
52
|
+
function readOrParse(str) {
|
|
53
|
+
const trimmed = str.trim();
|
|
54
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(trimmed);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(`loadTree: input looked like JSON but failed to parse: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let contents;
|
|
62
|
+
try {
|
|
63
|
+
contents = readFileSync(str, "utf8");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code === "ENOENT") {
|
|
66
|
+
throw new Error(`loadTree: tree file not found: ${str}`);
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`loadTree: could not read tree file '${str}': ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(contents);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new Error(`loadTree: tree file '${str}' is not valid JSON: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Depth-first walk yielding every node with a stable, dotted path.
|
|
79
|
+
* Root's path is "root"; children are "root.children[0]", etc.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} tree a full tree ({root}) OR a bare node.
|
|
82
|
+
* @yields {{ node: object, path: string }}
|
|
83
|
+
*/
|
|
84
|
+
export function* walk(tree) {
|
|
85
|
+
const root = tree && tree.root ? tree.root : tree;
|
|
86
|
+
if (!root || typeof root !== "object") return;
|
|
87
|
+
yield* walkNode(root, "root");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function* walkNode(node, path) {
|
|
91
|
+
yield { node, path };
|
|
92
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
93
|
+
for (let i = 0; i < children.length; i++) {
|
|
94
|
+
yield* walkNode(children[i], `${path}.children[${i}]`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Find the first node with the given testTag. Returns { node, path } or null.
|
|
100
|
+
* @param {object} tree
|
|
101
|
+
* @param {string} tag
|
|
102
|
+
*/
|
|
103
|
+
export function findByTestTag(tree, tag) {
|
|
104
|
+
for (const entry of walk(tree)) {
|
|
105
|
+
if (entry.node.testTag === tag) return entry;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// preview-gallery.mjs — build a self-contained HTML gallery from renderScreens output.
|
|
3
|
+
//
|
|
4
|
+
// node qa/preview-gallery.mjs [previewsDir]
|
|
5
|
+
//
|
|
6
|
+
// Reads <previewsDir>/manifest.json (default composeApp/build/previews — the output of
|
|
7
|
+
// `./gradlew :composeApp:renderScreens`), renders each screen's tree.json to a wireframe
|
|
8
|
+
// SVG with the vendored inspector render lib (structure for the AI), and embeds the
|
|
9
|
+
// harness PNGs (pixels for the human) into ONE index.html — no server, no device, open
|
|
10
|
+
// the file. Also drops <id>/wireframe.svg next to each tree.
|
|
11
|
+
//
|
|
12
|
+
// Zero dependencies beyond qa/lib (vendored, pure logic) — works without the create-cmp
|
|
13
|
+
// plugin installed, like every other qa/ script.
|
|
14
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join, resolve, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const previewsDir = resolve(
|
|
20
|
+
process.argv[2] || join(HERE, "..", "composeApp", "build", "previews"),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const { renderTreeSvg } = await import(new URL("./lib/render.mjs", import.meta.url));
|
|
24
|
+
const { auditA11y } = await import(new URL("./lib/a11y.mjs", import.meta.url));
|
|
25
|
+
|
|
26
|
+
// The app's display name is read at RUNTIME from create-cmp.json, never stamped in.
|
|
27
|
+
// Every .mjs under qa/ is machine-owned harness code copied byte-identical from the
|
|
28
|
+
// engine — token substitution must not touch it (see qa/lib/harness-region.mjs), so
|
|
29
|
+
// anything app-specific is looked up from the record that already holds that truth.
|
|
30
|
+
function appName() {
|
|
31
|
+
try {
|
|
32
|
+
const rec = JSON.parse(readFileSync(join(HERE, "..", "create-cmp.json"), "utf8"));
|
|
33
|
+
if (typeof rec.name === "string" && rec.name.trim()) return rec.name;
|
|
34
|
+
} catch {
|
|
35
|
+
// Not stamped, or an unreadable record — the gallery is a report, not a gate.
|
|
36
|
+
}
|
|
37
|
+
return "App";
|
|
38
|
+
}
|
|
39
|
+
const APP_NAME = appName();
|
|
40
|
+
|
|
41
|
+
const manifest = JSON.parse(readFileSync(join(previewsDir, "manifest.json"), "utf8"));
|
|
42
|
+
const { width, height, pngScale } = manifest.viewport;
|
|
43
|
+
|
|
44
|
+
const cards = [];
|
|
45
|
+
for (const screen of manifest.screens) {
|
|
46
|
+
const tree = JSON.parse(readFileSync(join(previewsDir, screen.tree), "utf8"));
|
|
47
|
+
const audit = auditA11y(tree);
|
|
48
|
+
const svg = renderTreeSvg(tree, { a11y: audit });
|
|
49
|
+
writeFileSync(join(previewsDir, screen.id, "wireframe.svg"), svg);
|
|
50
|
+
|
|
51
|
+
const png = readFileSync(join(previewsDir, screen.png));
|
|
52
|
+
const summary = summarize(tree);
|
|
53
|
+
cards.push({ screen, svg, pngB64: png.toString("base64"), audit, summary });
|
|
54
|
+
console.log(
|
|
55
|
+
`${screen.id}: ${summary.nodes} nodes, ${summary.tokenized} tokenized, ` +
|
|
56
|
+
`${summary.tagged} tagged, a11y ${audit.pass ? "PASS" : audit.violations.length + " violation(s)"}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function summarize(tree) {
|
|
61
|
+
let nodes = 0, tokenized = 0, tagged = 0;
|
|
62
|
+
(function walk(n) {
|
|
63
|
+
nodes++;
|
|
64
|
+
if (n.designToken) tokenized++;
|
|
65
|
+
if (n.testTag) tagged++;
|
|
66
|
+
(n.children || []).forEach(walk);
|
|
67
|
+
})(tree.root);
|
|
68
|
+
return { nodes, tokenized, tagged };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
72
|
+
|
|
73
|
+
const html = `<!doctype html>
|
|
74
|
+
<meta charset="utf-8">
|
|
75
|
+
<title>${esc(APP_NAME)} — screen previews (headless)</title>
|
|
76
|
+
<style>
|
|
77
|
+
:root { color-scheme: light; }
|
|
78
|
+
body { font-family: -apple-system, system-ui, sans-serif; margin: 0; background: #F7F9FC; color: #1A1A1A; }
|
|
79
|
+
header { padding: 20px 28px 8px; }
|
|
80
|
+
header h1 { margin: 0 0 4px; font-size: 20px; }
|
|
81
|
+
header p { margin: 0; color: #6B7280; font-size: 13px; }
|
|
82
|
+
.grid { display: flex; flex-wrap: wrap; gap: 24px; padding: 20px 28px 40px; }
|
|
83
|
+
.card { background: #fff; border: 1px solid #E5E7EB; border-radius: 16px; padding: 16px; }
|
|
84
|
+
.card h2 { margin: 0 0 2px; font-size: 15px; }
|
|
85
|
+
.meta { color: #6B7280; font-size: 12px; margin: 0 0 10px; }
|
|
86
|
+
.meta .fail { color: #DC2626; font-weight: 600; }
|
|
87
|
+
.meta .pass { color: #16A34A; font-weight: 600; }
|
|
88
|
+
.panes { display: flex; gap: 12px; align-items: flex-start; }
|
|
89
|
+
.panes img { width: ${Math.round(width * 0.62)}px; border: 1px solid #E5E7EB; border-radius: 12px; display: block; }
|
|
90
|
+
.panes .wire svg { width: ${Math.round(width * 0.78)}px; height: auto; display: block; }
|
|
91
|
+
.wire { border: 1px dashed #C8D0DA; border-radius: 12px; overflow: hidden; }
|
|
92
|
+
.lbl { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: #9CA3AF; margin: 0 0 4px; }
|
|
93
|
+
</style>
|
|
94
|
+
<header>
|
|
95
|
+
<h1>${esc(APP_NAME)} — screen previews</h1>
|
|
96
|
+
<p>Rendered headlessly (no device/emulator) by <code>:composeApp:renderScreens</code> —
|
|
97
|
+
${width}×${height}dp, PNG @${pngScale}x · pixels for humans, wireframe+tree for the AI ·
|
|
98
|
+
regenerate: <code>./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs</code></p>
|
|
99
|
+
</header>
|
|
100
|
+
<div class="grid">
|
|
101
|
+
${cards
|
|
102
|
+
.map(
|
|
103
|
+
({ screen, svg, pngB64, audit, summary }) => ` <div class="card">
|
|
104
|
+
<h2>${esc(screen.title)}</h2>
|
|
105
|
+
<p class="meta">id <code>${esc(screen.id)}</code> · ${summary.nodes} nodes ·
|
|
106
|
+
${summary.tokenized} tokenized · ${summary.tagged} tagged ·
|
|
107
|
+
a11y <span class="${audit.pass ? "pass" : "fail"}">${
|
|
108
|
+
audit.pass ? "PASS" : esc(audit.violations.length + " violation(s)")
|
|
109
|
+
}</span></p>
|
|
110
|
+
<div class="panes">
|
|
111
|
+
<div><p class="lbl">pixels</p><img alt="${esc(screen.id)} pixels" src="data:image/png;base64,${pngB64}"></div>
|
|
112
|
+
<div><p class="lbl">structure</p><div class="wire">${svg}</div></div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>`,
|
|
115
|
+
)
|
|
116
|
+
.join("\n")}
|
|
117
|
+
</div>
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
const outFile = join(previewsDir, "index.html");
|
|
121
|
+
writeFileSync(outFile, html);
|
|
122
|
+
console.log(`gallery -> ${outFile}`);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The evidence-binding predicate — answers one question: does the committed
|
|
3
|
+
// receipt (qa/evidence/latest.json) validly attest THIS tree, right now?
|
|
4
|
+
//
|
|
5
|
+
// node qa/receipt-check.mjs [--hook] [--json]
|
|
6
|
+
//
|
|
7
|
+
// Both enforcement points reduce to this predicate: the local Stop hook
|
|
8
|
+
// (.claude/settings.json) calls it on every turn-end, and CI calls it before
|
|
9
|
+
// re-running the lane. See docs/adr/0005-evidence-binding-by-inputs-hash.md.
|
|
10
|
+
//
|
|
11
|
+
// VALID iff receipt.verdict === "PASS" && receipt.inputs.hash === recompute(tree)
|
|
12
|
+
// Exit codes (normal mode): VALID -> 0, INVALID -> 1.
|
|
13
|
+
// Exit codes (--hook mode, Claude Code Stop-hook protocol):
|
|
14
|
+
// stop_hook_active === true -> 0 (never block twice in a row)
|
|
15
|
+
// INVALID -> 2, reason on stderr (Claude Code's block-and-feed-back signal)
|
|
16
|
+
// VALID -> 0, silent
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
23
|
+
import { evaluateReceipt, readReceipt } from "./lib/receipt-validate.mjs";
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const asHook = args.includes("--hook");
|
|
29
|
+
const asJson = args.includes("--json");
|
|
30
|
+
|
|
31
|
+
function readStdinJson() {
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(0, "utf8");
|
|
34
|
+
if (!raw.trim()) return {};
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The predicate itself lives in qa/lib/receipt-validate.mjs (vendored from the
|
|
42
|
+
// cmp-receipts package — one definition everywhere a receipt is judged); this
|
|
43
|
+
// CLI only reads the receipt and frames the exit codes.
|
|
44
|
+
function evaluate() {
|
|
45
|
+
const receipt = readReceipt(ROOT);
|
|
46
|
+
if (receipt === null) {
|
|
47
|
+
return { valid: false, reason: "no receipt — run `node qa/verify.mjs`", profile: undefined };
|
|
48
|
+
}
|
|
49
|
+
// A fast-mode receipt (verify --fast) is an inner-loop signal, never done
|
|
50
|
+
// evidence — refused here before the hash is even recomputed, so a session
|
|
51
|
+
// can never end on "done" while its evidence trail's last run was --fast.
|
|
52
|
+
if (receipt.mode === "fast") {
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
reason: "the last verify run was --fast (inner-loop only); run the full lane (`node qa/verify.mjs`) before finishing",
|
|
56
|
+
profile: receipt.profile,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const result = evaluateReceipt(receipt, () => computeInputsHash(ROOT));
|
|
60
|
+
// Surface the receipt's evidence rung (the ladder — qa/lib/evidence-level.mjs)
|
|
61
|
+
// alongside the verdict: the rung is the receipt's own derived field, read
|
|
62
|
+
// verbatim, never recomputed here. Older receipts without it stay valid.
|
|
63
|
+
const level = receipt.evidenceLevel;
|
|
64
|
+
if (level && typeof level === "object" && typeof level.rung === "string") {
|
|
65
|
+
result.evidenceLevel = level;
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = evaluate();
|
|
71
|
+
|
|
72
|
+
if (asHook) {
|
|
73
|
+
const hookInput = readStdinJson();
|
|
74
|
+
if (hookInput.stop_hook_active === true) {
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
if (!result.valid) {
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
`Not done: ${result.reason}. Run \`node qa/verify.mjs\` and commit the receipt, or see README §Verification enforcement to bypass.\n`,
|
|
80
|
+
);
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rungSuffix = result.evidenceLevel ? ` — evidence ${result.evidenceLevel.rung} · ${result.evidenceLevel.name}` : "";
|
|
87
|
+
|
|
88
|
+
if (asJson) {
|
|
89
|
+
console.log(JSON.stringify(result, null, 2));
|
|
90
|
+
} else if (result.valid) {
|
|
91
|
+
console.log(`VALID — ${result.reason}${rungSuffix}`);
|
|
92
|
+
} else {
|
|
93
|
+
console.error(`INVALID — ${result.reason}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
process.exit(result.valid ? 0 : 1);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Record that a cmp-audit of one androidMain subsystem happened — the
|
|
3
|
+
// write half of the audit-cadence report (qa/lib/audit-cadence.mjs).
|
|
4
|
+
//
|
|
5
|
+
// node qa/record-audit.mjs <subsystem> [--by <who-or-what>]
|
|
6
|
+
// node qa/record-audit.mjs --list
|
|
7
|
+
//
|
|
8
|
+
// Recording is a CLAIM — "this subsystem, as of this commit, was audited" —
|
|
9
|
+
// so the entry's sha is derived from HEAD by the library, never passed in,
|
|
10
|
+
// and the write is REFUSED when there is no git history, when the subsystem
|
|
11
|
+
// is not one this app actually has (derived from the tree, printed on
|
|
12
|
+
// refusal), or when the subsystem's files differ from HEAD (the record
|
|
13
|
+
// would name a commit the audited bytes did not match — commit first).
|
|
14
|
+
// Refusal over fabrication, the same stance as qa/approve.mjs.
|
|
15
|
+
//
|
|
16
|
+
// This CLI exists so the audit loop closes mechanically: the release
|
|
17
|
+
// profile's receipt nudges "changed since last audit → cmp-audit <name>",
|
|
18
|
+
// and the auditor's last act is this one command.
|
|
19
|
+
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { AUDITS_REL_PATH, ROOT_SUBSYSTEM, androidMainPackageRoot, evaluateAuditCadence, listSubsystems, recordAudit } from "./lib/audit-cadence.mjs";
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
+
|
|
27
|
+
const USAGE = `node qa/record-audit.mjs <subsystem> [--by <who-or-what>]
|
|
28
|
+
|
|
29
|
+
Appends one audit record (subsystem, HEAD sha, ISO timestamp, recorder) to
|
|
30
|
+
${AUDITS_REL_PATH}. The verify lane's release profile reports which
|
|
31
|
+
subsystems changed since their last record. Subsystems are derived from the
|
|
32
|
+
tree: the immediate package directories under the androidMain Kotlin source
|
|
33
|
+
root ("${ROOT_SUBSYSTEM}" for files directly at the package root).
|
|
34
|
+
|
|
35
|
+
--list print the derived subsystems and their audit status
|
|
36
|
+
--by <name> who/what recorded this (default: git user.name)
|
|
37
|
+
--help, -h this usage
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const args = process.argv.slice(2);
|
|
41
|
+
|
|
42
|
+
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
43
|
+
console.log(USAGE);
|
|
44
|
+
process.exit(args.length === 0 ? 2 : 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (args.includes("--list")) {
|
|
48
|
+
const report = evaluateAuditCadence(ROOT);
|
|
49
|
+
if (!report.ok) {
|
|
50
|
+
console.log(report.reason);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
console.log(`androidMain subsystems under ${report.packageRoot} (${report.summary}):`);
|
|
54
|
+
for (const s of report.subsystems) {
|
|
55
|
+
const when = s.audit?.at ? ` — last audit ${s.audit.at.slice(0, 10)} (${s.audit.sha.slice(0, 7)}, by ${s.audit.by ?? "unknown"})` : "";
|
|
56
|
+
console.log(` ${s.name}: ${s.status}${when}`);
|
|
57
|
+
}
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const byIdx = args.indexOf("--by");
|
|
62
|
+
const by = byIdx >= 0 ? args[byIdx + 1] : undefined;
|
|
63
|
+
if (byIdx >= 0 && !by) {
|
|
64
|
+
console.error("--by needs a value");
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
const positional = args.filter((a, i) => !(byIdx >= 0 && (i === byIdx || i === byIdx + 1)));
|
|
68
|
+
if (positional.length !== 1 || positional[0].startsWith("--")) {
|
|
69
|
+
console.error(`expected exactly one subsystem name — run node qa/record-audit.mjs --help`);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const res = recordAudit(ROOT, { subsystem: positional[0], by });
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
console.error(`refused: ${res.reason}`);
|
|
76
|
+
const pkgRoot = androidMainPackageRoot(ROOT);
|
|
77
|
+
if (pkgRoot.ok) {
|
|
78
|
+
console.error(`derived subsystems: ${listSubsystems(ROOT, pkgRoot.rel).join(", ")}`);
|
|
79
|
+
}
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
console.log(`recorded: audit of ${res.entry.subsystem} against ${res.sha.slice(0, 7)} (by ${res.entry.by}) → ${AUDITS_REL_PATH}`);
|
|
83
|
+
console.log("commit the ledger with your change — the release profile's receipt reads it.");
|