@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.
Files changed (64) hide show
  1. package/dist/chunk-RANPUC6C.js +72 -0
  2. package/dist/chunk-RANPUC6C.js.map +1 -0
  3. package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
  4. package/dist/chunk-WNMWKUYG.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +1 -1
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/compiled-types/index.js +8 -0
  9. package/dist/generate/index.d.ts +1 -1
  10. package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
  11. package/dist/index.d.ts +800 -179
  12. package/dist/index.js +1003 -237
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +36 -36
  16. package/dist/schemas/index.d.ts +1 -1
  17. package/dist/test-utils.d.ts +1 -1
  18. package/package.json +2 -1
  19. package/src/__tests__/policy-exclude.test.ts +180 -0
  20. package/src/agent-format.test.ts +13 -0
  21. package/src/agent-format.ts +9 -3
  22. package/src/canonical-bridge.ts +69 -1
  23. package/src/canonical-direction.test.ts +118 -0
  24. package/src/canonical-direction.ts +43 -2
  25. package/src/codes/__tests__/codes.test.ts +14 -2
  26. package/src/codes/codes.ts +41 -2
  27. package/src/compiled-types/index.ts +81 -0
  28. package/src/compiled-types/parse.test.ts +47 -0
  29. package/src/facts/builders.ts +48 -0
  30. package/src/facts/compile.ts +148 -34
  31. package/src/facts/fact-index.ts +15 -2
  32. package/src/facts/facts.test.ts +44 -3
  33. package/src/facts/index.ts +11 -6
  34. package/src/facts/types.ts +60 -9
  35. package/src/governance-integrity.test.ts +272 -4
  36. package/src/governance-integrity.ts +345 -25
  37. package/src/governance.ts +87 -1
  38. package/src/index.ts +46 -1
  39. package/src/policy-exclude.ts +113 -0
  40. package/src/rules/a11y-required-accessible-name.ts +175 -28
  41. package/src/rules/a11y-standard.ts +102 -0
  42. package/src/rules/a11y-utils.ts +7 -0
  43. package/src/rules/components-prefer-library.test.ts +75 -28
  44. package/src/rules/components-prefer-library.ts +35 -15
  45. package/src/rules/components-shadow-component.test.ts +21 -9
  46. package/src/rules/emit-gate.test.ts +74 -4
  47. package/src/rules/emit-gate.ts +24 -9
  48. package/src/rules/families.test.ts +69 -0
  49. package/src/rules/families.ts +52 -0
  50. package/src/rules/fix-availability.ts +1 -0
  51. package/src/rules/index.ts +18 -2
  52. package/src/rules/jsx-preferred-import-path.ts +29 -11
  53. package/src/rules/rules.test.ts +188 -8
  54. package/src/rules/styles-no-raw-color.ts +13 -4
  55. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  56. package/src/rules/styles-no-raw-spacing.ts +12 -4
  57. package/src/rules/styles-no-raw-typography.ts +13 -4
  58. package/src/rules/tiers.ts +1 -0
  59. package/src/rules/utils.ts +39 -0
  60. package/src/tokens/design-token-parser.test.ts +131 -0
  61. package/src/tokens/design-token-parser.ts +362 -49
  62. package/src/types.ts +2 -2
  63. package/dist/chunk-BAHCOAVG.js.map +0 -1
  64. package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
@@ -1,5 +1,5 @@
1
1
  import type { ContractCanonicalMappingInput } from "./contract/preimage.js";
2
- import type { CanonicalBridgeV1 } from "./governance.js";
2
+ import type { CanonicalBridgeV1, CanonicalSource } from "./governance.js";
3
3
  import { ownedImportMatchesRoot } from "./package-identity-match.js";
4
4
 
5
5
  export function canonicalBridgeUnderlyingKey(input: {
@@ -28,6 +28,74 @@ export function canonicalBridgeContractMappings(
28
28
  }));
29
29
  }
30
30
 
