@starklab/stark-mcp 0.2.0 → 0.3.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.
@@ -259,10 +259,39 @@ export function resolveTerminal(propGraph, propName, selector, inventory, layers
259
259
  });
260
260
  }
261
261
 
262
+ /**
263
+ * Four states, not three, and the split between the last two is the point:
264
+ * a cycle and a depth-limit are *proven* from the CSS alone — the whole
265
+ * chain is in the graph and it doesn't terminate — whereas an undefined
266
+ * terminal only means "no definition among the files this scan happened to
267
+ * read." Those are not the same claim, and only the first is a defect.
268
+ *
269
+ * Measured live against openstatusHQ/openstatus's apps/dashboard, where
270
+ * treating them as one produced a 100% false-positive rate on real code:
271
+ * every one of the 165 Tailwind class usages resolved to `broken`, and so
272
+ * did all 5 of the `var(--radius)` usages in its globals.css. Two distinct
273
+ * causes, neither a defect:
274
+ * - `--radius` is defined in packages/ui/src/globals.css, reached through
275
+ * `@import "@openstatus/ui/globals"` — outside the scanned target root,
276
+ * and behind a bare package specifier this scan never follows.
277
+ * - `--font-mono` / `--font-cal` / `--font-commit-mono` (164 of the 165)
278
+ * ARE defined in the scanned file, but alias next/font-injected
279
+ * variables (`--font-geist-mono`, …, declared via `variable:` in
280
+ * layout.tsx and applied as a class on <html>) that no CSS file can
281
+ * ever contain.
282
+ * Both shapes are ordinary in a Next.js monorepo, so "not found in CSS"
283
+ * cannot be reported as breakage. `unresolved` is surfaced as an info
284
+ * finding instead — still visible, never a gate.
285
+ *
286
+ * Note the RN counterpart in rnTokenAliasResolver.js keeps the three-state
287
+ * mapping deliberately: it resolves JS object keys, where the whole graph
288
+ * IS the scanned source and an undefined terminal really is provable.
289
+ */
262
290
  export function classifyTerminalState(kind) {
263
291
  if (kind === 'stk') return 'conformant';
264
292
  if (kind === 'raw') return 'drift';
265
- return 'broken'; // undefined | cycle | depth-limit
293
+ if (kind === 'undefined') return 'unresolved';
294
+ return 'broken'; // cycle | depth-limit
266
295
  }
267
296
 
268
297
  function classifyProperties(propGraph, inventory, layers) {
@@ -322,6 +351,17 @@ function classifyProperties(propGraph, inventory, layers) {
322
351
  line: s.line,
323
352
  });
324
353
  }
