@fragments-sdk/cli 0.14.2 → 0.15.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 (135) hide show
  1. package/README.md +0 -3
  2. package/dist/bin.js +4290 -3754
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-TXFCEDOC.js → chunk-2WXKALIG.js} +2 -2
  5. package/dist/{chunk-I34BC3CU.js → chunk-32LIWN2P.js} +1006 -3
  6. package/dist/chunk-32LIWN2P.js.map +1 -0
  7. package/dist/{chunk-55KERLWL.js → chunk-65WSVDV5.js} +314 -89
  8. package/dist/chunk-65WSVDV5.js.map +1 -0
  9. package/dist/chunk-7DZC4YEV.js +294 -0
  10. package/dist/chunk-7DZC4YEV.js.map +1 -0
  11. package/dist/{chunk-LOYS64QS.js → chunk-7WHVW72L.js} +230 -19
  12. package/dist/chunk-7WHVW72L.js.map +1 -0
  13. package/dist/{chunk-PJT5IZ37.js → chunk-BJE3425I.js} +19 -52
  14. package/dist/{chunk-PJT5IZ37.js.map → chunk-BJE3425I.js.map} +1 -1
  15. package/dist/{chunk-5A6X2Y73.js → chunk-CZD3AD4Q.js} +12 -11
  16. package/dist/chunk-CZD3AD4Q.js.map +1 -0
  17. package/dist/{chunk-EYXVAMEX.js → chunk-MN3TJ3D5.js} +72 -3
  18. package/dist/chunk-MN3TJ3D5.js.map +1 -0
  19. package/dist/chunk-QCN35LJU.js +630 -0
  20. package/dist/chunk-QCN35LJU.js.map +1 -0
  21. package/dist/chunk-T47OLCSF.js +36 -0
  22. package/dist/chunk-T47OLCSF.js.map +1 -0
  23. package/dist/{chunk-APTQIBS5.js → chunk-XJQ5BIWI.js} +144 -1049
  24. package/dist/chunk-XJQ5BIWI.js.map +1 -0
  25. package/dist/codebase-scanner-VOTPXRYW.js +22 -0
  26. package/dist/converter-JLINP7CJ.js +34 -0
  27. package/dist/converter-JLINP7CJ.js.map +1 -0
  28. package/dist/core/index.js +43 -1
  29. package/dist/{generate-RYWIPDN2.js → generate-A4FP5426.js} +3 -4
  30. package/dist/{generate-RYWIPDN2.js.map → generate-A4FP5426.js.map} +1 -1
  31. package/dist/govern-scan-UCBZR6D6.js +280 -0
  32. package/dist/govern-scan-UCBZR6D6.js.map +1 -0
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.js +11 -11
  35. package/dist/{init-WRUSW7R5.js → init-HGSM35XA.js} +131 -128
  36. package/dist/init-HGSM35XA.js.map +1 -0
  37. package/dist/{init-cloud-REQ3XLHO.js → init-cloud-MQ6GRJAZ.js} +2 -2
  38. package/dist/mcp-bin.js +5 -36
  39. package/dist/mcp-bin.js.map +1 -1
  40. package/dist/scan-VNNKACG2.js +15 -0
  41. package/dist/{scan-generate-TFZVL3BT.js → scan-generate-TWRHNU5M.js} +335 -46
  42. package/dist/scan-generate-TWRHNU5M.js.map +1 -0
  43. package/dist/scanner-7LAZYPWZ.js +13 -0
  44. package/dist/{service-HKJ6B7P7.js → service-FHQU7YS7.js} +27 -23
  45. package/dist/{snapshot-C5DYIGIV.js → snapshot-KQEQ6XHL.js} +2 -2
  46. package/dist/{static-viewer-DUVC4UIM.js → static-viewer-63PG6FWY.js} +3 -3
  47. package/dist/static-viewer-63PG6FWY.js.map +1 -0
  48. package/dist/{test-JW7JIDFG.js → test-UQYUCZIS.js} +4 -6
  49. package/dist/{test-JW7JIDFG.js.map → test-UQYUCZIS.js.map} +1 -1
  50. package/dist/{tokens-KE73G5JC.js → tokens-6GYKDV6U.js} +6 -5
  51. package/dist/{tokens-KE73G5JC.js.map → tokens-6GYKDV6U.js.map} +1 -1
  52. package/dist/tokens-generate-VTZV5EEW.js +86 -0
  53. package/dist/tokens-generate-VTZV5EEW.js.map +1 -0
  54. package/package.json +6 -6
  55. package/src/bin.ts +210 -48
  56. package/src/build.ts +130 -6
  57. package/src/commands/__fixtures__/shadcn-label-wrapper/package.json +7 -0
  58. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +42 -0
  59. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.tsx +11 -0
  60. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +20 -0
  61. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.tsx +14 -0
  62. package/src/commands/__fixtures__/shadcn-label-wrapper/tsconfig.app.json +23 -0
  63. package/src/commands/__tests__/init.test.ts +113 -0
  64. package/src/commands/__tests__/scan-generate.test.ts +188 -69
  65. package/src/commands/__tests__/verify.test.ts +91 -0
  66. package/src/commands/discover.ts +151 -0
  67. package/src/commands/enhance.ts +3 -1
  68. package/src/commands/govern-scan.ts +386 -0
  69. package/src/commands/govern.ts +2 -2
  70. package/src/commands/init.ts +152 -28
  71. package/src/commands/inspect.ts +290 -0
  72. package/src/commands/migrate-contract.ts +85 -0
  73. package/src/commands/scan-generate.ts +438 -50
  74. package/src/commands/scan.ts +1 -0
  75. package/src/commands/setup.ts +27 -50
  76. package/src/commands/tokens-generate.ts +113 -0
  77. package/src/commands/verify.ts +195 -1
  78. package/src/core/__fixtures__/shadcn-input/input.tsx +7 -0
  79. package/src/core/__fixtures__/shadcn-input/tsconfig.json +14 -0
  80. package/src/core/__fixtures__/shadcn-label/label.tsx +11 -0
  81. package/src/core/__fixtures__/shadcn-label/primitive.tsx +14 -0
  82. package/src/core/__fixtures__/shadcn-label/tsconfig.json +14 -0
  83. package/src/core/__fixtures__/shadcn-radix-label/label.tsx +11 -0
  84. package/src/core/__fixtures__/shadcn-radix-label/node_modules/radix-ui/index.d.ts +12 -0
  85. package/src/core/__fixtures__/shadcn-radix-label/tsconfig.json +14 -0
  86. package/src/core/__tests__/contract-parity.test.ts +316 -0
  87. package/src/core/component-extractor.test.ts +39 -0
  88. package/src/core/component-extractor.ts +92 -1
  89. package/src/core/config.ts +2 -1
  90. package/src/core/discovery.ts +13 -2
  91. package/src/core/drift-verifier.ts +123 -0
  92. package/src/core/extractor-adapter.ts +80 -0
  93. package/src/mcp/__tests__/projectFields.test.ts +1 -1
  94. package/src/mcp/utils.ts +1 -50
  95. package/src/migrate/converter.ts +3 -3
  96. package/src/migrate/fragment-to-contract.ts +253 -0
  97. package/src/migrate/report.ts +1 -1
  98. package/src/scripts/token-benchmark.ts +121 -0
  99. package/src/service/__tests__/props-extractor.test.ts +94 -0
  100. package/src/service/__tests__/token-normalizer.test.ts +690 -0
  101. package/src/service/ast-utils.ts +4 -23
  102. package/src/service/babel-config.ts +23 -0
  103. package/src/service/enhance/converter.ts +61 -0
  104. package/src/service/enhance/props-extractor.ts +25 -8
  105. package/src/service/enhance/scanner.ts +5 -24
  106. package/src/service/snippet-validation.ts +9 -3
  107. package/src/service/token-normalizer.ts +510 -0
  108. package/src/shared/index.ts +1 -0
  109. package/src/shared/project-fields.ts +46 -0
  110. package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
  111. package/src/viewer/preview-adapter.ts +116 -0
  112. package/src/viewer/style-utils.ts +27 -412
  113. package/src/viewer/vite-plugin.ts +2 -2
  114. package/dist/chunk-55KERLWL.js.map +0 -1
  115. package/dist/chunk-5A6X2Y73.js.map +0 -1
  116. package/dist/chunk-APTQIBS5.js.map +0 -1
  117. package/dist/chunk-EYXVAMEX.js.map +0 -1
  118. package/dist/chunk-I34BC3CU.js.map +0 -1
  119. package/dist/chunk-LOYS64QS.js.map +0 -1
  120. package/dist/chunk-ZKTFKHWN.js +0 -324
  121. package/dist/chunk-ZKTFKHWN.js.map +0 -1
  122. package/dist/discovery-VDANZAJ2.js +0 -28
  123. package/dist/init-WRUSW7R5.js.map +0 -1
  124. package/dist/scan-YJHQIRKG.js +0 -14
  125. package/dist/scan-generate-TFZVL3BT.js.map +0 -1
  126. package/dist/viewer-2TZS3NDL.js +0 -2730
  127. package/dist/viewer-2TZS3NDL.js.map +0 -1
  128. package/src/commands/dev.ts +0 -107
  129. /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
  130. /package/dist/{discovery-VDANZAJ2.js.map → codebase-scanner-VOTPXRYW.js.map} +0 -0
  131. /package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-MQ6GRJAZ.js.map} +0 -0
  132. /package/dist/{scan-YJHQIRKG.js.map → scan-VNNKACG2.js.map} +0 -0
  133. /package/dist/{service-HKJ6B7P7.js.map → scanner-7LAZYPWZ.js.map} +0 -0
  134. /package/dist/{static-viewer-DUVC4UIM.js.map → service-FHQU7YS7.js.map} +0 -0
  135. /package/dist/{snapshot-C5DYIGIV.js.map → snapshot-KQEQ6XHL.js.map} +0 -0
