@starklab/stark-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/package.json +31 -0
  4. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
  5. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
  6. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
  7. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
  8. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
  9. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
  10. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
  11. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
  12. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
  13. package/src/adopt/catalog.js +88 -0
  14. package/src/adopt/dominionFixture.test.js +165 -0
  15. package/src/adopt/moduleGraph.js +232 -0
  16. package/src/adopt/parseSource.js +25 -0
  17. package/src/adopt/propApiResolver.js +278 -0
  18. package/src/adopt/propApiResolver.test.js +229 -0
  19. package/src/adopt/referenceResolver.js +151 -0
  20. package/src/adopt/referenceResolver.test.js +213 -0
  21. package/src/adopt/rnTailwindResolver.js +347 -0
  22. package/src/adopt/rnTailwindResolver.test.js +263 -0
  23. package/src/adopt/rnTokenAliasResolver.js +474 -0
  24. package/src/adopt/rnTokenAliasResolver.test.js +260 -0
  25. package/src/adopt/tailwindResolver.js +512 -0
  26. package/src/adopt/tailwindResolver.test.js +178 -0
  27. package/src/adopt/targetDiscovery.js +237 -0
  28. package/src/adopt/targetDiscovery.test.js +227 -0
  29. package/src/adopt/tokenAliasResolver.js +513 -0
  30. package/src/adopt/tokenAliasResolver.test.js +319 -0
  31. package/src/adopt/wrapperResolver.js +874 -0
  32. package/src/adopt/wrapperResolver.test.js +324 -0
  33. package/src/cli.js +376 -0
  34. package/src/data.js +267 -0
  35. package/src/data.test.js +231 -0
  36. package/src/index.js +8 -0
  37. package/src/server.js +149 -0
