@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starklab/stark-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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.
|
|
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
|
+
}
|
package/src/adopt/catalog.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
+
}
|