@usefragments/core 1.5.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 (61) hide show
  1. package/dist/{chunk-HXGE3O2F.js → chunk-AOG4FTV6.js} +108 -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-BLsyk56o.d.ts → governance-B88uR3Zq.d.ts} +218 -1
  10. package/dist/{index-h_yWj15D.d.ts → index-DbkPE46t.d.ts} +40 -0
  11. package/dist/index.d.ts +429 -28
  12. package/dist/index.js +326 -33
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/schemas/index.d.ts +1 -1
  16. package/dist/schemas/index.js +1 -1
  17. package/dist/test-utils.d.ts +1 -1
  18. package/package.json +1 -1
  19. package/src/agent-format.test.ts +1 -0
  20. package/src/canonical-bridge.ts +46 -0
  21. package/src/canonical-direction.ts +118 -0
  22. package/src/codes/codes.ts +9 -0
  23. package/src/contract/preimage.test.ts +40 -0
  24. package/src/contract/preimage.ts +20 -0
  25. package/src/effective-governance-inputs.test.ts +52 -0
  26. package/src/effective-governance-inputs.ts +106 -0
  27. package/src/facts/builders.ts +25 -1
  28. package/src/facts/compile.ts +20 -2
  29. package/src/facts/fact-index.ts +4 -0
  30. package/src/facts/types.ts +25 -6
  31. package/src/governance-integrity.test.ts +22 -0
  32. package/src/governance-integrity.ts +10 -4
  33. package/src/governance.test.ts +67 -0
  34. package/src/governance.ts +76 -0
  35. package/src/index.ts +27 -4
  36. package/src/rules/__tests__/fix-emission-invariant.test.ts +35 -4
  37. package/src/rules/components-prefer-library.test.ts +103 -0
  38. package/src/rules/components-prefer-library.ts +30 -4
  39. package/src/rules/fix-availability.ts +1 -0
  40. package/src/rules/index.ts +7 -0
  41. package/src/rules/jsx-preferred-import-path.ts +45 -9
  42. package/src/rules/rules.test.ts +202 -6
  43. package/src/rules/spacing-resolution.ts +2 -2
  44. package/src/rules/styles-no-raw-color.test.ts +6 -1
  45. package/src/rules/styles-no-raw-color.ts +3 -2
  46. package/src/rules/styles-no-raw-dimensions.ts +5 -7
  47. package/src/rules/styles-no-raw-spacing.ts +5 -2
  48. package/src/rules/styles-no-raw-typography.ts +7 -4
  49. package/src/rules/taxonomy.test.ts +3 -0
  50. package/src/rules/tiers.ts +2 -0
  51. package/src/rules/token-candidates.ts +36 -0
  52. package/src/rules/tokens-require-dual-fallback.ts +2 -1
  53. package/src/rules/tokens-upstream-drift.test.ts +111 -0
  54. package/src/rules/tokens-upstream-drift.ts +57 -0
  55. package/src/rules/utils.ts +22 -14
  56. package/src/schema.ts +13 -0
  57. package/src/schemas/index.ts +5 -8
  58. package/src/token-types.ts +71 -1
  59. package/src/types.ts +6 -0
  60. package/dist/chunk-57QDBEHQ.js.map +0 -1
  61. package/dist/chunk-HXGE3O2F.js.map +0 -1