@@ -0,0 +1,874 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import traverseModule from '@babel/traverse';
5
+
6
+ import { loadCatalog, packageNameForPlatform } from './catalog.js';
7
+ import { buildModuleGraph, resolveOriginDeep, EXTENSIONS } from './moduleGraph.js';
8
+ import { classifyReference, KIND_CONFIDENCE } from './referenceResolver.js';
9
+
10
+ const traverse = traverseModule.default ?? traverseModule;
11
+
12
+ const WARN_DEPTH = 5; // ADOPTION_APP_PLAN.md §4 "5 is governance, 20 is safety"
13
+ const MAX_DEPTH = 20;
14
+
15
+ // ───────────────────────────── component declaration registry ─────────────────────────────
16
+
17
+ function isFunctionNode(node) {
18
+ return (
19
+ node.type === 'FunctionDeclaration' ||
20
+ node.type === 'FunctionExpression' ||
21
+ node.type === 'ArrowFunctionExpression'
22
+ );
23
+ }
24
+
25
+ function isForwardRefCall(node) {
26
+ if (node.type !== 'CallExpression') return null;
27
+ const callee = node.callee;
28
+ const isBareForwardRef = callee.type === 'Identifier' && callee.name === 'forwardRef';
29
+ const isNamespacedForwardRef =
30
+ callee.type === 'MemberExpression' &&
31
+ !callee.computed &&
32
+ callee.property.type === 'Identifier' &&
33
+ callee.property.name === 'forwardRef';
34
+ if (!isBareForwardRef && !isNamespacedForwardRef) return null;
35
+ const fn = node.arguments[0];
36
+ return fn && isFunctionNode(fn) ? fn : null;
37
+ }
38
+
39
+ /**
40
+ * Collects every top-level, component-shaped local declaration in a file
41
+ * (function/arrow/forwardRef, capitalized name — the same rule JSX itself
42
+ * uses to tell a component tag from an intrinsic one) and annotates which
43
+ * ones are exported, and under what name. This is the registry
44
+ * resolveWrapperChain walks to find "does this JSX tag point at a wrapper
45
+ * this repo defines, rather than an import" (ADOPTION_APP_PLAN.md §4 blind
46
+ * spot 3).
47
+ */
48
+ function extractComponentDecls(ast) {
49
+ const decls = new Map();
50
+
51
+ function registerNamed(name, fnNode, isForwardRef) {
52
+ if (!name || !/^[A-Z]/.test(name)) return;
53
+ decls.set(name, { fnNode, isForwardRef, exportedAs: null });
54
+ }
55
+
56
+ for (const node of ast.program.body) {
57
+ if (node.type === 'FunctionDeclaration' && node.id) {
58
+ registerNamed(node.id.name, node, false);
59
+ } else if (node.type === 'VariableDeclaration') {
60
+ for (const d of node.declarations) {
61
+ if (d.id.type !== 'Identifier' || !d.init) continue;
62
+ if (isFunctionNode(d.init)) {
63
+ registerNamed(d.id.name, d.init, false);
64
+ } else {
65
+ const fr = isForwardRefCall(d.init);
66
+ if (fr) registerNamed(d.id.name, fr, true);
67
+ }
68
+ }
69
+ } else if (node.type === 'ExportNamedDeclaration' && node.declaration) {
70
+ const decl = node.declaration;
71
+ if (decl.type === 'FunctionDeclaration' && decl.id) {
72
+ registerNamed(decl.id.name, decl, false);
73
+ if (decls.has(decl.id.name)) decls.get(decl.id.name).exportedAs = decl.id.name;
74
+ } else if (decl.type === 'VariableDeclaration') {
75
+ for (const d of decl.declarations) {
76
+ if (d.id.type !== 'Identifier' || !d.init) continue;
77
+ if (isFunctionNode(d.init)) {
78
+ registerNamed(d.id.name, d.init, false);
79
+ if (decls.has(d.id.name)) decls.get(d.id.name).exportedAs = d.id.name;
80
+ } else {
81
+ const fr = isForwardRefCall(d.init);
82
+ if (fr) {
83
+ registerNamed(d.id.name, fr, true);
84
+ if (decls.has(d.id.name)) decls.get(d.id.name).exportedAs = d.id.name;
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ // Second pass: `export { Foo, Bar as Baz }` and `export default Foo` only
93
+ // annotate exportedAs on declarations already collected above.
94
+ for (const node of ast.program.body) {
95
+ if (node.type === 'ExportNamedDeclaration' && !node.declaration && !node.source && node.specifiers?.length) {
96
+ for (const spec of node.specifiers) {
97
+ const localName = spec.local.name;
98
+ const exportedName = spec.exported.type === 'Identifier' ? spec.exported.name : spec.exported.value;
99
+ if (decls.has(localName)) decls.get(localName).exportedAs = exportedName;
100
+ }
101
+ } else if (node.type === 'ExportDefaultDeclaration') {
102
+ const decl = node.declaration;
103
+ if (decl.type === 'Identifier' && decls.has(decl.name)) {
104
+ decls.get(decl.name).exportedAs = 'default';
105
+ } else if (decl.type === 'FunctionDeclaration' && decl.id) {
106
+ registerNamed(decl.id.name, decl, false);
107
+ if (decls.has(decl.id.name)) decls.get(decl.id.name).exportedAs = 'default';
108
+ else decls.set(decl.id.name, { fnNode: decl, isForwardRef: false, exportedAs: 'default' });
109
+ } else if (isFunctionNode(decl)) {
110
+ decls.set('default', { fnNode: decl, isForwardRef: false, exportedAs: 'default' });
111
+ } else {
112
+ const fr = isForwardRefCall(decl);
113
+ if (fr) decls.set('default', { fnNode: fr, isForwardRef: true, exportedAs: 'default' });
114
+ }
115
+ }
116
+ }
117
+
118
+ return decls;
119
+ }
120
+
121
+ function getFileDecls(file, moduleGraph, declsCache) {
122
+ if (declsCache.has(file)) return declsCache.get(file);
123
+ const entry = moduleGraph.graph.get(file);
124
+ const decls = entry && entry.ast ? extractComponentDecls(entry.ast) : new Map();
125
+ declsCache.set(file, decls);
126
+ return decls;
127
+ }
128
+
129
+ // ───────────────────────────── JSX return-shape analysis ─────────────────────────────
130
+
131
+ function significantChildren(children) {
132
+ return children.filter((c) => {
133
+ if (c.type === 'JSXText') return c.value.trim().length > 0;
134
+ if (c.type === 'JSXExpressionContainer' && c.expression.type === 'JSXEmptyExpression') return false;
135
+ return true;
136
+ });
137
+ }
138
+
139
+ function unwrapFragment(node) {
140
+ if (node.type !== 'JSXFragment') return node;
141
+ const kids = significantChildren(node.children);
142
+ if (kids.length === 1 && kids[0].type === 'JSXElement') return unwrapFragment(kids[0]);
143
+ return node; // ambiguous: 0 or 2+ significant children — no single root tag
144
+ }
145
+
146
+ function getOpeningName(node) {
147
+ if (node.type !== 'JSXElement') return null;
148
+ const nameNode = node.openingElement.name;
149
+ if (nameNode.type === 'JSXIdentifier') return { kind: 'identifier', name: nameNode.name };
150
+ if (nameNode.type === 'JSXMemberExpression' && nameNode.object.type === 'JSXIdentifier') {
151
+ return { kind: 'member', name: nameNode.object.name, member: nameNode.property.name };
152
+ }
153
+ return null; // JSXNamespacedName, or a member expression on a non-identifier object
154
+ }
155
+
156
+ function tagKeyOf(t) {
157
+ return t.kind === 'member' ? `member:${t.name}.${t.member}` : `identifier:${t.name}`;
158
+ }
159
+
160
+ function unwrapJsx(expr, out) {
161
+ if (!expr) return;
162
+ if (expr.type === 'JSXElement' || expr.type === 'JSXFragment') {
163
+ out.candidates.push(expr);
164
+ return;
165
+ }
166
+ if (expr.type === 'ConditionalExpression') {
167
+ unwrapJsx(expr.consequent, out);
168
+ unwrapJsx(expr.alternate, out);
169
+ return;
170
+ }
171
+ if (expr.type === 'LogicalExpression') {
172
+ unwrapJsx(expr.left, out);
173
+ unwrapJsx(expr.right, out);
174
+ return;
175
+ }
176
+ if (expr.type === 'ParenthesizedExpression') {
177
+ unwrapJsx(expr.expression, out);
178
+ return;
179
+ }
180
+ if (expr.type === 'NullLiteral' || (expr.type === 'BooleanLiteral' && expr.value === false)) return;
181
+ // Anything else (call expression, identifier, member access, template
182
+ // literal...) is a render path we can't classify without executing it.
183
+ out.dynamic = true;
184
+ }
185
+
186
+ function collectReturns(stmt, out) {
187
+ if (!stmt) return;
188
+ if (stmt.type === 'BlockStatement') {
189
+ for (const s of stmt.body) collectReturns(s, out);
190
+ return;
191
+ }
192
+ if (stmt.type === 'ReturnStatement') {
193
+ unwrapJsx(stmt.argument, out);
194
+ return;
195
+ }
196
+ if (stmt.type === 'IfStatement') {
197
+ collectReturns(stmt.consequent, out);
198
+ if (stmt.alternate) collectReturns(stmt.alternate, out);
199
+ return;
200
+ }
201
+ // Other statement kinds (loops, switch, try/catch, variable decls...) are
202
+ // deliberately not walked — this is a best-effort scan of the common
203
+ // early-return shapes, not a full control-flow analysis.
204
+ }
205
+
206
+ function analyzeFunctionReturns(fnNode) {
207
+ const out = { candidates: [], dynamic: false };
208
+ if (fnNode.body?.type === 'BlockStatement') {
209
+ collectReturns(fnNode.body, out);
210
+ } else if (fnNode.body) {
211
+ unwrapJsx(fnNode.body, out); // arrow function with an expression body
212
+ }
213
+ return out;
214
+ }
215
+
216
+ function singleSignificantJsxChild(node) {
217
+ const children = node.type === 'JSXElement' || node.type === 'JSXFragment' ? node.children : [];
218
+ const sig = significantChildren(children);
219
+ if (sig.length !== 1) return null;
220
+ return sig[0].type === 'JSXElement' || sig[0].type === 'JSXFragment' ? sig[0] : null;
221
+ }
222
+
223
+ /**
224
+ * Resolves a component function down to the single tag its JSX actually
225
+ * returns. Refuses to guess: multiple distinct return paths, a dynamic
226
+ * (non-JSX, non-null/false) return branch, or an ambiguous fragment all come
227
+ * back `ok:false` with a reason rather than picking one arbitrarily.
228
+ *
229
+ * When the outer root is a plain native/intrinsic element (a <div> etc.), it
230
+ * looks exactly one level inside for a single DS-bearing child before giving
231
+ * up — the "augmenting" pattern from ADOPTION_APP_PLAN.md §4 (a form-field
232
+ * wrapper: <div className="field"><Label/><Button/></div>). Multiple or zero
233
+ * significant children at that inner level means there's no single thing to
234
+ * attribute the wrap to, so it terminates as 'native' instead of guessing.
235
+ */
236
+ function analyzeComponentBody(fnNode) {
237
+ const out = analyzeFunctionReturns(fnNode);
238
+ if (out.dynamic) return { ok: false, reason: 'dynamic-return' };
239
+ if (out.candidates.length === 0) return { ok: false, reason: 'no-jsx-return' };
240
+
241
+ const resolved = out.candidates.map(unwrapFragment);
242
+ const tagInfos = resolved.map(getOpeningName);
243
+ if (tagInfos.some((t) => !t)) return { ok: false, reason: 'ambiguous-root' };
244
+
245
+ const tagKeys = new Set(tagInfos.map(tagKeyOf));
246
+ if (tagKeys.size > 1) return { ok: false, reason: 'multiple-return-paths' };
247
+
248
+ const tag = tagInfos[0];
249
+ const node = resolved[0];
250
+
251
+ if (tag.kind === 'identifier' && /^[a-z]/.test(tag.name)) {
252
+ const inner = singleSignificantJsxChild(node);
253
+ if (inner) {
254
+ const innerUnwrapped = unwrapFragment(inner);
255
+ const innerTag = getOpeningName(innerUnwrapped);
256
+ if (innerTag) {
257
+ return { ok: true, tag: innerTag, node: innerUnwrapped, augmenting: true };
258
+ }
259
+ }
260
+ return { ok: false, reason: 'native-root', tag: tag.name };
261
+ }
262
+
263
+ return { ok: true, tag, node, augmenting: false };
264
+ }
265
+
266
+ // ───────────────────────────── faithfulness classification ─────────────────────────────
267
+
268
+ const STYLE_ATTR_NAMES = new Set(['className', 'style', 'dangerouslySetInnerHTML']);
269
+
270
+ function functionParamNames(fnNode) {
271
+ const params = fnNode.params || [];
272
+ return {
273
+ propsParamName: params[0]?.type === 'Identifier' ? params[0].name : null,
274
+ refParamName: params[1]?.type === 'Identifier' ? params[1].name : null,
275
+ };
276
+ }
277
+
278
+ function analyzeFaithfulness(rootJsxElement, fnNode, isForwardRef) {
279
+ const opening = rootJsxElement.openingElement;
280
+ const { refParamName } = functionParamNames(fnNode);
281
+
282
+ let hasSpread = false;
283
+ let refForwarded = false;
284
+ const hardcodedProps = [];
285
+ const divergentProps = [];
286
+
287
+ for (const attr of opening.attributes) {
288
+ if (attr.type === 'JSXSpreadAttribute') {
289
+ hasSpread = true; // spreading anything still forwards unknown props through
290
+ continue;
291
+ }
292
+ if (attr.type !== 'JSXAttribute') continue;
293
+ const name = attr.name.type === 'JSXIdentifier' ? attr.name.name : null;
294
+ if (!name) continue;
295
+
296
+ if (name === 'ref') {
297
+ if (
298
+ refParamName &&
299
+ attr.value?.type === 'JSXExpressionContainer' &&
300
+ attr.value.expression.type === 'Identifier' &&
301
+ attr.value.expression.name === refParamName
302
+ ) {
303
+ refForwarded = true;
304
+ }
305
+ continue;
306
+ }
307
+ if (name === 'children') continue;
308
+
309
+ if (STYLE_ATTR_NAMES.has(name)) {
310
+ divergentProps.push(name);
311
+ continue;
312
+ }
313
+
314
+ if (attr.value === null) {
315
+ hardcodedProps.push(name); // boolean shorthand — hardcoded `true`
316
+ } else if (attr.value.type === 'StringLiteral') {
317
+ hardcodedProps.push(name);
318
+ } else if (attr.value.type === 'JSXExpressionContainer') {
319
+ const expr = attr.value.expression;
320
+ const isPassthrough = expr.type === 'Identifier' || expr.type === 'MemberExpression' || expr.type === 'OptionalMemberExpression';
321
+ if (!isPassthrough) hardcodedProps.push(name);
322
+ }
323
+ }
324
+
325
+ return { hasSpread, refForwarded: isForwardRef ? refForwarded : true, hardcodedProps, divergentProps };
326
+ }
327
+
328
+ /**
329
+ * Priority order per ADOPTION_APP_PLAN.md §4: divergent (styling override) >
330
+ * augmenting (reached via the nested-native-wrapper path) > constraining (any
331
+ * hardcoded prop) > transparent.
332
+ */
333
+ function classifyFaithfulness({ augmenting, divergentProps, hardcodedProps, hasSpread, refForwarded }) {
334
+ if (divergentProps.length > 0) return 'divergent';
335
+ if (augmenting) return 'augmenting';
336
+ if (hardcodedProps.length > 0) return 'constraining';
337
+ if (hasSpread && refForwarded) return 'transparent';
338
+ return 'transparent'; // no attributes to hardcode or forward — trivially faithful
339
+ }
340
+
341
+ // ───────────────────────────── root-terminal chain resolution ─────────────────────────────
342
+
343
+ function resolveWrapperChain(
344
+ file,
345
+ localName,
346
+ moduleGraph,
347
+ declsCache,
348
+ catalogByName,
349
+ pkgName,
350
+ workspace,
351
+ visited = new Set(),
352
+ depth = 0
353
+ ) {
354
+ const key = `${file}#${localName}`;
355
+ if (visited.has(key)) {
356
+ return { terminal: 'cycle', cycle: [...visited, key], chain: [], depth };
357
+ }
358
+ if (depth > MAX_DEPTH) {
359
+ return { terminal: 'unresolved', reason: 'depth-limit', chain: [], depth };
360
+ }
361
+
362
+ const fileDecls = getFileDecls(file, moduleGraph, declsCache);
363
+ const declInfo = fileDecls.get(localName);
364
+ if (!declInfo) {
365
+ return { terminal: 'unresolved', reason: 'not-found', chain: [], depth };
366
+ }
367
+
368
+ const analysis = analyzeComponentBody(declInfo.fnNode);
369
+ if (!analysis.ok) {
370
+ if (analysis.reason === 'native-root') {
371
+ return { terminal: 'native', tag: analysis.tag, chain: [], depth };
372
+ }
373
+ return { terminal: 'unresolved', reason: analysis.reason, chain: [], depth };
374
+ }
375
+
376
+ const nextVisited = new Set(visited).add(key);
377
+ const sub = resolveTagTerminal(
378
+ file,
379
+ analysis.tag,
380
+ moduleGraph,
381
+ declsCache,
382
+ catalogByName,
383
+ pkgName,
384
+ workspace,
385
+ nextVisited,
386
+ depth + 1
387
+ );
388
+
389
+ const faithfulness = classifyFaithfulness({
390
+ augmenting: analysis.augmenting,
391
+ ...analyzeFaithfulness(analysis.node, declInfo.fnNode, declInfo.isForwardRef),
392
+ });
393
+
394
+ const hop = { file, localName, exportedAs: declInfo.exportedAs, faithfulness };
395
+ const chain = [hop, ...(sub.chain || [])];
396
+
397
+ return { ...sub, chain, depth: chain.length, warnDepth: chain.length > WARN_DEPTH };
398
+ }
399
+
400
+ function resolveTagTerminal(file, tagInfo, moduleGraph, declsCache, catalogByName, pkgName, workspace, visited, depth) {
401
+ const lookupName = tagInfo.name;
402
+
403
+ if (/^[a-z]/.test(lookupName)) {
404
+ return { terminal: 'native', tag: lookupName };
405
+ }
406
+
407
+ const fileDecls = getFileDecls(file, moduleGraph, declsCache);
408
+ if (fileDecls.has(lookupName)) {
409
+ return resolveWrapperChain(file, lookupName, moduleGraph, declsCache, catalogByName, pkgName, workspace, visited, depth);
410
+ }
411
+
412
+ const entry = moduleGraph.graph.get(file);
413
+ const binding = entry?.imports.get(lookupName);
414
+ if (!binding) {
415
+ return { terminal: 'unresolved', reason: 'not-found' };
416
+ }
417
+
418
+ const { source, imported } = binding;
419
+ if (!source.startsWith('.') && !source.startsWith('/')) {
420
+ if (source === pkgName && catalogByName.has(imported)) {
421
+ return { terminal: 'ds-component', component: imported };
422
+ }
423
+ const crossed = resolveWorkspacePackage(source, imported, workspace, catalogByName, pkgName, visited, depth);
424
+ if (crossed) return crossed;
425
+ return { terminal: 'third-party', pkg: source };
426
+ }
427
+ if (imported === '*') {
428
+ return { terminal: 'unresolved', reason: 'namespace-import' };
429
+ }
430
+
431
+ const resolvedFile = resolveRelativeFile(file, source);
432
+ if (!resolvedFile || !moduleGraph.graph.has(resolvedFile)) {
433
+ return { terminal: 'unresolved', reason: 'file-not-found' };
434
+ }
435
+
436
+ return resolveImportedSymbol(resolvedFile, imported, moduleGraph, declsCache, catalogByName, pkgName, workspace, visited, depth);
437
+ }
438
+
439
+ /**
440
+ * Shared tail for resolving a named export at a known file — used both for
441
+ * a same-repo relative import and, once a workspace-crossing import lands
442
+ * on a sibling package's entry file, for that sibling's own exports too.
443
+ * Not a local component export at `resolvedFile` could mean a pure
444
+ * re-export barrel hop (`export { Button } from '../wherever'`).
445
+ */
446
+ function resolveImportedSymbol(resolvedFile, imported, moduleGraph, declsCache, catalogByName, pkgName, workspace, visited, depth) {
447
+ const targetDecls = getFileDecls(resolvedFile, moduleGraph, declsCache);
448
+ for (const [targetLocalName, info] of targetDecls) {
449
+ if (info.exportedAs === imported) {
450
+ return resolveWrapperChain(resolvedFile, targetLocalName, moduleGraph, declsCache, catalogByName, pkgName, workspace, visited, depth);
451
+ }
452
+ }
453
+
454
+ const deep = resolveOriginDeep(moduleGraph, resolvedFile, imported, { viaExport: true });
455
+ if (deep?.type === 'package') {
456
+ if (deep.pkg === pkgName && catalogByName.has(deep.name)) {
457
+ return { terminal: 'ds-component', component: deep.name };
458
+ }
459
+ const crossed = resolveWorkspacePackage(deep.pkg, deep.name, workspace, catalogByName, pkgName, visited, depth);
460
+ if (crossed) return crossed;
461
+ return { terminal: 'third-party', pkg: deep.pkg };
462
+ }
463
+ if (deep?.type === 'local') {
464
+ const deepDecls = getFileDecls(deep.file, moduleGraph, declsCache);
465
+ if (deepDecls.has(deep.name)) {
466
+ return resolveWrapperChain(deep.file, deep.name, moduleGraph, declsCache, catalogByName, pkgName, workspace, visited, depth);
467
+ }
468
+ }
469
+ if (deep?.cycle) return { terminal: 'cycle', cycle: deep.cycle };
470
+ return { terminal: 'unresolved', reason: 'unresolved-export' };
471
+ }
472
+
473
+ /**
474
+ * ADOPTION_APP_PLAN.md §3e: "the wrapper resolver must cross workspace
475
+ * boundaries inside a repo, resolving @acme/ui to its sibling source rather
476
+ * than treating it as an opaque third-party import." When an import's
477
+ * package name matches another discovered workspace target, builds (and
478
+ * caches) that sibling's own module graph and resolves the imported symbol
479
+ * inside it, so a chain can continue through a shared internal UI package
480
+ * instead of dead-ending at the workspace boundary. Returns null (not a
481
+ * workspace package, or entry file/graph unavailable) to fall through to
482
+ * the ordinary third-party terminal.
483
+ */
484
+ function resolveWorkspacePackage(source, imported, workspace, catalogByName, pkgName, visited, depth) {
485
+ if (!workspace?.packages?.has(source)) return null;
486
+ const siblingDir = workspace.packages.get(source);
487
+ if (siblingDir === workspace.currentDir) return null; // a package importing its own name — not a crossing
488
+
489
+ const siblingPkgPath = path.join(siblingDir, 'package.json');
490
+ let siblingPkg = null;
491
+ try {
492
+ siblingPkg = existsSync(siblingPkgPath) ? JSON.parse(readFileSync(siblingPkgPath, 'utf-8')) : null;
493
+ } catch {
494
+ siblingPkg = null;
495
+ }
496
+ const entryFile = resolveEntryFile(siblingDir, siblingPkg);
497
+ if (!entryFile) return null;
498
+
499
+ const { moduleGraph: sibGraph, declsCache: sibDecls } = getWorkspaceGraph(workspace, siblingDir);
500
+ if (!sibGraph.graph.has(entryFile)) return null;
501
+
502
+ return resolveImportedSymbol(entryFile, imported, sibGraph, sibDecls, catalogByName, pkgName, workspace, visited, depth);
503
+ }
504
+
505
+ /**
506
+ * Resolves a package directory's public entry file from package.json's
507
+ * main/module/exports fields, falling back to the src/index.* convention
508
+ * this workspace's own packages use.
509
+ */
510
+ function resolveEntryFile(dir, pkg) {
511
+ const candidates = [];
512
+ if (typeof pkg?.main === 'string') candidates.push(path.resolve(dir, pkg.main));
513
+ if (typeof pkg?.module === 'string') candidates.push(path.resolve(dir, pkg.module));
514
+ const exp = pkg?.exports;
515
+ if (typeof exp === 'string') candidates.push(path.resolve(dir, exp));
516
+ else if (exp && typeof exp === 'object') {
517
+ const dot = exp['.'];
518
+ if (typeof dot === 'string') candidates.push(path.resolve(dir, dot));
519
+ else if (dot && typeof dot === 'object') {
520
+ const val = dot.import || dot.require || dot.default;
521
+ if (typeof val === 'string') candidates.push(path.resolve(dir, val));
522
+ }
523
+ }
524
+ candidates.push(path.join(dir, 'src', 'index'), path.join(dir, 'index'));
525
+
526
+ for (const base of candidates) {
527
+ const tries = [base, ...EXTENSIONS.map((ext) => base + ext), ...EXTENSIONS.map((ext) => path.join(base, 'index' + ext))];
528
+ for (const candidate of tries) {
529
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
530
+ }
531
+ }
532
+ return null;
533
+ }
534
+
535
+ /** Lazily builds and caches a sibling workspace package's own module graph. */
536
+ function getWorkspaceGraph(workspace, dir) {
537
+ if (workspace.graphs.has(dir)) return workspace.graphs.get(dir);
538
+ const entry = { moduleGraph: buildModuleGraph(dir, {}), declsCache: new Map() };
539
+ workspace.graphs.set(dir, entry);
540
+ return entry;
541
+ }
542
+
543
+ function resolveRelativeFile(fromFile, specifier) {
544
+ const base = path.resolve(path.dirname(fromFile), specifier);
545
+ const candidates = [base, ...EXTENSIONS.map((ext) => base + ext), ...EXTENSIONS.map((ext) => path.join(base, 'index' + ext))];
546
+ for (const candidate of candidates) {
547
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
548
+ }
549
+ return null;
550
+ }
551
+
552
+ // ───────────────────────────── fanout counting ─────────────────────────────
553
+
554
+ /**
555
+ * Counts, for each tracked wrapper (file+exportedAs key), how many places in
556
+ * the repo reference it — reusing referenceResolver.js's exact reference-kind
557
+ * classification so a wrapper's fanout and a catalog component's direct
558
+ * fanout are always counted the same way.
559
+ *
560
+ * `internalConsumers` excludes the wiring reference a parent wrapper makes to
561
+ * its own child wrapper (e.g. A's file rendering <B/> as A's implementation)
562
+ * from B's fanout — that reference is the chain edge itself, not an external
563
+ * usage, and is already reflected in A's own separately-tracked chain. A
564
+ * wrapper file that happens to *also* have an unrelated second reference to
565
+ * the same target is excluded too — a known v1 simplification.
566
+ */
567
+ function computeFanouts(root, moduleGraph, trackedKeys, internalConsumers) {
568
+ const results = new Map(
569
+ [...trackedKeys].map((k) => [k, { counts: { jsx: 0, createElement: 0, hoc: 0, indirect: 0, reexport: 0 }, sites: [] }])
570
+ );
571
+
572
+ for (const file of moduleGraph.files) {
573
+ const entry = moduleGraph.graph.get(file);
574
+ if (!entry || entry.parseError) continue;
575
+
576
+ const localToKey = new Map();
577
+ for (const localName of entry.imports.keys()) {
578
+ const origin = resolveOriginDeep(moduleGraph, file, localName);
579
+ if (origin?.type !== 'local') continue;
580
+ const key = `${origin.file}#${origin.name}`;
581
+ if (!trackedKeys.has(key)) continue;
582
+ const excluded = internalConsumers.get(key);
583
+ if (excluded && excluded.has(file)) continue;
584
+ localToKey.set(localName, key);
585
+ }
586
+ if (localToKey.size === 0) continue;
587
+
588
+ traverse(entry.ast, {
589
+ Program(programPath) {
590
+ for (const [localName, key] of localToKey) {
591
+ const binding = programPath.scope.getBinding(localName);
592
+ if (!binding) continue;
593
+ const result = results.get(key);
594
+ for (const refPath of binding.referencePaths) {
595
+ const kind = classifyReference(refPath);
596
+ if (!kind) continue;
597
+ result.counts[kind] += 1;
598
+ result.sites.push({
599
+ file: path.relative(root, file),
600
+ line: refPath.node.loc?.start.line ?? null,
601
+ kind,
602
+ confidence: KIND_CONFIDENCE[kind],
603
+ });
604
+ }
605
+ }
606
+ },
607
+ });
608
+ }
609
+
610
+ return results;
611
+ }
612
+
613
+ // ───────────────────────────── dominion.config.json declared wrappers ─────────────────────────────
614
+
615
+ function loadDominionConfig(root) {
616
+ const configPath = path.join(root, 'dominion.config.json');
617
+ if (!existsSync(configPath)) return { wrappers: {} };
618
+ try {
619
+ const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
620
+ return { wrappers: raw.wrappers && typeof raw.wrappers === 'object' ? raw.wrappers : {} };
621
+ } catch {
622
+ return { wrappers: {} };
623
+ }
624
+ }
625
+
626
+ function resolveConfigFile(root, relPath) {
627
+ const base = path.resolve(root, relPath);
628
+ const candidates = [base, ...EXTENSIONS.map((ext) => base + ext), ...EXTENSIONS.map((ext) => path.join(base, 'index' + ext))];
629
+ for (const candidate of candidates) {
630
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
631
+ }
632
+ return null;
633
+ }
634
+
635
+ /**
636
+ * A declared file may export more than one component-shaped symbol, so pick
637
+ * "the" one deterministically: default export, else an export matching the
638
+ * file's own basename, else the first component export found.
639
+ */
640
+ function pickWrapperExport(fileDecls, filePath) {
641
+ const base = path.basename(filePath).replace(/\.(jsx|tsx|js|ts|mjs|cjs)$/, '');
642
+ let fallback = null;
643
+ for (const [localName, info] of fileDecls) {
644
+ if (!info.exportedAs) continue;
645
+ if (info.exportedAs === 'default') return { localName, info };
646
+ if (localName === base || info.exportedAs === base) return { localName, info };
647
+ if (!fallback) fallback = { localName, info };
648
+ }
649
+ return fallback;
650
+ }
651
+
652
+ // ───────────────────────────── assembly ─────────────────────────────
653
+
654
+ /**
655
+ * Resolves every exported wrapper component in a consumer repo down to its
656
+ * root terminal (a DS component, a native/intrinsic element, a third-party
657
+ * component, or unresolved/cycle), classifies how faithfully each wrapper
658
+ * passes through the DS component it wraps, counts how many places each
659
+ * wrapper is itself used, and left-joins the DS-terminal wrappers onto the
660
+ * catalog — same shape convention as resolveReferences (ADOPTION_APP_PLAN.md
661
+ * §4 blind spot 3: "adoption hidden behind indirection").
662
+ *
663
+ * `workspacePackages` (optional `Map<packageName, absoluteDir>`) enables
664
+ * cross-workspace resolution (ADOPTION_APP_PLAN.md §3e): a third-party
665
+ * import whose source matches another discovered workspace target's own
666
+ * package name is resolved into that sibling's source instead of
667
+ * terminating at the workspace boundary. Omit it for single-repo scans.
668
+ */
669
+ export function resolveWrappers(root, { platform = 'web', ignore = [], workspacePackages = null } = {}) {
670
+ const pkgName = packageNameForPlatform(platform);
671
+ const catalog = loadCatalog(platform);
672
+ const catalogByName = new Map(catalog.components.map((c) => [c.name, c]));
673
+
674
+ const moduleGraph = buildModuleGraph(root, { ignore });
675
+ const declsCache = new Map();
676
+ const workspace = {
677
+ packages: workspacePackages || new Map(),
678
+ currentDir: root,
679
+ graphs: new Map([[root, { moduleGraph, declsCache }]]),
680
+ };
681
+
682
+ // Pass 1 — resolve a chain for every exported component decl in the repo.
683
+ const exportedDecls = [];
684
+ for (const file of moduleGraph.files) {
685
+ const entry = moduleGraph.graph.get(file);
686
+ if (!entry || entry.parseError) continue;
687
+ const decls = getFileDecls(file, moduleGraph, declsCache);
688
+ for (const [localName, info] of decls) {
689
+ if (!info.exportedAs) continue;
690
+ exportedDecls.push({ file, localName, exportedAs: info.exportedAs });
691
+ }
692
+ }
693
+
694
+ const autoResults = new Map();
695
+ for (const { file, localName, exportedAs } of exportedDecls) {
696
+ autoResults.set(
697
+ `${file}#${exportedAs}`,
698
+ resolveWrapperChain(file, localName, moduleGraph, declsCache, catalogByName, pkgName, workspace)
699
+ );
700
+ }
701
+
702
+ // Internal-consumer edges: for every chain, each hop's file "internally
703
+ // consumes" the next hop's export — exclude those sites from that next
704
+ // wrapper's own fanout count (see computeFanouts's docstring).
705
+ const internalConsumers = new Map();
706
+ for (const result of autoResults.values()) {
707
+ const chain = result.chain || [];
708
+ for (let i = 0; i < chain.length - 1; i++) {
709
+ const consumerFile = chain[i].file;
710
+ const target = chain[i + 1];
711
+ if (!target.exportedAs) continue;
712
+ const targetKey = `${target.file}#${target.exportedAs}`;
713
+ if (!internalConsumers.has(targetKey)) internalConsumers.set(targetKey, new Set());
714
+ internalConsumers.get(targetKey).add(consumerFile);
715
+ }
716
+ }
717
+
718
+ // dominion.config.json — declared wrappers fill gaps, never override a
719
+ // contradicting auto-detected terminal.
720
+ const dominionConfig = loadDominionConfig(root);
721
+ const declaredEntries = [];
722
+ const contradictions = [];
723
+ const unresolvedDeclarations = [];
724
+
725
+ for (const [relPath, componentName] of Object.entries(dominionConfig.wrappers)) {
726
+ const resolvedFile = resolveConfigFile(root, relPath);
727
+ if (!resolvedFile || !moduleGraph.graph.has(resolvedFile)) {
728
+ unresolvedDeclarations.push({ configPath: relPath, componentName, reason: 'file-not-found' });
729
+ continue;
730
+ }
731
+ const fileDecls = getFileDecls(resolvedFile, moduleGraph, declsCache);
732
+ const picked = pickWrapperExport(fileDecls, resolvedFile);
733
+ if (!picked) {
734
+ unresolvedDeclarations.push({ configPath: relPath, componentName, reason: 'no-component-export-found' });
735
+ continue;
736
+ }
737
+
738
+ const key = `${resolvedFile}#${picked.info.exportedAs}`;
739
+ const auto = autoResults.get(key);
740
+
741
+ if (auto?.terminal === 'ds-component') {
742
+ if (auto.component !== componentName) {
743
+ contradictions.push({
744
+ file: resolvedFile,
745
+ exportedAs: picked.info.exportedAs,
746
+ declaredAs: componentName,
747
+ autoDetected: auto.component,
748
+ });
749
+ }
750
+ continue; // already auto-verified either way — nothing to add
751
+ }
752
+ if (auto?.terminal === 'native' || auto?.terminal === 'third-party') {
753
+ contradictions.push({
754
+ file: resolvedFile,
755
+ exportedAs: picked.info.exportedAs,
756
+ declaredAs: componentName,
757
+ autoDetected: auto.terminal,
758
+ });
759
+ continue;
760
+ }
761
+ if (!catalogByName.has(componentName)) {
762
+ unresolvedDeclarations.push({ configPath: relPath, componentName, reason: 'unknown-catalog-component' });
763
+ continue;
764
+ }
765
+
766
+ // auto-detection came back unresolved/cycle/missing — the declaration fills the gap.
767
+ declaredEntries.push({
768
+ file: resolvedFile,
769
+ exportedAs: picked.info.exportedAs,
770
+ localName: picked.localName,
771
+ terminal: 'ds-component',
772
+ component: componentName,
773
+ chain: [{ file: resolvedFile, localName: picked.localName, exportedAs: picked.info.exportedAs, faithfulness: 'unknown' }],
774
+ depth: 1,
775
+ warnDepth: false,
776
+ provenance: 'declared',
777
+ });
778
+ }
779
+
780
+ // Auto-verified wrappers — only entries that actually resolve to a DS component.
781
+ const wrapperKeys = new Set();
782
+ const autoWrappers = [];
783
+ for (const { file, localName, exportedAs } of exportedDecls) {
784
+ const key = `${file}#${exportedAs}`;
785
+ const result = autoResults.get(key);
786
+ if (result?.terminal === 'ds-component') {
787
+ wrapperKeys.add(key);
788
+ autoWrappers.push({
789
+ file,
790
+ exportedAs,
791
+ localName,
792
+ terminal: 'ds-component',
793
+ component: result.component,
794
+ chain: result.chain,
795
+ depth: result.depth,
796
+ warnDepth: result.warnDepth,
797
+ provenance: 'auto-verified',
798
+ });
799
+ }
800
+ }
801
+ for (const declared of declaredEntries) wrapperKeys.add(`${declared.file}#${declared.exportedAs}`);
802
+
803
+ const unresolvedKeys = [];
804
+ for (const { file, exportedAs } of exportedDecls) {
805
+ const key = `${file}#${exportedAs}`;
806
+ const result = autoResults.get(key);
807
+ if (result?.terminal === 'unresolved' || result?.terminal === 'cycle') unresolvedKeys.push(key);
808
+ }
809
+
810
+ const trackedKeys = new Set([...wrapperKeys, ...unresolvedKeys]);
811
+ const fanoutResults = computeFanouts(root, moduleGraph, trackedKeys, internalConsumers);
812
+
813
+ const emptyFanout = { counts: { jsx: 0, createElement: 0, hoc: 0, indirect: 0, reexport: 0 }, sites: [] };
814
+ const allWrappers = [...autoWrappers, ...declaredEntries].map((w) => {
815
+ const key = `${w.file}#${w.exportedAs}`;
816
+ const fanout = fanoutResults.get(key) || emptyFanout;
817
+ return { ...w, file: path.relative(root, w.file), fanout };
818
+ });
819
+
820
+ // Rollup per catalog component — left-joined, so zero-adoption-via-wrapper
821
+ // components still appear.
822
+ const byComponent = new Map(
823
+ catalog.components.map((c) => [
824
+ c.name,
825
+ { name: c.name, slug: c.slug, viaWrappers: [], totalFanout: { jsx: 0, createElement: 0, hoc: 0, indirect: 0 } },
826
+ ])
827
+ );
828
+ for (const w of allWrappers) {
829
+ const bucket = byComponent.get(w.component);
830
+ if (!bucket) continue;
831
+ bucket.viaWrappers.push({
832
+ file: w.file,
833
+ exportedAs: w.exportedAs,
834
+ faithfulness: w.chain[0]?.faithfulness ?? 'unknown',
835
+ depth: w.depth,
836
+ warnDepth: w.warnDepth,
837
+ provenance: w.provenance,
838
+ fanout: w.fanout.counts,
839
+ });
840
+ bucket.totalFanout.jsx += w.fanout.counts.jsx;
841
+ bucket.totalFanout.createElement += w.fanout.counts.createElement;
842
+ bucket.totalFanout.hoc += w.fanout.counts.hoc;
843
+ bucket.totalFanout.indirect += w.fanout.counts.indirect;
844
+ }
845
+
846
+ // Provenance triple (ADOPTION_APP_PLAN.md's dominion.config.json section) —
847
+ // approximated at the wrapper level: sum each wrapper's counted (jsx +
848
+ // createElement + hoc) fanout, grouped by how its own terminal was found.
849
+ let autoVerifiedSites = 0;
850
+ let declaredSites = 0;
851
+ for (const w of allWrappers) {
852
+ const counted = w.fanout.counts.jsx + w.fanout.counts.createElement + w.fanout.counts.hoc;
853
+ if (w.provenance === 'auto-verified') autoVerifiedSites += counted;
854
+ else declaredSites += counted;
855
+ }
856
+ let unresolvedSites = 0;
857
+ for (const key of unresolvedKeys) {
858
+ const f = fanoutResults.get(key);
859
+ if (f) unresolvedSites += f.counts.jsx + f.counts.createElement + f.counts.hoc;
860
+ }
861
+
862
+ return {
863
+ platform,
864
+ package: pkgName,
865
+ root,
866
+ scannedFiles: moduleGraph.files.length,
867
+ wrappers: allWrappers,
868
+ byComponent: [...byComponent.values()].sort((a, b) => a.name.localeCompare(b.name)),
869
+ contradictions,
870
+ unresolvedDeclarations,
871
+ provenanceTriple: { autoVerified: autoVerifiedSites, declared: declaredSites, unresolved: unresolvedSites },
872
+ dominionConfigFound: Object.keys(dominionConfig.wrappers).length > 0,
873
+ };
874
+ }