@usefragments/core 1.4.0 → 1.5.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.
Files changed (72) hide show
  1. package/dist/{chunk-CBSNXFOD.js → chunk-AOG4FTV6.js} +146 -6
  2. package/dist/chunk-AOG4FTV6.js.map +1 -0
  3. package/dist/{chunk-57QDBEHQ.js → chunk-ZHS52OT4.js} +3 -9
  4. package/dist/chunk-ZHS52OT4.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +2 -2
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/generate/index.d.ts +1 -1
  9. package/dist/{governance-DxdJV6lx.d.ts → governance-B88uR3Zq.d.ts} +229 -1
  10. package/dist/{index-0lmh0Lbo.d.ts → index-DbkPE46t.d.ts} +48 -8
  11. package/dist/index.d.ts +779 -158
  12. package/dist/index.js +674 -127
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +18 -18
  16. package/dist/schemas/index.d.ts +1 -1
  17. package/dist/schemas/index.js +1 -1
  18. package/dist/test-utils.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/agent-format.test.ts +1 -0
  21. package/src/canonical-bridge.ts +46 -0
  22. package/src/canonical-direction.ts +118 -0
  23. package/src/codes/codes.ts +18 -0
  24. package/src/conform.ts +5 -0
  25. package/src/contract/preimage.test.ts +40 -0
  26. package/src/contract/preimage.ts +20 -0
  27. package/src/effective-governance-inputs.test.ts +52 -0
  28. package/src/effective-governance-inputs.ts +106 -0
  29. package/src/facts/builders.ts +70 -2
  30. package/src/facts/compile.ts +20 -2
  31. package/src/facts/fact-index.ts +4 -0
  32. package/src/facts/facts.test.ts +30 -0
  33. package/src/facts/index.ts +6 -0
  34. package/src/facts/types.ts +86 -7
  35. package/src/governance-integrity.test.ts +22 -0
  36. package/src/governance-integrity.ts +10 -4
  37. package/src/governance.test.ts +93 -0
  38. package/src/governance.ts +76 -0
  39. package/src/identity/classify.test.ts +185 -0
  40. package/src/identity/classify.ts +257 -0
  41. package/src/identity/component-key.test.ts +32 -0
  42. package/src/identity/component-key.ts +35 -0
  43. package/src/identity/usage.ts +39 -0
  44. package/src/index.ts +48 -0
  45. package/src/rules/__tests__/fix-emission-invariant.test.ts +35 -4
  46. package/src/rules/components-prefer-library.test.ts +151 -0
  47. package/src/rules/components-prefer-library.ts +46 -4
  48. package/src/rules/components-shadow-component.test.ts +145 -0
  49. package/src/rules/components-shadow-component.ts +81 -0
  50. package/src/rules/fix-availability.ts +2 -0
  51. package/src/rules/index.ts +14 -0
  52. package/src/rules/jsx-preferred-import-path.ts +45 -9
  53. package/src/rules/rules.test.ts +202 -6
  54. package/src/rules/spacing-resolution.ts +2 -2
  55. package/src/rules/styles-no-raw-color.test.ts +6 -1
  56. package/src/rules/styles-no-raw-color.ts +3 -2
  57. package/src/rules/styles-no-raw-dimensions.ts +5 -7
  58. package/src/rules/styles-no-raw-spacing.ts +5 -2
  59. package/src/rules/styles-no-raw-typography.ts +7 -4
  60. package/src/rules/taxonomy.test.ts +19 -1
  61. package/src/rules/tiers.ts +9 -5
  62. package/src/rules/token-candidates.ts +36 -0
  63. package/src/rules/tokens-require-dual-fallback.ts +2 -1
  64. package/src/rules/tokens-upstream-drift.test.ts +111 -0
  65. package/src/rules/tokens-upstream-drift.ts +57 -0
  66. package/src/rules/utils.ts +22 -14
  67. package/src/schema.ts +28 -0
  68. package/src/schemas/index.ts +5 -8
  69. package/src/token-types.ts +71 -1
  70. package/src/types.ts +18 -0
  71. package/dist/chunk-57QDBEHQ.js.map +0 -1
  72. package/dist/chunk-CBSNXFOD.js.map +0 -1
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { GovernanceSeverity } from "../governance.js";
12
- import { factId } from "./ids.js";
12
+ import { asComponentId, factId } from "./ids.js";
13
13
  import type {
14
14
  A11yNameRequiredFact,
15
15
  CanonicalCandidateFact,
@@ -20,7 +20,9 @@ import type {
20
20
  ClassNameLiteralFact,
21
21
  ClassNameOrigin,
22
22
  ComponentCapabilityFact,
23
+ ComponentDefinitionFact,
23
24
  ComponentId,
25
+ ComponentIdentityFact,
24
26
  ComponentMetadataFact,
25
27
  FactLocation,
26
28
  GovernanceRuleConfigFact,
@@ -99,6 +101,50 @@ export function makeComponentCapabilityFact(input: {
99
101
  };
100
102
  }
101
103
 
104
+ export function makeComponentDefinitionFact(input: {
105
+ file: string;
106
+ exportName: string;
107
+ exported?: boolean;
108
+ componentKey: string;
109
+ renderRoot: ComponentDefinitionFact["renderRoot"];
110
+ propSurface: string[];
111
+ stylingChannel?: ComponentDefinitionFact["stylingChannel"];
112
+ }): ComponentDefinitionFact {
113
+ return {
114
+ id: factId("component_definition", { componentKey: input.componentKey }),
115
+ kind: "component_definition",
116
+ componentId: asComponentId(input.componentKey),
117
+ file: input.file,
118
+ exportName: input.exportName,
119
+ exported: input.exported ?? false,
120
+ componentKey: input.componentKey,
121
+ renderRoot: input.renderRoot,
122
+ propSurface: [...input.propSurface],
123
+ ...(input.stylingChannel ? { stylingChannel: { ...input.stylingChannel } } : {}),
124
+ };
125
+ }
126
+
127
+ export function makeComponentIdentityFact(input: {
128
+ componentKey: string;
129
+ state: ComponentIdentityFact["state"];
130
+ confidence: ComponentIdentityFact["confidence"];
131
+ canonicalTarget?: string;
132
+ decisionId?: string;
133
+ evidence: FactId[];
134
+ }): ComponentIdentityFact {
135
+ return {
136
+ id: factId("component_identity", { componentKey: input.componentKey }),
137
+ kind: "component_identity",
138
+ componentId: asComponentId(input.componentKey),
139
+ componentKey: input.componentKey,
140
+ state: input.state,
141
+ confidence: input.confidence,
142
+ ...(input.canonicalTarget ? { canonicalTarget: input.canonicalTarget } : {}),
143
+ ...(input.decisionId ? { decisionId: input.decisionId } : {}),
144
+ evidence: [...input.evidence],
145
+ };
146
+ }
147
+
102
148
  // ---------------------------------------------------------------------------
103
149
  // Classifier facts
104
150
  // ---------------------------------------------------------------------------
@@ -423,12 +469,14 @@ export function makeJsxImportPathPreferredFact(input: {
423
469
  imported?: string;
424
470
  because?: string;
425
471
  severity: GovernanceSeverity;
472
+ bridge?: JsxImportPathPreferredFact["bridge"];
426
473
  }): JsxImportPathPreferredFact {
427
474
  return {
428
475
  id: factId("jsx_import_path_preferred", {
429
476
  from: input.from,
430
477
  to: input.to,
431
478
  imported: input.imported,
479
+ bridge: input.bridge,
432
480
  }),
433
481
  kind: "jsx_import_path_preferred",
434
482
  from: input.from,
@@ -436,6 +484,14 @@ export function makeJsxImportPathPreferredFact(input: {
436
484
  imported: input.imported,
437
485
  because: input.because,
438
486
  severity: input.severity,
487
+ ...(input.bridge
488
+ ? {
489
+ bridge: {
490
+ ...input.bridge,
491
+ implementationFiles: [...input.bridge.implementationFiles],
492
+ },
493
+ }
494
+ : {}),
439
495
  };
440
496
  }
441
497
 
@@ -465,9 +521,17 @@ export function makeTokenDefinitionFact(input: {
465
521
  category?: TokenDefinitionFact["category"];
466
522
  referenceFormat?: TokenDefinitionFact["referenceFormat"];
467
523
  sourceNames?: TokenDefinitionFact["sourceNames"];
524
+ role?: TokenDefinitionFact["role"];
525
+ authority?: TokenDefinitionFact["authority"];
526
+ sourceIdentity?: TokenDefinitionFact["sourceIdentity"];
527
+ upstreamName?: TokenDefinitionFact["upstreamName"];
468
528
  }): TokenDefinitionFact {
529
+ const authorityIdentity =
530
+ input.role || input.sourceIdentity
531
+ ? { role: input.role ?? "local", sourceIdentity: input.sourceIdentity ?? "" }
532
+ : {};
469
533
  return {
470
- id: factId("token_definition", { name: input.name }),
534
+ id: factId("token_definition", { name: input.name, ...authorityIdentity }),
471
535
  kind: "token_definition",
472
536
  name: input.name,
473
537
  value: input.value,
@@ -475,6 +539,10 @@ export function makeTokenDefinitionFact(input: {
475
539
  category: input.category,
476
540
  referenceFormat: input.referenceFormat,
477
541
  ...(input.sourceNames?.length ? { sourceNames: [...input.sourceNames] } : {}),
542
+ ...(input.role ? { role: input.role } : {}),
543
+ ...(input.authority ? { authority: input.authority } : {}),
544
+ ...(input.sourceIdentity ? { sourceIdentity: input.sourceIdentity } : {}),
545
+ ...(input.upstreamName ? { upstreamName: input.upstreamName } : {}),
478
546
  };
479
547
  }
480
548
 
@@ -153,6 +153,25 @@ export function compileGlobalGovernanceFacts(govern: GovernanceConfig | undefine
153
153
  }
154
154
  }
155
155
 
156
+ for (const bridge of govern.canonicalBridges ?? []) {
157
+ out.push(
158
+ makeJsxImportPathPreferredFact({
159
+ from: bridge.underlying.packageName,
160
+ to: bridge.local.moduleSpecifier,
161
+ imported: bridge.underlying.exportName,
162
+ because: `Use the confirmed local ${bridge.local.exportName} wrapper`,
163
+ severity: ruleSeverity(govern.rules?.["imports/preferred-path"], govern.severity ?? "warn"),
164
+ bridge: {
165
+ componentKey: bridge.local.componentKey,
166
+ localExportName: bridge.local.exportName,
167
+ underlyingExportName: bridge.underlying.exportName,
168
+ implementationFiles: [...bridge.local.implementationFiles],
169
+ decisionSource: bridge.decision.source,
170
+ },
171
+ })
172
+ );
173
+ }
174
+
156
175
  const tailwindPalette = govern.tailwind?.palette;
157
176
  const forbiddenPaletteSeverity = ruleSeverity(
158
177
  govern.rules?.["tailwind/forbidden-palette"],
@@ -230,8 +249,7 @@ function compileRuleConfigFacts(govern: GovernanceConfig): PolicyFact[] {
230
249
  severity: existing?.severity ?? govern.severity ?? "warn",
231
250
  options: {
232
251
  ...existing?.options,
233
- canonicalSources:
234
- existing?.options?.canonicalSources ?? govern.canonicalSources,
252
+ canonicalSources: existing?.options?.canonicalSources ?? govern.canonicalSources,
235
253
  },
236
254
  });
237
255
  }
@@ -360,6 +360,10 @@ export class FactIndex {
360
360
  // ---------------------------------------------------------------------
361
361
 
362
362
  private indexTokenSymbols(fact: TokenDefinitionFact): void {
363
+ // Upstream definitions are comparison evidence, not local replacement
364
+ // candidates. Keeping them out of the reverse symbol index also prevents a
365
+ // known-drift upstream value from making the local token look safe.
366
+ if (fact.role === "upstream") return;
363
367
  const legacyScssAlias =
364
368
  fact.referenceFormat === "scss-var" && fact.name.startsWith("--")
365
369
  ? `$${fact.name.slice(2)}`
@@ -10,6 +10,8 @@ import {
10
10
  compileGlobalGovernanceFacts,
11
11
  g,
12
12
  makeTokenDefinitionFact,
13
+ makeComponentDefinitionFact,
14
+ makeComponentIdentityFact,
13
15
  makeUsageNodeFact,
14
16
  } from "../index.js";
15
17
  import type { ComponentId, FactId, PropValueForbiddenFact } from "../index.js";
@@ -151,6 +153,34 @@ describe("fact IR — content-addressed IDs", () => {
151
153
  expect(a).toBe(b);
152
154
  });
153
155
 
156
+ it("keys definition and identity facts by the portable componentKey", () => {
157
+ const first = makeComponentDefinitionFact({
158
+ file: "src/a/Button.tsx",
159
+ exportName: "Button",
160
+ componentKey: "src/a/Button.tsx#Button",
161
+ renderRoot: { resolution: "intrinsic", tag: "button", interactive: true },
162
+ propSurface: ["disabled"],
163
+ });
164
+ const second = makeComponentDefinitionFact({
165
+ file: "src/b/Button.tsx",
166
+ exportName: "Button",
167
+ componentKey: "src/b/Button.tsx#Button",
168
+ renderRoot: { resolution: "intrinsic", tag: "button", interactive: true },
169
+ propSurface: ["disabled"],
170
+ });
171
+ const identity = makeComponentIdentityFact({
172
+ componentKey: first.componentKey,
173
+ state: "shadow",
174
+ confidence: "confirmed",
175
+ canonicalTarget: "Button",
176
+ evidence: [first.id],
177
+ });
178
+
179
+ expect(first.id).not.toBe(second.id);
180
+ expect(String(identity.componentId)).toBe(first.componentKey);
181
+ expect(identity.evidence).toEqual([first.id]);
182
+ });
183
+
154
184
  it("keeps v1 IDs stable across owned package spellings only in typed identity fields", () => {
155
185
  const legacyComponentId = asComponentId("@fragments-sdk/ui#Button");
156
186
  const currentComponentId = asComponentId("@usefragments/ui#Button");
@@ -18,6 +18,10 @@ export type {
18
18
  CanonicalMappingFact,
19
19
  CanonicalReplacementFact,
20
20
  ComponentLevelFact,
21
+ ComponentDefinitionFact,
22
+ ComponentDefinitionRenderRoot,
23
+ ComponentIdentityFact,
24
+ IdentityFact,
21
25
  PolicyFact,
22
26
  UsageFact,
23
27
  ComponentMetadataFact,
@@ -73,6 +77,8 @@ export { componentId, asComponentId, factId, hash64Hex, canonicalJson } from "./
73
77
  export {
74
78
  makeComponentMetadataFact,
75
79
  makeComponentCapabilityFact,
80
+ makeComponentDefinitionFact,
81
+ makeComponentIdentityFact,
76
82
  makePropMetadataFact,
77
83
  makePropValueAvoidedFact,
78
84
  makePropValueForbiddenFact,
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { GovernanceSeverity } from "../governance.js";
16
+ import type { IdentityConfidence, IdentityState } from "../identity/classify.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Branded IDs
@@ -183,6 +184,14 @@ export interface JsxImportPathPreferredFact extends BaseFact {
183
184
  imported?: string;
184
185
  because?: string;
185
186
  severity: GovernanceSeverity;
187
+ /** Present when the policy was compiled from a confirmed local-canonical bridge. */
188
+ bridge?: {
189
+ componentKey: string;
190
+ localExportName: string;
191
+ underlyingExportName: string;
192
+ implementationFiles: string[];
193
+ decisionSource: "authored" | "migration";
194
+ };
186
195
  }
187
196
 
188
197
  export interface JsxComponentPreferredFact extends BaseFact {
@@ -196,7 +205,9 @@ export interface JsxComponentPreferredFact extends BaseFact {
196
205
  /**
197
206
  * A design token from the system. Used by fixes (e.g., raw color → token
198
207
  * substitution) and rules that need to know the token vocabulary. Identity is
199
- * the token `name`; two scans of the same token registry produce the same ID.
208
+ * the token `name` for legacy/local facts. Authority-aware facts also include
209
+ * role and source identity so same-name local/upstream definitions cannot
210
+ * silently collapse in the fact index.
200
211
  */
201
212
  export interface TokenDefinitionFact extends BaseFact {
202
213
  kind: "token_definition";
@@ -211,14 +222,21 @@ export interface TokenDefinitionFact extends BaseFact {
211
222
  * How the token is referenced from authored styles, inferred from its source.
212
223
  * Governs whether a "use the token" fix can be *applied* automatically: a
213
224
  * `css-var`/`scss-var` reference resolves verbatim, but a `scss-map` member
214
- * (`$colors-primary` minted from a Sass map) or a `dtcg` JSON token (no CSS
215
- * custom property emitted) does not those are surfaced as suggestions, not
216
- * deterministic rewrites, so the fixer never writes non-compiling output.
217
- * Absent on legacy/hand-built facts, which are treated as safe.
225
+ * (`$colors-primary` minted from a Sass map), a `dtcg` JSON token (no CSS
226
+ * custom property emitted), or a `js-member` path (`colors.primary`) does not
227
+ * — those are surfaced as suggestions, not deterministic rewrites, so the
228
+ * fixer never writes invented syntax. Absent on legacy/hand-built facts,
229
+ * which fail closed because an unknown authored form is not applicability
230
+ * proof.
218
231
  */
219
- referenceFormat?: "css-var" | "scss-var" | "scss-map" | "dtcg";
232
+ referenceFormat?: "css-var" | "scss-var" | "scss-map" | "dtcg" | "js-member";
220
233
  /** Original authored spellings retained before parser normalization. */
221
234
  sourceNames?: string[];
235
+ role?: "local" | "upstream";
236
+ authority?: "authored" | "declared-package";
237
+ sourceIdentity?: string;
238
+ /** Authored upstream identity when the local and upstream names differ. */
239
+ upstreamName?: string;
222
240
  }
223
241
 
224
242
  export interface TailwindPaletteAllowFact extends BaseFact {
@@ -574,6 +592,65 @@ export interface CanonicalReplacementFact extends BaseFact {
574
592
  location?: FactLocation;
575
593
  }
576
594
 
595
+ // ---------------------------------------------------------------------------
596
+ // Component identity facts
597
+ // ---------------------------------------------------------------------------
598
+
599
+ export type ComponentDefinitionRenderRoot =
600
+ | {
601
+ resolution: "canonical";
602
+ canonical: string;
603
+ importSource: string;
604
+ }
605
+ | {
606
+ resolution: "intrinsic";
607
+ tag: string;
608
+ role?: string;
609
+ interactive?: boolean;
610
+ }
611
+ | {
612
+ resolution: "mixed";
613
+ canonicals: string[];
614
+ intrinsics: string[];
615
+ }
616
+ | {
617
+ resolution: "external";
618
+ importSource: string;
619
+ }
620
+ | {
621
+ resolution: "unresolved";
622
+ reason: string;
623
+ };
624
+
625
+ export interface ComponentDefinitionFact extends BaseFact {
626
+ kind: "component_definition";
627
+ /** Index-compatible projection of `componentKey`. */
628
+ componentId: ComponentId;
629
+ file: string;
630
+ exportName: string;
631
+ /** Whether this definition is reachable through an authored ESM export. */
632
+ exported: boolean;
633
+ componentKey: string;
634
+ renderRoot: ComponentDefinitionRenderRoot;
635
+ propSurface: string[];
636
+ stylingChannel?: {
637
+ module: string;
638
+ ownsVisualRole: boolean;
639
+ };
640
+ }
641
+
642
+ export interface ComponentIdentityFact extends BaseFact {
643
+ kind: "component_identity";
644
+ /** Index-compatible projection of `componentKey`. */
645
+ componentId: ComponentId;
646
+ componentKey: string;
647
+ state: IdentityState;
648
+ confidence: IdentityConfidence;
649
+ canonicalTarget?: string;
650
+ decisionId?: string;
651
+ evidence: FactId[];
652
+ }
653
+
577
654
  // ---------------------------------------------------------------------------
578
655
  // Discriminated unions
579
656
  // ---------------------------------------------------------------------------
@@ -622,7 +699,9 @@ export type UsageFact =
622
699
  | StructuralConfidenceFact
623
700
  | CanonicalReplacementFact;
624
701
 
625
- export type Fact = ComponentLevelFact | PolicyFact | UsageFact;
702
+ export type IdentityFact = ComponentDefinitionFact | ComponentIdentityFact;
703
+
704
+ export type Fact = ComponentLevelFact | PolicyFact | UsageFact | IdentityFact;
626
705
 
627
706
  export type FactKind = Fact["kind"];
628
707
 
@@ -139,6 +139,28 @@ describe("evaluateGovernanceIntegrity", () => {
139
139
  expect(verdict.status).toBe("healthy");
140
140
  });
141
141
 
142
+ it("6b. a confirmed local bridge arms exact component governance", () => {
143
+ const policy: GovernanceConfig = {
144
+ canonicalBridges: [
145
+ {
146
+ underlying: { packageName: "@mui/material", exportName: "Button" },
147
+ local: {
148
+ componentKey: "src/components/Button.tsx#Button",
149
+ moduleSpecifier: "@/components/Button",
150
+ exportName: "Button",
151
+ implementationFiles: ["src/components/Button.tsx"],
152
+ },
153
+ decision: { state: "confirmed", source: "authored" },
154
+ },
155
+ ],
156
+ };
157
+ const verdict = evaluate({ policy, policySource: "config", declared: true });
158
+ const components = verdict.families.find((family) => family.id === "components");
159
+ expect(components).toMatchObject({ armed: true });
160
+ expect(components?.rules).toContain("imports/preferred-path");
161
+ expect(verdict.status).toBe("healthy");
162
+ });
163
+
142
164
  it("7. css-vars active + token vocabulary → tokens armed → healthy", () => {
143
165
  const policy: GovernanceConfig = {
144
166
  styles: [{ kind: "style.cssVars.mustBeDefined", severity: "warn" }],
@@ -166,23 +166,29 @@ export function evaluateGovernanceIntegrity(
166
166
 
167
167
  // --- components family ----------------------------------------------------
168
168
  const componentsConfig = configs.get("components/prefer-library");
169
+ const confirmedBridges = input.policy?.canonicalBridges ?? [];
169
170
  const componentsArmed =
170
- componentsConfig?.enabled === true && hasEffectiveComponentVocabulary(componentsConfig.options);
171
+ confirmedBridges.length > 0 ||
172
+ (componentsConfig?.enabled === true &&
173
+ hasEffectiveComponentVocabulary(componentsConfig.options));
171
174
  const componentsFamily: GovernanceIntegrityFamily = {
172
175
  id: "components",
173
176
  armed: componentsArmed,
174
- rules: ["components/prefer-library"],
177
+ rules: [
178
+ ...(confirmedBridges.length > 0 ? ["imports/preferred-path"] : []),
179
+ ...(componentsConfig?.enabled === true ? ["components/prefer-library"] : []),
180
+ ],
175
181
  };
176
182
  if (!componentsArmed) {
177
183
  if (componentsConfig?.enabled === true) {
178
184
  componentsFamily.reason =
179
185
  "components/prefer-library enabled but no effective canonical source";
180
186
  componentsFamily.remediation =
181
- "add govern.canonicalSources (a directory source, or npm/registry with a non-empty include) or designSystem.path/packageName";
187
+ "add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
182
188
  } else {
183
189
  componentsFamily.reason = "components/prefer-library not enabled";
184
190
  componentsFamily.remediation =
185
- "add govern.canonicalSources (a directory source, or npm/registry with a non-empty include) or designSystem.path/packageName";
191
+ "add govern.canonicalBridges for local wrappers, govern.canonicalSources, or designSystem.path/packageName";
186
192
  }
187
193
  }
188
194
 
@@ -139,6 +139,99 @@ describe("governance DSL", () => {
139
139
  });
140
140
  });
141
141
 
142
+ it("accepts one confirmed package-to-local canonical bridge", () => {
143
+ const config = defineConfig({
144
+ govern: {
145
+ canonicalBridges: [
146
+ {
147
+ underlying: { packageName: "@mui/material", exportName: "Button" },
148
+ local: {
149
+ componentKey: "src/components/Button.tsx#Button",
150
+ moduleSpecifier: "@/components",
151
+ exportName: "Button",
152
+ implementationFiles: ["src/components/Button.tsx"],
153
+ },
154
+ decision: { state: "confirmed", source: "authored" },
155
+ },
156
+ ],
157
+ },
158
+ });
159
+
160
+ expect(config.govern?.canonicalBridges?.[0]).toMatchObject({
161
+ underlying: { packageName: "@mui/material", exportName: "Button" },
162
+ local: { moduleSpecifier: "@/components", exportName: "Button" },
163
+ decision: { state: "confirmed", source: "authored" },
164
+ });
165
+ });
166
+
167
+ it("rejects ambiguous or unsafe canonical bridge declarations", () => {
168
+ const bridge = {
169
+ underlying: { packageName: "@mui/material", exportName: "Button" },
170
+ local: {
171
+ componentKey: "src/components/Button.tsx#Button",
172
+ moduleSpecifier: "@/components",
173
+ exportName: "Button",
174
+ implementationFiles: ["src/components/Button.tsx"],
175
+ },
176
+ decision: { state: "confirmed" as const, source: "authored" as const },
177
+ };
178
+
179
+ expect(() => defineConfig({ govern: { canonicalBridges: [bridge, bridge] } })).toThrow(
180
+ "Invalid fragments config"
181
+ );
182
+ expect(() =>
183
+ defineConfig({
184
+ govern: {
185
+ canonicalBridges: [
186
+ bridge,
187
+ {
188
+ ...bridge,
189
+ underlying: { packageName: "@radix-ui/react-slot", exportName: "Slot" },
190
+ },
191
+ ],
192
+ },
193
+ })
194
+ ).toThrow("Invalid fragments config");
195
+ expect(() =>
196
+ defineConfig({
197
+ govern: {
198
+ canonicalBridges: [
199
+ {
200
+ ...bridge,
201
+ local: { ...bridge.local, moduleSpecifier: "../components" },
202
+ },
203
+ ],
204
+ },
205
+ })
206
+ ).toThrow("Invalid fragments config");
207
+ });
208
+
209
+ it("preserves top-level component identity decisions", () => {
210
+ const config = defineConfig({
211
+ identity: {
212
+ decisions: [
213
+ {
214
+ component: "src/components/AppDialog.tsx#AppDialog",
215
+ kind: "sanction",
216
+ canonicalTarget: "Dialog",
217
+ reason: "Owns the application modal defaults.",
218
+ decisionId: "local:app-dialog",
219
+ },
220
+ ],
221
+ },
222
+ });
223
+
224
+ expect(config.identity?.decisions).toEqual([
225
+ {
226
+ component: "src/components/AppDialog.tsx#AppDialog",
227
+ kind: "sanction",
228
+ canonicalTarget: "Dialog",
229
+ reason: "Owns the application modal defaults.",
230
+ decisionId: "local:app-dialog",
231
+ },
232
+ ]);
233
+ });
234
+
142
235
  it("rejects unsafe registry canonical source paths", () => {
143
236
  expect(() =>
144
237
  defineConfig({
package/src/governance.ts CHANGED
@@ -192,6 +192,79 @@ const canonicalSourcePathSchema = z
192
192
 
193
193
  const canonicalSourceRegistryHashSchema = z.string().regex(/^[0-9a-f]{64}$/);
194
194
 
195
+ const canonicalBridgeModuleSpecifierSchema = z
196
+ .string()
197
+ .min(1)
198
+ .superRefine((value, ctx) => {
199
+ const normalized = value.replace(/\\/gu, "/");
200
+ if (normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) {
201
+ ctx.addIssue({
202
+ code: z.ZodIssueCode.custom,
203
+ message: "Canonical bridge moduleSpecifier must be a portable import specifier",
204
+ });
205
+ }
206
+ if (normalized.split("/").includes("..")) {
207
+ ctx.addIssue({
208
+ code: z.ZodIssueCode.custom,
209
+ message: "Canonical bridge moduleSpecifier must not traverse outside the project",
210
+ });
211
+ }
212
+ });
213
+
214
+ export const canonicalBridgeV1Schema = z
215
+ .object({
216
+ underlying: z
217
+ .object({
218
+ packageName: z.string().min(1),
219
+ exportName: z.string().min(1),
220
+ })
221
+ .strict(),
222
+ local: z
223
+ .object({
224
+ componentKey: z.string().min(1),
225
+ moduleSpecifier: canonicalBridgeModuleSpecifierSchema,
226
+ exportName: z.string().min(1),
227
+ implementationFiles: z.array(canonicalSourcePathSchema).min(1),
228
+ })
229
+ .strict(),
230
+ decision: z
231
+ .object({
232
+ state: z.literal("confirmed"),
233
+ source: z.enum(["authored", "migration"]),
234
+ })
235
+ .strict(),
236
+ })
237
+ .strict();
238
+
239
+ const canonicalBridgesV1Schema = z.array(canonicalBridgeV1Schema).superRefine((bridges, ctx) => {
240
+ const underlying = new Map<string, number>();
241
+ const componentKeys = new Map<string, number>();
242
+ for (const [index, bridge] of bridges.entries()) {
243
+ const underlyingKey = `${bridge.underlying.packageName}\u0000${bridge.underlying.exportName}`;
244
+ const previousUnderlying = underlying.get(underlyingKey);
245
+ if (previousUnderlying !== undefined) {
246
+ ctx.addIssue({
247
+ code: z.ZodIssueCode.custom,
248
+ message: `Duplicate confirmed bridge target for ${bridge.underlying.packageName}#${bridge.underlying.exportName} (also at canonicalBridges.${previousUnderlying})`,
249
+ path: [index, "underlying"],
250
+ });
251
+ } else {
252
+ underlying.set(underlyingKey, index);
253
+ }
254
+
255
+ const previousComponent = componentKeys.get(bridge.local.componentKey);
256
+ if (previousComponent !== undefined) {
257
+ ctx.addIssue({
258
+ code: z.ZodIssueCode.custom,
259
+ message: `Local component key overlaps canonicalBridges.${previousComponent}`,
260
+ path: [index, "local", "componentKey"],
261
+ });
262
+ } else {
263
+ componentKeys.set(bridge.local.componentKey, index);
264
+ }
265
+ }
266
+ });
267
+
195
268
  const canonicalSourceSchema = z.discriminatedUnion("kind", [
196
269
  z.object({
197
270
  kind: z.literal("npm"),
@@ -237,6 +310,7 @@ export const governanceConfigSchema = z
237
310
  audit: z.object({}).passthrough().optional(),
238
311
  runners: z.record(z.string(), z.object({}).passthrough()).optional(),
239
312
  canonicalSources: z.array(canonicalSourceSchema).optional(),
313
+ canonicalBridges: canonicalBridgesV1Schema.optional(),
240
314
  presets: z.array(z.string()).optional(),
241
315
  scales: z.record(z.string(), scaleGovernanceRecordSchema).optional(),
242
316
  styles: z.array(globalStyleGovernanceRecordSchema).optional(),
@@ -278,6 +352,7 @@ export type ComponentPolicyMatch = z.infer<typeof componentPolicyMatchSchema>;
278
352
  export type ComponentPolicyRecord = z.infer<typeof componentPolicyRecordSchema>;
279
353
  export type ComponentPolicyOverride = z.infer<typeof componentPolicyOverrideSchema>;
280
354
  export type CanonicalSource = z.infer<typeof canonicalSourceSchema>;
355
+ export type CanonicalBridgeV1 = z.infer<typeof canonicalBridgeV1Schema>;
281
356
 
282
357
  export interface GovernanceConfig {
283
358
  extends?: string[];
@@ -287,6 +362,7 @@ export interface GovernanceConfig {
287
362
  audit?: Record<string, unknown>;
288
363
  runners?: Record<string, Record<string, unknown>>;
289
364
  canonicalSources?: CanonicalSource[];
365
+ canonicalBridges?: CanonicalBridgeV1[];
290
366
  presets?: string[];
291
367
  scales?: Record<string, ScaleGovernanceRecord>;
292
368
  styles?: GlobalStyleGovernanceRecord[];