@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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/package.json +31 -0
  4. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
  5. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
  6. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
  7. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
  8. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
  9. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
  10. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
  11. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
  12. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
  13. package/src/adopt/catalog.js +88 -0
  14. package/src/adopt/dominionFixture.test.js +165 -0
  15. package/src/adopt/moduleGraph.js +232 -0
  16. package/src/adopt/parseSource.js +25 -0
  17. package/src/adopt/propApiResolver.js +278 -0
  18. package/src/adopt/propApiResolver.test.js +229 -0
  19. package/src/adopt/referenceResolver.js +151 -0
  20. package/src/adopt/referenceResolver.test.js +213 -0
  21. package/src/adopt/rnTailwindResolver.js +347 -0
  22. package/src/adopt/rnTailwindResolver.test.js +263 -0
  23. package/src/adopt/rnTokenAliasResolver.js +474 -0
  24. package/src/adopt/rnTokenAliasResolver.test.js +260 -0
  25. package/src/adopt/tailwindResolver.js +512 -0
  26. package/src/adopt/tailwindResolver.test.js +178 -0
  27. package/src/adopt/targetDiscovery.js +237 -0
  28. package/src/adopt/targetDiscovery.test.js +227 -0
  29. package/src/adopt/tokenAliasResolver.js +513 -0
  30. package/src/adopt/tokenAliasResolver.test.js +319 -0
  31. package/src/adopt/wrapperResolver.js +874 -0
  32. package/src/adopt/wrapperResolver.test.js +324 -0
  33. package/src/cli.js +376 -0
  34. package/src/data.js +267 -0
  35. package/src/data.test.js +231 -0
  36. package/src/index.js +8 -0
  37. 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
+ }