@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +31 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
- package/src/adopt/catalog.js +88 -0
- package/src/adopt/dominionFixture.test.js +165 -0
- package/src/adopt/moduleGraph.js +232 -0
- package/src/adopt/parseSource.js +25 -0
- package/src/adopt/propApiResolver.js +278 -0
- package/src/adopt/propApiResolver.test.js +229 -0
- package/src/adopt/referenceResolver.js +151 -0
- package/src/adopt/referenceResolver.test.js +213 -0
- package/src/adopt/rnTailwindResolver.js +347 -0
- package/src/adopt/rnTailwindResolver.test.js +263 -0
- package/src/adopt/rnTokenAliasResolver.js +474 -0
- package/src/adopt/rnTokenAliasResolver.test.js +260 -0
- package/src/adopt/tailwindResolver.js +512 -0
- package/src/adopt/tailwindResolver.test.js +178 -0
- package/src/adopt/targetDiscovery.js +237 -0
- package/src/adopt/targetDiscovery.test.js +227 -0
- package/src/adopt/tokenAliasResolver.js +513 -0
- package/src/adopt/tokenAliasResolver.test.js +319 -0
- package/src/adopt/wrapperResolver.js +874 -0
- package/src/adopt/wrapperResolver.test.js +324 -0
- package/src/cli.js +376 -0
- package/src/data.js +267 -0
- package/src/data.test.js +231 -0
- package/src/index.js +8 -0
- 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
|
+
});
|