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,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
|
+
}
|