@starklab/stark-mcp 0.2.0 → 0.3.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/README.md +42 -4
- package/package.json +12 -1
- package/src/adopt/a11yPass.js +397 -0
- package/src/adopt/adoptGate.js +471 -0
- package/src/adopt/adoptScanReport.js +9 -0
- package/src/adopt/componentPropApi.js +200 -0
- package/src/adopt/findingSnippet.js +386 -0
- package/src/adopt/foreignDiscoveryResolver.js +20 -3
- package/src/adopt/foreignScoringResolver.js +163 -22
- package/src/adopt/moduleGraph.js +44 -2
- package/src/adopt/prCheckReport.js +274 -0
- package/src/adopt/propApiResolver.js +2 -2
- package/src/adopt/referenceResolver.js +2 -2
- package/src/adopt/scanRollup.js +192 -0
- package/src/adopt/tailwindResolver.js +6 -1
- package/src/adopt/targetDiscovery.js +31 -3
- package/src/adopt/tokenAliasResolver.js +45 -2
- package/src/adopt/usageRulesResolver.js +299 -14
- package/src/adopt/vecnaMaterializer.js +45 -5
- package/src/adopt/vecnaVerifier.js +20 -9
- package/src/adopt/wrapperResolver.js +3 -3
- package/src/cli.js +343 -8
- package/src/data.js +105 -11
- package/src/server.js +69 -14
- package/src/whisperer.d.ts +106 -0
- package/src/whisperer.js +814 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { stkRoot } from '../data.js';
|
|
4
|
+
import { loadTokenInventory } from './tokenAliasResolver.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Captures the source a finding is actually about — the "wrong" side — and,
|
|
8
|
+
* where the fix is *derivable rather than guessed*, the "right" side next to
|
|
9
|
+
* it. This is what turns a finding from a coordinate ("drift-behind-alias at
|
|
10
|
+
* PromoBanner.tsx:24") into something a developer can act on without leaving
|
|
11
|
+
* the tracker.
|
|
12
|
+
*
|
|
13
|
+
* Three constraints shape everything below.
|
|
14
|
+
*
|
|
15
|
+
* 1. **Source code leaving the customer's machine is a new data flow.** Every
|
|
16
|
+
* other field a scan reports is a name, a count, or a coordinate; this is
|
|
17
|
+
* the first one that carries the customer's own code. So it is bounded on
|
|
18
|
+
* three axes — `CONTEXT_LINES` around the finding, `MAX_LINE_LENGTH` per
|
|
19
|
+
* line, `MAX_SNIPPETS` per scan — and it is opt-out at the CLI
|
|
20
|
+
* (`stark-cli adopt --no-snippets`), which skips this module entirely and
|
|
21
|
+
* leaves the rest of the report byte-identical.
|
|
22
|
+
*
|
|
23
|
+
* 2. **The "right" side is never invented.** A wrong snippet is an observation;
|
|
24
|
+
* a right one is a claim about what the code should say, and a plausible
|
|
25
|
+
* wrong answer there is worse than no answer. So `after` is produced only
|
|
26
|
+
* by a literal substitution the token build itself proves — a value that
|
|
27
|
+
* resolves to exactly one semantic token — and is `null` for every rule and
|
|
28
|
+
* every value where it doesn't. Two of the nine rules that carry a location
|
|
29
|
+
* can ever produce one (see DERIVABLE_RULES); the other seven get a wrong
|
|
30
|
+
* side and an honest empty right side.
|
|
31
|
+
*
|
|
32
|
+
* 3. **It must never break a scan.** An unreadable file, a missing token
|
|
33
|
+
* build, a line number past the end of the file: each drops that one
|
|
34
|
+
* snippet and nothing else. Nothing here throws.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// Lines of context on each side of the offending line. Two is enough to see
|
|
38
|
+
// the declaration's block without shipping a whole function body.
|
|
39
|
+
const CONTEXT_LINES = 2;
|
|
40
|
+
// Long minified or generated lines are truncated rather than dropped — the
|
|
41
|
+
// finding is still worth showing, the tail of a 4000-column bundle line is not.
|
|
42
|
+
const MAX_LINE_LENGTH = 200;
|
|
43
|
+
// Whole-scan budget. A repo with thousands of findings would otherwise turn a
|
|
44
|
+
// small JSON report into a partial copy of its own source tree.
|
|
45
|
+
const MAX_SNIPPETS = 200;
|
|
46
|
+
|
|
47
|
+
// What the inline `// annotation` on the highlighted line says. Keyed by rule,
|
|
48
|
+
// because the annotation is the one-line explanation of *why* that line is
|
|
49
|
+
// highlighted, and that differs per rule even when the capture doesn't.
|
|
50
|
+
const RULE_ANNOTATIONS = {
|
|
51
|
+
'drift-behind-alias': { before: 'hardcoded value', after: 'design system token' },
|
|
52
|
+
'layer-violation': { before: 'primitive token — skips the semantic layer', after: 'semantic token' },
|
|
53
|
+
'broken-alias': { before: 'alias chain never resolves' },
|
|
54
|
+
'unresolved-alias': { before: 'alias target not found in the scanned files' },
|
|
55
|
+
'raw-fallback': { before: 'raw fallback value' },
|
|
56
|
+
'style-escape-hatch': { before: 'inline style bypasses the component API' },
|
|
57
|
+
'deprecated-prop-passed': { before: 'deprecated prop' },
|
|
58
|
+
'invalid-enum-value': { before: 'value outside the prop enum' },
|
|
59
|
+
'required-prop-missing': { before: 'required prop missing' },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// The only two rules whose fix is a substitution rather than a judgement.
|
|
63
|
+
// `drift-behind-alias` says a declaration bottoms out in a raw value, and
|
|
64
|
+
// `layer-violation` says it bottoms out in a primitive — in both cases the
|
|
65
|
+
// correct replacement is a semantic token with the same resolved value, which
|
|
66
|
+
// the token build can be asked for. Every other rule needs a decision
|
|
67
|
+
// (which prop, which enum member, which component) that this module has no
|
|
68
|
+
// basis to make.
|
|
69
|
+
const DERIVABLE_RULES = new Set(['drift-behind-alias', 'layer-violation']);
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Token values
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function normalizeValue(value) {
|
|
76
|
+
const v = String(value).trim().toLowerCase().replace(/\s+/g, ' ');
|
|
77
|
+
// #abc and #aabbcc are the same colour and the build emits both forms.
|
|
78
|
+
const short = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
79
|
+
return short ? `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}` : v;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* literal value -> the tokens a consumer should be writing instead, from the
|
|
84
|
+
* built `tokens.css`.
|
|
85
|
+
*
|
|
86
|
+
* Two layers are indexed, and the split is not arbitrary. Colour has a real
|
|
87
|
+
* semantic layer (`surface/*`, `text/*`, `border/*`, `icon/*`, `state/*`), so
|
|
88
|
+
* a colour suggestion is a semantic token and a primitive shade is exactly the
|
|
89
|
+
* thing `layer-violation` reports — never a suggestion. Dimensions have no
|
|
90
|
+
* semantic layer at all: `radius/md`, `spacing/xs` and the type scale live in
|
|
91
|
+
* `tokens/base/` and *are* the published vocabulary (CLAUDE.md's own
|
|
92
|
+
* "semantic tokens quick reference" lists them), so for a length the primitive
|
|
93
|
+
* is the honest answer. Hence: every semantic token, plus every primitive
|
|
94
|
+
* whose value is not a colour.
|
|
95
|
+
*
|
|
96
|
+
* Component tokens are excluded from both — suggesting `--stk-button-bg` for a
|
|
97
|
+
* consumer's own property would bind their code to another component's slot.
|
|
98
|
+
*
|
|
99
|
+
* Returns an empty index rather than throwing when the consumer has no token
|
|
100
|
+
* build — then nothing is derivable and every snippet is wrong-side only.
|
|
101
|
+
*/
|
|
102
|
+
function isColorValue(value) {
|
|
103
|
+
return /^(#|rgb|hsl|color\()/i.test(String(value).trim());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function loadTokenValueIndex(stkPkgRoot) {
|
|
107
|
+
const empty = { byValue: new Map(), declared: new Map(), resolve: () => null };
|
|
108
|
+
const cssPath = path.join(stkPkgRoot, 'build', 'css', 'tokens.css');
|
|
109
|
+
if (!existsSync(cssPath)) return empty;
|
|
110
|
+
|
|
111
|
+
let src;
|
|
112
|
+
let layers;
|
|
113
|
+
try {
|
|
114
|
+
src = readFileSync(cssPath, 'utf-8');
|
|
115
|
+
({ layers } = loadTokenInventory(stkPkgRoot));
|
|
116
|
+
} catch {
|
|
117
|
+
return empty;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const declared = new Map();
|
|
121
|
+
for (const m of src.matchAll(/(--stk-[\w-]+)\s*:\s*([^;]+);/g)) declared.set(m[1], m[2].trim());
|
|
122
|
+
|
|
123
|
+
// The build's own var() chains have to be followed to a literal, or a
|
|
124
|
+
// semantic token that aliases a primitive would index under the string
|
|
125
|
+
// "var(--stk-…)" and match nothing a consumer ever writes.
|
|
126
|
+
const resolve = (name, seen) => {
|
|
127
|
+
if (seen.has(name)) return null;
|
|
128
|
+
seen.add(name);
|
|
129
|
+
const value = declared.get(name);
|
|
130
|
+
if (!value) return null;
|
|
131
|
+
const ref = value.match(/^var\(\s*(--stk-[\w-]+)\s*\)$/);
|
|
132
|
+
return ref ? resolve(ref[1], seen) : value;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const byValue = new Map();
|
|
136
|
+
for (const name of declared.keys()) {
|
|
137
|
+
const layer = layers.get(name);
|
|
138
|
+
if (layer !== 'semantic' && layer !== 'primitive') continue;
|
|
139
|
+
const literal = resolve(name, new Set());
|
|
140
|
+
if (!literal) continue;
|
|
141
|
+
if (layer === 'primitive' && isColorValue(literal)) continue;
|
|
142
|
+
const key = normalizeValue(literal);
|
|
143
|
+
if (!byValue.has(key)) byValue.set(key, []);
|
|
144
|
+
byValue.get(key).push(name);
|
|
145
|
+
}
|
|
146
|
+
return { byValue, declared, resolve: (name) => resolve(name, new Set()) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The token family a declaration is asking for, read off the words its author
|
|
150
|
+
// already wrote. This is the only tie-breaker between equal-valued tokens, and
|
|
151
|
+
// it exists because a bare value is genuinely ambiguous: `8px` is a radius, a
|
|
152
|
+
// spacing step *and* a border width in this scale, and `#aaaaaa` is a surface,
|
|
153
|
+
// a border and a press state.
|
|
154
|
+
//
|
|
155
|
+
// Ordered, and the order is the whole content of the table: `borderRadius` is
|
|
156
|
+
// a radius before it is a border, and `backgroundColor` is a surface before it
|
|
157
|
+
// is a colour. First match wins; a hint that matches nothing gets no
|
|
158
|
+
// suggestion rather than an arbitrary one.
|
|
159
|
+
const FAMILY_RULES = [
|
|
160
|
+
[/background|(^|[^a-z])bg([^a-z]|$)|surface/, 'surface'],
|
|
161
|
+
[/radius|rounded/, 'radius'],
|
|
162
|
+
[/border-?width|outline-?width|stroke-?width/, 'border-width'],
|
|
163
|
+
[/border|outline|stroke|divider/, 'border'],
|
|
164
|
+
[/gap|padding|margin|inset|spacing|space/, 'spacing'],
|
|
165
|
+
[/font-?size|text-?size|type-?size/, 'typography-size'],
|
|
166
|
+
[/icon/, 'icon'],
|
|
167
|
+
[/shadow|elevation/, 'shadow'],
|
|
168
|
+
[/duration|delay|easing|motion|transition/, 'motion'],
|
|
169
|
+
[/opacity/, 'opacity'],
|
|
170
|
+
[/colou?r|text|label|title|copy|foreground/, 'text'],
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
export function tokenFamilyFor(...hints) {
|
|
174
|
+
const text = hints
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.join(' ')
|
|
177
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
178
|
+
.toLowerCase();
|
|
179
|
+
for (const [pattern, family] of FAMILY_RULES) {
|
|
180
|
+
if (pattern.test(text)) return family;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The replacement token for `value`, or null when there isn't exactly one
|
|
187
|
+
* honest answer.
|
|
188
|
+
*
|
|
189
|
+
* Two gates, both of which must pass. The value has to resolve to a token that
|
|
190
|
+
* exists — no rounding to the nearest step, since a `12px` in a scale that
|
|
191
|
+
* publishes 8 and 16 is a decision only the author can make — and the family
|
|
192
|
+
* that value belongs to has to be identifiable from the declaration and has to
|
|
193
|
+
* leave exactly one candidate. Anything else returns null, and the finding is
|
|
194
|
+
* reported with a wrong side and no right side.
|
|
195
|
+
*/
|
|
196
|
+
export function suggestReplacementToken(value, hints, tokenValues) {
|
|
197
|
+
if (!tokenValues || !tokenValues.byValue) return null;
|
|
198
|
+
|
|
199
|
+
// A token reference resolves through the build first (layer-violation), a
|
|
200
|
+
// literal is looked up as written (drift-behind-alias).
|
|
201
|
+
const ref = String(value).trim().match(/^var\(\s*(--stk-[\w-]+)\s*\)$/);
|
|
202
|
+
const literal = ref ? tokenValues.resolve(ref[1]) : value;
|
|
203
|
+
if (!literal) return null;
|
|
204
|
+
|
|
205
|
+
const candidates = (tokenValues.byValue.get(normalizeValue(literal)) ?? [])
|
|
206
|
+
// Replacing a token with itself is not a fix.
|
|
207
|
+
.filter((c) => !ref || c !== ref[1]);
|
|
208
|
+
if (candidates.length === 0) return null;
|
|
209
|
+
|
|
210
|
+
// The family filter runs even when only one candidate survived the value
|
|
211
|
+
// lookup, and that is deliberate: `12px` happens to be exactly
|
|
212
|
+
// `--stk-spacing-sm` and is not a radius in this scale at all, so a lone
|
|
213
|
+
// match from the wrong family is a confident wrong answer, not a lucky one.
|
|
214
|
+
const family = tokenFamilyFor(...hints);
|
|
215
|
+
if (family) {
|
|
216
|
+
const narrowed = candidates.filter((c) => c.startsWith(`--stk-${family}-`));
|
|
217
|
+
return narrowed.length === 1 ? narrowed[0] : null;
|
|
218
|
+
}
|
|
219
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Source capture
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The name and value a declaration assigns, in either of the two languages a
|
|
228
|
+
* finding can point at: a CSS declaration (`--promo-radius: 12px;`) and a
|
|
229
|
+
* JS/JSX style-object entry (`borderRadius: '12px' }}>`). Both halves are
|
|
230
|
+
* returned: the value is what gets substituted, the name is what disambiguates
|
|
231
|
+
* which token to substitute it with.
|
|
232
|
+
*/
|
|
233
|
+
export function extractDeclaredValue(line) {
|
|
234
|
+
const text = String(line);
|
|
235
|
+
const idx = text.indexOf(':');
|
|
236
|
+
if (idx < 0) return null;
|
|
237
|
+
|
|
238
|
+
const name = text.slice(0, idx).replace(/[^\w-]+/g, ' ').trim();
|
|
239
|
+
const rest = text.slice(idx + 1).trim();
|
|
240
|
+
if (!rest) return null;
|
|
241
|
+
|
|
242
|
+
// A quoted value ends at its own closing quote, so whatever follows it —
|
|
243
|
+
// `}}>`, a comma, a JSX tail — is not part of the value and needs no
|
|
244
|
+
// balancing pass.
|
|
245
|
+
const quoted = rest.match(/^(['"`])(.*?)\1/);
|
|
246
|
+
if (quoted) return { name, value: quoted[2], quote: quoted[1] };
|
|
247
|
+
|
|
248
|
+
// Otherwise strip the statement terminator, then peel closers that belong to
|
|
249
|
+
// an expression opened outside this value — but only while they are actually
|
|
250
|
+
// unbalanced, or `rgb(0, 0, 0)` would lose its own parenthesis.
|
|
251
|
+
const openerFor = { '}': '{', ')': '(', ']': '[' };
|
|
252
|
+
let value = rest.replace(/[;,]\s*$/, '').trim();
|
|
253
|
+
for (;;) {
|
|
254
|
+
const last = value[value.length - 1];
|
|
255
|
+
if (last === '>') { value = value.slice(0, -1).trim(); continue; }
|
|
256
|
+
const opener = openerFor[last];
|
|
257
|
+
if (opener && value.split(last).length > value.split(opener).length) {
|
|
258
|
+
value = value.slice(0, -1).replace(/[;,]\s*$/, '').trim();
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
return value ? { name, value, quote: '' } : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function truncate(line) {
|
|
267
|
+
return line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}…` : line;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Strips the shared leading indentation so a snippet from a deeply nested
|
|
271
|
+
// block doesn't spend half its width on whitespace. Blank lines are ignored
|
|
272
|
+
// when measuring, or a single empty line would pin the indent at zero.
|
|
273
|
+
function dedent(lines) {
|
|
274
|
+
const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length);
|
|
275
|
+
const common = indents.length ? Math.min(...indents) : 0;
|
|
276
|
+
return common ? lines.map((l) => l.slice(common)) : lines;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readLines(absPath, cache) {
|
|
280
|
+
if (cache.has(absPath)) return cache.get(absPath);
|
|
281
|
+
let lines = null;
|
|
282
|
+
try {
|
|
283
|
+
lines = readFileSync(absPath, 'utf-8').split(/\r?\n/);
|
|
284
|
+
} catch {
|
|
285
|
+
lines = null;
|
|
286
|
+
}
|
|
287
|
+
cache.set(absPath, lines);
|
|
288
|
+
return lines;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function relativeFile(absPath, root) {
|
|
292
|
+
const rel = path.relative(root, absPath).split(path.sep).join('/');
|
|
293
|
+
// A finding outside the scan root has no meaningful project-relative path,
|
|
294
|
+
// and the absolute one is exactly what must not be shipped.
|
|
295
|
+
return !rel || rel.startsWith('..') ? path.basename(absPath) : rel;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The snippet for one finding, or null when there is nothing honest to show:
|
|
300
|
+
* no location, an unreadable file, or a line number the file doesn't have.
|
|
301
|
+
*/
|
|
302
|
+
export function captureFindingSnippet(finding, { root, tokenValues = null, cache = new Map() } = {}) {
|
|
303
|
+
const { file, line } = finding ?? {};
|
|
304
|
+
if (typeof file !== 'string' || !file || typeof line !== 'number' || line < 1) return null;
|
|
305
|
+
|
|
306
|
+
const absPath = path.isAbsolute(file) ? file : path.join(root, file);
|
|
307
|
+
const lines = readLines(absPath, cache);
|
|
308
|
+
if (!lines || line > lines.length) return null;
|
|
309
|
+
|
|
310
|
+
const index = line - 1;
|
|
311
|
+
const start = Math.max(0, index - CONTEXT_LINES);
|
|
312
|
+
const end = Math.min(lines.length - 1, index + CONTEXT_LINES);
|
|
313
|
+
const before = dedent(lines.slice(start, end + 1)).map(truncate);
|
|
314
|
+
const highlightIndex = index - start;
|
|
315
|
+
|
|
316
|
+
const annotations = RULE_ANNOTATIONS[finding.rule] ?? {};
|
|
317
|
+
let after = null;
|
|
318
|
+
let afterAnnotation = null;
|
|
319
|
+
|
|
320
|
+
if (DERIVABLE_RULES.has(finding.rule)) {
|
|
321
|
+
const declared = extractDeclaredValue(before[highlightIndex]);
|
|
322
|
+
const token = declared
|
|
323
|
+
? suggestReplacementToken(declared.value, [finding.property, finding.prop, declared.name], tokenValues)
|
|
324
|
+
: null;
|
|
325
|
+
if (token && declared) {
|
|
326
|
+
const replacement = `var(${token})`;
|
|
327
|
+
after = before.map((l, i) => (i === highlightIndex ? l.replace(declared.value, replacement) : l));
|
|
328
|
+
// A no-op replacement means the value wasn't where it was parsed from —
|
|
329
|
+
// better to show no right side than an identical one labelled as a fix.
|
|
330
|
+
if (after[highlightIndex] === before[highlightIndex]) after = null;
|
|
331
|
+
else afterAnnotation = annotations.after ?? 'design system token';
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
file: relativeFile(absPath, root),
|
|
337
|
+
before,
|
|
338
|
+
after,
|
|
339
|
+
highlightIndex,
|
|
340
|
+
beforeAnnotation: annotations.before ?? finding.rule,
|
|
341
|
+
afterAnnotation,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Walks a `runAdopt` result and attaches a `snippet` to every finding that can
|
|
347
|
+
* have one. Returns a new object; the input is not mutated.
|
|
348
|
+
*
|
|
349
|
+
* Keyed on the shape rather than on a list of resolvers: every resolver that
|
|
350
|
+
* reports defects does so under a `findings` array, and a new one gets this for
|
|
351
|
+
* free rather than by remembering to register itself here.
|
|
352
|
+
*/
|
|
353
|
+
export function attachFindingSnippets(result, { root, stkPkgRoot = null, maxSnippets = MAX_SNIPPETS } = {}) {
|
|
354
|
+
let tokenValues = null;
|
|
355
|
+
try {
|
|
356
|
+
tokenValues = loadTokenValueIndex(stkPkgRoot ?? stkRoot());
|
|
357
|
+
} catch {
|
|
358
|
+
tokenValues = null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const cache = new Map();
|
|
362
|
+
let remaining = maxSnippets;
|
|
363
|
+
|
|
364
|
+
const walk = (value) => {
|
|
365
|
+
if (Array.isArray(value)) return value.map(walk);
|
|
366
|
+
if (!value || typeof value !== 'object') return value;
|
|
367
|
+
|
|
368
|
+
const out = {};
|
|
369
|
+
for (const [key, child] of Object.entries(value)) {
|
|
370
|
+
if (key === 'findings' && Array.isArray(child)) {
|
|
371
|
+
out[key] = child.map((finding) => {
|
|
372
|
+
if (remaining <= 0 || !finding || typeof finding !== 'object') return finding;
|
|
373
|
+
const snippet = captureFindingSnippet(finding, { root, tokenValues, cache });
|
|
374
|
+
if (!snippet) return finding;
|
|
375
|
+
remaining -= 1;
|
|
376
|
+
return { ...finding, snippet };
|
|
377
|
+
});
|
|
378
|
+
} else {
|
|
379
|
+
out[key] = walk(child);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
return walk(result);
|
|
386
|
+
}
|
|
@@ -154,7 +154,7 @@ function hasComponentExport(ast) {
|
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
156
|
* Component enumeration — walks for componentDir matches (e.g.
|
|
157
|
-
* components/ui), then parses
|
|
157
|
+
* components/ui), then parses every .tsx/.jsx beneath one and keeps
|
|
158
158
|
* only files with a real component export. This is literally reading a
|
|
159
159
|
* conventional folder shadcn always writes to, not import-usage detection
|
|
160
160
|
* — a consumer who renamed or restructured that folder produces zero
|
|
@@ -174,7 +174,20 @@ function enumerateComponents(root, ignore, system) {
|
|
|
174
174
|
const unresolvedFiles = [];
|
|
175
175
|
|
|
176
176
|
for (const dir of dirs) {
|
|
177
|
-
|
|
177
|
+
// Recursive, not flat. shadcn's own CLI writes flat into components/ui,
|
|
178
|
+
// but consumers group related files into a subfolder and those are
|
|
179
|
+
// still the catalog: openstatusHQ/openstatus's apps/dashboard keeps 8
|
|
180
|
+
// of its 10 components/ui files under a data-table/ subdir, and a flat
|
|
181
|
+
// read reported "2 available, 2 used, 100% coverage" for it — a number
|
|
182
|
+
// that reads as complete adoption while missing 80% of the folder.
|
|
183
|
+
// hasComponentExport() below is what keeps the wider net honest; test
|
|
184
|
+
// and story files are dropped by name since a story does export a
|
|
185
|
+
// component-shaped value.
|
|
186
|
+
const files = fg.sync(['**/*.tsx', '**/*.jsx'], {
|
|
187
|
+
cwd: dir,
|
|
188
|
+
absolute: true,
|
|
189
|
+
ignore: [...DEFAULT_IGNORE, '**/*.test.*', '**/*.spec.*', '**/*.stories.*'],
|
|
190
|
+
});
|
|
178
191
|
for (const file of files) {
|
|
179
192
|
let code;
|
|
180
193
|
try {
|
|
@@ -190,8 +203,12 @@ function enumerateComponents(root, ignore, system) {
|
|
|
190
203
|
continue;
|
|
191
204
|
}
|
|
192
205
|
if (!hasComponentExport(ast)) continue;
|
|
206
|
+
// A file directly in the dir keeps its bare basename; one in a
|
|
207
|
+
// subfolder carries the subfolder so two "data-table" rows can't
|
|
208
|
+
// appear side by side with nothing to tell them apart.
|
|
209
|
+
const withinDir = path.relative(dir, file);
|
|
193
210
|
components.push({
|
|
194
|
-
name:
|
|
211
|
+
name: withinDir.replace(/\.(tsx|jsx)$/, '').split(path.sep).join('/'),
|
|
195
212
|
file: path.relative(root, file),
|
|
196
213
|
});
|
|
197
214
|
}
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { getForeignSystem } from './foreignSystemConfig.js';
|
|
5
5
|
import { buildModuleGraph, resolveCandidateFile, resolveRelative } from './moduleGraph.js';
|
|
6
6
|
import { resolveOpportunities } from './opportunityResolver.js';
|
|
7
|
+
import { discoverTargets, workspacePackageMap } from './targetDiscovery.js';
|
|
7
8
|
|
|
8
9
|
// Strips // and block comments from JSONC source, string-literal-aware — a
|
|
9
10
|
// naive regex stripper is NOT safe here: a first attempt matched the block-
|
|
@@ -49,8 +50,16 @@ function stripJsonComments(src) {
|
|
|
49
50
|
return out;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
function readJson(file) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(stripJsonComments(readFileSync(file, 'utf-8')).replace(/,(\s*[}\]])/g, '$1'));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
/**
|
|
53
|
-
* Reads compilerOptions.paths/baseUrl from
|
|
62
|
+
* Reads compilerOptions.paths/baseUrl from one directory's own tsconfig.json
|
|
54
63
|
* (falling back to jsconfig.json). A live run against shadcn-ui/taxonomy
|
|
55
64
|
* caught the reason this exists: every real Next.js + shadcn consumer
|
|
56
65
|
* imports its own components via the `@/*` alias (`create-next-app`'s and
|
|
@@ -60,27 +69,58 @@ function stripJsonComments(src) {
|
|
|
60
69
|
* JSON; a config that still fails to parse just yields no aliases, same as
|
|
61
70
|
* a repo with no path aliases at all.
|
|
62
71
|
*/
|
|
63
|
-
function loadPathAliases(
|
|
72
|
+
function loadPathAliases(dir) {
|
|
64
73
|
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
65
|
-
const file = path.join(
|
|
74
|
+
const file = path.join(dir, name);
|
|
66
75
|
if (!existsSync(file)) continue;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
const json = readJson(file);
|
|
77
|
+
const paths = json?.compilerOptions?.paths;
|
|
78
|
+
if (!paths) continue;
|
|
79
|
+
return { dir, baseUrl: json.compilerOptions?.baseUrl || '.', paths };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The tsconfig that actually governs one file: the nearest one at or above
|
|
86
|
+
* it, not the one at the scan root. Reading the root's alone is correct for
|
|
87
|
+
* a single-package repo and wrong for every monorepo — measured live against
|
|
88
|
+
* openstatusHQ/openstatus, which has *no* tsconfig.json at its root at all
|
|
89
|
+
* and declares `@/*` separately in each of apps/dashboard, apps/web and
|
|
90
|
+
* apps/status-page. Scanning it from the root therefore resolved zero `@/*`
|
|
91
|
+
* imports, and components with 29 and 10 real usages (checkbox-tree,
|
|
92
|
+
* sortable) scored 0 — the same components scored correctly when the scan
|
|
93
|
+
* was pointed at apps/dashboard directly. Results are memoised per
|
|
94
|
+
* directory, including the negative one, so the walk runs once per dir
|
|
95
|
+
* rather than once per file.
|
|
96
|
+
*/
|
|
97
|
+
function nearestPathAliases(root, file, cache) {
|
|
98
|
+
const chain = [];
|
|
99
|
+
let dir = path.dirname(file);
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (cache.has(dir)) {
|
|
102
|
+
const hit = cache.get(dir);
|
|
103
|
+
for (const d of chain) cache.set(d, hit);
|
|
104
|
+
return hit;
|
|
75
105
|
}
|
|
106
|
+
chain.push(dir);
|
|
107
|
+
const found = loadPathAliases(dir);
|
|
108
|
+
if (found) {
|
|
109
|
+
for (const d of chain) cache.set(d, found);
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
const parent = path.dirname(dir);
|
|
113
|
+
if (dir === root || parent === dir || !dir.startsWith(root)) break;
|
|
114
|
+
dir = parent;
|
|
76
115
|
}
|
|
116
|
+
for (const d of chain) cache.set(d, null);
|
|
77
117
|
return null;
|
|
78
118
|
}
|
|
79
119
|
|
|
80
120
|
/** Resolves a bare specifier (e.g. "@/components/ui/button") against tsconfig paths, if any match. */
|
|
81
|
-
function resolveAliasedImport(
|
|
121
|
+
function resolveAliasedImport(aliases, specifier) {
|
|
82
122
|
if (!aliases) return null;
|
|
83
|
-
const base = path.resolve(
|
|
123
|
+
const base = path.resolve(aliases.dir, aliases.baseUrl);
|
|
84
124
|
for (const [pattern, targets] of Object.entries(aliases.paths)) {
|
|
85
125
|
if (pattern.endsWith('/*')) {
|
|
86
126
|
const prefix = pattern.slice(0, -2);
|
|
@@ -100,21 +140,103 @@ function resolveAliasedImport(root, aliases, specifier) {
|
|
|
100
140
|
return null;
|
|
101
141
|
}
|
|
102
142
|
|
|
143
|
+
/** Splits "@scope/pkg/a/b" into { name: "@scope/pkg", subpath: "a/b" }. */
|
|
144
|
+
function splitPackageSpecifier(specifier) {
|
|
145
|
+
const parts = specifier.split('/');
|
|
146
|
+
if (specifier.startsWith('@')) {
|
|
147
|
+
if (parts.length < 2) return null;
|
|
148
|
+
return { name: parts.slice(0, 2).join('/'), subpath: parts.slice(2).join('/') };
|
|
149
|
+
}
|
|
150
|
+
return { name: parts[0], subpath: parts.slice(1).join('/') };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Picks a file path out of one "exports" value, string or conditions object. */
|
|
154
|
+
function pickExportTarget(value) {
|
|
155
|
+
if (typeof value === 'string') return value;
|
|
156
|
+
if (!value || typeof value !== 'object') return null;
|
|
157
|
+
for (const key of ['default', 'import', 'require', 'types']) {
|
|
158
|
+
if (typeof value[key] === 'string') return value[key];
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolves one subpath through a package.json "exports" map, including
|
|
165
|
+
* wildcard patterns ("./components/ui/*": "./src/components/ui/*.tsx"),
|
|
166
|
+
* longest-prefix-first the way Node's own resolver orders them. Deep
|
|
167
|
+
* subpaths are the shape that matters here, not the "." entry:
|
|
168
|
+
* openstatusHQ/openstatus's @openstatus/ui declares *no* "." export at all,
|
|
169
|
+
* and all 1082 of its consumers import a deep path.
|
|
170
|
+
*/
|
|
171
|
+
function resolveExportsSubpath(pkgDir, pkg, subpath) {
|
|
172
|
+
const exp = pkg?.exports;
|
|
173
|
+
if (!exp || typeof exp !== 'object') return null;
|
|
174
|
+
const key = subpath ? `./${subpath}` : '.';
|
|
175
|
+
|
|
176
|
+
const exact = pickExportTarget(exp[key]);
|
|
177
|
+
if (exact) return resolveCandidateFile(path.resolve(pkgDir, exact));
|
|
178
|
+
|
|
179
|
+
let best = null;
|
|
180
|
+
for (const [pattern, value] of Object.entries(exp)) {
|
|
181
|
+
const star = pattern.indexOf('*');
|
|
182
|
+
if (star === -1) continue;
|
|
183
|
+
const prefix = pattern.slice(0, star);
|
|
184
|
+
const suffix = pattern.slice(star + 1);
|
|
185
|
+
if (!key.startsWith(prefix) || !key.endsWith(suffix)) continue;
|
|
186
|
+
if (key.length < prefix.length + suffix.length) continue;
|
|
187
|
+
if (!best || prefix.length > best.prefix.length) {
|
|
188
|
+
best = { prefix, value, match: key.slice(prefix.length, key.length - suffix.length) };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!best) return null;
|
|
192
|
+
const target = pickExportTarget(best.value);
|
|
193
|
+
if (!target) return null;
|
|
194
|
+
return resolveCandidateFile(path.resolve(pkgDir, target.replace('*', best.match)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolves an import of a *sibling workspace package* into that sibling's
|
|
199
|
+
* own source file — the same crossing resolveWrappers() already makes via
|
|
200
|
+
* its `workspacePackages` option (ADOPTION_APP_PLAN.md §3e), applied here
|
|
201
|
+
* so a shared internal UI package's components are not scored as unused.
|
|
202
|
+
* Measured live: openstatusHQ/openstatus keeps 41 of its 61 shadcn
|
|
203
|
+
* components in packages/ui and imports every one of them as
|
|
204
|
+
* "@openstatus/ui/components/ui/<name>", so without this all 41 scored 0
|
|
205
|
+
* usages and the repo's coverage read 13.1% instead of its real value.
|
|
206
|
+
*/
|
|
207
|
+
function resolveWorkspaceImport(workspacePackages, specifier) {
|
|
208
|
+
if (!workspacePackages || workspacePackages.size === 0) return null;
|
|
209
|
+
const split = splitPackageSpecifier(specifier);
|
|
210
|
+
if (!split) return null;
|
|
211
|
+
const pkgDir = workspacePackages.get(split.name);
|
|
212
|
+
if (!pkgDir) return null;
|
|
213
|
+
|
|
214
|
+
const viaExports = resolveExportsSubpath(pkgDir, readJson(path.join(pkgDir, 'package.json')), split.subpath);
|
|
215
|
+
if (viaExports) return viaExports;
|
|
216
|
+
if (!split.subpath) return null;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
resolveCandidateFile(path.resolve(pkgDir, split.subpath)) ||
|
|
220
|
+
resolveCandidateFile(path.resolve(pkgDir, 'src', split.subpath))
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
103
224
|
/**
|
|
104
225
|
* Coverage — which of the components resolveForeignDiscovery found are
|
|
105
226
|
* actually imported anywhere else in the repo, vs. the full inventory the
|
|
106
227
|
* componentDir holds. shadcn components are local files, never bare
|
|
107
228
|
* package specifiers, so the origin?.pkg === pkgName filter every other
|
|
108
229
|
* resolver uses (usageRulesResolver.js, propApiResolver.js) doesn't apply
|
|
109
|
-
* here — instead this walks every file's
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
230
|
+
* here — instead this walks every file's imports through moduleGraph.js's
|
|
231
|
+
* own resolveRelative(), the nearest tsconfig's path aliases, and the
|
|
232
|
+
* workspace package map, and checks whether the resolved absolute path
|
|
233
|
+
* lands on a discovered component file. An import edge stands in for
|
|
234
|
+
* "used" — no JSX-usage confirmation beyond that, which is exactly the
|
|
235
|
+
* "structural, not opinionated" scope Layer 3 commits to.
|
|
114
236
|
*/
|
|
115
|
-
function scoreCoverage(root, ignore, components) {
|
|
237
|
+
function scoreCoverage(root, ignore, components, workspacePackages) {
|
|
116
238
|
const moduleGraph = buildModuleGraph(root, { ignore });
|
|
117
|
-
const
|
|
239
|
+
const aliasCache = new Map();
|
|
118
240
|
const absComponentFiles = components.map((c) => path.resolve(root, c.file));
|
|
119
241
|
const absSet = new Set(absComponentFiles);
|
|
120
242
|
const usageCounts = new Map(absComponentFiles.map((f) => [f, 0]));
|
|
@@ -126,7 +248,8 @@ function scoreCoverage(root, ignore, components) {
|
|
|
126
248
|
const resolved =
|
|
127
249
|
source.startsWith('.') || source.startsWith('/')
|
|
128
250
|
? resolveRelative(file, source)
|
|
129
|
-
: resolveAliasedImport(root,
|
|
251
|
+
: resolveAliasedImport(nearestPathAliases(root, file, aliasCache), source) ||
|
|
252
|
+
resolveWorkspaceImport(workspacePackages, source);
|
|
130
253
|
if (resolved && resolved !== file && absSet.has(resolved)) {
|
|
131
254
|
usageCounts.set(resolved, usageCounts.get(resolved) + 1);
|
|
132
255
|
}
|
|
@@ -169,6 +292,24 @@ function findDuplicates(root, ignore, system) {
|
|
|
169
292
|
.filter((o) => !o.file.includes(uiDirFragment));
|
|
170
293
|
}
|
|
171
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The `Map<packageName, absoluteDir>` resolveWorkspaceImport() needs. Built
|
|
297
|
+
* here rather than threaded in from cli.js — unlike `adopt --all-targets`,
|
|
298
|
+
* which already had a discovery result in hand, scan-foreign has one entry
|
|
299
|
+
* point and every caller of it needs the map, so a caller that forgets to
|
|
300
|
+
* pass it would silently undercount. Discovery failing (an unreadable or
|
|
301
|
+
* malformed workspace manifest) degrades to the no-workspace behaviour
|
|
302
|
+
* instead of failing the scan: coverage is a fact this command reports, not
|
|
303
|
+
* a gate it enforces.
|
|
304
|
+
*/
|
|
305
|
+
function discoverWorkspacePackages(root) {
|
|
306
|
+
try {
|
|
307
|
+
return workspacePackageMap(discoverTargets(root));
|
|
308
|
+
} catch {
|
|
309
|
+
return new Map();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
172
313
|
/**
|
|
173
314
|
* Layer 3 of "Version B" (ADOPTION_APP_PLAN.md §10 decision #25) —
|
|
174
315
|
* deliberately structural-only: catalog coverage and hand-rolled
|
|
@@ -179,7 +320,7 @@ function findDuplicates(root, ignore, system) {
|
|
|
179
320
|
*/
|
|
180
321
|
export function scoreForeignAdoption(root, systemId, discovery, { ignore = [] } = {}) {
|
|
181
322
|
const system = getForeignSystem(systemId);
|
|
182
|
-
const coverage = scoreCoverage(root, ignore, discovery.components);
|
|
323
|
+
const coverage = scoreCoverage(root, ignore, discovery.components, discoverWorkspacePackages(root));
|
|
183
324
|
const duplicates = findDuplicates(root, ignore, system);
|
|
184
325
|
|
|
185
326
|
return {
|