@usefragments/core 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/chunk-RANPUC6C.js +72 -0
  2. package/dist/chunk-RANPUC6C.js.map +1 -0
  3. package/dist/{chunk-WVFNDPM4.js → chunk-WNMWKUYG.js} +26 -14
  4. package/dist/chunk-WNMWKUYG.js.map +1 -0
  5. package/dist/codes/index.d.ts +1 -1
  6. package/dist/codes/index.js +1 -1
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/compiled-types/index.js +8 -0
  9. package/dist/generate/index.d.ts +1 -1
  10. package/dist/{governance-DxFipN5V.d.ts → governance-D9KtH-vg.d.ts} +9 -3
  11. package/dist/index.d.ts +165 -139
  12. package/dist/index.js +700 -201
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +36 -36
  16. package/dist/schemas/index.d.ts +1 -1
  17. package/dist/test-utils.d.ts +1 -1
  18. package/package.json +2 -1
  19. package/src/agent-format.test.ts +13 -0
  20. package/src/agent-format.ts +9 -3
  21. package/src/codes/__tests__/codes.test.ts +0 -1
  22. package/src/codes/codes.ts +1 -2
  23. package/src/compiled-types/index.ts +81 -0
  24. package/src/compiled-types/parse.test.ts +47 -0
  25. package/src/facts/builders.ts +13 -0
  26. package/src/facts/compile.ts +13 -13
  27. package/src/facts/facts.test.ts +38 -1
  28. package/src/facts/index.ts +2 -0
  29. package/src/facts/types.ts +15 -0
  30. package/src/governance-integrity.test.ts +98 -1
  31. package/src/governance-integrity.ts +40 -20
  32. package/src/index.ts +8 -0
  33. package/src/rules/a11y-required-accessible-name.ts +175 -28
  34. package/src/rules/a11y-standard.ts +102 -0
  35. package/src/rules/a11y-utils.ts +7 -0
  36. package/src/rules/components-prefer-library.test.ts +75 -28
  37. package/src/rules/components-prefer-library.ts +35 -15
  38. package/src/rules/components-shadow-component.test.ts +21 -9
  39. package/src/rules/emit-gate.test.ts +74 -4
  40. package/src/rules/emit-gate.ts +24 -9
  41. package/src/rules/families.ts +1 -1
  42. package/src/rules/fix-availability.ts +1 -0
  43. package/src/rules/index.ts +12 -2
  44. package/src/rules/rules.test.ts +63 -7
  45. package/src/rules/tiers.ts +1 -0
  46. package/src/tokens/design-token-parser.test.ts +131 -0
  47. package/src/tokens/design-token-parser.ts +362 -49
  48. package/src/types.ts +2 -2
  49. package/dist/chunk-WVFNDPM4.js.map +0 -1
  50. package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
