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.
- package/README.md +83 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +97 -4
- package/src/scaffold.mjs +52 -1
- 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 +213 -47
- package/template/README.md +32 -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/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -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/brand/BrandMark.kt +75 -0
- 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 +63 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -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 +22 -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 +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- 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 +300 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
- 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 +372 -34
- package/template/docs/TESTING.md +13 -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/e2e/smoke.yaml +6 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +822 -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 +181 -15
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +49 -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();
|