@starklab/stark-mcp 0.1.0 → 0.2.1
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/package.json +10 -4
- package/src/adopt/adoptScanReport.js +124 -0
- package/src/adopt/catalog.js +26 -6
- package/src/adopt/foreignDiscoveryResolver.js +276 -0
- package/src/adopt/foreignPropSchemaResolver.js +134 -0
- package/src/adopt/foreignScanReport.js +210 -0
- package/src/adopt/foreignScoringResolver.js +192 -0
- package/src/adopt/foreignSystemConfig.js +356 -0
- package/src/adopt/installedPackageDiscoveryResolver.js +602 -0
- package/src/adopt/installedPackagePropSchemaResolver.js +279 -0
- package/src/adopt/installedPackageScoringResolver.js +153 -0
- package/src/adopt/installedSystemAutoDetector.js +51 -0
- package/src/adopt/installedSystemScan.js +101 -0
- package/src/adopt/jsxOpportunityHelpers.js +99 -0
- package/src/adopt/moduleGraph.js +39 -8
- package/src/adopt/opportunityResolver.js +255 -0
- package/src/adopt/opportunitySignaturesNative.js +47 -0
- package/src/adopt/usageRulesResolver.js +298 -0
- package/src/adopt/vecnaMaterializer.js +165 -0
- package/src/adopt/vecnaVerifier.js +127 -0
- package/src/cli.js +407 -1
- package/src/server.js +38 -14
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
- package/src/adopt/dominionFixture.test.js +0 -165
- package/src/adopt/propApiResolver.test.js +0 -229
- package/src/adopt/referenceResolver.test.js +0 -213
- package/src/adopt/rnTailwindResolver.test.js +0 -263
- package/src/adopt/rnTokenAliasResolver.test.js +0 -260
- package/src/adopt/tailwindResolver.test.js +0 -178
- package/src/adopt/targetDiscovery.test.js +0 -227
- package/src/adopt/tokenAliasResolver.test.js +0 -319
- package/src/adopt/wrapperResolver.test.js +0 -324
- 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
|
+
}
|