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.
Files changed (145) 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 +46 -18
  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 +747 -0
  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/agent-hold.mjs +234 -0
  16. package/packages/harness/src/lib/approvals.mjs +177 -350
  17. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  18. package/packages/harness/src/lib/comments.mjs +18 -4
  19. package/packages/harness/src/lib/determinism.mjs +275 -14
  20. package/packages/harness/src/lib/evidence-badge.mjs +38 -6
  21. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  22. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  23. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  24. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  25. package/packages/harness/src/lib/framework-check.mjs +778 -0
  26. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  27. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  28. package/packages/harness/src/lib/harness-region.mjs +106 -2
  29. package/packages/harness/src/lib/harness-source.mjs +142 -0
  30. package/packages/harness/src/lib/inputs-hash.mjs +180 -22
  31. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  32. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  33. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  34. package/packages/harness/src/lib/plan.mjs +44 -11
  35. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  36. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  37. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  38. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  39. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  40. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  41. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  42. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  43. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  44. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  45. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  46. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  47. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  48. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  49. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
  50. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  51. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  52. package/packages/harness/src/lib/spec-model.mjs +262 -0
  53. package/packages/harness/src/lib/step-cache.mjs +11 -2
  54. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  55. package/packages/harness/src/lib/walk.mjs +1 -1
  56. package/packages/harness/src/plan.mjs +30 -1
  57. package/packages/harness/src/preview-gallery.mjs +1 -1
  58. package/packages/harness/src/receipt-check.mjs +95 -26
  59. package/packages/harness/src/record-audit.mjs +1 -1
  60. package/packages/harness/src/scaffold-feature.mjs +10 -2
  61. package/packages/harness/src/verify.mjs +222 -142
  62. package/packages/harness/src/watch.mjs +146 -33
  63. package/packages/receipts/package.json +1 -1
  64. package/packages/receipts/src/index.mjs +2 -2
  65. package/packages/receipts/src/inputs-hash.mjs +180 -22
  66. package/packages/receipts/src/receipt-validate.mjs +84 -16
  67. package/src/commands/attach.mjs +128 -4
  68. package/src/commands/harden.mjs +7 -1
  69. package/src/commands/upgrade.mjs +7 -0
  70. package/src/lib/minimal.mjs +23 -1
  71. package/src/scaffold.mjs +8 -0
  72. package/template/.claude/settings.json +2 -2
  73. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  74. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  75. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  76. package/template/.githooks/pre-push +9 -0
  77. package/template/CLAUDE.md +55 -186
  78. package/template/docs/TESTING.md +1 -1
  79. package/template/gitignore +4 -0
  80. package/template/qa/approve.mjs +46 -18
  81. package/template/qa/comments.json +1 -1
  82. package/template/qa/evidence/schema.json +258 -34
  83. package/template/qa/framework-check.mjs +747 -0
  84. package/template/qa/harness-manifest.json +10 -0
  85. package/template/qa/lib/a11y.mjs +1 -1
  86. package/template/qa/lib/affected-tests.mjs +99 -44
  87. package/template/qa/lib/agent-hold.mjs +234 -0
  88. package/template/qa/lib/approvals.mjs +177 -350
  89. package/template/qa/lib/audit-cadence.mjs +1 -1
  90. package/template/qa/lib/comments.mjs +18 -4
  91. package/template/qa/lib/determinism.mjs +275 -14
  92. package/template/qa/lib/evidence-badge.mjs +38 -6
  93. package/template/qa/lib/evidence-ladder.mjs +202 -0
  94. package/template/qa/lib/evidence-level.mjs +144 -59
  95. package/template/qa/lib/feature-brief.mjs +53 -16
  96. package/template/qa/lib/flight-recorder.mjs +72 -8
  97. package/template/qa/lib/framework-check.mjs +778 -0
  98. package/template/qa/lib/harness-lock.mjs +87 -19
  99. package/template/qa/lib/harness-manifest.mjs +208 -0
  100. package/template/qa/lib/harness-region.mjs +106 -2
  101. package/template/qa/lib/harness-source.mjs +142 -0
  102. package/template/qa/lib/inputs-hash.mjs +180 -22
  103. package/template/qa/lib/lane-markers.mjs +78 -0
  104. package/template/qa/lib/lane-narrator.mjs +4 -2
  105. package/template/qa/lib/lane-runner.mjs +72 -6
  106. package/template/qa/lib/plan.mjs +44 -11
  107. package/template/qa/lib/plant-calibration.mjs +135 -0
  108. package/template/qa/lib/profile-loader.mjs +323 -0
  109. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  110. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  111. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  112. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  113. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  114. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  115. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  116. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  117. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  118. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  119. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  120. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  121. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
  122. package/template/qa/lib/receipt-validate.mjs +84 -16
  123. package/template/qa/lib/spec-coverage.mjs +179 -92
  124. package/template/qa/lib/spec-model.mjs +262 -0
  125. package/template/qa/lib/step-cache.mjs +11 -2
  126. package/template/qa/lib/step-outcomes.mjs +84 -151
  127. package/template/qa/lib/walk.mjs +1 -1
  128. package/template/qa/plan.mjs +30 -1
  129. package/template/qa/preview-gallery.mjs +1 -1
  130. package/template/qa/receipt-check.mjs +95 -26
  131. package/template/qa/record-audit.mjs +1 -1
  132. package/template/qa/scaffold-feature.mjs +10 -2
  133. package/template/qa/verified-surface.json +17 -0
  134. package/template/qa/verify.mjs +222 -142
  135. package/template/qa/watch.mjs +146 -33
  136. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  137. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  138. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  139. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  140. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  141. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  142. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  143. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  144. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  145. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -8,10 +8,22 @@
