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.
Files changed (61) hide show
  1. package/package.json +6 -2
  2. package/packages/harness/package.json +38 -0
  3. package/packages/harness/src/approve.mjs +247 -0
  4. package/packages/harness/src/arch-doc.mjs +69 -0
  5. package/packages/harness/src/comment.mjs +76 -0
  6. package/packages/harness/src/lib/a11y.mjs +113 -0
  7. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  8. package/packages/harness/src/lib/approvals.mjs +1403 -0
  9. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  10. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  11. package/packages/harness/src/lib/comments.mjs +252 -0
  12. package/packages/harness/src/lib/component-stories.mjs +183 -0
  13. package/packages/harness/src/lib/determinism.mjs +179 -0
  14. package/packages/harness/src/lib/device-lease.mjs +249 -0
  15. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  16. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  17. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  18. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  19. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  20. package/packages/harness/src/lib/harness-region.mjs +159 -0
  21. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  22. package/packages/harness/src/lib/reachability.mjs +211 -0
  23. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  24. package/packages/harness/src/lib/render.mjs +254 -0
  25. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  26. package/packages/harness/src/lib/step-cache.mjs +221 -0
  27. package/packages/harness/src/lib/token-drift.mjs +94 -0
  28. package/packages/harness/src/lib/tree.mjs +108 -0
  29. package/packages/harness/src/preview-gallery.mjs +122 -0
  30. package/packages/harness/src/receipt-check.mjs +96 -0
  31. package/packages/harness/src/record-audit.mjs +83 -0
  32. package/packages/harness/src/refusal-demo.mjs +498 -0
  33. package/packages/harness/src/retrospective.mjs +51 -0
  34. package/packages/harness/src/scaffold-feature.mjs +723 -0
  35. package/packages/harness/src/setup-hooks.mjs +33 -0
  36. package/packages/harness/src/verify.mjs +1723 -0
  37. package/packages/harness/src/walkthrough.mjs +499 -0
  38. package/packages/harness/src/watch.mjs +622 -0
  39. package/packages/receipts/package.json +36 -0
  40. package/packages/receipts/src/index.mjs +16 -0
  41. package/packages/receipts/src/inputs-hash.mjs +194 -0
  42. package/packages/receipts/src/receipt-validate.mjs +234 -0
  43. package/src/commands/upgrade.mjs +115 -1
  44. package/src/lib/harness-upgrade.mjs +193 -5
  45. package/src/scaffold.mjs +60 -1
  46. package/template/AGENTS.md +5 -0
  47. package/template/CLAUDE.md +30 -0
  48. package/template/gitignore +8 -0
  49. package/template/qa/lib/harness-lock.mjs +147 -0
  50. package/template/qa/lib/harness-region.mjs +159 -0
  51. package/template/qa/lib/inputs-hash.mjs +1 -1
  52. package/template/qa/lib/receipt-validate.mjs +1 -1
  53. package/template/qa/preview-gallery.mjs +17 -2
  54. package/template/qa/verify.mjs +110 -2
  55. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  56. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  57. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  58. package/template/.gradle/8.11.1/gc.properties +0 -0
  59. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  60. package/template/.gradle/buildOutputCleanup/cache.properties +0 -2
  61. package/template/.gradle/vcs-1/gc.properties +0 -0
