@usefragments/core 1.2.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 (43) hide show
  1. package/dist/{chunk-BQQTMGLA.js → chunk-57QDBEHQ.js} +79 -6
  2. package/dist/chunk-57QDBEHQ.js.map +1 -0
  3. package/dist/{chunk-DBVTSUMT.js → chunk-MLRDNSSA.js} +20 -6
  4. package/dist/chunk-MLRDNSSA.js.map +1 -0
  5. package/dist/codes/index.d.ts +1 -1
  6. package/dist/codes/index.js +2 -2
  7. package/dist/index-h_yWj15D.d.ts +6981 -0
  8. package/dist/index.d.ts +106 -92
  9. package/dist/index.js +235 -98
  10. package/dist/index.js.map +1 -1
  11. package/dist/registry.d.ts +36 -36
  12. package/dist/schemas/index.d.ts +3 -3377
  13. package/dist/schemas/index.js +1 -1
  14. package/package.json +1 -1
  15. package/src/agent-format.test.ts +75 -3
  16. package/src/agent-format.ts +28 -6
  17. package/src/evidence.ts +27 -0
  18. package/src/facts/builders.ts +2 -0
  19. package/src/facts/fact-index.ts +22 -2
  20. package/src/facts/facts.test.ts +1 -1
  21. package/src/facts/types.ts +2 -0
  22. package/src/index.ts +4 -0
  23. package/src/rules/__tests__/fix-emission-invariant.test.ts +174 -0
  24. package/src/rules/__tests__/tokens-require-dual-fallback.test.ts +90 -0
  25. package/src/rules/emit-gate.test.ts +16 -0
  26. package/src/rules/emit-gate.ts +6 -3
  27. package/src/rules/finding.ts +3 -0
  28. package/src/rules/rules.test.ts +2 -1
  29. package/src/rules/spacing-resolution.ts +5 -8
  30. package/src/rules/styles-no-raw-color.ts +32 -23
  31. package/src/rules/styles-no-raw-dimensions.ts +31 -12
  32. package/src/rules/styles-no-raw-spacing.ts +33 -11
  33. package/src/rules/styles-no-raw-typography.ts +37 -15
  34. package/src/rules/taxonomy.test.ts +13 -0
  35. package/src/rules/tokens-require-dual-fallback.ts +54 -17
  36. package/src/rules/utils.ts +51 -0
  37. package/src/schemas/index.ts +81 -3
  38. package/src/token-types.ts +6 -0
  39. package/src/tokens/__tests__/source-names.test.ts +42 -0
  40. package/src/tokens/design-token-parser.test.ts +24 -5
  41. package/src/tokens/design-token-parser.ts +40 -8
  42. package/dist/chunk-BQQTMGLA.js.map +0 -1
  43. package/dist/chunk-DBVTSUMT.js.map +0 -1
@@ -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
@@ -42,7 +42,13 @@ import {
42
42
  type SpacingTokenLookup,
43
43
  } from "./spacing-resolution.js";
44
44
  import type { Finding } from "./types.js";
45
- import { indexComponentByNodeId, readUsageNode } from "./utils.js";
45
+ import {
46
+ emitFix,
47
+ indexComponentByNodeId,
48
+ readUsageNode,
49
+ tokenSymbolsInText,
50
+ type FixEmission,
51
+ } from "./utils.js";
46
52
 
47
53
  export const RULE_ID = "styles/no-raw-spacing";
48
54
  export const RULE_VERSION = "1";
@@ -104,6 +110,7 @@ function checkDeclaration(
104
110
  tokens: lookupFor(scale),
105
111
  });
106
112
  if (!checked) return null;
113
+ const fixEmission = buildSpacingFix(decl.property, checked, ix);
107
114
 
108
115
  return makeFinding({
109
116
  ruleId: RULE_ID,
@@ -120,7 +127,7 @@ function checkDeclaration(
120
127
  property: decl.property,
121
128
  value: decl.value,
122
129
  },
123
- fix: buildSpacingFix(decl.property, checked),
130
+ fix: fixEmission?.fix,
124
131
  attributes: {
125
132
  property: decl.property,
126
133
  rawValue: decl.value,
@@ -135,6 +142,7 @@ function checkDeclaration(
135
142
  assumedRootFontSizePx: checked.assumedRootFontSizePx,
136
143
  assumedEmBasePx: checked.assumedEmBasePx,
137
144
  source: "css",
145
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
138
146
  },
139
147
  });
140
148
  }
@@ -162,6 +170,7 @@ function checkInlineStyle(
162
170
  bareNumberFix: inline.valueKind === "number",
163
171
  });
164
172
  if (!checked) return null;
173
+ const fixEmission = buildSpacingFix(inline.property, checked, ix);
165
174
 
