@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.
@@ -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 each .tsx/.jsx directly inside and keeps
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
- const files = fg.sync(['*.tsx', '*.jsx'], { cwd: dir, absolute: true });
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: path.basename(file, path.extname(file)),
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 the target's own tsconfig.json
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(root) {
72
+ function loadPathAliases(dir) {
64
73
  for (const name of ['tsconfig.json', 'jsconfig.json']) {
65
- const file = path.join(root, name);
74
+ const file = path.join(dir, name);
66
75
  if (!existsSync(file)) continue;
67
- try {
68
- const raw = stripJsonComments(readFileSync(file, 'utf-8')).replace(/,(\s*[}\]])/g, '$1');
69
- const json = JSON.parse(raw);
70
- const paths = json.compilerOptions?.paths;
71
- if (!paths) continue;
72
- return { baseUrl: json.compilerOptions?.baseUrl || '.', paths };
73
- } catch {
74
- continue;
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(root, aliases, specifier) {
121
+ function resolveAliasedImport(aliases, specifier) {
82
122
  if (!aliases) return null;
83
- const base = path.resolve(root, aliases.baseUrl);
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 relative imports through
110
- * moduleGraph.js's own resolveRelative() and checks whether the resolved
111
- * absolute path lands on a discovered component file. A local-file import
112
- * edge stands in for "used" no JSX-usage confirmation beyond that, which
113
- * is exactly the "structural, not opinionated" scope Layer 3 commits to.
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 aliases = loadPathAliases(root);
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, aliases, source);
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 {