create-cmp-cli 0.24.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 +10 -11
- 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 +271 -37
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- 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 +34 -2
- 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 +434 -53
- 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 +175 -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} +229 -27
- 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/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +80 -24
- 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 +175 -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 +3 -0
- package/template/qa/approve.mjs +10 -11
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +271 -37
- 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/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 +34 -2
- 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 +434 -53
- 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 +175 -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} +229 -27
- 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/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +80 -24
- 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
|
@@ -34,7 +34,7 @@ import fs from "node:fs";
|
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
|
|
36
36
|
export const AUDITS_REL_PATH = "qa/audits.jsonl";
|
|
37
|
-
export const AUDIT_RECORD_SCHEMA = "
|
|
37
|
+
export const AUDIT_RECORD_SCHEMA = "prooflane-audit-record/1";
|
|
38
38
|
|
|
39
39
|
/** The pseudo-subsystem for Kotlin files directly at the androidMain package root. */
|
|
40
40
|
export const ROOT_SUBSYSTEM = "(root)";
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// - A MISSING file is tolerated as the empty seed on read (a brand-new project has
|
|
18
18
|
// no comments yet — that's not corruption) and is created on first write.
|
|
19
19
|
// - A file that EXISTS but is corrupt (unparsable JSON, wrong shape, or a schema
|
|
20
|
-
// string
|
|
20
|
+
// string this reader does not know) is NOT tolerated on read — listComments
|
|
21
21
|
// throws a descriptive error instead of returning an empty list. Approvals can
|
|
22
22
|
// safely treat corruption as "all unreviewed" because that is the conservative
|
|
23
23
|
// (non-blocking) default; silently reading a broken comments ledger as "no
|
|
@@ -30,7 +30,21 @@ import fs from "node:fs";
|
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
|
|
32
32
|
export const COMMENTS_REL_PATH = "qa/comments.json";
|
|
33
|
-
export const COMMENTS_SCHEMA = "
|
|
33
|
+
export const COMMENTS_SCHEMA = "prooflane-comments/1";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every schema name a comments ledger may declare — ADR-0007's enum pattern,
|
|
37
|
+
* applied where it is load-bearing rather than cosmetic.
|
|
38
|
+
*
|
|
39
|
+
* This reader REFUSES an unknown schema by design ("a ledger whose schema we do
|
|
40
|
+
* not know is not one we may interpret"), which makes it the one artifact in
|
|
41
|
+
* this rename where getting it wrong destroys user data: an adopter's existing
|
|
42
|
+
* `cmp-comments/1` ledger would become unreadable, and the comments in it are
|
|
43
|
+
* theirs, not ours. So the old name stays ACCEPTED for the life of /1 and stops
|
|
44
|
+
* only being WRITTEN — exactly what ADR-0007 decided for the receipt, for the
|
|
45
|
+
* same reason: renaming a label must not invalidate what was written under it.
|
|
46
|
+
*/
|
|
47
|
+
export const COMMENTS_SCHEMAS = Object.freeze(["cmp-comments/1", COMMENTS_SCHEMA]);
|
|
34
48
|
|
|
35
49
|
/** target.type -> the fields addComment requires on `target` for that type. */
|
|
36
50
|
const TARGET_FIELD_REQUIREMENTS = {
|
|
@@ -70,9 +84,9 @@ function parseLedger(raw) {
|
|
|
70
84
|
`${COMMENTS_REL_PATH} has an unexpected shape (expected {schema, comments: [...]}) — refusing to read it as a ledger.`,
|
|
71
85
|
);
|
|
72
86
|
}
|
|
73
|
-
if (parsed.schema !== undefined && parsed.schema
|
|
87
|
+
if (parsed.schema !== undefined && !COMMENTS_SCHEMAS.includes(parsed.schema)) {
|
|
74
88
|
throw new Error(
|
|
75
|
-
`${COMMENTS_REL_PATH} declares schema
|
|
89
|
+
`${COMMENTS_REL_PATH} declares schema ${JSON.stringify(parsed.schema)}, expected one of ${COMMENTS_SCHEMAS.map((x) => `"${x}"`).join(" or ")} — refusing to read an unknown-schema ledger.`,
|
|
76
90
|
);
|
|
77
91
|
}
|
|
78
92
|
return { schema: COMMENTS_SCHEMA, comments: parsed.comments };
|
|
@@ -55,7 +55,13 @@ function unescapeXml(s) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function attr(attrs, name) {
|
|
58
|
-
|
|
58
|
+
// ANCHORED. Unanchored, `name="…"` matches inside `classname="…"`, so for any
|
|
59
|
+
// writer that emits classname FIRST — pytest, jest-junit, gotestsum all do —
|
|
60
|
+
// `attr(attrs, "name")` returned the CLASSNAME. Every test in a class then
|
|
61
|
+
// collapsed onto one `classname.classname` key, last-write-wins, and the
|
|
62
|
+
// determinism probe reported a genuine timezone flip as deterministic. Gradle
|
|
63
|
+
// emits name first and worked by luck, which is why this survived.
|
|
64
|
+
const m = attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`));
|
|
59
65
|
return m ? unescapeXml(m[1]) : null;
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -70,11 +76,51 @@ function attr(attrs, name) {
|
|
|
70
76
|
* keyed by `classname.name`; empty object when the directory is absent
|
|
71
77
|
* (the caller decides what an empty leg means — this parser never guesses)
|
|
72
78
|
*/
|
|
73
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The report formats the core can read. Each is its OWN parser, dispatched by
|
|
81
|
+
* the profile's declaration — never sniffed from the file, because a wrong
|
|
82
|
+
* guess here produces `{}`, and an empty leg compared against an empty leg
|
|
83
|
+
* yields no differences: the probe would pass having read nothing. That exact
|
|
84
|
+
* failure is why the declaration exists.
|
|
85
|
+
*
|
|
86
|
+
* Every parser here obeys one rule, and it is the rule the whole probe rests
|
|
87
|
+
* on: READ ONLY VERDICT-BEARING CONTENT. No durations, no timestamps, no run
|
|
88
|
+
* ids. Two runs that differ solely in how long they took must produce byte-
|
|
89
|
+
* identical outcome maps, or the probe reports its own noise as a defect.
|
|
90
|
+
*/
|
|
91
|
+
export const REPORT_FORMATS = Object.freeze(["junit-xml", "tap", "ctrf"]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Why a profile's `reports` declaration cannot be read — or null when it can.
|
|
95
|
+
* PATTERN: JUnit XML as the lingua franca (pytest --junitxml, go-junit-report,
|
|
96
|
+
* cargo2junit, jest-junit, swift test --xunit-output); the profile DECLARES it.
|
|
97
|
+
* WHY IT WORKS: the parser stops assuming what a runner emitted. HOW IT FAILS:
|
|
98
|
+
* a dialect parses to {} and looks like "no tests". WHAT WE DO: undeclared or
|
|
99
|
+
* unsupported is a refusal by name here, and the probe treats an empty leg as a
|
|
100
|
+
* refusal, never a pass.
|
|
101
|
+
* @param {{format?: string}|null|undefined} reports
|
|
102
|
+
* @returns {string|null}
|
|
103
|
+
*/
|
|
104
|
+
export function reportFormatProblem(reports) {
|
|
105
|
+
if (!reports || typeof reports !== "object") return "the profile declares no `reports` — declare { format: \"junit-xml\" } (the format this stack's test runner emits) so the probe parses what was declared rather than assuming it";
|
|
106
|
+
if (!REPORT_FORMATS.includes(reports.format)) return `reports.format ${JSON.stringify(reports.format)} is not one the core can read (${REPORT_FORMATS.join(", ")}) — a parser for it is its own change`;
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function parseJUnitOutcomes(dir, { format } = {}) {
|
|
111
|
+
const problem = reportFormatProblem({ format });
|
|
112
|
+
if (problem) throw new Error(problem);
|
|
74
113
|
const outcomes = {};
|
|
75
114
|
if (!fs.existsSync(dir)) return outcomes;
|
|
76
115
|
for (const entry of fs.readdirSync(dir)) {
|
|
77
|
-
|
|
116
|
+
// ANY .xml, not just `TEST-*.xml`. That prefix is the Ant/Gradle/Surefire
|
|
117
|
+
// filename convention; pytest writes `junit.xml`, jest-junit `junit.xml`,
|
|
118
|
+
// gotestsum `junit.xml`, cargo2junit `results.xml`, `dotnet test`
|
|
119
|
+
// `TestResults.xml`. Every one of them parsed to {} — and an empty leg
|
|
120
|
+
// compared against an empty leg yields no differences, so the probe passed
|
|
121
|
+
// having read nothing. The `<testcase` match below is the real filter: a
|
|
122
|
+
// file with no test cases contributes nothing either way.
|
|
123
|
+
if (!entry.endsWith(".xml")) continue;
|
|
78
124
|
const xml = fs.readFileSync(path.join(dir, entry), "utf8");
|
|
79
125
|
const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
80
126
|
for (const m of xml.matchAll(caseRe)) {
|
|
@@ -100,17 +146,25 @@ export function parseJUnitOutcomes(dir) {
|
|
|
100
146
|
}
|
|
101
147
|
|
|
102
148
|
/**
|
|
103
|
-
* Which lane step owns a test class — so the probe's failure message names
|
|
104
|
-
*
|
|
105
|
-
*
|
|
149
|
+
* Which lane step owns a test class — so the probe's failure message names the
|
|
150
|
+
* step a reader would re-run, not just a class name.
|
|
151
|
+
*
|
|
152
|
+
* THIS IS THE PROFILE'S KNOWLEDGE and it moved there (profiles/cmp/steps-cmp.mjs).
|
|
153
|
+
* The core used to answer it with four names — `goldenTrees`, `conformance`,
|
|
154
|
+
* `a11y`, `unitTests` — three of which exist only in the cmp pack, matched
|
|
155
|
+
* against Kotlin class-name conventions. `compareOutcomes` called it
|
|
156
|
+
* unconditionally, so ANY profile reusing the core's determinism comparison got
|
|
157
|
+
* another stack's step names stamped onto its own diffs. It survived the lint
|
|
158
|
+
* only because this module was not in the lint's list; it is now.
|
|
159
|
+
*
|
|
160
|
+
* A caller that supplies no attribution gets `null` and the diff carries no
|
|
161
|
+
* step — an honest absence, not a borrowed name.
|
|
106
162
|
* @param {string} classname fully-qualified test class
|
|
107
|
-
* @
|
|
163
|
+
* @param {((classname: string) => string|null)} [attribute] the profile's mapping
|
|
164
|
+
* @returns {string|null}
|
|
108
165
|
*/
|
|
109
|
-
export function laneStepForTestClass(classname) {
|
|
110
|
-
|
|
111
|
-
if (/ArchitectureConformanceTest$/.test(classname)) return "conformance";
|
|
112
|
-
if (/A11yConformanceTest$/.test(classname)) return "a11y";
|
|
113
|
-
return "unitTests";
|
|
166
|
+
export function laneStepForTestClass(classname, attribute) {
|
|
167
|
+
return typeof attribute === "function" ? (attribute(classname) ?? null) : null;
|
|
114
168
|
}
|
|
115
169
|
|
|
116
170
|
function classnameOf(testId) {
|
|
@@ -143,11 +197,11 @@ function classnameOf(testId) {
|
|
|
143
197
|
* @param {string} labelB human label for leg B
|
|
144
198
|
* @returns {Array<{test: string, step: string, kind: string, detail: string}>}
|
|
145
199
|
*/
|
|
146
|
-
export function compareOutcomes(a, b, labelA, labelB) {
|
|
200
|
+
export function compareOutcomes(a, b, labelA, labelB, attribute) {
|
|
147
201
|
const diffs = [];
|
|
148
202
|
const ids = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
149
203
|
for (const id of ids) {
|
|
150
|
-
const step = laneStepForTestClass(classnameOf(id));
|
|
204
|
+
const step = laneStepForTestClass(classnameOf(id), attribute);
|
|
151
205
|
const inA = a[id];
|
|
152
206
|
const inB = b[id];
|
|
153
207
|
if (!inA || !inB) {
|
|
@@ -177,3 +231,210 @@ export function compareOutcomes(a, b, labelA, labelB) {
|
|
|
177
231
|
}
|
|
178
232
|
return diffs;
|
|
179
233
|
}
|
|
234
|
+
|
|
235
|
+
// ── TAP and CTRF ────────────────────────────────────────────────────────────
|
|
236
|
+
// The second and third formats the core can read. Both were named as "next"
|
|
237
|
+
// beside REPORT_FORMATS for months; a profile declaring either got a refusal
|
|
238
|
+
// telling it a parser was its own change. This is that change.
|
|
239
|
+
//
|
|
240
|
+
// The rule both obey is the one the whole probe rests on: READ ONLY
|
|
241
|
+
// VERDICT-BEARING CONTENT. TAP carries timings in YAML diagnostics and in
|
|
242
|
+
// `# time=…` comments; CTRF carries `duration`, `start` and `stop` on every
|
|
243
|
+
// test and a `summary` block full of them. None of it is read. A parser that
|
|
244
|
+
// let a millisecond through would make every second run "nondeterministic" and
|
|
245
|
+
// the probe would be reporting its own noise.
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parse a TAP stream into per-test outcomes.
|
|
249
|
+
*
|
|
250
|
+
* IDENTITY. TAP has no classname — a test is its description, and the number is
|
|
251
|
+
* positional, so it is NOT part of the key: a suite whose tests reorder between
|
|
252
|
+
* two runs would otherwise read as every test having changed. Duplicate
|
|
253
|
+
* descriptions are disambiguated by occurrence, which is the honest answer when
|
|
254
|
+
* a runner emits two tests with one name.
|
|
255
|
+
*
|
|
256
|
+
* DIRECTIVES decide status before the ok/not-ok does: `# SKIP` is a skip
|
|
257
|
+
* whichever way the line reads, and a `# TODO` failure is an expected one, so
|
|
258
|
+
* it is a skip rather than a fail — that is TAP's own semantics, and reading it
|
|
259
|
+
* as a failure would make a passing suite look broken.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} text one TAP stream
|
|
262
|
+
* @returns {Record<string, {status: "pass"|"fail"|"error"|"skip", messages: string[]}>}
|
|
263
|
+
*/
|
|
264
|
+
export function parseTapStream(text) {
|
|
265
|
+
const outcomes = {};
|
|
266
|
+
const seen = new Map();
|
|
267
|
+
const lines = String(text ?? "").split("\n");
|
|
268
|
+
// NESTING IS INDENTATION, and identity is the PATH. Found by running Node's
|
|
269
|
+
// own `--test-reporter=tap` at it: a child keyed by its bare name collides
|
|
270
|
+
// with a same-named child under another parent, and the collision is then
|
|
271
|
+
// resolved positionally — which reintroduces exactly the ordering
|
|
272
|
+
// sensitivity the bare test number was excluded to avoid. `parent > child`
|
|
273
|
+
// is stable however the parents run, and it mirrors what JUnit keys as
|
|
274
|
+
// `classname.name` and CTRF as `suite.name`, so all three formats identify
|
|
275
|
+
// the same test the same way.
|
|
276
|
+
//
|
|
277
|
+
// THE PARENT IS NOT KNOWN FROM ITS RESULT LINE. TAP emits a parent's `ok`
|
|
278
|
+
// AFTER its children — it cannot report a verdict it has not finished
|
|
279
|
+
// computing — so a stack built from result lines makes the previous SIBLING
|
|
280
|
+
// the parent, which is what the first attempt did (children came out under
|
|
281
|
+
// "todo one"). The line that does precede the block is `# Subtest: <name>`,
|
|
282
|
+
// at the parent's own indentation, and both major TAP producers emit it.
|
|
283
|
+
// A producer that emits none has a flat stream, where there is nothing to
|
|
284
|
+
// nest and bare names are already the whole path.
|
|
285
|
+
const stack = [];
|
|
286
|
+
let pending = null;
|
|
287
|
+
|
|
288
|
+
const commit = () => {
|
|
289
|
+
if (!pending) return;
|
|
290
|
+
const n = (seen.get(pending.name) ?? 0) + 1;
|
|
291
|
+
seen.set(pending.name, n);
|
|
292
|
+
outcomes[n === 1 ? pending.name : `${pending.name} #${n}`] = { status: pending.status, messages: pending.messages };
|
|
293
|
+
pending = null;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
297
|
+
const line = lines[i];
|
|
298
|
+
const sub = line.match(/^(\s*)#\s*Subtest:\s*(.+?)\s*$/);
|
|
299
|
+
if (sub) {
|
|
300
|
+
commit();
|
|
301
|
+
const depth = sub[1].length;
|
|
302
|
+
while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop();
|
|
303
|
+
stack.push({ depth, name: sub[2] });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const m = line.match(/^(\s*)(not ok|ok)\b[ \t]*(\d+)?[ \t]*-?[ \t]*(.*)$/);
|
|
307
|
+
if (!m) {
|
|
308
|
+
// A YAML diagnostic block belongs to the test above it.
|
|
309
|
+
//
|
|
310
|
+
// WHICH KEYS ARE READ decides whether a whole defect class is visible.
|
|
311
|
+
// The first version read `message` and `severity` — the keys the TAP spec's
|
|
312
|
+
// own examples show — and Node writes the failure text under `error:` as a
|
|
313
|
+
// block scalar instead. Every failure therefore parsed with NO messages,
|
|
314
|
+
// and `compareOutcomes`'s "failed under both, with different output" could
|
|
315
|
+
// never fire for the most widely available TAP producer there is. That is a
|
|
316
|
+
// silent wrong verdict, and no amount of reading the spec would have found
|
|
317
|
+
// it.
|
|
318
|
+
//
|
|
319
|
+
// `duration_ms` and `location` stay excluded: one is time, the other is an
|
|
320
|
+
// absolute path. Neither is verdict-bearing, and both would make two
|
|
321
|
+
// identical runs look different.
|
|
322
|
+
if (pending && /^\s*---\s*$/.test(line)) {
|
|
323
|
+
for (i += 1; i < lines.length && !/^\s*\.\.\.\s*$/.test(lines[i]); i += 1) {
|
|
324
|
+
const kv = lines[i].match(/^(\s*)(message|severity|error|code|name|failureType)\s*:\s*(.*)$/);
|
|
325
|
+
if (!kv) continue;
|
|
326
|
+
const [, indent, key, rawValue] = kv;
|
|
327
|
+
const value = rawValue.trim();
|
|
328
|
+
if (/^[|>]-?\+?$/.test(value)) {
|
|
329
|
+
// A block scalar: its body is the more-indented lines beneath it.
|
|
330
|
+
const body = [];
|
|
331
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
332
|
+
if (/^\s*\.\.\.\s*$/.test(lines[j])) break;
|
|
333
|
+
const deeper = lines[j].match(/^(\s*)(.*)$/);
|
|
334
|
+
if (lines[j].trim() && deeper[1].length <= indent.length) break;
|
|
335
|
+
body.push(deeper[2]);
|
|
336
|
+
i = j;
|
|
337
|
+
}
|
|
338
|
+
const firstLine = body.find((b) => b.trim());
|
|
339
|
+
if (firstLine) pending.messages.push(`${key}: ${firstLine.trim()}`);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (value) pending.messages.push(key === "message" ? value.replace(/^["']|["']$/g, "") : `${key}: ${value.replace(/^["']|["']$/g, "")}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
commit();
|
|
348
|
+
const depth = m[1].length;
|
|
349
|
+
// A result line CLOSES its own frame: everything at or deeper than it is
|
|
350
|
+
// finished, and the frame it names is itself (pushed by its `# Subtest:`).
|
|
351
|
+
while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop();
|
|
352
|
+
const rest = m[4] ?? "";
|
|
353
|
+
// The directive is separated from the description by ` # `. Splitting on it
|
|
354
|
+
// also strips a runner's `# time=…` trailer, which must never reach a key.
|
|
355
|
+
const hash = rest.indexOf("#");
|
|
356
|
+
const description = (hash === -1 ? rest : rest.slice(0, hash)).trim();
|
|
357
|
+
const directive = hash === -1 ? "" : rest.slice(hash + 1).trim();
|
|
358
|
+
let status = m[2] === "ok" ? "pass" : "fail";
|
|
359
|
+
if (/^skip\b/i.test(directive)) status = "skip";
|
|
360
|
+
else if (/^todo\b/i.test(directive)) status = "skip";
|
|
361
|
+
const own = description || `test ${m[3] ?? Object.keys(outcomes).length + 1}`;
|
|
362
|
+
const name = [...stack.map((f) => f.name), own].join(" > ");
|
|
363
|
+
pending = { name, status, messages: [] };
|
|
364
|
+
}
|
|
365
|
+
commit();
|
|
366
|
+
return outcomes;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Parse a CTRF report (JSON) into per-test outcomes.
|
|
371
|
+
*
|
|
372
|
+
* CTRF names four statuses this maps onto the probe's four: `passed`,
|
|
373
|
+
* `failed`, `skipped`, `pending` (a skip — it did not run), and `other`, which
|
|
374
|
+
* is the honest match for `error`: the runner could not say. A status the
|
|
375
|
+
* schema does not define is `error` too, because "I do not know what this
|
|
376
|
+
* means" is not "it passed" — the same stance `ERROR` takes everywhere else in
|
|
377
|
+
* this harness.
|
|
378
|
+
*
|
|
379
|
+
* IDENTITY is `suite.name` when a suite is given, mirroring JUnit's
|
|
380
|
+
* `classname.name`, so the two formats key the same test the same way.
|
|
381
|
+
*
|
|
382
|
+
* @param {string} json one CTRF document
|
|
383
|
+
* @returns {Record<string, {status: "pass"|"fail"|"error"|"skip", messages: string[]}>}
|
|
384
|
+
*/
|
|
385
|
+
export function parseCtrfReport(json) {
|
|
386
|
+
const outcomes = {};
|
|
387
|
+
let doc;
|
|
388
|
+
try {
|
|
389
|
+
doc = JSON.parse(json);
|
|
390
|
+
} catch {
|
|
391
|
+
// Unparseable is EMPTY, not a throw: the caller's own rule is that an empty
|
|
392
|
+
// leg is a refusal, never a pass, so this cannot hide a defect — and one
|
|
393
|
+
// corrupt file must not stop the other leg from being read.
|
|
394
|
+
return outcomes;
|
|
395
|
+
}
|
|
396
|
+
const tests = Array.isArray(doc?.results?.tests) ? doc.results.tests : Array.isArray(doc?.tests) ? doc.tests : [];
|
|
397
|
+
const STATUS = { passed: "pass", failed: "fail", skipped: "skip", pending: "skip", other: "error" };
|
|
398
|
+
const seen = new Map();
|
|
399
|
+
for (const t of tests) {
|
|
400
|
+
if (!t || typeof t.name !== "string" || !t.name) continue;
|
|
401
|
+
const base = typeof t.suite === "string" && t.suite ? `${t.suite}.${t.name}` : t.name;
|
|
402
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
403
|
+
seen.set(base, n);
|
|
404
|
+
const messages = [];
|
|
405
|
+
if (typeof t.message === "string" && t.message) messages.push(t.message);
|
|
406
|
+
if (!messages.length && typeof t.trace === "string" && t.trace) messages.push(t.trace.split("\n")[0]);
|
|
407
|
+
outcomes[n === 1 ? base : `${base} #${n}`] = { status: STATUS[t.status] ?? "error", messages };
|
|
408
|
+
}
|
|
409
|
+
return outcomes;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The one entry point a step should call: parse a results directory according
|
|
414
|
+
* to the format the PROFILE declared.
|
|
415
|
+
*
|
|
416
|
+
* Dispatching here rather than at each call site is what keeps the refusal in
|
|
417
|
+
* one place. A step that picked its own parser would be free to disagree with
|
|
418
|
+
* `reportFormatProblem` about what is supported, and the disagreement would
|
|
419
|
+
* surface as an empty outcome map — the failure mode this whole file is
|
|
420
|
+
* arranged to prevent.
|
|
421
|
+
*
|
|
422
|
+
* @param {string} dir a test-results directory
|
|
423
|
+
* @param {{format?: string}} declared the profile's `reports`
|
|
424
|
+
* @returns {Record<string, {status: string, messages: string[]}>}
|
|
425
|
+
*/
|
|
426
|
+
export function parseReportOutcomes(dir, { format } = {}) {
|
|
427
|
+
const problem = reportFormatProblem({ format });
|
|
428
|
+
if (problem) throw new Error(problem);
|
|
429
|
+
if (format === "junit-xml") return parseJUnitOutcomes(dir, { format });
|
|
430
|
+
|
|
431
|
+
const outcomes = {};
|
|
432
|
+
if (!fs.existsSync(dir)) return outcomes;
|
|
433
|
+
const wanted = format === "tap" ? /\.(tap|txt)$/i : /\.json$/i;
|
|
434
|
+
for (const entry of fs.readdirSync(dir).sort()) {
|
|
435
|
+
if (!wanted.test(entry)) continue;
|
|
436
|
+
const body = fs.readFileSync(path.join(dir, entry), "utf8");
|
|
437
|
+
Object.assign(outcomes, format === "tap" ? parseTapStream(body) : parseCtrfReport(body));
|
|
438
|
+
}
|
|
439
|
+
return outcomes;
|
|
440
|
+
}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
// a FAIL says so, and a --fast run (which the ladder deliberately grants no
|
|
19
19
|
// rung) says so rather than borrowing the last good one.
|
|
20
20
|
//
|
|
21
|
+
// And it never renders a rung without the PACK that graded it, which is the
|
|
22
|
+
// same rule in the other axis: the commit says WHEN the claim was true, the
|
|
23
|
+
// pack says WHOSE L2 it is. Until 2026-09-08 it printed the rung alone, so the
|
|
24
|
+
// most-travelled surface this harness writes invited exactly the comparison
|
|
25
|
+
// NORTH-STAR.md §8.9 forbids — one pack's L2 read as another's.
|
|
26
|
+
//
|
|
21
27
|
// Written by the lane AFTER the receipt (it is an output derived from the
|
|
22
28
|
// receipt, never a gate), and committed alongside it.
|
|
23
29
|
|
|
@@ -94,10 +100,36 @@ export function renderEvidenceBadge(receipt) {
|
|
|
94
100
|
const satisfied = Array.isArray(level.satisfiedBy) && level.satisfiedBy.length
|
|
95
101
|
? ` Earned by: ${level.satisfiedBy.map((s) => `\`${s}\``).join(", ")}.`
|
|
96
102
|
: "";
|
|
103
|
+
// THE PACK TRAVELS WITH THE RUNG, in the badge itself and not only in the
|
|
104
|
+
// prose under it. NORTH-STAR.md §6.5 requires it of every surface that shows
|
|
105
|
+
// a rung, and §8.9 says why: one pack's L2 and another pack's L2 are
|
|
106
|
+
// different claims — one may mean a phone drove the app, another that a
|
|
107
|
+
// container held a real database. This badge is the surface that TRAVELS
|
|
108
|
+
// furthest (a GitHub repo page, a PR, a screenshot in a deck), so it is the
|
|
109
|
+
// one where a bare "L2" does the most damage: the reader who sees it has no
|
|
110
|
+
// receipt in front of them to check which ladder it was graded against.
|
|
111
|
+
//
|
|
112
|
+
// Only the ID. `pack.version` on a receipt is currently the harness lock's
|
|
113
|
+
// number rather than the profile's — docs/adr/0008-a-resolved-harness-is-
|
|
114
|
+
// still-a-vendored-one.md — so a badge that printed it would be publishing a
|
|
115
|
+
// borrowed fact on the most-quoted surface we own. The id is the part that
|
|
116
|
+
// carries the meaning, and the id is the profile's own.
|
|
117
|
+
//
|
|
118
|
+
// A receipt with no pack SAYS SO instead of rendering as though the question
|
|
119
|
+
// never arose — an unattributed rung is comparable to nothing, and hiding
|
|
120
|
+
// that is the overclaim this file exists to refuse.
|
|
121
|
+
const pack = typeof receipt.pack?.id === "string" && receipt.pack.id.trim() ? receipt.pack.id.trim() : null;
|
|
122
|
+
const message = pack ? `${pack} ${level.rung} ${level.name}` : `${level.rung} ${level.name}, pack unnamed`;
|
|
123
|
+
const comparability = pack
|
|
124
|
+
? ` The rung is this pack's: \`${pack}\`'s ${level.rung} and another pack's ${level.rung} are different claims.`
|
|
125
|
+
: ` The receipt names no pack, so this rung cannot be compared with any other project's.`;
|
|
97
126
|
return (
|
|
98
|
-
|
|
127
|
+
// The alt text is a rung-bearing surface too — it is what a screen reader
|
|
128
|
+
// and a text-only renderer get instead of the image, so it carries the pack
|
|
129
|
+
// for the same reason the image does.
|
|
130
|
+
`${badge("evidence", message, color, `Evidence ${level.rung} — ${level.name}${pack ? ` (pack ${pack})` : " (pack unnamed)"}`)}` +
|
|
99
131
|
` — the verify lane passed${at}${on} at rung **${level.rung} · ${level.name}**.` +
|
|
100
|
-
`${satisfied}${uncommitted}` +
|
|
132
|
+
`${comparability}${satisfied}${uncommitted}` +
|
|
101
133
|
` The rung describes that run; it says nothing about changes made since.`
|
|
102
134
|
);
|
|
103
135
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The evidence ladder, resolved — ONE answer, for every reader.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. Until 2026-09-08 the ladder had two spellings and each
|
|
4
|
+
// had a different reader, and neither reader knew the other existed:
|
|
5
|
+
//
|
|
6
|
+
// `export const ladder` the top-level declaration. `harness init`
|
|
7
|
+
// seeds it as THE way to declare rungs
|
|
8
|
+
// (install/init.mjs), and
|
|
9
|
+
// qa/receipt-check.mjs read it.
|
|
10
|
+
// `evidenceLadder` on the pack a key on the object `steps(ctx)` returns.
|
|
11
|
+
// The lane runner read only this one
|
|
12
|
+
// (qa/verify.mjs).
|
|
13
|
+
//
|
|
14
|
+
// The one real profile exports BOTH, out of the same frozen constant, so
|
|
15
|
+
// whichever reader ran got the same object and the split was invisible from
|
|
16
|
+
// inside it. A foreign author who did exactly what the seeded skeleton says
|
|
17
|
+
// declared `ladder`; the lane looked for `evidenceLadder`, found none, and
|
|
18
|
+
// graded the run at NO RUNG with no message — because "no ladder, no rung" is
|
|
19
|
+
// the honest answer to a profile that declared nothing and a silently wrong one
|
|
20
|
+
// to a profile that declared it in the other place. Found on 2026-09-08 by
|
|
21
|
+
// scripts/stage2-gate.mjs running in an ecosystem this code had never met,
|
|
22
|
+
// which is how all eight wrong verdicts in NORTH-STAR.md §9.1 were found.
|
|
23
|
+
//
|
|
24
|
+
// BOTH SPELLINGS STAY, because they answer different questions. The pack's is
|
|
25
|
+
// the only one a pack can COMPUTE — it is built inside `steps(ctx)`, where the
|
|
26
|
+
// run's own context is in hand. The top-level one is the only one a reader that
|
|
27
|
+
// must not START A LANE can ask: the Stop hook deciding whether a tier that
|
|
28
|
+
// could have run did holds a project root and nothing else, and calling
|
|
29
|
+
// `steps(ctx)` to find out would be a done-gate that runs a lane
|
|
30
|
+
// (lib/profiles/cmp/index.mjs states this reason where it re-exports the
|
|
31
|
+
// constant).
|
|
32
|
+
//
|
|
33
|
+
// THE PRECEDENCE, AND WHY IT IS DELIBERATELY UNOBSERVABLE. The top-level
|
|
34
|
+
// declaration wins. The argument is not that it is better data; it is that it
|
|
35
|
+
// is the data the WEAKEST reader can see, and the weakest reader and the lane
|
|
36
|
+
// must grade from the same bytes or the product's own comparability rule
|
|
37
|
+
// (NORTH-STAR.md §8.9 — one pack's L2 and another's are different claims)
|
|
38
|
+
// becomes a question of which reader ran. Preferring the pack's would mean the
|
|
39
|
+
// lane grades from something the Stop hook can never see.
|
|
40
|
+
//
|
|
41
|
+
// And because the two must be the SAME ladder, the precedence can never change
|
|
42
|
+
// a rung: where both are present they must agree, and where they disagree this
|
|
43
|
+
// refuses instead of choosing. Deriving a grade from a contradiction would mint
|
|
44
|
+
// a claim nobody made, in the vocabulary evidence is actually sold in. That
|
|
45
|
+
// refusal is the one gate this module adds, and it is calibrated by a kept
|
|
46
|
+
// plant that watches the real lane refuse by name (test/evidence-ladder.test.mjs).
|
|
47
|
+
//
|
|
48
|
+
// WHAT COUNTS AS DISAGREEMENT is exactly the fields the grader reads
|
|
49
|
+
// (evidence-level.mjs), and nothing else. A difference the grader cannot see
|
|
50
|
+
// cannot change a rung, so refusing over one would be a gate with no wrong
|
|
51
|
+
// verdict behind it — mechanism for its own sake, which NORTH-STAR.md §8.8
|
|
52
|
+
// presumes against. The pairing is pinned by a test that derives the read
|
|
53
|
+
// fields from evidence-level.mjs's own source, so the two cannot drift apart.
|
|
54
|
+
//
|
|
55
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/evidence-ladder.mjs in the
|
|
56
|
+
// create-cmp repo. Vendored byte-identical into qa/lib/ — edit the package
|
|
57
|
+
// source, then run `node scripts/sync-harness.mjs`.
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The ladder fields `evidenceLevel` actually reads (evidence-level.mjs). Two
|
|
61
|
+
* declarations differing anywhere else are not in disagreement about any grade,
|
|
62
|
+
* so they are not refused.
|
|
63
|
+
*/
|
|
64
|
+
export const GRADED_FIELDS = Object.freeze(["scaffoldCore", "l0Required", "l1Required", "deviceExecution", "release", "names"]);
|
|
65
|
+
|
|
66
|
+
/** The two spellings, named the way an author wrote them, for every message below. */
|
|
67
|
+
const DECLARED_SPELLING = "`export const ladder` (the profile's top-level declaration)";
|
|
68
|
+
const PACK_SPELLING = "`evidenceLadder` (a key on the object `steps(ctx)` returns)";
|
|
69
|
+
|
|
70
|
+
/** A declaration is PRESENT when it is neither absent nor an explicit "none". */
|
|
71
|
+
function present(v) {
|
|
72
|
+
return v !== undefined && v !== null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Deep equality with functions compared by IDENTITY — a ladder is data, and two
|
|
77
|
+
* distinct functions in the same slot are a difference this cannot see through
|
|
78
|
+
* and must not pretend to.
|
|
79
|
+
*/
|
|
80
|
+
function same(a, b) {
|
|
81
|
+
if (a === b) return true;
|
|
82
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
83
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
84
|
+
return a.every((x, i) => same(x, b[i]));
|
|
85
|
+
}
|
|
86
|
+
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
87
|
+
const ka = Object.keys(a).sort();
|
|
88
|
+
const kb = Object.keys(b).sort();
|
|
89
|
+
if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
|
|
90
|
+
return ka.every((k) => same(a[k], b[k]));
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** How a value reads in a refusal — short, and never a whole ladder dumped inline. */
|
|
96
|
+
function brief(v) {
|
|
97
|
+
if (v === undefined) return "absent";
|
|
98
|
+
if (Array.isArray(v)) return JSON.stringify(v);
|
|
99
|
+
if (v && typeof v === "object") return `{${Object.keys(v).sort().join(", ")}}`;
|
|
100
|
+
return JSON.stringify(v) ?? String(v);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The evidence ladder this profile declares, however it declared it.
|
|
105
|
+
*
|
|
106
|
+
* @param {object|null|undefined} profile the loaded profile module namespace
|
|
107
|
+
* @param {object|null|undefined} [pack] the object `steps(ctx)` returned, when
|
|
108
|
+
* the caller has one. A reader that must not start a lane passes nothing and
|
|
109
|
+
* gets what the profile DECLARES — which is the whole reason the top-level
|
|
110
|
+
* spelling takes precedence.
|
|
111
|
+
* @returns {{ok: true, ladder: object|null, source: "profile"|"pack"|"both"|"none"}
|
|
112
|
+
* |{ok: false, reason: string, source: "both"|"profile"|"pack"}}
|
|
113
|
+
* `ladder: null` with `source: "none"` is the honest grade for a profile that
|
|
114
|
+
* declared no rungs — it is not a failure, and the caller derives no rung.
|
|
115
|
+
*/
|
|
116
|
+
export function evidenceLadderFor(profile, pack) {
|
|
117
|
+
const id = profile && typeof profile.id === "string" ? profile.id : "(unnamed)";
|
|
118
|
+
const declared = profile && typeof profile === "object" ? profile.ladder : undefined;
|
|
119
|
+
const packed = pack && typeof pack === "object" ? pack.evidenceLadder : undefined;
|
|
120
|
+
|
|
121
|
+
// Present-but-wrong is refused, never ignored — the same rule the profile
|
|
122
|
+
// loader applies to `artifacts` and `governable`. A ladder that is not an
|
|
123
|
+
// object silently grades as no ladder at all, which is the defect this
|
|
124
|
+
// module exists to close wearing a different hat.
|
|
125
|
+
for (const [value, spelling, source] of [
|
|
126
|
+
[declared, DECLARED_SPELLING, "profile"],
|
|
127
|
+
[packed, PACK_SPELLING, "pack"],
|
|
128
|
+
]) {
|
|
129
|
+
if (present(value) && (typeof value !== "object" || Array.isArray(value))) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
source,
|
|
133
|
+
reason:
|
|
134
|
+
`profile "${id}" declares ${spelling} as ${brief(value)}, which is not an evidence ladder — ` +
|
|
135
|
+
`a ladder is an object of step names ({ names, l0Required, l1Required, deviceExecution, release }). ` +
|
|
136
|
+
`Fix it or remove it; a profile that declares no ladder earns no rung, which is honest, and this is not that.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// `release` is the ONE graded field the grader reads as a single step name
|
|
142
|
+
// rather than as a list, and that asymmetry produces a silently wrong verdict.
|
|
143
|
+
// evidence-level.mjs does `passed.has(L.release)` against a Set of step-name
|
|
144
|
+
// STRINGS, so a `release: ["distribution"]` never matches anything and L3
|
|
145
|
+
// becomes unreachable without one word being said about it. Every sibling
|
|
146
|
+
// field — scaffoldCore, l0Required, l1Required, deviceExecution — IS a list,
|
|
147
|
+
// and the seeded skeleton showed `release: []`, so the shape an author is
|
|
148
|
+
// most likely to reach for is exactly the one that fails silently. A real
|
|
149
|
+
// one did: the second-stack author who wrote the ktor-backend profile from
|
|
150
|
+
// the contract alone declared `release: ["distribution"]`, and their L3 was
|
|
151
|
+
// unreachable. Executed rather than read — the same ladder grades L2 as a
|
|
152
|
+
// list and L3 as a string.
|
|
153
|
+
//
|
|
154
|
+
// This REFUSES rather than reinterpreting. Accepting the list would be the
|
|
155
|
+
// semantically obvious repair, and it is not taken here, because it would
|
|
156
|
+
// move an existing pack's rung from L2 to L3 — changing what a receipt
|
|
157
|
+
// claims, which fit-test question 5 (NORTH-STAR.md §10) sends to an ADR
|
|
158
|
+
// first. A refusal changes no grade anywhere: a string means exactly what it
|
|
159
|
+
// meant, an absent one earns no L3 exactly as before, and the only tree
|
|
160
|
+
// whose behaviour moves is one that was already being graded wrongly and
|
|
161
|
+
// silently. Whether the ladder should be uniformly list-shaped is the open
|
|
162
|
+
// question, and it belongs in that ADR rather than in this fix.
|
|
163
|
+
for (const [value, spelling, source] of [
|
|
164
|
+
[declared, DECLARED_SPELLING, "profile"],
|
|
165
|
+
[packed, PACK_SPELLING, "pack"],
|
|
166
|
+
]) {
|
|
167
|
+
const release = present(value) ? value.release : undefined;
|
|
168
|
+
if (present(release) && typeof release !== "string") {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
source,
|
|
172
|
+
reason:
|
|
173
|
+
`profile "${id}" declares ${spelling} with release = ${brief(release)}, which names no step. ` +
|
|
174
|
+
`\`release\` takes ONE step name as a string — it is the only ladder field that is not a list, ` +
|
|
175
|
+
`and a list here matches nothing, so L3 is unreachable and nothing says so. ` +
|
|
176
|
+
`Write \`release: "${Array.isArray(release) && typeof release[0] === "string" ? release[0] : "yourReleaseStep"}"\`, ` +
|
|
177
|
+
`or remove the field: a profile that declares no release step earns no L3, which is honest.`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (present(declared) && present(packed)) {
|
|
183
|
+
const differing = GRADED_FIELDS.filter((f) => !same(declared[f], packed[f]));
|
|
184
|
+
if (differing.length) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
source: "both",
|
|
188
|
+
reason:
|
|
189
|
+
`profile "${id}" declares its evidence ladder TWICE and the two disagree, at ` +
|
|
190
|
+
`${differing.map((f) => `${f} (${brief(declared[f])} vs ${brief(packed[f])})`).join("; ")}. ` +
|
|
191
|
+
`${DECLARED_SPELLING} and ${PACK_SPELLING} must be the same ladder — the top-level one is what a reader ` +
|
|
192
|
+
`that cannot start a lane asks, and a rung graded from whichever declaration a given reader happened to ` +
|
|
193
|
+
`see would be a claim nobody made. Delete one, or export the same object from both.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, ladder: declared, source: "both" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (present(declared)) return { ok: true, ladder: declared, source: "profile" };
|
|
200
|
+
if (present(packed)) return { ok: true, ladder: packed, source: "pack" };
|
|
201
|
+
return { ok: true, ladder: null, source: "none" };
|
|
202
|
+
}
|