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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI
|
|
3
|
+
"version": "0.14.1",
|
|
4
|
+
"description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI \u2014 the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-cmp": "bin/create-cmp.mjs",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"bin",
|
|
15
15
|
"src",
|
|
16
16
|
"template",
|
|
17
|
+
"packages/harness/package.json",
|
|
18
|
+
"packages/harness/src",
|
|
19
|
+
"packages/receipts/package.json",
|
|
20
|
+
"packages/receipts/src",
|
|
17
21
|
"options.schema.json",
|
|
18
22
|
"llms.txt"
|
|
19
23
|
],
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-cmp-harness",
|
|
3
|
+
"version": "0.14.1",
|
|
4
|
+
"description": "The create-cmp verify lane \u2014 the machine-owned harness code every stamped app carries byte-identical: evidence receipts, spec coverage, approvals, conformance reporting, golden trees, a11y, and the preview/inspector libs. Dependency-free ESM, vendored into each generated project so the lane runs offline with no install step, and content-hashed so a receipt can name the exact lane that issued it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/verify.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/verify.mjs",
|
|
9
|
+
"./harness-region": "./src/lib/harness-region.mjs",
|
|
10
|
+
"./lib/*": "./src/lib/*.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
|
+
"verify",
|
|
25
|
+
"evidence",
|
|
26
|
+
"receipt",
|
|
27
|
+
"harness",
|
|
28
|
+
"compose-multiplatform",
|
|
29
|
+
"kotlin-multiplatform"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"author": "Karel van der Merwe",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/kvdm-co-pilot/create-cmp.git",
|
|
36
|
+
"directory": "packages/harness"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The approvals CLI — thin shell over qa/lib/approvals.mjs.
|
|
3
|
+
//
|
|
4
|
+
// node qa/approve.mjs <artifact> records approval (recomputes the artifact's
|
|
5
|
+
// hash now, stamps the time, writes qa/approvals.json)
|
|
6
|
+
// node qa/approve.mjs --status lists every governed artifact + live state
|
|
7
|
+
// (unreviewed / approved / changed-since-approval /
|
|
8
|
+
// reopened, + mode when set) + short hash
|
|
9
|
+
// node qa/approve.mjs --accept-defaults express lane (GENESIS-FLOW-DESIGN.md §2): approves
|
|
10
|
+
// every currently-resolvable, not-yet-approved
|
|
11
|
+
// artifact, each stamped "defaults-accepted"
|
|
12
|
+
// node qa/approve.mjs --reopen <artifact> --reason "…"
|
|
13
|
+
// moves an approved artifact back to "reopened" for
|
|
14
|
+
// redesign (refuses anything not currently approved).
|
|
15
|
+
// --reason is REQUIRED — a reopen walks back a
|
|
16
|
+
// signature, and the signer must be able to read why
|
|
17
|
+
// from the ledger itself (2026-07-28 flow audit)
|
|
18
|
+
// node qa/approve.mjs --reopen-feature <name> --reason "…"
|
|
19
|
+
// one recorded change, not N reopen commands: reopens
|
|
20
|
+
// the brief + its spec + its design + every artifact
|
|
21
|
+
// the brief declares in `touches` (each only if
|
|
22
|
+
// currently approved), all under one reason
|
|
23
|
+
// node qa/approve.mjs --log the journal — every approve/reopen/accept with
|
|
24
|
+
// when, which surface, and why (newest last)
|
|
25
|
+
// node qa/approve.mjs --accept <name> the HUMAN's bookend on a feature brief
|
|
26
|
+
// (feature-brief:<name>) — refused until the feature
|
|
27
|
+
// is provenDone (every live clause cited + receipt
|
|
28
|
+
// PASS + receipt attests this tree). There is no
|
|
29
|
+
// --deliver: doneness is DERIVED, never claimed
|
|
30
|
+
// (CHANGE-FLOW-DESIGN.md §2).
|
|
31
|
+
//
|
|
32
|
+
// This file has NO logic of its own — every decision (the registry, hashing,
|
|
33
|
+
// state, the transitions) lives in qa/lib/approvals.mjs. That's deliberate: the
|
|
34
|
+
// console (VERIFICATION-LAYER-DESIGN.md §4, `POST /api/approve`; GENESIS-FLOW-DESIGN.md
|
|
35
|
+
// §2, `POST /api/reopen`) calls the SAME library this CLI calls, so this file is the
|
|
36
|
+
// API surface, kept intentionally thin and easy to keep in lockstep.
|
|
37
|
+
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
acceptFeature,
|
|
43
|
+
approveAllDefaults,
|
|
44
|
+
approveArtifact,
|
|
45
|
+
getApprovalStatuses,
|
|
46
|
+
getFeatureBoard,
|
|
47
|
+
isPackageResolvable,
|
|
48
|
+
listGovernedArtifacts,
|
|
49
|
+
readJournal,
|
|
50
|
+
reopenArtifact,
|
|
51
|
+
reopenFeature,
|
|
52
|
+
} from "./lib/approvals.mjs";
|
|
53
|
+
|
|
54
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
55
|
+
const args = process.argv.slice(2);
|
|
56
|
+
|
|
57
|
+
function shortHash(hash) {
|
|
58
|
+
return hash ? hash.slice(0, 8) : "none";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printStatus() {
|
|
62
|
+
const statuses = getApprovalStatuses(ROOT);
|
|
63
|
+
if (statuses.length === 0) {
|
|
64
|
+
console.log("No governed artifacts resolved in this project (no specs/, or the package could not be resolved).");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
console.log("Approval status:\n");
|
|
68
|
+
for (const s of statuses) {
|
|
69
|
+
const mark =
|
|
70
|
+
s.status === "approved" ? "✓" : s.status === "changed-since-approval" ? "✗" : s.status === "reopened" ? "↺" : "→";
|
|
71
|
+
// An unresolvable artifact (0 files, or a partial kotlin set in a
|
|
72
|
+
// pre-stamp tree) must never display its degraded hash as if it were
|
|
73
|
+
// approvable — approval over an unresolved file set is refused.
|
|
74
|
+
const hashInfo =
|
|
75
|
+
s.status === "reopened"
|
|
76
|
+
? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})${s.reason ? ` — ${s.reason}` : ""}`
|
|
77
|
+
: s.status === "changed-since-approval"
|
|
78
|
+
? s.resolvable
|
|
79
|
+
? `approved ${shortHash(s.storedHash)} -> now ${shortHash(s.hash)}`
|
|
80
|
+
: `approved ${shortHash(s.storedHash)} -> unresolvable (${s.fileCount} of expected files resolved)`
|
|
81
|
+
: s.status === "approved"
|
|
82
|
+
? // storedHash is the signature; s.hash is the live recompute. They
|
|
83
|
+
// agree except on a legacy raw-bytes row, where printing the live
|
|
84
|
+
// value would name a hash nobody signed.
|
|
85
|
+
`${shortHash(s.storedHash) || shortHash(s.hash)}${
|
|
86
|
+
s.hashBasis === "raw-bytes" ? ", signed pre-strip — bytes unchanged since" : ""
|
|
87
|
+
}`
|
|
88
|
+
: s.resolvable
|
|
89
|
+
? `would approve at ${shortHash(s.hash)}`
|
|
90
|
+
: `unresolvable (${s.fileCount} of expected files resolved) — not approvable`;
|
|
91
|
+
const modeInfo = s.mode ? ` [${s.mode}]` : "";
|
|
92
|
+
// A feature brief's acceptance lives on the ledger row, never in the doc
|
|
93
|
+
// (the signed bytes must not move when the human accepts).
|
|
94
|
+
const lifecycle = s.accepted ? ` · accepted ${s.acceptedAt}` : "";
|
|
95
|
+
console.log(`${mark} ${s.id}: ${s.status} (${hashInfo})${modeInfo}${lifecycle} — ${s.label}`);
|
|
96
|
+
if (s.missing.length > 0) {
|
|
97
|
+
console.log(` missing: ${s.missing.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Per-feature DERIVED doneness + next step — the same getFeatureBoard the
|
|
102
|
+
// console renders, so --status and the console never tell different
|
|
103
|
+
// stories. The next step names its owner: a signature HANDS OFF, it never
|
|
104
|
+
// commands — the agent drafts/builds/proves, the human signs/accepts.
|
|
105
|
+
const { features } = getFeatureBoard(ROOT);
|
|
106
|
+
if (features.length > 0) {
|
|
107
|
+
console.log("\nFeatures (doneness is derived, never claimed):");
|
|
108
|
+
for (const f of features) {
|
|
109
|
+
const mark = f.provenDone ? "✓" : "…";
|
|
110
|
+
console.log(` ${mark} ${f.name}: ${f.doneReason}${f.blockError ? ` — BLOCK ERROR: ${f.blockError}` : ""}`);
|
|
111
|
+
console.log(` next → ${f.nextStep.label}${f.nextStep.owner ? ` · ${f.nextStep.owner}` : ""}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (args.includes("--status")) {
|
|
117
|
+
printStatus();
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Write guard: refuse to RECORD approvals in a tree whose package is not
|
|
122
|
+
// resolvable (the raw template / a pre-stamp tree). Approvals belong to a
|
|
123
|
+
// generated project; writing qa/approvals.json into the template pollutes the
|
|
124
|
+
// template itself. Read-only --status (above) stays available anywhere. Applies
|
|
125
|
+
// to every write operation below (single approve, express lane, reopen).
|
|
126
|
+
function refuseIfUnresolvable() {
|
|
127
|
+
if (isPackageResolvable(ROOT)) return;
|
|
128
|
+
console.error(
|
|
129
|
+
"error: this tree's package is not resolvable (composeApp/build.gradle.kts namespace is missing or still a placeholder) — " +
|
|
130
|
+
"this looks like the raw template or a pre-stamp tree. Approvals are recorded in a generated project; refusing to write qa/approvals.json here.",
|
|
131
|
+
);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (args.includes("--accept-defaults")) {
|
|
136
|
+
refuseIfUnresolvable();
|
|
137
|
+
const { approved, skipped } = approveAllDefaults(ROOT);
|
|
138
|
+
for (const id of approved) {
|
|
139
|
+
console.log(`✓ approved ${id} [defaults-accepted]`);
|
|
140
|
+
}
|
|
141
|
+
for (const s of skipped) {
|
|
142
|
+
console.log(`→ skipped ${s.id}: ${s.reason}`);
|
|
143
|
+
}
|
|
144
|
+
console.log(`\n${approved.length} approved (defaults-accepted), ${skipped.length} skipped (unresolvable).`);
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --reason is shared by --reopen and --reopen-feature: one plain sentence for
|
|
149
|
+
// the human who signed, recorded on the ledger row AND the journal. The library
|
|
150
|
+
// refuses without it; parsing it here just makes the usage line honest.
|
|
151
|
+
const reasonFlagIdx = args.indexOf("--reason");
|
|
152
|
+
const reason = reasonFlagIdx !== -1 ? args[reasonFlagIdx + 1] : undefined;
|
|
153
|
+
|
|
154
|
+
if (args.includes("--log")) {
|
|
155
|
+
const events = readJournal(ROOT);
|
|
156
|
+
if (events.length === 0) {
|
|
157
|
+
console.log("No journal yet (qa/approvals.log.jsonl) — it starts recording with the next approve/reopen/accept.");
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
console.log("Governance journal (oldest first):\n");
|
|
161
|
+
for (const e of events) {
|
|
162
|
+
const mark = e.verb === "approve" ? "✓" : e.verb === "reopen" ? "↺" : e.verb === "accept" ? "◆" : "·";
|
|
163
|
+
const bits = [e.via ? `via ${e.via}` : null, e.mode ? `[${e.mode}]` : null, e.feature ? `feature ${e.feature}` : null]
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.join(" · ");
|
|
166
|
+
console.log(`${mark} ${e.at} ${e.verb} ${e.artifact}${bits ? ` (${bits})` : ""}${e.reason ? ` — ${e.reason}` : ""}`);
|
|
167
|
+
}
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const reopenFlagIdx = args.indexOf("--reopen");
|
|
172
|
+
if (reopenFlagIdx !== -1) {
|
|
173
|
+
refuseIfUnresolvable();
|
|
174
|
+
const artifactId = args[reopenFlagIdx + 1];
|
|
175
|
+
if (!artifactId || artifactId === "--reason") {
|
|
176
|
+
console.error('usage: node qa/approve.mjs --reopen <artifact> --reason "why, in one sentence"');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const result = reopenArtifact(ROOT, artifactId, { reason, via: "cli" });
|
|
180
|
+
if (!result.ok) {
|
|
181
|
+
console.error(`error: ${result.reason}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
console.log(`↺ reopened ${result.artifact} for redesign — at ${result.reopenedAt}\n reason: ${reason.trim()}`);
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// One recorded change, not N reopen commands (2026-07-28 flow audit, fix 4):
|
|
189
|
+
// the brief is the container the human thinks in — reopen its whole declared
|
|
190
|
+
// set under one reason, each journal event grouped by the feature's name.
|
|
191
|
+
const reopenFeatureFlagIdx = args.indexOf("--reopen-feature");
|
|
192
|
+
if (reopenFeatureFlagIdx !== -1) {
|
|
193
|
+
refuseIfUnresolvable();
|
|
194
|
+
const name = args[reopenFeatureFlagIdx + 1];
|
|
195
|
+
if (!name || name === "--reason") {
|
|
196
|
+
console.error('usage: node qa/approve.mjs --reopen-feature <name> --reason "why, in one sentence"');
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const result = reopenFeature(ROOT, name, { reason, via: "cli" });
|
|
200
|
+
if (!result.ok) {
|
|
201
|
+
console.error(`error: ${result.reason}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
|
|
205
|
+
for (const id of result.reopened) console.log(` ↺ ${id}`);
|
|
206
|
+
for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The human's bookend: --accept takes the brief NAME, not the full artifact
|
|
211
|
+
// id — the docs/features/<name>.md filename is what a human knows. The library
|
|
212
|
+
// refuses until the feature is provenDone; there is no agent claim in between.
|
|
213
|
+
const acceptFlagIdx = args.indexOf("--accept");
|
|
214
|
+
if (acceptFlagIdx !== -1) {
|
|
215
|
+
refuseIfUnresolvable();
|
|
216
|
+
const name = args[acceptFlagIdx + 1];
|
|
217
|
+
if (!name) {
|
|
218
|
+
console.error("usage: node qa/approve.mjs --accept <name> (the brief's name — docs/features/<name>.md)");
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
const result = acceptFeature(ROOT, name, { via: "cli" });
|
|
222
|
+
if (!result.ok) {
|
|
223
|
+
console.error(`error: ${result.reason}`);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
console.log(`✓ accepted ${result.artifact} at ${result.acceptedAt} — the feature's card closes; the brief is its doc-of-record.`);
|
|
227
|
+
process.exit(0);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (args.length === 0) {
|
|
231
|
+
const ids = listGovernedArtifacts(ROOT).map((a) => a.id);
|
|
232
|
+
console.error(
|
|
233
|
+
'usage: node qa/approve.mjs <artifact> | --status | --log | --accept-defaults | --reopen <artifact> --reason "…" | --reopen-feature <name> --reason "…" | --accept <name>\n' +
|
|
234
|
+
` valid artifacts: ${ids.length > 0 ? ids.join(", ") : "(none resolved in this project)"}`,
|
|
235
|
+
);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
refuseIfUnresolvable();
|
|
240
|
+
|
|
241
|
+
const artifactId = args[0];
|
|
242
|
+
const result = approveArtifact(ROOT, artifactId, { via: "cli" });
|
|
243
|
+
if (!result.ok) {
|
|
244
|
+
console.error(`error: ${result.reason}`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
console.log(`✓ approved ${result.artifact} — hash ${shortHash(result.hash)}, at ${result.approvedAt}`);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The architecture-doc generator/checker — thin shell over qa/lib/arch-doc.mjs,
|
|
3
|
+
// mirroring qa/approve.mjs's CLI-over-library split.
|
|
4
|
+
//
|
|
5
|
+
// node qa/arch-doc.mjs regenerate docs/ARCHITECTURE.md's
|
|
6
|
+
// `cmp:generated` sections in place from a
|
|
7
|
+
// real tree walk — touches NOTHING outside
|
|
8
|
+
// the markers
|
|
9
|
+
// node qa/arch-doc.mjs --check exit nonzero (naming every stale/missing
|
|
10
|
+
// section) if regenerating would change the
|
|
11
|
+
// file; never writes
|
|
12
|
+
//
|
|
13
|
+
// This file has no logic of its own — every decision (what each section
|
|
14
|
+
// derives from, the marker grammar) lives in qa/lib/arch-doc.mjs. The verify
|
|
15
|
+
// lane's `archDoc` step (qa/verify.mjs) calls the SAME library in --check mode.
|
|
16
|
+
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { ARCH_DOC_REL_PATH, regenerateArchDoc, writeArchDoc } from "./lib/arch-doc.mjs";
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const checkOnly = process.argv.includes("--check");
|
|
24
|
+
|
|
25
|
+
function reportUnknown(result) {
|
|
26
|
+
if (result.unknownSections.length === 0) return false;
|
|
27
|
+
console.error(
|
|
28
|
+
`error: ${ARCH_DOC_REL_PATH} has cmp:generated marker(s) with no registered generator: ${result.unknownSections.join(", ")}`,
|
|
29
|
+
);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (checkOnly) {
|
|
34
|
+
const result = regenerateArchDoc(ROOT);
|
|
35
|
+
if (!result.ok) {
|
|
36
|
+
console.error(`error: ${result.reason}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (reportUnknown(result)) process.exit(1);
|
|
40
|
+
|
|
41
|
+
const stale = result.changed || result.missingSections.length > 0;
|
|
42
|
+
if (!stale) {
|
|
43
|
+
console.log(`✓ ${ARCH_DOC_REL_PATH} is fresh — every generated section matches the tree.`);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.error(`✗ ${ARCH_DOC_REL_PATH} is stale:`);
|
|
48
|
+
for (const id of result.changedSections) {
|
|
49
|
+
console.error(` [${id}] regenerating would change this section — the tree no longer matches the doc`);
|
|
50
|
+
}
|
|
51
|
+
for (const id of result.missingSections) {
|
|
52
|
+
console.error(` [${id}] marker missing from the doc entirely — never generated`);
|
|
53
|
+
}
|
|
54
|
+
console.error("Run: node qa/arch-doc.mjs");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = writeArchDoc(ROOT);
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
console.error(`error: ${result.reason}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (reportUnknown(result)) process.exit(1);
|
|
64
|
+
|
|
65
|
+
if (!result.wrote) {
|
|
66
|
+
console.log(`✓ ${ARCH_DOC_REL_PATH} already fresh — nothing to regenerate.`);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
console.log(`✓ regenerated ${ARCH_DOC_REL_PATH} — updated section(s): ${result.changedSections.join(", ")}`);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The comments CLI — thin shell over qa/lib/comments.mjs (mirrors qa/approve.mjs).
|
|
3
|
+
//
|
|
4
|
+
// node qa/comment.mjs --list [--open] readable table of the ledger,
|
|
5
|
+
// including resolution notes
|
|
6
|
+
// node qa/comment.mjs --resolve <id> --note "..." resolves a comment as "agent-cli",
|
|
7
|
+
// recording the note explaining
|
|
8
|
+
// what changed as a result
|
|
9
|
+
//
|
|
10
|
+
// This file has NO logic of its own — every decision (validation, ids, the ledger)
|
|
11
|
+
// lives in qa/lib/comments.mjs. Adding a comment is a console/human action (the
|
|
12
|
+
// console's `POST /api/comment` calls the same library through a bridge); this CLI
|
|
13
|
+
// covers the agent's side of the loop of record: observe, act, resolve.
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { listComments, resolveComment } from "./lib/comments.mjs";
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
|
|
23
|
+
function argValue(flag) {
|
|
24
|
+
const idx = args.indexOf(flag);
|
|
25
|
+
if (idx === -1 || idx === args.length - 1) return undefined;
|
|
26
|
+
return args[idx + 1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printList(openOnly) {
|
|
30
|
+
let result;
|
|
31
|
+
try {
|
|
32
|
+
result = listComments(ROOT, openOnly ? { status: "open" } : {});
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(`error: ${err.message}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
if (result.comments.length === 0) {
|
|
38
|
+
console.log(openOnly ? "No open comments." : "No comments recorded yet.");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log(`Comments${openOnly ? " (open only)" : ""}:\n`);
|
|
42
|
+
for (const c of result.comments) {
|
|
43
|
+
const mark = c.status === "resolved" ? "✓" : "→";
|
|
44
|
+
console.log(`${mark} ${c.id} [${c.target?.type ?? "unknown"}] ${c.status} — by ${c.author} at ${c.createdAt}`);
|
|
45
|
+
console.log(` ${c.text}`);
|
|
46
|
+
if (c.status === "resolved") {
|
|
47
|
+
const note = c.resolutionNote ? `: ${c.resolutionNote}` : " (no note)";
|
|
48
|
+
console.log(` resolved by ${c.resolvedBy} at ${c.resolvedAt}${note}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (args.includes("--list")) {
|
|
54
|
+
printList(args.includes("--open"));
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (args.includes("--resolve")) {
|
|
59
|
+
const id = argValue("--resolve");
|
|
60
|
+
if (!id) {
|
|
61
|
+
console.error('usage: node qa/comment.mjs --resolve <id> --note "..."');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const note = argValue("--note");
|
|
65
|
+
const result = resolveComment(ROOT, id, { note, author: "agent-cli" });
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
console.error(`error: ${result.reason}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const noteSuffix = result.comment.resolutionNote ? ` — ${result.comment.resolutionNote}` : "";
|
|
71
|
+
console.log(`✓ resolved ${result.comment.id}${noteSuffix}`);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.error('usage: node qa/comment.mjs --list [--open] | --resolve <id> --note "..."');
|
|
76
|
+
process.exit(1);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// a11y.mjs — accessibility audit over the tree contract.
|
|
2
|
+
// Pure logic only — no fs, no MCP imports; unit-testable.
|
|
3
|
+
//
|
|
4
|
+
// Rules (violations):
|
|
5
|
+
// touch-target-too-small — a clickable node whose width or height is below the
|
|
6
|
+
// minimum touch target (default 48px; the harness dumps
|
|
7
|
+
// at density 1 so px == dp there — pass a different
|
|
8
|
+
// minTouchTargetPx for device-density trees). Judged on the
|
|
9
|
+
// FULL composed size (the tree's additive `size` field) when
|
|
10
|
+
// present: `bounds` is the visible slice after ancestor
|
|
11
|
+
// clipping, so a list row bisected by a scroll fold reports
|
|
12
|
+
// e.g. 371x36 while measuring 371x88 — a scroll-position
|
|
13
|
+
// artifact, never an a11y defect. Trees without `size`
|
|
14
|
+
// (older dumps) are judged on bounds, as before.
|
|
15
|
+
// missing-label — a clickable node with no text, no contentDescription,
|
|
16
|
+
// and no descendant text: nothing for a screen reader.
|
|
17
|
+
// Rules (warnings):
|
|
18
|
+
// empty-content-description — contentDescription === "" (redundant/empty; either
|
|
19
|
+
// label it or drop the attribute).
|
|
20
|
+
//
|
|
21
|
+
// Trees produced before the role/clickable/disabled contract extension are handled
|
|
22
|
+
// gracefully: nodes without `clickable` are simply skipped, never crashed on.
|
|
23
|
+
|
|
24
|
+
import { walk } from "./tree.mjs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Audit a tree for accessibility faults.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} tree a full tree ({root}) or bare node.
|
|
30
|
+
* @param {{minTouchTargetPx?: number}} [opts]
|
|
31
|
+
* @returns {{
|
|
32
|
+
* violations: Array<{path:string, testTag:string|null, rule:string, detail:string, bounds:object|null}>,
|
|
33
|
+
* warnings: Array<{path:string, testTag:string|null, rule:string, detail:string, bounds:object|null}>,
|
|
34
|
+
* warningCount: number,
|
|
35
|
+
* passCount: number
|
|
36
|
+
* }} passCount = clickable nodes that passed every check.
|
|
37
|
+
*/
|
|
38
|
+
export function auditA11y(tree, opts = {}) {
|
|
39
|
+
const minTouchTargetPx =
|
|
40
|
+
typeof opts.minTouchTargetPx === "number" && opts.minTouchTargetPx > 0
|
|
41
|
+
? opts.minTouchTargetPx
|
|
42
|
+
: 48;
|
|
43
|
+
|
|
44
|
+
const violations = [];
|
|
45
|
+
const warnings = [];
|
|
46
|
+
let passCount = 0;
|
|
47
|
+
|
|
48
|
+
for (const { node, path } of walk(tree)) {
|
|
49
|
+
const entryBase = {
|
|
50
|
+
path,
|
|
51
|
+
testTag: node.testTag ?? null,
|
|
52
|
+
bounds: node.bounds ?? null,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Warn on redundant/empty contentDescription regardless of clickability.
|
|
56
|
+
if (node.contentDescription === "") {
|
|
57
|
+
warnings.push({
|
|
58
|
+
...entryBase,
|
|
59
|
+
rule: "empty-content-description",
|
|
60
|
+
detail: 'contentDescription is an empty string ("") — either label the node or drop the attribute',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Interactive checks only apply to nodes that self-report clickable:true.
|
|
65
|
+
// Old trees without the optional field are skipped gracefully.
|
|
66
|
+
if (node.clickable !== true) continue;
|
|
67
|
+
|
|
68
|
+
let violated = false;
|
|
69
|
+
|
|
70
|
+
// Judge the FULL composed size when the dump carries it (`size` is unclipped; `bounds`
|
|
71
|
+
// is the visible slice after ancestor clipping — see the rule doc above). max() keeps
|
|
72
|
+
// the check honest either way: a genuinely small target is small in both.
|
|
73
|
+
const b = node.bounds;
|
|
74
|
+
const s = node.size;
|
|
75
|
+
const dim = (bv, sv) =>
|
|
76
|
+
Math.max(typeof bv === "number" ? bv : -1, typeof sv === "number" ? sv : -1);
|
|
77
|
+
const width = dim(b && b.width, s && s.width);
|
|
78
|
+
const height = dim(b && b.height, s && s.height);
|
|
79
|
+
if (width >= 0 && height >= 0 && (width < minTouchTargetPx || height < minTouchTargetPx)) {
|
|
80
|
+
violations.push({
|
|
81
|
+
...entryBase,
|
|
82
|
+
rule: "touch-target-too-small",
|
|
83
|
+
detail: `clickable node is ${width}x${height}px; minimum touch target is ${minTouchTargetPx}x${minTouchTargetPx}px`,
|
|
84
|
+
});
|
|
85
|
+
violated = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const hasOwnLabel =
|
|
89
|
+
(node.text != null && node.text !== "") ||
|
|
90
|
+
(node.contentDescription != null && node.contentDescription !== "");
|
|
91
|
+
if (!hasOwnLabel && !hasDescendantText(node)) {
|
|
92
|
+
violations.push({
|
|
93
|
+
...entryBase,
|
|
94
|
+
rule: "missing-label",
|
|
95
|
+
detail: "clickable node has no text, no contentDescription, and no descendant text — invisible to screen readers",
|
|
96
|
+
});
|
|
97
|
+
violated = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!violated) passCount++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { violations, warnings, warningCount: warnings.length, passCount };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasDescendantText(node) {
|
|
107
|
+
for (const child of node.children || []) {
|
|
108
|
+
if (child.text != null && child.text !== "") return true;
|
|
109
|
+
if (child.contentDescription != null && child.contentDescription !== "") return true;
|
|
110
|
+
if (hasDescendantText(child)) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|