8
8
  // If these two scanned differently, the Features view and the lane could
9
9
  // disagree about the same clause — the exact two-truths problem this file
10
10
  // exists to prevent.
11
+ //
12
+ // THE MECHANIC IS THE CORE'S; THE MODEL IS THE PROFILE'S (Stage 0 PR 4,
13
+ // docs/NORTH-STAR.md §6). This file knows what a clause is, how a citation
14
+ // binds to a test, that coverage runs both ways, and that a clause tagged
15
+ // with a tier must be cited from a tier that can observe it. It does NOT know
16
+ // where specs live, which trees hold citations, what a source file is called,
17
+ // or what the tiers are: those come in as a `model` (qa/lib/spec-model.mjs),
18
+ // built from the profile's `layout` and `tiers` declarations. Every scanner
19
+ // below takes the model as its last argument; a caller that has only a root
20
+ // gets it resolved from the project's manifest. Nothing here names a stack.
11
21
 
12
22
  import fs from "node:fs";
13
23
  import path from "node:path";
14
24
 
25
+ import { TIER_NAME_RE, requireSpecModel, GRAMMAR_DEFAULTS } from "./spec-model.mjs";
26
+
15
27
  /** `- **HOME-01** — …` (live) or `- ~~**HOME-01**~~ — …` (withdrawn). */
16
28
  export const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
17
29
  // An OPTIONAL tier requirement on the clause line itself:
@@ -19,17 +31,14 @@ export const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
19
31
  // - **MOTION-13** [tier: device] — Given a cold start, When … Then …
20
32
  //
21
33
  // The clause declares what it takes to OBSERVE it, which is a property of the
22
- // promise, not of whatever test happened to cite it. Note this attaches to the
23
- // clause line, not to `[enforced: …]` that tags docs/ARCHITECTURE.md prose and
24
- // is a different grammar entirely.
25
- const CLAUSE_TIER_RE = /\[tier:\s*(device|e2e)\]/i;
26
- /** Which citing tiers satisfy a declared requirement. */
27
- export const TIERS_SATISFYING = Object.freeze({
28
- device: ["androidInstrumentedTest", "e2e"],
29
- e2e: ["e2e"],
30
- });
34
+ // promise, not of whatever test happened to cite it. The requirement NAME is
35
+ // the profile's (its `tiers.satisfying` keys); the grammar accepts any name
36
+ // and the coverage check refuses one the profile does not declare. Note this
37
+ // attaches to the clause line, not to `[enforced: …]` — that tags
38
+ // docs/ARCHITECTURE.md prose and is a different grammar entirely.
39
+ const CLAUSE_TIER_RE = /\[tier:\s*([a-z][a-z0-9-]*)\]/i;
31
40
 
