@usefragments/core 1.5.2 → 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 (64) hide show
  1. package/dist/chunk-RANPUC6C.js +72 -0
  2. package/dist/chunk-RANPUC6C.js.map +1 -0
  3. package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
  4. package/dist/chunk-WNMWKUYG.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  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-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
  11. package/dist/index.d.ts +800 -179
  12. package/dist/index.js +1003 -237
  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/__tests__/policy-exclude.test.ts +180 -0
  20. package/src/agent-format.test.ts +13 -0
  21. package/src/agent-format.ts +9 -3
  22. package/src/canonical-bridge.ts +69 -1
  23. package/src/canonical-direction.test.ts +118 -0
  24. package/src/canonical-direction.ts +43 -2
  25. package/src/codes/__tests__/codes.test.ts +14 -2
  26. package/src/codes/codes.ts +41 -2
  27. package/src/compiled-types/index.ts +81 -0
  28. package/src/compiled-types/parse.test.ts +47 -0
  29. package/src/facts/builders.ts +48 -0
  30. package/src/facts/compile.ts +148 -34
  31. package/src/facts/fact-index.ts +15 -2
  32. package/src/facts/facts.test.ts +44 -3
  33. package/src/facts/index.ts +11 -6
  34. package/src/facts/types.ts +60 -9
  35. package/src/governance-integrity.test.ts +272 -4
  36. package/src/governance-integrity.ts +345 -25
  37. package/src/governance.ts +87 -1
  38. package/src/index.ts +46 -1
  39. package/src/policy-exclude.ts +113 -0
  40. package/src/rules/a11y-required-accessible-name.ts +175 -28
  41. package/src/rules/a11y-standard.ts +102 -0
  42. package/src/rules/a11y-utils.ts +7 -0
  43. package/src/rules/components-prefer-library.test.ts +75 -28
  44. package/src/rules/components-prefer-library.ts +35 -15
  45. package/src/rules/components-shadow-component.test.ts +21 -9
  46. package/src/rules/emit-gate.test.ts +74 -4
  47. package/src/rules/emit-gate.ts +24 -9
  48. package/src/rules/families.test.ts +69 -0
  49. package/src/rules/families.ts +52 -0
  50. package/src/rules/fix-availability.ts +1 -0
  51. package/src/rules/index.ts +18 -2
  52. package/src/rules/jsx-preferred-import-path.ts +29 -11
  53. package/src/rules/rules.test.ts +188 -8
  54. package/src/rules/styles-no-raw-color.ts +13 -4
  55. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  56. package/src/rules/styles-no-raw-spacing.ts +12 -4
  57. package/src/rules/styles-no-raw-typography.ts +13 -4
  58. package/src/rules/tiers.ts +1 -0
  59. package/src/rules/utils.ts +39 -0
  60. package/src/tokens/design-token-parser.test.ts +131 -0
  61. package/src/tokens/design-token-parser.ts +362 -49
  62. package/src/types.ts +2 -2
  63. package/dist/chunk-BAHCOAVG.js.map +0 -1
  64. package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
package/src/index.ts CHANGED
@@ -133,6 +133,11 @@ export {
133
133
  } from "./config-paths.js";
134
134
 
135
135
  // Types
