create-cmp-cli 0.2.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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/bin/create-cmp.mjs +91 -0
  4. package/options.schema.json +110 -0
  5. package/package.json +52 -0
  6. package/src/bootstrap/checks.mjs +283 -0
  7. package/src/bootstrap/exec.mjs +90 -0
  8. package/src/commands/clean.mjs +195 -0
  9. package/src/commands/create.mjs +223 -0
  10. package/src/commands/doctor.mjs +256 -0
  11. package/src/commands/upgrade.mjs +196 -0
  12. package/src/commands/verify.mjs +94 -0
  13. package/src/doctor.mjs +109 -0
  14. package/src/lib/args.mjs +38 -0
  15. package/src/lib/clean.mjs +72 -0
  16. package/src/lib/fsutil.mjs +90 -0
  17. package/src/lib/log.mjs +40 -0
  18. package/src/lib/project-doctor.mjs +329 -0
  19. package/src/lib/registry.mjs +102 -0
  20. package/src/lib/rename.mjs +116 -0
  21. package/src/lib/schema.mjs +123 -0
  22. package/src/lib/toggle.mjs +130 -0
  23. package/src/lib/tokens.mjs +96 -0
  24. package/src/lib/toml.mjs +137 -0
  25. package/src/lib/upgrade.mjs +159 -0
  26. package/src/lib/verify.mjs +97 -0
  27. package/src/scaffold.mjs +347 -0
  28. package/src/verify.mjs +4 -0
  29. package/src/versions/registry.json +42 -0
  30. package/template/.github/workflows/verify.yml +70 -0
  31. package/template/build.gradle.kts +17 -0
  32. package/template/composeApp/build.gradle.kts +257 -0
  33. package/template/composeApp/google-services.json +29 -0
  34. package/template/composeApp/proguard-rules.pro +34 -0
  35. package/template/composeApp/src/androidDebug/AndroidManifest.xml +13 -0
  36. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/ComposeRootRegistry.kt +40 -0
  37. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +80 -0
  38. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +322 -0
  39. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +16 -0
  40. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +102 -0
  41. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/RemoteControlPage.kt +106 -0
  42. package/template/composeApp/src/androidDebug/res/xml/debug_network_security_config.xml +9 -0
  43. package/template/composeApp/src/androidMain/AndroidManifest.xml +28 -0
  44. package/template/composeApp/src/androidMain/kotlin/com/example/app/AppApplication.kt +72 -0
  45. package/template/composeApp/src/androidMain/kotlin/com/example/app/MainActivity.kt +24 -0
  46. package/template/composeApp/src/androidMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +42 -0
  47. package/template/composeApp/src/androidMain/kotlin/com/example/app/data/local/DatabaseBuilder.android.kt +13 -0
  48. package/template/composeApp/src/androidMain/kotlin/com/example/app/di/AndroidModule.kt +9 -0
  49. package/template/composeApp/src/androidMain/kotlin/com/example/app/presentation/components/TestTagAutomation.android.kt +11 -0
  50. package/template/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +9 -0
  51. package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  52. package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  53. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png +0 -0
  54. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  55. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  56. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png +0 -0
  57. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  58. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  59. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png +0 -0
  60. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  61. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  62. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  63. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  64. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  65. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  66. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  67. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  68. package/template/composeApp/src/androidMain/res/values/colors.xml +6 -0
  69. package/template/composeApp/src/androidMain/res/values/themes.xml +9 -0
  70. package/template/composeApp/src/androidMain/res/values-v31/themes.xml +8 -0
  71. package/template/composeApp/src/androidRelease/kotlin/com/example/app/inspector/InspectorInit.kt +13 -0
  72. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Bold.ttf +0 -0
  73. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Medium.ttf +0 -0
  74. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Regular.ttf +0 -0
  75. package/template/composeApp/src/commonMain/composeResources/font/DMSans_SemiBold.ttf +0 -0
  76. package/template/composeApp/src/commonMain/kotlin/com/example/app/App.kt +7 -0
  77. package/template/composeApp/src/commonMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +7 -0
  78. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/AppDatabase.kt +28 -0
  79. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/DatabaseBuilder.kt +14 -0
  80. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/ItemDao.kt +18 -0
  81. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/FirebaseConfig.kt +5 -0
  82. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +22 -0
  83. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +23 -0
  84. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/Item.kt +8 -0
  85. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +8 -0
  86. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +12 -0
  87. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/App.kt +13 -0
  88. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +69 -0
  89. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +18 -0
  90. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +39 -0
  91. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +87 -0
  92. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +35 -0
  93. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +43 -0
  94. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +144 -0
  95. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppTab.kt +25 -0
  96. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +15 -0
  97. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +43 -0
  98. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/DesignToken.kt +25 -0
  99. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Shape.kt +12 -0
  100. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Theme.kt +64 -0
  101. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Tokens.kt +19 -0
  102. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +36 -0
  103. package/template/composeApp/src/desktopMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.desktop.kt +9 -0
  104. package/template/composeApp/src/desktopMain/kotlin/com/example/app/data/local/DatabaseBuilder.desktop.kt +13 -0
  105. package/template/composeApp/src/desktopMain/kotlin/com/example/app/di/DesktopModule.kt +32 -0
  106. package/template/composeApp/src/desktopMain/kotlin/com/example/app/main.kt +29 -0
  107. package/template/composeApp/src/desktopMain/kotlin/com/example/app/presentation/components/TestTagAutomation.desktop.kt +6 -0
  108. package/template/composeApp/src/iosMain/kotlin/com/example/app/KoinHelper.kt +50 -0
  109. package/template/composeApp/src/iosMain/kotlin/com/example/app/MainViewController.kt +10 -0
  110. package/template/composeApp/src/iosMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +32 -0
  111. package/template/composeApp/src/iosMain/kotlin/com/example/app/data/local/DatabaseBuilder.ios.kt +10 -0
  112. package/template/composeApp/src/iosMain/kotlin/com/example/app/presentation/components/TestTagAutomation.ios.kt +11 -0
  113. package/template/docs/dev-client.md +52 -0
  114. package/template/gitignore +21 -0
  115. package/template/gradle/libs.versions.toml +100 -0
  116. package/template/gradle/wrapper/gradle-wrapper.jar +0 -0
  117. package/template/gradle/wrapper/gradle-wrapper.properties +7 -0
  118. package/template/gradle.properties +9 -0
  119. package/template/gradlew +152 -0
  120. package/template/gradlew.bat +94 -0
  121. package/template/iosApp/Podfile +27 -0
  122. package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png +0 -0
  123. package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  124. package/template/iosApp/iosApp/Assets.xcassets/Contents.json +6 -0
  125. package/template/iosApp/iosApp/ContentView.swift +17 -0
  126. package/template/iosApp/iosApp/GoogleService-Info.plist +34 -0
  127. package/template/iosApp/iosApp/Info.plist +43 -0
  128. package/template/iosApp/iosApp/PrivacyInfo.xcprivacy +42 -0
  129. package/template/iosApp/iosApp/iOSApp.swift +33 -0
  130. package/template/iosApp/project.yml +56 -0
  131. package/template/local.properties.example +2 -0
  132. package/template/manifest.json +125 -0
  133. package/template/qa/appium/README.md +23 -0
  134. package/template/qa/appium/lib/appium-client.mjs +225 -0
  135. package/template/qa/appium/package.json +8 -0
  136. package/template/qa/appium/run-android-smoke.mjs +39 -0
  137. package/template/settings.gradle.kts +34 -0
  138. package/template/tests/appium/cmp/conftest.py +96 -0
  139. package/template/tests/appium/cmp/test_smoke.py +17 -0
