@starklab/stark-mcp 0.1.0 → 0.2.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 (40) hide show
  1. package/package.json +10 -4
  2. package/src/adopt/adoptScanReport.js +124 -0
  3. package/src/adopt/catalog.js +26 -6
  4. package/src/adopt/foreignDiscoveryResolver.js +276 -0
  5. package/src/adopt/foreignPropSchemaResolver.js +134 -0
  6. package/src/adopt/foreignScanReport.js +210 -0
  7. package/src/adopt/foreignScoringResolver.js +192 -0
  8. package/src/adopt/foreignSystemConfig.js +356 -0
  9. package/src/adopt/installedPackageDiscoveryResolver.js +602 -0
  10. package/src/adopt/installedPackagePropSchemaResolver.js +279 -0
  11. package/src/adopt/installedPackageScoringResolver.js +153 -0
  12. package/src/adopt/installedSystemAutoDetector.js +51 -0
  13. package/src/adopt/installedSystemScan.js +101 -0
  14. package/src/adopt/jsxOpportunityHelpers.js +99 -0
  15. package/src/adopt/moduleGraph.js +39 -8
  16. package/src/adopt/opportunityResolver.js +255 -0
  17. package/src/adopt/opportunitySignaturesNative.js +47 -0
  18. package/src/adopt/usageRulesResolver.js +298 -0
  19. package/src/adopt/vecnaMaterializer.js +165 -0
  20. package/src/adopt/vecnaVerifier.js +127 -0
  21. package/src/cli.js +407 -1
  22. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
  23. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
  24. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
  25. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
  26. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
  27. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
  28. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
  29. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
  30. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
  31. package/src/adopt/dominionFixture.test.js +0 -165
  32. package/src/adopt/propApiResolver.test.js +0 -229
  33. package/src/adopt/referenceResolver.test.js +0 -213
  34. package/src/adopt/rnTailwindResolver.test.js +0 -263
  35. package/src/adopt/rnTokenAliasResolver.test.js +0 -260
  36. package/src/adopt/tailwindResolver.test.js +0 -178
  37. package/src/adopt/targetDiscovery.test.js +0 -227
  38. package/src/adopt/tokenAliasResolver.test.js +0 -319
  39. package/src/adopt/wrapperResolver.test.js +0 -324
  40. package/src/data.test.js +0 -231
