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.
Files changed (141) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +10 -11
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +271 -37
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/approvals.mjs +177 -350
  16. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  17. package/packages/harness/src/lib/comments.mjs +18 -4
  18. package/packages/harness/src/lib/determinism.mjs +275 -14
  19. package/packages/harness/src/lib/evidence-badge.mjs +34 -2
  20. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  21. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  22. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  23. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  24. package/packages/harness/src/lib/framework-check.mjs +434 -53
  25. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  26. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  27. package/packages/harness/src/lib/harness-region.mjs +106 -2
  28. package/packages/harness/src/lib/harness-source.mjs +142 -0
  29. package/packages/harness/src/lib/inputs-hash.mjs +175 -22
  30. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  31. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  32. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  33. package/packages/harness/src/lib/plan.mjs +44 -11
  34. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  35. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  36. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  37. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  38. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  39. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  40. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  41. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  42. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  43. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  44. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  45. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  46. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  47. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  48. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  49. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  50. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  51. package/packages/harness/src/lib/spec-model.mjs +262 -0
  52. package/packages/harness/src/lib/step-cache.mjs +11 -2
  53. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  54. package/packages/harness/src/lib/walk.mjs +1 -1
  55. package/packages/harness/src/preview-gallery.mjs +1 -1
  56. package/packages/harness/src/receipt-check.mjs +80 -24
  57. package/packages/harness/src/record-audit.mjs +1 -1
  58. package/packages/harness/src/scaffold-feature.mjs +10 -2
  59. package/packages/harness/src/verify.mjs +222 -142
  60. package/packages/harness/src/watch.mjs +146 -33
  61. package/packages/receipts/package.json +1 -1
  62. package/packages/receipts/src/index.mjs +2 -2
  63. package/packages/receipts/src/inputs-hash.mjs +175 -22
  64. package/packages/receipts/src/receipt-validate.mjs +84 -16
  65. package/src/commands/attach.mjs +128 -4
  66. package/src/commands/harden.mjs +7 -1
  67. package/src/commands/upgrade.mjs +7 -0
  68. package/src/lib/minimal.mjs +23 -1
  69. package/src/scaffold.mjs +8 -0
  70. package/template/.claude/settings.json +2 -2
  71. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  72. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  73. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  74. package/template/.githooks/pre-push +9 -0
  75. package/template/CLAUDE.md +55 -186
  76. package/template/docs/TESTING.md +1 -1
  77. package/template/gitignore +3 -0
  78. package/template/qa/approve.mjs +10 -11
  79. package/template/qa/comments.json +1 -1
  80. package/template/qa/evidence/schema.json +258 -34
  81. package/template/qa/framework-check.mjs +271 -37
  82. package/template/qa/harness-manifest.json +10 -0
  83. package/template/qa/lib/a11y.mjs +1 -1
  84. package/template/qa/lib/affected-tests.mjs +99 -44
  85. package/template/qa/lib/approvals.mjs +177 -350
  86. package/template/qa/lib/audit-cadence.mjs +1 -1
  87. package/template/qa/lib/comments.mjs +18 -4
  88. package/template/qa/lib/determinism.mjs +275 -14
  89. package/template/qa/lib/evidence-badge.mjs +34 -2
  90. package/template/qa/lib/evidence-ladder.mjs +202 -0
  91. package/template/qa/lib/evidence-level.mjs +144 -59
  92. package/template/qa/lib/feature-brief.mjs +53 -16
  93. package/template/qa/lib/flight-recorder.mjs +72 -8
  94. package/template/qa/lib/framework-check.mjs +434 -53
  95. package/template/qa/lib/harness-lock.mjs +87 -19
  96. package/template/qa/lib/harness-manifest.mjs +208 -0
  97. package/template/qa/lib/harness-region.mjs +106 -2
  98. package/template/qa/lib/harness-source.mjs +142 -0
  99. package/template/qa/lib/inputs-hash.mjs +175 -22
  100. package/template/qa/lib/lane-markers.mjs +78 -0
  101. package/template/qa/lib/lane-narrator.mjs +4 -2
  102. package/template/qa/lib/lane-runner.mjs +72 -6
  103. package/template/qa/lib/plan.mjs +44 -11
  104. package/template/qa/lib/plant-calibration.mjs +135 -0
  105. package/template/qa/lib/profile-loader.mjs +323 -0
  106. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  107. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  108. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  109. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  110. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  111. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  112. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  113. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  114. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  115. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  116. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  117. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  118. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  119. package/template/qa/lib/receipt-validate.mjs +84 -16
  120. package/template/qa/lib/spec-coverage.mjs +179 -92
  121. package/template/qa/lib/spec-model.mjs +262 -0
  122. package/template/qa/lib/step-cache.mjs +11 -2
  123. package/template/qa/lib/step-outcomes.mjs +84 -151
  124. package/template/qa/lib/walk.mjs +1 -1
  125. package/template/qa/preview-gallery.mjs +1 -1
  126. package/template/qa/receipt-check.mjs +80 -24
  127. package/template/qa/record-audit.mjs +1 -1
  128. package/template/qa/scaffold-feature.mjs +10 -2
  129. package/template/qa/verified-surface.json +17 -0
  130. package/template/qa/verify.mjs +222 -142
  131. package/template/qa/watch.mjs +146 -33
  132. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  133. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  134. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  135. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  136. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  137. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  138. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  139. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  140. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  141. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -0,0 +1,262 @@
