@usefragments/core 1.9.0 → 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';
@@ -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.0",
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);