create-cmp-cli 0.2.0 → 0.3.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 +65 -27
- package/bin/create-cmp.mjs +2 -1
- package/options.schema.json +5 -2
- package/package.json +2 -2
- package/src/commands/create.mjs +26 -3
- package/src/lib/toggle.mjs +2 -2
- package/template/.claude/settings.json +12 -0
- package/template/.claude/skills/add-feature/SKILL.md +131 -0
- package/template/.claude/skills/add-repository/SKILL.md +106 -0
- package/template/.claude/skills/add-screen/SKILL.md +130 -0
- package/template/.github/workflows/verify.yml +30 -8
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/template/.gradle/vcs-1/gc.properties +0 -0
- package/template/CHANGELOG.md +14 -0
- package/template/CLAUDE.md +70 -0
- package/template/CONTRIBUTING.md +39 -0
- package/template/README.md +103 -0
- package/template/composeApp/build.gradle.kts +15 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
- package/template/docs/ARCHITECTURE.md +51 -0
- package/template/docs/TESTING.md +64 -0
- package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
- package/template/docs/adr/template.md +17 -0
- package/template/gitignore +4 -0
- package/template/gradle/libs.versions.toml +2 -0
- package/template/manifest.json +11 -8
- package/template/qa/e2e/README.md +29 -0
- package/template/qa/e2e/smoke.yaml +35 -0
- package/template/qa/evidence/schema.json +56 -0
- package/template/qa/golden/home.json +15 -0
- package/template/qa/lib/inputs-hash.mjs +105 -0
- package/template/qa/lib/token-drift.mjs +94 -0
- package/template/qa/receipt-check.mjs +113 -0
- package/template/qa/refusal-demo.mjs +491 -0
- package/template/qa/scaffold-feature.mjs +488 -0
- package/template/qa/verify.mjs +467 -0
- package/template/specs/README.md +33 -0
- package/template/specs/app-base.spec.md +30 -0
- package/template/specs/home.spec.md +18 -0
- package/template/qa/appium/README.md +0 -23
- package/template/qa/appium/lib/appium-client.mjs +0 -225
- package/template/qa/appium/package.json +0 -8
- package/template/qa/appium/run-android-smoke.mjs +0 -39
- package/template/tests/appium/cmp/conftest.py +0 -96
- package/template/tests/appium/cmp/test_smoke.py +0 -17
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `add-feature` stamper — deterministic vertical-slice generator.
|
|
3
|
+
//
|
|
4
|
+
// node qa/scaffold-feature.mjs <FeatureName> [--entity <EntityName>] [--dry-run]
|
|
5
|
+
// node qa/scaffold-feature.mjs <Entity> --preset repository [--dry-run]
|
|
6
|
+
// node qa/scaffold-feature.mjs <FeatureName> --entity <EntityName> --preset screen [--dry-run]
|
|
7
|
+
//
|
|
8
|
+
// Copies the `home` exemplar file set, applies a curated WHOLE-WORD identifier
|
|
9
|
+
// rename (never a blind substring replace — see the rename map below), injects
|
|
10
|
+
// the new feature into the three shared files at their `// cmp:anchor` markers,
|
|
11
|
+
// and writes a default spec clause set. Pure Node, no dependencies.
|
|
12
|
+
//
|
|
13
|
+
// Philosophy: skills instruct, scripts stamp (HARNESS-ROADMAP M3). The AI only
|
|
14
|
+
// refines spec wording after this runs; the file set + wiring are mechanical.
|
|
15
|
+
//
|
|
16
|
+
// --preset (default `feature`, unchanged behavior): one stamping mechanic,
|
|
17
|
+
// three front-doors. Every FILES entry and every injection step below is
|
|
18
|
+
// tagged with the set of presets it belongs to; the active preset filters
|
|
19
|
+
// both lists before anything is written. There is no forked copy of this
|
|
20
|
+
// script per preset — `feature` is simply `repository` + `screen` + nav wiring
|
|
21
|
+
// that spans both, applied together.
|
|
22
|
+
//
|
|
23
|
+
// feature (default) — all 11 files; DI repo+usecase+viewModel; nav
|
|
24
|
+
// route+import; spec FEATURE-01..06.
|
|
25
|
+
// repository <Entity> — ONLY the 5 data/domain files; DI repo+usecase ONLY;
|
|
26
|
+
// no nav, no viewModel, no spec file, zero SPEC tags. The
|
|
27
|
+
// positional arg IS the entity (no --entity, no feature name).
|
|
28
|
+
// screen <Feature> --entity <E> — ONLY presentation + tests + spec (3
|
|
29
|
+
// test files carry all 6 SPEC tags); DI viewModel ONLY; nav
|
|
30
|
+
// route+import. Requires the entity's data layer to already
|
|
31
|
+
// exist (validated before anything is written — see below).
|
|
32
|
+
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
|
|
37
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
38
|
+
|
|
39
|
+
function die(message) {
|
|
40
|
+
console.error(`error: ${message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Argument parsing ────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const args = process.argv.slice(2);
|
|
47
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
48
|
+
const dryRun = args.includes("--dry-run");
|
|
49
|
+
const entityFlagIdx = args.indexOf("--entity");
|
|
50
|
+
const entityArg = entityFlagIdx !== -1 ? args[entityFlagIdx + 1] : undefined;
|
|
51
|
+
|
|
52
|
+
const PRESETS = new Set(["feature", "screen", "repository"]);
|
|
53
|
+
const presetFlagIdx = args.indexOf("--preset");
|
|
54
|
+
const preset = presetFlagIdx !== -1 ? args[presetFlagIdx + 1] : "feature";
|
|
55
|
+
if (!PRESETS.has(preset)) {
|
|
56
|
+
die(`"${preset}" is not a valid --preset — choose one of: feature, screen, repository.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const USAGE =
|
|
60
|
+
"usage:\n" +
|
|
61
|
+
" node qa/scaffold-feature.mjs <FeatureName> [--entity <EntityName>] [--dry-run]\n" +
|
|
62
|
+
" node qa/scaffold-feature.mjs <Entity> --preset repository [--dry-run]\n" +
|
|
63
|
+
" node qa/scaffold-feature.mjs <FeatureName> --entity <EntityName> --preset screen [--dry-run]";
|
|
64
|
+
|
|
65
|
+
const positionalName = positional[0];
|
|
66
|
+
if (!positionalName) {
|
|
67
|
+
die(`${USAGE}\n The positional name is required, e.g. \`Favorites\`.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const IDENTIFIER_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
71
|
+
if (!IDENTIFIER_RE.test(positionalName)) {
|
|
72
|
+
die(
|
|
73
|
+
`"${positionalName}" is not a valid PascalCase Kotlin identifier. Use e.g. "Favorites", "Bookmarks".`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function defaultEntity(feature) {
|
|
78
|
+
// Naive de-pluralization — the skill's interview step should let a human
|
|
79
|
+
// override this via --entity when it's wrong (Categories -> Category, etc).
|
|
80
|
+
if (feature.endsWith("ies") && feature.length > 3) return `${feature.slice(0, -3)}y`;
|
|
81
|
+
if (feature.endsWith("s") && !feature.endsWith("ss")) return feature.slice(0, -1);
|
|
82
|
+
return feature;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// `repository` preset: the positional arg IS the entity — no feature name, no
|
|
86
|
+
// nav/presentation slice at all. `feature`/`screen`: positional is the feature
|
|
87
|
+
// name; --entity defaults via de-pluralization if omitted.
|
|
88
|
+
const featureName = preset === "repository" ? undefined : positionalName;
|
|
89
|
+
const entityName = preset === "repository" ? positionalName : (entityArg ?? defaultEntity(positionalName));
|
|
90
|
+
if (!IDENTIFIER_RE.test(entityName)) {
|
|
91
|
+
die(`"${entityName}" is not a valid PascalCase Kotlin identifier for --entity.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// `repository` preset has no feature name (no nav/presentation/spec slice), so
|
|
95
|
+
// F/f/F_UPPER are never read for it — the rename map still needs harmless
|
|
96
|
+
// values to build (its feature-shaped entries never match repository-preset
|
|
97
|
+
// file contents, which only reference Item/ItemRepository/GetItemsUseCase).
|
|
98
|
+
const F = featureName ?? entityName; // PascalCase feature, e.g. Favorites
|
|
99
|
+
const f = F[0].toLowerCase() + F.slice(1); // camelCase/package segment, e.g. favorites
|
|
100
|
+
const F_UPPER = F.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); // FAVORITES
|
|
101
|
+
const E = entityName; // PascalCase entity, e.g. Favorite
|
|
102
|
+
|
|
103
|
+
// ── 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.
|
|
107
|
+
|
|
108
|
+
function resolvePackage() {
|
|
109
|
+
const gradleFile = path.join(ROOT, "composeApp", "build.gradle.kts");
|
|
110
|
+
if (fs.existsSync(gradleFile)) {
|
|
111
|
+
const contents = fs.readFileSync(gradleFile, "utf8");
|
|
112
|
+
const m = contents.match(/namespace\s*=\s*"([^"]+)"/);
|
|
113
|
+
if (m && m[1] !== "__PACKAGE__") return m[1];
|
|
114
|
+
}
|
|
115
|
+
const homeViewModel = path.join(
|
|
116
|
+
ROOT,
|
|
117
|
+
"composeApp/src/commonMain/kotlin",
|
|
118
|
+
...guessPackageDirFromDisk(),
|
|
119
|
+
"presentation/home/HomeViewModel.kt",
|
|
120
|
+
);
|
|
121
|
+
if (fs.existsSync(homeViewModel)) {
|
|
122
|
+
const m = fs.readFileSync(homeViewModel, "utf8").match(/^package\s+([\w.]+)\.presentation\.home\s*$/m);
|
|
123
|
+
if (m) return m[1];
|
|
124
|
+
}
|
|
125
|
+
die(
|
|
126
|
+
"could not resolve the project's package — expected a resolved `namespace = \"...\"` in " +
|
|
127
|
+
"composeApp/build.gradle.kts (found __PACKAGE__ unresolved, or the file is missing). " +
|
|
128
|
+
"Run this script POST-scaffold, in a project that has already been stamped.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Best-effort directory walk to find the HomeViewModel.kt under some package
|
|
133
|
+
// path when build.gradle.kts didn't yield an answer (fallback path only).
|
|
134
|
+
function guessPackageDirFromDisk() {
|
|
135
|
+
const base = path.join(ROOT, "composeApp/src/commonMain/kotlin");
|
|
136
|
+
let dir = base;
|
|
137
|
+
const segments = [];
|
|
138
|
+
// Walk down single-child directories until we hit `presentation` or run out.
|
|
139
|
+
while (fs.existsSync(dir)) {
|
|
140
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
141
|
+
if (entries.length !== 1) break;
|
|
142
|
+
if (entries[0].name === "presentation") break;
|
|
143
|
+
segments.push(entries[0].name);
|
|
144
|
+
dir = path.join(dir, entries[0].name);
|
|
145
|
+
}
|
|
146
|
+
return segments;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const PACKAGE = resolvePackage();
|
|
150
|
+
const PACKAGE_DIR = PACKAGE.split(".").join("/");
|
|
151
|
+
|
|
152
|
+
// ── The rename map (§3) ──────────────────────────────────────────────────────
|
|
153
|
+
// 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).
|
|
158
|
+
|
|
159
|
+
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],
|
|
180
|
+
// 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],
|
|
186
|
+
].sort((a, b) => b[0].length - a[0].length);
|
|
187
|
+
|
|
188
|
+
function escapeRegExp(s) {
|
|
189
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const COMPILED_RENAMES = RENAME_MAP.map(([from, to]) => [new RegExp(`\\b${escapeRegExp(from)}\\b`, "g"), to]);
|
|
193
|
+
|
|
194
|
+
function applyRename(text) {
|
|
195
|
+
let out = text;
|
|
196
|
+
for (const [re, to] of COMPILED_RENAMES) out = out.replace(re, to);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
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
|
+
// Every entry is tagged with the presets it belongs to. `feature` gets all 11
|
|
206
|
+
// (the union); `repository` gets just the 5 data/domain files; `screen` gets
|
|
207
|
+
// just the 6 presentation+tests+spec files. Filtered by the active preset
|
|
208
|
+
// right after definition — nothing below this point sees the untagged list.
|
|
209
|
+
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"] },
|
|
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"] },
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
const FILES = ALL_FILES.filter((file) => file.presets.includes(preset));
|
|
224
|
+
|
|
225
|
+
// Golden baseline: NOT copied (a feature's golden tree is captured fresh via
|
|
226
|
+
// UPDATE_GOLDEN=1, per the skill's step 5), but we still verify the source
|
|
227
|
+
// files above genuinely exist before doing anything.
|
|
228
|
+
for (const file of FILES) {
|
|
229
|
+
if (!fs.existsSync(file.from)) {
|
|
230
|
+
die(
|
|
231
|
+
`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.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// `screen` preset composes on top of an existing entity's data layer — the
|
|
239
|
+
// stamped ViewModel test references Get<E>sUseCase/Fake<E>Repository and the
|
|
240
|
+
// screen references <E>, so those must already exist. Validate BEFORE writing
|
|
241
|
+
// anything (die early, no half-stamp).
|
|
242
|
+
if (preset === "screen") {
|
|
243
|
+
const requiredExisting = [
|
|
244
|
+
path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`),
|
|
245
|
+
path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`),
|
|
246
|
+
path.join(SRC("commonMain"), `domain/model/${E}.kt`),
|
|
247
|
+
];
|
|
248
|
+
const missing = requiredExisting.filter((p) => !fs.existsSync(p));
|
|
249
|
+
if (missing.length > 0) {
|
|
250
|
+
die(
|
|
251
|
+
`entity "${E}" not found — run \`node qa/scaffold-feature.mjs ${E} --preset repository\` first, ` +
|
|
252
|
+
"or use --preset feature to generate the data layer too.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Name-taken check.
|
|
258
|
+
const existing = FILES.filter((file) => fs.existsSync(file.to) && !file.isDefaultSpec).map((file) =>
|
|
259
|
+
path.relative(ROOT, file.to),
|
|
260
|
+
);
|
|
261
|
+
if (existing.length > 0) {
|
|
262
|
+
die(
|
|
263
|
+
`"${preset === "repository" ? E : featureName}" appears to already exist — these target files are already present:\n` +
|
|
264
|
+
existing.map((p) => ` ${p}`).join("\n"),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (FILES.some((file) => file.isDefaultSpec) && fs.existsSync(path.join(ROOT, `specs/${f}.spec.md`))) {
|
|
268
|
+
die(`specs/${f}.spec.md already exists — feature "${featureName}" appears to already exist.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Default spec clause set (§6) ────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
function defaultSpec() {
|
|
274
|
+
return `# Spec: ${f}
|
|
275
|
+
|
|
276
|
+
> Generated by \`scaffold-feature.mjs\` from the \`home\` exemplar shape. Refine the clause
|
|
277
|
+
> prose below for ${F}'s real behavior (ids stay fixed) before running the verify lane.
|
|
278
|
+
|
|
279
|
+
- **${F_UPPER}-01** — Given the ${F} screen opens, When ${f} are being loaded, Then a loading
|
|
280
|
+
indicator is shown and no ${f} are visible.
|
|
281
|
+
- **${F_UPPER}-02** — Given the repository returns ${f}, When loading completes, Then the ${f}
|
|
282
|
+
are listed with their title and subtitle, and no error is shown.
|
|
283
|
+
- **${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.
|
|
285
|
+
- **${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.
|
|
287
|
+
- **${F_UPPER}-05** — Given ${f} are listed, When the user taps an item, Then the app navigates
|
|
288
|
+
to that item's detail.
|
|
289
|
+
- **${F_UPPER}-06** — Given the ${F} screen renders, When its structure is inspected, Then the
|
|
290
|
+
screen matches its committed golden tree (\`qa/golden/${f}.json\`) — structural regressions
|
|
291
|
+
are intentional, declared changes only.
|
|
292
|
+
`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Anchor injection (§5) ────────────────────────────────────────────────────
|
|
296
|
+
// Idempotent (skip if the feature's line is already present); fails loudly if
|
|
297
|
+
// an anchor marker is missing from the shared file. Each function is a pure
|
|
298
|
+
// string -> string transform so multiple injections into the SAME file can be
|
|
299
|
+
// chained (each one sees the previous one's output) before a single write.
|
|
300
|
+
|
|
301
|
+
function injectAtAnchor(content, filePathForErrors, anchorName, lineToInsert) {
|
|
302
|
+
const anchorLine = `// cmp:anchor ${anchorName}`;
|
|
303
|
+
const lines = content.split("\n");
|
|
304
|
+
const anchorLineIdx = lines.findIndex((l) => l.trim() === anchorLine);
|
|
305
|
+
if (anchorLineIdx === -1) {
|
|
306
|
+
die(
|
|
307
|
+
`anchor "${anchorName}" not found in ${path.relative(ROOT, filePathForErrors)}. ` +
|
|
308
|
+
"The template shared file may be out of date with this stamper — " +
|
|
309
|
+
"check for the `// cmp:anchor` marker comments.",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Idempotency: if the line is already present verbatim (ignoring leading
|
|
314
|
+
// whitespace), skip — running the stamper twice for the same feature must
|
|
315
|
+
// not duplicate wiring.
|
|
316
|
+
const alreadyPresent = lines.some((l) => l.trim() === lineToInsert.trim());
|
|
317
|
+
if (alreadyPresent) return { content, skipped: true, diff: "" };
|
|
318
|
+
|
|
319
|
+
// Match the anchor comment's own indentation so the inserted line sits at
|
|
320
|
+
// the same nesting level as its sibling lines (e.g. inside a `module { }`
|
|
321
|
+
// block, or a `NavHost { }` block).
|
|
322
|
+
const anchorIndent = lines[anchorLineIdx].match(/^\s*/)[0];
|
|
323
|
+
const insertedLine = `${anchorIndent}${lineToInsert}`;
|
|
324
|
+
lines.splice(anchorLineIdx, 0, insertedLine);
|
|
325
|
+
return { content: lines.join("\n"), skipped: false, diff: `${insertedLine}\n` };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function injectImport(content, filePathForErrors, importLine) {
|
|
329
|
+
if (content.split("\n").some((l) => l.trim() === importLine.trim())) {
|
|
330
|
+
return { content, skipped: true, diff: "" };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const diImportsAnchor = "// cmp:anchor di-imports";
|
|
334
|
+
const lines = content.split("\n");
|
|
335
|
+
const anchorLineIdx = lines.findIndex((l) => l.trim() === diImportsAnchor);
|
|
336
|
+
if (anchorLineIdx !== -1) {
|
|
337
|
+
lines.splice(anchorLineIdx, 0, importLine);
|
|
338
|
+
return { content: lines.join("\n"), skipped: false, diff: `${importLine}\n` };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Fallback: append after the last existing `import ` line (used by
|
|
342
|
+
// AppNavHost.kt, which has no dedicated imports anchor).
|
|
343
|
+
let lastImportIdx = -1;
|
|
344
|
+
lines.forEach((line, i) => {
|
|
345
|
+
if (line.startsWith("import ")) lastImportIdx = i;
|
|
346
|
+
});
|
|
347
|
+
if (lastImportIdx === -1) {
|
|
348
|
+
die(`no import block found in ${path.relative(ROOT, filePathForErrors)} to inject "${importLine}" near.`);
|
|
349
|
+
}
|
|
350
|
+
lines.splice(lastImportIdx + 1, 0, importLine);
|
|
351
|
+
return { content: lines.join("\n"), skipped: false, diff: `${importLine}\n` };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Applies an ordered list of (content -> result) steps to one file, chaining
|
|
355
|
+
// each step's output into the next, and returns the final content plus a flat
|
|
356
|
+
// diff log. Reads the file once; the caller writes it once.
|
|
357
|
+
function applyInjectionSteps(filePath, steps) {
|
|
358
|
+
if (!fs.existsSync(filePath)) {
|
|
359
|
+
die(`shared file missing: ${path.relative(ROOT, filePath)} — cannot inject wiring for the new feature.`);
|
|
360
|
+
}
|
|
361
|
+
let content = fs.readFileSync(filePath, "utf8");
|
|
362
|
+
const log = [];
|
|
363
|
+
for (const step of steps) {
|
|
364
|
+
const result = step(content);
|
|
365
|
+
content = result.content;
|
|
366
|
+
log.push({ skipped: result.skipped, diff: result.diff });
|
|
367
|
+
}
|
|
368
|
+
return { filePath, content, log };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Plan ─────────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
const plan = {
|
|
374
|
+
feature: F,
|
|
375
|
+
entity: E,
|
|
376
|
+
package: PACKAGE,
|
|
377
|
+
files: FILES.map((file) => ({
|
|
378
|
+
from: path.relative(ROOT, file.from),
|
|
379
|
+
to: path.relative(ROOT, file.to),
|
|
380
|
+
})),
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const APP_MODULE = path.join(SRC("commonMain"), "di/AppModule.kt");
|
|
384
|
+
const SCREEN_KT = path.join(SRC("commonMain"), "presentation/navigation/Screen.kt");
|
|
385
|
+
const APP_NAV_HOST = path.join(SRC("commonMain"), "presentation/navigation/AppNavHost.kt");
|
|
386
|
+
|
|
387
|
+
// Each step is tagged with the presets it belongs to, same mechanism as
|
|
388
|
+
// FILES above: `repository` gets repo+usecase DI (+ imports) only; `screen`
|
|
389
|
+
// gets viewModel DI (+ import) + nav route + import only; `feature` gets the
|
|
390
|
+
// union (unchanged).
|
|
391
|
+
const ALL_INJECTION_PLANS = [
|
|
392
|
+
{
|
|
393
|
+
filePath: APP_MODULE,
|
|
394
|
+
steps: [
|
|
395
|
+
{ presets: ["feature", "repository"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.data.remote.${E}RepositoryImpl`) },
|
|
396
|
+
{ presets: ["feature", "repository"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.domain.repository.${E}Repository`) },
|
|
397
|
+
{ presets: ["feature", "repository"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.domain.usecase.Get${E}sUseCase`) },
|
|
398
|
+
{ presets: ["feature", "screen"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.presentation.${f}.${F}ViewModel`) },
|
|
399
|
+
{ presets: ["feature", "repository"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-repositories", `single<${E}Repository> { ${E}RepositoryImpl() }`) },
|
|
400
|
+
{ presets: ["feature", "repository"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-usecases", `factory { Get${E}sUseCase(get()) }`) },
|
|
401
|
+
{ presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-viewmodels", `viewModelOf(::${F}ViewModel)`) },
|
|
402
|
+
],
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
filePath: SCREEN_KT,
|
|
406
|
+
steps: [
|
|
407
|
+
{ presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, SCREEN_KT, "screen-objects", `data object ${F} : Screen(Routes.${F_UPPER})`) },
|
|
408
|
+
{ presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, SCREEN_KT, "route-consts", `const val ${F_UPPER} = "${f}"`) },
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
filePath: APP_NAV_HOST,
|
|
413
|
+
steps: [
|
|
414
|
+
{ presets: ["feature", "screen"], apply: (c) => injectImport(c, APP_NAV_HOST, `import ${PACKAGE}.presentation.${f}.${F}Screen`) },
|
|
415
|
+
{ presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, APP_NAV_HOST, "nav-destinations", `composable(Screen.${F}.route) { ${F}Screen(onItemClick = {}) }`) },
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
// Filter steps by active preset; drop any file plan left with zero steps
|
|
421
|
+
// (e.g. Screen.kt / AppNavHost.kt entirely for `repository`).
|
|
422
|
+
const fileInjectionPlans = ALL_INJECTION_PLANS.map((p) => ({
|
|
423
|
+
filePath: p.filePath,
|
|
424
|
+
steps: p.steps.filter((s) => s.presets.includes(preset)).map((s) => s.apply),
|
|
425
|
+
})).filter((p) => p.steps.length > 0);
|
|
426
|
+
|
|
427
|
+
const fileResults = fileInjectionPlans.map((p) => applyInjectionSteps(p.filePath, p.steps));
|
|
428
|
+
|
|
429
|
+
plan.injections = fileResults.flatMap((r) =>
|
|
430
|
+
r.log.map((entry) => ({ file: path.relative(ROOT, r.filePath), skipped: entry.skipped, diff: entry.diff })),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// ── Dry-run: print the plan and exit ────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
const writesSpec = FILES.some((file) => file.isDefaultSpec);
|
|
436
|
+
const planLabel =
|
|
437
|
+
preset === "repository"
|
|
438
|
+
? `entity "${E}"`
|
|
439
|
+
: `feature "${F}" (entity "${E}")`;
|
|
440
|
+
|
|
441
|
+
if (dryRun) {
|
|
442
|
+
console.log(`Plan for ${planLabel}, package "${PACKAGE}", preset "${preset}":\n`);
|
|
443
|
+
console.log("Files to create:");
|
|
444
|
+
for (const pf of plan.files) console.log(` ${pf.from}\n -> ${pf.to}`);
|
|
445
|
+
console.log("\nAnchor injections:");
|
|
446
|
+
if (plan.injections.length === 0) console.log(" (none for this preset)");
|
|
447
|
+
for (const inj of plan.injections) {
|
|
448
|
+
if (inj.skipped) {
|
|
449
|
+
console.log(` ${inj.file}: (already present, skip)`);
|
|
450
|
+
} else {
|
|
451
|
+
console.log(` ${inj.file}:`);
|
|
452
|
+
for (const line of inj.diff.split("\n").filter(Boolean)) console.log(` + ${line}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (writesSpec) {
|
|
456
|
+
console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..06.`);
|
|
457
|
+
} else {
|
|
458
|
+
console.log("\nNo spec file written by this preset (zero SPEC clauses/tags added).");
|
|
459
|
+
}
|
|
460
|
+
console.log("\n(dry run — nothing written)");
|
|
461
|
+
process.exit(0);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── Execute ──────────────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
let filesWritten = 0;
|
|
467
|
+
for (const file of FILES) {
|
|
468
|
+
const contents = file.isDefaultSpec ? defaultSpec() : applyRename(fs.readFileSync(file.from, "utf8"));
|
|
469
|
+
fs.mkdirSync(path.dirname(file.to), { recursive: true });
|
|
470
|
+
fs.writeFileSync(file.to, contents);
|
|
471
|
+
filesWritten += 1;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let injectionsApplied = 0;
|
|
475
|
+
for (const result of fileResults) {
|
|
476
|
+
const anyApplied = result.log.some((entry) => !entry.skipped);
|
|
477
|
+
if (!anyApplied) continue;
|
|
478
|
+
fs.writeFileSync(result.filePath, result.content);
|
|
479
|
+
injectionsApplied += result.log.filter((entry) => !entry.skipped).length;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
console.log(`✓ Scaffolded ${planLabel} [preset: ${preset}] — ${filesWritten} files written, ${injectionsApplied} anchor injections applied.`);
|
|
483
|
+
if (writesSpec) {
|
|
484
|
+
console.log(` specs/${f}.spec.md written with default clauses ${F_UPPER}-01..06 — refine the prose next.`);
|
|
485
|
+
} else {
|
|
486
|
+
console.log(" No spec file written by this preset (zero SPEC clauses/tags added).");
|
|
487
|
+
}
|
|
488
|
+
console.log(` Next: ${preset === "repository" ? "customize the entity + repository impl, then" : "capture the golden tree, then"} run node qa/verify.mjs.`);
|