@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,347 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import fg from 'fast-glob';
|
|
5
|
+
|
|
6
|
+
import { stkRoot } from '../data.js';
|
|
7
|
+
import { buildModuleGraph } from './moduleGraph.js';
|
|
8
|
+
import {
|
|
9
|
+
loadRnTokenInventory,
|
|
10
|
+
isAnalyzableValueNode,
|
|
11
|
+
buildAliasGraph,
|
|
12
|
+
classifyTerminalState,
|
|
13
|
+
classifyNode,
|
|
14
|
+
} from './rnTokenAliasResolver.js';
|
|
15
|
+
import {
|
|
16
|
+
findExportedConfigObject,
|
|
17
|
+
getObjectProp,
|
|
18
|
+
objectKey,
|
|
19
|
+
stripVariants,
|
|
20
|
+
matchUtilityBase,
|
|
21
|
+
collectClassNameUsages,
|
|
22
|
+
} from './tailwindResolver.js';
|
|
23
|
+
|
|
24
|
+
// "5 is governance, 20 is safety" — see ADOPTION_APP_PLAN.md §4/§5a. Kept as
|
|
25
|
+
// its own local constant per the established per-file duplication convention
|
|
26
|
+
// (tailwindResolver.js and rnTokenAliasResolver.js each keep their own copy
|
|
27
|
+
// rather than importing one another's).
|
|
28
|
+
const WARN_DEPTH = 3;
|
|
29
|
+
|
|
30
|
+
const DEFAULT_IGNORE = [
|
|
31
|
+
'**/node_modules/**',
|
|
32
|
+
'**/dist/**',
|
|
33
|
+
'**/build/**',
|
|
34
|
+
'**/.next/**',
|
|
35
|
+
'**/coverage/**',
|
|
36
|
+
'**/storybook-static/**',
|
|
37
|
+
'**/.storybook*/**',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// NativeWind detection
|
|
42
|
+
//
|
|
43
|
+
// A bare tailwind.config.js is not itself a NativeWind signal — it could
|
|
44
|
+
// belong to an unrelated web app elsewhere in the same monorepo. Three real
|
|
45
|
+
// signals, checked cheapest-first: the "nativewind" package.json dependency,
|
|
46
|
+
// the "nativewind/babel" Babel preset, or the "withNativeWind" Metro config
|
|
47
|
+
// wrapper. Any one of them is sufficient.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function detectNativeWind(root, ignore) {
|
|
51
|
+
const scanIgnore = [...DEFAULT_IGNORE, ...ignore];
|
|
52
|
+
|
|
53
|
+
const pkgFiles = fg.sync(['**/package.json'], { cwd: root, absolute: true, ignore: scanIgnore });
|
|
54
|
+
for (const file of pkgFiles) {
|
|
55
|
+
let json;
|
|
56
|
+
try {
|
|
57
|
+
json = JSON.parse(readFileSync(file, 'utf-8'));
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const deps = { ...json.dependencies, ...json.devDependencies, ...json.peerDependencies };
|
|
62
|
+
if (Object.prototype.hasOwnProperty.call(deps, 'nativewind')) {
|
|
63
|
+
return { found: true, signal: 'package.json', file };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const babelFiles = fg.sync(['**/babel.config.{js,cjs,mjs}'], { cwd: root, absolute: true, ignore: scanIgnore });
|
|
68
|
+
for (const file of babelFiles) {
|
|
69
|
+
let src;
|
|
70
|
+
try {
|
|
71
|
+
src = readFileSync(file, 'utf-8');
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (src.includes('nativewind/babel')) {
|
|
76
|
+
return { found: true, signal: 'babel.config', file };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const metroFiles = fg.sync(['**/metro.config.{js,cjs,mjs}'], { cwd: root, absolute: true, ignore: scanIgnore });
|
|
81
|
+
for (const file of metroFiles) {
|
|
82
|
+
let src;
|
|
83
|
+
try {
|
|
84
|
+
src = readFileSync(file, 'utf-8');
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (src.includes('withNativeWind')) {
|
|
89
|
+
return { found: true, signal: 'metro.config', file };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { found: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Theme extraction — v3 tailwind.config.{js,cjs,mjs,ts} theme.extend.colors
|
|
98
|
+
//
|
|
99
|
+
// RN has no CSS custom properties, so a NativeWind consumer aliasing a Stark
|
|
100
|
+
// token can only do it by importing the RN token constant directly into the
|
|
101
|
+
// config and referencing it as a JS expression (an Identifier or a single-hop
|
|
102
|
+
// member access) — never var(), since there's no DOM/CSS runtime on RN to
|
|
103
|
+
// resolve that against. This is the AST-node-capturing counterpart of
|
|
104
|
+
// tailwindResolver.js's collectV3Colors: it keeps the raw value node instead
|
|
105
|
+
// of a resolved string, because resolution here is classifyNode's JS-import-
|
|
106
|
+
// chain analysis (rnTokenAliasResolver.js), not resolveTerminal's CSS
|
|
107
|
+
// var()-chain analysis.
|
|
108
|
+
//
|
|
109
|
+
// Colors-only, same as the web v3 config parser (its NAMESPACES table
|
|
110
|
+
// implies broader coverage, but spacing/radius/shadow/font are only actually
|
|
111
|
+
// reachable there via v4 CSS @theme — which has no NativeWind analog, since a
|
|
112
|
+
// CSS file can't import a JS token constant). No v4 CSS @theme support here
|
|
113
|
+
// for the same reason.
|
|
114
|
+
//
|
|
115
|
+
// The config file's AST is read straight off the already-built moduleGraph
|
|
116
|
+
// rather than re-parsed — buildModuleGraph globs every .js/.jsx/.ts/.tsx/
|
|
117
|
+
// .mjs/.cjs file under root, tailwind.config.js included, so this reuses that
|
|
118
|
+
// parse and (more importantly) lets classifyNode resolve identifiers through
|
|
119
|
+
// the exact same moduleGraph.imports the rest of the consumer's code is
|
|
120
|
+
// resolved against. A config file that only uses CommonJS require() (no ES
|
|
121
|
+
// import) will have an empty imports map for that file — moduleGraph.js only
|
|
122
|
+
// parses ES import syntax — so a token identifier `require()`'d in rather
|
|
123
|
+
// than `import`'d won't resolve. That's an inherited limitation of
|
|
124
|
+
// moduleGraph.js, not something new being solved here.
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
function collectV3ColorsRn(objExpr, into, file) {
|
|
128
|
+
if (!objExpr || objExpr.type !== 'ObjectExpression') return;
|
|
129
|
+
for (const prop of objExpr.properties) {
|
|
130
|
+
if (prop.type !== 'ObjectProperty') continue;
|
|
131
|
+
const key = objectKey(prop);
|
|
132
|
+
if (!key) continue;
|
|
133
|
+
|
|
134
|
+
if (isAnalyzableValueNode(prop.value)) {
|
|
135
|
+
into.set(`--color-${key}`, { node: prop.value, file, line: prop.loc?.start.line ?? null });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (prop.value.type === 'ObjectExpression') {
|
|
140
|
+
for (const sub of prop.value.properties) {
|
|
141
|
+
if (sub.type !== 'ObjectProperty') continue;
|
|
142
|
+
const subKey = objectKey(sub);
|
|
143
|
+
if (!subKey) continue;
|
|
144
|
+
if (!isAnalyzableValueNode(sub.value)) continue; // nested-again or dynamic — out of scope
|
|
145
|
+
const cssKey = subKey === 'DEFAULT' ? key : `${key}-${subKey}`;
|
|
146
|
+
into.set(`--color-${cssKey}`, { node: sub.value, file, line: sub.loc?.start.line ?? null });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collectV3ThemeEntriesRn(root, moduleGraph, ignore) {
|
|
153
|
+
const entries = new Map(); // --color-key -> { node, file, line }
|
|
154
|
+
const configFiles = fg
|
|
155
|
+
.sync(['**/tailwind.config.{js,cjs,mjs,ts}'], { cwd: root, absolute: true, ignore: [...DEFAULT_IGNORE, ...ignore] })
|
|
156
|
+
.sort();
|
|
157
|
+
|
|
158
|
+
for (const file of configFiles) {
|
|
159
|
+
const entry = moduleGraph.graph.get(file);
|
|
160
|
+
if (!entry?.ast) continue;
|
|
161
|
+
const configObj = findExportedConfigObject(entry.ast);
|
|
162
|
+
const themeObj = getObjectProp(configObj, 'theme');
|
|
163
|
+
// theme.colors (replaces Tailwind's defaults) then theme.extend.colors
|
|
164
|
+
// (adds to them) — extend wins on a same-name clash, matching Tailwind's
|
|
165
|
+
// own layering (same order tailwindResolver.js's v3 parser uses).
|
|
166
|
+
collectV3ColorsRn(getObjectProp(themeObj, 'colors'), entries, file);
|
|
167
|
+
collectV3ColorsRn(getObjectProp(getObjectProp(themeObj, 'extend'), 'colors'), entries, file);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { found: configFiles.length > 0, entries, configFiles };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Theme-entry classification — reuses rnTokenAliasResolver.js's classifyNode
|
|
175
|
+
// directly on each theme entry's raw value node. This is exactly what
|
|
176
|
+
// classifyAliasGraph does per alias-graph entry there; a NativeWind theme
|
|
177
|
+
// value is just another JS expression subject to the same import/local-const
|
|
178
|
+
// alias-chain analysis, so no separate resolution machinery is needed.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
function classifyThemeEntriesRn(entries, ctx) {
|
|
182
|
+
const properties = [];
|
|
183
|
+
const findings = [];
|
|
184
|
+
const classified = new Map();
|
|
185
|
+
|
|
186
|
+
for (const [name, entry] of entries) {
|
|
187
|
+
const terminal = classifyNode(entry.file, entry.node, ctx, new Set(), 0);
|
|
188
|
+
const state = classifyTerminalState(terminal.kind);
|
|
189
|
+
properties.push({ name, file: entry.file, line: entry.line, state, terminal });
|
|
190
|
+
classified.set(name, { terminal, file: entry.file, line: entry.line });
|
|
191
|
+
|
|
192
|
+
if (terminal.kind === 'stk' && terminal.layer === 'primitive') {
|
|
193
|
+
findings.push({ rule: 'layer-violation', severity: 'critical', property: name, file: entry.file, line: entry.line, stkToken: terminal.stkToken });
|
|
194
|
+
}
|
|
195
|
+
if (terminal.kind === 'stk' && terminal.depth > WARN_DEPTH) {
|
|
196
|
+
findings.push({ rule: 'deep-alias-chain', severity: 'warning', property: name, file: entry.file, line: entry.line, depth: terminal.depth });
|
|
197
|
+
}
|
|
198
|
+
if (state === 'drift') {
|
|
199
|
+
findings.push({ rule: 'drift-behind-alias', severity: 'critical', property: name, file: entry.file, line: entry.line });
|
|
200
|
+
}
|
|
201
|
+
if (state === 'broken') {
|
|
202
|
+
findings.push({ rule: 'broken-alias', severity: 'critical', property: name, file: entry.file, line: entry.line, reason: terminal.kind });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { properties, findings, classified };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Pass 2 — JSX className usage scanning
|
|
211
|
+
//
|
|
212
|
+
// Reuses tailwindResolver.js's collectClassNameUsages verbatim (statically-
|
|
213
|
+
// resolvable className/class string values, JSX/TSX-wide). No arbitrary-
|
|
214
|
+
// value bracket handling (`bg-[...]`) — var() has no RN meaning, and matching
|
|
215
|
+
// on a raw hex value would violate "never match by value"; an arbitrary
|
|
216
|
+
// utility simply won't match a theme key below and falls through as out of
|
|
217
|
+
// scope. There is accordingly no "direct" classification path at all on this
|
|
218
|
+
// resolver — "direct" is reserved for that web-only escape hatch.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
function classifyClassNameUsageRn(cls, classifiedEntries) {
|
|
222
|
+
const base = stripVariants(cls);
|
|
223
|
+
const propName = matchUtilityBase(base);
|
|
224
|
+
if (!propName) return null; // not a utility shape this resolver tracks at all
|
|
225
|
+
const entry = classifiedEntries.get(propName);
|
|
226
|
+
if (!entry) return null; // not a consumer theme key — out of scope, not a violation
|
|
227
|
+
const state = classifyTerminalState(entry.terminal.kind);
|
|
228
|
+
return { classification: state === 'conformant' ? 'aliased' : state, propName, stkToken: entry.terminal.stkToken };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Same three-number report as the other three resolvers — local duplicate
|
|
232
|
+
// per the established per-file convention.
|
|
233
|
+
function summarizeReport(usages) {
|
|
234
|
+
const counts = { direct: 0, aliased: 0, unresolved: 0 };
|
|
235
|
+
for (const u of usages) {
|
|
236
|
+
if (u.classification === 'direct') counts.direct++;
|
|
237
|
+
else if (u.classification === 'aliased') counts.aliased++;
|
|
238
|
+
else counts.unresolved++;
|
|
239
|
+
}
|
|
240
|
+
const total = counts.direct + counts.aliased + counts.unresolved;
|
|
241
|
+
const pct = (n) => (total === 0 ? 0 : Math.round((n / total) * 1000) / 10);
|
|
242
|
+
return {
|
|
243
|
+
total,
|
|
244
|
+
direct: counts.direct,
|
|
245
|
+
directPct: pct(counts.direct),
|
|
246
|
+
aliased: counts.aliased,
|
|
247
|
+
aliasedPct: pct(counts.aliased),
|
|
248
|
+
unresolved: counts.unresolved,
|
|
249
|
+
unresolvedPct: pct(counts.unresolved),
|
|
250
|
+
conformancePct: pct(counts.direct + counts.aliased),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Resolves NativeWind's class-name indirection back to Stark RN tokens — the
|
|
256
|
+
* React Native analog of tailwindResolver.js's web resolver. NativeWind is
|
|
257
|
+
* the one real Tailwind-syntax library on React Native (a Babel transform
|
|
258
|
+
* bringing `className` props to RN components, built on Tailwind's own
|
|
259
|
+
* config/theming), so this is scoped to it specifically rather than any
|
|
260
|
+
* general "RN Tailwind" abstraction.
|
|
261
|
+
*
|
|
262
|
+
* Architecturally this is a JS-import-resolution problem (like
|
|
263
|
+
* rnTokenAliasResolver.js), not a CSS-var()-chain problem (like
|
|
264
|
+
* tailwindResolver.js) wearing RN clothes: a NativeWind theme value has no
|
|
265
|
+
* var() to chase, so a consumer aliasing a Stark token can only do it by
|
|
266
|
+
* importing the RN token constant into tailwind.config.js and referencing it
|
|
267
|
+
* as a JS expression. Detection is always cheap, resolution isn't — the same
|
|
268
|
+
* refusal-to-fabricate-a-score rule as every other resolver in this module.
|
|
269
|
+
*/
|
|
270
|
+
export function resolveRnTailwindTokens(root, { platform = 'native', ignore = [] } = {}) {
|
|
271
|
+
if (platform !== 'native') {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`resolveRnTailwindTokens only supports platform "native" (got "${platform}") — NativeWind is the RN analog of web Tailwind, see tailwindResolver.js for the web resolver.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const nativewind = detectNativeWind(root, ignore);
|
|
278
|
+
if (!nativewind.found) {
|
|
279
|
+
return {
|
|
280
|
+
platform,
|
|
281
|
+
root,
|
|
282
|
+
detected: false,
|
|
283
|
+
reason: 'No NativeWind signal found ("nativewind" package.json dependency, "nativewind/babel" Babel preset, or "withNativeWind" Metro config wrapper).',
|
|
284
|
+
report: null,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const moduleGraph = buildModuleGraph(root, { ignore });
|
|
289
|
+
const { found: v3Found, entries: rawEntries, configFiles } = collectV3ThemeEntriesRn(root, moduleGraph, ignore);
|
|
290
|
+
|
|
291
|
+
if (!v3Found) {
|
|
292
|
+
return {
|
|
293
|
+
platform,
|
|
294
|
+
root,
|
|
295
|
+
detected: true,
|
|
296
|
+
nativewindSignal: nativewind.signal,
|
|
297
|
+
reason: 'NativeWind detected but no tailwind.config.{js,cjs,mjs,ts} found.',
|
|
298
|
+
report: null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (rawEntries.size === 0) {
|
|
303
|
+
return {
|
|
304
|
+
platform,
|
|
305
|
+
root,
|
|
306
|
+
detected: true,
|
|
307
|
+
nativewindSignal: nativewind.signal,
|
|
308
|
+
themeParseIncomplete: true,
|
|
309
|
+
reason: 'NativeWind detected but no statically-resolvable theme.colors entries were found — likely a dynamic/function-based tailwind.config theme, a CommonJS require()-only config, or theme keys outside the color namespace this resolver understands. Not scored.',
|
|
310
|
+
configFiles: configFiles.map((f) => path.relative(root, f)),
|
|
311
|
+
report: null,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const stkPkgRoot = stkRoot();
|
|
316
|
+
const { inventory, layers } = loadRnTokenInventory(stkPkgRoot);
|
|
317
|
+
const { aliasGraph } = buildAliasGraph(moduleGraph);
|
|
318
|
+
const ctx = { aliasGraph, moduleGraph, inventory, layers };
|
|
319
|
+
|
|
320
|
+
const { properties, findings, classified } = classifyThemeEntriesRn(rawEntries, ctx);
|
|
321
|
+
|
|
322
|
+
const rawUsages = collectClassNameUsages(moduleGraph);
|
|
323
|
+
const usages = [];
|
|
324
|
+
for (const u of rawUsages) {
|
|
325
|
+
const result = classifyClassNameUsageRn(u.className, classified);
|
|
326
|
+
if (!result) continue;
|
|
327
|
+
usages.push({ file: path.relative(root, u.file), line: u.line, className: u.className, ...result });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const report = summarizeReport(usages);
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
platform,
|
|
334
|
+
root,
|
|
335
|
+
detected: true,
|
|
336
|
+
nativewindSignal: nativewind.signal,
|
|
337
|
+
source: 'v3',
|
|
338
|
+
scannedConfigFiles: configFiles.length,
|
|
339
|
+
scannedJsFiles: moduleGraph.files.length,
|
|
340
|
+
themeEntryCount: rawEntries.size,
|
|
341
|
+
tokenInventoryTotal: inventory.size,
|
|
342
|
+
properties,
|
|
343
|
+
usages,
|
|
344
|
+
findings,
|
|
345
|
+
report,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
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 { resolveRnTailwindTokens } from './rnTailwindResolver.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-rn-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
|
+
const NATIVEWIND_PKG = `{ "name": "app", "dependencies": { "nativewind": "^4.0.0" } }`;
|
|
28
|
+
|
|
29
|
+
function usage(result, className) {
|
|
30
|
+
return result.usages.find((u) => u.className === className);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('resolveRnTailwindTokens — NativeWind detection', () => {
|
|
34
|
+
it('reports detected:false with no report when there is no NativeWind signal at all', () => {
|
|
35
|
+
const root = fixture({
|
|
36
|
+
'package.json': `{ "name": "app", "dependencies": {} }`,
|
|
37
|
+
'src/App.js': `import { View } from 'react-native'; export const App = () => <View className="flex" />;`,
|
|
38
|
+
});
|
|
39
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
40
|
+
expect(result.detected).toBe(false);
|
|
41
|
+
expect(result.report).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not treat a bare tailwind.config.js as a NativeWind signal (could belong to an unrelated web app in the same monorepo)', () => {
|
|
45
|
+
const root = fixture({
|
|
46
|
+
'package.json': `{ "name": "app", "dependencies": {} }`,
|
|
47
|
+
'tailwind.config.js': `
|
|
48
|
+
import { stkSurfaceBrand1Strong } from '@starklab/stk/rn';
|
|
49
|
+
module.exports = { theme: { extend: { colors: { primary: stkSurfaceBrand1Strong } } } };
|
|
50
|
+
`,
|
|
51
|
+
});
|
|
52
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
53
|
+
expect(result.detected).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('detects via a "nativewind" package.json dependency', () => {
|
|
57
|
+
const root = fixture({ 'package.json': NATIVEWIND_PKG });
|
|
58
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
59
|
+
expect(result.detected).toBe(true);
|
|
60
|
+
expect(result.nativewindSignal).toBe('package.json');
|
|
61
|
+
expect(result.reason).toMatch(/no tailwind\.config/i);
|
|
62
|
+
expect(result.report).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('detects via a "nativewind/babel" preset in babel.config.js', () => {
|
|
66
|
+
const root = fixture({
|
|
67
|
+
'package.json': `{ "name": "app", "dependencies": {} }`,
|
|
68
|
+
'babel.config.js': `module.exports = { presets: ['babel-preset-expo', 'nativewind/babel'] };`,
|
|
69
|
+
});
|
|
70
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
71
|
+
expect(result.detected).toBe(true);
|
|
72
|
+
expect(result.nativewindSignal).toBe('babel.config');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('detects via a "withNativeWind" wrapper in metro.config.js', () => {
|
|
76
|
+
const root = fixture({
|
|
77
|
+
'package.json': `{ "name": "app", "dependencies": {} }`,
|
|
78
|
+
'metro.config.js': `
|
|
79
|
+
const { withNativeWind } = require('nativewind/metro');
|
|
80
|
+
module.exports = withNativeWind(config, { input: './global.css' });
|
|
81
|
+
`,
|
|
82
|
+
});
|
|
83
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
84
|
+
expect(result.detected).toBe(true);
|
|
85
|
+
expect(result.nativewindSignal).toBe('metro.config');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reports themeParseIncomplete instead of a zero score for a dynamic/function-based v3 config', () => {
|
|
89
|
+
const root = fixture({
|
|
90
|
+
'package.json': NATIVEWIND_PKG,
|
|
91
|
+
'tailwind.config.js': `module.exports = { theme: (helpers) => ({ colors: { primary: helpers.colors.blue } }) };`,
|
|
92
|
+
});
|
|
93
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
94
|
+
expect(result.detected).toBe(true);
|
|
95
|
+
expect(result.themeParseIncomplete).toBe(true);
|
|
96
|
+
expect(result.report).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('resolveRnTailwindTokens — theme.colors classification', () => {
|
|
101
|
+
it('classifies a theme entry importing a Stark RN token identifier directly as conformant', () => {
|
|
102
|
+
const root = fixture({
|
|
103
|
+
'package.json': NATIVEWIND_PKG,
|
|
104
|
+
'tailwind.config.js': `
|
|
105
|
+
import { stkSurfaceBrand1Strong } from '@starklab/stk/rn';
|
|
106
|
+
module.exports = { theme: { extend: { colors: { primary: stkSurfaceBrand1Strong } } } };
|
|
107
|
+
`,
|
|
108
|
+
});
|
|
109
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
110
|
+
const prop = result.properties.find((p) => p.name === '--color-primary');
|
|
111
|
+
expect(prop.state).toBe('conformant');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('classifies a nested DEFAULT/shade color entry', () => {
|
|
115
|
+
const root = fixture({
|
|
116
|
+
'package.json': NATIVEWIND_PKG,
|
|
117
|
+
'tailwind.config.js': `
|
|
118
|
+
import { stkSurfaceBrand1Strong } from '@starklab/stk/rn';
|
|
119
|
+
module.exports = { theme: { extend: { colors: { brand: { DEFAULT: stkSurfaceBrand1Strong, 500: '#1956dd' } } } } };
|
|
120
|
+
`,
|
|
121
|
+
});
|
|
122
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
123
|
+
expect(result.properties.find((p) => p.name === '--color-brand').state).toBe('conformant');
|
|
124
|
+
expect(result.properties.find((p) => p.name === '--color-brand-500').state).toBe('drift');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('classifies a theme entry aliasing a raw literal as drift, not aliased', () => {
|
|
128
|
+
const root = fixture({
|
|
129
|
+
'package.json': NATIVEWIND_PKG,
|
|
130
|
+
'tailwind.config.js': `module.exports = { theme: { extend: { colors: { primary: '#1956dd' } } } };`,
|
|
131
|
+
});
|
|
132
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
133
|
+
const prop = result.properties.find((p) => p.name === '--color-primary');
|
|
134
|
+
expect(prop.state).toBe('drift');
|
|
135
|
+
expect(result.findings.some((f) => f.rule === 'drift-behind-alias' && f.property === '--color-primary')).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('classifies a theme entry aliasing a foreign (non-Stark) import as broken', () => {
|
|
139
|
+
const root = fixture({
|
|
140
|
+
'package.json': NATIVEWIND_PKG,
|
|
141
|
+
'tailwind.config.js': `
|
|
142
|
+
import { someColor } from 'some-other-lib';
|
|
143
|
+
module.exports = { theme: { extend: { colors: { primary: someColor } } } };
|
|
144
|
+
`,
|
|
145
|
+
});
|
|
146
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
147
|
+
const prop = result.properties.find((p) => p.name === '--color-primary');
|
|
148
|
+
expect(prop.state).toBe('broken');
|
|
149
|
+
expect(result.findings.some((f) => f.rule === 'broken-alias' && f.property === '--color-primary')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('flags a theme entry resolving straight to a primitive RN token as a layer violation', () => {
|
|
153
|
+
const root = fixture({
|
|
154
|
+
'package.json': NATIVEWIND_PKG,
|
|
155
|
+
'tailwind.config.js': `
|
|
156
|
+
import { stkSpacingMd } from '@starklab/stk/rn-spacing';
|
|
157
|
+
module.exports = { theme: { extend: { colors: { primary: stkSpacingMd } } } };
|
|
158
|
+
`,
|
|
159
|
+
});
|
|
160
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
161
|
+
const prop = result.properties.find((p) => p.name === '--color-primary');
|
|
162
|
+
expect(prop.state).toBe('conformant');
|
|
163
|
+
const findings = result.findings.filter((f) => f.rule === 'layer-violation' && f.property === '--color-primary');
|
|
164
|
+
expect(findings).toHaveLength(1);
|
|
165
|
+
expect(findings[0].severity).toBe('critical');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('warns past ~3 hops on a multi-hop conformant chain declared in the config file itself', () => {
|
|
169
|
+
const root = fixture({
|
|
170
|
+
'package.json': NATIVEWIND_PKG,
|
|
171
|
+
'tailwind.config.js': `
|
|
172
|
+
import { stkSurfaceBrand1Strong } from '@starklab/stk/rn';
|
|
173
|
+
const hop4 = stkSurfaceBrand1Strong;
|
|
174
|
+
const hop3 = hop4;
|
|
175
|
+
const hop2 = hop3;
|
|
176
|
+
const hop1 = hop2;
|
|
177
|
+
module.exports = { theme: { extend: { colors: { primary: hop1 } } } };
|
|
178
|
+
`,
|
|
179
|
+
});
|
|
180
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
181
|
+
const prop = result.properties.find((p) => p.name === '--color-primary');
|
|
182
|
+
expect(prop.state).toBe('conformant');
|
|
183
|
+
expect(result.findings.some((f) => f.rule === 'deep-alias-chain' && f.property === '--color-primary')).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('resolveRnTailwindTokens — className usage scanning', () => {
|
|
188
|
+
it('classifies a className resolving to a conformant theme entry as aliased', () => {
|
|
189
|
+
const root = fixture({
|
|
190
|
+
'package.json': NATIVEWIND_PKG,
|
|
191
|
+
'tailwind.config.js': `
|
|
192
|
+
import { stkSurfaceBrand1Strong } from '@starklab/stk/rn';
|
|
193
|
+
module.exports = { theme: { extend: { colors: { primary: stkSurfaceBrand1Strong } } } };
|
|
194
|
+
`,
|
|
195
|
+
'src/App.js': `import { View } from 'react-native'; export const App = () => <View className="bg-primary" />;`,
|
|
196
|
+
});
|
|
197
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
198
|
+
expect(usage(result, 'bg-primary').classification).toBe('aliased');
|
|
199
|
+
expect(result.report.aliased).toBe(1);
|
|
200
|
+
expect(result.report.direct).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('classifies a className resolving to a drifted theme entry as drift, not aliased', () => {
|
|
204
|
+
const root = fixture({
|
|
205
|
+
'package.json': NATIVEWIND_PKG,
|
|
206
|
+
'tailwind.config.js': `module.exports = { theme: { extend: { colors: { primary: '#1956dd' } } } };`,
|
|
207
|
+
'src/App.js': `import { View } from 'react-native'; export const App = () => <View className="bg-primary" />;`,
|
|
208
|
+
});
|
|
209
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
210
|
+
expect(usage(result, 'bg-primary').classification).toBe('drift');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('strips a variant prefix before matching the base utility', () => {
|
|
214
|
+
const root = fixture({
|
|
215
|
+
'package.json': NATIVEWIND_PKG,
|
|
216
|
+
'tailwind.config.js': `
|
|
217
|
+
import { stkSurfaceBrand1Strong } from '@starklab/stk/rn';
|
|
218
|
+
module.exports = { theme: { extend: { colors: { primary: stkSurfaceBrand1Strong } } } };
|
|
219
|
+
`,
|
|
220
|
+
'src/App.js': `import { View } from 'react-native'; export const App = () => <View className="dark:bg-primary" />;`,
|
|
221
|
+
});
|
|
222
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
223
|
+
expect(usage(result, 'dark:bg-primary').classification).toBe('aliased');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('does not count an unrelated utility class or Tailwind default-palette color as a usage', () => {
|
|
227
|
+
const root = fixture({
|
|
228
|
+
'package.json': NATIVEWIND_PKG,
|
|
229
|
+
'tailwind.config.js': `
|
|
230
|
+
import { stkSurfaceBrand1Strong } from '@starklab/stk/rn';
|
|
231
|
+
module.exports = { theme: { extend: { colors: { primary: stkSurfaceBrand1Strong } } } };
|
|
232
|
+
`,
|
|
233
|
+
'src/App.js': `import { View } from 'react-native'; export const App = () => <View className="flex bg-red-500" />;`,
|
|
234
|
+
});
|
|
235
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
236
|
+
expect(usage(result, 'flex')).toBeUndefined();
|
|
237
|
+
expect(usage(result, 'bg-red-500')).toBeUndefined();
|
|
238
|
+
expect(result.report.total).toBe(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('does not count a dynamic className expression (ternary) since it is not statically resolvable', () => {
|
|
242
|
+
const root = fixture({
|
|
243
|
+
'package.json': NATIVEWIND_PKG,
|
|
244
|
+
'tailwind.config.js': `
|
|
245
|
+
import { stkSurfaceBrand1Strong } from '@starklab/stk/rn';
|
|
246
|
+
module.exports = { theme: { extend: { colors: { primary: stkSurfaceBrand1Strong } } } };
|
|
247
|
+
`,
|
|
248
|
+
'src/App.js': `
|
|
249
|
+
import { View } from 'react-native';
|
|
250
|
+
export const App = ({ active }) => <View className={active ? 'bg-primary' : 'bg-primary'} />;
|
|
251
|
+
`,
|
|
252
|
+
});
|
|
253
|
+
const result = resolveRnTailwindTokens(root, { platform: 'native' });
|
|
254
|
+
expect(result.report.total).toBe(0);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('resolveRnTailwindTokens — platform scope', () => {
|
|
259
|
+
it('throws for a non-native platform since NativeWind is the RN analog of web Tailwind', () => {
|
|
260
|
+
const root = fixture({ 'package.json': NATIVEWIND_PKG });
|
|
261
|
+
expect(() => resolveRnTailwindTokens(root, { platform: 'web' })).toThrow(/native/i);
|
|
262
|
+
});
|
|
263
|
+
});
|