@buoy-design/core 0.3.36 → 0.3.37
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.
- package/dist/analysis/audit.d.ts.sync-conflict-20260313-164724-6PCZ3ZU.map +1 -0
- package/dist/analysis/audit.test.sync-conflict-20260313-170321-6PCZ3ZU.d.ts +2 -0
- package/dist/analysis/audit.test.sync-conflict-20260313-170321-6PCZ3ZU.d.ts.map +1 -0
- package/dist/analysis/audit.test.sync-conflict-20260313-170321-6PCZ3ZU.js +896 -0
- package/dist/analysis/audit.test.sync-conflict-20260313-170321-6PCZ3ZU.js.map +1 -0
- package/dist/analysis/semantic-diff.test.sync-conflict-20260313-111438-6PCZ3ZU.d.ts +2 -0
- package/dist/analysis/semantic-diff.test.sync-conflict-20260313-111438-6PCZ3ZU.d.ts.map +1 -0
- package/dist/analysis/semantic-diff.test.sync-conflict-20260313-111438-6PCZ3ZU.js +766 -0
- package/dist/analysis/semantic-diff.test.sync-conflict-20260313-111438-6PCZ3ZU.js.map +1 -0
- package/dist/graph/collectors/usages.sync-conflict-20260313-145408-6PCZ3ZU.js +248 -0
- package/package.json +1 -1
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
// packages/core/src/analysis/semantic-diff.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { SemanticDiffEngine } from "./semantic-diff.js";
|
|
4
|
+
describe("SemanticDiffEngine", () => {
|
|
5
|
+
const engine = new SemanticDiffEngine();
|
|
6
|
+
describe("checkFrameworkSprawl", () => {
|
|
7
|
+
it("returns null for single framework", () => {
|
|
8
|
+
const result = engine.checkFrameworkSprawl([
|
|
9
|
+
{ name: "react", version: "18.2.0" },
|
|
10
|
+
]);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
it("returns null for empty frameworks", () => {
|
|
14
|
+
const result = engine.checkFrameworkSprawl([]);
|
|
15
|
+
expect(result).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
it("detects sprawl with two UI frameworks", () => {
|
|
18
|
+
const result = engine.checkFrameworkSprawl([
|
|
19
|
+
{ name: "react", version: "18.2.0" },
|
|
20
|
+
{ name: "vue", version: "3.0.0" },
|
|
21
|
+
]);
|
|
22
|
+
expect(result).not.toBeNull();
|
|
23
|
+
expect(result?.type).toBe("framework-sprawl");
|
|
24
|
+
expect(result?.severity).toBe("warning");
|
|
25
|
+
expect(result?.message).toContain("2 UI frameworks");
|
|
26
|
+
});
|
|
27
|
+
it("ignores non-UI frameworks", () => {
|
|
28
|
+
const result = engine.checkFrameworkSprawl([
|
|
29
|
+
{ name: "react", version: "18.2.0" },
|
|
30
|
+
{ name: "express", version: "4.0.0" },
|
|
31
|
+
]);
|
|
32
|
+
expect(result).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it("detects sprawl with meta-frameworks", () => {
|
|
35
|
+
const result = engine.checkFrameworkSprawl([
|
|
36
|
+
{ name: "nextjs", version: "14.0.0" },
|
|
37
|
+
{ name: "nuxt", version: "3.0.0" },
|
|
38
|
+
]);
|
|
39
|
+
expect(result).not.toBeNull();
|
|
40
|
+
expect(result?.message).toContain("nextjs");
|
|
41
|
+
expect(result?.message).toContain("nuxt");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("compareComponents", () => {
|
|
45
|
+
it("matches components with exact names", () => {
|
|
46
|
+
const source = [createMockComponent("Button", "react")];
|
|
47
|
+
const target = [createMockComponent("Button", "figma")];
|
|
48
|
+
const result = engine.compareComponents(source, target);
|
|
49
|
+
expect(result.matches).toHaveLength(1);
|
|
50
|
+
expect(result.matches[0].matchType).toBe("exact");
|
|
51
|
+
expect(result.matches[0].confidence).toBe(1);
|
|
52
|
+
expect(result.orphanedSource).toHaveLength(0);
|
|
53
|
+
expect(result.orphanedTarget).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
it("identifies orphaned source components", () => {
|
|
56
|
+
const source = [
|
|
57
|
+
createMockComponent("Button", "react"),
|
|
58
|
+
createMockComponent("Card", "react"),
|
|
59
|
+
];
|
|
60
|
+
const target = [createMockComponent("Button", "figma")];
|
|
61
|
+
const result = engine.compareComponents(source, target);
|
|
62
|
+
expect(result.orphanedSource).toHaveLength(1);
|
|
63
|
+
expect(result.orphanedSource[0].name).toBe("Card");
|
|
64
|
+
});
|
|
65
|
+
it("identifies orphaned target components", () => {
|
|
66
|
+
const source = [createMockComponent("Button", "react")];
|
|
67
|
+
const target = [
|
|
68
|
+
createMockComponent("Button", "figma"),
|
|
69
|
+
createMockComponent("Modal", "figma"),
|
|
70
|
+
];
|
|
71
|
+
const result = engine.compareComponents(source, target);
|
|
72
|
+
expect(result.orphanedTarget).toHaveLength(1);
|
|
73
|
+
expect(result.orphanedTarget[0].name).toBe("Modal");
|
|
74
|
+
});
|
|
75
|
+
it("generates drift signals for orphaned components", () => {
|
|
76
|
+
const source = [createMockComponent("UniqueComponent", "react")];
|
|
77
|
+
const target = [];
|
|
78
|
+
const result = engine.compareComponents(source, target);
|
|
79
|
+
expect(result.drifts).toHaveLength(1);
|
|
80
|
+
expect(result.drifts[0].type).toBe("orphaned-component");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe("analyzeComponents", () => {
|
|
84
|
+
describe("deprecated patterns", () => {
|
|
85
|
+
it("detects deprecated components", () => {
|
|
86
|
+
const components = [
|
|
87
|
+
createMockComponentWithMetadata("OldButton", { deprecated: true }),
|
|
88
|
+
];
|
|
89
|
+
const result = engine.analyzeComponents(components, {
|
|
90
|
+
checkDeprecated: true,
|
|
91
|
+
});
|
|
92
|
+
expect(result.drifts).toHaveLength(1);
|
|
93
|
+
expect(result.drifts[0].type).toBe("deprecated-pattern");
|
|
94
|
+
expect(result.drifts[0].severity).toBe("warning");
|
|
95
|
+
});
|
|
96
|
+
it("includes deprecation reason in suggestions", () => {
|
|
97
|
+
const components = [
|
|
98
|
+
createMockComponentWithMetadata("OldButton", {
|
|
99
|
+
deprecated: true,
|
|
100
|
+
deprecationReason: "Use NewButton instead",
|
|
101
|
+
}),
|
|
102
|
+
];
|
|
103
|
+
const result = engine.analyzeComponents(components, {
|
|
104
|
+
checkDeprecated: true,
|
|
105
|
+
});
|
|
106
|
+
expect(result.drifts[0].details.suggestions).toContain("Use NewButton instead");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("hardcoded values", () => {
|
|
110
|
+
it("detects hardcoded colors", () => {
|
|
111
|
+
const components = [
|
|
112
|
+
createMockComponentWithMetadata("Button", {
|
|
113
|
+
hardcodedValues: [
|
|
114
|
+
{
|
|
115
|
+
type: "color",
|
|
116
|
+
value: "#ff0000",
|
|
117
|
+
property: "backgroundColor",
|
|
118
|
+
location: "line 10",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}),
|
|
122
|
+
];
|
|
123
|
+
const result = engine.analyzeComponents(components, {});
|
|
124
|
+
const colorDrift = result.drifts.find((d) => d.type === "hardcoded-value" && d.message.includes("color"));
|
|
125
|
+
expect(colorDrift).toBeDefined();
|
|
126
|
+
expect(colorDrift?.severity).toBe("warning");
|
|
127
|
+
});
|
|
128
|
+
it("provides actionable token suggestions when tokens available", () => {
|
|
129
|
+
const components = [
|
|
130
|
+
createMockComponentWithMetadata("Button", {
|
|
131
|
+
hardcodedValues: [
|
|
132
|
+
{
|
|
133
|
+
type: "color",
|
|
134
|
+
value: "#ff0000",
|
|
135
|
+
property: "backgroundColor",
|
|
136
|
+
location: "line 10",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
type: "color",
|
|
140
|
+
value: "#0066cc",
|
|
141
|
+
property: "color",
|
|
142
|
+
location: "line 15",
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
}),
|
|
146
|
+
];
|
|
147
|
+
const availableTokens = [
|
|
148
|
+
createMockToken("--color-danger", "#ff0000", "css"),
|
|
149
|
+
createMockToken("--color-primary", "#0066cc", "css"),
|
|
150
|
+
createMockToken("--color-secondary", "#666666", "css"),
|
|
151
|
+
];
|
|
152
|
+
const result = engine.analyzeComponents(components, {
|
|
153
|
+
availableTokens,
|
|
154
|
+
});
|
|
155
|
+
const colorDrift = result.drifts.find((d) => d.type === "hardcoded-value" && d.message.includes("color"));
|
|
156
|
+
expect(colorDrift).toBeDefined();
|
|
157
|
+
expect(colorDrift?.details.tokenSuggestions).toBeDefined();
|
|
158
|
+
expect(colorDrift?.details.tokenSuggestions).toHaveLength(2);
|
|
159
|
+
expect(colorDrift?.details.tokenSuggestions?.[0]).toContain("--color-danger");
|
|
160
|
+
expect(colorDrift?.details.tokenSuggestions?.[1]).toContain("--color-primary");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
describe("token suggestions", () => {
|
|
165
|
+
it("finds exact color matches", () => {
|
|
166
|
+
const tokens = [
|
|
167
|
+
createMockToken("--color-red", "#ff0000", "css"),
|
|
168
|
+
createMockToken("--color-blue", "#0000ff", "css"),
|
|
169
|
+
];
|
|
170
|
+
const suggestions = engine.findColorTokenSuggestions("#ff0000", tokens);
|
|
171
|
+
expect(suggestions).toHaveLength(1);
|
|
172
|
+
expect(suggestions[0]?.suggestedToken).toBe("--color-red");
|
|
173
|
+
expect(suggestions[0]?.confidence).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
it("finds similar color matches", () => {
|
|
176
|
+
const tokens = [
|
|
177
|
+
createMockToken("--color-red", "#ff0000", "css"),
|
|
178
|
+
createMockToken("--color-red-dark", "#cc0000", "css"),
|
|
179
|
+
];
|
|
180
|
+
const suggestions = engine.findColorTokenSuggestions("#ee0000", tokens);
|
|
181
|
+
expect(suggestions.length).toBeGreaterThan(0);
|
|
182
|
+
// Should suggest #ff0000 as closer match
|
|
183
|
+
expect(suggestions[0]?.confidence).toBeGreaterThan(0.9);
|
|
184
|
+
});
|
|
185
|
+
it("normalizes hex color formats", () => {
|
|
186
|
+
const tokens = [createMockToken("--color-red", "#ff0000", "css")];
|
|
187
|
+
// Test shorthand hex
|
|
188
|
+
const shorthand = engine.findColorTokenSuggestions("#f00", tokens);
|
|
189
|
+
expect(shorthand).toHaveLength(1);
|
|
190
|
+
expect(shorthand[0]?.confidence).toBe(1);
|
|
191
|
+
// Test rgb()
|
|
192
|
+
const rgb = engine.findColorTokenSuggestions("rgb(255, 0, 0)", tokens);
|
|
193
|
+
expect(rgb).toHaveLength(1);
|
|
194
|
+
expect(rgb[0]?.confidence).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
it("finds spacing token matches", () => {
|
|
197
|
+
const tokens = [
|
|
198
|
+
{
|
|
199
|
+
id: "spacing:small",
|
|
200
|
+
name: "--spacing-small",
|
|
201
|
+
value: { type: "spacing", value: 8, unit: "px" },
|
|
202
|
+
category: "spacing",
|
|
203
|
+
source: { type: "css", path: "tokens.css" },
|
|
204
|
+
aliases: [],
|
|
205
|
+
usedBy: [],
|
|
206
|
+
metadata: {},
|
|
207
|
+
scannedAt: new Date(),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "spacing:medium",
|
|
211
|
+
name: "--spacing-medium",
|
|
212
|
+
value: { type: "spacing", value: 16, unit: "px" },
|
|
213
|
+
category: "spacing",
|
|
214
|
+
source: { type: "css", path: "tokens.css" },
|
|
215
|
+
aliases: [],
|
|
216
|
+
usedBy: [],
|
|
217
|
+
metadata: {},
|
|
218
|
+
scannedAt: new Date(),
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
const suggestions = engine.findSpacingTokenSuggestions("16px", tokens);
|
|
222
|
+
expect(suggestions).toHaveLength(1);
|
|
223
|
+
expect(suggestions[0]?.suggestedToken).toBe("--spacing-medium");
|
|
224
|
+
expect(suggestions[0]?.confidence).toBe(1);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
describe("compareTokens", () => {
|
|
228
|
+
it("matches tokens with same names", () => {
|
|
229
|
+
const source = [createMockToken("--primary-color", "#0066cc", "css")];
|
|
230
|
+
const target = [createMockToken("--primary-color", "#0066cc", "figma")];
|
|
231
|
+
const result = engine.compareTokens(source, target);
|
|
232
|
+
expect(result.matches).toHaveLength(1);
|
|
233
|
+
expect(result.drifts).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
it("detects value divergence", () => {
|
|
236
|
+
const source = [createMockToken("--primary-color", "#0066cc", "css")];
|
|
237
|
+
const target = [createMockToken("--primary-color", "#ff0000", "figma")];
|
|
238
|
+
const result = engine.compareTokens(source, target);
|
|
239
|
+
expect(result.matches).toHaveLength(1);
|
|
240
|
+
expect(result.drifts).toHaveLength(1);
|
|
241
|
+
expect(result.drifts[0].type).toBe("value-divergence");
|
|
242
|
+
});
|
|
243
|
+
it("identifies orphaned tokens", () => {
|
|
244
|
+
const source = [
|
|
245
|
+
createMockToken("--primary-color", "#0066cc", "css"),
|
|
246
|
+
createMockToken("--secondary-color", "#666666", "css"),
|
|
247
|
+
];
|
|
248
|
+
const target = [createMockToken("--primary-color", "#0066cc", "figma")];
|
|
249
|
+
const result = engine.compareTokens(source, target);
|
|
250
|
+
expect(result.orphanedSource).toHaveLength(1);
|
|
251
|
+
expect(result.orphanedSource[0].name).toBe("--secondary-color");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe("performance", () => {
|
|
255
|
+
it("handles large component sets efficiently", () => {
|
|
256
|
+
// Create 500 source and 500 target components
|
|
257
|
+
const sourceComponents = [];
|
|
258
|
+
const targetComponents = [];
|
|
259
|
+
for (let i = 0; i < 500; i++) {
|
|
260
|
+
sourceComponents.push(createMockComponent(`Component${i}`, "react"));
|
|
261
|
+
targetComponents.push(createMockComponent(`Component${i}`, "figma"));
|
|
262
|
+
}
|
|
263
|
+
// Add some unique components to test orphan detection
|
|
264
|
+
for (let i = 500; i < 550; i++) {
|
|
265
|
+
sourceComponents.push(createMockComponent(`UniqueSource${i}`, "react"));
|
|
266
|
+
targetComponents.push(createMockComponent(`UniqueTarget${i}`, "figma"));
|
|
267
|
+
}
|
|
268
|
+
const startTime = performance.now();
|
|
269
|
+
const result = engine.compareComponents(sourceComponents, targetComponents);
|
|
270
|
+
const endTime = performance.now();
|
|
271
|
+
const duration = endTime - startTime;
|
|
272
|
+
// Verify correctness
|
|
273
|
+
expect(result.matches).toHaveLength(500);
|
|
274
|
+
expect(result.orphanedSource).toHaveLength(50);
|
|
275
|
+
expect(result.orphanedTarget).toHaveLength(50);
|
|
276
|
+
// Performance assertion: should complete in under 500ms
|
|
277
|
+
// With O(n²) this would take several seconds for 1000 components
|
|
278
|
+
expect(duration).toBeLessThan(500);
|
|
279
|
+
// Log performance for visibility
|
|
280
|
+
console.log(`Performance: 550+550 components matched in ${duration.toFixed(2)}ms`);
|
|
281
|
+
});
|
|
282
|
+
it("caches normalized names correctly", () => {
|
|
283
|
+
// Create components with similar names to test caching
|
|
284
|
+
const source = [
|
|
285
|
+
createMockComponent("MyButton", "react"),
|
|
286
|
+
createMockComponent("my-button", "react"),
|
|
287
|
+
createMockComponent("my_button", "react"),
|
|
288
|
+
];
|
|
289
|
+
const target = [createMockComponent("mybutton", "figma")];
|
|
290
|
+
const result = engine.compareComponents(source, target);
|
|
291
|
+
// All three should match the same target (first one wins)
|
|
292
|
+
expect(result.matches).toHaveLength(1);
|
|
293
|
+
expect(result.matches[0].source.name).toBe("MyButton");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
describe("duplicate detection", () => {
|
|
297
|
+
it("does NOT flag compound components as duplicates (Button vs ButtonGroup)", () => {
|
|
298
|
+
const components = [
|
|
299
|
+
createMockComponent("Button", "react"),
|
|
300
|
+
createMockComponent("ButtonGroup", "react"),
|
|
301
|
+
];
|
|
302
|
+
// Analyze should not produce duplicate warnings for compound components
|
|
303
|
+
const result = engine.analyzeComponents(components, {
|
|
304
|
+
checkNaming: true,
|
|
305
|
+
});
|
|
306
|
+
// Filter for naming-inconsistency drift signals about potential duplicates
|
|
307
|
+
const duplicateDrifts = result.drifts.filter((d) => d.type === "naming-inconsistency" &&
|
|
308
|
+
d.message.toLowerCase().includes("duplicate"));
|
|
309
|
+
expect(duplicateDrifts).toHaveLength(0);
|
|
310
|
+
});
|
|
311
|
+
it("does NOT flag Card vs CardHeader/CardBody/CardFooter as duplicates", () => {
|
|
312
|
+
const components = [
|
|
313
|
+
createMockComponent("Card", "react"),
|
|
314
|
+
createMockComponent("CardHeader", "react"),
|
|
315
|
+
createMockComponent("CardBody", "react"),
|
|
316
|
+
createMockComponent("CardFooter", "react"),
|
|
317
|
+
];
|
|
318
|
+
const result = engine.analyzeComponents(components, {
|
|
319
|
+
checkNaming: true,
|
|
320
|
+
});
|
|
321
|
+
const duplicateDrifts = result.drifts.filter((d) => d.type === "naming-inconsistency" &&
|
|
322
|
+
d.message.toLowerCase().includes("duplicate"));
|
|
323
|
+
expect(duplicateDrifts).toHaveLength(0);
|
|
324
|
+
});
|
|
325
|
+
it("does NOT flag Modal vs ModalTrigger/ModalContent/ModalOverlay as duplicates", () => {
|
|
326
|
+
const components = [
|
|
327
|
+
createMockComponent("Modal", "react"),
|
|
328
|
+
createMockComponent("ModalTrigger", "react"),
|
|
329
|
+
createMockComponent("ModalContent", "react"),
|
|
330
|
+
createMockComponent("ModalOverlay", "react"),
|
|
331
|
+
];
|
|
332
|
+
const result = engine.analyzeComponents(components, {
|
|
333
|
+
checkNaming: true,
|
|
334
|
+
});
|
|
335
|
+
const duplicateDrifts = result.drifts.filter((d) => d.type === "naming-inconsistency" &&
|
|
336
|
+
d.message.toLowerCase().includes("duplicate"));
|
|
337
|
+
expect(duplicateDrifts).toHaveLength(0);
|
|
338
|
+
});
|
|
339
|
+
it("DOES flag version duplicates like Button vs ButtonNew", () => {
|
|
340
|
+
const components = [
|
|
341
|
+
createMockComponent("Button", "react"),
|
|
342
|
+
createMockComponent("ButtonNew", "react"),
|
|
343
|
+
];
|
|
344
|
+
const result = engine.analyzeComponents(components, {
|
|
345
|
+
checkNaming: true,
|
|
346
|
+
});
|
|
347
|
+
const duplicateDrifts = result.drifts.filter((d) => d.type === "naming-inconsistency" &&
|
|
348
|
+
d.message.toLowerCase().includes("duplicate"));
|
|
349
|
+
expect(duplicateDrifts.length).toBeGreaterThan(0);
|
|
350
|
+
});
|
|
351
|
+
it("DOES flag legacy duplicates like Card vs CardLegacy", () => {
|
|
352
|
+
const components = [
|
|
353
|
+
createMockComponent("Card", "react"),
|
|
354
|
+
createMockComponent("CardLegacy", "react"),
|
|
355
|
+
];
|
|
356
|
+
const result = engine.analyzeComponents(components, {
|
|
357
|
+
checkNaming: true,
|
|
358
|
+
});
|
|
359
|
+
const duplicateDrifts = result.drifts.filter((d) => d.type === "naming-inconsistency" &&
|
|
360
|
+
d.message.toLowerCase().includes("duplicate"));
|
|
361
|
+
expect(duplicateDrifts.length).toBeGreaterThan(0);
|
|
362
|
+
});
|
|
363
|
+
it("DOES flag versioned duplicates like Input vs InputV2", () => {
|
|
364
|
+
const components = [
|
|
365
|
+
createMockComponent("Input", "react"),
|
|
366
|
+
createMockComponent("InputV2", "react"),
|
|
367
|
+
];
|
|
368
|
+
const result = engine.analyzeComponents(components, {
|
|
369
|
+
checkNaming: true,
|
|
370
|
+
});
|
|
371
|
+
const duplicateDrifts = result.drifts.filter((d) => d.type === "naming-inconsistency" &&
|
|
372
|
+
d.message.toLowerCase().includes("duplicate"));
|
|
373
|
+
expect(duplicateDrifts.length).toBeGreaterThan(0);
|
|
374
|
+
});
|
|
375
|
+
it("does NOT flag unrelated components with similar prefixes", () => {
|
|
376
|
+
const components = [
|
|
377
|
+
createMockComponent("Tab", "react"),
|
|
378
|
+
createMockComponent("Table", "react"),
|
|
379
|
+
createMockComponent("Tabs", "react"),
|
|
380
|
+
];
|
|
381
|
+
const result = engine.analyzeComponents(components, {
|
|
382
|
+
checkNaming: true,
|
|
383
|
+
});
|
|
384
|
+
const duplicateDrifts = result.drifts.filter((d) => d.type === "naming-inconsistency" &&
|
|
385
|
+
d.message.toLowerCase().includes("duplicate"));
|
|
386
|
+
expect(duplicateDrifts).toHaveLength(0);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
describe("accessibility-conflict", () => {
|
|
390
|
+
it("detects accessibility issues on interactive components without aria-label or children", () => {
|
|
391
|
+
const component = {
|
|
392
|
+
...createMockComponent("Button", "react"),
|
|
393
|
+
props: [], // No children or aria-label props
|
|
394
|
+
metadata: {
|
|
395
|
+
accessibility: {
|
|
396
|
+
issues: ["Missing accessible label for interactive element"],
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
const result = engine.analyzeComponents([component], {
|
|
401
|
+
checkAccessibility: true,
|
|
402
|
+
});
|
|
403
|
+
const a11yDrifts = result.drifts.filter((d) => d.type === "accessibility-conflict");
|
|
404
|
+
expect(a11yDrifts).toHaveLength(1);
|
|
405
|
+
expect(a11yDrifts[0].severity).toBe("critical");
|
|
406
|
+
expect(a11yDrifts[0].message).toContain("accessibility issues");
|
|
407
|
+
});
|
|
408
|
+
it("does not flag interactive components that have aria-label prop", () => {
|
|
409
|
+
const component = {
|
|
410
|
+
...createMockComponent("Button", "react"),
|
|
411
|
+
props: [
|
|
412
|
+
{
|
|
413
|
+
name: "aria-label",
|
|
414
|
+
type: "string",
|
|
415
|
+
required: false,
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
metadata: {
|
|
419
|
+
accessibility: {
|
|
420
|
+
issues: ["Missing accessible label for interactive element"],
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
const result = engine.analyzeComponents([component], {
|
|
425
|
+
checkAccessibility: true,
|
|
426
|
+
});
|
|
427
|
+
const a11yDrifts = result.drifts.filter((d) => d.type === "accessibility-conflict");
|
|
428
|
+
expect(a11yDrifts).toHaveLength(0);
|
|
429
|
+
});
|
|
430
|
+
it("does not flag interactive components that have children prop", () => {
|
|
431
|
+
const component = {
|
|
432
|
+
...createMockComponent("Button", "react"),
|
|
433
|
+
props: [
|
|
434
|
+
{
|
|
435
|
+
name: "children",
|
|
436
|
+
type: "ReactNode",
|
|
437
|
+
required: false,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
metadata: {
|
|
441
|
+
accessibility: {
|
|
442
|
+
issues: ["Missing accessible label for interactive element"],
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
const result = engine.analyzeComponents([component], {
|
|
447
|
+
checkAccessibility: true,
|
|
448
|
+
});
|
|
449
|
+
const a11yDrifts = result.drifts.filter((d) => d.type === "accessibility-conflict");
|
|
450
|
+
expect(a11yDrifts).toHaveLength(0);
|
|
451
|
+
});
|
|
452
|
+
it("skips accessibility checks when checkAccessibility is false", () => {
|
|
453
|
+
const component = {
|
|
454
|
+
...createMockComponent("Button", "react"),
|
|
455
|
+
props: [],
|
|
456
|
+
metadata: {
|
|
457
|
+
accessibility: {
|
|
458
|
+
issues: ["Missing accessible label for interactive element"],
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
const result = engine.analyzeComponents([component], {
|
|
463
|
+
checkAccessibility: false,
|
|
464
|
+
});
|
|
465
|
+
const a11yDrifts = result.drifts.filter((d) => d.type === "accessibility-conflict");
|
|
466
|
+
expect(a11yDrifts).toHaveLength(0);
|
|
467
|
+
});
|
|
468
|
+
it("skips accessibility checks when checkAccessibility is undefined", () => {
|
|
469
|
+
const component = {
|
|
470
|
+
...createMockComponent("Button", "react"),
|
|
471
|
+
props: [],
|
|
472
|
+
metadata: {
|
|
473
|
+
accessibility: {
|
|
474
|
+
issues: ["Missing accessible label for interactive element"],
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
const result = engine.analyzeComponents([component], {});
|
|
479
|
+
const a11yDrifts = result.drifts.filter((d) => d.type === "accessibility-conflict");
|
|
480
|
+
expect(a11yDrifts).toHaveLength(0);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
describe("checkColorContrast", () => {
|
|
484
|
+
it("detects insufficient color contrast", () => {
|
|
485
|
+
const component = createMockComponentWithMetadata("Button", {
|
|
486
|
+
hardcodedValues: [
|
|
487
|
+
{
|
|
488
|
+
type: "color",
|
|
489
|
+
value: "#777",
|
|
490
|
+
property: "color",
|
|
491
|
+
location: "Button.tsx:10",
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
type: "color",
|
|
495
|
+
value: "#999",
|
|
496
|
+
property: "background-color",
|
|
497
|
+
location: "Button.tsx:11",
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
});
|
|
501
|
+
const result = engine.analyzeComponents([component], {
|
|
502
|
+
checkAccessibility: true,
|
|
503
|
+
});
|
|
504
|
+
const contrastDrifts = result.drifts.filter((d) => d.type === "color-contrast");
|
|
505
|
+
expect(contrastDrifts.length).toBeGreaterThan(0);
|
|
506
|
+
expect(contrastDrifts[0].severity).toBe("critical");
|
|
507
|
+
expect(contrastDrifts[0].message).toContain("insufficient color contrast");
|
|
508
|
+
});
|
|
509
|
+
it("does not flag sufficient color contrast", () => {
|
|
510
|
+
const component = createMockComponentWithMetadata("Button", {
|
|
511
|
+
hardcodedValues: [
|
|
512
|
+
{
|
|
513
|
+
type: "color",
|
|
514
|
+
value: "#000",
|
|
515
|
+
property: "color",
|
|
516
|
+
location: "Button.tsx:10",
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
type: "color",
|
|
520
|
+
value: "#fff",
|
|
521
|
+
property: "background-color",
|
|
522
|
+
location: "Button.tsx:11",
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
});
|
|
526
|
+
const result = engine.analyzeComponents([component], {
|
|
527
|
+
checkAccessibility: true,
|
|
528
|
+
});
|
|
529
|
+
const contrastDrifts = result.drifts.filter((d) => d.type === "color-contrast");
|
|
530
|
+
expect(contrastDrifts).toHaveLength(0);
|
|
531
|
+
});
|
|
532
|
+
it("skips contrast check when accessibility checking is disabled", () => {
|
|
533
|
+
const component = createMockComponentWithMetadata("Button", {
|
|
534
|
+
hardcodedValues: [
|
|
535
|
+
{
|
|
536
|
+
type: "color",
|
|
537
|
+
value: "#777",
|
|
538
|
+
property: "color",
|
|
539
|
+
location: "Button.tsx:10",
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
type: "color",
|
|
543
|
+
value: "#999",
|
|
544
|
+
property: "background-color",
|
|
545
|
+
location: "Button.tsx:11",
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
});
|
|
549
|
+
const result = engine.analyzeComponents([component], {
|
|
550
|
+
checkAccessibility: false,
|
|
551
|
+
});
|
|
552
|
+
const contrastDrifts = result.drifts.filter((d) => d.type === "color-contrast");
|
|
553
|
+
expect(contrastDrifts).toHaveLength(0);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
describe("missing-documentation", () => {
|
|
557
|
+
it("flags components without documentation when there is partial adoption", () => {
|
|
558
|
+
const undocumented = createMockComponent("Button", "react");
|
|
559
|
+
undocumented.metadata.documentation = undefined;
|
|
560
|
+
const documented = createMockComponent("Card", "react");
|
|
561
|
+
documented.metadata.documentation = "A card container component";
|
|
562
|
+
const result = engine.analyzeComponents([undocumented, documented], {
|
|
563
|
+
checkDocumentation: true,
|
|
564
|
+
});
|
|
565
|
+
const docDrifts = result.drifts.filter((d) => d.type === "missing-documentation");
|
|
566
|
+
expect(docDrifts.length).toBeGreaterThan(0);
|
|
567
|
+
expect(docDrifts[0].severity).toBe("info");
|
|
568
|
+
expect(docDrifts[0].message).toContain("Button");
|
|
569
|
+
});
|
|
570
|
+
it("does not flag when no components have documentation (no adoption)", () => {
|
|
571
|
+
const comp1 = createMockComponent("Button", "react");
|
|
572
|
+
comp1.metadata.documentation = undefined;
|
|
573
|
+
const comp2 = createMockComponent("Card", "react");
|
|
574
|
+
comp2.metadata.documentation = undefined;
|
|
575
|
+
const result = engine.analyzeComponents([comp1, comp2], {
|
|
576
|
+
checkDocumentation: true,
|
|
577
|
+
});
|
|
578
|
+
const docDrifts = result.drifts.filter((d) => d.type === "missing-documentation");
|
|
579
|
+
expect(docDrifts).toHaveLength(0);
|
|
580
|
+
});
|
|
581
|
+
it("does not flag components with documentation", () => {
|
|
582
|
+
const comp = createMockComponent("Button", "react");
|
|
583
|
+
comp.metadata.documentation = "A primary action button component";
|
|
584
|
+
const result = engine.analyzeComponents([comp], {
|
|
585
|
+
checkDocumentation: true,
|
|
586
|
+
});
|
|
587
|
+
const docDrifts = result.drifts.filter((d) => d.type === "missing-documentation");
|
|
588
|
+
expect(docDrifts).toHaveLength(0);
|
|
589
|
+
});
|
|
590
|
+
it("skips documentation check when flag is false", () => {
|
|
591
|
+
const comp = createMockComponent("Button", "react");
|
|
592
|
+
comp.metadata.documentation = undefined;
|
|
593
|
+
const result = engine.analyzeComponents([comp], {
|
|
594
|
+
checkDocumentation: false,
|
|
595
|
+
});
|
|
596
|
+
const docDrifts = result.drifts.filter((d) => d.type === "missing-documentation");
|
|
597
|
+
expect(docDrifts).toHaveLength(0);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
describe("checkUnusedComponents", () => {
|
|
601
|
+
it("detects unused components", () => {
|
|
602
|
+
const components = [
|
|
603
|
+
createMockComponent("Button", "react"),
|
|
604
|
+
createMockComponent("Card", "react"),
|
|
605
|
+
];
|
|
606
|
+
const usageMap = new Map([
|
|
607
|
+
["react:Button", 5], // Button is used 5 times
|
|
608
|
+
["react:Card", 0], // Card is never used
|
|
609
|
+
]);
|
|
610
|
+
const drifts = engine.checkUnusedComponents(components, usageMap);
|
|
611
|
+
expect(drifts).toHaveLength(1);
|
|
612
|
+
expect(drifts[0].type).toBe("unused-component");
|
|
613
|
+
expect(drifts[0].severity).toBe("warning");
|
|
614
|
+
expect(drifts[0].message).toContain("Card");
|
|
615
|
+
expect(drifts[0].message).toContain("never used");
|
|
616
|
+
});
|
|
617
|
+
it("does not flag used components", () => {
|
|
618
|
+
const components = [
|
|
619
|
+
createMockComponent("Button", "react"),
|
|
620
|
+
createMockComponent("Card", "react"),
|
|
621
|
+
];
|
|
622
|
+
const usageMap = new Map([
|
|
623
|
+
["react:Button", 5],
|
|
624
|
+
["react:Card", 3],
|
|
625
|
+
]);
|
|
626
|
+
const drifts = engine.checkUnusedComponents(components, usageMap);
|
|
627
|
+
expect(drifts).toHaveLength(0);
|
|
628
|
+
});
|
|
629
|
+
it("checks usage by component name if id not found", () => {
|
|
630
|
+
const components = [createMockComponent("Button", "react")];
|
|
631
|
+
const usageMap = new Map([
|
|
632
|
+
["Button", 5], // Usage tracked by name instead of ID
|
|
633
|
+
]);
|
|
634
|
+
const drifts = engine.checkUnusedComponents(components, usageMap);
|
|
635
|
+
expect(drifts).toHaveLength(0);
|
|
636
|
+
});
|
|
637
|
+
it("flags components not in the usage map (no references found)", () => {
|
|
638
|
+
const comp = createMockComponent("NewComp", "react");
|
|
639
|
+
const usageMap = new Map();
|
|
640
|
+
const drifts = engine.checkUnusedComponents([comp], usageMap);
|
|
641
|
+
expect(drifts).toHaveLength(1);
|
|
642
|
+
expect(drifts[0].type).toBe("unused-component");
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
describe("checkUnusedTokens", () => {
|
|
646
|
+
it("detects unused tokens", () => {
|
|
647
|
+
const tokens = [
|
|
648
|
+
createMockToken("primary", "#3b82f6", "css"),
|
|
649
|
+
createMockToken("secondary", "#6b7280", "css"),
|
|
650
|
+
];
|
|
651
|
+
const usageMap = new Map([
|
|
652
|
+
["css:primary", 10], // primary is used
|
|
653
|
+
["css:secondary", 0], // secondary is never used
|
|
654
|
+
]);
|
|
655
|
+
const drifts = engine.checkUnusedTokens(tokens, usageMap);
|
|
656
|
+
expect(drifts).toHaveLength(1);
|
|
657
|
+
expect(drifts[0].type).toBe("unused-token");
|
|
658
|
+
expect(drifts[0].severity).toBe("info");
|
|
659
|
+
expect(drifts[0].message).toContain("secondary");
|
|
660
|
+
expect(drifts[0].message).toContain("never used");
|
|
661
|
+
});
|
|
662
|
+
it("does not flag used tokens", () => {
|
|
663
|
+
const tokens = [
|
|
664
|
+
createMockToken("primary", "#3b82f6", "css"),
|
|
665
|
+
createMockToken("secondary", "#6b7280", "css"),
|
|
666
|
+
];
|
|
667
|
+
const usageMap = new Map([
|
|
668
|
+
["css:primary", 10],
|
|
669
|
+
["css:secondary", 5],
|
|
670
|
+
]);
|
|
671
|
+
const drifts = engine.checkUnusedTokens(tokens, usageMap);
|
|
672
|
+
expect(drifts).toHaveLength(0);
|
|
673
|
+
});
|
|
674
|
+
it("checks usage by token name if id not found", () => {
|
|
675
|
+
const tokens = [createMockToken("primary", "#3b82f6", "css")];
|
|
676
|
+
const usageMap = new Map([
|
|
677
|
+
["primary", 10], // Usage tracked by name instead of ID
|
|
678
|
+
]);
|
|
679
|
+
const drifts = engine.checkUnusedTokens(tokens, usageMap);
|
|
680
|
+
expect(drifts).toHaveLength(0);
|
|
681
|
+
});
|
|
682
|
+
it("flags tokens not in the usage map (no references found)", () => {
|
|
683
|
+
const token = createMockToken("color-primary", "#ff0000", "css");
|
|
684
|
+
const usageMap = new Map();
|
|
685
|
+
const drifts = engine.checkUnusedTokens([token], usageMap);
|
|
686
|
+
expect(drifts).toHaveLength(1);
|
|
687
|
+
expect(drifts[0].type).toBe("unused-token");
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
describe("checkUnusedTokens prefix matching", () => {
|
|
691
|
+
it("matches token usage when map key lacks -- prefix", () => {
|
|
692
|
+
const tokens = [
|
|
693
|
+
createMockToken("--primary-color", "#0066cc", "css"),
|
|
694
|
+
];
|
|
695
|
+
// Usage map has key WITHOUT -- prefix (as collectUsages captures)
|
|
696
|
+
const usageMap = new Map([["primary-color", 3]]);
|
|
697
|
+
const drifts = engine.checkUnusedTokens(tokens, usageMap);
|
|
698
|
+
expect(drifts).toHaveLength(0); // Should NOT flag as unused
|
|
699
|
+
});
|
|
700
|
+
it("matches token usage when map key has -- prefix", () => {
|
|
701
|
+
const tokens = [
|
|
702
|
+
createMockToken("--primary-color", "#0066cc", "css"),
|
|
703
|
+
];
|
|
704
|
+
const usageMap = new Map([["--primary-color", 3]]);
|
|
705
|
+
const drifts = engine.checkUnusedTokens(tokens, usageMap);
|
|
706
|
+
expect(drifts).toHaveLength(0);
|
|
707
|
+
});
|
|
708
|
+
it("matches SCSS token when map key lacks $ prefix", () => {
|
|
709
|
+
const tokens = [
|
|
710
|
+
createMockToken("$primary-color", "#0066cc", "css"),
|
|
711
|
+
];
|
|
712
|
+
const usageMap = new Map([["primary-color", 3]]);
|
|
713
|
+
const drifts = engine.checkUnusedTokens(tokens, usageMap);
|
|
714
|
+
expect(drifts).toHaveLength(0);
|
|
715
|
+
});
|
|
716
|
+
it("flags truly unused token", () => {
|
|
717
|
+
const tokens = [
|
|
718
|
+
createMockToken("--unused-color", "#999999", "css"),
|
|
719
|
+
];
|
|
720
|
+
const usageMap = new Map([["primary-color", 3]]);
|
|
721
|
+
const drifts = engine.checkUnusedTokens(tokens, usageMap);
|
|
722
|
+
expect(drifts).toHaveLength(1);
|
|
723
|
+
expect(drifts[0].type).toBe("unused-token");
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
// Helper functions
|
|
728
|
+
function createMockComponent(name, type) {
|
|
729
|
+
const source = type === "react"
|
|
730
|
+
? { type: "react", path: `src/${name}.tsx`, exportName: name }
|
|
731
|
+
: { type: "figma", fileKey: "abc", nodeId: "1:1" };
|
|
732
|
+
return {
|
|
733
|
+
id: `${type}:${name}`,
|
|
734
|
+
name,
|
|
735
|
+
source,
|
|
736
|
+
props: [],
|
|
737
|
+
variants: [],
|
|
738
|
+
tokens: [],
|
|
739
|
+
dependencies: [],
|
|
740
|
+
metadata: {},
|
|
741
|
+
scannedAt: new Date(),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function createMockComponentWithMetadata(name, metadata) {
|
|
745
|
+
return {
|
|
746
|
+
...createMockComponent(name, "react"),
|
|
747
|
+
metadata,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
function createMockToken(name, hexValue, type) {
|
|
751
|
+
const source = type === "css"
|
|
752
|
+
? { type: "css", path: "tokens.css" }
|
|
753
|
+
: { type: "figma", fileKey: "abc" };
|
|
754
|
+
return {
|
|
755
|
+
id: `${type}:${name}`,
|
|
756
|
+
name,
|
|
757
|
+
value: { type: "color", hex: hexValue },
|
|
758
|
+
category: "color",
|
|
759
|
+
source,
|
|
760
|
+
aliases: [],
|
|
761
|
+
usedBy: [],
|
|
762
|
+
metadata: {},
|
|
763
|
+
scannedAt: new Date(),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
//# sourceMappingURL=semantic-diff.test.sync-conflict-20260313-111438-6PCZ3ZU.js.map
|