@@ -0,0 +1,329 @@
1
+ // Project-level diagnosis for `create-cmp doctor` — works on ANY Gradle/KMP
2
+ // project with a gradle/libs.versions.toml, not only ones we scaffolded (this
3
+ // is the ecosystem-funnel feature). Pure logic: the command layer gathers
4
+ // filesystem/env inputs and passes them in, so every check unit-tests without
5
+ // touching disk.
6
+
7
+ import { parseVersions, parseProperties } from "./toml.mjs";
8
+ import { lockstepViolation } from "./upgrade.mjs";
9
+ import { nearestSet } from "./registry.mjs";
10
+
11
+ export const GIB = 1024 ** 3;
12
+ export const KONAN_WARN_BYTES = 10 * GIB;
13
+ export const DISK_WARN_BYTES = 3 * GIB;
14
+
15
+ /**
16
+ * @typedef {object} Finding
17
+ * @property {string} id
18
+ * @property {"ok"|"warn"|"fail"} level
19
+ * @property {string} title
20
+ * @property {string} detail
21
+ * @property {{auto:boolean, description:string}} [fix] auto = --fix can heal it
22
+ */
23
+
24
+ /**
25
+ * Diagnose a project from pre-gathered inputs.
26
+ * @param {object} input
27
+ * @param {string|null} input.toml gradle/libs.versions.toml content (null = absent)
28
+ * @param {string|null} input.gradleProperties gradle.properties content (null = absent)
29
+ * @param {string|null} input.localProperties local.properties content (null = absent)
30
+ * @param {boolean|null} input.sdkDirExists does local.properties sdk.dir point at a real dir (null = no sdk.dir line)
31
+ * @param {boolean} input.androidHomeSet ANDROID_HOME/ANDROID_SDK_ROOT points at a real dir
32
+ * @param {boolean} input.hasIos project has an iOS side (iosApp/ or iosMain sources)
33
+ * @param {object|null} input.registry version-set registry (null = skip drift check)
34
+ * @param {number|null} input.konanBytes ~/.konan size in bytes (null = unknown/absent)
35
+ * @param {number|null} input.freeDiskBytes free disk space in bytes (null = unknown)
36
+ * @param {string[]|null} [input.inspectorHits] relative (posix) paths of Kotlin sources that
37
+ * reference the live-inspector endpoint (`/inspect/` or `InspectorHttpServer`);
38
+ * null = scan skipped (no composeApp sources), [] = project has no inspector code.
39
+ * @param {{catalog:string, theme:string}|null} [input.inspectorCatalog] the stamped
40
+ * InspectorCatalog.kt content + concatenated theme sources (Tokens.kt/Theme.kt) for
41
+ * the declared-token drift tripwire; null = skip.
42
+ * @returns {Finding[]}
43
+ */
44
+ export function diagnoseProject(input) {
45
+ const findings = [];
46
+ const {
47
+ toml,
48
+ gradleProperties,
49
+ localProperties,
50
+ sdkDirExists,
51
+ androidHomeSet,
52
+ hasIos,
53
+ registry,
54
+ konanBytes,
55
+ freeDiskBytes,
56
+ inspectorHits = null,
57
+ inspectorCatalog = null,
58
+ } = input;
59
+
60
+ // --- version catalog ------------------------------------------------------
61
+ let versions = null;
62
+ if (toml === null) {
63
+ findings.push({
64
+ id: "version-catalog",
65
+ level: "warn",
66
+ title: "No gradle/libs.versions.toml",
67
+ detail:
68
+ "No version catalog found — version checks (kotlin↔ksp lockstep, drift vs proven-green sets) are skipped.",
69
+ });
70
+ } else {
71
+ versions = parseVersions(toml);
72
+ const values = Object.fromEntries([...versions].map(([k, v]) => [k, v.value]));
73
+
74
+ // kotlin ↔ ksp lockstep (the classic KMP build-killer).
75
+ if (values.kotlin && values.ksp) {
76
+ const violation = lockstepViolation(values);
77
+ if (violation) {
78
+ findings.push({
79
+ id: "kotlin-ksp-lockstep",
80
+ level: "fail",
81
+ title: "kotlin ↔ ksp lockstep VIOLATED",
82
+ detail: violation.replace(" Refusing to write a broken pairing.", ""),
83
+ fix: {
84
+ auto: false,
85
+ description: `Set ksp = "${values.kotlin}-<kspVersion>" in gradle/libs.versions.toml (or run \`create-cmp upgrade\` to move to a proven-green set).`,
86
+ },
87
+ });
88
+ } else {
89
+ findings.push({
90
+ id: "kotlin-ksp-lockstep",
91
+ level: "ok",
92
+ title: "kotlin ↔ ksp lockstep",
93
+ detail: `kotlin ${values.kotlin} / ksp ${values.ksp} agree.`,
94
+ });
95
+ }
96
+ }
97
+
98
+ // Known-bad combo: Room on Kotlin/Native (iOS) needs KSP2.
99
+ if (values.room && hasIos) {
100
+ const props = gradleProperties !== null ? parseProperties(gradleProperties) : new Map();
101
+ const ksp2 = props.get("ksp.useKSP2");
102
+ if (!ksp2 || ksp2.value !== "true") {
103
+ findings.push({
104
+ id: "ksp2-flag",
105
+ level: "fail",
106
+ title: "Room + iOS without ksp.useKSP2=true",
107
+ detail:
108
+ "Room's KSP processor on Kotlin/Native (iOS) requires KSP2. Without ksp.useKSP2=true the iOS " +
109
+ "build dies with `ClassNotFoundException: org.jetbrains.kotlin.cli.utilities.MainKt` (the KSP2/iOS catch-22).",
110
+ fix: { auto: true, description: "Add `ksp.useKSP2=true` to gradle.properties." },
111
+ });
112
+ } else {
113
+ findings.push({
114
+ id: "ksp2-flag",
115
+ level: "ok",
116
+ title: "ksp.useKSP2=true",
117
+ detail: "Room + iOS detected and KSP2 is enabled (the native catch-22 is defused).",
118
+ });
119
+ }
120
+ }
121
+
122
+ // Known-bad combo: Room < 2.7 has no Kotlin/Native support at all.
123
+ if (values.room && hasIos) {
124
+ const m = values.room.match(/^(\d+)\.(\d+)/);
125
+ if (m && (Number(m[1]) < 2 || (Number(m[1]) === 2 && Number(m[2]) < 7))) {
126
+ findings.push({
127
+ id: "room-native-support",
128
+ level: "fail",
129
+ title: `Room ${values.room} cannot target iOS`,
130
+ detail: "Room gained Kotlin Multiplatform (native) support in 2.7.0 — upgrade Room to use it from iOS code.",
131
+ fix: { auto: false, description: "Run `create-cmp upgrade` to move Room (and its lockstep partners) to a proven-green set." },
132
+ });
133
+ }
134
+ }
135
+
136
+ // Drift vs the nearest proven-green registry set.
137
+ if (registry) {
138
+ const near = nearestSet(registry, versions);
139
+ if (near) {
140
+ const drift = [];
141
+ for (const [k, v] of Object.entries(near.set.versions)) {
142
+ const cur = versions.get(k);
143
+ if (cur && cur.value !== v) drift.push(`${k} ${cur.value} → ${v}`);
144
+ }
145
+ if (drift.length === 0) {
146
+ findings.push({
147
+ id: "registry-drift",
148
+ level: "ok",
149
+ title: `Matches proven-green set ${near.set.id}`,
150
+ detail: "Every catalog version this set pins is at the proven-green value.",
151
+ });
152
+ } else {
153
+ findings.push({
154
+ id: "registry-drift",
155
+ level: "warn",
156
+ title: `Drift vs proven-green set ${near.set.id} (${drift.length} version${drift.length === 1 ? "" : "s"})`,
157
+ detail: drift.join(", "),
158
+ fix: { auto: false, description: `Run \`create-cmp upgrade --set ${near.set.id}\` to align (diff shown before anything is written).` },
159
+ });
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // --- local.properties / SDK ------------------------------------------------
166
+ if (localProperties === null) {
167
+ findings.push({
168
+ id: "local-properties",
169
+ level: androidHomeSet ? "warn" : "fail",
170
+ title: "No local.properties",
171
+ detail: androidHomeSet
172
+ ? "local.properties is missing, but ANDROID_HOME/ANDROID_SDK_ROOT is set so Gradle can still resolve the SDK."
173
+ : "local.properties is missing and ANDROID_HOME/ANDROID_SDK_ROOT is not set — the Android build cannot locate an SDK.",
174
+ fix: {
175
+ auto: androidHomeSet,
176
+ description: androidHomeSet
177
+ ? "Write local.properties with sdk.dir from ANDROID_HOME."
178
+ : "Install the Android SDK (run `create-cmp doctor` toolchain bootstrap), then set ANDROID_HOME or write local.properties with sdk.dir=<sdk path>.",
179
+ },
180
+ });
181
+ } else if (sdkDirExists === null) {
182
+ findings.push({
183
+ id: "local-properties",
184
+ level: androidHomeSet ? "warn" : "fail",
185
+ title: "local.properties has no sdk.dir",
186
+ detail: "local.properties exists but declares no sdk.dir.",
187
+ fix: {
188
+ auto: androidHomeSet,
189
+ description: androidHomeSet
190
+ ? "Add sdk.dir from ANDROID_HOME to local.properties."
191
+ : "Add `sdk.dir=<android sdk path>` to local.properties or export ANDROID_HOME.",
192
+ },
193
+ });
194
+ } else if (sdkDirExists === false) {
195
+ findings.push({
196
+ id: "local-properties",
197
+ level: "fail",
198
+ title: "sdk.dir points at a missing directory",
199
+ detail: "local.properties sdk.dir does not exist on disk — Gradle will fail to resolve the Android SDK.",
200
+ fix: {
201
+ auto: androidHomeSet,
202
+ description: androidHomeSet
203
+ ? "Rewrite sdk.dir from ANDROID_HOME."
204
+ : "Fix sdk.dir in local.properties to point at a real Android SDK install.",
205
+ },
206
+ });
207
+ } else {
208
+ findings.push({
209
+ id: "local-properties",
210
+ level: "ok",
211
+ title: "local.properties sdk.dir",
212
+ detail: "sdk.dir exists and points at a real directory.",
213
+ });
214
+ }
215
+
216
+ // --- environment ------------------------------------------------------------
217
+ if (typeof konanBytes === "number") {
218
+ if (konanBytes > KONAN_WARN_BYTES) {
219
+ findings.push({
220
+ id: "konan-size",
221
+ level: "warn",
222
+ title: `~/.konan is ${formatBytes(konanBytes)}`,
223
+ detail:
224
+ "The Kotlin/Native toolchain cache has accumulated old compiler versions. Stale prebuilt toolchains are safe to remove.",
225
+ fix: { auto: false, description: "Run `create-cmp clean` to report and remove stale kotlin-native-prebuilt versions." },
226
+ });
227
+ } else {
228
+ findings.push({
229
+ id: "konan-size",
230
+ level: "ok",
231
+ title: `~/.konan is ${formatBytes(konanBytes)}`,
232
+ detail: "Within the expected footprint.",
233
+ });
234
+ }
235
+ }
236
+
237
+ if (typeof freeDiskBytes === "number") {
238
+ if (freeDiskBytes < DISK_WARN_BYTES) {
239
+ findings.push({
240
+ id: "disk-free",
241
+ level: "warn",
242
+ title: `Only ${formatBytes(freeDiskBytes)} free disk space`,
243
+ detail:
244
+ "KMP builds routinely fail mid-package with `No space left on device` below ~3 GB free. Free space before building.",
245
+ fix: { auto: false, description: "Run `create-cmp clean` (project build dirs + stale ~/.konan), and consider `rm -rf ~/.gradle/caches` manually." },
246
+ });
247
+ } else {
248
+ findings.push({
249
+ id: "disk-free",
250
+ level: "ok",
251
+ title: `${formatBytes(freeDiskBytes)} free disk space`,
252
+ detail: "Enough headroom for a KMP build (needs ≥3 GB).",
253
+ });
254
+ }
255
+ }
256
+
257
+ // --- live inspector placement (must never be reachable in release) ---------
258
+ // Static check by design: no network probe, no adb — the release guarantee is
259
+ // STRUCTURAL (inspector code lives only in the androidDebug source set), so we
260
+ // verify structure. inspectorHits are Kotlin sources referencing the inspector
261
+ // endpoint ("/inspect/" or InspectorHttpServer).
262
+ if (Array.isArray(inspectorHits) && inspectorHits.length > 0) {
263
+ const leaks = inspectorHits.filter((p) => !normalizePath(p).includes("/androidDebug/"));
264
+ if (leaks.length === 0) {
265
+ findings.push({
266
+ id: "inspector-placement",
267
+ level: "ok",
268
+ title: "Live inspector is debug-only",
269
+ detail:
270
+ "All inspector endpoint code lives in the androidDebug source set — structurally absent from release builds.",
271
+ });
272
+ } else {
273
+ findings.push({
274
+ id: "inspector-placement",
275
+ level: "warn",
276
+ title: "Inspector endpoint may be reachable in RELEASE builds",
277
+ detail:
278
+ `Inspector code referenced outside androidDebug: ${leaks.join(", ")}. ` +
279
+ "The live inspector server must never ship in release — keep the server, root registry " +
280
+ "and catalog in composeApp/src/androidDebug/kotlin only (the androidRelease twin must stay a no-op).",
281
+ fix: {
282
+ auto: false,
283
+ description:
284
+ "Move the offending code back to composeApp/src/androidDebug/kotlin (release keeps only the no-op startInspector() twin).",
285
+ },
286
+ });
287
+ }
288
+ }
289
+
290
+ // --- inspector catalog drift tripwire ---------------------------------------
291
+ // The design-system catalog is a hand-registry: a token ADDED to the theme but
292
+ // missing from InspectorCatalog.kt silently vanishes from /inspect/design-system.
293
+ if (inspectorCatalog && inspectorCatalog.catalog && inspectorCatalog.theme) {
294
+ const declared = [...inspectorCatalog.theme.matchAll(/^\s*val\s+([A-Z]\w*)\s*=/gm)].map((m) => m[1]);
295
+ const missing = declared.filter((name) => !inspectorCatalog.catalog.includes(`"${name}"`));
296
+ if (missing.length === 0) {
297
+ findings.push({
298
+ id: "inspector-catalog-drift",
299
+ level: "ok",
300
+ title: "Inspector catalog covers all declared tokens",
301
+ detail: `Every theme token (${declared.length}) appears in InspectorCatalog.kt.`,
302
+ });
303
+ } else {
304
+ findings.push({
305
+ id: "inspector-catalog-drift",
306
+ level: "warn",
307
+ title: `Inspector catalog is missing ${missing.length} declared token${missing.length === 1 ? "" : "s"}`,
308
+ detail:
309
+ `Declared in the theme but absent from InspectorCatalog.kt: ${missing.join(", ")}. ` +
310
+ "These tokens will not appear on /inspect/design-system, so drift on them goes uncaught.",
311
+ fix: { auto: false, description: "Add the missing entries to InspectorCatalog.kt (read from the real theme objects)." },
312
+ });
313
+ }
314
+ }
315
+
316
+ return findings;
317
+ }
318
+
319
+ function normalizePath(p) {
320
+ return String(p).split("\\").join("/");
321
+ }
322
+
323
+ /** Human-readable byte size (GB/MB). */
324
+ export function formatBytes(bytes) {
325
+ if (bytes >= GIB) return `${(bytes / GIB).toFixed(1)} GB`;
326
+ const mib = 1024 ** 2;
327
+ if (bytes >= mib) return `${(bytes / mib).toFixed(0)} MB`;
328
+ return `${bytes} B`;
329
+ }
@@ -0,0 +1,102 @@
1
+ // Loader + helpers for the proven-green version-set registry
2
+ // (src/versions/registry.json). Pure logic (nearest-set matching, validation)
3
+ // is exported for unit testing.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const REGISTRY_PATH = path.join(__dirname, "..", "versions", "registry.json");
11
+
12
+ /**
13
+ * Load the registry JSON (or a custom one for tests).
14
+ * @param {string} [registryPath]
15
+ */
16
+ export function loadRegistry(registryPath = REGISTRY_PATH) {
17
+ const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
18
+ const errors = validateRegistry(registry);
19
+ if (errors.length) {
20
+ throw new Error(`Invalid version-set registry ${registryPath}:\n - ${errors.join("\n - ")}`);
21
+ }
22
+ return registry;
23
+ }
24
+
25
+ /**
26
+ * Structural validation of a registry object. Returns a list of problems
27
+ * (empty = valid). Also enforces the kotlin↔ksp lockstep INSIDE each set —
28
+ * a registry must never ship a broken pairing.
29
+ * @param {object} registry
30
+ * @returns {string[]}
31
+ */
32
+ export function validateRegistry(registry) {
33
+ const errors = [];
34
+ if (!registry || typeof registry !== "object") return ["registry is not an object"];
35
+ if (!Array.isArray(registry.sets) || registry.sets.length === 0) {
36
+ return ["registry.sets must be a non-empty array"];
37
+ }
38
+ const seen = new Set();
39
+ registry.sets.forEach((set, i) => {
40
+ const where = `sets[${i}]${set && set.id ? ` (${set.id})` : ""}`;
41
+ if (!set || typeof set !== "object") {
42
+ errors.push(`${where}: not an object`);
43
+ return;
44
+ }
45
+ if (typeof set.id !== "string" || !set.id) errors.push(`${where}: missing string id`);
46
+ if (set.id && seen.has(set.id)) errors.push(`${where}: duplicate id`);
47
+ seen.add(set.id);
48
+ if (!set.versions || typeof set.versions !== "object" || Array.isArray(set.versions)) {
49
+ errors.push(`${where}: missing versions object`);
50
+ return;
51
+ }
52
+ for (const [k, v] of Object.entries(set.versions)) {
53
+ if (typeof v !== "string" || !v) errors.push(`${where}: versions.${k} must be a non-empty string`);
54
+ }
55
+ const { kotlin, ksp } = set.versions;
56
+ if (kotlin && ksp && !ksp.startsWith(`${kotlin}-`)) {
57
+ errors.push(`${where}: ksp "${ksp}" is not in lockstep with kotlin "${kotlin}" (must be "${kotlin}-<kspVersion>")`);
58
+ }
59
+ if (set.gradleProperties && typeof set.gradleProperties !== "object") {
60
+ errors.push(`${where}: gradleProperties must be an object`);
61
+ }
62
+ if (set.gradleWrapper && typeof set.gradleWrapper.distributionUrl !== "string") {
63
+ errors.push(`${where}: gradleWrapper.distributionUrl must be a string`);
64
+ }
65
+ if (set.notes && !Array.isArray(set.notes)) errors.push(`${where}: notes must be an array`);
66
+ });
67
+ return errors;
68
+ }
69
+
70
+ /** The default upgrade target: the newest (last) set. */
71
+ export function latestSet(registry) {
72
+ return registry.sets[registry.sets.length - 1];
73
+ }
74
+
75
+ /** Look a set up by id; returns null when absent. */
76
+ export function getSet(registry, id) {
77
+ return registry.sets.find((s) => s.id === id) || null;
78
+ }
79
+
80
+ /**
81
+ * The registry set that best matches a project's current [versions] values
82
+ * (most exact key+value matches; ties go to the NEWER set). Used by doctor to
83
+ * report drift against the closest known-green baseline.
84
+ * @param {object} registry
85
+ * @param {Map<string,{value:string}>|Record<string,string>} projectVersions
86
+ * @returns {{set:object, matches:number}|null}
87
+ */
88
+ export function nearestSet(registry, projectVersions) {
89
+ const values =
90
+ projectVersions instanceof Map
91
+ ? Object.fromEntries([...projectVersions].map(([k, v]) => [k, v.value]))
92
+ : projectVersions;
93
+ let best = null;
94
+ for (const set of registry.sets) {
95
+ let matches = 0;
96
+ for (const [k, v] of Object.entries(set.versions)) {
97
+ if (values[k] === v) matches++;
98
+ }
99
+ if (!best || matches >= best.matches) best = { set, matches };
100
+ }
101
+ return best;
102
+ }
@@ -0,0 +1,116 @@
1
+ // Package-source directory rename: com/example/app → com/<org>/<app>.
2
+ //
3
+ // This is the classic scaffolder bug: a naive `mv com/example/app com/acme/app`
4
+ // fails or corrupts when the new package SHARES a prefix with the old one
5
+ // (e.g. com.example.app → com.example.demo keeps `com/example`), when the new
6
+ // package is DEEPER or SHALLOWER, or when an intermediate dir already holds
7
+ // other content. We handle it by:
8
+ // 1. moving the leaf source tree to a unique temp location (atomic rename),
9
+ // 2. removing now-empty old intermediate dirs,
10
+ // 3. creating the new intermediate dirs,
11
+ // 4. moving the temp tree into the final location.
12
+ // Step (1)+(4) decouples source and destination so overlapping prefixes,
13
+ // nesting changes, and re-runs (idempotency) all behave.
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ const OLD_PACKAGE_PATH = "com/example/app";
19
+
20
+ /**
21
+ * Rename the package source dir under each root.
22
+ * @param {string} projectDir absolute path to the stamped project
23
+ * @param {string[]} packageSourceRoots e.g. ["composeApp/src/commonMain/kotlin", ...]
24
+ * @param {string} newPackagePath slash form, e.g. "com/acme/app"
25
+ * @param {object} [opts]
26
+ * @param {(msg:string)=>void} [opts.log]
27
+ * @returns {{moved: string[], skipped: string[]}}
28
+ */
29
+ export function renamePackageDirs(projectDir, packageSourceRoots, newPackagePath, opts = {}) {
30
+ const log = opts.log || (() => {});
31
+ const moved = [];
32
+ const skipped = [];
33
+
34
+ const oldRel = OLD_PACKAGE_PATH;
35
+ const newRel = newPackagePath.split("/").filter(Boolean).join("/");
36
+
37
+ for (const root of packageSourceRoots) {
38
+ const rootAbs = path.join(projectDir, root);
39
+ const oldAbs = path.join(rootAbs, oldRel);
40
+ const newAbs = path.join(rootAbs, newRel);
41
+
42
+ if (!fs.existsSync(oldAbs) || !fs.statSync(oldAbs).isDirectory()) {
43
+ // Idempotent: if already renamed (or root absent) there's nothing to do.
44
+ skipped.push(root);
45
+ continue;
46
+ }
47
+
48
+ if (path.resolve(oldAbs) === path.resolve(newAbs)) {
49
+ // Same package — no rename needed.
50
+ skipped.push(root);
51
+ continue;
52
+ }
53
+
54
+ // 1. Move the leaf to a unique temp dir under the root.
55
+ const tmpAbs = path.join(rootAbs, `.__cmp_pkg_tmp_${process.pid}_${moved.length}`);
56
+ if (fs.existsSync(tmpAbs)) fs.rmSync(tmpAbs, { recursive: true, force: true });
57
+ fs.renameSync(oldAbs, tmpAbs);
58
+
59
+ // 2. Prune now-empty old intermediate dirs (com/example, com) up to root.
60
+ pruneEmptyDirsUp(rootAbs, path.dirname(oldAbs));
61
+
62
+ // 3. Ensure new parent exists.
63
+ fs.mkdirSync(path.dirname(newAbs), { recursive: true });
64
+
65
+ // 4. Move temp into place. If destination already exists (overlap edge),
66
+ // merge contents rather than clobber.
67
+ if (fs.existsSync(newAbs)) {
68
+ mergeMove(tmpAbs, newAbs);
69
+ fs.rmSync(tmpAbs, { recursive: true, force: true });
70
+ } else {
71
+ fs.renameSync(tmpAbs, newAbs);
72
+ }
73
+
74
+ moved.push(root);
75
+ log(` renamed ${root}/${oldRel} → ${root}/${newRel}`);
76
+ }
77
+
78
+ return { moved, skipped };
79
+ }
80
+
81
+ /**
82
+ * Remove empty directories walking up from `startDir` until `stopAtDir`
83
+ * (exclusive). Stops at the first non-empty directory.
84
+ */
85
+ function pruneEmptyDirsUp(stopAtDir, startDir) {
86
+ let dir = startDir;
87
+ const stop = path.resolve(stopAtDir);
88
+ while (path.resolve(dir) !== stop && path.resolve(dir).startsWith(stop)) {
89
+ let entries;
90
+ try {
91
+ entries = fs.readdirSync(dir);
92
+ } catch {
93
+ break;
94
+ }
95
+ if (entries.length > 0) break;
96
+ fs.rmdirSync(dir);
97
+ dir = path.dirname(dir);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Recursively move src tree contents into an existing dest dir, merging.
103
+ */
104
+ function mergeMove(src, dest) {
105
+ fs.mkdirSync(dest, { recursive: true });
106
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
107
+ const s = path.join(src, entry.name);
108
+ const d = path.join(dest, entry.name);
109
+ if (entry.isDirectory()) {
110
+ mergeMove(s, d);
111
+ } else {
112
+ fs.mkdirSync(path.dirname(d), { recursive: true });
113
+ fs.renameSync(s, d);
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,123 @@
1
+ // Tiny hand-rolled JSON-Schema (draft-07 subset) validator.
2
+ // Supports exactly the keywords used by options.schema.json:
3
+ // type, required, properties, additionalProperties, enum, const,
4
+ // pattern, minLength, minItems, maxItems, items.
5
+ // Returns { valid, errors: [{ path, message }] }.
6
+
7
+ function typeOf(value) {
8
+ if (value === null) return "null";
9
+ if (Array.isArray(value)) return "array";
10
+ return typeof value; // object | string | number | boolean
11
+ }
12
+
13
+ function matchesType(value, type) {
14
+ switch (type) {
15
+ case "object":
16
+ return value !== null && typeof value === "object" && !Array.isArray(value);
17
+ case "array":
18
+ return Array.isArray(value);
19
+ case "string":
20
+ return typeof value === "string";
21
+ case "number":
22
+ return typeof value === "number";
23
+ case "integer":
24
+ return typeof value === "number" && Number.isInteger(value);
25
+ case "boolean":
26
+ return typeof value === "boolean";
27
+ case "null":
28
+ return value === null;
29
+ default:
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function validateNode(value, schema, path, errors) {
35
+ if (!schema || typeof schema !== "object") return;
36
+
37
+ if (schema.type !== undefined) {
38
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
39
+ if (!types.some((t) => matchesType(value, t))) {
40
+ errors.push({
41
+ path,
42
+ message: `expected type ${types.join(" | ")} but got ${typeOf(value)}`,
43
+ });
44
+ // Type mismatch — further keyword checks are unreliable; stop here.
45
+ return;
46
+ }
47
+ }
48
+
49
+ if (schema.const !== undefined && value !== schema.const) {
50
+ errors.push({ path, message: `must equal ${JSON.stringify(schema.const)}` });
51
+ }
52
+
53
+ if (schema.enum !== undefined && !schema.enum.includes(value)) {
54
+ errors.push({
55
+ path,
56
+ message: `must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`,
57
+ });
58
+ }
59
+
60
+ if (typeof value === "string") {
61
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
62
+ errors.push({ path, message: `must be at least ${schema.minLength} characters` });
63
+ }
64
+ if (schema.pattern !== undefined) {
65
+ let re;
66
+ try {
67
+ re = new RegExp(schema.pattern);
68
+ } catch {
69
+ re = null;
70
+ }
71
+ if (re && !re.test(value)) {
72
+ errors.push({ path, message: `must match pattern ${schema.pattern}` });
73
+ }
74
+ }
75
+ }
76
+
77
+ if (Array.isArray(value)) {
78
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
79
+ errors.push({ path, message: `must have at least ${schema.minItems} item(s)` });
80
+ }
81
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) {
82
+ errors.push({ path, message: `must have at most ${schema.maxItems} item(s)` });
83
+ }
84
+ if (schema.items) {
85
+ value.forEach((item, i) => {
86
+ validateNode(item, schema.items, `${path}[${i}]`, errors);
87
+ });
88
+ }
89
+ }
90
+
91
+ if (matchesType(value, "object")) {
92
+ if (Array.isArray(schema.required)) {
93
+ for (const key of schema.required) {
94
+ if (!(key in value)) {
95
+ errors.push({ path: path ? `${path}.${key}` : key, message: "is required" });
96
+ }
97
+ }
98
+ }
99
+ const props = schema.properties || {};
100
+ for (const [key, val] of Object.entries(value)) {
101
+ const childPath = path ? `${path}.${key}` : key;
102
+ if (props[key]) {
103
+ validateNode(val, props[key], childPath, errors);
104
+ } else if (schema.additionalProperties === false) {
105
+ errors.push({ path: childPath, message: "is not an allowed property" });
106
+ } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
107
+ validateNode(val, schema.additionalProperties, childPath, errors);
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ export function validate(value, schema) {
114
+ const errors = [];
115
+ validateNode(value, schema, "", errors);
116
+ return { valid: errors.length === 0, errors };
117
+ }
118
+
119
+ export function formatErrors(errors) {
120
+ return errors
121
+ .map((e) => ` - ${e.path || "(root)"}: ${e.message}`)
122
+ .join("\n");
123
+ }