@starklab/stark-mcp 0.1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +31 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
- package/src/adopt/catalog.js +88 -0
- package/src/adopt/dominionFixture.test.js +165 -0
- package/src/adopt/moduleGraph.js +232 -0
- package/src/adopt/parseSource.js +25 -0
- package/src/adopt/propApiResolver.js +278 -0
- package/src/adopt/propApiResolver.test.js +229 -0
- package/src/adopt/referenceResolver.js +151 -0
- package/src/adopt/referenceResolver.test.js +213 -0
- package/src/adopt/rnTailwindResolver.js +347 -0
- package/src/adopt/rnTailwindResolver.test.js +263 -0
- package/src/adopt/rnTokenAliasResolver.js +474 -0
- package/src/adopt/rnTokenAliasResolver.test.js +260 -0
- package/src/adopt/tailwindResolver.js +512 -0
- package/src/adopt/tailwindResolver.test.js +178 -0
- package/src/adopt/targetDiscovery.js +237 -0
- package/src/adopt/targetDiscovery.test.js +227 -0
- package/src/adopt/tokenAliasResolver.js +513 -0
- package/src/adopt/tokenAliasResolver.test.js +319 -0
- package/src/adopt/wrapperResolver.js +874 -0
- package/src/adopt/wrapperResolver.test.js +324 -0
- package/src/cli.js +376 -0
- package/src/data.js +267 -0
- package/src/data.test.js +231 -0
- package/src/index.js +8 -0
- package/src/server.js +149 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import fg from 'fast-glob';
|
|
5
|
+
|
|
6
|
+
import { parseSource } from './parseSource.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_IGNORE = [
|
|
9
|
+
'**/node_modules/**',
|
|
10
|
+
'**/dist/**',
|
|
11
|
+
'**/build/**',
|
|
12
|
+
'**/.next/**',
|
|
13
|
+
'**/coverage/**',
|
|
14
|
+
'**/storybook-static/**',
|
|
15
|
+
'**/.storybook*/**',
|
|
16
|
+
'**/*.stories.*',
|
|
17
|
+
'**/*.test.*',
|
|
18
|
+
'**/*.spec.*',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
|
|
22
|
+
|
|
23
|
+
export function resolveRelative(fromFile, specifier) {
|
|
24
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
25
|
+
const candidates = [
|
|
26
|
+
base,
|
|
27
|
+
...EXTENSIONS.map((ext) => base + ext),
|
|
28
|
+
...EXTENSIONS.map((ext) => path.join(base, 'index' + ext)),
|
|
29
|
+
];
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses every source file under `root` once and extracts each file's import
|
|
38
|
+
* bindings and re-export edges. This is the graph the reference resolver
|
|
39
|
+
* walks to trace a local binding — however many hops of the consumer's own
|
|
40
|
+
* barrels away — back to its ultimate origin package (ADOPTION_APP_PLAN.md
|
|
41
|
+
* §4 blind spot 2: "resolve barrel re-exports transitively so a consumer's
|
|
42
|
+
* own src/ui/index.ts doesn't hide everything behind it").
|
|
43
|
+
*
|
|
44
|
+
* Known limitation: `export * from '...'` edges are recorded (exportAll) but
|
|
45
|
+
* not chased during resolution — this repo's own barrels use flat named
|
|
46
|
+
* re-exports exclusively (see reconcile-catalog.js's readBarrel, which makes
|
|
47
|
+
* the same assumption), so it's consistent with the convention being
|
|
48
|
+
* measured, not a shortcut around it.
|
|
49
|
+
*/
|
|
50
|
+
export function buildModuleGraph(root, { ignore = [] } = {}) {
|
|
51
|
+
const files = fg.sync(['**/*.{js,jsx,ts,tsx,mjs,cjs}'], {
|
|
52
|
+
cwd: root,
|
|
53
|
+
absolute: true,
|
|
54
|
+
ignore: [...DEFAULT_IGNORE, ...ignore],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const graph = new Map();
|
|
58
|
+
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
let code;
|
|
61
|
+
try {
|
|
62
|
+
code = readFileSync(file, 'utf-8');
|
|
63
|
+
} catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let ast;
|
|
68
|
+
try {
|
|
69
|
+
ast = parseSource(code, file);
|
|
70
|
+
} catch {
|
|
71
|
+
graph.set(file, {
|
|
72
|
+
ast: null,
|
|
73
|
+
code,
|
|
74
|
+
imports: new Map(),
|
|
75
|
+
reexports: new Map(),
|
|
76
|
+
exportAll: [],
|
|
77
|
+
parseError: true,
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const imports = new Map();
|
|
83
|
+
const reexports = new Map();
|
|
84
|
+
const exportAll = [];
|
|
85
|
+
|
|
86
|
+
for (const node of ast.program.body) {
|
|
87
|
+
if (node.type === 'ImportDeclaration') {
|
|
88
|
+
const source = node.source.value;
|
|
89
|
+
for (const spec of node.specifiers) {
|
|
90
|
+
if (spec.type === 'ImportDefaultSpecifier') {
|
|
91
|
+
imports.set(spec.local.name, { source, imported: 'default' });
|
|
92
|
+
} else if (spec.type === 'ImportNamespaceSpecifier') {
|
|
93
|
+
imports.set(spec.local.name, { source, imported: '*' });
|
|
94
|
+
} else if (spec.type === 'ImportSpecifier') {
|
|
95
|
+
const importedName = spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value;
|
|
96
|
+
imports.set(spec.local.name, { source, imported: importedName });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} else if (node.type === 'ExportNamedDeclaration') {
|
|
100
|
+
if (node.source) {
|
|
101
|
+
for (const spec of node.specifiers) {
|
|
102
|
+
const localName = spec.local.name;
|
|
103
|
+
const exportedName = spec.exported.type === 'Identifier' ? spec.exported.name : spec.exported.value;
|
|
104
|
+
reexports.set(exportedName, { source: node.source.value, imported: localName });
|
|
105
|
+
}
|
|
106
|
+
} else if (node.specifiers?.length) {
|
|
107
|
+
// export { X, Y as Z }; — forwarding a local binding. Only meaningful
|
|
108
|
+
// as a reexport edge when the local name is itself an import binding
|
|
109
|
+
// (forwarding a locally-*defined* wrapper is blind spot 3's concern).
|
|
110
|
+
for (const spec of node.specifiers) {
|
|
111
|
+
const localName = spec.local.name;
|
|
112
|
+
const exportedName = spec.exported.type === 'Identifier' ? spec.exported.name : spec.exported.value;
|
|
113
|
+
if (imports.has(localName)) {
|
|
114
|
+
reexports.set(exportedName, imports.get(localName));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (node.type === 'ExportAllDeclaration') {
|
|
119
|
+
exportAll.push(node.source.value);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
graph.set(file, { ast, code, imports, reexports, exportAll, parseError: false });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { root, files, graph };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolves `name` back to its ultimate origin, following relative-import and
|
|
131
|
+
* re-export hops within the target repo only (never into node_modules).
|
|
132
|
+
*
|
|
133
|
+
* viaExport=false — `name` is a local variable in `file`'s own code; look
|
|
134
|
+
* it up in that file's *imports* (what does `file` bind `name` to?).
|
|
135
|
+
* viaExport=true — `name` is an exported symbol of `file`; look it up in
|
|
136
|
+
* that file's *reexports* (what does `file` re-export as `name`?). These
|
|
137
|
+
* are deliberately kept separate: falling back to `imports` on a
|
|
138
|
+
* viaExport lookup would let an unrelated same-named local import
|
|
139
|
+
* masquerade as an export that doesn't actually exist.
|
|
140
|
+
*
|
|
141
|
+
* Returns:
|
|
142
|
+
* { pkg, name } — terminal is a bare package import (e.g. stk-components)
|
|
143
|
+
* { cycle: [...] } — the chain revisits a file+name it already visited
|
|
144
|
+
* null — unresolved (third-party package, missing file, or no
|
|
145
|
+
* such binding)
|
|
146
|
+
*/
|
|
147
|
+
export function resolveOrigin(moduleGraph, file, name, opts = {}) {
|
|
148
|
+
const { visited = new Set(), depth = 0, viaExport = false } = opts;
|
|
149
|
+
const MAX_DEPTH = 20; // safety valve only — see ADOPTION_APP_PLAN.md §4 "5 is governance, 20 is safety"
|
|
150
|
+
const key = `${file}#${viaExport ? 'export' : 'local'}#${name}`;
|
|
151
|
+
if (visited.has(key)) return { cycle: [...visited, key] };
|
|
152
|
+
if (depth > MAX_DEPTH) return null;
|
|
153
|
+
|
|
154
|
+
const entry = moduleGraph.graph.get(file);
|
|
155
|
+
if (!entry) return null;
|
|
156
|
+
|
|
157
|
+
const binding = viaExport ? entry.reexports.get(name) : entry.imports.get(name);
|
|
158
|
+
if (!binding) return null;
|
|
159
|
+
|
|
160
|
+
const { source, imported } = binding;
|
|
161
|
+
const nextVisited = new Set(visited).add(key);
|
|
162
|
+
|
|
163
|
+
if (!source.startsWith('.') && !source.startsWith('/')) {
|
|
164
|
+
return { pkg: source, name: imported };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (imported === '*') {
|
|
168
|
+
// Namespace import/re-export — X.Foo member access isn't walked by the
|
|
169
|
+
// reference resolver, so there's nothing further to resolve here.
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const resolvedFile = resolveRelative(file, source);
|
|
174
|
+
if (!resolvedFile || !moduleGraph.graph.has(resolvedFile)) return null;
|
|
175
|
+
|
|
176
|
+
return resolveOrigin(moduleGraph, resolvedFile, imported, {
|
|
177
|
+
visited: nextVisited,
|
|
178
|
+
depth: depth + 1,
|
|
179
|
+
viaExport: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Like resolveOrigin, but never gives up at "no import/reexport edge for this
|
|
185
|
+
* name" — it reports that as a *local* terminal instead of null. That's the
|
|
186
|
+
* distinction blind spot 3 (wrapper resolution, ADOPTION_APP_PLAN.md §4) needs:
|
|
187
|
+
* resolveOrigin's null means "not a catalog-package usage, don't care what it
|
|
188
|
+
* is"; resolveOriginDeep's {type:'local', file, name} means "this name isn't
|
|
189
|
+
* imported from anywhere — it's declared right here," which is exactly what a
|
|
190
|
+
* consumer's own wrapper component looks like from an importer's point of view.
|
|
191
|
+
*
|
|
192
|
+
* Returns:
|
|
193
|
+
* { type: 'package', pkg, name } — terminal is a bare package import
|
|
194
|
+
* { type: 'local', file, name } — terminal is a same-repo local declaration
|
|
195
|
+
* { cycle: [...] } — the chain revisits a file+name already visited
|
|
196
|
+
* null — dead end (missing file, namespace import, unparseable)
|
|
197
|
+
*/
|
|
198
|
+
export function resolveOriginDeep(moduleGraph, file, name, opts = {}) {
|
|
199
|
+
const { visited = new Set(), depth = 0, viaExport = false } = opts;
|
|
200
|
+
const MAX_DEPTH = 20; // safety valve only — see ADOPTION_APP_PLAN.md §4 "5 is governance, 20 is safety"
|
|
201
|
+
const key = `${file}#${viaExport ? 'export' : 'local'}#${name}`;
|
|
202
|
+
if (visited.has(key)) return { cycle: [...visited, key] };
|
|
203
|
+
if (depth > MAX_DEPTH) return null;
|
|
204
|
+
|
|
205
|
+
const entry = moduleGraph.graph.get(file);
|
|
206
|
+
if (!entry) return null;
|
|
207
|
+
|
|
208
|
+
const binding = viaExport ? entry.reexports.get(name) : entry.imports.get(name);
|
|
209
|
+
if (!binding) {
|
|
210
|
+
return { type: 'local', file, name };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const { source, imported } = binding;
|
|
214
|
+
const nextVisited = new Set(visited).add(key);
|
|
215
|
+
|
|
216
|
+
if (!source.startsWith('.') && !source.startsWith('/')) {
|
|
217
|
+
return { type: 'package', pkg: source, name: imported };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (imported === '*') {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const resolvedFile = resolveRelative(file, source);
|
|
225
|
+
if (!resolvedFile || !moduleGraph.graph.has(resolvedFile)) return null;
|
|
226
|
+
|
|
227
|
+
return resolveOriginDeep(moduleGraph, resolvedFile, imported, {
|
|
228
|
+
visited: nextVisited,
|
|
229
|
+
depth: depth + 1,
|
|
230
|
+
viaExport: true,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
|
|
3
|
+
// jsx + typescript together handle .tsx (and .jsx/.js files still parse fine
|
|
4
|
+
// with typescript enabled — it only changes how ambiguous syntax like `<Foo>`
|
|
5
|
+
// casts is read, and this codebase's consumers use `as` casts, not the old
|
|
6
|
+
// angle-bracket form). errorRecovery lets one malformed file fall through to
|
|
7
|
+
// the caller as a parseError rather than aborting the whole scan.
|
|
8
|
+
const PLUGINS = [
|
|
9
|
+
'jsx',
|
|
10
|
+
'typescript',
|
|
11
|
+
'classProperties',
|
|
12
|
+
'objectRestSpread',
|
|
13
|
+
'optionalChaining',
|
|
14
|
+
'nullishCoalescingOperator',
|
|
15
|
+
'decorators-legacy',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function parseSource(code, filePath) {
|
|
19
|
+
return parse(code, {
|
|
20
|
+
sourceType: 'module',
|
|
21
|
+
sourceFilename: filePath,
|
|
22
|
+
plugins: PLUGINS,
|
|
23
|
+
errorRecovery: true,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import traverseModule from '@babel/traverse';
|
|
4
|
+
|
|
5
|
+
import { loadCatalog, packageNameForPlatform } from './catalog.js';
|
|
6
|
+
import { buildModuleGraph, resolveOrigin } from './moduleGraph.js';
|
|
7
|
+
import { getComponentProps } from '../data.js';
|
|
8
|
+
|
|
9
|
+
const traverse = traverseModule.default ?? traverseModule;
|
|
10
|
+
|
|
11
|
+
// className/style bypass the component's own token-driven styling contract
|
|
12
|
+
// (ADOPTION_APP_PLAN.md §4 blind spot 4: "className/style passed to a
|
|
13
|
+
// component whose contract says tokens only → drift"). Both are legitimate,
|
|
14
|
+
// intentional React escape hatches — nearly every mapping file lists them in
|
|
15
|
+
// `ignore` precisely because components accept them — so this is a drift
|
|
16
|
+
// signal to track, not a certain-severity break, unlike an invalid enum
|
|
17
|
+
// value or a missing required prop.
|
|
18
|
+
const STYLE_ESCAPE_ATTRS = new Set(['className', 'style']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reads a JSXAttribute's value as a statically-known literal, or reports
|
|
22
|
+
* that it can't be resolved. Mirrors the literal-vs-passthrough distinction
|
|
23
|
+
* wrapperResolver.js's analyzeFaithfulness already makes for hardcoded
|
|
24
|
+
* attributes — a dynamic expression (`variant={someVar}`, a ternary, a
|
|
25
|
+
* template literal, a call) is never scored as valid or invalid, only
|
|
26
|
+
* `unresolved` (ADOPTION_APP_PLAN.md §9 Phase 1b: "never scored as a
|
|
27
|
+
* violation, never scored as clean").
|
|
28
|
+
*/
|
|
29
|
+
function literalAttrValue(attr) {
|
|
30
|
+
if (attr.value === null) return { resolved: true, value: true }; // boolean shorthand: <Button showLabel />
|
|
31
|
+
if (attr.value.type === 'StringLiteral') return { resolved: true, value: attr.value.value };
|
|
32
|
+
if (attr.value.type === 'JSXExpressionContainer') {
|
|
33
|
+
const expr = attr.value.expression;
|
|
34
|
+
if (expr.type === 'StringLiteral' || expr.type === 'NumericLiteral' || expr.type === 'BooleanLiteral') {
|
|
35
|
+
return { resolved: true, value: expr.value };
|
|
36
|
+
}
|
|
37
|
+
if (expr.type === 'NullLiteral') return { resolved: true, value: null };
|
|
38
|
+
return { resolved: false };
|
|
39
|
+
}
|
|
40
|
+
return { resolved: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Checks one JSX call site's attribute list against one component's
|
|
45
|
+
* prop-mapping propMap/ignore. Pure and file-agnostic — takes already-loaded
|
|
46
|
+
* mapping data so it's independently unit-testable without real mapping
|
|
47
|
+
* files or a scanned consumer app on disk.
|
|
48
|
+
*
|
|
49
|
+
* Four checks, per ADOPTION_APP_PLAN.md §4 blind spot 4:
|
|
50
|
+
* - invalid enum value (`checks` + `findings`, severity critical)
|
|
51
|
+
* - required prop missing (`checks` + `findings`, severity critical)
|
|
52
|
+
* - deprecated prop passed (`findings` only, severity warning)
|
|
53
|
+
* - className/style passed (`findings` only, severity warning — "drift")
|
|
54
|
+
*
|
|
55
|
+
* `checks` (not `findings`) feed the three-number ledger — only the first
|
|
56
|
+
* two have a real valid/invalid/unresolved population; the last two only
|
|
57
|
+
* ever fire when something is actually wrong, so there's no "valid" case to
|
|
58
|
+
* count (mirrors tokenAliasResolver.js's raw-fallback/layer-violation
|
|
59
|
+
* findings, which are also never part of its usages ledger).
|
|
60
|
+
*/
|
|
61
|
+
export function evaluateCallSite({ attributes, propMap = [], ignore = [], component, file, line }) {
|
|
62
|
+
const checks = [];
|
|
63
|
+
const findings = [];
|
|
64
|
+
|
|
65
|
+
const byName = new Map(propMap.map((entry) => [entry.react, entry]));
|
|
66
|
+
const ignoreSet = new Set(ignore);
|
|
67
|
+
|
|
68
|
+
let hasSpread = false;
|
|
69
|
+
const passedNames = new Set();
|
|
70
|
+
|
|
71
|
+
for (const attr of attributes) {
|
|
72
|
+
if (attr.type === 'JSXSpreadAttribute') {
|
|
73
|
+
hasSpread = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const name = attr.name.name;
|
|
77
|
+
passedNames.add(name);
|
|
78
|
+
|
|
79
|
+
if (STYLE_ESCAPE_ATTRS.has(name)) {
|
|
80
|
+
findings.push({ rule: 'style-escape-hatch', severity: 'warning', component, prop: name, file, line });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Legitimate prop the mapping deliberately excludes from Figma sync
|
|
85
|
+
// (aria-*, onClick, id, ref, key, children, ...) — not a contract gap.
|
|
86
|
+
if (ignoreSet.has(name)) continue;
|
|
87
|
+
|
|
88
|
+
const entry = byName.get(name);
|
|
89
|
+
// Not tracked by the mapping at all. Could be a native DOM attribute or
|
|
90
|
+
// an untracked passthrough — never flagged (§9: absence of evidence
|
|
91
|
+
// never becomes a violation).
|
|
92
|
+
if (!entry) continue;
|
|
93
|
+
|
|
94
|
+
if (entry.deprecated) {
|
|
95
|
+
findings.push({
|
|
96
|
+
rule: 'deprecated-prop-passed',
|
|
97
|
+
severity: 'warning',
|
|
98
|
+
component,
|
|
99
|
+
prop: name,
|
|
100
|
+
reason: typeof entry.deprecated === 'string' ? entry.deprecated : undefined,
|
|
101
|
+
file,
|
|
102
|
+
line,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (entry.type === 'enum') {
|
|
107
|
+
const literal = literalAttrValue(attr);
|
|
108
|
+
if (!literal.resolved) {
|
|
109
|
+
checks.push({ component, prop: name, rule: 'enum-value', classification: 'unresolved', file, line });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const key = String(literal.value);
|
|
113
|
+
const valid = Object.prototype.hasOwnProperty.call(entry.values ?? {}, key);
|
|
114
|
+
checks.push({
|
|
115
|
+
component,
|
|
116
|
+
prop: name,
|
|
117
|
+
rule: 'enum-value',
|
|
118
|
+
classification: valid ? 'valid' : 'invalid',
|
|
119
|
+
value: literal.value,
|
|
120
|
+
file,
|
|
121
|
+
line,
|
|
122
|
+
});
|
|
123
|
+
if (!valid) {
|
|
124
|
+
findings.push({
|
|
125
|
+
rule: 'invalid-enum-value',
|
|
126
|
+
severity: 'critical',
|
|
127
|
+
component,
|
|
128
|
+
prop: name,
|
|
129
|
+
value: literal.value,
|
|
130
|
+
allowed: Object.keys(entry.values ?? {}),
|
|
131
|
+
file,
|
|
132
|
+
line,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const entry of propMap) {
|
|
139
|
+
if (!entry.required) continue;
|
|
140
|
+
if (passedNames.has(entry.react)) {
|
|
141
|
+
checks.push({ component, prop: entry.react, rule: 'required-prop', classification: 'valid', file, line });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (hasSpread) {
|
|
145
|
+
// A spread (`{...rest}`) might forward the required prop dynamically —
|
|
146
|
+
// can't be statically ruled out, so this stays unresolved rather than
|
|
147
|
+
// a false-positive violation.
|
|
148
|
+
checks.push({ component, prop: entry.react, rule: 'required-prop', classification: 'unresolved', file, line });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
checks.push({ component, prop: entry.react, rule: 'required-prop', classification: 'invalid', file, line });
|
|
152
|
+
findings.push({ rule: 'required-prop-missing', severity: 'critical', component, prop: entry.react, file, line });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { checks, findings };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function summarizeReport(checks) {
|
|
159
|
+
const counts = { valid: 0, invalid: 0, unresolved: 0 };
|
|
160
|
+
for (const c of checks) counts[c.classification] += 1;
|
|
161
|
+
const total = counts.valid + counts.invalid + counts.unresolved;
|
|
162
|
+
const pct = (n) => (total === 0 ? 0 : Math.round((n / total) * 1000) / 10);
|
|
163
|
+
return {
|
|
164
|
+
total,
|
|
165
|
+
valid: counts.valid,
|
|
166
|
+
validPct: pct(counts.valid),
|
|
167
|
+
invalid: counts.invalid,
|
|
168
|
+
invalidPct: pct(counts.invalid),
|
|
169
|
+
unresolved: counts.unresolved,
|
|
170
|
+
unresolvedPct: pct(counts.unresolved),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Walks every JSX call site of a catalog component and validates its
|
|
176
|
+
* attributes against `prop-mapping/components/{slug}.mapping.json` (metric
|
|
177
|
+
* 6, ADOPTION_APP_PLAN.md §4 blind spot 4 / §9 Phase 1b).
|
|
178
|
+
*
|
|
179
|
+
* Web only, and not by choice — `prop-mapping/` has no `rn/` subdirectory
|
|
180
|
+
* (§4 blind spot 4), so there is no artifact to validate an RN prop against.
|
|
181
|
+
* Matches resolveTokenAliases/resolveTailwindTokens: throws rather than
|
|
182
|
+
* silently no-opping, so a caller can never mistake "not run" for "clean".
|
|
183
|
+
*
|
|
184
|
+
* `required`/`deprecated` propMapEntry fields are optional and, as of this
|
|
185
|
+
* resolver's introduction, not yet authored into any real mapping file — so
|
|
186
|
+
* those two checks currently produce zero findings against real data. That
|
|
187
|
+
* is an honest "not yet assessed" state, not a bug: population is a
|
|
188
|
+
* separate authoring pass (same shape as §3d.2's RN accessibility-contract
|
|
189
|
+
* gap), and this resolver never fabricates a requirement that isn't
|
|
190
|
+
* declared.
|
|
191
|
+
*/
|
|
192
|
+
export function resolvePropApi(root, { platform = 'web', ignore = [] } = {}) {
|
|
193
|
+
if (platform !== 'web') {
|
|
194
|
+
throw new Error(`resolvePropApi only supports platform "web" (got "${platform}") — prop-mapping/ has no RN equivalent.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const pkgName = packageNameForPlatform(platform);
|
|
198
|
+
const catalog = loadCatalog(platform);
|
|
199
|
+
const byName = new Map(catalog.components.map((c) => [c.name, c]));
|
|
200
|
+
|
|
201
|
+
const mappingCache = new Map();
|
|
202
|
+
function mappingFor(componentName) {
|
|
203
|
+
if (mappingCache.has(componentName)) return mappingCache.get(componentName);
|
|
204
|
+
let result = null;
|
|
205
|
+
try {
|
|
206
|
+
const props = getComponentProps(componentName, 'web');
|
|
207
|
+
result = { propMap: props.propMap, ignore: props.ignore };
|
|
208
|
+
} catch {
|
|
209
|
+
result = null; // no mapping file for this component — nothing to validate
|
|
210
|
+
}
|
|
211
|
+
mappingCache.set(componentName, result);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const moduleGraph = buildModuleGraph(root, { ignore });
|
|
216
|
+
const checks = [];
|
|
217
|
+
const findings = [];
|
|
218
|
+
const unresolvedFiles = [];
|
|
219
|
+
|
|
220
|
+
for (const file of moduleGraph.files) {
|
|
221
|
+
const entry = moduleGraph.graph.get(file);
|
|
222
|
+
if (!entry) continue;
|
|
223
|
+
if (entry.parseError) {
|
|
224
|
+
unresolvedFiles.push(path.relative(root, file));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const localToComponent = new Map();
|
|
229
|
+
for (const localName of entry.imports.keys()) {
|
|
230
|
+
const origin = resolveOrigin(moduleGraph, file, localName);
|
|
231
|
+
if (origin?.pkg === pkgName && byName.has(origin.name)) {
|
|
232
|
+
localToComponent.set(localName, origin.name);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (localToComponent.size === 0) continue;
|
|
236
|
+
|
|
237
|
+
traverse(entry.ast, {
|
|
238
|
+
JSXOpeningElement(elPath) {
|
|
239
|
+
const nameNode = elPath.node.name;
|
|
240
|
+
let componentName = null;
|
|
241
|
+
if (nameNode.type === 'JSXIdentifier') {
|
|
242
|
+
componentName = localToComponent.get(nameNode.name) ?? null;
|
|
243
|
+
} else if (nameNode.type === 'JSXMemberExpression' && nameNode.object.type === 'JSXIdentifier') {
|
|
244
|
+
// Compound tags (`<DropdownMenu.Item/>`) validate against the root
|
|
245
|
+
// component's own propMap — subComponents carry a separate propMap
|
|
246
|
+
// in the mapping file that a future pass could wire in here.
|
|
247
|
+
componentName = localToComponent.get(nameNode.object.name) ?? null;
|
|
248
|
+
}
|
|
249
|
+
if (!componentName) return;
|
|
250
|
+
|
|
251
|
+
const mapping = mappingFor(componentName);
|
|
252
|
+
if (!mapping) return;
|
|
253
|
+
|
|
254
|
+
const result = evaluateCallSite({
|
|
255
|
+
attributes: elPath.node.attributes,
|
|
256
|
+
propMap: mapping.propMap,
|
|
257
|
+
ignore: mapping.ignore,
|
|
258
|
+
component: componentName,
|
|
259
|
+
file: path.relative(root, file),
|
|
260
|
+
line: elPath.node.loc?.start.line ?? null,
|
|
261
|
+
});
|
|
262
|
+
checks.push(...result.checks);
|
|
263
|
+
findings.push(...result.findings);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
platform,
|
|
270
|
+
package: pkgName,
|
|
271
|
+
root,
|
|
272
|
+
scannedFiles: moduleGraph.files.length,
|
|
273
|
+
unresolvedFiles,
|
|
274
|
+
report: summarizeReport(checks),
|
|
275
|
+
checks,
|
|
276
|
+
findings,
|
|
277
|
+
};
|
|
278
|
+
}
|