create-cmp-cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-cmp.mjs +3 -0
- 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 +1709 -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 +383 -0
- package/src/lib/harness-upgrade.mjs +521 -0
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +34 -1
- package/template/README.md +4 -0
- package/template/gitignore +8 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/flight-recorder.mjs +332 -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 +17 -2
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/verify.mjs +400 -10
- package/template/qa/watch.mjs +2 -2
package/template/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# __APP_NAME__
|
|
2
2
|
|
|
3
|
+
<!-- cmp:generated evidence -->
|
|
4
|
+
[](https://github.com/kvdm-co-pilot/create-cmp) — no verify receipt yet. Run `node qa/verify.mjs`.
|
|
5
|
+
<!-- /cmp:generated -->
|
|
6
|
+
|
|
3
7
|
A Kotlin / Compose Multiplatform app, generated by
|
|
4
8
|
[create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a **verification harness**:
|
|
5
9
|
the architecture, testing conventions, and definition of done are enforced mechanically, not
|
package/template/gitignore
CHANGED
|
@@ -26,3 +26,11 @@ iosApp/*.xcworkspace/
|
|
|
26
26
|
iosApp/build/
|
|
27
27
|
xcuserdata/
|
|
28
28
|
*.xcuserstate
|
|
29
|
+
|
|
30
|
+
# `create-cmp upgrade`'s pre-write backups. In a git repo the previous commit IS
|
|
31
|
+
# the backup, so these are redundant the moment they are created — and committing
|
|
32
|
+
# them alongside the upgrade puts a stale copy of every touched file in history.
|
|
33
|
+
# Kept on disk (an upgrade run in a dirty or non-git tree still needs them),
|
|
34
|
+
# ignored by git. Delete them once you have reviewed the upgrade's diff.
|
|
35
|
+
*.bak-upgrade
|
|
36
|
+
*.cmp-new
|
|
@@ -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,179 @@
|
|
|
1
|
+
// determinism.mjs — the comparison half of the lane's determinism probe.
|
|
2
|
+
//
|
|
3
|
+
// ARCH-13 statically bans ambient time reads (Clock.System, LocalDate.now,
|
|
4
|
+
// TimeZone.currentSystemDefault) in APP code — but a library the app calls
|
|
5
|
+
// can still read the wall clock, and a golden test can still depend on the
|
|
6
|
+
// machine's timezone through a seam the static net cannot see. This project
|
|
7
|
+
// family has already been bitten: a golden tree green at 23:00 and red by
|
|
8
|
+
// morning, because a ViewModel was constructed without its injected clock.
|
|
9
|
+
//
|
|
10
|
+
// The probe (verify.mjs stepDeterminism) runs the JVM test tier TWICE under
|
|
11
|
+
// maximally-shifted timezones and fails iff the two runs' OUTCOMES differ.
|
|
12
|
+
// This module owns the two judgments that make that comparison honest:
|
|
13
|
+
//
|
|
14
|
+
// - WHAT COUNTS AS AN OUTCOME: a test's verdict (pass/fail/error/skip)
|
|
15
|
+
// and its failure output — never its duration. Durations are not parsed
|
|
16
|
+
// at all, so a timing wobble is structurally incapable of tripping the
|
|
17
|
+
// probe (the brief-level rule "duration is not a difference" is enforced
|
|
18
|
+
// by construction, not by filtering).
|
|
19
|
+
//
|
|
20
|
+
// - WHAT THE FAILURE MESSAGE MUST SAY: which test, which lane step owns
|
|
21
|
+
// it, and the observable difference between the two runs — never a bare
|
|
22
|
+
// "nondeterministic". A probe whose red is unactionable just teaches
|
|
23
|
+
// people to turn it off.
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The two probe timezones — chosen so the two legs NEVER share a calendar
|
|
30
|
+
* date, at any instant:
|
|
31
|
+
*
|
|
32
|
+
* Etc/GMT+12 = UTC-12 (POSIX sign convention: Etc/GMT+N means UTC-N)
|
|
33
|
+
* Etc/GMT-14 = UTC+14 (the highest real-world offset, Line Islands)
|
|
34
|
+
*
|
|
35
|
+
* The offsets are 26 hours apart — more than a full day — so the two legs'
|
|
36
|
+
* local dates differ at EVERY moment of every day, and any date-derived
|
|
37
|
+
* value (a "today" default, a day-boundary bucket, a formatted date in a
|
|
38
|
+
* golden tree) is guaranteed to differ between the legs. A UTC-vs-UTC+14
|
|
39
|
+
* pair would NOT have this property: those legs share a date for ten hours
|
|
40
|
+
* of every day, so the probe's power would depend on what time you ran it —
|
|
41
|
+
* the exact class of flakiness it exists to hunt.
|
|
42
|
+
*/
|
|
43
|
+
export const DETERMINISM_TIMEZONES = [
|
|
44
|
+
{ tz: "Etc/GMT+12", label: "UTC-12" },
|
|
45
|
+
{ tz: "Etc/GMT-14", label: "UTC+14" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const XML_ENTITIES = { "<": "<", ">": ">", """: '"', "'": "'", "&": "&" };
|
|
49
|
+
|
|
50
|
+
function unescapeXml(s) {
|
|
51
|
+
return s
|
|
52
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
53
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
|
54
|
+
.replace(/&(lt|gt|quot|apos|amp);/g, (m) => XML_ENTITIES[m]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function attr(attrs, name) {
|
|
58
|
+
const m = attrs.match(new RegExp(`${name}="([^"]*)"`));
|
|
59
|
+
return m ? unescapeXml(m[1]) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse one Gradle JUnit results directory into per-test outcomes.
|
|
64
|
+
* DELIBERATELY parses only verdict-bearing content: testcase identity,
|
|
65
|
+
* status, and failure/error text. `time="…"` attributes are never read, so
|
|
66
|
+
* two runs that differ only in duration produce identical outcome maps.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} dir a test-results directory (TEST-*.xml files, flat)
|
|
69
|
+
* @returns {Record<string, {status: "pass"|"fail"|"error"|"skip", messages: string[]}>}
|
|
70
|
+
* keyed by `classname.name`; empty object when the directory is absent
|
|
71
|
+
* (the caller decides what an empty leg means — this parser never guesses)
|
|
72
|
+
*/
|
|
73
|
+
export function parseJUnitOutcomes(dir) {
|
|
74
|
+
const outcomes = {};
|
|
75
|
+
if (!fs.existsSync(dir)) return outcomes;
|
|
76
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
77
|
+
if (!entry.startsWith("TEST-") || !entry.endsWith(".xml")) continue;
|
|
78
|
+
const xml = fs.readFileSync(path.join(dir, entry), "utf8");
|
|
79
|
+
const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
80
|
+
for (const m of xml.matchAll(caseRe)) {
|
|
81
|
+
const attrs = m[1];
|
|
82
|
+
const body = m[2] ?? "";
|
|
83
|
+
const classname = attr(attrs, "classname") ?? "";
|
|
84
|
+
const name = attr(attrs, "name") ?? "";
|
|
85
|
+
if (!classname && !name) continue;
|
|
86
|
+
let status = "pass";
|
|
87
|
+
const messages = [];
|
|
88
|
+
const childRe = /<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g;
|
|
89
|
+
for (const c of body.matchAll(childRe)) {
|
|
90
|
+
status = c[1] === "error" ? "error" : "fail";
|
|
91
|
+
const message = attr(c[2], "message");
|
|
92
|
+
const text = c[3] ? unescapeXml(c[3]).trim() : "";
|
|
93
|
+
messages.push(message ?? text.split("\n")[0] ?? "");
|
|
94
|
+
}
|
|
95
|
+
if (status === "pass" && /<skipped\b/.test(body)) status = "skip";
|
|
96
|
+
outcomes[`${classname}.${name}`] = { status, messages };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return outcomes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which lane step owns a test class — so the probe's failure message names
|
|
104
|
+
* the step a reader would re-run, not just a class name. The patterns are
|
|
105
|
+
* the same filters the lane's own gradleTestStep calls use.
|
|
106
|
+
* @param {string} classname fully-qualified test class
|
|
107
|
+
* @returns {"goldenTrees"|"conformance"|"a11y"|"unitTests"}
|
|
108
|
+
*/
|
|
109
|
+
export function laneStepForTestClass(classname) {
|
|
110
|
+
if (/GoldenTreeTest$/.test(classname)) return "goldenTrees";
|
|
111
|
+
if (/ArchitectureConformanceTest$/.test(classname)) return "conformance";
|
|
112
|
+
if (/A11yConformanceTest$/.test(classname)) return "a11y";
|
|
113
|
+
return "unitTests";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function classnameOf(testId) {
|
|
117
|
+
// testId is `classname.name`; the class is everything before the last dot
|
|
118
|
+
// segment that starts the (possibly backticked, space-bearing) test name.
|
|
119
|
+
// Kotlin test names contain dots rarely but spaces often — the classname
|
|
120
|
+
// never contains a space, so split at the first segment containing one,
|
|
121
|
+
// falling back to the last dot.
|
|
122
|
+
const spaceIdx = testId.indexOf(" ");
|
|
123
|
+
const scope = spaceIdx === -1 ? testId : testId.slice(0, spaceIdx);
|
|
124
|
+
const lastDot = scope.lastIndexOf(".");
|
|
125
|
+
return lastDot === -1 ? testId : testId.slice(0, lastDot);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compare two legs' outcomes. Returns one entry per observable difference,
|
|
130
|
+
* each carrying everything the failure message must name: the test, the
|
|
131
|
+
* owning lane step, and what differed between the legs.
|
|
132
|
+
*
|
|
133
|
+
* Kinds:
|
|
134
|
+
* verdict-flip different status (pass/fail/error/skip)
|
|
135
|
+
* only-in-one-leg the test executed in one leg only
|
|
136
|
+
* failure-text-changed failed in BOTH legs, but with different output —
|
|
137
|
+
* a date-dependent assertion message is still a
|
|
138
|
+
* timezone leak even when both legs are red
|
|
139
|
+
*
|
|
140
|
+
* @param {Record<string, {status: string, messages: string[]}>} a leg A outcomes
|
|
141
|
+
* @param {Record<string, {status: string, messages: string[]}>} b leg B outcomes
|
|
142
|
+
* @param {string} labelA human label for leg A (e.g. "TZ=Etc/GMT+12 (UTC-12)")
|
|
143
|
+
* @param {string} labelB human label for leg B
|
|
144
|
+
* @returns {Array<{test: string, step: string, kind: string, detail: string}>}
|
|
145
|
+
*/
|
|
146
|
+
export function compareOutcomes(a, b, labelA, labelB) {
|
|
147
|
+
const diffs = [];
|
|
148
|
+
const ids = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
149
|
+
for (const id of ids) {
|
|
150
|
+
const step = laneStepForTestClass(classnameOf(id));
|
|
151
|
+
const inA = a[id];
|
|
152
|
+
const inB = b[id];
|
|
153
|
+
if (!inA || !inB) {
|
|
154
|
+
const where = inA ? labelA : labelB;
|
|
155
|
+
const missing = inA ? labelB : labelA;
|
|
156
|
+
diffs.push({ test: id, step, kind: "only-in-one-leg", detail: `executed under ${where} but produced no result under ${missing}` });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (inA.status !== inB.status) {
|
|
160
|
+
const firstLine = (inA.status === "pass" ? inB : inA).messages[0]?.split("\n")[0] ?? "";
|
|
161
|
+
diffs.push({
|
|
162
|
+
test: id,
|
|
163
|
+
step,
|
|
164
|
+
kind: "verdict-flip",
|
|
165
|
+
detail: `${inA.status.toUpperCase()} under ${labelA}, ${inB.status.toUpperCase()} under ${labelB}${firstLine ? `: ${firstLine}` : ""}`,
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (inA.status !== "pass" && inA.messages.join("\n") !== inB.messages.join("\n")) {
|
|
170
|
+
diffs.push({
|
|
171
|
+
test: id,
|
|
172
|
+
step,
|
|
173
|
+
kind: "failure-text-changed",
|
|
174
|
+
detail: `failed under both, with different output — ${labelA}: "${inA.messages[0]?.split("\n")[0] ?? ""}" vs ${labelB}: "${inB.messages[0]?.split("\n")[0] ?? ""}"`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return diffs;
|
|
179
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The README's evidence badge — the evidence ladder, rendered where a human
|
|
2
|
+
// actually looks (roadmap §10 item 2: "render the rung in the console and
|
|
3
|
+
// README badge").
|
|
4
|
+
//
|
|
5
|
+
// The console already shows the rung (rail foot, Evidence section, receipt
|
|
6
|
+
// timeline). The README is the surface a human meets FIRST, and the one that
|
|
7
|
+
// travels — into a GitHub repo page, a PR, a screenshot in a deck. That makes
|
|
8
|
+
// it the surface where an overclaim does the most damage, so the badge obeys
|
|
9
|
+
// one rule above all others:
|
|
10
|
+
//
|
|
11
|
+
// **The badge is a statement about a specific commit, never about "now".**
|
|
12
|
+
//
|
|
13
|
+
// A badge that says "L2 device" says nothing about whether the code has moved
|
|
14
|
+
// since. So it never renders a bare rung: it renders the rung AND the commit
|
|
15
|
+
// it was attested against AND the date. That sentence stays true forever — a
|
|
16
|
+
// reader can see at a glance whether the sha still matches what they are
|
|
17
|
+
// looking at. Everything else follows from the same rule: no receipt says so,
|
|
18
|
+
// a FAIL says so, and a --fast run (which the ladder deliberately grants no
|
|
19
|
+
// rung) says so rather than borrowing the last good one.
|
|
20
|
+
//
|
|
21
|
+
// Written by the lane AFTER the receipt (it is an output derived from the
|
|
22
|
+
// receipt, never a gate), and committed alongside it.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
export const README_REL_PATH = "README.md";
|
|
28
|
+
export const BADGE_SECTION_ID = "evidence";
|
|
29
|
+
|
|
30
|
+
const MARKER_RE = new RegExp(
|
|
31
|
+
`<!-- cmp:generated ${BADGE_SECTION_ID} -->\\n([\\s\\S]*?)<!-- /cmp:generated -->`
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Shields.io colours, one per rung — the ladder read at a glance. */
|
|
35
|
+
const RUNG_COLOR = {
|
|
36
|
+
L0: "9E9E9E", // scaffold — grey: a green build, nothing proven about behavior
|
|
37
|
+
L1: "42A5F5", // desktop — blue
|
|
38
|
+
L2: "26A69A", // device — teal
|
|
39
|
+
L3: "43A047", // release — green: the strongest rung this harness can attest
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** shields.io escaping: `-` → `--`, `_` → `__`, space → `_`. */
|
|
43
|
+
function shieldEscape(s) {
|
|
44
|
+
return String(s).replace(/-/g, "--").replace(/_/g, "__").replace(/ /g, "_");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The badge body for a receipt — Markdown, no trailing newline handling (the
|
|
49
|
+
* caller frames it). Pure: every degraded state has its own honest rendering
|
|
50
|
+
* and NONE of them fall back to a rung.
|
|
51
|
+
*
|
|
52
|
+
* @param {object|null} receipt parsed qa/evidence/latest.json, or null
|
|
53
|
+
* @returns {string} Markdown
|
|
54
|
+
*/
|
|
55
|
+
export function renderEvidenceBadge(receipt) {
|
|
56
|
+
const link = "https://github.com/kvdm-co-pilot/create-cmp";
|
|
57
|
+
const badge = (label, message, color, title) =>
|
|
58
|
+
`[}-${shieldEscape(message)}-${color})](${link})`;
|
|
59
|
+
|
|
60
|
+
if (!receipt || typeof receipt !== "object") {
|
|
61
|
+
return `${badge("evidence", "none yet", "9E9E9E", "No evidence receipt")} — no verify receipt yet. Run \`node qa/verify.mjs\`.`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const verdict = typeof receipt.verdict === "string" ? receipt.verdict : "?";
|
|
65
|
+
const mode = receipt.mode === "fast" ? "fast" : "full";
|
|
66
|
+
const sha = typeof receipt.commit?.sha === "string" ? receipt.commit.sha.slice(0, 7) : null;
|
|
67
|
+
const dirty = Array.isArray(receipt.commit?.dirty) ? receipt.commit.dirty.length : 0;
|
|
68
|
+
const when = typeof receipt.generatedAt === "string" ? receipt.generatedAt.slice(0, 10) : null;
|
|
69
|
+
|
|
70
|
+
// Provenance is not decoration — it is what keeps the sentence true later.
|
|
71
|
+
const at = sha ? ` at \`${sha}\`` : "";
|
|
72
|
+
const on = when ? ` on ${when}` : "";
|
|
73
|
+
const uncommitted =
|
|
74
|
+
dirty > 0
|
|
75
|
+
? ` The tree had ${dirty} uncommitted file${dirty === 1 ? "" : "s"} at attestation, so this describes that run, not that commit.`
|
|
76
|
+
: "";
|
|
77
|
+
|
|
78
|
+
if (verdict !== "PASS") {
|
|
79
|
+
return `${badge("evidence", `lane ${verdict}`, "E53935", `Verify lane ${verdict}`)} — the last lane run${at}${on} did not pass. No rung is earned by a failed lane.${uncommitted}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (mode === "fast") {
|
|
83
|
+
// The inner loop is a signal, never evidence. Borrowing the previous
|
|
84
|
+
// full run's rung here is exactly the lie the ladder exists to prevent.
|
|
85
|
+
return `${badge("evidence", "fast run, no rung", "9E9E9E", "Fast run — no evidence rung")} — the last run${at}${on} was \`--fast\`: the device and release tiers were skipped, so it earns no rung.${uncommitted} Run \`node qa/verify.mjs\` for evidence.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const level = receipt.evidenceLevel;
|
|
89
|
+
if (!level || typeof level.rung !== "string" || typeof level.name !== "string") {
|
|
90
|
+
return `${badge("evidence", `PASS, rung unrecorded`, "9E9E9E", "Lane PASS, no rung recorded")} — the lane passed${at}${on} but the receipt records no evidence rung.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const color = RUNG_COLOR[level.rung] || "9E9E9E";
|
|
94
|
+
const satisfied = Array.isArray(level.satisfiedBy) && level.satisfiedBy.length
|
|
95
|
+
? ` Earned by: ${level.satisfiedBy.map((s) => `\`${s}\``).join(", ")}.`
|
|
96
|
+
: "";
|
|
97
|
+
return (
|
|
98
|
+
`${badge("evidence", `${level.rung} ${level.name}`, color, `Evidence ${level.rung} — ${level.name}`)}` +
|
|
99
|
+
` — the verify lane passed${at}${on} at rung **${level.rung} · ${level.name}**.` +
|
|
100
|
+
`${satisfied}${uncommitted}` +
|
|
101
|
+
` The rung describes that run; it says nothing about changes made since.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read the project's receipt and rewrite README.md's `cmp:generated evidence`
|
|
107
|
+
* block from it. Never creates the marker — a project that removed the block
|
|
108
|
+
* has opted out, and that is honoured silently.
|
|
109
|
+
*
|
|
110
|
+
* NOTE the asymmetry with renderEvidenceBadge above: the RENDERER is total —
|
|
111
|
+
* every receipt, including a `--fast` one, has an honest rendering. The WRITER
|
|
112
|
+
* is selective: a fast receipt is not written to the README at all. Two
|
|
113
|
+
* reasons, and the second is the load-bearing one:
|
|
114
|
+
* 1. The badge reports EVIDENCE. A fast run produces none, so it has nothing
|
|
115
|
+
* to say — and overwriting a true statement about a real full-lane run
|
|
116
|
+
* with "no rung" loses information rather than adding honesty.
|
|
117
|
+
* 2. `qa/watch.mjs` runs the fast lane on every save. A writer that fired
|
|
118
|
+
* there would rewrite README.md on every keystroke-to-save cycle, putting
|
|
119
|
+
* a permanently-dirty file in the inner loop. A recorder must not disturb
|
|
120
|
+
* what it records.
|
|
121
|
+
* The badge therefore always describes the last run that could BEAR evidence,
|
|
122
|
+
* and says so by naming that run's commit and date.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} root project root
|
|
125
|
+
* @returns {{changed: boolean, reason?: string}}
|
|
126
|
+
*/
|
|
127
|
+
export function updateReadmeBadge(root) {
|
|
128
|
+
const readmePath = path.join(root, README_REL_PATH);
|
|
129
|
+
let readme;
|
|
130
|
+
try {
|
|
131
|
+
readme = fs.readFileSync(readmePath, "utf8");
|
|
132
|
+
} catch {
|
|
133
|
+
return { changed: false, reason: `${README_REL_PATH} not found` };
|
|
134
|
+
}
|
|
135
|
+
if (!MARKER_RE.test(readme)) {
|
|
136
|
+
return { changed: false, reason: `${README_REL_PATH} has no cmp:generated ${BADGE_SECTION_ID} block` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let receipt = null;
|
|
140
|
+
try {
|
|
141
|
+
receipt = JSON.parse(fs.readFileSync(path.join(root, "qa", "evidence", "latest.json"), "utf8"));
|
|
142
|
+
} catch {
|
|
143
|
+
receipt = null; // no receipt / unreadable → the "none yet" rendering, never a guess
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (receipt && receipt.mode === "fast") {
|
|
147
|
+
return { changed: false, reason: "fast run — the inner loop bears no evidence, so the badge is left as it stands" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const body = `${renderEvidenceBadge(receipt)}\n`;
|
|
151
|
+
const next = readme.replace(
|
|
152
|
+
MARKER_RE,
|
|
153
|
+
() => `<!-- cmp:generated ${BADGE_SECTION_ID} -->\n${body}<!-- /cmp:generated -->`
|
|
154
|
+
);
|
|
155
|
+
if (next === readme) return { changed: false };
|
|
156
|
+
fs.writeFileSync(readmePath, next);
|
|
157
|
+
return { changed: true };
|
|
158
|
+
}
|