@usefragments/core 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/chunk-RANPUC6C.js +72 -0
  2. package/dist/chunk-RANPUC6C.js.map +1 -0
  3. package/dist/{chunk-BAHCOAVG.js → chunk-WNMWKUYG.js} +447 -201
  4. package/dist/chunk-WNMWKUYG.js.map +1 -0
  5. package/dist/codes/index.d.ts +2 -2
  6. package/dist/codes/index.js +1 -1
  7. package/dist/compiled-types/index.d.ts +1 -1
  8. package/dist/compiled-types/index.js +8 -0
  9. package/dist/generate/index.d.ts +1 -1
  10. package/dist/{governance-pKrfh517.d.ts → governance-D9KtH-vg.d.ts} +617 -22
  11. package/dist/index.d.ts +800 -179
  12. package/dist/index.js +1003 -237
  13. package/dist/index.js.map +1 -1
  14. package/dist/react-types.d.ts +1 -1
  15. package/dist/registry.d.ts +36 -36
  16. package/dist/schemas/index.d.ts +1 -1
  17. package/dist/test-utils.d.ts +1 -1
  18. package/package.json +2 -1
  19. package/src/__tests__/policy-exclude.test.ts +180 -0
  20. package/src/agent-format.test.ts +13 -0
  21. package/src/agent-format.ts +9 -3
  22. package/src/canonical-bridge.ts +69 -1
  23. package/src/canonical-direction.test.ts +118 -0
  24. package/src/canonical-direction.ts +43 -2
  25. package/src/codes/__tests__/codes.test.ts +14 -2
  26. package/src/codes/codes.ts +41 -2
  27. package/src/compiled-types/index.ts +81 -0
  28. package/src/compiled-types/parse.test.ts +47 -0
  29. package/src/facts/builders.ts +48 -0
  30. package/src/facts/compile.ts +148 -34
  31. package/src/facts/fact-index.ts +15 -2
  32. package/src/facts/facts.test.ts +44 -3
  33. package/src/facts/index.ts +11 -6
  34. package/src/facts/types.ts +60 -9
  35. package/src/governance-integrity.test.ts +272 -4
  36. package/src/governance-integrity.ts +345 -25
  37. package/src/governance.ts +87 -1
  38. package/src/index.ts +46 -1
  39. package/src/policy-exclude.ts +113 -0
  40. package/src/rules/a11y-required-accessible-name.ts +175 -28
  41. package/src/rules/a11y-standard.ts +102 -0
  42. package/src/rules/a11y-utils.ts +7 -0
  43. package/src/rules/components-prefer-library.test.ts +75 -28
  44. package/src/rules/components-prefer-library.ts +35 -15
  45. package/src/rules/components-shadow-component.test.ts +21 -9
  46. package/src/rules/emit-gate.test.ts +74 -4
  47. package/src/rules/emit-gate.ts +24 -9
  48. package/src/rules/families.test.ts +69 -0
  49. package/src/rules/families.ts +52 -0
  50. package/src/rules/fix-availability.ts +1 -0
  51. package/src/rules/index.ts +18 -2
  52. package/src/rules/jsx-preferred-import-path.ts +29 -11
  53. package/src/rules/rules.test.ts +188 -8
  54. package/src/rules/styles-no-raw-color.ts +13 -4
  55. package/src/rules/styles-no-raw-dimensions.ts +13 -4
  56. package/src/rules/styles-no-raw-spacing.ts +12 -4
  57. package/src/rules/styles-no-raw-typography.ts +13 -4
  58. package/src/rules/tiers.ts +1 -0
  59. package/src/rules/utils.ts +39 -0
  60. package/src/tokens/design-token-parser.test.ts +131 -0
  61. package/src/tokens/design-token-parser.ts +362 -49
  62. package/src/types.ts +2 -2
  63. package/dist/chunk-BAHCOAVG.js.map +0 -1
  64. package/dist/{index-DbkPE46t.d.ts → index-hZAlYCli.d.ts} +8 -8
@@ -1,4 +1,4 @@
1
- import type { FactIndex } from "../facts/index.js";
1
+ import type { FactIndex, JsxImportPathPreferredFact, UsageImportFact } from "../facts/index.js";
2
2
  import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
3
3
 
4
4
  import { makeFinding } from "./finding.js";
@@ -15,22 +15,21 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
15
15
  const seen = new Set<string>();
16
16
 
