@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.
- package/README.md +42 -4
- package/package.json +12 -1
- package/src/adopt/a11yPass.js +397 -0
- package/src/adopt/adoptGate.js +471 -0
- package/src/adopt/adoptScanReport.js +9 -0
- package/src/adopt/componentPropApi.js +200 -0
- package/src/adopt/findingSnippet.js +386 -0
- package/src/adopt/foreignDiscoveryResolver.js +20 -3
- package/src/adopt/foreignScoringResolver.js +163 -22
- package/src/adopt/moduleGraph.js +44 -2
- package/src/adopt/prCheckReport.js +274 -0
- package/src/adopt/propApiResolver.js +2 -2
- package/src/adopt/referenceResolver.js +2 -2
- package/src/adopt/scanRollup.js +192 -0
- package/src/adopt/tailwindResolver.js +6 -1
- package/src/adopt/targetDiscovery.js +31 -3
- package/src/adopt/tokenAliasResolver.js +45 -2
- package/src/adopt/usageRulesResolver.js +299 -14
- package/src/adopt/vecnaMaterializer.js +45 -5
- package/src/adopt/vecnaVerifier.js +20 -9
- package/src/adopt/wrapperResolver.js +3 -3
- package/src/cli.js +343 -8
- package/src/data.js +105 -11
- package/src/server.js +69 -14
- package/src/whisperer.d.ts +106 -0
- package/src/whisperer.js +814 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
181
|
+
const subComponent = name.type === 'JSXMemberExpression'
|
|
147
182
|
&& name.object.type === 'JSXIdentifier' && name.object.name === localName
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
222
|
-
|
|
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
|
|
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
|
-
|
|
502
|
+
entries.push({
|
|
503
|
+
node,
|
|
504
|
+
section: overlaySectionOf(elPath, localToOverlay),
|
|
505
|
+
constraints: guardOf(elPath),
|
|
506
|
+
});
|
|
251
507
|
},
|
|
252
508
|
});
|
|
253
509
|
|
|
254
|
-
if (
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
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)
|
|
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 —
|
|
36
|
-
* construction here
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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);
|