create-cmp-cli 0.7.1 → 0.9.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/README.md +72 -11
- package/llms.txt +6 -2
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +8 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/registry.mjs +15 -2
- package/src/lib/tabs.mjs +91 -4
- package/src/lib/upgrade.mjs +49 -5
- package/src/scaffold.mjs +52 -1
- package/src/versions/candidates.json +4 -0
- package/src/versions/registry.json +88 -0
- package/template/.claude/skills/add-feature/SKILL.md +35 -10
- package/template/.claude/skills/add-repository/SKILL.md +1 -1
- package/template/.claude/skills/add-screen/SKILL.md +13 -7
- package/template/.githooks/pre-push +24 -0
- package/template/CLAUDE.md +196 -48
- package/template/README.md +23 -27
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
- package/template/docs/ARCHITECTURE.md +317 -34
- package/template/docs/TESTING.md +6 -5
- package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
- package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
- package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
- package/template/qa/approvals.json +42 -0
- package/template/qa/approve.mjs +139 -0
- package/template/qa/arch-doc.mjs +69 -0
- package/template/qa/comment.mjs +76 -0
- package/template/qa/comments.json +4 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/approvals.mjs +806 -0
- package/template/qa/lib/arch-doc.mjs +451 -0
- package/template/qa/lib/comments.mjs +252 -0
- package/template/qa/lib/component-stories.mjs +183 -0
- package/template/qa/lib/inputs-hash.mjs +5 -1
- package/template/qa/scaffold-feature.mjs +184 -67
- package/template/qa/setup-hooks.mjs +33 -0
- package/template/qa/verify.mjs +118 -9
- package/template/specs/app-base.spec.md +44 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// that spans both, applied together.
|
|
22
22
|
//
|
|
23
23
|
// feature (default) — all 11 files; DI repo+usecase+viewModel; nav
|
|
24
|
-
// route+import; spec FEATURE-01..
|
|
24
|
+
// route+import; spec FEATURE-01..07.
|
|
25
25
|
// repository <Entity> — ONLY the 5 data/domain files; DI repo+usecase ONLY;
|
|
26
26
|
// no nav, no viewModel, no spec file, zero SPEC tags. The
|
|
27
27
|
// positional arg IS the entity (no --entity, no feature name).
|
|
@@ -94,23 +94,35 @@ if (!IDENTIFIER_RE.test(entityName)) {
|
|
|
94
94
|
// `repository` preset has no feature name (no nav/presentation/spec slice), so
|
|
95
95
|
// F/f/F_UPPER are never read for it — the rename map still needs harmless
|
|
96
96
|
// values to build (its feature-shaped entries never match repository-preset
|
|
97
|
-
// file contents, which only reference
|
|
97
|
+
// file contents, which only reference the exemplar's own entity, e.g.
|
|
98
|
+
// Item/ItemRepository/GetItemsUseCase for the default `home` exemplar).
|
|
98
99
|
const F = featureName ?? entityName; // PascalCase feature, e.g. Favorites
|
|
99
100
|
const f = F[0].toLowerCase() + F.slice(1); // camelCase/package segment, e.g. favorites
|
|
100
101
|
const F_UPPER = F.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); // FAVORITES
|
|
101
102
|
const E = entityName; // PascalCase entity, e.g. Favorite
|
|
102
103
|
|
|
103
104
|
// ── Resolve the target project's real package ───────────────────────────────
|
|
104
|
-
// This script runs POST-scaffold, so
|
|
105
|
-
// target project. Parse it from composeApp/build.gradle.kts
|
|
106
|
-
// failing that, from any source file's `package` line.
|
|
105
|
+
// This script runs POST-scaffold, so the package placeholder token is already
|
|
106
|
+
// resolved in the target project. Parse it from composeApp/build.gradle.kts
|
|
107
|
+
// (namespace) or, failing that, from any source file's `package` line.
|
|
108
|
+
//
|
|
109
|
+
// Detect "still unresolved" by TOKEN SHAPE (`/^__[A-Z_]+__$/`), never by
|
|
110
|
+
// comparing against the literal placeholder string: this script is itself a
|
|
111
|
+
// template file that goes through the scaffold's own token-replacement pass,
|
|
112
|
+
// which does a blind text substitution of that literal placeholder -> the
|
|
113
|
+
// real package wherever it appears in file CONTENT — including a string
|
|
114
|
+
// literal sitting right here. A literal comparison would silently become
|
|
115
|
+
// `m[1] !== "<the real package>"` post-stamp (always true for the very
|
|
116
|
+
// project it should detect as unresolved) and never fire. A shape regex never
|
|
117
|
+
// spells the placeholder out, so the pipeline has nothing to match.
|
|
118
|
+
const UNRESOLVED_TOKEN_RE = /^__[A-Z_]+__$/;
|
|
107
119
|
|
|
108
120
|
function resolvePackage() {
|
|
109
121
|
const gradleFile = path.join(ROOT, "composeApp", "build.gradle.kts");
|
|
110
122
|
if (fs.existsSync(gradleFile)) {
|
|
111
123
|
const contents = fs.readFileSync(gradleFile, "utf8");
|
|
112
124
|
const m = contents.match(/namespace\s*=\s*"([^"]+)"/);
|
|
113
|
-
if (m && m[1]
|
|
125
|
+
if (m && !UNRESOLVED_TOKEN_RE.test(m[1])) return m[1];
|
|
114
126
|
}
|
|
115
127
|
const homeViewModel = path.join(
|
|
116
128
|
ROOT,
|
|
@@ -149,46 +161,126 @@ function guessPackageDirFromDisk() {
|
|
|
149
161
|
const PACKAGE = resolvePackage();
|
|
150
162
|
const PACKAGE_DIR = PACKAGE.split(".").join("/");
|
|
151
163
|
|
|
164
|
+
// ── The file set (§4) ───────────────────────────────────────────────────────
|
|
165
|
+
// Source paths are relative to composeApp/src/<sourceSet>/kotlin/<PACKAGE_DIR>.
|
|
166
|
+
|
|
167
|
+
const SRC = (sourceSet) => path.join(ROOT, "composeApp/src", sourceSet, "kotlin", PACKAGE_DIR);
|
|
168
|
+
|
|
169
|
+
// ── Configurable exemplar (GENESIS-FLOW-DESIGN.md §1) ───────────────────────
|
|
170
|
+
// This stamper clones from the CONFIGURED exemplar (qa/approvals.json's
|
|
171
|
+
// `exemplarFeature`, defaulting to `home`) — the same resolution
|
|
172
|
+
// qa/lib/approvals.mjs's governed-artifact registry uses, so a stamped feature
|
|
173
|
+
// always matches what the registry hashes. Tolerant of a missing/pre-genesis-flow
|
|
174
|
+
// qa/lib/approvals.mjs (mirrors the seedUnreviewed step further down): a broken
|
|
175
|
+
// or absent import falls back to the hardcoded `home`/`Item` shape that predates
|
|
176
|
+
// configurability — this script must never refuse to stamp because the approvals
|
|
177
|
+
// ledger's tooling isn't present.
|
|
178
|
+
let approvalsLib = null;
|
|
179
|
+
try {
|
|
180
|
+
approvalsLib = await import("./lib/approvals.mjs");
|
|
181
|
+
} catch {
|
|
182
|
+
approvalsLib = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resolveSourceNames() {
|
|
186
|
+
if (approvalsLib && typeof approvalsLib.resolveExemplarNames === "function") {
|
|
187
|
+
return approvalsLib.resolveExemplarNames(ROOT);
|
|
188
|
+
}
|
|
189
|
+
return { f: "home", F: "Home", F_UPPER: "HOME", E: "Item" };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { f: SOURCE_f, F: SOURCE_F, F_UPPER: SOURCE_F_UPPER, E: SOURCE_E } = resolveSourceNames();
|
|
193
|
+
|
|
194
|
+
// Extras beyond the canonical 11-file shape (§1: "clone the canonical set and
|
|
195
|
+
// WARN, listing what it skipped — never silently"). Scoped by NAME, not by
|
|
196
|
+
// directory membership: the exemplar's presentation directory can legitimately
|
|
197
|
+
// hold files that aren't "this feature's own" (e.g. the shipped `home`
|
|
198
|
+
// exemplar's directory also holds `DetailScreen.kt` — a deliberately
|
|
199
|
+
// NOT-cloned, permanent fixture per add-feature's SKILL.md, not drift) — so a
|
|
200
|
+
// file only counts as an extra when its name is actually built FROM the
|
|
201
|
+
// exemplar's own identifiers (starts with `${sourceF}` in presentation/, or
|
|
202
|
+
// contains the entity as a whole word in the shared domain/data directories).
|
|
203
|
+
function escapeRegExp(s) {
|
|
204
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function scanDirForExtras(sourceSet, dirRel, canonicalBasenames, matches) {
|
|
208
|
+
const dirAbs = path.join(SRC(sourceSet), dirRel);
|
|
209
|
+
if (!fs.existsSync(dirAbs)) return [];
|
|
210
|
+
return fs
|
|
211
|
+
.readdirSync(dirAbs, { withFileTypes: true })
|
|
212
|
+
.filter((e) => e.isFile() && e.name.endsWith(".kt") && matches(e.name) && !canonicalBasenames.has(e.name))
|
|
213
|
+
.map((e) => path.relative(ROOT, path.join(dirAbs, e.name)));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function findExemplarExtras(sourceF, sourceLowerF, sourceEntity) {
|
|
217
|
+
const namedForFeature = (n) => n.startsWith(sourceF);
|
|
218
|
+
const entityWholeWord = new RegExp(`\\b${escapeRegExp(sourceEntity)}\\b`);
|
|
219
|
+
return [
|
|
220
|
+
...scanDirForExtras("commonMain", `presentation/${sourceLowerF}`, new Set([`${sourceF}Screen.kt`, `${sourceF}ViewModel.kt`]), namedForFeature),
|
|
221
|
+
...scanDirForExtras("commonTest", `presentation/${sourceLowerF}`, new Set([`${sourceF}ViewModelTest.kt`]), namedForFeature),
|
|
222
|
+
...scanDirForExtras("desktopTest", `presentation/${sourceLowerF}`, new Set([`${sourceF}ScreenTest.kt`, `${sourceF}GoldenTreeTest.kt`]), namedForFeature),
|
|
223
|
+
...scanDirForExtras("commonMain", "domain/model", new Set([`${sourceEntity}.kt`]), (n) => entityWholeWord.test(n)),
|
|
224
|
+
...scanDirForExtras("commonMain", "domain/repository", new Set([`${sourceEntity}Repository.kt`]), (n) => entityWholeWord.test(n)),
|
|
225
|
+
...scanDirForExtras("commonMain", "domain/usecase", new Set([`Get${sourceEntity}sUseCase.kt`]), (n) => entityWholeWord.test(n)),
|
|
226
|
+
...scanDirForExtras("commonMain", "data/remote", new Set([`${sourceEntity}RepositoryImpl.kt`]), (n) => entityWholeWord.test(n)),
|
|
227
|
+
...scanDirForExtras("commonTest", "testing/fakes", new Set([`Fake${sourceEntity}Repository.kt`]), (n) => entityWholeWord.test(n)),
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const exemplarExtras = findExemplarExtras(SOURCE_F, SOURCE_f, SOURCE_E);
|
|
232
|
+
if (exemplarExtras.length > 0) {
|
|
233
|
+
console.log(
|
|
234
|
+
`WARNING: the "${SOURCE_f}" exemplar has files beyond the canonical 11-file shape — only the canonical set is cloned, these are SKIPPED:\n` +
|
|
235
|
+
exemplarExtras.map((p) => ` - ${p}`).join("\n"),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
152
239
|
// ── The rename map (§3) ──────────────────────────────────────────────────────
|
|
153
240
|
// Whole-word (\b-delimited), applied LONGEST KEY FIRST so compound entries
|
|
154
|
-
// (
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
241
|
+
// (<Entity>RepositoryImpl) resolve before their substrings (<Entity>Repository,
|
|
242
|
+
// <Entity>). Generated from the CLONE-SOURCE's own names (SOURCE_F/SOURCE_f/
|
|
243
|
+
// SOURCE_E/SOURCE_F_UPPER) rather than hardcoded `Home`/`home`/`Item`/`HOME` — when
|
|
244
|
+
// the source is the shipped `home` exemplar these resolve to exactly those
|
|
245
|
+
// literals, so stamping from `home` is byte-identical to before configurability
|
|
246
|
+
// existed (pinned by test/genesis-flow.test.mjs's parity pin). Anything not in this
|
|
247
|
+
// list is left untouched by design (see design doc §3 "LEAVE GENERIC" —
|
|
248
|
+
// awaitItem, items, item, goldenItems, itemId, onItemClick, id, title, subtitle,
|
|
249
|
+
// and every androidx./kotlinx./org.koin./kotlin. token).
|
|
158
250
|
|
|
159
251
|
const RENAME_MAP = [
|
|
160
|
-
[
|
|
161
|
-
[
|
|
162
|
-
[
|
|
163
|
-
[
|
|
164
|
-
[
|
|
165
|
-
[
|
|
166
|
-
[
|
|
167
|
-
[
|
|
168
|
-
[
|
|
169
|
-
[
|
|
170
|
-
[
|
|
171
|
-
[
|
|
172
|
-
[
|
|
173
|
-
[
|
|
174
|
-
[
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
252
|
+
[`${SOURCE_F}ScreenTest`, `${F}ScreenTest`],
|
|
253
|
+
[`${SOURCE_F}ViewModelTest`, `${F}ViewModelTest`],
|
|
254
|
+
[`${SOURCE_F}GoldenTreeTest`, `${F}GoldenTreeTest`],
|
|
255
|
+
[`${SOURCE_F}Screen`, `${F}Screen`],
|
|
256
|
+
[`${SOURCE_F}ViewModel`, `${F}ViewModel`],
|
|
257
|
+
[`${SOURCE_f}_title`, `${f}_title`],
|
|
258
|
+
[`${SOURCE_f}_error`, `${f}_error`],
|
|
259
|
+
[`${SOURCE_f}_empty`, `${f}_empty`],
|
|
260
|
+
[`${SOURCE_f}_loading`, `${f}_loading`],
|
|
261
|
+
[`${SOURCE_f}_retry`, `${f}_retry`],
|
|
262
|
+
[`${SOURCE_f}_item_`, `${f}_item_`],
|
|
263
|
+
[`Fake${SOURCE_E}Repository`, `Fake${E}Repository`],
|
|
264
|
+
[`${SOURCE_E}RepositoryImpl`, `${E}RepositoryImpl`],
|
|
265
|
+
[`${SOURCE_E}Repository`, `${E}Repository`],
|
|
266
|
+
[`Get${SOURCE_E}sUseCase`, `Get${E}sUseCase`],
|
|
267
|
+
[`get${SOURCE_E}sCallCount`, `get${E}sCallCount`],
|
|
268
|
+
[`get${SOURCE_E}s`, `get${E}s`],
|
|
269
|
+
[SOURCE_E, E],
|
|
270
|
+
// Spec + test SPEC-tag retargeting (§6): <SOURCE_F_UPPER>-0N -> <F_UPPER>-0N,
|
|
271
|
+
// then the bare <SOURCE_F_UPPER> -> <F_UPPER> (must run AFTER the -0 form or
|
|
272
|
+
// "<SOURCE_F_UPPER>-0" would be partially consumed oddly — longest-key-first
|
|
273
|
+
// already orders this).
|
|
274
|
+
[`${SOURCE_F_UPPER}-0`, `${F_UPPER}-0`],
|
|
275
|
+
[SOURCE_F_UPPER, F_UPPER],
|
|
180
276
|
// Package segment / path / golden filename / display text. Order matters:
|
|
181
|
-
// must run after
|
|
182
|
-
// resolved; the bare
|
|
183
|
-
// segment, golden filename stem, and prose by this point.
|
|
184
|
-
[
|
|
185
|
-
[
|
|
277
|
+
// must run after <SOURCE_F>Xxx / <SOURCE_f>_xxx above so those compounds are
|
|
278
|
+
// already resolved; the bare source-feature word only matches the standalone
|
|
279
|
+
// package segment, golden filename stem, and prose by this point.
|
|
280
|
+
[SOURCE_f, f],
|
|
281
|
+
[SOURCE_F, F],
|
|
186
282
|
].sort((a, b) => b[0].length - a[0].length);
|
|
187
283
|
|
|
188
|
-
function escapeRegExp(s) {
|
|
189
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
190
|
-
}
|
|
191
|
-
|
|
192
284
|
const COMPILED_RENAMES = RENAME_MAP.map(([from, to]) => [new RegExp(`\\b${escapeRegExp(from)}\\b`, "g"), to]);
|
|
193
285
|
|
|
194
286
|
function applyRename(text) {
|
|
@@ -197,27 +289,30 @@ function applyRename(text) {
|
|
|
197
289
|
return out;
|
|
198
290
|
}
|
|
199
291
|
|
|
200
|
-
// ── The file set (§4) ───────────────────────────────────────────────────────
|
|
201
|
-
// Source paths are relative to composeApp/src/<sourceSet>/kotlin/<PACKAGE_DIR>.
|
|
202
|
-
|
|
203
|
-
const SRC = (sourceSet) => path.join(ROOT, "composeApp/src", sourceSet, "kotlin", PACKAGE_DIR);
|
|
204
|
-
|
|
205
292
|
// Every entry is tagged with the presets it belongs to. `feature` gets all 11
|
|
206
293
|
// (the union); `repository` gets just the 5 data/domain files; `screen` gets
|
|
207
294
|
// just the 6 presentation+tests+spec files. Filtered by the active preset
|
|
208
295
|
// right after definition — nothing below this point sees the untagged list.
|
|
296
|
+
//
|
|
297
|
+
// `from:` sides are built from the CLONE-SOURCE names (SOURCE_F/SOURCE_f/
|
|
298
|
+
// SOURCE_E) instead of hardcoded `home`/`Item` literals — this is exactly the
|
|
299
|
+
// canonical-11-file SHAPE `exemplarKotlinFileSet` in qa/lib/approvals.mjs
|
|
300
|
+
// encodes (that function is the registry's copy; this one is the stamper's,
|
|
301
|
+
// kept independently so the stamper works even without qa/lib/approvals.mjs —
|
|
302
|
+
// see the resolveSourceNames() fallback above). test/approvals-exemplar-list.test.mjs
|
|
303
|
+
// pins that the two never disagree for a real project.
|
|
209
304
|
const ALL_FILES = [
|
|
210
|
-
{ from: path.join(SRC("commonMain"),
|
|
211
|
-
{ from: path.join(SRC("commonMain"),
|
|
212
|
-
{ from: path.join(SRC("commonMain"),
|
|
213
|
-
{ from: path.join(SRC("commonMain"),
|
|
214
|
-
{ from: path.join(SRC("commonTest"),
|
|
215
|
-
{ from: path.join(SRC("commonMain"),
|
|
216
|
-
{ from: path.join(SRC("commonMain"),
|
|
217
|
-
{ from: path.join(SRC("commonTest"),
|
|
218
|
-
{ from: path.join(SRC("desktopTest"),
|
|
219
|
-
{ from: path.join(SRC("desktopTest"),
|
|
220
|
-
{ from: path.join(ROOT,
|
|
305
|
+
{ from: path.join(SRC("commonMain"), `domain/model/${SOURCE_E}.kt`), to: path.join(SRC("commonMain"), `domain/model/${E}.kt`), presets: ["feature", "repository"] },
|
|
306
|
+
{ from: path.join(SRC("commonMain"), `domain/repository/${SOURCE_E}Repository.kt`), to: path.join(SRC("commonMain"), `domain/repository/${E}Repository.kt`), presets: ["feature", "repository"] },
|
|
307
|
+
{ from: path.join(SRC("commonMain"), `domain/usecase/Get${SOURCE_E}sUseCase.kt`), to: path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`), presets: ["feature", "repository"] },
|
|
308
|
+
{ from: path.join(SRC("commonMain"), `data/remote/${SOURCE_E}RepositoryImpl.kt`), to: path.join(SRC("commonMain"), `data/remote/${E}RepositoryImpl.kt`), presets: ["feature", "repository"] },
|
|
309
|
+
{ from: path.join(SRC("commonTest"), `testing/fakes/Fake${SOURCE_E}Repository.kt`), to: path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`), presets: ["feature", "repository"] },
|
|
310
|
+
{ from: path.join(SRC("commonMain"), `presentation/${SOURCE_f}/${SOURCE_F}Screen.kt`), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"], wrapInBaseScreen: true },
|
|
311
|
+
{ from: path.join(SRC("commonMain"), `presentation/${SOURCE_f}/${SOURCE_F}ViewModel.kt`), to: path.join(SRC("commonMain"), `presentation/${f}/${F}ViewModel.kt`), presets: ["feature", "screen"] },
|
|
312
|
+
{ from: path.join(SRC("commonTest"), `presentation/${SOURCE_f}/${SOURCE_F}ViewModelTest.kt`), to: path.join(SRC("commonTest"), `presentation/${f}/${F}ViewModelTest.kt`), presets: ["feature", "screen"] },
|
|
313
|
+
{ from: path.join(SRC("desktopTest"), `presentation/${SOURCE_f}/${SOURCE_F}ScreenTest.kt`), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}ScreenTest.kt`), presets: ["feature", "screen"] },
|
|
314
|
+
{ from: path.join(SRC("desktopTest"), `presentation/${SOURCE_f}/${SOURCE_F}GoldenTreeTest.kt`), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}GoldenTreeTest.kt`), presets: ["feature", "screen"] },
|
|
315
|
+
{ from: path.join(ROOT, `specs/${SOURCE_f}.spec.md`), to: path.join(ROOT, `specs/${f}.spec.md`), isDefaultSpec: true, presets: ["feature", "screen"] },
|
|
221
316
|
];
|
|
222
317
|
|
|
223
318
|
const FILES = ALL_FILES.filter((file) => file.presets.includes(preset));
|
|
@@ -229,8 +324,8 @@ for (const file of FILES) {
|
|
|
229
324
|
if (!fs.existsSync(file.from)) {
|
|
230
325
|
die(
|
|
231
326
|
`exemplar source file missing: ${path.relative(ROOT, file.from)}\n` +
|
|
232
|
-
|
|
233
|
-
|
|
327
|
+
`This script must run in an unmodified (or already-featured) create-cmp scaffold ` +
|
|
328
|
+
`where the "${SOURCE_f}" exemplar (qa/approvals.json's "exemplarFeature") still exists in its canonical 11-file shape.`,
|
|
234
329
|
);
|
|
235
330
|
}
|
|
236
331
|
}
|
|
@@ -273,7 +368,7 @@ if (FILES.some((file) => file.isDefaultSpec) && fs.existsSync(path.join(ROOT, `s
|
|
|
273
368
|
function defaultSpec() {
|
|
274
369
|
return `# Spec: ${f}
|
|
275
370
|
|
|
276
|
-
> Generated by \`scaffold-feature.mjs\` from the \`
|
|
371
|
+
> Generated by \`scaffold-feature.mjs\` from the \`${SOURCE_f}\` exemplar shape. Refine the clause
|
|
277
372
|
> prose below for ${F}'s real behavior (ids stay fixed) before running the verify lane.
|
|
278
373
|
|
|
279
374
|
- **${F_UPPER}-01** — Given the ${F} screen opens, When ${f} are being loaded, Then a loading
|
|
@@ -281,14 +376,17 @@ function defaultSpec() {
|
|
|
281
376
|
- **${F_UPPER}-02** — Given the repository returns ${f}, When loading completes, Then the ${f}
|
|
282
377
|
are listed with their title and subtitle, and no error is shown.
|
|
283
378
|
- **${F_UPPER}-03** — Given the repository fails, When loading completes, Then a human-readable
|
|
284
|
-
error message is shown (\`${f}_error\`) and no ${f} are visible
|
|
379
|
+
error message is shown (\`${f}_error\`) and no ${f} are visible — the copy is mapped in
|
|
380
|
+
presentation from the failure's \`DomainError\` kind, never a raw exception message.
|
|
285
381
|
- **${F_UPPER}-04** — Given a load has failed, When the data source recovers and the user
|
|
286
|
-
triggers a reload, Then the error clears and the ${f} render.
|
|
382
|
+
triggers a reload (\`${f}_retry\`), Then the error clears and the ${f} render.
|
|
287
383
|
- **${F_UPPER}-05** — Given ${f} are listed, When the user taps an item, Then the app navigates
|
|
288
384
|
to that item's detail.
|
|
289
385
|
- **${F_UPPER}-06** — Given the ${F} screen renders, When its structure is inspected, Then the
|
|
290
386
|
screen matches its committed golden tree (\`qa/golden/${f}.json\`) — structural regressions
|
|
291
387
|
are intentional, declared changes only.
|
|
388
|
+
- **${F_UPPER}-07** — Given the repository succeeds with zero ${f}, When loading completes, Then
|
|
389
|
+
the empty state is shown (\`${f}_empty\`) and neither ${f} nor an error are visible.
|
|
292
390
|
`;
|
|
293
391
|
}
|
|
294
392
|
|
|
@@ -309,7 +407,7 @@ function wrapScreenInBaseScreen(content, relPathForErrors) {
|
|
|
309
407
|
const themeImportIdx = lines.findIndex((l) => /^import .+\.presentation\.theme\./.test(l));
|
|
310
408
|
if (themeImportIdx === -1) {
|
|
311
409
|
die(
|
|
312
|
-
`no presentation.theme import found in ${relPathForErrors} — the
|
|
410
|
+
`no presentation.theme import found in ${relPathForErrors} — the ${SOURCE_F}Screen exemplar ` +
|
|
313
411
|
"drifted from the shape this stamper wraps; cannot place the BaseScreen import.",
|
|
314
412
|
);
|
|
315
413
|
}
|
|
@@ -319,11 +417,14 @@ function wrapScreenInBaseScreen(content, relPathForErrors) {
|
|
|
319
417
|
);
|
|
320
418
|
lines.splice(themeImportIdx, 0, importLine);
|
|
321
419
|
|
|
322
|
-
// 2. Root container start: the exemplar's body root is a top-level `
|
|
323
|
-
|
|
420
|
+
// 2. Root container start: the exemplar's body root is a top-level ` ScreenColumn(`
|
|
421
|
+
// call (the component-vocabulary rewrite — see docs/proposals/component-system-deep-dive.md
|
|
422
|
+
// §5). The tag literal inside the call (`screenTag = "home"`) is renamed separately by
|
|
423
|
+
// RENAME_MAP, so match on the call shape only, not the full line.
|
|
424
|
+
const rootIdx = lines.findIndex((l) => /^ {4}ScreenColumn\(/.test(l));
|
|
324
425
|
if (rootIdx === -1) {
|
|
325
426
|
die(
|
|
326
|
-
`root "
|
|
427
|
+
`root " ScreenColumn(" not found in ${relPathForErrors} — the ${SOURCE_F}Screen exemplar drifted ` +
|
|
327
428
|
"from the shape this stamper wraps in BaseScreen.",
|
|
328
429
|
);
|
|
329
430
|
}
|
|
@@ -339,7 +440,7 @@ function wrapScreenInBaseScreen(content, relPathForErrors) {
|
|
|
339
440
|
if (funCloseIdx === -1 || lines[funCloseIdx - 1] !== " }") {
|
|
340
441
|
die(
|
|
341
442
|
`could not locate the root container's closing brace in ${relPathForErrors} — the ` +
|
|
342
|
-
|
|
443
|
+
`${SOURCE_F}Screen exemplar drifted from the shape this stamper wraps in BaseScreen.`,
|
|
343
444
|
);
|
|
344
445
|
}
|
|
345
446
|
const rootCloseIdx = funCloseIdx - 1;
|
|
@@ -547,7 +648,7 @@ if (dryRun) {
|
|
|
547
648
|
);
|
|
548
649
|
}
|
|
549
650
|
if (writesSpec) {
|
|
550
|
-
console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..
|
|
651
|
+
console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..07.`);
|
|
551
652
|
} else {
|
|
552
653
|
console.log("\nNo spec file written by this preset (zero SPEC clauses/tags added).");
|
|
553
654
|
}
|
|
@@ -580,7 +681,23 @@ for (const result of fileResults) {
|
|
|
580
681
|
|
|
581
682
|
console.log(`✓ Scaffolded ${planLabel} [preset: ${preset}] — ${filesWritten} files written, ${injectionsApplied} anchor injections applied.`);
|
|
582
683
|
if (writesSpec) {
|
|
583
|
-
console.log(` specs/${f}.spec.md written with default clauses ${F_UPPER}-01..
|
|
684
|
+
console.log(` specs/${f}.spec.md written with default clauses ${F_UPPER}-01..07 — refine the prose next.`);
|
|
685
|
+
|
|
686
|
+
// Approvals seeding (VERIFICATION-LAYER-DESIGN.md §2): the new feature's spec
|
|
687
|
+
// is a governed artifact (`feature-spec:<name>`) — seed it unreviewed so the
|
|
688
|
+
// verify lane's `approvals` gate SKIP-warns until a human signs off. v1 does
|
|
689
|
+
// NOT refuse to stamp over this (warn-then-enforce-at-verify is the honest
|
|
690
|
+
// default) — reuses `approvalsLib` (already resolved above, tolerantly, for
|
|
691
|
+
// clone-source resolution) so a missing or out-of-date qa/lib/approvals.mjs
|
|
692
|
+
// (an older, pre-approvals scaffold) never blocks the stamp itself.
|
|
693
|
+
try {
|
|
694
|
+
if (!approvalsLib || typeof approvalsLib.seedUnreviewed !== "function") throw new Error("no seedUnreviewed export");
|
|
695
|
+
const artifactId = `feature-spec:${f}`;
|
|
696
|
+
approvalsLib.seedUnreviewed(ROOT, artifactId);
|
|
697
|
+
console.log(` Approval: specs/${f}.spec.md is unreviewed — run \`node qa/approve.mjs ${artifactId}\` once you've reviewed it.`);
|
|
698
|
+
} catch {
|
|
699
|
+
console.log(" (approvals seeding skipped — qa/lib/approvals.mjs not found in this scaffold)");
|
|
700
|
+
}
|
|
584
701
|
} else {
|
|
585
702
|
console.log(" No spec file written by this preset (zero SPEC clauses/tags added).");
|
|
586
703
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Enable this project's shipped git hooks — one-time, idempotent, no dependency
|
|
3
|
+
// and no hook-manager. Run it once after `git init`: it points git at the
|
|
4
|
+
// tracked .githooks/ directory (core.hooksPath) and makes the hooks executable.
|
|
5
|
+
//
|
|
6
|
+
// The pre-push hook it activates gates a push on the evidence receipt attesting
|
|
7
|
+
// HEAD — the same cheap check CI runs, before your code leaves the machine.
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
function main() {
|
|
14
|
+
try {
|
|
15
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { stdio: "ignore" });
|
|
16
|
+
} catch {
|
|
17
|
+
console.error("Not a git repository yet. Run `git init` first, then `node qa/setup-hooks.mjs`.");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
// core.hooksPath (git 2.9+) makes the tracked .githooks/ the hooks directory,
|
|
21
|
+
// so the hooks live in the repo and survive as one source of truth.
|
|
22
|
+
execFileSync("git", ["config", "core.hooksPath", ".githooks"], { stdio: "ignore" });
|
|
23
|
+
try {
|
|
24
|
+
fs.chmodSync(path.join(".githooks", "pre-push"), 0o755);
|
|
25
|
+
} catch {
|
|
26
|
+
// best-effort — on a filesystem without exec bits the hook still runs via core.hooksPath
|
|
27
|
+
}
|
|
28
|
+
console.log("✓ git hooks enabled (core.hooksPath = .githooks).");
|
|
29
|
+
console.log(" pre-push now blocks a push whose committed receipt doesn't attest HEAD.");
|
|
30
|
+
console.log(" Bypass in a pinch with `git push --no-verify`; CI still enforces the check.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
main();
|
package/template/qa/verify.mjs
CHANGED
|
@@ -26,6 +26,9 @@ import { fileURLToPath } from "node:url";
|
|
|
26
26
|
|
|
27
27
|
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
28
28
|
import { compareTokenDrift } from "./lib/token-drift.mjs";
|
|
29
|
+
import { evaluateApprovalsGate } from "./lib/approvals.mjs";
|
|
30
|
+
import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
|
|
31
|
+
import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
|
|
29
32
|
|
|
30
33
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
31
34
|
const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
|
|
@@ -176,6 +179,77 @@ function stepSpecCoverage() {
|
|
|
176
179
|
};
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
// Human-approval gate (VERIFICATION-LAYER-DESIGN.md §2) — pure Node, no Gradle,
|
|
183
|
+
// same grouping as specCoverage. The decision itself lives in
|
|
184
|
+
// qa/lib/approvals.mjs (evaluateApprovalsGate); this step only adds the
|
|
185
|
+
// name/duration bookkeeping every step in this file carries.
|
|
186
|
+
function stepApprovals() {
|
|
187
|
+
const started = Date.now();
|
|
188
|
+
const { verdict, reason, statuses } = evaluateApprovalsGate(ROOT);
|
|
189
|
+
return {
|
|
190
|
+
name: "approvals",
|
|
191
|
+
verdict,
|
|
192
|
+
reason,
|
|
193
|
+
durationMs: Date.now() - started,
|
|
194
|
+
details: { artifacts: statuses.map((s) => ({ id: s.id, status: s.status, hash: s.hash })) },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Component ↔ story parity gate (STUDIO-REDESIGN.md §3.3) — pure Node, no
|
|
199
|
+
// Gradle, same grouping as specCoverage/approvals. The decision itself lives
|
|
200
|
+
// in qa/lib/component-stories.mjs (evaluateComponentStoryParity); this step
|
|
201
|
+
// only adds the name/duration bookkeeping every step in this file carries.
|
|
202
|
+
function stepComponentStories() {
|
|
203
|
+
const started = Date.now();
|
|
204
|
+
const { verdict, reason, details } = evaluateComponentStoryParity(ROOT);
|
|
205
|
+
return { name: "componentStories", verdict, reason, durationMs: Date.now() - started, details };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Architecture-doc freshness gate (Wave B, docs/proposals/architecture-document-
|
|
209
|
+
// standard.md §6) — pure Node, no Gradle, same grouping as specCoverage/
|
|
210
|
+
// approvals. The decision itself lives in qa/lib/arch-doc.mjs
|
|
211
|
+
// (regenerateArchDoc); this step only adds the name/duration bookkeeping every
|
|
212
|
+
// step in this file carries, plus wording the FAIL reason for an AI
|
|
213
|
+
// collaborator (name the stale/missing section, name the fix command).
|
|
214
|
+
function stepArchDoc() {
|
|
215
|
+
const started = Date.now();
|
|
216
|
+
const elapsed = () => Date.now() - started;
|
|
217
|
+
|
|
218
|
+
const result = regenerateArchDoc(ROOT);
|
|
219
|
+
if (!result.ok) {
|
|
220
|
+
return { name: "archDoc", verdict: "SKIP", reason: `${result.reason} — nothing to check`, durationMs: elapsed() };
|
|
221
|
+
}
|
|
222
|
+
if (result.unknownSections.length > 0) {
|
|
223
|
+
return {
|
|
224
|
+
name: "archDoc",
|
|
225
|
+
verdict: "FAIL",
|
|
226
|
+
reason: `${ARCH_DOC_REL_PATH} has cmp:generated marker(s) with no registered generator: ${result.unknownSections.join(", ")} — add a generator in qa/lib/arch-doc.mjs or remove the marker.`,
|
|
227
|
+
durationMs: elapsed(),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const stale = result.changed || result.missingSections.length > 0;
|
|
232
|
+
if (!stale) {
|
|
233
|
+
return { name: "archDoc", verdict: "PASS", durationMs: elapsed(), details: { sectionsChecked: SECTION_IDS.length } };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const lines = [`${ARCH_DOC_REL_PATH} is stale — a generated section no longer matches the tree:`];
|
|
237
|
+
for (const id of result.changedSections) {
|
|
238
|
+
lines.push(` [${id}] regenerating would change this section.`);
|
|
239
|
+
}
|
|
240
|
+
for (const id of result.missingSections) {
|
|
241
|
+
lines.push(` [${id}] marker missing from the doc entirely — never generated.`);
|
|
242
|
+
}
|
|
243
|
+
lines.push("Run: node qa/arch-doc.mjs");
|
|
244
|
+
return {
|
|
245
|
+
name: "archDoc",
|
|
246
|
+
verdict: "FAIL",
|
|
247
|
+
reason: lines.join("\n"),
|
|
248
|
+
durationMs: elapsed(),
|
|
249
|
+
details: { changedSections: result.changedSections, missingSections: result.missingSections },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
179
253
|
function stepBuild() {
|
|
180
254
|
const res = sh(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
|
|
181
255
|
return {
|
|
@@ -372,15 +446,44 @@ function stepE2eSmoke() {
|
|
|
372
446
|
// that steal focus over the app — a Maestro assert would then see only the dialog;
|
|
373
447
|
// - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
|
|
374
448
|
// up on a slow emulator (the built-in default gives up too early under load).
|
|
375
|
-
// Both are benign, reversible, and only touch the device while the lane is driving it
|
|
449
|
+
// Both are benign, reversible, and only touch the device while the lane is driving it —
|
|
450
|
+
// hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
|
|
451
|
+
// to its default) in the finally below, on every exit path.
|
|
452
|
+
// hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
|
|
453
|
+
// run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
|
|
454
|
+
// them. The eyes must report what automation stability had to hide.
|
|
455
|
+
const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
|
|
376
456
|
sh("adb shell settings put global hide_error_dialogs 1");
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
457
|
+
sh("adb logcat -c"); // clear so the post-run dump only reflects this run
|
|
458
|
+
try {
|
|
459
|
+
const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
|
|
460
|
+
if (!res.ok) {
|
|
461
|
+
return {
|
|
462
|
+
name: "e2eSmoke",
|
|
463
|
+
verdict: "FAIL",
|
|
464
|
+
reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
|
|
465
|
+
durationMs: install.durationMs + res.durationMs,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const anrDump = sh("adb logcat -d -b system,crash,main");
|
|
469
|
+
const anrRe = /ANR in |FATAL EXCEPTION/i;
|
|
470
|
+
if (anrDump.ok && anrRe.test(anrDump.out)) {
|
|
471
|
+
const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
|
|
472
|
+
return {
|
|
473
|
+
name: "e2eSmoke",
|
|
474
|
+
verdict: "FAIL",
|
|
475
|
+
reason: `Maestro smoke passed, but the device log shows an ANR/crash during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${anrLines}`,
|
|
476
|
+
durationMs: install.durationMs + res.durationMs,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
return { name: "e2eSmoke", verdict: "PASS", durationMs: install.durationMs + res.durationMs };
|
|
480
|
+
} finally {
|
|
481
|
+
if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
|
|
482
|
+
sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
|
|
483
|
+
} else {
|
|
484
|
+
sh("adb shell settings delete global hide_error_dialogs");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
384
487
|
}
|
|
385
488
|
|
|
386
489
|
// ── Lane ───────────────────────────────────────────────────────────────────
|
|
@@ -388,9 +491,12 @@ function stepE2eSmoke() {
|
|
|
388
491
|
const stepsForProfile = {
|
|
389
492
|
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
390
493
|
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
391
|
-
scaffold: [stepSpecCoverage, stepBuild, stepUnitTests],
|
|
494
|
+
scaffold: [stepSpecCoverage, stepApprovals, stepComponentStories, stepArchDoc, stepBuild, stepUnitTests],
|
|
392
495
|
local: [
|
|
393
496
|
stepSpecCoverage,
|
|
497
|
+
stepApprovals,
|
|
498
|
+
stepComponentStories,
|
|
499
|
+
stepArchDoc,
|
|
394
500
|
stepBuild,
|
|
395
501
|
stepUnitTests,
|
|
396
502
|
stepConformance,
|
|
@@ -465,6 +571,9 @@ const receipt = {
|
|
|
465
571
|
|
|
466
572
|
fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
|
|
467
573
|
fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
574
|
+
// latest.json is the single receipt-of-record. Commit it with your change: the
|
|
575
|
+
// studio console's Evidence audit trail reconstructs the full history from the
|
|
576
|
+
// git log of this file — every commit is one verified, attributed state.
|
|
468
577
|
|
|
469
578
|
if (asJson) console.log(JSON.stringify(receipt, null, 2));
|
|
470
579
|
else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} — receipt written to qa/evidence/latest.json (commit it with your change)`);
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
- **ARCH-01** — Given any file in `presentation`, When its imports **and fully-qualified
|
|
9
9
|
inline references** are inspected, Then none resolve into the `data` layer (presentation
|
|
10
|
-
depends on domain only
|
|
11
|
-
violation).
|
|
10
|
+
depends on domain only).
|
|
12
11
|
- **ARCH-02** — Given any file in `domain`, When its imports **and fully-qualified inline
|
|
13
12
|
references** are inspected, Then none resolve into `presentation`, `data`, or `di`, and
|
|
14
13
|
none reference Compose, Koin, or platform types (domain is pure Kotlin).
|
|
@@ -16,11 +15,37 @@
|
|
|
16
15
|
corresponding `*ViewModelTest` exists (no untested presentation state).
|
|
17
16
|
- **ARCH-04** — Given any file in a `presentation` feature package that contains a
|
|
18
17
|
`@Composable` function, When its source is inspected, Then it declares at least one
|
|
19
|
-
`testTag`
|
|
20
|
-
|
|
18
|
+
literal `testTag` **or** passes a `screenTag =` argument to a component imported from
|
|
19
|
+
`presentation.components` (scoped by content, not `*Screen.kt` filename — split
|
|
20
|
+
`Content.kt` UI files are covered, ViewModel-only files are exempt). Component-derived
|
|
21
|
+
tags count as tag provenance: a screen built entirely from `ScreenColumn`/`AppHeader`/
|
|
22
|
+
`ContentStateContainer`/etc. is automation-reachable through the tags those components
|
|
23
|
+
emit (`<screenTag>_screen`, `<screenTag>_title`, `<screenTag>_loading`, …) even without a
|
|
24
|
+
literal `testTag` of its own.
|
|
21
25
|
- **ARCH-05** — Given any file outside `presentation/theme`, When its source is inspected,
|
|
22
26
|
Then it constructs no literal `Color(0x…)` values (design colors come from the token
|
|
23
27
|
catalog).
|
|
28
|
+
- **ARCH-06** — Given any repository interface in `domain/repository`, When its one-shot
|
|
29
|
+
operations (`suspend fun`s) are inspected, Then each declares an `AppResult<…>` return
|
|
30
|
+
type — raw exceptions never cross the data → domain boundary; failures travel as typed
|
|
31
|
+
`DomainError` values assigned inside the data implementation.
|
|
32
|
+
- **ARCH-07** — Given any ViewModel in `presentation`, When its source is inspected, Then it
|
|
33
|
+
contains no `try`/`catch`/`runCatching` — ViewModels fold over `AppResult` and map
|
|
34
|
+
`DomainError` kinds to user-facing copy; a raw exception message is never shown to a user.
|
|
35
|
+
- **ARCH-08** — Given any file in the `data` layer, When its source is inspected, Then the
|
|
36
|
+
only exception-catching mechanism is the shared `suspendRunCatching` helper
|
|
37
|
+
(`data/AppResultCatching.kt`), and the helper always rethrows `CancellationException` —
|
|
38
|
+
cancellation is never swallowed into a failure state.
|
|
39
|
+
- **ARCH-09** — Given any file in `data`, When its imports and fully-qualified inline
|
|
40
|
+
references are inspected, Then none resolve into `presentation` or `di` (data serves
|
|
41
|
+
domain contracts; it never reaches upward).
|
|
42
|
+
- **ARCH-10** — Given any file in `core`, When its imports and fully-qualified inline
|
|
43
|
+
references are inspected, Then none resolve into `presentation`, `data`, or `di` (core
|
|
44
|
+
is leaf utility code; `domain` at most).
|
|
45
|
+
- **ARCH-11** — Given any file in a presentation feature package (`components/` excluded),
|
|
46
|
+
When its source is inspected, Then it references neither `CircularProgressIndicator` nor
|
|
47
|
+
`LinearProgressIndicator` directly — loading is presented through the components
|
|
48
|
+
registry (`ContentStateContainer`/`ContentStateDefaults`), never hand-rolled per screen.
|
|
24
49
|
|
|
25
50
|
## App shell
|
|
26
51
|
|
|
@@ -33,6 +58,18 @@
|
|
|
33
58
|
- **SHELL-04** — Given the app renders any screen, When interactive elements are present,
|
|
34
59
|
Then each is perceivable by automation: it exposes a testTag, text, or content description.
|
|
35
60
|
- **SHELL-05** — Given any screen registered directly on the NavHost (not a shell tab), When
|
|
36
|
-
it renders, Then its content is composed inside `BaseScreen
|
|
37
|
-
|
|
38
|
-
|
|
61
|
+
it renders, Then its content is composed inside `BaseScreen`.
|
|
62
|
+
|
|
63
|
+
## Component vocabulary
|
|
64
|
+
|
|
65
|
+
> Component *contracts* — the shared state/a11y behavior every screen inherits from
|
|
66
|
+
> `presentation/components/*.kt` (the governed `components` artifact). Feature clauses
|
|
67
|
+
> (e.g. `HOME-NN`) keep citing feature behavior; these clauses are covered once, here.
|
|
68
|
+
|
|
69
|
+
- **COMP-01** — Given any screen with a data-backed state, When it renders, Then
|
|
70
|
+
loading/error/empty are presented by `ContentStateContainer` with tags
|
|
71
|
+
`<screen>_loading` / `<screen>_error` / `<screen>_empty`.
|
|
72
|
+
- **COMP-02** — Given a recoverable load failure and a retry handler, When the error state
|
|
73
|
+
renders, Then a `<screen>_retry` control of at least 48 dp is present.
|
|
74
|
+
- **COMP-03** — Given any interactive registry component, When it renders, Then its
|
|
75
|
+
pointer target is at least 48×48 dp.
|