create-cmp-cli 0.7.1 → 0.9.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 (79) hide show
  1. package/README.md +72 -11
  2. package/llms.txt +6 -2
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +8 -1
  5. package/src/lib/adr-seed.mjs +178 -0
  6. package/src/lib/registry.mjs +15 -2
  7. package/src/lib/tabs.mjs +91 -4
  8. package/src/lib/upgrade.mjs +49 -5
  9. package/src/scaffold.mjs +52 -1
  10. package/src/versions/candidates.json +4 -0
  11. package/src/versions/registry.json +88 -0
  12. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  13. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  14. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  15. package/template/.githooks/pre-push +24 -0
  16. package/template/CLAUDE.md +196 -48
  17. package/template/README.md +23 -27
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  20. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
  21. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  22. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  55. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  56. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  57. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  58. package/template/docs/ARCHITECTURE.md +317 -34
  59. package/template/docs/TESTING.md +6 -5
  60. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  61. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  62. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  63. package/template/qa/approvals.json +42 -0
  64. package/template/qa/approve.mjs +139 -0
  65. package/template/qa/arch-doc.mjs +69 -0
  66. package/template/qa/comment.mjs +76 -0
  67. package/template/qa/comments.json +4 -0
  68. package/template/qa/golden/home.json +3 -3
  69. package/template/qa/lib/approvals.mjs +806 -0
  70. package/template/qa/lib/arch-doc.mjs +451 -0
  71. package/template/qa/lib/comments.mjs +252 -0
  72. package/template/qa/lib/component-stories.mjs +183 -0
  73. package/template/qa/lib/inputs-hash.mjs +5 -1
  74. package/template/qa/scaffold-feature.mjs +184 -67
  75. package/template/qa/setup-hooks.mjs +33 -0
  76. package/template/qa/verify.mjs +118 -9
  77. package/template/specs/app-base.spec.md +44 -7
  78. package/template/specs/home.spec.md +7 -4
  79. package/template/specs/intent.md +50 -0
