@starklab/stark-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +31 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
- package/src/adopt/catalog.js +88 -0
- package/src/adopt/dominionFixture.test.js +165 -0
- package/src/adopt/moduleGraph.js +232 -0
- package/src/adopt/parseSource.js +25 -0
- package/src/adopt/propApiResolver.js +278 -0
- package/src/adopt/propApiResolver.test.js +229 -0
- package/src/adopt/referenceResolver.js +151 -0
- package/src/adopt/referenceResolver.test.js +213 -0
- package/src/adopt/rnTailwindResolver.js +347 -0
- package/src/adopt/rnTailwindResolver.test.js +263 -0
- package/src/adopt/rnTokenAliasResolver.js +474 -0
- package/src/adopt/rnTokenAliasResolver.test.js +260 -0
- package/src/adopt/tailwindResolver.js +512 -0
- package/src/adopt/tailwindResolver.test.js +178 -0
- package/src/adopt/targetDiscovery.js +237 -0
- package/src/adopt/targetDiscovery.test.js +227 -0
- package/src/adopt/tokenAliasResolver.js +513 -0
- package/src/adopt/tokenAliasResolver.test.js +319 -0
- package/src/adopt/wrapperResolver.js +874 -0
- package/src/adopt/wrapperResolver.test.js +324 -0
- package/src/cli.js +376 -0
- package/src/data.js +267 -0
- package/src/data.test.js +231 -0
- package/src/index.js +8 -0
- package/src/server.js +149 -0
|
@@ -0,0 +1,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
|
+
}
|