@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
|
@@ -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
|
+
}
|