@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.
- package/dist/chunk-RANPUC6C.js +72 -0
- package/dist/chunk-RANPUC6C.js.map +1 -0
- package/dist/{chunk-WVFNDPM4.js → chunk-WNMWKUYG.js} +26 -14
- package/dist/chunk-WNMWKUYG.js.map +1 -0
- package/dist/codes/index.d.ts +1 -1
- 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-DxFipN5V.d.ts → governance-D9KtH-vg.d.ts} +9 -3
- package/dist/index.d.ts +165 -139
- package/dist/index.js +700 -201
- 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/agent-format.test.ts +13 -0
- package/src/agent-format.ts +9 -3
- package/src/codes/__tests__/codes.test.ts +0 -1
- package/src/codes/codes.ts +1 -2
- package/src/compiled-types/index.ts +81 -0
- package/src/compiled-types/parse.test.ts +47 -0
- package/src/facts/builders.ts +13 -0
- package/src/facts/compile.ts +13 -13
- package/src/facts/facts.test.ts +38 -1
- package/src/facts/index.ts +2 -0
- package/src/facts/types.ts +15 -0
- package/src/governance-integrity.test.ts +98 -1
- package/src/governance-integrity.ts +40 -20
- package/src/index.ts +8 -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.ts +1 -1
- package/src/rules/fix-availability.ts +1 -0
- package/src/rules/index.ts +12 -2
- package/src/rules/rules.test.ts +63 -7
- package/src/rules/tiers.ts +1 -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-WVFNDPM4.js.map +0 -1
- package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
package/src/facts/facts.test.ts
CHANGED
|
@@ -370,7 +370,7 @@ describe("FactIndex — query layer", () => {
|
|
|
370
370
|
expect(unknownProps?.severity).toBe("error");
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
-
it("activates
|
|
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", () => {
|
package/src/facts/index.ts
CHANGED
|
@@ -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,
|
package/src/facts/types.ts
CHANGED
|
@@ -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")
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
...(
|
|
797
|
+
...(preferLibraryConfig?.enabled === true ? ["components/prefer-library"] : []),
|
|
798
|
+
...(shadowComponentConfig?.enabled === true ? ["components/shadow-component"] : []),
|
|
783
799
|
],
|
|
784
800
|
};
|
|
785
801
|
if (!componentsArmed) {
|
|
786
|
-
if (
|
|
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 = "
|
|
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 =
|
|
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
|
|
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
|
}
|