@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
@@ -0,0 +1,513 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import fg from 'fast-glob';
5
+ import postcss from 'postcss';
6
+
7
+ import { stkRoot } from '../data.js';
8
+
9
+ // "5 is governance, 20 is safety" — see ADOPTION_APP_PLAN.md §4/§5a and the
10
+ // same pattern in moduleGraph.js's resolveOrigin/resolveOriginDeep.
11
+ const WARN_DEPTH = 3;
12
+ const MAX_DEPTH = 20;
13
+
14
+ const DEFAULT_IGNORE = [
15
+ '**/node_modules/**',
16
+ '**/dist/**',
17
+ '**/build/**',
18
+ '**/.next/**',
19
+ '**/coverage/**',
20
+ '**/storybook-static/**',
21
+ '**/.storybook*/**',
22
+ ];
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Inventory + layer classification
26
+ //
27
+ // packages/stk/build/css/tokens.css is the flat, authoritative set of every
28
+ // --stk-* custom property the design system ships (mirrors the approach
29
+ // packages/stk/scripts/token-gov/token-scanner.js already trusts for the
30
+ // same purpose). packages/stk/tokens/{base,semantic,components}/*.json are
31
+ // the DTCG source files the CSS was compiled from — their directory of
32
+ // origin is exactly the layer classification (primitive/semantic/component)
33
+ // a "layer violation via alias" finding needs, so this flattens each dir's
34
+ // nested $value leaves back into the CSS variable names Style Dictionary
35
+ // would produce (path segments joined with "-", prefixed "--stk-").
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function flattenDtcg(node, prefix, layer, out) {
39
+ if (node == null || typeof node !== 'object') return;
40
+ if ('$value' in node) {
41
+ out.set(`--stk-${prefix.join('-')}`, layer);
42
+ return;
43
+ }
44
+ for (const [key, child] of Object.entries(node)) {
45
+ if (key.startsWith('$')) continue;
46
+ flattenDtcg(child, [...prefix, key], layer, out);
47
+ }
48
+ }
49
+
50
+ function loadTokenLayers(stkPkgRoot) {
51
+ const layers = new Map(); // cssVarName -> 'primitive' | 'semantic' | 'component'
52
+ const dirs = [
53
+ ['base', 'primitive'],
54
+ ['semantic', 'semantic'],
55
+ ['components', 'component'],
56
+ ];
57
+ for (const [dir, layer] of dirs) {
58
+ const full = path.join(stkPkgRoot, 'tokens', dir);
59
+ if (!existsSync(full)) continue;
60
+ for (const file of fg.sync('**/*.json', { cwd: full, absolute: true })) {
61
+ let json;
62
+ try {
63
+ json = JSON.parse(readFileSync(file, 'utf-8'));
64
+ } catch {
65
+ continue;
66
+ }
67
+ flattenDtcg(json, [], layer, layers);
68
+ }
69
+ }
70
+ return layers;
71
+ }
72
+
73
+ export function loadTokenInventory(stkPkgRoot) {
74
+ const cssPath = path.join(stkPkgRoot, 'build', 'css', 'tokens.css');
75
+ if (!existsSync(cssPath)) {
76
+ throw new Error(`Token build not found at ${cssPath} — run "npm run build" in packages/stk first.`);
77
+ }
78
+ const src = readFileSync(cssPath, 'utf-8');
79
+ const inventory = new Set([...src.matchAll(/--stk-([\w-]+)\s*:/g)].map((m) => `--stk-${m[1]}`));
80
+ const layers = loadTokenLayers(stkPkgRoot);
81
+ return { inventory, layers };
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // var() parsing
86
+ //
87
+ // Handles nested var() (fallback chains) and multiple refs in one value
88
+ // (e.g. `padding: var(--x) var(--y)`) without needing a full CSS-values
89
+ // grammar — a hand-rolled paren-depth scan is enough for var()'s own syntax.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ function topLevelCommaIndex(s) {
93
+ let depth = 0;
94
+ for (let i = 0; i < s.length; i++) {
95
+ if (s[i] === '(') depth++;
96
+ else if (s[i] === ')') depth--;
97
+ else if (s[i] === ',' && depth === 0) return i;
98
+ }
99
+ return -1;
100
+ }
101
+
102
+ export function extractVarRefs(value) {
103
+ const refs = [];
104
+ let i = 0;
105
+ while (i < value.length) {
106
+ const start = value.indexOf('var(', i);
107
+ if (start === -1) break;
108
+ let depth = 1;
109
+ let j = start + 4;
110
+ while (j < value.length && depth > 0) {
111
+ if (value[j] === '(') depth++;
112
+ else if (value[j] === ')') depth--;
113
+ j++;
114
+ }
115
+ const inner = value.slice(start + 4, j - 1);
116
+ const commaIdx = topLevelCommaIndex(inner);
117
+ const name = (commaIdx === -1 ? inner : inner.slice(0, commaIdx)).trim();
118
+ const fallback = commaIdx === -1 ? null : inner.slice(commaIdx + 1).trim();
119
+ if (/^--[\w-]+$/.test(name)) {
120
+ refs.push({ name, fallback, start, end: j });
121
+ }
122
+ i = j;
123
+ }
124
+ return refs;
125
+ }
126
+
127
+ function isRawFallback(fallback) {
128
+ return !!fallback && !fallback.trim().startsWith('var(');
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Pass 1 — the consumer's own alias graph
133
+ //
134
+ // Scoped per-selector, not global: the same custom-property name can be
135
+ // redefined differently under different selectors (:root conformant vs
136
+ // .theme-promo drift), so resolution must never collapse scopes together
137
+ // (ADOPTION_APP_PLAN.md §5a — "the lossy alternative" is picking a dominant
138
+ // scope; this instead surfaces disagreement as partial-conformance).
139
+ //
140
+ // Known simplification: media-query context is not tracked, so a
141
+ // selector guarded by @media is treated the same as an unguarded one with
142
+ // the same selector text. Full conditional-scope modelling is a possible
143
+ // future enhancement, not required to capture the plan's core example.
144
+ // ---------------------------------------------------------------------------
145
+
146
+ export function collectCssFiles(root, ignore) {
147
+ return fg
148
+ .sync(['**/*.css'], { cwd: root, absolute: true, ignore: [...DEFAULT_IGNORE, ...ignore] })
149
+ .sort();
150
+ }
151
+
152
+ export function buildPropertyGraph(cssFiles) {
153
+ // propName -> selector -> { value, file, line }
154
+ const graph = new Map();
155
+
156
+ for (const file of cssFiles) {
157
+ let src;
158
+ try {
159
+ src = readFileSync(file, 'utf-8');
160
+ } catch {
161
+ continue;
162
+ }
163
+ let root;
164
+ try {
165
+ root = postcss.parse(src, { from: file });
166
+ } catch {
167
+ continue;
168
+ }
169
+
170
+ root.walkRules((rule) => {
171
+ const selectors = rule.selectors ?? [rule.selector];
172
+ for (const selector of selectors) {
173
+ const normalizedSelector = selector.trim();
174
+ rule.walkDecls((decl) => {
175
+ if (!decl.prop.startsWith('--')) return;
176
+ // --stk-* declarations are the design system's OWN internal token
177
+ // definitions (e.g. a synced copy of tokens.css checked into a
178
+ // consumer's src/tokens/ dir), not a consumer-authored alias — Pass
179
+ // 1 models "the consumer's own alias graph" (§5a), so these must
180
+ // never enter it. Without this, scanning a copy of Stark's own
181
+ // compiled CSS flags its legitimate internal semantic→primitive
182
+ // aliasing as consumer "layer violations."
183
+ if (decl.prop.startsWith('--stk-')) return;
184
+ let bySelector = graph.get(decl.prop);
185
+ if (!bySelector) {
186
+ bySelector = new Map();
187
+ graph.set(decl.prop, bySelector);
188
+ }
189
+ bySelector.set(normalizedSelector, {
190
+ value: decl.value,
191
+ file,
192
+ line: decl.source?.start?.line ?? null,
193
+ });
194
+ });
195
+ }
196
+ });
197
+ }
198
+
199
+ return graph;
200
+ }
201
+
202
+ function resolveValue(value, propGraph, selector, inventory, layers, visited, depth, meta) {
203
+ const trimmed = value.trim();
204
+ const refs = extractVarRefs(trimmed);
205
+ const isPureVar = refs.length === 1 && trimmed === trimmed.slice(refs[0].start, refs[0].end);
206
+
207
+ if (!isPureVar) {
208
+ // Composite value (calc(), multiple refs, mixed literal + var(), or a
209
+ // plain literal) — not a 1:1 alias, so it can't be treated as
210
+ // identity-preserving even if it happens to reference an stk token
211
+ // somewhere inside (e.g. calc(var(--stk-spacing-md) * 2)).
212
+ const rawFallbackPresent = refs.some((r) => isRawFallback(r.fallback));
213
+ return { kind: 'raw', depth, rawFallbackPresent, ...meta };
214
+ }
215
+
216
+ const { name, fallback } = refs[0];
217
+
218
+ if (name.startsWith('--stk-')) {
219
+ if (inventory.has(name)) {
220
+ return { kind: 'stk', stkToken: name, layer: layers.get(name) ?? 'unknown', depth: depth + 1, rawFallbackPresent: isRawFallback(fallback), ...meta };
221
+ }
222
+ // Looks like an stk token name but isn't in the built inventory —
223
+ // most likely stale/renamed.
224
+ return { kind: 'undefined', depth: depth + 1, staleStkName: name, rawFallbackPresent: isRawFallback(fallback), ...meta };
225
+ }
226
+
227
+ const resolved = resolveTerminal(propGraph, name, selector, inventory, layers, visited, depth + 1);
228
+
229
+ if (resolved.kind === 'undefined' && fallback) {
230
+ const fallbackResolved = resolveValue(fallback, propGraph, selector, inventory, layers, visited, depth, meta);
231
+ return { ...fallbackResolved, viaFallback: true, rawFallbackPresent: fallbackResolved.kind !== 'stk' };
232
+ }
233
+
234
+ return { ...resolved, rawFallbackPresent: resolved.rawFallbackPresent || isRawFallback(fallback) };
235
+ }
236
+
237
+ export function resolveTerminal(propGraph, propName, selector, inventory, layers, visited = new Set(), depth = 0) {
238
+ const key = `${selector}::${propName}`;
239
+ if (visited.has(key)) {
240
+ return { kind: 'cycle', depth, rawFallbackPresent: false };
241
+ }
242
+ if (depth > MAX_DEPTH) {
243
+ return { kind: 'depth-limit', depth, rawFallbackPresent: false };
244
+ }
245
+
246
+ const bySelector = propGraph.get(propName);
247
+ const boundSelector = bySelector?.has(selector) ? selector : bySelector?.has(':root') ? ':root' : null;
248
+ if (!boundSelector) {
249
+ return { kind: 'undefined', depth, rawFallbackPresent: false };
250
+ }
251
+
252
+ const decl = bySelector.get(boundSelector);
253
+ const nextVisited = new Set(visited).add(key);
254
+
255
+ return resolveValue(decl.value, propGraph, selector, inventory, layers, nextVisited, depth, {
256
+ file: decl.file,
257
+ line: decl.line,
258
+ boundSelector,
259
+ });
260
+ }
261
+
262
+ export function classifyTerminalState(kind) {
263
+ if (kind === 'stk') return 'conformant';
264
+ if (kind === 'raw') return 'drift';
265
+ return 'broken'; // undefined | cycle | depth-limit
266
+ }
267
+
268
+ function classifyProperties(propGraph, inventory, layers) {
269
+ const properties = [];
270
+ const findings = [];
271
+
272
+ for (const [propName, bySelector] of propGraph) {
273
+ const scopeResults = [];
274
+ for (const [selector, decl] of bySelector) {
275
+ const terminal = resolveTerminal(propGraph, propName, selector, inventory, layers, new Set(), 0);
276
+ const state = classifyTerminalState(terminal.kind);
277
+ scopeResults.push({ selector, file: decl.file, line: decl.line, state, terminal });
278
+ }
279
+
280
+ const states = new Set(scopeResults.map((s) => s.state));
281
+ const overall = states.size === 1 ? [...states][0] : 'partial-conformance';
282
+
283
+ for (const s of scopeResults) {
284
+ if (s.terminal.kind === 'stk' && s.terminal.layer === 'primitive') {
285
+ findings.push({
286
+ rule: 'layer-violation',
287
+ severity: 'critical',
288
+ property: propName,
289
+ selector: s.selector,
290
+ file: s.file,
291
+ line: s.line,
292
+ stkToken: s.terminal.stkToken,
293
+ });
294
+ }
295
+ if (s.terminal.kind === 'stk' && s.terminal.depth > WARN_DEPTH) {
296
+ findings.push({
297
+ rule: 'deep-alias-chain',
298
+ severity: 'warning',
299
+ property: propName,
300
+ selector: s.selector,
301
+ depth: s.terminal.depth,
302
+ });
303
+ }
304
+ if (s.state === 'drift') {
305
+ findings.push({
306
+ rule: 'drift-behind-alias',
307
+ severity: 'critical',
308
+ property: propName,
309
+ selector: s.selector,
310
+ file: s.file,
311
+ line: s.line,
312
+ });
313
+ }
314
+ if (s.state === 'broken') {
315
+ findings.push({
316
+ rule: 'broken-alias',
317
+ severity: 'critical',
318
+ property: propName,
319
+ selector: s.selector,
320
+ reason: s.terminal.kind,
321
+ file: s.file,
322
+ line: s.line,
323
+ });
324
+ }
325
+ if (s.terminal.rawFallbackPresent) {
326
+ // Works today, silently becomes a hardcoded value the day the
327
+ // aliased property is renamed — a latent-risk signal, never Critical.
328
+ findings.push({
329
+ rule: 'raw-fallback',
330
+ severity: 'info',
331
+ property: propName,
332
+ selector: s.selector,
333
+ file: s.file,
334
+ line: s.line,
335
+ });
336
+ }
337
+ }
338
+
339
+ if (overall === 'partial-conformance') {
340
+ // Nullable severity: this is an observed state, not a graded
341
+ // judgement — see ADOPTION_APP_PLAN.md §5a.
342
+ findings.push({
343
+ rule: 'partial-conformance',
344
+ severity: null,
345
+ property: propName,
346
+ scopes: scopeResults.map((s) => ({ selector: s.selector, state: s.state })),
347
+ });
348
+ }
349
+
350
+ properties.push({ name: propName, scopes: scopeResults, state: overall });
351
+ }
352
+
353
+ return { properties, findings };
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Pass 2 — usage classification
358
+ //
359
+ // Every var(...) occurrence in every declaration value (not just
360
+ // custom-property declarations) is classified against its resolved
361
+ // terminal, not its literal name.
362
+ // ---------------------------------------------------------------------------
363
+
364
+ function bindUsageScope(bySelector, selector) {
365
+ if (!bySelector) return null;
366
+ // Exact-selector match is a confident bind. Otherwise, only fall back
367
+ // when there is a single declared scope for this property anywhere —
368
+ // no competing definition to disambiguate against. When multiple scopes
369
+ // disagree, defaulting to :root would be the "lossy" choice the plan
370
+ // warns against (we have no DOM tree here to know whether this usage
371
+ // site's real rendered element sits inside the more specific scope or
372
+ // not), so it stays unresolved instead.
373
+ if (bySelector.has(selector)) return selector;
374
+ if (bySelector.size === 1) return [...bySelector.keys()][0];
375
+ return null;
376
+ }
377
+
378
+ function collectUsages(cssFiles, propGraph, inventory, layers) {
379
+ const usages = [];
380
+
381
+ for (const file of cssFiles) {
382
+ let src;
383
+ try {
384
+ src = readFileSync(file, 'utf-8');
385
+ } catch {
386
+ continue;
387
+ }
388
+ let root;
389
+ try {
390
+ root = postcss.parse(src, { from: file });
391
+ } catch {
392
+ continue;
393
+ }
394
+
395
+ root.walkRules((rule) => {
396
+ const selectors = rule.selectors ?? [rule.selector];
397
+ for (const selector of selectors) {
398
+ const normalizedSelector = selector.trim();
399
+ rule.walkDecls((decl) => {
400
+ // Custom-property declarations are Pass 1's alias-graph edges, not
401
+ // Pass 2 usage/consumption sites — scanning them here too would
402
+ // double-count every conformant alias as a phantom "direct" usage
403
+ // of whatever it ultimately points at.
404
+ if (decl.prop.startsWith('--')) return;
405
+ const refs = extractVarRefs(decl.value);
406
+ for (const ref of refs) {
407
+ const site = {
408
+ file,
409
+ line: decl.source?.start?.line ?? null,
410
+ selector: normalizedSelector,
411
+ property: decl.prop,
412
+ refName: ref.name,
413
+ };
414
+
415
+ if (ref.name.startsWith('--stk-')) {
416
+ usages.push({
417
+ ...site,
418
+ classification: inventory.has(ref.name) ? 'direct' : 'broken',
419
+ stkToken: ref.name,
420
+ });
421
+ continue;
422
+ }
423
+
424
+ const bySelector = propGraph.get(ref.name);
425
+ if (!bySelector) {
426
+ usages.push({ ...site, classification: 'broken', reason: 'undefined-property' });
427
+ continue;
428
+ }
429
+
430
+ const boundSelector = bindUsageScope(bySelector, normalizedSelector);
431
+ if (!boundSelector) {
432
+ usages.push({ ...site, classification: 'unresolved', reason: 'ambiguous-scope' });
433
+ continue;
434
+ }
435
+
436
+ const terminal = resolveTerminal(propGraph, ref.name, boundSelector, inventory, layers, new Set(), 0);
437
+ const state = classifyTerminalState(terminal.kind);
438
+ const classification = state === 'conformant' ? 'aliased' : state;
439
+ usages.push({
440
+ ...site,
441
+ classification,
442
+ boundSelector,
443
+ depth: terminal.depth,
444
+ stkToken: terminal.stkToken,
445
+ });
446
+ }
447
+ });
448
+ }
449
+ });
450
+ }
451
+
452
+ return usages;
453
+ }
454
+
455
+ // Per the plan's "report three numbers, never one": direct + aliased is the
456
+ // scored conformance number; everything that fails to resolve to an stk
457
+ // token for any reason (hardcoded/drift, broken, or genuinely unresolvable
458
+ // scope) buckets into "unresolved" for this headline view. The finer-grained
459
+ // per-usage classification (drift vs broken vs unresolved) is preserved in
460
+ // `usages` for findings/tooling that need it.
461
+ function summarizeReport(usages) {
462
+ const counts = { direct: 0, aliased: 0, unresolved: 0 };
463
+ for (const u of usages) {
464
+ if (u.classification === 'direct') counts.direct++;
465
+ else if (u.classification === 'aliased') counts.aliased++;
466
+ else counts.unresolved++;
467
+ }
468
+ const total = counts.direct + counts.aliased + counts.unresolved;
469
+ const pct = (n) => (total === 0 ? 0 : Math.round((n / total) * 1000) / 10);
470
+ return {
471
+ total,
472
+ direct: counts.direct,
473
+ directPct: pct(counts.direct),
474
+ aliased: counts.aliased,
475
+ aliasedPct: pct(counts.aliased),
476
+ unresolved: counts.unresolved,
477
+ unresolvedPct: pct(counts.unresolved),
478
+ conformancePct: pct(counts.direct + counts.aliased),
479
+ };
480
+ }
481
+
482
+ /**
483
+ * Resolves a consumer's CSS custom-property alias graph transitively so an
484
+ * aliased --stk-* token is recognized as adopted, not counted as absent
485
+ * (ADOPTION_APP_PLAN.md §5a). Web-only: CSS var() has no React Native
486
+ * equivalent (RN indirection is JS-object based) — that's a separate,
487
+ * later resolver.
488
+ */
489
+ export function resolveTokenAliases(root, { platform = 'web', ignore = [] } = {}) {
490
+ if (platform !== 'web') {
491
+ throw new Error(`resolveTokenAliases only supports platform "web" (got "${platform}") — CSS custom properties have no RN equivalent.`);
492
+ }
493
+
494
+ const stkPkgRoot = stkRoot();
495
+ const { inventory, layers } = loadTokenInventory(stkPkgRoot);
496
+ const cssFiles = collectCssFiles(root, ignore);
497
+ const propGraph = buildPropertyGraph(cssFiles);
498
+ const { properties, findings } = classifyProperties(propGraph, inventory, layers);
499
+ const usages = collectUsages(cssFiles, propGraph, inventory, layers);
500
+ const report = summarizeReport(usages);
501
+
502
+ return {
503
+ platform,
504
+ root,
505
+ scannedFiles: cssFiles.length,
506
+ tokenInventoryTotal: inventory.size,
507
+ aliasCount: propGraph.size, // distinct consumer-declared custom properties — a roadmap signal, not a violation
508
+ properties,
509
+ usages,
510
+ findings,
511
+ report,
512
+ };
513
+ }