@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,512 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import fg from 'fast-glob';
5
+ import postcss from 'postcss';
6
+ import traverseModule from '@babel/traverse';
7
+
8
+ import { stkRoot } from '../data.js';
9
+ import { parseSource } from './parseSource.js';
10
+ import { buildModuleGraph } from './moduleGraph.js';
11
+ import {
12
+ extractVarRefs,
13
+ resolveTerminal,
14
+ loadTokenInventory,
15
+ collectCssFiles,
16
+ buildPropertyGraph,
17
+ classifyTerminalState,
18
+ } from './tokenAliasResolver.js';
19
+
20
+ const traverse = traverseModule.default ?? traverseModule;
21
+
22
+ // "5 is governance, 20 is safety" — see ADOPTION_APP_PLAN.md §4/§5a. Kept as
23
+ // its own local constant rather than importing tokenAliasResolver's (not
24
+ // exported) — resolveTerminal already enforces MAX_DEPTH internally; this
25
+ // file only needs the warn threshold for its own findings.
26
+ const WARN_DEPTH = 3;
27
+
28
+ const DEFAULT_IGNORE = [
29
+ '**/node_modules/**',
30
+ '**/dist/**',
31
+ '**/build/**',
32
+ '**/.next/**',
33
+ '**/coverage/**',
34
+ '**/storybook-static/**',
35
+ '**/.storybook*/**',
36
+ ];
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Namespace → utility-class-prefix table
40
+ //
41
+ // Deliberately a bounded subset, not a full Tailwind theme-key clone: color,
42
+ // spacing (named keys), radius, shadow, font-family. This covers the
43
+ // dominant real-world case — a consumer re-namespacing Stark's color scale
44
+ // under Tailwind (ADOPTION_APP_PLAN.md §5a's own worked example) — plus the
45
+ // next most common indirections. Deliberately NOT covered: font-size
46
+ // (--text-*), because its utility prefix ("text-") collides with the color
47
+ // namespace's own "text-" prefix (Tailwind itself disambiguates by checking
48
+ // which scale actually has the key; replicating that is disproportionate for
49
+ // a dimension this repo's own token rules already steer away from — text
50
+ // styles own typography, not tokens, per CLAUDE.md), tracking/leading,
51
+ // breakpoints/containers, ease/animate, blur/perspective/aspect. A bare
52
+ // utility keyword with no key suffix (`rounded`, `shadow`, `border` alone)
53
+ // is also out of scope — it almost always resolves to Tailwind's own
54
+ // hardcoded default, not a named theme key.
55
+ //
56
+ // Prefixes are matched longest-first so e.g. "ring-offset" wins over "ring",
57
+ // "gap-x" over "gap", "min-w" over "w".
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const NAMESPACES = {
61
+ color: {
62
+ cssPrefix: 'color',
63
+ classPrefixes: [
64
+ 'ring-offset', 'ring', 'bg', 'text', 'border', 'outline', 'decoration',
65
+ 'accent', 'caret', 'divide', 'fill', 'stroke', 'placeholder', 'from', 'via', 'to',
66
+ ],
67
+ },
68
+ spacing: {
69
+ cssPrefix: 'spacing',
70
+ classPrefixes: [
71
+ 'min-w', 'max-w', 'min-h', 'max-h', 'gap-x', 'gap-y', 'space-x', 'space-y',
72
+ 'px', 'py', 'pt', 'pr', 'pb', 'pl', 'ps', 'pe', 'p',
73
+ 'mx', 'my', 'mt', 'mr', 'mb', 'ml', 'ms', 'me', 'm',
74
+ 'gap', 'w', 'h', 'size', 'inset', 'top', 'right', 'bottom', 'left',
75
+ ],
76
+ },
77
+ radius: { cssPrefix: 'radius', classPrefixes: ['rounded'] },
78
+ shadow: { cssPrefix: 'shadow', classPrefixes: ['shadow'] },
79
+ font: { cssPrefix: 'font', classPrefixes: ['font'] },
80
+ };
81
+
82
+ const ALL_PREFIXES = Object.entries(NAMESPACES)
83
+ .flatMap(([, def]) => def.classPrefixes.map((prefix) => ({ prefix, cssPrefix: def.cssPrefix })))
84
+ .sort((a, b) => b.prefix.length - a.prefix.length);
85
+
86
+ export function matchUtilityBase(base) {
87
+ for (const { prefix, cssPrefix } of ALL_PREFIXES) {
88
+ if (base.startsWith(`${prefix}-`)) {
89
+ const key = base.slice(prefix.length + 1);
90
+ if (!key) continue;
91
+ return `--${cssPrefix}-${key}`;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ // Strips variant prefixes (`hover:`, `dark:`, `sm:`, `data-[state=open]:`,
98
+ // `[&:hover]:`) by splitting on the LAST top-level colon — bracket-aware, so
99
+ // a colon inside an arbitrary-variant selector (`[&:hover]:bg-primary`)
100
+ // isn't mistaken for the variant/utility boundary.
101
+ export function stripVariants(cls) {
102
+ let depth = 0;
103
+ let lastColon = -1;
104
+ for (let i = 0; i < cls.length; i++) {
105
+ if (cls[i] === '[') depth++;
106
+ else if (cls[i] === ']') depth--;
107
+ else if (cls[i] === ':' && depth === 0) lastColon = i;
108
+ }
109
+ return lastColon === -1 ? cls : cls.slice(lastColon + 1);
110
+ }
111
+
112
+ // `bg-[var(--stk-surface-brand-1-strong)]` — an arbitrary-value utility
113
+ // whose bracket content is itself a var() call, bypassing the theme layer
114
+ // entirely. This is the Tailwind-syntax equivalent of a direct var() call
115
+ // site in plain CSS, so it's resolved the same way rather than through the
116
+ // theme map.
117
+ function matchArbitraryVar(base) {
118
+ const m = base.match(/^[a-z][\w-]*-\[(.+)\]$/i);
119
+ if (!m) return null;
120
+ const inner = m[1];
121
+ const refs = extractVarRefs(inner);
122
+ const isPureVar = refs.length === 1 && inner.trim() === inner.trim().slice(refs[0].start, refs[0].end);
123
+ return isPureVar ? refs[0] : null;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Theme extraction — v4 CSS @theme blocks
128
+ //
129
+ // Tailwind v4's @theme block compiles to plain `:root { --key: value; }`
130
+ // declarations, so its entries are folded into the SAME per-selector alias
131
+ // graph the token-alias resolver builds from ordinary CSS (at :root scope) —
132
+ // resolveTerminal then resolves through a --stk-* reference, a further
133
+ // consumer alias, a raw value, or an undefined name identically either way.
134
+ // ---------------------------------------------------------------------------
135
+
136
+ function collectV4ThemeEntries(cssFiles) {
137
+ const entries = new Map(); // --key -> { value, file, line }
138
+ let found = false;
139
+ for (const file of cssFiles) {
140
+ let src;
141
+ try {
142
+ src = readFileSync(file, 'utf-8');
143
+ } catch {
144
+ continue;
145
+ }
146
+ let root;
147
+ try {
148
+ root = postcss.parse(src, { from: file });
149
+ } catch {
150
+ continue;
151
+ }
152
+ root.walkAtRules('theme', (atRule) => {
153
+ found = true;
154
+ atRule.walkDecls((decl) => {
155
+ if (!decl.prop.startsWith('--')) return;
156
+ entries.set(decl.prop, { value: decl.value, file, line: decl.source?.start?.line ?? null });
157
+ });
158
+ });
159
+ }
160
+ return { found, entries };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Theme extraction — v3 tailwind.config.{js,cjs,mjs,ts} theme.extend.colors
165
+ //
166
+ // Statically parsed (never executed — this scans arbitrary consumer repos).
167
+ // Only plain string/template-literal values are resolvable; a
168
+ // function-based `theme: (helpers) => ({...})` config, or values built from
169
+ // expressions, are silently skipped as unresolvable (documented, not
170
+ // silently misreported — see the themeParseIncomplete escape hatch in
171
+ // resolveTailwindTokens).
172
+ // ---------------------------------------------------------------------------
173
+
174
+ export function findExportedConfigObject(ast) {
175
+ let found = null;
176
+ traverse(ast, {
177
+ AssignmentExpression(path_) {
178
+ const { left, right } = path_.node;
179
+ if (
180
+ left.type === 'MemberExpression' &&
181
+ left.object.type === 'Identifier' && left.object.name === 'module' &&
182
+ !left.computed &&
183
+ left.property.type === 'Identifier' && left.property.name === 'exports' &&
184
+ right.type === 'ObjectExpression'
185
+ ) {
186
+ found = right;
187
+ }
188
+ },
189
+ ExportDefaultDeclaration(path_) {
190
+ let decl = path_.node.declaration;
191
+ // `export default {...} satisfies Config` / `as Config`
192
+ if (decl.type === 'TSSatisfiesExpression' || decl.type === 'TSAsExpression') {
193
+ decl = decl.expression;
194
+ }
195
+ if (decl.type === 'ObjectExpression') found = decl;
196
+ },
197
+ });
198
+ return found;
199
+ }
200
+
201
+ export function objectKey(propNode) {
202
+ if (propNode.key.type === 'Identifier') return propNode.key.name;
203
+ if (propNode.key.type === 'StringLiteral') return propNode.key.value;
204
+ if (propNode.key.type === 'NumericLiteral') return String(propNode.key.value);
205
+ return null;
206
+ }
207
+
208
+ export function getObjectProp(objExpr, key) {
209
+ if (!objExpr || objExpr.type !== 'ObjectExpression') return null;
210
+ for (const prop of objExpr.properties) {
211
+ if (prop.type !== 'ObjectProperty') continue;
212
+ if (objectKey(prop) === key) return prop.value;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function staticStringValue(node) {
218
+ if (!node) return null;
219
+ if (node.type === 'StringLiteral') return node.value;
220
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
221
+ return node.quasis.map((q) => q.value.cooked).join('');
222
+ }
223
+ return null;
224
+ }
225
+
226
+ // One level of DEFAULT/shade nesting: `colors: { primary: { DEFAULT: '...',
227
+ // 500: '...' } }` → `--color-primary`, `--color-primary-500`.
228
+ function collectV3Colors(objExpr, into, file) {
229
+ if (!objExpr || objExpr.type !== 'ObjectExpression') return;
230
+ for (const prop of objExpr.properties) {
231
+ if (prop.type !== 'ObjectProperty') continue;
232
+ const key = objectKey(prop);
233
+ if (!key) continue;
234
+
235
+ const flat = staticStringValue(prop.value);
236
+ if (flat !== null) {
237
+ into.set(`--color-${key}`, { value: flat, file, line: prop.loc?.start.line ?? null });
238
+ continue;
239
+ }
240
+
241
+ if (prop.value.type === 'ObjectExpression') {
242
+ for (const sub of prop.value.properties) {
243
+ if (sub.type !== 'ObjectProperty') continue;
244
+ const subKey = objectKey(sub);
245
+ if (!subKey) continue;
246
+ const subVal = staticStringValue(sub.value);
247
+ if (subVal === null) continue; // nested-again or dynamic — out of scope
248
+ const cssKey = subKey === 'DEFAULT' ? key : `${key}-${subKey}`;
249
+ into.set(`--color-${cssKey}`, { value: subVal, file, line: sub.loc?.start.line ?? null });
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ function collectV3ThemeEntries(root, ignore) {
256
+ const entries = new Map();
257
+ const configFiles = fg
258
+ .sync(['**/tailwind.config.{js,cjs,mjs,ts}'], { cwd: root, absolute: true, ignore: [...DEFAULT_IGNORE, ...ignore] })
259
+ .sort();
260
+
261
+ for (const file of configFiles) {
262
+ let code;
263
+ try {
264
+ code = readFileSync(file, 'utf-8');
265
+ } catch {
266
+ continue;
267
+ }
268
+ let ast;
269
+ try {
270
+ ast = parseSource(code, file);
271
+ } catch {
272
+ continue;
273
+ }
274
+ const configObj = findExportedConfigObject(ast);
275
+ const themeObj = getObjectProp(configObj, 'theme');
276
+ // theme.colors (replaces Tailwind's defaults) and theme.extend.colors
277
+ // (adds to them) are both just "keys the consumer declared" for our
278
+ // purposes — walked in that order so extend wins on a same-name clash,
279
+ // matching Tailwind's own layering.
280
+ collectV3Colors(getObjectProp(themeObj, 'colors'), entries, file);
281
+ collectV3Colors(getObjectProp(getObjectProp(themeObj, 'extend'), 'colors'), entries, file);
282
+ }
283
+
284
+ return { found: configFiles.length > 0, entries, configFiles };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Theme-entry classification — same terminal/state machinery as the CSS
289
+ // alias resolver, scoped to only the Tailwind-sourced property names (not
290
+ // the whole merged graph — this resolver reports on Tailwind's own surface,
291
+ // not the consumer's general CSS aliasing, which tokenAliasResolver already
292
+ // covers).
293
+ // ---------------------------------------------------------------------------
294
+
295
+ function classifyThemeProperties(propGraph, themeEntryNames, inventory, layers) {
296
+ const properties = [];
297
+ const findings = [];
298
+
299
+ for (const name of themeEntryNames) {
300
+ const terminal = resolveTerminal(propGraph, name, ':root', inventory, layers, new Set(), 0);
301
+ const state = classifyTerminalState(terminal.kind);
302
+ properties.push({ name, state, terminal });
303
+
304
+ if (terminal.kind === 'stk' && terminal.layer === 'primitive') {
305
+ findings.push({ rule: 'layer-violation', severity: 'critical', property: name, stkToken: terminal.stkToken });
306
+ }
307
+ if (terminal.kind === 'stk' && terminal.depth > WARN_DEPTH) {
308
+ findings.push({ rule: 'deep-alias-chain', severity: 'warning', property: name, depth: terminal.depth });
309
+ }
310
+ if (state === 'drift') {
311
+ findings.push({ rule: 'drift-behind-alias', severity: 'critical', property: name });
312
+ }
313
+ if (state === 'broken') {
314
+ findings.push({ rule: 'broken-alias', severity: 'critical', property: name, reason: terminal.kind });
315
+ }
316
+ if (terminal.rawFallbackPresent) {
317
+ findings.push({ rule: 'raw-fallback', severity: 'info', property: name });
318
+ }
319
+ }
320
+
321
+ return { properties, findings };
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Pass 2 — JSX className utility-class occurrences as usages
326
+ // ---------------------------------------------------------------------------
327
+
328
+ function extractClassNameString(valueNode) {
329
+ if (!valueNode) return null;
330
+ if (valueNode.type === 'StringLiteral') return valueNode.value;
331
+ if (valueNode.type === 'JSXExpressionContainer') {
332
+ const expr = valueNode.expression;
333
+ if (expr.type === 'StringLiteral') return expr.value;
334
+ if (expr.type === 'TemplateLiteral' && expr.expressions.length === 0) {
335
+ return expr.quasis.map((q) => q.value.cooked).join('');
336
+ }
337
+ }
338
+ // clsx()/classnames()/ternaries/interpolated templates — dynamic
339
+ // composition, out of scope (documented, same "static only" boundary the
340
+ // v3 config parser uses).
341
+ return null;
342
+ }
343
+
344
+ export function collectClassNameUsages(moduleGraph) {
345
+ const usages = [];
346
+ for (const file of moduleGraph.files) {
347
+ const entry = moduleGraph.graph.get(file);
348
+ if (!entry || entry.parseError) continue;
349
+ traverse(entry.ast, {
350
+ JSXAttribute(path_) {
351
+ const attrName = path_.node.name.name;
352
+ if (attrName !== 'className' && attrName !== 'class') return;
353
+ const str = extractClassNameString(path_.node.value);
354
+ if (str == null) return;
355
+ for (const cls of str.split(/\s+/).filter(Boolean)) {
356
+ usages.push({ file, line: path_.node.loc?.start.line ?? null, className: cls });
357
+ }
358
+ },
359
+ });
360
+ }
361
+ return usages;
362
+ }
363
+
364
+ function classifyClassNameUsage(cls, propGraph, themeEntryNames, inventory, layers) {
365
+ const base = stripVariants(cls);
366
+
367
+ const arbitrary = matchArbitraryVar(base);
368
+ if (arbitrary) {
369
+ const { name } = arbitrary;
370
+ if (name.startsWith('--stk-')) {
371
+ return inventory.has(name)
372
+ ? { classification: 'direct', stkToken: name }
373
+ : { classification: 'broken', reason: 'undefined-stk-token' };
374
+ }
375
+ const bySelector = propGraph.get(name);
376
+ if (!bySelector) return { classification: 'broken', reason: 'undefined-property' };
377
+ const bound = bySelector.has(':root') ? ':root' : [...bySelector.keys()][0];
378
+ const terminal = resolveTerminal(propGraph, name, bound, inventory, layers, new Set(), 0);
379
+ const state = classifyTerminalState(terminal.kind);
380
+ return { classification: state === 'conformant' ? 'aliased' : state, stkToken: terminal.stkToken };
381
+ }
382
+
383
+ const propName = matchUtilityBase(base);
384
+ if (!propName) return null; // not a utility shape this resolver tracks at all
385
+ if (!themeEntryNames.has(propName)) return null; // Tailwind's own default scale, not a consumer theme key — out of scope, not a violation
386
+
387
+ const terminal = resolveTerminal(propGraph, propName, ':root', inventory, layers, new Set(), 0);
388
+ const state = classifyTerminalState(terminal.kind);
389
+ return { classification: state === 'conformant' ? 'aliased' : state, propName, stkToken: terminal.stkToken };
390
+ }
391
+
392
+ // Same "three numbers, never one" shape as tokenAliasResolver's report — see
393
+ // its summarizeReport for the full rationale. Utility-class usages are never
394
+ // "direct" from the theme map (that classification is reserved for the
395
+ // arbitrary-value var() escape hatch, which really is a call-site literal).
396
+ function summarizeReport(usages) {
397
+ const counts = { direct: 0, aliased: 0, unresolved: 0 };
398
+ for (const u of usages) {
399
+ if (u.classification === 'direct') counts.direct++;
400
+ else if (u.classification === 'aliased') counts.aliased++;
401
+ else counts.unresolved++;
402
+ }
403
+ const total = counts.direct + counts.aliased + counts.unresolved;
404
+ const pct = (n) => (total === 0 ? 0 : Math.round((n / total) * 1000) / 10);
405
+ return {
406
+ total,
407
+ direct: counts.direct,
408
+ directPct: pct(counts.direct),
409
+ aliased: counts.aliased,
410
+ aliasedPct: pct(counts.aliased),
411
+ unresolved: counts.unresolved,
412
+ unresolvedPct: pct(counts.unresolved),
413
+ conformancePct: pct(counts.direct + counts.aliased),
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Resolves Tailwind's class-name indirection back to Stark tokens
419
+ * (ADOPTION_APP_PLAN.md §5a, "Tailwind is the common case"): a `bg-primary`
420
+ * utility is invisible to a CSS var() scanner, but is a token usage if
421
+ * `--color-primary` (v4 `@theme`) or `colors.primary` (v3
422
+ * `theme.extend.colors`) resolves to a Stark token.
423
+ *
424
+ * Per the phase-1 promotion criterion's companion rule (§9): detection is
425
+ * always cheap, resolution isn't. When Tailwind isn't detected at all, or is
426
+ * detected but no theme entries could be statically resolved (a dynamic
427
+ * config, or theme keys outside this resolver's namespace table), this
428
+ * refuses to produce a score rather than reporting a misleadingly low one —
429
+ * `report` is `null` and `detected`/`themeParseIncomplete` say why.
430
+ */
431
+ export function resolveTailwindTokens(root, { platform = 'web', ignore = [] } = {}) {
432
+ if (platform !== 'web') {
433
+ throw new Error(`resolveTailwindTokens only supports platform "web" (got "${platform}") — Tailwind utility classes have no RN equivalent.`);
434
+ }
435
+
436
+ const stkPkgRoot = stkRoot();
437
+ const { inventory, layers } = loadTokenInventory(stkPkgRoot);
438
+ const cssFiles = collectCssFiles(root, ignore);
439
+
440
+ const { found: v4Found, entries: v4Entries } = collectV4ThemeEntries(cssFiles);
441
+ const { found: v3Found, entries: v3Entries, configFiles } = collectV3ThemeEntries(root, ignore);
442
+
443
+ const detected = v4Found || v3Found;
444
+ if (!detected) {
445
+ return {
446
+ platform,
447
+ root,
448
+ detected: false,
449
+ reason: 'No @theme{} block or tailwind.config.{js,cjs,mjs,ts} found — Tailwind not in use here, not scored.',
450
+ themeProperties: [],
451
+ usages: [],
452
+ findings: [],
453
+ report: null,
454
+ };
455
+ }
456
+
457
+ const themeEntries = new Map([...v4Entries, ...v3Entries]);
458
+
459
+ if (themeEntries.size === 0) {
460
+ return {
461
+ platform,
462
+ root,
463
+ detected: true,
464
+ themeParseIncomplete: true,
465
+ reason: 'Tailwind detected but no statically-resolvable theme entries were found — likely a dynamic/function-based tailwind.config theme, or theme keys outside the namespaces this resolver understands (color, spacing, radius, shadow, font). Not scored.',
466
+ configFiles: configFiles.map((f) => path.relative(root, f)),
467
+ themeProperties: [],
468
+ usages: [],
469
+ findings: [],
470
+ report: null,
471
+ };
472
+ }
473
+
474
+ const propGraph = buildPropertyGraph(cssFiles);
475
+ for (const [name, decl] of themeEntries) {
476
+ let bySelector = propGraph.get(name);
477
+ if (!bySelector) {
478
+ bySelector = new Map();
479
+ propGraph.set(name, bySelector);
480
+ }
481
+ bySelector.set(':root', decl);
482
+ }
483
+
484
+ const themeEntryNames = new Set(themeEntries.keys());
485
+ const { properties, findings } = classifyThemeProperties(propGraph, themeEntryNames, inventory, layers);
486
+
487
+ const moduleGraph = buildModuleGraph(root, { ignore });
488
+ const rawUsages = collectClassNameUsages(moduleGraph);
489
+ const usages = [];
490
+ for (const u of rawUsages) {
491
+ const result = classifyClassNameUsage(u.className, propGraph, themeEntryNames, inventory, layers);
492
+ if (!result) continue;
493
+ usages.push({ file: path.relative(root, u.file), line: u.line, className: u.className, ...result });
494
+ }
495
+
496
+ const report = summarizeReport(usages);
497
+
498
+ return {
499
+ platform,
500
+ root,
501
+ detected: true,
502
+ source: v4Found && v3Found ? 'both' : v4Found ? 'v4' : 'v3',
503
+ scannedCssFiles: cssFiles.length,
504
+ scannedJsFiles: moduleGraph.files.length,
505
+ themeEntryCount: themeEntries.size,
506
+ tokenInventoryTotal: inventory.size,
507
+ properties,
508
+ usages,
509
+ findings,
510
+ report,
511
+ };
512
+ }
@@ -0,0 +1,178 @@
1
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { describe, it, expect, afterEach } from 'vitest';
6
+
7
+ import { resolveTailwindTokens } from './tailwindResolver.js';
8
+
9
+ let tmpDirs = [];
10
+
11
+ afterEach(() => {
12
+ for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true });
13
+ tmpDirs = [];
14
+ });
15
+
16
+ function fixture(files) {
17
+ const root = mkdtempSync(path.join(os.tmpdir(), 'stark-adopt-tailwind-'));
18
+ tmpDirs.push(root);
19
+ for (const [rel, content] of Object.entries(files)) {
20
+ const full = path.join(root, rel);
21
+ mkdirSync(path.dirname(full), { recursive: true });
22
+ writeFileSync(full, content);
23
+ }
24
+ return root;
25
+ }
26
+
27
+ function usage(result, className) {
28
+ return result.usages.find((u) => u.className === className);
29
+ }
30
+
31
+ describe('resolveTailwindTokens — detection', () => {
32
+ it('reports detected:false with no report when neither a config nor an @theme block exists', () => {
33
+ const root = fixture({
34
+ 'src/app.css': `.btn { color: red; }`,
35
+ 'src/App.jsx': `export const App = () => <div className="flex p-4" />;`,
36
+ });
37
+ const result = resolveTailwindTokens(root, { platform: 'web' });
38
+ expect(result.detected).toBe(false);
39
+ expect(result.report).toBeNull();
40
+ });
41
+
42
+ it('detects a v4 @theme block even with no config file', () => {
43
+ const root = fixture({
44
+ 'src/app.css': `@theme { --color-primary: var(--stk-surface-brand-1-strong); }`,
45
+ });
46
+ const result = resolveTailwindTokens(root, { platform: 'web' });
47
+ expect(result.detected).toBe(true);
48
+ expect(result.source).toBe('v4');
49
+ });
50
+
51
+ it('detects a v3 tailwind.config.js even with no @theme block', () => {
52
+ const root = fixture({
53
+ 'tailwind.config.js': `module.exports = { theme: { extend: { colors: { primary: 'var(--stk-surface-brand-1-strong)' } } } };`,
54
+ });
55
+ const result = resolveTailwindTokens(root, { platform: 'web' });
56
+ expect(result.detected).toBe(true);
57
+ expect(result.source).toBe('v3');
58
+ });
59
+
60
+ it('reports themeParseIncomplete instead of a zero score for a dynamic/function-based v3 config', () => {
61
+ const root = fixture({
62
+ 'tailwind.config.js': `module.exports = { theme: (helpers) => ({ colors: { primary: helpers.colors.blue } }) };`,
63
+ });
64
+ const result = resolveTailwindTokens(root, { platform: 'web' });
65
+ expect(result.detected).toBe(true);
66
+ expect(result.themeParseIncomplete).toBe(true);
67
+ expect(result.report).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe('resolveTailwindTokens — v4 @theme classification', () => {
72
+ it('classifies a @theme entry aliasing an inventory stk token as conformant', () => {
73
+ const root = fixture({
74
+ 'src/app.css': `@theme { --color-primary: var(--stk-surface-brand-1-strong); }`,
75
+ });
76
+ const result = resolveTailwindTokens(root, { platform: 'web' });
77
+ const prop = result.properties.find((p) => p.name === '--color-primary');
78
+ expect(prop.state).toBe('conformant');
79
+ });
80
+
81
+ it('classifies a @theme entry with a raw value as drift', () => {
82
+ const root = fixture({
83
+ 'src/app.css': `@theme { --color-primary: #1956dd; }`,
84
+ });
85
+ const result = resolveTailwindTokens(root, { platform: 'web' });
86
+ const prop = result.properties.find((p) => p.name === '--color-primary');
87
+ expect(prop.state).toBe('drift');
88
+ });
89
+ });
90
+
91
+ describe('resolveTailwindTokens — v3 tailwind.config theme.extend.colors classification', () => {
92
+ it('classifies a flat color entry aliasing an inventory stk token as conformant', () => {
93
+ const root = fixture({
94
+ 'tailwind.config.js': `module.exports = { theme: { extend: { colors: { primary: 'var(--stk-surface-brand-1-strong)' } } } };`,
95
+ });
96
+ const result = resolveTailwindTokens(root, { platform: 'web' });
97
+ const prop = result.properties.find((p) => p.name === '--color-primary');
98
+ expect(prop.state).toBe('conformant');
99
+ });
100
+
101
+ it('classifies a nested DEFAULT/shade color entry', () => {
102
+ const root = fixture({
103
+ 'tailwind.config.js': `module.exports = { theme: { extend: { colors: { brand: { DEFAULT: 'var(--stk-surface-brand-1-strong)', 500: '#1956dd' } } } } };`,
104
+ });
105
+ const result = resolveTailwindTokens(root, { platform: 'web' });
106
+ expect(result.properties.find((p) => p.name === '--color-brand').state).toBe('conformant');
107
+ expect(result.properties.find((p) => p.name === '--color-brand-500').state).toBe('drift');
108
+ });
109
+ });
110
+
111
+ describe('resolveTailwindTokens — JSX className usage scanning', () => {
112
+ it('classifies a utility class resolving to a conformant theme entry as aliased', () => {
113
+ const root = fixture({
114
+ 'src/app.css': `@theme { --color-primary: var(--stk-surface-brand-1-strong); }`,
115
+ 'src/App.jsx': `export const App = () => <div className="bg-primary" />;`,
116
+ });
117
+ const result = resolveTailwindTokens(root, { platform: 'web' });
118
+ expect(usage(result, 'bg-primary').classification).toBe('aliased');
119
+ expect(result.report.aliased).toBe(1);
120
+ });
121
+
122
+ it('classifies a utility class resolving to a drifted theme entry as drift, not aliased', () => {
123
+ const root = fixture({
124
+ 'src/app.css': `@theme { --color-primary: #1956dd; }`,
125
+ 'src/App.jsx': `export const App = () => <div className="bg-primary" />;`,
126
+ });
127
+ const result = resolveTailwindTokens(root, { platform: 'web' });
128
+ expect(usage(result, 'bg-primary').classification).toBe('drift');
129
+ });
130
+
131
+ it('strips a variant prefix before matching the base utility', () => {
132
+ const root = fixture({
133
+ 'src/app.css': `@theme { --color-primary: var(--stk-surface-brand-1-strong); }`,
134
+ 'src/App.jsx': `export const App = () => <div className="hover:bg-primary dark:[&:hover]:bg-primary" />;`,
135
+ });
136
+ const result = resolveTailwindTokens(root, { platform: 'web' });
137
+ expect(usage(result, 'hover:bg-primary').classification).toBe('aliased');
138
+ expect(usage(result, 'dark:[&:hover]:bg-primary').classification).toBe('aliased');
139
+ });
140
+
141
+ it('classifies an arbitrary-value var() utility as direct', () => {
142
+ const root = fixture({
143
+ 'src/app.css': `@theme { --color-primary: var(--stk-surface-brand-1-strong); }`,
144
+ 'src/App.jsx': `export const App = () => <div className="bg-[var(--stk-surface-brand-1-strong)]" />;`,
145
+ });
146
+ const result = resolveTailwindTokens(root, { platform: 'web' });
147
+ expect(usage(result, 'bg-[var(--stk-surface-brand-1-strong)]').classification).toBe('direct');
148
+ expect(result.report.direct).toBe(1);
149
+ });
150
+
151
+ it('does not count an unrelated utility class or Tailwind default-palette color as a usage', () => {
152
+ const root = fixture({
153
+ 'src/app.css': `@theme { --color-primary: var(--stk-surface-brand-1-strong); }`,
154
+ 'src/App.jsx': `export const App = () => <div className="flex bg-red-500 p-4" />;`,
155
+ });
156
+ const result = resolveTailwindTokens(root, { platform: 'web' });
157
+ expect(usage(result, 'flex')).toBeUndefined();
158
+ expect(usage(result, 'bg-red-500')).toBeUndefined();
159
+ expect(usage(result, 'p-4')).toBeUndefined();
160
+ expect(result.report.total).toBe(0);
161
+ });
162
+
163
+ it('does not count a dynamic className expression (clsx/ternary) since it is not statically resolvable', () => {
164
+ const root = fixture({
165
+ 'src/app.css': `@theme { --color-primary: var(--stk-surface-brand-1-strong); }`,
166
+ 'src/App.jsx': `export const App = ({ active }) => <div className={active ? 'bg-primary' : 'bg-primary'} />;`,
167
+ });
168
+ const result = resolveTailwindTokens(root, { platform: 'web' });
169
+ expect(result.report.total).toBe(0);
170
+ });
171
+ });
172
+
173
+ describe('resolveTailwindTokens — platform scope', () => {
174
+ it('throws for a non-web platform since Tailwind utility classes have no RN equivalent', () => {
175
+ const root = fixture({ 'tailwind.config.js': `module.exports = {};` });
176
+ expect(() => resolveTailwindTokens(root, { platform: 'native' })).toThrow(/web/i);
177
+ });
178
+ });