@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,237 @@
1
+ /**
2
+ * `styles/no-raw-typography` — fires when a `font-size` declaration or JSX
3
+ * inline `fontSize` uses a raw length literal instead of a typography token.
4
+ *
5
+ * Consumes the `style.fontSize.mustMatchScale` policy (a `style_font_size_scale`
6
+ * fact) plus a backing scale. Without both it is silent — typography is only
7
+ * enforced once a project actually has a font-size scale (derived from its
8
+ * detected typography tokens). This closes the teach-vs-enforce gap where the
9
+ * agent context advertises typography tokens but nothing flags off-scale text.
10
+ *
11
+ * Behaviour mirrors `styles/no-raw-spacing`:
12
+ * - off the scale (`font-size: 13px` on [12,14,16]) → suggestion, never applied
13
+ * silently (snapping changes rendered size);
14
+ * - on the scale but a raw literal equal to a token (`font-size: 16px` where
15
+ * `--font-size-md` is `16px`) → deterministic, value-preserving token swap.
16
+ */
17
+
18
+ import type {
19
+ FactId,
20
+ FactIndex,
21
+ ScaleFact,
22
+ StyleDeclarationFact,
23
+ TokenDefinitionFact,
24
+ UsageComponentFact,
25
+ UsageInlineStyleFact,
26
+ } from "../facts/index.js";
27
+
28
+ import { makeFinding } from "./finding.js";
29
+ import type { Finding } from "./types.js";
30
+ import {
31
+ indexComponentByNodeId,
32
+ isApplicableTokenReference,
33
+ matchesScale,
34
+ nearestSignedScaleValue,
35
+ normalizeLengthForScale,
36
+ parseLengthValue,
37
+ readUsageNode,
38
+ tokenReference,
39
+ } from "./utils.js";
40
+
41
+ export const RULE_ID = "styles/no-raw-typography";
42
+ export const RULE_VERSION = "1";
43
+
44
+ const FONT_SIZE_PROPERTIES = new Set(["font-size", "fontsize"]);
45
+
46
+ export function ruleStylesNoRawTypography(ix: FactIndex): Finding[] {
47
+ const policy = ix.policy.fontSizeScale();
48
+ if (!policy) return [];
49
+ const scale = ix.policy.scale(policy.scale);
50
+ if (!scale) return [];
51
+ const allowed = ix.policy.scaleValues(policy.scale).map((v) => v.value);
52
+ if (allowed.length === 0) return [];
53
+
54
+ const tokens = buildTypographyTokenLookup(ix, scale);
55
+ const findings: Finding[] = [];
56
+
57
+ for (const decl of ix.byKind("style_declaration")) {
58
+ if (decl.property.toLowerCase() !== "font-size") continue;
59
+ const checked = checkFontSize(decl.value, allowed, scale, tokens);
60
+ if (!checked) continue;
61
+ findings.push(
62
+ makeFinding({
63
+ ruleId: RULE_ID,
64
+ ruleVersion: RULE_VERSION,
65
+ severity: policy.severity,
66
+ message: typographyMessage(decl.property, decl.value, allowed, scale.unit, checked),
67
+ location: decl.location,
68
+ evidence: ix.evidence([decl.id, policy.id, scale.id]),
69
+ fingerprintIdentity: {
70
+ source: "style_declaration",
71
+ file: decl.file,
72
+ selector: decl.selector,
73
+ declarationPath: decl.declarationPath,
74
+ property: decl.property,
75
+ value: decl.value,
76
+ },
77
+ fix: buildFix(decl.property, checked),
78
+ attributes: {
79
+ property: decl.property,
80
+ rawValue: decl.value,
81
+ scale: scale.name,
82
+ allowed,
83
+ suggestedValue: checked.suggestedValue,
84
+ matchedToken: checked.matchedToken,
85
+ source: "css",
86
+ },
87
+ })
88
+ );
89
+ }
90
+
91
+ const componentByNode = indexComponentByNodeId(ix);
92
+ for (const inline of ix.byKind("usage_inline_style")) {
93
+ if (inline.valueKind === "css-variable") continue;
94
+ if (!FONT_SIZE_PROPERTIES.has(inline.property.toLowerCase())) continue;
95
+ const checked = checkFontSize(inline.value, allowed, scale, tokens, inline.valueKind === "number");
96
+ if (!checked) continue;
97
+ const node = readUsageNode(ix, inline.nodeId);
98
+ if (!node) continue;
99
+ const componentEvidenceId = componentByNode.get(node.id)?.id;
100
+ const evidenceIds = componentEvidenceId
101
+ ? [node.id, componentEvidenceId, inline.id, policy.id, scale.id]
102
+ : [node.id, inline.id, policy.id, scale.id];
103
+ findings.push(
104
+ makeFinding({
105
+ ruleId: RULE_ID,
106
+ ruleVersion: RULE_VERSION,
107
+ severity: policy.severity,
108
+ message: typographyMessage(inline.property, inline.value, allowed, scale.unit, checked),
109
+ location: node.location,
110
+ evidence: ix.evidence(evidenceIds),
111
+ fingerprintIdentity: {
112
+ source: "usage_inline_style",
113
+ file: node.file,
114
+ nodePath: node.nodePath,
115
+ element: node.element,
116
+ property: inline.property,
117
+ value: inline.value,
118
+ },
119
+ fix: buildFix(inline.property, checked),
120
+ attributes: {
121
+ property: inline.property,
122
+ rawValue: inline.value,
123
+ scale: scale.name,
124
+ allowed,
125
+ suggestedValue: checked.suggestedValue,
126
+ matchedToken: checked.matchedToken,
127
+ source: "jsx",
128
+ },
129
+ })
130
+ );
131
+ }
132
+
133
+ return findings;
134
+ }
135
+
136
+ function buildTypographyTokenLookup(
137
+ ix: FactIndex,
138
+ scale: ScaleFact
139
+ ): Map<number, TokenDefinitionFact> {
140
+ const out = new Map<number, TokenDefinitionFact>();
141
+ for (const token of ix.tokens.byCategory("typography")) {
142
+ const parsed = parseLengthValue(token.value);
143
+ if (!parsed || parsed.unit === null) continue;
144
+ const normalized = normalizeLengthForScale(parsed, scale);
145
+ if (normalized === null) continue;
146
+ const key = Math.abs(normalized.value);
147
+ if (!out.has(key)) out.set(key, token);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ interface CheckedFontSize {
153
+ suggestedValue?: string;
154
+ fixEmittable: boolean;
155
+ deterministicFix: boolean;
156
+ reason: "off-scale" | "token-equivalent";
157
+ matchedToken?: string;
158
+ }
159
+
160
+ function checkFontSize(
161
+ raw: string,
162
+ allowed: number[],
163
+ scale: ScaleFact,
164
+ tokens: Map<number, TokenDefinitionFact>,
165
+ bareNumberFix = false
166
+ ): CheckedFontSize | null {
167
+ const trimmed = raw.trim();
168
+ if (!trimmed || /var\(|calc\(|clamp\(|min\(|max\(|,|\s/.test(trimmed)) return null;
169
+ const parsed = parseLengthValue(trimmed);
170
+ if (!parsed) return null;
171
+ const normalized = normalizeLengthForScale(parsed, scale);
172
+ if (normalized === null) return null;
173
+ const magnitude = Math.abs(normalized.value);
174
+ const exactToken = tokens.get(magnitude);
175
+
176
+ if (matchesScale(normalized.value, allowed)) {
177
+ if (!exactToken) return null;
178
+ const applicable =
179
+ isApplicableTokenReference(exactToken.referenceFormat) && normalized.deterministicFix;
180
+ return {
181
+ suggestedValue: tokenReference(exactToken.name),
182
+ fixEmittable: true,
183
+ deterministicFix: applicable,
184
+ reason: "token-equivalent",
185
+ matchedToken: exactToken.name,
186
+ };
187
+ }
188
+
189
+ const nearest = nearestSignedScaleValue(normalized.value, allowed);
190
+ if (nearest === null || (nearest === 0 && normalized.value !== 0)) {
191
+ return { fixEmittable: false, deterministicFix: false, reason: "off-scale" };
192
+ }
193
+ const nearestToken = tokens.get(Math.abs(nearest));
194
+ const suggestedValue = nearestToken
195
+ ? tokenReference(nearestToken.name)
196
+ : bareNumberFix
197
+ ? `${nearest}`
198
+ : `${nearest}${scale.unit}`;
199
+ // Off-scale snaps change rendered size → suggestion only; and an assumed
200
+ // root base makes the px guess unreliable, so attach no fix then.
201
+ return {
202
+ suggestedValue,
203
+ fixEmittable: normalized.deterministicFix,
204
+ deterministicFix: false,
205
+ reason: "off-scale",
206
+ };
207
+ }
208
+
209
+ function buildFix(property: string, checked: CheckedFontSize) {
210
+ 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
+ };
218
+ }
219
+
220
+ function typographyMessage(
221
+ property: string,
222
+ value: string,
223
+ allowed: number[],
224
+ unit: "px" | "rem",
225
+ checked: CheckedFontSize
226
+ ): string {
227
+ if (checked.reason === "token-equivalent" && checked.matchedToken) {
228
+ return `\`${property}: ${value}\` is a raw literal that matches token \`${checked.matchedToken}\`. Reference the token instead.`;
229
+ }
230
+ const sample = allowed
231
+ .slice()
232
+ .sort((a, b) => a - b)
233
+ .slice(0, 6);
234
+ const suffix = `${sample.join(unit + ", ")}${unit}`;
235
+ const ellipsis = allowed.length > sample.length ? ", …" : "";
236
+ return `\`${property}: ${value}\` is not on the typography scale. Allowed: ${suffix}${ellipsis}.`;
237
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ compileGlobalGovernanceFacts,
5
+ FactIndex,
6
+ g,
7
+ makeTailwindClassFact,
8
+ makeUsageNodeFact,
9
+ ruleTailwindArbitraryColor,
10
+ runRules,
11
+ } from "../index.js";
12
+ import type { TailwindValue } from "../index.js";
13
+
14
+ const FILE = "components/card.tsx";
15
+
16
+ function indexWithColorPolicy(): FactIndex {
17
+ const ix = new FactIndex();
18
+ ix.addMany(
19
+ compileGlobalGovernanceFacts({
20
+ styles: [
21
+ g.styles.rawColors().forbid({
22
+ except: ["transparent", "currentColor"],
23
+ prefer: "token",
24
+ severity: "warn",
25
+ }),
26
+ ],
27
+ }),
28
+ );
29
+ return ix;
30
+ }
31
+
32
+ function addClass(ix: FactIndex, input: {
33
+ raw: string;
34
+ utility: string;
35
+ value: TailwindValue;
36
+ }) {
37
+ const node = makeUsageNodeFact({
38
+ file: FILE,
39
+ nodePath: "0:0",
40
+ element: "div",
41
+ location: { file: FILE, line: 4, column: 9 },
42
+ });
43
+ ix.add(node);
44
+ ix.add(
45
+ makeTailwindClassFact({
46
+ file: FILE,
47
+ nodeId: node.id,
48
+ raw: input.raw,
49
+ originPath: `attribute#0/class#${input.raw}`,
50
+ prefix: null,
51
+ modifiers: [],
52
+ important: false,
53
+ negative: false,
54
+ utility: input.utility,
55
+ value: input.value,
56
+ location: { file: FILE, line: 4, column: 18 },
57
+ }),
58
+ );
59
+ }
60
+
61
+ describe("tailwind/arbitrary-color", () => {
62
+ it("flags arbitrary hex colors — ACCEPTANCE §3.19", () => {
63
+ const ix = indexWithColorPolicy();
64
+ addClass(ix, {
65
+ raw: "bg-[#abc123]",
66
+ utility: "bg",
67
+ value: { kind: "arbitrary", raw: "#abc123" },
68
+ });
69
+
70
+ const findings = ruleTailwindArbitraryColor(ix);
71
+ expect(findings).toHaveLength(1);
72
+ expect(findings[0]).toMatchObject({
73
+ ruleId: "tailwind/arbitrary-color",
74
+ severity: "moderate",
75
+ attributes: {
76
+ rawClass: "bg-[#abc123]",
77
+ rawValue: "#abc123",
78
+ color: "#abc123",
79
+ utility: "bg",
80
+ source: "tailwind",
81
+ },
82
+ });
83
+ expect(findings[0].location).toMatchObject({ file: FILE, line: 4 });
84
+ });
85
+
86
+ it("flags arbitrary rgb() colors — ACCEPTANCE §3.20", () => {
87
+ const ix = indexWithColorPolicy();
88
+ addClass(ix, {
89
+ raw: "text-[rgb(10,20,30)]",
90
+ utility: "text",
91
+ value: { kind: "arbitrary", raw: "rgb(10,20,30)" },
92
+ });
93
+
94
+ const findings = ruleTailwindArbitraryColor(ix);
95
+ expect(findings).toHaveLength(1);
96
+ expect(findings[0].attributes).toMatchObject({
97
+ rawValue: "rgb(10,20,30)",
98
+ color: "rgb(10,20,30)",
99
+ });
100
+ });
101
+
102
+ it("does not fire for policy exemptions or non-color utilities", () => {
103
+ const ix = indexWithColorPolicy();
104
+ addClass(ix, {
105
+ raw: "bg-[transparent]",
106
+ utility: "bg",
107
+ value: { kind: "arbitrary", raw: "transparent" },
108
+ });
109
+ addClass(ix, {
110
+ raw: "m-[#abc123]",
111
+ utility: "m",
112
+ value: { kind: "arbitrary", raw: "#abc123" },
113
+ });
114
+
115
+ expect(ruleTailwindArbitraryColor(ix)).toHaveLength(0);
116
+ });
117
+
118
+ it("is registered in runRules", () => {
119
+ const ix = indexWithColorPolicy();
120
+ addClass(ix, {
121
+ raw: "fill-[#abc123]",
122
+ utility: "fill",
123
+ value: { kind: "arbitrary", raw: "#abc123" },
124
+ });
125
+
126
+ expect(runRules(ix).map((f) => f.ruleId)).toContain("tailwind/arbitrary-color");
127
+ });
128
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `tailwind/arbitrary-color` — flags color-bearing Tailwind utilities with
3
+ * arbitrary raw color values, e.g. `bg-[#abc123]` or
4
+ * `text-[rgb(10,20,30)]`.
5
+ */
6
+
7
+ import type { FactIndex, TailwindClassFact } from "../facts/index.js";
8
+
9
+ import { makeFinding } from "./finding.js";
10
+ import type { Finding } from "./types.js";
11
+ import { detectRawColor, isExemptColor, readUsageNode } from "./utils.js";
12
+
13
+ export const RULE_ID = "tailwind/arbitrary-color";
14
+ export const RULE_VERSION = "1";
15
+
16
+ const COLOR_UTILITIES = new Set([
17
+ "accent",
18
+ "bg",
19
+ "border",
20
+ "border-b",
21
+ "border-e",
22
+ "border-l",
23
+ "border-r",
24
+ "border-s",
25
+ "border-t",
26
+ "border-x",
27
+ "border-y",
28
+ "caret",
29
+ "decoration",
30
+ "divide",
31
+ "fill",
32
+ "from",
33
+ "outline",
34
+ "placeholder",
35
+ "ring",
36
+ "ring-offset",
37
+ "shadow",
38
+ "stroke",
39
+ "text",
40
+ "to",
41
+ "via",
42
+ ]);
43
+
44
+ export function ruleTailwindArbitraryColor(ix: FactIndex): Finding[] {
45
+ const policy = ix.policy.rawColorPolicy();
46
+ if (!policy) return [];
47
+
48
+ const findings: Finding[] = [];
49
+ for (const klass of ix.byKind("tailwind_class")) {
50
+ if (klass.value.kind !== "arbitrary") continue;
51
+ if (!COLOR_UTILITIES.has(klass.utility)) continue;
52
+
53
+ const color = detectRawColor(klass.value.raw);
54
+ if (!color) continue;
55
+ if (isExemptColor(color, policy.except)) continue;
56
+
57
+ const node = readUsageNode(ix, klass.nodeId);
58
+ const evidenceIds = node ? [node.id, klass.id, policy.id] : [klass.id, policy.id];
59
+
60
+ findings.push(
61
+ makeFinding({
62
+ ruleId: RULE_ID,
63
+ ruleVersion: RULE_VERSION,
64
+ severity: policy.severity,
65
+ message: `\`${klass.raw}\` uses arbitrary raw color ${color}. Use a ${policy.prefer === "token" ? "design token" : "CSS variable"} instead.`,
66
+ location: klass.location,
67
+ evidence: ix.evidence(evidenceIds),
68
+ fingerprintIdentity: fingerprintIdentity(klass, color),
69
+ attributes: {
70
+ rawClass: klass.raw,
71
+ rawValue: klass.value.raw,
72
+ color,
73
+ utility: klass.utility,
74
+ source: "tailwind",
75
+ },
76
+ }),
77
+ );
78
+ }
79
+
80
+ return findings;
81
+ }
82
+
83
+ function fingerprintIdentity(klass: TailwindClassFact, color: string): Record<string, unknown> {
84
+ return {
85
+ source: "tailwind_class",
86
+ file: klass.file,
87
+ nodeId: klass.nodeId,
88
+ originPath: klass.originPath,
89
+ raw: klass.raw,
90
+ utility: klass.utility,
91
+ value: color,
92
+ };
93
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ compileGlobalGovernanceFacts,
5
+ FactIndex,
6
+ g,
7
+ makeTailwindClassFact,
8
+ makeUsageNodeFact,
9
+ ruleTailwindArbitrarySpacing,
10
+ runRules,
11
+ } from "../index.js";
12
+ import type { TailwindValue } from "../index.js";
13
+
14
+ const FILE = "components/product/gallery.tsx";
15
+
16
+ function indexWithSpacingPolicy(values = [0, 4, 8, 16, 32]): FactIndex {
17
+ const ix = new FactIndex();
18
+ ix.addMany(
19
+ compileGlobalGovernanceFacts({
20
+ scales: {
21
+ space: g.scale.px(values),
22
+ },
23
+ styles: [
24
+ g.styles.rawSpacing().mustMatchScale("space", {
25
+ appliesTo: ["margin", "padding", "gap"],
26
+ severity: "warn",
27
+ }),
28
+ ],
29
+ }),
30
+ );
31
+ return ix;
32
+ }
33
+
34
+ function addClass(ix: FactIndex, input: {
35
+ raw: string;
36
+ utility: string;
37
+ value: TailwindValue;
38
+ negative?: boolean;
39
+ }) {
40
+ const node = makeUsageNodeFact({
41
+ file: FILE,
42
+ nodePath: "0:0",
43
+ element: "button",
44
+ location: { file: FILE, line: 12, column: 6 },
45
+ });
46
+ ix.add(node);
47
+ ix.add(
48
+ makeTailwindClassFact({
49
+ file: FILE,
50
+ nodeId: node.id,
51
+ raw: input.raw,
52
+ originPath: `attribute#0/class#${input.raw}`,
53
+ prefix: null,
54
+ modifiers: [],
55
+ important: false,
56
+ negative: input.negative ?? false,
57
+ utility: input.utility,
58
+ value: input.value,
59
+ location: { file: FILE, line: 12, column: 30 },
60
+ }),
61
+ );
62
+ }
63
+
64
+ describe("tailwind/arbitrary-spacing", () => {
65
+ it("flags arbitrary percent spacing — ACCEPTANCE §3.18", () => {
66
+ const ix = indexWithSpacingPolicy();
67
+ addClass(ix, {
68
+ raw: "m-[15%]",
69
+ utility: "m",
70
+ value: { kind: "arbitrary", raw: "15%" },
71
+ });
72
+
73
+ const findings = ruleTailwindArbitrarySpacing(ix);
74
+ expect(findings).toHaveLength(1);
75
+ expect(findings[0]).toMatchObject({
76
+ ruleId: "tailwind/arbitrary-spacing",
77
+ severity: "moderate",
78
+ message: "`m-[15%]` is an arbitrary spacing value not on the project's spacing scale.",
79
+ attributes: {
80
+ rawValue: "15%",
81
+ utility: "m",
82
+ property: "margin",
83
+ scale: "space",
84
+ source: "tailwind",
85
+ },
86
+ });
87
+ expect(findings[0].fix).toMatchObject({
88
+ kind: "replaceClassToken",
89
+ from: "m-[15%]",
90
+ to: null,
91
+ deterministic: false,
92
+ });
93
+ });
94
+
95
+ it("normalizes rem values before checking a px scale — ACCEPTANCE §3.21", () => {
96
+ const ix = indexWithSpacingPolicy([0, 4, 8, 16, 32]);
97
+ addClass(ix, {
98
+ raw: "p-[1rem]",
99
+ utility: "p",
100
+ value: { kind: "arbitrary", raw: "1rem" },
101
+ });
102
+ addClass(ix, {
103
+ raw: "p-[1.5rem]",
104
+ utility: "p",
105
+ value: { kind: "arbitrary", raw: "1.5rem" },
106
+ });
107
+
108
+ const findings = ruleTailwindArbitrarySpacing(ix);
109
+ expect(findings).toHaveLength(1);
110
+ expect(findings[0].attributes).toMatchObject({
111
+ rawClass: "p-[1.5rem]",
112
+ rawValue: "1.5rem",
113
+ normalizedValue: 24,
114
+ assumedRootFontSizePx: 16,
115
+ });
116
+ expect(findings[0].fix).toMatchObject({
117
+ kind: "replaceClassToken",
118
+ from: "p-[1.5rem]",
119
+ to: null,
120
+ deterministic: false,
121
+ });
122
+ });
123
+
124
+ it("does not flag positional arbitrary offsets — ACCEPTANCE §3.22", () => {
125
+ const ix = indexWithSpacingPolicy();
126
+ addClass(ix, {
127
+ raw: "top-[-1px]",
128
+ utility: "top",
129
+ value: { kind: "arbitrary", raw: "-1px" },
130
+ });
131
+
132
+ expect(ruleTailwindArbitrarySpacing(ix)).toHaveLength(0);
133
+ });
134
+
135
+ it("allows negative values whose magnitude is on the scale", () => {
136
+ const ix = indexWithSpacingPolicy();
137
+ addClass(ix, {
138
+ raw: "-mx-[4px]",
139
+ utility: "mx",
140
+ negative: true,
141
+ value: { kind: "arbitrary", raw: "4px" },
142
+ });
143
+
144
+ expect(ruleTailwindArbitrarySpacing(ix)).toHaveLength(0);
145
+ });
146
+
147
+ it("is registered in runRules", () => {
148
+ const ix = indexWithSpacingPolicy();
149
+ addClass(ix, {
150
+ raw: "gap-[13px]",
151
+ utility: "gap",
152
+ value: { kind: "arbitrary", raw: "13px" },
153
+ });
154
+
155
+ expect(runRules(ix).map((f) => f.ruleId)).toContain("tailwind/arbitrary-spacing");
156
+ });
157
+ });