@@ -0,0 +1,690 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ normalizeCSSVarTokens,
7
+ normalizeTailwindTheme,
8
+ loadTailwindConfig,
9
+ findTailwindConfig,
10
+ NormalizedTokenLookup,
11
+ createTokenLookup,
12
+ } from "../token-normalizer.js";
13
+ import type { DesignToken, NormalizedToken } from "@fragments-sdk/core";
14
+
15
+ // ─── normalizeCSSVarTokens ──────────────────────────────────────────────────
16
+
17
+ describe("normalizeCSSVarTokens", () => {
18
+ it("converts DesignToken[] to NormalizedToken[]", () => {
19
+ const tokens: DesignToken[] = [
20
+ {
21
+ name: "--color-primary",
22
+ rawValue: "var(--color-cobalt-50)",
23
+ resolvedValue: "#0051c2",
24
+ category: "color",
25
+ level: 2,
26
+ referenceChain: ["--color-cobalt-50"],
27
+ sourceFile: "src/tokens.css",
28
+ lineNumber: 5,
29
+ theme: "default",
30
+ selector: ":root",
31
+ description: "Primary brand color",
32
+ },
33
+ ];
34
+
35
+ const normalized = normalizeCSSVarTokens(tokens);
36
+
37
+ expect(normalized).toHaveLength(1);
38
+ expect(normalized[0]).toEqual({
39
+ name: "--color-primary",
40
+ value: "#0051c2",
41
+ category: "color",
42
+ source: "css-var",
43
+ originalName: "--color-primary",
44
+ theme: "default",
45
+ });
46
+ });
47
+
48
+ it("preserves category and theme from source tokens", () => {
49
+ const tokens: DesignToken[] = [
50
+ {
51
+ name: "--spacing-md",
52
+ rawValue: "16px",
53
+ resolvedValue: "16px",
54
+ category: "spacing",
55
+ level: 1,
56
+ referenceChain: [],
57
+ sourceFile: "tokens.css",
58
+ theme: "default",
59
+ selector: ":root",
60
+ },
61
+ {
62
+ name: "--color-bg",
63
+ rawValue: "#1a1a1a",
64
+ resolvedValue: "#1a1a1a",
65
+ category: "color",
66
+ level: 1,
67
+ referenceChain: [],
68
+ sourceFile: "tokens.css",
69
+ theme: "dark",
70
+ selector: "[data-theme='dark']",
71
+ },
72
+ ];
73
+
74
+ const normalized = normalizeCSSVarTokens(tokens);
75
+
76
+ expect(normalized[0].category).toBe("spacing");
77
+ expect(normalized[0].theme).toBe("default");
78
+ expect(normalized[1].category).toBe("color");
79
+ expect(normalized[1].theme).toBe("dark");
80
+ });
81
+
82
+ it("handles empty input", () => {
83
+ expect(normalizeCSSVarTokens([])).toEqual([]);
84
+ });
85
+ });
86
+
87
+ // ─── normalizeTailwindTheme ──────────────────────────────────────────────────
88
+
89
+ describe("normalizeTailwindTheme", () => {
90
+ it("extracts flat color tokens", () => {
91
+ const theme = {
92
+ colors: {
93
+ primary: "#0051c2",
94
+ secondary: "#6b7280",
95
+ },
96
+ };
97
+
98
+ const tokens = normalizeTailwindTheme(theme);
99
+
100
+ expect(tokens).toContainEqual({
101
+ name: "colors.primary",
102
+ value: "#0051c2",
103
+ category: "color",
104
+ source: "tailwind",
105
+ originalName: "colors.primary",
106
+ theme: "default",
107
+ });
108
+ expect(tokens).toContainEqual({
109
+ name: "colors.secondary",
110
+ value: "#6b7280",
111
+ category: "color",
112
+ source: "tailwind",
113
+ originalName: "colors.secondary",
114
+ theme: "default",
115
+ });
116
+ });
117
+
118
+ it("extracts nested color scale tokens", () => {
119
+ const theme = {
120
+ colors: {
121
+ blue: {
122
+ 50: "#eff6ff",
123
+ 500: "#3b82f6",
124
+ 900: "#1e3a5f",
125
+ },
126
+ },
127
+ };
128
+
129
+ const tokens = normalizeTailwindTheme(theme);
130
+
131
+ expect(tokens).toHaveLength(3);
132
+ expect(tokens).toContainEqual(
133
+ expect.objectContaining({ name: "colors.blue.50", value: "#eff6ff" })
134
+ );
135
+ expect(tokens).toContainEqual(
136
+ expect.objectContaining({ name: "colors.blue.500", value: "#3b82f6" })
137
+ );
138
+ expect(tokens).toContainEqual(
139
+ expect.objectContaining({ name: "colors.blue.900", value: "#1e3a5f" })
140
+ );
141
+ });
142
+
143
+ it("collapses DEFAULT key to parent path", () => {
144
+ const theme = {
145
+ colors: {
146
+ blue: {
147
+ DEFAULT: "#3b82f6",
148
+ light: "#93c5fd",
149
+ },
150
+ },
151
+ };
152
+
153
+ const tokens = normalizeTailwindTheme(theme);
154
+
155
+ expect(tokens).toContainEqual(
156
+ expect.objectContaining({ name: "colors.blue", value: "#3b82f6" })
157
+ );
158
+ expect(tokens).toContainEqual(
159
+ expect.objectContaining({ name: "colors.blue.light", value: "#93c5fd" })
160
+ );
161
+ });
162
+
163
+ it("extracts spacing tokens", () => {
164
+ const theme = {
165
+ spacing: {
166
+ "1": "4px",
167
+ "2": "8px",
168
+ "4": "16px",
169
+ "8": "32px",
170
+ },
171
+ };
172
+
173
+ const tokens = normalizeTailwindTheme(theme);
174
+
175
+ expect(tokens).toHaveLength(4);
176
+ expect(tokens.every((t) => t.category === "spacing")).toBe(true);
177
+ expect(tokens).toContainEqual(
178
+ expect.objectContaining({ name: "spacing.4", value: "16px" })
179
+ );
180
+ });
181
+
182
+ it("extracts typography tokens", () => {
183
+ const theme = {
184
+ fontSize: {
185
+ sm: "14px",
186
+ base: "16px",
187
+ lg: "18px",
188
+ },
189
+ fontWeight: {
190
+ normal: "400",
191
+ bold: "700",
192
+ },
193
+ };
194
+
195
+ const tokens = normalizeTailwindTheme(theme);
196
+
197
+ expect(tokens).toContainEqual(
198
+ expect.objectContaining({
199
+ name: "fontSize.base",
200
+ value: "16px",
201
+ category: "typography",
202
+ })
203
+ );
204
+ expect(tokens).toContainEqual(
205
+ expect.objectContaining({
206
+ name: "fontWeight.bold",
207
+ value: "700",
208
+ category: "typography",
209
+ })
210
+ );
211
+ });
212
+
213
+ it("extracts borderRadius tokens", () => {
214
+ const theme = {
215
+ borderRadius: {
216
+ sm: "4px",
217
+ md: "8px",
218
+ lg: "16px",
219
+ full: "9999px",
220
+ },
221
+ };
222
+
223
+ const tokens = normalizeTailwindTheme(theme);
224
+
225
+ expect(tokens).toHaveLength(4);
226
+ expect(tokens.every((t) => t.category === "radius")).toBe(true);
227
+ });
228
+
229
+ it("handles fontFamily arrays by joining", () => {
230
+ const theme = {
231
+ fontFamily: {
232
+ sans: ["Inter", "system-ui", "sans-serif"],
233
+ mono: ["JetBrains Mono", "monospace"],
234
+ },
235
+ };
236
+
237
+ const tokens = normalizeTailwindTheme(theme);
238
+
239
+ expect(tokens).toContainEqual(
240
+ expect.objectContaining({
241
+ name: "fontFamily.sans",
242
+ value: "Inter, system-ui, sans-serif",
243
+ })
244
+ );
245
+ });
246
+
247
+ it("handles fontSize tuples by taking first element", () => {
248
+ const theme = {
249
+ fontSize: {
250
+ sm: ["14px", { lineHeight: "20px" }],
251
+ base: ["16px", { lineHeight: "24px" }],
252
+ },
253
+ };
254
+
255
+ const tokens = normalizeTailwindTheme(theme);
256
+
257
+ expect(tokens).toContainEqual(
258
+ expect.objectContaining({ name: "fontSize.sm", value: "14px" })
259
+ );
260
+ expect(tokens).toContainEqual(
261
+ expect.objectContaining({ name: "fontSize.base", value: "16px" })
262
+ );
263
+ });
264
+
265
+ it("handles numeric values", () => {
266
+ const theme = {
267
+ fontWeight: {
268
+ light: 300,
269
+ normal: 400,
270
+ bold: 700,
271
+ },
272
+ };
273
+
274
+ const tokens = normalizeTailwindTheme(theme);
275
+
276
+ expect(tokens).toContainEqual(
277
+ expect.objectContaining({ name: "fontWeight.normal", value: "400" })
278
+ );
279
+ });
280
+
281
+ it("skips function values", () => {
282
+ const theme = {
283
+ colors: {
284
+ primary: "#0051c2",
285
+ dynamic: () => "#ff0000",
286
+ },
287
+ };
288
+
289
+ const tokens = normalizeTailwindTheme(theme);
290
+
291
+ expect(tokens).toHaveLength(1);
292
+ expect(tokens[0].name).toBe("colors.primary");
293
+ });
294
+
295
+ it("skips unknown theme keys", () => {
296
+ const theme = {
297
+ colors: { primary: "#0051c2" },
298
+ screens: { sm: "640px", md: "768px" },
299
+ plugins: [],
300
+ };
301
+
302
+ const tokens = normalizeTailwindTheme(theme);
303
+
304
+ // Only colors should be extracted, screens and plugins are not mapped
305
+ expect(tokens).toHaveLength(1);
306
+ });
307
+
308
+ it("respects custom source option", () => {
309
+ const tokens = normalizeTailwindTheme(
310
+ { colors: { primary: "#000" } },
311
+ { source: "tailwind-v4" }
312
+ );
313
+
314
+ expect(tokens[0].source).toBe("tailwind-v4");
315
+ });
316
+
317
+ it("respects custom theme option", () => {
318
+ const tokens = normalizeTailwindTheme(
319
+ { colors: { primary: "#000" } },
320
+ { defaultTheme: "dark" }
321
+ );
322
+
323
+ expect(tokens[0].theme).toBe("dark");
324
+ });
325
+ });
326
+
327
+ // ─── loadTailwindConfig ──────────────────────────────────────────────────────
328
+
329
+ describe("loadTailwindConfig", () => {
330
+ let testDir: string;
331
+
332
+ beforeEach(async () => {
333
+ testDir = join(
334
+ tmpdir(),
335
+ `tw-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
336
+ );
337
+ await mkdir(testDir, { recursive: true });
338
+ });
339
+
340
+ afterEach(async () => {
341
+ try {
342
+ await rm(testDir, { recursive: true, force: true });
343
+ } catch {
344
+ // Ignore cleanup errors
345
+ }
346
+ });
347
+
348
+ it("loads a CJS tailwind config", async () => {
349
+ const config = `
350
+ module.exports = {
351
+ theme: {
352
+ colors: {
353
+ primary: '#0051c2',
354
+ secondary: '#6b7280',
355
+ },
356
+ spacing: {
357
+ '1': '4px',
358
+ '2': '8px',
359
+ },
360
+ },
361
+ };
362
+ `;
363
+ const configPath = join(testDir, "tailwind.config.cjs");
364
+ await writeFile(configPath, config);
365
+
366
+ const tokens = await loadTailwindConfig(configPath);
367
+
368
+ expect(tokens.length).toBeGreaterThanOrEqual(4);
369
+ expect(tokens).toContainEqual(
370
+ expect.objectContaining({
371
+ name: "colors.primary",
372
+ value: "#0051c2",
373
+ source: "tailwind",
374
+ })
375
+ );
376
+ expect(tokens).toContainEqual(
377
+ expect.objectContaining({
378
+ name: "spacing.2",
379
+ value: "8px",
380
+ category: "spacing",
381
+ })
382
+ );
383
+ });
384
+
385
+ it("loads a config with theme.extend", async () => {
386
+ const config = `
387
+ module.exports = {
388
+ theme: {
389
+ colors: {
390
+ white: '#ffffff',
391
+ },
392
+ extend: {
393
+ colors: {
394
+ brand: '#ff6600',
395
+ },
396
+ },
397
+ },
398
+ };
399
+ `;
400
+ const configPath = join(testDir, "tailwind.config.cjs");
401
+ await writeFile(configPath, config);
402
+
403
+ const tokens = await loadTailwindConfig(configPath);
404
+
405
+ // Should have both base and extended colors
406
+ expect(tokens).toContainEqual(
407
+ expect.objectContaining({ name: "colors.white", value: "#ffffff" })
408
+ );
409
+ expect(tokens).toContainEqual(
410
+ expect.objectContaining({ name: "colors.brand", value: "#ff6600" })
411
+ );
412
+ });
413
+
414
+ it("throws for missing config", async () => {
415
+ await expect(
416
+ loadTailwindConfig(join(testDir, "nonexistent.config.js"))
417
+ ).rejects.toThrow("Tailwind config not found");
418
+ });
419
+ });
420
+
421
+ // ─── findTailwindConfig ──────────────────────────────────────────────────────
422
+
423
+ describe("findTailwindConfig", () => {
424
+ let testDir: string;
425
+
426
+ beforeEach(async () => {
427
+ testDir = join(
428
+ tmpdir(),
429
+ `tw-find-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
430
+ );
431
+ await mkdir(testDir, { recursive: true });
432
+ });
433
+
434
+ afterEach(async () => {
435
+ try {
436
+ await rm(testDir, { recursive: true, force: true });
437
+ } catch {
438
+ // Ignore cleanup errors
439
+ }
440
+ });
441
+
442
+ it("returns null when no config exists", () => {
443
+ expect(findTailwindConfig(testDir)).toBeNull();
444
+ });
445
+
446
+ it("finds tailwind.config.ts", async () => {
447
+ await writeFile(join(testDir, "tailwind.config.ts"), "export default {}");
448
+
449
+ const result = findTailwindConfig(testDir);
450
+ expect(result).toContain("tailwind.config.ts");
451
+ });
452
+
453
+ it("prefers .ts over .js", async () => {
454
+ await writeFile(join(testDir, "tailwind.config.ts"), "export default {}");
455
+ await writeFile(join(testDir, "tailwind.config.js"), "module.exports = {}");
456
+
457
+ const result = findTailwindConfig(testDir);
458
+ expect(result).toContain("tailwind.config.ts");
459
+ });
460
+ });
461
+
462
+ // ─── NormalizedTokenLookup ───────────────────────────────────────────────────
463
+
464
+ describe("NormalizedTokenLookup", () => {
465
+ const sampleTokens: NormalizedToken[] = [
466
+ {
467
+ name: "--color-primary",
468
+ value: "#ff0000",
469
+ category: "color",
470
+ source: "css-var",
471
+ originalName: "--color-primary",
472
+ theme: "default",
473
+ },
474
+ {
475
+ name: "--color-bg-dark",
476
+ value: "#1a1a1a",
477
+ category: "color",
478
+ source: "css-var",
479
+ originalName: "--color-bg-dark",
480
+ theme: "dark",
481
+ },
482
+ {
483
+ name: "--spacing-md",
484
+ value: "16px",
485
+ category: "spacing",
486
+ source: "css-var",
487
+ originalName: "--spacing-md",
488
+ theme: "default",
489
+ },
490
+ {
491
+ name: "colors.primary",
492
+ value: "#ff0000",
493
+ category: "color",
494
+ source: "tailwind",
495
+ originalName: "colors.primary",
496
+ theme: "default",
497
+ },
498
+ ];
499
+
500
+ describe("findByValue", () => {
501
+ it("finds tokens by exact value", () => {
502
+ const lookup = new NormalizedTokenLookup(sampleTokens);
503
+ const names = lookup.findByValue("#ff0000");
504
+
505
+ expect(names).toContain("--color-primary");
506
+ expect(names).toContain("colors.primary");
507
+ });
508
+
509
+ it("normalizes hex case for lookup", () => {
510
+ const lookup = new NormalizedTokenLookup(sampleTokens);
511
+ const names = lookup.findByValue("#FF0000");
512
+
513
+ expect(names).toContain("--color-primary");
514
+ });
515
+
516
+ it("normalizes rgb to hex for lookup", () => {
517
+ const lookup = new NormalizedTokenLookup(sampleTokens);
518
+ const names = lookup.findByValue("rgb(255, 0, 0)");
519
+
520
+ expect(names).toContain("--color-primary");
521
+ });
522
+
523
+ it("filters by theme", () => {
524
+ const lookup = new NormalizedTokenLookup(sampleTokens);
525
+ const defaultNames = lookup.findByValue("#ff0000", "default");
526
+ const darkNames = lookup.findByValue("#1a1a1a", "dark");
527
+
528
+ expect(defaultNames).toContain("--color-primary");
529
+ expect(darkNames).toContain("--color-bg-dark");
530
+ });
531
+
532
+ it("includes default-theme tokens when filtering by theme", () => {
533
+ const lookup = new NormalizedTokenLookup(sampleTokens);
534
+ // When requesting dark theme, default-theme tokens should also appear
535
+ const names = lookup.findByValue("#ff0000", "dark");
536
+
537
+ expect(names).toContain("--color-primary");
538
+ });
539
+
540
+ it("returns empty array for unknown value", () => {
541
+ const lookup = new NormalizedTokenLookup(sampleTokens);
542
+ expect(lookup.findByValue("#ffffff")).toEqual([]);
543
+ });
544
+ });
545
+
546
+ describe("getToken", () => {
547
+ it("returns a DesignToken for known name", () => {
548
+ const lookup = new NormalizedTokenLookup(sampleTokens);
549
+ const token = lookup.getToken("--color-primary");
550
+
551
+ expect(token).toBeDefined();
552
+ expect(token?.name).toBe("--color-primary");
553
+ expect(token?.resolvedValue).toBe("#ff0000");
554
+ expect(token?.category).toBe("color");
555
+ });
556
+
557
+ it("returns undefined for unknown name", () => {
558
+ const lookup = new NormalizedTokenLookup(sampleTokens);
559
+ expect(lookup.getToken("--nonexistent")).toBeUndefined();
560
+ });
561
+ });
562
+
563
+ describe("calculateUsageSummary", () => {
564
+ it("calculates compliance for token-using properties", () => {
565
+ const lookup = new NormalizedTokenLookup(sampleTokens);
566
+ const summary = lookup.calculateUsageSummary([
567
+ {
568
+ property: "backgroundColor",
569
+ figma: "#ff0000",
570
+ rendered: "#ff0000",
571
+ match: true,
572
+ },
573
+ ]);
574
+
575
+ expect(summary.totalProperties).toBe(1);
576
+ expect(summary.compliancePercent).toBeGreaterThan(0);
577
+ });
578
+
579
+ it("detects hardcoded values", () => {
580
+ const lookup = new NormalizedTokenLookup(sampleTokens);
581
+ const summary = lookup.calculateUsageSummary([
582
+ {
583
+ property: "backgroundColor",
584
+ figma: "#ff0000",
585
+ rendered: "#cccccc",
586
+ match: false,
587
+ },
588
+ ]);
589
+
590
+ expect(summary.hardcoded).toBe(1);
591
+ expect(summary.hardcodedProperties).toHaveLength(1);
592
+ });
593
+
594
+ it("handles empty diffs", () => {
595
+ const lookup = new NormalizedTokenLookup(sampleTokens);
596
+ const summary = lookup.calculateUsageSummary([]);
597
+
598
+ expect(summary.totalProperties).toBe(0);
599
+ expect(summary.compliancePercent).toBe(100);
600
+ });
601
+ });
602
+ });
603
+
604
+ // ─── createTokenLookup factory ───────────────────────────────────────────────
605
+
606
+ describe("createTokenLookup", () => {
607
+ it("creates a working TokenLookup from NormalizedToken[]", () => {
608
+ const tokens: NormalizedToken[] = [
609
+ {
610
+ name: "colors.primary",
611
+ value: "#0051c2",
612
+ category: "color",
613
+ source: "tailwind",
614
+ originalName: "colors.primary",
615
+ theme: "default",
616
+ },
617
+ ];
618
+
619
+ const lookup = createTokenLookup(tokens);
620
+
621
+ expect(lookup.findByValue("#0051c2")).toContain("colors.primary");
622
+ expect(lookup.getToken("colors.primary")).toBeDefined();
623
+ });
624
+ });
625
+
626
+ // ─── Cross-source consistency ────────────────────────────────────────────────
627
+
628
+ describe("cross-source consistency", () => {
629
+ it("lookup works consistently across CSS var and Tailwind tokens", () => {
630
+ const cssTokens = normalizeCSSVarTokens([
631
+ {
632
+ name: "--color-primary",
633
+ rawValue: "#3b82f6",
634
+ resolvedValue: "#3b82f6",
635
+ category: "color",
636
+ level: 1,
637
+ referenceChain: [],
638
+ sourceFile: "tokens.css",
639
+ theme: "default",
640
+ selector: ":root",
641
+ },
642
+ ]);
643
+
644
+ const twTokens = normalizeTailwindTheme({
645
+ colors: { primary: "#3b82f6" },
646
+ });
647
+
648
+ // Combine tokens from both sources
649
+ const lookup = createTokenLookup([...cssTokens, ...twTokens]);
650
+
651
+ // Both should be found for the same value
652
+ const names = lookup.findByValue("#3b82f6");
653
+ expect(names).toContain("--color-primary");
654
+ expect(names).toContain("colors.primary");
655
+ });
656
+
657
+ it("produces same NormalizedToken shape from both sources", () => {
658
+ const cssTokens = normalizeCSSVarTokens([
659
+ {
660
+ name: "--spacing-md",
661
+ rawValue: "16px",
662
+ resolvedValue: "16px",
663
+ category: "spacing",
664
+ level: 1,
665
+ referenceChain: [],
666
+ sourceFile: "tokens.css",
667
+ theme: "default",
668
+ selector: ":root",
669
+ },
670
+ ]);
671
+
672
+ const twTokens = normalizeTailwindTheme({
673
+ spacing: { md: "16px" },
674
+ });
675
+
676
+ // Both should have the required NormalizedToken fields
677
+ for (const token of [...cssTokens, ...twTokens]) {
678
+ expect(token).toHaveProperty("name");
679
+ expect(token).toHaveProperty("value");
680
+ expect(token).toHaveProperty("category");
681
+ expect(token).toHaveProperty("source");
682
+ expect(token).toHaveProperty("originalName");
683
+ expect(token).toHaveProperty("theme");
684
+ }
685
+
686
+ // Categories should match for equivalent tokens
687
+ expect(cssTokens[0].category).toBe("spacing");
688
+ expect(twTokens[0].category).toBe("spacing");
689
+ });
690
+ });