17
17
  for (const usage of ix.byKind("usage_import")) {
18
+ // A confirmed bridge supersedes any non-bridge preference over the same
19
+ // underlying import (report #2 B6): without this, both policies emit with
20
+ // the same fingerprint (corrupting baseline/Cloud dedupe) and the hand
21
+ // rule flags the wrapper's own implementation file with a self-import fix.
22
+ // The bridge's domain — not its implementation-file exemption — decides
23
+ // supersession, so inside an exempt file the duplicate stays silent too.
24
+ const bridgeGoverned = policies.some((policy) => bridgeGovernsUnderlyingImport(policy, usage));
18
25
  const matches = policies.filter((policy) => {
19
26
  if (policy.bridge) {
20
27
  if (policy.bridge.implementationFiles.some((file) => sameRepoPath(file, usage.file))) {
21
28
  return false;
22
29
  }
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
- );
30
+ return bridgeGovernsUnderlyingImport(policy, usage);
33
31
  }
32
+ if (bridgeGoverned) return false;
34
33
  if (!ownedImportsEqual(policy.from, usage.source)) return false;
35
34
  if (policy.imported !== undefined && policy.imported !== usage.imported) return false;
36
35
  return true;
@@ -94,6 +93,25 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
94
93
  return findings;
95
94
  }
96
95
 
96
+ /** Whether the bridge policy's underlying-import domain covers this usage. */
97
+ function bridgeGovernsUnderlyingImport(
98
+ policy: JsxImportPathPreferredFact,
99
+ usage: UsageImportFact
100
+ ): boolean {
101
+ const bridge = policy.bridge;
102
+ if (!bridge) return false;
103
+ if (!ownedImportMatchesRoot(usage.source, policy.from)) return false;
104
+ if (ownedImportsEqual(usage.source, policy.from)) {
105
+ return usage.imported === bridge.underlyingExportName;
106
+ }
107
+ const subpath = usage.source.slice(policy.from.length + 1);
108
+ const leaf = subpath.split("/").at(-1);
109
+ return (
110
+ leaf === bridge.underlyingExportName &&
111
+ (usage.imported === "default" || usage.imported === bridge.underlyingExportName)
112
+ );
113
+ }
114
+
97
115
  function sameRepoPath(left: string, right: string): boolean {
98
116
  return normalizeRepoPath(left) === normalizeRepoPath(right);
99
117
  }
@@ -11,12 +11,15 @@ import {
11
11
  makeClassNameDynamicFact,
12
12
  makeStyleDeclarationFact,
13
13
  makeTokenDefinitionFact,
14
+ makeUsageChildContentFact,
14
15
  makeUsageComponentFact,
15
16
  makeUsageImportFact,
16
17
  makeUsageInlineStyleFact,
17
18
  makeUsageNodeFact,
18
19
  makeUsagePropResolvedFact,
19
20
  makeUsageTextChildFact,
21
+ projectSupersededImportPathPreferences,
22
+ RULES,
20
23
  ruleA11yRequiredAccessibleName,
21
24
  ruleComponentsForbiddenPropValue,
22
25
  ruleComponentsPreferLibrary,
@@ -32,7 +35,8 @@ import {
32
35
  ruleTokensRequireDualFallback,
33
36
  runRules,
34
37
  } from "../index.js";
35
- import type { Fact, FactId } from "../index.js";
38
+ import type { CanonicalBridgeV1, Fact, FactId } from "../index.js";
39
+ import { RULE_VERSION as A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION } from "./a11y-required-accessible-name.js";
36
40
 
37
41
  type ButtonProps = {
38
42
  variant?: "primary" | "secondary" | "ghost" | "link";
@@ -137,6 +141,7 @@ interface ButtonUsageInput {
137
141
  value: string;
138
142
  }>;
139
143
  text?: string;
144
+ childContent?: "static-text" | "runtime" | "structural" | "empty";
140
145
  }
141
146
 
142
147
  function addButtonUsage(ix: FactIndex, input: ButtonUsageInput): { nodeId: FactId } {
@@ -175,6 +180,14 @@ function addButtonUsage(ix: FactIndex, input: ButtonUsageInput): { nodeId: FactI
175
180
  if (input.text !== undefined) {
176
181
  ix.add(makeUsageTextChildFact({ nodeId: node.id, text: input.text, index: 0 }));
177
182
  }
183
+ if (input.childContent !== undefined) {
184
+ ix.add(
185
+ makeUsageChildContentFact({
186
+ nodeId: node.id,
187
+ content: input.childContent,
188
+ })
189
+ );
190
+ }
178
191
  return { nodeId: node.id };
179
192
  }
180
193
 
@@ -625,6 +638,129 @@ describe("preferred JSX imports and components", () => {
625
638
  expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
626
639
  });
627
640
 
641
+ // Report #2 B6 (brownfield-canonical brief 03): a confirmed bridge
642
+ // supersedes any non-bridge preference over the same underlying import —
643
+ // otherwise both emit with the same fingerprint and the hand rule flags the
644
+ // wrapper's own implementation file with a self-import fix.
645
+ describe("bridge supersession of non-bridge import preferences", () => {
646
+ const B6_BRIDGE: CanonicalBridgeV1 = {
647
+ underlying: { packageName: "@mui/material", exportName: "Button" },
648
+ local: {
649
+ componentKey: "src/components/Button.tsx#Button",
650
+ moduleSpecifier: "@/components",
651
+ exportName: "Button",
652
+ implementationFiles: ["src/components/Button.tsx"],
653
+ },
654
+ decision: { state: "confirmed", source: "authored" },
655
+ };
656
+
657
+ it("emits only the bridge finding when a hand-authored record duplicates it", () => {
658
+ const ix = new FactIndex();
659
+ ix.addMany(
660
+ compileGlobalGovernanceFacts({
661
+ jsx: [g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" })],
662
+ canonicalBridges: [B6_BRIDGE],
663
+ })
664
+ );
665
+ ix.add(
666
+ makeUsageImportFact({
667
+ file: "src/app/page.tsx",
668
+ local: "MuiButton",
669
+ imported: "Button",
670
+ source: "@mui/material",
671
+ location: { file: "src/app/page.tsx", line: 1, column: 0 },
672
+ })
673
+ );
674
+
675
+ const findings = ruleJsxPreferredImportPath(ix);
676
+ expect(findings).toHaveLength(1);
677
+ expect(findings[0].attributes).toMatchObject({ canonicalBridge: true });
678
+ });
679
+
680
+ it("keeps the hand rule silent inside the wrapper implementation file", () => {
681
+ const ix = new FactIndex();
682
+ ix.addMany(
683
+ compileGlobalGovernanceFacts({
684
+ jsx: [g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" })],
685
+ canonicalBridges: [B6_BRIDGE],
686
+ })
687
+ );
688
+ ix.add(
689
+ makeUsageImportFact({
690
+ file: "src/components/Button.tsx",
691
+ local: "MuiButton",
692
+ imported: "Button",
693
+ source: "@mui/material",
694
+ location: { file: "src/components/Button.tsx", line: 1, column: 0 },
695
+ })
696
+ );
697
+
698
+ expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
699
+ });
700
+
701
+ it("leaves the hand rule firing on imports the bridge does not govern", () => {
702
+ const ix = new FactIndex();
703
+ ix.addMany(
704
+ compileGlobalGovernanceFacts({
705
+ jsx: [g.jsx.importPath().prefer("@mui/material", "@/components")],
706
+ canonicalBridges: [B6_BRIDGE],
707
+ })
708
+ );
709
+ ix.add(
710
+ makeUsageImportFact({
711
+ file: "src/app/page.tsx",
712
+ local: "Stepper",
713
+ imported: "Stepper",
714
+ source: "@mui/material",
715
+ location: { file: "src/app/page.tsx", line: 1, column: 0 },
716
+ })
717
+ );
718
+
719
+ const findings = ruleJsxPreferredImportPath(ix);
720
+ expect(findings).toHaveLength(1);
721
+ expect(findings[0].attributes).toMatchObject({ imported: "Stepper" });
722
+ expect(findings[0].attributes?.canonicalBridge).toBeUndefined();
723
+ });
724
+
725
+ it("names each superseded record through the compile-side projection", () => {
726
+ const superseded = projectSupersededImportPathPreferences({
727
+ jsx: [
728
+ g.jsx.importPath().prefer("@mui/material", "@/components", { imported: "Button" }),
729
+ g.jsx.importPath().prefer("@mui/material/Button", "@/components"),
730
+ g.jsx.importPath().prefer("@mui/material", "@/inputs", { imported: "TextField" }),
731
+ g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui"),
732
+ ],
733
+ canonicalBridges: [B6_BRIDGE],
734
+ });
735
+
736
+ expect(superseded).toEqual([
737
+ {
738
+ from: "@mui/material",
739
+ imported: "Button",
740
+ to: "@/components",
741
+ packageName: "@mui/material",
742
+ underlyingExportName: "Button",
743
+ localExportName: "Button",
744
+ },
745
+ {
746
+ from: "@mui/material/Button",
747
+ to: "@/components",
748
+ packageName: "@mui/material",
749
+ underlyingExportName: "Button",
750
+ localExportName: "Button",
751
+ },
752
+ ]);
753
+ });
754
+
755
+ it("projects nothing without bridges", () => {
756
+ expect(
757
+ projectSupersededImportPathPreferences({
758
+ jsx: [g.jsx.importPath().prefer("@mui/material", "@/components")],
759
+ })
760
+ ).toEqual([]);
761
+ });
762
+ });
763
+
628
764
  it("does not exempt sibling helpers, stories, barrels, or consumers", () => {
629
765
  const ix = new FactIndex();
630
766
  ix.addMany(
@@ -1148,6 +1284,47 @@ describe("a11y/required-accessible-name", () => {
1148
1284
  }
1149
1285
  });
1150
1286
 
1287
+ it("does not fire for conservatively classified runtime child content", () => {
1288
+ const ix = emptyIndexWithGovernance();
1289
+ addButtonUsage(ix, {
1290
+ file: "apps/x.tsx",
1291
+ nodePath: "0",
1292
+ line: 1,
1293
+ column: 0,
1294
+ props: [{ prop: "children", resolution: "jsx" }],
1295
+ childContent: "runtime",
1296
+ });
1297
+
1298
+ expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
1299
+ });
1300
+
1301
+ it.each(["structural", "empty"] as const)("still fires for %s child content", (childContent) => {
1302
+ const ix = emptyIndexWithGovernance();
1303
+ addButtonUsage(ix, {
1304
+ file: "apps/x.tsx",
1305
+ nodePath: "0",
1306
+ line: 1,
1307
+ column: 0,
1308
+ props: [{ prop: "children", resolution: "jsx" }],
1309
+ childContent,
1310
+ });
1311
+
1312
+ expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(1);
1313
+ });
1314
+
1315
+ it("does not infer a name from the public children prop-resolution fact", () => {
1316
+ const ix = emptyIndexWithGovernance();
1317
+ addButtonUsage(ix, {
1318
+ file: "apps/x.tsx",
1319
+ nodePath: "0",
1320
+ line: 1,
1321
+ column: 0,
1322
+ props: [{ prop: "children", resolution: "dynamic" }],
1323
+ });
1324
+
1325
+ expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(1);
1326
+ });
1327
+
1151
1328
  it("fires for an empty Button with no name source", () => {
1152
1329
  const ix = emptyIndexWithGovernance();
1153
1330
  addButtonUsage(ix, {
@@ -1174,6 +1351,11 @@ describe("a11y/required-accessible-name", () => {
1174
1351
 
1175
1352
  expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
1176
1353
  });
1354
+
1355
+ it("keeps the registry version in lockstep with the emitted finding version", () => {
1356
+ const registered = RULES.find((rule) => rule.id === "a11y/required-accessible-name");
1357
+ expect(registered?.version).toBe(A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION);
1358
+ });
1177
1359
  });
1178
1360
 
1179
1361
  // ---------------------------------------------------------------------------
@@ -2991,7 +3173,7 @@ describe("components/prefer-library", () => {
2991
3173
  expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2992
3174
  });
2993
3175
 
2994
- it("flags raw <button> → Button without advertising it as deterministic when Button is not imported", () => {
3176
+ it("keeps raw <button> → Button advisory without treating the directory as import-ready", () => {
2995
3177
  const ix = new FactIndex();
2996
3178
  ix.addMany(
2997
3179
  compileGlobalGovernanceFacts({
@@ -3027,14 +3209,12 @@ describe("components/prefer-library", () => {
3027
3209
  rawValue: "button",
3028
3210
  suggestedComponent: "Button",
3029
3211
  suggestedImportSourceKind: "directory",
3212
+ canonicalDirectory: "src/fragments/ui/components",
3213
+ importReady: false,
3214
+ advisory: true,
3030
3215
  },
3031
3216
  });
3032
- expect(findings[0].fix).toMatchObject({
3033
- kind: "replaceComponent",
3034
- from: "button",
3035
- to: "Button",
3036
- deterministic: false,
3037
- });
3217
+ expect(findings[0].fix).toBeUndefined();
3038
3218
  });
3039
3219
 
3040
3220
  it("flags a raw element by canonical vocab id when display name diverges (Modal → Dialog)", () => {
@@ -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
  );
@@ -51,6 +51,7 @@ export const RULE_TIER: Readonly<Record<string, RuleTier>> = {
51
51
  "tokens/require-dual-fallback": "hygiene",
52
52
  "theme/no-theme-coupled-literal": "hygiene",
53
53
  "a11y/required-accessible-name": "hygiene",
54
+ "a11y/standard": "hygiene",
54
55
  // Composition constraints are authored contracts, but not part of the
55
56
  // two-choice headline contract; they self-activate when patterns are declared
56
57
  // (the compiler injects their configs), so off-by-default here is harmless.
@@ -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