@@ -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,73 @@ 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
+
142
209
  it("preserves top-level component identity decisions", () => {
143
210
  const config = defineConfig({
144
211
  identity: {
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[];
package/src/index.ts CHANGED
@@ -227,6 +227,12 @@ export type {
227
227
  // Token types
228
228
  export type {
229
229
  TokenCategory,
230
+ TokenCatalog,
231
+ TokenCatalogDefinition,
232
+ TokenCatalogDiagnostic,
233
+ TokenCatalogLocation,
234
+ TokenCatalogReference,
235
+ TokenCatalogSourceState,
230
236
  DesignToken,
231
237
  TokenRegistry,
232
238
  TokenRegistryMeta,
@@ -435,6 +441,7 @@ export type { InferProps } from "./defineFragment.js";
435
441
  export {
436
442
  componentGovernanceRecordSchema,
437
443
  componentGovernanceRecordsSchema,
444
+ canonicalBridgeV1Schema,
438
445
  g,
439
446
  globalGovernanceRecordSchema,
440
447
  globalJsxGovernanceRecordSchema,
@@ -446,6 +453,7 @@ export {
446
453
  } from "./governance.js";
447
454
  export type {
448
455
  AccessibilityGovernanceBuilder,
456
+ CanonicalBridgeV1,
449
457
  CanonicalSource,
450
458
  ComponentGovernanceBuilder,
451
459
  ComponentGovernanceRecord,
@@ -645,10 +653,7 @@ export {
645
653
  ruleTokensRequireDualFallback,
646
654
  ruleA11yRequiredAccessibleName,
647
655
  } from "./rules/index.js";
648
- export {
649
- indexComponentIdentityUsage,
650
- type ComponentIdentityUsage,
651
- } from "./identity/usage.js";
656
+ export { indexComponentIdentityUsage, type ComponentIdentityUsage } from "./identity/usage.js";
652
657
  export { nearestSignedScaleValue } from "./rules/utils.js";
653
658
  export {
654
659
  buildSpacingTokenLookup,
@@ -720,6 +725,24 @@ export type {
720
725
  GovernanceIntegrityVerdict,
721
726
  } from "./governance-integrity.js";
722
727
 
728
+ export {
729
+ canonicalDirectionConflictsTarget,
730
+ projectCanonicalDirectionConflicts,
731
+ } from "./canonical-direction.js";
732
+ export type { CanonicalDirectionConflict } from "./canonical-direction.js";
733
+
734
+ export { canonicalBridgeContractMappings } from "./canonical-bridge.js";
735
+
736
+ export {
737
+ compileEffectiveGovernanceInputs,
738
+ EFFECTIVE_GOVERNANCE_INPUTS_SCHEMA,
739
+ } from "./effective-governance-inputs.js";
740
+ export type {
741
+ CompileEffectiveGovernanceInputsOptions,
742
+ EffectiveGovernanceInputs,
743
+ EffectiveGovernanceInputsSourceState,
744
+ } from "./effective-governance-inputs.js";
745
+
723
746
  // Fact IR + index (Phase 3 of the governance compiler refactor)
724
747
  export {
725
748
  componentId,
@@ -65,6 +65,34 @@ describe("proof-carrying fix invariant", () => {
65
65
  "token reference is not directly applicable"
66
66
  );
67
67
  });
68
+
69
+ it("keeps JavaScript member tokens advisory without inventing a CSS variable", () => {
70
+ const ix = buildFixtureCorpus("js-member", "radii.md");
71
+ const dimension = RULES.find((rule) => rule.id === "styles/no-raw-dimensions")
72
+ ?.run(ix)
73
+ .find((finding) => finding.fix);
74
+
75
+ expect(dimension?.fix).toMatchObject({
76
+ value: "radii.md",
77
+ deterministic: false,
78
+ });
79
+ expect(JSON.stringify(dimension?.fix)).not.toContain("var(--radii-md)");
80
+ expect(dimension?.attributes?.downgradeReason).toContain(
81
+ "token reference is not directly applicable"
82
+ );
83
+ });
84
+
85
+ it("fails closed when a legacy token fact has no authored reference format", () => {
86
+ const ix = buildFixtureCorpus("unknown");
87
+ const dimension = RULES.find((rule) => rule.id === "styles/no-raw-dimensions")
88
+ ?.run(ix)
89
+ .find((finding) => finding.fix);
90
+
91
+ expect(dimension?.fix?.deterministic).toBe(false);
92
+ expect(dimension?.attributes?.downgradeReason).toContain(
93
+ "token reference is not directly applicable"
94
+ );
95
+ });
68
96
  });
69
97
 
70
98
  function runEveryRule(ix: FactIndex): Finding[] {
@@ -98,7 +126,10 @@ function withoutDeterminism(fix: FindingFix): Omit<FindingFix, "deterministic">
98
126
  return rest;
99
127
  }
100
128
 
101
- function buildFixtureCorpus(radiusReferenceFormat: "css-var" | "dtcg" = "css-var"): FactIndex {
129
+ function buildFixtureCorpus(
130
+ radiusReferenceFormat: "css-var" | "dtcg" | "js-member" | "unknown" = "css-var",
131
+ radiusName = "--proof-radius"
132
+ ): FactIndex {
102
133
  const ix = new FactIndex();
103
134
  ix.addMany([
104
135
  makeTokenDefinitionFact({
@@ -116,11 +147,11 @@ function buildFixtureCorpus(radiusReferenceFormat: "css-var" | "dtcg" = "css-var
116
147
  sourceNames: ["--proof-space"],
117
148
  }),
118
149
  makeTokenDefinitionFact({
119
- name: "--proof-radius",
150
+ name: radiusName,
120
151
  value: "4px",
121
152
  category: "radius",
122
- referenceFormat: radiusReferenceFormat,
123
- sourceNames: ["--proof-radius"],
153
+ referenceFormat: radiusReferenceFormat === "unknown" ? undefined : radiusReferenceFormat,
154
+ sourceNames: [radiusName],
124
155
  }),
125
156
  makeTokenDefinitionFact({
126
157
  name: "--proof-font",
@@ -4,6 +4,7 @@ import {
4
4
  asComponentId,
5
5
  compileGlobalGovernanceFacts,
6
6
  FactIndex,
7
+ makeComponentDefinitionFact,
7
8
  makeComponentIdentityFact,
8
9
  makeUsageComponentFact,
9
10
  makeUsageImportFact,
@@ -11,6 +12,108 @@ import {
11
12
  ruleComponentsPreferLibrary,
12
13
  } from "../index.js";
13
14
 
15
+ describe("components/prefer-library — canonical direction containment", () => {
16
+ const componentKey = "src/components/Button.tsx#Button";
17
+
18
+ function indexFor(decision?: "sanction" | "reject", exported = true) {
19
+ const ix = new FactIndex();
20
+ ix.addMany(
21
+ compileGlobalGovernanceFacts({
22
+ rules: {
23
+ "components/prefer-library": {
24
+ enabled: true,
25
+ severity: "warning",
26
+ options: {
27
+ canonicalSources: [
28
+ { kind: "npm", specifier: "@mui/material", include: ["Button", "Stepper"] },
29
+ ],
30
+ },
31
+ },
32
+ },
33
+ })
34
+ );
35
+ const definition = makeComponentDefinitionFact({
36
+ file: "src/components/Button.tsx",
37
+ exportName: "Button",
38
+ exported,
39
+ componentKey,
40
+ renderRoot: {
41
+ resolution: "canonical",
42
+ canonical: "@mui/material#Button",
43
+ importSource: "@mui/material",
44
+ },
45
+ propSurface: [],
46
+ });
47
+ ix.add(definition);
48
+ ix.add(
49
+ makeComponentIdentityFact({
50
+ componentKey,
51
+ state: decision === "reject" ? "shadow" : "variant",
52
+ confidence: decision ? "confirmed" : "review",
53
+ canonicalTarget: "Button",
54
+ ...(decision ? { decisionId: `local:${decision}:${componentKey}` } : {}),
55
+ evidence: [definition.id],
56
+ })
57
+ );
58
+ return ix;
59
+ }
60
+
61
+ function addLocalUsage(ix: FactIndex, name: "Button" | "Stepper") {
62
+ ix.add(
63
+ makeUsageNodeFact({
64
+ file: "src/App.tsx",
65
+ nodePath: `0:${name}`,
66
+ element: name,
67
+ location: { file: "src/App.tsx", line: 3, column: 2 },
68
+ })
69
+ );
70
+ ix.add(
71
+ makeUsageImportFact({
72
+ file: "src/App.tsx",
73
+ local: name,
74
+ imported: name,
75
+ source: `./components/${name}`,
76
+ location: { file: "src/App.tsx", line: 1, column: 0 },
77
+ })
78
+ );
79
+ }
80
+
81
+ it("suppresses only the exact conflicted npm export when direction is unresolved", () => {
82
+ const ix = indexFor();
83
+ addLocalUsage(ix, "Button");
84
+ addLocalUsage(ix, "Stepper");
85
+ ix.add(
86
+ makeUsageNodeFact({
87
+ file: "src/App.tsx",
88
+ nodePath: "0:raw-button",
89
+ element: "button",
90
+ interactive: true,
91
+ location: { file: "src/App.tsx", line: 4, column: 2 },
92
+ })
93
+ );
94
+
95
+ const findings = ruleComponentsPreferLibrary(ix);
96
+ expect(findings).toHaveLength(1);
97
+ expect(findings[0]?.attributes?.suggestedComponent).toBe("Stepper");
98
+ });
99
+
100
+ it("keeps a sanctioned wrapper exempt and restores npm guidance after explicit rejection", () => {
101
+ const sanctioned = indexFor("sanction");
102
+ addLocalUsage(sanctioned, "Button");
103
+ expect(ruleComponentsPreferLibrary(sanctioned)).toHaveLength(0);
104
+
105
+ const rejected = indexFor("reject");
106
+ addLocalUsage(rejected, "Button");
107
+ expect(ruleComponentsPreferLibrary(rejected)).toHaveLength(1);
108
+ });
109
+
110
+ it("does not let a private helper deactivate canonical policy", () => {
111
+ const ix = indexFor(undefined, false);
112
+ addLocalUsage(ix, "Button");
113
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(1);
114
+ });
115
+ });
116
+
14
117
  // #9e — import-path identity for a DIRECTORY canonical source. A directory
15
118
  // source (e.g. `src/components/ui`) declares its canonical component NAMES via an
16
119
  // include list. The rule must:
@@ -15,6 +15,11 @@ import {
15
15
  type RawHtmlPrecisionTier,
16
16
  } from "../raw-html-canonical.js";
17
17
  import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
18
+ import {
19
+ canonicalDirectionConflictsTarget,
20
+ projectCanonicalDirectionConflicts,
21
+ type CanonicalDirectionConflict,
22
+ } from "../canonical-direction.js";
18
23
 
19
24
  import { makeFinding } from "./finding.js";
20
25
  import type { Finding } from "./types.js";
@@ -254,8 +259,12 @@ function findClassNameReimpl(
254
259
  export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
255
260
  const policy = ix.policy.ruleConfig(RULE_ID);
256
261
  if (!policy?.enabled) return [];
257
- const sources = canonicalSourcesFromPolicy(policy.options?.canonicalSources);
258
- const mappings = canonicalMappingsFromPolicy(policy.options?.canonicalMappings);
262
+ const configuredSources = canonicalSourcesFromPolicy(policy.options?.canonicalSources);
263
+ const conflicts = projectCanonicalDirectionConflicts(ix, configuredSources);
264
+ const sources = configuredSources.map((source) => suppressConflictedExports(source, conflicts));
265
+ const mappings = canonicalMappingsFromPolicy(policy.options?.canonicalMappings).filter(
266
+ (mapping) => !canonicalDirectionConflictsTarget(conflicts, mapping.importPath, mapping.name)
267
+ );
259
268
  if (sources.length === 0 && mappings.length === 0) return [];
260
269
 
261
270
  const importsByFileAndLocal = indexImportsByFileAndLocal(ix);
@@ -605,6 +614,22 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
605
614
  return findings;
606
615
  }
607
616
 
617
+ function suppressConflictedExports(
618
+ source: CanonicalSource,
619
+ conflicts: readonly CanonicalDirectionConflict[]
620
+ ): CanonicalSource {
621
+ if (source.kind !== "npm") return source;
622
+ const names = conflicts
623
+ .filter((conflict) => ownedImportMatchesRoot(source.specifier, conflict.packageName))
624
+ .map((conflict) => conflict.exportName);
625
+ if (names.length === 0) return source;
626
+ const blocked = new Set(names);
627
+ if (source.include) {
628
+ return { ...source, include: source.include.filter((name) => !blocked.has(name)) };
629
+ }
630
+ return { ...source, exclude: [...new Set([...(source.exclude ?? []), ...names])].sort() };
631
+ }
632
+
608
633
  function indexShadowRenderRootNodeIds(ix: FactIndex): Set<FactId> {
609
634
  const identities = ix.byKind("component_identity");
610
635
  if (identities.length === 0) return new Set();
@@ -1113,8 +1138,9 @@ function indexCanonicalDirectoryResolvedNodes(
1113
1138
  ): Set<FactId> {
1114
1139
  const out = new Set<FactId>();
1115
1140
  const directoryPaths = sources
1116
- .filter((source): source is Extract<CanonicalSource, { kind: "directory" }> =>
1117
- source.kind === "directory"
1141
+ .filter(
1142
+ (source): source is Extract<CanonicalSource, { kind: "directory" }> =>
1143
+ source.kind === "directory"
1118
1144
  )
1119
1145
  .map((source) => normalizePath(source.path));
1120
1146
  if (directoryPaths.length === 0) return out;
@@ -25,6 +25,7 @@ export const RULE_FIX_AVAILABLE = {
25
25
  "tailwind/unknown-class": false,
26
26
  "tokens/require-dual-fallback": true,
27
27
  "tokens/css-vars-must-be-defined": false,
28
+ "tokens/upstream-drift": false,
28
29
  "theme/no-theme-coupled-literal": false,
29
30
  "a11y/required-accessible-name": false,
30
31
  "composition/cardinality": false,
@@ -31,6 +31,7 @@ import { ruleTailwindRawColorViaToken } from "./tailwind-raw-color-via-token.js"
31
31
  import { ruleTailwindUnknownClass } from "./tailwind-unknown-class.js";
32
32
  import { ruleTokensRequireDualFallback } from "./tokens-require-dual-fallback.js";
33
33
  import { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-defined.js";
34
+ import { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
34
35
  import type { Finding } from "./types.js";
35
36
 
36
37
  export type RuleFn = (ix: FactIndex) => Finding[];
@@ -137,6 +138,11 @@ export const RULES: readonly Rule[] = [
137
138
  version: "1",
138
139
  run: ruleTokensCssVarsMustBeDefined,
139
140
  },
141
+ {
142
+ id: "tokens/upstream-drift",
143
+ version: "1",
144
+ run: ruleTokensUpstreamDrift,
145
+ },
140
146
  {
141
147
  id: "theme/no-theme-coupled-literal",
142
148
  version: "1",
@@ -211,6 +217,7 @@ export { ruleTailwindRawColorViaToken } from "./tailwind-raw-color-via-token.js"
211
217
  export { ruleTailwindUnknownClass } from "./tailwind-unknown-class.js";
212
218
  export { ruleTokensRequireDualFallback } from "./tokens-require-dual-fallback.js";
213
219
  export { ruleTokensCssVarsMustBeDefined } from "./tokens-css-vars-must-be-defined.js";
220
+ export { ruleTokensUpstreamDrift } from "./tokens-upstream-drift.js";
214
221
  export { ruleThemeNoThemeCoupledLiteral } from "./theme-no-theme-coupled-literal.js";
215
222
  export { ruleA11yRequiredAccessibleName } from "./a11y-required-accessible-name.js";
216
223
  export { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js";
@@ -1,5 +1,5 @@
1
1
  import type { FactIndex } from "../facts/index.js";
2
- import { ownedImportsEqual } from "../package-identity-match.js";
2
+ import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
3
3
 
4
4
  import { makeFinding } from "./finding.js";
5
5
  import type { Finding } from "./types.js";
@@ -16,6 +16,21 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
16
16
 
17
17
  for (const usage of ix.byKind("usage_import")) {
18
18
  const matches = policies.filter((policy) => {
19
+ if (policy.bridge) {
20
+ if (policy.bridge.implementationFiles.some((file) => sameRepoPath(file, usage.file))) {
21
+ return false;
22
+ }
23
+ if (!ownedImportMatchesRoot(usage.source, policy.from)) return false;
24
+ if (ownedImportsEqual(usage.source, policy.from)) {
25
+ return usage.imported === policy.bridge.underlyingExportName;
26
+ }
27
+ const subpath = usage.source.slice(policy.from.length + 1);
28
+ const leaf = subpath.split("/").at(-1);
29
+ return (
30
+ leaf === policy.bridge.underlyingExportName &&
31
+ (usage.imported === "default" || usage.imported === policy.bridge.underlyingExportName)
32
+ );
33
+ }
19
34
  if (!ownedImportsEqual(policy.from, usage.source)) return false;
20
35
  if (policy.imported !== undefined && policy.imported !== usage.imported) return false;
21
36
  return true;
@@ -33,7 +48,9 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
33
48
  ruleId: RULE_ID,
34
49
  ruleVersion: RULE_VERSION,
35
50
  severity: policy.severity,
36
- message: `Import from "${usage.source}" should use "${policy.to}".`,
51
+ message: policy.bridge
52
+ ? `Import ${policy.bridge.underlyingExportName} through the confirmed local ${policy.bridge.localExportName} wrapper from "${policy.to}".`
53
+ : `Import from "${usage.source}" should use "${policy.to}".`,
37
54
  location: usage.location,
38
55
  evidence: ix.evidence([usage.id, policy.id]),
39
56
  fingerprintIdentity: {
@@ -44,19 +61,30 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
44
61
  line: usage.location.line,
45
62
  column: usage.location.column,
46
63
  },
47
- fix: {
48
- kind: "replaceImport",
49
- title: policy.because ?? `Replace import path with "${policy.to}"`,
50
- from: usage.source,
51
- to: policy.to,
52
- deterministic: true,
53
- },
64
+ fix: policy.bridge
65
+ ? undefined
66
+ : {
67
+ kind: "replaceImport",
68
+ title: policy.because ?? `Replace import path with "${policy.to}"`,
69
+ from: usage.source,
70
+ to: policy.to,
71
+ deterministic: true,
72
+ },
54
73
  attributes: {
55
74
  importPath: usage.source,
56
75
  suggestedImport: policy.to,
57
76
  imported: usage.imported,
58
77
  local: usage.local,
59
78
  rawValue: usage.source,
79
+ ...(policy.bridge
80
+ ? {
81
+ canonicalBridge: true,
82
+ canonicalTarget: policy.bridge.componentKey,
83
+ suggestedComponent: policy.bridge.localExportName,
84
+ implementationFiles: policy.bridge.implementationFiles,
85
+ decisionSource: policy.bridge.decisionSource,
86
+ }
87
+ : {}),
60
88
  },
61
89
  })
62
90
  );
@@ -65,3 +93,11 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
65
93
 
66
94
  return findings;
67
95
  }
96
+
97
+ function sameRepoPath(left: string, right: string): boolean {
98
+ return normalizeRepoPath(left) === normalizeRepoPath(right);
99
+ }
100
+
101
+ function normalizeRepoPath(path: string): string {
102
+ return path.replace(/\\/gu, "/").replace(/^\.\//u, "");
103
+ }