create-cmp-cli 0.23.0 → 0.25.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 +33 -3
- package/package.json +10 -4
- package/packages/harness/package.json +18 -8
- package/packages/harness/src/approve.mjs +46 -18
- package/packages/harness/src/console/console-data.mjs +117 -0
- package/packages/harness/src/console/console-evidence.mjs +122 -0
- package/packages/harness/src/console/console-overview.mjs +642 -0
- package/packages/harness/src/console/console-shell.mjs +1139 -0
- package/packages/harness/src/console/console-tabs.mjs +2828 -0
- package/packages/harness/src/console/contrast.mjs +74 -0
- package/packages/harness/src/console/preview-service.mjs +1384 -0
- package/packages/harness/src/framework-check.mjs +747 -0
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/agent-hold.mjs +234 -0
- package/packages/harness/src/lib/approvals.mjs +177 -350
- package/packages/harness/src/lib/audit-cadence.mjs +1 -1
- package/packages/harness/src/lib/comments.mjs +18 -4
- package/packages/harness/src/lib/determinism.mjs +275 -14
- package/packages/harness/src/lib/evidence-badge.mjs +38 -6
- package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
- package/packages/harness/src/lib/evidence-level.mjs +144 -59
- package/packages/harness/src/lib/feature-brief.mjs +53 -16
- package/packages/harness/src/lib/flight-recorder.mjs +72 -8
- package/packages/harness/src/lib/framework-check.mjs +778 -0
- package/packages/harness/src/lib/harness-lock.mjs +87 -19
- package/packages/harness/src/lib/harness-manifest.mjs +208 -0
- package/packages/harness/src/lib/harness-region.mjs +106 -2
- package/packages/harness/src/lib/harness-source.mjs +142 -0
- package/packages/harness/src/lib/inputs-hash.mjs +180 -22
- package/packages/harness/src/lib/lane-markers.mjs +78 -0
- package/packages/harness/src/lib/lane-narrator.mjs +4 -2
- package/packages/harness/src/lib/lane-runner.mjs +72 -6
- package/packages/harness/src/lib/plan.mjs +44 -11
- package/packages/harness/src/lib/plant-calibration.mjs +135 -0
- package/packages/harness/src/lib/profile-loader.mjs +323 -0
- package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
- package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
- package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
- package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
- package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
- package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
- package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
- package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
- package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/packages/harness/src/lib/receipt-validate.mjs +84 -16
- package/packages/harness/src/lib/spec-coverage.mjs +179 -92
- package/packages/harness/src/lib/spec-model.mjs +262 -0
- package/packages/harness/src/lib/step-cache.mjs +11 -2
- package/packages/harness/src/lib/step-outcomes.mjs +84 -151
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/plan.mjs +30 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +95 -26
- package/packages/harness/src/record-audit.mjs +1 -1
- package/packages/harness/src/scaffold-feature.mjs +10 -2
- package/packages/harness/src/verify.mjs +222 -142
- package/packages/harness/src/watch.mjs +146 -33
- package/packages/receipts/package.json +1 -1
- package/packages/receipts/src/index.mjs +2 -2
- package/packages/receipts/src/inputs-hash.mjs +180 -22
- package/packages/receipts/src/receipt-validate.mjs +84 -16
- package/src/commands/attach.mjs +128 -4
- package/src/commands/harden.mjs +7 -1
- package/src/commands/upgrade.mjs +7 -0
- package/src/lib/minimal.mjs +23 -1
- package/src/scaffold.mjs +8 -0
- package/template/.claude/settings.json +2 -2
- package/template/.claude/skills/add-feature/SKILL.md +7 -3
- package/template/.claude/skills/add-repository/SKILL.md +2 -2
- package/template/.claude/skills/add-screen/SKILL.md +8 -4
- package/template/.githooks/pre-push +9 -0
- package/template/CLAUDE.md +55 -186
- package/template/docs/TESTING.md +1 -1
- package/template/gitignore +4 -0
- package/template/qa/approve.mjs +46 -18
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +747 -0
- package/template/qa/harness-manifest.json +10 -0
- package/template/qa/lib/a11y.mjs +1 -1
- package/template/qa/lib/affected-tests.mjs +99 -44
- package/template/qa/lib/agent-hold.mjs +234 -0
- package/template/qa/lib/approvals.mjs +177 -350
- package/template/qa/lib/audit-cadence.mjs +1 -1
- package/template/qa/lib/comments.mjs +18 -4
- package/template/qa/lib/determinism.mjs +275 -14
- package/template/qa/lib/evidence-badge.mjs +38 -6
- package/template/qa/lib/evidence-ladder.mjs +202 -0
- package/template/qa/lib/evidence-level.mjs +144 -59
- package/template/qa/lib/feature-brief.mjs +53 -16
- package/template/qa/lib/flight-recorder.mjs +72 -8
- package/template/qa/lib/framework-check.mjs +778 -0
- package/template/qa/lib/harness-lock.mjs +87 -19
- package/template/qa/lib/harness-manifest.mjs +208 -0
- package/template/qa/lib/harness-region.mjs +106 -2
- package/template/qa/lib/harness-source.mjs +142 -0
- package/template/qa/lib/inputs-hash.mjs +180 -22
- package/template/qa/lib/lane-markers.mjs +78 -0
- package/template/qa/lib/lane-narrator.mjs +4 -2
- package/template/qa/lib/lane-runner.mjs +72 -6
- package/template/qa/lib/plan.mjs +44 -11
- package/template/qa/lib/plant-calibration.mjs +135 -0
- package/template/qa/lib/profile-loader.mjs +323 -0
- package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
- package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
- package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
- package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
- package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/template/qa/lib/profiles/cmp/index.mjs +47 -0
- package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
- package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
- package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
- package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/template/qa/lib/receipt-validate.mjs +84 -16
- package/template/qa/lib/spec-coverage.mjs +179 -92
- package/template/qa/lib/spec-model.mjs +262 -0
- package/template/qa/lib/step-cache.mjs +11 -2
- package/template/qa/lib/step-outcomes.mjs +84 -151
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/plan.mjs +30 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +95 -26
- package/template/qa/record-audit.mjs +1 -1
- package/template/qa/scaffold-feature.mjs +10 -2
- package/template/qa/verified-surface.json +17 -0
- package/template/qa/verify.mjs +222 -142
- package/template/qa/watch.mjs +146 -33
- /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
- /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
|
@@ -29,92 +29,177 @@
|
|
|
29
29
|
// the inner loop, a signal rather than evidence, so a fast receipt must
|
|
30
30
|
// never be silently reused as if it were a full-lane result: pass the
|
|
31
31
|
// run's mode and "fast" derives null, always.
|
|
32
|
+
// - A PROFILE THAT SHIPS NO PLANTS EARNS NO RUNG — the badge floor. A ladder
|
|
33
|
+
// names rungs; plants are what prove the steps under them still bite. A
|
|
34
|
+
// profile with the second and not the first is a vocabulary nobody
|
|
35
|
+
// calibrated, and grading it says something about a lane that nothing
|
|
36
|
+
// checked. See "the badge floor" below for the episode and the argument.
|
|
32
37
|
// - The rung is COARSE by design. The per-step list (and the existing
|
|
33
38
|
// strength string) stays the fine print alongside it — steps that may
|
|
34
39
|
// SKIP for honest configuration absence (approvals unreviewed, no
|
|
35
40
|
// exported schemas) are visible there; only the always-run steps gate
|
|
36
41
|
// the desktop rungs, and only executed PASSes gate the device rungs.
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
// The Compose ladder that used to live here is qa/lib/profiles/cmp/ladder.mjs
|
|
44
|
+
// (Stage 0 PR 3). This module derives a rung from WHATEVER ladder it is handed;
|
|
45
|
+
// it carries no step names of its own and defaults to none — a profile that
|
|
46
|
+
// declares no ladder earns no rung. The rung vocabulary above is one profile's
|
|
47
|
+
// example, kept because it is the clearest statement of what a rung is for.
|
|
48
|
+
//
|
|
49
|
+
// WHERE THAT LADDER COMES FROM IS NOT THIS MODULE'S QUESTION, and saying it was
|
|
50
|
+
// is how a defect hid: this comment used to name one of the two spellings a
|
|
51
|
+
// profile may use (`evidenceLadder`, on the pack), which is the one the runner
|
|
52
|
+
// happened to read while the Stop hook read the other. Resolving between them
|
|
53
|
+
// is qa/lib/evidence-ladder.mjs's single job, and every caller goes through it.
|
|
54
|
+
// The fields read below are the fields that resolution compares — the pairing
|
|
55
|
+
// is derived from this source by test/evidence-ladder.test.mjs so the two
|
|
56
|
+
// cannot drift.
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
// ── THE BADGE FLOOR, AND WHY THE GRADER IS WHERE IT LIVES ───────────────────
|
|
59
|
+
//
|
|
60
|
+
// Until 2026-09-08 this module derived a rung from the ladder alone. Two
|
|
61
|
+
// scratch adopters built by scripts/stage2-gate.mjs, differing in EXACTLY one
|
|
62
|
+
// export — one shipping `plants`, one shipping none — ran the real lane and
|
|
63
|
+
// both earned `L1 · every promise bound`. The twin that could calibrate nothing
|
|
64
|
+
// was graded identically to the twin that could, against a guarantee that was
|
|
65
|
+
// already binding (NORTH-STAR.md §8.9, §6.7, and §3's third *never*).
|
|
66
|
+
//
|
|
67
|
+
// The floor could have been enforced one layer out, in the lane runner, leaving
|
|
68
|
+
// this function a pure ladder→rung derivation. It is here instead, because a
|
|
69
|
+
// grader that can be called without the floor is a grader that WILL be: the
|
|
70
|
+
// ladder's own two-spellings defect (§9.2) happened precisely because two
|
|
71
|
+
// readers reached the same question by different routes and only one of them
|
|
72
|
+
// was right. There is one grader, it takes the profile's plant declaration, and
|
|
73
|
+
// a caller that does not pass one gets NO RUNG rather than an ungrounded one —
|
|
74
|
+
// forgetting fails CLOSED, which is the only direction a floor may fail in.
|
|
75
|
+
//
|
|
76
|
+
// WHAT COUNTS AS PLANTS is deliberately not decided here. It is the
|
|
77
|
+
// instrument's judgement, and asking qa/lib/plant-calibration.mjs is how the
|
|
78
|
+
// grader and the instrument are kept from holding two definitions of the same
|
|
79
|
+
// word — the `flowCitation`/`scanCitations` failure, which cost a gate nobody
|
|
80
|
+
// was calibrating. That file also carries the argument for WHICH reading of
|
|
81
|
+
// "calibrated" is implemented (structurally usable, not merely declared, and
|
|
82
|
+
// not the observed reading, which no lane run can derive).
|
|
83
|
+
import { plantCalibration } from "./plant-calibration.mjs";
|
|
53
84
|
|
|
54
85
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
86
|
+
* Derive the receipt's evidence rung AND the sentence explaining an absent one.
|
|
87
|
+
*
|
|
88
|
+
* TWO RETURNS, ONE DECISION. `evidenceLevel` below is this function's `.level`
|
|
89
|
+
* and nothing else — there is no second copy of the derivation — because the
|
|
90
|
+
* rung and the reason it is missing must never be able to disagree. The reason
|
|
91
|
+
* exists because "no rung, no explanation" is itself a defect this project has
|
|
92
|
+
* already paid for: a foreign author who declared their ladder in the seeded
|
|
93
|
+
* spelling got a green lane, no rung and not one word about why, and it took a
|
|
94
|
+
* gate written in another ecosystem to find it (NORTH-STAR.md §9.2).
|
|
95
|
+
*
|
|
96
|
+
* The `why` is NOT written to the receipt. A receipt records what was earned;
|
|
97
|
+
* this is a sentence for a human reading a lane, and putting it on the receipt
|
|
98
|
+
* would change the receipt's schema, which is a decision for an ADR rather than
|
|
99
|
+
* for this fix (fit-test question 5).
|
|
100
|
+
*
|
|
101
|
+
* @param {Array<{name: string, verdict: string}>} stepResults the lane's steps
|
|
102
|
+
* @param {string} [profile] the profile that was REQUESTED (scaffold | local |
|
|
103
|
+
* ci | …) — recorded context only, never part of the derivation, and
|
|
104
|
+
* deliberately never quoted in `why`: it names the RUN, not the pack, and a
|
|
105
|
+
* message that confused the two would send a reader to the wrong file
|
|
106
|
+
* @param {{mode?: string, ladder?: object|null, plants?: object|null}} [opts]
|
|
107
|
+
* @returns {{level: {rung: string, name: string, satisfiedBy: string[]}|null, why: string|null}}
|
|
108
|
+
* `why` is null exactly when `level` is not.
|
|
58
109
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const DEVICE_EXECUTION = ["e2eSmoke", "tokenDrift", "androidChecks"];
|
|
110
|
+
export function gradeEvidence(stepResults, profile, { mode, ladder, plants } = {}) { // eslint-disable-line no-unused-vars
|
|
111
|
+
const none = (why) => ({ level: null, why });
|
|
112
|
+
if (mode === "fast") return none("no evidence rung: this run was `--fast`, the inner loop — a signal, never evidence");
|
|
63
113
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
114
|
+
// THE BADGE FLOOR IS ASKED FIRST, before the ladder, because it decides
|
|
115
|
+
// whether this profile may be graded AT ALL: a ladder over uncalibrated
|
|
116
|
+
// plants is a vocabulary for a claim nobody checked, so the ladder question
|
|
117
|
+
// is moot until this one is answered. Both absences are reported together
|
|
118
|
+
// when both apply — an author who fixes one and comes back for the other has
|
|
119
|
+
// been charged two round trips by a message that knew both answers.
|
|
120
|
+
const calibrated = plantCalibration(plants);
|
|
121
|
+
if (!calibrated.ok) {
|
|
122
|
+
const alsoNoLadder = !ladder || typeof ladder !== "object";
|
|
123
|
+
return none(
|
|
124
|
+
// The §6.7 citation is already inside `calibrated.reason`; repeating it
|
|
125
|
+
// here made the most-read line in the lane cite the same section twice.
|
|
126
|
+
`no evidence rung: ${calibrated.reason}. A profile ships plants its Rule 0 instrument can run — ` +
|
|
127
|
+
"`node qa/framework-check.mjs` — or it ships without a badge (§8.9)" +
|
|
128
|
+
(alsoNoLadder ? ", and this profile declares no `ladder` either, which alone earns no rung" : ""),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
68
131
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
132
|
+
// No ladder → no rung. There is no default: a rung is a claim in a
|
|
133
|
+
// profile's own vocabulary, and the spine has none to lend. `null` and
|
|
134
|
+
// `undefined` mean the same thing here — the pack declared nothing.
|
|
135
|
+
if (!ladder || typeof ladder !== "object") {
|
|
136
|
+
return none("no evidence rung: this profile declares no `ladder`, so there are no rungs to earn — which is the honest grade, not a failure");
|
|
137
|
+
}
|
|
138
|
+
const level = rungFor(stepResults, ladder);
|
|
139
|
+
if (level) return { level, why: null };
|
|
140
|
+
const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
|
|
141
|
+
const red = steps.filter((s) => s.verdict === "FAIL" || s.verdict === "ERROR");
|
|
142
|
+
if (red.length) {
|
|
143
|
+
return none(
|
|
144
|
+
`no evidence rung: ${red.map((s) => `${s.name} ${s.verdict}`).join(", ")} — a lane that failed, or that could not check, has no rung`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const floor = (ladder.l0Required ?? []).filter((name) => !steps.some((s) => s.name === name && s.verdict === "PASS"));
|
|
148
|
+
return none(
|
|
149
|
+
`no evidence rung: this ladder's floor rung needs ${(ladder.l0Required ?? []).join(", ") || "(nothing)"} to PASS and ` +
|
|
150
|
+
`${floor.join(", ")} did not — a SKIP never earns a rung`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
89
153
|
|
|
90
154
|
/**
|
|
91
155
|
* Derive the receipt's evidence rung from the lane's step results.
|
|
92
156
|
*
|
|
157
|
+
* THE ONE GRADER. Everything that shows a rung derives it here or reads one a
|
|
158
|
+
* run of this function wrote onto a receipt; nothing recomputes it its own way.
|
|
159
|
+
*
|
|
93
160
|
* @param {Array<{name: string, verdict: string}>} stepResults the lane's steps
|
|
94
161
|
* as recorded on the receipt (verdict PASS | FAIL | SKIP per step)
|
|
95
162
|
* @param {string} [profile] the profile that was REQUESTED — recorded context
|
|
96
163
|
* only, never part of the derivation (see honesty rules above)
|
|
97
|
-
* @param {{mode?: string}} [opts]
|
|
98
|
-
*
|
|
99
|
-
*
|
|
164
|
+
* @param {{mode?: string, ladder?: object|null, plants?: object|null}} [opts]
|
|
165
|
+
* the run's mode ("full" | "fast"), the PROFILE's resolved ladder
|
|
166
|
+
* (qa/lib/evidence-ladder.mjs) and the PROFILE's `plants` declaration.
|
|
167
|
+
* "fast" derives null unconditionally — the inner loop earns no rung.
|
|
168
|
+
* Absent/other values for mode mean full; an absent `plants` means no rung,
|
|
169
|
+
* because the badge floor fails closed (see above).
|
|
100
170
|
* @returns {{rung: "L0"|"L1"|"L2"|"L3", name: string, satisfiedBy: string[]}|null}
|
|
101
|
-
* null when
|
|
102
|
-
*
|
|
103
|
-
* was
|
|
104
|
-
*
|
|
171
|
+
* null when the profile ships no usable plants (the badge floor), when it
|
|
172
|
+
* declares no ladder, when any step FAILed (a failed lane has no rung), when
|
|
173
|
+
* the run was fast-mode (the inner loop is never evidence), or when even the
|
|
174
|
+
* L0 floor was not earned. `satisfiedBy` lists the PASSed steps the rung
|
|
175
|
+
* counts as its evidence, in lane order. A caller that wants the SENTENCE for
|
|
176
|
+
* an absent rung calls `gradeEvidence` and reads `.why` — same decision, one
|
|
177
|
+
* implementation.
|
|
178
|
+
*/
|
|
179
|
+
export function evidenceLevel(stepResults, profile, opts = {}) {
|
|
180
|
+
return gradeEvidence(stepResults, profile, opts).level;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The ladder half of the derivation, once the badge floor has been cleared:
|
|
185
|
+
* which rung THESE steps earn under THIS ladder, and nothing about whether the
|
|
186
|
+
* profile was entitled to be graded. Private on purpose — `gradeEvidence` is
|
|
187
|
+
* the only caller, so the floor cannot be walked around from inside this file
|
|
188
|
+
* any more than it can from outside it.
|
|
189
|
+
*
|
|
190
|
+
* @param {Array<{name: string, verdict: string}>} stepResults
|
|
191
|
+
* @param {object} ladder
|
|
192
|
+
* @returns {{rung: string, name: string, satisfiedBy: string[]}|null}
|
|
105
193
|
*/
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// `ladder` absent → the Compose ladder (every caller before packs declared
|
|
109
|
-
// one). `ladder: null` → the pack declares none: no rung, by decision.
|
|
110
|
-
if (ladder === null) return null;
|
|
111
|
-
const L = ladder ?? CMP_LADDER;
|
|
194
|
+
function rungFor(stepResults, ladder) {
|
|
195
|
+
const L = ladder;
|
|
112
196
|
const SCAFFOLD_CORE = L.scaffoldCore ?? [];
|
|
113
197
|
const L0_REQUIRED = L.l0Required ?? [];
|
|
114
198
|
const L1_REQUIRED = L.l1Required ?? [];
|
|
115
199
|
const DEVICE_EXECUTION = L.deviceExecution ?? [];
|
|
116
200
|
const RELEASE_EXECUTION = L.release ?? null;
|
|
117
|
-
|
|
201
|
+
// A ladder without labels still grades — the rung id is its own label.
|
|
202
|
+
const RUNG_NAMES = L.names ?? { L0: "L0", L1: "L1", L2: "L2", L3: "L3" };
|
|
118
203
|
const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
|
|
119
204
|
// A failed lane has no rung — and a lane with a step that could not run
|
|
120
205
|
// (ERROR) has none either: a rung is evidence, and "could not check" is not.
|
|
@@ -50,6 +50,7 @@ import path from "node:path";
|
|
|
50
50
|
|
|
51
51
|
import { computeInputsHash } from "./inputs-hash.mjs";
|
|
52
52
|
import { CLAUSE_LINE_RE, scanCitations } from "./spec-coverage.mjs";
|
|
53
|
+
import { requireSpecModel } from "./spec-model.mjs";
|
|
53
54
|
|
|
54
55
|
export const FEATURES_DIR_REL = "docs/features";
|
|
55
56
|
|
|
@@ -197,7 +198,7 @@ export function parseFeatureBlock(markdown) {
|
|
|
197
198
|
...new Set(
|
|
198
199
|
parsed.specs
|
|
199
200
|
.filter((s) => typeof s === "string" && s.trim() !== "")
|
|
200
|
-
.map((s) => s.trim().replace(/^specs\//, "").replace(/\.spec\.md$/, "")),
|
|
201
|
+
.map((s) => s.trim().replace(/^[A-Za-z0-9_./-]*?specs\//, "").replace(/\.spec\.md$/, "")),
|
|
201
202
|
),
|
|
202
203
|
]
|
|
203
204
|
: [];
|
|
@@ -223,10 +224,10 @@ export function parseFeatureBlock(markdown) {
|
|
|
223
224
|
* caller already has one (avoids re-parsing; same answer either way)
|
|
224
225
|
* @returns {string[]} spec names, e.g. ["catalog", "entry-editing"]
|
|
225
226
|
*/
|
|
226
|
-
export function pairedSpecNames(markdown, name, block) {
|
|
227
|
+
export function pairedSpecNames(markdown, name, block, specsDir = "specs") {
|
|
227
228
|
const declared = (block ?? parseFeatureBlock(markdown)).specs ?? [];
|
|
228
229
|
if (declared.length > 0) return declared;
|
|
229
|
-
const fromHeader = specHeaderNames(markdown);
|
|
230
|
+
const fromHeader = specHeaderNames(markdown, specsDir);
|
|
230
231
|
if (fromHeader.length > 0) return fromHeader;
|
|
231
232
|
return [name];
|
|
232
233
|
}
|
|
@@ -236,7 +237,7 @@ export function pairedSpecNames(markdown, name, block) {
|
|
|
236
237
|
* paragraph — the line starting `**Spec:**` through the next blank line, so
|
|
237
238
|
* later prose that merely MENTIONS a spec path never redirects the pairing.
|
|
238
239
|
*/
|
|
239
|
-
function specHeaderNames(markdown) {
|
|
240
|
+
function specHeaderNames(markdown, specsDir = "specs") {
|
|
240
241
|
if (typeof markdown !== "string") return [];
|
|
241
242
|
const lines = markdown.split("\n");
|
|
242
243
|
const start = lines.findIndex((l) => /^\*\*Spec:?\*\*/.test(l.trim()));
|
|
@@ -244,7 +245,7 @@ function specHeaderNames(markdown) {
|
|
|
244
245
|
const para = [];
|
|
245
246
|
for (let i = start; i < lines.length && lines[i].trim() !== ""; i++) para.push(lines[i]);
|
|
246
247
|
const out = [];
|
|
247
|
-
for (const m of para.join("\n").matchAll(/
|
|
248
|
+
for (const m of para.join("\n").matchAll(new RegExp(`${specsDir}/([A-Za-z0-9_-]+)\\.spec\\.md`, "g"))) {
|
|
248
249
|
if (!out.includes(m[1])) out.push(m[1]);
|
|
249
250
|
}
|
|
250
251
|
return out;
|
|
@@ -315,6 +316,25 @@ export function receiptAttestation(root) {
|
|
|
315
316
|
* shared scans (callers resolving many features pass these once)
|
|
316
317
|
* @returns {object}
|
|
317
318
|
*/
|
|
319
|
+
/**
|
|
320
|
+
* Where this stack's journey for `name` is written — the profile's flow
|
|
321
|
+
* directory when it declares one, else the tier's own name.
|
|
322
|
+
*
|
|
323
|
+
* Stage 0 PR 6c: the doneReason used to say "write the journey in
|
|
324
|
+
* qa/e2e/<name>.yaml" on every stack, which is a Maestro flow path. The
|
|
325
|
+
* sentence a human is told to act on must name a file their project could
|
|
326
|
+
* actually have.
|
|
327
|
+
* @param {import("./spec-model.mjs").SpecModel} model
|
|
328
|
+
* @param {string} name
|
|
329
|
+
* @returns {string}
|
|
330
|
+
*/
|
|
331
|
+
function journeyWhere(model, name) {
|
|
332
|
+
if (model.flows && model.flows.dir && model.flows.exts.length) {
|
|
333
|
+
return `write it in ${model.flows.dir}/${name}${model.flows.exts[0]} and cite the clause it proves`;
|
|
334
|
+
}
|
|
335
|
+
return `add a ${model.tiers.journey} test that cites one of its clauses`;
|
|
336
|
+
}
|
|
337
|
+
|
|
318
338
|
export function deriveFeatureStatus(root, brief, pre = {}) {
|
|
319
339
|
let markdown = "";
|
|
320
340
|
let readable = true;
|
|
@@ -330,24 +350,40 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
|
|
|
330
350
|
// The paired specs (walk-legibility L1): usually one, by filename; a brief
|
|
331
351
|
// may name several. Clauses concatenate in declaration order — "done" means
|
|
332
352
|
// every live clause across ALL of them is cited.
|
|
333
|
-
|
|
334
|
-
|
|
353
|
+
// The MODEL first, because the spec directory is the profile's and this
|
|
354
|
+
// module used to hardcode `specs/`. A project declaring `"specs": "docs/specs"`
|
|
355
|
+
// — a legal, validated manifest field the lane's own scanner honours — had
|
|
356
|
+
// `deriveFeatureStatus` looking somewhere else entirely: specExists false,
|
|
357
|
+
// total 0, provenDone false forever, and the Features view telling a human to
|
|
358
|
+
// start writing a spec that already existed. The header of spec-coverage.mjs
|
|
359
|
+
// says these two readers exist so the Features view and the lane can never
|
|
360
|
+
// disagree about the same clause. They disagreed.
|
|
361
|
+
const model = pre.model ?? requireSpecModel(root);
|
|
362
|
+
const specNames = pairedSpecNames(markdown, brief.name, block, model.specsDir);
|
|
363
|
+
const specRels = specNames.map((n) => `${model.specsDir}/${n}.spec.md`);
|
|
335
364
|
const specExists = specRels.every((rel) => fs.existsSync(path.join(root, rel)));
|
|
336
365
|
const specRel = specRels.join(" + ");
|
|
337
|
-
const citations = pre.citations ?? scanCitations(root);
|
|
366
|
+
const citations = pre.citations ?? scanCitations(root, model);
|
|
338
367
|
const citedIds = new Set(citations.map((t) => t.id));
|
|
339
|
-
// Which clauses a
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
const
|
|
368
|
+
// Which clauses a JOURNEY proves: citations from the profile's journey tier
|
|
369
|
+
// (mobile: flows under qa/e2e). A UI feature (screens: true) is not done
|
|
370
|
+
// until at least one of its live clauses is cited from that tier: JVM tests
|
|
371
|
+
// prove logic and structure, the journey proves it on the target.
|
|
372
|
+
const journeyTier = model.tiers.journey;
|
|
373
|
+
const e2eCitedIds = new Set(citations.filter((t) => journeyTier && t.tier === journeyTier).map((t) => t.id));
|
|
344
374
|
const clauses = specRels
|
|
345
375
|
.flatMap((rel) => clausesOfSpec(root, rel))
|
|
346
376
|
.map((c) => ({ ...c, cited: citedIds.has(c.id), e2eCited: e2eCitedIds.has(c.id) }));
|
|
347
377
|
const live = clauses.filter((c) => !c.withdrawn);
|
|
348
378
|
const covered = live.filter((c) => c.cited).length;
|
|
349
379
|
const e2eCovered = live.filter((c) => c.e2eCited).length;
|
|
350
|
-
|
|
380
|
+
// A journey is only required when the profile HAS a journey tier.
|
|
381
|
+
// `tiers.journey` is documented as nullable ("or null when this stack has no
|
|
382
|
+
// journey tier") and the citation filter above already yields nothing for
|
|
383
|
+
// null — but this condition never asked, so on a backend, a CLI or a library
|
|
384
|
+
// every feature with a surface was permanently un-done and the remedy printed
|
|
385
|
+
// was literally "add a null test that cites one of its clauses".
|
|
386
|
+
const needsJourney = block.screens === true && block.unrouted !== true && Boolean(model.tiers.journey);
|
|
351
387
|
|
|
352
388
|
const receipt = pre.receipt ?? receiptAttestation(root);
|
|
353
389
|
const provenDone =
|
|
@@ -384,7 +420,7 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
|
|
|
384
420
|
: live.length === 0
|
|
385
421
|
? `${specRel} has no live clauses — nothing is promised yet`
|
|
386
422
|
: needsJourney && covered === live.length && e2eCovered === 0
|
|
387
|
-
? `${covered}/${live.length} clauses cited, but none from
|
|
423
|
+
? `${covered}/${live.length} clauses cited, but none from the ${journeyTier} tier — a feature with a surface is proven where it runs, not only on the host: ${journeyWhere(model, brief.name)}`
|
|
388
424
|
: covered < live.length
|
|
389
425
|
? `${covered}/${live.length} clauses cited — ${live.length - covered} promise(s) have no citing test`
|
|
390
426
|
: !receipt.present
|
|
@@ -404,6 +440,7 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
|
|
|
404
440
|
export function deriveAllFeatures(root) {
|
|
405
441
|
const briefs = listFeatureBriefs(root);
|
|
406
442
|
if (briefs.length === 0) return [];
|
|
407
|
-
const
|
|
443
|
+
const model = requireSpecModel(root);
|
|
444
|
+
const pre = { model, citations: scanCitations(root, model), receipt: receiptAttestation(root) };
|
|
408
445
|
return briefs.map((b) => deriveFeatureStatus(root, b, pre));
|
|
409
446
|
}
|
|
@@ -52,13 +52,44 @@ import fs from "node:fs";
|
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
|
|
54
54
|
export const FLIGHT_JOURNAL_REL_PATH = "qa/flight-recorder.jsonl";
|
|
55
|
-
|
|
55
|
+
// ADR-0007's principle, applied to the journal. The receipt's rename left this
|
|
56
|
+
// behind, and the reason that ADR gave for deferring LOCK_SCHEMA — "it finishes
|
|
57
|
+
// that journey with the package work" — does NOT carry over: a journal is
|
|
58
|
+
// APPEND-ONLY and is never rewritten, so deferring it is deciding never. It is
|
|
59
|
+
// committed in every stamped app, so a Go project's repo carried `cmp` on every
|
|
60
|
+
// line of it. Nothing reads this field (readFlightJournal accepts any object),
|
|
61
|
+
// so old entries and new ones coexist in one file and both stay readable.
|
|
62
|
+
export const FLIGHT_SCHEMA = "prooflane-flight/1";
|
|
56
63
|
|
|
57
64
|
// Below this many entries the report carries an explicit shortness note —
|
|
58
65
|
// two entries are two facts, not a trend, and the report must say so rather
|
|
59
66
|
// than let a reader infer a pattern from a journal that cannot support one.
|
|
60
67
|
const SHORT_JOURNAL_FLOOR = 5;
|
|
61
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The highest rung recorded, said the only way it can honestly be said: with
|
|
71
|
+
* the pack that graded it.
|
|
72
|
+
*
|
|
73
|
+
* One pack — "L2 (pack cmp)". Several — each named, because §8.9 makes "the
|
|
74
|
+
* highest of an L2 and an L1 from different packs" a question with no answer.
|
|
75
|
+
* A rung whose entry predates pack recording is `unattributed`, never quietly
|
|
76
|
+
* credited to whichever pack is present.
|
|
77
|
+
*
|
|
78
|
+
* @param {{highestRung: string|null, highestRungByPack?: Record<string,string>}} device
|
|
79
|
+
* @returns {string} "" when nothing was recorded
|
|
80
|
+
*/
|
|
81
|
+
export function describeHighestRung(device) {
|
|
82
|
+
const byPack = device?.highestRungByPack ?? {};
|
|
83
|
+
const entries = Object.entries(byPack);
|
|
84
|
+
if (entries.length === 0) return "";
|
|
85
|
+
if (entries.length === 1) {
|
|
86
|
+
const [pack, rung] = entries[0];
|
|
87
|
+
return ` (highest evidence rung recorded: ${rung}, pack ${pack})`;
|
|
88
|
+
}
|
|
89
|
+
const parts = entries.sort(([a], [b]) => a.localeCompare(b)).map(([pack, rung]) => `${rung} (${pack})`);
|
|
90
|
+
return ` (highest evidence rung per pack — not comparable across them: ${parts.join(", ")})`;
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
/**
|
|
63
94
|
* Shape one lane run into a journal entry. Pure — verify.mjs passes what it
|
|
64
95
|
* already computed for the receipt, so the journal can never disagree with
|
|
@@ -71,6 +102,11 @@ const SHORT_JOURNAL_FLOOR = 5;
|
|
|
71
102
|
* @param {{rung: string}|null} run.evidenceLevel the derived rung (or null —
|
|
72
103
|
* fast runs and FAILed lanes carry none, and the journal records that
|
|
73
104
|
* honestly rather than borrowing a rung from elsewhere)
|
|
105
|
+
* @param {{id: string}|null} run.pack WHICH PACK graded that rung. A rung is
|
|
106
|
+
* comparable only within its pack (NORTH-STAR §8.9): a `cmp` L2 and a
|
|
107
|
+
* backend pack's L2 are different claims. An entry that stored a rung with
|
|
108
|
+
* no pack could not be compared to anything without inventing the missing
|
|
109
|
+
* half, and `highestRung` below did exactly that across every entry.
|
|
74
110
|
* @param {Array<{name: string, verdict: string, reason?: string}>} run.steps
|
|
75
111
|
* the lane's step results, verbatim
|
|
76
112
|
* @param {string|null} run.sha parent HEAD at run time (null before git init)
|
|
@@ -82,7 +118,7 @@ const SHORT_JOURNAL_FLOOR = 5;
|
|
|
82
118
|
* (self-heals, fallbacks) — each a short verbatim description
|
|
83
119
|
* @returns {object} one journal entry (JSON-serializable)
|
|
84
120
|
*/
|
|
85
|
-
export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps, sha, durationMs, onDeviceSteps, degraded }) {
|
|
121
|
+
export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, pack, steps, sha, durationMs, onDeviceSteps, degraded }) {
|
|
86
122
|
const stepList = Array.isArray(steps) ? steps.filter((s) => s && typeof s.name === "string") : [];
|
|
87
123
|
return {
|
|
88
124
|
schema: FLIGHT_SCHEMA,
|
|
@@ -92,6 +128,10 @@ export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps,
|
|
|
92
128
|
mode,
|
|
93
129
|
verdict,
|
|
94
130
|
evidenceRung: evidenceLevel?.rung ?? null,
|
|
131
|
+
// The grader, beside the grade. Null on an entry that earned no rung, and
|
|
132
|
+
// on one written before packs were recorded — absent is honest, invented is
|
|
133
|
+
// not, and the summary below refuses to compare what it cannot attribute.
|
|
134
|
+
pack: typeof pack?.id === "string" ? pack.id : null,
|
|
95
135
|
durationMs,
|
|
96
136
|
// durationMs per step (additive, schema id unchanged — old entries stay
|
|
97
137
|
// readable): the source for the lane's own "usually ~Ns" narration
|
|
@@ -231,11 +271,31 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
|
|
|
231
271
|
|
|
232
272
|
const fullRuns = runs.filter((e) => e.mode === "full");
|
|
233
273
|
const deviceReached = runs.filter((e) => Array.isArray(e.deviceSteps) && e.deviceSteps.length > 0);
|
|
274
|
+
// THE HIGHEST RUNG, PER PACK — never across them.
|
|
275
|
+
//
|
|
276
|
+
// This used to take the maximum over every entry in the journal, which is a
|
|
277
|
+
// cross-pack comparison in a single line: if the journal held a `cmp` L2 and
|
|
278
|
+
// a backend pack's L1, it reported "L2" as though one number described both.
|
|
279
|
+
// §8.9 forbids exactly that, and the fix is not a smarter sort — it is
|
|
280
|
+
// refusing to put two incomparable claims in one ordering.
|
|
281
|
+
//
|
|
282
|
+
// An entry with a rung and no pack (written before packs were recorded)
|
|
283
|
+
// cannot be attributed, so it is counted under `null` and reported as
|
|
284
|
+
// unattributed rather than folded into whichever pack happens to be present.
|
|
234
285
|
const rungOrder = { L0: 0, L1: 1, L2: 2, L3: 3 };
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
286
|
+
const highestRungByPack = new Map();
|
|
287
|
+
for (const e of runs) {
|
|
288
|
+
const r = e.evidenceRung;
|
|
289
|
+
if (typeof r !== "string" || !(r in rungOrder)) continue;
|
|
290
|
+
const id = typeof e.pack === "string" ? e.pack : null;
|
|
291
|
+
const seen = highestRungByPack.get(id);
|
|
292
|
+
if (!seen || rungOrder[r] > rungOrder[seen]) highestRungByPack.set(id, r);
|
|
293
|
+
}
|
|
294
|
+
// The single-value form survives ONLY where the journal holds one pack, which
|
|
295
|
+
// is every project that has not changed profiles. With more than one it is
|
|
296
|
+
// null, and the per-pack map is the answer — a reader asking for one number
|
|
297
|
+
// over two packs is asking a question §8.9 says has no answer.
|
|
298
|
+
const highestRung = highestRungByPack.size === 1 ? [...highestRungByPack.values()][0] : null;
|
|
239
299
|
|
|
240
300
|
// Longest stretch with no full lane — only computable BETWEEN two recorded
|
|
241
301
|
// full runs. One full run is a date, not a stretch; the report says so
|
|
@@ -269,7 +329,7 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
|
|
|
269
329
|
lastAgoMs: lastFullAgoMs,
|
|
270
330
|
longestGap: longestFullGap,
|
|
271
331
|
},
|
|
272
|
-
device: { reachedRuns: deviceReached.length, highestRung },
|
|
332
|
+
device: { reachedRuns: deviceReached.length, highestRung, highestRungByPack: Object.fromEntries([...highestRungByPack].map(([k, v]) => [k ?? "unattributed", v])) },
|
|
273
333
|
};
|
|
274
334
|
}
|
|
275
335
|
|
|
@@ -338,7 +398,11 @@ export function renderFlightReport(summary, { malformed = 0 } = {}) {
|
|
|
338
398
|
|
|
339
399
|
if (summary.device.reachedRuns > 0) {
|
|
340
400
|
lines.push(
|
|
341
|
-
|
|
401
|
+
// A rung is shown WITH its pack, always — an unqualified "L2" is a claim
|
|
402
|
+
// about a grader nobody named (§8.9, §6.5). With more than one pack in
|
|
403
|
+
// the journal there is no single highest, and each is printed on its own
|
|
404
|
+
// rather than one being chosen to stand for both.
|
|
405
|
+
`device tier: reached in ${summary.device.reachedRuns} of ${summary.total} run(s)${describeHighestRung(summary.device)}`,
|
|
342
406
|
);
|
|
343
407
|
} else {
|
|
344
408
|
lines.push("device tier: never reached in any recorded run (no device-tier step ever PASSed)");
|