create-cmp-cli 0.18.0 → 0.20.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/README.md +3 -3
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/packages/harness/src/approve.mjs +30 -2
- package/packages/harness/src/lib/approvals.mjs +74 -10
- package/packages/harness/src/lib/evidence-level.mjs +3 -1
- package/packages/harness/src/lib/flight-recorder.mjs +47 -2
- package/packages/harness/src/lib/inputs-hash.mjs +71 -3
- package/packages/harness/src/lib/lane-narrator.mjs +97 -0
- package/packages/harness/src/lib/lane-runner.mjs +173 -0
- package/packages/harness/src/lib/plan.mjs +286 -20
- package/packages/harness/src/lib/receipt-validate.mjs +56 -1
- package/packages/harness/src/lib/spec-coverage.mjs +111 -3
- package/packages/harness/src/lib/step-cache.mjs +1 -1
- package/packages/harness/src/lib/step-outcomes.mjs +123 -0
- package/packages/harness/src/lib/steps-cmp.mjs +1284 -0
- package/packages/harness/src/lib/walk.mjs +67 -17
- package/packages/harness/src/receipt-check.mjs +80 -4
- package/packages/harness/src/verify.mjs +119 -1197
- package/packages/receipts/src/index.mjs +1 -0
- package/packages/receipts/src/inputs-hash.mjs +71 -3
- package/packages/receipts/src/receipt-validate.mjs +56 -1
- package/template/CLAUDE.md +52 -6
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +1 -1
- package/template/gitignore +3 -0
- package/template/qa/approve.mjs +30 -2
- package/template/qa/lib/approvals.mjs +74 -10
- package/template/qa/lib/evidence-level.mjs +3 -1
- package/template/qa/lib/flight-recorder.mjs +47 -2
- package/template/qa/lib/inputs-hash.mjs +71 -3
- package/template/qa/lib/lane-narrator.mjs +97 -0
- package/template/qa/lib/lane-runner.mjs +173 -0
- package/template/qa/lib/plan.mjs +286 -20
- package/template/qa/lib/receipt-validate.mjs +56 -1
- package/template/qa/lib/spec-coverage.mjs +111 -3
- package/template/qa/lib/step-cache.mjs +1 -1
- package/template/qa/lib/step-outcomes.mjs +123 -0
- package/template/qa/lib/steps-cmp.mjs +1284 -0
- package/template/qa/lib/walk.mjs +67 -17
- package/template/qa/receipt-check.mjs +80 -4
- package/template/qa/verify.mjs +119 -1197
- package/template/specs/README.md +26 -0
|
@@ -17,8 +17,19 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
|
|
20
|
-
// Directories / files
|
|
20
|
+
// Directories / files included in the verified surface (relative to project ROOT).
|
|
21
21
|
// Principle: every tracked file whose content can change the lane's verdict.
|
|
22
|
+
//
|
|
23
|
+
// THIS IS A DEFAULT, NOT A LAW (evidence-economics S8, 2026-09-03). It is the
|
|
24
|
+
// surface of a Compose Multiplatform app, and it used to be hardcoded inside
|
|
25
|
+
// this module — which is the SPINE, shared by every adopter. A repo whose code
|
|
26
|
+
// lives in services/ or src/ that vendored this file had its verified surface
|
|
27
|
+
// silently shrink to whatever happened to match: no error, no failed step, a
|
|
28
|
+
// receipt that still validated and still looked identical, and a hash that had
|
|
29
|
+
// quietly stopped covering the application. A gate that attests less while
|
|
30
|
+
// looking the same is the worst failure this harness can have, so the surface
|
|
31
|
+
// is now resolved per project (see resolveVerifiedSurface) and an empty one is
|
|
32
|
+
// refused rather than hashed.
|
|
22
33
|
export const VERIFIED_SURFACE = [
|
|
23
34
|
"composeApp",
|
|
24
35
|
"specs",
|
|
@@ -57,6 +68,7 @@ export const VERIFIED_SURFACE = [
|
|
|
57
68
|
const EXCLUDED_PREFIXES = [
|
|
58
69
|
"qa/.plan.json",
|
|
59
70
|
"qa/.request.json",
|
|
71
|
+
"qa/.plan-history.jsonl",
|
|
60
72
|
"qa/evidence",
|
|
61
73
|
"qa-artifacts",
|
|
62
74
|
"qa/comments.json",
|
|
@@ -146,9 +158,52 @@ function walkAllFiles(dir) {
|
|
|
146
158
|
return out;
|
|
147
159
|
}
|
|
148
160
|
|
|
161
|
+
/** Where a project may declare its own verified surface (see resolveVerifiedSurface). */
|
|
162
|
+
export const SURFACE_CONFIG_REL = "qa/verified-surface.json";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The surface THIS project attests — its own declaration when it has one, the
|
|
166
|
+
* Compose Multiplatform default otherwise.
|
|
167
|
+
*
|
|
168
|
+
* Read from a file rather than passed as an argument on purpose: qa/verify.mjs
|
|
169
|
+
* (which writes inputs.hash) and qa/receipt-check.mjs (which recomputes it)
|
|
170
|
+
* must never disagree about what was hashed, and two call sites taking a
|
|
171
|
+
* parameter is two places to get it wrong. The file lives under qa/, so it is
|
|
172
|
+
* itself inside the surface — changing the definition invalidates receipts,
|
|
173
|
+
* which is correct: the tree's coverage changed.
|
|
174
|
+
*
|
|
175
|
+
* Shape: {"surface": ["services", "docs", "build-logic", ".github", "qa"]}.
|
|
176
|
+
* Malformed or empty content is REFUSED, never silently defaulted — a project
|
|
177
|
+
* that tried to declare a surface and failed must not fall back to a smaller
|
|
178
|
+
* one behind the operator's back.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} root project root
|
|
181
|
+
* @returns {string[]} surface entries, relative to root
|
|
182
|
+
*/
|
|
183
|
+
export function resolveVerifiedSurface(root) {
|
|
184
|
+
const p = path.join(root, SURFACE_CONFIG_REL);
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = fs.readFileSync(p, "utf8");
|
|
188
|
+
} catch {
|
|
189
|
+
return VERIFIED_SURFACE; // no declaration — the CMP default, unchanged
|
|
190
|
+
}
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(raw);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
throw new Error(`${SURFACE_CONFIG_REL} is not valid JSON (${err.message}) — refusing to hash a surface this project failed to declare.`);
|
|
196
|
+
}
|
|
197
|
+
const list = parsed && Array.isArray(parsed.surface) ? parsed.surface.filter((x) => typeof x === "string" && x.trim() !== "") : null;
|
|
198
|
+
if (!list || list.length === 0) {
|
|
199
|
+
throw new Error(`${SURFACE_CONFIG_REL} declares no surface — expected {"surface": ["dir", …]}. Refusing to hash nothing.`);
|
|
200
|
+
}
|
|
201
|
+
return list;
|
|
202
|
+
}
|
|
203
|
+
|
|
149
204
|
// Resolve the verified surface to a flat, sorted list of paths (relative to
|
|
150
205
|
// root, POSIX-style `/` separators) that currently exist on disk.
|
|
151
|
-
function resolveSurfaceFiles(root) {
|
|
206
|
+
function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
|
|
152
207
|
const gitFiles = tryGitLsFiles(root);
|
|
153
208
|
|
|
154
209
|
if (gitFiles) {
|
|
@@ -188,7 +243,20 @@ export function computeInputsHash(root) {
|
|
|
188
243
|
// on iteration order, and ICU collation varies with the machine's locale
|
|
189
244
|
// (e.g. a da_DK machine orders "aa" after "z"; en orders case-insensitively
|
|
190
245
|
// where code units do not) — the same tree must hash identically everywhere.
|
|
191
|
-
const
|
|
246
|
+
const surface = resolveVerifiedSurface(root);
|
|
247
|
+
const files = [...new Set(resolveSurfaceFiles(root, surface))].sort();
|
|
248
|
+
|
|
249
|
+
// A surface that matches NOTHING is a misconfiguration, not a valid hash.
|
|
250
|
+
// Hashing zero files yields a stable, confident-looking digest that attests
|
|
251
|
+
// the empty set — the silent shrink this whole change exists to prevent, in
|
|
252
|
+
// its most extreme form. Refuse, and name what was looked for.
|
|
253
|
+
if (files.length === 0) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`the verified surface matched no files under ${root} — nothing would be attested. ` +
|
|
256
|
+
`Surface: ${surface.join(", ")}. ` +
|
|
257
|
+
`A project whose code lives elsewhere declares its own in ${SURFACE_CONFIG_REL}: {"surface": ["services", "qa", …]}.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
192
260
|
|
|
193
261
|
const overall = createHash("sha256");
|
|
194
262
|
for (const relPath of files) {
|
|
@@ -45,6 +45,51 @@ export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
|
|
|
45
45
|
* FAIL verdict), so callers don't pay for a hash they don't need.
|
|
46
46
|
* @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Does this receipt's own row-level evidence support its PASS?
|
|
50
|
+
*
|
|
51
|
+
* The receipt is necessarily excluded from the inputs hash it carries — a file
|
|
52
|
+
* cannot hash itself — so steps[] is the only thing between this gate and a text
|
|
53
|
+
* editor, and the top-level verdict is the most editable field on it.
|
|
54
|
+
*
|
|
55
|
+
* Two failures this catches, both observed downstream (payment-blueprint F2/F3):
|
|
56
|
+
* a receipt whose verdict was hand-edited from FAIL to PASS while its rows still
|
|
57
|
+
* said otherwise, and a lane made green by DELETING harness.lock.json, which
|
|
58
|
+
* downgraded harnessIntegrity from FAIL to SKIP and took the lane's verdict with
|
|
59
|
+
* it — a lane vouching for a tree with nothing vouching for the lane.
|
|
60
|
+
*
|
|
61
|
+
* @param {{verdict?: string, steps?: Array<{name?: string, verdict?: string}>}} receipt
|
|
62
|
+
* @returns {{ok: boolean, detail: string}}
|
|
63
|
+
*/
|
|
64
|
+
export function checkLaneVouching(receipt) {
|
|
65
|
+
const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
|
|
66
|
+
if (!steps || steps.length === 0) {
|
|
67
|
+
return { ok: false, detail: "receipt lists no verify-lane steps — a PASS over nothing attests nothing" };
|
|
68
|
+
}
|
|
69
|
+
const failed = steps.filter((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR"));
|
|
70
|
+
if (failed.length > 0) {
|
|
71
|
+
const names = failed.map((s) => `${s.name ?? "?"} (${s.verdict})`).join(", ");
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
detail: `the receipt's verdict is PASS but ${failed.length} step(s) did not pass: ${names} — the row is the more specific truth`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const integrity = steps.find((s) => s && s.name === "harnessIntegrity");
|
|
78
|
+
if (!integrity) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
detail: "receipt has no harnessIntegrity row — nothing vouches that the lane's own code is the code that ran",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (integrity.verdict !== "PASS") {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
detail: `harnessIntegrity is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, detail: "lane vouched for itself (harnessIntegrity PASS, no failing rows)" };
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
export function evaluateReceipt(receipt, recompute) {
|
|
49
94
|
const profile = receipt.profile;
|
|
50
95
|
|
|
@@ -83,6 +128,13 @@ export function evaluateReceipt(receipt, recompute) {
|
|
|
83
128
|
};
|
|
84
129
|
}
|
|
85
130
|
|
|
131
|
+
// Did the lane vouch for ITSELF? See checkLaneVouching — the top-level verdict
|
|
132
|
+
// is the most editable field on a file the hash cannot cover.
|
|
133
|
+
const vouching = checkLaneVouching(receipt);
|
|
134
|
+
if (!vouching.ok) {
|
|
135
|
+
return { valid: false, reason: `${vouching.detail} (attesting profile: ${profile ?? "unknown"})`, profile, recomputed };
|
|
136
|
+
}
|
|
137
|
+
|
|
86
138
|
return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
|
|
87
139
|
}
|
|
88
140
|
|
|
@@ -134,7 +186,10 @@ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_PO
|
|
|
134
186
|
if (!steps || steps.length === 0) {
|
|
135
187
|
return { ok: false, detail: "receipt lists no verify-lane steps — nothing was executed" };
|
|
136
188
|
}
|
|
137
|
-
|
|
189
|
+
// Executed = produced a verdict about the tree. SKIP did not try; ERROR
|
|
190
|
+
// tried and could not (a deadline, zero tests, a throw) — neither measured
|
|
191
|
+
// anything, so neither counts toward "this lane verified something".
|
|
192
|
+
const executed = steps.filter((s) => s && s.verdict !== "SKIP" && s.verdict !== "ERROR");
|
|
138
193
|
if (executed.length === 0) {
|
|
139
194
|
return { ok: false, detail: "every step in the receipt is a SKIP — the lane verified nothing" };
|
|
140
195
|
}
|
package/template/CLAUDE.md
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
Generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a verification
|
|
5
5
|
harness. Every AI session in this repo works under this contract.
|
|
6
6
|
|
|
7
|
+
**Principles** (the full form, with the episode behind each, is create-cmp's
|
|
8
|
+
`docs/PRINCIPLES.md`): derived, never claimed · prove the instrument before you read it · the
|
|
9
|
+
layer you changed cannot certify itself · proof costs what the change costs and never runs
|
|
10
|
+
silent · never wait on nothing · a signature binds content, a decision is closed · one record,
|
|
11
|
+
read first. These govern every rule below; when a rule below and a principle disagree, the
|
|
12
|
+
principle wins and the rule is the bug.
|
|
13
|
+
|
|
7
14
|
## Definition of done
|
|
8
15
|
|
|
9
16
|
Done means `node qa/verify.mjs` reports PASS and the receipt it writes
|
|
@@ -38,6 +45,13 @@ it; CI still enforces it).
|
|
|
38
45
|
New behavior begins as a spec clause in `specs/<feature>.spec.md`: Given/When/Then with a
|
|
39
46
|
stable id (see [`specs/README.md`](./specs/README.md)). Propose the clause, get it confirmed,
|
|
40
47
|
then implement. Durable tests cite their clause (`// SPEC: HOME-02`).
|
|
48
|
+
|
|
49
|
+
**A clause about device behavior must say so.** A citation proves a test *exists*; it cannot
|
|
50
|
+
prove that test could ever *observe* the promise. Add `[tier: device]` (or `[tier: e2e]`)
|
|
51
|
+
after the id when the claim is about OS facts a host JVM cannot see — lifecycle, alarms,
|
|
52
|
+
notifications, permissions, real navigation. `specCoverage` then requires a citation from
|
|
53
|
+
`androidInstrumentedTest` or `qa/e2e` and FAILS without one, rather than accepting a
|
|
54
|
+
desktop test that is structurally blind to the claim.
|
|
41
55
|
[`specs/app-base.spec.md`](./specs/app-base.spec.md) states the architecture and shell
|
|
42
56
|
invariants the conformance gates enforce.
|
|
43
57
|
|
|
@@ -90,8 +104,15 @@ the tree. The governed `architecture` artifact (below) hashes the document along
|
|
|
90
104
|
if the test itself is wrong, say so in your summary and justify the change.
|
|
91
105
|
|
|
92
106
|
**Platform behavior tests live in `composeApp/src/androidInstrumentedTest`** — when a
|
|
93
|
-
feature touches alarms, notifications, lock-screen intents,
|
|
94
|
-
|
|
107
|
+
feature touches alarms, notifications, lock-screen intents, audio routing, **or app/process
|
|
108
|
+
lifecycle** (cold start vs warm resume, "once per process start", process death and
|
|
109
|
+
restore, `ON_STOP`/`ON_START`), its behavior test goes there, because no desktop tier can
|
|
110
|
+
see those OS facts. A desktop Compose test has no process lifecycle *at all*, so a claim
|
|
111
|
+
about one is unobservable there by construction — and `ProcessControl` below is the organ
|
|
112
|
+
that puts the device into the state such a claim is about. **Declare it on the clause**:
|
|
113
|
+
`- **MOTION-13** [tier: device] — Given a cold start, …`. The lane's `specCoverage` then
|
|
114
|
+
FAILS unless a test from a tier that can actually see it cites the clause, instead of
|
|
115
|
+
accepting a citation from a tier that cannot. Assertion helpers:
|
|
95
116
|
`NotificationAsserts`, `AlarmAsserts`, `SystemState`. **Runtime state control** — put the
|
|
96
117
|
device into the state your claim is about, instead of waiting for it: `TimeWarp` (clock,
|
|
97
118
|
timezone), `DozeControl` (forced idle), `PermissionControl`, `ProcessControl`,
|
|
@@ -198,6 +219,20 @@ understood the change to be, which lane it takes, and why, before any tool runs.
|
|
|
198
219
|
can overrule the lane in a word; a silent route is a routing error even when the lane was
|
|
199
220
|
right.
|
|
200
221
|
|
|
222
|
+
**Grill before the brief** (the `grill-me` plugin skill; the rule holds without the plugin):
|
|
223
|
+
on the brief lane, after the triage restatement and before a word of the brief is drafted,
|
|
224
|
+
settle the load-bearing questions. Read what the repo already answers first — a signed brief
|
|
225
|
+
or spec is a CLOSED decision: cite it, never re-ask it. Then ask the frontier of unsettled
|
|
226
|
+
decisions as a numbered list, at most five per round, each with why it matters and a
|
|
227
|
+
recommended answer — and WAIT for the answers before anything else. Stop when no remaining
|
|
228
|
+
question would change the work; three rounds is the ceiling (more means the request needs
|
|
229
|
+
splitting). Answers land in the brief — settled calls become **Decisions** with their why,
|
|
230
|
+
the human's own calls the **Open decisions** section; the brief's signature closes them. The
|
|
231
|
+
direct lane is not grilled (one inline question at most, only when the restatement cannot
|
|
232
|
+
be made unambiguous); a bug fix or an emergency fix, never. While the grill is open, the
|
|
233
|
+
chain's first step reads `settle the open questions` (declare it before the first round;
|
|
234
|
+
re-declare when the answers reshape the steps).
|
|
235
|
+
|
|
201
236
|
**Brief lane** — when the change carries **decisions a future contributor could plausibly
|
|
202
237
|
"simplify" away** ("the day boundary is configurable, default 04:00 — not midnight") OR
|
|
203
238
|
**blast radius into other governed artifacts**. After naming the lane:
|
|
@@ -330,12 +365,21 @@ steps you just printed:
|
|
|
330
365
|
node qa/plan.mjs --set "sign the brief | draft screens | agree the promises | build | full check | your sign-off" --title "navigation redesign"
|
|
331
366
|
```
|
|
332
367
|
|
|
368
|
+
**The chain is an offer, not an announcement** (drive-narration N6): show the declared
|
|
369
|
+
steps in your first reply and invite the reshape in one breath — "say the word and I'll
|
|
370
|
+
reorder" — then start work immediately; the chain gates nothing, so the offer never
|
|
371
|
+
blocks. If the human redirects, re-declare (`--set` again) without ceremony: their
|
|
372
|
+
reshape IS the new chain.
|
|
373
|
+
|
|
333
374
|
**The chain stays current** — this is part of the contract, not a nicety: advance it
|
|
334
375
|
with `node qa/plan.mjs --step N` as each step lands and `--done` when the request
|
|
335
|
-
lands
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
376
|
+
lands (closing writes the request's line into the local trail the studio's Recent
|
|
377
|
+
requests fold shows). The current request itself is recorded mechanically (the
|
|
378
|
+
per-prompt hook), the steps are yours to declare, and every surface shows the
|
|
379
|
+
declaration's age — a stale chain reads as stale to the human watching the studio,
|
|
380
|
+
which is worse than no chain. While the full check runs, the chain's observed line
|
|
381
|
+
narrates the lane's own position (step, elapsed, usual cost) — quote THAT, never an
|
|
382
|
+
estimate. The chain gates nothing; the walk stays the truth for doneness.
|
|
339
383
|
|
|
340
384
|
**The studio is a standing check:** every injected context opens with a `[studio: …]`
|
|
341
385
|
line. If it says DOWN or not running, restore it before proceeding — call the
|
|
@@ -481,6 +525,8 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
|
|
|
481
525
|
| `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
|
|
482
526
|
| `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
|
|
483
527
|
| `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
|
|
528
|
+
| `node qa/verify.mjs --profile smoke` | The smallest end-to-end lane: every pure-Node gate, no Gradle, no device — seconds. Proves the framework *returns*, never the change (its receipt is refused as done-evidence). Run it first in any repo whose harness is new or freshly upgraded |
|
|
529
|
+
| `node qa/verify.mjs --profile nightly` | Scheduled stage: everything `ci` proves with the determinism probe forced on. Proves the harness, never a change — its receipt (`stage: "nightly"`) is refused as done-evidence, exactly like `--fast`. Schedule it; never wait on it |
|
|
484
530
|
| `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the audit-cadence report (`auditCadence` — which androidMain subsystems changed since their last recorded `cmp-audit`; a nudge, never a gate) and the release-APK Maestro smoke (`releaseSmoke`) |
|
|
485
531
|
| `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
|
|
486
532
|
| `node qa/record-audit.mjs <subsystem>` | Record that a `cmp-audit` of an androidMain subsystem happened (appends subsystem + HEAD sha + timestamp to `qa/audits.jsonl`; refuses dirty/unknown targets). `--list` shows every derived subsystem and its audit status |
|
|
@@ -119,7 +119,6 @@ class ArchitectureConformanceTest {
|
|
|
119
119
|
)
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// SPEC: ARCH-04
|
|
123
122
|
// Component-derived tags (component-system-deep-dive.md §6.4) count as tag provenance:
|
|
124
123
|
// a screen built entirely from registry components (ScreenColumn/AppHeader/
|
|
125
124
|
// ContentStateContainer/…) is automation-reachable through the tags THOSE components
|
|
@@ -138,6 +137,7 @@ class ArchitectureConformanceTest {
|
|
|
138
137
|
return text.contains("testTag") || (importsComponents && screenTagArgument.containsMatchIn(text))
|
|
139
138
|
}
|
|
140
139
|
|
|
140
|
+
// SPEC: ARCH-04
|
|
141
141
|
@Test
|
|
142
142
|
fun `ARCH-04 every feature composable file is automation-reachable - literal testTag or screenTag provenance`() {
|
|
143
143
|
// Scoped by CONTENT (contains @Composable), not by *Screen.kt filename: real apps
|
package/template/gitignore
CHANGED
|
@@ -41,3 +41,6 @@ xcuserdata/
|
|
|
41
41
|
# hard-excluded from the receipt's hashed input surface (qa/lib/inputs-hash.mjs).
|
|
42
42
|
qa/.request.json
|
|
43
43
|
qa/.plan.json
|
|
44
|
+
# The closed-chain trail (drive-narration N5): local because it carries raw
|
|
45
|
+
# human prompts — the committed journal for lane runs stays qa/flight-recorder.jsonl.
|
|
46
|
+
qa/.plan-history.jsonl
|
package/template/qa/approve.mjs
CHANGED
|
@@ -134,7 +134,13 @@ function refuseIfUnresolvable() {
|
|
|
134
134
|
|
|
135
135
|
if (args.includes("--accept-defaults")) {
|
|
136
136
|
refuseIfUnresolvable();
|
|
137
|
-
const
|
|
137
|
+
const expressAsIdx = args.indexOf("--as");
|
|
138
|
+
const expressSigner = expressAsIdx >= 0 ? args[expressAsIdx + 1] : undefined;
|
|
139
|
+
if (!expressSigner || expressSigner.startsWith("--")) {
|
|
140
|
+
console.error('--accept-defaults needs a signer: node qa/approve.mjs --accept-defaults --as "Your Name <you@example.com>"');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const { approved, skipped } = approveAllDefaults(ROOT, expressSigner);
|
|
138
144
|
for (const id of approved) {
|
|
139
145
|
console.log(`✓ approved ${id} [defaults-accepted]`);
|
|
140
146
|
}
|
|
@@ -201,9 +207,16 @@ if (reopenFeatureFlagIdx !== -1) {
|
|
|
201
207
|
console.error(`error: ${result.reason}`);
|
|
202
208
|
process.exit(1);
|
|
203
209
|
}
|
|
210
|
+
const inScope = result.reopened.length + result.skipped.length + (result.stillSigned ?? []).length;
|
|
204
211
|
console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
|
|
212
|
+
console.log(` ${inScope} in scope · ${result.reopened.length} reopened · ${(result.stillSigned ?? []).length} still signed`);
|
|
205
213
|
for (const id of result.reopened) console.log(` ↺ ${id}`);
|
|
206
214
|
for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
|
|
215
|
+
// The declared blast radius is reported, not walked back: a signature is
|
|
216
|
+
// demanded again only if the change actually moves the bytes it covers.
|
|
217
|
+
for (const t of result.stillSigned ?? []) {
|
|
218
|
+
console.log(` ✓ ${t.id} still signed (${t.status}${t.hash ? ` @${t.hash}` : ""}) — re-signature demanded only if it changes; the hash enforces that`);
|
|
219
|
+
}
|
|
207
220
|
process.exit(0);
|
|
208
221
|
}
|
|
209
222
|
|
|
@@ -239,7 +252,22 @@ if (args.length === 0) {
|
|
|
239
252
|
refuseIfUnresolvable();
|
|
240
253
|
|
|
241
254
|
const artifactId = args[0];
|
|
242
|
-
|
|
255
|
+
// The signer is REQUIRED, not optional: see approveArtifact's refusal. Parsed
|
|
256
|
+
// here rather than defaulted from git config on purpose — `git config user.name`
|
|
257
|
+
// is whatever the machine says, and an agent running on a developer's laptop
|
|
258
|
+
// would sign with that developer's name. An approval must be typed by whoever
|
|
259
|
+
// is answerable for it.
|
|
260
|
+
const asIndex = args.indexOf("--as");
|
|
261
|
+
const approvedBy = asIndex >= 0 ? args[asIndex + 1] : undefined;
|
|
262
|
+
if (!approvedBy || approvedBy.startsWith("--")) {
|
|
263
|
+
console.error(
|
|
264
|
+
'approve needs a signer: node qa/approve.mjs <artifact> --as "Your Name <you@example.com>"\n' +
|
|
265
|
+
"An approval is a signature on a hash; a row that records no signer cannot tell a human's\n" +
|
|
266
|
+
"sign-off from an agent's.",
|
|
267
|
+
);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
const result = approveArtifact(ROOT, artifactId, { via: "cli", approvedBy });
|
|
243
271
|
if (!result.ok) {
|
|
244
272
|
console.error(`error: ${result.reason}`);
|
|
245
273
|
process.exit(1);
|
|
@@ -781,6 +781,7 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
781
781
|
hash: recomputed.hash,
|
|
782
782
|
storedHash: storedRecord.hash ?? null,
|
|
783
783
|
approvedAt: storedRecord.approvedAt ?? null,
|
|
784
|
+
approvedBy: storedRecord.approvedBy ?? null,
|
|
784
785
|
fileCount: recomputed.fileCount,
|
|
785
786
|
missing: recomputed.missing,
|
|
786
787
|
resolvable,
|
|
@@ -801,6 +802,7 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
801
802
|
hash: recomputed.hash,
|
|
802
803
|
storedHash: null,
|
|
803
804
|
approvedAt: null,
|
|
805
|
+
approvedBy: null,
|
|
804
806
|
fileCount: recomputed.fileCount,
|
|
805
807
|
missing: recomputed.missing,
|
|
806
808
|
resolvable,
|
|
@@ -834,6 +836,10 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
834
836
|
hash: recomputed.hash,
|
|
835
837
|
storedHash: storedRecord.hash,
|
|
836
838
|
approvedAt: storedRecord.approvedAt,
|
|
839
|
+
// WHO signed. Null on rows written before signers were recorded; the gate
|
|
840
|
+
// treats a signed-by-nobody approval as FAIL, because that row cannot tell
|
|
841
|
+
// a human's sign-off from an agent's.
|
|
842
|
+
approvedBy: storedRecord.approvedBy ?? null,
|
|
837
843
|
fileCount: recomputed.fileCount,
|
|
838
844
|
missing: recomputed.missing,
|
|
839
845
|
resolvable,
|
|
@@ -920,7 +926,23 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
920
926
|
const state = loadApprovals(root);
|
|
921
927
|
const others = state.artifacts.filter((a) => a.artifact !== artifactId);
|
|
922
928
|
const approvedAt = new Date().toISOString();
|
|
923
|
-
|
|
929
|
+
if (!options.approvedBy || !String(options.approvedBy).trim()) {
|
|
930
|
+
return {
|
|
931
|
+
ok: false,
|
|
932
|
+
reason:
|
|
933
|
+
`cannot approve "${artifactId}" — no signer was given. An approval is a person's ` +
|
|
934
|
+
"signature on a hash; a row that records no signer cannot distinguish a human's sign-off " +
|
|
935
|
+
"from an agent's, and an agent that invalidates an approval can clear it by re-approving. " +
|
|
936
|
+
"Pass the signer: `node qa/approve.mjs <artifact> --as \"Name <email>\"`.",
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
const record = {
|
|
940
|
+
artifact: artifactId,
|
|
941
|
+
status: "approved",
|
|
942
|
+
hash: resolved.hash,
|
|
943
|
+
approvedAt,
|
|
944
|
+
approvedBy: String(options.approvedBy).trim(),
|
|
945
|
+
};
|
|
924
946
|
if (options.mode) record.mode = options.mode;
|
|
925
947
|
if (options.via) record.via = options.via;
|
|
926
948
|
others.push(record);
|
|
@@ -929,10 +951,11 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
929
951
|
verb: "approve",
|
|
930
952
|
artifact: artifactId,
|
|
931
953
|
hash: resolved.hash,
|
|
954
|
+
approvedBy: String(options.approvedBy).trim(),
|
|
932
955
|
...(options.via ? { via: options.via } : {}),
|
|
933
956
|
...(options.mode ? { mode: options.mode } : {}),
|
|
934
957
|
});
|
|
935
|
-
return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, ...(options.mode ? { mode: options.mode } : {}) };
|
|
958
|
+
return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, approvedBy: record.approvedBy, ...(options.mode ? { mode: options.mode } : {}) };
|
|
936
959
|
}
|
|
937
960
|
|
|
938
961
|
/**
|
|
@@ -945,7 +968,7 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
945
968
|
* @param {string} root
|
|
946
969
|
* @returns {{ok: true, approved: string[], skipped: Array<{id: string, reason: string}>}}
|
|
947
970
|
*/
|
|
948
|
-
export function approveAllDefaults(root) {
|
|
971
|
+
export function approveAllDefaults(root, approvedBy) {
|
|
949
972
|
const registry = listGovernedArtifacts(root);
|
|
950
973
|
const state = loadApprovals(root);
|
|
951
974
|
const byId = new Map(state.artifacts.map((a) => [a.artifact, a]));
|
|
@@ -954,7 +977,7 @@ export function approveAllDefaults(root) {
|
|
|
954
977
|
for (const artifact of registry) {
|
|
955
978
|
const live = resolveArtifactStatus(root, artifact, byId.get(artifact.id));
|
|
956
979
|
if (live.status === "approved") continue; // already settled — never overwritten by the express lane
|
|
957
|
-
const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted" });
|
|
980
|
+
const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted", approvedBy });
|
|
958
981
|
if (result.ok) approved.push(artifact.id);
|
|
959
982
|
else skipped.push({ id: artifact.id, reason: result.reason });
|
|
960
983
|
}
|
|
@@ -1066,13 +1089,33 @@ export function reopenFeature(root, name, options = {}) {
|
|
|
1066
1089
|
// The spec side of the family follows the brief's own pairing (a multi-spec
|
|
1067
1090
|
// brief reopens every spec its promises live in), defaulting to the name.
|
|
1068
1091
|
const specIds = (derived?.specNames ?? [name]).map((n) => `feature-spec:${n}`);
|
|
1069
|
-
|
|
1092
|
+
// WHAT A FEATURE REOPEN WALKS BACK (evidence-economics S5, aligning this
|
|
1093
|
+
// function with CHANGE-FLOW-DESIGN.md §"touches": "hashes enforce,
|
|
1094
|
+
// declaration lets the console tell as-planned from undeclared blast").
|
|
1095
|
+
//
|
|
1096
|
+
// reopened the brief, its declared spec(s), and its design when the
|
|
1097
|
+
// brief declares a UI surface — the documents the change
|
|
1098
|
+
// will AMEND. Their signatures are walked back on purpose.
|
|
1099
|
+
// stillSigned the declared `touches`. Before this, every one of them was
|
|
1100
|
+
// reopened too, and every one came back byte-identical:
|
|
1101
|
+
// twelve signatures for zero changes (design-system
|
|
1102
|
+
// d8fbdce8 → d8fbdce8). An `approved` artifact is, by
|
|
1103
|
+
// definition, one whose bytes still match what was signed —
|
|
1104
|
+
// so reopening it re-asks a question the hash has already
|
|
1105
|
+
// answered. Worse than wasted: it trains the signer to
|
|
1106
|
+
// approve without reading, the exact habit approvals exist
|
|
1107
|
+
// to prevent. They stay signed. If the change DOES move one,
|
|
1108
|
+
// its hash flips it to `changed` and demands a fresh
|
|
1109
|
+
// signature — the enforcement the doc always assigned to the
|
|
1110
|
+
// hash, not to this verb.
|
|
1111
|
+
const amendSet = [briefId, ...specIds, ...(derived?.screens ? [`${FEATURE_DESIGN_PREFIX}${name}`] : [])];
|
|
1112
|
+
const touchSet = (derived ? derived.touches : []).filter((id) => !amendSet.includes(id));
|
|
1070
1113
|
const byId = new Map(getApprovalStatuses(root).map((s) => [s.id, s]));
|
|
1071
1114
|
const reopened = [];
|
|
1072
1115
|
const skipped = [];
|
|
1073
|
-
for (const id of [...new Set(
|
|
1116
|
+
for (const id of [...new Set(amendSet)]) {
|
|
1074
1117
|
const live = byId.get(id);
|
|
1075
|
-
if (!live) continue; //
|
|
1118
|
+
if (!live) continue; // resolves to no governed artifact — nothing to reopen
|
|
1076
1119
|
if (live.status !== "approved") {
|
|
1077
1120
|
skipped.push({ id, status: live.status });
|
|
1078
1121
|
continue;
|
|
@@ -1081,15 +1124,21 @@ export function reopenFeature(root, name, options = {}) {
|
|
|
1081
1124
|
if (result.ok) reopened.push(id);
|
|
1082
1125
|
else skipped.push({ id, status: `refused: ${result.reason}` });
|
|
1083
1126
|
}
|
|
1127
|
+
const stillSigned = [];
|
|
1128
|
+
for (const id of [...new Set(touchSet)]) {
|
|
1129
|
+
const live = byId.get(id);
|
|
1130
|
+
if (!live) continue;
|
|
1131
|
+
stillSigned.push({ id, status: live.status, hash: typeof live.hash === "string" ? live.hash.slice(0, 8) : null });
|
|
1132
|
+
}
|
|
1084
1133
|
if (reopened.length === 0) {
|
|
1085
1134
|
return {
|
|
1086
1135
|
ok: false,
|
|
1087
1136
|
reason:
|
|
1088
|
-
`nothing in "${name}"'s set is currently approved — there is no signature to walk back. ` +
|
|
1089
|
-
`Set: ${[...new Set(
|
|
1137
|
+
`nothing in "${name}"'s amend set is currently approved — there is no signature to walk back. ` +
|
|
1138
|
+
`Set: ${[...new Set(amendSet)].join(", ")}; states: ${skipped.map((s) => `${s.id}=${s.status}`).join(", ") || "(unresolved)"}`,
|
|
1090
1139
|
};
|
|
1091
1140
|
}
|
|
1092
|
-
return { ok: true, feature: name, reopened, skipped };
|
|
1141
|
+
return { ok: true, feature: name, reopened, skipped, stillSigned };
|
|
1093
1142
|
}
|
|
1094
1143
|
|
|
1095
1144
|
// ── The verify-lane gate ─────────────────────────────────────────────────────
|
|
@@ -1117,6 +1166,21 @@ export function evaluateApprovalsGate(root) {
|
|
|
1117
1166
|
const statuses = getApprovalStatuses(root);
|
|
1118
1167
|
const mismatched = statuses.filter((s) => s.status === "changed-since-approval");
|
|
1119
1168
|
const pending = statuses.filter((s) => s.status === "unreviewed" || s.status === "reopened");
|
|
1169
|
+
// An "approved" row with no signer attests nothing about WHO signed, which is
|
|
1170
|
+
// the one fact an approval exists to record. It cannot distinguish a human's
|
|
1171
|
+
// sign-off from an agent's, and an agent that invalidates an approval can
|
|
1172
|
+
// clear it by re-approving — the gate then guards only against accident, not
|
|
1173
|
+
// against the population it is pointed at. Rows written before signers were
|
|
1174
|
+
// recorded land here; the fix is one re-approval each, and the message says so.
|
|
1175
|
+
const unsigned = statuses.filter((s) => s.status === "approved" && !s.approvedBy);
|
|
1176
|
+
|
|
1177
|
+
if (unsigned.length > 0) {
|
|
1178
|
+
const lines = ["Approval recorded without a signer — re-approve to say who signed:"];
|
|
1179
|
+
for (const s of unsigned) {
|
|
1180
|
+
lines.push(` [${s.id}] ${s.label} — approved ${shortHash(s.storedHash)} by nobody. Re-approve: node qa/approve.mjs ${s.id} --as "Your Name <you@example.com>"`);
|
|
1181
|
+
}
|
|
1182
|
+
return { verdict: "FAIL", reason: lines.join("\n"), statuses };
|
|
1183
|
+
}
|
|
1120
1184
|
|
|
1121
1185
|
if (mismatched.length > 0) {
|
|
1122
1186
|
const lines = ["Approval invalidated — a governed artifact changed after sign-off:"];
|
|
@@ -84,7 +84,9 @@ const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release"
|
|
|
84
84
|
export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
|
|
85
85
|
if (mode === "fast") return null; // the inner loop derives no rung — ever
|
|
86
86
|
const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
|
|
87
|
-
|
|
87
|
+
// A failed lane has no rung — and a lane with a step that could not run
|
|
88
|
+
// (ERROR) has none either: a rung is evidence, and "could not check" is not.
|
|
89
|
+
if (steps.some((s) => s.verdict === "FAIL" || s.verdict === "ERROR")) return null;
|
|
88
90
|
const passed = new Set(steps.filter((s) => s.verdict === "PASS").map((s) => s.name));
|
|
89
91
|
|
|
90
92
|
if (!L0_REQUIRED.every((name) => passed.has(name))) return null; // not even a stamp-time green build
|
|
@@ -93,7 +93,14 @@ export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps,
|
|
|
93
93
|
verdict,
|
|
94
94
|
evidenceRung: evidenceLevel?.rung ?? null,
|
|
95
95
|
durationMs,
|
|
96
|
-
|
|
96
|
+
// durationMs per step (additive, schema id unchanged — old entries stay
|
|
97
|
+
// readable): the source for the lane's own "usually ~Ns" narration
|
|
98
|
+
// (drive-narration N4). Quoted from the journal, never from memory.
|
|
99
|
+
steps: stepList.map((s) => ({
|
|
100
|
+
name: s.name,
|
|
101
|
+
verdict: s.verdict,
|
|
102
|
+
...(typeof s.durationMs === "number" && s.durationMs >= 0 ? { durationMs: s.durationMs } : {}),
|
|
103
|
+
})),
|
|
97
104
|
// SKIP reasons verbatim — the journal's core signal (see file header).
|
|
98
105
|
skips: stepList.filter((s) => s.verdict === "SKIP").map((s) => ({ step: s.name, reason: s.reason ?? "" })),
|
|
99
106
|
deviceSteps: Array.isArray(onDeviceSteps) ? onDeviceSteps : [],
|
|
@@ -198,7 +205,15 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
|
|
|
198
205
|
// JSON-array key: reasons are arbitrary text, so a delimiter-joined
|
|
199
206
|
// string key would be ambiguous — and ambiguity here merges two
|
|
200
207
|
// different problems into one count.
|
|
201
|
-
|
|
208
|
+
//
|
|
209
|
+
// Grouped on the reason's FIRST LINE, which is exactly what the report
|
|
210
|
+
// prints. Several gates (approvals above all) end their reason with a
|
|
211
|
+
// variable list of artifact names, so keying on the whole string split
|
|
212
|
+
// ONE recurring reason into seven near-identical rows carrying the same
|
|
213
|
+
// visible text — a count the reader had to add up by eye. The detail is
|
|
214
|
+
// not lost: the verbatim reasons are still in the journal, which is the
|
|
215
|
+
// artifact that owes verbatim. The REPORT owes legibility.
|
|
216
|
+
const key = JSON.stringify([s.step ?? "?", (s.reason ?? "").split("\n")[0]]);
|
|
202
217
|
skipGroups.set(key, (skipGroups.get(key) ?? 0) + 1);
|
|
203
218
|
}
|
|
204
219
|
}
|
|
@@ -258,6 +273,36 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
|
|
|
258
273
|
};
|
|
259
274
|
}
|
|
260
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Steps that SKIPped in THIS run and have skipped in EVERY recorded full run —
|
|
278
|
+
* a tier that has never executed on this machine.
|
|
279
|
+
*
|
|
280
|
+
* A single SKIP is a fact; skipping every recorded run is a different fact,
|
|
281
|
+
* and only the journal can tell them apart. maestro was never installed on one
|
|
282
|
+
* machine, so e2eSmoke skipped on all 37 recorded runs while the lane said
|
|
283
|
+
* PASS each time — the end-to-end flow had never run once, and nothing said so.
|
|
284
|
+
*
|
|
285
|
+
* Needs a journal long enough to mean something: below `floor` recorded runs
|
|
286
|
+
* carrying the step, "every time" is a coincidence, not a pattern.
|
|
287
|
+
*
|
|
288
|
+
* @param {Array<{name: string, verdict: string, reason?: string}>} steps this run's results
|
|
289
|
+
* @param {object[]} entries parsed journal entries (any mode; fast runs are ignored)
|
|
290
|
+
* @param {{floor?: number}} [opts]
|
|
291
|
+
* @returns {Array<{name: string, runs: number, reason: string}>}
|
|
292
|
+
*/
|
|
293
|
+
export function neverRunTiers(steps, entries, { floor = 3 } = {}) {
|
|
294
|
+
const full = (Array.isArray(entries) ? entries : []).filter((e) => e && e.mode !== "fast" && Array.isArray(e.steps));
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const st of (Array.isArray(steps) ? steps : []).filter((x) => x && x.verdict === "SKIP")) {
|
|
297
|
+
const seen = full.filter((e) => e.steps.some((s) => s && s.name === st.name));
|
|
298
|
+
// "Ran" means produced a verdict about the tree: PASS or FAIL. An ERROR
|
|
299
|
+
// tried and could not; it is not evidence that the tier works here.
|
|
300
|
+
const ran = seen.filter((e) => e.steps.some((s) => s && s.name === st.name && (s.verdict === "PASS" || s.verdict === "FAIL")));
|
|
301
|
+
if (seen.length >= floor && ran.length === 0) out.push({ name: st.name, runs: seen.length, reason: st.reason ?? "" });
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
261
306
|
/**
|
|
262
307
|
* Render the summary as the plain-text report a human reads in ten seconds.
|
|
263
308
|
* Every line is a recorded fact; the honesty notes (short journal, single
|