166
175
  const node = readUsageNode(ix, inline.nodeId);
167
176
  if (!node) return null;
@@ -186,7 +195,7 @@ function checkInlineStyle(
186
195
  property: inline.property,
187
196
  value: inline.value,
188
197
  },
189
- fix: buildSpacingFix(inline.property, checked),
198
+ fix: fixEmission?.fix,
190
199
  attributes: {
191
200
  property: inline.property,
192
201
  rawValue: inline.value,
@@ -201,19 +210,32 @@ function checkInlineStyle(
201
210
  assumedRootFontSizePx: checked.assumedRootFontSizePx,
202
211
  assumedEmBasePx: checked.assumedEmBasePx,
203
212
  source: "jsx",
213
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
204
214
  },
205
215
  });
206
216
  }
207
217
 
208
- function buildSpacingFix(property: string, checked: CheckedSpacingValue) {
218
+ function buildSpacingFix(
219
+ property: string,
220
+ checked: CheckedSpacingValue,
221
+ ix: FactIndex
222
+ ): FixEmission | undefined {
209
223
  if (!checked.fixEmittable || checked.suggestedValue === undefined) return undefined;
210
- return {
211
- kind: "replaceStyleValue" as const,
212
- title: `Replace ${property} with ${checked.suggestedValue}`,
213
- property,
214
- value: checked.suggestedValue,
215
- deterministic: checked.deterministicFix,
216
- };
224
+ return emitFix(
225
+ {
226
+ kind: "replaceStyleValue" as const,
227
+ title: `Replace ${property} with ${checked.suggestedValue}`,
228
+ property,
229
+ value: checked.suggestedValue,
230
+ deterministic: true,
231
+ },
232
+ {
233
+ symbols: tokenSymbolsInText(checked.suggestedValue),
234
+ editKind: "replaceStyleValue",
235
+ valuePreserving: checked.deterministicFix,
236
+ },
237
+ ix
238
+ );
217
239
  }
218
240
 
219
241
  function spacingMessage(
@@ -29,13 +29,15 @@ import { makeFinding } from "./finding.js";
29
29
  import type { Finding } from "./types.js";
30
30
  import {
31
31
  indexComponentByNodeId,
32
- isApplicableTokenReference,
32
+ emitFix,
33
33
  matchesScale,
34
34
  nearestSignedScaleValue,
35
35
  normalizeLengthForScale,
36
36
  parseLengthValue,
37
37
  readUsageNode,
38
38
  tokenReference,
39
+ tokenSymbolsInText,
40
+ type FixEmission,
39
41
  } from "./utils.js";
40
42
 
41
43
  export const RULE_ID = "styles/no-raw-typography";
@@ -58,6 +60,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
58
60
  if (decl.property.toLowerCase() !== "font-size") continue;
59
61
  const checked = checkFontSize(decl.value, allowed, scale, tokens);
60
62
  if (!checked) continue;
63
+ const fixEmission = buildFix(decl.property, checked, ix);
61
64
  findings.push(
62
65
  makeFinding({
63
66
  ruleId: RULE_ID,
@@ -74,7 +77,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
74
77
  property: decl.property,
75
78
  value: decl.value,
76
79
  },
77
- fix: buildFix(decl.property, checked),
80
+ fix: fixEmission?.fix,
78
81
  attributes: {
79
82
  property: decl.property,
80
83
  rawValue: decl.value,
@@ -83,6 +86,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
83
86
  suggestedValue: checked.suggestedValue,
84
87
  matchedToken: checked.matchedToken,
85
88
  source: "css",
89
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
86
90
  },
87
91
  })
88
92
  );
@@ -92,8 +96,15 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
92
96
  for (const inline of ix.byKind("usage_inline_style")) {
93
97
  if (inline.valueKind === "css-variable") continue;
94
98
  if (!FONT_SIZE_PROPERTIES.has(inline.property.toLowerCase())) continue;
95
- const checked = checkFontSize(inline.value, allowed, scale, tokens, inline.valueKind === "number");
99
+ const checked = checkFontSize(
100
+ inline.value,
101
+ allowed,
102
+ scale,
103
+ tokens,
104
+ inline.valueKind === "number"
105
+ );
96
106
  if (!checked) continue;
107
+ const fixEmission = buildFix(inline.property, checked, ix);
97
108
  const node = readUsageNode(ix, inline.nodeId);
98
109
  if (!node) continue;
99
110
  const componentEvidenceId = componentByNode.get(node.id)?.id;
@@ -116,7 +127,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
116
127
  property: inline.property,
117
128
  value: inline.value,
118
129
  },
119
- fix: buildFix(inline.property, checked),
130
+ fix: fixEmission?.fix,
120
131
  attributes: {
121
132
  property: inline.property,
122
133
  rawValue: inline.value,
@@ -125,6 +136,7 @@ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
125
136
  suggestedValue: checked.suggestedValue,
126
137
  matchedToken: checked.matchedToken,
127
138
  source: "jsx",
139
+ ...(fixEmission?.downgradeReason ? { downgradeReason: fixEmission.downgradeReason } : {}),
128
140
  },
129
141
  })