1
+ // spec-model.mjs — the scanner's view of a stack: what a profile's `layout`
2
+ // and `tiers` declarations become once the core has checked them.
3
+ //
4
+ // qa/lib/spec-coverage.mjs owns the MECHANIC — the clause grammar, the
5
+ // citation binding window, coverage in both directions, tier-must-observe.
6
+ // The profile owns the MODEL — where specs and sources live, what a citation
7
+ // file looks like, which tiers exist and which can observe which promise
8
+ // (docs/NORTH-STAR.md §6; AGNOSTIC-HARNESS-ARCHITECTURE.md §4.2 #1 and #4).
9
+ // This module joins them: it builds one validated `model` object from a
10
+ // profile, and it resolves that model from a project root for the callers
11
+ // that only have a root — the console's Specs bridge, feature-brief's derived
12
+ // doneness, the framework-check runner.
13
+ //
14
+ // Resolution is SYNCHRONOUS on purpose. Every root-only caller is a sync
15
+ // function deep in a sync chain (approvals → feature-brief → scanCitations),
16
+ // and the profile is plain ESM with no top-level await, so it is loaded with
17
+ // `require()` — supported for ESM since Node 20.19 / 22.12 and sharing the
18
+ // module cache with `import()`, so the lane and a sync reader see the same
19
+ // instance. Node 18 and 20.18 are end-of-life; the engines floor says so.
20
+ //
21
+ // Two override rules, both deliberate:
22
+ // - the manifest's layout fields (`specs`, `citationRoots`) override the
23
+ // profile's, field by field — the same semantics the console applies, so
24
+ // an attached foreign repo that told `attach` where its tests are gets the
25
+ // same scan from both readers. A stamped app's manifest carries the
26
+ // profile's own values, so the override is the identity there.
27
+ // - there is NO fallback model. A profile that declares no `layout` or no
28
+ // `tiers` is refused by name at load; a root with no manifest is refused
29
+ // with the command that writes one. A scanner that guessed a layout would
30
+ // report honest-looking absences about files ten characters away.
31
+ //
32
+ // SINGLE SOURCE OF TRUTH: packages/harness/src/lib/spec-model.mjs in the
33
+ // create-cmp repo. Vendored byte-identical into qa/lib/ — edit the package
34
+ // source, then run `node scripts/sync-harness.mjs`.
35
+
36
+ import { resolveHarnessManifest } from "./harness-manifest.mjs";
37
+ import { loadProfileSync } from "./profile-loader.mjs";
38
+
39
+ /** A clause's declared requirement name: `[tier: device]` → "device". One path segment of letters/digits/dashes. */
40
+ export const TIER_NAME_RE = /^[a-z][a-z0-9-]*$/i;
41
+
42
+ function isStringList(v) {
43
+ return Array.isArray(v) && v.length > 0 && v.every((s) => typeof s === "string" && s.trim() !== "");
44
+ }
45
+
46
+ function relPathProblem(field, value) {
47
+ if (typeof value !== "string" || value.trim() === "") return `${field} must be a non-empty string`;
48
+ if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) return `${field} must be relative to the project root (got "${value}")`;
49
+ if (value.includes("\\")) return `${field} must use "/" separators (got "${value}")`;
50
+ if (value.split("/").some((seg) => seg === "..")) return `${field} may not escape the project root (got "${value}")`;
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Every contract violation in a profile's `layout` and `tiers`, or [] when
56
+ * they are usable. Never throws; a caller prints all defects at once.
57
+ * @param {{layout?: unknown, tiers?: unknown}} profile
58
+ * @returns {string[]}
59
+ */
60
+ /**
61
+ * The core's fallback grammar: Kotlin/JVM and JavaScript. It is a FALLBACK and
62
+ * not a default in the approving sense — `SpecModel.grammar.isDefault` records
63
+ * that a profile declared none of it, and the coverage scan says so out loud
64
+ * when nothing binds. A stack whose tests look like anything else declares its
65
+ * own `grammar` export; `create-cmp harness init` seeds one from the language
66
+ * it detects.
67
+ */
68
+ /**
69
+ * THE GRAMMAR IS DECLARED, NEVER DEFAULTED. Until 2026-09-08 this constant held
70
+ * Kotlin's and JavaScript's test-declaration regexes as the core's FALLBACK, and
71
+ * `cmp` itself never declared a grammar — so every profile that forgot the
72
+ * export was graded with Kotlin's, silently. PATTERN: declaration over
73
+ * inference, the shape tree-sitter uses (one query per language, named
74
+ * captures). WHY IT WORKS: a required field cannot be silently wrong for the
75
+ * author who forgot it — the lane refuses by name. HOW IT FAILS: a copied regex
76
+ * from another language binds nothing or the wrong lines. WHAT WE DO: the Rule
77
+ * 0 instrument plants an unbound citation in THIS language and watches the
78
+ * grammar fail it by name; the coverage diagnostic prints "N markers seen, 0
79
+ * bound". The two fields below are language-neutral and may default.
80
+ */
81
+ export const GRAMMAR_REQUIRED = Object.freeze(["citationMarker", "testDeclaration", "lineComment"]);
82
+ export const GRAMMAR_DEFAULTS = Object.freeze({ blockComment: null, bindingWindow: 5 });
83
+
84
+ /** A profile may declare a pattern as a RegExp or as a source string. */
85
+ function regexOr(value, fallback) {
86
+ if (value instanceof RegExp) return value;
87
+ if (typeof value === "string" && value.trim()) {
88
+ try {
89
+ return new RegExp(value);
90
+ } catch {
91
+ return fallback;
92
+ }
93
+ }
94
+ return fallback;
95
+ }
96
+
97
+ export function specDeclarationProblems(profile) {
98
+ const out = [];
99
+ const layout = profile?.layout;
100
+ const tiers = profile?.tiers;
101
+ if (!layout || typeof layout !== "object") out.push("layout must be an object");
102
+ else {
103
+ const p = relPathProblem("layout.specs", layout.specs);
104
+ if (p) out.push(p);
105
+ if (!isStringList(layout.citationRoots)) out.push("layout.citationRoots must be a non-empty list of project-relative paths");
106
+ else for (const r of layout.citationRoots) { const q = relPathProblem("layout.citationRoots[]", r); if (q) out.push(q); }
107
+ if (!isStringList(layout.citationExts) || !layout.citationExts.every((e) => e.startsWith("."))) out.push('layout.citationExts must be a non-empty list of file extensions, each starting with "."');
108
+ if (layout.sourceRoots != null) {
109
+ if (!isStringList(layout.sourceRoots)) out.push("layout.sourceRoots must be a non-empty list of project-relative paths");
110
+ else for (const r of layout.sourceRoots) { const q = relPathProblem("layout.sourceRoots[]", r); if (q) out.push(q); }
111
+ }
112
+ if (layout.buildDir != null) {
113
+ const q = relPathProblem("layout.buildDir", layout.buildDir);
114
+ if (q) out.push(q);
115
+ }
116
+ if (layout.flows != null) {
117
+ if (typeof layout.flows !== "object") out.push("layout.flows must be null or {dir, exts}");
118
+ else {
119
+ const q = relPathProblem("layout.flows.dir", layout.flows.dir);
120
+ if (q) out.push(q);
121
+ if (!isStringList(layout.flows.exts) || !layout.flows.exts.every((e) => e.startsWith("."))) out.push('layout.flows.exts must be a non-empty list of file extensions, each starting with "."');
122
+ }
123
+ }
124
+ }
125
+ if (!tiers || typeof tiers !== "object") out.push("tiers must be an object");
126
+ else {
127
+ if (!isStringList(tiers.names)) out.push("tiers.names must be a non-empty list of tier names");
128
+ const names = new Set(isStringList(tiers.names) ? tiers.names : []);
129
+ if (!Array.isArray(tiers.hostOnly) || !tiers.hostOnly.every((t) => names.has(t))) out.push("tiers.hostOnly must list tiers from tiers.names");
130
+ if (!tiers.satisfying || typeof tiers.satisfying !== "object") out.push("tiers.satisfying must map a requirement name to the tiers that satisfy it");
131
+ else {
132
+ for (const [req, list] of Object.entries(tiers.satisfying)) {
133
+ if (!TIER_NAME_RE.test(req)) out.push(`tiers.satisfying has an invalid requirement name "${req}"`);
134
+ if (!isStringList(list) || !list.every((t) => names.has(t))) out.push(`tiers.satisfying.${req} must list tiers from tiers.names`);
135
+ }
136
+ }
137
+ if (tiers.journey != null && !names.has(tiers.journey)) out.push("tiers.journey must be one of tiers.names (or null when this stack has no journey tier)");
138
+ if (typeof tiers.forFile !== "function") out.push("tiers.forFile(rel) must be a function returning the citing file's tier");
139
+ }
140
+ const grammar = profile?.grammar;
141
+ if (!grammar || typeof grammar !== "object") {
142
+ out.push("grammar is required — declare citationMarker, testDeclaration and lineComment for this stack's language; the core has no fallback grammar (`create-cmp harness init` seeds one per language)");
143
+ } else {
144
+ for (const f of GRAMMAR_REQUIRED) {
145
+ const v = grammar[f];
146
+ if (!(v instanceof RegExp) && !(typeof v === "string" && v.trim())) out.push(`grammar.${f} is required — a RegExp or a pattern string for this language`);
147
+ else if (typeof v === "string") {
148
+ // A pattern that does not compile used to fall back to Kotlin's. It is a
149
+ // declaration problem, named here, so the lane refuses before it grades.
150
+ try { new RegExp(v); } catch (err) { out.push(`grammar.${f} is not a valid pattern: ${err.message}`); }
151
+ }
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Build the scanner's model from a profile's declarations, applying manifest
159
+ * layout overrides field by field. Pure.
160
+ * @param {{id?: string, layout: object, tiers: object}} profile
161
+ * @param {{specs?: string, citationRoots?: string[]}} [overrides] manifest layout fields
162
+ * @returns {{ok: true, model: SpecModel} | {ok: false, reason: string}}
163
+ */
164
+ export function specModelFrom(profile, overrides = {}) {
165
+ const problems = specDeclarationProblems(profile);
166
+ if (problems.length) {
167
+ return { ok: false, reason: `profile ${JSON.stringify(profile?.id ?? "?")} declares an unusable layout/tiers: ${problems.join("; ")}` };
168
+ }
169
+ const { layout, tiers } = profile;
170
+ const specsDir = typeof overrides.specs === "string" && overrides.specs.trim() ? overrides.specs : layout.specs;
171
+ const citationRoots = isStringList(overrides.citationRoots) ? [...overrides.citationRoots] : [...layout.citationRoots];
172
+ const flows = layout.flows ? { dir: layout.flows.dir, exts: [...layout.flows.exts] } : null;
173
+ return {
174
+ ok: true,
175
+ model: Object.freeze({
176
+ profileId: typeof profile.id === "string" ? profile.id : null,
177
+ specsDir,
178
+ citationRoots: Object.freeze(citationRoots),
179
+ citationExts: Object.freeze([...layout.citationExts]),
180
+ // Absent means "the citation roots are the source roots" — a profile that
181
+ // never distinguished them keeps working, and nothing is invented.
182
+ sourceRoots: Object.freeze(isStringList(layout.sourceRoots) ? [...layout.sourceRoots] : [...citationRoots]),
183
+ flows: flows ? Object.freeze(flows) : null,
184
+ buildDir: typeof layout.buildDir === "string" ? layout.buildDir : null,
185
+ // THE GRAMMAR — what a citation and a test declaration LOOK LIKE in this
186
+ // stack's language. Stage 0 moved names, paths and tier names into the
187
+ // profile and left this behind, which was the more dangerous half: a
188
+ // directory name that is wrong produces a refusal, and a grammar that is
189
+ // wrong produces a WRONG VERDICT. The core's fallbacks match Kotlin/JVM
190
+ // and JavaScript only, so a Python or Go project scanned with them finds
191
+ // every marker and binds none — every clause reads as uncited and the
192
+ // message points at the spec file, which is not the problem. Measured on
193
+ // a real Python adoption, 2026-09-05. Field-by-field override: a profile
194
+ // that declares only `testDeclaration` keeps the rest.
195
+ grammar: Object.freeze({
196
+ citationMarker: regexOr(profile.grammar?.citationMarker, null),
197
+ testDeclaration: regexOr(profile.grammar?.testDeclaration, null),
198
+ typeDeclaration: regexOr(profile.grammar?.typeDeclaration, null),
199
+ lineComment: regexOr(profile.grammar?.lineComment, null),
200
+ blockComment: Object.freeze(
201
+ profile.grammar?.blockComment && typeof profile.grammar.blockComment.open === "string" && typeof profile.grammar.blockComment.close === "string"
202
+ ? { open: profile.grammar.blockComment.open, close: profile.grammar.blockComment.close }
203
+ : GRAMMAR_DEFAULTS.blockComment,
204
+ ),
205
+ bindingWindow:
206
+ Number.isInteger(profile.grammar?.bindingWindow) && profile.grammar.bindingWindow > 0
207
+ ? profile.grammar.bindingWindow
208
+ : GRAMMAR_DEFAULTS.bindingWindow,
209
+ // Always false since 2026-09-08: there is no fallback to be running on.
210
+ // Kept as a field because two readers print it.
211
+ isDefault: false,
212
+ }),
213
+ tiers: Object.freeze({
214
+ names: Object.freeze([...tiers.names]),
215
+ hostOnly: Object.freeze([...tiers.hostOnly]),
216
+ satisfying: Object.freeze(Object.fromEntries(Object.entries(tiers.satisfying).map(([k, v]) => [k, Object.freeze([...v])]))),
217
+ journey: tiers.journey ?? null,
218
+ forFile: (rel) => {
219
+ const t = tiers.forFile(rel);
220
+ return typeof t === "string" && t ? t : "other";
221
+ },
222
+ }),
223
+ }),
224
+ };
225
+ }
226
+
227
+ /**
228
+ * The model for a project root: manifest → profile (sync) → declarations,
229
+ * with the manifest's layout fields overriding the profile's.
230
+ * @param {string} root
231
+ * @returns {{ok: true, model: SpecModel} | {ok: false, reason: string}}
232
+ */
233
+ export function resolveSpecModel(root) {
234
+ const manifest = resolveHarnessManifest(root);
235
+ if (!manifest.ok) return { ok: false, reason: manifest.reason };
236
+ const loaded = loadProfileSync(root, manifest.manifest.profile);
237
+ if (!loaded.ok) return { ok: false, reason: loaded.reason };
238
+ return specModelFrom(loaded.profile, { specs: manifest.manifest.specs, citationRoots: manifest.manifest.citationRoots });
239
+ }
240
+
241
+ /**
242
+ * resolveSpecModel for callers with no refusal channel: throws the reason.
243
+ * @param {string} root
244
+ * @returns {SpecModel}
245
+ */
246
+ export function requireSpecModel(root) {
247
+ const r = resolveSpecModel(root);
248
+ if (!r.ok) throw new Error(r.reason);
249
+ return r.model;
250
+ }
251
+
252
+ /**
253
+ * @typedef {object} SpecModel
254
+ * @property {string|null} profileId
255
+ * @property {string} specsDir
256
+ * @property {readonly string[]} citationRoots
257
+ * @property {readonly string[]} citationExts
258
+ * @property {readonly string[]} sourceRoots
259
+ * @property {{dir: string, exts: readonly string[]}|null} flows
260
+ * @property {string|null} buildDir
261
+ * @property {{names: readonly string[], hostOnly: readonly string[], satisfying: Record<string, readonly string[]>, journey: string|null, forFile: (rel: string) => string}} tiers
262
+ */
@@ -38,7 +38,16 @@ import fs from "node:fs";
38
38
  import path from "node:path";
39
39
 
40
40
  export const STEP_CACHE_REL_PATH = "composeApp/build/.cmp-step-cache.json";
41
- export const STEP_CACHE_SCHEMA = "cmp-step-cache/1";
41
+ export const STEP_CACHE_SCHEMA = "prooflane-step-cache/1";
42
+
43
+ /**
44
+ * Schema names a cache file may carry. Unlike the comments ledger, nothing here
45
+ * is at stake but time: an unrecognised schema makes `loadStepCache` return an
46
+ * EMPTY cache, which is correct — a cache you cannot read is a cache you do not
47
+ * have. Accepting the old name only spares every existing adopter one needless
48
+ * cold rebuild on the run after they upgrade.
49
+ */
50
+ export const STEP_CACHE_SCHEMAS = Object.freeze(["cmp-step-cache/1", STEP_CACHE_SCHEMA]);
42
51
 
43
52
  function toPosix(p) {
44
53
  return p.split(path.sep).join("/");
@@ -116,7 +125,7 @@ export function computeStepInputsHash(root, inputs) {
116
125
  export function loadStepCache(root) {
117
126
  try {
118
127
  const parsed = JSON.parse(fs.readFileSync(path.join(root, STEP_CACHE_REL_PATH), "utf8"));
119
- if (!parsed || parsed.schema !== STEP_CACHE_SCHEMA || typeof parsed.steps !== "object" || parsed.steps === null || Array.isArray(parsed.steps)) {
128
+ if (!parsed || !STEP_CACHE_SCHEMAS.includes(parsed.schema) || typeof parsed.steps !== "object" || parsed.steps === null || Array.isArray(parsed.steps)) {
120
129
  return { schema: STEP_CACHE_SCHEMA, steps: {} };
121
130
  }
122
131
  return { schema: STEP_CACHE_SCHEMA, steps: parsed.steps };
@@ -10,7 +10,7 @@
10
10
  // the step — a gate that misattributes its own failures corrodes the gates that
11
11
  // are right.
12
12
  //
13
- // Pure, so the wording and the rule are testable without Gradle or a device.
13
+ // Pure, so the wording and the rule are testable without a build tool or a device.
14
14
  // (docs/proposals/evidence-economics.md C3, S4.)
15
15
  //
16
16
  // FOUR VERDICTS. PASS / FAIL / SKIP had no way to say "I could not run": a
@@ -24,47 +24,6 @@
24
24
  // TIMEOUT vs FAILED, pytest's error vs failed — the distinction every mature
25
25
  // runner makes and this one did not.
26
26
 
27
- /**
28
- * The androidChecks outcome from Gradle's exit and the JUnit summary.
29
- *
30
- * @param {{ok: boolean, out: string}} res the Gradle invocation
31
- * @param {{tests: number, failures: number, errors: number}|null} summary parsed JUnit
32
- * results, or null when none were written
33
- * @param {{gradlew?: string}} [opts]
34
- * @returns {{verdict: "PASS"|"FAIL"|"ERROR", executed: boolean, reason?: string}}
35
- */
36
- export function androidChecksOutcome(res, summary, { gradlew = "./gradlew" } = {}) {
37
- const executed = Boolean(summary && summary.tests > 0);
38
- if (res.ok) return { verdict: "PASS", executed };
39
- const tail = String(res.out ?? "")
40
- .split("\n")
41
- .filter((l) => /FAILED|error:|failed/i.test(l))
42
- .slice(0, 12)
43
- .join("\n");
44
- if (executed) {
45
- return {
46
- verdict: "FAIL",
47
- executed,
48
- reason:
49
- `connectedDebugAndroidTest failed (${summary.failures + summary.errors} of ${summary.tests} tests) — ` +
50
- `an on-device behavior claim is broken. Fix the behavior, not the test:\n${tail}`,
51
- };
52
- }
53
- // ERROR, not FAIL: the step could not execute. A device tier that could not
54
- // run is not evidence (the lane still FAILs), and going green would be the
55
- // worse lie — but "your behaviour is broken" is withdrawn, and the receipt
56
- // can tell a red that measured something from a red that measured nothing.
57
- return {
58
- verdict: "ERROR",
59
- executed,
60
- reason:
61
- "connectedDebugAndroidTest DID NOT EXECUTE — the run reported no tests at all, so this step has observed " +
62
- "nothing about your change and is not accusing it. Usual cause: another adb/Gradle session touching the same " +
63
- "device (a manual `adb` command, a second lane, a running preview), or an install that never landed. " +
64
- `Re-run this step alone with nothing else on the device before suspecting the code:\n ${gradlew} :composeApp:connectedDebugAndroidTest --rerun\n${tail}`,
65
- };
66
- }
67
-
68
27
  /** Thrown by the lane's subprocess helper when a step's deadline passes. */
69
28
  export class StepTimeout extends Error {
70
29
  constructor(cmd, deadlineMs) {
@@ -88,15 +47,69 @@ export function spawnTimedOut(res) {
88
47
  return res.signal === "SIGTERM" && (res.status === null || res.status === undefined);
89
48
  }
90
49
 
50
+ /**
51
+ * The bounds a pack gets when it declares none.
52
+ *
53
+ * These were the rule. "Never under five minutes, never over thirty" is a
54
+ * judgement about how long a build can honestly take before it is wedged, and
55
+ * it was made once, for one toolchain: a cold `xcodebuild`, a cold `cargo test`
56
+ * against an empty target dir, or a Gradle daemon starting on a cold CI runner
57
+ * can each exceed thirty minutes honestly — and the lane killed the step and
58
+ * wrote an ERROR row, which is a wrong verdict about a healthy build.
59
+ *
60
+ * `stepDeadlineMs` always accepted overrides. Nothing supplied them: there was
61
+ * no channel from the profile to the call, so the numbers were unreachable from
62
+ * the only place that knows the stack. That was the defect, not the constants —
63
+ * which is why they survive here as a fallback rather than being replaced by a
64
+ * different guess.
65
+ */
66
+ export const DEFAULT_STEP_DEADLINES = Object.freeze({ floorMs: 5 * 60_000, ceilingMs: 30 * 60_000 });
67
+
68
+ /** A positive, finite number of milliseconds, or null. */
69
+ function msOrNull(v) {
70
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
71
+ }
72
+
73
+ /**
74
+ * Resolve a pack's declared deadline bounds against the fallback.
75
+ *
76
+ * A malformed declaration is refused into the fallback rather than trusted: a
77
+ * deadline is the one number where believing a bad value either kills a healthy
78
+ * build or lets a wedged one run forever. This mirrors how the spine treats
79
+ * every other pack field — duck-typed and degrading — except that here the
80
+ * degradation is TOLD to the adopter, because `isDefault` reaches the ERROR row.
81
+ *
82
+ * `isDefault` is true only when NOTHING usable was declared. A pack that named
83
+ * one bound has made a choice and is not lectured about the other.
84
+ *
85
+ * @param {{floorMs?: number, ceilingMs?: number}|null|undefined} declared
86
+ * @returns {{floorMs: number, ceilingMs: number, isDefault: boolean}}
87
+ */
88
+ export function resolveStepDeadlines(declared) {
89
+ const floor = declared && typeof declared === "object" ? msOrNull(declared.floorMs) : null;
90
+ const ceiling = declared && typeof declared === "object" ? msOrNull(declared.ceilingMs) : null;
91
+ return {
92
+ floorMs: floor ?? DEFAULT_STEP_DEADLINES.floorMs,
93
+ ceilingMs: ceiling ?? DEFAULT_STEP_DEADLINES.ceilingMs,
94
+ isDefault: floor === null && ceiling === null,
95
+ };
96
+ }
97
+
91
98
  /**
92
99
  * A step's own deadline, from the journal's last measured duration for it:
93
- * three times what it usually takes, never under five minutes (a cold Gradle
94
- * daemon is slow, not wedged), never over thirty (past that it IS wedged).
100
+ * three times what it usually takes, never under the pack's floor (a cold build
101
+ * daemon is slow, not wedged), never over its ceiling (past that it IS wedged).
95
102
  * Unknown steps get the ceiling — a first run is never cut short.
103
+ *
104
+ * A floor above the ceiling is incoherent and the CEILING wins, because `min`
105
+ * is the outer operation: cutting a healthy step short is the failure this
106
+ * mechanism exists to avoid, so an incoherent declaration errs toward waiting.
107
+ *
96
108
  * @param {number|null|undefined} expectedMs
109
+ * @param {{floorMs?: number, ceilingMs?: number}} [bounds] the pack's, via resolveStepDeadlines
97
110
  * @returns {number}
98
111
  */
99
- export function stepDeadlineMs(expectedMs, { floorMs = 5 * 60_000, ceilingMs = 30 * 60_000 } = {}) {
112
+ export function stepDeadlineMs(expectedMs, { floorMs = DEFAULT_STEP_DEADLINES.floorMs, ceilingMs = DEFAULT_STEP_DEADLINES.ceilingMs } = {}) {
100
113
  if (!(expectedMs > 0)) return ceilingMs;
101
114
  return Math.min(ceilingMs, Math.max(floorMs, Math.round(expectedMs * 3)));
102
115
  }
@@ -106,122 +119,42 @@ export function stepDeadlineMs(expectedMs, { floorMs = 5 * 60_000, ceilingMs = 3
106
119
  * out of the step's own body (which used to crash the whole lane; now it is
107
120
  * one ERROR row and the lane keeps going, because the other steps' verdicts
108
121
  * are still worth having).
122
+ * WHERE TO LOOK is the pack's to say, never the spine's: this used to end with
123
+ * "check `./gradlew --status` and `adb devices`", which is confident wrong
124
+ * advice in a repo that has neither. A pack marks a step with `fn.timeoutHint`
125
+ * (the same mechanism as `fn.layer`) and the runner passes it through; with no
126
+ * hint the message says what it honestly knows and stops.
127
+ *
109
128
  * @param {string} name the step's display name
110
129
  * @param {unknown} err
111
130
  * @param {number} durationMs
131
+ * HOW LONG TO WAIT is the pack's too, and when it has not said, the row says
132
+ * so. A killed step used to give an adopter no way to learn the bound existed —
133
+ * let alone that it was another stack's number — so a cold `xcodebuild` looked
134
+ * like a wedged build rather than a knob nobody had turned.
135
+ *
136
+ * @param {{hint?: string, deadlineWasDefault?: boolean}} [opts] `hint` — the pack's own
137
+ * where-to-look sentence; `deadlineWasDefault` — the bound was inherited, so name the knob
112
138
  * @returns {{name: string, verdict: "ERROR", reason: string, durationMs: number, details: {executed: false, kind: string}}}
113
139
  */
114
- export function stepErrorResult(name, err, durationMs) {
140
+ export function stepErrorResult(name, err, durationMs, { hint, deadlineWasDefault = false } = {}) {
115
141
  const timeout = err instanceof StepTimeout;
142
+ const where = typeof hint === "string" && hint.trim() ? ` ${hint.trim()}` : "";
143
+ const knob = deadlineWasDefault
144
+ ? ` This deadline is the spine's fallback, not a number this stack chose — if the step was healthy and simply slow, declare \`stepDeadlines: { ceilingMs }\` on the step pack.`
145
+ : "";
116
146
  const reason = timeout
117
- ? `DID NOT COMPLETE — no result within its deadline (${Math.round(err.deadlineMs / 60000)} min). This step has observed nothing about your change and is not accusing it. ` +
118
- `A wedged Gradle daemon or a device that stopped answering are the usual causes; check \`./gradlew --status\` and \`adb devices\`, then re-run the step alone.
147
+ ? `DID NOT COMPLETE — no result within its deadline (${Math.round(err.deadlineMs / 60000)} min). This step has observed nothing about your change and is not accusing it.${where}${knob} ` +
148
+ `Re-run the step alone before suspecting the code.
119
149
  ${err.cmd}`
120
150
  : `DID NOT RUN — the step threw before producing a verdict: ${err && err.message ? err.message : String(err)}. ` +
121
151
  `Nothing here is a claim about your change.`;
122
152
  return { name, verdict: "ERROR", reason, durationMs, details: { executed: false, kind: timeout ? "deadline" : "threw" } };
123
153
  }
124
154
 
125
- // ── Maestro directory run per-flow outcome ────────────────────────────────
126
- /**
127
- * Parse Maestro's JUnit report (`maestro test <dir> --format junit --output f`)
128
- * into one row per flow. Tolerant: a report that is missing or unparsable
129
- * returns null and the caller falls back to the exit code — never a fabricated
130
- * per-flow list.
131
- * @param {string|null} xml
132
- * @returns {Array<{flow: string, ok: boolean, message?: string}>|null}
133
- */
134
- export function parseMaestroJunit(xml) {
135
- if (typeof xml !== "string" || !/<testcase\b/.test(xml)) return null;
136
- const rows = [];
137
- const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
138
- let m;
139
- while ((m = caseRe.exec(xml))) {
140
- const attrs = m[1] || "";
141
- const body = m[2] || "";
142
- const name = (attrs.match(/\bname="([^"]*)"/) || [])[1] ?? (attrs.match(/\bid="([^"]*)"/) || [])[1] ?? "?";
143
- const status = (attrs.match(/\bstatus="([^"]*)"/) || [])[1];
144
- const failed = /<(failure|error)\b/.test(body) || (status && !/^(SUCCESS|PASSED?|OK)$/i.test(status));
145
- const message = failed ? ((body.match(/<(?:failure|error)\b[^>]*message="([^"]*)"/) || [])[1] ?? body.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 300)) : undefined;
146
- rows.push(failed ? { flow: name, ok: false, message } : { flow: name, ok: true });
147
- }
148
- return rows.length ? rows : null;
149
- }
150
-
151
- /**
152
- * The e2e step's verdict from the Maestro run: exit code + per-flow report +
153
- * the list of flows the directory held. FAIL names every failing flow; a run
154
- * whose report lists fewer flows than the directory holds is ERROR — the lane
155
- * cannot claim flows it has no row for.
156
- * @param {{ok: boolean, out: string}} res
157
- * @param {Array<{flow: string, ok: boolean, message?: string}>|null} perFlow
158
- * @param {string[]} flows root-relative flow files the directory holds
159
- * @returns {{verdict: "PASS"|"FAIL"|"ERROR", reason?: string, details: object}}
160
- */
161
- export function maestroOutcome(res, perFlow, flows) {
162
- const details = { flows, results: perFlow ?? undefined };
163
- if (perFlow) {
164
- const failed = perFlow.filter((r) => !r.ok);
165
- if (failed.length) {
166
- return {
167
- verdict: "FAIL",
168
- reason: `Maestro: ${failed.length} of ${perFlow.length} flow${perFlow.length === 1 ? "" : "s"} failed — ${failed.map((r) => `${r.flow}${r.message ? ` (${r.message.split("\n")[0].slice(0, 120)})` : ""}`).join("; ")}`,
169
- details,
170
- };
171
- }
172
- if (perFlow.length < flows.length) {
173
- return {
174
- verdict: "ERROR",
175
- reason: `Maestro reported ${perFlow.length} flow${perFlow.length === 1 ? "" : "s"} but ${E2E_DIR_LABEL} holds ${flows.length} — the run did not cover every flow, so no verdict can be claimed for the rest`,
176
- details,
177
- };
178
- }
179
- if (!res.ok) {
180
- return { verdict: "FAIL", reason: `Maestro exited non-zero with every flow reported green — treat as a run failure:\n${String(res.out).split("\n").slice(-10).join("\n")}`, details };
181
- }
182
- return { verdict: "PASS", details };
183
- }
184
- if (!res.ok) {
185
- return { verdict: "FAIL", reason: `Maestro failed (no per-flow report was written):\n${String(res.out).split("\n").slice(-15).join("\n")}`, details };
186
- }
187
- return { verdict: "PASS", reason: "Maestro exited 0 but wrote no per-flow report — verdict from the exit code only", details };
188
- }
189
- const E2E_DIR_LABEL = "qa/e2e";
190
-
191
- // ── Device-log incidents, scoped to the app under test ──────────────────────
192
- /**
193
- * ANR / fatal-exception lines from `adb logcat -d -b system,crash,main` that
194
- * belong to one of `appIds` (an app's process may be `pkg` or `pkg:remote`).
195
- * An emulator carries other apps — on 2026-09-03 the first self-booted lane
196
- * went red on `ANR in com.karel.bratometer` while driving com.fleet.check —
197
- * so an incident in another package is NOT this lane's failure. With no
198
- * appIds known the sweep stays unscoped (every incident counts) and says so.
199
- * @param {string} log
200
- * @param {string[]} appIds
201
- * @returns {{lines: string[], scoped: boolean}}
202
- */
203
- export function deviceLogIncidents(log, appIds = []) {
204
- const lines = String(log ?? "").split("\n");
205
- const ids = appIds.filter((id) => typeof id === "string" && id.trim() && id !== "__PACKAGE__");
206
- const scoped = ids.length > 0;
207
- const ours = (proc) => !scoped || ids.some((id) => proc === id || proc.startsWith(`${id}:`));
208
- const out = [];
209
- lines.forEach((line, i) => {
210
- const anr = line.match(/ANR in (\S+?)(?:\s|,|$)/);
211
- if (anr) {
212
- if (ours(anr[1].replace(/[,)]$/, ""))) out.push(line);
213
- return;
214
- }
215
- if (/FATAL EXCEPTION/i.test(line)) {
216
- if (!scoped) {
217
- out.push(line);
218
- return;
219
- }
220
- // The crash buffer names the process on one of the next few lines.
221
- const window = lines.slice(i, i + 8).join("\n");
222
- const proc = window.match(/Process:\s*(\S+?)(?:,|\s|$)/);
223
- if (proc && ours(proc[1])) out.push(line);
224
- }
225
- });
226
- return { lines: out, scoped };
227
- }
155
+ // The Maestro per-flow verdict and the device-log sweep moved to
156
+ // qa/lib/profiles/cmp/maestro.mjs (Stage 0 PR 3); androidChecksOutcome moved to
157
+ // qa/lib/profiles/cmp/android-checks.mjs (Stage 0 PR 6b.2). Facts about an
158
+ // Android device, a Gradle task and an APK are a mobile profile's, not the
159
+ // spine's. What is left here is neutral: deadlines, timeouts, and turning a
160
+ // throw into one ERROR row.
@@ -512,7 +512,7 @@ export function renderInject(data) {
512
512
  for (const a of arrivals)
513
513
  parts.push(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}. Offer: handle now, or after the current walk lands (recommended: after).`);
514
514
  parts.push(
515
- "Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently. Principles that bind THIS turn (docs/PRINCIPLES.md): derived, never claimed — name the command behind any claim; the layer you changed cannot certify itself — run its consumers, stamp a fresh app for a template or harness change; never wait on nothing — every wait is bounded, and if you are blocked, say on what and stop.",
515
+ "Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently. Principles that bind THIS turn (create-cmp's docs/PRINCIPLES.md): derived, never claimed — name the command behind any claim; the layer you changed cannot certify itself — run its consumers, stamp a fresh app for a template or harness change; never wait on nothing — every wait is bounded, and if you are blocked, say on what and stop.",
516
516
  );
517
517
  return parts.join("\n\n");
518
518
  }
@@ -20,7 +20,7 @@ const previewsDir = resolve(
20
20
  process.argv[2] || join(HERE, "..", "composeApp", "build", "previews"),
21
21
  );
22
22
 
23
- const { renderTreeSvg } = await import(new URL("./lib/render.mjs", import.meta.url));
23
+ const { renderTreeSvg } = await import(new URL("./lib/profiles/cmp/render.mjs", import.meta.url));
24
24
  const { auditA11y } = await import(new URL("./lib/a11y.mjs", import.meta.url));
25
25
 
26
26
  // The app's display name is read at RUNTIME from create-cmp.json, never stamped in.