create-cmp-cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +97 -4
- package/src/scaffold.mjs +52 -1
- package/template/.claude/skills/add-feature/SKILL.md +35 -10
- package/template/.claude/skills/add-repository/SKILL.md +1 -1
- package/template/.claude/skills/add-screen/SKILL.md +13 -7
- package/template/.githooks/pre-push +24 -0
- package/template/CLAUDE.md +213 -47
- package/template/README.md +32 -27
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
- package/template/docs/ARCHITECTURE.md +372 -34
- package/template/docs/TESTING.md +13 -5
- package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
- package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
- package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
- package/template/qa/approvals.json +42 -0
- package/template/qa/approve.mjs +139 -0
- package/template/qa/arch-doc.mjs +69 -0
- package/template/qa/comment.mjs +76 -0
- package/template/qa/comments.json +4 -0
- package/template/qa/e2e/smoke.yaml +6 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +822 -0
- package/template/qa/lib/arch-doc.mjs +451 -0
- package/template/qa/lib/comments.mjs +252 -0
- package/template/qa/lib/component-stories.mjs +183 -0
- package/template/qa/lib/inputs-hash.mjs +5 -1
- package/template/qa/scaffold-feature.mjs +184 -67
- package/template/qa/setup-hooks.mjs +33 -0
- package/template/qa/verify.mjs +181 -15
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +49 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
|
@@ -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
|
-
|
|
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) {
|