@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.
Files changed (41) 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/server.js +38 -14
  23. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
  24. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
  25. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
  26. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
  27. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
  28. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
  29. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
  30. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
  31. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
  32. package/src/adopt/dominionFixture.test.js +0 -165
  33. package/src/adopt/propApiResolver.test.js +0 -229
  34. package/src/adopt/referenceResolver.test.js +0 -213
  35. package/src/adopt/rnTailwindResolver.test.js +0 -263
  36. package/src/adopt/rnTokenAliasResolver.test.js +0 -260
  37. package/src/adopt/tailwindResolver.test.js +0 -178
  38. package/src/adopt/targetDiscovery.test.js +0 -227
  39. package/src/adopt/tokenAliasResolver.test.js +0 -319
  40. package/src/adopt/wrapperResolver.test.js +0 -324
  41. package/src/data.test.js +0 -231
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starklab/stark-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "MCP server exposing the Stark design system catalog, usage rules, prop mappings, and layout conformance checks to any MCP-compatible coding agent — without needing the stark-workspace monorepo checked out.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,22 +10,28 @@
10
10
  },
11
11
  "main": "./src/server.js",
12
12
  "files": [
13
- "src/"
13
+ "src/",
14
+ "!src/**/*.test.js",
15
+ "!src/adopt/__fixtures__"
14
16
  ],
15
17
  "publishConfig": {
16
18
  "access": "public"
17
19
  },
18
20
  "scripts": {
19
21
  "start": "node src/index.js",
20
- "generate-manifest": "node scripts/generate-manifest.js"
22
+ "generate-manifest": "node scripts/generate-manifest.js",
23
+ "generate-foreign-systems": "node scripts/generate-foreign-systems.js",
24
+ "validate:pack": "node ../../scripts/validate-pack.mjs",
25
+ "prepublishOnly": "npm run validate:pack"
21
26
  },
22
27
  "dependencies": {
23
- "@starklab/stk": "^1.1.0",
28
+ "@starklab/stk": "^1.2.0",
24
29
  "@babel/parser": "^7.29.7",
25
30
  "@babel/traverse": "^7.29.7",
26
31
  "@modelcontextprotocol/sdk": "^1.29.0",
27
32
  "fast-glob": "^3.3.3",
28
33
  "postcss": "^8.5.25",
34
+ "typescript": "^6.0.0",
29
35
  "zod": "^4.0.0"
30
36
  }
31
37
  }
