@usefragments/core 1.0.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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/dist/chunk-243QYRUF.js +2145 -0
  3. package/dist/chunk-243QYRUF.js.map +1 -0
  4. package/dist/chunk-3LLRNCPX.js +111 -0
  5. package/dist/chunk-3LLRNCPX.js.map +1 -0
  6. package/dist/chunk-EIYNNS77.js +48 -0
  7. package/dist/chunk-EIYNNS77.js.map +1 -0
  8. package/dist/chunk-JNBFJ34I.js +326 -0
  9. package/dist/chunk-JNBFJ34I.js.map +1 -0
  10. package/dist/chunk-R3X7UBHU.js +228 -0
  11. package/dist/chunk-R3X7UBHU.js.map +1 -0
  12. package/dist/chunk-SH4KPIYH.js +393 -0
  13. package/dist/chunk-SH4KPIYH.js.map +1 -0
  14. package/dist/chunk-V4VQB57N.js +56 -0
  15. package/dist/chunk-V4VQB57N.js.map +1 -0
  16. package/dist/chunk-X34IA4LR.js +319 -0
  17. package/dist/chunk-X34IA4LR.js.map +1 -0
  18. package/dist/chunk-YF65VYRY.js +493 -0
  19. package/dist/chunk-YF65VYRY.js.map +1 -0
  20. package/dist/codes/index.d.ts +40 -0
  21. package/dist/codes/index.js +23 -0
  22. package/dist/codes/index.js.map +1 -0
  23. package/dist/compiled-types/index.d.ts +5 -0
  24. package/dist/compiled-types/index.js +1 -0
  25. package/dist/compiled-types/index.js.map +1 -0
  26. package/dist/generate/index.d.ts +47 -0
  27. package/dist/generate/index.js +11 -0
  28. package/dist/generate/index.js.map +1 -0
  29. package/dist/governance-BOa3KyiJ.d.ts +3279 -0
  30. package/dist/graph/index.d.ts +66 -0
  31. package/dist/graph/index.js +17 -0
  32. package/dist/graph/index.js.map +1 -0
  33. package/dist/index.d.ts +11388 -0
  34. package/dist/index.js +8711 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/package-identity.d.ts +42 -0
  37. package/dist/package-identity.js +23 -0
  38. package/dist/package-identity.js.map +1 -0
  39. package/dist/preview/index.d.ts +484 -0
  40. package/dist/preview/index.js +1129 -0
  41. package/dist/preview/index.js.map +1 -0
  42. package/dist/react-types.d.ts +22 -0
  43. package/dist/react-types.js +1 -0
  44. package/dist/react-types.js.map +1 -0
  45. package/dist/registry.d.ts +2367 -0
  46. package/dist/registry.js +62 -0
  47. package/dist/registry.js.map +1 -0
  48. package/dist/schemas/index.d.ts +2584 -0
  49. package/dist/schemas/index.js +55 -0
  50. package/dist/schemas/index.js.map +1 -0
  51. package/dist/severity.d.ts +18 -0
  52. package/dist/severity.js +27 -0
  53. package/dist/severity.js.map +1 -0
  54. package/dist/test-utils.d.ts +14 -0
  55. package/dist/test-utils.js +63 -0
  56. package/dist/test-utils.js.map +1 -0
  57. package/dist/topology/index.d.ts +48 -0
  58. package/dist/topology/index.js +7 -0
  59. package/dist/topology/index.js.map +1 -0
  60. package/dist/types-xJ2xyp_G.d.ts +119 -0
  61. package/package.json +120 -0
  62. package/src/__tests__/contract-parser.test.ts +614 -0
  63. package/src/__tests__/dtcg-generators.test.ts +439 -0
  64. package/src/__tests__/dtcg-parser.test.ts +884 -0
  65. package/src/__tests__/preview-runtime.test.tsx +111 -0
  66. package/src/__tests__/schema.test.ts +31 -0
  67. package/src/__tests__/severity.test.ts +34 -0
  68. package/src/__tests__/style-comparison.test.ts +662 -0
  69. package/src/__tests__/tokens-compat.test.ts +51 -0
  70. package/src/agent-format.test.ts +105 -0
  71. package/src/agent-format.ts +141 -0
  72. package/src/bundle.ts +149 -0
  73. package/src/codes/__tests__/codes.test.ts +73 -0
  74. package/src/codes/codes.ts +463 -0
  75. package/src/codes/index.ts +7 -0
  76. package/src/codes/source-finding-bridge.ts +113 -0
  77. package/src/compiled-types/index.ts +293 -0
  78. package/src/component-contract.ts +231 -0
  79. package/src/component-discovery.ts +80 -0
  80. package/src/composition.test.ts +262 -0
  81. package/src/composition.ts +318 -0
  82. package/src/config-paths.test.ts +78 -0
  83. package/src/config-paths.ts +47 -0
  84. package/src/config.ts +18 -0
  85. package/src/conform-prove.test.ts +274 -0
  86. package/src/conform-prove.ts +326 -0
  87. package/src/conform.ts +160 -0
  88. package/src/constants.ts +114 -0
  89. package/src/context.ts +2 -0
  90. package/src/contract/fixture.ts +110 -0
  91. package/src/contract/hash.test.ts +86 -0
  92. package/src/contract/hash.ts +195 -0
  93. package/src/contract/index.ts +43 -0
  94. package/src/contract/preimage.test.ts +254 -0
  95. package/src/contract/preimage.ts +365 -0
  96. package/src/contract/stamp.test.ts +100 -0
  97. package/src/contract/stamp.ts +105 -0
  98. package/src/contract-parser.ts +141 -0
  99. package/src/defineFragment.test.ts +257 -0
  100. package/src/defineFragment.ts +293 -0
  101. package/src/dtcg-generators.ts +324 -0
  102. package/src/dtcg-parser.ts +1 -0
  103. package/src/dtcg.ts +237 -0
  104. package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
  105. package/src/facts/builders.ts +835 -0
  106. package/src/facts/compile.ts +422 -0
  107. package/src/facts/fact-index.ts +394 -0
  108. package/src/facts/facts.test.ts +494 -0
  109. package/src/facts/ids.ts +125 -0
  110. package/src/facts/index.ts +119 -0
  111. package/src/facts/types.ts +614 -0
  112. package/src/figma.ts +263 -0
  113. package/src/fragment-types.ts +214 -0
  114. package/src/generate/generate.test.ts +594 -0
  115. package/src/generate/index.ts +438 -0
  116. package/src/governance-integrity.test.ts +186 -0
  117. package/src/governance-integrity.ts +282 -0
  118. package/src/governance.test.ts +300 -0
  119. package/src/governance.ts +605 -0
  120. package/src/governance.type-test.ts +75 -0
  121. package/src/graph/engine.ts +446 -0
  122. package/src/graph/graph.test.ts +746 -0
  123. package/src/graph/index.ts +32 -0
  124. package/src/graph/serialization.ts +55 -0
  125. package/src/graph/types.ts +178 -0
  126. package/src/index.ts +891 -0
  127. package/src/local-canonical.ts +102 -0
  128. package/src/mcp-snapshot.ts +222 -0
  129. package/src/package-identity-match.ts +33 -0
  130. package/src/package-identity.generated.ts +233 -0
  131. package/src/package-identity.test.ts +99 -0
  132. package/src/package-identity.ts +157 -0
  133. package/src/performance-presets.ts +142 -0
  134. package/src/preview/comparison-artifact.test.ts +228 -0
  135. package/src/preview/comparison-artifact.ts +181 -0
  136. package/src/preview/comparison-context.ts +93 -0
  137. package/src/preview/comparison-evidence.ts +112 -0
  138. package/src/preview/comparison-format.ts +21 -0
  139. package/src/preview/comparison-presentation.ts +187 -0
  140. package/src/preview/comparison-references.test.ts +430 -0
  141. package/src/preview/comparison-references.ts +72 -0
  142. package/src/preview/comparison-score.ts +59 -0
  143. package/src/preview/comparison-styles.ts +197 -0
  144. package/src/preview/comparison.test.ts +559 -0
  145. package/src/preview/comparison.ts +488 -0
  146. package/src/preview/index.ts +10 -0
  147. package/src/preview/types.ts +309 -0
  148. package/src/preview/validation.test.ts +290 -0
  149. package/src/preview/validation.ts +356 -0
  150. package/src/preview-runtime.tsx +144 -0
  151. package/src/raw-html-canonical.test.ts +41 -0
  152. package/src/raw-html-canonical.ts +179 -0
  153. package/src/react-types.ts +21 -0
  154. package/src/registry-install-plan.ts +344 -0
  155. package/src/registry.test.ts +245 -0
  156. package/src/registry.ts +369 -0
  157. package/src/rules/a11y-required-accessible-name.ts +93 -0
  158. package/src/rules/color-math.test.ts +72 -0
  159. package/src/rules/color-math.ts +192 -0
  160. package/src/rules/components-forbidden-prop-value.ts +108 -0
  161. package/src/rules/components-prefer-library.ts +943 -0
  162. package/src/rules/components-unknown-prop.ts +94 -0
  163. package/src/rules/composition-pattern-schema.ts +125 -0
  164. package/src/rules/composition-pattern.test.ts +320 -0
  165. package/src/rules/composition-pattern.ts +271 -0
  166. package/src/rules/emit-gate.test.ts +42 -0
  167. package/src/rules/emit-gate.ts +60 -0
  168. package/src/rules/finding.ts +54 -0
  169. package/src/rules/index.ts +235 -0
  170. package/src/rules/jsx-preferred-component.ts +68 -0
  171. package/src/rules/jsx-preferred-import-path.ts +67 -0
  172. package/src/rules/presets.ts +57 -0
  173. package/src/rules/props-invalid-value.ts +115 -0
  174. package/src/rules/rules.test.ts +3636 -0
  175. package/src/rules/spacing-resolution.ts +184 -0
  176. package/src/rules/styles-no-raw-color.ts +329 -0
  177. package/src/rules/styles-no-raw-dimensions.ts +357 -0
  178. package/src/rules/styles-no-raw-spacing.ts +236 -0
  179. package/src/rules/styles-no-raw-typography.ts +237 -0
  180. package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
  181. package/src/rules/tailwind-arbitrary-color.ts +93 -0
  182. package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
  183. package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
  184. package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
  185. package/src/rules/tailwind-forbidden-palette.ts +107 -0
  186. package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
  187. package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
  188. package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
  189. package/src/rules/tailwind-raw-color-via-token.ts +82 -0
  190. package/src/rules/tailwind-rule-utils.ts +106 -0
  191. package/src/rules/tailwind-unknown-class.test.ts +157 -0
  192. package/src/rules/tailwind-unknown-class.ts +121 -0
  193. package/src/rules/taxonomy.test.ts +238 -0
  194. package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
  195. package/src/rules/tiers.ts +111 -0
  196. package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
  197. package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
  198. package/src/rules/tokens-require-dual-fallback.ts +78 -0
  199. package/src/rules/types.ts +27 -0
  200. package/src/rules/utils.test.ts +110 -0
  201. package/src/rules/utils.ts +599 -0
  202. package/src/schema.ts +427 -0
  203. package/src/schemas/index.ts +275 -0
  204. package/src/schemas/schemas.test.ts +124 -0
  205. package/src/severity.ts +54 -0
  206. package/src/storyAdapter.test.ts +571 -0
  207. package/src/storyAdapter.ts +761 -0
  208. package/src/storyFilters.test.ts +350 -0
  209. package/src/storyFilters.ts +253 -0
  210. package/src/storybook-csf.ts +11 -0
  211. package/src/style-comparison.ts +423 -0
  212. package/src/test-utils.ts +68 -0
  213. package/src/token-parser.ts +2 -0
  214. package/src/token-types.ts +287 -0
  215. package/src/tokens/categories.ts +131 -0
  216. package/src/tokens/color.ts +193 -0
  217. package/src/tokens/design-token-parser.test.ts +159 -0
  218. package/src/tokens/design-token-parser.ts +402 -0
  219. package/src/tokens/dtcg.ts +428 -0
  220. package/src/tokens/index.ts +130 -0
  221. package/src/tokens/lookup.ts +19 -0
  222. package/src/tokens/scss.ts +171 -0
  223. package/src/tokens/tailwind-v4.ts +187 -0
  224. package/src/tokens/types.ts +43 -0
  225. package/src/topology/index.ts +9 -0
  226. package/src/topology/resolve-area.test.ts +174 -0
  227. package/src/topology/resolve-area.ts +215 -0
  228. package/src/types.ts +1057 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * `styles/no-raw-dimensions` — fires when a length-bearing CSS declaration or
