@usefragments/core 1.5.2 → 1.6.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 (41) hide show
  1. package/dist/{chunk-BAHCOAVG.js → chunk-WVFNDPM4.js} +423 -189
  2. package/dist/chunk-WVFNDPM4.js.map +1 -0
  3. package/dist/codes/index.d.ts +1 -1
  4. package/dist/codes/index.js +1 -1
  5. package/dist/compiled-types/index.d.ts +1 -1
  6. package/dist/generate/index.d.ts +1 -1
  7. package/dist/{governance-pKrfh517.d.ts → governance-DxFipN5V.d.ts} +609 -20
  8. package/dist/index.d.ts +638 -43
  9. package/dist/index.js +304 -37
  10. package/dist/index.js.map +1 -1
  11. package/dist/react-types.d.ts +1 -1
  12. package/dist/test-utils.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/src/__tests__/policy-exclude.test.ts +180 -0
  15. package/src/canonical-bridge.ts +69 -1
  16. package/src/canonical-direction.test.ts +118 -0
  17. package/src/canonical-direction.ts +43 -2
  18. package/src/codes/__tests__/codes.test.ts +14 -1
  19. package/src/codes/codes.ts +40 -0
  20. package/src/facts/builders.ts +35 -0
  21. package/src/facts/compile.ts +135 -21
  22. package/src/facts/fact-index.ts +15 -2
  23. package/src/facts/facts.test.ts +6 -2
  24. package/src/facts/index.ts +9 -6
  25. package/src/facts/types.ts +45 -9
  26. package/src/governance-integrity.test.ts +174 -3
  27. package/src/governance-integrity.ts +305 -5
  28. package/src/governance.ts +87 -1
  29. package/src/index.ts +38 -1
  30. package/src/policy-exclude.ts +113 -0
  31. package/src/rules/families.test.ts +69 -0
  32. package/src/rules/families.ts +52 -0
  33. package/src/rules/index.ts +6 -0
  34. package/src/rules/jsx-preferred-import-path.ts +29 -11
  35. package/src/rules/rules.test.ts +125 -1
  36. package/src/rules/styles-no-raw-color.ts +13 -4
  37. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  38. package/src/rules/styles-no-raw-spacing.ts +12 -4
  39. package/src/rules/styles-no-raw-typography.ts +13 -4
  40. package/src/rules/utils.ts +39 -0
  41. package/dist/chunk-BAHCOAVG.js.map +0 -1
