create-cmp-cli 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-cmp.mjs +33 -3
- package/package.json +10 -4
- package/packages/harness/package.json +18 -8
- package/packages/harness/src/approve.mjs +46 -18
- package/packages/harness/src/console/console-data.mjs +117 -0
- package/packages/harness/src/console/console-evidence.mjs +122 -0
- package/packages/harness/src/console/console-overview.mjs +642 -0
- package/packages/harness/src/console/console-shell.mjs +1139 -0
- package/packages/harness/src/console/console-tabs.mjs +2828 -0
- package/packages/harness/src/console/contrast.mjs +74 -0
- package/packages/harness/src/console/preview-service.mjs +1384 -0
- package/packages/harness/src/framework-check.mjs +747 -0
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/agent-hold.mjs +234 -0
- package/packages/harness/src/lib/approvals.mjs +177 -350
- package/packages/harness/src/lib/audit-cadence.mjs +1 -1
- package/packages/harness/src/lib/comments.mjs +18 -4
- package/packages/harness/src/lib/determinism.mjs +275 -14
- package/packages/harness/src/lib/evidence-badge.mjs +38 -6
- package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
- package/packages/harness/src/lib/evidence-level.mjs +144 -59
- package/packages/harness/src/lib/feature-brief.mjs +53 -16
- package/packages/harness/src/lib/flight-recorder.mjs +72 -8
- package/packages/harness/src/lib/framework-check.mjs +778 -0
- package/packages/harness/src/lib/harness-lock.mjs +87 -19
- package/packages/harness/src/lib/harness-manifest.mjs +208 -0
- package/packages/harness/src/lib/harness-region.mjs +106 -2
- package/packages/harness/src/lib/harness-source.mjs +142 -0
- package/packages/harness/src/lib/inputs-hash.mjs +180 -22
- package/packages/harness/src/lib/lane-markers.mjs +78 -0
- package/packages/harness/src/lib/lane-narrator.mjs +4 -2
- package/packages/harness/src/lib/lane-runner.mjs +72 -6
- package/packages/harness/src/lib/plan.mjs +44 -11
- package/packages/harness/src/lib/plant-calibration.mjs +135 -0
- package/packages/harness/src/lib/profile-loader.mjs +323 -0
- package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
- package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
- package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
- package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
- package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
- package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
- package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
- package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
- package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/packages/harness/src/lib/receipt-validate.mjs +84 -16
- package/packages/harness/src/lib/spec-coverage.mjs +179 -92
- package/packages/harness/src/lib/spec-model.mjs +262 -0
- package/packages/harness/src/lib/step-cache.mjs +11 -2
- package/packages/harness/src/lib/step-outcomes.mjs +84 -151
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/plan.mjs +30 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +95 -26
- package/packages/harness/src/record-audit.mjs +1 -1
- package/packages/harness/src/scaffold-feature.mjs +10 -2
- package/packages/harness/src/verify.mjs +222 -142
- package/packages/harness/src/watch.mjs +146 -33
- package/packages/receipts/package.json +1 -1
- package/packages/receipts/src/index.mjs +2 -2
- package/packages/receipts/src/inputs-hash.mjs +180 -22
- package/packages/receipts/src/receipt-validate.mjs +84 -16
- package/src/commands/attach.mjs +128 -4
- package/src/commands/harden.mjs +7 -1
- package/src/commands/upgrade.mjs +7 -0
- package/src/lib/minimal.mjs +23 -1
- package/src/scaffold.mjs +8 -0
- package/template/.claude/settings.json +2 -2
- package/template/.claude/skills/add-feature/SKILL.md +7 -3
- package/template/.claude/skills/add-repository/SKILL.md +2 -2
- package/template/.claude/skills/add-screen/SKILL.md +8 -4
- package/template/.githooks/pre-push +9 -0
- package/template/CLAUDE.md +55 -186
- package/template/docs/TESTING.md +1 -1
- package/template/gitignore +4 -0
- package/template/qa/approve.mjs +46 -18
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +747 -0
- package/template/qa/harness-manifest.json +10 -0
- package/template/qa/lib/a11y.mjs +1 -1
- package/template/qa/lib/affected-tests.mjs +99 -44
- package/template/qa/lib/agent-hold.mjs +234 -0
- package/template/qa/lib/approvals.mjs +177 -350
- package/template/qa/lib/audit-cadence.mjs +1 -1
- package/template/qa/lib/comments.mjs +18 -4
- package/template/qa/lib/determinism.mjs +275 -14
- package/template/qa/lib/evidence-badge.mjs +38 -6
- package/template/qa/lib/evidence-ladder.mjs +202 -0
- package/template/qa/lib/evidence-level.mjs +144 -59
- package/template/qa/lib/feature-brief.mjs +53 -16
- package/template/qa/lib/flight-recorder.mjs +72 -8
- package/template/qa/lib/framework-check.mjs +778 -0
- package/template/qa/lib/harness-lock.mjs +87 -19
- package/template/qa/lib/harness-manifest.mjs +208 -0
- package/template/qa/lib/harness-region.mjs +106 -2
- package/template/qa/lib/harness-source.mjs +142 -0
- package/template/qa/lib/inputs-hash.mjs +180 -22
- package/template/qa/lib/lane-markers.mjs +78 -0
- package/template/qa/lib/lane-narrator.mjs +4 -2
- package/template/qa/lib/lane-runner.mjs +72 -6
- package/template/qa/lib/plan.mjs +44 -11
- package/template/qa/lib/plant-calibration.mjs +135 -0
- package/template/qa/lib/profile-loader.mjs +323 -0
- package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
- package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
- package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
- package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
- package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/template/qa/lib/profiles/cmp/index.mjs +47 -0
- package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
- package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
- package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
- package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/template/qa/lib/receipt-validate.mjs +84 -16
- package/template/qa/lib/spec-coverage.mjs +179 -92
- package/template/qa/lib/spec-model.mjs +262 -0
- package/template/qa/lib/step-cache.mjs +11 -2
- package/template/qa/lib/step-outcomes.mjs +84 -151
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/plan.mjs +30 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +95 -26
- package/template/qa/record-audit.mjs +1 -1
- package/template/qa/scaffold-feature.mjs +10 -2
- package/template/qa/verified-surface.json +17 -0
- package/template/qa/verify.mjs +222 -142
- package/template/qa/watch.mjs +146 -33
- /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
- /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
|
@@ -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.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
67
|
-
*
|
|
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
|
-
|
|
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() &&
|
|
81
|
-
.map((e) => `${
|
|
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 <
|
|
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(
|
|
122
|
+
if (BLOCK && line.includes(BLOCK.close)) inBlockComment = false;
|
|
100
123
|
continue;
|
|
101
124
|
}
|
|
102
|
-
if (line.startsWith(
|
|
103
|
-
|
|
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
|
-
|
|
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 &&
|
|
110
|
-
if (
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
195
|
+
* Every clause in every `*.spec.md` under the model's specs directory.
|
|
144
196
|
* @param {string} root
|
|
145
|
-
* @
|
|
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, "
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
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 =
|
|
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 (!
|
|
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 =
|
|
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
|
|
224
|
-
*
|
|
225
|
-
* `
|
|
226
|
-
* (
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
-
* @
|
|
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 `
|
|
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]) =>
|
|
251
|
-
|
|
252
|
-
|
|
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) =>
|
|
316
|
+
return tiers && tiers.every((t) => hostTiers.includes(t));
|
|
258
317
|
});
|
|
259
|
-
const summaryLine =
|
|
260
|
-
? `${
|
|
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,
|
|
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
|
}
|