@starklab/stark-mcp 0.1.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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/package.json +31 -0
  4. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
  5. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
  6. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
  7. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
  8. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
  9. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
  10. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
  11. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
  12. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
  13. package/src/adopt/catalog.js +88 -0
  14. package/src/adopt/dominionFixture.test.js +165 -0
  15. package/src/adopt/moduleGraph.js +232 -0
  16. package/src/adopt/parseSource.js +25 -0
  17. package/src/adopt/propApiResolver.js +278 -0
  18. package/src/adopt/propApiResolver.test.js +229 -0
  19. package/src/adopt/referenceResolver.js +151 -0
  20. package/src/adopt/referenceResolver.test.js +213 -0
  21. package/src/adopt/rnTailwindResolver.js +347 -0
  22. package/src/adopt/rnTailwindResolver.test.js +263 -0
  23. package/src/adopt/rnTokenAliasResolver.js +474 -0
  24. package/src/adopt/rnTokenAliasResolver.test.js +260 -0
  25. package/src/adopt/tailwindResolver.js +512 -0
  26. package/src/adopt/tailwindResolver.test.js +178 -0
  27. package/src/adopt/targetDiscovery.js +237 -0
  28. package/src/adopt/targetDiscovery.test.js +227 -0
  29. package/src/adopt/tokenAliasResolver.js +513 -0
  30. package/src/adopt/tokenAliasResolver.test.js +319 -0
  31. package/src/adopt/wrapperResolver.js +874 -0
  32. package/src/adopt/wrapperResolver.test.js +324 -0
  33. package/src/cli.js +376 -0
  34. package/src/data.js +267 -0
  35. package/src/data.test.js +231 -0
  36. package/src/index.js +8 -0
  37. package/src/server.js +149 -0
