@starklab/stark-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +10 -4
  2. package/src/adopt/adoptScanReport.js +124 -0
  3. package/src/adopt/catalog.js +26 -6
  4. package/src/adopt/foreignDiscoveryResolver.js +276 -0
  5. package/src/adopt/foreignPropSchemaResolver.js +134 -0
  6. package/src/adopt/foreignScanReport.js +210 -0
  7. package/src/adopt/foreignScoringResolver.js +192 -0
  8. package/src/adopt/foreignSystemConfig.js +356 -0
  9. package/src/adopt/installedPackageDiscoveryResolver.js +602 -0
  10. package/src/adopt/installedPackagePropSchemaResolver.js +279 -0
  11. package/src/adopt/installedPackageScoringResolver.js +153 -0
  12. package/src/adopt/installedSystemAutoDetector.js +51 -0
  13. package/src/adopt/installedSystemScan.js +101 -0
  14. package/src/adopt/jsxOpportunityHelpers.js +99 -0
  15. package/src/adopt/moduleGraph.js +39 -8
  16. package/src/adopt/opportunityResolver.js +255 -0
  17. package/src/adopt/opportunitySignaturesNative.js +47 -0
  18. package/src/adopt/usageRulesResolver.js +298 -0
  19. package/src/adopt/vecnaMaterializer.js +165 -0
  20. package/src/adopt/vecnaVerifier.js +127 -0
  21. package/src/cli.js +407 -1
  22. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +0 -21
  23. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +0 -13
  24. package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +0 -11
  25. package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +0 -34
  26. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +0 -8
  27. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +0 -9
  28. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +0 -9
  29. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +0 -7
  30. package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +0 -12
  31. package/src/adopt/dominionFixture.test.js +0 -165
  32. package/src/adopt/propApiResolver.test.js +0 -229
  33. package/src/adopt/referenceResolver.test.js +0 -213
  34. package/src/adopt/rnTailwindResolver.test.js +0 -263
  35. package/src/adopt/rnTokenAliasResolver.test.js +0 -260
  36. package/src/adopt/tailwindResolver.test.js +0 -178
  37. package/src/adopt/targetDiscovery.test.js +0 -227
  38. package/src/adopt/tokenAliasResolver.test.js +0 -319
  39. package/src/adopt/wrapperResolver.test.js +0 -324
  40. package/src/data.test.js +0 -231
