@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,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
+ });