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