@@ -0,0 +1,298 @@
1
+ import path from 'node:path';
2
+
3
+ import traverseModule from '@babel/traverse';
4
+
5
+ import { checkComponentRules } from '@starklab/stk/conformance/index.js';
6
+
7
+ import { packageNameForPlatform } from './catalog.js';
8
+ import { buildModuleGraph, resolveOrigin } from './moduleGraph.js';
9
+
10
+ const traverse = traverseModule.default ?? traverseModule;
11
+
12
+ // The only catalog components checkComponentRules has rules for at all
13
+ // (Toolbar count, Button primary-variant count, DropdownMenu empty-items).
14
+ // Extracting every other component into a fake node would just widen the
15
+ // AST walk for zero additional findings — checkComponentRules doesn't look
16
+ // at anything else.
17
+ const RULE_TYPES = new Set(['Toolbar', 'Button', 'DropdownMenu']);
18
+
19
+ /**
20
+ * Evaluates a JSX attribute value as a (possibly nested) literal — recurses
21
+ * into object/array expressions so `logo={{ src: '/x.svg' }}` and
22
+ * `items={[{ label: 'A' }]}` resolve, not just scalars like
23
+ * propApiResolver.js's literalAttrValue handles. A dynamic expression
24
+ * (identifier, call, member access, ternary, template, spread, JSX child)
25
+ * is reported unresolved rather than guessed — this resolver never invents
26
+ * a violation it can't statically prove.
27
+ */
28
+ export function resolveJsxLiteral(node) {
29
+ if (!node) return { resolved: false };
30
+ switch (node.type) {
31
+ case 'StringLiteral':
32
+ case 'NumericLiteral':
33
+ case 'BooleanLiteral':
34
+ return { resolved: true, value: node.value };
35
+ case 'NullLiteral':
36
+ return { resolved: true, value: null };
37
+ case 'JSXExpressionContainer':
38
+ return resolveJsxLiteral(node.expression);
39
+ case 'ArrayExpression': {
40
+ const values = [];
41
+ for (const el of node.elements) {
42
+ if (el === null) { values.push(null); continue; }
43
+ if (el.type === 'SpreadElement') return { resolved: false };
44
+ const r = resolveJsxLiteral(el);
45
+ if (!r.resolved) return { resolved: false };
46
+ values.push(r.value);
47
+ }
48
+ return { resolved: true, value: values };
49
+ }
50
+ case 'ObjectExpression': {
51
+ const obj = {};
52
+ for (const prop of node.properties) {
53
+ if (prop.type !== 'ObjectProperty' || prop.computed) return { resolved: false };
54
+ const key = prop.key.type === 'Identifier' ? prop.key.name
55
+ : prop.key.type === 'StringLiteral' ? prop.key.value
56
+ : null;
57
+ if (key === null) return { resolved: false };
58
+ const r = resolveJsxLiteral(prop.value);
59
+ if (!r.resolved) return { resolved: false };
60
+ obj[key] = r.value;
61
+ }
62
+ return { resolved: true, value: obj };
63
+ }
64
+ default:
65
+ // Includes JSXElement/JSXFragment (a component passed as a prop, e.g.
66
+ // accountMenu={<DropdownMenu .../>}) — out of scope, treated as
67
+ // unresolved like any other non-literal expression.
68
+ return { resolved: false };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Turns one JSX call site into the `{ type, ...props }` node shape
74
+ * checkComponentRules expects from an AI-generated layout tree, built
75
+ * instead from statically-resolvable JSX attributes. A prop that can't be
76
+ * resolved is omitted from the node rather than guessed at.
77
+ *
78
+ * That omission is safe for Toolbar (`logo`) and DropdownMenu (`items`) —
79
+ * an absent key just skips that sub-check, the same as a real layout node
80
+ * that never set it. It is NOT safe for Button's `variant`: checkComponentRules
81
+ * defaults a missing variant to "primary" (mirroring the real renderer), so
82
+ * silently dropping an unresolved `variant={cond ? 'primary' : 'ghost'}`
83
+ * would make it count as a bare default-primary button — a false positive
84
+ * this resolver has no evidence for. `unresolvedDynamic` flags that one case
85
+ * so the caller can drop the whole node from the primary-count rule instead
86
+ * of guessing either way.
87
+ */
88
+ export function extractUsageNode({ type, attributes }) {
89
+ const node = { type };
90
+ let unresolvedDynamic = false;
91
+
92
+ for (const attr of attributes) {
93
+ if (attr.type === 'JSXSpreadAttribute') continue;
94
+ const name = attr.name.name;
95
+ if (attr.value === null) { node[name] = true; continue; } // boolean shorthand: <Button showLabel />
96
+ const r = resolveJsxLiteral(attr.value);
97
+ if (r.resolved) {
98
+ node[name] = r.value;
99
+ } else if (type === 'Button' && name === 'variant') {
100
+ unresolvedDynamic = true;
101
+ }
102
+ }
103
+
104
+ return { node, unresolvedDynamic };
105
+ }
106
+
107
+ /**
108
+ * DropdownMenu's real JSX API is compound/children-based
109
+ * (`<DropdownMenu><DropdownMenu.Trigger/><DropdownMenu.Content><DropdownMenu.Item
110
+ * label="Edit" /></DropdownMenu.Content></DropdownMenu>`), but
111
+ * checkComponentRules' `resolvedDropdownItems` only reads the `items` /
112
+ * `children.content` / `content` *props* — the shape Vecna's generated layout
113
+ * JSON uses. Without this scan, every hand-authored compound-API usage (e.g.
114
+ * LayoutCanvas.jsx's own renderer) would read as an empty menu: not because it
115
+ * is one, but because its items live in JSX children this resolver otherwise
116
+ * never looks at. Recurses through wrapping elements/fragments to find
117
+ * `<localName>.Item>` descendants and pull a static `label`; any other `{…}`
118
+ * expression in the subtree (a `.map()`, a conditional) means the real item
119
+ * list is dynamic, so this reports `dynamic: true` rather than guessing empty
120
+ * or non-empty.
121
+ */
122
+ function scanDropdownItemsInChildren(children, localName) {
123
+ let dynamic = false;
124
+ const items = [];
125
+
126
+ for (const child of children ?? []) {
127
+ switch (child.type) {
128
+ case 'JSXText':
129
+ case 'JSXEmptyExpression':
130
+ break;
131
+ case 'JSXExpressionContainer':
132
+ if (child.expression.type !== 'JSXEmptyExpression') dynamic = true;
133
+ break;
134
+ case 'JSXSpreadChild':
135
+ dynamic = true;
136
+ break;
137
+ case 'JSXFragment':
138
+ {
139
+ const sub = scanDropdownItemsInChildren(child.children, localName);
140
+ if (sub.dynamic) dynamic = true;
141
+ items.push(...sub.items);
142
+ }
143
+ break;
144
+ case 'JSXElement': {
145
+ const name = child.openingElement.name;
146
+ const isItem = name.type === 'JSXMemberExpression'
147
+ && name.object.type === 'JSXIdentifier' && name.object.name === localName
148
+ && name.property.name === 'Item';
149
+ if (isItem) {
150
+ const attrs = child.openingElement.attributes;
151
+ const isSeparator = attrs.some(a => a.type === 'JSXAttribute' && a.name.name === 'separator');
152
+ if (!isSeparator) {
153
+ const labelAttr = attrs.find(a => a.type === 'JSXAttribute'
154
+ && ['label', 'title', 'text'].includes(a.name.name));
155
+ const r = labelAttr ? resolveJsxLiteral(labelAttr.value) : { resolved: false };
156
+ if (r.resolved && r.value) items.push({ label: r.value });
157
+ else dynamic = true; // an Item with no statically-provable label — don't guess its presence away
158
+ }
159
+ } else {
160
+ const sub = scanDropdownItemsInChildren(child.children, localName);
161
+ if (sub.dynamic) dynamic = true;
162
+ items.push(...sub.items);
163
+ }
164
+ break;
165
+ }
166
+ default:
167
+ break;
168
+ }
169
+ }
170
+
171
+ return { dynamic, items };
172
+ }
173
+
174
+ /**
175
+ * Runs the Vecna conformance gate's component-usage rules
176
+ * (`packages/stk/conformance/index.js`'s `checkComponentRules` — Toolbar
177
+ * count, Button primary-variant count, DropdownMenu empty-items) against a
178
+ * scanned consumer repo's own JSX, not just Vecna's AI-generated layout
179
+ * JSON. This is the adapter ADOPTION_APP_PLAN.md's R3/§10 decision #3 called
180
+ * for: the gate's input contract is a `layout.page.sections[]` node tree, so
181
+ * each scanned file's statically-resolvable Toolbar/Button/DropdownMenu
182
+ * usages are assembled into one such tree — treating one file as one "page",
183
+ * since that's the natural unit a consumer app's own routing already uses,
184
+ * and it's what keeps "at most one Toolbar" / "at most one primary Button"
185
+ * meaningful instead of comparing unrelated screens against each other.
186
+ *
187
+ * Only the three rule-bearing checks are reusable this way — checkGuardrails
188
+ * needs Vecna's own `intent`, checkStructure/checkCatalogRendererParity only
189
+ * make sense against Vecna's own renderer cases, and checkRendered needs a
190
+ * live DOM snapshot. Reusing all of those against arbitrary external code
191
+ * isn't what "wiring them together" can mean; checkComponentRules is the one
192
+ * genuinely platform-agnostic, structural piece.
193
+ *
194
+ * Web only — `packages/stk/conformance/`'s node-shape conventions
195
+ * (`logo.src`, `items[].label`) are the web renderer's own, and RN's
196
+ * Toolbar/Button/DropdownMenu equivalents don't share them.
197
+ */
198
+ export function resolveUsageRules(root, { platform = 'web', ignore = [] } = {}) {
199
+ if (platform !== 'web') {
200
+ throw new Error(`resolveUsageRules only supports platform "web" (got "${platform}") — checkComponentRules' node shapes (logo.src, items[].label) are the web renderer's own.`);
201
+ }
202
+
203
+ const pkgName = packageNameForPlatform(platform);
204
+ const moduleGraph = buildModuleGraph(root, { ignore });
205
+
206
+ const findings = [];
207
+ const unresolvedFiles = [];
208
+ let filesWithUsage = 0;
209
+
210
+ for (const file of moduleGraph.files) {
211
+ const entry = moduleGraph.graph.get(file);
212
+ if (!entry) continue;
213
+ if (entry.parseError) {
214
+ unresolvedFiles.push(path.relative(root, file));
215
+ continue;
216
+ }
217
+
218
+ const localToType = new Map();
219
+ for (const localName of entry.imports.keys()) {
220
+ const origin = resolveOrigin(moduleGraph, file, localName);
221
+ if (origin?.pkg === pkgName && RULE_TYPES.has(origin.name)) {
222
+ localToType.set(localName, origin.name);
223
+ }
224
+ }
225
+ if (localToType.size === 0) continue;
226
+
227
+ const nodes = [];
228
+ let droppedForUnresolvedVariant = 0;
229
+ let droppedForUnresolvedDropdownChildren = 0;
230
+
231
+ traverse(entry.ast, {
232
+ JSXOpeningElement(elPath) {
233
+ const nameNode = elPath.node.name;
234
+ const localName = nameNode.type === 'JSXIdentifier' ? nameNode.name : null;
235
+ const type = localName ? localToType.get(localName) : null;
236
+ if (!type) return;
237
+ const { node, unresolvedDynamic } = extractUsageNode({ type, attributes: elPath.node.attributes });
238
+ if (unresolvedDynamic) { droppedForUnresolvedVariant += 1; return; }
239
+
240
+ if (type === 'DropdownMenu' && node.items === undefined
241
+ && node.content === undefined && node.children === undefined) {
242
+ const parent = elPath.parentPath.node;
243
+ const scan = parent.type === 'JSXElement'
244
+ ? scanDropdownItemsInChildren(parent.children, localName)
245
+ : { dynamic: false, items: [] };
246
+ if (scan.dynamic) { droppedForUnresolvedDropdownChildren += 1; return; }
247
+ if (scan.items.length > 0) node.items = scan.items;
248
+ }
249
+
250
+ nodes.push(node);
251
+ },
252
+ });
253
+
254
+ if (nodes.length === 0 && droppedForUnresolvedVariant === 0 && droppedForUnresolvedDropdownChildren === 0) continue;
255
+ filesWithUsage += 1;
256
+
257
+ const relFile = path.relative(root, file);
258
+
259
+ if (nodes.length > 0) {
260
+ const layout = { page: { sections: [{ id: relFile, nodes }] } };
261
+ for (const f of checkComponentRules(layout)) {
262
+ findings.push({ ...f, file: relFile });
263
+ }
264
+ }
265
+
266
+ if (droppedForUnresolvedVariant > 0) {
267
+ findings.push({
268
+ severity: 'Info',
269
+ finding: `${droppedForUnresolvedVariant} Button node(s) in ${relFile} have a dynamic \`variant\` (not a string literal) — excluded from the primary-button-count rule rather than guessed.`,
270
+ violated: 'usage pattern',
271
+ owner: 'vecna-layouts',
272
+ fix: 'No action needed unless this recurs across many files in the same view — a static variant lets the check be exact.',
273
+ file: relFile,
274
+ });
275
+ }
276
+
277
+ if (droppedForUnresolvedDropdownChildren > 0) {
278
+ findings.push({
279
+ severity: 'Info',
280
+ finding: `${droppedForUnresolvedDropdownChildren} DropdownMenu node(s) in ${relFile} build their items dynamically (a \`.map()\`, a conditional, or an unlabeled \`Item\`) — excluded from the empty-menu rule rather than guessed.`,
281
+ violated: 'usage pattern',
282
+ owner: 'vecna-layouts',
283
+ fix: 'No action needed unless the menu is actually empty at runtime — a static `DropdownMenu.Item` list or `items` prop lets the check be exact.',
284
+ file: relFile,
285
+ });
286
+ }
287
+ }
288
+
289
+ return {
290
+ platform,
291
+ package: pkgName,
292
+ root,
293
+ scannedFiles: moduleGraph.files.length,
294
+ filesWithUsage,
295
+ unresolvedFiles,
296
+ findings,
297
+ };
298
+ }
@@ -0,0 +1,165 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { buildCatalogFromDir } from '@starklab/stk/conformance/catalog.js';
5
+ import { mappingDir } from '../data.js';
6
+
7
+ // The 5 layout primitives (logo/heading/text/divider/spacer) are hardcoded
8
+ // as plain HTML in LayoutCanvas.jsx and Vecna's own system prompt — they
9
+ // have no prop-mapping/*.mapping.json, so buildCatalogFromDir() never
10
+ // returns them. Every other nodeType is expected to resolve to a real
11
+ // catalog component; these five are the one *known* class of intentional
12
+ // skip, not evidence of an incomplete catalog.
13
+ const KNOWN_PRIMITIVE_TYPES = new Set(['logo', 'heading', 'text', 'divider', 'spacer']);
14
+
15
+ function buildCatalogMap() {
16
+ const entries = buildCatalogFromDir(mappingDir(), { platform: 'web' });
17
+ return new Map(entries.map((e) => [e.schema.nodeType, e]));
18
+ }
19
+
20
+ function serializePropValue(name, def, value) {
21
+ if (def.type === 'boolean') {
22
+ return value ? name : `${name}={false}`;
23
+ }
24
+ if (def.type === 'string' || def.type === 'enum') {
25
+ return `${name}=${JSON.stringify(String(value))}`;
26
+ }
27
+ if (def.type === 'stringArray') {
28
+ if (!Array.isArray(value)) return null;
29
+ return `${name}={${JSON.stringify(value)}}`;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Emits one node as a JSX element string, recursing into its slots.
36
+ * Never guesses: a nodeType absent from the catalog, a prop of unknown
37
+ * type, or a slot value of the wrong shape is skipped with a logged
38
+ * reason instead of being fabricated — same discipline as
39
+ * usageRulesResolver.js's resolveJsxLiteral/extractUsageNode.
40
+ */
41
+ function emitNode(node, catalogMap, componentsUsed, skipped, nodePath, counter) {
42
+ if (!node || typeof node !== 'object') {
43
+ skipped.push({ path: nodePath, kind: 'malformed', reason: 'missing or malformed node' });
44
+ return null;
45
+ }
46
+
47
+ // A slot value with no `type` is not a broken node — several slots are
48
+ // declared in layoutSchema as *literal payload shapes* rather than nested
49
+ // catalog nodes, and carry no `type` by design: Toolbar's `logo` is
50
+ // documented as a flat `{ src, alt }`, DropdownMenu's `items[]` entries are
51
+ // `{ label }` / `{ separator }` descriptors, and the lowercase `accepts`
52
+ // values (heading, image, spacer, text) mark the same thing. These carry no
53
+ // component props, so there is genuinely nothing for propApi to verify —
54
+ // they are correctly excluded, but must not be reported as malformed, or
55
+ // the coverage number reads as a defect count instead of a denominator.
56
+ if (!node.type) {
57
+ skipped.push({
58
+ path: nodePath,
59
+ kind: 'literal',
60
+ reason: 'schema-declared literal slot payload (no "type" field, e.g. {src,alt} or {label}) — not a catalog node, no props to verify',
61
+ });
62
+ return null;
63
+ }
64
+
65
+ const entry = catalogMap.get(node.type);
66
+ if (!entry) {
67
+ const isPrimitive = KNOWN_PRIMITIVE_TYPES.has(node.type);
68
+ skipped.push({
69
+ path: nodePath,
70
+ nodeType: node.type,
71
+ kind: isPrimitive ? 'primitive' : 'unmapped',
72
+ reason: isPrimitive
73
+ ? 'layout primitive (hardcoded HTML in LayoutCanvas, no prop-mapping file — not a catalog component)'
74
+ : 'no catalog entry for this nodeType (unmapped or missing layoutSchema)',
75
+ });
76
+ return null;
77
+ }
78
+
79
+ componentsUsed.add(entry.name);
80
+ counter.emitted++; // node count, not distinct-name count — this is the coverage denominator
81
+
82
+ const attrParts = [];
83
+ for (const propDef of entry.schema.props ?? []) {
84
+ const value = node[propDef.name];
85
+ if (value === undefined) continue; // optional/absent — nothing to emit, never defaulted
86
+ const serialized = serializePropValue(propDef.name, propDef, value);
87
+ if (serialized === null) {
88
+ skipped.push({ path: `${nodePath}.${propDef.name}`, kind: 'unrepresentable', reason: `unrepresentable prop value for type "${propDef.type}"` });
89
+ continue;
90
+ }
91
+ attrParts.push(serialized);
92
+ }
93
+
94
+ const slotDefs = entry.schema.slots ?? [];
95
+ for (const slotDef of slotDefs) {
96
+ const slotValue = node[slotDef.name];
97
+ if (slotValue === undefined) continue;
98
+
99
+ if (slotDef.array) {
100
+ if (!Array.isArray(slotValue)) {
101
+ skipped.push({ path: `${nodePath}.${slotDef.name}`, kind: 'malformed', reason: 'expected array slot value, got non-array' });
102
+ continue;
103
+ }
104
+ const children = [];
105
+ slotValue.forEach((child, i) => {
106
+ const jsx = emitNode(child, catalogMap, componentsUsed, skipped, `${nodePath}.${slotDef.name}[${i}]`, counter);
107
+ if (jsx) children.push(jsx);
108
+ });
109
+ if (children.length > 0) {
110
+ attrParts.push(`${slotDef.name}={<>${children.join('')}</>}`);
111
+ }
112
+ } else {
113
+ const jsx = emitNode(slotValue, catalogMap, componentsUsed, skipped, `${nodePath}.${slotDef.name}`, counter);
114
+ if (jsx) attrParts.push(`${slotDef.name}={${jsx}}`);
115
+ }
116
+ }
117
+
118
+ const openTag = attrParts.length > 0 ? `<${entry.name} ${attrParts.join(' ')}` : `<${entry.name}`;
119
+ return `${openTag} />`;
120
+ }
121
+
122
+ /**
123
+ * Compiles a Vecna LayoutConfig into a real, syntactically valid .jsx file
124
+ * with real `import { X } from '@starklab/stk-components'` statements and
125
+ * real JSX element trees — so the actual adopt/ resolvers (which require a
126
+ * scannable file on disk, not a JSON tree) can run against Vecna's own
127
+ * generated output unmodified. See ADOPTION_APP_PLAN.md R3 point 3.
128
+ *
129
+ * Only mechanically representable content is emitted. Anything else
130
+ * (unmapped nodeType, malformed prop/slot shape) is recorded in
131
+ * `skippedNodes` with a reason, never guessed or defaulted.
132
+ *
133
+ * The output filename must never contain ".stories." — moduleGraph.js's
134
+ * DEFAULT_IGNORE excludes that glob, which would make every resolver
135
+ * silently skip the file.
136
+ */
137
+ export function materializeLayout(layoutConfig, { outDir, slug = 'layout' } = {}) {
138
+ const catalogMap = buildCatalogMap();
139
+ const componentsUsed = new Set();
140
+ const counter = { emitted: 0 };
141
+ const skippedNodes = [];
142
+
143
+ const sections = layoutConfig?.page?.sections ?? [];
144
+ const elements = [];
145
+ sections.forEach((section, si) => {
146
+ const nodes = section?.nodes ?? [];
147
+ nodes.forEach((node, ni) => {
148
+ const jsx = emitNode(node, catalogMap, componentsUsed, skippedNodes, `page.sections[${si}](${section?.id ?? si}).nodes[${ni}]`, counter);
149
+ if (jsx) elements.push(jsx);
150
+ });
151
+ });
152
+
153
+ const componentNames = [...componentsUsed].sort();
154
+ const importLine = componentNames.length > 0
155
+ ? `import { ${componentNames.join(', ')} } from '@starklab/stk-components';\n\n`
156
+ : '';
157
+
158
+ const source = `${importLine}export default function MaterializedLayout() {\n return (\n <>\n ${elements.join('\n ')}\n </>\n );\n}\n`;
159
+
160
+ mkdirSync(outDir, { recursive: true });
161
+ const filePath = path.join(outDir, `${slug}.materialized.jsx`);
162
+ writeFileSync(filePath, source, 'utf-8');
163
+
164
+ return { filePath, componentsUsed: componentNames, nodesEmitted: counter.emitted, skippedNodes };
165
+ }
@@ -0,0 +1,127 @@
1
+ import path from 'node:path';
2
+
3
+ import { materializeLayout } from './vecnaMaterializer.js';
4
+ import { resolveWrappers } from './wrapperResolver.js';
5
+ import { resolveTokenAliases } from './tokenAliasResolver.js';
6
+ import { resolvePropApi } from './propApiResolver.js';
7
+
8
+ /**
9
+ * Runs the real client-side adopt/ resolvers against one Vecna-generated
10
+ * LayoutConfig, via a materialized real .jsx file (see vecnaMaterializer.js).
11
+ * This is the reverse direction of R3 (ADOPTION_APP_PLAN.md): stark-cli adopt
12
+ * checks real client code against the catalog; this checks Vecna's own
13
+ * output against the same catalog, using the same resolver code, not a
14
+ * second parallel reimplementation of the checks.
15
+ *
16
+ * IMPORTANT — of the three resolvers run here, only propApi is structurally
17
+ * capable of producing real findings against materialized output:
18
+ * - wrapperResolver detects a *consumer's own local wrapper component*
19
+ * that renders a catalog component internally (e.g. a local MyButton
20
+ * wrapping <Button/>). Vecna's materialized output only ever contains
21
+ * direct catalog JSX — it never generates a wrapper component — so this
22
+ * always returns real-but-empty results. Included for parity with
23
+ * stark-cli adopt's own resolver set, not because it can find anything
24
+ * here; this is by construction, not a materializer gap.
25
+ * - tokenAliasResolver scans every .css file for a `--custom-prop:
26
+ * var(--stk-*)` alias graph. Vecna never emits CSS files or alias indirection — it
27
+ * only ever references `var(--stk-*)` tokens as one-hop literal prop/
28
+ * style string values — so this resolver finds zero files to scan and
29
+ * always returns empty. Same "vacuous by construction" caveat as above.
30
+ * - propApiResolver validates literal JSX call-site props (invalid enum
31
+ * values, missing required props, deprecated props, className/style
32
+ * escape hatches) against prop-mapping/*.mapping.json. A materialized
33
+ * layout *is* a set of call-site JSX props, so this is the one check
34
+ * whose domain actually matches — this is where real findings live.
35
+ * NOTE — one of propApiResolver's four rules is itself vacuous by
36
+ * construction here: style-escape-hatch checks for STYLE_ESCAPE_ATTRS
37
+ * (className/style) on a call site, but vecnaMaterializer.js only ever
38
+ * emits props declared in a component's layoutSchema — className/style
39
+ * are never among them, so this rule can never fire against Vecna
40
+ * output. The other three (invalid-enum-value, required-prop-missing,
41
+ * deprecated-prop-passed) are real. Same honesty as the wrappers/
42
+ * tokenAliases bullets above — propApi as a whole is not vacuous, but
43
+ * don't count this rule as live coverage when citing "N rules checked."
44
+ *
45
+ * This mirrors R3 point 1's own finding (only 1 of 6 conformance checks
46
+ * generalizes to arbitrary scanned JSX) one level deeper — see
47
+ * feedback_dont_conflate_architecture_fix_with_full_resolution. Do not
48
+ * describe this as "runs the same 4 resolvers as adopt" without this
49
+ * caveat; moduleGraph is shared internal plumbing, not an independent
50
+ * check, so only 1 of the 3 externally-visible resolvers here is load-
51
+ * bearing for this specific input shape.
52
+ */
53
+ export function verifyVecnaLayout(layoutConfig, { outDir, slug = 'layout' } = {}) {
54
+ const dir = path.resolve(outDir ?? path.join(process.cwd(), '.vecna-verify'));
55
+ const { filePath, componentsUsed, nodesEmitted, skippedNodes } = materializeLayout(layoutConfig, { outDir: dir, slug });
56
+
57
+ // Each resolver is isolated: a crash inside one must never take down the
58
+ // others. This matters most for the two vacuous ones — their results are
59
+ // never read by the caller, but they still *execute*, so without isolation
60
+ // a bug in a resolver whose output is declared meaningless can still kill
61
+ // the gate. That was live: resolveWrappers() was called with
62
+ // `workspacePackages: {}`, but resolveWrappers documents that option as an
63
+ // optional `Map<packageName, absoluteDir>` and does `workspacePackages ||
64
+ // new Map()` — `{}` is truthy, so it survived the default and threw
65
+ // `workspace?.packages?.has is not a function` from resolveWorkspacePackage
66
+ // as soon as a wrapper chain reached a terminal tag. Omitting the option
67
+ // fixes that call site; the isolation below is what stops the next one.
68
+ // Same per-item failure-isolation discipline as scan-foreign's.
69
+ const run = (name, fn) => {
70
+ try {
71
+ return { result: fn(), failed: false, error: null };
72
+ } catch (err) {
73
+ return { result: null, failed: true, error: `${name} threw: ${err.message}` };
74
+ }
75
+ };
76
+
77
+ const wrappers = run('resolveWrappers', () => resolveWrappers(dir, { platform: 'web', ignore: [] }));
78
+ const tokenAliases = run('resolveTokenAliases', () => resolveTokenAliases(dir, { platform: 'web', ignore: [] }));
79
+ const propApi = run('resolvePropApi', () => resolvePropApi(dir, { platform: 'web', ignore: [] }));
80
+
81
+ // Coverage denominator. `verified` counts nodes that actually became JSX and
82
+ // were therefore seen by propApi; `unverifiable` counts the skips that are
83
+ // correct by construction (layout primitives, literal slot payloads), and
84
+ // `gaps` counts the ones that are genuine blind spots (malformed input, an
85
+ // unmapped nodeType, a prop shape the materializer can't represent). A gate
86
+ // that reports "clean" without this is reporting the absence of findings in
87
+ // an unstated denominator — gaps > 0 means "clean" is a weaker claim.
88
+ const byKind = {};
89
+ for (const s of skippedNodes) byKind[s.kind ?? 'unknown'] = (byKind[s.kind ?? 'unknown'] ?? 0) + 1;
90
+ const unverifiable = (byKind.primitive ?? 0) + (byKind.literal ?? 0);
91
+ const coverage = {
92
+ verified: nodesEmitted,
93
+ skipped: skippedNodes.length,
94
+ unverifiable,
95
+ gaps: skippedNodes.length - unverifiable,
96
+ byKind,
97
+ };
98
+
99
+ return {
100
+ materialized: { filePath, componentsUsed, nodesEmitted, skippedNodes, coverage },
101
+ resolvers: {
102
+ wrappers: {
103
+ vacuousByConstruction: true,
104
+ reason: 'Vecna output has no local wrapper components — only direct catalog JSX.',
105
+ failed: wrappers.failed,
106
+ error: wrappers.error,
107
+ result: wrappers.result,
108
+ },
109
+ tokenAliases: {
110
+ vacuousByConstruction: true,
111
+ reason: 'Vecna output has no CSS files or custom-property alias chains.',
112
+ failed: tokenAliases.failed,
113
+ error: tokenAliases.error,
114
+ result: tokenAliases.result,
115
+ },
116
+ propApi: {
117
+ vacuousByConstruction: false,
118
+ reason: 'Materialized call-site JSX props are exactly what this resolver validates. ' +
119
+ 'One exception: style-escape-hatch is vacuous by construction here — the materializer ' +
120
+ 'never emits className/style, so only 3 of the 4 rules can produce real findings.',
121
+ failed: propApi.failed,
122
+ error: propApi.error,
123
+ result: propApi.result,
124
+ },
125
+ },
126
+ };
127
+ }