@@ -17,6 +17,7 @@ import {
17
17
  makeUsageNodeFact,
18
18
  makeUsagePropResolvedFact,
19
19
  makeUsageTextChildFact,
20
+ projectSupersededImportPathPreferences,
20
21
  ruleA11yRequiredAccessibleName,
21
22
  ruleComponentsForbiddenPropValue,
22
23
  ruleComponentsPreferLibrary,
@@ -32,7 +33,7 @@ import {
32
33
  ruleTokensRequireDualFallback,
33
34
  runRules,
34
35
  } from "../index.js";
35
- import type { Fact, FactId } from "../index.js";
36
+ import type { CanonicalBridgeV1, Fact, FactId } from "../index.js";
36
37
 
37
38
  type ButtonProps = {
38
39
  variant?: "primary" | "secondary" | "ghost" | "link";
@@ -625,6 +626,129 @@ describe("preferred JSX imports and components", () => {
625
626
  expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
626
627
  });
627
628
 
629
+ // Report #2 B6 (brownfield-canonical brief 03): a confirmed bridge
630
+ // supersedes any non-bridge preference over the same underlying import —
631
+ // otherwise both emit with the same fingerprint and the hand rule flags the
632
+ // wrapper's own implementation file with a self-import fix.
633
+ describe("bridge supersession of non-bridge import preferences", () => {
634
+ const B6_BRIDGE: CanonicalBridgeV1 = {
635
+ underlying: { packageName: "@mui/material", exportName: "Button" },
636
+ local: {
637
+ componentKey: "src/components/Button.tsx#Button",
638
+ moduleSpecifier: "@/components",
639
+ exportName: "Button",
640
+ implementationFiles: ["src/components/Button.tsx"],
641
+ },
642
+ decision: { state: "confirmed", source: "authored" },
643
+ };
644
+
645
+ it("emits only the bridge finding when a hand-authored record duplicates it", () => {
646
+ const ix = new FactIndex();
647
+ ix.addMany(
648
+ compileGlobalGovernanceFacts({
649
+ jsx: [g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" })],
650
+ canonicalBridges: [B6_BRIDGE],
651
+ })
652
+ );
653
+ ix.add(
654
+ makeUsageImportFact({
655
+ file: "src/app/page.tsx",
656
+ local: "MuiButton",
657
+ imported: "Button",
658
+ source: "@mui/material",
659
+ location: { file: "src/app/page.tsx", line: 1, column: 0 },
660
+ })
661
+ );
662
+
663
+ const findings = ruleJsxPreferredImportPath(ix);
664
+ expect(findings).toHaveLength(1);
665
+ expect(findings[0].attributes).toMatchObject({ canonicalBridge: true });
666
+ });
667
+
668
+ it("keeps the hand rule silent inside the wrapper implementation file", () => {
669
+ const ix = new FactIndex();
670
+ ix.addMany(
671
+ compileGlobalGovernanceFacts({
672
+ jsx: [g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" })],
673
+ canonicalBridges: [B6_BRIDGE],
674
+ })
675
+ );
676
+ ix.add(
677
+ makeUsageImportFact({
678
+ file: "src/components/Button.tsx",
679
+ local: "MuiButton",
680
+ imported: "Button",
681
+ source: "@mui/material",
682
+ location: { file: "src/components/Button.tsx", line: 1, column: 0 },
683
+ })
684
+ );
685
+
686
+ expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
687
+ });
688
+
689
+ it("leaves the hand rule firing on imports the bridge does not govern", () => {
690
+ const ix = new FactIndex();
691
+ ix.addMany(
692
+ compileGlobalGovernanceFacts({
693
+ jsx: [g.jsx.importPath().prefer("@mui/material", "@/components")],
694
+ canonicalBridges: [B6_BRIDGE],
695
+ })
696
+ );
697
+ ix.add(
698
+ makeUsageImportFact({
699
+ file: "src/app/page.tsx",
700
+ local: "Stepper",
701
+ imported: "Stepper",
702
+ source: "@mui/material",
703
+ location: { file: "src/app/page.tsx", line: 1, column: 0 },
704
+ })
705
+ );
706
+
707
+ const findings = ruleJsxPreferredImportPath(ix);
708
+ expect(findings).toHaveLength(1);
709
+ expect(findings[0].attributes).toMatchObject({ imported: "Stepper" });
710
+ expect(findings[0].attributes?.canonicalBridge).toBeUndefined();
711
+ });
712
+
713
+ it("names each superseded record through the compile-side projection", () => {
714
+ const superseded = projectSupersededImportPathPreferences({
715
+ jsx: [
716
+ g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" }),
717
+ g.jsx.importPath().prefer("@mui/material/Button", "@/components"),
718
+ g.jsx.importPath().prefer("@mui/material", "@/inputs", { imported: "TextField" }),
719
+ g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui"),
720
+ ],
721
+ canonicalBridges: [B6_BRIDGE],
722
+ });
723
+
724
+ expect(superseded).toEqual([
725
+ {
726
+ from: "@mui/material",
727
+ imported: "Button",
728
+ to: "@/components",
729
+ packageName: "@mui/material",
730
+ underlyingExportName: "Button",
731
+ localExportName: "Button",
732
+ },
733
+ {
734
+ from: "@mui/material/Button",
735
+ to: "@/components",
736
+ packageName: "@mui/material",
737
+ underlyingExportName: "Button",
738
+ localExportName: "Button",
739
+ },
740
+ ]);
741
+ });
742
+
743
+ it("projects nothing without bridges", () => {
744
+ expect(
745
+ projectSupersededImportPathPreferences({
746
+ jsx: [g.jsx.importPath().prefer("@mui/material", "@/components")],
747
+ })
748
+ ).toEqual([]);
749
+ });
750
+ });
751
+
628
752
  it("does not exempt sibling helpers, stories, barrels, or consumers", () => {
629
753
  const ix = new FactIndex();
630
754
  ix.addMany(
@@ -20,6 +20,7 @@ import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
20
20
  import {
21
21
  detectRawColor,
22
22
  emitFix,
23
+ hoppedValue,
23
24
  indexComponentByNodeId,
24
25
  isExemptColor,
25
26
  readUsageNode,
@@ -51,8 +52,9 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
51
52
  if (isExemptColor(color, policy.except)) continue;
52
53
 
53
54
  const resolution = resolveColorToken(decl.property, color, colorTokens);
55
+ const hopped = hoppedValue(decl);
54
56
  const fixEmission =
55
- resolution?.kind === "exact"
57
+ resolution?.kind === "exact" && !hopped
56
58
  ? buildTokenFix(decl.property, decl.value, color, resolution, ix)
57
59
  : undefined;
58
60
  const evidenceIds = resolution
@@ -64,7 +66,9 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
64
66
  ruleId: RULE_ID,
65
67
  ruleVersion: RULE_VERSION,
66
68
  severity: policy.severity,
67
- message: colorMessage(color, `\`${decl.property}\``, resolution, preferLabel),
69
+ message:
70
+ colorMessage(color, `\`${decl.property}\``, resolution, preferLabel) +
71
+ (hopped?.suffix ?? ""),
68
72
  location: decl.location,
69
73
  evidence: ix.evidence(evidenceIds),
70
74
  fingerprintIdentity: {
@@ -84,6 +88,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
84
88
  suggestedToken: resolution?.token.name,
85
89
  ...(resolution ? { tokenMatch: resolution.kind } : {}),
86
90
  ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
91
+ ...(hopped?.attributes ?? {}),
87
92
  },
88
93
  })
89
94
  );
@@ -138,8 +143,9 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
138
143
 
139
144
  const componentEvidenceId = componentByNode.get(node.id)?.id;
140
145
  const resolution = resolveColorToken(inline.property, color, colorTokens);
146
+ const hopped = hoppedValue(inline);
141
147
  const fixEmission =
142
- resolution?.kind === "exact"
148
+ resolution?.kind === "exact" && !hopped
143
149
  ? buildTokenFix(inline.property, inline.value, color, resolution, ix)
144
150
  : undefined;
145
151
  const baseEvidence = componentEvidenceId
@@ -152,7 +158,9 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
152
158
  ruleId: RULE_ID,
153
159
  ruleVersion: RULE_VERSION,
154
160
  severity: policy.severity,
155
- message: colorMessage(color, `inline \`${inline.property}\``, resolution, preferLabel),
161
+ message:
162
+ colorMessage(color, `inline \`${inline.property}\``, resolution, preferLabel) +
163
+ (hopped?.suffix ?? ""),
156
164
  location: node.location,
157
165
  evidence: ix.evidence(evidenceIds),
158
166
  fingerprintIdentity: {
@@ -172,6 +180,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
172
180
  suggestedToken: resolution?.token.name,
173
181
  ...(resolution ? { tokenMatch: resolution.kind } : {}),
174
182
  ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
183
+ ...(hopped?.attributes ?? {}),
175
184
  },
176
185
  })
177
186
  );
@@ -34,6 +34,7 @@ import { localTokenCandidates } from "./token-candidates.js";
34
34
  import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
35
35
  import {
36
36
  emitFix,
37
+ hoppedValue,
37
38
  indexComponentByNodeId,
38
39
  parseLengthValue,
39
40
  readUsageNode,
@@ -83,14 +84,17 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
83
84
  if (!resolution) continue;
84
85
  const tokenIds = resolutionTokenIds(resolution);
85
86
  const evidenceIds = tokenIds.length ? [decl.id, policy.id, ...tokenIds] : [decl.id, policy.id];
86
- const fixEmission = dimensionFix(decl.property, resolution, ix);
87
+ const hopped = hoppedValue(decl);
88
+ const fixEmission = hopped ? undefined : dimensionFix(decl.property, resolution, ix);
87
89
 
88
90
  findings.push(
89
91
  makeFinding({
90
92
  ruleId: RULE_ID,
91
93
  ruleVersion: RULE_VERSION,
92
94
  severity: policy.severity,
93
- message: dimensionMessage(decl.value, decl.property, preferLabel, resolution, false),
95
+ message:
96
+ dimensionMessage(decl.value, decl.property, preferLabel, resolution, false) +
97
+ (hopped?.suffix ?? ""),
94
98
  location: decl.location,
95
99
  evidence: ix.evidence(evidenceIds),
96
100
  fingerprintIdentity: {
@@ -108,6 +112,7 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
108
112
  source: "css",
109
113
  suggestedToken: resolutionSuggestedToken(resolution),
110
114
  ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
115
+ ...(hopped?.attributes ?? {}),
111
116
  },
112
117
  })
113
118
  );
@@ -131,14 +136,17 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
131
136
  : [node.id, inline.id, policy.id];
132
137
  const tokenIds = resolutionTokenIds(resolution);
133
138
  const evidenceIds = tokenIds.length ? [...baseEvidence, ...tokenIds] : baseEvidence;
134
- const fixEmission = dimensionFix(inline.property, resolution, ix);
139
+ const hopped = hoppedValue(inline);
140
+ const fixEmission = hopped ? undefined : dimensionFix(inline.property, resolution, ix);
135
141
 
136
142
  findings.push(
137
143
  makeFinding({
138
144
  ruleId: RULE_ID,
139
145
  ruleVersion: RULE_VERSION,
140
146
  severity: policy.severity,
141
- message: dimensionMessage(inline.value, inline.property, preferLabel, resolution, true),
147
+ message:
148
+ dimensionMessage(inline.value, inline.property, preferLabel, resolution, true) +
149
+ (hopped?.suffix ?? ""),
142
150
  location: node.location,
143
151
  evidence: ix.evidence(evidenceIds),
144
152
  fingerprintIdentity: {
@@ -156,6 +164,7 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
156
164
  source: "jsx",
157
165
  suggestedToken: resolutionSuggestedToken(resolution),
158
166
  ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
167
+ ...(hopped?.attributes ?? {}),
159
168
  },
160
169
  })
161
170
  );
@@ -45,6 +45,7 @@ import {
45
45
  import type { Finding } from "./types.js";
46
46
  import {
47
47
  emitFix,
48
+ hoppedValue,
48
49
  indexComponentByNodeId,
49
50
  readUsageNode,
50
51
  tokenSymbolsInText,
@@ -111,13 +112,15 @@ function checkDeclaration(
111
112
  tokens: lookupFor(scale),
112
113
  });
113
114
  if (!checked) return null;
114
- const fixEmission = buildSpacingFix(decl.property, checked, ix);
115
+ const hopped = hoppedValue(decl);
116
+ const fixEmission = hopped ? undefined : buildSpacingFix(decl.property, checked, ix);
115
117
 
116
118
  return makeFinding({
117
119
  ruleId: RULE_ID,
118
120
  ruleVersion: RULE_VERSION,
119
121
  severity: policy.severity,
120
- message: spacingMessage(decl.property, decl.value, allowed, scale, checked),
122
+ message:
123
+ spacingMessage(decl.property, decl.value, allowed, scale, checked) + (hopped?.suffix ?? ""),
121
124
  location: decl.location,
122
125
  evidence: ix.evidence([decl.id, policy.id, scale.id]),
123
126
  fingerprintIdentity: {
@@ -144,6 +147,7 @@ function checkDeclaration(
144
147
  assumedEmBasePx: checked.assumedEmBasePx,
145
148
  source: "css",
146
149
  ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
150
+ ...(hopped?.attributes ?? {}),
147
151
  },
148
152
  });
149
153
  }
@@ -171,7 +175,8 @@ function checkInlineStyle(
171
175
  bareNumberFix: inline.valueKind === "number",
172
176
  });
173
177
  if (!checked) return null;
174
- const fixEmission = buildSpacingFix(inline.property, checked, ix);
178
+ const hopped = hoppedValue(inline);
179
+ const fixEmission = hopped ? undefined : buildSpacingFix(inline.property, checked, ix);
175
180
 
176
181
  const node = readUsageNode(ix, inline.nodeId);
177
182
  if (!node) return null;
@@ -185,7 +190,9 @@ function checkInlineStyle(
185
190
  ruleId: RULE_ID,
186
191
  ruleVersion: RULE_VERSION,
187
192
  severity: policy.severity,
188
- message: spacingMessage(inline.property, inline.value, allowed, scale, checked),
193
+ message:
194
+ spacingMessage(inline.property, inline.value, allowed, scale, checked) +
195
+ (hopped?.suffix ?? ""),
189
196
  location: node.location,
190
197
  evidence: ix.evidence(evidenceIds),
191
198
  fingerprintIdentity: {
@@ -212,6 +219,7 @@ function checkInlineStyle(
212
219
  assumedEmBasePx: checked.assumedEmBasePx,
213
220
  source: "jsx",
214
221
  ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
222
+ ...(hopped?.attributes ?? {}),
215
223
  },
216
224
  });
217
225
  }
@@ -31,6 +31,7 @@ import type { Finding } from "./types.js";
31
31
  import {
32
32
  indexComponentByNodeId,
33
33
  emitFix,
34
+ hoppedValue,
34
35
  matchesScale,
35
36
  nearestSignedScaleValue,
36
37
  normalizeLengthForScale,
@@ -61,13 +62,16 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
61
62
  if (decl.property.toLowerCase() !== "font-size") continue;
62
63
  const checked = checkFontSize(decl.value, allowed, scale, tokens);
63
64
  if (!checked) continue;
64
- const fixEmission = buildFix(decl.property, checked, ix);
65
+ const hopped = hoppedValue(decl);
66
+ const fixEmission = hopped ? undefined : buildFix(decl.property, checked, ix);
65
67
  findings.push(
66
68
  makeFinding({
67
69
  ruleId: RULE_ID,
68
70
  ruleVersion: RULE_VERSION,
69
71
  severity: policy.severity,
70
- message: typographyMessage(decl.property, decl.value, allowed, scale.unit, checked),
72
+ message:
73
+ typographyMessage(decl.property, decl.value, allowed, scale.unit, checked) +
74
+ (hopped?.suffix ?? ""),
71
75
  location: decl.location,
72
76
  evidence: ix.evidence([decl.id, policy.id, scale.id]),
73
77
  fingerprintIdentity: {
@@ -88,6 +92,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
88
92
  matchedToken: checked.matchedToken,
89
93
  source: "css",
90
94
  ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
95
+ ...(hopped?.attributes ?? {}),
91
96
  },
92
97
  })
93
98
  );
@@ -105,7 +110,8 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
105
110
  inline.valueKind === "number"
106
111
  );
107
112
  if (!checked) continue;
108
- const fixEmission = buildFix(inline.property, checked, ix);
113
+ const hopped = hoppedValue(inline);
114
+ const fixEmission = hopped ? undefined : buildFix(inline.property, checked, ix);
109
115
  const node = readUsageNode(ix, inline.nodeId);
110
116
  if (!node) continue;
111
117
  const componentEvidenceId = componentByNode.get(node.id)?.id;
@@ -117,7 +123,9 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
117
123
  ruleId: RULE_ID,
118
124
  ruleVersion: RULE_VERSION,
119
125
  severity: policy.severity,
120
- message: typographyMessage(inline.property, inline.value, allowed, scale.unit, checked),
126
+ message:
127
+ typographyMessage(inline.property, inline.value, allowed, scale.unit, checked) +
128
+ (hopped?.suffix ?? ""),
121
129
  location: node.location,
122
130
  evidence: ix.evidence(evidenceIds),
123
131
  fingerprintIdentity: {
@@ -138,6 +146,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
138
146
  matchedToken: checked.matchedToken,
139
147
  source: "jsx",
140
148
  ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
149
+ ...(hopped?.attributes ?? {}),
141
150
  },
142
151
  })
