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.
Files changed (66) hide show
  1. package/README.md +65 -27
  2. package/bin/create-cmp.mjs +2 -1
  3. package/options.schema.json +5 -2
  4. package/package.json +2 -2
  5. package/src/commands/create.mjs +26 -3
  6. package/src/lib/toggle.mjs +2 -2
  7. package/template/.claude/settings.json +12 -0
  8. package/template/.claude/skills/add-feature/SKILL.md +131 -0
  9. package/template/.claude/skills/add-repository/SKILL.md +106 -0
  10. package/template/.claude/skills/add-screen/SKILL.md +130 -0
  11. package/template/.github/workflows/verify.yml +30 -8
  12. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  13. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  14. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  15. package/template/.gradle/8.11.1/gc.properties +0 -0
  16. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  17. package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
  18. package/template/.gradle/vcs-1/gc.properties +0 -0
  19. package/template/CHANGELOG.md +14 -0
  20. package/template/CLAUDE.md +70 -0
  21. package/template/CONTRIBUTING.md +39 -0
  22. package/template/README.md +103 -0
  23. package/template/composeApp/build.gradle.kts +15 -8
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
  30. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
  31. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
  32. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
  33. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
  34. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
  35. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
  36. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
  37. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
  38. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
  39. package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
  40. package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
  41. package/template/docs/ARCHITECTURE.md +51 -0
  42. package/template/docs/TESTING.md +64 -0
  43. package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
  44. package/template/docs/adr/template.md +17 -0
  45. package/template/gitignore +4 -0
  46. package/template/gradle/libs.versions.toml +2 -0
  47. package/template/manifest.json +11 -8
  48. package/template/qa/e2e/README.md +29 -0
  49. package/template/qa/e2e/smoke.yaml +35 -0
  50. package/template/qa/evidence/schema.json +56 -0
  51. package/template/qa/golden/home.json +15 -0
  52. package/template/qa/lib/inputs-hash.mjs +105 -0
  53. package/template/qa/lib/token-drift.mjs +94 -0
  54. package/template/qa/receipt-check.mjs +113 -0
  55. package/template/qa/refusal-demo.mjs +491 -0
  56. package/template/qa/scaffold-feature.mjs +488 -0
  57. package/template/qa/verify.mjs +467 -0
  58. package/template/specs/README.md +33 -0
  59. package/template/specs/app-base.spec.md +30 -0
  60. package/template/specs/home.spec.md +18 -0
  61. package/template/qa/appium/README.md +0 -23
  62. package/template/qa/appium/lib/appium-client.mjs +0 -225
  63. package/template/qa/appium/package.json +0 -8
  64. package/template/qa/appium/run-android-smoke.mjs +0 -39
  65. package/template/tests/appium/cmp/conftest.py +0 -96
  66. 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.`);