31
+ /**
32
+ * One authored bridge, projected into the two facts a consumer needs to honor
33
+ * it WITHOUT widening it: the npm canonical source its underlying export
34
+ * implies, and the local component the user actually sanctioned.
35
+ *
36
+ * The pairing is the point. A bridge says "`src/components/Button.tsx#Button`
37
+ * wraps `@mui/material#Button`" — a fact about ONE component. Reading only the
38
+ * source half turns it into "`@mui/material#Button` is canonical in this repo",
39
+ * which is a strictly wider claim: every component in the tree that happens to
40
+ * render that export inherits the sanction it was never given.
41
+ */
42
+ export interface CanonicalBridgeIdentityBinding {
43
+ /** The npm canonical source this bridge's underlying export implies. */
44
+ source: Extract<CanonicalSource, { kind: "npm" }>;
45
+ /** The sanctioned local component — `<repo-relative-file>#<exportName>`. */
46
+ localComponentKey: string;
47
+ }
48
+
49
+ /**
50
+ * The per-component bindings an authored bridge set implies — one definition,
51
+ * because every consumer of "which components did the user sanction, against
52
+ * which package export" must see the same set.
53
+ *
54
+ * Consumed by `classify`'s RENDER_ROOT_CANONICAL context, which gates the
55
+ * signal on `localComponentKey`: only the component the bridge names may claim
56
+ * the bridge's canonical, so a sanction stays as narrow as the user authored it.
57
+ */
58
+ export function canonicalBridgeIdentityBindings(
59
+ bridges: readonly CanonicalBridgeV1[] | undefined
60
+ ): CanonicalBridgeIdentityBinding[] {
61
+ return (bridges ?? []).map((bridge) => ({
62
+ source: {
63
+ kind: "npm",
64
+ specifier: bridge.underlying.packageName,
65
+ include: [bridge.underlying.exportName],
66
+ },
67
+ localComponentKey: bridge.local.componentKey,
68
+ }));
69
+ }
70
+
71
+ /**
72
+ * The npm canonical sources an authored bridge set implies — the source half of
73
+ * `canonicalBridgeIdentityBindings`, for consumers that resolve the whole tree
74
+ * at once and scope the result themselves.
75
+ *
76
+ * A bridge states that `underlying.packageName#exportName` IS the canonical
77
+ * thing a local component wraps, so identity resolution treats that export as
78
+ * declared, exactly as an explicit `govern.canonicalSources` npm entry would.
79
+ * Before this projection was shared, a repo whose decision lived only in
80
+ * `canonicalBridges` (hand-authored, or written by `identity sanction`)
81
+ * resolved render roots at `canonical` grade in the pipeline while the
82
+ * classifier saw no declaration at all — the authored fact reached one consumer
83
+ * and silently missed the other.
84
+ *
85
+ * Consumer: the scan pipeline's identity input
86
+ * (`buildCanonicalDirectionIdentityInput`), which resolves render roots for the
87
+ * whole definition set and reports the grade rather than acting on it.
88
+ * Anything that turns the grade into an AUTHORED decision must use
89
+ * `canonicalBridgeIdentityBindings` instead — this projection deliberately
90
+ * drops `local.componentKey`, so on its own it cannot tell a sanctioned wrapper
91
+ * from any other component that renders the same export.
92
+ */
93
+ export function canonicalBridgeIdentitySources(
94
+ bridges: readonly CanonicalBridgeV1[] | undefined
95
+ ): Array<Extract<CanonicalSource, { kind: "npm" }>> {
96
+ return canonicalBridgeIdentityBindings(bridges).map((binding) => binding.source);
97
+ }
98
+
31
99
  export function canonicalBridgeMatchesUnderlyingImport(
32
100
  bridge: CanonicalBridgeV1,
33
101
  source: string,
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { FactIndex, makeComponentDefinitionFact } from "./facts/index.js";
4
+ import type { ComponentDefinitionRenderRoot } from "./facts/index.js";
5
+ import { projectCanonicalDirectionConflicts } from "./canonical-direction.js";
6
+
7
+ const NPM_SOURCE = {
8
+ kind: "npm",
9
+ specifier: "@mui/material",
10
+ include: ["Button", "TextField"],
11
+ } as const;
12
+
13
+ function wrapperDefinition(input: {
14
+ file: string;
15
+ exportName: string;
16
+ renderRoot: ComponentDefinitionRenderRoot;
17
+ exported?: boolean;
18
+ }) {
19
+ return makeComponentDefinitionFact({
20
+ file: input.file,
21
+ exportName: input.exportName,
22
+ exported: input.exported ?? true,
23
+ componentKey: `${input.file}#${input.exportName}`,
24
+ renderRoot: input.renderRoot,
25
+ propSurface: [],
26
+ });
27
+ }
28
+
29
+ describe("projectCanonicalDirectionConflicts", () => {
30
+ it("projects a conflict from a package-identity canonical key", () => {
31
+ const ix = new FactIndex();
32
+ ix.add(
33
+ wrapperDefinition({
34
+ file: "src/components/Button.tsx",
35
+ exportName: "Button",
36
+ renderRoot: {
37
+ resolution: "canonical",
38
+ canonical: "@mui/material#Button",
39
+ importSource: "@mui/material",
40
+ },
41
+ })
42
+ );
43
+
44
+ expect(projectCanonicalDirectionConflicts(ix, [NPM_SOURCE])).toMatchObject([
45
+ {
46
+ packageName: "@mui/material",
47
+ exportName: "Button",
48
+ wrapperComponentKey: "src/components/Button.tsx#Button",
49
+ state: "unresolved",
50
+ },
51
+ ]);
52
+ });
53
+
54
+ // A canonical package vendored inside the repo (a workspace package or the
55
+ // committed archetype fixture's fake-packages/) resolves the wrapper's render
56
+ // root to its local file identity. The original import specifier is still
57
+ // exact and source-backed — the conflict must not vanish when resolution
58
+ // goes local, or init detects an ambiguity the scan can never see.
59
+ it("projects a conflict through the import source when the canonical key resolved locally", () => {
60
+ const ix = new FactIndex();
61
+ ix.add(
62
+ wrapperDefinition({
63
+ file: "src/components/Button.tsx",
64
+ exportName: "Button",
65
+ renderRoot: {
66
+ resolution: "canonical",
67
+ canonical: "fake-packages/mui-material/index.js#Button",
68
+ importSource: "@mui/material",
69
+ },
70
+ })
71
+ );
72
+
73
+ expect(projectCanonicalDirectionConflicts(ix, [NPM_SOURCE])).toMatchObject([
74
+ {
75
+ packageName: "@mui/material",
76
+ exportName: "Button",
77
+ wrapperComponentKey: "src/components/Button.tsx#Button",
78
+ wrapperFile: "src/components/Button.tsx",
79
+ wrapperExportName: "Button",
80
+ state: "unresolved",
81
+ },
82
+ ]);
83
+ });
84
+
85
+ it("ignores locally-resolved roots whose import source is not a canonical package", () => {
86
+ const ix = new FactIndex();
87
+ ix.add(
88
+ wrapperDefinition({
89
+ file: "src/components/Button.tsx",
90
+ exportName: "Button",
91
+ renderRoot: {
92
+ resolution: "canonical",
93
+ canonical: "packages/other-kit/index.ts#Button",
94
+ importSource: "@acme/other-kit",
95
+ },
96
+ })
97
+ );
98
+
99
+ expect(projectCanonicalDirectionConflicts(ix, [NPM_SOURCE])).toEqual([]);
100
+ });
101
+
102
+ it("honors the include list on the import-source fallback", () => {
103
+ const ix = new FactIndex();
104
+ ix.add(
105
+ wrapperDefinition({
106
+ file: "src/components/Chip.tsx",
107
+ exportName: "Chip",
108
+ renderRoot: {
109
+ resolution: "canonical",
110
+ canonical: "fake-packages/mui-material/index.js#Chip",
111
+ importSource: "@mui/material",
112
+ },
113
+ })
114
+ );
115
+
116
+ expect(projectCanonicalDirectionConflicts(ix, [NPM_SOURCE])).toEqual([]);
117
+ });
118
+ });
@@ -1,7 +1,9 @@
1
1
  import type { CanonicalSource } from "./governance.js";
2
- import type { FactIndex } from "./facts/index.js";
2
+ import type { ComponentDefinitionRenderRoot, FactIndex } from "./facts/index.js";
3
3
  import { ownedImportMatchesRoot } from "./package-identity-match.js";
4
4
 
5
+ type CanonicalRenderRoot = Extract<ComponentDefinitionRenderRoot, { resolution: "canonical" }>;
6
+
5
7
  export interface CanonicalDirectionConflict {
6
8
  packageName: string;
7
9
  exportName: string;
@@ -32,7 +34,7 @@ export function projectCanonicalDirectionConflicts(
32
34
 
33
35
  for (const definition of ix.byKind("component_definition")) {
34
36
  if (!definition.exported || definition.renderRoot.resolution !== "canonical") continue;
35
- const target = npmTarget(definition.renderRoot.canonical, npmSources);
37
+ const target = npmTarget(definition.renderRoot, npmSources);
36
38
  if (!target) continue;
37
39
 
38
40
  const identity = identityByComponent.get(definition.componentKey);
@@ -83,6 +85,16 @@ export function canonicalDirectionConflictsTarget(
83
85
  }
84
86
 
85
87
  function npmTarget(
88
+ renderRoot: CanonicalRenderRoot,
89
+ sources: readonly Extract<CanonicalSource, { kind: "npm" }>[]
90
+ ): { packageName: string; exportName: string } | null {
91
+ return (
92
+ npmTargetFromCanonicalKey(renderRoot.canonical, sources) ??
93
+ npmTargetFromImportSource(renderRoot, sources)
94
+ );
95
+ }
96
+
97
+ function npmTargetFromCanonicalKey(
86
98
  canonicalTarget: string,
87
99
  sources: readonly Extract<CanonicalSource, { kind: "npm" }>[]
88
100
  ): { packageName: string; exportName: string } | null {
@@ -108,6 +120,35 @@ function npmTarget(
108
120
  return null;
109
121
  }
110
122
 
123
+ /**
124
+ * A canonical package that also lives in the repo (a vendored fixture package
125
+ * or a workspace package whose package.json name matches the specifier)
126
+ * resolves to its local file identity, so the canonical key carries no package
127
+ * name. The original import specifier still does — and it is just as exact and
128
+ * source-backed, so the conflict must not vanish when resolution goes local
129
+ * (init's detector sees it; the scan must see the same set).
130
+ */
131
+ function npmTargetFromImportSource(
132
+ renderRoot: CanonicalRenderRoot,
133
+ sources: readonly Extract<CanonicalSource, { kind: "npm" }>[]
134
+ ): { packageName: string; exportName: string } | null {
135
+ const separator = renderRoot.canonical.lastIndexOf("#");
136
+ if (separator <= 0 || separator === renderRoot.canonical.length - 1) return null;
137
+ const exportName = renderRoot.canonical
138
+ .slice(separator + 1)
139
+ .split(".")
140
+ .at(-1);
141
+ if (!exportName || exportName === "default") return null;
142
+
143
+ for (const source of sources) {
144
+ if (!ownedImportMatchesRoot(renderRoot.importSource, source.specifier)) continue;
145
+ if (source.exclude?.includes(exportName)) continue;
146
+ if (source.include && !source.include.includes(exportName)) continue;
147
+ return { packageName: source.specifier, exportName };
148
+ }
149
+ return null;
150
+ }
151
+
111
152
  function packageSubpath(importPath: string, packageName: string): string {
112
153
  return importPath === packageName ? "" : importPath.slice(packageName.length + 1);
113
154
  }
@@ -11,7 +11,20 @@ const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = path.dirname(__filename);
12
12
  const registryPath = path.resolve(__dirname, "../../../../../docs/codes/registry.json");
13
13
 
14
- const SUBSYSTEM_EMITTED_CODES = ["FUI9001", "FUI9002", "FUI9003", "FUI9004", "FUI9005"] as const;
14
+ const SUBSYSTEM_EMITTED_CODES = [
15
+ "FUI9001",
16
+ "FUI9002",
17
+ "FUI9003",
18
+ "FUI9004",
19
+ "FUI9005",
20
+ "FUI9006",
21
+ "FUI9007",
22
+ "FUI9008",
23
+ // Emitted by the CLI scan layer, not the rule engine: the contract's token
24
+ // sources are read before any rule runs, and an unreadable one aborts the
25
+ // scan rather than producing a finding.
26
+ "FUI9009",
27
+ ] as const;
15
28
 
16
29
  const RESERVED_CODES = {
17
30
  FUI1001: "Reserved for canonical primitive findings; no core rule emits it.",
@@ -20,7 +33,6 @@ const RESERVED_CODES = {
20
33
  FUI2001: "Reserved for generic design-token requirements; no core rule emits it.",
21
34
  FUI2002: "Reserved for token-prefix policy; no core rule emits it.",
22
35
  FUI2013: "Reserved for brand-seed enforcement; no core rule emits it.",
23
- FUI3002: "Reserved for general accessibility standards; no core rule emits it.",
24
36
  FUI5001: "Reserved for required-child composition; no core rule emits it.",
25
37
  FUI5002: "Deprecated canonical-composition alias; no active core rule emits it.",
26
38
  FUI6001: "Reserved for generic unknown props; no core rule emits it.",
@@ -282,14 +282,13 @@ export const CODES = [
282
282
  lifecycle: "experimental",
283
283
  evidenceRequired: true,
284
284
  }),
285
- code({
285
+ ruleCode({
286
286
  code: "FUI3002",
287
287
  ruleId: "a11y/standard",
288
288
  category: "a11y",
289
289
  defaultSeverity: "serious",
290
290
  title: "Accessibility standard failed",
291
291
  lifecycle: "experimental",
292
- fixAvailable: false,
293
292
  evidenceRequired: true,
294
293
  }),
295
294
  code({
@@ -488,6 +487,46 @@ export const CODES = [
488
487
  fixAvailable: false,
489
488
  evidenceRequired: false,
490
489
  }),
490
+ code({
491
+ code: "FUI9006",
492
+ ruleId: "config/unmatched-exclude",
493
+ category: "system",
494
+ defaultSeverity: "moderate",
495
+ title: "Rule exclude matched no scanned file",
496
+ lifecycle: "experimental",
497
+ fixAvailable: false,
498
+ evidenceRequired: false,
499
+ }),
500
+ code({
501
+ code: "FUI9007",
502
+ ruleId: "config/colliding-record",
503
+ category: "system",
504
+ defaultSeverity: "moderate",
505
+ title: "Governance record was dropped as a duplicate",
506
+ lifecycle: "experimental",
507
+ fixAvailable: false,
508
+ evidenceRequired: false,
509
+ }),
510
+ code({
511
+ code: "FUI9008",
512
+ ruleId: "config/overridden-record-severity",
513
+ category: "system",
514
+ defaultSeverity: "moderate",
515
+ title: "Rule override outranks a record's authored severity",
516
+ lifecycle: "experimental",
517
+ fixAvailable: false,
518
+ evidenceRequired: false,
519
+ }),
520
+ code({
521
+ code: "FUI9009",
522
+ ruleId: "contract/unreadable-token-source",
523
+ category: "system",
524
+ defaultSeverity: "critical",
525
+ title: "Contract token source cannot be read",
526
+ lifecycle: "experimental",
527
+ fixAvailable: false,
528
+ evidenceRequired: false,
529
+ }),
491
530
  ] as const satisfies readonly FuiCode[];
492
531
 
493
532
  export const byCode: ReadonlyMap<string, FuiCode> = new Map(
@@ -267,6 +267,87 @@ export interface CompiledFragmentsFile {
267
267
  recipes?: Record<string, CompiledBlock>;
268
268
  }
269
269
 
270
+ export class CompiledFragmentsFileValidationError extends Error {
271
+ constructor(
272
+ readonly source: string,
273
+ readonly issues: string[]
274
+ ) {
275
+ super(`Invalid compiled Fragments catalog at ${source}: ${issues.join("; ")}`);
276
+ this.name = "CompiledFragmentsFileValidationError";
277
+ }
278
+ }
279
+
280
+ export function parseCompiledFragmentsFile(
281
+ input: string | unknown,
282
+ source = "fragments.json"
283
+ ): CompiledFragmentsFile {
284
+ let value: unknown = input;
285
+ if (typeof input === "string") {
286
+ try {
287
+ value = JSON.parse(input);
288
+ } catch (error) {
289
+ throw new CompiledFragmentsFileValidationError(source, [
290
+ `invalid JSON (${error instanceof Error ? error.message : String(error)})`,
291
+ ]);
292
+ }
293
+ }
294
+
295
+ const issues: string[] = [];
296
+ if (!isRecord(value)) {
297
+ throw new CompiledFragmentsFileValidationError(source, ["root must be an object"]);
298
+ }
299
+ if (typeof value.version !== "string" || value.version.length === 0) {
300
+ issues.push("version must be a non-empty string");
301
+ }
302
+ if (typeof value.generatedAt !== "string" || value.generatedAt.length === 0) {
303
+ issues.push("generatedAt must be a non-empty string");
304
+ }
305
+ if (!isRecord(value.fragments)) {
306
+ issues.push("fragments must be an object");
307
+ } else {
308
+ for (const [key, fragment] of Object.entries(value.fragments)) {
309
+ validateCompiledFragment(key, fragment, issues);
310
+ }
311
+ }
312
+ if (value.graph !== undefined) {
313
+ if (
314
+ !isRecord(value.graph) ||
315
+ !Array.isArray(value.graph.nodes) ||
316
+ !Array.isArray(value.graph.edges) ||
317
+ !isRecord(value.graph.health)
318
+ ) {
319
+ issues.push("graph must contain nodes[], edges[], and health");
320
+ }
321
+ }
322
+ if (issues.length > 0) throw new CompiledFragmentsFileValidationError(source, issues);
323
+ return value as unknown as CompiledFragmentsFile;
324
+ }
325
+
326
+ function validateCompiledFragment(key: string, value: unknown, issues: string[]): void {
327
+ const path = `fragments.${key}`;
328
+ if (!isRecord(value)) {
329
+ issues.push(`${path} must be an object`);
330
+ return;
331
+ }
332
+ if (typeof value.filePath !== "string") issues.push(`${path}.filePath must be a string`);
333
+ if (!isRecord(value.meta) || typeof value.meta.name !== "string") {
334
+ issues.push(`${path}.meta.name must be a string`);
335
+ }
336
+ if (!isRecord(value.props)) issues.push(`${path}.props must be an object`);
337
+ if (!Array.isArray(value.variants)) issues.push(`${path}.variants must be an array`);
338
+ if (
339
+ !isRecord(value.usage) ||
340
+ !Array.isArray(value.usage.when) ||
341
+ !Array.isArray(value.usage.whenNot)
342
+ ) {
343
+ issues.push(`${path}.usage must contain when[] and whenNot[]`);
344
+ }
345
+ }
346
+
347
+ function isRecord(value: unknown): value is Record<string, unknown> {
348
+ return value !== null && typeof value === "object" && !Array.isArray(value);
349
+ }
350
+
270
351
  /**
271
352
  * Theme identifier
272
353
  */
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ CompiledFragmentsFileValidationError,
5
+ parseCompiledFragmentsFile,
6
+ } from "./index.js";
7
+
8
+ const validCatalog = {
9
+ version: "1.0.0",
10
+ generatedAt: "2026-07-28T00:00:00.000Z",
11
+ fragments: {
12
+ Button: {
13
+ filePath: "src/Button.fragment.ts",
14
+ meta: { name: "Button", description: "Action", category: "Actions" },
15
+ usage: { when: ["Actions"], whenNot: [] },
16
+ props: {},
17
+ variants: [],
18
+ },
19
+ },
20
+ };
21
+
22
+ describe("parseCompiledFragmentsFile", () => {
23
+ it("accepts a compiled catalog with the command-critical shape", () => {
24
+ expect(parseCompiledFragmentsFile(JSON.stringify(validCatalog))).toMatchObject(validCatalog);
25
+ });
26
+
27
+ it("reports invalid fragment paths instead of allowing command-specific crashes", () => {
28
+ expect(() =>
29
+ parseCompiledFragmentsFile(
30
+ {
31
+ ...validCatalog,
32
+ fragments: { Button: { meta: {}, variants: "bad" } },
33
+ },
34
+ "/repo/fragments.json"
35
+ )
36
+ ).toThrow(CompiledFragmentsFileValidationError);
37
+ expect(() =>
38
+ parseCompiledFragmentsFile(
39
+ {
40
+ ...validCatalog,
41
+ fragments: { Button: { meta: {}, variants: "bad" } },
42
+ },
43
+ "/repo/fragments.json"
44
+ )
45
+ ).toThrow("fragments.Button.meta.name");
46
+ });
47
+ });