@usefragments/core 1.9.1 → 1.10.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.
@@ -1,5 +1,5 @@
1
1
  import { ComponentType, ReactNode, JSX } from 'react';
2
- export { F as FragmentDefinition, a as FragmentDefinitionV2 } from './governance-eEzCyfes.js';
2
+ export { F as FragmentDefinition, a as FragmentDefinitionV2 } from './governance-B5WWcmFY.js';
3
3
  import 'zod';
4
4
  import './topology/index.js';
5
5
  import './types-xJ2xyp_G.js';
@@ -215,7 +215,7 @@ declare const registryComponentSchema: z.ZodObject<{
215
215
  componentId: string;
216
216
  name: string;
217
217
  publicRef: string;
218
- tier: "composition" | "core" | "custom" | "utility";
218
+ tier: "core" | "composition" | "custom" | "utility";
219
219
  provenance: Record<string, unknown>;
220
220
  examples: unknown[];
221
221
  files: {
@@ -263,7 +263,7 @@ declare const registryComponentSchema: z.ZodObject<{
263
263
  status?: string | undefined;
264
264
  category?: string | undefined;
265
265
  contract?: unknown;
266
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
266
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
267
267
  provenance?: Record<string, unknown> | undefined;
268
268
  examples?: unknown[] | undefined;
269
269
  styleFiles?: string[] | undefined;
@@ -497,7 +497,7 @@ declare const registryManifestDraftSchema: z.ZodObject<{
497
497
  componentId: string;
498
498
  name: string;
499
499
  publicRef: string;
500
- tier: "composition" | "core" | "custom" | "utility";
500
+ tier: "core" | "composition" | "custom" | "utility";
501
501
  provenance: Record<string, unknown>;
502
502
  examples: unknown[];
503
503
  files: {
@@ -545,7 +545,7 @@ declare const registryManifestDraftSchema: z.ZodObject<{
545
545
  status?: string | undefined;
546
546
  category?: string | undefined;
547
547
  contract?: unknown;
548
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
548
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
549
549
  provenance?: Record<string, unknown> | undefined;
550
550
  examples?: unknown[] | undefined;
551
551
  styleFiles?: string[] | undefined;
@@ -610,7 +610,7 @@ declare const registryManifestDraftSchema: z.ZodObject<{
610
610
  componentId: string;
611
611
  name: string;
612
612
  publicRef: string;
613
- tier: "composition" | "core" | "custom" | "utility";
613
+ tier: "core" | "composition" | "custom" | "utility";
614
614
  provenance: Record<string, unknown>;
615
615
  examples: unknown[];
616
616
  files: {
@@ -712,7 +712,7 @@ declare const registryManifestDraftSchema: z.ZodObject<{
712
712
  status?: string | undefined;
713
713
  category?: string | undefined;
714
714
  contract?: unknown;
715
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
715
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
716
716
  provenance?: Record<string, unknown> | undefined;
717
717
  examples?: unknown[] | undefined;
718
718
  styleFiles?: string[] | undefined;
@@ -970,7 +970,7 @@ declare const registryManifestSchema: z.ZodObject<{
970
970
  componentId: string;
971
971
  name: string;
972
972
  publicRef: string;
973
- tier: "composition" | "core" | "custom" | "utility";
973
+ tier: "core" | "composition" | "custom" | "utility";
974
974
  provenance: Record<string, unknown>;
975
975
  examples: unknown[];
976
976
  files: {
@@ -1018,7 +1018,7 @@ declare const registryManifestSchema: z.ZodObject<{
1018
1018
  status?: string | undefined;
1019
1019
  category?: string | undefined;
1020
1020
  contract?: unknown;
1021
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
1021
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
1022
1022
  provenance?: Record<string, unknown> | undefined;
1023
1023
  examples?: unknown[] | undefined;
1024
1024
  styleFiles?: string[] | undefined;
@@ -1085,7 +1085,7 @@ declare const registryManifestSchema: z.ZodObject<{
1085
1085
  componentId: string;
1086
1086
  name: string;
1087
1087
  publicRef: string;
1088
- tier: "composition" | "core" | "custom" | "utility";
1088
+ tier: "core" | "composition" | "custom" | "utility";
1089
1089
  provenance: Record<string, unknown>;
1090
1090
  examples: unknown[];
1091
1091
  files: {
@@ -1188,7 +1188,7 @@ declare const registryManifestSchema: z.ZodObject<{
1188
1188
  status?: string | undefined;
1189
1189
  category?: string | undefined;
1190
1190
  contract?: unknown;
1191
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
1191
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
1192
1192
  provenance?: Record<string, unknown> | undefined;
1193
1193
  examples?: unknown[] | undefined;
1194
1194
  styleFiles?: string[] | undefined;
@@ -1449,7 +1449,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
1449
1449
  componentId: string;
1450
1450
  name: string;
1451
1451
  publicRef: string;
1452
- tier: "composition" | "core" | "custom" | "utility";
1452
+ tier: "core" | "composition" | "custom" | "utility";
1453
1453
  provenance: Record<string, unknown>;
1454
1454
  examples: unknown[];
1455
1455
  files: {
@@ -1497,7 +1497,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
1497
1497
  status?: string | undefined;
1498
1498
  category?: string | undefined;
1499
1499
  contract?: unknown;
1500
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
1500
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
1501
1501
  provenance?: Record<string, unknown> | undefined;
1502
1502
  examples?: unknown[] | undefined;
1503
1503
  styleFiles?: string[] | undefined;
@@ -1564,7 +1564,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
1564
1564
  componentId: string;
1565
1565
  name: string;
1566
1566
  publicRef: string;
1567
- tier: "composition" | "core" | "custom" | "utility";
1567
+ tier: "core" | "composition" | "custom" | "utility";
1568
1568
  provenance: Record<string, unknown>;
1569
1569
  examples: unknown[];
1570
1570
  files: {
@@ -1667,7 +1667,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
1667
1667
  status?: string | undefined;
1668
1668
  category?: string | undefined;
1669
1669
  contract?: unknown;
1670
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
1670
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
1671
1671
  provenance?: Record<string, unknown> | undefined;
1672
1672
  examples?: unknown[] | undefined;
1673
1673
  styleFiles?: string[] | undefined;
@@ -1772,7 +1772,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
1772
1772
  componentId: string;
1773
1773
  name: string;
1774
1774
  publicRef: string;
1775
- tier: "composition" | "core" | "custom" | "utility";
1775
+ tier: "core" | "composition" | "custom" | "utility";
1776
1776
  provenance: Record<string, unknown>;
1777
1777
  examples: unknown[];
1778
1778
  files: {
@@ -1885,7 +1885,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
1885
1885
  status?: string | undefined;
1886
1886
  category?: string | undefined;
1887
1887
  contract?: unknown;
1888
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
1888
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
1889
1889
  provenance?: Record<string, unknown> | undefined;
1890
1890
  examples?: unknown[] | undefined;
1891
1891
  styleFiles?: string[] | undefined;
@@ -1978,7 +1978,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
1978
1978
  componentId: string;
1979
1979
  name: string;
1980
1980
  publicRef: string;
1981
- tier: "composition" | "core" | "custom" | "utility";
1981
+ tier: "core" | "composition" | "custom" | "utility";
1982
1982
  provenance: Record<string, unknown>;
1983
1983
  examples: unknown[];
1984
1984
  files: {
@@ -2091,7 +2091,7 @@ declare const registryArtifactSchema: z.ZodEffects<z.ZodObject<{
2091
2091
  status?: string | undefined;
2092
2092
  category?: string | undefined;
2093
2093
  contract?: unknown;
2094
- tier?: "composition" | "core" | "custom" | "utility" | undefined;
2094
+ tier?: "core" | "composition" | "custom" | "utility" | undefined;
2095
2095
  provenance?: Record<string, unknown> | undefined;
2096
2096
  examples?: unknown[] | undefined;
2097
2097
  styleFiles?: string[] | undefined;
@@ -1,3 +1,3 @@
1
1
  import 'zod';
2
2
  export { LegacySeverityLevel, Severity, SeverityLevel, legacySeverityLevelSchema, severityLevelSchema, severitySchema } from '../severity.js';
3
- export { b as AGENT_FORMAT_SCHEMA_VERSION, c as AgentErrorEnvelope, A as AgentFormat, d as AgentOutput, Q as FactEvidence, R as FactLocation, F as Finding, a as FindingFix, f as FindingReplaceClassTokenFix, g as FindingReplaceComponentFix, h as FindingReplaceImportFix, i as FindingReplacePropValueFix, j as FindingReplaceStyleValueFix, k as Fix, G as GovernanceVerdict, l as GovernanceVerdictMetadata, S as SuppressionDirective, V as ValidatorResult, n as Violation, o as agentErrorEnvelopeSchema, T as agentFindingSchema, p as agentFormatSchema, q as agentIntegritySchema, r as agentOutputSchema, U as agentPlanSchema, t as factEvidenceSchema, u as factLocationSchema, v as findingFixSchema, w as findingReplaceClassTokenFixSchema, x as findingReplaceComponentFixSchema, y as findingReplaceImportFixSchema, z as findingReplacePropValueFixSchema, B as findingReplaceStyleValueFixSchema, C as findingSchema, D as fixSchema, H as governanceVerdictMetadataSchema, J as governanceVerdictSchema, K as normalizeFinding, L as normalizeSeverity, M as normalizeViolation, N as suppressionDirectiveSchema, O as validatorResultSchema, P as violationSchema } from '../index-_sxhUNqx.js';
3
+ export { b as AGENT_FORMAT_SCHEMA_VERSION, c as AgentErrorEnvelope, A as AgentFormat, d as AgentOutput, Q as FactEvidence, R as FactLocation, F as Finding, a as FindingFix, f as FindingReplaceClassTokenFix, g as FindingReplaceComponentFix, h as FindingReplaceImportFix, i as FindingReplacePropValueFix, j as FindingReplaceStyleValueFix, k as Fix, G as GovernanceVerdict, l as GovernanceVerdictMetadata, S as SuppressionDirective, V as ValidatorResult, n as Violation, o as agentErrorEnvelopeSchema, T as agentFindingSchema, p as agentFormatSchema, q as agentIntegritySchema, r as agentOutputSchema, U as agentPlanSchema, t as factEvidenceSchema, u as factLocationSchema, v as findingFixSchema, w as findingReplaceClassTokenFixSchema, x as findingReplaceComponentFixSchema, y as findingReplaceImportFixSchema, z as findingReplacePropValueFixSchema, B as findingReplaceStyleValueFixSchema, C as findingSchema, D as fixSchema, H as governanceVerdictMetadataSchema, J as governanceVerdictSchema, K as normalizeFinding, L as normalizeSeverity, M as normalizeViolation, N as suppressionDirectiveSchema, O as validatorResultSchema, P as violationSchema } from '../index-DtHxs0Pf.js';
@@ -24,7 +24,7 @@ import {
24
24
  suppressionDirectiveSchema,
25
25
  validatorResultSchema,
26
26
  violationSchema
27
- } from "../chunk-DGHZQTLH.js";
27
+ } from "../chunk-XN3LSDPY.js";
28
28
  import "../chunk-JNBFJ34I.js";
29
29
  import "../chunk-EIYNNS77.js";
30
30
  import {
@@ -1,4 +1,4 @@
1
- import { C as CompiledBlock, b as CompiledFragment } from './governance-eEzCyfes.js';
1
+ import { C as CompiledBlock, b as CompiledFragment } from './governance-B5WWcmFY.js';
2
2
  import 'zod';
3
3
  import 'react';
4
4
  import './topology/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefragments/core",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "license": "MIT",
5
5
  "description": "Core types, schemas, and runtime API for Fragments component definitions",
6
6
  "author": "Conan McNicholl",
@@ -118,6 +118,90 @@ describe("inert config diagnostics", () => {
118
118
  expect(detectUnconsumedConfigKeys(authored, parsed)).toEqual([]);
119
119
  });
120
120
 
121
+ it("accepts valid options declared by a registered rule schema", () => {
122
+ const options = { ignoreParents: ["FormControlLabel"], ignoreSpread: true };
123
+ const authored = {
124
+ govern: {
125
+ rules: {
126
+ "a11y/required-accessible-name": { enabled: true, options },
127
+ },
128
+ },
129
+ };
130
+ const parsed = fragmentsConfigSchema.parse(authored);
131
+
132
+ expect(detectUnconsumedConfigKeys(authored, parsed)).toEqual([]);
133
+ expect(
134
+ (
135
+ parsed.govern?.rules?.["a11y/required-accessible-name"] as {
136
+ options?: unknown;
137
+ }
138
+ ).options
139
+ ).toEqual(options);
140
+ });
141
+
142
+ it("diagnoses invalid options against the registered rule schema", () => {
143
+ const authored = {
144
+ govern: {
145
+ rules: {
146
+ "a11y/required-accessible-name": {
147
+ enabled: true,
148
+ options: { ignoreSpread: "yes" },
149
+ },
150
+ },
151
+ },
152
+ };
153
+ const parsed = fragmentsConfigSchema.parse(authored);
154
+
155
+ expect(detectUnconsumedConfigKeys(authored, parsed)).toEqual([
156
+ expect.objectContaining({
157
+ code: "FUI9004",
158
+ path: "govern.rules.a11y/required-accessible-name.options",
159
+ message: expect.stringMatching(
160
+ /a11y\/required-accessible-name.*ignoreSpread.*Expected boolean/
161
+ ),
162
+ }),
163
+ ]);
164
+ });
165
+
166
+ it("diagnoses a typo'd option key instead of letting it silently no-op", () => {
167
+ const authored = {
168
+ govern: {
169
+ rules: {
170
+ "a11y/required-accessible-name": {
171
+ enabled: true,
172
+ options: { ignoredParents: ["FormControlLabel"] },
173
+ },
174
+ },
175
+ },
176
+ };
177
+ const parsed = fragmentsConfigSchema.parse(authored);
178
+
179
+ expect(detectUnconsumedConfigKeys(authored, parsed)).toEqual([
180
+ expect.objectContaining({
181
+ code: "FUI9004",
182
+ path: "govern.rules.a11y/required-accessible-name.options",
183
+ message: expect.stringMatching(/ignoredParents/),
184
+ }),
185
+ ]);
186
+ });
187
+
188
+ it("leaves options on rules without a schema untouched", () => {
189
+ const options = { projectSpecificSetting: "unchanged" };
190
+ const authored = {
191
+ govern: {
192
+ rules: {
193
+ "styles/no-raw-color": { enabled: true, options },
194
+ },
195
+ },
196
+ };
197
+ const parsed = fragmentsConfigSchema.parse(authored);
198
+
199
+ expect(detectUnconsumedConfigKeys(authored, parsed)).toEqual([]);
200
+ expect(
201
+ (parsed.govern?.rules?.["styles/no-raw-color"] as { options?: unknown }).options
202
+ ).toEqual(options);
203
+ });
204
+
121
205
  it("reports an authored spacing scale that no effective property policy references", () => {
122
206
  const declared: GovernanceConfig = {
123
207
  scales: {
@@ -167,8 +251,15 @@ describe("inert config diagnostics", () => {
167
251
  expect(detectOrphanGovernanceScales(policy, policy)).toEqual([]);
168
252
  });
169
253
 
170
- it("accepts govern.ci.failOnInert as a consumed key", () => {
171
- const authored = { govern: { ci: { failOnInert: true } } };
254
+ it("accepts governance CI gates as consumed keys", () => {
255
+ const authored = {
256
+ govern: {
257
+ ci: {
258
+ failOnInert: true,
259
+ failOnAdoptionRegression: true,
260
+ },
261
+ },
262
+ };
172
263
 
173
264
  expect(detectUnconsumedConfigKeys(authored, fragmentsConfigSchema.parse(authored))).toEqual([]);
174
265
  });
@@ -17,6 +17,8 @@
17
17
  * calling in.
18
18
  */
19
19
 
20
+ import { ZodObject } from "zod";
21
+
20
22
  import type { CanonicalSource, GovernanceConfig, GovernanceSeverity } from "./governance.js";
21
23
  import {
22
24
  normalizePolicyExcludes,
@@ -25,7 +27,8 @@ import {
25
27
  } from "./policy-exclude.js";
26
28
  import { compileGlobalGovernanceFacts } from "./facts/index.js";
27
29
  import type { FactConflict } from "./facts/index.js";
28
- import { RULE_FAMILY_IDS } from "./rules/families.js";
30
+ import { RULES } from "./rules/index.js";
31
+ import { RULE_FAMILY_IDS, ruleFamilyMembers } from "./rules/families.js";
29
32
  import { FRAGMENTS_INTERNAL_RULE_IDS, RULE_TIER } from "./rules/tiers.js";
30
33
  import { BLOCKING_RULE_ALLOWLIST } from "./rules/emit-gate.js";
31
34
 
@@ -175,6 +178,23 @@ function dedupe(values: string[]): string[] {
175
178
 
176
179
  const CONSUMED_RULE_IDS = new Set(Object.keys(RULE_TIER));
177
180
  const RECOGNIZED_RULE_IDS = new Set([...CONSUMED_RULE_IDS, ...RULE_FAMILY_IDS]);
181
+ // Reporter-strict, executor-lenient: unknown option keys are diagnosed here as
182
+ // the inert-config class FUI9004 exists to name, while rules keep their own
183
+ // tolerant parse (unknown keys stripped, valid keys still honored).
184
+ const RULE_OPTIONS_SCHEMAS = new Map(
185
+ RULES.flatMap((rule) =>
186
+ rule.optionsSchema
187
+ ? [
188
+ [
189
+ rule.id,
190
+ rule.optionsSchema instanceof ZodObject
191
+ ? rule.optionsSchema.strict()
192
+ : rule.optionsSchema,
193
+ ] as const,
194
+ ]
195
+ : []
196
+ )
197
+ );
178
198
  // `exclude` is consumed by the scan's finding-override pass (it scopes the rule off
179
199
  // the matching paths and reports each drop in the ignored accounting). It was absent
180
200
  // from this allow-set, so a working key was diagnosed as inert — the diagnostic was
@@ -219,7 +239,7 @@ const PASSTHROUGH_KEYS = [
219
239
  },
220
240
  {
221
241
  path: ["govern", "ci"],
222
- keys: ["failOnWarnings", "failOnInert"],
242
+ keys: ["failOnWarnings", "failOnInert", "failOnAdoptionRegression"],
223
243
  },
224
244
  ] as const;
225
245
 
@@ -546,6 +566,30 @@ function collectRuleConfigKeys(
546
566
  diagnostics.push(unconsumedKeyDiagnostic([...path, ruleId, key]));
547
567
  }
548
568
  }
569
+ if (config["options"] === undefined) continue;
570
+
571
+ const invalidOptions = ruleFamilyMembers(ruleId).flatMap((memberRuleId) => {
572
+ const schema = RULE_OPTIONS_SCHEMAS.get(memberRuleId);
573
+ if (!schema) return [];
574
+ const parsed = schema.safeParse(config["options"]);
575
+ if (parsed.success) return [];
576
+ const problem = parsed.error.errors
577
+ .map((issue) => `${issue.path.join(".") || "options"}: ${issue.message}`)
578
+ .join("; ");
579
+ return [memberRuleId === ruleId ? problem : `${memberRuleId}: ${problem}`];
580
+ });
581
+ if (invalidOptions.length === 0) continue;
582
+
583
+ const diagnosticPath = configPath([...path, ruleId, "options"]);
584
+ diagnostics.push({
585
+ code: "FUI9004",
586
+ kind: "unconsumed-key",
587
+ severity: "warn",
588
+ path: diagnosticPath,
589
+ message:
590
+ `${diagnosticPath} is invalid for rule \`${ruleId}\`: ${invalidOptions.join("; ")}. ` +
591
+ "Fix the option values or remove the options block.",
592
+ });
549
593
  }
550
594
  }
551
595
 
package/src/governance.ts CHANGED
@@ -355,6 +355,7 @@ export const governanceConfigSchema = z
355
355
  .object({
356
356
  failOnWarnings: z.boolean().optional(),
357
357
  failOnInert: z.boolean().optional(),
358
+ failOnAdoptionRegression: z.boolean().optional(),
358
359
  })
359
360
  .passthrough()
360
361
  .optional(),
@@ -443,9 +444,13 @@ export interface GovernanceConfig {
443
444
  overrides?: ComponentPolicyOverride[];
444
445
 
445
446
  /**
446
- * Governance CI rendering options: `failOnWarnings` makes warning findings fail the
447
- * `--ci` verdict, `failOnInert` makes inert-config diagnostics (FUI9004-FUI9008) fail
448
- * it. Both are opt-in; `--allow-inert` bypasses the inert gates.
447
+ * Governance CI rendering options. Under `--ci`, `failOnWarnings` defaults to `true`
448
+ * when absent; set it to `false` to keep warning findings reported but advisory.
449
+ * `failOnInert` is opt-in and makes inert-config diagnostics (FUI9004-FUI9008) fail
450
+ * the verdict; `--allow-inert` bypasses the inert gates.
451
+ * `failOnAdoptionRegression` is opt-in and makes `check --ci` compare the
452
+ * current component-identity adoption percentage with the committed
453
+ * `.fragments/adoption-baseline.json` floor.
449
454
  */
450
455
  ci?: {
451
456
  failOnWarnings?: boolean;
@@ -456,6 +461,13 @@ export interface GovernanceConfig {
456
461
  * bypasses it, like the governance-inert gate.
457
462
  */
458
463
  failOnInert?: boolean;
464
+ /**
465
+ * Fail `check --ci` when measurable component-identity adoption falls below
466
+ * the committed local floor. Update the floor through `check
467
+ * --update-baseline`; accepting a lower floor additionally requires
468
+ * `--allow-regression`.
469
+ */
470
+ failOnAdoptionRegression?: boolean;
459
471
  [key: string]: unknown;
460
472
  };
461
473
  [key: string]: unknown;
@@ -24,6 +24,7 @@ import type {
24
24
  UsageNodeFact,
25
25
  UsagePropResolvedFact,
26
26
  } from "../facts/index.js";
27
+ import { z } from "zod";
27
28
 
28
29
  import { isAriaHidden } from "./a11y-utils.js";
29
30
  import { makeFinding } from "./finding.js";
@@ -31,7 +32,16 @@ import type { Finding } from "./types.js";
31
32
  import { indexPropsByNodeId, indexTextChildrenByNodeId } from "./utils.js";
32
33
 
33
34
  export const RULE_ID = "a11y/required-accessible-name";
34
- export const RULE_VERSION = "2";
35
+ export const RULE_VERSION = "3";
36
+
37
+ export const a11yRequiredAccessibleNameOptionsSchema = z.object({
38
+ ignoreParents: z.array(z.string().min(1)).optional(),
39
+ ignoreSpread: z.boolean().optional(),
40
+ });
41
+
42
+ export type A11yRequiredAccessibleNameOptions = z.infer<
43
+ typeof a11yRequiredAccessibleNameOptionsSchema
44
+ >;
35
45
 
36
46
  const NAME_SOURCE_PROPS = new Set(["aria-label", "aria-labelledby", "title"]);
37
47
  const COMPONENT_NAME_SOURCE_PROPS = new Set([...NAME_SOURCE_PROPS, "label"]);
@@ -57,7 +67,12 @@ export function ruleA11yRequiredAccessibleName(ix: FactIndex): Finding[] {
57
67
  ix.byKind("usage_child_content").map((content) => [content.nodeId, content])
58
68
  );
59
69
  const globalPolicy = ix.policy.ruleConfig(RULE_ID);
70
+ const parsedOptions = a11yRequiredAccessibleNameOptionsSchema.safeParse(globalPolicy?.options);
71
+ const options: A11yRequiredAccessibleNameOptions = parsedOptions.success
72
+ ? parsedOptions.data
73
+ : {};
60
74
  const usageByNode = new Map(ix.byKind("usage_component").map((usage) => [usage.nodeId, usage]));
75
+ const usageNodes = ix.byKind("usage_node");
61
76
  const definitions = new Map(
62
77
  ix.byKind("component_definition").map((definition) => [definition.componentKey, definition])
63
78
  );
@@ -73,6 +88,7 @@ export function ruleA11yRequiredAccessibleName(ix: FactIndex): Finding[] {
73
88
  globalPolicy?.enabled === true &&
74
89
  requiresAccessibleName(node, usageComponent, definitions, props);
75
90
  if (!componentPolicy && !genericControl) continue;
91
+ if (isInsideIgnoredAttributeParent(node, usageNodes, options.ignoreParents)) continue;
76
92
 
77
93
  if (
78
94
  hasAccessibleName(
@@ -86,11 +102,16 @@ export function ruleA11yRequiredAccessibleName(ix: FactIndex): Finding[] {
86
102
  continue;
87
103
  }
88
104
 
105
+ const hasSpreadProps = props.some((prop) => prop.resolution === "spread");
106
+ if (hasSpreadProps && options.ignoreSpread === true) continue;
107
+
89
108
  findings.push(
90
109
  makeFinding({
91
110
  ruleId: RULE_ID,
92
111
  ruleVersion: RULE_VERSION,
93
- severity: componentPolicy?.severity ?? globalPolicy?.severity ?? "warn",
112
+ severity: hasSpreadProps
113
+ ? "minor"
114
+ : (componentPolicy?.severity ?? globalPolicy?.severity ?? "warn"),
94
115
  message: `<${node.element}> has no accessible name. ${
95
116
  componentPolicy?.because ??
96
117
  "Add visible text, aria-label, aria-labelledby, or a component label."
@@ -119,6 +140,28 @@ export function ruleA11yRequiredAccessibleName(ix: FactIndex): Finding[] {
119
140
  return findings;
120
141
  }
121
142
 
143
+ function isInsideIgnoredAttributeParent(
144
+ node: UsageNodeFact,
145
+ usageNodes: readonly UsageNodeFact[],
146
+ ignoreParents: readonly string[] | undefined
147
+ ): boolean {
148
+ if (!ignoreParents?.length || !node.nodePath.includes("/attr:")) return false;
149
+ const ignored = new Set(ignoreParents);
150
+ const segments = node.nodePath.split("/");
151
+ const ancestorPaths = new Set<string>();
152
+ for (let index = 1; index < segments.length; index += 1) {
153
+ if (segments[index].startsWith("attr:")) {
154
+ ancestorPaths.add(segments.slice(0, index).join("/"));
155
+ }
156
+ }
157
+ return usageNodes.some(
158
+ (candidate) =>
159
+ candidate.file === node.file &&
160
+ ancestorPaths.has(candidate.nodePath) &&
161
+ ignored.has(candidate.element)
162
+ );
163
+ }
164
+
122
165
  function hasAccessibleName(
123
166
  props: UsagePropResolvedFact[],
124
167
  textChildren: ReadonlyArray<{ text: string }>,
@@ -8,8 +8,10 @@
8
8
  */
9
9
 
10
10
  import type { FactIndex } from "../facts/index.js";
11
+ import type { ZodTypeAny } from "zod";
11
12
 
12
13
  import {
14
+ a11yRequiredAccessibleNameOptionsSchema,
13
15
  RULE_VERSION as A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION,
14
16
  ruleA11yRequiredAccessibleName,
15
17
  } from "./a11y-required-accessible-name.js";
@@ -44,6 +46,7 @@ export interface Rule {
44
46
  id: string;
45
47
  version: string;
46
48
  run: RuleFn;
49
+ optionsSchema?: ZodTypeAny;
47
50
  }
48
51
 
49
52
  export const RULES: readonly Rule[] = [
@@ -156,6 +159,7 @@ export const RULES: readonly Rule[] = [
156
159
  id: "a11y/required-accessible-name",
157
160
  version: A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION,
158
161
  run: ruleA11yRequiredAccessibleName,
162
+ optionsSchema: a11yRequiredAccessibleNameOptionsSchema,
159
163
  },
160
164
  {
161
165
  id: "a11y/standard",
@@ -36,6 +36,7 @@ import {
36
36
  runRules,
37
37
  } from "../index.js";
38
38
  import type { CanonicalBridgeV1, Fact, FactId } from "../index.js";
39
+ import { makeGovernanceRuleConfigFact } from "../facts/index.js";
39
40
  import { RULE_VERSION as A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION } from "./a11y-required-accessible-name.js";
40
41
  import { makeFinding } from "./finding.js";
41
42
 
@@ -1425,6 +1426,77 @@ describe("a11y/required-accessible-name", () => {
1425
1426
  expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
1426
1427
  });
1427
1428
 
1429
+ it("ignores controls rendered through an ignored same-file attribute parent", () => {
1430
+ const ix = emptyIndexWithGovernance();
1431
+ ix.add(
1432
+ makeGovernanceRuleConfigFact({
1433
+ ruleId: "a11y/required-accessible-name",
1434
+ enabled: true,
1435
+ options: { ignoreParents: ["FormControlLabel"] },
1436
+ })
1437
+ );
1438
+ ix.add(
1439
+ makeUsageNodeFact({
1440
+ file: "apps/x.tsx",
1441
+ nodePath: "0:0",
1442
+ element: "FormControlLabel",
1443
+ location: { file: "apps/x.tsx", line: 1, column: 0 },
1444
+ })
1445
+ );
1446
+ addButtonUsage(ix, {
1447
+ file: "apps/x.tsx",
1448
+ nodePath: "0:0/attr:control/0:0",
1449
+ line: 1,
1450
+ column: 28,
1451
+ });
1452
+
1453
+ expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
1454
+ });
1455
+
1456
+ it("demotes an unnamed component with spread props to minor", () => {
1457
+ const ix = emptyIndexWithGovernance();
1458
+ addButtonUsage(ix, {
1459
+ file: "apps/x.tsx",
1460
+ nodePath: "0:0",
1461
+ line: 1,
1462
+ column: 0,
1463
+ props: [{ prop: "...props", resolution: "spread" }],
1464
+ });
1465
+
1466
+ expect(ruleA11yRequiredAccessibleName(ix)).toMatchObject([{ severity: "minor" }]);
1467
+ });
1468
+
1469
+ it("skips an unnamed component with spread props when ignoreSpread is enabled", () => {
1470
+ const ix = emptyIndexWithGovernance();
1471
+ ix.add(
1472
+ makeGovernanceRuleConfigFact({
1473
+ ruleId: "a11y/required-accessible-name",
1474
+ enabled: true,
1475
+ options: { ignoreSpread: true },
1476
+ })
1477
+ );
1478
+ addButtonUsage(ix, {
1479
+ file: "apps/x.tsx",
1480
+ nodePath: "0:0",
1481
+ line: 1,
1482
+ column: 0,
1483
+ props: [{ prop: "...props", resolution: "spread" }],
1484
+ });
1485
+
1486
+ expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
1487
+ });
1488
+
1489
+ it("registers ignoreParents and ignoreSpread as supported rule options", () => {
1490
+ const registered = RULES.find((rule) => rule.id === "a11y/required-accessible-name");
1491
+
1492
+ expect(
1493
+ registered?.optionsSchema?.safeParse({
1494
+ ignoreParents: ["FormControlLabel"],
1495
+ ignoreSpread: true,
1496
+ }).success
1497
+ ).toBe(true);
1498
+ });
1499
+
1428
1500
  it("keeps the registry version in lockstep with the emitted finding version", () => {
1429
1501
  const registered = RULES.find((rule) => rule.id === "a11y/required-accessible-name");
1430
1502
  expect(registered?.version).toBe(A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION);