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
@@ -0,0 +1,381 @@
1
+ // profiles/cmp/artifacts.mjs — what a human SIGNS in a Compose Multiplatform
2
+ // app, in definition order, and how each artifact resolves to files on disk.
3
+ // Mobile's governance model, by definition (docs/NORTH-STAR.md §5, §6;
4
+ // AGNOSTIC-HARNESS-ARCHITECTURE.md §4.2 #2).
5
+ //
6
+ // Stage 0 PR 5. Until this file, qa/lib/approvals.mjs hardcoded the six
7
+ // genesis artifacts — intent, architecture, exemplar-spec, exemplar-feature,
8
+ // design-system, components — with their Kotlin source-set roots, the
9
+ // composeApp namespace lookup, the exemplar's eleven-file shape and the
10
+ // presentation/<name>/*Screen.kt design surface. A Kotlin backend that adopted
11
+ // the harness replaced the whole file (1,636 lines) to change that list. The
12
+ // MECHANIC — artifact = path set + hash, signature on the hash, status
13
+ // derivation, reopen/accept, the ledger, the gate — is the core's and never
14
+ // moved. THIS is the model: which artifacts, in what order, how each is
15
+ // hashed. The core reads it through the profile the manifest names.
16
+ //
17
+ // The neutral entries — feature briefs (the Decide layer), feature designs
18
+ // (one per brief with a surface) and feature specs — are built by the core's
19
+ // own helpers, so every profile that has briefs gets the same walk; this
20
+ // profile only says what a design SURFACE is here (screens on disk) and which
21
+ // spec files are not feature specs (the base spec, the exemplar's).
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+
26
+ import { ARCH_DOC_REL_PATH } from "../../arch-doc.mjs";
27
+ import {
28
+ architectureArtifact,
29
+ featureBriefArtifacts,
30
+ featureDesignArtifacts,
31
+ featureSpecArtifacts,
32
+ loadApprovals,
33
+ } from "../../approvals.mjs";
34
+
35
+ // Kotlin source-set roots, relative to project root — mirrors qa/scaffold-feature.mjs's
36
+ // SRC() helper (composeApp/src/<sourceSet>/kotlin/<packageDir>).
37
+ const KOTLIN_SOURCE_SETS = {
38
+ commonMain: "composeApp/src/commonMain/kotlin",
39
+ commonTest: "composeApp/src/commonTest/kotlin",
40
+ desktopTest: "composeApp/src/desktopTest/kotlin",
41
+ };
42
+
43
+ // The canonical 11-file EXEMPLAR SHAPE (10 kotlin files + 1 spec), parametrized by
44
+ // the exemplar's own names — F (PascalCase feature, e.g. "Home"), f (lowercase
45
+ // package segment, e.g. "home"), E (PascalCase entity, e.g. "Item"). This is the
46
+ // SAME shape qa/scaffold-feature.mjs's ALL_FILES clones FROM (GENESIS-FLOW-DESIGN.md
47
+ // §1's "configurable exemplar") — the stamper imports this exact function so the
48
+ // clone-source list and the governed-artifact list can never drift from each other
49
+ // (single source of truth, not a parallel copy to keep in sync by hand).
50
+ // @param {string} F PascalCase feature name (e.g. "Home", "Favorites")
51
+ // @param {string} f lowercase package-segment name (e.g. "home", "favorites")
52
+ // @param {string} E PascalCase entity name (e.g. "Item", "Favorite")
53
+ // @returns {Array<{sourceSet: string, rel: string}>}
54
+ export function exemplarKotlinFileSet(F, f, E) {
55
+ return [
56
+ { sourceSet: "commonMain", rel: `domain/model/${E}.kt` },
57
+ { sourceSet: "commonMain", rel: `domain/repository/${E}Repository.kt` },
58
+ { sourceSet: "commonMain", rel: `domain/usecase/Get${E}sUseCase.kt` },
59
+ { sourceSet: "commonMain", rel: `data/remote/${E}RepositoryImpl.kt` },
60
+ { sourceSet: "commonTest", rel: `testing/fakes/Fake${E}Repository.kt` },
61
+ { sourceSet: "commonMain", rel: `presentation/${f}/${F}Screen.kt` },
62
+ { sourceSet: "commonMain", rel: `presentation/${f}/${F}ViewModel.kt` },
63
+ { sourceSet: "commonTest", rel: `presentation/${f}/${F}ViewModelTest.kt` },
64
+ { sourceSet: "desktopTest", rel: `presentation/${f}/${F}ScreenTest.kt` },
65
+ { sourceSet: "desktopTest", rel: `presentation/${f}/${F}GoldenTreeTest.kt` },
66
+ ];
67
+ }
68
+
69
+ // Naive de-pluralization, shared verbatim with qa/scaffold-feature.mjs's own
70
+ // entity-name default (a feature stamped without `--entity` gets this exact
71
+ // guess). Exported so both the stamper (deriving a NEW feature's entity) and this
72
+ // registry (guessing a CONFIGURED exemplar's entity from its feature name alone —
73
+ // see resolveExemplarNames) apply the identical heuristic. Unreliable for
74
+ // irregular nouns by design (the skill surfaces the guess for human override at
75
+ // stamp time); a wrong guess here simply fails to resolve files, which is refused
76
+ // (never fabricated), not silently wrong.
77
+ export function defaultEntityName(feature) {
78
+ if (feature.endsWith("ies") && feature.length > 3) return `${feature.slice(0, -3)}y`;
79
+ if (feature.endsWith("s") && !feature.endsWith("ss")) return feature.slice(0, -1);
80
+ return feature;
81
+ }
82
+
83
+ function toPascalCase(f) {
84
+ return f.charAt(0).toUpperCase() + f.slice(1);
85
+ }
86
+
87
+ function toUpperSnake(F) {
88
+ return F.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
89
+ }
90
+
91
+ /**
92
+ * The configured exemplar feature's lowercase name (the package-segment form,
93
+ * e.g. `"home"`, `"favorites"`) — `qa/approvals.json`'s top-level
94
+ * `exemplarFeature` key, defaulting to `"home"` when absent (GENESIS-FLOW-DESIGN.md
95
+ * §1). This is the ONE function both `resolveExemplarNames` (registry) and
96
+ * qa/scaffold-feature.mjs (clone-source resolution) call — never read the raw key
97
+ * directly, so the default lives in exactly one place.
98
+ * @param {string} root
99
+ * @returns {string}
100
+ */
101
+ export function getExemplarFeature(root) {
102
+ return loadApprovals(root).exemplarFeature ?? "home";
103
+ }
104
+
105
+ /**
106
+ * Resolve the CONFIGURED exemplar's names — the ones the exemplar-feature/
107
+ * exemplar-spec governed artifacts (and qa/scaffold-feature.mjs's clone source)
108
+ * are built from.
109
+ *
110
+ * `home` (the default, and the only exemplar that predates configurability) is a
111
+ * hardcoded exception: its entity is `Item`, not derivable from `Home` by
112
+ * `defaultEntityName` (which would naively guess `Home`). Every OTHER exemplar is
113
+ * itself a feature that was stamped by qa/scaffold-feature.mjs, so its entity
114
+ * followed defaultEntityName(F) UNLESS it was stamped with an explicit `--entity`
115
+ * override — a choice this config key cannot see. In that mismatch case the guess
116
+ * is wrong and the file set simply fails to resolve (0 or partial files), which
117
+ * `resolveArtifactStatus`/`approveArtifact` already refuse rather than fabricate —
118
+ * the correct failure mode, not a special case to add here.
119
+ * @param {string} root
120
+ * @returns {{f: string, F: string, F_UPPER: string, E: string}}
121
+ */
122
+ export function resolveExemplarNames(root) {
123
+ const f = getExemplarFeature(root);
124
+ const F = toPascalCase(f);
125
+ const F_UPPER = toUpperSnake(F);
126
+ const E = f === "home" ? "Item" : defaultEntityName(F);
127
+ return { f, F, F_UPPER, E };
128
+ }
129
+
130
+ // Backward-compatible constants for the DEFAULT (`home`) exemplar — kept exported
131
+ // because they describe the shipped template's own exemplar shape independent of
132
+ // any project's configuration, and because they're the fixture the "stamping from
133
+ // home must be byte-identical" pin (test/genesis-flow.test.mjs) anchors to.
134
+ export const EXEMPLAR_FEATURE_KOTLIN_FILES = exemplarKotlinFileSet("Home", "home", "Item");
135
+ export const EXEMPLAR_SPEC_REL = "specs/home.spec.md";
136
+ export const ARCHITECTURE_SPEC_REL = "specs/app-base.spec.md";
137
+ export const INTENT_REL = "specs/intent.md";
138
+
139
+ // ── Package resolution ───────────────────────────────────────────────────────
140
+ // Mirrors qa/scaffold-feature.mjs's resolvePackage() primary path (the
141
+ // composeApp/build.gradle.kts namespace). Unlike the stamper, this NEVER dies —
142
+ // an unresolved package means the kotlin-rooted artifacts resolve to zero files.
143
+ // Zero resolution never CRASHES anything (the lane and the stamper stay up),
144
+ // but it is NOT benign for decisions: an approval over zero files would be the
145
+ // empty-input sha256 attesting nothing — a silent vacuous PASS, the exact
146
+ // failure mode this harness exists to kill (evidence must attest execution).
147
+ // So: approveArtifact REFUSES zero-file artifacts, and an already-approved
148
+ // artifact whose files stop resolving goes to changed-since-approval (FAIL),
149
+ // never PASS.
150
+ //
151
+ // IMPORTANT: detect "unresolved" by TOKEN SHAPE (`/^__[A-Z_]+__$/`), never by
152
+ // comparing against the literal string "__PACKAGE__". This file ships through
153
+ // the SAME scaffold pipeline that resolves that token — a literal comparison
154
+ // string is itself blindly text-substituted at stamp time (`replaceContents`
155
+ // does a global `"__PACKAGE__" -> config.package` replace over every template
156
+ // file's content, this one included), which would silently rewrite the
157
+ // sentinel into the real package and make the check always fail. A shape
158
+ // regex never spells the token out, so the pipeline has nothing to match.
159
+ const UNRESOLVED_TOKEN_RE = /^__[A-Z_]+__$/;
160
+
161
+ function resolvePackageDir(root) {
162
+ const gradleFile = path.join(root, "composeApp", "build.gradle.kts");
163
+ if (!fs.existsSync(gradleFile)) return null;
164
+ let contents;
165
+ try {
166
+ contents = fs.readFileSync(gradleFile, "utf8");
167
+ } catch {
168
+ return null;
169
+ }
170
+ const m = contents.match(/namespace\s*=\s*"([^"]+)"/);
171
+ if (!m || UNRESOLVED_TOKEN_RE.test(m[1])) return null;
172
+ return m[1].split(".").join("/");
173
+ }
174
+
175
+ function kotlinFile(root, sourceSet, rel) {
176
+ const packageDir = resolvePackageDir(root);
177
+ if (!packageDir) return null;
178
+ return path.posix.join(KOTLIN_SOURCE_SETS[sourceSet], packageDir, rel);
179
+ }
180
+
181
+ /**
182
+ * Is the project's package resolvable at all? False in the raw template (the
183
+ * namespace is still a placeholder token) and in any pre-stamp tree — the tell
184
+ * that this is not a generated project. The approve CLI refuses to WRITE
185
+ * approvals in such a tree (recording decisions against a template pollutes
186
+ * the template itself); read-only status remains available.
187
+ * @param {string} root
188
+ * @returns {boolean}
189
+ */
190
+ export function isPackageResolvable(root) {
191
+ return resolvePackageDir(root) !== null;
192
+ }
193
+
194
+ /**
195
+ * The profile's answer to "may approvals be RECORDED in this tree?" — the
196
+ * core's `isProjectGovernable` asks every profile this and refuses to write
197
+ * a ledger where the answer is no.
198
+ * @param {string} root
199
+ * @returns {{ok: true} | {ok: false, reason: string}}
200
+ */
201
+ export function governable(root) {
202
+ if (isPackageResolvable(root)) return { ok: true };
203
+ return {
204
+ ok: false,
205
+ reason:
206
+ "this tree's package is not resolvable (composeApp/build.gradle.kts namespace is missing or still a placeholder) — " +
207
+ "this looks like the raw template or a pre-stamp tree. Approvals are recorded in a generated project; refusing to write qa/approvals.json here.",
208
+ };
209
+ }
210
+
211
+ // ── Components glob ─────────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Sorted list of `presentation/components/*.kt` files under the resolved
215
+ * package, non-recursive (GENESIS-FLOW-DESIGN.md §1's `components` artifact — the
216
+ * component vocabulary conversation 3 approves). Package-unresolvable or a
217
+ * missing/empty directory both yield `[]` — resolveArtifactStatus/approveArtifact
218
+ * already treat a 0-file artifact as unresolvable ("a components glob matching
219
+ * zero files is unresolvable, not approvable-empty" — §1), so no special-casing
220
+ * is needed here beyond returning the honest (possibly empty) list.
221
+ * @param {string} root
222
+ * @returns {string[]} root-relative paths, sorted
223
+ */
224
+ function listComponentFiles(root) {
225
+ const dirRel = kotlinFile(root, "commonMain", "presentation/components");
226
+ if (!dirRel) return [];
227
+ let entries;
228
+ try {
229
+ entries = fs.readdirSync(path.join(root, dirRel), { withFileTypes: true });
230
+ } catch {
231
+ return [];
232
+ }
233
+ return entries
234
+ .filter((e) => e.isFile() && e.name.endsWith(".kt"))
235
+ .map((e) => path.posix.join(dirRel, e.name))
236
+ .sort((a, b) => a.localeCompare(b));
237
+ }
238
+
239
+ // ── Feature screens glob ────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * The screen files of one feature — `presentation/<name>/**\/*Screen.kt`,
243
+ * recursive, sorted. DELIBERATELY only `*Screen.kt`: the design signature
244
+ * covers the FORM (what renders), so binding the whole presentation dir would
245
+ * make every ViewModel edit during a legitimate build read as design drift.
246
+ * This is what a DESIGN SURFACE is on mobile — the core's feature-design
247
+ * mechanic asks the profile for it and knows nothing else.
248
+ * @param {string} root
249
+ * @param {string} name the feature name (presentation/<name>/)
250
+ * @returns {string[]} repo-relative posix paths
251
+ */
252
+ export function listFeatureScreenFiles(root, name) {
253
+ const dirRel = kotlinFile(root, "commonMain", `presentation/${name}`);
254
+ if (!dirRel) return [];
255
+ const out = [];
256
+ const walk = (rel) => {
257
+ let entries;
258
+ try {
259
+ entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
260
+ } catch {
261
+ return;
262
+ }
263
+ for (const e of entries) {
264
+ const childRel = path.posix.join(rel, e.name);
265
+ if (e.isDirectory()) walk(childRel);
266
+ else if (e.isFile() && e.name.endsWith("Screen.kt")) out.push(childRel);
267
+ }
268
+ };
269
+ walk(dirRel);
270
+ return out.sort((a, b) => a.localeCompare(b));
271
+ }
272
+
273
+ // ── The registry ────────────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * The governed-artifact registry, resolved against the project at `root` right
277
+ * now. GENESIS-FLOW-DESIGN.md §1 definition order — two ordering principles,
278
+ * one per artifact kind (the dogfooding-run correction):
279
+ * BEHAVIORAL artifacts are SPEC-FIRST — the exemplar's clauses are proposed
280
+ * and human-confirmed BEFORE the slice is built (exemplar-spec precedes
281
+ * exemplar-feature, matching add-feature's discipline).
282
+ * VISUAL artifacts are UI-FIRST — the design system and component vocabulary
283
+ * are distilled FROM the real screens, so they lock AFTER the exemplar
284
+ * exists (a provisional palette carries the build until then).
285
+ * Order: intent(0), then feature-brief:<name> per docs/features/*.md — the
286
+ * DECIDE layer sits directly after intent (a brief speaks intent's
287
+ * vocabulary; only the SPEC needs architecture's) — then architecture,
288
+ * exemplar-spec, exemplar-feature, design-system, components, one
289
+ * feature-design:<name> per brief with a screen surface, and one
290
+ * feature-spec:<name> per non-base, non-CONFIGURED-exemplar spec present.
291
+ *
292
+ * `complete: false` marks an artifact whose kotlin-rooted files could NOT be
293
+ * resolved (unresolvable package — raw template / pre-stamp tree). Such an
294
+ * artifact's `files` list is empty or partial (spec files only), so hashing it
295
+ * would attest nothing (or only a fraction) of what the artifact governs —
296
+ * approveArtifact refuses it, and the status surfaces treat it as unresolvable.
297
+ * @param {string} root absolute path to the project root
298
+ * @returns {Array<{id: string, label: string, files: string[], complete: boolean, hash?: (root: string) => {hash: string, fileCount: number, missing: string[]}}>}
299
+ */
300
+ export function artifacts(root) {
301
+ const out = [];
302
+ const packageResolved = resolvePackageDir(root) !== null;
303
+ // Why a kotlin-rooted artifact is incomplete, quoted by the core's refusal.
304
+ const incompleteReason = packageResolved
305
+ ? undefined
306
+ : "the kotlin-rooted files are unresolvable because the project package is not resolvable from composeApp/build.gradle.kts (likely the raw template or a pre-stamp tree — run this in a generated project)";
307
+
308
+ out.push({
309
+ id: "intent",
310
+ label: `Intent brief (${INTENT_REL})`,
311
+ files: [INTENT_REL],
312
+ complete: true,
313
+ });
314
+
315
+ // The decide layer, directly after intent — the core's helper, so every
316
+ // profile with briefs walks them the same way.
317
+ out.push(...featureBriefArtifacts(root));
318
+
319
+ // Hashed via the core's architecture hasher (spec bytes + the doc with its
320
+ // generated sections stripped), NOT raw files — `files` is still the
321
+ // artifact's expected-files surface (missing-file refusals, "what governs
322
+ // this" bookkeeping), just not what gets hashed raw.
323
+ out.push(architectureArtifact(root, { specRel: ARCHITECTURE_SPEC_REL, docRel: ARCH_DOC_REL_PATH }));
324
+
325
+ const { f: exemplarF, F: exemplarF_Pascal, E: exemplarE } = resolveExemplarNames(root);
326
+ const exemplarSpecRel = `specs/${exemplarF}.spec.md`;
327
+ const exemplarKotlinFiles = exemplarKotlinFileSet(exemplarF_Pascal, exemplarF, exemplarE);
328
+
329
+ // Spec-first: the exemplar's behavior clauses are confirmed BEFORE the slice
330
+ // is built — the definition order is the discipline, not just a display order.
331
+ out.push({
332
+ id: "exemplar-spec",
333
+ label: `Exemplar spec (${exemplarSpecRel})`,
334
+ files: [exemplarSpecRel],
335
+ complete: true,
336
+ });
337
+
338
+ out.push({
339
+ id: "exemplar-feature",
340
+ label: `Exemplar feature (${exemplarF} — the file set the stamper clones)`,
341
+ files: [...exemplarKotlinFiles.map((f) => kotlinFile(root, f.sourceSet, f.rel)).filter(Boolean), exemplarSpecRel],
342
+ complete: packageResolved,
343
+ incompleteReason,
344
+ });
345
+
346
+ // UI-first: the design system LOCKS on the real exemplar (candidates render on
347
+ // real screens, never stubs), and the component vocabulary is DISTILLED from
348
+ // those screens — both follow the exemplar in the definition order.
349
+ out.push({
350
+ id: "design-system",
351
+ label: "Design system (presentation/theme/Theme.kt, Tokens.kt)",
352
+ files: [kotlinFile(root, "commonMain", "presentation/theme/Theme.kt"), kotlinFile(root, "commonMain", "presentation/theme/Tokens.kt")].filter(Boolean),
353
+ complete: packageResolved,
354
+ incompleteReason,
355
+ });
356
+
357
+ out.push({
358
+ id: "components",
359
+ label: "Components (presentation/components/*.kt)",
360
+ files: listComponentFiles(root),
361
+ complete: packageResolved,
362
+ incompleteReason,
363
+ });
364
+
365
+ // Feature designs — the core's mechanic (one per brief with a surface,
366
+ // signed on rendered output BEFORE the behaviour contract); this profile
367
+ // says what the surface is: `"screens": true` declared, or *Screen.kt on disk.
368
+ out.push(
369
+ ...featureDesignArtifacts(root, {
370
+ surfaceFiles: (r, name) => listFeatureScreenFiles(r, name),
371
+ declares: (block) => block.screens === true,
372
+ label: (name) => `Feature design (${name} — presentation/${name}/*Screen.kt, signed on rendered output)`,
373
+ complete: (files) => packageResolved && files.length > 0,
374
+ incompleteReason: (files) => (packageResolved ? (files.length === 0 ? "no *Screen.kt rendered yet for this feature" : undefined) : incompleteReason),
375
+ }),
376
+ );
377
+
378
+ out.push(...featureSpecArtifacts(root, { specsDir: "specs", exclude: ["app-base.spec.md", `${exemplarF}.spec.md`] }));
379
+
380
+ return out;
381
+ }
@@ -1,5 +1,8 @@
1
- // component-stories.mjs — the component ↔ story parity gate (the IMP-1
2
- // screen↔registry parity idea, applied at component granularity).
1
+ // profiles/cmp/component-stories.mjs — the component ↔ story parity gate (the
2
+ // IMP-1 screen↔registry parity idea, applied at component granularity).
3
+ // Mobile's, by definition: a @Composable and a preview-registry story are
4
+ // Compose facts, and this file names composeApp source paths throughout.
5
+ // Stage 0 PR 6b.2 moved it out of the spine (docs/NORTH-STAR.md §6).
3
6
  //
4
7
  // Every `@Composable fun` in `composeApp/src/commonMain/**/presentation/
5
8
  // components/*.kt` must have a preview-registry story whose id is
@@ -0,0 +1,29 @@
1
+ // THE CONSOLE'S COMPOSE-SPECIFIC COPY, OWNED BY THE PROFILE THAT KNOWS IT.
2
+ //
3
+ // PATTERN: contribution points (VS Code `contributes.viewsWelcome`, Backstage
4
+ // plugins, Grafana panels): the shell renders neutral section types; the
5
+ // provider supplies rows AND the words around them. WHY IT WORKS: the shell
6
+ // stops naming Tokens.kt or @Composable, so a Python profile's console does not
7
+ // tell its user to edit a Kotlin file. HOW IT FAILS: a provider forgets a key
8
+ // and the shell shows a neutral placeholder where the adopter expected help —
9
+ // or the shell grows a new string nobody routes through `copy`. WHAT WE DO:
10
+ // every key has a neutral default in the shell, the widened agnostic lint now
11
+ // scans console/ for language-shaped strings, and this file is the one place
12
+ // a Compose word may live on the console path.
13
+ //
14
+ // SINGLE SOURCE OF TRUTH: packages/harness/src/lib/profiles/cmp/console-copy.mjs.
15
+ export const copy = Object.freeze({
16
+ usesIn: "commonMain",
17
+ tokensEditHint: "Edit <code>Tokens.kt</code>, let the preview re-render, then stash the result with the",
18
+ componentsEmpty: "no @Composable components found in presentation/components/*.kt",
19
+ versionSetFile: "gradle/libs.versions.toml",
20
+ versionSetUnreadable: "gradle/libs.versions.toml not readable",
21
+ kspPairLabel: "KSP is <code>&lt;kotlin&gt;-&lt;ksp&gt;</code>",
22
+ kspPrefixWarning: "is not prefixed by Kotlin",
23
+ previewRegistryFile: "inspector/PreviewRegistry.kt",
24
+ noRenderableApp: "no Compose app",
25
+ kspName: "KSP",
26
+ kspCarriesLabel: "carries Kotlin",
27
+ kspCarriesNote: " — Room's KMP native compilation breaks on this.",
28
+ depGraphGatesNote: "The Kotlin conformance gates (and the receipt they write, below) are authoritative.",
29
+ });
@@ -0,0 +1,181 @@
1
+ // profiles/cmp/declarations.mjs — what a Compose Multiplatform app IS, to the
2
+ // harness: where its specs, sources, tests and flows live, and which test
3
+ // tiers can observe which kind of promise. Mobile's facts, by definition.
4
+ //
5
+ // Stage 0 PR 4 (docs/proposals/AGNOSTIC-HARNESS-ARCHITECTURE.md §11.3 step 4;
6
+ // docs/NORTH-STAR.md §6). Until this file, qa/lib/spec-coverage.mjs carried
7
+ // these as constants — `composeApp/src`, `qa/e2e`, `.kt`, the four tier names,
8
+ // which tier satisfies `[tier: device]` — so a Kotlin backend that adopted the
9
+ // spine had to fork the scanner to change a path. The scanner is the core's
10
+ // (the clause grammar, the citation binding window, both-direction coverage,
11
+ // tier-must-observe); THESE are the profile's. The core reads them through
12
+ // qa/lib/spec-model.mjs and never names any of them.
13
+ //
14
+ // Two declarations of the nine (§4.2): #1 layout and #4 tiers. The rest move
15
+ // here in the PRs that follow, one at a time.
16
+
17
+ /**
18
+ * Layout — where the things the scanner reads live, relative to the project
19
+ * root, posix-separated.
20
+ *
21
+ * specs the directory of `*.spec.md` files
22
+ * citationRoots the trees walked for `// SPEC:` citations
23
+ * sourceRoots the trees a human edits — watched by the inner loop, counted
24
+ * as activity by the chain view
25
+ * citationExts the source files a citation may sit in
26
+ * buildDir the stack's build output directory, when it has one — the
27
+ * provider's render marker lives there (optional)
28
+ * flows flow-shaped citation files: the file IS the test, so a tag
29
+ * in one binds to the flow rather than to a declaration
30
+ * inside it. Only TOP-LEVEL files in `dir` count — the lane
31
+ * runs that directory, and a citation may only come from a
32
+ * flow that executes (2026-09-03: four nested hand-written
33
+ * flows on the showcase satisfied clauses without running).
34
+ */
35
+ export const layout = Object.freeze({
36
+ specs: "specs",
37
+ citationRoots: Object.freeze(["composeApp/src", "qa/e2e"]),
38
+ // The trees a human edits when they work on this app — what the inner-loop
39
+ // watcher watches and what the chain view counts as observed activity.
40
+ // Narrower than citationRoots: qa/e2e is a citation source but not app source.
41
+ sourceRoots: Object.freeze(["composeApp/src"]),
42
+ citationExts: Object.freeze([".kt", ".kts"]),
43
+ flows: Object.freeze({ dir: "qa/e2e", exts: Object.freeze([".yaml", ".yml"]) }),
44
+ // Gradle's output directory for the app module — where the eyes stamp their
45
+ // render marker (qa/lib/lane-markers.mjs) and where KSP's single-owner
46
+ // incremental storage lives (steps-cmp.mjs's coexistence self-heal).
47
+ buildDir: "composeApp/build",
48
+ // Directories the inputs hash and the activity scan skip when there is no
49
+ // git and no .gitignore to ask. The repo's own .gitignore is the truth
50
+ // (git ls-files --exclude-standard, or the walk that reads the same file);
51
+ // this is the floor beneath it, and it is THIS stack's to declare — the core
52
+ // used to hardcode .gradle and .kotlin for every ecosystem.
53
+ ignore: Object.freeze([".gradle", ".kotlin", ".idea"]),
54
+ });
55
+
56
+ /**
57
+ * THE GRAMMAR — what a citation and a test declaration look like in Kotlin.
58
+ *
59
+ * PATTERN: declaration over fallback (the shape tree-sitter uses — one query
60
+ * file per language, named captures, nothing inferred). WHY IT WORKS: a
61
+ * required declaration cannot be silently wrong for the profile that forgot
62
+ * it; until 2026-09-08 this regex lived in the core as a FALLBACK and `cmp`
63
+ * itself never declared one, so any profile that omitted `grammar` was graded
64
+ * with Kotlin's — and nothing said so. HOW IT FAILS: an author copies another
65
+ * language's regex and it binds nothing, or binds the wrong lines. WHAT WE DO:
66
+ * the Rule 0 instrument plants `unboundCitationSource` in THIS language and
67
+ * watches the grammar fail to bind it by name, so a grammar that cannot see
68
+ * its own language is caught before it grades anything; and the coverage
69
+ * diagnostic prints "N markers seen, 0 bound" rather than a quiet PASS.
70
+ */
71
+ export const grammar = Object.freeze({
72
+ // TWO citation dialects, both this profile's: Kotlin sources cite with `//`, and
73
+ // the Maestro YAML journeys under qa/e2e cite with `#`. Declared here — the
74
+ // old core fallback happened to accept both, which is how nobody noticed.
75
+ citationMarker: /^(?:\/\/|#)\s*SPEC:/,
76
+ lineComment: /^(?:\/\/|\*)/,
77
+ blockComment: Object.freeze({ open: "/*", close: "*/" }),
78
+ testDeclaration: /@Test\b|\bfun\s+`[^`]+`\s*\(/,
79
+ typeDeclaration: /^(?:@\w+\s+)*(?:public\s+|internal\s+|private\s+|abstract\s+|open\s+|sealed\s+|data\s+|enum\s+)*(?:class|object|interface)\s+\w+/,
80
+ bindingWindow: 5,
81
+ });
82
+
83
+ /**
84
+ * THE REPORT FORMAT the lane's runners emit, so the core parses what was
85
+ * declared and never assumes. PATTERN: JUnit XML as the lingua franca (pytest
86
+ * --junitxml, go-junit-report, cargo2junit, jest-junit, swift test
87
+ * --xunit-output all emit it). WHY IT WORKS: one parser, every ecosystem, and
88
+ * the declaration is a fact the profile author knows. HOW IT FAILS: a runner
89
+ * emits a dialect (no classname, nested suites) and the parser reads {} — an
90
+ * empty leg that looks like "no tests". WHAT WE DO: the parser refuses an
91
+ * undeclared or unsupported format by name instead of returning {}, and the
92
+ * determinism probe treats an empty outcome map as a refusal, never a pass.
93
+ */
94
+ export const reports = Object.freeze({ format: "junit-xml", dir: "composeApp/build/test-results" });
95
+
96
+ /**
97
+ * The top-level `qa/*.mjs` tools that are THIS profile's, not the spine's, and
98
+ * are therefore never vendored into a repo that does not run this profile.
99
+ *
100
+ * PATTERN: the contribution point — the core holds no list of stacks, and the
101
+ * only thing that knows a tool belongs to Compose is the Compose profile.
102
+ * WHY IT WORKS: the fact is declared once, where it is known, and the installer
103
+ * derives the set (install/portability.mjs) instead of mirroring it by hand in
104
+ * two packages, which is what it did until 2026-09-08. HOW IT FAILS: this
105
+ * profile grows a fifth tool and nobody adds it here, so a foreign repo is
106
+ * vendored a module that names a stack it is not. WHAT WE DO: the import
107
+ * closure is the check — `undeclaredProfileTools` refuses a tool whose own
108
+ * imports reach this profile and that is missing from this list, so the only
109
+ * gap this can leave is a tool that lies in strings alone.
110
+ *
111
+ * These four live at the spine's path for historical reasons; the debt is
112
+ * recorded in test/agnostic-lint.test.mjs, and the exit is that they move into
113
+ * this directory, at which point this declaration becomes redundant and goes.
114
+ */
115
+ export const tools = Object.freeze([
116
+ "preview-gallery.mjs",
117
+ "refusal-demo.mjs",
118
+ "scaffold-feature.mjs",
119
+ "walkthrough.mjs",
120
+ ]);
121
+
122
+ /**
123
+ * Does this tree belong to THIS profile? PATTERN: Cloud Native Buildpacks'
124
+ * `bin/detect` — each stack recognises itself from marker files and the
125
+ * platform holds no table. WHY IT WORKS: the profile author knows the markers;
126
+ * `harness init` asks every known profile and keeps no language list of its
127
+ * own. HOW IT FAILS: an eager detect claims a tree that is not its (any Gradle
128
+ * repo is not a Compose app), or two profiles claim one tree. WHAT WE DO:
129
+ * evidence is returned and printed, never a bare boolean; two claims refuse
130
+ * and ask; and the claim needs BOTH the build file and the module the stamper
131
+ * writes, not either.
132
+ */
133
+ export function detect(root, fs) {
134
+ const has = (rel) => fs.existsSync(`${root}/${rel}`);
135
+ const evidence = [];
136
+ if (has("composeApp/build.gradle.kts")) evidence.push("composeApp/build.gradle.kts");
137
+ if (has("settings.gradle.kts") || has("settings.gradle")) evidence.push("settings.gradle(.kts)");
138
+ const claims = evidence.length === 2;
139
+ return { claims, evidence, reason: claims ? "a Compose Multiplatform app: the composeApp module and a Gradle settings file" : `not a Compose Multiplatform app (found: ${evidence.join(", ") || "neither marker"})` };
140
+ }
141
+
142
+
143
+ /**
144
+ * Evidence tiers — the source-set / harness boundaries that decide what a
145
+ * citing test can actually SEE. commonTest and desktopTest run on the host
146
+ * JVM, blind to androidMain and to every OS fact (lifecycle, alarms,
147
+ * notifications, permissions); androidInstrumentedTest runs in the app's
148
+ * process on a device; e2e flows drive the installed app.
149
+ *
150
+ * names every tier this profile knows, in ascending observability
151
+ * hostOnly tiers that run on the host and cannot observe the target
152
+ * platform — a clause cited only from these is REPORTED
153
+ * satisfying a clause tagged `[tier: X]` must be cited from one of
154
+ * satisfying[X], or specCoverage FAILS by name (MOTION-13: an
155
+ * animation "plays once per process start", cited only from a
156
+ * desktop test with no process lifecycle at all)
157
+ * journey the tier whose citation proves a device journey — what a UI
158
+ * feature needs at least one of to be done (feature-brief)
159
+ * forFile the tier a citing file belongs to, from its path
160
+ */
161
+ export const tiers = Object.freeze({
162
+ names: Object.freeze(["commonTest", "desktopTest", "androidInstrumentedTest", "e2e"]),
163
+ hostOnly: Object.freeze(["commonTest", "desktopTest"]),
164
+ satisfying: Object.freeze({
165
+ device: Object.freeze(["androidInstrumentedTest", "e2e"]),
166
+ e2e: Object.freeze(["e2e"]),
167
+ }),
168
+ journey: "e2e",
169
+ /**
170
+ * @param {string} rel path relative to the project root (either separator)
171
+ * @returns {"commonTest"|"desktopTest"|"androidInstrumentedTest"|"e2e"|"other"}
172
+ */
173
+ forFile(rel) {
174
+ const p = String(rel).split("\\").join("/");
175
+ if (p.includes("/androidInstrumentedTest/")) return "androidInstrumentedTest";
176
+ if (p.includes("/commonTest/")) return "commonTest";
177
+ if (p.includes("/desktopTest/")) return "desktopTest";
178
+ if (p.startsWith("qa/e2e/")) return "e2e";
179
+ return "other";
180
+ },
181
+ });
@@ -19,7 +19,17 @@
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
21
  import { evaluateReachability } from "./reachability.mjs";
22
- import { E2E_FLOW_DIR, scanCitations, scanSpecClauses } from "./spec-coverage.mjs";
22
+ import { scanCitations, scanSpecClauses } from "../../spec-coverage.mjs";
23
+ import { specModelFrom } from "../../spec-model.mjs";
24
+ import { layout as cmpLayout, tiers as cmpTiers, grammar as cmpGrammar } from "./declarations.mjs";
25
+
26
+ const SPEC_MODEL = (() => {
27
+ const r = specModelFrom({ id: "cmp", layout: cmpLayout, tiers: cmpTiers, grammar: cmpGrammar });
28
+ if (!r.ok) throw new Error(r.reason);
29
+ return r.model;
30
+ })();
31
+ const E2E_FLOW_DIR = cmpLayout.flows.dir;
32
+ const JOURNEY_TIER = cmpTiers.journey;
23
33
 
24
34
  /**
25
35
  * @param {string} root
@@ -34,8 +44,8 @@ export function evaluateE2eCoverage(root) {
34
44
  if (screenFeatures.length === 0) {
35
45
  return { verdict: "SKIP", reason: reach.reason ?? "no presentation/<feature> directory has a *Screen.kt file — nothing to cover", details: { features: [] } };
36
46
  }
37
- const clauses = scanSpecClauses(root);
38
- const e2eTags = scanCitations(root).filter((t) => t.tier === "e2e");
47
+ const clauses = scanSpecClauses(root, SPEC_MODEL);
48
+ const e2eTags = scanCitations(root, SPEC_MODEL).filter((t) => t.tier === JOURNEY_TIER);
39
49
  const features = screenFeatures.map((f) => {
40
50
  const specRel = `specs/${f.name}.spec.md`;
41
51
  const spec = fs.existsSync(path.join(root, specRel)) ? specRel : null;