@@ -0,0 +1,124 @@
1
+ import { scrubForeignScanPaths } from './foreignScanReport.js';
2
+
3
+ /**
4
+ * The reporting half of `stark-cli adopt --report`: turns a `runAdopt` result
5
+ * into the payload Dominion's POST /api/adoption-scan accepts, and sends it.
6
+ *
7
+ * This is the sibling of foreignScanReport.js and inherits both of its
8
+ * governing constraints verbatim — read that file's header for the full
9
+ * reasoning:
10
+ *
11
+ * 1. **It must never break the consumer's CI.** Every failure path returns
12
+ * `{ reported: false, reason }`; nothing throws, and the caller never
13
+ * touches `process.exitCode`.
14
+ * 2. **It must not leak the customer's filesystem layout.** `runAdopt`'s
15
+ * output embeds the absolute scan root at the top level and again inside
16
+ * every resolver's sub-result, so the same scrub applies. It is imported
17
+ * rather than reimplemented: `scrubForeignScanPaths` is already generic
18
+ * over any value, and a second copy would be a second thing to keep
19
+ * correct.
20
+ */
21
+
22
+ /**
23
+ * Per-site enumerations dropped before sending, at any depth.
24
+ *
25
+ * Same reduction rationale as foreignScanReport.js's `stripPropSchema`: the
26
+ * ingest reads counts and the three-number `report` summaries, never the
27
+ * enumeration behind them, and the enumeration is re-derivable by re-scanning.
28
+ * Each of these is one entry per source location, so on a real repo they are
29
+ * most of the document:
30
+ *
31
+ * sites — referenceResolver: one per component reference
32
+ * usages — tokenAlias/tailwind (web + RN): one per token usage site
33
+ * properties — tokenAlias/tailwind (web + RN): one per consumer-declared
34
+ * custom property / theme entry
35
+ * checks — propApiResolver: one per validated JSX attribute
36
+ *
37
+ * `findings` is deliberately NOT in this list. A finding is a defect the
38
+ * tracker exists to surface, and the counts alone can't reconstruct one.
39
+ *
40
+ * stdout is untouched either way — `--report` changes what is sent, never what
41
+ * the command prints.
42
+ */
43
+ const DROPPED_KEYS = new Set(['sites', 'usages', 'properties', 'checks']);
44
+
45
+ export function stripAdoptDetail(value) {
46
+ if (Array.isArray(value)) return value.map(stripAdoptDetail);
47
+ if (value && typeof value === 'object') {
48
+ const out = {};
49
+ for (const [key, child] of Object.entries(value)) {
50
+ if (DROPPED_KEYS.has(key)) continue;
51
+ out[key] = stripAdoptDetail(child);
52
+ }
53
+ return out;
54
+ }
55
+ return value;
56
+ }
57
+
58
+ /** Builds the request body POST /api/adoption-scan expects. */
59
+ export function buildAdoptScanReport(result, { root, targetDir, commitSha, scannerVersion }) {
60
+ return {
61
+ targetDir,
62
+ commitSha,
63
+ scannerVersion,
64
+ result: scrubForeignScanPaths(stripAdoptDetail(result), root),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * POSTs the adoption scan and resolves to an outcome record. Never throws,
70
+ * never rejects — see property (1) in this file's header.
71
+ *
72
+ * `fetchImpl` exists for tests; production always uses global fetch.
73
+ */
74
+ export async function reportAdoptScan(result, {
75
+ url,
76
+ token,
77
+ root,
78
+ targetDir,
79
+ commitSha,
80
+ scannerVersion,
81
+ timeoutMs = 15000,
82
+ fetchImpl = globalThis.fetch,
83
+ } = {}) {
84
+ const missing = Object.entries({ url, token, targetDir, commitSha, scannerVersion })
85
+ .filter(([, v]) => !v)
86
+ .map(([k]) => k);
87
+ if (missing.length > 0) {
88
+ return { reported: false, reason: `Missing required reporting field(s): ${missing.join(', ')}.` };
89
+ }
90
+
91
+ const body = buildAdoptScanReport(result, { root, targetDir, commitSha, scannerVersion });
92
+
93
+ let response;
94
+ try {
95
+ response = await fetchImpl(url, {
96
+ method: 'POST',
97
+ headers: {
98
+ 'content-type': 'application/json',
99
+ authorization: `Bearer ${token}`,
100
+ },
101
+ body: JSON.stringify(body),
102
+ signal: AbortSignal.timeout(timeoutMs),
103
+ });
104
+ } catch (err) {
105
+ // Network down, DNS failure, TLS error, timeout. The scan itself already
106
+ // succeeded and has already been printed.
107
+ return { reported: false, reason: `Could not reach ${url}: ${err.message}` };
108
+ }
109
+
110
+ const text = await response.text().catch(() => '');
111
+ let parsed = null;
112
+ try {
113
+ parsed = text ? JSON.parse(text) : null;
114
+ } catch {
115
+ // A proxy or error page rather than the API. Surfaced as-is below.
116
+ }
117
+
118
+ if (!response.ok) {
119
+ const detail = parsed?.error ?? (text ? text.slice(0, 200) : '(empty response)');
120
+ return { reported: false, status: response.status, reason: `${url} returned ${response.status}: ${detail}` };
121
+ }
122
+
123
+ return { reported: true, status: response.status, response: parsed };
124
+ }
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
4
  import { stkRoot } from '../data.js';
@@ -30,7 +30,11 @@ function readBarrel(file) {
30
30
  const m = bare.match(/^export\s*\{([^}]+)\}\s*from\s*['"][^'"]+['"];?$/);
31
31
  if (!m) continue;
32
32
  for (const part of m[1].split(',')) {
33
- const name = part.trim().split(/\s+as\s+/).pop().trim();
33
+ const name = part
34
+ .trim()
35
+ .split(/\s+as\s+/)
36
+ .pop()
37
+ .trim();
34
38
  if (name) names.push(name);
35
39
  }
36
40
  }
@@ -54,7 +58,9 @@ const toSlug = (s) =>
54
58
  export function loadCatalog(platform = 'web') {
55
59
  const catalogKey = CATALOG_KEY[platform];
56
60
  if (!catalogKey) {
57
- throw new Error(`Unsupported platform "${platform}". Available: ${Object.keys(CATALOG_KEY).join(', ')}.`);
61
+ throw new Error(
62
+ `Unsupported platform "${platform}". Available: ${Object.keys(CATALOG_KEY).join(', ')}.`
63
+ );
58
64
  }
59
65
 
60
66
  const root = stkRoot();
@@ -64,10 +70,22 @@ export function loadCatalog(platform = 'web') {
64
70
  throw new Error(`catalog.json has no "${catalogKey}" platform entry.`);
65
71
  }
66
72
 
67
- const barrelPath = path.resolve(root, cfg.barrel);
68
- const exported = readBarrel(barrelPath);
69
73
  const classified = cfg.exports ?? {};
70
74
 
75
+ // In the monorepo the barrel is read directly, so drift between it and
76
+ // catalog.json surfaces here as well as in reconcile-catalog.js. In a
77
+ // published install it is absent: cfg.barrel points at a sibling package
78
+ // (@starklab/stk-components / -react-native) that stark-mcp deliberately
79
+ // does not depend on — those ship React, floating-ui and the whole UI
80
+ // graph, which an MCP server has no business pulling in to read a list of
81
+ // names. Fall back to catalog.json's own classified export list, which
82
+ // reconcile-catalog.js already gates against the barrel in CI. Same set
83
+ // either way (verified in catalog.test.js); this is a resolution
84
+ // fallback, not a second source of truth. Order differs between the two
85
+ // paths and does not matter — referenceResolver sorts by name.
86
+ const barrelPath = path.resolve(root, cfg.barrel);
87
+ const exported = existsSync(barrelPath) ? readBarrel(barrelPath) : Object.keys(classified);
88
+
71
89
  const components = exported
72
90
  .filter((name) => classified[name]?.kind === 'component')
73
91
  .map((name) => ({ name, slug: toSlug(name) }));
@@ -82,7 +100,9 @@ export function loadCatalog(platform = 'web') {
82
100
  export function packageNameForPlatform(platform) {
83
101
  const name = PACKAGE_NAME[platform];
84
102
  if (!name) {
85
- throw new Error(`Unsupported platform "${platform}". Available: ${Object.keys(PACKAGE_NAME).join(', ')}.`);
103
+ throw new Error(
104
+ `Unsupported platform "${platform}". Available: ${Object.keys(PACKAGE_NAME).join(', ')}.`
105
+ );
86
106
  }
87
107
  return name;
88
108
  }
@@ -0,0 +1,276 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import fg from 'fast-glob';
5
+ import postcss from 'postcss';
6
+
7
+ import { getForeignSystem } from './foreignSystemConfig.js';
8
+ import { parseSource } from './parseSource.js';
9
+
10
+ const DEFAULT_IGNORE = [
11
+ '**/node_modules/**',
12
+ '**/dist/**',
13
+ '**/build/**',
14
+ '**/.next/**',
15
+ '**/coverage/**',
16
+ '**/storybook-static/**',
17
+ '**/.storybook*/**',
18
+ ];
19
+
20
+ function readJson(file) {
21
+ try {
22
+ return JSON.parse(readFileSync(file, 'utf-8'));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function depNames(pkg) {
29
+ return new Set([
30
+ ...Object.keys(pkg?.dependencies || {}),
31
+ ...Object.keys(pkg?.devDependencies || {}),
32
+ ...Object.keys(pkg?.peerDependencies || {}),
33
+ ]);
34
+ }
35
+
36
+ /**
37
+ * Package detection — mirrors targetDiscovery.js's hasDirectStkDep() shape,
38
+ * but a foreign system needs two signals rather than one dependency
39
+ * prefix: a copy-paste system like shadcn is never itself an npm
40
+ * dependency, so its primary signal is a config file written by its own
41
+ * init CLI (components.json), with the wrapped dependencies it generates
42
+ * (depFallback, e.g. @radix-ui/*) only as corroborating evidence.
43
+ */
44
+ export function detectPackage(root, ignore, system) {
45
+ const configFiles = fg.sync(
46
+ system.packageDetect.configFiles.map((f) => `**/${f}`),
47
+ { cwd: root, ignore: [...DEFAULT_IGNORE, ...ignore], absolute: true }
48
+ );
49
+
50
+ const depEvidence = [];
51
+ if (system.packageDetect.depFallback?.length) {
52
+ const pkgFiles = fg.sync(['**/package.json'], {
53
+ cwd: root,
54
+ ignore: [...DEFAULT_IGNORE, ...ignore],
55
+ absolute: true,
56
+ });
57
+ for (const pf of pkgFiles) {
58
+ const pkg = readJson(pf);
59
+ if (!pkg) continue;
60
+ for (const dep of depNames(pkg)) {
61
+ if (system.packageDetect.depFallback.some((re) => re.test(dep))) {
62
+ depEvidence.push({ file: path.relative(root, pf), dep });
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ return {
69
+ detected: configFiles.length > 0 || depEvidence.length > 0,
70
+ configFiles: configFiles.map((f) => path.relative(root, f)),
71
+ depEvidence,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * True when a node's value is a real component implementation — a
77
+ * function/arrow expression, or a forwardRef(...)/memo(...) call wrapping
78
+ * one — never a bare filename match.
79
+ */
80
+ function isComponentValue(node) {
81
+ if (!node) return false;
82
+ if (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') return true;
83
+ if (node.type === 'CallExpression') {
84
+ const { callee } = node;
85
+ const calleeName =
86
+ callee.type === 'Identifier'
87
+ ? callee.name
88
+ : callee.type === 'MemberExpression' && callee.property.type === 'Identifier'
89
+ ? callee.property.name
90
+ : null;
91
+ if (calleeName === 'forwardRef' || calleeName === 'memo') {
92
+ return node.arguments.some((arg) => isComponentValue(arg));
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * True when the file's AST has a real component export — a named or
100
+ * default export that's a function declaration, or an isComponentValue()
101
+ * expression. The spike's own regex approach counted type-only
102
+ * declarations as components on other systems; this is where that
103
+ * discipline actually matters, since shadcn's directory-listing
104
+ * enumeration mode would otherwise trust the filename alone.
105
+ *
106
+ * shadcn's own generator never inlines the export — every real component
107
+ * file declares locally (`const Button = React.forwardRef(...)`) and
108
+ * exports by specifier at the bottom (`export { Button, buttonVariants }`).
109
+ * A live run against shadcn-ui/taxonomy caught this: the inline-only checks
110
+ * above matched 1 of 37 real components/ui files (only Toaster, which
111
+ * happens to use `export function Toaster()` directly). So this also
112
+ * indexes top-level function/variable declarations by name and resolves
113
+ * bare `export { Name }` specifiers against that index.
114
+ */
115
+ function hasComponentExport(ast) {
116
+ const localDeclarations = new Map();
117
+ for (const node of ast.program.body) {
118
+ if (node.type === 'FunctionDeclaration' && node.id) {
119
+ localDeclarations.set(node.id.name, node);
120
+ }
121
+ if (node.type === 'VariableDeclaration') {
122
+ for (const decl of node.declarations) {
123
+ if (decl.id.type === 'Identifier') localDeclarations.set(decl.id.name, decl.init);
124
+ }
125
+ }
126
+ }
127
+
128
+ for (const node of ast.program.body) {
129
+ if (node.type === 'ExportDefaultDeclaration') {
130
+ if (node.declaration.type === 'FunctionDeclaration') return true;
131
+ if (isComponentValue(node.declaration)) return true;
132
+ }
133
+ if (node.type === 'ExportNamedDeclaration') {
134
+ if (node.declaration?.type === 'FunctionDeclaration') return true;
135
+ if (node.declaration?.type === 'VariableDeclaration') {
136
+ for (const decl of node.declaration.declarations) {
137
+ if (isComponentValue(decl.init)) return true;
138
+ }
139
+ }
140
+ if (!node.declaration) {
141
+ for (const specifier of node.specifiers) {
142
+ if (specifier.type !== 'ExportSpecifier') continue;
143
+ const localName = specifier.local.name;
144
+ const local = localDeclarations.get(localName);
145
+ if (!local) continue;
146
+ if (local.type === 'FunctionDeclaration') return true;
147
+ if (isComponentValue(local)) return true;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ /**
156
+ * Component enumeration — walks for componentDir matches (e.g.
157
+ * components/ui), then parses each .tsx/.jsx directly inside and keeps
158
+ * only files with a real component export. This is literally reading a
159
+ * conventional folder shadcn always writes to, not import-usage detection
160
+ * — a consumer who renamed or restructured that folder produces zero
161
+ * components here, not a false "clean" signal (surfaced via
162
+ * unresolvedFiles for files that fail to parse at all).
163
+ */
164
+ function enumerateComponents(root, ignore, system) {
165
+ const { parentName, dirName } = system.componentDir;
166
+ const dirs = fg.sync([`**/${parentName}/${dirName}`], {
167
+ cwd: root,
168
+ ignore: [...DEFAULT_IGNORE, ...ignore],
169
+ absolute: true,
170
+ onlyDirectories: true,
171
+ });
172
+
173
+ const components = [];
174
+ const unresolvedFiles = [];
175
+
176
+ for (const dir of dirs) {
177
+ const files = fg.sync(['*.tsx', '*.jsx'], { cwd: dir, absolute: true });
178
+ for (const file of files) {
179
+ let code;
180
+ try {
181
+ code = readFileSync(file, 'utf-8');
182
+ } catch {
183
+ continue;
184
+ }
185
+ let ast;
186
+ try {
187
+ ast = parseSource(code, file);
188
+ } catch {
189
+ unresolvedFiles.push(path.relative(root, file));
190
+ continue;
191
+ }
192
+ if (!hasComponentExport(ast)) continue;
193
+ components.push({
194
+ name: path.basename(file, path.extname(file)),
195
+ file: path.relative(root, file),
196
+ });
197
+ }
198
+ }
199
+
200
+ return { componentDirs: dirs.map((d) => path.relative(root, d)), components, unresolvedFiles };
201
+ }
202
+
203
+ /**
204
+ * Token source detection — postcss.parse() over every .css file, walking
205
+ * Declaration nodes for a custom property matching the system's
206
+ * markerVars. Reuses the exact postcss dependency and parse call
207
+ * tokenAliasResolver.js already makes, rather than a second hand-rolled
208
+ * CSS scanner. Token NAMES here are a shadcn convention (--background/
209
+ * --primary/--radius inside a :root block in globals.css), not enforced
210
+ * by any package.
211
+ */
212
+ function detectTokenSource(root, ignore, system) {
213
+ const cssFiles = fg.sync(['**/*.css'], {
214
+ cwd: root,
215
+ ignore: [...DEFAULT_IGNORE, ...ignore],
216
+ absolute: true,
217
+ });
218
+ const markerSet = new Set(system.tokenSource.markerVars);
219
+ const matches = [];
220
+
221
+ for (const file of cssFiles) {
222
+ let src;
223
+ try {
224
+ src = readFileSync(file, 'utf-8');
225
+ } catch {
226
+ continue;
227
+ }
228
+ let parsed;
229
+ try {
230
+ parsed = postcss.parse(src, { from: file });
231
+ } catch {
232
+ continue;
233
+ }
234
+ let hit = false;
235
+ parsed.walkDecls((decl) => {
236
+ if (decl.prop.startsWith('--') && markerSet.has(decl.prop)) hit = true;
237
+ });
238
+ if (hit) matches.push(path.relative(root, file));
239
+ }
240
+
241
+ return { detected: matches.length > 0, files: matches };
242
+ }
243
+
244
+ /**
245
+ * Layer 1 of "Version B" (ADOPTION_APP_PLAN.md §10 decision #25) — static,
246
+ * source-only discovery of a foreign design system's shape, the same
247
+ * no-install/no-registry constraint every adopt/ resolver already operates
248
+ * under for Stark itself. Not merged into runAdopt's per-target Stark-
249
+ * adoption shape: this answers a different question ("what does this
250
+ * repo's OWN design system look like") and gets its own CLI command
251
+ * (stark-cli scan-foreign) rather than a new key inside "adopt".
252
+ */
253
+ export function resolveForeignDiscovery(root, systemId, { ignore = [] } = {}) {
254
+ const system = getForeignSystem(systemId);
255
+ if (system.distribution !== 'copy-paste') {
256
+ throw new Error(
257
+ `resolveForeignDiscovery only supports 'copy-paste' distribution systems (got "${systemId}" — distribution: "${system.distribution}"). ` +
258
+ `Use resolveInstalledPackageDiscovery for installed-package systems instead.`
259
+ );
260
+ }
261
+ const packageDetection = detectPackage(root, ignore, system);
262
+ const { componentDirs, components, unresolvedFiles } = enumerateComponents(root, ignore, system);
263
+ const tokenSource = detectTokenSource(root, ignore, system);
264
+
265
+ return {
266
+ system: system.id,
267
+ root,
268
+ packageDetected: packageDetection.detected,
269
+ evidence: { configFiles: packageDetection.configFiles, depEvidence: packageDetection.depEvidence },
270
+ componentDirs,
271
+ components,
272
+ unresolvedFiles,
273
+ tokenFile: tokenSource.files[0] ?? null,
274
+ tokenSource,
275
+ };
276
+ }
@@ -0,0 +1,134 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { parseSource } from './parseSource.js';
5
+
6
+ function typeAnnotationToString(node) {
7
+ if (!node) return 'unknown';
8
+ switch (node.type) {
9
+ case 'TSStringKeyword':
10
+ return 'string';
11
+ case 'TSNumberKeyword':
12
+ return 'number';
13
+ case 'TSBooleanKeyword':
14
+ return 'boolean';
15
+ case 'TSAnyKeyword':
16
+ return 'any';
17
+ case 'TSVoidKeyword':
18
+ return 'void';
19
+ case 'TSFunctionType':
20
+ return 'function';
21
+ case 'TSArrayType':
22
+ return `${typeAnnotationToString(node.elementType)}[]`;
23
+ case 'TSTypeReference':
24
+ return node.typeName.type === 'Identifier' ? node.typeName.name : 'unknown';
25
+ case 'TSUnionType':
26
+ return node.types.map(typeAnnotationToString).join(' | ');
27
+ case 'TSLiteralType':
28
+ return node.literal.type === 'StringLiteral' ? `"${node.literal.value}"` : String(node.literal.value ?? 'literal');
29
+ default:
30
+ return node.type.replace(/^TS/, '').replace(/Keyword$/, '').toLowerCase() || 'unknown';
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Extracts { name, type, optional } members from a TSInterfaceDeclaration
36
+ * body or a TSTypeAliasDeclaration whose right-hand side is a literal
37
+ * object shape. Returns null when the declaration isn't a literal shape at
38
+ * all (e.g. `type CardProps = ComponentProps<'div'>`) — that's a real
39
+ * "unreachable" case, not zero props.
40
+ */
41
+ function membersFromDeclaration(node) {
42
+ const members =
43
+ node.type === 'TSInterfaceDeclaration'
44
+ ? node.body.body
45
+ : node.typeAnnotation?.type === 'TSTypeLiteral'
46
+ ? node.typeAnnotation.members
47
+ : null;
48
+ if (!members) return null;
49
+
50
+ return members
51
+ .filter((m) => m.type === 'TSPropertySignature')
52
+ .map((m) => ({
53
+ name: m.key.type === 'Identifier' ? m.key.name : m.key.type === 'StringLiteral' ? m.key.value : null,
54
+ type: typeAnnotationToString(m.typeAnnotation?.typeAnnotation),
55
+ optional: Boolean(m.optional),
56
+ }))
57
+ .filter((m) => m.name !== null);
58
+ }
59
+
60
+ /**
61
+ * Finds the file's own `${Component}Props` interface/type — case-
62
+ * insensitive against the component name, since shadcn filenames are
63
+ * lowercase (button.tsx) while the exported type is capitalized
64
+ * (ButtonProps). Falls back to the only Props-suffixed declaration in the
65
+ * file when exactly one exists, since some components alias the shape
66
+ * under a name that doesn't literally prefix-match the filename — but
67
+ * never guesses among multiple candidates.
68
+ */
69
+ function findPropsDeclaration(ast, componentName) {
70
+ const candidates = [];
71
+ for (const node of ast.program.body) {
72
+ const decl = node.type === 'ExportNamedDeclaration' ? node.declaration : node;
73
+ if (!decl) continue;
74
+ if (decl.type === 'TSInterfaceDeclaration' && /Props$/.test(decl.id.name)) candidates.push(decl);
75
+ if (decl.type === 'TSTypeAliasDeclaration' && /Props$/.test(decl.id.name)) candidates.push(decl);
76
+ }
77
+ if (candidates.length === 0) return null;
78
+
79
+ const prefixMatch = candidates.find((c) => c.id.name.toLowerCase() === `${componentName.toLowerCase()}props`);
80
+ if (prefixMatch) return prefixMatch;
81
+ return candidates.length === 1 ? candidates[0] : null;
82
+ }
83
+
84
+ /**
85
+ * Layer 2 of "Version B" (ADOPTION_APP_PLAN.md §10 decision #25) —
86
+ * shadcn-only for Phase 1, since it's the one system where the B1 spike
87
+ * found real prop types reachable from repo source alone (copy-paste
88
+ * distribution, no node_modules boundary). Does the spike's own
89
+ * hasExplicitPropsType regex check for real, via AST, instead of a
90
+ * boolean. Deliberately has no installed-package branch (Phase 2,
91
+ * MUI-shaped) — that stays entirely absent rather than a stub that always
92
+ * returns reachable:false with no caller.
93
+ */
94
+ export function resolvePropSchema(root, systemId, components) {
95
+ const sampled = components.map(({ name, file }) => {
96
+ const absFile = path.isAbsolute(file) ? file : path.join(root, file);
97
+ let code;
98
+ try {
99
+ code = readFileSync(absFile, 'utf-8');
100
+ } catch {
101
+ return { component: name, file, reachable: false, reason: 'file not readable' };
102
+ }
103
+ let ast;
104
+ try {
105
+ ast = parseSource(code, absFile);
106
+ } catch {
107
+ return { component: name, file, reachable: false, reason: 'parse error' };
108
+ }
109
+
110
+ const declaration = findPropsDeclaration(ast, name);
111
+ if (!declaration) {
112
+ return { component: name, file, reachable: false, reason: 'no Props interface/type found' };
113
+ }
114
+
115
+ const props = membersFromDeclaration(declaration);
116
+ if (props === null) {
117
+ return {
118
+ component: name,
119
+ file,
120
+ reachable: false,
121
+ reason: `${declaration.id.name} isn't a literal object shape (e.g. aliases ComponentProps<...>)`,
122
+ };
123
+ }
124
+
125
+ return { component: name, file, reachable: true, typeName: declaration.id.name, props };
126
+ });
127
+
128
+ return {
129
+ system: systemId,
130
+ root,
131
+ reachableFromRepoAlone: true,
132
+ sampled,
133
+ };
134
+ }