3
+ * JSX inline style uses a raw dimension literal (`16px`, `1rem`, …) on a
4
+ * property covered by the global `style.rawDimensions.forbid` policy.
5
+ *
6
+ * Suggestion strength is tiered, mirroring `styles/no-raw-spacing`:
7
+ *
8
+ * - **exact token match** — the raw value EXACTLY equals a tenant dimension
9
+ * token of the same unit (units are never cross-converted — matching `1rem`
10
+ * to a `16px` token would assume a root font size we cannot guarantee). Emits
11
+ * a value-preserving, deterministic `var(--token)` fix.
12
+ * - **off-scale single value** — `border-radius: 10px` when the tenant has no
13
+ * `10px` token. Names the NEAREST token of the same unit via `suggestedToken`
14
+ * and the message, but attaches NO fix — snapping changes the rendered value,
15
+ * so it must stay a suggestion the author confirms, never an auto-rewrite.
16
+ * Same-unit only: a `px` value never snaps to a `rem` token, keeping the
17
+ * suggestion free of an assumed root-font-size.
18
+ * - **no nearby token** (or a multi-value shorthand off-scale) — flagged with no
19
+ * fix and no suggestion, leaving the choice to the author.
20
+ *
21
+ * Nearest snapping adds NO new findings and changes NO rendered values: an
22
+ * off-scale dimension already fires under this policy; the snap only enriches
23
+ * that finding with a `suggestedToken` hint.
24
+ *
25
+ * The policy is intentionally opt-in (no preset enables it): a plain
26
+ * `fragments govern scan` never flags raw dimensions. Only callers that supply
27
+ * a `style.rawDimensions.forbid` record (e.g. the conform tool) activate it.
28
+ */
29
+
30
+ import type { FactId, FactIndex, TokenDefinitionFact } from "../facts/index.js";
31
+
32
+ import { makeFinding } from "./finding.js";
33
+ import type { Finding, FindingReplaceStyleValueFix } from "./types.js";
34
+ import { indexComponentByNodeId, parseLengthValue, readUsageNode } from "./utils.js";
35
+
36
+ export const RULE_ID = "styles/no-raw-dimensions";
37
+ export const RULE_VERSION = "1";
38
+
39
+ interface DimensionFixMatch {
40
+ tokens: TokenDefinitionFact[];
41
+ fixValue: string;
42
+ suggestedToken: string;
43
+ }
44
+
45
+ /**
46
+ * How a raw dimension resolves against the tenant's tokens:
47
+ * - `exact` — value-preserving token swap (deterministic, auto-applicable fix)
48
+ * - `nearest` — closest same-unit token, surfaced as a `suggestedToken` hint with
49
+ * NO fix object: snapping changes the rendered value, and the conform engine
50
+ * auto-applies any token-backed `replaceStyleValue` fix, so attaching one would
51
+ * silently rewrite the value. The suggestion rides on the message + attribute.
52
+ * - `raw` — flagged, but no token to suggest (no fix)
53
+ */
54
+ type DimensionResolution =
55
+ | { kind: "exact"; tokens: TokenDefinitionFact[]; fixValue: string; suggestedToken: string }
56
+ | { kind: "nearest"; token: TokenDefinitionFact; suggestedToken: string }
57
+ | { kind: "raw" };
58
+
59
+ export function ruleStylesNoRawDimensions(ix: FactIndex): Finding[] {
60
+ const policy = ix.policy.rawDimensionPolicy();
61
+ if (!policy) return [];
62
+
63
+ const appliesTo = new Set(policy.appliesTo);
64
+ const spacingTokens = ix.tokens.byCategory("spacing");
65
+ const radiusTokens = ix.tokens.byCategory("radius");
66
+ const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable";
67
+ const findings: Finding[] = [];
68
+
69
+ for (const decl of ix.byKind("style_declaration")) {
70
+ const property = toKebabCase(decl.property);
71
+ if (!appliesTo.has(property)) continue;
72
+
73
+ const tokens = tokensForProperty(property, spacingTokens, radiusTokens);
74
+ const resolution = resolveDimension(decl.value, tokens);
75
+ if (!resolution) continue;
76
+ const tokenIds = resolutionTokenIds(resolution);
77
+ const evidenceIds = tokenIds.length ? [decl.id, policy.id, ...tokenIds] : [decl.id, policy.id];
78
+
79
+ findings.push(
80
+ makeFinding({
81
+ ruleId: RULE_ID,
82
+ ruleVersion: RULE_VERSION,
83
+ severity: policy.severity,
84
+ message: dimensionMessage(decl.value, decl.property, preferLabel, resolution, false),
85
+ location: decl.location,
86
+ evidence: ix.evidence(evidenceIds),
87
+ fingerprintIdentity: {
88
+ source: "style_declaration",
89
+ file: decl.file,
90
+ selector: decl.selector,
91
+ declarationPath: decl.declarationPath,
92
+ property: decl.property,
93
+ value: decl.value,
94
+ },
95
+ fix: dimensionFix(decl.property, resolution),
96
+ attributes: {
97
+ property: decl.property,
98
+ rawValue: decl.value,
99
+ source: "css",
100
+ suggestedToken: resolutionSuggestedToken(resolution),
101
+ },
102
+ })
103
+ );
104
+ }
105
+
106
+ const componentByNode = indexComponentByNodeId(ix);
107
+ for (const inline of ix.byKind("usage_inline_style")) {
108
+ if (inline.valueKind === "css-variable") continue;
109
+ const property = toKebabCase(inline.property);
110
+ if (!appliesTo.has(property)) continue;
111
+
112
+ const node = readUsageNode(ix, inline.nodeId);
113
+ if (!node) continue;
114
+
115
+ const componentEvidenceId = componentByNode.get(node.id)?.id;
116
+ const tokens = tokensForProperty(property, spacingTokens, radiusTokens);
117
+ const resolution = resolveDimension(inline.value, tokens);
118
+ if (!resolution) continue;
119
+ const baseEvidence = componentEvidenceId
120
+ ? [node.id, componentEvidenceId, inline.id, policy.id]
121
+ : [node.id, inline.id, policy.id];
122
+ const tokenIds = resolutionTokenIds(resolution);
123
+ const evidenceIds = tokenIds.length ? [...baseEvidence, ...tokenIds] : baseEvidence;
124
+
125
+ findings.push(
126
+ makeFinding({
127
+ ruleId: RULE_ID,
128
+ ruleVersion: RULE_VERSION,
129
+ severity: policy.severity,
130
+ message: dimensionMessage(inline.value, inline.property, preferLabel, resolution, true),
131
+ location: node.location,
132
+ evidence: ix.evidence(evidenceIds),
133
+ fingerprintIdentity: {
134
+ source: "usage_inline_style",
135
+ file: node.file,
136
+ nodePath: node.nodePath,
137
+ element: node.element,
138
+ property: inline.property,
139
+ value: inline.value,
140
+ },
141
+ fix: dimensionFix(inline.property, resolution),
142
+ attributes: {
143
+ property: inline.property,
144
+ rawValue: inline.value,
145
+ source: "jsx",
146
+ suggestedToken: resolutionSuggestedToken(resolution),
147
+ },
148
+ })
149
+ );
150
+ }
151
+
152
+ return findings;
153
+ }
154
+
155
+ /** Normalize a JSX camelCase style key (`paddingTop`) to CSS kebab (`padding-top`). */
156
+ function toKebabCase(property: string): string {
157
+ return property.replace(/([A-Z])/g, "-$1").toLowerCase();
158
+ }
159
+
160
+ function tokensForProperty(
161
+ property: string,
162
+ spacingTokens: readonly TokenDefinitionFact[],
163
+ radiusTokens: readonly TokenDefinitionFact[]
164
+ ): readonly TokenDefinitionFact[] {
165
+ return property.includes("radius") ? radiusTokens : spacingTokens;
166
+ }
167
+
168
+ /**
169
+ * Resolve a raw dimension against the candidate tokens, preferring an exact
170
+ * (value-preserving) match, then a nearest same-unit snap, then no token.
171
+ * Returns `null` when the value is neither a token match nor a raw dimension —
172
+ * i.e. nothing to flag.
173
+ */
174
+ function resolveDimension(
175
+ raw: string,
176
+ tokens: readonly TokenDefinitionFact[]
177
+ ): DimensionResolution | null {
178
+ const exact = findDimensionFixMatch(raw, tokens);
179
+ if (exact) {
180
+ return {
181
+ kind: "exact",
182
+ tokens: exact.tokens,
183
+ fixValue: exact.fixValue,
184
+ suggestedToken: exact.suggestedToken,
185
+ };
186
+ }
187
+ if (!isRawDimensionValue(raw)) return null;
188
+
189
+ const nearest = findNearestDimensionToken(raw, tokens);
190
+ if (nearest) return { kind: "nearest", ...nearest };
191
+ return { kind: "raw" };
192
+ }
193
+
194
+ function resolutionTokenIds(resolution: DimensionResolution): FactId[] {
195
+ if (resolution.kind === "exact") return resolution.tokens.map((token) => token.id);
196
+ if (resolution.kind === "nearest") return [resolution.token.id];
197
+ return [];
198
+ }
199
+
200
+ function resolutionSuggestedToken(resolution: DimensionResolution): string | undefined {
201
+ if (resolution.kind === "exact") return resolution.suggestedToken;
202
+ if (resolution.kind === "nearest") return resolution.suggestedToken;
203
+ return undefined;
204
+ }
205
+
206
+ function dimensionMessage(
207
+ rawValue: string,
208
+ property: string,
209
+ preferLabel: string,
210
+ resolution: DimensionResolution,
211
+ inline: boolean
212
+ ): string {
213
+ const where = inline ? `inline \`${property}\`` : `\`${property}\``;
214
+ if (resolution.kind === "nearest") {
215
+ return `Raw dimension ${rawValue} on ${where}. Closest token is \`${resolution.suggestedToken}\` (${resolution.token.value}); use a ${preferLabel} instead.`;
216
+ }
217
+ return `Raw dimension ${rawValue} on ${where}. Use a ${preferLabel} instead.`;
218
+ }
219
+
220
+ function dimensionFix(
221
+ property: string,
222
+ resolution: DimensionResolution
223
+ ): FindingReplaceStyleValueFix | undefined {
224
+ 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
+ };
232
+ }
233
+ // `nearest` deliberately emits NO fix — a value-changing snap must stay a
234
+ // suggestion (see DimensionResolution); only the exact, value-preserving swap
235
+ // is safe to auto-apply.
236
+ return undefined;
237
+ }
238
+
239
+ /**
240
+ * Find a dimension token whose value matches the raw literal exactly, compared
241
+ * in canonical px. No fuzzy/nearest matching — only value-preserving swaps earn
242
+ * an exact match.
243
+ */
244
+ function findDimensionMatch(
245
+ raw: string,
246
+ tokens: readonly TokenDefinitionFact[]
247
+ ): TokenDefinitionFact | null {
248
+ const target = normalizeDimension(raw);
249
+ if (target === null) return null;
250
+ for (const token of tokens) {
251
+ if (normalizeDimension(token.value) === target) return token;
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function findDimensionFixMatch(
257
+ raw: string,
258
+ tokens: readonly TokenDefinitionFact[]
259
+ ): DimensionFixMatch | null {
260
+ const single = findDimensionMatch(raw, tokens);
261
+ if (single) {
262
+ return {
263
+ tokens: [single],
264
+ fixValue: tokenVar(single),
265
+ suggestedToken: single.name,
266
+ };
267
+ }
268
+
269
+ const parts = raw.trim().split(/\s+/);
270
+ if (parts.length < 2 || parts.length > 4) return null;
271
+
272
+ const matches: TokenDefinitionFact[] = [];
273
+ for (const part of parts) {
274
+ const match = findDimensionMatch(part, tokens);
275
+ if (!match) return null;
276
+ matches.push(match);
277
+ }
278
+
279
+ return {
280
+ tokens: matches,
281
+ fixValue: matches.map(tokenVar).join(" "),
282
+ suggestedToken: matches.map((token) => token.name).join(" "),
283
+ };
284
+ }
285
+
286
+ /**
287
+ * For an off-scale SINGLE dimension value, find the nearest token of the SAME
288
+ * unit. Same-unit only keeps the suggestion value-safe: matching a `px` value
289
+ * to a `rem` token would assume a root font size we cannot guarantee. A non-zero
290
+ * value never snaps to a `0` token (that silently removes the dimension), and a
291
+ * multi-value shorthand returns null (no single nearest target).
292
+ */
293
+ function findNearestDimensionToken(
294
+ raw: string,
295
+ tokens: readonly TokenDefinitionFact[]
296
+ ): { token: TokenDefinitionFact; suggestedToken: string } | null {
297
+ const parsed = parseLengthValue(raw);
298
+ if (!parsed) return null;
299
+ const rawUnit = parsed.unit ?? "px";
300
+
301
+ let best: { token: TokenDefinitionFact; value: number } | null = null;
302
+ for (const token of tokens) {
303
+ const tokenLength = parseLengthValue(token.value);
304
+ if (!tokenLength) continue;
305
+ const tokenUnit = tokenLength.unit ?? "px";
306
+ if (tokenUnit !== rawUnit) continue;
307
+ if (tokenLength.value === parsed.value) continue; // exact handled elsewhere
308
+ if (tokenLength.value === 0 && parsed.value !== 0) continue; // never snap to 0
309
+ if (best === null) {
310
+ best = { token, value: tokenLength.value };
311
+ continue;
312
+ }
313
+ const delta = Math.abs(parsed.value - tokenLength.value);
314
+ const bestDelta = Math.abs(parsed.value - best.value);
315
+ // Ties break toward the smaller token value, then first-seen, for stability.
316
+ if (delta < bestDelta || (delta === bestDelta && tokenLength.value < best.value)) {
317
+ best = { token, value: tokenLength.value };
318
+ }
319
+ }
320
+
321
+ if (!best) return null;
322
+ return {
323
+ token: best.token,
324
+ suggestedToken: best.token.name,
325
+ };
326
+ }
327
+
328
+ function isRawDimensionValue(raw: string): boolean {
329
+ if (normalizeDimension(raw) !== null) return true;
330
+ const parts = raw.trim().split(/\s+/);
331
+ return (
332
+ parts.length >= 2 &&
333
+ parts.length <= 4 &&
334
+ parts.every((part) => normalizeDimension(part) !== null)
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Canonical match key `${amount}${unit}` (unitless treated as px, for React
340
+ * inline numerics and `0`). Units are intentionally NOT cross-converted:
341
+ * matching `1rem` to a `16px` token would assume a 16px root font size we
342
+ * cannot guarantee, so a unit mismatch never matches. This keeps every emitted
343
+ * fix value-preserving.
344
+ */
345
+ function normalizeDimension(value: string): string | null {
346
+ const parsed = parseLengthValue(value);
347
+ if (!parsed) return null;
348
+ return `${parsed.value}${parsed.unit ?? "px"}`;
349
+ }
350
+
351
+ function tokenVar(token: TokenDefinitionFact): string {
352
+ if (token.name.startsWith("$")) return token.name;
353
+ const cssVarName = token.name.startsWith("--")
354
+ ? token.name
355
+ : `--${token.name.replace(/\./g, "-")}`;
356
+ return `var(${cssVarName})`;
357
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * `styles/no-raw-spacing` — fires when a length-bearing CSS declaration or
3
+ * JSX inline style on a scaled property uses a raw spacing literal instead of
4
+ * a design token.
5
+ *
6
+ * The rule needs both a `style.property.scale` policy (which property is
7
+ * scaled, and which scale name backs it) and a matching set of `scale_value`
8
+ * facts (the allowed numbers). If either is missing the rule is silent for
9
+ * that property.
10
+ *
11
+ * Two violation shapes, mirroring `styles/no-raw-color`:
12
+ *
13
+ * - **off the scale** — `padding: 13px` on a [0,4,8,12,16] scale. Flagged; the
14
+ * suggested fix snaps to the nearest scale value (as the matching token when
15
+ * one exists, else the raw grid value). Because snapping *changes* the
16
+ * rendered value, the fix is a suggestion (non-deterministic) — never applied
17
+ * silently.
18
+ * - **on the scale but a raw literal that equals a token** — `padding: 16px`
19
+ * where `--space-md` is `16px`. Flagged; the fix swaps in `var(--space-md)`.
20
+ * This is value-preserving, so it is a deterministic, auto-applicable fix.
21
+ * (Only fires when a matching spacing token exists — a pure-grid value with no
22
+ * named token, like `0`, stays silent.)
23
+ *
24
+ * `padding: var(--space-3)` → silent (token reference). `padding: 1.5rem` on a
25
+ * px scale → normalized through the configured root font size before matching.
26
+ */
27
+
28
+ import type {
29
+ FactId,
30
+ FactIndex,
31
+ ScaleFact,
32
+ StyleDeclarationFact,
33
+ UsageComponentFact,
34
+ UsageInlineStyleFact,
35
+ } from "../facts/index.js";
36
+
37
+ import { makeFinding } from "./finding.js";
38
+ import {
39
+ buildSpacingTokenLookup,
40
+ resolveSpacingValue,
41
+ type CheckedSpacingValue,
42
+ type SpacingTokenLookup,
43
+ } from "./spacing-resolution.js";
44
+ import type { Finding } from "./types.js";
45
+ import { indexComponentByNodeId, readUsageNode } from "./utils.js";
46
+
47
+ export const RULE_ID = "styles/no-raw-spacing";
48
+ export const RULE_VERSION = "1";
49
+
50
+ export function ruleStylesNoRawSpacing(ix: FactIndex): Finding[] {
51
+ const findings: Finding[] = [];
52
+
53
+ // Token lookups are scale-relative (token values normalize to the scale's
54
+ // unit), so cache one per scale name to avoid rebuilding per declaration.
55
+ const lookupCache = new Map<string, SpacingTokenLookup>();
56
+ const lookupFor = (scale: ScaleFact): SpacingTokenLookup => {
57
+ let lookup = lookupCache.get(scale.name);
58
+ if (!lookup) {
59
+ lookup = buildSpacingTokenLookup(ix.tokens.byCategory("spacing"), scale);
60
+ lookupCache.set(scale.name, lookup);
61
+ }
62
+ return lookup;
63
+ };
64
+
65
+ for (const decl of ix.byKind("style_declaration")) {
66
+ const finding = checkDeclaration(ix, decl, lookupFor);
67
+ if (finding) findings.push(finding);
68
+ }
69
+
70
+ const componentByNode = indexComponentByNodeId(ix);
71
+ for (const inline of ix.byKind("usage_inline_style")) {
72
+ const finding = checkInlineStyle(ix, inline, componentByNode, lookupFor);
73
+ if (finding) findings.push(finding);
74
+ }
75
+
76
+ return findings;
77
+ }
78
+
79
+ /**
80
+ * Inline style property names arrive camelCase (`marginTop`); the policy keys
81
+ * spacing scales by CSS property (`margin-top`). Normalize so longhand inline
82
+ * props resolve their scale the same way the CSS file path does.
83
+ */
84
+ function cssPropertyName(property: string): string {
85
+ return property.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
86
+ }
87
+
88
+ function checkDeclaration(
89
+ ix: FactIndex,
90
+ decl: StyleDeclarationFact,
91
+ lookupFor: (scale: ScaleFact) => SpacingTokenLookup
92
+ ): Finding | null {
93
+ const policy = ix.policy.propertyScale(decl.property);
94
+ if (!policy) return null;
95
+ const scale = ix.policy.scale(policy.scale);
96
+ if (!scale) return null;
97
+ const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value);
98
+ if (allowed.length === 0) return null;
99
+
100
+ const checked = resolveSpacingValue({
101
+ raw: decl.value,
102
+ allowed,
103
+ scale,
104
+ tokens: lookupFor(scale),
105
+ });
106
+ if (!checked) return null;
107
+
108
+ return makeFinding({
109
+ ruleId: RULE_ID,
110
+ ruleVersion: RULE_VERSION,
111
+ severity: policy.severity,
112
+ message: spacingMessage(decl.property, decl.value, allowed, scale.unit, checked),
113
+ location: decl.location,
114
+ evidence: ix.evidence([decl.id, policy.id, scale.id]),
115
+ fingerprintIdentity: {
116
+ source: "style_declaration",
117
+ file: decl.file,
118
+ selector: decl.selector,
119
+ declarationPath: decl.declarationPath,
120
+ property: decl.property,
121
+ value: decl.value,
122
+ },
123
+ fix: buildSpacingFix(decl.property, checked),
124
+ attributes: {
125
+ property: decl.property,
126
+ rawValue: decl.value,
127
+ scale: scale.name,
128
+ allowed,
129
+ suggestedValue: checked.suggestedValue,
130
+ matchedToken: checked.matchedToken,
131
+ suggestedToken: checked.suggestedToken,
132
+ reason: checked.reason,
133
+ normalizedValue: checked.normalizedValue,
134
+ normalizedUnit: scale.unit,
135
+ assumedRootFontSizePx: checked.assumedRootFontSizePx,
136
+ assumedEmBasePx: checked.assumedEmBasePx,
137
+ source: "css",
138
+ },
139
+ });
140
+ }
141
+
142
+ function checkInlineStyle(
143
+ ix: FactIndex,
144
+ inline: UsageInlineStyleFact,
145
+ componentByNode: Map<FactId, UsageComponentFact>,
146
+ lookupFor: (scale: ScaleFact) => SpacingTokenLookup
147
+ ): Finding | null {
148
+ const policy = ix.policy.propertyScale(cssPropertyName(inline.property));
149
+ if (!policy) return null;
150
+ const scale = ix.policy.scale(policy.scale);
151
+ if (!scale) return null;
152
+ const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value);
153
+ if (allowed.length === 0) return null;
154
+
155
+ if (inline.valueKind === "css-variable") return null;
156
+
157
+ const checked = resolveSpacingValue({
158
+ raw: inline.value,
159
+ allowed,
160
+ scale,
161
+ tokens: lookupFor(scale),
162
+ bareNumberFix: inline.valueKind === "number",
163
+ });
164
+ if (!checked) return null;
165
+
166
+ const node = readUsageNode(ix, inline.nodeId);
167
+ if (!node) return null;
168
+
169
+ const componentEvidenceId = componentByNode.get(node.id)?.id;
170
+ const evidenceIds = componentEvidenceId
171
+ ? [node.id, componentEvidenceId, inline.id, policy.id, scale.id]
172
+ : [node.id, inline.id, policy.id, scale.id];
173
+
174
+ return makeFinding({
175
+ ruleId: RULE_ID,
176
+ ruleVersion: RULE_VERSION,
177
+ severity: policy.severity,
178
+ message: spacingMessage(inline.property, inline.value, allowed, scale.unit, checked),
179
+ location: node.location,
180
+ evidence: ix.evidence(evidenceIds),
181
+ fingerprintIdentity: {
182
+ source: "usage_inline_style",
183
+ file: node.file,
184
+ nodePath: node.nodePath,
185
+ element: node.element,
186
+ property: inline.property,
187
+ value: inline.value,
188
+ },
189
+ fix: buildSpacingFix(inline.property, checked),
190
+ attributes: {
191
+ property: inline.property,
192
+ rawValue: inline.value,
193
+ scale: scale.name,
194
+ allowed,
195
+ suggestedValue: checked.suggestedValue,
196
+ matchedToken: checked.matchedToken,
197
+ suggestedToken: checked.suggestedToken,
198
+ reason: checked.reason,
199
+ normalizedValue: checked.normalizedValue,
200
+ normalizedUnit: scale.unit,
201
+ assumedRootFontSizePx: checked.assumedRootFontSizePx,
202
+ assumedEmBasePx: checked.assumedEmBasePx,
203
+ source: "jsx",
204
+ },
205
+ });
206
+ }
207
+
208
+ function buildSpacingFix(property: string, checked: CheckedSpacingValue) {
209
+ 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
+ };
217
+ }
218
+
219
+ function spacingMessage(
220
+ property: string,
221
+ value: string,
222
+ allowed: number[],
223
+ unit: "px" | "rem",
224
+ checked: CheckedSpacingValue
225
+ ): string {
226
+ if (checked.reason === "token-equivalent" && checked.matchedToken) {
227
+ return `\`${property}: ${value}\` is a raw literal that matches token \`${checked.matchedToken}\`. Reference the token instead.`;
228
+ }
229
+ const sample = allowed
230
+ .slice()
231
+ .sort((a, b) => a - b)
232
+ .slice(0, 6);
233
+ const suffix = `${sample.join(unit + ", ")}${unit}`;
234
+ const ellipsis = allowed.length > sample.length ? ", …" : "";
235
+ return `\`${property}: ${value}\` is not on the spacing scale. Allowed: ${suffix}${ellipsis}.`;
236
+ }