package/src/cli.js ADDED
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ listComponents,
6
+ getComponentUsage,
7
+ getComponentProps,
8
+ buildManifest,
9
+ ejectComponent,
10
+ } from './data.js';
11
+ import { resolveReferences } from './adopt/referenceResolver.js';
12
+ import { resolveWrappers } from './adopt/wrapperResolver.js';
13
+ import { resolveTokenAliases } from './adopt/tokenAliasResolver.js';
14
+ import { resolveTailwindTokens } from './adopt/tailwindResolver.js';
15
+ import { resolvePropApi } from './adopt/propApiResolver.js';
16
+ import { resolveRnTokenAliases } from './adopt/rnTokenAliasResolver.js';
17
+ import { resolveRnTailwindTokens } from './adopt/rnTailwindResolver.js';
18
+ import { discoverTargets, workspacePackageMap } from './adopt/targetDiscovery.js';
19
+
20
+ function print(value) {
21
+ console.log(JSON.stringify(value, null, 2));
22
+ }
23
+
24
+ function fail(message) {
25
+ console.error(message);
26
+ process.exitCode = 1;
27
+ }
28
+
29
+ function hasFlag(args, name) {
30
+ return args.includes(`--${name}`);
31
+ }
32
+
33
+ function flagValue(args, name, fallback) {
34
+ const prefix = `--${name}=`;
35
+ const arg = args.find(a => a.startsWith(prefix));
36
+ return arg ? arg.slice(prefix.length) : fallback;
37
+ }
38
+
39
+ // Shared by the single-target `adopt` path and the `--all-targets` loop —
40
+ // `workspacePackages` is only non-empty in the latter, where it enables
41
+ // wrapperResolver.js's cross-workspace resolution (ADOPTION_APP_PLAN.md §3e).
42
+ function runAdopt(root, platform, ignore, workspacePackages) {
43
+ const references = resolveReferences(root, { platform, ignore });
44
+ const wrappers = resolveWrappers(root, { platform, ignore, workspacePackages });
45
+ // CSS custom properties and Tailwind utility classes have no RN
46
+ // equivalent — both resolvers only run for the web platform.
47
+ const tokenAliases = platform === 'web' ? resolveTokenAliases(root, { platform, ignore }) : null;
48
+ const tailwind = platform === 'web' ? resolveTailwindTokens(root, { platform, ignore }) : null;
49
+ // prop-mapping/ is flat, with no rn/ subdirectory — there is no artifact
50
+ // to validate an RN prop against, so metric 6 only runs on web.
51
+ const propApi = platform === 'web' ? resolvePropApi(root, { platform, ignore }) : null;
52
+ // The RN JS-object token indirection resolver is the inverse: it only
53
+ // makes sense for the native platform (see rnTokenAliasResolver.js).
54
+ const rnTokenAliases = platform === 'native' ? resolveRnTokenAliases(root, { platform, ignore }) : null;
55
+ // NativeWind is the RN analog of web Tailwind — same platform split.
56
+ const rnTailwind = platform === 'native' ? resolveRnTailwindTokens(root, { platform, ignore }) : null;
57
+ return {
58
+ ...references,
59
+ wrappers: wrappers.wrappers,
60
+ byComponent: wrappers.byComponent,
61
+ contradictions: wrappers.contradictions,
62
+ unresolvedDeclarations: wrappers.unresolvedDeclarations,
63
+ provenanceTriple: wrappers.provenanceTriple,
64
+ dominionConfigFound: wrappers.dominionConfigFound,
65
+ tokenAliases,
66
+ tailwind,
67
+ propApi,
68
+ rnTokenAliases,
69
+ rnTailwind,
70
+ };
71
+ }
72
+
73
+ const HELP = `stark-cli — query the Stark design system catalog from the terminal
74
+
75
+ Usage:
76
+ stark-cli list
77
+ stark-cli usage <Component>
78
+ stark-cli props <Component> [--platform=web|native] [--tokens]
79
+ stark-cli manifest
80
+ stark-cli eject <Component> [--platform=web|native] [--out=<dir>] [--force]
81
+ stark-cli adopt [path] [--platform=web|native] [--ignore=<glob>,<glob>,...] [--all-targets]
82
+ stark-cli targets [path]
83
+
84
+ Options:
85
+ --platform=<web|native> Target platform for "props", "eject", and "adopt" (default: web)
86
+ --tokens Include component token JSON in "props" output
87
+ --out=<dir> Where to copy the component source (default: ./stark-eject/<Component>)
88
+ --force Overwrite the output directory if it already exists and isn't empty
89
+ --ignore=<globs> Extra comma-separated glob patterns to exclude from "adopt"'s scan
90
+ --all-targets Discover every workspace target under [path] and run "adopt" on
91
+ each in-scope one, instead of treating [path] as a single target
92
+
93
+ "eject --platform=native" copies from @starklab/stk-react-native
94
+ instead of @starklab/stk-components.
95
+
96
+ "adopt [path]" scans a consumer repo (default: current directory) for
97
+ references to catalog components and reports usage counts per component,
98
+ classified by how certain each reference is (jsx/createElement/hoc are
99
+ counted usages; indirect needs review; reexport isn't a usage). Every
100
+ catalog component is reported, including ones with zero references —
101
+ never derive the component list from the scan alone.
102
+
103
+ It also resolves the consumer's own wrapper components (e.g. a local
104
+ MyButton that renders <Button/>) down to the catalog component each one
105
+ ultimately wraps, classifies how faithfully each wrapper passes props
106
+ through (transparent/constraining/augmenting/divergent), and counts how
107
+ often each wrapper is itself used — reported under "wrappers" and rolled
108
+ up per-component under "byComponent". A dominion.config.json at the repo
109
+ root ({"wrappers": {"<path>": "<ComponentName>"}}) fills in wrappers
110
+ auto-detection couldn't resolve; it never overrides a contradicting
111
+ auto-detected terminal (see "contradictions" in the output).
112
+
113
+ On the web platform, "adopt" also resolves the consumer's own CSS custom-
114
+ property alias graph (e.g. --app-primary: var(--stk-surface-brand-1-default))
115
+ transitively, so an aliased token counts as adopted rather than absent —
116
+ reported under "tokenAliases". Each consumer property is classified
117
+ conformant / drift (aliases a raw value) / broken (undefined or cyclic),
118
+ or partial-conformance when the same property resolves differently under
119
+ different selectors (e.g. :root vs a theme override) — never collapsed to
120
+ one dominant scope. Findings also flag alias chains through a primitive
121
+ token (layer-violation), chains deeper than 3 hops, and raw-value var()
122
+ fallbacks (info-level: works today, silently hardcodes if the aliased
123
+ property is ever renamed). "tokenAliases.report" gives the three headline
124
+ numbers — direct / aliased / unresolved — never a single collapsed score.
125
+ Not run for --platform=native (CSS custom properties have no RN
126
+ equivalent).
127
+
128
+ Also on the web platform, "adopt" resolves Tailwind's own indirection layer:
129
+ a v4 CSS "@theme{}" block or a v3 "tailwind.config.{js,cjs,mjs,ts}"
130
+ theme.extend.colors map. A utility class like "bg-primary" has no var() at
131
+ its call site, so tokenAliasResolver alone can't see it — tailwind resolves
132
+ the theme key back through the same alias graph and scans JSX/TSX
133
+ "className"/"class" attributes for statically-known utility usages,
134
+ reported under "tailwind". Variant prefixes (hover:, dark:, arbitrary
135
+ [&:hover]:) are stripped before matching; an arbitrary-value var() utility
136
+ like "bg-[var(--stk-surface-brand-1-strong)]" is classified "direct", same
137
+ as a literal var() call site. Utility classes that don't correspond to a
138
+ theme key the consumer declared (Tailwind's own default palette, e.g.
139
+ "bg-red-500") are out of scope, not counted as violations. Dynamic
140
+ className expressions (clsx(), ternaries, interpolated templates) aren't
141
+ statically resolvable and are skipped.
142
+
143
+ Detection is always cheap, resolution isn't: if no Tailwind config or
144
+ @theme block is found at all, "tailwind.detected" is false and "report" is
145
+ null rather than a fabricated 0% score. If Tailwind is detected but no
146
+ theme entries could be statically extracted (e.g. a dynamic/function-based
147
+ v3 config), "tailwind.themeParseIncomplete" is true and "report" is again
148
+ null — never scored low just because resolution, not detection, hit a
149
+ wall. Not run for --platform=native (Tailwind utility classes have no RN
150
+ equivalent).
151
+
152
+ Also on the web platform, "adopt" validates every JSX call site of a
153
+ catalog component against that component's own
154
+ prop-mapping/components/<slug>.mapping.json — the legal prop surface
155
+ already used to keep Storybook and Figma in sync — reported under
156
+ "propApi". Four checks: an enum prop set to a value outside its declared
157
+ "values" (invalid-enum-value, critical), a prop marked "deprecated" in the
158
+ mapping still passed at a call site (deprecated-prop-passed, warning), a
159
+ prop marked "required" missing from a call site (required-prop-missing,
160
+ critical, unless the element also spreads props ({...rest}), which could
161
+ forward it dynamically and is left unresolved rather than flagged), and
162
+ "className"/"style" passed to a component whose contract is token-driven
163
+ styling (style-escape-hatch, warning — a visible, intentional escape hatch,
164
+ so lower severity than an invalid value). A dynamic prop value
165
+ (variant={someVar}) is never scored valid or invalid, only unresolved, same
166
+ rule as every other resolver. "propApi.report" gives the three headline
167
+ numbers — valid / invalid / unresolved — computed only from the two checks
168
+ with a real valid population (enum values, required props); the
169
+ deprecated/style-escape-hatch checks only ever produce findings, never a
170
+ "valid" row, so they sit outside that ledger the same way tokenAliases'
171
+ raw-fallback/layer-violation findings do. "required"/"deprecated" are
172
+ optional propMapEntry fields (packages/stk/prop-mapping/schema.json) that
173
+ no shipped mapping file sets yet, so those two checks currently report zero
174
+ findings against real data — an honest "not yet authored" state, not a
175
+ missing feature; population is a separate, later pass. Not run for
176
+ --platform=native (prop-mapping/ has no RN equivalent) — metric 6 is a
177
+ visible "n/a" there, never an implicit zero.
178
+
179
+ On the native platform, "adopt" instead resolves the consumer's own JS-
180
+ object token indirection — RN has no CSS custom properties, so the
181
+ indirection is a local const or object property instead of var(), e.g.
182
+ "const theme = { primary: stkSurfaceBrand1Strong }" or
183
+ "const primary = stkSurfaceBrand1Strong". Reported under "rnTokenAliases"
184
+ with the same shape as "tokenAliases": conformant / drift (aliases a raw
185
+ literal) / broken (undefined or foreign import, or a cycle), findings for
186
+ layer-violation and alias chains deeper than 3 hops, and a three-number
187
+ "report" (direct / aliased / unresolved). A usage site whose containing
188
+ object has no static origin (e.g. a "const t = useTokens()" hook return)
189
+ is still classified "direct" with "heuristic: true" when the accessed
190
+ property name is an unambiguous Stark RN token name — a name match, never
191
+ a value match. If no file imports from a @starklab/stk RN token
192
+ package (rn, rn-spacing, rn-typography, rn-easing, rn-shadows, rn-dark) at
193
+ all, "rnTokenAliases.detected" is false and "report" is null, same
194
+ detection-first rule as "tailwind". Unlike the web resolvers, this one is
195
+ static-only — there is no getComputedStyle()-equivalent runtime oracle on
196
+ React Native to reconcile against — so "rnTokenAliases.confidence" is
197
+ always "uncertain" on a detected result. Not run for --platform=web (CSS
198
+ var()/Tailwind classes are the web analog).
199
+
200
+ Also on the native platform, "adopt" resolves NativeWind — the one real
201
+ Tailwind-syntax library on React Native ("className" props via a Babel
202
+ transform, built on Tailwind's own config/theming). RN has no CSS custom
203
+ properties, so a NativeWind consumer can only alias a Stark token by
204
+ importing the RN token constant directly into tailwind.config.{js,cjs,mjs,ts}
205
+ and referencing it as a JS expression (an identifier or a single-hop member
206
+ access) — never var(), since there's no DOM/CSS runtime on RN to resolve
207
+ that against. Reported under "rnTailwind" with the same shape as "tailwind":
208
+ "properties" (theme.colors entries classified conformant/drift/broken),
209
+ "usages" (className utility occurrences resolved back through the theme
210
+ map), findings for layer-violation and alias chains deeper than 3 hops, and
211
+ a three-number "report" (direct / aliased / unresolved) — "direct" is
212
+ always 0 here, since there's no var()-arbitrary-value escape hatch on RN.
213
+ Colors-only (theme.colors / theme.extend.colors), matching the web v3
214
+ config parser's actual coverage; no v4 CSS "@theme" support (a CSS file
215
+ can't import a JS token constant). Detection requires a genuine NativeWind
216
+ signal — a "nativewind" package.json dependency, a "nativewind/babel" Babel
217
+ preset, or a "withNativeWind" Metro config wrapper — a bare
218
+ tailwind.config.js alone doesn't count, since it could belong to an
219
+ unrelated web app elsewhere in the same monorepo; "rnTailwind.detected" is
220
+ false and "report" is null when none of those are found, and
221
+ "rnTailwind.themeParseIncomplete" is true (report still null) when
222
+ NativeWind is detected but no theme.colors entries could be statically
223
+ resolved. Not run for --platform=web (Tailwind's own web resolver is the
224
+ analog).
225
+
226
+ "stark-cli targets [path]" and "adopt --all-targets" address
227
+ ADOPTION_APP_PLAN.md §3e: a product is rarely one repo passed as a single
228
+ [path] — it's a monorepo of workspace packages, only some of which
229
+ actually depend on the design system. "targets" discovers every workspace
230
+ target under [path] (from package.json#workspaces, falling back to
231
+ pnpm-workspace.yaml, falling back to a scan for any nested package.json if
232
+ neither manifest exists) and classifies each one in or out of scope: a
233
+ target with no dependency on @starklab/stk*, direct or via a
234
+ workspace sibling, is out of scope, not scored 0%. Excluded targets that
235
+ still depend on a UI framework (React, React Native, etc.) are surfaced
236
+ separately under "opportunities" — the most valuable excluded rows,
237
+ which must never be read as zeros. A dominion.config.json
238
+ {"targets": {"forceInclude": ["<dir>"], "forceExclude": {"<dir>": "<reason>"}}}
239
+ overrides the automatic classification asymmetrically: force-include is
240
+ unrestricted, force-exclude always carries a recorded reason shown beside
241
+ the excluded count.
242
+
243
+ "adopt --all-targets [path]" runs the full "adopt" scan (references,
244
+ wrappers, tokenAliases, tailwind, propApi, etc.) once per in-scope target
245
+ discovered under [path], reported under "targets" keyed by each target's
246
+ directory, alongside the same "excluded" and "opportunities" lists
247
+ "targets" reports on its own. Every per-target wrapper resolution is given
248
+ the full set of discovered workspace package names, so a wrapper that
249
+ imports a *sibling* workspace package (e.g. an app importing its own repo's
250
+ internal @acme/ui) resolves into that sibling's real source instead of
251
+ terminating at the workspace boundary as an ordinary third-party import —
252
+ without this, a shared internal UI package would score near-0% in every
253
+ consuming target and 100% in the one that defines it, the same inverted-
254
+ result problem catalog left-joining exists to prevent, recurring one level
255
+ up at package granularity.
256
+ `;
257
+
258
+ const [, , command, ...rest] = process.argv;
259
+
260
+ switch (command) {
261
+ case 'list':
262
+ print(listComponents());
263
+ break;
264
+
265
+ case 'usage': {
266
+ const [component] = rest;
267
+ if (!component) {
268
+ fail('Usage: stark-cli usage <Component>');
269
+ break;
270
+ }
271
+ try {
272
+ print(getComponentUsage(component));
273
+ } catch (err) {
274
+ fail(err.message);
275
+ }
276
+ break;
277
+ }
278
+
279
+ case 'props': {
280
+ const [component] = rest;
281
+ if (!component) {
282
+ fail('Usage: stark-cli props <Component> [--platform=web|native] [--tokens]');
283
+ break;
284
+ }
285
+ const platform = flagValue(rest, 'platform', 'web');
286
+ const includeTokens = hasFlag(rest, 'tokens');
287
+ try {
288
+ print(getComponentProps(component, platform, includeTokens));
289
+ } catch (err) {
290
+ fail(err.message);
291
+ }
292
+ break;
293
+ }
294
+
295
+ case 'manifest':
296
+ print(buildManifest());
297
+ break;
298
+
299
+ case 'eject': {
300
+ const [component] = rest;
301
+ if (!component) {
302
+ fail('Usage: stark-cli eject <Component> [--platform=web|native] [--out=<dir>] [--force]');
303
+ break;
304
+ }
305
+ const platform = flagValue(rest, 'platform', 'web');
306
+ const outDir = flagValue(rest, 'out', undefined);
307
+ const force = hasFlag(rest, 'force');
308
+ try {
309
+ print(ejectComponent(component, { platform, outDir, force }));
310
+ } catch (err) {
311
+ fail(err.message);
312
+ }
313
+ break;
314
+ }
315
+
316
+ case 'adopt': {
317
+ const [maybePath] = rest;
318
+ const target = maybePath && !maybePath.startsWith('--') ? maybePath : '.';
319
+ const root = path.resolve(process.cwd(), target);
320
+ const platform = flagValue(rest, 'platform', 'web');
321
+ const ignoreArg = flagValue(rest, 'ignore', '');
322
+ const ignore = ignoreArg ? ignoreArg.split(',').map(s => s.trim()).filter(Boolean) : [];
323
+
324
+ if (hasFlag(rest, 'all-targets')) {
325
+ try {
326
+ const discovery = discoverTargets(root);
327
+ const workspacePackages = workspacePackageMap(discovery);
328
+ const targets = {};
329
+ for (const t of discovery.inScope) {
330
+ const targetRoot = path.join(discovery.root, t.dir);
331
+ targets[t.dir] = runAdopt(targetRoot, platform, ignore, workspacePackages);
332
+ }
333
+ print({
334
+ root: discovery.root,
335
+ manifestSource: discovery.manifestSource,
336
+ monorepoTooling: discovery.monorepoTooling,
337
+ targets,
338
+ excluded: discovery.excluded,
339
+ opportunities: discovery.opportunities,
340
+ });
341
+ } catch (err) {
342
+ fail(err.message);
343
+ }
344
+ break;
345
+ }
346
+
347
+ try {
348
+ print(runAdopt(root, platform, ignore));
349
+ } catch (err) {
350
+ fail(err.message);
351
+ }
352
+ break;
353
+ }
354
+
355
+ case 'targets': {
356
+ const [maybePath] = rest;
357
+ const target = maybePath && !maybePath.startsWith('--') ? maybePath : '.';
358
+ const root = path.resolve(process.cwd(), target);
359
+ try {
360
+ print(discoverTargets(root));
361
+ } catch (err) {
362
+ fail(err.message);
363
+ }
364
+ break;
365
+ }
366
+
367
+ case undefined:
368
+ case '--help':
369
+ case '-h':
370
+ case 'help':
371
+ console.log(HELP);
372
+ break;
373
+
374
+ default:
375
+ fail(`Unknown command "${command}".\n\n${HELP}`);
376
+ }
package/src/data.js ADDED
@@ -0,0 +1,267 @@
1
+ import { createRequire } from 'node:module';
2
+ import { readFileSync, readdirSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { loadUsage } from '@starklab/stk/usage/loader.js';
6
+ import { buildCatalogFromDir } from '@starklab/stk/conformance/catalog.js';
7
+ import { runConformance, hasBlocking } from '@starklab/stk/conformance/index.js';
8
+
9
+ const require = createRequire(import.meta.url);
10
+
11
+ export function stkRoot() {
12
+ return path.dirname(require.resolve('@starklab/stk/package.json'));
13
+ }
14
+
15
+ export function mappingDir(root = stkRoot()) {
16
+ return path.join(root, 'prop-mapping', 'components');
17
+ }
18
+
19
+ // Resolves @starklab/stk-components relative to the caller's own
20
+ // project (cwd), not stark-mcp's own dependency tree — eject only makes sense
21
+ // against the component library the consumer actually has installed.
22
+ export function stkComponentsRoot(cwd = process.cwd()) {
23
+ return path.dirname(require.resolve('@starklab/stk-components/package.json', { paths: [cwd] }));
24
+ }
25
+
26
+ // Same idea as stkComponentsRoot, for the React Native port. RN component
27
+ // dirs have a different shape (single .jsx, no .css, no .figma.tsx) but
28
+ // ejectComponent's "copy every file in the source dir" logic already handles
29
+ // that without a separate code path.
30
+ export function stkReactNativeRoot(cwd = process.cwd()) {
31
+ return path.dirname(require.resolve('@starklab/stk-react-native/package.json', { paths: [cwd] }));
32
+ }
33
+
34
+ export function tokensDir(root = stkRoot()) {
35
+ return path.join(root, 'tokens', 'components');
36
+ }
37
+
38
+ // Mirrors the file-naming convention across prop-mapping/ and usage/:
39
+ // PascalCase component names on disk as kebab-case ("TextInput" -> "text-input").
40
+ export function toSlug(name) {
41
+ return String(name)
42
+ .trim()
43
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
44
+ .replace(/[\s_]+/g, '-')
45
+ .toLowerCase();
46
+ }
47
+
48
+ function readJson(absPath) {
49
+ return JSON.parse(readFileSync(absPath, 'utf-8'));
50
+ }
51
+
52
+ function mappingFiles(dir) {
53
+ return readdirSync(dir).filter(f => f.endsWith('.mapping.json'));
54
+ }
55
+
56
+ export function listComponents() {
57
+ const dir = mappingDir();
58
+ return mappingFiles(dir)
59
+ .map(file => {
60
+ const mapping = readJson(path.join(dir, file));
61
+ const slug = toSlug(mapping.component);
62
+ const usage = loadUsage(slug);
63
+ return {
64
+ name: mapping.component,
65
+ slug,
66
+ platforms: mapping.platforms ?? ['web'],
67
+ status: usage?.status ?? 'unknown',
68
+ description: usage?.description ?? null,
69
+ figmaUrl: usage?.figmaUrl ?? null,
70
+ };
71
+ })
72
+ .sort((a, b) => a.name.localeCompare(b.name));
73
+ }
74
+
75
+ export function getComponentUsage(component) {
76
+ const slug = toSlug(component);
77
+ const usage = loadUsage(slug);
78
+ if (!usage) {
79
+ throw new Error(
80
+ `No usage rules found for "${component}" (looked for slug "${slug}"). Call list_components to see valid names.`
81
+ );
82
+ }
83
+ return usage;
84
+ }
85
+
86
+ function findMappingFile(dir, slug) {
87
+ return mappingFiles(dir).find(f => toSlug(f.replace(/\.mapping\.json$/, '')) === slug);
88
+ }
89
+
90
+ // Component token JSON (tokens/components/{slug}.json) doesn't exist for pure
91
+ // layout primitives (Container, Grid, Stack...) that carry no component-layer
92
+ // tokens of their own — absence is expected, not an error.
93
+ export function getComponentTokens(component) {
94
+ const slug = toSlug(component);
95
+ const file = path.join(tokensDir(), `${slug}.json`);
96
+ try {
97
+ return readJson(file);
98
+ } catch (err) {
99
+ if (err.code === 'ENOENT') return null;
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ export function getComponentProps(component, platform = 'web', includeTokens = false) {
105
+ const dir = mappingDir();
106
+ const slug = toSlug(component);
107
+ const file = findMappingFile(dir, slug);
108
+ if (!file) {
109
+ throw new Error(
110
+ `No prop mapping found for "${component}" (looked for slug "${slug}"). Call list_components to see valid names.`
111
+ );
112
+ }
113
+ const mapping = readJson(path.join(dir, file));
114
+ const platforms = mapping.platforms ?? ['web'];
115
+ if (!platforms.includes(platform)) {
116
+ throw new Error(
117
+ `Component "${mapping.component}" does not support platform "${platform}". Available: ${platforms.join(', ')}.`
118
+ );
119
+ }
120
+ // Mirrors buildCatalogFromDir's platform-namespaced-vs-legacy-flat fallback.
121
+ const block = mapping[platform] ?? mapping;
122
+ const result = {
123
+ component: mapping.component,
124
+ figmaComponentSetName: mapping.figmaComponentSetName,
125
+ platform,
126
+ propMap: block.propMap ?? [],
127
+ ignore: block.ignore ?? [],
128
+ };
129
+ if (includeTokens) {
130
+ result.tokens = getComponentTokens(mapping.component);
131
+ }
132
+ return result;
133
+ }
134
+
135
+ // Aggregates the full catalog — usage, props (per supported platform), and
136
+ // tokens — into one artifact, so an external caller doesn't need N round
137
+ // trips (list_components, then get_component_usage/get_component_props per
138
+ // component) just to see the whole system's shape.
139
+ export function buildManifest() {
140
+ const components = listComponents().map(({ name, slug, status, description, figmaUrl, platforms }) => {
141
+ let usage = null;
142
+ try {
143
+ usage = getComponentUsage(name);
144
+ } catch {
145
+ // no usage/*.usage.json for this component
146
+ }
147
+
148
+ const props = {};
149
+ for (const platform of ['web', 'native']) {
150
+ try {
151
+ const { propMap, ignore } = getComponentProps(name, platform);
152
+ props[platform] = { propMap, ignore };
153
+ } catch {
154
+ // component doesn't support this platform
155
+ }
156
+ }
157
+
158
+ return {
159
+ name,
160
+ slug,
161
+ status,
162
+ description,
163
+ figmaUrl,
164
+ platforms,
165
+ usage,
166
+ props,
167
+ tokens: getComponentTokens(name),
168
+ };
169
+ });
170
+
171
+ return {
172
+ generatedAt: new Date().toISOString(),
173
+ package: '@starklab/stk',
174
+ componentCount: components.length,
175
+ components,
176
+ };
177
+ }
178
+
179
+ export function getLayoutCatalog(platform = 'web') {
180
+ return buildCatalogFromDir(mappingDir(), { platform });
181
+ }
182
+
183
+ export function getLayoutSchema(component, platform = 'web') {
184
+ const slug = toSlug(component);
185
+ const catalog = getLayoutCatalog(platform);
186
+ const entry = catalog.find(c => toSlug(c.name) === slug);
187
+ if (!entry) {
188
+ throw new Error(
189
+ `No layoutSchema for "${component}" on platform "${platform}" — it may be an overlay/sub-component with no ` +
190
+ `standalone layout node, or the platform doesn't apply. Call get_layout_catalog to see what's available.`
191
+ );
192
+ }
193
+ return entry;
194
+ }
195
+
196
+ // `canvasCases` (the renderer's supported node types) is optional and defaults
197
+ // to []. Omitting it silently skips the catalog<->renderer parity check inside
198
+ // runConformance — real for external callers whose renderer isn't Stark's own
199
+ // LayoutCanvas.jsx. Pass your own renderer's supported type list if you have one.
200
+ export function validateLayout({ layout, intent = {}, platform = 'web', canvasCases = [] } = {}) {
201
+ const catalog = getLayoutCatalog(platform);
202
+ const findings = runConformance({ layout, intent, catalog, canvasCases });
203
+ return { findings, hasBlocking: hasBlocking(findings) };
204
+ }
205
+
206
+ export function getGenerationProtocol() {
207
+ return readJson(path.join(stkRoot(), 'generation-protocol.json'));
208
+ }
209
+
210
+ const EJECT_PACKAGES = {
211
+ web: { name: '@starklab/stk-components', resolve: stkComponentsRoot },
212
+ native: { name: '@starklab/stk-react-native', resolve: stkReactNativeRoot },
213
+ };
214
+
215
+ // Copies one component's source out of the installed component package
216
+ // (@starklab/stk-components for web, @starklab/stk-react-native
217
+ // for native) into the caller's own project, so they can customize it locally
218
+ // without forking the whole library — at the cost of losing future upstream
219
+ // fixes for that component. Mirrors Docusaurus's "swizzle"/eject: an escape
220
+ // hatch for the rare case where the token/prop API genuinely can't express
221
+ // what's needed.
222
+ export function ejectComponent(component, { outDir, cwd = process.cwd(), force = false, platform = 'web' } = {}) {
223
+ const pkg = EJECT_PACKAGES[platform];
224
+ if (!pkg) {
225
+ throw new Error(`Unsupported platform "${platform}". Available: ${Object.keys(EJECT_PACKAGES).join(', ')}.`);
226
+ }
227
+
228
+ const match = listComponents().find(c => c.slug === toSlug(component));
229
+ if (!match) {
230
+ throw new Error(`Unknown component "${component}". Call list_components to see valid names.`);
231
+ }
232
+ const name = match.name;
233
+ if (!match.platforms.includes(platform)) {
234
+ throw new Error(`Component "${name}" does not support platform "${platform}". Available: ${match.platforms.join(', ')}.`);
235
+ }
236
+
237
+ let root;
238
+ try {
239
+ root = pkg.resolve(cwd);
240
+ } catch {
241
+ throw new Error(`Could not resolve "${pkg.name}" from ${cwd}. Install it first: npm install ${pkg.name}`);
242
+ }
243
+
244
+ const sourceDir = path.join(root, 'src', name);
245
+ if (!existsSync(sourceDir)) {
246
+ throw new Error(
247
+ `"${name}" has no source directory in the installed ${pkg.name} (looked in ${sourceDir}). ` +
248
+ `The installed version may be out of date.`
249
+ );
250
+ }
251
+
252
+ const target = path.resolve(cwd, outDir || path.join('stark-eject', name));
253
+ if (existsSync(target) && readdirSync(target).length > 0 && !force) {
254
+ throw new Error(`"${target}" already exists and is not empty. Pass force to overwrite.`);
255
+ }
256
+
257
+ // .figma.tsx is Code Connect metadata, not runtime source — an ejected
258
+ // component doesn't need it, and it may reference internal Figma node IDs.
259
+ const files = readdirSync(sourceDir).filter(f => !f.endsWith('.figma.tsx'));
260
+
261
+ mkdirSync(target, { recursive: true });
262
+ for (const file of files) {
263
+ copyFileSync(path.join(sourceDir, file), path.join(target, file));
264
+ }
265
+
266
+ return { component: name, platform, from: sourceDir, to: target, files };
267
+ }