create-cmp-cli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +83 -9
  2. package/llms.txt +5 -1
  3. package/package.json +1 -1
  4. package/src/lib/adr-seed.mjs +178 -0
  5. package/src/lib/tabs.mjs +97 -4
  6. package/src/scaffold.mjs +52 -1
  7. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  8. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  9. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  10. package/template/.githooks/pre-push +24 -0
  11. package/template/CLAUDE.md +213 -47
  12. package/template/README.md +32 -27
  13. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  14. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  15. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  55. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  56. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  57. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  58. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
  59. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  60. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  61. package/template/docs/ARCHITECTURE.md +372 -34
  62. package/template/docs/TESTING.md +13 -5
  63. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  64. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  65. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  66. package/template/qa/approvals.json +42 -0
  67. package/template/qa/approve.mjs +139 -0
  68. package/template/qa/arch-doc.mjs +69 -0
  69. package/template/qa/comment.mjs +76 -0
  70. package/template/qa/comments.json +4 -0
  71. package/template/qa/e2e/smoke.yaml +6 -0
  72. package/template/qa/golden/home.json +3 -3
  73. package/template/qa/lib/a11y.mjs +17 -8
  74. package/template/qa/lib/approvals.mjs +822 -0
  75. package/template/qa/lib/arch-doc.mjs +451 -0
  76. package/template/qa/lib/comments.mjs +252 -0
  77. package/template/qa/lib/component-stories.mjs +183 -0
  78. package/template/qa/lib/inputs-hash.mjs +5 -1
  79. package/template/qa/scaffold-feature.mjs +184 -67
  80. package/template/qa/setup-hooks.mjs +33 -0
  81. package/template/qa/verify.mjs +181 -15
  82. package/template/qa/walkthrough.mjs +499 -0
  83. package/template/specs/app-base.spec.md +49 -7
  84. package/template/specs/home.spec.md +7 -4
  85. 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..06.
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 Item/ItemRepository/GetItemsUseCase).
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 __PACKAGE__ is already resolved in the
105
- // target project. Parse it from composeApp/build.gradle.kts (namespace) or,
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] !== "__PACKAGE__") return 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
- // (ItemRepositoryImpl) resolve before their substrings (ItemRepository, Item).
155
- // Anything not in this list is left untouched by design (see design doc §3
156
- // "LEAVE GENERIC" awaitItem, items, item, goldenItems, itemId, onItemClick,
157
- // id, title, subtitle, and every androidx./kotlinx./org.koin./kotlin. token).
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
- ["HomeScreenTest", `${F}ScreenTest`],
161
- ["HomeViewModelTest", `${F}ViewModelTest`],
162
- ["HomeGoldenTreeTest", `${F}GoldenTreeTest`],
163
- ["HomeScreen", `${F}Screen`],
164
- ["HomeViewModel", `${F}ViewModel`],
165
- ["HomeUiState", `${F}UiState`],
166
- ["home_title", `${f}_title`],
167
- ["home_error", `${f}_error`],
168
- ["FakeItemRepository", `Fake${E}Repository`],
169
- ["ItemRepositoryImpl", `${E}RepositoryImpl`],
170
- ["ItemRepository", `${E}Repository`],
171
- ["GetItemsUseCase", `Get${E}sUseCase`],
172
- ["getItemsCallCount", `get${E}sCallCount`],
173
- ["getItems", `get${E}s`],
174
- ["Item", E],
175
- // Spec + test SPEC-tag retargeting (§6): HOME-0N -> <F_UPPER>-0N, then the
176
- // bare HOME -> <F_UPPER> (must run AFTER the -0 form or "HOME-0" would be
177
- // partially consumed oddly — longest-key-first already orders this).
178
- ["HOME-0", `${F_UPPER}-0`],
179
- ["HOME", F_UPPER],
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 HomeXxx / home_xxx above so those compounds are already
182
- // resolved; the bare `home` word only matches the standalone package
183
- // segment, golden filename stem, and prose by this point.
184
- ["home", f],
185
- ["Home", F],
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"), "domain/model/Item.kt"), to: path.join(SRC("commonMain"), `domain/model/${E}.kt`), presets: ["feature", "repository"] },
211
- { from: path.join(SRC("commonMain"), "domain/repository/ItemRepository.kt"), to: path.join(SRC("commonMain"), `domain/repository/${E}Repository.kt`), presets: ["feature", "repository"] },
212
- { from: path.join(SRC("commonMain"), "domain/usecase/GetItemsUseCase.kt"), to: path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`), presets: ["feature", "repository"] },
213
- { from: path.join(SRC("commonMain"), "data/remote/ItemRepositoryImpl.kt"), to: path.join(SRC("commonMain"), `data/remote/${E}RepositoryImpl.kt`), presets: ["feature", "repository"] },
214
- { from: path.join(SRC("commonTest"), "testing/fakes/FakeItemRepository.kt"), to: path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`), presets: ["feature", "repository"] },
215
- { from: path.join(SRC("commonMain"), "presentation/home/HomeScreen.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"], wrapInBaseScreen: true },
216
- { from: path.join(SRC("commonMain"), "presentation/home/HomeViewModel.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}ViewModel.kt`), presets: ["feature", "screen"] },
217
- { from: path.join(SRC("commonTest"), "presentation/home/HomeViewModelTest.kt"), to: path.join(SRC("commonTest"), `presentation/${f}/${F}ViewModelTest.kt`), presets: ["feature", "screen"] },
218
- { from: path.join(SRC("desktopTest"), "presentation/home/HomeScreenTest.kt"), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}ScreenTest.kt`), presets: ["feature", "screen"] },
219
- { from: path.join(SRC("desktopTest"), "presentation/home/HomeGoldenTreeTest.kt"), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}GoldenTreeTest.kt`), presets: ["feature", "screen"] },
220
- { from: path.join(ROOT, "specs/home.spec.md"), to: path.join(ROOT, `specs/${f}.spec.md`), isDefaultSpec: true, presets: ["feature", "screen"] },
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
- "This script must run in an unmodified (or already-featured) create-cmp scaffold " +
233
- "where the `home` exemplar still exists.",
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 \`home\` exemplar shape. Refine the clause
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 HomeScreen exemplar ` +
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 ` Column(`.
323
- const rootIdx = lines.findIndex((l) => l === " Column(");
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 " Column(" not found in ${relPathForErrors} — the HomeScreen exemplar drifted ` +
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
- "HomeScreen exemplar drifted from the shape this stamper wraps in BaseScreen.",
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..06.`);
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..06 — refine the prose next.`);
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();