@usefragments/core 1.9.1 → 1.10.1

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/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;
package/src/index.ts CHANGED
@@ -501,28 +501,6 @@ export {
501
501
  } from "./policy-exclude.js";
502
502
  export type { PolicyExclude, PolicyExcludeInput } from "./policy-exclude.js";
503
503
 
504
- // Story adapter (runtime conversion of Storybook modules)
505
- export {
506
- storyModuleToFragment,
507
- setPreviewConfig,
508
- getPreviewConfig,
509
- // Re-export @storybook/csf utilities
510
- toId,
511
- storyNameFromExport,
512
- isExportStory,
513
- } from "./storyAdapter.js";
514
- export type {
515
- StoryModule,
516
- StoryMeta,
517
- Story,
518
- StoryArgType,
519
- StoryContext,
520
- Decorator,
521
- Loader,
522
- PreviewConfig,
523
- CSF2Story,
524
- } from "./storyAdapter.js";
525
-
526
504
  // Storybook adapter filtering
527
505
  export {
528
506
  checkStoryExclusion,
@@ -640,19 +618,6 @@ export type {
640
618
  CompositionGuideline,
641
619
  } from "./composition.js";
642
620
 
643
- // Shared preview runtime
644
- export {
645
- executeVariantLoaders,
646
- resolvePreviewRuntimeState,
647
- usePreviewVariantRuntime,
648
- PreviewVariantRuntime,
649
- } from "./preview-runtime.js";
650
- export type {
651
- PreviewVariantLike,
652
- PreviewRuntimeState,
653
- PreviewRuntimeOptions,
654
- } from "./preview-runtime.js";
655
-
656
621
  // Component discovery
657
622
  export { discoverComponents, isReactComponent } from "./component-discovery.js";
658
623
  export type { DiscoverComponentsOptions } from "./component-discovery.js";
@@ -0,0 +1,22 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ describe("getCreateElement", () => {
4
+ afterEach(() => {
5
+ vi.resetModules();
6
+ vi.doUnmock("node:module");
7
+ });
8
+
9
+ it("throws a clear error when react cannot be resolved", async () => {
10
+ vi.doMock("node:module", () => ({
11
+ createRequire: () => () => {
12
+ throw new Error("Cannot find module 'react'");
13
+ },
14
+ }));
15
+
16
+ const { getCreateElement } = await import("./react-create-element.js");
17
+
18
+ expect(() => getCreateElement()).toThrow(
19
+ '@usefragments/core: story rendering requires the optional peer dependency "react" (>=18). Install react to use storyModuleToFragment default render paths.',
20
+ );
21
+ });
22
+ });
@@ -0,0 +1,12 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ export function getCreateElement(): typeof import("react")["createElement"] {
4
+ try {
5
+ return (createRequire(import.meta.url)("react") as typeof import("react")).createElement;
6
+ } catch (error) {
7
+ throw new Error(
8
+ '@usefragments/core: story rendering requires the optional peer dependency "react" (>=18). Install react to use storyModuleToFragment default render paths.',
9
+ { cause: error instanceof Error ? error : undefined },
10
+ );
11
+ }
12
+ }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Import from '@usefragments/core/react' when you need React-typed
5
5
  * component definitions. The main '@usefragments/core' entrypoint
6
- * works without React installed.
6
+ * works without React installed — story and preview-runtime APIs live on
7
+ * `@usefragments/core/story` and `@usefragments/core/preview-runtime`.
7
8
  */
8
9
 
9
10
  import type { ComponentType, ReactNode, JSX } from 'react';
@@ -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);
@@ -8,8 +8,9 @@
8
8
  * Supports Storybook 8.x with both CSF2 (Template.bind) and CSF3 (object stories).
9
9
  */
10
10
 
11
- import { createElement, type ComponentType, type ReactNode } from "react";
11
+ import type { ComponentType, ReactNode } from "react";
12
12
  import { toId, storyNameFromExport, isExportStory } from "./storybook-csf.js";
13
+ import { getCreateElement } from "./react-create-element.js";
13
14
  import type {
14
15
  FragmentDefinition,
15
16
  FragmentMeta,
@@ -711,7 +712,7 @@ function createRenderFunction(
711
712
  : meta.render!(args);
712
713
  } else {
713
714
  // Default: render component with args
714
- renderFn = () => createElement(component, args);
715
+ renderFn = () => getCreateElement()(component, args);
715
716
  }
716
717
 
717
718
  // Collect decorators in Storybook order