@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
@@ -370,7 +370,7 @@ describe("FactIndex — query layer", () => {
370
370
  expect(unknownProps?.severity).toBe("error");
371
371
  });
372
372
 
373
- it("activates prefer-library from top-level canonical sources", () => {
373
+ it("activates canonical-component rules from top-level canonical sources", () => {
374
374
  const ix = new FactIndex();
375
375
  ix.addMany(
376
376
  compileGlobalGovernanceFacts({
@@ -397,6 +397,43 @@ describe("FactIndex — query layer", () => {
397
397
  ],
398
398
  },
399
399
  });
400
+ expect(ix.policy.ruleConfig("components/shadow-component")).toMatchObject({
401
+ enabled: true,
402
+ severity: "warn",
403
+ options: {
404
+ canonicalSources: [
405
+ {
406
+ kind: "npm",
407
+ specifier: "@usefragments/ui",
408
+ include: ["Button"],
409
+ },
410
+ ],
411
+ },
412
+ });
413
+ });
414
+
415
+ it("preserves an explicit shadow-component disable when canonical sources are declared", () => {
416
+ const ix = new FactIndex();
417
+ ix.addMany(
418
+ compileGlobalGovernanceFacts({
419
+ canonicalSources: [
420
+ {
421
+ kind: "npm",
422
+ specifier: "@usefragments/ui",
423
+ include: ["Button"],
424
+ },
425
+ ],
426
+ rules: {
427
+ "components/shadow-component": false,
428
+ },
429
+ })
430
+ );
431
+
432
+ expect(ix.policy.ruleConfig("components/prefer-library")?.enabled).toBe(true);
433
+ expect(ix.policy.ruleConfig("components/shadow-component")).toMatchObject({
434
+ enabled: false,
435
+ ruleId: "components/shadow-component",
436
+ });
400
437
  });
401
438
 
402
439
  it("resolves preferred import and component policy facts", () => {
@@ -48,6 +48,7 @@ export type {
48
48
  GovernanceRuleConfigFact,
49
49
  UsageNodeFact,
50
50
  UsageComponentFact,
51
+ UsageChildContentFact,
51
52
  UsageImportFact,
52
53
  UsagePropResolvedFact,
53
54
  UsageInlineStyleFact,
@@ -106,6 +107,7 @@ export {
106
107
  makeGovernanceRuleConfigFact,
107
108
  makeUsageNodeFact,
108
109
  makeUsageComponentFact,
110
+ makeUsageChildContentFact,
109
111
  makeUsageImportFact,
110
112
  makeUsagePropResolvedFact,
111
113
  makeUsageInlineStyleFact,
@@ -364,6 +364,20 @@ export interface UsageTextChildFact extends BaseFact {
364
364
  index: number;
365
365
  }
366
366
 
367
+ /**
368
+ * Framework-neutral summary of the rendered child content for one usage node.
369
+ *
370
+ * An adapter that supports semantic child analysis preserves its existing prop
371
+ * facts and additionally emits exactly one summary per usage node, so rules do
372
+ * not have to interpret React's `children`, Vue slots, or Svelte snippets
373
+ * themselves. Adapters may add support independently.
374
+ */
375
+ export interface UsageChildContentFact extends BaseFact {
376
+ kind: "usage_child_content";
377
+ nodeId: FactId;
378
+ content: "static-text" | "runtime" | "structural" | "empty";
379
+ }
380
+
367
381
  // ---------------------------------------------------------------------------
368
382
  // Tailwind className facts.
369
383
  //
@@ -722,6 +736,7 @@ export type UsageFact =
722
736
  | UsagePropResolvedFact
723
737
  | UsageInlineStyleFact
724
738
  | UsageTextChildFact
739
+ | UsageChildContentFact
725
740
  | StyleDeclarationFact
726
741
  | StyleUnsupportedFact
727
742
  | ClassNameLiteralFact
@@ -343,7 +343,10 @@ describe("evaluateGovernanceIntegrity", () => {
343
343
  canonicalSources: [{ kind: "directory", path: "ui" }],
344
344
  };
345
345
  const verdict = evaluate({ policy, policySource: "config", declared: true });
346
- expect(verdict.families.find((f) => f.id === "components")?.armed).toBe(true);
346
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
347
+ armed: true,
348
+ rules: ["components/prefer-library", "components/shadow-component"],
349
+ });
347
350
  expect(verdict.status).toBe("healthy");
348
351
  expect(verdict.blockingCapable).toBe(false);
349
352
  expect(verdict.armed).toContain("components");
@@ -372,6 +375,83 @@ describe("evaluateGovernanceIntegrity", () => {
372
375
  expect(verdict.blockingCapable).toBe(true);
373
376
  });
374
377
 
378
+ it("4c. prefer-library disabled + canonical source leaves shadow governance armed", () => {
379
+ const policy: GovernanceConfig = {
380
+ severity: "error",
381
+ canonicalSources: [{ kind: "directory", path: "ui" }],
382
+ rules: { "components/prefer-library": false },
383
+ };
384
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
385
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
386
+ armed: true,
387
+ rules: ["components/shadow-component"],
388
+ });
389
+ expect(verdict.families.find((f) => f.id === "blocking-deny")?.rules).toEqual([
390
+ "components/shadow-component",
391
+ ]);
392
+ expect(verdict.status).toBe("healthy");
393
+ expect(verdict.blockingCapable).toBe(true);
394
+ });
395
+
396
+ it("4d. shadow-component disabled + canonical source leaves prefer-library armed", () => {
397
+ const policy: GovernanceConfig = {
398
+ severity: "error",
399
+ canonicalSources: [{ kind: "directory", path: "ui" }],
400
+ rules: { "components/shadow-component": false },
401
+ };
402
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
403
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
404
+ armed: true,
405
+ rules: ["components/prefer-library"],
406
+ });
407
+ expect(verdict.families.find((f) => f.id === "blocking-deny")?.rules).toEqual([
408
+ "components/prefer-library",
409
+ ]);
410
+ });
411
+
412
+ it("4e. explicitly disabling both canonical-component rules leaves the policy inert", () => {
413
+ const policy: GovernanceConfig = {
414
+ severity: "error",
415
+ canonicalSources: [{ kind: "directory", path: "ui" }],
416
+ rules: {
417
+ "components/prefer-library": false,
418
+ "components/shadow-component": false,
419
+ },
420
+ };
421
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
422
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
423
+ armed: false,
424
+ rules: [],
425
+ reason: "canonical-component rules not enabled",
426
+ });
427
+ expect(verdict.status).toBe("inert");
428
+ expect(verdict.blockingCapable).toBe(false);
429
+ });
430
+
431
+ it("4f. vocabulary on a disabled sibling rule cannot arm shadow governance", () => {
432
+ const policy: GovernanceConfig = {
433
+ rules: {
434
+ "components/prefer-library": {
435
+ enabled: false,
436
+ options: {
437
+ canonicalMappings: [
438
+ { name: "Button", htmlEquivalent: "button", importPath: "@acme/ui" },
439
+ ],
440
+ },
441
+ },
442
+ "components/shadow-component": { enabled: true, severity: "error" },
443
+ },
444
+ };
445
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
446
+ expect(verdict.families.find((f) => f.id === "components")).toMatchObject({
447
+ armed: false,
448
+ rules: ["components/shadow-component"],
449
+ reason: "component governance enabled but no effective canonical source",
450
+ });
451
+ expect(verdict.status).toBe("inert");
452
+ expect(verdict.blockingCapable).toBe(false);
453
+ });
454
+
375
455
  it("5. npm source with NO include → components NOT armed → inert (blocker-#2 lock)", () => {
376
456
  const policy: GovernanceConfig = {
377
457
  canonicalSources: [{ kind: "npm", specifier: "@acme/ui" }],
@@ -445,6 +525,23 @@ describe("evaluateGovernanceIntegrity", () => {
445
525
  expect(tokens?.reason).toBe("no token vocabulary");
446
526
  });
447
527
 
528
+ it("7c. error-level token drift with a vocabulary is blocking-capable", () => {
529
+ const policy: GovernanceConfig = {
530
+ styles: [{ kind: "style.cssVars.mustBeDefined", severity: "error" }],
531
+ };
532
+ const verdict = evaluate({
533
+ policy,
534
+ policySource: "config",
535
+ declared: true,
536
+ cssVarsActive: true,
537
+ tokenVocabularySize: 5,
538
+ });
539
+ expect(verdict.families.find((family) => family.id === "blocking-deny")?.rules).toContain(
540
+ "tokens/css-vars-must-be-defined"
541
+ );
542
+ expect(verdict.blockingCapable).toBe(true);
543
+ });
544
+
448
545
  it("8. hygiene-only policy (raw-style rule enabled, no contract) → degraded", () => {
449
546
  const policy: GovernanceConfig = {
450
547
  rules: { "styles/no-raw-color": { enabled: true, severity: "warn" } },
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * The load-bearing distinction is **enabled ≠ armed**. A rule can be `enabled`
6
6
  * in the policy yet inert because it has no vocabulary to measure against:
7
- * `components/prefer-library` with no effective canonical source enforces
7
+ * canonical-component rules with no effective canonical source enforce
8
8
  * nothing; `tokens/css-vars-must-be-defined` with an empty token vocabulary
9
9
  * enforces nothing. This module derives, per rule *family*, whether the family
10
10
  * is armed (has both an enabled rule AND the vocabulary/signal it needs), then
@@ -119,7 +119,7 @@ export function isEffectiveCanonicalSource(source: CanonicalSource): boolean {
119
119
  }
120
120
 
121
121
  /**
122
- * Whether `components/prefer-library`'s effective options carry a real component
122
+ * Whether a canonical-component rule's effective options carry a real component
123
123
  * vocabulary: at least one canonical mapping, or at least one effective
124
124
  * canonical source. Accepts the raw fact `options` bag (values are `unknown`).
125
125
  */
@@ -138,18 +138,27 @@ export function hasEffectiveComponentVocabulary(
138
138
  /**
139
139
  * Compile the policy to effective per-rule configs, keyed by rule id. Reuses the
140
140
  * fact compiler so the effective enabled/severity/options match exactly what the
141
- * scan engine sees (including the canonicalSources → prefer-library injection).
141
+ * scan engine sees (including canonicalSources → component-rule injection).
142
142
  */
143
143
  function effectiveRuleConfigs(
144
144
  policy: GovernanceConfig | undefined
145
145
  ): Map<string, EffectiveRuleConfig> {
146
146
  const configs = new Map<string, EffectiveRuleConfig>();
147
- for (const fact of compileGlobalGovernanceFacts(policy)) {
148
- if (fact.kind !== "governance_rule_config") continue;
149
- configs.set(fact.ruleId, {
150
- enabled: fact.enabled,
151
- severity: fact.severity,
152
- options: fact.options,
147
+ const facts = compileGlobalGovernanceFacts(policy);
148
+ for (const fact of facts) {
149
+ if (fact.kind === "governance_rule_config") {
150
+ configs.set(fact.ruleId, {
151
+ enabled: fact.enabled,
152
+ severity: fact.severity,
153
+ options: fact.options,
154
+ });
155
+ }
156
+ }
157
+ const cssVarsPolicy = facts.find((fact) => fact.kind === "style_css_vars_must_be_defined");
158
+ if (cssVarsPolicy?.kind === "style_css_vars_must_be_defined") {
159
+ configs.set("tokens/css-vars-must-be-defined", {
160
+ enabled: true,
161
+ severity: cssVarsPolicy.severity,
153
162
  });
154
163
  }
155
164
  return configs;
@@ -768,28 +777,34 @@ export function evaluateGovernanceIntegrity(
768
777
  }
769
778
 
770
779
  // --- components family ----------------------------------------------------
771
- const componentsConfig = configs.get("components/prefer-library");
780
+ const preferLibraryConfig = configs.get("components/prefer-library");
781
+ const shadowComponentConfig = configs.get("components/shadow-component");
772
782
  const confirmedBridges = input.policy?.canonicalBridges ?? [];
773
- const componentsArmed =
774
- confirmedBridges.length > 0 ||
775
- (componentsConfig?.enabled === true &&
776
- hasEffectiveComponentVocabulary(componentsConfig.options));
783
+ const componentRuleEnabled =
784
+ preferLibraryConfig?.enabled === true || shadowComponentConfig?.enabled === true;
785
+ const preferLibraryArmed =
786
+ preferLibraryConfig?.enabled === true &&
787
+ hasEffectiveComponentVocabulary(preferLibraryConfig.options);
788
+ const shadowComponentArmed =
789
+ shadowComponentConfig?.enabled === true &&
790
+ hasEffectiveComponentVocabulary(shadowComponentConfig.options);
791
+ const componentsArmed = confirmedBridges.length > 0 || preferLibraryArmed || shadowComponentArmed;
777
792
  const componentsFamily: GovernanceIntegrityFamily = {
778
793
  id: "components",
779
794
  armed: componentsArmed,
780
795
  rules: [
781
796
  ...(confirmedBridges.length > 0 ? ["imports/preferred-path"] : []),
782
- ...(componentsConfig?.enabled === true ? ["components/prefer-library"] : []),
797
+ ...(preferLibraryConfig?.enabled === true ? ["components/prefer-library"] : []),
798
+ ...(shadowComponentConfig?.enabled === true ? ["components/shadow-component"] : []),
783
799
  ],
784
800
  };
785
801
  if (!componentsArmed) {
786
- if (componentsConfig?.enabled === true) {
787
- componentsFamily.reason =
788
- "components/prefer-library enabled but no effective canonical source";
802
+ if (componentRuleEnabled) {
803
+ componentsFamily.reason = "component governance enabled but no effective canonical source";
789
804
  componentsFamily.remediation =
790
805
  "add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
791
806
  } else {
792
- componentsFamily.reason = "components/prefer-library not enabled";
807
+ componentsFamily.reason = "canonical-component rules not enabled";
793
808
  componentsFamily.remediation =
794
809
  "add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
795
810
  }
@@ -841,7 +856,12 @@ export function evaluateGovernanceIntegrity(
841
856
  for (const ruleId of BLOCKING_RULE_ALLOWLIST) {
842
857
  const config = configs.get(ruleId);
843
858
  if (config?.enabled !== true) continue;
844
- const familyArmed = ruleId === "components/prefer-library" ? componentsArmed : hygieneArmed;
859
+ const familyArmed =
860
+ ruleId === "components/prefer-library" || ruleId === "components/shadow-component"
861
+ ? componentsArmed
862
+ : ruleId === "tokens/css-vars-must-be-defined"
863
+ ? tokensArmed
864
+ : hygieneArmed;
845
865
  if (!familyArmed) continue;
846
866
  if (config.severity !== "error" && !failOnWarnings) continue;
847
867
  blockingRules.push(ruleId);
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,
@@ -666,6 +671,7 @@ export {
666
671
  ruleTailwindUnknownClass,
667
672
  ruleTokensRequireDualFallback,
668
673
  ruleA11yRequiredAccessibleName,
674
+ ruleA11yStandard,
669
675
  } from "./rules/index.js";
670
676
  export { indexComponentIdentityUsage, type ComponentIdentityUsage } from "./identity/usage.js";
671
677
  export { nearestSignedScaleValue } from "./rules/utils.js";
@@ -820,6 +826,7 @@ export {
820
826
  makeTailwindUnknownClassEnabledFact,
821
827
  makeUsageNodeFact,
822
828
  makeUsageComponentFact,
829
+ makeUsageChildContentFact,
823
830
  makeUsageImportFact,
824
831
  makeUsagePropResolvedFact,
825
832
  makeUsageInlineStyleFact,
@@ -963,6 +970,7 @@ export type {
963
970
  TailwindUnknownClassEnabledFact,
964
971
  UsageNodeFact,
965
972
  UsageComponentFact,
973
+ UsageChildContentFact,
966
974
  UsageImportFact,
967
975
  UsagePropResolvedFact,
968
976
  UsageInlineStyleFact,
@@ -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
  }