32
- const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
41
+ // Kept as the module-local fallback; the live values come from the SpecModel.
33
42
 
34
43
  // A citation is a claim that a TEST covers a clause, so it has to sit on one.
35
44
  // Counting the tag wherever it appears makes a red specCoverage curable with a
@@ -41,12 +50,11 @@ const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
41
50
  // BINDING_WINDOW non-blank lines. The window is small enough that the tag must
42
51
  // be attached to the test, and loose enough for the @DisplayName / annotation
43
52
  // stack that idiomatically sits between them.
44
- export const BINDING_WINDOW = 5;
53
+ export const BINDING_WINDOW = GRAMMAR_DEFAULTS.bindingWindow;
45
54
 
46
55
  // Kotlin @Test, a backticked test function, and the node:test / Maestro-adjacent
47
56
  // `test(` / `it(` call forms. Deliberately syntactic: a citation's binding must
48
57
  // be readable without compiling anything.
49
- const TEST_DECL_RE = /@Test\b|\bfun\s+`[^`]+`\s*\(|\b(?:test|it)\s*\(/;
50
58
 
51
59
  // A tag whose first meaningful line declares a TYPE is documenting that type,
52
60
  // not claiming a test — and it must be refused structurally rather than by
@@ -54,31 +62,28 @@ const TEST_DECL_RE = /@Test\b|\bfun\s+`[^`]+`\s*\(|\b(?:test|it)\s*\(/;
54
62
  // would otherwise launder the citation. This is exactly payment-blueprint's
55
63
  // drift: `// SPEC: PP-07` sat on `class PaymentWorkerTest`, three properties
56
64
  // above a genuine @Test, and vouched for the whole file.
57
- const TYPE_DECL_RE = /^(?:@\w+\s+)*(?:public\s+|internal\s+|private\s+|abstract\s+|open\s+|sealed\s+|data\s+|enum\s+)*(?:class|object|interface)\b/;
58
-
59
- // A YAML flow's own shape counts as its test: a Maestro file IS the test, so a
60
- // tag in one binds to the flow rather than to a declaration inside it.
61
- export const FLOW_EXTS = [".yaml", ".yml"];
62
- /** Where Maestro flows live. The lane runs this DIRECTORY (every top-level flow). */
63
- export const E2E_FLOW_DIR = "qa/e2e";
64
65
 
65
66
  /**
66
- * The flows the lane executes: top-level `*.yaml`/`*.yml` under qa/e2e, sorted,
67
- * root-relative. ONE list serves two readers the e2eSmoke step (what runs)
67
+ * The flow-shaped citation files the lane executes: top-level files in the
68
+ * model's flow directory with one of its extensions, sorted, root-relative.
69
+ * ONE list serves two readers — the profile's flow-running step (what runs)
68
70
  * and scanCitations (what may count as coverage) — so a citation can only
69
71
  * ever come from a flow that executes. Before 2026-09-03 the step ran ONE
70
72
  * file by name (smoke.yaml) while the scan walked the whole directory: four
71
73
  * hand-written flows on the showcase satisfied clauses without ever running.
74
+ * A profile with no flows (`layout.flows: null`) has none.
72
75
  * @param {string} root
76
+ * @param {import("./spec-model.mjs").SpecModel} [model]
73
77
  * @returns {string[]}
74
78
  */
75
- export function listFlowFiles(root) {
76
- const dir = path.join(root, E2E_FLOW_DIR);
79
+ export function listFlowFiles(root, model = requireSpecModel(root)) {
80
+ if (!model.flows) return [];
81
+ const dir = path.join(root, ...model.flows.dir.split("/"));
77
82
  if (!fs.existsSync(dir)) return [];
78
83
  return fs
79
84
  .readdirSync(dir, { withFileTypes: true })
80
- .filter((e) => e.isFile() && FLOW_EXTS.some((ext) => e.name.endsWith(ext)))
81
- .map((e) => `${E2E_FLOW_DIR}/${e.name}`)
85
+ .filter((e) => e.isFile() && model.flows.exts.some((ext) => e.name.endsWith(ext)))
86
+ .map((e) => `${model.flows.dir}/${e.name}`)
82
87
  .sort();
83
88
  }
84
89
 
@@ -89,37 +94,84 @@ export function listFlowFiles(root) {
89
94
  * @param {number} index line the tag sits on
90
95
  * @returns {boolean}
91
96
  */
92
- export function citationIsBound(lines, index) {
97
+ export function citationIsBound(lines, index, grammar) {
98
+ // The grammar is the PROFILE's. There is no fallback to reach for: a caller
99
+ // without one is a caller about to grade a language it cannot read.
100
+ if (!grammar || !(grammar.testDeclaration instanceof RegExp) || !(grammar.lineComment instanceof RegExp)) {
101
+ throw new Error("citationIsBound needs the profile's grammar (testDeclaration, lineComment) — the core has no fallback grammar");
102
+ }
103
+ // THE GRAMMAR IS THE PROFILE'S. These three patterns decide whether a
104
+ // citation counts at all, and they are the most language-specific thing in
105
+ // the lane — far more so than a directory name. Held in the spine they
106
+ // matched Kotlin and JavaScript and silently discarded every Python, Go and
107
+ // Rust citation, which reported as "declared but never cited" and pointed the
108
+ // reader at the spec file. Passing them in is what makes that a declaration
109
+ // the adopter can see and fix rather than a rule they must reverse-engineer.
110
+ const TEST_DECL = grammar.testDeclaration;
111
+ // No type declaration declared: no citation is ever judged "on a type" — never-match, not Kotlin's.
112
+ const TYPE_DECL = grammar.typeDeclaration instanceof RegExp ? grammar.typeDeclaration : /(?!)/;
113
+ const WINDOW = grammar.bindingWindow ?? GRAMMAR_DEFAULTS.bindingWindow;
114
+ const LINE_COMMENT = grammar.lineComment;
115
+ const BLOCK = grammar.blockComment ?? GRAMMAR_DEFAULTS.blockComment;
93
116
  let seen = 0;
94
117
  let inBlockComment = false;
95
- for (let i = index + 1; i < lines.length && seen < BINDING_WINDOW; i += 1) {
118
+ for (let i = index + 1; i < lines.length && seen < WINDOW; i += 1) {
96
119
  const line = lines[i].trim();
97
120
  if (line === "") continue;
98
121
  if (inBlockComment) {
99
- if (line.includes("*/")) inBlockComment = false;
122
+ if (BLOCK && line.includes(BLOCK.close)) inBlockComment = false;
100
123
  continue;
101
124
  }
102
- if (line.startsWith("/*")) {
103
- if (!line.includes("*/")) inBlockComment = true;
125
+ if (BLOCK && line.startsWith(BLOCK.open)) {
126
+ // A same-delimiter block (Python's """) opens and closes with the same
127
+ // token, so a single line carrying it twice is a complete block.
128
+ const closes = BLOCK.open === BLOCK.close ? line.split(BLOCK.close).length - 1 >= 2 : line.includes(BLOCK.close);
129
+ if (!closes) inBlockComment = true;
104
130
  continue;
105
131
  }
106
- if (line.startsWith("//") || line.startsWith("*")) continue;
132
+ // A comment line is SKIPPED, not counted. Burning the binding window on
133
+ // prose is how a Python citation with five "#" lines under it was discarded
134
+ // while the byte-identical Kotlin arrangement bound — the window is meant to
135
+ // measure distance from the TEST, not from the documentation.
136
+ if (LINE_COMMENT.test(line)) continue;
107
137
  seen += 1;
108
138
  // The FIRST meaningful line decides whether this tag is on a test at all.
109
- if (seen === 1 && TYPE_DECL_RE.test(line)) return false;
110
- if (TEST_DECL_RE.test(line)) return true;
139
+ if (seen === 1 && TYPE_DECL.test(line)) return false;
140
+ if (TEST_DECL.test(line)) return true;
111
141
  }
112
142
  return false;
113
143
  }
114
144
 
115
145
  /** Is this tag inside a block comment that began earlier in the file? */
116
- function insideBlockComment(lines, index) {
146
+ function insideBlockComment(lines, index, grammar) {
147
+ // A language with no block comments (declared null) has nothing to be inside of.
148
+ if (!grammar || !grammar.blockComment) return false;
149
+ // A citation inside a block comment is documentation, not a claim, and must
150
+ // never bind. This scanned for `/*` and `*/` only, so a `# SPEC:` sitting in
151
+ // a Python docstring — or a Ruby =begin block — counted as a real citation
152
+ // over whatever test happened to follow. That is the laundering hole the
153
+ // whole binder exists to close, open for every language outside the C family.
154
+ const { open: OPEN, close: CLOSE } = grammar.blockComment;
155
+ // Same-delimiter blocks (Python's triple quote) have no open/close pair to
156
+ // match, so parity is the only honest reading: an odd count before this line
157
+ // means we are inside one.
158
+ if (OPEN === CLOSE) {
159
+ let count = 0;
160
+ for (let i = 0; i < index; i += 1) count += lines[i].split(OPEN).length - 1;
161
+ return count % 2 === 1;
162
+ }
117
163
  let open = false;
118
164
  for (let i = 0; i < index; i += 1) {
119
165
  const line = lines[i];
120
- for (let c = 0; c < line.length - 1; c += 1) {
121
- if (!open && line[c] === "/" && line[c + 1] === "*") open = true;
122
- else if (open && line[c] === "*" && line[c + 1] === "/") open = false;
166
+ let c = 0;
167
+ while (c < line.length) {
168
+ if (!open && line.startsWith(OPEN, c)) {
169
+ open = true;
170
+ c += OPEN.length;
171
+ } else if (open && line.startsWith(CLOSE, c)) {
172
+ open = false;
173
+ c += CLOSE.length;
174
+ } else c += 1;
123
175
  }
124
176
  }
125
177
  return open;
@@ -140,13 +192,14 @@ export function walkFiles(dir, exts) {
140
192
  }
141
193
 
142
194
  /**
143
- * Every clause in every specs/*.spec.md.
195
+ * Every clause in every `*.spec.md` under the model's specs directory.
144
196
  * @param {string} root
145
- * @returns {Map<string, {file: string, withdrawn: boolean}>} id -> where/state
197
+ * @param {import("./spec-model.mjs").SpecModel} [model]
198
+ * @returns {Map<string, {file: string, withdrawn: boolean, requiredTier: string|null}>} id -> where/state
146
199
  */
147
- export function scanSpecClauses(root) {
200
+ export function scanSpecClauses(root, model = requireSpecModel(root)) {
148
201
  const clauses = new Map();
149
- const specsDir = path.join(root, "specs");
202
+ const specsDir = path.join(root, ...model.specsDir.split("/"));
150
203
  if (!fs.existsSync(specsDir)) return clauses;
151
204
  for (const f of fs.readdirSync(specsDir).filter((n) => n.endsWith(".spec.md"))) {
152
205
  const abs = path.join(specsDir, f);
@@ -165,50 +218,45 @@ export function scanSpecClauses(root) {
165
218
  }
166
219
 
167
220
  /**
168
- * Which test tier a citing file belongs to, derived from its path. Tiers are
169
- * the source-set/harness boundaries that decide what a citation can actually
170
- * SEE: commonTest and desktopTest run on the JVM (blind to androidMain code),
171
- * androidInstrumentedTest runs on a device, e2e flows drive the installed app.
172
- * @param {string} relFile path relative to the project root
173
- * @returns {"commonTest"|"desktopTest"|"androidInstrumentedTest"|"e2e"|"other"}
174
- */
175
- export function tierForFile(relFile) {
176
- const p = relFile.split(path.sep).join("/");
177
- if (p.includes("/androidInstrumentedTest/")) return "androidInstrumentedTest";
178
- if (p.includes("/commonTest/")) return "commonTest";
179
- if (p.includes("/desktopTest/")) return "desktopTest";
180
- if (p.startsWith("qa/e2e/")) return "e2e";
181
- return "other";
182
- }
183
-
184
- /** Tiers whose tests run entirely on the host JVM — blind to platform source sets. */
185
- export const DESKTOP_TIERS = Object.freeze(["commonTest", "desktopTest"]);
186
-
187
- /**
188
- * Every `// SPEC: ID[, ID…]` / `# SPEC: …` citation tag under composeApp/src
189
- * and qa/e2e. Each entry carries the citing file's `tier` (see tierForFile).
221
+ * Every `// SPEC: ID[, ID…]` / `# SPEC: …` citation tag under the model's
222
+ * citation roots (source files by extension) plus the flows the lane runs.
223
+ * Each entry carries the citing file's `tier` (the profile's `tiers.forFile`).
190
224
  * @param {string} root
225
+ * @param {import("./spec-model.mjs").SpecModel} [model]
191
226
  * @returns {Array<{id: string, file: string, line: number, tier: string}>}
192
227
  */
193
- export function scanCitations(root) {
228
+ export function scanCitations(root, model = requireSpecModel(root)) {
194
229
  const tags = [];
195
- // Kotlin sources anywhere under composeApp/src; flows ONLY from the list the
196
- // lane runs (listFlowFiles) a flow in a subfolder, or a yaml under
197
- // composeApp/src, is not executed by e2eSmoke and therefore proves nothing.
198
- const files = [...walkFiles(path.join(root, "composeApp/src"), [".kt", ".kts"]), ...listFlowFiles(root).map((rel) => path.join(root, rel))];
230
+ const MARKER = model.grammar.citationMarker; // present specDeclarationProblems refused the profile otherwise
231
+ // How many markers were SEEN, before binding threw any away. The gap between
232
+ // this and tags.length is the single most useful diagnostic the scan has: all
233
+ // markers found and none bound means the grammar does not match this
234
+ // language, which is otherwise indistinguishable from a project that wrote no
235
+ // citations at all. `citationScanDiagnostic` turns it into a sentence.
236
+ let markerCount = 0;
237
+ // Sources anywhere under the citation roots; flows ONLY from the list the
238
+ // lane runs (listFlowFiles) — a flow in a subfolder, or a flow-shaped file
239
+ // under a source root, is not executed and therefore proves nothing.
240
+ const flowExts = model.flows ? model.flows.exts : [];
241
+ const sources = model.citationRoots.flatMap((rel) => walkFiles(path.join(root, ...rel.split("/")), model.citationExts));
242
+ const files = [...sources, ...listFlowFiles(root, model).map((rel) => path.join(root, ...rel.split("/")))];
243
+ const seen = new Set();
199
244
  for (const f of files) {
245
+ if (seen.has(f)) continue;
246
+ seen.add(f);
200
247
  const rel = path.relative(root, f);
201
- const tier = tierForFile(rel);
248
+ const tier = model.tiers.forFile(rel);
202
249
  fs.readFileSync(f, "utf8")
203
250
  .split("\n")
204
251
  .forEach((line, i, lines) => {
205
252
  const trimmed = line.trim();
206
- if (!TAG_LINE_RE.test(trimmed)) return;
253
+ if (!MARKER.test(trimmed)) return;
254
+ markerCount += 1;
207
255
  const m = trimmed.match(TAG_IDS_RE);
208
256
  if (!m) return;
209
257
  // A flow file IS its test; anything else must have a test under the tag.
210
- const isFlow = FLOW_EXTS.some((ext) => rel.endsWith(ext));
211
- if (!isFlow && (insideBlockComment(lines, i) || !citationIsBound(lines, i))) return;
258
+ const isFlow = flowExts.some((ext) => rel.endsWith(ext));
259
+ if (!isFlow && (insideBlockComment(lines, i, model.grammar) || !citationIsBound(lines, i, model.grammar))) return;
212
260
  const ids = m[1]
213
261
  .split(/[,\s]+/)
214
262
  .map((s) => s.trim())
@@ -216,48 +264,87 @@ export function scanCitations(root) {
216
264
  for (const id of ids) tags.push({ id, file: rel, line: i + 1, tier });
217
265
  });
218
266
  }
267
+ Object.defineProperty(tags, "markersSeen", { value: markerCount, enumerable: false });
219
268
  return tags;
220
269
  }
221
270
 
222
271
  /**
223
- * Per-clause tier visibility — report data only, never a pass/fail input
224
- * (instrument before you police). For each cited clause: which tiers cite it.
225
- * `desktopOnly` lists live clauses whose every citation is desktop-tier
226
- * (commonTest/desktopTest) — behavior claims no device-tier evidence backs.
227
- * `summaryLine` is the one line the lane's specCoverage step (and any other
228
- * consumer) can print verbatim; null when nothing is desktop-only.
229
- * `unmetTier` is the PRESCRIPTIVE half clauses that declared `[tier: …]` and
230
- * have no citation from a tier that could observe them. specCoverage FAILS on it:
231
- * "instrument before you police" was the right first move, and this is the second.
232
- * @param {Map<string, {file: string, withdrawn: boolean}>} clauses from scanSpecClauses
272
+ * Per-clause tier visibility — report data AND the one prescriptive check.
273
+ * For each cited clause: which tiers cite it.
274
+ * `hostOnly` lists live clauses whose every citation is from a host-only tier
275
+ * (the profile's `tiers.hostOnly`) — behaviour claims no on-target evidence
276
+ * backs. Report only, never a pass/fail input for UNDECLARED clauses
277
+ * (instrument before you police). `summaryLine` is the one line the lane's
278
+ * specCoverage step (and any other consumer) can print verbatim; null when
279
+ * nothing is host-only.
280
+ * `unmetTier` is the PRESCRIPTIVE half clauses that declared `[tier: …]`
281
+ * and have no citation from a tier that could observe them, OR that named a
282
+ * requirement the profile does not declare (`unknown: true`, so the message
283
+ * can say which names exist). specCoverage FAILS on it: "instrument before
284
+ * you police" was the right first move, and this is the second.
285
+ * @param {Map<string, {file: string, withdrawn: boolean, requiredTier: string|null}>} clauses from scanSpecClauses
233
286
  * @param {Array<{id: string, tier: string}>} tags from scanCitations
234
- * @returns {{tiersByClause: Record<string, string[]>, desktopOnly: string[], summaryLine: string|null}}
287
+ * @param {import("./spec-model.mjs").SpecModel} model
288
+ * @returns {{tiersByClause: Record<string, string[]>, hostOnly: string[], unmetTier: Array<{id: string, requiredTier: string, tiers: string[], file: string, unknown: boolean}>, summaryLine: string|null}}
235
289
  */
236
- export function clauseTierCoverage(clauses, tags) {
290
+ export function clauseTierCoverage(clauses, tags, model) {
291
+ if (!model || !model.tiers) throw new Error("clauseTierCoverage needs the profile's spec model (qa/lib/spec-model.mjs)");
292
+ const { satisfying, hostOnly: hostTiers } = model.tiers;
237
293
  const tiersByClause = {};
238
294
  for (const t of tags) {
239
295
  (tiersByClause[t.id] ??= []).includes(t.tier) || tiersByClause[t.id].push(t.tier);
240
296
  }
241
297
  // The gate input. A clause that DECLARED the tier it needs and has no citation
242
298
  // from that tier is not covered — it is cited by tests structurally incapable
243
- // of observing it, which is the exact hole `desktopOnly` below could only ever
299
+ // of observing it, which is the exact hole `hostOnly` below could only ever
244
300
  // describe. MOTION-13 promised an animation "plays once per process start" and
245
301
  // was cited by a desktop Compose test, a tier with no process lifecycle at all:
246
302
  // the citation existed, the gate went green, and nothing ever observed the
247
303
  // promise. Declared requirements are checked; undeclared clauses are unchanged.
248
304
  const unmetTier = [...clauses.entries()]
249
305
  .filter(([, c]) => !c.withdrawn && c.requiredTier)
250
- .map(([id, c]) => ({ id, requiredTier: c.requiredTier, tiers: tiersByClause[id] ?? [], file: c.file }))
251
- .filter((u) => !(TIERS_SATISFYING[u.requiredTier] ?? []).some((t) => u.tiers.includes(t)));
252
- const desktopOnly = [...clauses.entries()]
306
+ .map(([id, c]) => {
307
+ const known = TIER_NAME_RE.test(c.requiredTier) && Object.hasOwn(satisfying, c.requiredTier);
308
+ return { id, requiredTier: c.requiredTier, tiers: tiersByClause[id] ?? [], file: c.file, unknown: !known };
309
+ })
310
+ .filter((u) => u.unknown || !satisfying[u.requiredTier].some((t) => u.tiers.includes(t)));
311
+ const hostOnly = [...clauses.entries()]
253
312
  .filter(([, c]) => !c.withdrawn)
254
313
  .map(([id]) => id)
255
314
  .filter((id) => {
256
315
  const tiers = tiersByClause[id];
257
- return tiers && tiers.every((t) => DESKTOP_TIERS.includes(t));
316
+ return tiers && tiers.every((t) => hostTiers.includes(t));
258
317
  });
259
- const summaryLine = desktopOnly.length
260
- ? `${desktopOnly.length} clause${desktopOnly.length === 1 ? "" : "s"} cited only from desktop-tier tests (${desktopOnly.join(", ")})`
318
+ const summaryLine = hostOnly.length
319
+ ? `${hostOnly.length} clause${hostOnly.length === 1 ? "" : "s"} cited only from host-only tiers (${hostOnly.join(", ")})`
261
320
  : null;
262
- return { tiersByClause, desktopOnly, unmetTier, summaryLine };
321
+ return { tiersByClause, hostOnly, unmetTier, summaryLine };
322
+ }
323
+
324
+ /**
325
+ * Why a scan found no citations — the sentence that would have saved six
326
+ * minutes of reverse-engineering on the first Python adoption.
327
+ *
328
+ * "Declared but never cited" is the right message when a project wrote no
329
+ * citations. It is a badly WRONG message when the project wrote twenty and the
330
+ * grammar could not bind one of them, because it points the reader at the spec
331
+ * file, which is the one place that is not the problem. The gap between markers
332
+ * seen and citations kept is what tells those two cases apart, and nothing was
333
+ * reporting it.
334
+ *
335
+ * @param {{length: number, markersSeen?: number}} tags the result of scanCitations
336
+ * @param {import("./spec-model.mjs").SpecModel} model
337
+ * @returns {string|null} a sentence to append to a coverage failure, or null
338
+ */
339
+ export function citationScanDiagnostic(tags, model) {
340
+ const seen = Number(tags?.markersSeen ?? 0);
341
+ const kept = tags?.length ?? 0;
342
+ if (seen === 0 || kept > 0) return null;
343
+ const how = "this profile's `grammar.testDeclaration` matched no line after a citation";
344
+ return (
345
+ `${seen} SPEC marker${seen === 1 ? "" : "s"} found and none bound to a test — ${how}. ` +
346
+ "A citation counts only when a test declaration follows it within " +
347
+ `${model?.grammar?.bindingWindow ?? 5} non-blank lines. Declare \`grammar.testDeclaration\` ` +
348
+ "in your profile with the pattern this language uses for a test declaration — `create-cmp harness init` seeds one per language."
349
+ );
263
350
  }