354
+ if (s.state === 'unresolved') {
355
+ findings.push({
356
+ rule: 'unresolved-alias',
357
+ severity: 'info',
358
+ property: propName,
359
+ selector: s.selector,
360
+ reason: 'terminal-not-in-scanned-css',
361
+ file: s.file,
362
+ line: s.line,
363
+ });
364
+ }
325
365
  if (s.terminal.rawFallbackPresent) {
326
366
  // Works today, silently becomes a hardcoded value the day the
327
367
  // aliased property is renamed — a latent-risk signal, never Critical.
@@ -423,7 +463,10 @@ function collectUsages(cssFiles, propGraph, inventory, layers) {
423
463
 
424
464
  const bySelector = propGraph.get(ref.name);
425
465
  if (!bySelector) {
426
- usages.push({ ...site, classification: 'broken', reason: 'undefined-property' });
466
+ // Not 'broken' — see classifyTerminalState: the definition may
467
+ // live in an @import'ed sibling package or be injected at
468
+ // runtime (next/font, inline style={{'--x': …}}).
469
+ usages.push({ ...site, classification: 'unresolved', reason: 'undefined-property' });
427
470
  continue;
428
471
  }
429
472
 
@@ -5,7 +5,7 @@ import traverseModule from '@babel/traverse';
5
5
  import { checkComponentRules } from '@starklab/stk/conformance/index.js';
6
6
 
7
7
  import { packageNameForPlatform } from './catalog.js';
8
- import { buildModuleGraph, resolveOrigin } from './moduleGraph.js';
8
+ import { buildModuleGraph, packageOfSpecifier, resolveOrigin } from './moduleGraph.js';
9
9
 
10
10
  const traverse = traverseModule.default ?? traverseModule;
11
11
 
@@ -16,6 +16,20 @@ const traverse = traverseModule.default ?? traverseModule;
16
16
  // at anything else.
17
17
  const RULE_TYPES = new Set(['Toolbar', 'Button', 'DropdownMenu']);
18
18
 
19
+ // Components that put their content on a layer of their own — a dialog, a
20
+ // sliding panel, a menu. "One clear CTA per view" is a rule about a *view*,
21
+ // and an open Modal is a different view from the page behind it: its own
22
+ // confirm button is that layer's CTA, not a second page-level one competing
23
+ // with the page's. So the rule is evaluated per layer, not per file.
24
+ //
25
+ // The trigger slot is the deliberate exception — `<Popover.Trigger>`, a
26
+ // `trigger`/`anchor` prop — because that control sits in the page and opens
27
+ // the layer; it is not on it.
28
+ const OVERLAY_TYPES = new Set([
29
+ 'Modal', 'SidePanel', 'BottomSheet', 'PushPanel', 'NonModal',
30
+ 'Popover', 'DropdownMenu', 'Toast',
31
+ ]);
32
+
19
33
  /**
20
34
  * Evaluates a JSX attribute value as a (possibly nested) literal — recurses
21
35
  * into object/array expressions so `logo={{ src: '/x.svg' }}` and
@@ -118,7 +132,28 @@ export function extractUsageNode({ type, attributes }) {
118
132
  * expression in the subtree (a `.map()`, a conditional) means the real item
119
133
  * list is dynamic, so this reports `dynamic: true` rather than guessing empty
120
134
  * or non-empty.
135
+ *
136
+ * Two details are read off DropdownMenu.jsx's own sub-component list rather
137
+ * than assumed, and both were live false-positive sources found by gating a
138
+ * real repo (apps/dominion) instead of a fixture:
139
+ *
140
+ * - **Four sub-components carry a menu entry, not one.** `Item` is the plain
141
+ * one, but `ItemRadio`/`ItemCheckbox` are entries in a RadioGroup/Group,
142
+ * and `SubTrigger` is the entry that opens a submenu. Matching only `Item`
143
+ * read a fully-populated radio menu as empty and fired the Critical
144
+ * "no resolvable menu entries" rule against valid code. The container
145
+ * sub-components (`Content`, `RadioGroup`, `Group`, `Sub`, `SubContent`)
146
+ * keep falling through to the recursion below, which is how the entries
147
+ * inside them are reached; `Separator` falls through too and contributes
148
+ * nothing, which is correct — it is not an entry.
149
+ * - **`Trigger` is skipped outright.** It holds the anchor control, never a
150
+ * menu entry, and its subtree is ordinary application JSX — so an
151
+ * expression child in there (a `{count}` in a button label) would set
152
+ * `dynamic` and silently drop the whole menu from a rule that had nothing
153
+ * to do with the trigger.
121
154
  */
155
+ const DROPDOWN_ENTRY_SUBCOMPONENTS = new Set(['Item', 'ItemRadio', 'ItemCheckbox', 'SubTrigger']);
156
+
122
157
  function scanDropdownItemsInChildren(children, localName) {
123
158
  let dynamic = false;
124
159
  const items = [];
@@ -143,10 +178,12 @@ function scanDropdownItemsInChildren(children, localName) {
143
178
  break;
144
179
  case 'JSXElement': {
145
180
  const name = child.openingElement.name;
146
- const isItem = name.type === 'JSXMemberExpression'
181
+ const subComponent = name.type === 'JSXMemberExpression'
147
182
  && name.object.type === 'JSXIdentifier' && name.object.name === localName
148
- && name.property.name === 'Item';
149
- if (isItem) {
183
+ ? name.property.name
184
+ : null;
185
+ if (subComponent === 'Trigger') break; // the anchor, never an entry
186
+ if (subComponent && DROPDOWN_ENTRY_SUBCOMPONENTS.has(subComponent)) {
150
187
  const attrs = child.openingElement.attributes;
151
188
  const isSeparator = attrs.some(a => a.type === 'JSXAttribute' && a.name.name === 'separator');
152
189
  if (!isSeparator) {
@@ -171,6 +208,204 @@ function scanDropdownItemsInChildren(children, localName) {
171
208
  return { dynamic, items };
172
209
  }
173
210
 
211
+ /**
212
+ * The overlay layer a JSX node is rendered on, or `''` for the page itself.
213
+ * Walks outward to the nearest enclosing OVERLAY_TYPES element, skipping any
214
+ * whose trigger slot the node sits in (see OVERLAY_TYPES). The layer's own
215
+ * source offset makes the key, so two sibling Modals in one file are two
216
+ * layers, not one.
217
+ */
218
+ export function overlaySectionOf(elPath, localToOverlay) {
219
+ let cur = elPath.parentPath;
220
+ let viaTrigger = false;
221
+ while (cur) {
222
+ const n = cur.node;
223
+ if (n.type === 'JSXAttribute') {
224
+ if (n.name?.name === 'trigger' || n.name?.name === 'anchor') viaTrigger = true;
225
+ } else if (n.type === 'JSXElement') {
226
+ const nm = n.openingElement.name;
227
+ if (nm.type === 'JSXMemberExpression' && nm.property?.name === 'Trigger') {
228
+ viaTrigger = true;
229
+ } else if (nm.type === 'JSXIdentifier' && localToOverlay.has(nm.name)) {
230
+ if (!viaTrigger) return `${localToOverlay.get(nm.name)}@${n.start}`;
231
+ viaTrigger = false; // that trigger belonged to this overlay — keep going outward
232
+ }
233
+ }
234
+ cur = cur.parentPath;
235
+ }
236
+ return '';
237
+ }
238
+
239
+ /** A stable key for a discriminant expression (`phase`, `props.mode`, `this.x`). */
240
+ function discriminantKey(node) {
241
+ switch (node?.type) {
242
+ case 'Identifier': return node.name;
243
+ case 'ThisExpression': return 'this';
244
+ case 'MemberExpression': {
245
+ if (node.computed) return null;
246
+ const obj = discriminantKey(node.object);
247
+ return obj && node.property?.name ? `${obj}.${node.property.name}` : null;
248
+ }
249
+ default: return null;
250
+ }
251
+ }
252
+
253
+ function literalOf(node) {
254
+ switch (node?.type) {
255
+ case 'StringLiteral':
256
+ case 'NumericLiteral':
257
+ case 'BooleanLiteral': return node.value;
258
+ case 'NullLiteral': return null;
259
+ default: return undefined;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Turns a rendering test into constraints on named discriminants. Only the
265
+ * shapes it can prove are returned; anything else yields `[]`, which reads as
266
+ * "unconstrained" — the same behaviour this resolver had before scenarios
267
+ * existed, so an unrecognised test can never make it miss a violation it used
268
+ * to report.
269
+ */
270
+ export function constraintsFrom(test, positive) {
271
+ switch (test?.type) {
272
+ case 'BinaryExpression': {
273
+ const key = discriminantKey(test.left);
274
+ const value = literalOf(test.right);
275
+ if (!key || value === undefined) return [];
276
+ const eq = test.operator === '===' || test.operator === '==';
277
+ const ne = test.operator === '!==' || test.operator === '!=';
278
+ if (!eq && !ne) return [];
279
+ const allow = eq === positive;
280
+ return [allow ? { key, allow: new Set([value]) } : { key, deny: new Set([value]) }];
281
+ }
282
+ case 'LogicalExpression': {
283
+ if (!positive) return []; // De Morgan on a negated compound — not worth proving
284
+ if (test.operator === '&&') {
285
+ return [...constraintsFrom(test.left, true), ...constraintsFrom(test.right, true)];
286
+ }
287
+ if (test.operator === '||') {
288
+ // `phase === 'input' || phase === 'error'` — one discriminant, a set of
289
+ // values. Mergeable only when both sides constrain the same key with
290
+ // `allow`; anything else is a disjunction this can't narrow.
291
+ const l = constraintsFrom(test.left, true);
292
+ const r = constraintsFrom(test.right, true);
293
+ if (l.length === 1 && r.length === 1 && l[0].key === r[0].key && l[0].allow && r[0].allow) {
294
+ return [{ key: l[0].key, allow: new Set([...l[0].allow, ...r[0].allow]) }];
295
+ }
296
+ }
297
+ return [];
298
+ }
299
+ case 'UnaryExpression':
300
+ return test.operator === '!' ? constraintsFrom(test.argument, !positive) : [];
301
+ default: {
302
+ // A bare truthiness test: `{open && <X/>}`, `cond ? <A/> : <B/>`.
303
+ const key = discriminantKey(test);
304
+ return key ? [{ key, allow: new Set([positive]) }] : [];
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Every constraint on the path from a JSX node up to the module root — the
311
+ * conditions that must hold for it to be rendered at all.
312
+ */
313
+ export function guardOf(elPath) {
314
+ const constraints = [];
315
+ let cur = elPath;
316
+ let parent = cur.parentPath;
317
+ while (parent) {
318
+ const p = parent.node;
319
+ const c = cur.node;
320
+ if (p.type === 'ConditionalExpression') {
321
+ if (c === p.consequent) constraints.push(...constraintsFrom(p.test, true));
322
+ else if (c === p.alternate) constraints.push(...constraintsFrom(p.test, false));
323
+ } else if (p.type === 'LogicalExpression' && p.operator === '&&' && c === p.right) {
324
+ constraints.push(...constraintsFrom(p.left, true));
325
+ }
326
+ cur = parent;
327
+ parent = parent.parentPath;
328
+ }
329
+ return constraints;
330
+ }
331
+
332
+ // A discriminant value standing for "none of the literals this file tests
333
+ // for" — the scenario where every `phase === '…'` branch is closed.
334
+ const OTHER = Symbol('other');
335
+
336
+ // Above this many scenarios the file is evaluated flat instead, exactly as
337
+ // before scenarios existed. A view with that many independent discriminants
338
+ // is not a state machine this can reason about, and enumerating it would cost
339
+ // more than the precision is worth.
340
+ const SCENARIO_CAP = 64;
341
+
342
+ function nodeMatchesScenario(constraints, scenario) {
343
+ for (const c of constraints) {
344
+ const value = scenario.get(c.key);
345
+ if (c.allow && !c.allow.has(value)) return false;
346
+ if (c.deny && c.deny.has(value)) return false;
347
+ }
348
+ return true;
349
+ }
350
+
351
+ /**
352
+ * Enumerates the mutually-exclusive rendering states of one layer: the
353
+ * cartesian product of the values each discriminant is tested against, plus
354
+ * OTHER. Returns `null` past SCENARIO_CAP, meaning "evaluate flat".
355
+ */
356
+ export function scenariosFor(entries) {
357
+ const values = new Map();
358
+ for (const e of entries) {
359
+ for (const c of e.constraints) {
360
+ if (!values.has(c.key)) values.set(c.key, new Set([OTHER]));
361
+ if (c.allow) for (const v of c.allow) values.get(c.key).add(v);
362
+ if (c.deny) for (const v of c.deny) values.get(c.key).add(v);
363
+ }
364
+ }
365
+ if (values.size === 0) return [new Map()];
366
+
367
+ let count = 1;
368
+ for (const set of values.values()) count *= set.size;
369
+ if (count > SCENARIO_CAP) return null;
370
+
371
+ let scenarios = [new Map()];
372
+ for (const [key, set] of values) {
373
+ const next = [];
374
+ for (const base of scenarios) {
375
+ for (const v of set) {
376
+ const m = new Map(base);
377
+ m.set(key, v);
378
+ next.push(m);
379
+ }
380
+ }
381
+ scenarios = next;
382
+ }
383
+ return scenarios;
384
+ }
385
+
386
+ /**
387
+ * Collapses primary Buttons that carry the same accessible name down to one.
388
+ * Two `variant="primary"` Buttons both reading "Invite teammate" are one CTA
389
+ * rendered twice — the responsive full-label/icon-only pair a component
390
+ * library without a responsive `iconOnly` forces an app to write, where CSS,
391
+ * not the JSX, decides which one is visible. The rule counts *competing*
392
+ * CTAs, so a repeat of the same one is not a second CTA. An unnamed Button is
393
+ * never collapsed: two of those are not provably the same control.
394
+ */
395
+ export function collapseDuplicateCtas(nodes) {
396
+ const seen = new Set();
397
+ return nodes.filter((n) => {
398
+ if (n.type !== 'Button' || (n.variant ?? 'primary') !== 'primary') return true;
399
+ const name = typeof n.label === 'string' && n.label ? n.label
400
+ : typeof n['aria-label'] === 'string' && n['aria-label'] ? n['aria-label']
401
+ : null;
402
+ if (name === null) return true;
403
+ if (seen.has(name)) return false;
404
+ seen.add(name);
405
+ return true;
406
+ });
407
+ }
408
+
174
409
  /**
175
410
  * Runs the Vecna conformance gate's component-usage rules
176
411
  * (`packages/stk/conformance/index.js`'s `checkComponentRules` — Toolbar
@@ -184,6 +419,22 @@ function scanDropdownItemsInChildren(children, localName) {
184
419
  * and it's what keeps "at most one Toolbar" / "at most one primary Button"
185
420
  * meaningful instead of comparing unrelated screens against each other.
186
421
  *
422
+ * A file is one page but not one *view*, though, and the difference is what
423
+ * these rules actually count. Three splits keep the comparison to controls
424
+ * that are on screen together — each one only narrowing what the resolver
425
+ * claims, never widening it:
426
+ *
427
+ * - **Overlay layers** (`overlaySectionOf`) — a Modal's confirm button is
428
+ * that dialog's CTA, not a rival to the page's.
429
+ * - **Mutually-exclusive rendering states** (`guardOf`, `scenariosFor`) — a
430
+ * connect wizard's `{phase === 'install' && …}` / `{phase === 'select' &&
431
+ * …}` branches are four screens in one file, each with its own single CTA.
432
+ * - **The same CTA rendered twice** (`collapseDuplicateCtas`) — a responsive
433
+ * full-label/icon-only pair, one of which CSS always hides.
434
+ *
435
+ * All three came out of gating a real repo (apps/dominion): every one of its
436
+ * primary-Button warnings was a view the file never actually renders at once.
437
+ *
187
438
  * Only the three rule-bearing checks are reusable this way — checkGuardrails
188
439
  * needs Vecna's own `intent`, checkStructure/checkCatalogRendererParity only
189
440
  * make sense against Vecna's own renderer cases, and checkRendered needs a
@@ -216,15 +467,16 @@ export function resolveUsageRules(root, { platform = 'web', ignore = [] } = {})
216
467
  }
217
468
 
218
469
  const localToType = new Map();
470
+ const localToOverlay = new Map();
219
471
  for (const localName of entry.imports.keys()) {
220
472
  const origin = resolveOrigin(moduleGraph, file, localName);
221
- if (origin?.pkg === pkgName && RULE_TYPES.has(origin.name)) {
222
- localToType.set(localName, origin.name);
223
- }
473
+ if (packageOfSpecifier(origin?.pkg) !== pkgName) continue;
474
+ if (RULE_TYPES.has(origin.name)) localToType.set(localName, origin.name);
475
+ if (OVERLAY_TYPES.has(origin.name)) localToOverlay.set(localName, origin.name);
224
476
  }
225
477
  if (localToType.size === 0) continue;
226
478
 
227
- const nodes = [];
479
+ const entries = [];
228
480
  let droppedForUnresolvedVariant = 0;
229
481
  let droppedForUnresolvedDropdownChildren = 0;
230
482
 
@@ -247,19 +499,52 @@ export function resolveUsageRules(root, { platform = 'web', ignore = [] } = {})
247
499
  if (scan.items.length > 0) node.items = scan.items;
248
500
  }
249
501
 
250
- nodes.push(node);
502
+ entries.push({
503
+ node,
504
+ section: overlaySectionOf(elPath, localToOverlay),
505
+ constraints: guardOf(elPath),
506
+ });
251
507
  },
252
508
  });
253
509
 
254
- if (nodes.length === 0 && droppedForUnresolvedVariant === 0 && droppedForUnresolvedDropdownChildren === 0) continue;
510
+ if (entries.length === 0 && droppedForUnresolvedVariant === 0 && droppedForUnresolvedDropdownChildren === 0) continue;
255
511
  filesWithUsage += 1;
256
512
 
257
513
  const relFile = path.relative(root, file);
258
514
 
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 });
515
+ // One file is one page, but not one *view*: a page renders one branch of
516
+ // its state machine at a time, and an open overlay is a layer of its own.
517
+ // Evaluating every Button in the file against each other reports views
518
+ // that never coexist — the exact "violation it can't statically prove"
519
+ // this resolver refuses to invent elsewhere. So the nodes are split by
520
+ // overlay layer, then by mutually-exclusive rendering state, and the rules
521
+ // run once per combination. A finding that holds in several scenarios is
522
+ // still reported once.
523
+ if (entries.length > 0) {
524
+ const bySection = new Map();
525
+ for (const e of entries) {
526
+ if (!bySection.has(e.section)) bySection.set(e.section, []);
527
+ bySection.get(e.section).push(e);
528
+ }
529
+
530
+ const seenFindings = new Set();
531
+ for (const [section, sectionEntries] of bySection) {
532
+ const scenarios = scenariosFor(sectionEntries);
533
+ const views = scenarios === null
534
+ ? [sectionEntries.map(e => e.node)]
535
+ : scenarios.map(s => sectionEntries.filter(e => nodeMatchesScenario(e.constraints, s)).map(e => e.node));
536
+
537
+ for (const view of views) {
538
+ const nodes = collapseDuplicateCtas(view);
539
+ if (nodes.length === 0) continue;
540
+ const layout = { page: { sections: [{ id: section ? `${relFile}#${section}` : relFile, nodes }] } };
541
+ for (const f of checkComponentRules(layout)) {
542
+ const key = `${f.severity}|${f.violated}|${f.finding}`;
543
+ if (seenFindings.has(key)) continue;
544
+ seenFindings.add(key);
545
+ findings.push({ ...f, file: relFile });
546
+ }
547
+ }
263
548
  }
264
549
  }
265
550
 
@@ -83,7 +83,24 @@ function emitNode(node, catalogMap, componentsUsed, skipped, nodePath, counter)
83
83
  for (const propDef of entry.schema.props ?? []) {
84
84
  const value = node[propDef.name];
85
85
  if (value === undefined) continue; // optional/absent — nothing to emit, never defaulted
86
- const serialized = serializePropValue(propDef.name, propDef, value);
86
+
87
+ // A canvasOnly key is consumed by LayoutCanvas to compose something
88
+ // (DataTable's pagination keys build a footer element) and is never
89
+ // forwarded as a prop. Emitting it would put an attribute on the call site
90
+ // that no real call site can carry — which is the whole failure mode this
91
+ // materializer exists to avoid.
92
+ if (propDef.canvasOnly) {
93
+ skipped.push({ path: `${nodePath}.${propDef.name}`, kind: 'canvas-only', reason: 'schema-declared canvasOnly key — composed by LayoutCanvas, never passed as a prop' });
94
+ continue;
95
+ }
96
+
97
+ // The layout key and the React prop are allowed to be spelled
98
+ // differently (BadgeStatus's "variant" is the component's `color`).
99
+ // `reactProp` is the declared rename, checked against the real component
100
+ // signature by packages/stk/scripts/validate-prop-api.js — emit that, or
101
+ // the resolvers verify an attribute the component never accepts.
102
+ const emitName = propDef.reactProp ?? propDef.name;
103
+ const serialized = serializePropValue(emitName, propDef, value);
87
104
  if (serialized === null) {
88
105
  skipped.push({ path: `${nodePath}.${propDef.name}`, kind: 'unrepresentable', reason: `unrepresentable prop value for type "${propDef.type}"` });
89
106
  continue;
@@ -91,10 +108,18 @@ function emitNode(node, catalogMap, componentsUsed, skipped, nodePath, counter)
91
108
  attrParts.push(serialized);
92
109
  }
93
110
 
111
+ // A canvasOnly slot is composed into the component's children by
112
+ // LayoutCanvas through a compound sub-component (Popover's trigger/content
113
+ // become <Popover.Trigger>/<Popover.Content>), so the component never
114
+ // receives a prop of that name. Emit those nodes as JSX children instead of
115
+ // as an attribute — that keeps the call site faithful *and* keeps the nested
116
+ // nodes in the file, so the resolvers still verify them.
117
+ const childParts = [];
94
118
  const slotDefs = entry.schema.slots ?? [];
95
119
  for (const slotDef of slotDefs) {
96
120
  const slotValue = node[slotDef.name];
97
121
  if (slotValue === undefined) continue;
122
+ const sink = slotDef.canvasOnly ? childParts : null;
98
123
 
99
124
  if (slotDef.array) {
100
125
  if (!Array.isArray(slotValue)) {
@@ -106,16 +131,21 @@ function emitNode(node, catalogMap, componentsUsed, skipped, nodePath, counter)
106
131
  const jsx = emitNode(child, catalogMap, componentsUsed, skipped, `${nodePath}.${slotDef.name}[${i}]`, counter);
107
132
  if (jsx) children.push(jsx);
108
133
  });
109
- if (children.length > 0) {
110
- attrParts.push(`${slotDef.name}={<>${children.join('')}</>}`);
111
- }
134
+ if (children.length === 0) continue;
135
+ if (sink) sink.push(...children);
136
+ else attrParts.push(`${slotDef.name}={<>${children.join('')}</>}`);
112
137
  } else {
113
138
  const jsx = emitNode(slotValue, catalogMap, componentsUsed, skipped, `${nodePath}.${slotDef.name}`, counter);
114
- if (jsx) attrParts.push(`${slotDef.name}={${jsx}}`);
139
+ if (!jsx) continue;
140
+ if (sink) sink.push(jsx);
141
+ else attrParts.push(`${slotDef.name}={${jsx}}`);
115
142
  }
116
143
  }
117
144
 
118
145
  const openTag = attrParts.length > 0 ? `<${entry.name} ${attrParts.join(' ')}` : `<${entry.name}`;
146
+ if (childParts.length > 0) {
147
+ return `${openTag}>${childParts.join('')}</${entry.name}>`;
148
+ }
119
149
  return `${openTag} />`;
120
150
  }
121
151
 
@@ -130,6 +160,16 @@ function emitNode(node, catalogMap, componentsUsed, skipped, nodePath, counter)
130
160
  * (unmapped nodeType, malformed prop/slot shape) is recorded in
131
161
  * `skippedNodes` with a reason, never guessed or defaulted.
132
162
  *
163
+ * The layout schema is not the component's signature, and the two are allowed
164
+ * to diverge in three declared ways — `reactProp` (a rename), `canvasOnly` on a
165
+ * prop (LayoutCanvas composes it, nothing is forwarded), and `canvasOnly` on a
166
+ * slot (composed into children through a compound sub-component). All three are
167
+ * honoured here, and enforced against the real signatures by
168
+ * packages/stk/scripts/validate-prop-api.js. Ignoring them is not a cosmetic
169
+ * bug: emitting the layout key verbatim produced `<BadgeStatus variant=...>`
170
+ * for a component whose prop is `color`, and the resolvers then verified an
171
+ * attribute no real call site can carry.
172
+ *
133
173
  * The output filename must never contain ".stories." — moduleGraph.js's
134
174
  * DEFAULT_IGNORE excludes that glob, which would make every resolver
135
175
  * silently skip the file.
@@ -32,15 +32,26 @@ import { resolvePropApi } from './propApiResolver.js';
32
32
  * escape hatches) against prop-mapping/*.mapping.json. A materialized
33
33
  * layout *is* a set of call-site JSX props, so this is the one check
34
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."
35
+ * NOTE — two of propApiResolver's four rules are themselves vacuous by
36
+ * construction here, both for the same reason: vecnaMaterializer.js only
37
+ * ever emits props declared in a component's layoutSchema.
38
+ * · style-escape-hatch checks for STYLE_ESCAPE_ATTRS (className/style)
39
+ * on a call site; no layoutSchema declares either, so it can never
40
+ * fire against Vecna output.
41
+ * · deprecated-prop-passed fires on a propMap entry marked
42
+ * `deprecated`. Measured: the only three in the system are Button's
43
+ * `disabled`/`loading`/`active` (button.mapping.json), all superseded
44
+ * by `state`, and none of the three appears in Button's layoutSchema
45
+ * (label, variant, size, fullWidth, radius). So a Vecna layout has no
46
+ * way to author one, and the rule is 0-reachable here — not by
47
+ * accident, but because the layout schema deliberately offers only the
48
+ * current API.
49
+ * The other two are real and both verified live: invalid-enum-value
50
+ * (an out-of-range enum), and required-prop-missing (Toast's `title` is
51
+ * the one propMap entry marked `required` system-wide, and it *is* in
52
+ * Toast's layoutSchema, so a layout that omits it fires the rule). Same
53
+ * honesty as the wrappers/tokenAliases bullets above — propApi as a whole
54
+ * is not vacuous, but count 2 live rules, not 4, when citing coverage.
44
55
  *
45
56
  * This mirrors R3 point 1's own finding (only 1 of 6 conformance checks
46
57
  * generalizes to arbitrary scanned JSX) one level deeper — see
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import traverseModule from '@babel/traverse';
5
5
 
6
6
  import { loadCatalog, packageNameForPlatform } from './catalog.js';
7
- import { buildModuleGraph, resolveOriginDeep, EXTENSIONS } from './moduleGraph.js';
7
+ import { buildModuleGraph, packageOfSpecifier, resolveOriginDeep, EXTENSIONS } from './moduleGraph.js';
8
8
  import { classifyReference, KIND_CONFIDENCE } from './referenceResolver.js';
9
9
 
10
10
  const traverse = traverseModule.default ?? traverseModule;
@@ -417,7 +417,7 @@ function resolveTagTerminal(file, tagInfo, moduleGraph, declsCache, catalogByNam
417
417
 
418
418
  const { source, imported } = binding;
419
419
  if (!source.startsWith('.') && !source.startsWith('/')) {
420
- if (source === pkgName && catalogByName.has(imported)) {
420
+ if (packageOfSpecifier(source) === pkgName && catalogByName.has(imported)) {
421
421
  return { terminal: 'ds-component', component: imported };
422
422
  }
423
423
  const crossed = resolveWorkspacePackage(source, imported, workspace, catalogByName, pkgName, visited, depth);
@@ -453,7 +453,7 @@ function resolveImportedSymbol(resolvedFile, imported, moduleGraph, declsCache,
453
453
 
454
454
  const deep = resolveOriginDeep(moduleGraph, resolvedFile, imported, { viaExport: true });
455
455
  if (deep?.type === 'package') {
456
- if (deep.pkg === pkgName && catalogByName.has(deep.name)) {
456
+ if (packageOfSpecifier(deep.pkg) === pkgName && catalogByName.has(deep.name)) {
457
457
  return { terminal: 'ds-component', component: deep.name };
458
458
  }
459
459
  const crossed = resolveWorkspacePackage(deep.pkg, deep.name, workspace, catalogByName, pkgName, visited, depth);