143
152
  );
@@ -232,6 +232,45 @@ export function readStyleDeclaration(ix: FactIndex, id: FactId): StyleDeclaratio
232
232
  return fact && fact.kind === "style_declaration" ? fact : undefined;
233
233
  }
234
234
 
235
+ /** What a raw-value rule needs to report a value that arrived through a hop. */
236
+ export interface HoppedValue {
237
+ /** Appended to the message so the use site names the declaration a reader
238
+ * cannot see from the finding's own line. */
239
+ suffix: string;
240
+ /** Merged into the finding's attributes for JSON/SARIF consumers. */
241
+ attributes: {
242
+ valueFrom: "const-binding";
243
+ declaredAs: string;
244
+ declaredAt: string;
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Shared reading of `valueFrom` provenance for the raw-value style rules
250
+ * (color, spacing, dimensions, typography). One helper so a const-resolved
251
+ * value reports identically everywhere instead of becoming a color special
252
+ * case.
253
+ *
254
+ * Callers must also drop their fix when this returns a value: the authored text
255
+ * at the finding's location is an identifier, so replacing "the value" there
256
+ * would rewrite code the user never wrote.
257
+ */
258
+ export function hoppedValue(fact: {
259
+ valueFrom?: { kind: "const-binding"; name: string; location: { file: string; line: number } };
260
+ }): HoppedValue | undefined {
261
+ const from = fact.valueFrom;
262
+ if (!from) return undefined;
263
+ const declaredAt = `${from.location.file}:${from.location.line}`;
264
+ return {
265
+ suffix: ` Value comes from \`${from.name}\`, declared at ${declaredAt}.`,
266
+ attributes: {
267
+ valueFrom: "const-binding",
268
+ declaredAs: from.name,
269
+ declaredAt,
270
+ },
271
+ };
272
+ }
273
+
235
274
  /**
236
275
  * The set of CSS custom-property names a token vocabulary defines, normalized to
237
276
  * `--`-prefixed form. Shared by the token-vocabulary rules