create-cmp-cli 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-cmp.mjs +33 -3
- package/package.json +10 -4
- package/packages/harness/package.json +18 -8
- package/packages/harness/src/approve.mjs +10 -11
- package/packages/harness/src/console/console-data.mjs +117 -0
- package/packages/harness/src/console/console-evidence.mjs +122 -0
- package/packages/harness/src/console/console-overview.mjs +642 -0
- package/packages/harness/src/console/console-shell.mjs +1139 -0
- package/packages/harness/src/console/console-tabs.mjs +2828 -0
- package/packages/harness/src/console/contrast.mjs +74 -0
- package/packages/harness/src/console/preview-service.mjs +1384 -0
- package/packages/harness/src/framework-check.mjs +271 -37
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/approvals.mjs +177 -350
- package/packages/harness/src/lib/audit-cadence.mjs +1 -1
- package/packages/harness/src/lib/comments.mjs +18 -4
- package/packages/harness/src/lib/determinism.mjs +275 -14
- package/packages/harness/src/lib/evidence-badge.mjs +34 -2
- package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
- package/packages/harness/src/lib/evidence-level.mjs +144 -59
- package/packages/harness/src/lib/feature-brief.mjs +53 -16
- package/packages/harness/src/lib/flight-recorder.mjs +72 -8
- package/packages/harness/src/lib/framework-check.mjs +434 -53
- package/packages/harness/src/lib/harness-lock.mjs +87 -19
- package/packages/harness/src/lib/harness-manifest.mjs +208 -0
- package/packages/harness/src/lib/harness-region.mjs +106 -2
- package/packages/harness/src/lib/harness-source.mjs +142 -0
- package/packages/harness/src/lib/inputs-hash.mjs +175 -22
- package/packages/harness/src/lib/lane-markers.mjs +78 -0
- package/packages/harness/src/lib/lane-narrator.mjs +4 -2
- package/packages/harness/src/lib/lane-runner.mjs +72 -6
- package/packages/harness/src/lib/plan.mjs +44 -11
- package/packages/harness/src/lib/plant-calibration.mjs +135 -0
- package/packages/harness/src/lib/profile-loader.mjs +323 -0
- package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
- package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
- package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
- package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
- package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
- package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
- package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
- package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
- package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
- package/packages/harness/src/lib/receipt-validate.mjs +84 -16
- package/packages/harness/src/lib/spec-coverage.mjs +179 -92
- package/packages/harness/src/lib/spec-model.mjs +262 -0
- package/packages/harness/src/lib/step-cache.mjs +11 -2
- package/packages/harness/src/lib/step-outcomes.mjs +84 -151
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +80 -24
- package/packages/harness/src/record-audit.mjs +1 -1
- package/packages/harness/src/scaffold-feature.mjs +10 -2
- package/packages/harness/src/verify.mjs +222 -142
- package/packages/harness/src/watch.mjs +146 -33
- package/packages/receipts/package.json +1 -1
- package/packages/receipts/src/index.mjs +2 -2
- package/packages/receipts/src/inputs-hash.mjs +175 -22
- package/packages/receipts/src/receipt-validate.mjs +84 -16
- package/src/commands/attach.mjs +128 -4
- package/src/commands/harden.mjs +7 -1
- package/src/commands/upgrade.mjs +7 -0
- package/src/lib/minimal.mjs +23 -1
- package/src/scaffold.mjs +8 -0
- package/template/.claude/settings.json +2 -2
- package/template/.claude/skills/add-feature/SKILL.md +7 -3
- package/template/.claude/skills/add-repository/SKILL.md +2 -2
- package/template/.claude/skills/add-screen/SKILL.md +8 -4
- package/template/.githooks/pre-push +9 -0
- package/template/CLAUDE.md +55 -186
- package/template/docs/TESTING.md +1 -1
- package/template/gitignore +3 -0
- package/template/qa/approve.mjs +10 -11
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +271 -37
- package/template/qa/harness-manifest.json +10 -0
- package/template/qa/lib/a11y.mjs +1 -1
- package/template/qa/lib/affected-tests.mjs +99 -44
- package/template/qa/lib/approvals.mjs +177 -350
- package/template/qa/lib/audit-cadence.mjs +1 -1
- package/template/qa/lib/comments.mjs +18 -4
- package/template/qa/lib/determinism.mjs +275 -14
- package/template/qa/lib/evidence-badge.mjs +34 -2
- package/template/qa/lib/evidence-ladder.mjs +202 -0
- package/template/qa/lib/evidence-level.mjs +144 -59
- package/template/qa/lib/feature-brief.mjs +53 -16
- package/template/qa/lib/flight-recorder.mjs +72 -8
- package/template/qa/lib/framework-check.mjs +434 -53
- package/template/qa/lib/harness-lock.mjs +87 -19
- package/template/qa/lib/harness-manifest.mjs +208 -0
- package/template/qa/lib/harness-region.mjs +106 -2
- package/template/qa/lib/harness-source.mjs +142 -0
- package/template/qa/lib/inputs-hash.mjs +175 -22
- package/template/qa/lib/lane-markers.mjs +78 -0
- package/template/qa/lib/lane-narrator.mjs +4 -2
- package/template/qa/lib/lane-runner.mjs +72 -6
- package/template/qa/lib/plan.mjs +44 -11
- package/template/qa/lib/plant-calibration.mjs +135 -0
- package/template/qa/lib/profile-loader.mjs +323 -0
- package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
- package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
- package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
- package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
- package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/template/qa/lib/profiles/cmp/index.mjs +47 -0
- package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
- package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
- package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
- package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
- package/template/qa/lib/receipt-validate.mjs +84 -16
- package/template/qa/lib/spec-coverage.mjs +179 -92
- package/template/qa/lib/spec-model.mjs +262 -0
- package/template/qa/lib/step-cache.mjs +11 -2
- package/template/qa/lib/step-outcomes.mjs +84 -151
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +80 -24
- package/template/qa/record-audit.mjs +1 -1
- package/template/qa/scaffold-feature.mjs +10 -2
- package/template/qa/verified-surface.json +17 -0
- package/template/qa/verify.mjs +222 -142
- package/template/qa/watch.mjs +146 -33
- /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
- /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
|
@@ -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
|
|
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><kotlin>-<ksp></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 {
|
|
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 ===
|
|
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;
|