@@ -0,0 +1,252 @@
1
+ // The comments ledger — the console's talk-back channel (VERIFICATION-LAYER-DESIGN.md
2
+ // §7.3). Approvals stay binding; comments are advisory input the agent must read, act
3
+ // on, and close. This file is the bridge seam: the console's `POST /api/comment` (via a
4
+ // dynamic bridge, same degrade-honestly pattern as approvals-bridge.mjs) and the
5
+ // `review_comments`/`resolve_comment` MCP tools call the SAME functions this file
6
+ // exports — the contract below is binding for both sides and must not drift.
7
+ //
8
+ // Three concerns, kept separable (mirrors qa/lib/approvals.mjs's split):
9
+ // 1. STATE (`qa/comments.json`) — the ledger: { schema, comments: Comment[] }.
10
+ // 2. VALIDATION (`addComment`) — refuses empty text and malformed/unknown targets
11
+ // before anything is written. Refusal over fabrication: an invalid comment is
12
+ // never silently coerced into a valid-looking one.
13
+ // 3. TRANSITIONS (`addComment`/`resolveComment`) — append-only; resolving never
14
+ // deletes a comment, it flips status and records who closed it and why.
15
+ //
16
+ // Read/write asymmetry is deliberate and differs from approvals.mjs on purpose:
17
+ // - A MISSING file is tolerated as the empty seed on read (a brand-new project has
18
+ // no comments yet — that's not corruption) and is created on first write.
19
+ // - A file that EXISTS but is corrupt (unparsable JSON, wrong shape, or a schema
20
+ // string that isn't "cmp-comments/1") is NOT tolerated on read — listComments
21
+ // throws a descriptive error instead of returning an empty list. Approvals can
22
+ // safely treat corruption as "all unreviewed" because that is the conservative
23
+ // (non-blocking) default; silently reading a broken comments ledger as "no
24
+ // comments" would instead HIDE real human feedback, which is the one thing this
25
+ // file exists to surface. Honest failure beats a fabricated empty inbox. Writers
26
+ // (addComment/resolveComment) catch that same error and turn it into
27
+ // {ok:false, reason} — they never overwrite a ledger they could not parse.
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+
32
+ export const COMMENTS_REL_PATH = "qa/comments.json";
33
+ export const COMMENTS_SCHEMA = "cmp-comments/1";
34
+
35
+ /** target.type -> the fields addComment requires on `target` for that type. */
36
+ const TARGET_FIELD_REQUIREMENTS = {
37
+ screen: ["screen"],
38
+ element: ["screen", "testTag"],
39
+ "spec-line": ["file", "clauseId"],
40
+ "design-system": ["token"],
41
+ architecture: ["path"],
42
+ general: [],
43
+ };
44
+
45
+ const VALID_TARGET_TYPES = Object.keys(TARGET_FIELD_REQUIREMENTS);
46
+
47
+ function ledgerPath(root) {
48
+ return path.join(root, COMMENTS_REL_PATH);
49
+ }
50
+
51
+ /**
52
+ * Parse a comments.json payload already read from disk. Throws a descriptive
53
+ * Error for anything that isn't a well-formed `{schema, comments:[]}` ledger —
54
+ * callers decide whether that means "surface it" (read) or "refuse the write"
55
+ * (addComment/resolveComment).
56
+ * @param {string} raw
57
+ * @returns {{schema: string, comments: object[]}}
58
+ */
59
+ function parseLedger(raw) {
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ } catch {
64
+ throw new Error(
65
+ `${COMMENTS_REL_PATH} is not valid JSON — refusing to treat it as an empty ledger (that would silently hide any comments it actually contains). Fix or restore the file.`,
66
+ );
67
+ }
68
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.comments)) {
69
+ throw new Error(
70
+ `${COMMENTS_REL_PATH} has an unexpected shape (expected {schema, comments: [...]}) — refusing to read it as a ledger.`,
71
+ );
72
+ }
73
+ if (parsed.schema !== undefined && parsed.schema !== COMMENTS_SCHEMA) {
74
+ throw new Error(
75
+ `${COMMENTS_REL_PATH} declares schema "${parsed.schema}", expected "${COMMENTS_SCHEMA}" — refusing to read an unknown-schema ledger.`,
76
+ );
77
+ }
78
+ return { schema: COMMENTS_SCHEMA, comments: parsed.comments };
79
+ }
80
+
81
+ /**
82
+ * Read the ledger. A MISSING file is the tolerated empty seed. A file that
83
+ * exists but fails `parseLedger` throws — see the file-level note on why reads
84
+ * do not tolerate corruption the way qa/lib/approvals.mjs does.
85
+ * @param {string} root
86
+ * @returns {{schema: string, comments: object[]}}
87
+ */
88
+ function readLedger(root) {
89
+ let raw;
90
+ try {
91
+ raw = fs.readFileSync(ledgerPath(root), "utf8");
92
+ } catch {
93
+ return { schema: COMMENTS_SCHEMA, comments: [] };
94
+ }
95
+ return parseLedger(raw);
96
+ }
97
+
98
+ /**
99
+ * Write the ledger (deterministic key order, trailing newline) — creates
100
+ * qa/comments.json and its parent dir if this is the first write.
101
+ * @param {string} root
102
+ * @param {{comments: object[]}} state
103
+ */
104
+ function writeLedger(root, state) {
105
+ const p = ledgerPath(root);
106
+ fs.mkdirSync(path.dirname(p), { recursive: true });
107
+ const out = { schema: COMMENTS_SCHEMA, comments: state.comments };
108
+ fs.writeFileSync(p, `${JSON.stringify(out, null, 2)}\n`);
109
+ }
110
+
111
+ /**
112
+ * Next id: "c" + (1 + the highest existing numeric suffix), so ids are
113
+ * monotonic and never reused even if the ledger is edited by hand between
114
+ * calls (count-based numbering would reuse an id after any external edit;
115
+ * max-based numbering does not).
116
+ * @param {object[]} comments
117
+ * @returns {string}
118
+ */
119
+ function nextId(comments) {
120
+ let max = 0;
121
+ for (const c of comments) {
122
+ const m = typeof c.id === "string" && c.id.match(/^c(\d+)$/);
123
+ if (m) max = Math.max(max, Number(m[1]));
124
+ }
125
+ return `c${max + 1}`;
126
+ }
127
+
128
+ function nonEmptyString(v) {
129
+ return typeof v === "string" && v.trim().length > 0;
130
+ }
131
+
132
+ // ── Reads ────────────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Every comment in the ledger, optionally filtered by status. Throws if
136
+ * qa/comments.json exists but is corrupt or declares an unknown schema (see
137
+ * the file-level note) — callers that must never throw (a console route, a
138
+ * blocking MCP tool) should catch and surface the message rather than
139
+ * swallow it into a fabricated empty list.
140
+ * @param {string} root
141
+ * @param {{status?: "open"|"resolved"}} [opts]
142
+ * @returns {{schema: string, comments: object[]}}
143
+ */
144
+ export function listComments(root, opts = {}) {
145
+ const state = readLedger(root);
146
+ const comments = opts.status ? state.comments.filter((c) => c.status === opts.status) : state.comments;
147
+ return { schema: COMMENTS_SCHEMA, comments };
148
+ }
149
+
150
+ // ── Writes ───────────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Add a comment. Refuses (never throws):
154
+ * - empty or whitespace-only `text`
155
+ * - a missing/malformed `target` or an unknown `target.type`
156
+ * - a `target` missing a field its type requires (screen -> screen; element ->
157
+ * screen, testTag; spec-line -> file, clauseId; design-system -> token;
158
+ * architecture -> path; general -> none)
159
+ * - a ledger that exists but cannot be parsed (corrupt/unknown-schema) — the
160
+ * write is refused rather than overwriting a file we could not honestly read
161
+ * @param {string} root
162
+ * @param {{target: object, text: string, author?: string}} input
163
+ * @returns {{ok: true, comment: object} | {ok: false, reason: string}}
164
+ */
165
+ export function addComment(root, { target, text, author } = {}) {
166
+ if (!nonEmptyString(text)) {
167
+ return { ok: false, reason: "comment text is empty or whitespace-only — refusing to record an empty comment." };
168
+ }
169
+ if (!target || typeof target !== "object" || typeof target.type !== "string") {
170
+ return {
171
+ ok: false,
172
+ reason: `comment target is missing or malformed — expected {type, ...} with type one of: ${VALID_TARGET_TYPES.join(", ")}.`,
173
+ };
174
+ }
175
+ const requiredFields = TARGET_FIELD_REQUIREMENTS[target.type];
176
+ if (!requiredFields) {
177
+ return {
178
+ ok: false,
179
+ reason: `unknown target type "${target.type}" — valid types: ${VALID_TARGET_TYPES.join(", ")}.`,
180
+ };
181
+ }
182
+ const missingFields = requiredFields.filter((f) => !nonEmptyString(target[f]));
183
+ if (missingFields.length > 0) {
184
+ return {
185
+ ok: false,
186
+ reason: `target type "${target.type}" requires ${requiredFields.join(", ")} — missing or empty: ${missingFields.join(", ")}.`,
187
+ };
188
+ }
189
+
190
+ let state;
191
+ try {
192
+ state = readLedger(root);
193
+ } catch (err) {
194
+ return { ok: false, reason: err.message };
195
+ }
196
+
197
+ const comment = {
198
+ id: nextId(state.comments),
199
+ target,
200
+ text: text.trim(),
201
+ author: nonEmptyString(author) ? author.trim() : "anonymous",
202
+ createdAt: new Date().toISOString(),
203
+ status: "open",
204
+ };
205
+ writeLedger(root, { comments: [...state.comments, comment] });
206
+ return { ok: true, comment };
207
+ }
208
+
209
+ /**
210
+ * Resolve a comment: stamps status "resolved", who closed it, when, and an
211
+ * optional note explaining what changed as a result. Refuses (never throws):
212
+ * - an unknown id
213
+ * - a comment that is already resolved (double-resolve)
214
+ * - a ledger that exists but cannot be parsed
215
+ * @param {string} root
216
+ * @param {string} id
217
+ * @param {{note?: string, author?: string}} [opts]
218
+ * @returns {{ok: true, comment: object} | {ok: false, reason: string}}
219
+ */
220
+ export function resolveComment(root, id, opts = {}) {
221
+ let state;
222
+ try {
223
+ state = readLedger(root);
224
+ } catch (err) {
225
+ return { ok: false, reason: err.message };
226
+ }
227
+
228
+ const idx = state.comments.findIndex((c) => c.id === id);
229
+ if (idx === -1) {
230
+ const known = state.comments.map((c) => c.id).join(", ") || "(none — the ledger is empty)";
231
+ return { ok: false, reason: `unknown comment id "${id}" — known ids: ${known}.` };
232
+ }
233
+ const existing = state.comments[idx];
234
+ if (existing.status === "resolved") {
235
+ return {
236
+ ok: false,
237
+ reason: `comment "${id}" is already resolved (at ${existing.resolvedAt} by ${existing.resolvedBy}) — refusing to double-resolve.`,
238
+ };
239
+ }
240
+
241
+ const resolved = {
242
+ ...existing,
243
+ status: "resolved",
244
+ resolvedAt: new Date().toISOString(),
245
+ resolvedBy: nonEmptyString(opts.author) ? opts.author.trim() : "anonymous",
246
+ ...(nonEmptyString(opts.note) ? { resolutionNote: opts.note.trim() } : {}),
247
+ };
248
+ const comments = [...state.comments];
249
+ comments[idx] = resolved;
250
+ writeLedger(root, { comments });
251
+ return { ok: true, comment: resolved };
252
+ }
@@ -0,0 +1,183 @@
1
+ // component-stories.mjs — the component ↔ story parity gate (the IMP-1
2
+ // screen↔registry parity idea, applied at component granularity).
3
+ //
4
+ // Every `@Composable fun` in `composeApp/src/commonMain/**/presentation/
5
+ // components/*.kt` must have a preview-registry story whose id is
6
+ // `component.<kebab-case-of-the-composable-name>` (AppHeader →
7
+ // "component.app-header"), registered in the desktopMain inspector sources
8
+ // (ComponentStories.kt, or PreviewRegistry.kt for generated conditional
9
+ // components like PlaceholderScreen). The gate fails BOTH directions, like
10
+ // specCoverage: a component with no story (the render pipeline is blind to
11
+ // it) and a story id with no component (a stale story surviving a rename).
12
+ //
13
+ // Detection is a pragmatic source scan, not a Kotlin front-end — the same
14
+ // stance as the console's components scan (inspector/mcp/src/lib/
15
+ // components.mjs, whose @Composable-window heuristic and kebab derivation
16
+ // this file mirrors; keep the two in sync).
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ /** PascalCase/camelCase → kebab-case: AppHeader → app-header, ListItemCard → list-item-card. */
22
+ export function kebabCase(name) {
23
+ return String(name)
24
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
25
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
26
+ .toLowerCase();
27
+ }
28
+
29
+ /** The registry story id a component of this name must register. */
30
+ export function componentStoryId(name) {
31
+ return `component.${kebabCase(name)}`;
32
+ }
33
+
34
+ // How far past an `@Composable` occurrence to look for the `fun Name(` it
35
+ // governs — mirrors the console scan's FUN_SEARCH_WINDOW.
36
+ const FUN_SEARCH_WINDOW = 500;
37
+
38
+ /**
39
+ * Every `@Composable fun Name(` declaration name in one file's text —
40
+ * includes private/internal composables (the console's Components page lists
41
+ * them, so the parity gate covers them too).
42
+ * @param {string} text
43
+ * @returns {string[]}
44
+ */
45
+ export function findComposableNames(text) {
46
+ const names = [];
47
+ const composableRe = /@Composable\b/g;
48
+ let m;
49
+ while ((m = composableRe.exec(text))) {
50
+ const window = text.slice(m.index, m.index + FUN_SEARCH_WINDOW);
51
+ const funMatch = window.match(/fun\s+(?:<[^>]*>\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]*>)?\s*\(/);
52
+ if (funMatch) names.push(funMatch[1]);
53
+ }
54
+ return names;
55
+ }
56
+
57
+ function walkDirs(root, wanted) {
58
+ const out = [];
59
+ (function walk(dir) {
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync(dir, { withFileTypes: true });
63
+ } catch {
64
+ return;
65
+ }
66
+ for (const e of entries) {
67
+ if (!e.isDirectory()) continue;
68
+ const p = path.join(dir, e.name);
69
+ if (e.name === wanted) out.push(p);
70
+ else walk(p);
71
+ }
72
+ })(root);
73
+ return out;
74
+ }
75
+
76
+ function ktFilesIn(dir) {
77
+ let entries;
78
+ try {
79
+ entries = fs.readdirSync(dir, { withFileTypes: true });
80
+ } catch {
81
+ return [];
82
+ }
83
+ return entries
84
+ .filter((e) => e.isFile() && e.name.endsWith(".kt"))
85
+ .map((e) => path.join(dir, e.name));
86
+ }
87
+
88
+ function walkKtFilesDeep(dir) {
89
+ const out = [];
90
+ let entries;
91
+ try {
92
+ entries = fs.readdirSync(dir, { withFileTypes: true });
93
+ } catch {
94
+ return out;
95
+ }
96
+ for (const e of entries) {
97
+ const p = path.join(dir, e.name);
98
+ if (e.isDirectory()) out.push(...walkKtFilesDeep(p));
99
+ else if (e.name.endsWith(".kt")) out.push(p);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ const STORY_ID_RE = /"component\.([a-z0-9][a-z0-9.-]*)"/g;
105
+
106
+ /**
107
+ * Evaluate component ↔ story parity for a project root.
108
+ * SKIPs (never fails) when the surface doesn't exist: no components dir, or
109
+ * no desktopMain inspector dir (the `--no-inspector` scaffold has no preview
110
+ * registry to hold stories).
111
+ * @param {string} root project root (contains composeApp/)
112
+ * @returns {{verdict: "PASS"|"FAIL"|"SKIP", reason?: string, details?: object}}
113
+ */
114
+ export function evaluateComponentStoryParity(root) {
115
+ const commonRoot = path.join(root, "composeApp", "src", "commonMain", "kotlin");
116
+ const desktopRoot = path.join(root, "composeApp", "src", "desktopMain", "kotlin");
117
+
118
+ const componentsDirs = walkDirs(commonRoot, "presentation")
119
+ .map((p) => path.join(p, "components"))
120
+ .filter((p) => fs.existsSync(p));
121
+ if (componentsDirs.length === 0) {
122
+ return { verdict: "SKIP", reason: "no presentation/components directory under commonMain — nothing to check" };
123
+ }
124
+
125
+ const inspectorDirs = walkDirs(desktopRoot, "inspector");
126
+ if (inspectorDirs.length === 0) {
127
+ return {
128
+ verdict: "SKIP",
129
+ reason: "no desktopMain inspector sources (preview harness not included) — no story registry to check against",
130
+ };
131
+ }
132
+
133
+ // Components: every @Composable in the registry dir (direct children only —
134
+ // the same surface the console's Components page scans).
135
+ const components = []; // { name, file }
136
+ for (const dir of componentsDirs) {
137
+ for (const file of ktFilesIn(dir)) {
138
+ const rel = path.relative(root, file).split(path.sep).join("/");
139
+ for (const name of findComposableNames(fs.readFileSync(file, "utf8"))) {
140
+ components.push({ name, file: rel });
141
+ }
142
+ }
143
+ }
144
+
145
+ // Stories: every quoted "component.<kebab>" id in the inspector sources.
146
+ const storyIds = new Set();
147
+ for (const dir of inspectorDirs) {
148
+ for (const file of walkKtFilesDeep(dir)) {
149
+ const text = fs.readFileSync(file, "utf8");
150
+ for (const match of text.matchAll(STORY_ID_RE)) {
151
+ storyIds.add(`component.${match[1]}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ const expectedIds = new Map(components.map((c) => [componentStoryId(c.name), c]));
157
+ const missing = [...expectedIds.entries()].filter(([id]) => !storyIds.has(id));
158
+ const orphans = [...storyIds].filter((id) => !expectedIds.has(id)).sort();
159
+
160
+ const details = {
161
+ components: components.length,
162
+ stories: storyIds.size,
163
+ missing: missing.map(([id]) => id),
164
+ orphans,
165
+ };
166
+
167
+ if (missing.length === 0 && orphans.length === 0) {
168
+ return { verdict: "PASS", details };
169
+ }
170
+
171
+ const lines = ["Component ↔ story parity broken — the components registry and its preview stories have drifted apart:"];
172
+ for (const [id, c] of missing) {
173
+ lines.push(
174
+ ` [${c.name}] ${c.file} — no component story registered. Add ScreenPreview("${id}", …) to composeApp/src/desktopMain/**/inspector/ComponentStories.kt (kebab-case of the composable name).`,
175
+ );
176
+ }
177
+ for (const id of orphans) {
178
+ lines.push(
179
+ ` ["${id}"] story id has no matching @Composable in presentation/components — remove the stale story or fix the id.`,
180
+ );
181
+ }
182
+ return { verdict: "FAIL", reason: lines.join("\n"), details };
183
+ }
@@ -125,7 +125,11 @@ function resolveSurfaceFiles(root) {
125
125
  * @returns {{ hash: string, fileCount: number }}
126
126
  */
127
127
  export function computeInputsHash(root) {
128
- const files = [...new Set(resolveSurfaceFiles(root))].sort((a, b) => a.localeCompare(b));
128
+ // Code-unit sort (default String sort), NOT localeCompare: the hash depends
129
+ // on iteration order, and ICU collation varies with the machine's locale
130
+ // (e.g. a da_DK machine orders "aa" after "z"; en orders case-insensitively
131
+ // where code units do not) — the same tree must hash identically everywhere.
132
+ const files = [...new Set(resolveSurfaceFiles(root))].sort();
129
133
 
130
134
  const overall = createHash("sha256");
131
135
  for (const relPath of files) {