136
+ export {
137
+ CompiledFragmentsFileValidationError,
138
+ parseCompiledFragmentsFile,
139
+ } from "./compiled-types/index.js";
140
+
136
141
  export type {
137
142
  FragmentComponent,
138
143
  ComponentRef,
@@ -448,6 +453,8 @@ export {
448
453
  globalStyleGovernanceRecordSchema,
449
454
  governanceConfigSchema,
450
455
  governanceSeveritySchema,
456
+ isPresetSourcedRule,
457
+ markPresetSourcedRules,
451
458
  normalizeGovernanceConfig,
452
459
  scaleGovernanceRecordSchema,
453
460
  } from "./governance.js";
@@ -471,6 +478,18 @@ export type {
471
478
  ScaleGovernanceRecord,
472
479
  } from "./governance.js";
473
480
 
481
+ // Scoped policy exemptions — per-record / per-rule path excludes
482
+ export {
483
+ describePolicyExclude,
484
+ excludeGlobToRegExp,
485
+ matchPolicyExclude,
486
+ normalizeExcludePath,
487
+ normalizePolicyExcludes,
488
+ policyExcludeMatchesPath,
489
+ policyExcludeSchema,
490
+ } from "./policy-exclude.js";
491
+ export type { PolicyExclude, PolicyExcludeInput } from "./policy-exclude.js";
492
+
474
493
  // Story adapter (runtime conversion of Storybook modules)
475
494
  export {
476
495
  storyModuleToFragment,
@@ -652,6 +671,7 @@ export {
652
671
  ruleTailwindUnknownClass,
653
672
  ruleTokensRequireDualFallback,
654
673
  ruleA11yRequiredAccessibleName,
674
+ ruleA11yStandard,
655
675
  } from "./rules/index.js";
656
676
  export { indexComponentIdentityUsage, type ComponentIdentityUsage } from "./identity/usage.js";
657
677
  export { nearestSignedScaleValue } from "./rules/utils.js";
@@ -706,6 +726,15 @@ export {
706
726
  } from "./rules/index.js";
707
727
  export type { RuleTier, PresetRuleState } from "./rules/index.js";
708
728
 
729
+ // Rule families — the ONE expansion of a broad `govern.rules` id (`tokens/hardcoded-values`)
730
+ // into the concrete rules it stands for. Three copies used to disagree.
731
+ export {
732
+ RULE_FAMILY_IDS,
733
+ RULE_FAMILY_MEMBERS,
734
+ isRuleFamilyId,
735
+ ruleFamilyMembers,
736
+ } from "./rules/index.js";
737
+
709
738
  // Blocking deny-set (Contract Mode Phase 0): shared "may this finding hard-deny
710
739
  // a write" criteria — single-sourced for the CLI hook + the emit gate.
711
740
  export { BLOCKING_RULE_ALLOWLIST, gatesCi, isDenyEligible } from "./rules/index.js";
@@ -713,8 +742,13 @@ export { BLOCKING_RULE_ALLOWLIST, gatesCi, isDenyEligible } from "./rules/index.
713
742
  // Governance integrity — armed-vs-declared verdict over a fully-resolved policy.
714
743
  // Encodes "enabled ≠ armed": a rule with no vocabulary enforces nothing.
715
744
  export {
745
+ collidingRecordDiagnostic,
746
+ collidingRecordDiagnostics,
747
+ configRecordShape,
748
+ overriddenRecordSeverityDiagnostic,
716
749
  detectOrphanGovernanceScales,
717
750
  detectUnconsumedConfigKeys,
751
+ detectUnmatchedPolicyExcludes,
718
752
  evaluateGovernanceIntegrity,
719
753
  hasEffectiveComponentVocabulary,
720
754
  isEffectiveCanonicalSource,
@@ -737,7 +771,12 @@ export {
737
771
  } from "./canonical-direction.js";
738
772
  export type { CanonicalDirectionConflict } from "./canonical-direction.js";
739
773
 
740
- export { canonicalBridgeContractMappings } from "./canonical-bridge.js";
774
+ export {
775
+ canonicalBridgeContractMappings,
776
+ canonicalBridgeIdentityBindings,
777
+ canonicalBridgeIdentitySources,
778
+ } from "./canonical-bridge.js";
779
+ export type { CanonicalBridgeIdentityBinding } from "./canonical-bridge.js";
741
780
 
742
781
  export {
743
782
  compileEffectiveGovernanceInputs,
@@ -787,6 +826,7 @@ export {
787
826
  makeTailwindUnknownClassEnabledFact,
788
827
  makeUsageNodeFact,
789
828
  makeUsageComponentFact,
829
+ makeUsageChildContentFact,
790
830
  makeUsageImportFact,
791
831
  makeUsagePropResolvedFact,
792
832
  makeUsageInlineStyleFact,
@@ -800,7 +840,9 @@ export {
800
840
  makeTailwindTokenResolvedFact,
801
841
  compileGlobalGovernanceFacts,
802
842
  compileComponentFacts,
843
+ projectSupersededImportPathPreferences,
803
844
  } from "./facts/index.js";
845
+ export type { SupersededImportPathPreference } from "./facts/index.js";
804
846
  export {
805
847
  canonicalPreimage,
806
848
  AGENT_CONTEXT_RELATIVE_PATH,
@@ -890,6 +932,7 @@ export type {
890
932
  ComponentId,
891
933
  FactId,
892
934
  Fact,
935
+ FactConflict,
893
936
  FactKind,
894
937
  FactOfKind,
895
938
  FactLocation,
@@ -927,11 +970,13 @@ export type {
927
970
  TailwindUnknownClassEnabledFact,
928
971
  UsageNodeFact,
929
972
  UsageComponentFact,
973
+ UsageChildContentFact,
930
974
  UsageImportFact,
931
975
  UsagePropResolvedFact,
932
976
  UsageInlineStyleFact,
933
977
  UsageTextChildFact,
934
978
  StyleDeclarationFact,
979
+ StyleValueProvenance,
935
980
  StyleUnsupportedFact,
936
981
  UnsupportedStyleReason,
937
982
  ClassNameLiteralFact,
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Policy path excludes — the one vocabulary every scoped-exemption surface shares.
3
+ *
4
+ * A global governance record (`govern.styles[]` / `govern.jsx[]`) or a rule-id-keyed
5
+ * override (`govern.rules["styles/no-raw-color"]`) may carry `exclude` globs that scope
6
+ * that policy off a path *without* hiding the path from every other rule — the
7
+ * difference between an exemption and a baseline. Two authoring forms are accepted:
8
+ *
9
+ * ```ts
10
+ * exclude: ["src/legacy/vendor/**"]
11
+ * exclude: [{ glob: "src/legacy/vendor/**", reason: "vendor drop, tracked in DS-611" }]
12
+ * ```
13
+ *
14
+ * Both normalize to `PolicyExclude`. The reason is free text and carries no expiry: an
15
+ * exclude is a standing scope decision, not a dated debt receipt (that is what
16
+ * suppressions and baselines are for). It travels into the scan's ignored-finding
17
+ * accounting so an exemption is never invisible policy.
18
+ *
19
+ * Browser-safe: pure string work, no filesystem.
20
+ */
21
+
22
+ import { z } from "zod";
23
+
24
+ export interface PolicyExclude {
25
+ /** Repo-relative glob; `*` stops at a path segment, `**` crosses segments. */
26
+ glob: string;
27
+ /** Why this path is out of scope for this policy. Surfaced in reports. */
28
+ reason?: string;
29
+ }
30
+
31
+ export const policyExcludeSchema = z.array(
32
+ z.union([
33
+ z.string().min(1),
34
+ z.object({
35
+ glob: z.string().min(1),
36
+ reason: z.string().min(1).optional(),
37
+ }),
38
+ ])
39
+ );
40
+
41
+ /** The authored (pre-normalization) shape of an `exclude` list. */
42
+ export type PolicyExcludeInput = z.infer<typeof policyExcludeSchema>;
43
+
44
+ /**
45
+ * Normalize an authored `exclude` list into `PolicyExclude[]`. Returns `undefined` when
46
+ * nothing usable was authored, so callers can omit the field entirely and keep fact
47
+ * shapes byte-identical for configs that declare no excludes.
48
+ */
49
+ export function normalizePolicyExcludes(value: unknown): PolicyExclude[] | undefined {
50
+ if (!Array.isArray(value)) return undefined;
51
+ const out: PolicyExclude[] = [];
52
+ for (const entry of value) {
53
+ if (typeof entry === "string") {
54
+ if (entry.length > 0) out.push({ glob: entry });
55
+ continue;
56
+ }
57
+ if (!entry || typeof entry !== "object") continue;
58
+ const record = entry as { glob?: unknown; reason?: unknown };
59
+ if (typeof record.glob !== "string" || record.glob.length === 0) continue;
60
+ out.push({
61
+ glob: record.glob,
62
+ ...(typeof record.reason === "string" && record.reason.length > 0
63
+ ? { reason: record.reason }
64
+ : {}),
65
+ });
66
+ }
67
+ return out.length > 0 ? out : undefined;
68
+ }
69
+
70
+ /** Normalize a repo path for glob comparison (POSIX separators, no `./` prefix). */
71
+ export function normalizeExcludePath(path: string): string {
72
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
73
+ }
74
+
75
+ /**
76
+ * Same glob dialect as the rest of the governance surface (`FactIndex.matchesGlob`,
77
+ * the scan's token-source matcher): `**` crosses path segments, `*` does not.
78
+ */
79
+ export function excludeGlobToRegExp(glob: string): RegExp {
80
+ const source = normalizeExcludePath(glob)
81
+ .split("**")
82
+ .map((part) =>
83
+ part
84
+ .split("*")
85
+ .map((segment) => segment.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"))
86
+ .join("[^/]*")
87
+ )
88
+ .join(".*");
89
+ return new RegExp(`^${source}$`);
90
+ }
91
+
92
+ /** Whether `filePath` is inside `exclude`'s glob. */
93
+ export function policyExcludeMatchesPath(exclude: PolicyExclude, filePath: string): boolean {
94
+ return excludeGlobToRegExp(exclude.glob).test(normalizeExcludePath(filePath));
95
+ }
96
+
97
+ /** The first exclude covering `filePath`, or `undefined`. */
98
+ export function matchPolicyExclude(
99
+ excludes: readonly PolicyExclude[] | undefined,
100
+ filePath: string
101
+ ): PolicyExclude | undefined {
102
+ if (!excludes?.length) return undefined;
103
+ const normalized = normalizeExcludePath(filePath);
104
+ return excludes.find((exclude) => excludeGlobToRegExp(exclude.glob).test(normalized));
105
+ }
106
+
107
+ /**
108
+ * The human half of the ignored-finding receipt: which policy stepped aside, on which
109
+ * glob, and why. `scope` is the policy's own identity (a record `kind` or a rule id).
110
+ */
111
+ export function describePolicyExclude(scope: string, exclude: PolicyExclude): string {
112
+ return `${scope} exclude matched ${exclude.glob}${exclude.reason ? ` — ${exclude.reason}` : ""}`;
113
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `a11y/required-accessible-name` — fires for components with an
3
- * `a11y.requireName` policy whose JSX usage has no discoverable accessible
4
- * name.
2
+ * `a11y/required-accessible-name` — fires for a component with an authored
3
+ * `a11y.requireName` policy, or for a statically proven control when the global
4
+ * rule is enabled, whose JSX usage has no discoverable accessible name.
5
5
  *
6
6
  * Accepted name sources (any one is enough):
7
7
  * - non-empty `usage_text_child` content
@@ -9,55 +9,107 @@
9
9
  * dynamic value is empty at scan time)
10
10
  * - `aria-labelledby={...}`
11
11
  * - `title="..."`
12
+ * - statically non-empty or conservatively classified runtime child content
13
+ * on controls whose accessible-name model permits naming from contents
12
14
  *
13
15
  * Components that explicitly opt out via `aria-hidden="true"` are skipped —
14
16
  * they are not part of the accessibility tree.
15
17
  */
16
18
 
17
- import type { FactIndex, UsagePropResolvedFact } from "../facts/index.js";
19
+ import type {
20
+ ComponentDefinitionFact,
21
+ FactIndex,
22
+ UsageComponentFact,
23
+ UsageChildContentFact,
24
+ UsageNodeFact,
25
+ UsagePropResolvedFact,
26
+ } from "../facts/index.js";
18
27
 
28
+ import { isAriaHidden } from "./a11y-utils.js";
19
29
  import { makeFinding } from "./finding.js";
20
30
  import type { Finding } from "./types.js";
21
- import { indexPropsByNodeId, indexTextChildrenByNodeId, readUsageNode } from "./utils.js";
31
+ import { indexPropsByNodeId, indexTextChildrenByNodeId } from "./utils.js";
22
32
 
23
33
  export const RULE_ID = "a11y/required-accessible-name";
24
- export const RULE_VERSION = "1";
34
+ export const RULE_VERSION = "2";
25
35
 
26
36
  const NAME_SOURCE_PROPS = new Set(["aria-label", "aria-labelledby", "title"]);
37
+ const COMPONENT_NAME_SOURCE_PROPS = new Set([...NAME_SOURCE_PROPS, "label"]);
38
+ const NAME_REQUIRED_TAGS = new Set(["button", "input", "select", "textarea"]);
39
+ const NAME_REQUIRED_ROLES = new Set(["button", "checkbox", "radio", "switch", "tab"]);
40
+ const NAME_FROM_CONTENT_TAGS = new Set(["button"]);
41
+ const NAME_FROM_CONTENT_ROLES = new Set(["button", "checkbox", "radio", "switch", "tab"]);
42
+ const NAME_REQUIRED_COMPONENT_SUFFIXES = [
43
+ "button",
44
+ "input",
45
+ "select",
46
+ "textarea",
47
+ "checkbox",
48
+ "radio",
49
+ "switch",
50
+ ] as const;
27
51
 
28
52
  export function ruleA11yRequiredAccessibleName(ix: FactIndex): Finding[] {
29
53
  const findings: Finding[] = [];
30
54
  const propsByNode = indexPropsByNodeId(ix);
31
55
  const textByNode = indexTextChildrenByNodeId(ix);
56
+ const childContentByNode = new Map(
57
+ ix.byKind("usage_child_content").map((content) => [content.nodeId, content])
58
+ );
59
+ const globalPolicy = ix.policy.ruleConfig(RULE_ID);
60
+ const usageByNode = new Map(ix.byKind("usage_component").map((usage) => [usage.nodeId, usage]));
61
+ const definitions = new Map(
62
+ ix.byKind("component_definition").map((definition) => [definition.componentKey, definition])
63
+ );
32
64
 
33
- for (const usageComponent of ix.byKind("usage_component")) {
34
- const policy = ix.policy.a11yNameRequired(usageComponent.componentId);
35
- if (!policy) continue;
36
-
37
- const node = readUsageNode(ix, usageComponent.nodeId);
38
- if (!node) continue;
39
-
65
+ for (const node of ix.byKind("usage_node")) {
66
+ const usageComponent = usageByNode.get(node.id);
67
+ const componentPolicy = usageComponent
68
+ ? ix.policy.a11yNameRequired(usageComponent.componentId)
69
+ : undefined;
40
70
  const props = propsByNode.get(node.id) ?? [];
41
- if (props.some(isAriaHidden)) continue;
71
+ if (isAriaHidden(props)) continue;
72
+ const genericControl =
73
+ globalPolicy?.enabled === true &&
74
+ requiresAccessibleName(node, usageComponent, definitions, props);
75
+ if (!componentPolicy && !genericControl) continue;
42
76
 
43
- if (hasAccessibleName(props, textByNode.get(node.id) ?? [])) continue;
77
+ if (
78
+ hasAccessibleName(
79
+ props,
80
+ textByNode.get(node.id) ?? [],
81
+ childContentByNode.get(node.id),
82
+ usageComponent !== undefined,
83
+ canNameFromContent(node, usageComponent, definitions)
84
+ )
85
+ ) {
86
+ continue;
87
+ }
44
88
 
45
89
  findings.push(
46
90
  makeFinding({
47
91
  ruleId: RULE_ID,
48
92
  ruleVersion: RULE_VERSION,
49
- severity: policy.severity,
50
- message: `<${node.element}> has no accessible name. ${policy.because}`.trim(),
93
+ severity: componentPolicy?.severity ?? globalPolicy?.severity ?? "warn",
94
+ message: `<${node.element}> has no accessible name. ${
95
+ componentPolicy?.because ??
96
+ "Add visible text, aria-label, aria-labelledby, or a component label."
97
+ }`.trim(),
51
98
  location: node.location,
52
- evidence: ix.evidence([node.id, usageComponent.id, policy.id]),
99
+ evidence: ix.evidence([
100
+ node.id,
101
+ ...(usageComponent ? [usageComponent.id] : []),
102
+ ...(componentPolicy ? [componentPolicy.id] : []),
103
+ ...(genericControl && globalPolicy ? [globalPolicy.id] : []),
104
+ ]),
53
105
  fingerprintIdentity: {
54
106
  file: node.file,
55
- componentId: usageComponent.componentId,
107
+ componentId: usageComponent?.componentId,
56
108
  element: node.element,
57
109
  nodePath: node.nodePath,
58
110
  },
59
111
  attributes: {
60
- componentId: usageComponent.componentId,
112
+ ...(usageComponent ? { componentId: usageComponent.componentId } : {}),
61
113
  element: node.element,
62
114
  },
63
115
  })
@@ -69,13 +121,22 @@ export function ruleA11yRequiredAccessibleName(ix: FactIndex): Finding[] {
69
121
 
70
122
  function hasAccessibleName(
71
123
  props: UsagePropResolvedFact[],
72
- textChildren: ReadonlyArray<{ text: string }>
124
+ textChildren: ReadonlyArray<{ text: string }>,
125
+ childContent: UsageChildContentFact | undefined,
126
+ componentControl: boolean,
127
+ nameFromContent: boolean
73
128
  ): boolean {
74
- for (const child of textChildren) {
75
- if (typeof child.text === "string" && child.text.trim().length > 0) return true;
129
+ const sourceProps = componentControl ? COMPONENT_NAME_SOURCE_PROPS : NAME_SOURCE_PROPS;
130
+ if (nameFromContent) {
131
+ for (const child of textChildren) {
132
+ if (typeof child.text === "string" && child.text.trim().length > 0) return true;
133
+ }
134
+ if (childContent?.content === "static-text" || childContent?.content === "runtime") {
135
+ return true;
136
+ }
76
137
  }
77
138
  for (const prop of props) {
78
- if (!NAME_SOURCE_PROPS.has(prop.prop)) continue;
139
+ if (!sourceProps.has(prop.prop)) continue;
79
140
  if (prop.resolution === "spread") continue;
80
141
  if (prop.resolution === "dynamic") return true;
81
142
  if (prop.resolution === "static") {
@@ -86,8 +147,94 @@ function hasAccessibleName(
86
147
  return false;
87
148
  }
88
149
 
89
- function isAriaHidden(prop: UsagePropResolvedFact): boolean {
90
- if (prop.prop !== "aria-hidden") return false;
91
- if (prop.resolution !== "static") return false;
92
- return prop.value === true || prop.value === "true";
150
+ function canNameFromContent(
151
+ node: UsageNodeFact,
152
+ usage: UsageComponentFact | undefined,
153
+ definitions: ReadonlyMap<string, ComponentDefinitionFact>
154
+ ): boolean {
155
+ const element = node.element.toLowerCase();
156
+ if (NAME_REQUIRED_TAGS.has(element)) return NAME_FROM_CONTENT_TAGS.has(element);
157
+ if (node.role && NAME_FROM_CONTENT_ROLES.has(node.role.toLowerCase())) return true;
158
+ if (!usage) return false;
159
+
160
+ const definition = definitions.get(String(usage.componentId));
161
+ if (definition) {
162
+ const root = definition.renderRoot;
163
+ if (root.resolution === "intrinsic") {
164
+ return intrinsicCanNameFromContent(root.tag, root.role);
165
+ }
166
+ if (root.resolution === "canonical") return isContentNamedControl(root.canonical);
167
+ if (root.resolution === "mixed") {
168
+ return (
169
+ root.intrinsics.some((tag) => intrinsicCanNameFromContent(tag)) ||
170
+ root.canonicals.some(isContentNamedControl)
171
+ );
172
+ }
173
+ }
174
+ return isContentNamedControl(String(usage.componentId));
175
+ }
176
+
177
+ function intrinsicCanNameFromContent(tag: string, role?: string): boolean {
178
+ const normalizedTag = tag.toLowerCase();
179
+ if (NAME_REQUIRED_TAGS.has(normalizedTag)) {
180
+ return NAME_FROM_CONTENT_TAGS.has(normalizedTag);
181
+ }
182
+ return role !== undefined && NAME_FROM_CONTENT_ROLES.has(role.toLowerCase());
183
+ }
184
+
185
+ function requiresAccessibleName(
186
+ node: UsageNodeFact,
187
+ usage: UsageComponentFact | undefined,
188
+ definitions: ReadonlyMap<string, ComponentDefinitionFact>,
189
+ props: readonly UsagePropResolvedFact[]
190
+ ): boolean {
191
+ if (node.role && NAME_REQUIRED_ROLES.has(node.role.toLowerCase())) return true;
192
+ if (NAME_REQUIRED_TAGS.has(node.element.toLowerCase())) {
193
+ if (
194
+ node.element.toLowerCase() === "input" &&
195
+ props.some(
196
+ (prop) =>
197
+ prop.prop === "type" &&
198
+ prop.resolution === "static" &&
199
+ String(prop.value).toLowerCase() === "hidden"
200
+ )
201
+ ) {
202
+ return false;
203
+ }
204
+ return true;
205
+ }
206
+ if (!usage) return false;
207
+
208
+ const definition = definitions.get(String(usage.componentId));
209
+ if (definition) {
210
+ const root = definition.renderRoot;
211
+ if (root.resolution === "intrinsic") {
212
+ return (
213
+ NAME_REQUIRED_TAGS.has(root.tag.toLowerCase()) ||
214
+ (root.role !== undefined && NAME_REQUIRED_ROLES.has(root.role.toLowerCase()))
215
+ );
216
+ }
217
+ if (root.resolution === "canonical") return isNamedControl(root.canonical);
218
+ if (root.resolution === "mixed") {
219
+ return (
220
+ root.intrinsics.some((tag) => NAME_REQUIRED_TAGS.has(tag.toLowerCase())) ||
221
+ root.canonicals.some(isNamedControl)
222
+ );
223
+ }
224
+ }
225
+ return isNamedControl(String(usage.componentId));
226
+ }
227
+
228
+ function isContentNamedControl(identity: string): boolean {
229
+ return normalizedControlName(identity).endsWith("button");
230
+ }
231
+
232
+ function isNamedControl(identity: string): boolean {
233
+ const normalized = normalizedControlName(identity);
234
+ return NAME_REQUIRED_COMPONENT_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
235
+ }
236
+
237
+ function normalizedControlName(identity: string): string {
238
+ const exported = identity.split("#").at(-1)?.split(".").at(-1) ?? identity;
239
+ return exported.replace(/[^a-z0-9]/gi, "").toLowerCase();
93
240
  }
@@ -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
+ }