@usefragments/core 1.0.0 → 1.3.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 (59) hide show
  1. package/dist/{chunk-R3X7UBHU.js → chunk-57QDBEHQ.js} +101 -6
  2. package/dist/chunk-57QDBEHQ.js.map +1 -0
  3. package/dist/{chunk-243QYRUF.js → chunk-MLRDNSSA.js} +88 -47
  4. package/dist/chunk-MLRDNSSA.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-BOa3KyiJ.d.ts → governance-DYSirwJu.d.ts} +22 -1
  10. package/dist/index-h_yWj15D.d.ts +6981 -0
  11. package/dist/index.d.ts +113 -97
  12. package/dist/index.js +387 -113
  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 +3 -2584
  17. package/dist/schemas/index.js +9 -1
  18. package/dist/test-utils.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/agent-format.test.ts +144 -2
  21. package/src/agent-format.ts +34 -7
  22. package/src/codes/__tests__/codes.test.ts +57 -7
  23. package/src/codes/codes.ts +44 -42
  24. package/src/conform-prove.ts +4 -8
  25. package/src/contract/index.ts +3 -0
  26. package/src/contract/preimage.test.ts +50 -0
  27. package/src/contract/preimage.ts +56 -0
  28. package/src/evidence.ts +27 -0
  29. package/src/facts/builders.ts +4 -0
  30. package/src/facts/fact-index.ts +25 -1
  31. package/src/facts/facts.test.ts +49 -0
  32. package/src/facts/types.ts +3 -0
  33. package/src/governance-integrity.test.ts +2 -0
  34. package/src/governance-integrity.ts +7 -1
  35. package/src/index.ts +13 -0
  36. package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
  37. package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
  38. package/src/rules/components-prefer-library.ts +165 -7
  39. package/src/rules/emit-gate.test.ts +40 -2
  40. package/src/rules/emit-gate.ts +21 -10
  41. package/src/rules/finding.ts +3 -0
  42. package/src/rules/fix-availability.ts +33 -0
  43. package/src/rules/rules.test.ts +180 -24
  44. package/src/rules/spacing-resolution.ts +5 -8
  45. package/src/rules/styles-no-raw-color.ts +32 -23
  46. package/src/rules/styles-no-raw-dimensions.ts +31 -12
  47. package/src/rules/styles-no-raw-spacing.ts +33 -11
  48. package/src/rules/styles-no-raw-typography.ts +37 -15
  49. package/src/rules/taxonomy.test.ts +19 -3
  50. package/src/rules/tiers.ts +1 -1
  51. package/src/rules/tokens-require-dual-fallback.ts +54 -17
  52. package/src/rules/utils.ts +51 -0
  53. package/src/schemas/index.ts +106 -4
  54. package/src/token-types.ts +6 -0
  55. package/src/tokens/__tests__/source-names.test.ts +42 -0
  56. package/src/tokens/design-token-parser.test.ts +24 -5
  57. package/src/tokens/design-token-parser.ts +40 -8
  58. package/dist/chunk-243QYRUF.js.map +0 -1
  59. package/dist/chunk-R3X7UBHU.js.map +0 -1
