@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,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
+ }