@usefragments/core 1.5.0 → 1.5.2

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 (65) hide show
  1. package/dist/{chunk-HXGE3O2F.js → chunk-BAHCOAVG.js} +133 -7
  2. package/dist/chunk-BAHCOAVG.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-pKrfh517.d.ts} +264 -4
  10. package/dist/{index-h_yWj15D.d.ts → index-DbkPE46t.d.ts} +40 -0
  11. package/dist/index.d.ts +472 -28
  12. package/dist/index.js +561 -39
  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/__tests__/codes.test.ts +1 -1
  23. package/src/codes/codes.ts +29 -0
  24. package/src/config.ts +20 -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 +25 -1
  30. package/src/facts/compile.ts +20 -2
  31. package/src/facts/fact-index.ts +12 -1
  32. package/src/facts/facts.test.ts +14 -18
  33. package/src/facts/types.ts +25 -6
  34. package/src/governance-integrity.test.ts +127 -0
  35. package/src/governance-integrity.ts +326 -4
  36. package/src/governance.test.ts +87 -1
  37. package/src/governance.ts +121 -0
  38. package/src/index.ts +34 -5
  39. package/src/rules/__tests__/fix-emission-invariant.test.ts +35 -4
  40. package/src/rules/components-prefer-library.test.ts +103 -0
  41. package/src/rules/components-prefer-library.ts +30 -4
  42. package/src/rules/fix-availability.ts +1 -0
  43. package/src/rules/index.ts +7 -0
  44. package/src/rules/jsx-preferred-import-path.ts +45 -9
  45. package/src/rules/rules.test.ts +202 -6
  46. package/src/rules/spacing-resolution.ts +2 -2
  47. package/src/rules/styles-no-raw-color.test.ts +6 -1
  48. package/src/rules/styles-no-raw-color.ts +3 -2
  49. package/src/rules/styles-no-raw-dimensions.ts +5 -7
  50. package/src/rules/styles-no-raw-spacing.test.ts +48 -0
  51. package/src/rules/styles-no-raw-spacing.ts +10 -7
  52. package/src/rules/styles-no-raw-typography.ts +7 -4
  53. package/src/rules/taxonomy.test.ts +3 -0
  54. package/src/rules/tiers.ts +2 -0
  55. package/src/rules/token-candidates.ts +36 -0
  56. package/src/rules/tokens-require-dual-fallback.ts +2 -1
  57. package/src/rules/tokens-upstream-drift.test.ts +111 -0
  58. package/src/rules/tokens-upstream-drift.ts +57 -0
  59. package/src/rules/utils.ts +22 -14
  60. package/src/schema.ts +13 -0
  61. package/src/schemas/index.ts +5 -8
  62. package/src/token-types.ts +71 -1
  63. package/src/types.ts +27 -3
  64. package/dist/chunk-57QDBEHQ.js.map +0 -1
  65. package/dist/chunk-HXGE3O2F.js.map +0 -1
@@ -509,6 +509,160 @@ describe("preferred JSX imports and components", () => {
509
509
  expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
510
510
  });
511
511
 