@@ -0,0 +1,290 @@
1
+ // audit-cadence.mjs — the mechanical nudge that keeps cmp-audit from
2
+ // depending on someone remembering.
3
+ //
4
+ // The adversarial platform-semantics audit (the cmp-audit skill) found six
5
+ // latent defects on its first real outing — and it only ran because a human
6
+ // happened to ask. This module is the cheapest honest replacement for that
7
+ // memory: the release profile's receipt lists which androidMain subsystems
8
+ // changed since their last RECORDED audit, so the ship-time surface itself
9
+ // says "these platform seams moved and nobody has interrogated them since".
10
+ //
11
+ // It is a REPORT, never a gate. Audit debt is a judgment call (a one-line
12
+ // rename is not six latent defects), so this file computes facts and the
13
+ // human decides — a FAIL here would train people to game the ledger, which
14
+ // would destroy the only thing it has: honesty.
15
+ //
16
+ // The ledger (qa/audits.jsonl) is append-only, one JSON object per line:
17
+ // subsystem, the commit sha the audit ran against, an ISO timestamp, and who
18
+ // or what recorded it. Recording is a CLAIM — "this subsystem, as of this
19
+ // commit, was audited" — so recordAudit() derives the sha from HEAD itself
20
+ // and refuses to record when the subsystem's files differ from HEAD: a
21
+ // record claiming a commit the audited bytes did not match would be the
22
+ // exact dishonesty the whole harness exists to prevent.
23
+ //
24
+ // "Subsystem" is DERIVED, never configured: the immediate package directory
25
+ // under the app's androidMain Kotlin source root (the root is resolved from
26
+ // the android namespace in composeApp/build.gradle.kts). This template is
27
+ // stamped into apps whose package names it cannot know; deriving from the
28
+ // tree is the only definition that survives that. Kotlin files sitting
29
+ // directly at the package root belong to no package directory and are
30
+ // reported under the literal name "(root)" rather than invented into one.
31
+
32
+ import { execSync } from "node:child_process";
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+
36
+ export const AUDITS_REL_PATH = "qa/audits.jsonl";
37
+ export const AUDIT_RECORD_SCHEMA = "cmp-audit-record/1";
38
+
39
+ /** The pseudo-subsystem for Kotlin files directly at the androidMain package root. */
40
+ export const ROOT_SUBSYSTEM = "(root)";
41
+
42
+ function tryGit(root, cmd) {
43
+ try {
44
+ return execSync(`git ${cmd}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function tryGitLines(root, cmd) {
51
+ try {
52
+ const out = execSync(`git ${cmd}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
53
+ return out.replace(/\n+$/, "").split("\n").filter(Boolean);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Resolve the androidMain Kotlin package root for this app, relative to the
61
+ * project root — derived from the android `namespace` (falling back to
62
+ * `applicationId`) in composeApp/build.gradle.kts, never hardcoded.
63
+ * @param {string} root project root (absolute)
64
+ * @returns {{ok: true, rel: string}|{ok: false, reason: string}}
65
+ */
66
+ export function androidMainPackageRoot(root) {
67
+ let gradle;
68
+ try {
69
+ gradle = fs.readFileSync(path.join(root, "composeApp", "build.gradle.kts"), "utf8");
70
+ } catch {
71
+ return { ok: false, reason: "composeApp/build.gradle.kts not readable — cannot derive the app package" };
72
+ }
73
+ const pkg = gradle.match(/namespace\s*=\s*"([^"]+)"/)?.[1] ?? gradle.match(/applicationId\s*=\s*"([^"]+)"/)?.[1];
74
+ if (!pkg) {
75
+ return { ok: false, reason: "no android namespace/applicationId in composeApp/build.gradle.kts — cannot derive the app package" };
76
+ }
77
+ const rel = path.posix.join("composeApp/src/androidMain/kotlin", ...pkg.split("."));
78
+ if (!fs.existsSync(path.join(root, rel))) {
79
+ return { ok: false, reason: `androidMain has no Kotlin sources under the app package (${rel} absent)` };
80
+ }
81
+ return { ok: true, rel };
82
+ }
83
+
84
+ /**
85
+ * List this app's androidMain subsystems: the immediate directories under
86
+ * the package root (sorted), plus ROOT_SUBSYSTEM when Kotlin files sit
87
+ * directly at the root.
88
+ * @param {string} root project root (absolute)
89
+ * @param {string} pkgRootRel from androidMainPackageRoot()
90
+ * @returns {string[]}
91
+ */
92
+ export function listSubsystems(root, pkgRootRel) {
93
+ const abs = path.join(root, pkgRootRel);
94
+ let entries;
95
+ try {
96
+ entries = fs.readdirSync(abs, { withFileTypes: true });
97
+ } catch {
98
+ return [];
99
+ }
100
+ const names = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
101
+ if (entries.some((e) => e.isFile() && e.name.endsWith(".kt"))) names.push(ROOT_SUBSYSTEM);
102
+ return names;
103
+ }
104
+
105
+ /**
106
+ * Read the audit ledger. Absent is the honest "no audit ever recorded"
107
+ * state; malformed lines are counted, never silently dropped — the report
108
+ * says how many records it could not read instead of under-counting audits.
109
+ * @param {string} root project root (absolute)
110
+ * @returns {{entries: Array<{subsystem: string, sha: string, at: string, by: string}>, malformed: number}}
111
+ */
112
+ export function readAuditLedger(root) {
113
+ const p = path.join(root, AUDITS_REL_PATH);
114
+ if (!fs.existsSync(p)) return { entries: [], malformed: 0 };
115
+ let raw;
116
+ try {
117
+ raw = fs.readFileSync(p, "utf8");
118
+ } catch {
119
+ return { entries: [], malformed: 0 };
120
+ }
121
+ const entries = [];
122
+ let malformed = 0;
123
+ for (const line of raw.split("\n")) {
124
+ if (!line.trim()) continue;
125
+ try {
126
+ const e = JSON.parse(line);
127
+ if (e && typeof e === "object" && typeof e.subsystem === "string" && typeof e.sha === "string") entries.push(e);
128
+ else malformed += 1;
129
+ } catch {
130
+ malformed += 1;
131
+ }
132
+ }
133
+ return { entries, malformed };
134
+ }
135
+
136
+ /**
137
+ * Record an audit claim: append {subsystem, sha: HEAD, at, by} to the
138
+ * ledger. Honesty guards, each a refusal rather than a fabrication:
139
+ * - no git HEAD → refused (a claim about no commit is not a claim);
140
+ * - unknown subsystem → refused, naming the derived ones;
141
+ * - the subsystem's files differ from HEAD → refused (the record would
142
+ * claim HEAD while the audited bytes are something else — commit first).
143
+ * @param {string} root project root (absolute)
144
+ * @param {{subsystem: string, by?: string}} claim
145
+ * @returns {{ok: true, sha: string, entry: object}|{ok: false, reason: string}}
146
+ */
147
+ export function recordAudit(root, { subsystem, by }) {
148
+ const sha = tryGit(root, "rev-parse HEAD");
149
+ if (!sha) {
150
+ return { ok: false, reason: "no git history — an audit record is a claim about a specific commit, and there is none to claim against. Commit first." };
151
+ }
152
+ const pkgRoot = androidMainPackageRoot(root);
153
+ if (!pkgRoot.ok) return { ok: false, reason: pkgRoot.reason };
154
+ const known = listSubsystems(root, pkgRoot.rel);
155
+ if (!known.includes(subsystem)) {
156
+ return { ok: false, reason: `unknown subsystem "${subsystem}" — derived subsystems under ${pkgRoot.rel}: ${known.join(", ") || "(none)"}` };
157
+ }
158
+ const scope = subsystem === ROOT_SUBSYSTEM ? pkgRoot.rel : path.posix.join(pkgRoot.rel, subsystem);
159
+ const dirty = tryGitLines(root, `status --porcelain -- "${scope}"`) ?? [];
160
+ // For "(root)" the porcelain scope is the whole package root; narrow to
161
+ // files directly at the root so a dirty subsystem dir doesn't block a
162
+ // root-level record it has nothing to do with.
163
+ const relevantDirty =
164
+ subsystem === ROOT_SUBSYSTEM
165
+ ? dirty.filter((l) => {
166
+ const rel = l.slice(3).trim();
167
+ return path.posix.dirname(rel) === pkgRoot.rel;
168
+ })
169
+ : dirty;
170
+ if (relevantDirty.length > 0) {
171
+ return {
172
+ ok: false,
173
+ reason: `uncommitted changes under ${scope} — the record would claim commit ${sha.slice(0, 7)} but the audited files are not that commit. Commit (or revert) first, then record.`,
174
+ };
175
+ }
176
+ const entry = {
177
+ schema: AUDIT_RECORD_SCHEMA,
178
+ subsystem,
179
+ sha,
180
+ at: new Date().toISOString(),
181
+ by: by || tryGit(root, "config user.name") || "unknown",
182
+ };
183
+ const p = path.join(root, AUDITS_REL_PATH);
184
+ fs.mkdirSync(path.dirname(p), { recursive: true });
185
+ fs.appendFileSync(p, `${JSON.stringify(entry)}\n`);
186
+ return { ok: true, sha, entry };
187
+ }
188
+
189
+ /**
190
+ * The report itself: for every derived subsystem, what the ledger claims
191
+ * and what git says moved since that claim.
192
+ *
193
+ * Statuses, each phrased so the receipt can print the line verbatim:
194
+ * never-audited no ledger entry — says exactly that, implies no staleness
195
+ * changed androidMain files under it changed between the audited
196
+ * sha and HEAD (committed changes only — sha vs HEAD is
197
+ * the honest comparison; the working tree is not history)
198
+ * unchanged no committed change since the audited sha
199
+ * unknown-commit the ledger names a sha this repo's history does not
200
+ * contain — drift cannot be measured, and the report says
201
+ * so instead of guessing
202
+ *
203
+ * @param {string} root project root (absolute)
204
+ * @returns {{ok: false, reason: string}|{ok: true, packageRoot: string,
205
+ * subsystems: Array<{name: string, status: string, audit: object|null, changedFiles: number}>,
206
+ * lines: string[], summary: string, malformed: number}}
207
+ */
208
+ export function evaluateAuditCadence(root) {
209
+ if (!tryGit(root, "rev-parse HEAD")) {
210
+ // No git history: "changed since the last audit" has no meaning yet.
211
+ // Report NOTHING rather than guessing — an invented staleness signal
212
+ // would be worse than none.
213
+ return { ok: false, reason: "no git history — changed-since-audit cannot be measured" };
214
+ }
215
+ const pkgRoot = androidMainPackageRoot(root);
216
+ if (!pkgRoot.ok) return { ok: false, reason: pkgRoot.reason };
217
+ const subsystems = listSubsystems(root, pkgRoot.rel);
218
+ if (subsystems.length === 0) {
219
+ return { ok: false, reason: `no subsystems under ${pkgRoot.rel} — nothing to report` };
220
+ }
221
+
222
+ const { entries, malformed } = readAuditLedger(root);
223
+ // Last entry per subsystem wins: the ledger is append-only, so file order
224
+ // IS chronological order — trusted over the `at` timestamps, which are
225
+ // claims a machine's clock made, not facts git can vouch for.
226
+ const latest = new Map();
227
+ for (const e of entries) latest.set(e.subsystem, e);
228
+
229
+ const gitTop = tryGit(root, "rev-parse --show-toplevel");
230
+ // Realpath both sides before re-anchoring diff paths: git reports the
231
+ // toplevel with symlinks resolved (macOS: /var/… vs /private/var/…), and a
232
+ // mismatch here would silently mis-attribute every changed file.
233
+ let rootReal = root;
234
+ try {
235
+ rootReal = fs.realpathSync(root);
236
+ } catch {
237
+ rootReal = root;
238
+ }
239
+ const results = [];
240
+ const lines = [];
241
+ for (const name of subsystems) {
242
+ const audit = latest.get(name) ?? null;
243
+ if (!audit) {
244
+ results.push({ name, status: "never-audited", audit: null, changedFiles: 0 });
245
+ lines.push(`no audit recorded for ${name} — when it gets one (cmp-audit ${name}), record it: node qa/record-audit.mjs ${JSON.stringify(name)}`);
246
+ continue;
247
+ }
248
+ // A ledger sha is a CLAIM read from a file — validate its shape before it
249
+ // touches a shell, and resolve it against history before trusting it.
250
+ const shaShapeOk = typeof audit.sha === "string" && /^[0-9a-f]{4,40}$/i.test(audit.sha);
251
+ const shaKnown = shaShapeOk && Boolean(tryGit(root, `rev-parse --verify --quiet "${audit.sha}^{commit}"`));
252
+ if (!shaKnown) {
253
+ results.push({ name, status: "unknown-commit", audit, changedFiles: 0 });
254
+ lines.push(`${name}: last audit (${fmtWhen(audit)}) was recorded against ${audit.sha.slice(0, 12)}, which is not in this repo's history — drift since it cannot be measured`);
255
+ continue;
256
+ }
257
+ const scope = name === ROOT_SUBSYSTEM ? pkgRoot.rel : path.posix.join(pkgRoot.rel, name);
258
+ const changedRaw = tryGitLines(root, `diff --name-only ${audit.sha} HEAD -- "${scope}"`) ?? [];
259
+ // Diff paths come back relative to the git toplevel, which may sit above
260
+ // the project root; re-anchor before subsystem attribution.
261
+ const changed = changedRaw
262
+ .map((rel) => (gitTop ? path.relative(rootReal, path.resolve(gitTop, rel)).split(path.sep).join("/") : rel))
263
+ .filter((rel) => (name === ROOT_SUBSYSTEM ? path.posix.dirname(rel) === pkgRoot.rel : true));
264
+ if (changed.length > 0) {
265
+ results.push({ name, status: "changed", audit, changedFiles: changed.length });
266
+ lines.push(
267
+ `${name}: ${changed.length} androidMain file(s) changed since its last recorded audit (${audit.sha.slice(0, 7)}, ${fmtWhen(audit)}) — audit it (cmp-audit ${name}), then record: node qa/record-audit.mjs ${JSON.stringify(name)}`,
268
+ );
269
+ } else {
270
+ results.push({ name, status: "unchanged", audit, changedFiles: 0 });
271
+ }
272
+ }
273
+
274
+ const changedCount = results.filter((r) => r.status === "changed").length;
275
+ const neverCount = results.filter((r) => r.status === "never-audited").length;
276
+ const unchangedCount = results.filter((r) => r.status === "unchanged").length;
277
+ if (unchangedCount > 0) {
278
+ lines.push(`${unchangedCount} subsystem(s) unchanged since their last recorded audit: ${results.filter((r) => r.status === "unchanged").map((r) => r.name).join(", ")}`);
279
+ }
280
+ if (malformed > 0) {
281
+ lines.push(`${malformed} ledger line(s) in ${AUDITS_REL_PATH} could not be parsed and are not counted`);
282
+ }
283
+ const summary = `${changedCount} changed since audit · ${neverCount} never audited · ${unchangedCount} unchanged`;
284
+
285
+ return { ok: true, packageRoot: pkgRoot.rel, subsystems: results, lines, summary, malformed };
286
+ }
287
+
288
+ function fmtWhen(audit) {
289
+ return typeof audit.at === "string" ? audit.at.slice(0, 10) : "undated";
290
+ }
@@ -0,0 +1,252 @@
1
+ // The comments ledger — the console's talk-back channel (VERIFICATION-LAYER-DESIGN.md
2
+ // §7.3). Approvals stay binding; comments are advisory input the agent must read, act
3
+ // on, and close. This file is the bridge seam: the console's `POST /api/comment` (via a
4
+ // dynamic bridge, same degrade-honestly pattern as approvals-bridge.mjs) and the
5
+ // `review_comments`/`resolve_comment` MCP tools call the SAME functions this file
6
+ // exports — the contract below is binding for both sides and must not drift.
7
+ //
8
+ // Three concerns, kept separable (mirrors qa/lib/approvals.mjs's split):
9
+ // 1. STATE (`qa/comments.json`) — the ledger: { schema, comments: Comment[] }.
10
+ // 2. VALIDATION (`addComment`) — refuses empty text and malformed/unknown targets
11
+ // before anything is written. Refusal over fabrication: an invalid comment is
12
+ // never silently coerced into a valid-looking one.
13
+ // 3. TRANSITIONS (`addComment`/`resolveComment`) — append-only; resolving never
14
+ // deletes a comment, it flips status and records who closed it and why.
15
+ //
16
+ // Read/write asymmetry is deliberate and differs from approvals.mjs on purpose:
17
+ // - A MISSING file is tolerated as the empty seed on read (a brand-new project has
18
+ // no comments yet — that's not corruption) and is created on first write.
19
+ // - A file that EXISTS but is corrupt (unparsable JSON, wrong shape, or a schema
20
+ // string that isn't "cmp-comments/1") is NOT tolerated on read — listComments
21
+ // throws a descriptive error instead of returning an empty list. Approvals can
22
+ // safely treat corruption as "all unreviewed" because that is the conservative
23
+ // (non-blocking) default; silently reading a broken comments ledger as "no
24
+ // comments" would instead HIDE real human feedback, which is the one thing this
25
+ // file exists to surface. Honest failure beats a fabricated empty inbox. Writers
26
+ // (addComment/resolveComment) catch that same error and turn it into
27
+ // {ok:false, reason} — they never overwrite a ledger they could not parse.
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+
32
+ export const COMMENTS_REL_PATH = "qa/comments.json";
33
+ export const COMMENTS_SCHEMA = "cmp-comments/1";
34
+
35
+ /** target.type -> the fields addComment requires on `target` for that type. */
36
+ const TARGET_FIELD_REQUIREMENTS = {
37
+ screen: ["screen"],
38
+ element: ["screen", "testTag"],
39
+ "spec-line": ["file", "clauseId"],
40
+ "design-system": ["token"],
41
+ architecture: ["path"],
42
+ general: [],
43
+ };
44
+
45
+ const VALID_TARGET_TYPES = Object.keys(TARGET_FIELD_REQUIREMENTS);
46
+
47
+ function ledgerPath(root) {
48
+ return path.join(root, COMMENTS_REL_PATH);
49
+ }
50
+
51
+ /**
52
+ * Parse a comments.json payload already read from disk. Throws a descriptive
53
+ * Error for anything that isn't a well-formed `{schema, comments:[]}` ledger —
54
+ * callers decide whether that means "surface it" (read) or "refuse the write"
55
+ * (addComment/resolveComment).
56
+ * @param {string} raw
57
+ * @returns {{schema: string, comments: object[]}}
58
+ */
59
+ function parseLedger(raw) {
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ } catch {
64
+ throw new Error(
65
+ `${COMMENTS_REL_PATH} is not valid JSON — refusing to treat it as an empty ledger (that would silently hide any comments it actually contains). Fix or restore the file.`,
66
+ );
67
+ }
68
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.comments)) {
69
+ throw new Error(
70
+ `${COMMENTS_REL_PATH} has an unexpected shape (expected {schema, comments: [...]}) — refusing to read it as a ledger.`,
71
+ );
72
+ }
73
+ if (parsed.schema !== undefined && parsed.schema !== COMMENTS_SCHEMA) {
74
+ throw new Error(
75
+ `${COMMENTS_REL_PATH} declares schema "${parsed.schema}", expected "${COMMENTS_SCHEMA}" — refusing to read an unknown-schema ledger.`,
76
+ );
77
+ }
78
+ return { schema: COMMENTS_SCHEMA, comments: parsed.comments };
79
+ }
80
+
81
+ /**
82
+ * Read the ledger. A MISSING file is the tolerated empty seed. A file that
83
+ * exists but fails `parseLedger` throws — see the file-level note on why reads
84
+ * do not tolerate corruption the way qa/lib/approvals.mjs does.
85
+ * @param {string} root
86
+ * @returns {{schema: string, comments: object[]}}
87
+ */
88
+ function readLedger(root) {
89
+ let raw;
90
+ try {
91
+ raw = fs.readFileSync(ledgerPath(root), "utf8");
92
+ } catch {
93
+ return { schema: COMMENTS_SCHEMA, comments: [] };
94
+ }
95
+ return parseLedger(raw);
96
+ }
97
+
98
+ /**
99
+ * Write the ledger (deterministic key order, trailing newline) — creates
100
+ * qa/comments.json and its parent dir if this is the first write.
101
+ * @param {string} root
102
+ * @param {{comments: object[]}} state
103
+ */
104
+ function writeLedger(root, state) {
105
+ const p = ledgerPath(root);
106
+ fs.mkdirSync(path.dirname(p), { recursive: true });
107
+ const out = { schema: COMMENTS_SCHEMA, comments: state.comments };
108
+ fs.writeFileSync(p, `${JSON.stringify(out, null, 2)}\n`);
109
+ }
110
+
111
+ /**
112
+ * Next id: "c" + (1 + the highest existing numeric suffix), so ids are
113
+ * monotonic and never reused even if the ledger is edited by hand between
114
+ * calls (count-based numbering would reuse an id after any external edit;
115
+ * max-based numbering does not).
116
+ * @param {object[]} comments
117
+ * @returns {string}
118
+ */
119
+ function nextId(comments) {
120
+ let max = 0;
121
+ for (const c of comments) {
122
+ const m = typeof c.id === "string" && c.id.match(/^c(\d+)$/);
123
+ if (m) max = Math.max(max, Number(m[1]));
124
+ }
125
+ return `c${max + 1}`;
126
+ }
127
+
128
+ function nonEmptyString(v) {
129
+ return typeof v === "string" && v.trim().length > 0;
130
+ }
131
+
132
+ // ── Reads ────────────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Every comment in the ledger, optionally filtered by status. Throws if
136
+ * qa/comments.json exists but is corrupt or declares an unknown schema (see
137
+ * the file-level note) — callers that must never throw (a console route, a
138
+ * blocking MCP tool) should catch and surface the message rather than
139
+ * swallow it into a fabricated empty list.
140
+ * @param {string} root
141
+ * @param {{status?: "open"|"resolved"}} [opts]
142
+ * @returns {{schema: string, comments: object[]}}
143
+ */
144
+ export function listComments(root, opts = {}) {
145
+ const state = readLedger(root);
146
+ const comments = opts.status ? state.comments.filter((c) => c.status === opts.status) : state.comments;
147
+ return { schema: COMMENTS_SCHEMA, comments };
148
+ }
149
+
150
+ // ── Writes ───────────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Add a comment. Refuses (never throws):
154
+ * - empty or whitespace-only `text`
155
+ * - a missing/malformed `target` or an unknown `target.type`
156
+ * - a `target` missing a field its type requires (screen -> screen; element ->
157
+ * screen, testTag; spec-line -> file, clauseId; design-system -> token;
158
+ * architecture -> path; general -> none)
159
+ * - a ledger that exists but cannot be parsed (corrupt/unknown-schema) — the
160
+ * write is refused rather than overwriting a file we could not honestly read
161
+ * @param {string} root
162
+ * @param {{target: object, text: string, author?: string}} input
163
+ * @returns {{ok: true, comment: object} | {ok: false, reason: string}}
164
+ */
165
+ export function addComment(root, { target, text, author } = {}) {
166
+ if (!nonEmptyString(text)) {
167
+ return { ok: false, reason: "comment text is empty or whitespace-only — refusing to record an empty comment." };
168
+ }
169
+ if (!target || typeof target !== "object" || typeof target.type !== "string") {
170
+ return {
171
+ ok: false,
172
+ reason: `comment target is missing or malformed — expected {type, ...} with type one of: ${VALID_TARGET_TYPES.join(", ")}.`,
173
+ };
174
+ }
175
+ const requiredFields = TARGET_FIELD_REQUIREMENTS[target.type];
176
+ if (!requiredFields) {
177
+ return {
178
+ ok: false,
179
+ reason: `unknown target type "${target.type}" — valid types: ${VALID_TARGET_TYPES.join(", ")}.`,
180
+ };
181
+ }
182
+ const missingFields = requiredFields.filter((f) => !nonEmptyString(target[f]));
183
+ if (missingFields.length > 0) {
184
+ return {
185
+ ok: false,
186
+ reason: `target type "${target.type}" requires ${requiredFields.join(", ")} — missing or empty: ${missingFields.join(", ")}.`,
187
+ };
188
+ }
189
+
190
+ let state;
191
+ try {
192
+ state = readLedger(root);
193
+ } catch (err) {
194
+ return { ok: false, reason: err.message };
195
+ }
196
+
197
+ const comment = {
198
+ id: nextId(state.comments),
199
+ target,
200
+ text: text.trim(),
201
+ author: nonEmptyString(author) ? author.trim() : "anonymous",
202
+ createdAt: new Date().toISOString(),
203
+ status: "open",
204
+ };
205
+ writeLedger(root, { comments: [...state.comments, comment] });
206
+ return { ok: true, comment };
207
+ }
208
+
209
+ /**
210
+ * Resolve a comment: stamps status "resolved", who closed it, when, and an
211
+ * optional note explaining what changed as a result. Refuses (never throws):
212
+ * - an unknown id
213
+ * - a comment that is already resolved (double-resolve)
214
+ * - a ledger that exists but cannot be parsed
215
+ * @param {string} root
216
+ * @param {string} id
217
+ * @param {{note?: string, author?: string}} [opts]
218
+ * @returns {{ok: true, comment: object} | {ok: false, reason: string}}
219
+ */
220
+ export function resolveComment(root, id, opts = {}) {
221
+ let state;
222
+ try {
223
+ state = readLedger(root);
224
+ } catch (err) {
225
+ return { ok: false, reason: err.message };
226
+ }
227
+
228
+ const idx = state.comments.findIndex((c) => c.id === id);
229
+ if (idx === -1) {
230
+ const known = state.comments.map((c) => c.id).join(", ") || "(none — the ledger is empty)";
231
+ return { ok: false, reason: `unknown comment id "${id}" — known ids: ${known}.` };
232
+ }
233
+ const existing = state.comments[idx];
234
+ if (existing.status === "resolved") {
235
+ return {
236
+ ok: false,
237
+ reason: `comment "${id}" is already resolved (at ${existing.resolvedAt} by ${existing.resolvedBy}) — refusing to double-resolve.`,
238
+ };
239
+ }
240
+
241
+ const resolved = {
242
+ ...existing,
243
+ status: "resolved",
244
+ resolvedAt: new Date().toISOString(),
245
+ resolvedBy: nonEmptyString(opts.author) ? opts.author.trim() : "anonymous",
246
+ ...(nonEmptyString(opts.note) ? { resolutionNote: opts.note.trim() } : {}),
247
+ };
248
+ const comments = [...state.comments];
249
+ comments[idx] = resolved;
250
+ writeLedger(root, { comments });
251
+ return { ok: true, comment: resolved };
252
+ }