@@ -0,0 +1,102 @@
1
+ import type { FactIndex, UsagePropResolvedFact } from "../facts/index.js";
2
+
3
+ import { isAriaHidden } from "./a11y-utils.js";
4
+ import { makeFinding } from "./finding.js";
5
+ import type { Finding } from "./types.js";
6
+ import { indexPropsByNodeId } from "./utils.js";
7
+
8
+ export const RULE_ID = "a11y/standard";
9
+ export const RULE_VERSION = "1";
10
+
11
+ const KEY_HANDLERS = new Set(["onKeyDown", "onKeyUp", "onKeyPress"]);
12
+
13
+ /**
14
+ * Narrow static standard check: a non-native `role="button"` needs both a
15
+ * keyboard-focus path and a keyboard activation handler. This intentionally
16
+ * does not claim to be a complete WCAG or axe implementation.
17
+ */
18
+ export function ruleA11yStandard(ix: FactIndex): Finding[] {
19
+ const policy = ix.policy.ruleConfig(RULE_ID);
20
+ if (!policy?.enabled) return [];
21
+
22
+ const propsByNode = indexPropsByNodeId(ix);
23
+ const findings: Finding[] = [];
24
+ for (const node of ix.byKind("usage_node")) {
25
+ if (node.role?.trim().toLowerCase() !== "button") continue;
26
+ const props = propsByNode.get(node.id) ?? [];
27
+ if (isAriaHidden(props) || isNativeButton(node.element, props)) continue;
28
+
29
+ const missing = [
30
+ ...(hasFocusableTabIndex(props) ? [] : ["tabIndex"]),
31
+ ...(hasKeyHandler(props) ? [] : ["keyboard handler"]),
32
+ ];
33
+ if (missing.length === 0) continue;
34
+
35
+ findings.push(
36
+ makeFinding({
37
+ ruleId: RULE_ID,
38
+ ruleVersion: RULE_VERSION,
39
+ severity: policy.severity ?? "warn",
40
+ message: `<${node.element} role="button"> is missing ${missing.join(
41
+ " and "
42
+ )}. Non-native buttons need tabIndex plus keyboard activation handling.`,
43
+ location: node.location,
44
+ evidence: ix.evidence([
45
+ node.id,
46
+ policy.id,
47
+ ...props
48
+ .filter(
49
+ (prop) =>
50
+ prop.prop === "tabIndex" ||
51
+ prop.prop === "tabindex" ||
52
+ KEY_HANDLERS.has(prop.prop)
53
+ )
54
+ .map((prop) => prop.id),
55
+ ]),
56
+ fingerprintIdentity: {
57
+ file: node.file,
58
+ element: node.element,
59
+ nodePath: node.nodePath,
60
+ standard: "keyboard-role-button",
61
+ },
62
+ attributes: {
63
+ standard: "keyboard-role-button",
64
+ role: "button",
65
+ missing,
66
+ },
67
+ })
68
+ );
69
+ }
70
+ return findings;
71
+ }
72
+
73
+ function isNativeButton(element: string, props: readonly UsagePropResolvedFact[]): boolean {
74
+ if (element.toLowerCase() === "button") return true;
75
+ if (element.toLowerCase() !== "input") return false;
76
+ return props.some(
77
+ (prop) =>
78
+ prop.prop === "type" &&
79
+ prop.resolution === "static" &&
80
+ ["button", "submit", "reset"].includes(String(prop.value).toLowerCase())
81
+ );
82
+ }
83
+
84
+ function hasFocusableTabIndex(props: readonly UsagePropResolvedFact[]): boolean {
85
+ return props.some((prop) => {
86
+ if (prop.prop !== "tabIndex" && prop.prop !== "tabindex") return false;
87
+ if (prop.resolution === "dynamic") return true;
88
+ if (prop.resolution !== "static") return false;
89
+ const value = typeof prop.value === "number" ? prop.value : Number(prop.value);
90
+ return Number.isFinite(value) && value >= 0;
91
+ });
92
+ }
93
+
94
+ function hasKeyHandler(props: readonly UsagePropResolvedFact[]): boolean {
95
+ return props.some(
96
+ (prop) =>
97
+ KEY_HANDLERS.has(prop.prop) &&
98
+ prop.resolution !== "spread" &&
99
+ prop.value !== null &&
100
+ prop.value !== false
101
+ );
102
+ }
@@ -0,0 +1,7 @@
1
+ import type { UsagePropResolvedFact } from "../facts/index.js";
2
+
3
+ export function isAriaHidden(props: readonly UsagePropResolvedFact[]): boolean {
4
+ const prop = props.find((candidate) => candidate.prop === "aria-hidden");
5
+ if (!prop || prop.resolution !== "static") return false;
6
+ return prop.value === true || prop.value === "true";
7
+ }
@@ -125,20 +125,19 @@ describe("components/prefer-library — canonical direction containment", () =>
125
125
  describe("components/prefer-library — #9e directory import-path identity", () => {
126
126
  const CANONICAL_DIR = "src/components/ui";
127
127
 
128
- function directoryIndex() {
128
+ function directoryIndex(options: { shadowEnabled?: boolean } = {}) {
129
129
  const ix = new FactIndex();
130
130
  ix.addMany(
131
131
  compileGlobalGovernanceFacts({
132
+ canonicalSources: [
133
+ { kind: "directory", path: CANONICAL_DIR, include: ["Button", "Input"] },
134
+ ],
132
135
  rules: {
133
136
  "components/prefer-library": {
134
137
  enabled: true,
135
138
  severity: "warning",
136
- options: {
137
- canonicalSources: [
138
- { kind: "directory", path: CANONICAL_DIR, include: ["Button", "Input"] },
139
- ],
140
- },
141
139
  },
140
+ ...(options.shadowEnabled === false ? { "components/shadow-component": false } : {}),
142
141
  },
143
142
  })
144
143
  );
@@ -184,31 +183,12 @@ describe("components/prefer-library — #9e directory import-path identity", ()
184
183
  expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
185
184
  });
186
185
 
187
- it("flags a local impostor Button imported from outside the canonical directory and redirects to the canonical path", () => {
186
+ it("does not advertise a directory path as an import redirect for a local impostor", () => {
188
187
  const ix = directoryIndex();
189
188
  // No usage_component fact: the impostor's import resolved OUTSIDE the dir.
190
189
  addButtonUsage(ix, { source: "../features/FakeButton" });
191
190
 
192
- const findings = ruleComponentsPreferLibrary(ix);
193
- expect(findings).toHaveLength(1);
194
- expect(findings[0]).toMatchObject({
195
- ruleId: "components/prefer-library",
196
- code: "FUI1004",
197
- fix: {
198
- kind: "replaceImport",
199
- from: "../features/FakeButton",
200
- to: CANONICAL_DIR,
201
- deterministic: false,
202
- },
203
- attributes: {
204
- suggestedComponent: "Button",
205
- suggestedImport: CANONICAL_DIR,
206
- advisory: true,
207
- contractProof: "missing",
208
- },
209
- });
210
- // Advisory only — never a hard error/block.
211
- expect(findings[0].level).toBe("warn");
191
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
212
192
  });
213
193
 
214
194
  it("does not flag a same-name Button imported from a third-party package (collision guard)", () => {
@@ -282,13 +262,22 @@ describe("components/prefer-library — #9e directory import-path identity", ()
282
262
  location: { file: "src/App.tsx", line: 9, column: 4 },
283
263
  });
284
264
  ix.addMany([shadowRoot, unrelated]);
265
+ ix.add(
266
+ makeComponentDefinitionFact({
267
+ file: "src/components/MyButton.tsx",
268
+ exportName: "MyButton",
269
+ componentKey: "src/components/MyButton.tsx#MyButton",
270
+ renderRoot: { resolution: "intrinsic", tag: "button", interactive: true },
271
+ propSurface: [],
272
+ })
273
+ );
285
274
  ix.add(
286
275
  makeComponentIdentityFact({
287
276
  componentKey: "src/components/MyButton.tsx#MyButton",
288
277
  state: "shadow",
289
278
  confidence: "confirmed",
290
279
  canonicalTarget: "Button",
291
- evidence: [shadowRoot.id],
280
+ evidence: [shadowRoot.id, unrelated.id],
292
281
  })
293
282
  );
294
283
 
@@ -296,4 +285,62 @@ describe("components/prefer-library — #9e directory import-path identity", ()
296
285
  expect(findings).toHaveLength(1);
297
286
  expect(findings[0]?.location.file).toBe("src/App.tsx");
298
287
  });
288
+
289
+ it("keeps the usage-level finding when shadow-component is explicitly disabled", () => {
290
+ const ix = directoryIndex({ shadowEnabled: false });
291
+ const shadowRoot = makeUsageNodeFact({
292
+ file: "src/components/MyButton.tsx",
293
+ nodePath: "0:0",
294
+ element: "button",
295
+ interactive: true,
296
+ location: { file: "src/components/MyButton.tsx", line: 5, column: 2 },
297
+ });
298
+ ix.addMany([
299
+ shadowRoot,
300
+ makeComponentDefinitionFact({
301
+ file: "src/components/MyButton.tsx",
302
+ exportName: "MyButton",
303
+ componentKey: "src/components/MyButton.tsx#MyButton",
304
+ renderRoot: { resolution: "intrinsic", tag: "button", interactive: true },
305
+ propSurface: [],
306
+ }),
307
+ makeComponentIdentityFact({
308
+ componentKey: "src/components/MyButton.tsx#MyButton",
309
+ state: "shadow",
310
+ confidence: "confirmed",
311
+ canonicalTarget: "Button",
312
+ evidence: [shadowRoot.id],
313
+ }),
314
+ ]);
315
+
316
+ expect(ruleComponentsPreferLibrary(ix)).toEqual([
317
+ expect.objectContaining({
318
+ ruleId: "components/prefer-library",
319
+ location: expect.objectContaining({ file: "src/components/MyButton.tsx" }),
320
+ }),
321
+ ]);
322
+ });
323
+
324
+ it("keeps the usage-level finding for an orphan shadow identity that cannot emit", () => {
325
+ const ix = directoryIndex();
326
+ const shadowRoot = makeUsageNodeFact({
327
+ file: "src/components/MyButton.tsx",
328
+ nodePath: "0:0",
329
+ element: "button",
330
+ interactive: true,
331
+ location: { file: "src/components/MyButton.tsx", line: 5, column: 2 },
332
+ });
333
+ ix.addMany([
334
+ shadowRoot,
335
+ makeComponentIdentityFact({
336
+ componentKey: "src/components/MyButton.tsx#MyButton",
337
+ state: "shadow",
338
+ confidence: "confirmed",
339
+ canonicalTarget: "Button",
340
+ evidence: [shadowRoot.id],
341
+ }),
342
+ ]);
343
+
344
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(1);
345
+ });
299
346
  });
@@ -279,7 +279,10 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
279
279
  // comparison of the raw specifier could never recognize them, so the resolved
280
280
  // fact is the identity of record. Never flag them as import impostors.
281
281
  const canonicalDirectoryResolvedNodes = indexCanonicalDirectoryResolvedNodes(ix, sources);
282
- const shadowRenderRootNodeIds = indexShadowRenderRootNodeIds(ix);
282
+ const shadowRenderRootNodeIds =
283
+ ix.policy.ruleConfig("components/shadow-component")?.enabled === true
284
+ ? indexShadowRenderRootNodeIds(ix)
285
+ : new Set<FactId>();
283
286
  const findings: Finding[] = [];
284
287
  const seenImportFixes = new Set<string>();
285
288
 
@@ -555,7 +558,10 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
555
558
  continue;
556
559
  }
557
560
  }
558
- const suggestedImport = sourceLabel(suggestion);
561
+ const importReady = suggestion.kind !== "directory";
562
+ const suggestedImport = importReady
563
+ ? sourceLabel(suggestion)
564
+ : "the canonical component directory (resolve its configured import alias)";
559
565
  const precisionTier = builtIn?.tier ?? "exact-html";
560
566
  const rawElement = formatRawElement(node, props);
561
567
 
@@ -563,7 +569,9 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
563
569
  makeFinding({
564
570
  ruleId: RULE_ID,
565
571
  ruleVersion: RULE_VERSION,
566
- severity: severityForTier(policy.severity, precisionTier),
572
+ severity: importReady
573
+ ? severityForTier(policy.severity, precisionTier)
574
+ : capAdvisorySeverity(policy.severity),
567
575
  message: messageForBuiltInMatch({
568
576
  node,
569
577
  rawElement,
@@ -580,7 +588,8 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
580
588
  suggestedComponent,
581
589
  suggestedImport,
582
590
  },
583
- ...(!isRawHtmlAdvisoryTier(precisionTier) &&
591
+ ...(importReady &&
592
+ !isRawHtmlAdvisoryTier(precisionTier) &&
584
593
  isIdentifier(node.element) &&
585
594
  isIdentifier(suggestedComponent)
586
595
  ? {
@@ -599,13 +608,15 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
599
608
  attributes: {
600
609
  rawValue: node.element,
601
610
  suggestedComponent,
602
- suggestedImport,
603
611
  suggestedImportSourceKind: suggestion.kind,
612
+ ...(importReady
613
+ ? { suggestedImport }
614
+ : { canonicalDirectory: suggestion.path, importReady: false }),
604
615
  precisionTier,
605
616
  propCompatibility: "unknown",
606
617
  ...(builtIn?.matchedRole ? { matchedRole: builtIn.matchedRole } : {}),
607
618
  ...(builtIn?.inputType ? { matchedInputType: builtIn.inputType } : {}),
608
- ...(isRawHtmlAdvisoryTier(precisionTier) ? { advisory: true } : {}),
619
+ ...(isRawHtmlAdvisoryTier(precisionTier) || !importReady ? { advisory: true } : {}),
609
620
  },
610
621
  })
611
622
  );
@@ -634,11 +645,24 @@ function indexShadowRenderRootNodeIds(ix: FactIndex): Set<FactId> {
634
645
  const identities = ix.byKind("component_identity");
635
646
  if (identities.length === 0) return new Set();
636
647
 
648
+ const definitions = new Map(
649
+ ix
650
+ .byKind("component_definition")
651
+ .map((definition) => [definition.componentKey, definition] as const)
652
+ );
637
653
  const nodeIds = new Set<FactId>();
638
654
  for (const identity of identities) {
639
655
  if (identity.state !== "shadow") continue;
656
+ // Suppress the usage-level duplicate only when the definition-level shadow
657
+ // rule can actually emit. An orphan identity fact must not turn governance
658
+ // into a no-op by hiding the only actionable finding.
659
+ const definition = definitions.get(identity.componentKey);
660
+ if (!definition) continue;
640
661
  for (const evidenceId of identity.evidence) {
641
- if (ix.get(evidenceId)?.kind === "usage_node") nodeIds.add(evidenceId);
662
+ const evidence = ix.get(evidenceId);
663
+ if (evidence?.kind === "usage_node" && evidence.file === definition.file) {
664
+ nodeIds.add(evidenceId);
665
+ }
642
666
  }
643
667
  }
644
668
  return nodeIds;
@@ -1115,14 +1139,10 @@ function sourceLabel(source: CanonicalSource): string {
1115
1139
  function canonicalImportPath(source: CanonicalSource): string | undefined {
1116
1140
  if (source.kind === "npm") return source.specifier;
1117
1141
  if (source.kind === "registry") return source.importPath;
1118
- // #9e a directory canonical source *does* have a redirect target: its own
1119
- // directory path. Returning it lets the import-identity branch point a local
1120
- // impostor (`<Button>` from `../features/FakeButton`) back at the canonical
1121
- // directory instead of `continue`-ing to zero findings. Gate on an explicit
1122
- // include list: without curated NAMES, `sourceIncludesComponent` matches every
1123
- // PascalCase import, so a redirect target here would blanket-flag them all
1124
- // (#224). Include-less directory sources keep the old no-target behavior.
1125
- return source.include?.length ? source.path : undefined;
1142
+ // A filesystem directory proves ownership but is not necessarily a valid
1143
+ // module specifier. Only an explicit mapping/registry importPath can produce
1144
+ // an import redirect.
1145
+ return undefined;
1126
1146
  }
1127
1147
 
1128
1148
  /**
@@ -54,24 +54,36 @@ describe("components/shadow-component", () => {
54
54
  expect(isDenyEligible(finding!, true)).toBe(false);
55
55
  });
56
56
 
57
- it("honors an error severity for a confirmed shadow but never enters the write deny-set", () => {
57
+ it("allows a confirmed error-severity shadow to enter the shared CI/write deny-set", () => {
58
58
  const finding = ruleComponentsShadowComponent(shadowIndex({ severity: "error" }))[0];
59
59
 
60
60
  expect(finding?.level).toBe("error");
61
61
  expect(finding?.attributes).not.toHaveProperty("advisory");
62
- expect(BLOCKING_RULE_ALLOWLIST.has("components/shadow-component")).toBe(false);
63
- expect(isDenyEligible(finding!, false)).toBe(false);
62
+ expect(BLOCKING_RULE_ALLOWLIST.has("components/shadow-component")).toBe(true);
63
+ expect(isDenyEligible(finding!, false)).toBe(true);
64
+ });
65
+
66
+ it("never denies a likely shadow classification, even when CI severity is error", () => {
67
+ const finding = ruleComponentsShadowComponent(
68
+ shadowIndex({ confidence: "likely", severity: "error" })
69
+ )[0];
70
+
71
+ expect(finding).toMatchObject({
72
+ level: "error",
73
+ attributes: {
74
+ state: "shadow",
75
+ confidence: "likely",
76
+ canonicalTarget: "Button",
77
+ },
78
+ });
79
+ expect(isDenyEligible(finding!, true)).toBe(false);
64
80
  });
65
81
 
66
82
  it("throws rather than emitting an evidence-free finding", () => {
67
83
  const ix = shadowIndex();
68
84
  const identity = ix.byKind("component_identity")[0]!;
69
85
  const empty = new FactIndex();
70
- empty.addMany(
71
- ix
72
- .all()
73
- .filter((fact) => fact.id !== identity.id)
74
- );
86
+ empty.addMany(ix.all().filter((fact) => fact.id !== identity.id));
75
87
  empty.add(makeComponentIdentityFact({ ...identity, evidence: [] }));
76
88
 
77
89
  expect(() => ruleComponentsShadowComponent(empty)).toThrow(
@@ -83,7 +95,7 @@ describe("components/shadow-component", () => {
83
95
  function shadowIndex(
84
96
  options: {
85
97
  state?: "shadow" | "variant";
86
- confidence?: "confirmed" | "review";
98
+ confidence?: "confirmed" | "likely" | "review";
87
99
  severity?: "warn" | "error";
88
100
  definitionLine?: number;
89
101
  } = {}
@@ -11,6 +11,18 @@ const base = {
11
11
  fix: { deterministic: true },
12
12
  } as unknown as Finding;
13
13
 
14
+ const confirmedShadowAttributes = {
15
+ state: "shadow",
16
+ confidence: "confirmed",
17
+ canonicalTarget: "Button",
18
+ };
19
+
20
+ const confirmedShadow = {
21
+ ruleId: "components/shadow-component",
22
+ severity: "serious",
23
+ attributes: confirmedShadowAttributes,
24
+ } as unknown as Finding;
25
+
14
26
  describe("isDenyEligible", () => {
15
27
  it("denies a confident, allowlisted, CI-gating, deterministic finding", () => {
16
28
  expect(isDenyEligible(base, false)).toBe(true);
@@ -35,6 +47,15 @@ describe("isDenyEligible", () => {
35
47
  expect(isDenyEligible(warn, true)).toBe(true);
36
48
  });
37
49
 
50
+ it("denies a source-backed off-contract token at error severity", () => {
51
+ const tokenDrift = {
52
+ ruleId: "tokens/css-vars-must-be-defined",
53
+ severity: "serious",
54
+ evidenceGrade: "source_backed",
55
+ } as unknown as Finding;
56
+ expect(isDenyEligible(tokenDrift, false)).toBe(true);
57
+ });
58
+
38
59
  it.each(["none", "runtime_advisory", "provenance_bound"] as const)(
39
60
  "never gates or denies %s evidence",
40
61
  (evidenceGrade) => {
@@ -64,17 +85,66 @@ describe("isDenyEligible", () => {
64
85
  const exactComponent = {
65
86
  ...base,
66
87
  fix: { deterministic: false },
67
- attributes: { precisionTier: "exact-html", propCompatibility: "observed-complete" },
88
+ attributes: {
89
+ precisionTier: "exact-html",
90
+ propCompatibility: "observed-complete",
91
+ suggestedComponent: "Button",
92
+ },
68
93
  } as unknown as Finding;
69
94
  expect(isDenyEligible(exactComponent, false)).toBe(true);
70
95
  });
71
96
 
72
- it("keeps a component replacement advisory when observed props are not proven compatible", () => {
97
+ it("denies an exact canonical bypass even when no deterministic replacement is available", () => {
73
98
  const incompleteComponent = {
74
99
  ...base,
75
100
  fix: { deterministic: false },
76
- attributes: { precisionTier: "exact-html", propCompatibility: "incomplete" },
101
+ attributes: {
102
+ precisionTier: "exact-html",
103
+ propCompatibility: "unknown",
104
+ suggestedComponent: "Button",
105
+ suggestedImportSourceKind: "directory",
106
+ },
77
107
  } as unknown as Finding;
78
- expect(isDenyEligible(incompleteComponent, false)).toBe(false);
108
+ expect(isDenyEligible(incompleteComponent, false)).toBe(true);
109
+ });
110
+
111
+ it("denies a confirmed, non-advisory shadow that gates CI", () => {
112
+ expect(isDenyEligible(confirmedShadow, false)).toBe(true);
113
+ });
114
+
115
+ it("does not deny a non-confirmed or malformed shadow classification", () => {
116
+ expect(
117
+ isDenyEligible(
118
+ {
119
+ ...confirmedShadow,
120
+ attributes: { ...confirmedShadowAttributes, confidence: "likely" },
121
+ } as unknown as Finding,
122
+ false
123
+ )
124
+ ).toBe(false);
125
+ expect(
126
+ isDenyEligible(
127
+ {
128
+ ...confirmedShadow,
129
+ attributes: { ...confirmedShadowAttributes, state: "variant" },
130
+ } as unknown as Finding,
131
+ false
132
+ )
133
+ ).toBe(false);
134
+ expect(
135
+ isDenyEligible(
136
+ {
137
+ ...confirmedShadow,
138
+ attributes: { state: "shadow", confidence: "confirmed" },
139
+ } as unknown as Finding,
140
+ false
141
+ )
142
+ ).toBe(false);
143
+ });
144
+
145
+ it("only denies a warn-level confirmed shadow under failOnWarnings", () => {
146
+ const warn = { ...confirmedShadow, severity: "moderate" } as unknown as Finding;
147
+ expect(isDenyEligible(warn, false)).toBe(false);
148
+ expect(isDenyEligible(warn, true)).toBe(true);
79
149
  });
80
150
  });
@@ -27,7 +27,9 @@ export const BLOCKING_RULE_ALLOWLIST: ReadonlySet<string> = new Set([
27
27
  "styles/no-raw-spacing", // off-scale / untokenized spacing literal
28
28
  "tailwind/off-scale-spacing-token", // off-scale Tailwind spacing utility
29
29
  "components/prefer-library", // raw element bypasses a canonical component
30
+ "components/shadow-component", // confirmed local semantic control shadows a canonical
30
31
  "components/preferred-component", // imported component bypasses its canonical
32
+ "tokens/css-vars-must-be-defined", // off-contract custom property reference
31
33
  ]);
32
34
 
33
35
  /**
@@ -52,18 +54,31 @@ export function isDenyEligible(finding: Finding, failOnWarnings: boolean): boole
52
54
  // their severity or sets failOnWarnings. Same "only deny when the engine is
53
55
  // confident" principle as the non-deterministic-fix guard below.
54
56
  if (finding.attributes?.advisory === true) return false;
57
+ // A shadow classification is deterministic enough to deny only when the
58
+ // identity engine confirmed both the shadow state and its canonical target.
59
+ // Likely/review identities remain reportable, but the write gate stays a
60
+ // strict high-confidence subset of CI.
61
+ if (
62
+ finding.ruleId === "components/shadow-component" &&
63
+ (finding.attributes?.state !== "shadow" ||
64
+ finding.attributes?.confidence !== "confirmed" ||
65
+ typeof finding.attributes?.canonicalTarget !== "string")
66
+ ) {
67
+ return false;
68
+ }
55
69
  if (!gatesCi(finding, failOnWarnings)) return false;
56
70
  // Most explicitly non-deterministic fixes are the engine's own low-confidence
57
- // signal (for example, several tokens share a value). A prefer-library
58
- // finding may still be an exact policy violation, but hard-denying it is only
59
- // DX-safe when the configured mapping proves that every observed prop can be
60
- // preserved. The conform AST preflight independently checks the source and
61
- // binding before any edit is applied.
71
+ // signal (for example, several tokens share a value). Exact raw-HTML bypasses
72
+ // are different: the declared canonical source proves the policy violation
73
+ // even when the engine cannot safely rewrite every prop. Enforcement and
74
+ // autofix confidence are intentionally separate; advisory precision tiers
75
+ // were already rejected above.
62
76
  if (finding.fix?.deterministic === false) {
63
- if (
64
- finding.ruleId !== "components/prefer-library" ||
65
- finding.attributes?.propCompatibility !== "observed-complete"
66
- ) {
77
+ const exactCanonicalBypass =
78
+ finding.ruleId === "components/prefer-library" &&
79
+ finding.attributes?.precisionTier === "exact-html" &&
80
+ typeof finding.attributes?.suggestedComponent === "string";
81
+ if (!exactCanonicalBypass) {
67
82
  return false;
68
83
  }
69
84
  }
@@ -33,7 +33,7 @@ export const RULE_FAMILY_MEMBERS: Readonly<Record<string, readonly string[]>> =
33
33
  "components/unknown-prop",
34
34
  "props/invalid-value",
35
35
  ],
36
- "a11y/wcag": ["a11y/required-accessible-name"],
36
+ "a11y/wcag": ["a11y/required-accessible-name", "a11y/standard"],
37
37
  };
38
38
 
39
39
  /** The family ids themselves — recognized in `govern.rules`, never executed. */
@@ -28,6 +28,7 @@ export const RULE_FIX_AVAILABLE = {
28
28
  "tokens/upstream-drift": false,
29
29
  "theme/no-theme-coupled-literal": false,
30
30
  "a11y/required-accessible-name": false,
31
+ "a11y/standard": false,
31
32
  "composition/cardinality": false,
32
33
  "composition/co-occurrence": false,
33
34
  } as const satisfies Readonly<Record<string, boolean>>;
@@ -9,7 +9,11 @@
9
9
 
10
10
  import type { FactIndex } from "../facts/index.js";
11
11
 
12
- import { ruleA11yRequiredAccessibleName } from "./a11y-required-accessible-name.js";
12
+ import {
13
+ RULE_VERSION as A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION,
14
+ ruleA11yRequiredAccessibleName,
15
+ } from "./a11y-required-accessible-name.js";
16
+ import { ruleA11yStandard } from "./a11y-standard.js";
13
17
  import { ruleComponentsPreferLibrary } from "./components-prefer-library.js";
14
18
  import { ruleComponentsShadowComponent } from "./components-shadow-component.js";
15
19
  import { ruleComponentsForbiddenPropValue } from "./components-forbidden-prop-value.js";
@@ -150,9 +154,14 @@ export const RULES: readonly Rule[] = [
150
154
  },
151
155
  {
152
156
  id: "a11y/required-accessible-name",
153
- version: "1",
157
+ version: A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION,
154
158
  run: ruleA11yRequiredAccessibleName,
155
159
  },
160
+ {
161
+ id: "a11y/standard",
162
+ version: "1",
163
+ run: ruleA11yStandard,
164
+ },
156
165
  {
157
166
  id: "composition/cardinality",
158
167
  version: "1",
@@ -220,6 +229,7 @@ export { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-define
220
229
  export { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
221
230
  export { ruleThemeNoThemeCoupledLiteral } from "./theme-no-theme-coupled-literal.js";
222
231
  export { ruleA11yRequiredAccessibleName } from "./a11y-required-accessible-name.js";
232
+ export { ruleA11yStandard } from "./a11y-standard.js";
223
233
  export { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js";
224
234
  export {
225
235
  RULE_TIER,