512
+ it("governs a confirmed package-to-local bridge without offering an unsafe fix", () => {
513
+ const ix = new FactIndex();
514
+ ix.addMany(
515
+ compileGlobalGovernanceFacts({
516
+ severity: "error",
517
+ canonicalBridges: [
518
+ {
519
+ underlying: { packageName: "@mui/material", exportName: "Button" },
520
+ local: {
521
+ componentKey: "src/components/Button.tsx#Button",
522
+ moduleSpecifier: "@/components",
523
+ exportName: "Button",
524
+ implementationFiles: ["src/components/Button.tsx"],
525
+ },
526
+ decision: { state: "confirmed", source: "authored" },
527
+ },
528
+ ],
529
+ })
530
+ );
531
+ ix.add(
532
+ makeUsageImportFact({
533
+ file: "src/app/page.tsx",
534
+ local: "MuiButton",
535
+ imported: "Button",
536
+ source: "@mui/material",
537
+ location: { file: "src/app/page.tsx", line: 1, column: 0 },
538
+ })
539
+ );
540
+
541
+ const findings = ruleJsxPreferredImportPath(ix);
542
+
543
+ expect(findings).toHaveLength(1);
544
+ expect(findings[0]).toMatchObject({
545
+ ruleId: "imports/preferred-path",
546
+ severity: "serious",
547
+ attributes: {
548
+ canonicalBridge: true,
549
+ canonicalTarget: "src/components/Button.tsx#Button",
550
+ suggestedImport: "@/components",
551
+ suggestedComponent: "Button",
552
+ },
553
+ });
554
+ expect(findings[0].fix).toBeUndefined();
555
+ });
556
+
557
+ it("matches an exact mapped subpath default import", () => {
558
+ const ix = new FactIndex();
559
+ ix.addMany(
560
+ compileGlobalGovernanceFacts({
561
+ canonicalBridges: [
562
+ {
563
+ underlying: { packageName: "@mui/material", exportName: "Button" },
564
+ local: {
565
+ componentKey: "src/components/Button.tsx#Button",
566
+ moduleSpecifier: "@/components",
567
+ exportName: "Button",
568
+ implementationFiles: ["src/components/Button.tsx"],
569
+ },
570
+ decision: { state: "confirmed", source: "migration" },
571
+ },
572
+ ],
573
+ })
574
+ );
575
+ ix.add(
576
+ makeUsageImportFact({
577
+ file: "src/app/page.tsx",
578
+ local: "Button",
579
+ imported: "default",
580
+ source: "@mui/material/Button",
581
+ location: { file: "src/app/page.tsx", line: 1, column: 0 },
582
+ })
583
+ );
584
+
585
+ expect(ruleJsxPreferredImportPath(ix)).toHaveLength(1);
586
+ });
587
+
588
+ it("exempts only implementation files and leaves unmapped exports unaffected", () => {
589
+ const ix = new FactIndex();
590
+ ix.addMany(
591
+ compileGlobalGovernanceFacts({
592
+ canonicalBridges: [
593
+ {
594
+ underlying: { packageName: "@mui/material", exportName: "Button" },
595
+ local: {
596
+ componentKey: "src/components/Button.tsx#Button",
597
+ moduleSpecifier: "@/components",
598
+ exportName: "Button",
599
+ implementationFiles: ["src/components/Button.tsx"],
600
+ },
601
+ decision: { state: "confirmed", source: "authored" },
602
+ },
603
+ ],
604
+ })
605
+ );
606
+ ix.add(
607
+ makeUsageImportFact({
608
+ file: "./src/components/Button.tsx",
609
+ local: "MuiButton",
610
+ imported: "Button",
611
+ source: "@mui/material",
612
+ location: { file: "./src/components/Button.tsx", line: 1, column: 0 },
613
+ })
614
+ );
615
+ ix.add(
616
+ makeUsageImportFact({
617
+ file: "src/app/page.tsx",
618
+ local: "Stepper",
619
+ imported: "Stepper",
620
+ source: "@mui/material",
621
+ location: { file: "src/app/page.tsx", line: 1, column: 0 },
622
+ })
623
+ );
624
+
625
+ expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
626
+ });
627
+
628
+ it("does not exempt sibling helpers, stories, barrels, or consumers", () => {
629
+ const ix = new FactIndex();
630
+ ix.addMany(
631
+ compileGlobalGovernanceFacts({
632
+ canonicalBridges: [
633
+ {
634
+ underlying: { packageName: "@mui/material", exportName: "Button" },
635
+ local: {
636
+ componentKey: "src/components/Button.tsx#Button",
637
+ moduleSpecifier: "@/components",
638
+ exportName: "Button",
639
+ implementationFiles: ["src/components/Button.tsx"],
640
+ },
641
+ decision: { state: "confirmed", source: "authored" },
642
+ },
643
+ ],
644
+ })
645
+ );
646
+ for (const file of [
647
+ "src/components/Button.helpers.tsx",
648
+ "src/components/Button.stories.tsx",
649
+ "src/components/index.ts",
650
+ "src/app/page.tsx",
651
+ ]) {
652
+ ix.add(
653
+ makeUsageImportFact({
654
+ file,
655
+ local: "Button",
656
+ imported: "Button",
657
+ source: "@mui/material",
658
+ location: { file, line: 1, column: 0 },
659
+ })
660
+ );
661
+ }
662
+
663
+ expect(ruleJsxPreferredImportPath(ix)).toHaveLength(4);
664
+ });
665
+
512
666
  it("matches preferred-component policy across component package epochs", () => {
513
667
  const ix = new FactIndex();
514
668
  ix.addMany(
@@ -1245,6 +1399,7 @@ describe("Phase 6 fix payloads", () => {
1245
1399
  name: "--fui-color-accent",
1246
1400
  value: "#2563eb",
1247
1401
  category: "color",
1402
+ referenceFormat: "css-var",
1248
1403
  })
1249
1404
  );
1250
1405
  ix.add(
@@ -1273,8 +1428,18 @@ describe("Phase 6 fix payloads", () => {
1273
1428
  it("styles/no-raw-color prefers a role-matching token when several share a value", () => {
1274
1429
  const ix = emptyIndexWithGovernance();
1275
1430
  ix.addMany([
1276
- makeTokenDefinitionFact({ name: "--color-bg", value: "#ffffff", category: "color" }),
1277
- makeTokenDefinitionFact({ name: "--color-text", value: "#ffffff", category: "color" }),
1431
+ makeTokenDefinitionFact({
1432
+ name: "--color-bg",
1433
+ value: "#ffffff",
1434
+ category: "color",
1435
+ referenceFormat: "css-var",
1436
+ }),
1437
+ makeTokenDefinitionFact({
1438
+ name: "--color-text",
1439
+ value: "#ffffff",
1440
+ category: "color",
1441
+ referenceFormat: "css-var",
1442
+ }),
1278
1443
  ]);
1279
1444
  ix.add(
1280
1445
  makeStyleDeclarationFact({
@@ -1396,6 +1561,7 @@ describe("Phase 6 fix payloads", () => {
1396
1561
  name: "--fui-color-white",
1397
1562
  value: "#ffffff",
1398
1563
  category: "color",
1564
+ referenceFormat: "css-var",
1399
1565
  })
1400
1566
  );
1401
1567
  ix.add(
@@ -1420,6 +1586,7 @@ describe("Phase 6 fix payloads", () => {
1420
1586
  name: "--fui-color-accent",
1421
1587
  value: "#2563eb",
1422
1588
  category: "color",
1589
+ referenceFormat: "css-var",
1423
1590
  })
1424
1591
  );
1425
1592
  ix.add(
@@ -1446,6 +1613,7 @@ describe("Phase 6 fix payloads", () => {
1446
1613
  name: "--fui-color-accent",
1447
1614
  value: "#2563eb",
1448
1615
  category: "color",
1616
+ referenceFormat: "css-var",
1449
1617
  })
1450
1618
  );
1451
1619
  addButtonUsage(ix, {
@@ -3367,14 +3535,15 @@ function spacingIndexWithTokens(
3367
3535
  name: token.name,
3368
3536
  value: token.value,
3369
3537
  category: "spacing",
3370
- referenceFormat: token.referenceFormat,
3538
+ referenceFormat:
3539
+ token.referenceFormat ?? (token.name.startsWith("$") ? "scss-var" : "css-var"),
3371
3540
  })
3372
3541
  );
3373
3542
  }
3374
3543
  return ix;
3375
3544
  }
3376
3545
 
3377
- type TokenRefFormat = "css-var" | "scss-var" | "scss-map" | "dtcg";
3546
+ type TokenRefFormat = "css-var" | "scss-var" | "scss-map" | "dtcg" | "js-member";
3378
3547
 
3379
3548
  describe("styles/no-raw-spacing — token-aware (BUG 1/13/15)", () => {
3380
3549
  it("flags an on-scale raw literal that equals a token with a deterministic var() swap", () => {
@@ -3438,6 +3607,27 @@ describe("styles/no-raw-spacing — token-aware (BUG 1/13/15)", () => {
3438
3607
  expect(findings[0].fix?.deterministic).toBe(false);
3439
3608
  });
3440
3609
 
3610
+ it("keeps an exact Sass variable swap deterministic", () => {
3611
+ const ix = spacingIndexWithTokens([
3612
+ { name: "$space-md", value: "16px", referenceFormat: "scss-var" },
3613
+ ]);
3614
+ ix.add(
3615
+ makeStyleDeclarationFact({
3616
+ file: "src/x.scss",
3617
+ selector: ".a",
3618
+ declarationPath: "0",
3619
+ property: "padding",
3620
+ value: "16px",
3621
+ location: { file: "src/x.scss", line: 1, column: 0 },
3622
+ })
3623
+ );
3624
+
3625
+ expect(ruleStylesNoRawSpacing(ix)[0]?.fix).toMatchObject({
3626
+ value: "$space-md",
3627
+ deterministic: true,
3628
+ });
3629
+ });
3630
+
3441
3631
  it("catches a camelCase longhand inline spacing prop (marginTop)", () => {
3442
3632
  const ix = spacingIndexWithTokens([]);
3443
3633
  const node = makeUsageNodeFact({
@@ -3477,7 +3667,8 @@ describe("styles/no-raw-color — role correctness + fixer safety (BUG 4/3/11)",
3477
3667
  name: token.name,
3478
3668
  value: token.value,
3479
3669
  category: "color",
3480
- referenceFormat: token.referenceFormat,
3670
+ referenceFormat:
3671
+ token.referenceFormat ?? (token.name.startsWith("$") ? "scss-var" : "css-var"),
3481
3672
  })
3482
3673
  );
3483
3674
  }
@@ -3548,7 +3739,12 @@ describe("styles/no-raw-typography (BUG 10)", () => {
3548
3739
  );
3549
3740
  for (const token of tokens) {
3550
3741
  ix.add(
3551
- makeTokenDefinitionFact({ name: token.name, value: token.value, category: "typography" })
3742
+ makeTokenDefinitionFact({
3743
+ name: token.name,
3744
+ value: token.value,
3745
+ category: "typography",
3746
+ referenceFormat: "css-var",
3747
+ })
3552
3748
  );
3553
3749
  }
3554
3750
  return ix;
@@ -91,7 +91,7 @@ export function resolveSpacingValue(input: {
91
91
  }
92
92
  // On-grid literal that equals a token: value-preserving swap.
93
93
  anyViolation = true;
94
- const reference = tokenReference(exactToken.name);
94
+ const reference = tokenReference(exactToken);
95
95
  if (!normalized.deterministicFix) allValuePreserving = false;
96
96
  suggestedParts.push(reference);
97
97
  if (!firstViolation) {
@@ -128,7 +128,7 @@ export function resolveSpacingValue(input: {
128
128
  const nearestToken = input.tokens.get(Math.abs(nearest));
129
129
  suggestedToken = nearestToken?.name;
130
130
  suggestedPart = nearestToken
131
- ? tokenReference(nearestToken.name)
131
+ ? tokenReference(nearestToken)
132
132
  : input.bareNumberFix
133
133
  ? `${nearest}`
134
134
  : `${nearest}${input.scale.unit}`;
@@ -77,7 +77,12 @@ describe("styles/no-raw-color — #9d SVG paint attributes", () => {
77
77
  it("emits a deterministic token fix when stroke matches an accent token exactly", () => {
78
78
  const ix = indexWithColorPolicy();
79
79
  ix.add(
80
- makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#7c3aed", category: "color" })
80
+ makeTokenDefinitionFact({
81
+ name: "--fui-color-accent",
82
+ value: "#7c3aed",
83
+ category: "color",
84
+ referenceFormat: "css-var",
85
+ })
81
86
  );
82
87
  const nodeId = addNode(ix, "path");
83
88
  ix.add(
@@ -15,6 +15,7 @@ import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
15
15
 
16
16
  import { colorDistance, parseOpaqueColor } from "./color-math.js";
17
17
  import { makeFinding } from "./finding.js";
18
+ import { localTokenCandidates } from "./token-candidates.js";
18
19
  import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
19
20
  import {
20
21
  detectRawColor,
@@ -33,7 +34,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
33
34
  const policy = ix.policy.rawColorPolicy();
34
35
  if (!policy) return [];
35
36
 
36
- const colorTokens = ix.tokens.byCategory("color");
37
+ const colorTokens = localTokenCandidates(ix, "color");
37
38
  const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
38
39
  const findings: Finding[] = [];
39
40
 
@@ -431,7 +432,7 @@ function buildTokenFix(
431
432
  match: TokenMatch,
432
433
  ix: FactIndex
433
434
  ): FixEmission<FindingReplaceStyleValueFix> {
434
- const reference = tokenReference(match.token.name);
435
+ const reference = tokenReference(match.token);
435
436
  const value = replaceRawColor(rawValue, rawColor, reference);
436
437
  return emitFix(
437
438
  {
@@ -30,12 +30,14 @@
30
30
  import type { FactId, FactIndex, TokenDefinitionFact } from "../facts/index.js";
31
31
 
32
32
  import { makeFinding } from "./finding.js";
33
+ import { localTokenCandidates } from "./token-candidates.js";
33
34
  import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
34
35
  import {
35
36
  emitFix,
36
37
  indexComponentByNodeId,
37
38
  parseLengthValue,
38
39
  readUsageNode,
40
+ tokenReference,
39
41
  type FixEmission,
40
42
  } from "./utils.js";
41
43
 
@@ -67,8 +69,8 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
67
69
  if (!policy) return [];
68
70
 
69
71
  const appliesTo = new Set(policy.appliesTo);
70
- const spacingTokens = ix.tokens.byCategory("spacing");
71
- const radiusTokens = ix.tokens.byCategory("radius");
72
+ const spacingTokens = localTokenCandidates(ix, "spacing");
73
+ const radiusTokens = localTokenCandidates(ix, "radius");
72
74
  const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
73
75
  const findings: Finding[] = [];
74
76
 
@@ -368,9 +370,5 @@ function normalizeDimension(value: string): string | null {
368
370
  }
369
371
 
370
372
  function tokenVar(token: TokenDefinitionFact): string {
371
- if (token.name.startsWith("$")) return token.name;
372
- const cssVarName = token.name.startsWith("--")
373
- ? token.name
374
- : `--${token.name.replace(/\./g, "-")}`;
375
- return `var(${cssVarName})`;
373
+ return tokenReference(token);
376
374
  }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ compileGlobalGovernanceFacts,
5
+ FactIndex,
6
+ g,
7
+ makeStyleDeclarationFact,
8
+ ruleStylesNoRawSpacing,
9
+ } from "../index.js";
10
+
11
+ describe("styles/no-raw-spacing fork copy", () => {
12
+ it("names the active scale and its config lever without changing machine attributes", () => {
13
+ const ix = new FactIndex();
14
+ ix.addMany(
15
+ compileGlobalGovernanceFacts({
16
+ scales: {
17
+ space: g.scale.px([0, 4, 8, 12, 16, 20, 24]),
18
+ },
19
+ styles: [
20
+ g.styles.rawSpacing().mustMatchScale("space", {
21
+ appliesTo: ["margin-top"],
22
+ severity: "warn",
23
+ }),
24
+ ],
25
+ })
26
+ );
27
+ ix.add(
28
+ makeStyleDeclarationFact({
29
+ file: "src/Card.css",
30
+ selector: ".card",
31
+ declarationPath: "0",
32
+ property: "margin-top",
33
+ value: "10px",
34
+ location: { file: "src/Card.css", line: 2, column: 3 },
35
+ })
36
+ );
37
+
38
+ const [finding] = ruleStylesNoRawSpacing(ix);
39
+
40
+ expect(finding?.message).toContain("not on the `space` scale");
41
+ expect(finding?.message).toContain("Configure `govern.scales.space` to change it");
42
+ expect(finding?.attributes).toMatchObject({
43
+ scale: "space",
44
+ rawValue: "10px",
45
+ source: "css",
46
+ });
47
+ });
48
+ });
@@ -35,6 +35,7 @@ import type {
35
35
  } from "../facts/index.js";
36
36
 
37
37
  import { makeFinding } from "./finding.js";
38
+ import { localTokenCandidates } from "./token-candidates.js";
38
39
  import {
39
40
  buildSpacingTokenLookup,
40
41
  resolveSpacingValue,
@@ -62,7 +63,7 @@ export function ruleStylesNoRawSpacing(ix: FactIndex): Finding[] {
62
63
  const lookupFor = (scale: ScaleFact): SpacingTokenLookup => {
63
64
  let lookup = lookupCache.get(scale.name);
64
65
  if (!lookup) {
65
- lookup = buildSpacingTokenLookup(ix.tokens.byCategory("spacing"), scale);
66
+ lookup = buildSpacingTokenLookup(localTokenCandidates(ix, "spacing"), scale);
66
67
  lookupCache.set(scale.name, lookup);
67
68
  }
68
69
  return lookup;
@@ -116,7 +117,7 @@ function checkDeclaration(
116
117
  ruleId: RULE_ID,
117
118
  ruleVersion: RULE_VERSION,
118
119
  severity: policy.severity,
119
- message: spacingMessage(decl.property, decl.value, allowed, scale.unit, checked),
120
+ message: spacingMessage(decl.property, decl.value, allowed, scale, checked),
120
121
  location: decl.location,
121
122
  evidence: ix.evidence([decl.id, policy.id, scale.id]),
122
123
  fingerprintIdentity: {
@@ -184,7 +185,7 @@ function checkInlineStyle(
184
185
  ruleId: RULE_ID,
185
186
  ruleVersion: RULE_VERSION,
186
187
  severity: policy.severity,
187
- message: spacingMessage(inline.property, inline.value, allowed, scale.unit, checked),
188
+ message: spacingMessage(inline.property, inline.value, allowed, scale, checked),
188
189
  location: node.location,
189
190
  evidence: ix.evidence(evidenceIds),
190
191
  fingerprintIdentity: {
@@ -230,7 +231,9 @@ function buildSpacingFix(
230
231
  deterministic: true,
231
232
  },
232
233
  {
233
- symbols: tokenSymbolsInText(checked.suggestedValue),
234
+ symbols: checked.matchedToken
235
+ ? [checked.matchedToken]
236
+ : tokenSymbolsInText(checked.suggestedValue),
234
237
  editKind: "replaceStyleValue",
235
238
  valuePreserving: checked.deterministicFix,
236
239
  },
@@ -242,7 +245,7 @@ function spacingMessage(
242
245
  property: string,
243
246
  value: string,
244
247
  allowed: number[],
245
- unit: "px" | "rem",
248
+ scale: ScaleFact,
246
249
  checked: CheckedSpacingValue
247
250
  ): string {
248
251
  if (checked.reason === "token-equivalent" && checked.matchedToken) {
@@ -252,7 +255,7 @@ function spacingMessage(
252
255
  .slice()
253
256
  .sort((a, b) => a - b)
254
257
  .slice(0, 6);
255
- const suffix = `${sample.join(unit + ", ")}${unit}`;
258
+ const suffix = `${sample.join(scale.unit + ", ")}${scale.unit}`;
256
259
  const ellipsis = allowed.length > sample.length ? ", …" : "";
257
- return `\`${property}: ${value}\` is not on the spacing scale. Allowed: ${suffix}${ellipsis}.`;
260
+ return `\`${property}: ${value}\` is not on the \`${scale.name}\` scale. Allowed: ${suffix}${ellipsis}. Configure \`govern.scales.${scale.name}\` to change it.`;
258
261
  }
@@ -26,6 +26,7 @@ import type {
26
26
  } from "../facts/index.js";
27
27
 
28
28
  import { makeFinding } from "./finding.js";
29
+ import { localTokenCandidates } from "./token-candidates.js";
29
30
  import type { Finding } from "./types.js";
30
31
  import {
31
32
  indexComponentByNodeId,
@@ -150,7 +151,7 @@ function buildTypographyTokenLookup(
150
151
  scale: ScaleFact
151
152
  ): Map<number, TokenDefinitionFact> {
152
153
  const out = new Map<number, TokenDefinitionFact>();
153
- for (const token of ix.tokens.byCategory("typography")) {
154
+ for (const token of localTokenCandidates(ix, "typography")) {
154
155
  const parsed = parseLengthValue(token.value);
155
156
  if (!parsed || parsed.unit === null) continue;
156
157
  const normalized = normalizeLengthForScale(parsed, scale);
@@ -188,7 +189,7 @@ function checkFontSize(
188
189
  if (matchesScale(normalized.value, allowed)) {
189
190
  if (!exactToken) return null;
190
191
  return {
191
- suggestedValue: tokenReference(exactToken.name),
192
+ suggestedValue: tokenReference(exactToken),
192
193
  fixEmittable: true,
193
194
  deterministicFix: normalized.deterministicFix,
194
195
  reason: "token-equivalent",
@@ -202,7 +203,7 @@ function checkFontSize(
202
203
  }
203
204
  const nearestToken = tokens.get(Math.abs(nearest));
204
205
  const suggestedValue = nearestToken
205
- ? tokenReference(nearestToken.name)
206
+ ? tokenReference(nearestToken)
206
207
  : bareNumberFix
207
208
  ? `${nearest}`
208
209
  : `${nearest}${scale.unit}`;
@@ -231,7 +232,9 @@ function buildFix(
231
232
  deterministic: true,
232
233
  },
233
234
  {
234
- symbols: tokenSymbolsInText(checked.suggestedValue),
235
+ symbols: checked.matchedToken
236
+ ? [checked.matchedToken]
237
+ : tokenSymbolsInText(checked.suggestedValue),
235
238
  editKind: "replaceStyleValue",
236
239
  valuePreserving: checked.deterministicFix,
237
240
  },
@@ -95,6 +95,7 @@ describe("rule taxonomy SSOT", () => {
95
95
  "components/prefer-library",
96
96
  "components/shadow-component",
97
97
  "tokens/css-vars-must-be-defined",
98
+ "tokens/upstream-drift",
98
99
  ]);
99
100
  });
100
101
 
@@ -102,6 +103,7 @@ describe("rule taxonomy SSOT", () => {
102
103
  expect(
103
104
  [
104
105
  "tokens/css-vars-must-be-defined",
106
+ "tokens/upstream-drift",
105
107
  "components/prefer-library",
106
108
  "components/shadow-component",
107
109
  ].sort(compareByVocabularyRank)
@@ -109,6 +111,7 @@ describe("rule taxonomy SSOT", () => {
109
111
  "components/shadow-component",
110
112
  "components/prefer-library",
111
113
  "tokens/css-vars-must-be-defined",
114
+ "tokens/upstream-drift",
112
115
  ]);
113
116
  });
114
117
 
@@ -30,6 +30,7 @@ export const RULE_TIER: Readonly<Record<string, RuleTier>> = {
30
30
  "components/shadow-component": "contract", // canonical-component identity drift
31
31
  "components/prefer-library": "contract", // canonical-component bypass (FUI1004)
32
32
  "tokens/css-vars-must-be-defined": "contract", // token drift (FUI2015)
33
+ "tokens/upstream-drift": "contract", // authored local/upstream value drift (FUI2017)
33
34
 
34
35
  // ---- Tier B — generic hygiene -----------------------------------------
35
36
  "components/unknown-prop": "hygiene",
@@ -99,6 +100,7 @@ const VOCABULARY_ORDER: readonly string[] = [
99
100
  "components/shadow-component",
100
101
  "components/prefer-library",
101
102
  "tokens/css-vars-must-be-defined",
103
+ "tokens/upstream-drift",
102
104
  ];
103
105
 
104
106
  /** Headline rank of a rule id (lower = higher in the vocabulary-first list). */
@@ -0,0 +1,36 @@
1
+ import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
2
+ import { normalizeColor } from "../tokens/color.js";
3
+
4
+ /** Normalize token values using the same comparison contract as drift findings. */
5
+ export function normalizedTokenValue(token: TokenDefinitionFact): string {
6
+ const value = token.value.trim();
7
+ return token.category === "color" ? normalizeColor(value) : value.replace(/\s+/g, " ");
8
+ }
9
+
10
+ /**
11
+ * Return local definitions that are safe to offer as replacement candidates.
12
+ * A local definition with one exact authored upstream counterpart is withheld
13
+ * while their values drift. Missing or ambiguous upstream input remains
14
+ * visible to drift diagnostics but is never guessed here.
15
+ */
16
+ export function localTokenCandidates(
17
+ ix: FactIndex,
18
+ category?: TokenDefinitionFact["category"]
19
+ ): TokenDefinitionFact[] {
20
+ const all = ix.tokens.list();
21
+ const upstreamByName = new Map<string, TokenDefinitionFact[]>();
22
+ for (const token of all) {
23
+ if (token.role !== "upstream") continue;
24
+ const matches = upstreamByName.get(token.name) ?? [];
25
+ matches.push(token);
26
+ upstreamByName.set(token.name, matches);
27
+ }
28
+
29
+ return all.filter((token) => {
30
+ if (token.role === "upstream" || (category !== undefined && token.category !== category)) {
31
+ return false;
32
+ }
33
+ const matches = upstreamByName.get(token.upstreamName ?? token.name) ?? [];
34
+ return matches.length !== 1 || normalizedTokenValue(token) === normalizedTokenValue(matches[0]);
35
+ });
36
+ }
@@ -1,6 +1,7 @@
1
1
  import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
2
2
 
3
3
  import { makeFinding } from "./finding.js";
4
+ import { localTokenCandidates } from "./token-candidates.js";
4
5
  import type { Finding } from "./types.js";
5
6
  import { emitFix } from "./utils.js";
6
7
 
@@ -22,7 +23,7 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
22
23
  const policy = ix.policy.ruleConfig(RULE_ID);
23
24
  if (!policy?.enabled) return [];
24
25
 
25
- const tokensByCssVariable = indexTokensByCssVariable(ix.tokens.list());
26
+ const tokensByCssVariable = indexTokensByCssVariable(localTokenCandidates(ix));
26
27
  const findings: Finding[] = [];
27
28
 
28
29
  for (const decl of ix.byKind("style_declaration")) {