@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.
- 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/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,279 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveInstalledLocation,
|
|
5
|
+
findTypesEntry,
|
|
6
|
+
fetchFromCdn,
|
|
7
|
+
} from './installedPackageDiscoveryResolver.js';
|
|
8
|
+
|
|
9
|
+
// A style-props system that unions in every pseudo-selector/breakpoint key
|
|
10
|
+
// (e.g. Chakra UI v3's `Omit<HTMLAttributes, ...>` props) can render past a
|
|
11
|
+
// megabyte of text per component with NoTruncation — harmless on its own,
|
|
12
|
+
// but multiplied across hundreds of sampled components it blows well past
|
|
13
|
+
// V8's max string length once the CLI's JSON.stringify(value, null, 2)
|
|
14
|
+
// tries to serialize the result (confirmed live against a real installed
|
|
15
|
+
// @chakra-ui/react: ~765 components, ~900KB typeName each, ~530MB compact
|
|
16
|
+
// JSON). Cap the rendered text rather than dropping NoTruncation — TS's own
|
|
17
|
+
// truncation elides from the middle of the type in arbitrary, unhelpful
|
|
18
|
+
// places, whereas an end-anchored cap keeps the (usually most informative)
|
|
19
|
+
// start of the type intact.
|
|
20
|
+
const MAX_TYPE_STRING_LENGTH = 2000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Renders a ts.Type back to a compact string via the checker itself —
|
|
24
|
+
* TypeScript's own type-to-string formatting handles unions, generics, and
|
|
25
|
+
* aliases correctly (e.g. resolves `ComponentProps<'div'>['onClick']` down
|
|
26
|
+
* to its real function signature), which is exactly the class of shape
|
|
27
|
+
* foreignPropSchemaResolver.js's hand-rolled typeAnnotationToString() can't
|
|
28
|
+
* reach — the real motivation for using the compiler API here instead of
|
|
29
|
+
* porting that Babel-AST approach to a second, installed-package case.
|
|
30
|
+
*/
|
|
31
|
+
function typeToString(checker, type) {
|
|
32
|
+
let rendered;
|
|
33
|
+
try {
|
|
34
|
+
rendered = checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseFullyQualifiedType);
|
|
35
|
+
} catch {
|
|
36
|
+
return '<unresolvable type>';
|
|
37
|
+
}
|
|
38
|
+
if (rendered.length <= MAX_TYPE_STRING_LENGTH) return rendered;
|
|
39
|
+
return `${rendered.slice(0, MAX_TYPE_STRING_LENGTH)}… (truncated, ${rendered.length} chars total)`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Finds the props parameter's type for a component symbol: for a function/
|
|
44
|
+
* arrow component, the first parameter's type; for a class component, the
|
|
45
|
+
* type argument to React.Component<Props, ...>. Returns null (a real
|
|
46
|
+
* "unreachable" case, not empty props) when neither shape is found —
|
|
47
|
+
* mirrors findPropsDeclaration()'s "never guess among ambiguous
|
|
48
|
+
* candidates" discipline in the shadcn resolver.
|
|
49
|
+
*/
|
|
50
|
+
function findPropsType(checker, symbol) {
|
|
51
|
+
const decls = symbol.getDeclarations() || [];
|
|
52
|
+
for (const decl of decls) {
|
|
53
|
+
if (ts.isClassDeclaration(decl) && decl.heritageClauses) {
|
|
54
|
+
for (const clause of decl.heritageClauses) {
|
|
55
|
+
for (const t of clause.types) {
|
|
56
|
+
if (t.typeArguments?.length) {
|
|
57
|
+
try {
|
|
58
|
+
return checker.getTypeFromTypeNode(t.typeArguments[0]);
|
|
59
|
+
} catch {
|
|
60
|
+
// fall through to the call-signature fallback below
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const type = checker.getTypeOfSymbolAtLocation(symbol, decls[0] ?? symbol.valueDeclaration);
|
|
70
|
+
const callSigs = type.getCallSignatures();
|
|
71
|
+
if (callSigs.length > 0) {
|
|
72
|
+
const params = callSigs[0].getParameters();
|
|
73
|
+
if (params.length > 0) {
|
|
74
|
+
return checker.getTypeOfSymbolAtLocation(params[0], params[0].valueDeclaration ?? decls[0]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// fall through to null below
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A style-props system (e.g. Chakra UI v3's `SystemProperties`) spreads
|
|
84
|
+
// every pseudo-selector (`_hover`, `_focus`, ...) and responsive breakpoint
|
|
85
|
+
// (`sm`, `md`, `smToLg`, ...) across CSS properties into hundreds of
|
|
86
|
+
// individually-named properties on the props type — confirmed live against
|
|
87
|
+
// @chakra-ui/react: up to 1268 "properties" for a single component, ~880 on
|
|
88
|
+
// average across all 765. That's a fundamentally different shape than a
|
|
89
|
+
// normal component API and isn't useful to enumerate exhaustively for a
|
|
90
|
+
// coverage/prop-schema report; cap it the same way typeToString() caps a
|
|
91
|
+
// single runaway type string, so one component's combinatorial style-prop
|
|
92
|
+
// surface can't blow the sampled-output size up the way it did before.
|
|
93
|
+
const MAX_PROPS_PER_COMPONENT = 150;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extracts { name, type, optional } for every real (non-inherited-from-
|
|
97
|
+
* Object) property on a resolved props type, via
|
|
98
|
+
* checker.getPropertiesOfType — the real fix for the exact limitation
|
|
99
|
+
* Phase 1's live-verification run documented for shadcn's own resolver
|
|
100
|
+
* (generic aliases like `ComponentProps<'div'>` returning "unreachable"):
|
|
101
|
+
* a real type checker resolves the generic instantiation properly, a
|
|
102
|
+
* string-pattern AST walk never can.
|
|
103
|
+
*/
|
|
104
|
+
function membersFromPropsType(checker, propsType) {
|
|
105
|
+
let properties;
|
|
106
|
+
try {
|
|
107
|
+
properties = checker.getPropertiesOfType(propsType);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { props: [], totalPropCount: 0, unresolvedReason: err.message };
|
|
110
|
+
}
|
|
111
|
+
const named = properties.filter((p) => !/^(toString|valueOf|hasOwnProperty|constructor)$/.test(p.getName()));
|
|
112
|
+
const props = named.slice(0, MAX_PROPS_PER_COMPONENT).map((p) => {
|
|
113
|
+
const decl = p.valueDeclaration ?? p.declarations?.[0];
|
|
114
|
+
let memberType;
|
|
115
|
+
try {
|
|
116
|
+
memberType = decl ? checker.getTypeOfSymbolAtLocation(p, decl) : checker.getAnyType();
|
|
117
|
+
} catch {
|
|
118
|
+
return {
|
|
119
|
+
name: p.getName(),
|
|
120
|
+
type: '<unresolvable type>',
|
|
121
|
+
optional: Boolean(p.flags & ts.SymbolFlags.Optional) || (p.getDeclarations() || []).some((d) => d.questionToken),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name: p.getName(),
|
|
126
|
+
type: typeToString(checker, memberType),
|
|
127
|
+
optional: Boolean(p.flags & ts.SymbolFlags.Optional) || (p.getDeclarations() || []).some((d) => d.questionToken),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
return { props, totalPropCount: named.length };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Per-component isolation: everything below asks the TypeScript checker to
|
|
135
|
+
* resolve arbitrary types from a third-party .d.ts, and the checker can
|
|
136
|
+
* throw on a circular or unusually deep/mapped generic rather than return a
|
|
137
|
+
* clean "can't resolve." Without the try/catch here, one such component
|
|
138
|
+
* would abort the whole sampled-output batch — losing every other component
|
|
139
|
+
* that resolved fine — instead of being reported as its own "unreachable"
|
|
140
|
+
* entry, the same discipline the rest of this file already applies to
|
|
141
|
+
* missing exports and unresolved props parameters.
|
|
142
|
+
*/
|
|
143
|
+
function sampleFromProgram(checker, moduleSymbol, components) {
|
|
144
|
+
const exports = new Map(checker.getExportsOfModule(moduleSymbol).map((s) => [s.getName(), s]));
|
|
145
|
+
return components.map(({ name }) => {
|
|
146
|
+
try {
|
|
147
|
+
const symbol = exports.get(name);
|
|
148
|
+
if (!symbol) {
|
|
149
|
+
return { component: name, reachable: false, reason: `"${name}" not found among the resolved module's exports` };
|
|
150
|
+
}
|
|
151
|
+
const propsType = findPropsType(checker, symbol);
|
|
152
|
+
if (!propsType) {
|
|
153
|
+
return { component: name, reachable: false, reason: 'no props parameter / class type argument could be resolved' };
|
|
154
|
+
}
|
|
155
|
+
const { props, totalPropCount, unresolvedReason } = membersFromPropsType(checker, propsType);
|
|
156
|
+
if (unresolvedReason) {
|
|
157
|
+
return { component: name, reachable: false, reason: unresolvedReason };
|
|
158
|
+
}
|
|
159
|
+
const result = { component: name, reachable: true, typeName: typeToString(checker, propsType), props };
|
|
160
|
+
if (totalPropCount > props.length) {
|
|
161
|
+
result.propsTruncated = true;
|
|
162
|
+
result.totalPropCount = totalPropCount;
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return { component: name, reachable: false, reason: err.message };
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Layer 2 of Phase 2 ("Version B" installed-package adapter) — reads prop
|
|
173
|
+
* shapes from the package's own .d.ts via the TypeScript compiler API
|
|
174
|
+
* (checker.getTypeOfSymbol/getPropertiesOfType), the installed-package twin
|
|
175
|
+
* of foreignPropSchemaResolver.js's shadcn-only Babel-AST walk. There is no
|
|
176
|
+
* local .tsx source to walk for an installed package — real prop types live
|
|
177
|
+
* in node_modules/<packageName>'s declarations, or (allowNetwork) a CDN
|
|
178
|
+
* fallback's fetched .d.ts text, so both branches build a real ts.Program /
|
|
179
|
+
* standalone SourceFile and resolve through the checker rather than pattern
|
|
180
|
+
* matching.
|
|
181
|
+
*/
|
|
182
|
+
export async function resolvePropSchema(root, packageName, components, { allowNetwork = false } = {}) {
|
|
183
|
+
const located = resolveInstalledLocation(root, packageName);
|
|
184
|
+
|
|
185
|
+
if (located) {
|
|
186
|
+
const typesEntryFile = findTypesEntry(located.installDir, located.pkg);
|
|
187
|
+
if (!typesEntryFile) {
|
|
188
|
+
return {
|
|
189
|
+
system: packageName,
|
|
190
|
+
root,
|
|
191
|
+
reachableFromRepoAlone: false,
|
|
192
|
+
source: 'node_modules',
|
|
193
|
+
sampled: components.map(({ name }) => ({ component: name, reachable: false, reason: 'no .d.ts types entry found for the package' })),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const program = ts.createProgram([typesEntryFile], {
|
|
197
|
+
allowJs: true,
|
|
198
|
+
jsx: ts.JsxEmit.React,
|
|
199
|
+
esModuleInterop: true,
|
|
200
|
+
skipLibCheck: true,
|
|
201
|
+
noEmit: true,
|
|
202
|
+
});
|
|
203
|
+
const checker = program.getTypeChecker();
|
|
204
|
+
const sourceFile = program.getSourceFile(typesEntryFile);
|
|
205
|
+
const moduleSymbol = sourceFile ? checker.getSymbolAtLocation(sourceFile) : null;
|
|
206
|
+
if (!moduleSymbol) {
|
|
207
|
+
return {
|
|
208
|
+
system: packageName,
|
|
209
|
+
root,
|
|
210
|
+
reachableFromRepoAlone: false,
|
|
211
|
+
source: 'node_modules',
|
|
212
|
+
sampled: components.map(({ name }) => ({ component: name, reachable: false, reason: 'types entry file could not be resolved as a module' })),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
system: packageName,
|
|
217
|
+
root,
|
|
218
|
+
reachableFromRepoAlone: true,
|
|
219
|
+
source: 'node_modules',
|
|
220
|
+
sampled: sampleFromProgram(checker, moduleSymbol, components),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!allowNetwork) {
|
|
225
|
+
return {
|
|
226
|
+
system: packageName,
|
|
227
|
+
root,
|
|
228
|
+
reachableFromRepoAlone: false,
|
|
229
|
+
source: null,
|
|
230
|
+
sampled: components.map(({ name }) => ({ component: name, reachable: false, reason: `"${packageName}" is not installed and --allow-network was not passed` })),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const cdn = await fetchFromCdn(packageName);
|
|
235
|
+
if (!cdn || !cdn.typesText) {
|
|
236
|
+
return {
|
|
237
|
+
system: packageName,
|
|
238
|
+
root,
|
|
239
|
+
reachableFromRepoAlone: false,
|
|
240
|
+
source: 'cdn',
|
|
241
|
+
sampled: components.map(({ name }) => ({ component: name, reachable: false, reason: 'CDN fallback could not fetch a usable .d.ts' })),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Compiling a standalone SourceFile (no real filesystem module graph) is
|
|
246
|
+
// enough to resolve locally-declared shapes but can't follow imports the
|
|
247
|
+
// .d.ts itself makes to other files (e.g. a shared "CommonProps" type in
|
|
248
|
+
// a sibling declaration file) — a known, narrower-than-node_modules
|
|
249
|
+
// limitation of the CDN path, reported per-component via "reachable" the
|
|
250
|
+
// same way an unresolved shadcn generic is, never silently guessed.
|
|
251
|
+
const virtualHost = ts.createCompilerHost({ allowJs: true, skipLibCheck: true, noEmit: true });
|
|
252
|
+
const originalGetSourceFile = virtualHost.getSourceFile.bind(virtualHost);
|
|
253
|
+
const VIRTUAL_NAME = 'cdn-types.d.ts';
|
|
254
|
+
virtualHost.getSourceFile = (fileName, languageVersion) => {
|
|
255
|
+
if (fileName === VIRTUAL_NAME) return ts.createSourceFile(fileName, cdn.typesText, languageVersion, true);
|
|
256
|
+
return originalGetSourceFile(fileName, languageVersion);
|
|
257
|
+
};
|
|
258
|
+
const program = ts.createProgram([VIRTUAL_NAME], { allowJs: true, skipLibCheck: true, noEmit: true }, virtualHost);
|
|
259
|
+
const checker = program.getTypeChecker();
|
|
260
|
+
const sourceFile = program.getSourceFile(VIRTUAL_NAME);
|
|
261
|
+
const moduleSymbol = sourceFile ? checker.getSymbolAtLocation(sourceFile) : null;
|
|
262
|
+
if (!moduleSymbol) {
|
|
263
|
+
return {
|
|
264
|
+
system: packageName,
|
|
265
|
+
root,
|
|
266
|
+
reachableFromRepoAlone: false,
|
|
267
|
+
source: 'cdn',
|
|
268
|
+
sampled: components.map(({ name }) => ({ component: name, reachable: false, reason: 'fetched .d.ts could not be resolved as a module' })),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
system: packageName,
|
|
274
|
+
root,
|
|
275
|
+
reachableFromRepoAlone: false,
|
|
276
|
+
source: 'cdn',
|
|
277
|
+
sampled: sampleFromProgram(checker, moduleSymbol, components),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { buildModuleGraph, resolveOrigin } from './moduleGraph.js';
|
|
4
|
+
import { nameFromPackageSpecifier } from './installedPackageDiscoveryResolver.js';
|
|
5
|
+
import { resolveOpportunities } from './opportunityResolver.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Coverage — which of the discovered components are actually imported
|
|
9
|
+
* anywhere in the target repo. Simpler than foreignScoringResolver.js's
|
|
10
|
+
* shadcn case (no tsconfig-alias resolution needed): an installed package's
|
|
11
|
+
* components are reached via resolveOrigin()'s
|
|
12
|
+
* `!source.startsWith('.') && !source.startsWith('/')` terminal, which
|
|
13
|
+
* returns `{ pkg, name }` — the same pattern referenceResolver.js uses for
|
|
14
|
+
* Stark's own catalog package, applied here with `packageName` in place of
|
|
15
|
+
* packageNameForPlatform()'s hardcoded Stark package name.
|
|
16
|
+
*
|
|
17
|
+
* Two import shapes both count as usage, confirmed by live-verifying
|
|
18
|
+
* against a real installed @mui/material (ADOPTION_APP_PLAN.md §10 decision
|
|
19
|
+
* #25): a bare specifier (`import { Button } from '@mui/material'`, where
|
|
20
|
+
* `pkg === packageName` and `name` is the component directly) and MUI's own
|
|
21
|
+
* common per-component subpath import (`import Button from
|
|
22
|
+
* '@mui/material/Button'`, where `pkg === '@mui/material/Button'` and a
|
|
23
|
+
* default import's `name` is always the literal `'default'`, never the real
|
|
24
|
+
* component name). The first live run against a real MUI consumer fixture
|
|
25
|
+
* reported 0/149 coverage for a file that plainly imported Button and
|
|
26
|
+
* TextField — this subpath case is why, and componentNameFromOrigin()
|
|
27
|
+
* below is the fix: for a `${packageName}/X` subpath it prefers a real
|
|
28
|
+
* named import (`name !== 'default'`) and otherwise falls back to the
|
|
29
|
+
* subpath's last segment, which is the component name for this import
|
|
30
|
+
* style by convention.
|
|
31
|
+
*
|
|
32
|
+
* A third shape hits the exact same `name === 'default'` problem on the
|
|
33
|
+
* bare-specifier branch itself: a single-component package whose whole
|
|
34
|
+
* default export IS the component (`import Button from '@atlaskit/button'`,
|
|
35
|
+
* where `pkg === packageName` and `name` is again the literal `'default'`).
|
|
36
|
+
* Confirmed live: this reported 0/3 coverage for a fixture that plainly
|
|
37
|
+
* imported and used Button. nameFromPackageSpecifier() — the same fallback
|
|
38
|
+
* installedPackageDiscoveryResolver.js's enumerateComponents() applies when
|
|
39
|
+
* it meets a "default"-named export — resolves both sides to the same name.
|
|
40
|
+
*/
|
|
41
|
+
function componentNameFromOrigin(packageName, origin) {
|
|
42
|
+
if (!origin) return null;
|
|
43
|
+
if (origin.pkg === packageName) {
|
|
44
|
+
return origin.name === 'default' ? nameFromPackageSpecifier(packageName) : origin.name;
|
|
45
|
+
}
|
|
46
|
+
if (origin.pkg.startsWith(`${packageName}/`)) {
|
|
47
|
+
if (origin.name && origin.name !== 'default') return origin.name;
|
|
48
|
+
const subpath = origin.pkg.slice(packageName.length + 1);
|
|
49
|
+
return subpath.split('/').pop() || null;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scoreCoverage(origins, packageName, components) {
|
|
55
|
+
const known = new Set(components.map((c) => c.name));
|
|
56
|
+
const usageCounts = new Map(components.map((c) => [c.name, 0]));
|
|
57
|
+
|
|
58
|
+
for (const origin of origins) {
|
|
59
|
+
const componentName = componentNameFromOrigin(packageName, origin);
|
|
60
|
+
if (componentName && known.has(componentName)) {
|
|
61
|
+
usageCounts.set(componentName, usageCounts.get(componentName) + 1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const perComponent = components.map((c) => ({
|
|
66
|
+
component: c.name,
|
|
67
|
+
usageCount: usageCounts.get(c.name) ?? 0,
|
|
68
|
+
}));
|
|
69
|
+
const used = perComponent.filter((c) => c.usageCount > 0).length;
|
|
70
|
+
const available = perComponent.length;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
used,
|
|
74
|
+
available,
|
|
75
|
+
pct: available > 0 ? Math.round((used / available) * 1000) / 10 : null,
|
|
76
|
+
perComponent,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Duplication — reuses resolveOpportunities() for both platforms rather
|
|
82
|
+
* than hand-rolling a second matcher, the same "reuse, don't reimplement"
|
|
83
|
+
* discipline foreignScoringResolver.js's findDuplicates() already
|
|
84
|
+
* established for shadcn. Unlike shadcn (whose own components live inside
|
|
85
|
+
* a known local componentDir that findings must be excluded from), an
|
|
86
|
+
* installed package's components live in node_modules — already outside
|
|
87
|
+
* DEFAULT_IGNORE-scanned repo source — so there's no local-directory
|
|
88
|
+
* exclusion filter needed here.
|
|
89
|
+
*/
|
|
90
|
+
function findDuplicates(root, ignore, platform) {
|
|
91
|
+
const { opportunities } = resolveOpportunities(root, { platform, ignore });
|
|
92
|
+
return opportunities;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Everything a score needs that does *not* depend on which package is being
|
|
97
|
+
* scored: every import origin the repo resolves to a package, and the repo's
|
|
98
|
+
* hand-rolled duplicates. Both are whole-repo sweeps whose cost is unrelated
|
|
99
|
+
* to the package name, so a multi-package system (installedSystemScan.js)
|
|
100
|
+
* builds this once and scores each of its packages against it rather than
|
|
101
|
+
* re-walking the module graph forty times and reporting the same duplicate
|
|
102
|
+
* set forty times over.
|
|
103
|
+
*
|
|
104
|
+
* A context is only valid for the `platform`/`ignore` it was built with —
|
|
105
|
+
* origins resolve differently per platform (moduleGraph.js's .native/.ios
|
|
106
|
+
* preference), so reusing one across platforms would silently score against
|
|
107
|
+
* the wrong resolution.
|
|
108
|
+
*/
|
|
109
|
+
export function buildInstalledScanContext(root, { platform = 'web', ignore = [] } = {}) {
|
|
110
|
+
const moduleGraph = buildModuleGraph(root, { ignore });
|
|
111
|
+
const origins = [];
|
|
112
|
+
|
|
113
|
+
for (const file of moduleGraph.files) {
|
|
114
|
+
const entry = moduleGraph.graph.get(file);
|
|
115
|
+
if (!entry || entry.parseError) continue;
|
|
116
|
+
for (const localName of entry.imports.keys()) {
|
|
117
|
+
const origin = resolveOrigin(moduleGraph, file, localName, { platform });
|
|
118
|
+
// resolveOrigin returns `{ cycle: [...] }` — no `pkg` — when the import
|
|
119
|
+
// chain is circular, which componentNameFromOrigin()'s
|
|
120
|
+
// `origin.pkg.startsWith(...)` would throw on. Filtering to real
|
|
121
|
+
// package origins here is what keeps one circular import in the
|
|
122
|
+
// scanned repo from taking down the whole scan.
|
|
123
|
+
if (origin && typeof origin.pkg === 'string') origins.push(origin);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { platform, origins, duplicates: findDuplicates(root, ignore, platform) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Layer 3 of Phase 2 ("Version B" installed-package adapter,
|
|
132
|
+
* ADOPTION_APP_PLAN.md §10 decision #25) — same structural-only, no-
|
|
133
|
+
* opinionated-rules discipline as foreignScoringResolver.js: catalog
|
|
134
|
+
* coverage and hand-rolled duplication, no CI-gate severity (Stark has no
|
|
135
|
+
* house opinion about a design system it doesn't own).
|
|
136
|
+
*
|
|
137
|
+
* Pass a `context` from buildInstalledScanContext() to score several
|
|
138
|
+
* packages of one system without repeating the repo-wide work; omit it and
|
|
139
|
+
* one is built for this call alone (the single-package path, unchanged).
|
|
140
|
+
*/
|
|
141
|
+
export function scoreInstalledPackageAdoption(root, packageName, discovery, { platform = 'web', ignore = [], context = null } = {}) {
|
|
142
|
+
const scanContext = context ?? buildInstalledScanContext(root, { platform, ignore });
|
|
143
|
+
const coverage = scoreCoverage(scanContext.origins, packageName, discovery.components);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
system: packageName,
|
|
147
|
+
root,
|
|
148
|
+
platform,
|
|
149
|
+
coverage: { used: coverage.used, available: coverage.available, pct: coverage.pct },
|
|
150
|
+
coverageDetail: coverage.perComponent,
|
|
151
|
+
duplicates: scanContext.duplicates,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { detectPackage } from './foreignDiscoveryResolver.js';
|
|
2
|
+
import { FOREIGN_SYSTEMS, systemPackages } from './foreignSystemConfig.js';
|
|
3
|
+
|
|
4
|
+
function exactDepPattern(npmPackage) {
|
|
5
|
+
return new RegExp(`^${npmPackage.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Answers "which registered foreign system does this repo already depend
|
|
10
|
+
* on" so `scan-foreign auto` can skip asking — reuses
|
|
11
|
+
* foreignDiscoveryResolver.js's detectPackage() (the same monorepo-aware
|
|
12
|
+
* package.json sweep shadcn's depFallback signal already relies on) for
|
|
13
|
+
* every FOREIGN_SYSTEMS entry, not just shadcn's own. For an
|
|
14
|
+
* 'installed-package' system there's no configFiles signal (that's a
|
|
15
|
+
* copy-paste-only concept), just an exact-name dependency match — a
|
|
16
|
+
* prefix/regex match here would misfire on scoped near-neighbors (e.g.
|
|
17
|
+
* "@mantine/core" matching "@mantine/hooks").
|
|
18
|
+
*
|
|
19
|
+
* A multi-package system matches on *any* one of its packages, since a repo
|
|
20
|
+
* adopting Atlaskit installs the handful of `@atlaskit/*` siblings it
|
|
21
|
+
* actually uses rather than all twenty-five the entry lists — requiring all
|
|
22
|
+
* of them would make the common case undetectable. The reported `npmPackages` is
|
|
23
|
+
* still the system's full declared set, not the subset that matched; what
|
|
24
|
+
* the repo actually has is in `evidence.depEvidence`.
|
|
25
|
+
*
|
|
26
|
+
* A match here only decides which system id to hand to the real discovery
|
|
27
|
+
* resolver afterward — it is not itself a component count or a "this will
|
|
28
|
+
* work" guarantee.
|
|
29
|
+
*/
|
|
30
|
+
export function detectForeignSystems(root, { ignore = [] } = {}) {
|
|
31
|
+
const matches = [];
|
|
32
|
+
for (const system of Object.values(FOREIGN_SYSTEMS)) {
|
|
33
|
+
const packages = systemPackages(system);
|
|
34
|
+
const detectSpec =
|
|
35
|
+
system.distribution === 'copy-paste'
|
|
36
|
+
? system.packageDetect
|
|
37
|
+
: { configFiles: [], depFallback: packages.map(exactDepPattern) };
|
|
38
|
+
const result = detectPackage(root, ignore, { packageDetect: detectSpec });
|
|
39
|
+
if (result.detected) {
|
|
40
|
+
matches.push({
|
|
41
|
+
id: system.id,
|
|
42
|
+
label: system.label,
|
|
43
|
+
distribution: system.distribution,
|
|
44
|
+
npmPackage: packages[0] ?? null,
|
|
45
|
+
npmPackages: packages,
|
|
46
|
+
evidence: result,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return matches;
|
|
51
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { resolveInstalledPackageDiscovery } from './installedPackageDiscoveryResolver.js';
|
|
2
|
+
import { resolvePropSchema } from './installedPackagePropSchemaResolver.js';
|
|
3
|
+
import { buildInstalledScanContext, scoreInstalledPackageAdoption } from './installedPackageScoringResolver.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runs the installed-package pipeline (discovery → prop schema → scoring)
|
|
7
|
+
* over the one-or-many npm packages that make up a foreign design system,
|
|
8
|
+
* and is the only place that knows a system can be more than one package.
|
|
9
|
+
*
|
|
10
|
+
* Why it exists: until 2026-08-27 `scan-foreign` took exactly one package
|
|
11
|
+
* name, which made a system with no umbrella barrel unregisterable rather
|
|
12
|
+
* than merely untested. That was not hypothetical — the registry already
|
|
13
|
+
* held such a system, mis-registered: Atlaskit ships ~100 sibling
|
|
14
|
+
* `@atlaskit/*` packages with no barrel over them, and its entry named
|
|
15
|
+
* `@atlaskit/button` alone, so it reported a four-component catalog as the
|
|
16
|
+
* whole of Atlassian's design system. Live-verified against a real
|
|
17
|
+
* four-package install: 1/4 (25%) under the old form, 3/13 (23.1%) under
|
|
18
|
+
* this one. The three resolvers stay single-package by design (a package's
|
|
19
|
+
* `.d.ts` entry is the unit of discovery); the fan-out belongs here.
|
|
20
|
+
*
|
|
21
|
+
* Two output shapes, deliberately:
|
|
22
|
+
*
|
|
23
|
+
* - **one package** → `{ discovery, propSchema, scoring }`, byte-identical
|
|
24
|
+
* to what this command has always printed. Nineteen of the twenty
|
|
25
|
+
* registered systems and every raw package name take this path, so it is
|
|
26
|
+
* not worth breaking to make the rare shape uniform.
|
|
27
|
+
* - **many packages** → an aggregate carrying `multiPackage: true`, a
|
|
28
|
+
* `packages[]` entry per package, and `totals`. `root`, `platform` and
|
|
29
|
+
* `duplicates` are hoisted out of the per-package scores because they are
|
|
30
|
+
* properties of the *repo*, not of any one package — repeating a
|
|
31
|
+
* duplicate finding once per package would inflate the same handful of
|
|
32
|
+
* findings forty-fold.
|
|
33
|
+
*
|
|
34
|
+
* `totals.coverage.pct` is recomputed from the summed used/available, never
|
|
35
|
+
* averaged across packages: averaging percentages over catalogs of wildly
|
|
36
|
+
* different sizes (a 2-component package and a 90-component one) weights
|
|
37
|
+
* them equally and reports a number no package actually has.
|
|
38
|
+
*/
|
|
39
|
+
export async function scanInstalledSystem(root, packages, {
|
|
40
|
+
platform = 'web',
|
|
41
|
+
ignore = [],
|
|
42
|
+
allowNetwork = false,
|
|
43
|
+
system = null,
|
|
44
|
+
label = null,
|
|
45
|
+
} = {}) {
|
|
46
|
+
const unique = [...new Set(packages)];
|
|
47
|
+
if (unique.length === 0) {
|
|
48
|
+
throw new Error('scanInstalledSystem needs at least one npm package name.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Built once and threaded through every package's score — the module graph
|
|
52
|
+
// walk and the duplicate sweep are whole-repo work that does not vary by
|
|
53
|
+
// package name. For a single package this is exactly the work
|
|
54
|
+
// scoreInstalledPackageAdoption would have done internally.
|
|
55
|
+
const context = buildInstalledScanContext(root, { platform, ignore });
|
|
56
|
+
|
|
57
|
+
const scanned = [];
|
|
58
|
+
for (const packageName of unique) {
|
|
59
|
+
const discovery = await resolveInstalledPackageDiscovery(root, packageName, { platform, allowNetwork });
|
|
60
|
+
const propSchema = await resolvePropSchema(root, packageName, discovery.components, { allowNetwork });
|
|
61
|
+
const scoring = scoreInstalledPackageAdoption(root, packageName, discovery, { platform, ignore, context });
|
|
62
|
+
scanned.push({ packageName, discovery, propSchema, scoring });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (scanned.length === 1) {
|
|
66
|
+
const { discovery, propSchema, scoring } = scanned[0];
|
|
67
|
+
return { discovery, propSchema, scoring };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const used = scanned.reduce((sum, s) => sum + s.scoring.coverage.used, 0);
|
|
71
|
+
const available = scanned.reduce((sum, s) => sum + s.scoring.coverage.available, 0);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
system: system ?? unique.join(','),
|
|
75
|
+
label,
|
|
76
|
+
root,
|
|
77
|
+
platform,
|
|
78
|
+
multiPackage: true,
|
|
79
|
+
packages: scanned.map(({ packageName, discovery, propSchema, scoring }) => ({
|
|
80
|
+
package: packageName,
|
|
81
|
+
discovery,
|
|
82
|
+
propSchema,
|
|
83
|
+
coverage: scoring.coverage,
|
|
84
|
+
coverageDetail: scoring.coverageDetail,
|
|
85
|
+
})),
|
|
86
|
+
totals: {
|
|
87
|
+
packages: scanned.length,
|
|
88
|
+
installed: scanned.filter((s) => s.discovery.installed).length,
|
|
89
|
+
components: scanned.reduce((sum, s) => sum + s.discovery.components.length, 0),
|
|
90
|
+
unreachableComponents: scanned.reduce((sum, s) => sum + s.discovery.unreachableComponents.length, 0),
|
|
91
|
+
coverage: { used, available, pct: available > 0 ? Math.round((used / available) * 1000) / 10 : null },
|
|
92
|
+
},
|
|
93
|
+
// Surfaced at the top level too, not just buried per package: for a
|
|
94
|
+
// forty-package system the one thing a reader needs up front is which
|
|
95
|
+
// packages the repo does not actually have installed.
|
|
96
|
+
unresolved: scanned
|
|
97
|
+
.filter((s) => s.discovery.unresolvedReason)
|
|
98
|
+
.map((s) => ({ package: s.packageName, reason: s.discovery.unresolvedReason })),
|
|
99
|
+
duplicates: context.duplicates,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { resolveJsxLiteral } from './usageRulesResolver.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared low-level JSX node helpers for opportunityResolver.js's web
|
|
5
|
+
* signatures and opportunitySignaturesNative.js's RN signatures. Split out
|
|
6
|
+
* to avoid a circular import between the two signature-table modules (the
|
|
7
|
+
* web table lives in opportunityResolver.js alongside resolveOpportunities()
|
|
8
|
+
* itself; the native table is a sibling file per ADOPTION_APP_PLAN.md §10
|
|
9
|
+
* decision #25's Phase 2 plan) — both import from here instead of from each
|
|
10
|
+
* other.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function getAttr(openingElement, name) {
|
|
14
|
+
return openingElement.attributes.find((a) => a.type === 'JSXAttribute' && a.name.name === name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True for a lowercase DOM tag (`<button>`), never a component (`<Pressable>`). */
|
|
18
|
+
export function domTagName(nameNode) {
|
|
19
|
+
return nameNode.type === 'JSXIdentifier' && /^[a-z]/.test(nameNode.name) ? nameNode.name : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True for a PascalCase component reference (`<Pressable>`, `<TouchableOpacity>`), the RN counterpart of domTagName. */
|
|
23
|
+
export function componentTagName(nameNode) {
|
|
24
|
+
return nameNode.type === 'JSXIdentifier' && /^[A-Z]/.test(nameNode.name) ? nameNode.name : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function classNameMatches(openingElement, regex) {
|
|
28
|
+
const attr = getAttr(openingElement, 'className');
|
|
29
|
+
if (!attr) return false;
|
|
30
|
+
const r = resolveJsxLiteral(attr.value);
|
|
31
|
+
return r.resolved && typeof r.value === 'string' && regex.test(r.value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when the element was given *some* real, static class name — see
|
|
36
|
+
* opportunityResolver.js's matchButton for why a keyword regex
|
|
37
|
+
* (/btn|button/) is the wrong bar: real codebases name their button classes
|
|
38
|
+
* anything.
|
|
39
|
+
*/
|
|
40
|
+
export function hasStaticClassNameContent(openingElement) {
|
|
41
|
+
const attr = getAttr(openingElement, 'className');
|
|
42
|
+
if (!attr) return false;
|
|
43
|
+
const literal = resolveJsxLiteral(attr.value);
|
|
44
|
+
if (literal.resolved && typeof literal.value === 'string' && literal.value.trim().length > 0) return true;
|
|
45
|
+
if (attr.value?.type === 'JSXExpressionContainer' && attr.value.expression.type === 'TemplateLiteral') {
|
|
46
|
+
return attr.value.expression.quasis.some((q) => q.value.raw.trim().length > 0);
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads the *keys* of a `style={{...}}` object literal, never the values —
|
|
53
|
+
* a dynamic value is still a real signal the element carries
|
|
54
|
+
* background/border/padding styling even when resolveJsxLiteral can't
|
|
55
|
+
* resolve the object as a whole.
|
|
56
|
+
*/
|
|
57
|
+
export function styleTouchesKeys(openingElement, keyRegex) {
|
|
58
|
+
const attr = getAttr(openingElement, 'style');
|
|
59
|
+
if (!attr || attr.value?.type !== 'JSXExpressionContainer') return false;
|
|
60
|
+
const expr = attr.value.expression;
|
|
61
|
+
if (expr.type !== 'ObjectExpression') return false;
|
|
62
|
+
return expr.properties.some((p) => {
|
|
63
|
+
if (p.type !== 'ObjectProperty' || p.computed) return false;
|
|
64
|
+
const key = p.key.type === 'Identifier' ? p.key.name : p.key.type === 'StringLiteral' ? p.key.value : null;
|
|
65
|
+
return key !== null && keyRegex.test(key);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when a `style={...}` attribute references a StyleSheet-created
|
|
71
|
+
* styles object — `styles.button` (MemberExpression) or
|
|
72
|
+
* `[styles.button, active && styles.active]` (ArrayExpression of the same)
|
|
73
|
+
* — RN's equivalent of a static className, since RN has no CSS classes at
|
|
74
|
+
* all. Doesn't attempt to resolve the styles object's own declaration back
|
|
75
|
+
* to a StyleSheet.create() call (that's a cross-scope lookup this
|
|
76
|
+
* per-element matcher doesn't have); referencing *some* `styles.*` member is
|
|
77
|
+
* itself the signal, the same "don't guess further" discipline
|
|
78
|
+
* hasStaticClassNameContent applies to a template literal's static quasis.
|
|
79
|
+
*/
|
|
80
|
+
export function styleReferencesStylesheetMember(openingElement) {
|
|
81
|
+
const attr = getAttr(openingElement, 'style');
|
|
82
|
+
if (!attr || attr.value?.type !== 'JSXExpressionContainer') return false;
|
|
83
|
+
const expr = attr.value.expression;
|
|
84
|
+
const isStylesMember = (n) =>
|
|
85
|
+
n.type === 'MemberExpression' && n.object.type === 'Identifier' && /styles?$/i.test(n.object.name);
|
|
86
|
+
if (isStylesMember(expr)) return true;
|
|
87
|
+
if (expr.type === 'ArrayExpression') {
|
|
88
|
+
return expr.elements.some((el) => el && (isStylesMember(el) || (el.type === 'LogicalExpression' && isStylesMember(el.right))));
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function significantJsxChildren(children) {
|
|
94
|
+
return (children || []).filter((c) => {
|
|
95
|
+
if (c.type === 'JSXText') return c.value.trim().length > 0;
|
|
96
|
+
if (c.type === 'JSXExpressionContainer') return c.expression.type !== 'JSXEmptyExpression';
|
|
97
|
+
return c.type === 'JSXElement' || c.type === 'JSXFragment';
|
|
98
|
+
});
|
|
99
|
+
}
|