@@ -1509,9 +1509,10 @@ describe("tokens/require-dual-fallback", () => {
1509
1509
  );
1510
1510
  ix.addMany([
1511
1511
  makeTokenDefinitionFact({
1512
- name: "--fui-color-brand",
1512
+ name: "$fui-color-brand",
1513
1513
  value: "#123456",
1514
1514
  category: "color",
1515
+ referenceFormat: "scss-var",
1515
1516
  }),
1516
1517
  makeStyleDeclarationFact({
1517
1518
  file: "src/button.scss",
@@ -1775,7 +1776,7 @@ describe("components/prefer-library", () => {
1775
1776
  expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
1776
1777
  });
1777
1778
 
1778
- it("flags imported bespoke components when package canonical sources include the exported component", () => {
1779
+ it("keeps same-name local import redirects advisory without a verified source contract", () => {
1779
1780
  const ix = new FactIndex();
1780
1781
  ix.addMany(
1781
1782
  compileGlobalGovernanceFacts({
@@ -1812,13 +1813,57 @@ describe("components/prefer-library", () => {
1812
1813
  expect(findings[0]).toMatchObject({
1813
1814
  ruleId: "components/prefer-library",
1814
1815
  code: "FUI1004",
1816
+ fix: {
1817
+ kind: "replaceImport",
1818
+ from: "../components/Card",
1819
+ to: "@acme/ui",
1820
+ deterministic: false,
1821
+ },
1815
1822
  attributes: {
1816
1823
  suggestedComponent: "Card",
1817
1824
  suggestedImport: "@acme/ui",
1825
+ advisory: true,
1826
+ contractProof: "missing",
1818
1827
  },
1819
1828
  });
1820
1829
  });
1821
1830
 
1831
+ it("does not mistake TanStack Router Link for the canonical Link by name alone", () => {
1832
+ const ix = new FactIndex();
1833
+ ix.addMany(
1834
+ compileGlobalGovernanceFacts({
1835
+ rules: {
1836
+ "components/prefer-library": {
1837
+ enabled: true,
1838
+ severity: "error",
1839
+ options: {
1840
+ canonicalSources: [{ kind: "npm", specifier: "@acme/ui", include: ["Link"] }],
1841
+ },
1842
+ },
1843
+ },
1844
+ })
1845
+ );
1846
+ ix.add(
1847
+ makeUsageNodeFact({
1848
+ file: "src/app.tsx",
1849
+ nodePath: "0:0",
1850
+ element: "Link",
1851
+ location: { file: "src/app.tsx", line: 5, column: 4 },
1852
+ })
1853
+ );
1854
+ ix.add(
1855
+ makeUsageImportFact({
1856
+ file: "src/app.tsx",
1857
+ local: "Link",
1858
+ imported: "Link",
1859
+ source: "@tanstack/react-router",
1860
+ location: { file: "src/app.tsx", line: 1, column: 0 },
1861
+ })
1862
+ );
1863
+
1864
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
1865
+ });
1866
+
1822
1867
  it("recognizes a current import through legacy npm source and mapping spellings", () => {
1823
1868
  const ix = new FactIndex();
1824
1869
  ix.addMany(
@@ -2159,7 +2204,7 @@ describe("components/prefer-library", () => {
2159
2204
  expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2160
2205
  });
2161
2206
 
2162
- it("attaches a deterministic component fix from confirmed canonical mappings", () => {
2207
+ it("keeps a complete mapped replacement guided until the conform AST preflight", () => {
2163
2208
  const ix = new FactIndex();
2164
2209
  ix.addMany(
2165
2210
  compileGlobalGovernanceFacts({
@@ -2183,6 +2228,15 @@ describe("components/prefer-library", () => {
2183
2228
  },
2184
2229
  })
2185
2230
  );
2231
+ ix.add(
2232
+ makeUsageImportFact({
2233
+ file: "src/app.tsx",
2234
+ local: "Button",
2235
+ imported: "Button",
2236
+ source: "@acme/ui",
2237
+ location: { file: "src/app.tsx", line: 1, column: 0 },
2238
+ })
2239
+ );
2186
2240
  ix.add(
2187
2241
  makeUsageNodeFact({
2188
2242
  file: "src/app.tsx",
@@ -2200,14 +2254,102 @@ describe("components/prefer-library", () => {
2200
2254
  kind: "replaceComponent",
2201
2255
  from: "button",
2202
2256
  to: "Button",
2203
- deterministic: true,
2257
+ deterministic: false,
2204
2258
  },
2205
2259
  attributes: {
2206
2260
  suggestedComponent: "Button",
2207
2261
  suggestedImport: "@acme/ui",
2208
2262
  propMapping: [{ rawProp: "className", canonicalProp: "variant" }],
2263
+ propCompatibility: "observed-complete",
2209
2264
  },
2210
2265
  });
2266
+ expect(findings[0].attributes).not.toHaveProperty("canonicalMappingConfirmed");
2267
+ });
2268
+
2269
+ it("withholds a native-element fix when a native-only prop has no confirmed mapping", () => {
2270
+ const ix = new FactIndex();
2271
+ ix.addMany(
2272
+ compileGlobalGovernanceFacts({
2273
+ rules: {
2274
+ "components/prefer-library": {
2275
+ enabled: true,
2276
+ severity: "warning",
2277
+ options: {
2278
+ canonicalMappings: [
2279
+ {
2280
+ name: "Button",
2281
+ canonical: "Button",
2282
+ htmlEquivalent: "button",
2283
+ importPath: "@acme/ui",
2284
+ },
2285
+ ],
2286
+ },
2287
+ },
2288
+ },
2289
+ })
2290
+ );
2291
+ ix.add(
2292
+ makeUsageImportFact({
2293
+ file: "src/app.tsx",
2294
+ local: "Button",
2295
+ imported: "Button",
2296
+ source: "@acme/ui",
2297
+ location: { file: "src/app.tsx", line: 1, column: 0 },
2298
+ })
2299
+ );
2300
+ addRawNode(ix, {
2301
+ element: "button",
2302
+ nodePath: "0:0",
2303
+ props: [{ prop: "formAction", value: "/save" }],
2304
+ });
2305
+
2306
+ const findings = ruleComponentsPreferLibrary(ix);
2307
+ expect(findings).toHaveLength(1);
2308
+ expect(findings[0]).toMatchObject({
2309
+ fix: { kind: "replaceComponent", deterministic: false },
2310
+ attributes: {
2311
+ propCompatibility: "incomplete",
2312
+ unprovenProps: ["formAction"],
2313
+ },
2314
+ });
2315
+ });
2316
+
2317
+ it("keeps replacement metadata but marks it non-deterministic when its import is absent", () => {
2318
+ const ix = new FactIndex();
2319
+ ix.addMany(
2320
+ compileGlobalGovernanceFacts({
2321
+ rules: {
2322
+ "components/prefer-library": {
2323
+ enabled: true,
2324
+ severity: "warning",
2325
+ options: {
2326
+ canonicalMappings: [
2327
+ {
2328
+ name: "Button",
2329
+ canonical: "Button",
2330
+ htmlEquivalent: "button",
2331
+ importPath: "@acme/ui",
2332
+ },
2333
+ ],
2334
+ },
2335
+ },
2336
+ },
2337
+ })
2338
+ );
2339
+ addRawNode(ix, { element: "button", nodePath: "0:0", text: "Save" });
2340
+
2341
+ const findings = ruleComponentsPreferLibrary(ix);
2342
+ expect(findings).toHaveLength(1);
2343
+ expect(findings[0].attributes).toMatchObject({
2344
+ suggestedComponent: "Button",
2345
+ suggestedImport: "@acme/ui",
2346
+ });
2347
+ expect(findings[0].fix).toMatchObject({
2348
+ kind: "replaceComponent",
2349
+ from: "button",
2350
+ to: "Button",
2351
+ deterministic: false,
2352
+ });
2211
2353
  });
2212
2354
 
2213
2355
  it("does not flag generic <div>/<span> from an inferred component primitive (anti-flood)", () => {
@@ -2561,29 +2703,38 @@ describe("components/prefer-library", () => {
2561
2703
  expect(findings.filter((f) => f.attributes?.suggestedComponent === "Hint")).toHaveLength(0);
2562
2704
  });
2563
2705
 
2564
- it("STILL fires the legit name-based import-redirect (<Modal> from wrong path → import @acme/ui)", () => {
2706
+ it("STILL fires the legit local name-based import redirect (<Modal> → import @acme/ui)", () => {
2565
2707
  // Regression guard: removing the canonical selector must not kill the
2566
2708
  // real name-match import-redirect. <Modal> (the component's own name)
2567
2709
  // imported from the wrong path is genuine drift.
2568
2710
  const findings = importedFindings(
2569
2711
  [{ name: "Modal", canonical: "Dialog", importPath: "@acme/ui" }],
2570
2712
  "Modal",
2571
- "react-aria"
2713
+ "../Modal"
2572
2714
  );
2573
2715
  expect(findings).toHaveLength(1);
2574
2716
  expect(findings[0].fix?.kind).toBe("replaceImport");
2575
2717
  expect(findings[0].attributes?.suggestedComponent).toBe("Modal");
2576
2718
  });
2577
2719
 
2578
- it("STILL fires when name === canonical (<Dialog> for name 'Dialog') from wrong path", () => {
2720
+ it("STILL fires for a local import when name === canonical (<Dialog> for name 'Dialog')", () => {
2579
2721
  const findings = importedFindings(
2580
2722
  [{ name: "Dialog", canonical: "Dialog", importPath: "@acme/ui" }],
2581
2723
  "Dialog",
2582
- "react-aria"
2724
+ "../Dialog"
2583
2725
  );
2584
2726
  expect(findings).toHaveLength(1);
2585
2727
  expect(findings[0].attributes?.suggestedComponent).toBe("Dialog");
2586
2728
  });
2729
+
2730
+ it("does not redirect a package-owned same-name Link without an explicit import policy", () => {
2731
+ const findings = importedFindings(
2732
+ [{ name: "Link", canonical: "Link", importPath: "@acme/ui" }],
2733
+ "Link",
2734
+ "@tanstack/react-router"
2735
+ );
2736
+ expect(findings).toHaveLength(0);
2737
+ });
2587
2738
  });
2588
2739
 
2589
2740
  describe("directory canonical source (unfiltered include — dogfood shape)", () => {
@@ -2672,7 +2823,7 @@ describe("components/prefer-library", () => {
2672
2823
  expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2673
2824
  });
2674
2825
 
2675
- it("flags raw <button> → Button when Button is a declared directory canonical", () => {
2826
+ it("flags raw <button> → Button without advertising it as deterministic when Button is not imported", () => {
2676
2827
  const ix = new FactIndex();
2677
2828
  ix.addMany(
2678
2829
  compileGlobalGovernanceFacts({
@@ -2704,18 +2855,18 @@ describe("components/prefer-library", () => {
2704
2855
  expect(findings).toHaveLength(1);
2705
2856
  expect(findings[0]).toMatchObject({
2706
2857
  ruleId: "components/prefer-library",
2707
- fix: {
2708
- kind: "replaceComponent",
2709
- from: "button",
2710
- to: "Button",
2711
- deterministic: true,
2712
- },
2713
2858
  attributes: {
2714
2859
  rawValue: "button",
2715
2860
  suggestedComponent: "Button",
2716
2861
  suggestedImportSourceKind: "directory",
2717
2862
  },
2718
2863
  });
2864
+ expect(findings[0].fix).toMatchObject({
2865
+ kind: "replaceComponent",
2866
+ from: "button",
2867
+ to: "Button",
2868
+ deterministic: false,
2869
+ });
2719
2870
  });
2720
2871
 
2721
2872
  it("flags a raw element by canonical vocab id when display name diverges (Modal → Dialog)", () => {
@@ -2825,7 +2976,9 @@ describe("components/prefer-library", () => {
2825
2976
 
2826
2977
  const findings = ruleComponentsPreferLibrary(ix);
2827
2978
 
2828
- // type-specific mappings win over the generic tag-level one; text falls back
2979
+ // Type-specific mappings win over the generic tag-level one; text falls
2980
+ // back. No canonical bindings exist in this fixture, so suggestions remain
2981
+ // useful but are not advertised as deterministic edits.
2829
2982
  expect(findings.map((finding) => finding.attributes?.suggestedComponent)).toEqual([
2830
2983
  "Checkbox",
2831
2984
  "RadioGroup",
@@ -2836,7 +2989,7 @@ describe("components/prefer-library", () => {
2836
2989
  "RadioGroup",
2837
2990
  "TextField",
2838
2991
  ]);
2839
- expect(findings.every((finding) => finding.fix?.deterministic === true)).toBe(true);
2992
+ expect(findings.every((finding) => finding.fix?.deterministic === false)).toBe(true);
2840
2993
  });
2841
2994
 
2842
2995
  it("does not let a type-constrained mapping match a different or untyped input", () => {
@@ -2872,7 +3025,7 @@ describe("components/prefer-library", () => {
2872
3025
  expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2873
3026
  });
2874
3027
 
2875
- it("attaches a deterministic import fix for canonical components from the wrong path", () => {
3028
+ it("keeps a same-name local import redirect advisory without source-contract proof", () => {
2876
3029
  const ix = new FactIndex();
2877
3030
  ix.addMany(
2878
3031
  compileGlobalGovernanceFacts({
@@ -2918,9 +3071,10 @@ describe("components/prefer-library", () => {
2918
3071
  kind: "replaceImport",
2919
3072
  from: "../Button",
2920
3073
  to: "@acme/ui",
2921
- deterministic: true,
3074
+ deterministic: false,
2922
3075
  },
2923
3076
  location: { line: 1, column: 0 },
3077
+ attributes: { advisory: true, contractProof: "missing" },
2924
3078
  });
2925
3079
  });
2926
3080
 
@@ -2967,13 +3121,14 @@ describe("components/prefer-library", () => {
2967
3121
  suggestedComponent: "Alert",
2968
3122
  matchedRole: "status",
2969
3123
  confidence: 0.9,
3124
+ advisory: true,
2970
3125
  },
2971
3126
  });
2972
3127
  // Role matches are advisory: no deterministic tag→component rewrite.
2973
3128
  expect(findings[0].fix).toBeUndefined();
2974
3129
  });
2975
3130
 
2976
- it("flags input type primitives config-free with deterministic fixes", () => {
3131
+ it("flags input type primitives config-free without unsafe missing-import fixes", () => {
2977
3132
  const ix = preferLibraryIndex();
2978
3133
  addRawNode(ix, {
2979
3134
  element: "input",
@@ -3003,7 +3158,7 @@ describe("components/prefer-library", () => {
3003
3158
  "input-type",
3004
3159
  "input-type",
3005
3160
  ]);
3006
- expect(findings.map((finding) => finding.fix?.deterministic)).toEqual([true, true, true]);
3161
+ expect(findings.every((finding) => finding.fix?.deterministic === false)).toBe(true);
3007
3162
  });
3008
3163
 
3009
3164
  it("does not infer generic text inputs as deterministic primitives", () => {
@@ -3093,7 +3248,7 @@ describe("components/prefer-library", () => {
3093
3248
  kind: "replaceComponent",
3094
3249
  from: "button",
3095
3250
  to: "Button",
3096
- deterministic: true,
3251
+ deterministic: false,
3097
3252
  });
3098
3253
  });
3099
3254
 
@@ -3139,13 +3294,13 @@ describe("components/prefer-library", () => {
3139
3294
  kind: "replaceComponent",
3140
3295
  from: "button",
3141
3296
  to: "Button",
3142
- deterministic: true,
3297
+ deterministic: false,
3143
3298
  });
3144
3299
  expect(findings[2].fix).toMatchObject({
3145
3300
  kind: "replaceComponent",
3146
3301
  from: "button",
3147
3302
  to: "Button",
3148
- deterministic: true,
3303
+ deterministic: false,
3149
3304
  });
3150
3305
  });
3151
3306
 
@@ -3430,6 +3585,7 @@ describe("styles/no-raw-typography (BUG 10)", () => {
3430
3585
  );
3431
3586
  const findings = ruleStylesNoRawTypography(ix);
3432
3587
  expect(findings).toHaveLength(1);
3588
+ expect(findings[0].code).toBe("FUI2016");
3433
3589
  expect(findings[0].fix).toMatchObject({
3434
3590
  value: "var(--font-size-md)",
3435
3591
  deterministic: true,
@@ -1,7 +1,6 @@
1
1
  import type { TokenDefinitionFact } from "../facts/index.js";
2
2
 
3
3
  import {
4
- isApplicableTokenReference,
5
4
  matchesScale,
6
5
  nearestSignedScaleValue,
7
6
  normalizeLengthForScale,
@@ -75,7 +74,7 @@ export function resolveSpacingValue(input: {
75
74
  let reliableSuggestion = true;
76
75
  // Deterministic only when every violating part is a value-preserving,
77
76
  // verbatim-resolvable token swap.
78
- let allApplicableValuePreserving = true;
77
+ let allValuePreserving = true;
79
78
 
80
79
  for (const part of parts) {
81
80
  const normalized = normalizeLengthForScale(part.parsed, input.scale);
@@ -93,9 +92,7 @@ export function resolveSpacingValue(input: {
93
92
  // On-grid literal that equals a token: value-preserving swap.
94
93
  anyViolation = true;
95
94
  const reference = tokenReference(exactToken.name);
96
- const applicable =
97
- isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix;
98
- if (!applicable) allApplicableValuePreserving = false;
95
+ if (!normalized.deterministicFix) allValuePreserving = false;
99
96
  suggestedParts.push(reference);
100
97
  if (!firstViolation) {
101
98
  firstViolation = {
@@ -103,7 +100,7 @@ export function resolveSpacingValue(input: {
103
100
  suggestedValue: reference,
104
101
  suggestedToken: exactToken.name,
105
102
  fixEmittable: true,
106
- deterministicFix: applicable,
103
+ deterministicFix: normalized.deterministicFix,
107
104
  reason: "token-equivalent",
108
105
  matchedToken: exactToken.name,
109
106
  assumedRootFontSizePx: normalized.assumedRootFontSizePx,
@@ -117,7 +114,7 @@ export function resolveSpacingValue(input: {
117
114
  // one exists. Snapping changes rendered value, so this is not deterministic.
118
115
  anyViolation = true;
119
116
  anyOffScale = true;
120
- allApplicableValuePreserving = false;
117
+ allValuePreserving = false;
121
118
 
122
119
  const nearest = nearestSignedScaleValue(normalized.value, input.allowed);
123
120
  const snapsToZero = nearest === 0 && normalized.value !== 0;
@@ -158,7 +155,7 @@ export function resolveSpacingValue(input: {
158
155
  const joined = suggestedParts.join(" ");
159
156
  const changes = joined !== input.raw.trim().replace(/\s+/g, " ");
160
157
  const fixEmittable = canFixAll && reliableSuggestion && changes;
161
- const deterministic = fixEmittable && !anyOffScale && allApplicableValuePreserving;
158
+ const deterministic = fixEmittable && !anyOffScale && allValuePreserving;
162
159
 
163
160
  return {
164
161
  ...firstViolation,
@@ -18,11 +18,12 @@ import { makeFinding } from "./finding.js";
18
18
  import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
19
19
  import {
20
20
  detectRawColor,
21
+ emitFix,
21
22
  indexComponentByNodeId,
22
- isApplicableTokenReference,
23
23
  isExemptColor,
24
24
  readUsageNode,
25
25
  tokenReference,
26
+ type FixEmission,
26
27
  } from "./utils.js";
27
28
 
28
29
  export const RULE_ID = "styles/no-raw-color";
@@ -43,6 +44,10 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
43
44
  if (isExemptColor(color, policy.except)) continue;
44
45
 
45
46
  const resolution = resolveColorToken(decl.property, color, colorTokens);
47
+ const fixEmission =
48
+ resolution?.kind === "exact"
49
+ ? buildTokenFix(decl.property, decl.value, color, resolution, ix)
50
+ : undefined;
46
51
  const evidenceIds = resolution
47
52
  ? [decl.id, policy.id, resolution.token.id]
48
53
  : [decl.id, policy.id];
@@ -63,10 +68,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
63
68
  property: decl.property,
64
69
  value: color,
65
70
  },
66
- fix:
67
- resolution?.kind === "exact"
68
- ? buildTokenFix(decl.property, decl.value, color, resolution)
69
- : undefined,
71
+ fix: fixEmission?.fix,
70
72
  attributes: {
71
73
  property: decl.property,
72
74
  rawValue: decl.value,
@@ -74,6 +76,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
74
76
  source: "css",
75
77
  suggestedToken: resolution?.token.name,
76
78
  ...(resolution ? { tokenMatch: resolution.kind } : {}),
79
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
77
80
  },
78
81
  })
79
82
  );
@@ -91,6 +94,10 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
91
94
 
92
95
  const componentEvidenceId = componentByNode.get(node.id)?.id;
93
96
  const resolution = resolveColorToken(inline.property, color, colorTokens);
97
+ const fixEmission =
98
+ resolution?.kind === "exact"
99
+ ? buildTokenFix(inline.property, inline.value, color, resolution, ix)
100
+ : undefined;
94
101
  const baseEvidence = componentEvidenceId
95
102
  ? [node.id, componentEvidenceId, inline.id, policy.id]
96
103
  : [node.id, inline.id, policy.id];
@@ -112,10 +119,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
112
119
  property: inline.property,
113
120
  value: color,
114
121
  },
115
- fix:
116
- resolution?.kind === "exact"
117
- ? buildTokenFix(inline.property, inline.value, color, resolution)
118
- : undefined,
122
+ fix: fixEmission?.fix,
119
123
  attributes: {
120
124
  property: inline.property,
121
125
  rawValue: inline.value,
@@ -123,6 +127,7 @@ export function ruleStylesNoRawColor(ix: FactIndex): Finding[] {
123
127
  source: "jsx",
124
128
  suggestedToken: resolution?.token.name,
125
129
  ...(resolution ? { tokenMatch: resolution.kind } : {}),
130
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
126
131
  },
127
132
  })
128
133
  );
@@ -298,22 +303,26 @@ function buildTokenFix(
298
303
  property: string,
299
304
  rawValue: string,
300
305
  rawColor: string,
301
- match: TokenMatch
302
- ): FindingReplaceStyleValueFix {
306
+ match: TokenMatch,
307
+ ix: FactIndex
308
+ ): FixEmission<FindingReplaceStyleValueFix> {
303
309
  const reference = tokenReference(match.token.name);
304
310
  const value = replaceRawColor(rawValue, rawColor, reference);
305
- return {
306
- kind: "replaceStyleValue",
307
- title: `Replace ${property} with ${value}`,
308
- property,
309
- value,
310
- // Apply automatically only when the swap is unambiguous AND the reference
311
- // resolves verbatim. An ambiguous match (several tokens share the value,
312
- // none role-matching) or a token that can't be referenced from CSS (a Sass
313
- // *map* member, or a DTCG JSON token with no emitted custom property) is a
314
- // suggestion — applying it would cross roles or write non-compiling code.
315
- deterministic: !match.ambiguous && isApplicableTokenReference(match.token.referenceFormat),
316
- };
311
+ return emitFix(
312
+ {
313
+ kind: "replaceStyleValue",
314
+ title: `Replace ${property} with ${value}`,
315
+ property,
316
+ value,
317
+ deterministic: true,
318
+ },
319
+ {
320
+ symbols: [match.token.name],
321
+ editKind: "replaceStyleValue",
322
+ valuePreserving: !match.ambiguous,
323
+ },
324
+ ix
325
+ );
317
326
  }
318
327
 
319
328
  function replaceRawColor(rawValue: string, rawColor: string, replacement: string): string {
@@ -31,7 +31,13 @@ import type { FactId, FactIndex, TokenDefinitionFact } from "../facts/index.js";
31
31
 
32
32
  import { makeFinding } from "./finding.js";
33
33
  import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
34
- import { indexComponentByNodeId, parseLengthValue, readUsageNode } from "./utils.js";
34
+ import {
35
+ emitFix,
36
+ indexComponentByNodeId,
37
+ parseLengthValue,
38
+ readUsageNode,
39
+ type FixEmission,
40
+ } from "./utils.js";
35
41
 
36
42
  export const RULE_ID = "styles/no-raw-dimensions";
37
43
  export const RULE_VERSION = "1";
@@ -75,6 +81,7 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
75
81
  if (!resolution) continue;
76
82
  const tokenIds = resolutionTokenIds(resolution);
77
83
  const evidenceIds = tokenIds.length ? [decl.id, policy.id, ...tokenIds] : [decl.id, policy.id];
84
+ const fixEmission = dimensionFix(decl.property, resolution, ix);
78
85
 
79
86
  findings.push(
80
87
  makeFinding({
@@ -92,12 +99,13 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
92
99
  property: decl.property,
93
100
  value: decl.value,
94
101
  },
95
- fix: dimensionFix(decl.property, resolution),
102
+ fix: fixEmission?.fix,
96
103
  attributes: {
97
104
  property: decl.property,
98
105
  rawValue: decl.value,
99
106
  source: "css",
100
107
  suggestedToken: resolutionSuggestedToken(resolution),
108
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
101
109
  },
102
110
  })
103
111
  );
@@ -121,6 +129,7 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
121
129
  : [node.id, inline.id, policy.id];
122
130
  const tokenIds = resolutionTokenIds(resolution);
123
131
  const evidenceIds = tokenIds.length ? [...baseEvidence, ...tokenIds] : baseEvidence;
132
+ const fixEmission = dimensionFix(inline.property, resolution, ix);
124
133
 
125
134
  findings.push(
126
135
  makeFinding({
@@ -138,12 +147,13 @@ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
138
147
  property: inline.property,
139
148
  value: inline.value,
140
149
  },
141
- fix: dimensionFix(inline.property, resolution),
150
+ fix: fixEmission?.fix,
142
151
  attributes: {
143
152
  property: inline.property,
144
153
  rawValue: inline.value,
145
154
  source: "jsx",
146
155
  suggestedToken: resolutionSuggestedToken(resolution),
156
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
147
157
  },
148
158
  })
149
159
  );
@@ -219,16 +229,25 @@ function dimensionMessage(
219
229
 
220
230
  function dimensionFix(
221
231
  property: string,
222
- resolution: DimensionResolution
223
- ): FindingReplaceStyleValueFix | undefined {
232
+ resolution: DimensionResolution,
233
+ ix: FactIndex
234
+ ): FixEmission<FindingReplaceStyleValueFix> | undefined {
224
235
  if (resolution.kind === "exact") {
225
- return {
226
- kind: "replaceStyleValue",
227
- title: `Replace ${property} with ${resolution.fixValue}`,
228
- property,
229
- value: resolution.fixValue,
230
- deterministic: true,
231
- };
236
+ return emitFix(
237
+ {
238
+ kind: "replaceStyleValue",
239
+ title: `Replace ${property} with ${resolution.fixValue}`,
240
+ property,
241
+ value: resolution.fixValue,
242
+ deterministic: true,
243
+ },
244
+ {
245
+ symbols: resolution.tokens.map((token) => token.name),
246
+ editKind: "replaceStyleValue",
247
+ valuePreserving: true,
248
+ },
249
+ ix
250
+ );
232
251
  }
233
252
  // `nearest` deliberately emits NO fix — a value-changing snap must stay a
234
253
  // suggestion (see DimensionResolution); only the exact, value-preserving swap