@@ -0,0 +1,602 @@
1
+ import { createRequire } from 'node:module';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import ts from 'typescript';
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ /**
10
+ * KNOWN_DISTRIBUTION_PATTERNS — the empirically-derived taxonomy of
11
+ * React/React Native design-system distribution/export shapes this adapter
12
+ * has hit in practice, one entry per real fix landed in this file or its
13
+ * siblings (ADOPTION_APP_PLAN.md §10 decision #25). When a new system's
14
+ * scan comes back wrong, check it against this list before assuming the
15
+ * failure is novel — most new systems land in an already-known category.
16
+ *
17
+ * 1. Direct named exports (the default/majority case — Carbon, Ant Design,
18
+ * Fluent UI, Tamagui, React Native Elements, React Native Paper, MUI).
19
+ * No special handling needed: isDirectlyComponentShaped() alone covers it.
20
+ * 2. Namespace re-export (Radix: every top-level export is itself a module
21
+ * re-export, e.g. `export { reactAccordion as Accordion }`). Fixed by
22
+ * isCompoundComponentNamespace() below, via checker.getAliasedSymbol().
23
+ * 3. Strict `exports` map with no `./package.json` subpath (Mantine).
24
+ * Fixed by resolveInstalledLocation()'s walk-up-from-entry-point fallback.
25
+ * 4. Broken/incomplete `types` field needing an independent `typings`
26
+ * fallback (native-base declares both; only `typings` resolves). Fixed
27
+ * by findTypesEntry() trying both fields independently, never `||`.
28
+ * 5. Oversized prop surface from a style-props system spreading every
29
+ * pseudo-selector/breakpoint into named properties (Chakra UI v3: up to
30
+ * 1268 properties on one component). Fixed by MAX_PROPS_PER_COMPONENT /
31
+ * MAX_TYPE_STRING_LENGTH caps in installedPackagePropSchemaResolver.js.
32
+ * 6. Cross-file re-export losing its type annotation at the export site
33
+ * (@atlaskit/button: `export { default } from './old-button/button'`).
34
+ * Fixed by isDirectlyComponentShaped()'s getAliasedSymbol() resolution
35
+ * through the re-export chain to the terminal declaration.
36
+ * 7. Runtime factory-synthesized components (Shopify Restyle:
37
+ * createBox()/createText() build the real UI primitives at runtime: they
38
+ * never exist as named package exports at all). Root-caused as a genuine
39
+ * architectural non-goal, not a bug — fixing it for real would mean
40
+ * tracing factory call sites and the local identifiers they're bound to
41
+ * across the consuming repo, a materially different mechanism than
42
+ * reading a package's own .d.ts. Not built; Restyle stays registered
43
+ * with this documented as its known limitation.
44
+ * 8. Root entry declared but not published (primereact 11: package.json's
45
+ * "main"/"types" and its own "exports" "." condition all point at
46
+ * "./index.mjs"/"./index.d.ts", but neither file exists in the published
47
+ * tarball — every real component lives only under subpaths like
48
+ * "primereact/button". Both of resolveInstalledLocation()'s
49
+ * require.resolve attempts throw MODULE_NOT_FOUND (not the
50
+ * ERR_PACKAGE_PATH_NOT_EXPORTED case #3 already handles), which previously
51
+ * fell through to a false "not installed" report even though the package
52
+ * plainly exists on disk. Fixed by resolveInstalledLocation()'s third,
53
+ * filesystem-only fallback tier below.
54
+ * 9. Compound component declared as an intersection with a `typeof`
55
+ * reference to an internal module (@ant-design/react-native 5:
56
+ * `declare const Checkbox: typeof InternalCheckbox & { CheckboxItem:
57
+ * typeof CheckboxItem; AgreeItem: typeof AgreeItem };`). The component
58
+ * shape lives behind the `typeof` operand, in a different module, so
59
+ * isDirectlyComponentShaped() — which recognises `React.FC<Props>` and
60
+ * `class X extends React.Component` at the declaration site — sees only
61
+ * an intersection type and drops the export. Distinct from #2, where the
62
+ * export is an alias the checker resolves in one hop, and from #6, where
63
+ * the terminal declaration is itself component-shaped. Four of that
64
+ * package's exports (Checkbox, Collapse, Input, List) are lost this way;
65
+ * they are real components a consumer imports and uses, so the coverage
66
+ * denominator under-reports. Not fixed: resolving it means following
67
+ * `typeof` operands into their declaring modules and re-running the
68
+ * component test there, which needs care not to walk the whole type
69
+ * graph. Ant Design Mobile RN stays registered with this documented as
70
+ * its known limitation, same treatment as #7 and #8.
71
+ */
72
+ export const KNOWN_DISTRIBUTION_PATTERNS = [
73
+ 'direct-named-exports',
74
+ 'namespace-re-export',
75
+ 'strict-exports-map',
76
+ 'broken-types-field',
77
+ 'oversized-prop-surface',
78
+ 'cross-file-re-export',
79
+ 'runtime-factory-synthesized',
80
+ 'root-entry-declared-but-not-published',
81
+ 'typeof-intersection-compound-component',
82
+ ];
83
+
84
+ function readJson(file) {
85
+ try {
86
+ return JSON.parse(readFileSync(file, 'utf-8'));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Best-effort component name for a symbol whose exported name is the
94
+ * literal string "default" — a single-component package re-exporting its
95
+ * whole default export with no named binding anywhere in its own .d.ts
96
+ * (confirmed live against @atlaskit/button: `export { default } from
97
+ * './old-button/button'`, no re-exported "Button" identifier at all). The
98
+ * bare symbol name "default" is neither PascalCase (so it silently failed
99
+ * the component-convention filter below, dropping the package's one real
100
+ * component from the catalog entirely) nor a name coverage scoring could
101
+ * ever match against a real import. Falls back to the package's own last
102
+ * path segment, cased the way an npm package name (always lowercase/kebab)
103
+ * would be written as the JS identifier a consumer binds it to on default
104
+ * import — "@atlaskit/button" -> "Button", "my-cool-button" ->
105
+ * "MyCoolButton". installedPackageScoringResolver.js's componentNameFromOrigin()
106
+ * applies this exact same fallback so a bare-specifier default import
107
+ * resolves to the same name discovery reports here.
108
+ */
109
+ export function nameFromPackageSpecifier(packageName) {
110
+ const last = packageName.split('/').pop() || '';
111
+ return last.replace(/(^|-)([a-z])/g, (_, boundary, c) => c.toUpperCase());
112
+ }
113
+
114
+ function depNames(pkg) {
115
+ return new Set([
116
+ ...Object.keys(pkg?.dependencies || {}),
117
+ ...Object.keys(pkg?.devDependencies || {}),
118
+ ...Object.keys(pkg?.peerDependencies || {}),
119
+ ]);
120
+ }
121
+
122
+ /**
123
+ * Package detection — a straight exact/scoped-name lookup against the
124
+ * target's own root package.json, unlike foreignDiscoveryResolver.js's
125
+ * detectPackage() which needs a two-signal (config file + dep regex)
126
+ * fallback because shadcn is never itself an npm dependency. An
127
+ * installed-package system IS the dependency, so one signal is sufficient
128
+ * and unambiguous.
129
+ */
130
+ function detectPackage(root, packageName) {
131
+ const pkgFile = path.join(root, 'package.json');
132
+ const pkg = readJson(pkgFile);
133
+ if (!pkg) return { detected: false, evidence: [] };
134
+ const names = depNames(pkg);
135
+ const detected = names.has(packageName);
136
+ return {
137
+ detected,
138
+ evidence: detected ? [{ file: 'package.json', dep: packageName }] : [],
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Resolves the installed package's own root dir and package.json via
144
+ * require.resolve against the target repo's node_modules — this correctly
145
+ * handles hoisted/pnpm/yarn-workspace layouts a manual node_modules walk
146
+ * would get wrong, the same reason moduleGraph.js/foreignScoringResolver.js
147
+ * never hand-roll their own resolution when a Node builtin already does it
148
+ * right.
149
+ *
150
+ * Falls back to resolving the package's main entry point and walking up to
151
+ * the nearest package.json when the direct "<pkg>/package.json" resolve
152
+ * throws — confirmed live against @mantine/core: its package.json declares
153
+ * a strict "exports" map with no "./package.json" entry, so Node's own
154
+ * resolver rejects the subpath with ERR_PACKAGE_PATH_NOT_EXPORTED even
155
+ * though the file plainly exists on disk (this only gates module
156
+ * resolution, not a raw fs read). Resolving the package's real entry point
157
+ * (which "exports" does allow, via its "." condition) and walking up
158
+ * directories to the first package.json whose own "name" matches sidesteps
159
+ * the restriction entirely without special-casing Mantine.
160
+ *
161
+ * Falls back a second time to a plain node_modules directory walk (still no
162
+ * special-casing) when even the entry-point resolve throws — confirmed live
163
+ * against primereact 11: its package.json's "main"/"types" AND its own
164
+ * "exports" "." condition all point at "./index.mjs"/"./index.d.ts", but
165
+ * neither file exists in the published package at all (every real
166
+ * component lives only under subpaths like "primereact/button"). That's not
167
+ * an exports-map access restriction like Mantine's (the "." condition does
168
+ * resolve, to a target that then 404s), so the walk-up-from-entry-point
169
+ * fallback above never gets an entry path to start from — both attempts
170
+ * throw MODULE_NOT_FOUND. Without this third tier, a package in exactly
171
+ * this shape was reported as "not installed" (wrong — it plainly is, see
172
+ * KNOWN_DISTRIBUTION_PATTERNS #8) instead of "installed, no root types
173
+ * entry." This walks up from `root` looking for
174
+ * node_modules/<packageName>/package.json directly via fs, bypassing
175
+ * Node's module resolution (and its exports-map enforcement) entirely — the
176
+ * same "raw fs read" insight the Mantine fallback already relies on, just
177
+ * without needing any resolvable file to anchor the walk.
178
+ */
179
+ export function resolveInstalledLocation(root, packageName) {
180
+ try {
181
+ const pkgJsonPath = require.resolve(`${packageName}/package.json`, { paths: [root] });
182
+ const pkg = readJson(pkgJsonPath);
183
+ return { installDir: path.dirname(pkgJsonPath), pkgJsonPath, pkg };
184
+ } catch {
185
+ // fall through to the walk-up strategy below
186
+ }
187
+
188
+ try {
189
+ const entryPath = require.resolve(packageName, { paths: [root] });
190
+ let dir = path.dirname(entryPath);
191
+ while (true) {
192
+ const candidate = path.join(dir, 'package.json');
193
+ if (existsSync(candidate)) {
194
+ const pkg = readJson(candidate);
195
+ if (pkg?.name === packageName) {
196
+ return { installDir: dir, pkgJsonPath: candidate, pkg };
197
+ }
198
+ }
199
+ const parent = path.dirname(dir);
200
+ if (parent === dir) break;
201
+ dir = parent;
202
+ }
203
+ } catch {
204
+ // fall through to the plain fs walk below
205
+ }
206
+
207
+ let dir = root;
208
+ while (true) {
209
+ const candidate = path.join(dir, 'node_modules', packageName, 'package.json');
210
+ if (existsSync(candidate)) {
211
+ const pkg = readJson(candidate);
212
+ if (pkg?.name === packageName) {
213
+ return { installDir: path.dirname(candidate), pkgJsonPath: candidate, pkg };
214
+ }
215
+ }
216
+ const parent = path.dirname(dir);
217
+ if (parent === dir) break;
218
+ dir = parent;
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ /**
225
+ * Finds the package's own .d.ts types entry point from its package.json —
226
+ * "types" then "typings" (the direct, unambiguous signals, tried
227
+ * independently), then a same-named .d.ts sibling of "main"/"module" (the
228
+ * common convention when a package omits "types" but ships declarations
229
+ * alongside its JS), then a root-level index.d.ts as a last resort. Returns
230
+ * null, never a guess, when none of these exist — that's a real "not
231
+ * reachable" case for the caller to report, not a silently-empty catalog.
232
+ *
233
+ * "types" and "typings" are tried as two independent candidates, not
234
+ * `pkg.types || pkg.typings` — confirmed live against native-base: its
235
+ * package.json declares both fields, but "types" points at a path that
236
+ * doesn't exist in the published package (a real upstream bug) while
237
+ * "typings" points at a real, valid one. `||` picks whichever is merely
238
+ * truthy first and never tries the second string once the first fails its
239
+ * existsSync check, so the valid "typings" path was silently never reached.
240
+ */
241
+ export function findTypesEntry(installDir, pkg) {
242
+ for (const field of ['types', 'typings']) {
243
+ const declared = pkg?.[field];
244
+ if (!declared) continue;
245
+ const resolved = path.resolve(installDir, declared);
246
+ if (existsSync(resolved)) return resolved;
247
+ }
248
+
249
+ for (const field of ['main', 'module']) {
250
+ const entry = pkg?.[field];
251
+ if (!entry) continue;
252
+ const dtsSibling = path.resolve(installDir, entry.replace(/\.jsx?$/, '.d.ts'));
253
+ if (existsSync(dtsSibling)) return dtsSibling;
254
+ }
255
+
256
+ const rootIndex = path.join(installDir, 'index.d.ts');
257
+ if (existsSync(rootIndex)) return rootIndex;
258
+
259
+ return null;
260
+ }
261
+
262
+ /**
263
+ * The decls-based + call-signature checks only, no namespace fallback —
264
+ * factored out of isComponentShapedSymbol() so isCompoundComponentNamespace()
265
+ * below can apply the same "is this thing itself component-shaped" test to a
266
+ * namespace's members without recursing back into the namespace check (which
267
+ * would only matter for a namespace nested inside a namespace — not a shape
268
+ * any real design system's compound-component convention produces).
269
+ */
270
+ function isDirectlyComponentShaped(checker, symbol) {
271
+ let decls = symbol.getDeclarations() || [];
272
+ // A cross-file re-export (`export { default } from './internal/button'`,
273
+ // confirmed live against @atlaskit/button) makes the exported symbol's own
274
+ // declaration an ExportSpecifier, not the original `declare const X: ...`
275
+ // — the text-pattern check below would never see the real type annotation.
276
+ // getAliasedSymbol() resolves through the whole re-export chain (verified:
277
+ // one hop or many) to the terminal declaration, letting the syntactic
278
+ // check fire without needing the checker to resolve React.FC's own module,
279
+ // which the callSigs fallback below requires and a target repo may lack.
280
+ if (symbol.flags & ts.SymbolFlags.Alias) {
281
+ try {
282
+ const aliased = checker.getAliasedSymbol(symbol);
283
+ if (aliased && aliased !== symbol) decls = decls.concat(aliased.getDeclarations() || []);
284
+ } catch {
285
+ // fall through with whatever decls we already have
286
+ }
287
+ }
288
+ for (const decl of decls) {
289
+ if (ts.isClassDeclaration(decl)) return true;
290
+ if (ts.isFunctionDeclaration(decl)) return true;
291
+ if (ts.isVariableDeclaration(decl) && decl.type) {
292
+ const typeText = decl.type.getText();
293
+ if (/^(React\.)?(FC|FunctionComponent|ForwardRefExoticComponent|MemoExoticComponent|ComponentType)\b/.test(typeText)) {
294
+ return true;
295
+ }
296
+ }
297
+ }
298
+ // Fallback: a symbol whose resolved type has call signatures returning
299
+ // something JSX-shaped is treated as component-shaped too — covers the
300
+ // common `export const Button: (props: ButtonProps) => JSX.Element` and
301
+ // arrow-const-without-an-explicit-FC-annotation shapes real libraries use.
302
+ // No declaration to resolve at all is a legitimate "not a component";
303
+ // deliberately no try/catch beyond that guard — a symbol with a real
304
+ // location never makes the checker throw for an ordinary non-component
305
+ // reason (it just returns zero call signatures), so an actual exception
306
+ // here is a genuine checker failure and must propagate to
307
+ // enumerateComponents()'s per-symbol guard so it's reported as
308
+ // unreachable, not silently mislabeled as "not a component."
309
+ const location = decls[0] ?? symbol.valueDeclaration;
310
+ if (!location) return false;
311
+ const type = checker.getTypeOfSymbolAtLocation(symbol, location);
312
+ const callSigs = type.getCallSignatures();
313
+ return callSigs.length > 0;
314
+ }
315
+
316
+ /**
317
+ * True for a "compound component namespace" export — a top-level export
318
+ * that is itself just a module re-export (`import * as reactAccordion from
319
+ * '@radix-ui/react-accordion'; export { reactAccordion as Accordion }`),
320
+ * confirmed live as radix-ui's whole package shape: every one of its ~30
321
+ * top-level exports (Accordion, Dialog, Select, ...) is this pattern, not a
322
+ * component/class/callable itself, so isDirectlyComponentShaped() always
323
+ * returned false and the entire catalog reported 0 components. The
324
+ * TypeScript checker resolves the re-export alias to the real module symbol
325
+ * (checker.getAliasedSymbol) whose own .exports contains the actual
326
+ * components (Root, Trigger, Content, ...) — this reports the namespace
327
+ * itself as one catalog entry (e.g. "Accordion") when at least one of its
328
+ * members is component-shaped, matching how a consumer actually imports and
329
+ * uses it (`import { Accordion } from 'radix-ui'` then `<Accordion.Root>`)
330
+ * rather than expanding into "Accordion.Root"/"Accordion.Trigger" entries,
331
+ * which would multiply out into redundant long-form/short-form aliases
332
+ * radix itself re-exports side by side (e.g. both "AccordionContent" and
333
+ * "Content" resolve to the same underlying component).
334
+ */
335
+ function isCompoundComponentNamespace(checker, symbol) {
336
+ if (!(symbol.flags & ts.SymbolFlags.Alias)) return false;
337
+ let resolved;
338
+ try {
339
+ resolved = checker.getAliasedSymbol(symbol);
340
+ } catch {
341
+ return false;
342
+ }
343
+ if (!resolved?.exports) return false;
344
+ let hasComponentMember = false;
345
+ resolved.exports.forEach((memberSymbol, memberName) => {
346
+ if (hasComponentMember) return;
347
+ if (!/^[A-Z]/.test(memberName)) return;
348
+ if (isDirectlyComponentShaped(checker, memberSymbol)) hasComponentMember = true;
349
+ });
350
+ return hasComponentMember;
351
+ }
352
+
353
+ /**
354
+ * True when a TypeScript type is component-shaped for enumeration purposes:
355
+ * callable (a function/const arrow component), a class extending
356
+ * React.Component/PureComponent, or a compound-component namespace object
357
+ * (see isCompoundComponentNamespace() above). Deliberately loose — the real
358
+ * "is this usable as JSX" narrowing happens in
359
+ * installedPackagePropSchemaResolver.js via getPropertiesOfType on the
360
+ * actual props parameter; this pass only needs to build the catalog, not
361
+ * validate it.
362
+ */
363
+ function isComponentShapedSymbol(checker, symbol) {
364
+ return isDirectlyComponentShaped(checker, symbol) || isCompoundComponentNamespace(checker, symbol);
365
+ }
366
+
367
+ /**
368
+ * Component enumeration via the TypeScript compiler API — the real fix for
369
+ * the Phase-1 spike's documented gap (ADOPTION_APP_PLAN.md §10 decision #25):
370
+ * a regex/import-usage scan only ever finds components the TARGET repo
371
+ * happens to already use, never the library's full catalog. Building a real
372
+ * ts.Program over the resolved types entry and walking
373
+ * checker.getExportsOfModule gives every exported symbol regardless of
374
+ * whether the target repo imports it — the same "every catalog component
375
+ * reported, including zero-usage ones" discipline runAdopt() already
376
+ * applies to Stark's own catalog.json, now available for a foreign one too.
377
+ *
378
+ * Known limitation, confirmed via live verification against react-native-paper
379
+ * (ADOPTION_APP_PLAN.md §10 decision #25): isComponentShapedSymbol()'s
380
+ * call-signature fallback needs the checker to actually resolve types like
381
+ * ForwardRefExoticComponent/ComponentType, which live in @types/react (and
382
+ * @types/react-native for RN-specific aliases) — not bundled with `react`
383
+ * itself. Without those type packages resolvable from the target repo
384
+ * (hoisted or local), any component whose declared type is an unresolvable
385
+ * alias silently reports 0 call signatures and is dropped from the catalog
386
+ * with no error — confirmed live: Button/Card vanished from a 29-component
387
+ * react-native-paper catalog until @types/react + @types/react-native were
388
+ * installed in the target repo, after which the same package reported 38
389
+ * components including both. A pure-JS target repo with no TypeScript
390
+ * tooling at all will see the same under-count for any forwardRef/generic
391
+ * component in the scanned package, even though the package itself is fully
392
+ * typed — this is an inherent limit of reading .d.ts via the checker, not a
393
+ * bug to fix here.
394
+ *
395
+ * Per-symbol isolation: isComponentShapedSymbol() ultimately asks the
396
+ * TypeScript checker to resolve arbitrary types from a third-party .d.ts —
397
+ * a circular or unusually deep generic can make the checker throw rather
398
+ * than return false. Without a guard here, one such symbol would abort the
399
+ * whole export loop and silently drop every component after it, not just
400
+ * the offending one. The try/catch below skips only that symbol and
401
+ * records it in unreachableComponents, the same "report honestly, don't
402
+ * silently drop data" discipline propsTruncated/totalPropCount already
403
+ * applies to Chakra's oversized prop surface (see the sibling
404
+ * installedPackagePropSchemaResolver.js).
405
+ */
406
+ function enumerateComponents(typesEntryFile, packageName) {
407
+ const program = ts.createProgram([typesEntryFile], {
408
+ allowJs: true,
409
+ jsx: ts.JsxEmit.React,
410
+ esModuleInterop: true,
411
+ skipLibCheck: true,
412
+ noEmit: true,
413
+ });
414
+ const checker = program.getTypeChecker();
415
+ const sourceFile = program.getSourceFile(typesEntryFile);
416
+ if (!sourceFile) return { components: [], unreachableComponents: [], moduleSymbol: null, checker: null, program: null };
417
+
418
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
419
+ if (!moduleSymbol) return { components: [], unreachableComponents: [], moduleSymbol: null, checker, program };
420
+
421
+ const exports = checker.getExportsOfModule(moduleSymbol);
422
+ const components = [];
423
+ const unreachableComponents = [];
424
+ const seen = new Set();
425
+ for (const symbol of exports) {
426
+ const rawName = symbol.getName();
427
+ const name = rawName === 'default' ? nameFromPackageSpecifier(packageName) : rawName;
428
+ if (!/^[A-Z]/.test(name)) continue; // component convention: PascalCase export
429
+ if (seen.has(name)) continue;
430
+ try {
431
+ if (!isComponentShapedSymbol(checker, symbol)) continue;
432
+ } catch (err) {
433
+ seen.add(name);
434
+ unreachableComponents.push({ name, reason: err.message });
435
+ continue;
436
+ }
437
+ seen.add(name);
438
+ components.push({ name });
439
+ }
440
+
441
+ return { components, unreachableComponents, moduleSymbol, checker, program };
442
+ }
443
+
444
+ /**
445
+ * Read-only CDN fallback for the no-node_modules case — jsdelivr's data API
446
+ * serves raw published package files as static GETs, never executed. This
447
+ * is explicitly opt-in (allowNetwork) and off by default: the whole point
448
+ * is that scanning a repo never triggers `npm install <arbitrary-package>`
449
+ * (real code execution via install-time lifecycle scripts), and a silent
450
+ * network fallback would be a surprising side effect of a "static analysis"
451
+ * tool. jsdelivr resolves "latest" when no version pin is known locally.
452
+ */
453
+ export async function fetchFromCdn(packageName) {
454
+ const pkgUrl = `https://cdn.jsdelivr.net/npm/${packageName}/package.json`;
455
+ const pkgRes = await fetch(pkgUrl);
456
+ if (!pkgRes.ok) return null;
457
+ const pkg = await pkgRes.json();
458
+
459
+ const declared = pkg.types || pkg.typings;
460
+ if (!declared) return { pkg, typesText: null };
461
+ const typesUrl = `https://cdn.jsdelivr.net/npm/${packageName}/${declared.replace(/^\.?\//, '')}`;
462
+ const typesRes = await fetch(typesUrl);
463
+ if (!typesRes.ok) return { pkg, typesText: null };
464
+ return { pkg, typesText: await typesRes.text() };
465
+ }
466
+
467
+ /**
468
+ * Layer 1 of Phase 2 ("Version B", ADOPTION_APP_PLAN.md §10 decision #25) —
469
+ * discovery for the 'installed-package' distribution mode (any npm-
470
+ * published React/React Native design system: MUI, Chakra, Carbon, or an
471
+ * unregistered package passed directly by name — see cli.js's scan-foreign
472
+ * handler for how "whatever design system" becomes real rather than a
473
+ * curated list). Unlike foreignDiscoveryResolver.js's shadcn-only path,
474
+ * there's no local .tsx source to glob — real component/prop types live in
475
+ * node_modules/<packageName>'s .d.ts, so this reads that instead, via the
476
+ * TypeScript compiler API rather than Babel-AST walking.
477
+ *
478
+ * RN platform-file handling: when platform is 'native', component/prop
479
+ * resolution in the sibling resolvers additionally considers .native.tsx/
480
+ * .ios.tsx/.android.tsx sources when the caller's own repo (not the
481
+ * package) uses platform-split files — see moduleGraph.js's EXTENSIONS.
482
+ * This resolver itself only reads the package's own .d.ts, which is
483
+ * platform-agnostic by construction (a single Button.d.ts covers whatever
484
+ * platform the JS implementation targets).
485
+ */
486
+ export async function resolveInstalledPackageDiscovery(root, packageName, { platform = 'web', allowNetwork = false } = {}) {
487
+ const packageDetection = detectPackage(root, packageName);
488
+ const located = resolveInstalledLocation(root, packageName);
489
+
490
+ if (located) {
491
+ const typesEntryFile = findTypesEntry(located.installDir, located.pkg);
492
+ if (!typesEntryFile) {
493
+ return {
494
+ system: packageName,
495
+ root,
496
+ platform,
497
+ distribution: 'installed-package',
498
+ packageDetected: packageDetection.detected,
499
+ evidence: packageDetection.evidence,
500
+ installed: true,
501
+ source: 'node_modules',
502
+ typesEntryFile: null,
503
+ components: [],
504
+ unreachableComponents: [],
505
+ unresolvedReason: `No .d.ts types entry found for "${packageName}" — checked package.json "types"/"typings", a same-named sibling of "main"/"module", and a root index.d.ts.`,
506
+ };
507
+ }
508
+
509
+ const { components, unreachableComponents } = enumerateComponents(typesEntryFile, packageName);
510
+ return {
511
+ system: packageName,
512
+ root,
513
+ platform,
514
+ distribution: 'installed-package',
515
+ packageDetected: packageDetection.detected,
516
+ evidence: packageDetection.evidence,
517
+ installed: true,
518
+ source: 'node_modules',
519
+ typesEntryFile: path.relative(root, typesEntryFile).startsWith('..')
520
+ ? typesEntryFile
521
+ : path.relative(root, typesEntryFile),
522
+ components,
523
+ unreachableComponents,
524
+ unresolvedReason: null,
525
+ };
526
+ }
527
+
528
+ if (!allowNetwork) {
529
+ return {
530
+ system: packageName,
531
+ root,
532
+ platform,
533
+ distribution: 'installed-package',
534
+ packageDetected: packageDetection.detected,
535
+ evidence: packageDetection.evidence,
536
+ installed: false,
537
+ source: null,
538
+ typesEntryFile: null,
539
+ components: [],
540
+ unreachableComponents: [],
541
+ unresolvedReason: `"${packageName}" is not installed in ${root}'s node_modules. Install it first, or pass --allow-network to fetch its published types read-only from a CDN (never installed, never executed).`,
542
+ };
543
+ }
544
+
545
+ const cdn = await fetchFromCdn(packageName);
546
+ if (!cdn || !cdn.typesText) {
547
+ return {
548
+ system: packageName,
549
+ root,
550
+ platform,
551
+ distribution: 'installed-package',
552
+ packageDetected: packageDetection.detected,
553
+ evidence: packageDetection.evidence,
554
+ installed: false,
555
+ source: 'cdn',
556
+ typesEntryFile: null,
557
+ components: [],
558
+ unreachableComponents: [],
559
+ unresolvedReason: `Could not fetch a usable .d.ts for "${packageName}" from the CDN fallback (package.json missing/no "types" field/fetch failed).`,
560
+ };
561
+ }
562
+
563
+ // The CDN fallback has no real filesystem location to hand ts.createProgram
564
+ // a resolvable module path, so it's parsed as a standalone source file
565
+ // instead — sufficient to enumerate top-level exports, which is this
566
+ // layer's job; installedPackagePropSchemaResolver.js's own CDN branch
567
+ // does the equivalent for prop extraction.
568
+ const sourceFile = ts.createSourceFile('cdn-types.d.ts', cdn.typesText, ts.ScriptTarget.Latest, true);
569
+ const components = [];
570
+ ts.forEachChild(sourceFile, (node) => {
571
+ const names = [];
572
+ if (ts.isClassDeclaration(node) && node.name && hasExportModifier(node)) names.push(node.name.text);
573
+ if (ts.isFunctionDeclaration(node) && node.name && hasExportModifier(node)) names.push(node.name.text);
574
+ if (ts.isVariableStatement(node) && hasExportModifier(node)) {
575
+ for (const decl of node.declarationList.declarations) {
576
+ if (ts.isIdentifier(decl.name)) names.push(decl.name.text);
577
+ }
578
+ }
579
+ for (const name of names) {
580
+ if (/^[A-Z]/.test(name)) components.push({ name });
581
+ }
582
+ });
583
+
584
+ return {
585
+ system: packageName,
586
+ root,
587
+ platform,
588
+ distribution: 'installed-package',
589
+ packageDetected: packageDetection.detected,
590
+ evidence: packageDetection.evidence,
591
+ installed: false,
592
+ source: 'cdn',
593
+ typesEntryFile: null,
594
+ components,
595
+ unreachableComponents: [],
596
+ unresolvedReason: null,
597
+ };
598
+ }
599
+
600
+ function hasExportModifier(node) {
601
+ return (node.modifiers || []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
602
+ }