@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,3636 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ asComponentId,
5
+ componentId as makeComponentId,
6
+ defineFragment,
7
+ FactIndex,
8
+ compileComponentFacts,
9
+ compileGlobalGovernanceFacts,
10
+ g,
11
+ makeClassNameDynamicFact,
12
+ makeStyleDeclarationFact,
13
+ makeTokenDefinitionFact,
14
+ makeUsageComponentFact,
15
+ makeUsageImportFact,
16
+ makeUsageInlineStyleFact,
17
+ makeUsageNodeFact,
18
+ makeUsagePropResolvedFact,
19
+ makeUsageTextChildFact,
20
+ ruleA11yRequiredAccessibleName,
21
+ ruleComponentsForbiddenPropValue,
22
+ ruleComponentsPreferLibrary,
23
+ ruleComponentsUnknownProp,
24
+ ruleJsxPreferredComponent,
25
+ ruleJsxPreferredImportPath,
26
+ rulePropsInvalidValue,
27
+ ruleStylesNoRawColor,
28
+ ruleStylesNoRawDimensions,
29
+ ruleStylesNoRawSpacing,
30
+ ruleStylesNoRawTypography,
31
+ ruleThemeNoThemeCoupledLiteral,
32
+ ruleTokensRequireDualFallback,
33
+ runRules,
34
+ } from "../index.js";
35
+ import type { Fact, FactId } from "../index.js";
36
+
37
+ type ButtonProps = {
38
+ variant?: "primary" | "secondary" | "ghost" | "link";
39
+ size?: "sm" | "md" | "lg";
40
+ disabled?: boolean;
41
+ };
42
+
43
+ function Button(_props: ButtonProps) {
44
+ return null;
45
+ }
46
+
47
+ const buttonId = makeComponentId("@usefragments/ui", "Button");
48
+
49
+ function buildButtonFragment() {
50
+ return defineFragment({
51
+ component: Button,
52
+ meta: {
53
+ name: "Button",
54
+ description: "Interactive element for user-triggered actions.",
55
+ category: "forms",
56
+ },
57
+ guidance: {
58
+ when: ["Submitting forms"],
59
+ whenNot: ["Simple navigation without side effects"],
60
+ },
61
+ props: {
62
+ variant: {
63
+ type: "enum",
64
+ values: ["primary", "secondary", "ghost", "link"],
65
+ required: false,
66
+ },
67
+ size: { type: "enum", values: ["sm", "md", "lg"], required: false },
68
+ disabled: { type: "boolean", required: false },
69
+ },
70
+ govern: (govern) => [
71
+ govern.capability("dom.button"),
72
+ govern.prop("variant").forbid("secondary", {
73
+ when: { path: "apps/checkout/**" },
74
+ because: "Checkout CTAs should stay visually dominant.",
75
+ fix: { replaceWith: "primary" },
76
+ severity: "error",
77
+ }),
78
+ govern.accessibility().requireName({
79
+ because: "Buttons must announce their action.",
80
+ severity: "error",
81
+ }),
82
+ ],
83
+ });
84
+ }
85
+
86
+ function buildBaseConfigFacts() {
87
+ return compileGlobalGovernanceFacts({
88
+ scales: {
89
+ space: g.scale.px([0, 4, 8, 12, 16, 20, 24, 32]),
90
+ },
91
+ styles: [
92
+ g.styles.rawColors().forbid({
93
+ except: ["transparent"],
94
+ prefer: "token",
95
+ severity: "error",
96
+ }),
97
+ g.styles.rawSpacing().mustMatchScale("space", {
98
+ appliesTo: ["padding", "margin", "gap"],
99
+ severity: "error",
100
+ }),
101
+ ],
102
+ jsx: [g.jsx.unknownProps().forbid({ severity: "error" })],
103
+ });
104
+ }
105
+
106
+ function emptyIndexWithGovernance(): FactIndex {
107
+ const ix = new FactIndex();
108
+ ix.addMany(buildBaseConfigFacts());
109
+ ix.addMany(compileComponentFacts(buttonId, buildButtonFragment()));
110
+ return ix;
111
+ }
112
+
113
+ function indexWithComponentUsageGovernance(): FactIndex {
114
+ const ix = new FactIndex();
115
+ ix.addMany(
116
+ compileGlobalGovernanceFacts({
117
+ rules: { "components/usage": { enabled: true, severity: "error" } },
118
+ })
119
+ );
120
+ ix.addMany(compileComponentFacts(buttonId, buildButtonFragment()));
121
+ return ix;
122
+ }
123
+
124
+ interface ButtonUsageInput {
125
+ file: string;
126
+ nodePath: string;
127
+ line: number;
128
+ column: number;
129
+ props?: Array<{
130
+ prop: string;
131
+ resolution?: "static" | "dynamic" | "spread";
132
+ value?: unknown;
133
+ }>;
134
+ inlineStyles?: Array<{
135
+ property: string;
136
+ valueKind: "static" | "number" | "css-variable";
137
+ value: string;
138
+ }>;
139
+ text?: string;
140
+ }
141
+
142
+ function addButtonUsage(ix: FactIndex, input: ButtonUsageInput): { nodeId: FactId } {
143
+ const node = makeUsageNodeFact({
144
+ file: input.file,
145
+ nodePath: input.nodePath,
146
+ element: "Button",
147
+ location: {
148
+ file: input.file,
149
+ line: input.line,
150
+ column: input.column,
151
+ },
152
+ });
153
+ ix.add(node);
154
+ ix.add(makeUsageComponentFact({ nodeId: node.id, componentId: buttonId }));
155
+ for (const prop of input.props ?? []) {
156
+ ix.add(
157
+ makeUsagePropResolvedFact({
158
+ nodeId: node.id,
159
+ prop: prop.prop,
160
+ resolution: prop.resolution ?? "static",
161
+ value: prop.value,
162
+ })
163
+ );
164
+ }
165
+ for (const inline of input.inlineStyles ?? []) {
166
+ ix.add(
167
+ makeUsageInlineStyleFact({
168
+ nodeId: node.id,
169
+ property: inline.property,
170
+ valueKind: inline.valueKind,
171
+ value: inline.value,
172
+ })
173
+ );
174
+ }
175
+ if (input.text !== undefined) {
176
+ ix.add(makeUsageTextChildFact({ nodeId: node.id, text: input.text, index: 0 }));
177
+ }
178
+ return { nodeId: node.id };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // components/unknown-prop
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe("components/unknown-prop", () => {
186
+ it("fires for an unknown prop with the prop's component location", () => {
187
+ const ix = emptyIndexWithGovernance();
188
+ addButtonUsage(ix, {
189
+ file: "apps/marketing/page.tsx",
190
+ nodePath: "0/0",
191
+ line: 12,
192
+ column: 4,
193
+ props: [
194
+ { prop: "variant", value: "primary" },
195
+ { prop: "wibble", value: "yes" },
196
+ ],
197
+ text: "Continue",
198
+ });
199
+
200
+ const findings = ruleComponentsUnknownProp(ix);
201
+ expect(findings).toHaveLength(1);
202
+ expect(findings[0].ruleId).toBe("components/unknown-prop");
203
+ expect(findings[0].severity).toBe("serious");
204
+ expect(findings[0].location).toMatchObject({
205
+ file: "apps/marketing/page.tsx",
206
+ line: 12,
207
+ column: 4,
208
+ });
209
+ expect(findings[0].attributes).toMatchObject({ prop: "wibble" });
210
+ expect(findings[0].evidence.length).toBeGreaterThan(0);
211
+ });
212
+
213
+ it("does not fire for universal props (aria-*, data-*, on*, key/ref/className/style)", () => {
214
+ const ix = emptyIndexWithGovernance();
215
+ addButtonUsage(ix, {
216
+ file: "apps/marketing/page.tsx",
217
+ nodePath: "0",
218
+ line: 1,
219
+ column: 0,
220
+ props: [
221
+ { prop: "aria-label", value: "Save" },
222
+ { prop: "data-testid", value: "save-btn" },
223
+ { prop: "onClick", resolution: "dynamic" },
224
+ { prop: "className", value: "x" },
225
+ { prop: "style", resolution: "dynamic" },
226
+ { prop: "key", value: "k" },
227
+ ],
228
+ });
229
+
230
+ expect(ruleComponentsUnknownProp(ix)).toHaveLength(0);
231
+ });
232
+
233
+ it("does not fire when the global jsx.unknownProps policy is absent", () => {
234
+ const ix = new FactIndex();
235
+ ix.addMany(compileComponentFacts(buttonId, buildButtonFragment()));
236
+ addButtonUsage(ix, {
237
+ file: "apps/marketing/page.tsx",
238
+ nodePath: "0",
239
+ line: 1,
240
+ column: 0,
241
+ props: [{ prop: "wibble", value: "yes" }],
242
+ });
243
+
244
+ expect(ruleComponentsUnknownProp(ix)).toHaveLength(0);
245
+ });
246
+ });
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // components/forbidden-prop-value
250
+ // ---------------------------------------------------------------------------
251
+
252
+ describe("components/forbidden-prop-value", () => {
253
+ it("fires only when the file matches the policy path glob", () => {
254
+ const ix = emptyIndexWithGovernance();
255
+ addButtonUsage(ix, {
256
+ file: "apps/checkout/page.tsx",
257
+ nodePath: "0/0",
258
+ line: 42,
259
+ column: 8,
260
+ props: [{ prop: "variant", value: "secondary" }],
261
+ text: "Continue",
262
+ });
263
+ addButtonUsage(ix, {
264
+ file: "apps/marketing/home.tsx",
265
+ nodePath: "0/0",
266
+ line: 10,
267
+ column: 4,
268
+ props: [{ prop: "variant", value: "secondary" }],
269
+ text: "Learn more",
270
+ });
271
+
272
+ const findings = ruleComponentsForbiddenPropValue(ix);
273
+ expect(findings).toHaveLength(1);
274
+ expect(findings[0].location.file).toBe("apps/checkout/page.tsx");
275
+ expect(findings[0].severity).toBe("serious");
276
+ });
277
+
278
+ it("attaches a deterministic replacePropValue fix from policy.replaceWith", () => {
279
+ const ix = emptyIndexWithGovernance();
280
+ addButtonUsage(ix, {
281
+ file: "apps/checkout/page.tsx",
282
+ nodePath: "0/0",
283
+ line: 42,
284
+ column: 8,
285
+ props: [{ prop: "variant", value: "secondary" }],
286
+ text: "Continue",
287
+ });
288
+
289
+ const findings = ruleComponentsForbiddenPropValue(ix);
290
+ expect(findings).toHaveLength(1);
291
+ expect(findings[0].fix).toMatchObject({
292
+ kind: "replacePropValue",
293
+ prop: "variant",
294
+ value: "primary",
295
+ deterministic: true,
296
+ });
297
+ });
298
+
299
+ it("does not fire for dynamic prop values", () => {
300
+ const ix = emptyIndexWithGovernance();
301
+ addButtonUsage(ix, {
302
+ file: "apps/checkout/page.tsx",
303
+ nodePath: "0/0",
304
+ line: 1,
305
+ column: 0,
306
+ props: [{ prop: "variant", resolution: "dynamic" }],
307
+ });
308
+
309
+ expect(ruleComponentsForbiddenPropValue(ix)).toHaveLength(0);
310
+ });
311
+ });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // props/invalid-value
315
+ // ---------------------------------------------------------------------------
316
+
317
+ describe("props/invalid-value", () => {
318
+ it("fires for static enum values outside the component prop schema", () => {
319
+ const ix = indexWithComponentUsageGovernance();
320
+ addButtonUsage(ix, {
321
+ file: "apps/marketing/page.tsx",
322
+ nodePath: "0/0",
323
+ line: 12,
324
+ column: 4,
325
+ props: [{ prop: "variant", value: "danger" }],
326
+ text: "Delete workspace",
327
+ });
328
+
329
+ const findings = rulePropsInvalidValue(ix);
330
+
331
+ expect(findings).toHaveLength(1);
332
+ expect(findings[0]).toMatchObject({
333
+ ruleId: "props/invalid-value",
334
+ code: "FUI6002",
335
+ severity: "serious",
336
+ location: {
337
+ file: "apps/marketing/page.tsx",
338
+ line: 12,
339
+ column: 4,
340
+ },
341
+ attributes: {
342
+ componentId: buttonId,
343
+ prop: "variant",
344
+ rawValue: "danger",
345
+ allowedValues: ["primary", "secondary", "ghost", "link"],
346
+ },
347
+ });
348
+ expect(findings[0].fix).toBeUndefined();
349
+ expect(findings[0].evidence.length).toBeGreaterThan(0);
350
+ });
351
+
352
+ it("skips dynamic, spread, unknown, and unconstrained props", () => {
353
+ const ix = indexWithComponentUsageGovernance();
354
+ addButtonUsage(ix, {
355
+ file: "apps/marketing/page.tsx",
356
+ nodePath: "0/0",
357
+ line: 12,
358
+ column: 4,
359
+ props: [
360
+ { prop: "variant", resolution: "dynamic" },
361
+ { prop: "size", resolution: "spread", value: "xxl" },
362
+ { prop: "wibble", value: "danger" },
363
+ { prop: "disabled", value: "definitely" },
364
+ ],
365
+ });
366
+
367
+ expect(rulePropsInvalidValue(ix)).toHaveLength(0);
368
+ });
369
+
370
+ it("does not fire unless the rule is enabled", () => {
371
+ const ix = new FactIndex();
372
+ ix.addMany(compileComponentFacts(buttonId, buildButtonFragment()));
373
+ addButtonUsage(ix, {
374
+ file: "apps/marketing/page.tsx",
375
+ nodePath: "0/0",
376
+ line: 12,
377
+ column: 4,
378
+ props: [{ prop: "variant", value: "danger" }],
379
+ });
380
+
381
+ expect(rulePropsInvalidValue(ix)).toHaveLength(0);
382
+ });
383
+ });
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // imports/preferred-path and components/preferred-component
387
+ // ---------------------------------------------------------------------------
388
+
389
+ describe("preferred JSX imports and components", () => {
390
+ it("emits a deterministic import replacement for configured source mappings", () => {
391
+ const ix = new FactIndex();
392
+ ix.addMany(
393
+ compileGlobalGovernanceFacts({
394
+ jsx: [
395
+ g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui", {
396
+ severity: "error",
397
+ because: "Use the canonical design-system package.",
398
+ }),
399
+ ],
400
+ })
401
+ );
402
+ ix.add(
403
+ makeUsageImportFact({
404
+ file: "apps/checkout/page.tsx",
405
+ local: "Button",
406
+ imported: "Button",
407
+ source: "@legacy/ui",
408
+ location: { file: "apps/checkout/page.tsx", line: 1, column: 0 },
409
+ })
410
+ );
411
+
412
+ const findings = ruleJsxPreferredImportPath(ix);
413
+
414
+ expect(findings).toHaveLength(1);
415
+ expect(findings[0].ruleId).toBe("imports/preferred-path");
416
+ expect(findings[0].fix).toMatchObject({
417
+ kind: "replaceImport",
418
+ from: "@legacy/ui",
419
+ to: "@usefragments/ui",
420
+ deterministic: true,
421
+ });
422
+ expect(findings[0].attributes).toMatchObject({
423
+ importPath: "@legacy/ui",
424
+ suggestedImport: "@usefragments/ui",
425
+ });
426
+ });
427
+
428
+ it("emits a deterministic component replacement for canonical component mappings", () => {
429
+ const legacyButtonId = makeComponentId("@legacy/ui", "LegacyButton");
430
+ const ix = new FactIndex();
431
+ ix.addMany(
432
+ compileGlobalGovernanceFacts({
433
+ jsx: [
434
+ g.jsx.component().prefer(legacyButtonId, buttonId, {
435
+ severity: "warn",
436
+ because: "Use the canonical Button component.",
437
+ }),
438
+ ],
439
+ })
440
+ );
441
+ const node = makeUsageNodeFact({
442
+ file: "apps/checkout/page.tsx",
443
+ nodePath: "0/0",
444
+ element: "LegacyButton",
445
+ location: { file: "apps/checkout/page.tsx", line: 4, column: 9 },
446
+ });
447
+ ix.add(node);
448
+ ix.add(makeUsageComponentFact({ nodeId: node.id, componentId: legacyButtonId }));
449
+
450
+ const findings = ruleJsxPreferredComponent(ix);
451
+
452
+ expect(findings).toHaveLength(1);
453
+ expect(findings[0].ruleId).toBe("components/preferred-component");
454
+ expect(findings[0].severity).toBe("moderate");
455
+ expect(findings[0].fix).toMatchObject({
456
+ kind: "replaceComponent",
457
+ from: "LegacyButton",
458
+ to: "Button",
459
+ deterministic: true,
460
+ });
461
+ });
462
+
463
+ it("matches legacy/current import policies once and fixes the actual source spelling", () => {
464
+ const ix = new FactIndex();
465
+ ix.addMany(
466
+ compileGlobalGovernanceFacts({
467
+ jsx: [
468
+ g.jsx.importPath().prefer("@usefragments/ui", "@acme/ui"),
469
+ g.jsx.importPath().prefer("@usefragments/ui", "@acme/ui"),
470
+ ],
471
+ })
472
+ );
473
+ ix.add(
474
+ makeUsageImportFact({
475
+ file: "src/app.tsx",
476
+ local: "Button",
477
+ imported: "Button",
478
+ source: "@usefragments/ui",
479
+ location: { file: "src/app.tsx", line: 1, column: 0 },
480
+ })
481
+ );
482
+
483
+ const findings = ruleJsxPreferredImportPath(ix);
484
+ expect(findings).toHaveLength(1);
485
+ expect(findings[0].fix).toMatchObject({
486
+ kind: "replaceImport",
487
+ from: "@usefragments/ui",
488
+ to: "@acme/ui",
489
+ });
490
+ });
491
+
492
+ it("does not emit an import rewrite between equivalent owned spellings", () => {
493
+ const ix = new FactIndex();
494
+ ix.addMany(
495
+ compileGlobalGovernanceFacts({
496
+ jsx: [g.jsx.importPath().prefer("@usefragments/ui", "@usefragments/ui")],
497
+ })
498
+ );
499
+ ix.add(
500
+ makeUsageImportFact({
501
+ file: "src/app.tsx",
502
+ local: "Button",
503
+ imported: "Button",
504
+ source: "@usefragments/ui",
505
+ location: { file: "src/app.tsx", line: 1, column: 0 },
506
+ })
507
+ );
508
+
509
+ expect(ruleJsxPreferredImportPath(ix)).toHaveLength(0);
510
+ });
511
+
512
+ it("matches preferred-component policy across component package epochs", () => {
513
+ const ix = new FactIndex();
514
+ ix.addMany(
515
+ compileGlobalGovernanceFacts({
516
+ jsx: [g.jsx.component().prefer("@usefragments/ui#LegacyButton", "@acme/ui#Button")],
517
+ })
518
+ );
519
+ const node = makeUsageNodeFact({
520
+ file: "src/app.tsx",
521
+ nodePath: "0:0",
522
+ element: "LegacyButton",
523
+ location: { file: "src/app.tsx", line: 4, column: 2 },
524
+ });
525
+ ix.add(node);
526
+ ix.add(
527
+ makeUsageComponentFact({
528
+ nodeId: node.id,
529
+ componentId: asComponentId("@usefragments/ui#LegacyButton"),
530
+ })
531
+ );
532
+
533
+ expect(ruleJsxPreferredComponent(ix)).toHaveLength(1);
534
+ });
535
+ });
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // styles/no-raw-color
539
+ // ---------------------------------------------------------------------------
540
+
541
+ describe("styles/no-raw-color", () => {
542
+ it("fires for hex colors in inline JSX style", () => {
543
+ const ix = emptyIndexWithGovernance();
544
+ addButtonUsage(ix, {
545
+ file: "apps/checkout/page.tsx",
546
+ nodePath: "0/0",
547
+ line: 42,
548
+ column: 8,
549
+ inlineStyles: [{ property: "color", valueKind: "static", value: "#2563eb" }],
550
+ text: "x",
551
+ });
552
+
553
+ const findings = ruleStylesNoRawColor(ix);
554
+ expect(findings).toHaveLength(1);
555
+ expect(findings[0].attributes).toMatchObject({ source: "jsx", color: "#2563eb" });
556
+ });
557
+
558
+ it("fires for raw colors in SCSS declarations", () => {
559
+ const ix = emptyIndexWithGovernance();
560
+ ix.add(
561
+ makeStyleDeclarationFact({
562
+ file: "libs/ui/Button.module.scss",
563
+ selector: ".button",
564
+ declarationPath: "0",
565
+ property: "color",
566
+ value: "#2563eb",
567
+ location: {
568
+ file: "libs/ui/Button.module.scss",
569
+ line: 3,
570
+ column: 2,
571
+ },
572
+ })
573
+ );
574
+
575
+ const findings = ruleStylesNoRawColor(ix);
576
+ expect(findings).toHaveLength(1);
577
+ expect(findings[0].attributes).toMatchObject({ source: "css", color: "#2563eb" });
578
+ expect(findings[0].location.line).toBe(3);
579
+ });
580
+
581
+ it("only exempts raw custom property definitions inside declared token source files", () => {
582
+ const tokenIx = emptyIndexWithGovernance();
583
+ tokenIx.add(
584
+ makeStyleDeclarationFact({
585
+ file: "src/styles/tokens.css",
586
+ selector: ":root",
587
+ declarationPath: "0",
588
+ property: "--fui-color-accent",
589
+ value: "#39594d",
590
+ declaredTokenSource: true,
591
+ location: { file: "src/styles/tokens.css", line: 1, column: 0 },
592
+ })
593
+ );
594
+
595
+ const productIx = emptyIndexWithGovernance();
596
+ productIx.add(
597
+ makeStyleDeclarationFact({
598
+ file: "src/landing.scss",
599
+ selector: ":root",
600
+ declarationPath: "0",
601
+ property: "--lp-accent",
602
+ value: "#39594d",
603
+ location: { file: "src/landing.scss", line: 1, column: 0 },
604
+ })
605
+ );
606
+
607
+ expect(ruleStylesNoRawColor(tokenIx)).toHaveLength(0);
608
+ expect(ruleStylesNoRawColor(productIx)).toHaveLength(1);
609
+ });
610
+
611
+ it("does not fire for var(--token) references", () => {
612
+ const ix = emptyIndexWithGovernance();
613
+ ix.add(
614
+ makeStyleDeclarationFact({
615
+ file: "libs/ui/x.scss",
616
+ selector: ".x",
617
+ declarationPath: "0",
618
+ property: "color",
619
+ value: "var(--fui-color-accent, #ff0000)",
620
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
621
+ })
622
+ );
623
+ addButtonUsage(ix, {
624
+ file: "apps/x.tsx",
625
+ nodePath: "0",
626
+ line: 1,
627
+ column: 0,
628
+ inlineStyles: [
629
+ { property: "color", valueKind: "css-variable", value: "var(--fui-color-accent)" },
630
+ ],
631
+ text: "x",
632
+ });
633
+
634
+ expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
635
+ });
636
+
637
+ it("does not flag a hex inside a var() fallback within a shorthand", () => {
638
+ const ix = emptyIndexWithGovernance();
639
+ ix.add(
640
+ makeStyleDeclarationFact({
641
+ file: "libs/ui/x.scss",
642
+ selector: ".bordered",
643
+ declarationPath: "0",
644
+ property: "border",
645
+ value: "1px solid var(--color-text, #1a1a1a)",
646
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
647
+ })
648
+ );
649
+
650
+ // The hex is the var() fallback, not a raw color — flagging it would both be
651
+ // a false positive and fight the dual-fallback rule that mandates it.
652
+ expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
653
+ });
654
+
655
+ it("does not fire for policy exemptions like 'transparent'", () => {
656
+ const ix = emptyIndexWithGovernance();
657
+ ix.add(
658
+ makeStyleDeclarationFact({
659
+ file: "libs/ui/x.scss",
660
+ selector: ".x",
661
+ declarationPath: "0",
662
+ property: "background",
663
+ value: "transparent",
664
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
665
+ })
666
+ );
667
+
668
+ expect(ruleStylesNoRawColor(ix)).toHaveLength(0);
669
+ });
670
+
671
+ it("fires for rgb() and hsl() function syntax", () => {
672
+ const ix = emptyIndexWithGovernance();
673
+ ix.add(
674
+ makeStyleDeclarationFact({
675
+ file: "libs/ui/x.scss",
676
+ selector: ".x",
677
+ declarationPath: "0",
678
+ property: "background",
679
+ value: "rgb(0, 0, 0)",
680
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
681
+ })
682
+ );
683
+ ix.add(
684
+ makeStyleDeclarationFact({
685
+ file: "libs/ui/x.scss",
686
+ selector: ".y",
687
+ declarationPath: "0",
688
+ property: "background",
689
+ value: "hsl(220, 100%, 50%)",
690
+ location: { file: "libs/ui/x.scss", line: 5, column: 0 },
691
+ })
692
+ );
693
+
694
+ expect(ruleStylesNoRawColor(ix)).toHaveLength(2);
695
+ });
696
+
697
+ it("finds colors embedded in border and shadow shorthands", () => {
698
+ const ix = emptyIndexWithGovernance();
699
+ ix.add(
700
+ makeStyleDeclarationFact({
701
+ file: "libs/ui/x.scss",
702
+ selector: ".border",
703
+ declarationPath: "0",
704
+ property: "border",
705
+ value: "1px solid #ccc",
706
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
707
+ })
708
+ );
709
+ ix.add(
710
+ makeStyleDeclarationFact({
711
+ file: "libs/ui/x.scss",
712
+ selector: ".shadow",
713
+ declarationPath: "1",
714
+ property: "box-shadow",
715
+ value: "0 1px 2px rgb(0, 0, 0)",
716
+ location: { file: "libs/ui/x.scss", line: 2, column: 0 },
717
+ })
718
+ );
719
+
720
+ const findings = ruleStylesNoRawColor(ix);
721
+ expect(findings).toHaveLength(2);
722
+ expect(findings.map((finding) => finding.attributes?.color)).toEqual(["#ccc", "rgb(0, 0, 0)"]);
723
+ });
724
+ });
725
+
726
+ // ---------------------------------------------------------------------------
727
+ // styles/no-raw-spacing
728
+ // ---------------------------------------------------------------------------
729
+
730
+ describe("styles/no-raw-spacing", () => {
731
+ it("fires for off-scale pixel values, silent for on-scale values", () => {
732
+ const ix = emptyIndexWithGovernance();
733
+ ix.add(
734
+ makeStyleDeclarationFact({
735
+ file: "libs/ui/x.scss",
736
+ selector: ".bad",
737
+ declarationPath: "0",
738
+ property: "padding",
739
+ value: "13px",
740
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
741
+ })
742
+ );
743
+ ix.add(
744
+ makeStyleDeclarationFact({
745
+ file: "libs/ui/x.scss",
746
+ selector: ".ok",
747
+ declarationPath: "0",
748
+ property: "padding",
749
+ value: "12px",
750
+ location: { file: "libs/ui/x.scss", line: 5, column: 0 },
751
+ })
752
+ );
753
+
754
+ const findings = ruleStylesNoRawSpacing(ix);
755
+ expect(findings).toHaveLength(1);
756
+ expect(findings[0].attributes).toMatchObject({ rawValue: "13px" });
757
+ });
758
+
759
+ it("checks every value in spacing shorthands", () => {
760
+ const ix = emptyIndexWithGovernance();
761
+ ix.add(
762
+ makeStyleDeclarationFact({
763
+ file: "libs/ui/x.scss",
764
+ selector: ".bad",
765
+ declarationPath: "0",
766
+ property: "padding",
767
+ value: "4px 10px",
768
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
769
+ })
770
+ );
771
+ ix.add(
772
+ makeStyleDeclarationFact({
773
+ file: "libs/ui/x.scss",
774
+ selector: ".ok",
775
+ declarationPath: "1",
776
+ property: "padding",
777
+ value: "4px 12px",
778
+ location: { file: "libs/ui/x.scss", line: 2, column: 0 },
779
+ })
780
+ );
781
+
782
+ const findings = ruleStylesNoRawSpacing(ix);
783
+ expect(findings).toHaveLength(1);
784
+ expect(findings[0].attributes).toMatchObject({
785
+ rawValue: "4px 10px",
786
+ suggestedValue: "4px 8px",
787
+ });
788
+ });
789
+
790
+ it("treats inline numeric props as pixel values (padding: 13 → fires)", () => {
791
+ const ix = emptyIndexWithGovernance();
792
+ addButtonUsage(ix, {
793
+ file: "apps/checkout/page.tsx",
794
+ nodePath: "0/0",
795
+ line: 42,
796
+ column: 8,
797
+ inlineStyles: [{ property: "padding", valueKind: "number", value: "13" }],
798
+ text: "x",
799
+ });
800
+
801
+ const findings = ruleStylesNoRawSpacing(ix);
802
+ expect(findings).toHaveLength(1);
803
+ expect(findings[0].attributes).toMatchObject({ source: "jsx", rawValue: "13" });
804
+ });
805
+
806
+ it("normalizes rem values before checking a px spacing scale", () => {
807
+ const ix = new FactIndex();
808
+ ix.addMany(
809
+ compileGlobalGovernanceFacts({
810
+ scales: {
811
+ space: g.scale.px([0, 4, 8, 12, 16, 20, 24, 32, 80]),
812
+ },
813
+ styles: [
814
+ g.styles.rawSpacing().mustMatchScale("space", {
815
+ appliesTo: ["padding"],
816
+ severity: "error",
817
+ }),
818
+ ],
819
+ })
820
+ );
821
+ for (const [index, value] of [
822
+ "0.25rem",
823
+ "0.5rem",
824
+ "1rem",
825
+ "1.5rem",
826
+ "2rem",
827
+ "5rem",
828
+ ].entries()) {
829
+ ix.add(
830
+ makeStyleDeclarationFact({
831
+ file: "libs/ui/x.scss",
832
+ selector: `.rem-${index}`,
833
+ declarationPath: String(index),
834
+ property: "padding",
835
+ value,
836
+ location: { file: "libs/ui/x.scss", line: index + 1, column: 0 },
837
+ })
838
+ );
839
+ }
840
+
841
+ expect(ruleStylesNoRawSpacing(ix)).toHaveLength(0);
842
+ });
843
+
844
+ it("normalizes em values for detection but does not autofix without an explicit em base", () => {
845
+ const ix = emptyIndexWithGovernance();
846
+ ix.add(
847
+ makeStyleDeclarationFact({
848
+ file: "libs/ui/x.scss",
849
+ selector: ".em",
850
+ declarationPath: "0",
851
+ property: "padding",
852
+ value: "1.1em",
853
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
854
+ })
855
+ );
856
+
857
+ const findings = ruleStylesNoRawSpacing(ix);
858
+ expect(findings).toHaveLength(1);
859
+ expect(findings[0].fix).toBeUndefined();
860
+ expect(findings[0].attributes).toMatchObject({
861
+ rawValue: "1.1em",
862
+ normalizedValue: 17.6,
863
+ assumedEmBasePx: 16,
864
+ suggestedValue: "16px",
865
+ });
866
+ });
867
+
868
+ it("emits a (non-deterministic) rem snap suggestion when the root font size is explicit", () => {
869
+ const ix = new FactIndex();
870
+ ix.addMany(
871
+ compileGlobalGovernanceFacts({
872
+ scales: {
873
+ space: {
874
+ kind: "scale",
875
+ unit: "px",
876
+ values: [0, 14, 28],
877
+ rootFontSizePx: 14,
878
+ },
879
+ },
880
+ styles: [
881
+ g.styles.rawSpacing().mustMatchScale("space", {
882
+ appliesTo: ["padding"],
883
+ severity: "error",
884
+ }),
885
+ ],
886
+ })
887
+ );
888
+ ix.add(
889
+ makeStyleDeclarationFact({
890
+ file: "libs/ui/x.scss",
891
+ selector: ".rem",
892
+ declarationPath: "0",
893
+ property: "padding",
894
+ value: "1.1rem",
895
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
896
+ })
897
+ );
898
+
899
+ const findings = ruleStylesNoRawSpacing(ix);
900
+ expect(findings).toHaveLength(1);
901
+ expect(findings[0].fix).toMatchObject({
902
+ value: "14px",
903
+ deterministic: false,
904
+ });
905
+ expect(findings[0].attributes).toMatchObject({
906
+ suggestedValue: "14px",
907
+ });
908
+ expect(Number(findings[0].attributes?.normalizedValue)).toBeCloseTo(15.4);
909
+ });
910
+
911
+ it("does not fire for properties without a scale policy", () => {
912
+ const ix = emptyIndexWithGovernance();
913
+ ix.add(
914
+ makeStyleDeclarationFact({
915
+ file: "libs/ui/x.scss",
916
+ selector: ".x",
917
+ declarationPath: "0",
918
+ property: "border-radius",
919
+ value: "13px",
920
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
921
+ })
922
+ );
923
+
924
+ expect(ruleStylesNoRawSpacing(ix)).toHaveLength(0);
925
+ });
926
+
927
+ it("does not fire for var(--token) references", () => {
928
+ const ix = emptyIndexWithGovernance();
929
+ ix.add(
930
+ makeStyleDeclarationFact({
931
+ file: "libs/ui/x.scss",
932
+ selector: ".x",
933
+ declarationPath: "0",
934
+ property: "padding",
935
+ value: "var(--fui-space-3)",
936
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
937
+ })
938
+ );
939
+
940
+ expect(ruleStylesNoRawSpacing(ix)).toHaveLength(0);
941
+ });
942
+
943
+ it("allows negative values whose magnitude is on the configured scale", () => {
944
+ const ix = emptyIndexWithGovernance();
945
+ ix.add(
946
+ makeStyleDeclarationFact({
947
+ file: "libs/ui/x.scss",
948
+ selector: ".x",
949
+ declarationPath: "0",
950
+ property: "margin",
951
+ value: "-4px",
952
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
953
+ })
954
+ );
955
+
956
+ expect(ruleStylesNoRawSpacing(ix)).toHaveLength(0);
957
+ });
958
+ });
959
+
960
+ // ---------------------------------------------------------------------------
961
+ // a11y/required-accessible-name
962
+ // ---------------------------------------------------------------------------
963
+
964
+ describe("a11y/required-accessible-name", () => {
965
+ it("does not fire when there is a non-empty text child", () => {
966
+ const ix = emptyIndexWithGovernance();
967
+ addButtonUsage(ix, {
968
+ file: "apps/x.tsx",
969
+ nodePath: "0",
970
+ line: 1,
971
+ column: 0,
972
+ text: "Continue",
973
+ });
974
+
975
+ expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
976
+ });
977
+
978
+ it("does not fire with aria-label, aria-labelledby, or title", () => {
979
+ const cases = [
980
+ { prop: "aria-label", value: "Save" },
981
+ { prop: "aria-labelledby", resolution: "dynamic" as const },
982
+ { prop: "title", value: "Save" },
983
+ ];
984
+ for (const propInput of cases) {
985
+ const ix = emptyIndexWithGovernance();
986
+ addButtonUsage(ix, {
987
+ file: "apps/x.tsx",
988
+ nodePath: "0",
989
+ line: 1,
990
+ column: 0,
991
+ props: [propInput],
992
+ });
993
+ expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
994
+ }
995
+ });
996
+
997
+ it("fires for an empty Button with no name source", () => {
998
+ const ix = emptyIndexWithGovernance();
999
+ addButtonUsage(ix, {
1000
+ file: "apps/x.tsx",
1001
+ nodePath: "0",
1002
+ line: 7,
1003
+ column: 4,
1004
+ });
1005
+
1006
+ const findings = ruleA11yRequiredAccessibleName(ix);
1007
+ expect(findings).toHaveLength(1);
1008
+ expect(findings[0].location).toMatchObject({ line: 7, column: 4 });
1009
+ });
1010
+
1011
+ it("does not fire for aria-hidden=true", () => {
1012
+ const ix = emptyIndexWithGovernance();
1013
+ addButtonUsage(ix, {
1014
+ file: "apps/x.tsx",
1015
+ nodePath: "0",
1016
+ line: 1,
1017
+ column: 0,
1018
+ props: [{ prop: "aria-hidden", value: true }],
1019
+ });
1020
+
1021
+ expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0);
1022
+ });
1023
+ });
1024
+
1025
+ // ---------------------------------------------------------------------------
1026
+ // Phase 5 vertical slice — runRules over the canonical fixture
1027
+ // ---------------------------------------------------------------------------
1028
+
1029
+ describe("runRules — Phase 5 vertical slice", () => {
1030
+ it("produces three findings for the canonical checkout fixture", () => {
1031
+ const ix = emptyIndexWithGovernance();
1032
+ // Mirrors the brief's fixture:
1033
+ // <Button variant="secondary" style={{ padding: 13, color: '#2563eb' }}>
1034
+ // Continue
1035
+ // </Button>
1036
+ addButtonUsage(ix, {
1037
+ file: "apps/checkout/page.tsx",
1038
+ nodePath: "0/0",
1039
+ line: 42,
1040
+ column: 8,
1041
+ props: [{ prop: "variant", value: "secondary" }],
1042
+ inlineStyles: [
1043
+ { property: "padding", valueKind: "number", value: "13" },
1044
+ { property: "color", valueKind: "static", value: "#2563eb" },
1045
+ ],
1046
+ text: "Continue",
1047
+ });
1048
+
1049
+ const findings = runRules(ix);
1050
+ const ruleIds = findings.map((f) => f.ruleId).sort();
1051
+ expect(ruleIds).toEqual([
1052
+ "components/forbidden-prop-value",
1053
+ "styles/no-raw-color",
1054
+ "styles/no-raw-spacing",
1055
+ ]);
1056
+ for (const finding of findings) {
1057
+ expect(finding.evidence.length).toBeGreaterThan(0);
1058
+ expect(finding.fingerprint).toMatch(/^[0-9a-f]{16}$/);
1059
+ }
1060
+ });
1061
+
1062
+ it("emits stable fingerprints across runs", () => {
1063
+ function buildIndex(): FactIndex {
1064
+ const ix = emptyIndexWithGovernance();
1065
+ addButtonUsage(ix, {
1066
+ file: "apps/checkout/page.tsx",
1067
+ nodePath: "0/0",
1068
+ line: 42,
1069
+ column: 8,
1070
+ props: [{ prop: "variant", value: "secondary" }],
1071
+ inlineStyles: [
1072
+ { property: "padding", valueKind: "number", value: "13" },
1073
+ { property: "color", valueKind: "static", value: "#2563eb" },
1074
+ ],
1075
+ text: "Continue",
1076
+ });
1077
+ return ix;
1078
+ }
1079
+
1080
+ const a = runRules(buildIndex()).map((f) => f.fingerprint);
1081
+ const b = runRules(buildIndex()).map((f) => f.fingerprint);
1082
+ expect(b).toEqual(a);
1083
+ });
1084
+
1085
+ it("emits the same fingerprint when the line number changes (fingerprint is content-addressed)", () => {
1086
+ function buildIndex(line: number): FactIndex {
1087
+ const ix = emptyIndexWithGovernance();
1088
+ addButtonUsage(ix, {
1089
+ file: "apps/checkout/page.tsx",
1090
+ nodePath: "0/0",
1091
+ line,
1092
+ column: 8,
1093
+ props: [{ prop: "variant", value: "secondary" }],
1094
+ text: "Continue",
1095
+ });
1096
+ return ix;
1097
+ }
1098
+
1099
+ const baseline = runRules(buildIndex(42));
1100
+ const moved = runRules(buildIndex(99));
1101
+ expect(moved.map((f) => f.fingerprint)).toEqual(baseline.map((f) => f.fingerprint));
1102
+ expect(moved[0].location.line).toBe(99);
1103
+ expect(baseline[0].location.line).toBe(42);
1104
+ });
1105
+
1106
+ it("every finding's evidence is non-empty and resolvable in the index", () => {
1107
+ const ix = emptyIndexWithGovernance();
1108
+ addButtonUsage(ix, {
1109
+ file: "apps/checkout/page.tsx",
1110
+ nodePath: "0/0",
1111
+ line: 1,
1112
+ column: 0,
1113
+ props: [{ prop: "variant", value: "secondary" }],
1114
+ inlineStyles: [{ property: "color", valueKind: "static", value: "#2563eb" }],
1115
+ });
1116
+
1117
+ const findings = runRules(ix);
1118
+ expect(findings.length).toBeGreaterThan(0);
1119
+ for (const finding of findings) {
1120
+ expect(finding.evidence.length).toBeGreaterThan(0);
1121
+ for (const evidence of finding.evidence) {
1122
+ expect(ix.has(evidence.factId)).toBe(true);
1123
+ }
1124
+ }
1125
+ });
1126
+ });
1127
+
1128
+ // ---------------------------------------------------------------------------
1129
+ // Phase 6 — deterministic fix payloads on rules
1130
+ // ---------------------------------------------------------------------------
1131
+
1132
+ describe("Phase 6 fix payloads", () => {
1133
+ it("styles/no-raw-spacing emits a nearest-scale replaceStyleValue fix for SCSS", () => {
1134
+ const ix = emptyIndexWithGovernance();
1135
+ ix.add(
1136
+ makeStyleDeclarationFact({
1137
+ file: "libs/ui/x.scss",
1138
+ selector: ".bad",
1139
+ declarationPath: "0",
1140
+ property: "padding",
1141
+ value: "13px",
1142
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1143
+ })
1144
+ );
1145
+
1146
+ const findings = ruleStylesNoRawSpacing(ix);
1147
+ expect(findings).toHaveLength(1);
1148
+ // Off-scale snaps change the rendered value, so the fix is a suggestion,
1149
+ // not a deterministic auto-apply (BUG 13 — no silent layout change).
1150
+ expect(findings[0].fix).toEqual({
1151
+ kind: "replaceStyleValue",
1152
+ title: "Replace padding with 12px",
1153
+ property: "padding",
1154
+ value: "12px",
1155
+ deterministic: false,
1156
+ });
1157
+ expect(findings[0].attributes).toMatchObject({ suggestedValue: "12px" });
1158
+ });
1159
+
1160
+ it("styles/no-raw-spacing emits a bare-number fix for inline numeric props", () => {
1161
+ const ix = emptyIndexWithGovernance();
1162
+ addButtonUsage(ix, {
1163
+ file: "apps/checkout/page.tsx",
1164
+ nodePath: "0/0",
1165
+ line: 42,
1166
+ column: 8,
1167
+ inlineStyles: [{ property: "padding", valueKind: "number", value: "13" }],
1168
+ text: "x",
1169
+ });
1170
+
1171
+ const findings = ruleStylesNoRawSpacing(ix);
1172
+ expect(findings).toHaveLength(1);
1173
+ expect(findings[0].fix).toEqual({
1174
+ kind: "replaceStyleValue",
1175
+ title: "Replace padding with 12",
1176
+ property: "padding",
1177
+ value: "12",
1178
+ deterministic: false,
1179
+ });
1180
+ });
1181
+
1182
+ it("styles/no-raw-spacing flags but does NOT auto-snap a non-zero value to 0", () => {
1183
+ const ix = emptyIndexWithGovernance();
1184
+ ix.add(
1185
+ makeStyleDeclarationFact({
1186
+ file: "libs/ui/x.scss",
1187
+ selector: ".tiny",
1188
+ declarationPath: "0",
1189
+ property: "padding",
1190
+ value: "2px",
1191
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1192
+ })
1193
+ );
1194
+
1195
+ const findings = ruleStylesNoRawSpacing(ix);
1196
+ expect(findings).toHaveLength(1);
1197
+ // 2px's nearest scale value is 0 — snapping would silently remove the
1198
+ // padding, so no deterministic fix is offered.
1199
+ expect(findings[0].fix).toBeUndefined();
1200
+ });
1201
+
1202
+ it("styles/no-raw-spacing snaps to the nearest allowed value (ties break low)", () => {
1203
+ const ix = emptyIndexWithGovernance();
1204
+ ix.add(
1205
+ makeStyleDeclarationFact({
1206
+ file: "libs/ui/x.scss",
1207
+ selector: ".tie",
1208
+ declarationPath: "0",
1209
+ property: "margin",
1210
+ value: "10px",
1211
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1212
+ })
1213
+ );
1214
+
1215
+ const findings = ruleStylesNoRawSpacing(ix);
1216
+ expect(findings).toHaveLength(1);
1217
+ // Scale is [0, 4, 8, 12, 16, 20, 24, 32]; 10 is equidistant from 8 and 12,
1218
+ // tie breaks low → 8px.
1219
+ expect(findings[0].fix).toMatchObject({ value: "8px" });
1220
+ });
1221
+
1222
+ it("styles/no-raw-spacing preserves negative signs in nearest-scale fixes", () => {
1223
+ const ix = emptyIndexWithGovernance();
1224
+ ix.add(
1225
+ makeStyleDeclarationFact({
1226
+ file: "libs/ui/x.scss",
1227
+ selector: ".tie",
1228
+ declarationPath: "0",
1229
+ property: "margin",
1230
+ value: "-10px",
1231
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1232
+ })
1233
+ );
1234
+
1235
+ const findings = ruleStylesNoRawSpacing(ix);
1236
+ expect(findings).toHaveLength(1);
1237
+ expect(findings[0].fix).toMatchObject({ value: "-8px" });
1238
+ expect(findings[0].attributes).toMatchObject({ suggestedValue: "-8px" });
1239
+ });
1240
+
1241
+ it("styles/no-raw-color emits a token-substitution fix when a color token matches", () => {
1242
+ const ix = emptyIndexWithGovernance();
1243
+ ix.add(
1244
+ makeTokenDefinitionFact({
1245
+ name: "--fui-color-accent",
1246
+ value: "#2563eb",
1247
+ category: "color",
1248
+ })
1249
+ );
1250
+ ix.add(
1251
+ makeStyleDeclarationFact({
1252
+ file: "libs/ui/x.scss",
1253
+ selector: ".x",
1254
+ declarationPath: "0",
1255
+ property: "color",
1256
+ value: "#2563eb",
1257
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1258
+ })
1259
+ );
1260
+
1261
+ const findings = ruleStylesNoRawColor(ix);
1262
+ expect(findings).toHaveLength(1);
1263
+ expect(findings[0].fix).toEqual({
1264
+ kind: "replaceStyleValue",
1265
+ title: "Replace color with var(--fui-color-accent)",
1266
+ property: "color",
1267
+ value: "var(--fui-color-accent)",
1268
+ deterministic: true,
1269
+ });
1270
+ expect(findings[0].attributes).toMatchObject({ suggestedToken: "--fui-color-accent" });
1271
+ });
1272
+
1273
+ it("styles/no-raw-color prefers a role-matching token when several share a value", () => {
1274
+ const ix = emptyIndexWithGovernance();
1275
+ ix.addMany([
1276
+ makeTokenDefinitionFact({ name: "--color-bg", value: "#ffffff", category: "color" }),
1277
+ makeTokenDefinitionFact({ name: "--color-text", value: "#ffffff", category: "color" }),
1278
+ ]);
1279
+ ix.add(
1280
+ makeStyleDeclarationFact({
1281
+ file: "libs/ui/x.scss",
1282
+ selector: ".x",
1283
+ declarationPath: "0",
1284
+ property: "color",
1285
+ value: "#ffffff",
1286
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1287
+ })
1288
+ );
1289
+
1290
+ const findings = ruleStylesNoRawColor(ix);
1291
+ expect(findings).toHaveLength(1);
1292
+ // Foreground property -> the text token, not the background token.
1293
+ expect(findings[0].fix).toMatchObject({ value: "var(--color-text)", deterministic: true });
1294
+ });
1295
+
1296
+ it("styles/no-raw-color emits no fix when no token matches the raw color", () => {
1297
+ const ix = emptyIndexWithGovernance();
1298
+ ix.add(
1299
+ makeTokenDefinitionFact({
1300
+ name: "--fui-color-accent",
1301
+ value: "#2563eb",
1302
+ category: "color",
1303
+ })
1304
+ );
1305
+ ix.add(
1306
+ makeStyleDeclarationFact({
1307
+ file: "libs/ui/x.scss",
1308
+ selector: ".x",
1309
+ declarationPath: "0",
1310
+ property: "color",
1311
+ value: "#ff00ff",
1312
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1313
+ })
1314
+ );
1315
+
1316
+ const findings = ruleStylesNoRawColor(ix);
1317
+ expect(findings).toHaveLength(1);
1318
+ expect(findings[0].fix).toBeUndefined();
1319
+ expect(findings[0].attributes).toMatchObject({ suggestedToken: undefined });
1320
+ });
1321
+
1322
+ it("styles/no-raw-color suggests the nearest token when no exact value matches", () => {
1323
+ const ix = emptyIndexWithGovernance();
1324
+ ix.addMany([
1325
+ makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color" }),
1326
+ makeTokenDefinitionFact({ name: "--fui-color-danger", value: "#dc2626", category: "color" }),
1327
+ ]);
1328
+ ix.add(
1329
+ makeStyleDeclarationFact({
1330
+ file: "libs/ui/x.scss",
1331
+ selector: ".x",
1332
+ declarationPath: "0",
1333
+ property: "color",
1334
+ value: "#2563f0", // one shade off the accent token
1335
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1336
+ })
1337
+ );
1338
+
1339
+ const findings = ruleStylesNoRawColor(ix);
1340
+ expect(findings).toHaveLength(1);
1341
+ expect(findings[0].message).toContain("Closest token is `--fui-color-accent` (#2563eb)");
1342
+ // A nearest match is a hint, never a fix — snapping changes the rendered color.
1343
+ expect(findings[0].fix).toBeUndefined();
1344
+ expect(findings[0].attributes).toMatchObject({
1345
+ suggestedToken: "--fui-color-accent",
1346
+ tokenMatch: "nearest",
1347
+ });
1348
+ });
1349
+
1350
+ it("styles/no-raw-color suggests nothing when the nearest token is a different hue", () => {
1351
+ const ix = emptyIndexWithGovernance();
1352
+ ix.add(
1353
+ makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color" })
1354
+ );
1355
+ ix.add(
1356
+ makeStyleDeclarationFact({
1357
+ file: "libs/ui/x.scss",
1358
+ selector: ".x",
1359
+ declarationPath: "0",
1360
+ property: "color",
1361
+ value: "#ff0000",
1362
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1363
+ })
1364
+ );
1365
+
1366
+ const findings = ruleStylesNoRawColor(ix);
1367
+ expect(findings).toHaveLength(1);
1368
+ expect(findings[0].message).not.toContain("Closest token");
1369
+ expect(findings[0].attributes).toMatchObject({ suggestedToken: undefined });
1370
+ });
1371
+
1372
+ it("styles/no-raw-color marks exact value matches with tokenMatch: exact", () => {
1373
+ const ix = emptyIndexWithGovernance();
1374
+ ix.add(
1375
+ makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color" })
1376
+ );
1377
+ ix.add(
1378
+ makeStyleDeclarationFact({
1379
+ file: "libs/ui/x.scss",
1380
+ selector: ".x",
1381
+ declarationPath: "0",
1382
+ property: "color",
1383
+ value: "#2563eb",
1384
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1385
+ })
1386
+ );
1387
+
1388
+ const findings = ruleStylesNoRawColor(ix);
1389
+ expect(findings[0].attributes).toMatchObject({ tokenMatch: "exact" });
1390
+ });
1391
+
1392
+ it("styles/no-raw-color normalizes 3-digit hex when matching tokens", () => {
1393
+ const ix = emptyIndexWithGovernance();
1394
+ ix.add(
1395
+ makeTokenDefinitionFact({
1396
+ name: "--fui-color-white",
1397
+ value: "#ffffff",
1398
+ category: "color",
1399
+ })
1400
+ );
1401
+ ix.add(
1402
+ makeStyleDeclarationFact({
1403
+ file: "libs/ui/x.scss",
1404
+ selector: ".x",
1405
+ declarationPath: "0",
1406
+ property: "background",
1407
+ value: "#FFF",
1408
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1409
+ })
1410
+ );
1411
+
1412
+ const findings = ruleStylesNoRawColor(ix);
1413
+ expect(findings[0].fix).toMatchObject({ value: "var(--fui-color-white)" });
1414
+ });
1415
+
1416
+ it("styles/no-raw-color preserves shorthand declarations when fixing embedded colors", () => {
1417
+ const ix = emptyIndexWithGovernance();
1418
+ ix.add(
1419
+ makeTokenDefinitionFact({
1420
+ name: "--fui-color-accent",
1421
+ value: "#2563eb",
1422
+ category: "color",
1423
+ })
1424
+ );
1425
+ ix.add(
1426
+ makeStyleDeclarationFact({
1427
+ file: "libs/ui/x.scss",
1428
+ selector: ".x",
1429
+ declarationPath: "0",
1430
+ property: "border",
1431
+ value: "1px solid #2563eb",
1432
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1433
+ })
1434
+ );
1435
+
1436
+ const findings = ruleStylesNoRawColor(ix);
1437
+ expect(findings[0].fix).toMatchObject({
1438
+ value: "1px solid var(--fui-color-accent)",
1439
+ });
1440
+ });
1441
+
1442
+ it("styles/no-raw-color emits a fix for inline JSX colors when a token matches", () => {
1443
+ const ix = emptyIndexWithGovernance();
1444
+ ix.add(
1445
+ makeTokenDefinitionFact({
1446
+ name: "--fui-color-accent",
1447
+ value: "#2563eb",
1448
+ category: "color",
1449
+ })
1450
+ );
1451
+ addButtonUsage(ix, {
1452
+ file: "apps/x.tsx",
1453
+ nodePath: "0",
1454
+ line: 1,
1455
+ column: 0,
1456
+ inlineStyles: [{ property: "color", valueKind: "static", value: "#2563eb" }],
1457
+ text: "x",
1458
+ });
1459
+
1460
+ const findings = ruleStylesNoRawColor(ix);
1461
+ expect(findings).toHaveLength(1);
1462
+ expect(findings[0].fix).toEqual({
1463
+ kind: "replaceStyleValue",
1464
+ title: "Replace color with var(--fui-color-accent)",
1465
+ property: "color",
1466
+ value: "var(--fui-color-accent)",
1467
+ deterministic: true,
1468
+ });
1469
+ });
1470
+
1471
+ it("token-match fix evidence resolves through the index (token fact is included)", () => {
1472
+ const ix = emptyIndexWithGovernance();
1473
+ ix.add(
1474
+ makeTokenDefinitionFact({
1475
+ name: "--fui-color-accent",
1476
+ value: "#2563eb",
1477
+ category: "color",
1478
+ })
1479
+ );
1480
+ ix.add(
1481
+ makeStyleDeclarationFact({
1482
+ file: "libs/ui/x.scss",
1483
+ selector: ".x",
1484
+ declarationPath: "0",
1485
+ property: "color",
1486
+ value: "#2563eb",
1487
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
1488
+ })
1489
+ );
1490
+
1491
+ const findings = ruleStylesNoRawColor(ix);
1492
+ expect(findings).toHaveLength(1);
1493
+ expect(findings[0].evidence.length).toBeGreaterThanOrEqual(3);
1494
+ for (const ev of findings[0].evidence) {
1495
+ expect(ix.has(ev.factId)).toBe(true);
1496
+ }
1497
+ });
1498
+ });
1499
+
1500
+ describe("tokens/require-dual-fallback", () => {
1501
+ it("flags CSS token vars without SCSS fallbacks", () => {
1502
+ const ix = new FactIndex();
1503
+ ix.addMany(
1504
+ compileGlobalGovernanceFacts({
1505
+ rules: {
1506
+ "tokens/require-dual-fallback": { enabled: true, severity: "error" },
1507
+ },
1508
+ })
1509
+ );
1510
+ ix.addMany([
1511
+ makeTokenDefinitionFact({
1512
+ name: "--fui-color-brand",
1513
+ value: "#123456",
1514
+ category: "color",
1515
+ }),
1516
+ makeStyleDeclarationFact({
1517
+ file: "src/button.scss",
1518
+ selector: ".button",
1519
+ declarationPath: "0",
1520
+ property: "color",
1521
+ value: "var(--fui-color-brand)",
1522
+ location: { file: "src/button.scss", line: 2, column: 2 },
1523
+ }),
1524
+ ]);
1525
+
1526
+ const findings = ruleTokensRequireDualFallback(ix);
1527
+ expect(findings).toHaveLength(1);
1528
+ expect(findings[0]).toMatchObject({
1529
+ ruleId: "tokens/require-dual-fallback",
1530
+ code: "FUI2003",
1531
+ severity: "serious",
1532
+ fix: {
1533
+ kind: "replaceStyleValue",
1534
+ value: "var(--fui-color-brand, $fui-color-brand)",
1535
+ },
1536
+ });
1537
+ });
1538
+
1539
+ it("does NOT fire on plain .css/.module.css files (no Sass pipeline)", () => {
1540
+ for (const file of ["src/button.css", "src/button.module.css", "src/Button.tsx"]) {
1541
+ const ix = new FactIndex();
1542
+ ix.addMany(
1543
+ compileGlobalGovernanceFacts({
1544
+ rules: { "tokens/require-dual-fallback": { enabled: true, severity: "error" } },
1545
+ })
1546
+ );
1547
+ ix.addMany([
1548
+ makeTokenDefinitionFact({ name: "--fui-color-brand", value: "#123456", category: "color" }),
1549
+ makeStyleDeclarationFact({
1550
+ file,
1551
+ selector: ".button",
1552
+ declarationPath: "0",
1553
+ property: "color",
1554
+ value: "var(--fui-color-brand)",
1555
+ location: { file, line: 2, column: 2 },
1556
+ }),
1557
+ ]);
1558
+ expect(ruleTokensRequireDualFallback(ix), `should skip ${file}`).toHaveLength(0);
1559
+ }
1560
+ });
1561
+ });
1562
+
1563
+ describe("theme/no-theme-coupled-literal", () => {
1564
+ it("flags theme-sensitive literal colors in custom shadows", () => {
1565
+ const ix = new FactIndex();
1566
+ ix.addMany(
1567
+ compileGlobalGovernanceFacts({
1568
+ rules: {
1569
+ "theme/no-theme-coupled-literal": {
1570
+ enabled: true,
1571
+ severity: "warning",
1572
+ },
1573
+ },
1574
+ })
1575
+ );
1576
+ ix.add(
1577
+ makeStyleDeclarationFact({
1578
+ file: "src/card.scss",
1579
+ selector: ".card",
1580
+ declarationPath: "0",
1581
+ property: "box-shadow",
1582
+ value: "0 0 0 1px rgba(0, 0, 0, 0.16)",
1583
+ location: { file: "src/card.scss", line: 4, column: 2 },
1584
+ })
1585
+ );
1586
+
1587
+ const findings = ruleThemeNoThemeCoupledLiteral(ix);
1588
+ expect(findings).toHaveLength(1);
1589
+ expect(findings[0]).toMatchObject({
1590
+ ruleId: "theme/no-theme-coupled-literal",
1591
+ code: "FUI2014",
1592
+ attributes: {
1593
+ color: "rgba(0, 0, 0, 0.16)",
1594
+ },
1595
+ });
1596
+ });
1597
+ });
1598
+
1599
+ describe("components/prefer-library", () => {
1600
+ function preferLibraryIndex() {
1601
+ const ix = new FactIndex();
1602
+ ix.addMany(
1603
+ compileGlobalGovernanceFacts({
1604
+ rules: {
1605
+ "components/prefer-library": {
1606
+ enabled: true,
1607
+ severity: "warning",
1608
+ options: {
1609
+ canonicalSources: [
1610
+ {
1611
+ kind: "npm",
1612
+ specifier: "@acme/ui",
1613
+ include: [
1614
+ "Alert",
1615
+ "Button",
1616
+ "Checkbox",
1617
+ "Collapsible",
1618
+ "IconButton",
1619
+ "NumberInput",
1620
+ "Radio",
1621
+ "Select",
1622
+ "Table",
1623
+ ],
1624
+ },
1625
+ ],
1626
+ },
1627
+ },
1628
+ },
1629
+ })
1630
+ );
1631
+ return ix;
1632
+ }
1633
+
1634
+ function addRawNode(
1635
+ ix: FactIndex,
1636
+ input: {
1637
+ element: string;
1638
+ nodePath: string;
1639
+ role?: string;
1640
+ interactive?: boolean;
1641
+ props?: Array<{ prop: string; value?: unknown; resolution?: "static" | "dynamic" | "jsx" }>;
1642
+ text?: string;
1643
+ }
1644
+ ) {
1645
+ const node = makeUsageNodeFact({
1646
+ file: "src/app.tsx",
1647
+ nodePath: input.nodePath,
1648
+ element: input.element,
1649
+ role: input.role,
1650
+ interactive: input.interactive,
1651
+ location: { file: "src/app.tsx", line: 5, column: 4 },
1652
+ });
1653
+ ix.add(node);
1654
+ for (const prop of input.props ?? []) {
1655
+ ix.add(
1656
+ makeUsagePropResolvedFact({
1657
+ nodeId: node.id,
1658
+ prop: prop.prop,
1659
+ resolution: prop.resolution ?? "static",
1660
+ value: prop.value,
1661
+ })
1662
+ );
1663
+ }
1664
+ if (input.text !== undefined) {
1665
+ ix.add(makeUsageTextChildFact({ nodeId: node.id, text: input.text, index: 0 }));
1666
+ }
1667
+ return node;
1668
+ }
1669
+
1670
+ it("caps advisory-tier severity at warn even when the rule is set to error (only confident tiers escalate)", () => {
1671
+ const ix = new FactIndex();
1672
+ ix.addMany(
1673
+ compileGlobalGovernanceFacts({
1674
+ rules: {
1675
+ "components/prefer-library": {
1676
+ enabled: true,
1677
+ severity: "error",
1678
+ options: {
1679
+ canonicalSources: [
1680
+ { kind: "npm", specifier: "@acme/ui", include: ["Button", "Select"] },
1681
+ ],
1682
+ },
1683
+ },
1684
+ },
1685
+ })
1686
+ );
1687
+ addRawNode(ix, { element: "button", nodePath: "0:0", interactive: true, text: "Go" });
1688
+ addRawNode(ix, { element: "select", nodePath: "0:1" });
1689
+
1690
+ const byRaw = Object.fromEntries(
1691
+ ruleComponentsPreferLibrary(ix).map((finding) => [finding.attributes?.rawValue, finding])
1692
+ );
1693
+ // Confident exact-html <button> → Button inherits the escalated error level…
1694
+ expect(byRaw.button?.level).toBe("error");
1695
+ expect(byRaw.button?.attributes?.advisory).toBeUndefined();
1696
+ // …but the advisory html-advisory <select> → Select is capped back to warn and
1697
+ // flagged advisory, so it can never hard-block or hard-fail CI.
1698
+ expect(byRaw.select?.level).toBe("warn");
1699
+ expect(byRaw.select?.attributes?.advisory).toBe(true);
1700
+ });
1701
+
1702
+ it("advisory-flags a plain <div className={styles.alert}> as a hand-rolled primitive (reimplements-primitive)", () => {
1703
+ const ix = preferLibraryIndex();
1704
+ const alertDiv = addRawNode(ix, { element: "div", nodePath: "0:2" });
1705
+ ix.add(
1706
+ makeClassNameDynamicFact({
1707
+ file: "src/app.tsx",
1708
+ nodeId: alertDiv.id,
1709
+ attr: "className",
1710
+ reason: "identifier",
1711
+ snippet: "styles.alert",
1712
+ originPath: "0:2",
1713
+ location: { file: "src/app.tsx", line: 6, column: 4 },
1714
+ })
1715
+ );
1716
+ // `Table` is canonical, but `table` is a common layout class — the denylist
1717
+ // must keep it from flagging (the heuristic's low-noise guarantee).
1718
+ const tableDiv = addRawNode(ix, { element: "div", nodePath: "0:3" });
1719
+ ix.add(
1720
+ makeClassNameDynamicFact({
1721
+ file: "src/app.tsx",
1722
+ nodeId: tableDiv.id,
1723
+ attr: "className",
1724
+ reason: "identifier",
1725
+ snippet: "styles.table",
1726
+ originPath: "0:3",
1727
+ location: { file: "src/app.tsx", line: 7, column: 4 },
1728
+ })
1729
+ );
1730
+
1731
+ const reimpls = ruleComponentsPreferLibrary(ix).filter(
1732
+ (finding) => finding.attributes?.precisionTier === "classname-reimpl"
1733
+ );
1734
+ expect(reimpls).toHaveLength(1);
1735
+ expect(reimpls[0].attributes?.suggestedComponent).toBe("Alert");
1736
+ expect(reimpls[0].attributes?.matchedClass).toBe("alert");
1737
+ expect(reimpls[0].attributes?.advisory).toBe(true);
1738
+ // Advisory: capped at warn (never a hard error/block) and no auto-fix.
1739
+ expect(reimpls[0].level).toBe("warn");
1740
+ expect(reimpls[0].fix).toBeUndefined();
1741
+ });
1742
+
1743
+ it("does not flag imported bespoke components from bare package canonical sources without export inventory", () => {
1744
+ const ix = new FactIndex();
1745
+ ix.addMany(
1746
+ compileGlobalGovernanceFacts({
1747
+ rules: {
1748
+ "components/prefer-library": {
1749
+ enabled: true,
1750
+ severity: "warning",
1751
+ options: {
1752
+ canonicalSources: [{ kind: "npm", specifier: "@acme/ui" }],
1753
+ },
1754
+ },
1755
+ },
1756
+ })
1757
+ );
1758
+ const node = makeUsageNodeFact({
1759
+ file: "src/app.tsx",
1760
+ nodePath: "0:0",
1761
+ element: "Card",
1762
+ location: { file: "src/app.tsx", line: 5, column: 4 },
1763
+ });
1764
+ ix.add(node);
1765
+ ix.add(
1766
+ makeUsageImportFact({
1767
+ file: "src/app.tsx",
1768
+ local: "Card",
1769
+ imported: "Card",
1770
+ source: "../components/Card",
1771
+ location: { file: "src/app.tsx", line: 1, column: 0 },
1772
+ })
1773
+ );
1774
+
1775
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
1776
+ });
1777
+
1778
+ it("flags imported bespoke components when package canonical sources include the exported component", () => {
1779
+ const ix = new FactIndex();
1780
+ ix.addMany(
1781
+ compileGlobalGovernanceFacts({
1782
+ rules: {
1783
+ "components/prefer-library": {
1784
+ enabled: true,
1785
+ severity: "warning",
1786
+ options: {
1787
+ canonicalSources: [{ kind: "npm", specifier: "@acme/ui", include: ["Card"] }],
1788
+ },
1789
+ },
1790
+ },
1791
+ })
1792
+ );
1793
+ const node = makeUsageNodeFact({
1794
+ file: "src/app.tsx",
1795
+ nodePath: "0:0",
1796
+ element: "Card",
1797
+ location: { file: "src/app.tsx", line: 5, column: 4 },
1798
+ });
1799
+ ix.add(node);
1800
+ ix.add(
1801
+ makeUsageImportFact({
1802
+ file: "src/app.tsx",
1803
+ local: "Card",
1804
+ imported: "Card",
1805
+ source: "../components/Card",
1806
+ location: { file: "src/app.tsx", line: 1, column: 0 },
1807
+ })
1808
+ );
1809
+
1810
+ const findings = ruleComponentsPreferLibrary(ix);
1811
+ expect(findings).toHaveLength(1);
1812
+ expect(findings[0]).toMatchObject({
1813
+ ruleId: "components/prefer-library",
1814
+ code: "FUI1004",
1815
+ attributes: {
1816
+ suggestedComponent: "Card",
1817
+ suggestedImport: "@acme/ui",
1818
+ },
1819
+ });
1820
+ });
1821
+
1822
+ it("recognizes a current import through legacy npm source and mapping spellings", () => {
1823
+ const ix = new FactIndex();
1824
+ ix.addMany(
1825
+ compileGlobalGovernanceFacts({
1826
+ rules: {
1827
+ "components/prefer-library": {
1828
+ enabled: true,
1829
+ severity: "warning",
1830
+ options: {
1831
+ canonicalSources: [
1832
+ {
1833
+ kind: "npm",
1834
+ specifier: "@usefragments/ui",
1835
+ include: ["Card"],
1836
+ },
1837
+ ],
1838
+ canonicalMappings: [
1839
+ {
1840
+ name: "Card",
1841
+ canonical: "Card",
1842
+ importPath: "@usefragments/ui",
1843
+ },
1844
+ ],
1845
+ },
1846
+ },
1847
+ },
1848
+ })
1849
+ );
1850
+ const node = makeUsageNodeFact({
1851
+ file: "src/app.tsx",
1852
+ nodePath: "0:0",
1853
+ element: "Card",
1854
+ location: { file: "src/app.tsx", line: 5, column: 4 },
1855
+ });
1856
+ ix.add(node);
1857
+ ix.add(
1858
+ makeUsageImportFact({
1859
+ file: "src/app.tsx",
1860
+ local: "Card",
1861
+ imported: "Card",
1862
+ source: "@usefragments/ui",
1863
+ location: { file: "src/app.tsx", line: 1, column: 0 },
1864
+ })
1865
+ );
1866
+
1867
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
1868
+ });
1869
+
1870
+ it("does not flag bespoke components from bare registry canonical sources without export inventory", () => {
1871
+ const ix = new FactIndex();
1872
+ ix.addMany(
1873
+ compileGlobalGovernanceFacts({
1874
+ rules: {
1875
+ "components/prefer-library": {
1876
+ enabled: true,
1877
+ severity: "warning",
1878
+ options: {
1879
+ canonicalSources: [
1880
+ {
1881
+ kind: "registry",
1882
+ registryId: "fragments-ui",
1883
+ receiptPath: ".fragments/registry-lock.json",
1884
+ installPath: "src/fragments/ui",
1885
+ importPath: "@/fragments/ui",
1886
+ },
1887
+ ],
1888
+ },
1889
+ },
1890
+ },
1891
+ })
1892
+ );
1893
+ const node = makeUsageNodeFact({
1894
+ file: "src/app.tsx",
1895
+ nodePath: "0:0",
1896
+ element: "Card",
1897
+ location: { file: "src/app.tsx", line: 5, column: 4 },
1898
+ });
1899
+ ix.add(node);
1900
+ ix.add(
1901
+ makeUsageImportFact({
1902
+ file: "src/app.tsx",
1903
+ local: "Card",
1904
+ imported: "Card",
1905
+ source: "../components/Card",
1906
+ location: { file: "src/app.tsx", line: 1, column: 1 },
1907
+ })
1908
+ );
1909
+
1910
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
1911
+ });
1912
+
1913
+ it("flags bespoke components when registry canonical sources include the exported component", () => {
1914
+ const ix = new FactIndex();
1915
+ ix.addMany(
1916
+ compileGlobalGovernanceFacts({
1917
+ rules: {
1918
+ "components/prefer-library": {
1919
+ enabled: true,
1920
+ severity: "warning",
1921
+ options: {
1922
+ canonicalSources: [
1923
+ {
1924
+ kind: "registry",
1925
+ registryId: "fragments-ui",
1926
+ receiptPath: ".fragments/registry-lock.json",
1927
+ installPath: "src/fragments/ui",
1928
+ importPath: "@/fragments/ui",
1929
+ include: ["Card"],
1930
+ },
1931
+ ],
1932
+ },
1933
+ },
1934
+ },
1935
+ })
1936
+ );
1937
+ const node = makeUsageNodeFact({
1938
+ file: "src/app.tsx",
1939
+ nodePath: "0:0",
1940
+ element: "Card",
1941
+ location: { file: "src/app.tsx", line: 5, column: 4 },
1942
+ });
1943
+ ix.add(node);
1944
+ ix.add(
1945
+ makeUsageImportFact({
1946
+ file: "src/app.tsx",
1947
+ local: "Card",
1948
+ imported: "Card",
1949
+ source: "../components/Card",
1950
+ location: { file: "src/app.tsx", line: 1, column: 1 },
1951
+ })
1952
+ );
1953
+
1954
+ const findings = ruleComponentsPreferLibrary(ix);
1955
+ expect(findings).toHaveLength(1);
1956
+ expect(findings[0]).toMatchObject({
1957
+ ruleId: "components/prefer-library",
1958
+ attributes: {
1959
+ suggestedComponent: "Card",
1960
+ suggestedImport: "@/fragments/ui",
1961
+ suggestedImportSourceKind: "registry",
1962
+ },
1963
+ });
1964
+ });
1965
+
1966
+ it("recognizes a current import through a legacy registry importPath", () => {
1967
+ const ix = new FactIndex();
1968
+ ix.addMany(
1969
+ compileGlobalGovernanceFacts({
1970
+ rules: {
1971
+ "components/prefer-library": {
1972
+ enabled: true,
1973
+ severity: "warning",
1974
+ options: {
1975
+ canonicalSources: [
1976
+ {
1977
+ kind: "registry",
1978
+ registryId: "fragments-ui",
1979
+ receiptPath: ".fragments/registry-lock.json",
1980
+ installPath: "src/fragments/ui",
1981
+ importPath: "@usefragments/ui",
1982
+ include: ["Card"],
1983
+ },
1984
+ ],
1985
+ },
1986
+ },
1987
+ },
1988
+ })
1989
+ );
1990
+ const node = makeUsageNodeFact({
1991
+ file: "src/app.tsx",
1992
+ nodePath: "0:0",
1993
+ element: "Card",
1994
+ location: { file: "src/app.tsx", line: 5, column: 4 },
1995
+ });
1996
+ ix.add(node);
1997
+ ix.add(
1998
+ makeUsageImportFact({
1999
+ file: "src/app.tsx",
2000
+ local: "Card",
2001
+ imported: "Card",
2002
+ source: "@usefragments/ui/Card",
2003
+ location: { file: "src/app.tsx", line: 1, column: 0 },
2004
+ })
2005
+ );
2006
+
2007
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2008
+ });
2009
+
2010
+ it("respects canonical source include lists", () => {
2011
+ const ix = new FactIndex();
2012
+ ix.addMany(
2013
+ compileGlobalGovernanceFacts({
2014
+ rules: {
2015
+ "components/prefer-library": {
2016
+ enabled: true,
2017
+ severity: "warning",
2018
+ options: {
2019
+ canonicalSources: [
2020
+ {
2021
+ kind: "npm",
2022
+ specifier: "@acme/ui",
2023
+ include: ["Button"],
2024
+ },
2025
+ ],
2026
+ },
2027
+ },
2028
+ },
2029
+ })
2030
+ );
2031
+ addRawNode(ix, {
2032
+ element: "button",
2033
+ nodePath: "0:0",
2034
+ text: "Save",
2035
+ interactive: true,
2036
+ });
2037
+ const node = makeUsageNodeFact({
2038
+ file: "src/app.tsx",
2039
+ nodePath: "0:1",
2040
+ element: "MarketingCard",
2041
+ location: { file: "src/app.tsx", line: 8, column: 4 },
2042
+ });
2043
+ ix.add(node);
2044
+ ix.add(
2045
+ makeUsageImportFact({
2046
+ file: "src/app.tsx",
2047
+ local: "MarketingCard",
2048
+ imported: "MarketingCard",
2049
+ source: "../components/MarketingCard",
2050
+ location: { file: "src/app.tsx", line: 2, column: 0 },
2051
+ })
2052
+ );
2053
+
2054
+ const findings = ruleComponentsPreferLibrary(ix);
2055
+
2056
+ expect(findings).toHaveLength(1);
2057
+ expect(findings[0]).toMatchObject({
2058
+ ruleId: "components/prefer-library",
2059
+ attributes: {
2060
+ rawValue: "button",
2061
+ suggestedComponent: "Button",
2062
+ suggestedImport: "@acme/ui",
2063
+ },
2064
+ });
2065
+ });
2066
+
2067
+ it("does not flag raw primitives inside a canonical source implementation", () => {
2068
+ const ix = new FactIndex();
2069
+ ix.addMany(
2070
+ compileGlobalGovernanceFacts({
2071
+ rules: {
2072
+ "components/prefer-library": {
2073
+ enabled: true,
2074
+ severity: "warning",
2075
+ options: {
2076
+ canonicalSources: [
2077
+ {
2078
+ kind: "npm",
2079
+ specifier: "@usefragments/ui",
2080
+ implementationPath: "src/fragments/ui",
2081
+ include: ["Button"],
2082
+ },
2083
+ ],
2084
+ },
2085
+ },
2086
+ },
2087
+ })
2088
+ );
2089
+ ix.add(
2090
+ makeUsageNodeFact({
2091
+ file: "src/fragments/ui/components/Button/index.tsx",
2092
+ nodePath: "0:0",
2093
+ element: "button",
2094
+ interactive: true,
2095
+ location: {
2096
+ file: "src/fragments/ui/components/Button/index.tsx",
2097
+ line: 5,
2098
+ column: 4,
2099
+ },
2100
+ })
2101
+ );
2102
+
2103
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2104
+ });
2105
+
2106
+ it("matches canonical imports within the current file only", () => {
2107
+ const ix = new FactIndex();
2108
+ ix.addMany(
2109
+ compileGlobalGovernanceFacts({
2110
+ rules: {
2111
+ "components/prefer-library": {
2112
+ enabled: true,
2113
+ severity: "warning",
2114
+ options: {
2115
+ canonicalSources: [
2116
+ {
2117
+ kind: "npm",
2118
+ specifier: "@usefragments/ui",
2119
+ implementationPath: "src/fragments/ui",
2120
+ include: ["Table"],
2121
+ },
2122
+ ],
2123
+ },
2124
+ },
2125
+ },
2126
+ })
2127
+ );
2128
+ ix.add(
2129
+ makeUsageNodeFact({
2130
+ file: "src/page/settings.tsx",
2131
+ nodePath: "0:0",
2132
+ element: "Table",
2133
+ location: { file: "src/page/settings.tsx", line: 5, column: 4 },
2134
+ })
2135
+ );
2136
+ ix.add(
2137
+ makeUsageImportFact({
2138
+ file: "src/page/settings.tsx",
2139
+ local: "Table",
2140
+ imported: "Table",
2141
+ source: "@usefragments/ui",
2142
+ location: { file: "src/page/settings.tsx", line: 1, column: 0 },
2143
+ })
2144
+ );
2145
+ ix.add(
2146
+ makeUsageImportFact({
2147
+ file: "src/fragments/ui/components/DataTable/index.tsx",
2148
+ local: "Table",
2149
+ imported: "Table",
2150
+ source: "../Table",
2151
+ location: {
2152
+ file: "src/fragments/ui/components/DataTable/index.tsx",
2153
+ line: 1,
2154
+ column: 0,
2155
+ },
2156
+ })
2157
+ );
2158
+
2159
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2160
+ });
2161
+
2162
+ it("attaches a deterministic component fix from confirmed canonical mappings", () => {
2163
+ const ix = new FactIndex();
2164
+ ix.addMany(
2165
+ compileGlobalGovernanceFacts({
2166
+ rules: {
2167
+ "components/prefer-library": {
2168
+ enabled: true,
2169
+ severity: "warning",
2170
+ options: {
2171
+ canonicalMappings: [
2172
+ {
2173
+ name: "Button",
2174
+ canonical: "Button",
2175
+ htmlEquivalent: "button",
2176
+ importPath: "@acme/ui",
2177
+ propMapping: [{ rawProp: "className", canonicalProp: "variant" }],
2178
+ confidence: 0.94,
2179
+ },
2180
+ ],
2181
+ },
2182
+ },
2183
+ },
2184
+ })
2185
+ );
2186
+ ix.add(
2187
+ makeUsageNodeFact({
2188
+ file: "src/app.tsx",
2189
+ nodePath: "0:0",
2190
+ element: "button",
2191
+ location: { file: "src/app.tsx", line: 5, column: 4 },
2192
+ })
2193
+ );
2194
+
2195
+ const findings = ruleComponentsPreferLibrary(ix);
2196
+ expect(findings).toHaveLength(1);
2197
+ expect(findings[0]).toMatchObject({
2198
+ ruleId: "components/prefer-library",
2199
+ fix: {
2200
+ kind: "replaceComponent",
2201
+ from: "button",
2202
+ to: "Button",
2203
+ deterministic: true,
2204
+ },
2205
+ attributes: {
2206
+ suggestedComponent: "Button",
2207
+ suggestedImport: "@acme/ui",
2208
+ propMapping: [{ rawProp: "className", canonicalProp: "variant" }],
2209
+ },
2210
+ });
2211
+ });
2212
+
2213
+ it("does not flag generic <div>/<span> from an inferred component primitive (anti-flood)", () => {
2214
+ // A library component built on a <div> (Accordion) or <span> (Badge) must
2215
+ // NOT turn every container into a bespoke re-implementation of it. This was
2216
+ // the 10k-finding canonical-usage flood: div → Accordion, span → Badge.
2217
+ const ix = new FactIndex();
2218
+ ix.addMany(
2219
+ compileGlobalGovernanceFacts({
2220
+ rules: {
2221
+ "components/prefer-library": {
2222
+ enabled: true,
2223
+ severity: "warning",
2224
+ options: {
2225
+ canonicalMappings: [
2226
+ {
2227
+ name: "Accordion",
2228
+ canonical: "Accordion",
2229
+ htmlEquivalent: "div",
2230
+ importPath: "@acme/ui",
2231
+ },
2232
+ {
2233
+ name: "Badge",
2234
+ canonical: "Badge",
2235
+ htmlEquivalent: "span",
2236
+ importPath: "@acme/ui",
2237
+ },
2238
+ ],
2239
+ },
2240
+ },
2241
+ },
2242
+ })
2243
+ );
2244
+ addRawNode(ix, { element: "div", nodePath: "0:0" });
2245
+ addRawNode(ix, { element: "span", nodePath: "0:1" });
2246
+
2247
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2248
+ });
2249
+
2250
+ it("suppresses <button> → specialized component but keeps <button> → Button (agreement)", () => {
2251
+ function findingsFor(mapping: Record<string, unknown>) {
2252
+ const ix = new FactIndex();
2253
+ ix.addMany(
2254
+ compileGlobalGovernanceFacts({
2255
+ rules: {
2256
+ "components/prefer-library": {
2257
+ enabled: true,
2258
+ severity: "warning",
2259
+ options: { canonicalMappings: [mapping] },
2260
+ },
2261
+ },
2262
+ })
2263
+ );
2264
+ addRawNode(ix, { element: "button", nodePath: "0:0", text: "Save", interactive: true });
2265
+ return ruleComponentsPreferLibrary(ix);
2266
+ }
2267
+
2268
+ // <button> → Chip disagrees with the curated canonical (Button) → suppressed.
2269
+ expect(
2270
+ findingsFor({
2271
+ name: "Chip",
2272
+ canonical: "Chip",
2273
+ htmlEquivalent: "button",
2274
+ importPath: "@acme/ui",
2275
+ })
2276
+ ).toHaveLength(0);
2277
+
2278
+ // <button> → Button agrees with the curated canonical → still fires.
2279
+ const buttonFindings = findingsFor({
2280
+ name: "Button",
2281
+ canonical: "Button",
2282
+ htmlEquivalent: "button",
2283
+ importPath: "@acme/ui",
2284
+ });
2285
+ expect(buttonFindings).toHaveLength(1);
2286
+ expect(buttonFindings[0].attributes?.suggestedComponent).toBe("Button");
2287
+ });
2288
+
2289
+ describe("@usefragments/ui-style mapped sources — canonical≠suggestion divergence", () => {
2290
+ // Live-prod leak (post-#224): a Chip component declares an inverted
2291
+ // per-component `htmlEquivalent: "button"` and ships `canonical: "Button"`
2292
+ // (the correct enforceable canonical) but `name: "Chip"`. The #224 guard
2293
+ // validated the `canonical` field (Button → enforceable → passes) while the
2294
+ // rule EMITTED `suggestedComponent = mapping.name` ("Chip"). Result: a raw
2295
+ // <button> produced canonical:"Button" + suggestedComponent:"Chip" —
2296
+ // the divergence that was ~68% of the dogfood's remaining FUI1004 findings.
2297
+ // The emitted suggestion must equal the tag's enforceable canonical.
2298
+ function mappedFindings(mappings: Array<Record<string, unknown>>, element: string) {
2299
+ const ix = new FactIndex();
2300
+ ix.addMany(
2301
+ compileGlobalGovernanceFacts({
2302
+ rules: {
2303
+ "components/prefer-library": {
2304
+ enabled: true,
2305
+ severity: "warning",
2306
+ options: {
2307
+ canonicalSources: [{ kind: "npm", specifier: "@usefragments/ui" }],
2308
+ canonicalMappings: mappings,
2309
+ },
2310
+ },
2311
+ },
2312
+ })
2313
+ );
2314
+ addRawNode(ix, { element, nodePath: "0:0", text: "Save", interactive: true });
2315
+ return ruleComponentsPreferLibrary(ix);
2316
+ }
2317
+
2318
+ it("never emits suggestedComponent:'Chip' when canonical:'Button' but name:'Chip' (divergence)", () => {
2319
+ // ADVERSARIAL: without the fix, isEnforceableHtmlEquivalent validates the
2320
+ // `canonical` (Button) and lets the divergent `name` (Chip) leak as
2321
+ // suggestedComponent (the live-prod symptom: canonical:"Button" +
2322
+ // suggestedComponent:"Chip"). With the fix, enforceability is checked
2323
+ // against the emitted `name` ("Chip" !== "Button") so the divergent Chip
2324
+ // mapping never matches; the bare <button> instead resolves to its curated
2325
+ // canonical (Button). Either way: ZERO button→Chip findings, and any
2326
+ // finding that DOES fire must be button→Button (suggestion === canonical).
2327
+ const findings = mappedFindings(
2328
+ [
2329
+ {
2330
+ name: "Chip",
2331
+ canonical: "Button",
2332
+ htmlEquivalent: "button",
2333
+ importPath: "@usefragments/ui",
2334
+ },
2335
+ ],
2336
+ "button"
2337
+ );
2338
+ expect(findings.filter((f) => f.attributes?.suggestedComponent === "Chip")).toHaveLength(0);
2339
+ for (const finding of findings) {
2340
+ // Any finding that fires for a bare <button> must point at the curated
2341
+ // canonical (Button) — never the divergent Chip name. When the mapped
2342
+ // path emits the `canonical` attribute it must agree with the suggestion.
2343
+ expect(finding.attributes?.suggestedComponent).toBe("Button");
2344
+ if (finding.attributes?.canonical !== undefined) {
2345
+ expect(finding.attributes.canonical).toBe(finding.attributes.suggestedComponent);
2346
+ }
2347
+ }
2348
+ });
2349
+
2350
+ it("still fires <button> → Button (suggestion === canonical)", () => {
2351
+ const findings = mappedFindings(
2352
+ [
2353
+ {
2354
+ name: "Button",
2355
+ canonical: "Button",
2356
+ htmlEquivalent: "button",
2357
+ importPath: "@usefragments/ui",
2358
+ },
2359
+ ],
2360
+ "button"
2361
+ );
2362
+ expect(findings).toHaveLength(1);
2363
+ expect(findings[0].attributes?.suggestedComponent).toBe("Button");
2364
+ expect(findings[0].attributes?.canonical).toBe("Button");
2365
+ });
2366
+
2367
+ it("Button wins even when a divergent Chip→button mapping coexists", () => {
2368
+ // Both a correct Button mapping and a divergent Chip mapping are present;
2369
+ // the divergent Chip must never be the emitted suggestion.
2370
+ const findings = mappedFindings(
2371
+ [
2372
+ {
2373
+ name: "Chip",
2374
+ canonical: "Button",
2375
+ htmlEquivalent: "button",
2376
+ importPath: "@usefragments/ui",
2377
+ },
2378
+ {
2379
+ name: "Button",
2380
+ canonical: "Button",
2381
+ htmlEquivalent: "button",
2382
+ importPath: "@usefragments/ui",
2383
+ },
2384
+ ],
2385
+ "button"
2386
+ );
2387
+ expect(findings.filter((f) => f.attributes?.suggestedComponent === "Chip")).toHaveLength(0);
2388
+ expect(findings.every((f) => f.attributes?.suggestedComponent === "Button")).toBe(true);
2389
+ });
2390
+
2391
+ it("semantic mapped tags still fire (textarea/select/table → custom-named)", () => {
2392
+ // Semantic, low-frequency tags remain enforceable even with custom-named
2393
+ // components — the fix must NOT regress these.
2394
+ const cases: Array<[string, string]> = [
2395
+ ["textarea", "Textarea"],
2396
+ ["select", "Select"],
2397
+ ["table", "DataTable"],
2398
+ ];
2399
+ for (const [tag, name] of cases) {
2400
+ const findings = mappedFindings(
2401
+ [{ name, canonical: name, htmlEquivalent: tag, importPath: "@usefragments/ui" }],
2402
+ tag
2403
+ );
2404
+ expect(findings).toHaveLength(1);
2405
+ expect(findings[0].attributes?.suggestedComponent).toBe(name);
2406
+ }
2407
+ });
2408
+
2409
+ it("does not flag generic <div>/<span> mapped to a divergent component", () => {
2410
+ // div/span have no curated canonical → never enforceable, never emitted.
2411
+ expect(
2412
+ mappedFindings(
2413
+ [
2414
+ { name: "Accordion", canonical: "Accordion", htmlEquivalent: "div" },
2415
+ { name: "Badge", canonical: "Badge", htmlEquivalent: "span" },
2416
+ ],
2417
+ "div"
2418
+ )
2419
+ ).toHaveLength(0);
2420
+ expect(
2421
+ mappedFindings([{ name: "Badge", canonical: "Badge", htmlEquivalent: "span" }], "span")
2422
+ ).toHaveLength(0);
2423
+ });
2424
+
2425
+ it("matches a mis-cased htmlEquivalent (case-insensitive raw-tag compare — audit #29)", () => {
2426
+ // A catalog/author can store `htmlEquivalent: "TEXTAREA"` while the raw tag
2427
+ // is `textarea`. The mapped path must still match (raw tags are
2428
+ // case-insensitive), so the finding carries the mapping's custom name
2429
+ // ("BigText") — not the built-in advisory fallback ("Textarea"). Before the
2430
+ // fix the case-sensitive `===` made the mapping inert.
2431
+ const findings = mappedFindings(
2432
+ [
2433
+ {
2434
+ name: "BigText",
2435
+ canonical: "BigText",
2436
+ htmlEquivalent: "TEXTAREA",
2437
+ importPath: "@usefragments/ui",
2438
+ },
2439
+ ],
2440
+ "textarea"
2441
+ );
2442
+ expect(findings).toHaveLength(1);
2443
+ expect(findings[0].attributes?.suggestedComponent).toBe("BigText");
2444
+ });
2445
+ });
2446
+
2447
+ it("does not emit a no-op finding when the element already is the canonical (icon self-map)", () => {
2448
+ // The element IS the canonical and there is no import-path fix to make:
2449
+ // "replace <GitBranch> with <GitBranch>" is not drift. These icon self-maps
2450
+ // were pure noise in the flood.
2451
+ const ix = new FactIndex();
2452
+ ix.addMany(
2453
+ compileGlobalGovernanceFacts({
2454
+ rules: {
2455
+ "components/prefer-library": {
2456
+ enabled: true,
2457
+ severity: "warning",
2458
+ options: {
2459
+ canonicalMappings: [{ name: "GitBranch", canonical: "GitBranch" }],
2460
+ },
2461
+ },
2462
+ },
2463
+ })
2464
+ );
2465
+ ix.add(
2466
+ makeUsageNodeFact({
2467
+ file: "src/app.tsx",
2468
+ nodePath: "0:0",
2469
+ element: "GitBranch",
2470
+ location: { file: "src/app.tsx", line: 5, column: 4 },
2471
+ })
2472
+ );
2473
+ ix.add(
2474
+ makeUsageImportFact({
2475
+ file: "src/app.tsx",
2476
+ local: "GitBranch",
2477
+ imported: "GitBranch",
2478
+ source: "@phosphor-icons/react",
2479
+ location: { file: "src/app.tsx", line: 1, column: 0 },
2480
+ })
2481
+ );
2482
+
2483
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2484
+ });
2485
+
2486
+ describe("component-name divergence — element === canonical vocab id (audit #1/#2)", () => {
2487
+ // Engine audit (2026-06-23): a confirmed mapping carries name = the user's
2488
+ // component display name and canonical = the vocabulary id, so divergence
2489
+ // (name "Modal", canonical "Dialog") is the NORM. The old findMapping /
2490
+ // shouldSuggestMapped matched purely on `mapping.canonical === node.element`,
2491
+ // so a user writing <Dialog> (the canonical vocab id) got told to swap to the
2492
+ // renamed wrapper <Modal> — a self-contradicting finding (rawValue ===
2493
+ // canonical) that also fired on compliant usage, and let any common vocab id
2494
+ // (Tooltip/Card/Switch) hijack an unrelated third-party usage. The canonical
2495
+ // selector is removed; only a real NAME match, an enforceable htmlEquivalent,
2496
+ // or an ARIA-role match may drive a finding. React-agnostic: uses @acme/ui.
2497
+ function importedFindings(
2498
+ mappings: Array<Record<string, unknown>>,
2499
+ element: string,
2500
+ importSource: string,
2501
+ sources: Array<Record<string, unknown>> = [{ kind: "npm", specifier: "@acme/ui" }]
2502
+ ) {
2503
+ const ix = new FactIndex();
2504
+ ix.addMany(
2505
+ compileGlobalGovernanceFacts({
2506
+ rules: {
2507
+ "components/prefer-library": {
2508
+ enabled: true,
2509
+ severity: "warning",
2510
+ options: { canonicalSources: sources, canonicalMappings: mappings },
2511
+ },
2512
+ },
2513
+ })
2514
+ );
2515
+ ix.add(
2516
+ makeUsageNodeFact({
2517
+ file: "src/app.tsx",
2518
+ nodePath: "0:0",
2519
+ element,
2520
+ location: { file: "src/app.tsx", line: 5, column: 4 },
2521
+ })
2522
+ );
2523
+ ix.add(
2524
+ makeUsageImportFact({
2525
+ file: "src/app.tsx",
2526
+ local: element,
2527
+ imported: element,
2528
+ source: importSource,
2529
+ location: { file: "src/app.tsx", line: 1, column: 0 },
2530
+ })
2531
+ );
2532
+ return ruleComponentsPreferLibrary(ix);
2533
+ }
2534
+
2535
+ it("does NOT suggest swapping <Dialog> → <Modal> when name 'Modal' diverges from canonical 'Dialog' (#1)", () => {
2536
+ // ADVERSARIAL: before the fix this emitted a "Dialog → Modal" swap with
2537
+ // attributes { rawValue:'Dialog', canonical:'Dialog', suggestedComponent:'Modal' }
2538
+ // — self-contradicting and firing even on the canonical primitive.
2539
+ const fromImportPath = importedFindings(
2540
+ [{ name: "Modal", canonical: "Dialog", importPath: "@acme/ui" }],
2541
+ "Dialog",
2542
+ "@acme/ui"
2543
+ );
2544
+ expect(fromImportPath).toHaveLength(0);
2545
+ const fromThirdParty = importedFindings(
2546
+ [{ name: "Modal", canonical: "Dialog" }],
2547
+ "Dialog",
2548
+ "react-aria"
2549
+ );
2550
+ expect(
2551
+ fromThirdParty.filter((f) => f.attributes?.suggestedComponent === "Modal")
2552
+ ).toHaveLength(0);
2553
+ });
2554
+
2555
+ it("does NOT let a common vocab id (Tooltip) hijack an unrelated mapping named 'Hint' (#2)", () => {
2556
+ const findings = importedFindings(
2557
+ [{ name: "Hint", canonical: "Tooltip" }],
2558
+ "Tooltip",
2559
+ "@radix-ui/react-tooltip"
2560
+ );
2561
+ expect(findings.filter((f) => f.attributes?.suggestedComponent === "Hint")).toHaveLength(0);
2562
+ });
2563
+
2564
+ it("STILL fires the legit name-based import-redirect (<Modal> from wrong path → import @acme/ui)", () => {
2565
+ // Regression guard: removing the canonical selector must not kill the
2566
+ // real name-match import-redirect. <Modal> (the component's own name)
2567
+ // imported from the wrong path is genuine drift.
2568
+ const findings = importedFindings(
2569
+ [{ name: "Modal", canonical: "Dialog", importPath: "@acme/ui" }],
2570
+ "Modal",
2571
+ "react-aria"
2572
+ );
2573
+ expect(findings).toHaveLength(1);
2574
+ expect(findings[0].fix?.kind).toBe("replaceImport");
2575
+ expect(findings[0].attributes?.suggestedComponent).toBe("Modal");
2576
+ });
2577
+
2578
+ it("STILL fires when name === canonical (<Dialog> for name 'Dialog') from wrong path", () => {
2579
+ const findings = importedFindings(
2580
+ [{ name: "Dialog", canonical: "Dialog", importPath: "@acme/ui" }],
2581
+ "Dialog",
2582
+ "react-aria"
2583
+ );
2584
+ expect(findings).toHaveLength(1);
2585
+ expect(findings[0].attributes?.suggestedComponent).toBe("Dialog");
2586
+ });
2587
+ });
2588
+
2589
+ describe("directory canonical source (unfiltered include — dogfood shape)", () => {
2590
+ // A DIRECTORY canonical source is derived without an `include` list, so it
2591
+ // claims every component name. Before the fix that turned every imported
2592
+ // component (Phosphor icons included) into a no-op `<X> → <X>` finding and
2593
+ // let a disagreeing raw-tag match through. The directory path must honor the
2594
+ // same #224 guards as the mapped path: no no-ops, raw lowercase HTML only,
2595
+ // enforceable-canonical only.
2596
+ function directoryIndex() {
2597
+ const ix = new FactIndex();
2598
+ ix.addMany(
2599
+ compileGlobalGovernanceFacts({
2600
+ rules: {
2601
+ "components/prefer-library": {
2602
+ enabled: true,
2603
+ severity: "warning",
2604
+ options: {
2605
+ canonicalSources: [{ kind: "directory", path: "src/fragments/ui/components" }],
2606
+ },
2607
+ },
2608
+ },
2609
+ })
2610
+ );
2611
+ return ix;
2612
+ }
2613
+
2614
+ it("does not flag imported icon components (already-capitalized, no-op)", () => {
2615
+ const ix = directoryIndex();
2616
+ for (const [i, icon] of ["X", "House"].entries()) {
2617
+ ix.add(
2618
+ makeUsageNodeFact({
2619
+ file: "src/app.tsx",
2620
+ nodePath: `0:${i}`,
2621
+ element: icon,
2622
+ location: { file: "src/app.tsx", line: 5 + i, column: 4 },
2623
+ })
2624
+ );
2625
+ ix.add(
2626
+ makeUsageImportFact({
2627
+ file: "src/app.tsx",
2628
+ local: icon,
2629
+ imported: icon,
2630
+ source: "@phosphor-icons/react",
2631
+ location: { file: "src/app.tsx", line: 1 + i, column: 0 },
2632
+ })
2633
+ );
2634
+ }
2635
+
2636
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2637
+ });
2638
+
2639
+ it("does not flag <button> when the canonical disagrees (button → Chip)", () => {
2640
+ // A directory source cannot supply Chip as button's canonical: button's
2641
+ // curated canonical is Button. The non-enforceable match is suppressed.
2642
+ const ix = new FactIndex();
2643
+ ix.addMany(
2644
+ compileGlobalGovernanceFacts({
2645
+ rules: {
2646
+ "components/prefer-library": {
2647
+ enabled: true,
2648
+ severity: "warning",
2649
+ options: {
2650
+ // No Button in the directory, only Chip → button's builtIn
2651
+ // canonical (Button) is not in the source, so even before the
2652
+ // enforceability gate there is no Chip target for a <button>.
2653
+ canonicalSources: [
2654
+ {
2655
+ kind: "directory",
2656
+ path: "src/fragments/ui/components",
2657
+ include: ["Chip"],
2658
+ },
2659
+ ],
2660
+ },
2661
+ },
2662
+ },
2663
+ })
2664
+ );
2665
+ addRawNode(ix, {
2666
+ element: "button",
2667
+ nodePath: "0:0",
2668
+ text: "Save",
2669
+ interactive: true,
2670
+ });
2671
+
2672
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2673
+ });
2674
+
2675
+ it("flags raw <button> → Button when Button is a declared directory canonical", () => {
2676
+ const ix = new FactIndex();
2677
+ ix.addMany(
2678
+ compileGlobalGovernanceFacts({
2679
+ rules: {
2680
+ "components/prefer-library": {
2681
+ enabled: true,
2682
+ severity: "warning",
2683
+ options: {
2684
+ canonicalSources: [
2685
+ {
2686
+ kind: "directory",
2687
+ path: "src/fragments/ui/components",
2688
+ include: ["Button"],
2689
+ },
2690
+ ],
2691
+ },
2692
+ },
2693
+ },
2694
+ })
2695
+ );
2696
+ addRawNode(ix, {
2697
+ element: "button",
2698
+ nodePath: "0:0",
2699
+ text: "Save",
2700
+ interactive: true,
2701
+ });
2702
+
2703
+ const findings = ruleComponentsPreferLibrary(ix);
2704
+ expect(findings).toHaveLength(1);
2705
+ expect(findings[0]).toMatchObject({
2706
+ ruleId: "components/prefer-library",
2707
+ fix: {
2708
+ kind: "replaceComponent",
2709
+ from: "button",
2710
+ to: "Button",
2711
+ deterministic: true,
2712
+ },
2713
+ attributes: {
2714
+ rawValue: "button",
2715
+ suggestedComponent: "Button",
2716
+ suggestedImportSourceKind: "directory",
2717
+ },
2718
+ });
2719
+ });
2720
+
2721
+ it("flags a raw element by canonical vocab id when display name diverges (Modal → Dialog)", () => {
2722
+ // The emitter scopes a divergent confirmed mapping (a "Modal" component
2723
+ // whose canonical is "Dialog") to include BOTH names. The raw <dialog>
2724
+ // resolves to the canonical "Dialog", so the suggestion must still fire
2725
+ // even though the team's component is named "Modal".
2726
+ const ix = new FactIndex();
2727
+ ix.addMany(
2728
+ compileGlobalGovernanceFacts({
2729
+ rules: {
2730
+ "components/prefer-library": {
2731
+ enabled: true,
2732
+ severity: "warning",
2733
+ options: {
2734
+ canonicalSources: [
2735
+ {
2736
+ kind: "directory",
2737
+ path: "src/overlays",
2738
+ include: ["Dialog", "Modal"],
2739
+ },
2740
+ ],
2741
+ },
2742
+ },
2743
+ },
2744
+ })
2745
+ );
2746
+ addRawNode(ix, {
2747
+ element: "dialog",
2748
+ nodePath: "0:0",
2749
+ text: "Confirm",
2750
+ interactive: true,
2751
+ });
2752
+
2753
+ const findings = ruleComponentsPreferLibrary(ix);
2754
+ expect(findings).toHaveLength(1);
2755
+ expect(findings[0]).toMatchObject({
2756
+ ruleId: "components/prefer-library",
2757
+ attributes: {
2758
+ rawValue: "dialog",
2759
+ suggestedComponent: "Dialog",
2760
+ suggestedImportSourceKind: "directory",
2761
+ },
2762
+ });
2763
+ });
2764
+
2765
+ it("does not flag generic <div>/<span> from a directory source (anti-flood)", () => {
2766
+ const ix = directoryIndex();
2767
+ addRawNode(ix, { element: "div", nodePath: "0:0" });
2768
+ addRawNode(ix, { element: "span", nodePath: "0:1" });
2769
+
2770
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2771
+ });
2772
+ });
2773
+
2774
+ it("discriminates raw <input> by type via htmlType mappings", () => {
2775
+ const ix = new FactIndex();
2776
+ ix.addMany(
2777
+ compileGlobalGovernanceFacts({
2778
+ rules: {
2779
+ "components/prefer-library": {
2780
+ enabled: true,
2781
+ severity: "warning",
2782
+ options: {
2783
+ canonicalMappings: [
2784
+ {
2785
+ name: "TextField",
2786
+ canonical: "Input",
2787
+ htmlEquivalent: "input",
2788
+ importPath: "@acme/ui",
2789
+ },
2790
+ {
2791
+ name: "Checkbox",
2792
+ canonical: "Checkbox",
2793
+ htmlEquivalent: "input",
2794
+ htmlType: "checkbox",
2795
+ importPath: "@acme/ui",
2796
+ },
2797
+ {
2798
+ name: "RadioGroup",
2799
+ canonical: "Radio",
2800
+ htmlEquivalent: "input",
2801
+ htmlType: "radio",
2802
+ importPath: "@acme/ui",
2803
+ },
2804
+ ],
2805
+ },
2806
+ },
2807
+ },
2808
+ })
2809
+ );
2810
+ addRawNode(ix, {
2811
+ element: "input",
2812
+ nodePath: "0:0",
2813
+ props: [{ prop: "type", value: "checkbox" }],
2814
+ });
2815
+ addRawNode(ix, {
2816
+ element: "input",
2817
+ nodePath: "0:1",
2818
+ props: [{ prop: "type", value: "radio" }],
2819
+ });
2820
+ addRawNode(ix, {
2821
+ element: "input",
2822
+ nodePath: "0:2",
2823
+ props: [{ prop: "type", value: "text" }],
2824
+ });
2825
+
2826
+ const findings = ruleComponentsPreferLibrary(ix);
2827
+
2828
+ // type-specific mappings win over the generic tag-level one; text falls back
2829
+ expect(findings.map((finding) => finding.attributes?.suggestedComponent)).toEqual([
2830
+ "Checkbox",
2831
+ "RadioGroup",
2832
+ "TextField",
2833
+ ]);
2834
+ expect(findings.map((finding) => finding.fix?.to)).toEqual([
2835
+ "Checkbox",
2836
+ "RadioGroup",
2837
+ "TextField",
2838
+ ]);
2839
+ expect(findings.every((finding) => finding.fix?.deterministic === true)).toBe(true);
2840
+ });
2841
+
2842
+ it("does not let a type-constrained mapping match a different or untyped input", () => {
2843
+ const ix = new FactIndex();
2844
+ ix.addMany(
2845
+ compileGlobalGovernanceFacts({
2846
+ rules: {
2847
+ "components/prefer-library": {
2848
+ enabled: true,
2849
+ severity: "warning",
2850
+ options: {
2851
+ canonicalMappings: [
2852
+ {
2853
+ name: "Checkbox",
2854
+ canonical: "Checkbox",
2855
+ htmlEquivalent: "input",
2856
+ htmlType: "checkbox",
2857
+ importPath: "@acme/ui",
2858
+ },
2859
+ ],
2860
+ },
2861
+ },
2862
+ },
2863
+ })
2864
+ );
2865
+ addRawNode(ix, {
2866
+ element: "input",
2867
+ nodePath: "0:0",
2868
+ props: [{ prop: "type", value: "text" }],
2869
+ });
2870
+ addRawNode(ix, { element: "input", nodePath: "0:1" });
2871
+
2872
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
2873
+ });
2874
+
2875
+ it("attaches a deterministic import fix for canonical components from the wrong path", () => {
2876
+ const ix = new FactIndex();
2877
+ ix.addMany(
2878
+ compileGlobalGovernanceFacts({
2879
+ rules: {
2880
+ "components/prefer-library": {
2881
+ enabled: true,
2882
+ severity: "warning",
2883
+ options: {
2884
+ canonicalMappings: [
2885
+ {
2886
+ name: "Button",
2887
+ canonical: "Button",
2888
+ importPath: "@acme/ui",
2889
+ },
2890
+ ],
2891
+ },
2892
+ },
2893
+ },
2894
+ })
2895
+ );
2896
+ ix.add(
2897
+ makeUsageNodeFact({
2898
+ file: "src/app.tsx",
2899
+ nodePath: "0:0",
2900
+ element: "Button",
2901
+ location: { file: "src/app.tsx", line: 5, column: 4 },
2902
+ })
2903
+ );
2904
+ ix.add(
2905
+ makeUsageImportFact({
2906
+ file: "src/app.tsx",
2907
+ local: "Button",
2908
+ imported: "Button",
2909
+ source: "../Button",
2910
+ location: { file: "src/app.tsx", line: 1, column: 0 },
2911
+ })
2912
+ );
2913
+
2914
+ const findings = ruleComponentsPreferLibrary(ix);
2915
+ expect(findings).toHaveLength(1);
2916
+ expect(findings[0]).toMatchObject({
2917
+ fix: {
2918
+ kind: "replaceImport",
2919
+ from: "../Button",
2920
+ to: "@acme/ui",
2921
+ deterministic: true,
2922
+ },
2923
+ location: { line: 1, column: 0 },
2924
+ });
2925
+ });
2926
+
2927
+ it("flags a bespoke element by its ARIA role, advisory (no deterministic fix)", () => {
2928
+ const ix = new FactIndex();
2929
+ ix.addMany(
2930
+ compileGlobalGovernanceFacts({
2931
+ rules: {
2932
+ "components/prefer-library": {
2933
+ enabled: true,
2934
+ severity: "warning",
2935
+ options: {
2936
+ canonicalMappings: [
2937
+ {
2938
+ name: "Alert",
2939
+ canonical: "Alert",
2940
+ ariaRole: "status",
2941
+ importPath: "@acme/ui",
2942
+ confidence: 0.9,
2943
+ },
2944
+ ],
2945
+ },
2946
+ },
2947
+ },
2948
+ })
2949
+ );
2950
+ // A raw <div role="status"> — matches the canonical's role, not its tag.
2951
+ ix.add(
2952
+ makeUsageNodeFact({
2953
+ file: "src/app.tsx",
2954
+ nodePath: "0:0",
2955
+ element: "div",
2956
+ role: "status",
2957
+ location: { file: "src/app.tsx", line: 9, column: 2 },
2958
+ })
2959
+ );
2960
+
2961
+ const findings = ruleComponentsPreferLibrary(ix);
2962
+ expect(findings).toHaveLength(1);
2963
+ expect(findings[0]).toMatchObject({
2964
+ ruleId: "components/prefer-library",
2965
+ message: 'Custom <div role="status"> reimplements a primitive. Use <Alert> from @acme/ui.',
2966
+ attributes: {
2967
+ suggestedComponent: "Alert",
2968
+ matchedRole: "status",
2969
+ confidence: 0.9,
2970
+ },
2971
+ });
2972
+ // Role matches are advisory: no deterministic tag→component rewrite.
2973
+ expect(findings[0].fix).toBeUndefined();
2974
+ });
2975
+
2976
+ it("flags input type primitives config-free with deterministic fixes", () => {
2977
+ const ix = preferLibraryIndex();
2978
+ addRawNode(ix, {
2979
+ element: "input",
2980
+ nodePath: "0:0",
2981
+ props: [{ prop: "type", value: "checkbox" }],
2982
+ });
2983
+ addRawNode(ix, {
2984
+ element: "input",
2985
+ nodePath: "0:1",
2986
+ props: [{ prop: "type", value: "radio" }],
2987
+ });
2988
+ addRawNode(ix, {
2989
+ element: "input",
2990
+ nodePath: "0:2",
2991
+ props: [{ prop: "type", value: "number" }],
2992
+ });
2993
+
2994
+ const findings = ruleComponentsPreferLibrary(ix);
2995
+
2996
+ expect(findings.map((finding) => finding.attributes?.suggestedComponent)).toEqual([
2997
+ "Checkbox",
2998
+ "Radio",
2999
+ "NumberInput",
3000
+ ]);
3001
+ expect(findings.map((finding) => finding.attributes?.precisionTier)).toEqual([
3002
+ "input-type",
3003
+ "input-type",
3004
+ "input-type",
3005
+ ]);
3006
+ expect(findings.map((finding) => finding.fix?.deterministic)).toEqual([true, true, true]);
3007
+ });
3008
+
3009
+ it("does not infer generic text inputs as deterministic primitives", () => {
3010
+ const ix = preferLibraryIndex();
3011
+ addRawNode(ix, { element: "input", nodePath: "0:0" });
3012
+ addRawNode(ix, {
3013
+ element: "input",
3014
+ nodePath: "0:1",
3015
+ props: [{ prop: "type", value: "text" }],
3016
+ });
3017
+ addRawNode(ix, {
3018
+ element: "input",
3019
+ nodePath: "0:2",
3020
+ props: [{ prop: "type", value: "email" }],
3021
+ });
3022
+
3023
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
3024
+ });
3025
+
3026
+ it("flags compound native tags as advisory raw HTML suggestions", () => {
3027
+ const ix = preferLibraryIndex();
3028
+ addRawNode(ix, { element: "select", nodePath: "0:0" });
3029
+ addRawNode(ix, { element: "table", nodePath: "0:1" });
3030
+ addRawNode(ix, { element: "details", nodePath: "0:2" });
3031
+
3032
+ const findings = ruleComponentsPreferLibrary(ix);
3033
+
3034
+ expect(findings.map((finding) => finding.attributes?.suggestedComponent)).toEqual([
3035
+ "Select",
3036
+ "Table",
3037
+ "Collapsible",
3038
+ ]);
3039
+ expect(findings.every((finding) => finding.fix === undefined)).toBe(true);
3040
+ expect(findings.every((finding) => finding.attributes?.advisory === true)).toBe(true);
3041
+ expect(findings.every((finding) => finding.attributes?.precisionTier === "html-advisory")).toBe(
3042
+ true
3043
+ );
3044
+ });
3045
+
3046
+ it("flags interactive non-semantic elements as advisory Button suggestions", () => {
3047
+ const ix = preferLibraryIndex();
3048
+ addRawNode(ix, {
3049
+ element: "div",
3050
+ nodePath: "0:0",
3051
+ interactive: true,
3052
+ props: [{ prop: "onClick", resolution: "jsx" }],
3053
+ });
3054
+ addRawNode(ix, {
3055
+ element: "span",
3056
+ nodePath: "0:1",
3057
+ interactive: true,
3058
+ props: [{ prop: "onPointerDown", resolution: "jsx" }],
3059
+ });
3060
+
3061
+ const findings = ruleComponentsPreferLibrary(ix);
3062
+
3063
+ expect(findings).toHaveLength(2);
3064
+ expect(findings.map((finding) => finding.attributes?.suggestedComponent)).toEqual([
3065
+ "Button",
3066
+ "Button",
3067
+ ]);
3068
+ expect(findings.every((finding) => finding.fix === undefined)).toBe(true);
3069
+ expect(findings.every((finding) => finding.attributes?.advisory === true)).toBe(true);
3070
+ expect(
3071
+ findings.every((finding) => finding.attributes?.precisionTier === "interactive-nonsemantic")
3072
+ ).toBe(true);
3073
+ });
3074
+
3075
+ it("uses the built-in role table on non-semantic tags without double-firing native elements", () => {
3076
+ const ix = preferLibraryIndex();
3077
+ addRawNode(ix, { element: "div", nodePath: "0:0", role: "button" });
3078
+ addRawNode(ix, { element: "div", nodePath: "0:1", role: "checkbox" });
3079
+ addRawNode(ix, { element: "button", nodePath: "0:2", role: "button", text: "Save" });
3080
+
3081
+ const findings = ruleComponentsPreferLibrary(ix);
3082
+
3083
+ expect(findings.map((finding) => finding.attributes?.suggestedComponent)).toEqual([
3084
+ "Button",
3085
+ "Checkbox",
3086
+ "Button",
3087
+ ]);
3088
+ expect(findings[0].fix).toBeUndefined();
3089
+ expect(findings[1].fix).toBeUndefined();
3090
+ expect(findings[0].attributes?.precisionTier).toBe("role-reimpl");
3091
+ expect(findings[1].attributes?.precisionTier).toBe("role-reimpl");
3092
+ expect(findings[2].fix).toMatchObject({
3093
+ kind: "replaceComponent",
3094
+ from: "button",
3095
+ to: "Button",
3096
+ deterministic: true,
3097
+ });
3098
+ });
3099
+
3100
+ it("suggests IconButton for icon-only buttons and Button when text is present", () => {
3101
+ const ix = preferLibraryIndex();
3102
+ addRawNode(ix, {
3103
+ element: "button",
3104
+ nodePath: "0:0",
3105
+ interactive: true,
3106
+ props: [
3107
+ { prop: "onClick", resolution: "jsx" },
3108
+ { prop: "aria-label", value: "Close" },
3109
+ ],
3110
+ });
3111
+ addRawNode(ix, {
3112
+ element: "button",
3113
+ nodePath: "0:1",
3114
+ interactive: true,
3115
+ props: [{ prop: "onClick", resolution: "jsx" }],
3116
+ text: "Save",
3117
+ });
3118
+ addRawNode(ix, {
3119
+ element: "button",
3120
+ nodePath: "0:2",
3121
+ interactive: true,
3122
+ props: [
3123
+ { prop: "onClick", resolution: "jsx" },
3124
+ { prop: "children", resolution: "jsx" },
3125
+ ],
3126
+ });
3127
+
3128
+ const findings = ruleComponentsPreferLibrary(ix);
3129
+
3130
+ expect(findings.map((finding) => finding.attributes?.suggestedComponent)).toEqual([
3131
+ "IconButton",
3132
+ "Button",
3133
+ "Button",
3134
+ ]);
3135
+ expect(findings[0].fix).toBeUndefined();
3136
+ expect(findings[0].attributes?.precisionTier).toBe("icon-only");
3137
+ expect(findings[0].attributes?.advisory).toBe(true);
3138
+ expect(findings[1].fix).toMatchObject({
3139
+ kind: "replaceComponent",
3140
+ from: "button",
3141
+ to: "Button",
3142
+ deterministic: true,
3143
+ });
3144
+ expect(findings[2].fix).toMatchObject({
3145
+ kind: "replaceComponent",
3146
+ from: "button",
3147
+ to: "Button",
3148
+ deterministic: true,
3149
+ });
3150
+ });
3151
+
3152
+ it("does not spray findings across plain wrappers or unknown roles", () => {
3153
+ const ix = preferLibraryIndex();
3154
+ addRawNode(ix, { element: "div", nodePath: "0:0" });
3155
+ addRawNode(ix, { element: "div", nodePath: "0:1", role: "region" });
3156
+ addRawNode(ix, { element: "section", nodePath: "0:2" });
3157
+
3158
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
3159
+ });
3160
+
3161
+ it("does not flag a role-bearing element when no mapping declares that role", () => {
3162
+ const ix = new FactIndex();
3163
+ ix.addMany(
3164
+ compileGlobalGovernanceFacts({
3165
+ rules: {
3166
+ "components/prefer-library": {
3167
+ enabled: true,
3168
+ severity: "warning",
3169
+ options: {
3170
+ canonicalMappings: [{ name: "Alert", canonical: "Alert", ariaRole: "alert" }],
3171
+ },
3172
+ },
3173
+ },
3174
+ })
3175
+ );
3176
+ ix.add(
3177
+ makeUsageNodeFact({
3178
+ file: "src/app.tsx",
3179
+ nodePath: "0:0",
3180
+ element: "div",
3181
+ role: "status",
3182
+ location: { file: "src/app.tsx", line: 9, column: 2 },
3183
+ })
3184
+ );
3185
+
3186
+ expect(ruleComponentsPreferLibrary(ix)).toHaveLength(0);
3187
+ });
3188
+ });
3189
+
3190
+ // ---------------------------------------------------------------------------
3191
+ // Run-5 fixes: token-aware spacing / typography, color role, fixer safety
3192
+ // ---------------------------------------------------------------------------
3193
+
3194
+ function spacingIndexWithTokens(
3195
+ tokens: Array<{ name: string; value: string; referenceFormat?: TokenRefFormat }>
3196
+ ): FactIndex {
3197
+ const ix = new FactIndex();
3198
+ ix.addMany(
3199
+ compileGlobalGovernanceFacts({
3200
+ scales: { space: g.scale.px([0, 4, 8, 12, 16, 24, 32]) },
3201
+ styles: [
3202
+ g.styles.rawSpacing().mustMatchScale("space", {
3203
+ appliesTo: ["padding", "margin", "margin-top", "gap"],
3204
+ severity: "warn",
3205
+ }),
3206
+ ],
3207
+ })
3208
+ );
3209
+ for (const token of tokens) {
3210
+ ix.add(
3211
+ makeTokenDefinitionFact({
3212
+ name: token.name,
3213
+ value: token.value,
3214
+ category: "spacing",
3215
+ referenceFormat: token.referenceFormat,
3216
+ })
3217
+ );
3218
+ }
3219
+ return ix;
3220
+ }
3221
+
3222
+ type TokenRefFormat = "css-var" | "scss-var" | "scss-map" | "dtcg";
3223
+
3224
+ describe("styles/no-raw-spacing — token-aware (BUG 1/13/15)", () => {
3225
+ it("flags an on-scale raw literal that equals a token with a deterministic var() swap", () => {
3226
+ const ix = spacingIndexWithTokens([{ name: "--space-md", value: "16px" }]);
3227
+ ix.add(
3228
+ makeStyleDeclarationFact({
3229
+ file: "src/x.css",
3230
+ selector: ".a",
3231
+ declarationPath: "0",
3232
+ property: "padding",
3233
+ value: "16px",
3234
+ location: { file: "src/x.css", line: 1, column: 0 },
3235
+ })
3236
+ );
3237
+ const findings = ruleStylesNoRawSpacing(ix);
3238
+ expect(findings).toHaveLength(1);
3239
+ expect(findings[0].fix).toMatchObject({
3240
+ value: "var(--space-md)",
3241
+ deterministic: true,
3242
+ });
3243
+ expect(findings[0].attributes).toMatchObject({ matchedToken: "--space-md" });
3244
+ });
3245
+
3246
+ it("suggests the nearest token for an off-scale value but does NOT auto-apply it", () => {
3247
+ const ix = spacingIndexWithTokens([{ name: "--space-md", value: "16px" }]);
3248
+ ix.add(
3249
+ makeStyleDeclarationFact({
3250
+ file: "src/x.css",
3251
+ selector: ".a",
3252
+ declarationPath: "0",
3253
+ property: "padding",
3254
+ value: "15px",
3255
+ location: { file: "src/x.css", line: 1, column: 0 },
3256
+ })
3257
+ );
3258
+ const findings = ruleStylesNoRawSpacing(ix);
3259
+ expect(findings).toHaveLength(1);
3260
+ // Nearest scale value is 16 → its token; value-changing so non-deterministic.
3261
+ expect(findings[0].fix).toMatchObject({
3262
+ value: "var(--space-md)",
3263
+ deterministic: false,
3264
+ });
3265
+ });
3266
+
3267
+ it("keeps a Sass-map token swap as a suggestion (non-compiling if applied)", () => {
3268
+ const ix = spacingIndexWithTokens([
3269
+ { name: "$space-md", value: "16px", referenceFormat: "scss-map" },
3270
+ ]);
3271
+ ix.add(
3272
+ makeStyleDeclarationFact({
3273
+ file: "src/x.scss",
3274
+ selector: ".a",
3275
+ declarationPath: "0",
3276
+ property: "padding",
3277
+ value: "16px",
3278
+ location: { file: "src/x.scss", line: 1, column: 0 },
3279
+ })
3280
+ );
3281
+ const findings = ruleStylesNoRawSpacing(ix);
3282
+ expect(findings).toHaveLength(1);
3283
+ expect(findings[0].fix?.deterministic).toBe(false);
3284
+ });
3285
+
3286
+ it("catches a camelCase longhand inline spacing prop (marginTop)", () => {
3287
+ const ix = spacingIndexWithTokens([]);
3288
+ const node = makeUsageNodeFact({
3289
+ file: "src/Hero.tsx",
3290
+ nodePath: "0",
3291
+ element: "div",
3292
+ location: { file: "src/Hero.tsx", line: 3, column: 2 },
3293
+ });
3294
+ ix.add(node);
3295
+ ix.add(
3296
+ makeUsageInlineStyleFact({
3297
+ nodeId: node.id,
3298
+ property: "marginTop",
3299
+ valueKind: "number",
3300
+ value: "13",
3301
+ })
3302
+ );
3303
+ const findings = ruleStylesNoRawSpacing(ix);
3304
+ expect(findings).toHaveLength(1);
3305
+ expect(findings[0].attributes).toMatchObject({ property: "marginTop" });
3306
+ });
3307
+ });
3308
+
3309
+ describe("styles/no-raw-color — role correctness + fixer safety (BUG 4/3/11)", () => {
3310
+ function colorIndex(
3311
+ tokens: Array<{ name: string; value: string; referenceFormat?: TokenRefFormat }>
3312
+ ): FactIndex {
3313
+ const ix = new FactIndex();
3314
+ ix.addMany(
3315
+ compileGlobalGovernanceFacts({
3316
+ styles: [g.styles.rawColors().forbid({ prefer: "token", severity: "warn" })],
3317
+ })
3318
+ );
3319
+ for (const token of tokens) {
3320
+ ix.add(
3321
+ makeTokenDefinitionFact({
3322
+ name: token.name,
3323
+ value: token.value,
3324
+ category: "color",
3325
+ referenceFormat: token.referenceFormat,
3326
+ })
3327
+ );
3328
+ }
3329
+ return ix;
3330
+ }
3331
+
3332
+ it("does NOT deterministically swap white text for a lone background token", () => {
3333
+ const ix = colorIndex([{ name: "--color-bg", value: "#ffffff" }]);
3334
+ ix.add(
3335
+ makeStyleDeclarationFact({
3336
+ file: "src/x.css",
3337
+ selector: ".a",
3338
+ declarationPath: "0",
3339
+ property: "color",
3340
+ value: "#ffffff",
3341
+ location: { file: "src/x.css", line: 1, column: 0 },
3342
+ })
3343
+ );
3344
+ const findings = ruleStylesNoRawColor(ix);
3345
+ expect(findings).toHaveLength(1);
3346
+ // The only matching token is a *background* token — wrong role for `color:`.
3347
+ expect(findings[0].fix?.deterministic).toBe(false);
3348
+ });
3349
+
3350
+ it("still deterministically swaps a role-neutral brand color used as text", () => {
3351
+ const ix = colorIndex([{ name: "--color-brand-500", value: "#3366ff" }]);
3352
+ ix.add(
3353
+ makeStyleDeclarationFact({
3354
+ file: "src/x.css",
3355
+ selector: ".a",
3356
+ declarationPath: "0",
3357
+ property: "color",
3358
+ value: "#3366ff",
3359
+ location: { file: "src/x.css", line: 1, column: 0 },
3360
+ })
3361
+ );
3362
+ const findings = ruleStylesNoRawColor(ix);
3363
+ expect(findings).toHaveLength(1);
3364
+ expect(findings[0].fix?.deterministic).toBe(true);
3365
+ });
3366
+
3367
+ it("keeps a DTCG-JSON token swap as a suggestion (no CSS var emitted)", () => {
3368
+ const ix = colorIndex([{ name: "--color-brand", value: "#3366ff", referenceFormat: "dtcg" }]);
3369
+ ix.add(
3370
+ makeStyleDeclarationFact({
3371
+ file: "src/x.css",
3372
+ selector: ".a",
3373
+ declarationPath: "0",
3374
+ property: "color",
3375
+ value: "#3366ff",
3376
+ location: { file: "src/x.css", line: 1, column: 0 },
3377
+ })
3378
+ );
3379
+ const findings = ruleStylesNoRawColor(ix);
3380
+ expect(findings).toHaveLength(1);
3381
+ expect(findings[0].fix?.deterministic).toBe(false);
3382
+ });
3383
+ });
3384
+
3385
+ describe("styles/no-raw-typography (BUG 10)", () => {
3386
+ function typeIndex(tokens: Array<{ name: string; value: string }>): FactIndex {
3387
+ const ix = new FactIndex();
3388
+ ix.addMany(
3389
+ compileGlobalGovernanceFacts({
3390
+ scales: { typeSize: g.scale.px([12, 14, 16, 20, 24]) },
3391
+ styles: [g.styles.fontSize().mustMatchScale("typeSize", { severity: "warn" })],
3392
+ })
3393
+ );
3394
+ for (const token of tokens) {
3395
+ ix.add(
3396
+ makeTokenDefinitionFact({ name: token.name, value: token.value, category: "typography" })
3397
+ );
3398
+ }
3399
+ return ix;
3400
+ }
3401
+
3402
+ it("flags an off-scale font-size as a suggestion", () => {
3403
+ const ix = typeIndex([{ name: "--font-size-md", value: "16px" }]);
3404
+ ix.add(
3405
+ makeStyleDeclarationFact({
3406
+ file: "src/x.css",
3407
+ selector: ".a",
3408
+ declarationPath: "0",
3409
+ property: "font-size",
3410
+ value: "13px",
3411
+ location: { file: "src/x.css", line: 1, column: 0 },
3412
+ })
3413
+ );
3414
+ const findings = ruleStylesNoRawTypography(ix);
3415
+ expect(findings).toHaveLength(1);
3416
+ expect(findings[0].fix?.deterministic).toBe(false);
3417
+ });
3418
+
3419
+ it("deterministically swaps an on-scale font-size literal for its token", () => {
3420
+ const ix = typeIndex([{ name: "--font-size-md", value: "16px" }]);
3421
+ ix.add(
3422
+ makeStyleDeclarationFact({
3423
+ file: "src/x.css",
3424
+ selector: ".a",
3425
+ declarationPath: "0",
3426
+ property: "font-size",
3427
+ value: "16px",
3428
+ location: { file: "src/x.css", line: 1, column: 0 },
3429
+ })
3430
+ );
3431
+ const findings = ruleStylesNoRawTypography(ix);
3432
+ expect(findings).toHaveLength(1);
3433
+ expect(findings[0].fix).toMatchObject({
3434
+ value: "var(--font-size-md)",
3435
+ deterministic: true,
3436
+ });
3437
+ });
3438
+
3439
+ it("is silent without a font-size scale policy", () => {
3440
+ const ix = new FactIndex();
3441
+ ix.add(
3442
+ makeStyleDeclarationFact({
3443
+ file: "src/x.css",
3444
+ selector: ".a",
3445
+ declarationPath: "0",
3446
+ property: "font-size",
3447
+ value: "13px",
3448
+ location: { file: "src/x.css", line: 1, column: 0 },
3449
+ })
3450
+ );
3451
+ expect(ruleStylesNoRawTypography(ix)).toHaveLength(0);
3452
+ });
3453
+ });
3454
+
3455
+ // ---------------------------------------------------------------------------
3456
+ // styles/no-raw-dimensions
3457
+ // ---------------------------------------------------------------------------
3458
+
3459
+ describe("styles/no-raw-dimensions", () => {
3460
+ function indexWithDimensions(opts: {
3461
+ appliesTo: string[];
3462
+ radius?: Array<{ name: string; value: string }>;
3463
+ spacing?: Array<{ name: string; value: string }>;
3464
+ }): FactIndex {
3465
+ const ix = new FactIndex();
3466
+ ix.addMany(
3467
+ compileGlobalGovernanceFacts({
3468
+ styles: [
3469
+ {
3470
+ kind: "style.rawDimensions.forbid",
3471
+ appliesTo: opts.appliesTo,
3472
+ prefer: "token",
3473
+ severity: "error",
3474
+ },
3475
+ ],
3476
+ })
3477
+ );
3478
+ for (const token of opts.radius ?? []) {
3479
+ ix.add(
3480
+ makeTokenDefinitionFact({
3481
+ name: token.name,
3482
+ value: token.value,
3483
+ category: "radius",
3484
+ referenceFormat: "css-var",
3485
+ })
3486
+ );
3487
+ }
3488
+ for (const token of opts.spacing ?? []) {
3489
+ ix.add(
3490
+ makeTokenDefinitionFact({
3491
+ name: token.name,
3492
+ value: token.value,
3493
+ category: "spacing",
3494
+ referenceFormat: "css-var",
3495
+ })
3496
+ );
3497
+ }
3498
+ return ix;
3499
+ }
3500
+
3501
+ function addDecl(ix: FactIndex, property: string, value: string, selector = ".x"): void {
3502
+ ix.add(
3503
+ makeStyleDeclarationFact({
3504
+ file: "libs/ui/x.scss",
3505
+ selector,
3506
+ declarationPath: "0",
3507
+ property,
3508
+ value,
3509
+ location: { file: "libs/ui/x.scss", line: 1, column: 0 },
3510
+ })
3511
+ );
3512
+ }
3513
+
3514
+ it("is silent when no rawDimensions policy is configured", () => {
3515
+ const ix = new FactIndex();
3516
+ ix.add(makeTokenDefinitionFact({ name: "--radius-md", value: "8px", category: "radius" }));
3517
+ addDecl(ix, "border-radius", "10px");
3518
+ expect(ruleStylesNoRawDimensions(ix)).toHaveLength(0);
3519
+ });
3520
+
3521
+ it("emits a deterministic var() fix on an exact token match", () => {
3522
+ const ix = indexWithDimensions({
3523
+ appliesTo: ["border-radius"],
3524
+ radius: [{ name: "--radius-md", value: "8px" }],
3525
+ });
3526
+ addDecl(ix, "border-radius", "8px");
3527
+
3528
+ const findings = ruleStylesNoRawDimensions(ix);
3529
+ expect(findings).toHaveLength(1);
3530
+ expect(findings[0].fix).toMatchObject({
3531
+ kind: "replaceStyleValue",
3532
+ value: "var(--radius-md)",
3533
+ deterministic: true,
3534
+ });
3535
+ expect(findings[0].attributes).toMatchObject({ suggestedToken: "--radius-md" });
3536
+ });
3537
+
3538
+ it("snaps an off-scale value to the nearest same-unit token as a suggestion", () => {
3539
+ const ix = indexWithDimensions({
3540
+ appliesTo: ["border-radius"],
3541
+ radius: [
3542
+ { name: "--radius-sm", value: "4px" },
3543
+ { name: "--radius-md", value: "8px" },
3544
+ { name: "--radius-lg", value: "16px" },
3545
+ ],
3546
+ });
3547
+ addDecl(ix, "border-radius", "10px");
3548
+
3549
+ const findings = ruleStylesNoRawDimensions(ix);
3550
+ expect(findings).toHaveLength(1);
3551
+ // 10px is nearest to 8px (--radius-md). Snapping changes the rendered value,
3552
+ // so it is surfaced as a suggestion (token + message) with NO auto-fix —
3553
+ // otherwise the conform engine would silently rewrite the value.
3554
+ expect(findings[0].fix).toBeUndefined();
3555
+ expect(findings[0].attributes).toMatchObject({
3556
+ rawValue: "10px",
3557
+ suggestedToken: "--radius-md",
3558
+ });
3559
+ expect(findings[0].message).toContain("Closest token");
3560
+ });
3561
+
3562
+ it("never snaps a non-zero value to a zero-valued token", () => {
3563
+ const ix = indexWithDimensions({
3564
+ appliesTo: ["border-radius"],
3565
+ radius: [
3566
+ { name: "--radius-none", value: "0" },
3567
+ { name: "--radius-md", value: "8px" },
3568
+ { name: "--radius-lg", value: "16px" },
3569
+ ],
3570
+ });
3571
+ addDecl(ix, "border-radius", "2px");
3572
+
3573
+ const findings = ruleStylesNoRawDimensions(ix);
3574
+ expect(findings).toHaveLength(1);
3575
+ // 2px is numerically closest to 0, but collapsing to 0 silently removes the
3576
+ // radius — so it suggests the nearest *non-zero* token instead.
3577
+ expect(findings[0].fix).toBeUndefined();
3578
+ expect(findings[0].attributes).toMatchObject({ suggestedToken: "--radius-md" });
3579
+ });
3580
+
3581
+ it("offers no token when the only candidates are a different unit", () => {
3582
+ const ix = indexWithDimensions({
3583
+ appliesTo: ["border-radius"],
3584
+ radius: [{ name: "--radius-md", value: "0.5rem" }],
3585
+ });
3586
+ addDecl(ix, "border-radius", "10px");
3587
+
3588
+ const findings = ruleStylesNoRawDimensions(ix);
3589
+ expect(findings).toHaveLength(1);
3590
+ expect(findings[0].fix).toBeUndefined();
3591
+ expect(findings[0].attributes.suggestedToken).toBeUndefined();
3592
+ });
3593
+
3594
+ it("flags an off-scale multi-value shorthand without a nearest suggestion", () => {
3595
+ const ix = indexWithDimensions({
3596
+ appliesTo: ["border-radius"],
3597
+ radius: [{ name: "--radius-md", value: "8px" }],
3598
+ });
3599
+ addDecl(ix, "border-radius", "10px 12px");
3600
+
3601
+ const findings = ruleStylesNoRawDimensions(ix);
3602
+ expect(findings).toHaveLength(1);
3603
+ expect(findings[0].fix).toBeUndefined();
3604
+ });
3605
+
3606
+ it("suggests the nearest token for off-scale JSX inline dimensions", () => {
3607
+ const ix = indexWithDimensions({
3608
+ appliesTo: ["border-radius"],
3609
+ radius: [
3610
+ { name: "--radius-md", value: "8px" },
3611
+ { name: "--radius-lg", value: "16px" },
3612
+ ],
3613
+ });
3614
+ addButtonUsage(ix, {
3615
+ file: "apps/checkout/page.tsx",
3616
+ nodePath: "0/0",
3617
+ line: 7,
3618
+ column: 2,
3619
+ inlineStyles: [{ property: "borderRadius", valueKind: "static", value: "9px" }],
3620
+ text: "x",
3621
+ });
3622
+
3623
+ const findings = ruleStylesNoRawDimensions(ix);
3624
+ expect(findings).toHaveLength(1);
3625
+ expect(findings[0].attributes).toMatchObject({
3626
+ source: "jsx",
3627
+ rawValue: "9px",
3628
+ suggestedToken: "--radius-md",
3629
+ });
3630
+ expect(findings[0].fix).toBeUndefined();
3631
+ });
3632
+ });
3633
+
3634
+ // Type witness so eslint/tsc don't trip on unused imports above.
3635
+ const _typeWitness: Fact[] = [];
3636
+ void _typeWitness;