@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.
- package/dist/chunk-RANPUC6C.js +72 -0
- package/dist/chunk-RANPUC6C.js.map +1 -0
- package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
- package/dist/chunk-WNMWKUYG.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- package/dist/codes/index.js +1 -1
- package/dist/compiled-types/index.d.ts +1 -1
- package/dist/compiled-types/index.js +8 -0
- package/dist/generate/index.d.ts +1 -1
- package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
- package/dist/index.d.ts +800 -179
- package/dist/index.js +1003 -237
- package/dist/index.js.map +1 -1
- package/dist/react-types.d.ts +1 -1
- package/dist/registry.d.ts +36 -36
- package/dist/schemas/index.d.ts +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/package.json +2 -1
- package/src/__tests__/policy-exclude.test.ts +180 -0
- package/src/agent-format.test.ts +13 -0
- package/src/agent-format.ts +9 -3
- package/src/canonical-bridge.ts +69 -1
- package/src/canonical-direction.test.ts +118 -0
- package/src/canonical-direction.ts +43 -2
- package/src/codes/__tests__/codes.test.ts +14 -2
- package/src/codes/codes.ts +41 -2
- package/src/compiled-types/index.ts +81 -0
- package/src/compiled-types/parse.test.ts +47 -0
- package/src/facts/builders.ts +48 -0
- package/src/facts/compile.ts +148 -34
- package/src/facts/fact-index.ts +15 -2
- package/src/facts/facts.test.ts +44 -3
- package/src/facts/index.ts +11 -6
- package/src/facts/types.ts +60 -9
- package/src/governance-integrity.test.ts +272 -4
- package/src/governance-integrity.ts +345 -25
- package/src/governance.ts +87 -1
- package/src/index.ts +46 -1
- package/src/policy-exclude.ts +113 -0
- package/src/rules/a11y-required-accessible-name.ts +175 -28
- package/src/rules/a11y-standard.ts +102 -0
- package/src/rules/a11y-utils.ts +7 -0
- package/src/rules/components-prefer-library.test.ts +75 -28
- package/src/rules/components-prefer-library.ts +35 -15
- package/src/rules/components-shadow-component.test.ts +21 -9
- package/src/rules/emit-gate.test.ts +74 -4
- package/src/rules/emit-gate.ts +24 -9
- package/src/rules/families.test.ts +69 -0
- package/src/rules/families.ts +52 -0
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +18 -2
- package/src/rules/jsx-preferred-import-path.ts +29 -11
- package/src/rules/rules.test.ts +188 -8
- package/src/rules/styles-no-raw-color.ts +13 -4
- package/src/rules/styles-no-raw-dimensions.ts +13 -4
- package/src/rules/styles-no-raw-spacing.ts +12 -4
- package/src/rules/styles-no-raw-typography.ts +13 -4
- package/src/rules/tiers.ts +1 -0
- package/src/rules/utils.ts +39 -0
- package/src/tokens/design-token-parser.test.ts +131 -0
- package/src/tokens/design-token-parser.ts +362 -49
- package/src/types.ts +2 -2
- package/dist/chunk-BAHCOAVG.js.map +0 -1
- 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 {
|
|
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
|
|
3
|
-
* `a11y.requireName` policy
|
|
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 {
|
|
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
|
|
31
|
+
import { indexPropsByNodeId, indexTextChildrenByNodeId } from "./utils.js";
|
|
22
32
|
|
|
23
33
|
export const RULE_ID = "a11y/required-accessible-name";
|
|
24
|
-
export const RULE_VERSION = "
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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 (
|
|
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:
|
|
50
|
-
message: `<${node.element}> has no accessible name. ${
|
|
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([
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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 (!
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|