130
142
  );
@@ -175,12 +187,10 @@ function checkFontSize(
175
187
 
176
188
  if (matchesScale(normalized.value, allowed)) {
177
189
  if (!exactToken) return null;
178
- const applicable =
179
- isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix;
180
190
  return {
181
191
  suggestedValue: tokenReference(exactToken.name),
182
192
  fixEmittable: true,
183
- deterministicFix: applicable,
193
+ deterministicFix: normalized.deterministicFix,
184
194
  reason: "token-equivalent",
185
195
  matchedToken: exactToken.name,
186
196
  };
@@ -206,15 +216,27 @@ function checkFontSize(
206
216
  };
207
217
  }
208
218
 
209
- function buildFix(property: string, checked: CheckedFontSize) {
219
+ function buildFix(
220
+ property: string,
221
+ checked: CheckedFontSize,
222
+ ix: FactIndex
223
+ ): FixEmission | undefined {
210
224
  if (!checked.fixEmittable || checked.suggestedValue === undefined) return undefined;
211
- return {
212
- kind: "replaceStyleValue" as const,
213
- title: `Replace ${property} with ${checked.suggestedValue}`,
214
- property,
215
- value: checked.suggestedValue,
216
- deterministic: checked.deterministicFix,
217
- };
225
+ return emitFix(
226
+ {
227
+ kind: "replaceStyleValue" as const,
228
+ title: `Replace ${property} with ${checked.suggestedValue}`,
229
+ property,
230
+ value: checked.suggestedValue,
231
+ deterministic: true,
232
+ },
233
+ {
234
+ symbols: tokenSymbolsInText(checked.suggestedValue),
235
+ editKind: "replaceStyleValue",
236
+ valuePreserving: checked.deterministicFix,
237
+ },
238
+ ix
239
+ );
218
240
  }
219
241
 
220
242
  function typographyMessage(
@@ -12,6 +12,7 @@ import {
12
12
  makeContractTokenFact,
13
13
  makeStyleCssVarsMustBeDefinedFact,
14
14
  makeStyleDeclarationFact,
15
+ makeTokenDefinitionFact,
15
16
  makeUsageNodeFact,
16
17
  makeUsageTextChildFact,
17
18
  RULE_TIER,
@@ -36,6 +37,7 @@ import type { Fact } from "../index.js";
36
37
  function rawValueFixture(): Fact[] {
37
38
  const file = "src/Widget.module.scss";
38
39
  return [
40
+ dualFallbackToken(),
39
41
  makeStyleDeclarationFact({
40
42
  file,
41
43
  selector: ".a",
@@ -64,6 +66,15 @@ function rawValueFixture(): Fact[] {
64
66
  ];
65
67
  }
66
68
 
69
+ function dualFallbackToken(): Fact {
70
+ return makeTokenDefinitionFact({
71
+ name: "$fui-surface-secondary",
72
+ value: "#f1f5f9",
73
+ category: "color",
74
+ referenceFormat: "scss-var",
75
+ });
76
+ }
77
+
67
78
  function index(facts: Fact[]): FactIndex {
68
79
  const ix = new FactIndex();
69
80
  ix.addMany(facts);
@@ -138,12 +149,14 @@ describe("§2 tokens/require-dual-fallback is Fragments-internal", () => {
138
149
  it("customer preset = 0, fragments preset = 1", () => {
139
150
  const customer = index([
140
151
  ...compileGlobalGovernanceFacts({ rules: customerDefaultRuleStates() }),
152
+ dualFallbackToken(),
141
153
  decl,
142
154
  ]);
143
155
  expect(runRules(customer)).toHaveLength(0);
144
156
 
145
157
  const fragments = index([
146
158
  ...compileGlobalGovernanceFacts({ rules: fragmentsPresetRuleStates() }),
159
+ dualFallbackToken(),
147
160
  decl,
148
161
  ]);
149
162
  const fragmentsFindings = runRules(fragments);
@@ -1,8 +1,8 @@
1
- import type { FactIndex } from "../facts/index.js";
1
+ import type { FactIndex, TokenDefinitionFact } from "../facts/index.js";
2
2
 
3
3
  import { makeFinding } from "./finding.js";
4
4
  import type { Finding } from "./types.js";
5
- import { cssVariableNames } from "./utils.js";
5
+ import { emitFix } from "./utils.js";
6
6
 
7
7
  export const RULE_ID = "tokens/require-dual-fallback";
8
8
  export const RULE_VERSION = "1";
@@ -22,7 +22,7 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
22
22
  const policy = ix.policy.ruleConfig(RULE_ID);
23
23
  if (!policy?.enabled) return [];
24
24
 
25
- const knownTokenVars = cssVariableNames(ix.tokens.list());
25
+ const tokensByCssVariable = indexTokensByCssVariable(ix.tokens.list());
26
26
  const findings: Finding[] = [];
27
27
 
28
28
  for (const decl of ix.byKind("style_declaration")) {
@@ -33,21 +33,43 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
33
33
  const tokenName = match[1];
34
34
  const hasFallback = match[2] !== undefined;
35
35
  if (hasFallback) continue;
36
- if (!knownTokenVars.has(tokenName) && !tokenName.startsWith("--fui-")) continue;
36
+ const token = tokensByCssVariable.get(tokenName);
37
+ if (!token) continue;
37
38
 
38
39
  const rawValue = match[0];
39
40
  const scssVar = tokenName.replace(/^--/, "$");
40
- const replacement = `var(${tokenName}, ${scssVar})`;
41
- const nextValue = decl.value.replace(rawValue, replacement);
41
+ const hasScssVariable = token.referenceFormat === "scss-var";
42
+ const nextValue = hasScssVariable
43
+ ? decl.value.replace(rawValue, `var(${tokenName}, ${scssVar})`)
44
+ : undefined;
45
+ const fixEmission = nextValue
46
+ ? emitFix(
47
+ {
48
+ kind: "replaceStyleValue",
49
+ title: `Add fallback for ${tokenName}`,
50
+ property: decl.property,
51
+ value: nextValue,
52
+ deterministic: true,
53
+ },
54
+ {
55
+ symbols: [tokenName, scssVar],
56
+ editKind: "replaceStyleValue",
57
+ valuePreserving: true,
58
+ },
59
+ ix
60
+ )
61
+ : undefined;
42
62
 
43
63
  findings.push(
44
64
  makeFinding({
45
65
  ruleId: RULE_ID,
46
66
  ruleVersion: RULE_VERSION,
47
67
  severity: policy.severity ?? "warn",
48
- message: `var(${tokenName}) is missing an SCSS fallback. Use var(${tokenName}, ${scssVar}).`,
68
+ message: hasScssVariable
69
+ ? `var(${tokenName}) is missing an SCSS fallback. Use var(${tokenName}, ${scssVar}).`
70
+ : `var(${tokenName}) is missing a fallback. No matching SCSS variable was found in the token source; add a fallback manually or define ${scssVar}.`,
49
71
  location: decl.location,
50
- evidence: ix.evidence([decl.id, policy.id]),
72
+ evidence: ix.evidence([decl.id, token.id, policy.id]),
51
73
  fingerprintIdentity: {
52
74
  file: decl.file,
53
75
  selector: decl.selector,
@@ -55,24 +77,39 @@ export function ruleTokensRequireDualFallback(ix: FactIndex): Finding[] {
55
77
  property: decl.property,
56
78
  tokenName,
57
79
  },
58
- fix: {
59
- kind: "replaceStyleValue",
60
- title: `Add fallback for ${tokenName}`,
61
- property: decl.property,
62
- value: nextValue,
63
- deterministic: true,
64
- },
80
+ fix: fixEmission?.fix,
65
81
  attributes: {
66
82
  property: decl.property,
67
83
  rawValue: decl.value,
68
84
  tokenName,
69
- suggestedValue: nextValue,
85
+ ...(nextValue ? { suggestedValue: nextValue } : {}),
86
+ ...(fixEmission?.downgradeReason
87
+ ? { downgradeReason: fixEmission.downgradeReason }
88
+ : {}),
70
89
  source: "css",
71
90
  },
72
- }),
91
+ })
73
92
  );
74
93
  }
75
94
  }
76
95
 
77
96
  return findings;
78
97
  }
98
+
99
+ function indexTokensByCssVariable(
100
+ tokens: readonly TokenDefinitionFact[]
101
+ ): Map<string, TokenDefinitionFact> {
102
+ const indexed = new Map<string, TokenDefinitionFact>();
103
+ for (const token of tokens) {
104
+ const cssVariable = token.name.startsWith("--")
105
+ ? token.name
106
+ : token.name.startsWith("$")
107
+ ? `--${token.name.slice(1)}`
108
+ : `--${token.name}`;
109
+ const existing = indexed.get(cssVariable);
110
+ if (!existing || token.referenceFormat === "scss-var") {
111
+ indexed.set(cssVariable, token);
112
+ }
113
+ }
114
+ return indexed;
115
+ }