@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.
@@ -0,0 +1,896 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateAuditReport, calculateHealthScore, calculateHealthScorePillar, getHealthTier, findCloseMatches, } from './audit.js';
3
+ // Helper to create extracted values
4
+ function createValue(category, value, file, line = 1) {
5
+ return { category, value, file, line };
6
+ }
7
+ describe('generateAuditReport', () => {
8
+ it('counts unique values by category', () => {
9
+ const values = [
10
+ createValue('color', '#3b82f6', 'src/Button.tsx'),
11
+ createValue('color', '#3b82f6', 'src/Card.tsx'), // duplicate
12
+ createValue('color', '#ef4444', 'src/Alert.tsx'),
13
+ createValue('spacing', '16px', 'src/Button.tsx'),
14
+ createValue('spacing', '8px', 'src/Card.tsx'),
15
+ ];
16
+ const report = generateAuditReport(values);
17
+ expect(report.categories.color.uniqueCount).toBe(2);
18
+ expect(report.categories.color.totalUsages).toBe(3);
19
+ expect(report.categories.spacing.uniqueCount).toBe(2);
20
+ expect(report.categories.spacing.totalUsages).toBe(2);
21
+ });
22
+ it('identifies most common values per category', () => {
23
+ const values = [
24
+ createValue('color', '#3b82f6', 'src/A.tsx'),
25
+ createValue('color', '#3b82f6', 'src/B.tsx'),
26
+ createValue('color', '#3b82f6', 'src/C.tsx'),
27
+ createValue('color', '#ef4444', 'src/D.tsx'),
28
+ ];
29
+ const report = generateAuditReport(values);
30
+ expect(report.categories.color.mostCommon[0]).toEqual({
31
+ value: '#3b82f6',
32
+ count: 3,
33
+ });
34
+ });
35
+ it('identifies worst offender files', () => {
36
+ const values = [
37
+ createValue('color', '#111', 'src/Bad.tsx', 1),
38
+ createValue('color', '#222', 'src/Bad.tsx', 2),
39
+ createValue('color', '#333', 'src/Bad.tsx', 3),
40
+ createValue('color', '#444', 'src/Good.tsx', 1),
41
+ ];
42
+ const report = generateAuditReport(values);
43
+ expect(report.worstFiles[0]).toEqual({
44
+ file: 'src/Bad.tsx',
45
+ issueCount: 3,
46
+ });
47
+ });
48
+ it('provides totals across all categories', () => {
49
+ const values = [
50
+ createValue('color', '#3b82f6', 'src/A.tsx'),
51
+ createValue('color', '#ef4444', 'src/B.tsx'),
52
+ createValue('spacing', '16px', 'src/C.tsx'),
53
+ ];
54
+ const report = generateAuditReport(values);
55
+ expect(report.totals.uniqueValues).toBe(3);
56
+ expect(report.totals.totalUsages).toBe(3);
57
+ expect(report.totals.filesAffected).toBe(3);
58
+ });
59
+ it('handles empty input', () => {
60
+ const report = generateAuditReport([]);
61
+ expect(report.totals.uniqueValues).toBe(0);
62
+ expect(report.totals.totalUsages).toBe(0);
63
+ expect(report.score).toBe(100); // Perfect score with no issues
64
+ });
65
+ });
66
+ describe('findCloseMatches', () => {
67
+ it('finds colors that are close to design tokens', () => {
68
+ const designTokens = ['#3b82f6', '#ef4444'];
69
+ const foundValues = ['#3b83f6', '#3b82f6']; // First is typo
70
+ const matches = findCloseMatches(foundValues, designTokens, 'color');
71
+ expect(matches).toContainEqual({
72
+ value: '#3b83f6',
73
+ closeTo: '#3b82f6',
74
+ distance: expect.any(Number),
75
+ });
76
+ });
77
+ it('does not flag exact matches', () => {
78
+ const designTokens = ['#3b82f6'];
79
+ const foundValues = ['#3b82f6'];
80
+ const matches = findCloseMatches(foundValues, designTokens, 'color');
81
+ expect(matches).toHaveLength(0);
82
+ });
83
+ it('finds spacing values close to a scale', () => {
84
+ const designTokens = ['4px', '8px', '16px', '24px', '32px'];
85
+ const foundValues = ['15px', '17px', '8px'];
86
+ const matches = findCloseMatches(foundValues, designTokens, 'spacing');
87
+ expect(matches).toContainEqual({
88
+ value: '15px',
89
+ closeTo: '16px',
90
+ distance: 1,
91
+ });
92
+ expect(matches).toContainEqual({
93
+ value: '17px',
94
+ closeTo: '16px',
95
+ distance: 1,
96
+ });
97
+ });
98
+ it('returns empty array when no close matches', () => {
99
+ const designTokens = ['#3b82f6'];
100
+ const foundValues = ['#000000']; // Very different
101
+ const matches = findCloseMatches(foundValues, designTokens, 'color');
102
+ expect(matches).toHaveLength(0);
103
+ });
104
+ });
105
+ describe('calculateHealthScore (legacy)', () => {
106
+ it('returns high score for no drift', () => {
107
+ const report = {
108
+ categories: {},
109
+ worstFiles: [],
110
+ totals: { uniqueValues: 0, totalUsages: 0, filesAffected: 0 },
111
+ closeMatches: [],
112
+ score: 0,
113
+ };
114
+ const score = calculateHealthScore(report);
115
+ // With componentCount=1 (from filesAffected=0||1), consistency and criticalIssues
116
+ // are scaled down by componentScale (1/3), so max is ~69 instead of ~83
117
+ expect(score).toBeGreaterThanOrEqual(60);
118
+ });
119
+ it('decreases score based on value density', () => {
120
+ const report = {
121
+ categories: {
122
+ color: {
123
+ uniqueCount: 50,
124
+ totalUsages: 100,
125
+ mostCommon: [],
126
+ },
127
+ },
128
+ worstFiles: [],
129
+ totals: { uniqueValues: 50, totalUsages: 100, filesAffected: 20 },
130
+ closeMatches: [],
131
+ score: 0,
132
+ };
133
+ const score = calculateHealthScore(report);
134
+ expect(score).toBeLessThan(50);
135
+ });
136
+ it('returns score between 0 and 100', () => {
137
+ const report = {
138
+ categories: {
139
+ color: { uniqueCount: 100, totalUsages: 500, mostCommon: [] },
140
+ spacing: { uniqueCount: 50, totalUsages: 200, mostCommon: [] },
141
+ },
142
+ worstFiles: [{ file: 'bad.tsx', issueCount: 100 }],
143
+ totals: { uniqueValues: 150, totalUsages: 700, filesAffected: 50 },
144
+ closeMatches: Array(20).fill({ value: 'x', closeTo: 'y', distance: 1 }),
145
+ score: 0,
146
+ };
147
+ const score = calculateHealthScore(report);
148
+ expect(score).toBeGreaterThanOrEqual(0);
149
+ expect(score).toBeLessThanOrEqual(100);
150
+ });
151
+ });
152
+ describe('calculateHealthScorePillar', () => {
153
+ function makeMetrics(overrides = {}) {
154
+ return {
155
+ componentCount: 50,
156
+ tokenCount: 0,
157
+ hardcodedValueCount: 0,
158
+ unusedTokenCount: 0,
159
+ namingInconsistencyCount: 0,
160
+ criticalCount: 0,
161
+ hasUtilityFramework: false,
162
+ hasDesignSystemLibrary: false,
163
+ ...overrides,
164
+ };
165
+ }
166
+ describe('Pillar 1: Value Discipline (0-60)', () => {
167
+ it('scores 60 when no hardcoded values', () => {
168
+ const result = calculateHealthScorePillar(makeMetrics());
169
+ expect(result.pillars.valueDiscipline.score).toBe(60);
170
+ });
171
+ it('scores 0 when density >= 2 (2+ hardcoded values per component)', () => {
172
+ const result = calculateHealthScorePillar(makeMetrics({
173
+ componentCount: 10,
174
+ hardcodedValueCount: 20, // density = 2
175
+ }));
176
+ expect(result.pillars.valueDiscipline.score).toBe(0);
177
+ });
178
+ it('scores proportionally to density', () => {
179
+ // density = 1.5 → 1 - 1.5/2 = 0.25 → round(60 * 0.25) = 15
180
+ const result = calculateHealthScorePillar(makeMetrics({
181
+ componentCount: 10,
182
+ hardcodedValueCount: 15,
183
+ }));
184
+ expect(result.pillars.valueDiscipline.score).toBe(15);
185
+ });
186
+ it('scores 60 when density is near-zero (<0.1 per component)', () => {
187
+ // 193 components, 12 hardcoded = 0.06 density — effectively perfect
188
+ const result = calculateHealthScorePillar(makeMetrics({
189
+ componentCount: 193,
190
+ hardcodedValueCount: 12,
191
+ }));
192
+ expect(result.pillars.valueDiscipline.score).toBe(60);
193
+ });
194
+ it('allows score 100 for excellent repos with tokens', () => {
195
+ const result = calculateHealthScorePillar(makeMetrics({
196
+ componentCount: 193,
197
+ hardcodedValueCount: 12, // density 0.06 → treated as 0
198
+ tokenCount: 50,
199
+ unusedTokenCount: 0,
200
+ hasUtilityFramework: true,
201
+ hasDesignSystemLibrary: true,
202
+ }));
203
+ // vd=60, th=20, co=10, ci=10 = 100
204
+ expect(result.score).toBe(100);
205
+ });
206
+ });
207
+ describe('Pillar 2: Token Health (0-20)', () => {
208
+ it('scores based on used/total ratio when tokens exist', () => {
209
+ const result = calculateHealthScorePillar(makeMetrics({
210
+ tokenCount: 100,
211
+ unusedTokenCount: 20,
212
+ }));
213
+ // utility=0, library=0, coverage=5 (100/20 capped at 1), usage=round(5*80/100)=4 → 9
214
+ expect(result.pillars.tokenHealth.score).toBe(9);
215
+ });
216
+ it('scores 20 when all tokens are used with full ecosystem', () => {
217
+ const result = calculateHealthScorePillar(makeMetrics({
218
+ tokenCount: 50,
219
+ unusedTokenCount: 0,
220
+ hasUtilityFramework: true,
221
+ hasDesignSystemLibrary: true,
222
+ }));
223
+ // utility=5, library=5, coverage=5 (50/20 capped at 1), usage=5 → 20
224
+ expect(result.pillars.tokenHealth.score).toBe(20);
225
+ });
226
+ it('scores 8 for clean Tailwind projects without tokens', () => {
227
+ const result = calculateHealthScorePillar(makeMetrics({
228
+ hasUtilityFramework: true,
229
+ hardcodedValueCount: 5,
230
+ componentCount: 50, // density = 0.1 < 0.5
231
+ }));
232
+ // utility=3 (no tokens), library=0, coverage=0, usage=5 (density < 0.5) → 8
233
+ expect(result.pillars.tokenHealth.score).toBe(8);
234
+ });
235
+ it('scores 6 for leaky Tailwind projects', () => {
236
+ const result = calculateHealthScorePillar(makeMetrics({
237
+ hasUtilityFramework: true,
238
+ hardcodedValueCount: 40,
239
+ componentCount: 50, // density = 0.8 (between 0.5 and 1.0)
240
+ }));
241
+ // utility=3 (no tokens), library=0, coverage=0, usage=3 (density 0.5-1.0) → 6
242
+ expect(result.pillars.tokenHealth.score).toBe(6);
243
+ });
244
+ it('scores 3 for implied system (very low density, no tokens)', () => {
245
+ const result = calculateHealthScorePillar(makeMetrics({
246
+ componentCount: 100,
247
+ hardcodedValueCount: 5, // density = 0.05 < 0.1
248
+ }));
249
+ // utility=0, library=0, coverage=0, usage=3 (density < 0.1) → 3
250
+ expect(result.pillars.tokenHealth.score).toBe(3);
251
+ });
252
+ it('scores 0 when no system detected and values are hardcoded', () => {
253
+ const result = calculateHealthScorePillar(makeMetrics({
254
+ componentCount: 10,
255
+ hardcodedValueCount: 20,
256
+ }));
257
+ expect(result.pillars.tokenHealth.score).toBe(0);
258
+ });
259
+ it('achieves score 100 when all four sub-factors are present', () => {
260
+ const result = calculateHealthScorePillar(makeMetrics({
261
+ componentCount: 100,
262
+ tokenCount: 50,
263
+ unusedTokenCount: 0,
264
+ hardcodedValueCount: 0,
265
+ hasUtilityFramework: true,
266
+ hasDesignSystemLibrary: true,
267
+ }));
268
+ expect(result.score).toBe(100);
269
+ expect(result.pillars.tokenHealth.score).toBe(20);
270
+ });
271
+ it('gives partial credit for utility framework without tokens', () => {
272
+ const result = calculateHealthScorePillar(makeMetrics({
273
+ componentCount: 50,
274
+ hasUtilityFramework: true,
275
+ hasDesignSystemLibrary: false,
276
+ }));
277
+ expect(result.pillars.tokenHealth.score).toBeGreaterThan(0);
278
+ expect(result.pillars.tokenHealth.score).toBeLessThan(20);
279
+ });
280
+ it('gives more credit for library + framework than framework alone', () => {
281
+ const fwOnly = calculateHealthScorePillar(makeMetrics({
282
+ componentCount: 50,
283
+ hasUtilityFramework: true,
284
+ hasDesignSystemLibrary: false,
285
+ }));
286
+ const both = calculateHealthScorePillar(makeMetrics({
287
+ componentCount: 50,
288
+ hasUtilityFramework: true,
289
+ hasDesignSystemLibrary: true,
290
+ }));
291
+ expect(both.pillars.tokenHealth.score).toBeGreaterThan(fwOnly.pillars.tokenHealth.score);
292
+ });
293
+ it('penalizes unused tokens proportionally', () => {
294
+ const halfUnused = calculateHealthScorePillar(makeMetrics({
295
+ tokenCount: 100,
296
+ unusedTokenCount: 50,
297
+ hasUtilityFramework: true,
298
+ hasDesignSystemLibrary: true,
299
+ }));
300
+ const allUsed = calculateHealthScorePillar(makeMetrics({
301
+ tokenCount: 100,
302
+ unusedTokenCount: 0,
303
+ hasUtilityFramework: true,
304
+ hasDesignSystemLibrary: true,
305
+ }));
306
+ expect(allUsed.pillars.tokenHealth.score).toBeGreaterThan(halfUnused.pillars.tokenHealth.score);
307
+ });
308
+ it('gives token coverage credit proportionally', () => {
309
+ const fewTokens = calculateHealthScorePillar(makeMetrics({
310
+ tokenCount: 5,
311
+ unusedTokenCount: 0,
312
+ }));
313
+ const manyTokens = calculateHealthScorePillar(makeMetrics({
314
+ tokenCount: 50,
315
+ unusedTokenCount: 0,
316
+ }));
317
+ // Both have usage=5, but coverage differs: 5*5/20=1.25 vs 5*50/20=5
318
+ expect(manyTokens.pillars.tokenHealth.score).toBeGreaterThan(fewTokens.pillars.tokenHealth.score);
319
+ });
320
+ it('produces granular scores between utility-only and utility+tokens', () => {
321
+ // With framework only (no tokens): utility=3, coverage=0, usage=5 → 8
322
+ const fwOnly = calculateHealthScorePillar(makeMetrics({
323
+ hasUtilityFramework: true,
324
+ componentCount: 50,
325
+ hardcodedValueCount: 5,
326
+ }));
327
+ // With framework + some tokens: utility=5, coverage=5*3/20=0.75, usage=5*3/3=5 → round(10.75)=11
328
+ const fwWithTokens = calculateHealthScorePillar(makeMetrics({
329
+ hasUtilityFramework: true,
330
+ tokenCount: 3,
331
+ unusedTokenCount: 0,
332
+ componentCount: 50,
333
+ hardcodedValueCount: 5,
334
+ }));
335
+ // Framework+tokens should score higher than framework-only
336
+ expect(fwWithTokens.pillars.tokenHealth.score).toBeGreaterThan(fwOnly.pillars.tokenHealth.score);
337
+ // And the scores should differ by more than 0 (not all snapping to same bucket)
338
+ expect(fwWithTokens.pillars.tokenHealth.score - fwOnly.pillars.tokenHealth.score).toBeGreaterThanOrEqual(1);
339
+ });
340
+ });
341
+ describe('Pillar 3: Consistency (0-10)', () => {
342
+ it('scores 10 when no naming issues', () => {
343
+ const result = calculateHealthScorePillar(makeMetrics());
344
+ expect(result.pillars.consistency.score).toBe(10);
345
+ });
346
+ it('scores 0 when naming rate >= 25%', () => {
347
+ const result = calculateHealthScorePillar(makeMetrics({
348
+ componentCount: 100,
349
+ namingInconsistencyCount: 25, // 25%
350
+ }));
351
+ expect(result.pillars.consistency.score).toBe(0);
352
+ });
353
+ it('scores proportionally', () => {
354
+ // namingRate = 5/100 = 0.05 → 1 - 0.05/0.25 = 0.8 → round(10 * 0.8) = 8
355
+ const result = calculateHealthScorePillar(makeMetrics({
356
+ componentCount: 100,
357
+ namingInconsistencyCount: 5,
358
+ }));
359
+ expect(result.pillars.consistency.score).toBe(8);
360
+ });
361
+ });
362
+ describe('Pillar 4: Critical Issues (0-10)', () => {
363
+ it('scores 10 when no critical issues', () => {
364
+ const result = calculateHealthScorePillar(makeMetrics());
365
+ expect(result.pillars.criticalIssues.score).toBe(10);
366
+ });
367
+ it('deducts 2 per critical issue', () => {
368
+ const result = calculateHealthScorePillar(makeMetrics({ criticalCount: 1 }));
369
+ expect(result.pillars.criticalIssues.score).toBe(8);
370
+ });
371
+ it('scores 0 when 5+ criticals', () => {
372
+ const result = calculateHealthScorePillar(makeMetrics({ criticalCount: 5 }));
373
+ expect(result.pillars.criticalIssues.score).toBe(0);
374
+ });
375
+ });
376
+ describe('Total score and tiers', () => {
377
+ it('perfect score: clean project with full token ecosystem', () => {
378
+ const result = calculateHealthScorePillar(makeMetrics({
379
+ tokenCount: 50,
380
+ unusedTokenCount: 0,
381
+ hasUtilityFramework: true,
382
+ hasDesignSystemLibrary: true,
383
+ }));
384
+ expect(result.score).toBe(100);
385
+ expect(result.tier).toBe('Great');
386
+ });
387
+ it('clean Tailwind project scores Great', () => {
388
+ const result = calculateHealthScorePillar(makeMetrics({
389
+ hasUtilityFramework: true,
390
+ componentCount: 50,
391
+ hardcodedValueCount: 2, // density 0.04
392
+ }));
393
+ // P1: ~59, P2: utility(5)+usage(5)=10, P3: 10, P4: 10 = ~89
394
+ expect(result.tier).toBe('Great');
395
+ });
396
+ it('high-drift project scores low', () => {
397
+ const result = calculateHealthScorePillar(makeMetrics({
398
+ componentCount: 10,
399
+ hardcodedValueCount: 100,
400
+ namingInconsistencyCount: 5,
401
+ criticalCount: 2,
402
+ }));
403
+ expect(result.score).toBeLessThan(20);
404
+ expect(result.tier).toBe('Terrible');
405
+ });
406
+ });
407
+ describe('total drift density penalty', () => {
408
+ it('penalizes high drift density even without hardcoded values', () => {
409
+ const result = calculateHealthScorePillar(makeMetrics({
410
+ componentCount: 100,
411
+ hardcodedValueCount: 0,
412
+ tokenCount: 50,
413
+ unusedTokenCount: 0,
414
+ hasUtilityFramework: true,
415
+ hasDesignSystemLibrary: true,
416
+ totalDriftCount: 500,
417
+ }));
418
+ // totalDriftDensity = 500/100 = 5, * 0.5 = 2.5
419
+ // density = max(0, 2.5) = 2.5
420
+ // valueDiscipline = round(60 * clamp(1 - 2.5/2, 0, 1)) = round(60 * 0) = 0
421
+ expect(result.score).toBeLessThan(90);
422
+ expect(result.pillars.valueDiscipline.score).toBeLessThan(60);
423
+ });
424
+ it('does not penalize when totalDriftCount is not provided', () => {
425
+ const result = calculateHealthScorePillar(makeMetrics({
426
+ componentCount: 100,
427
+ hardcodedValueCount: 0,
428
+ tokenCount: 50,
429
+ unusedTokenCount: 0,
430
+ hasUtilityFramework: true,
431
+ hasDesignSystemLibrary: true,
432
+ }));
433
+ expect(result.pillars.valueDiscipline.score).toBe(60);
434
+ });
435
+ it('uses hardcoded density when worse than half drift density', () => {
436
+ const result = calculateHealthScorePillar(makeMetrics({
437
+ componentCount: 100,
438
+ hardcodedValueCount: 200, // density = 2
439
+ totalDriftCount: 200, // totalDrift density * 0.5 = 1
440
+ }));
441
+ // hardcoded density (2) > totalDrift density * 0.5 (1)
442
+ // so uses hardcoded density = 2
443
+ // valueDiscipline = round(60 * clamp(1 - 2/2, 0, 1)) = round(60 * 0) = 0
444
+ expect(result.pillars.valueDiscipline.score).toBe(0);
445
+ });
446
+ it('caps score at 69 when drift count > 200', () => {
447
+ const result = calculateHealthScorePillar(makeMetrics({
448
+ componentCount: 100,
449
+ tokenCount: 50,
450
+ unusedTokenCount: 0,
451
+ hasUtilityFramework: true,
452
+ hasDesignSystemLibrary: true,
453
+ totalDriftCount: 250,
454
+ hardcodedValueCount: 0,
455
+ }));
456
+ expect(result.score).toBeLessThanOrEqual(69);
457
+ });
458
+ it('caps score at 74-84 when drift count > 100 based on density', () => {
459
+ const result = calculateHealthScorePillar(makeMetrics({
460
+ componentCount: 50,
461
+ tokenCount: 50,
462
+ unusedTokenCount: 0,
463
+ hasUtilityFramework: true,
464
+ hasDesignSystemLibrary: true,
465
+ totalDriftCount: 150,
466
+ hardcodedValueCount: 0,
467
+ }));
468
+ // driftPerComponent = 150/50 = 3, cap = round(74 + (1-1)*10) = 74
469
+ expect(result.score).toBeLessThanOrEqual(84);
470
+ });
471
+ it('applies graduated penalty for drift 50-100 with high density', () => {
472
+ const result = calculateHealthScorePillar(makeMetrics({
473
+ componentCount: 20,
474
+ tokenCount: 50,
475
+ unusedTokenCount: 0,
476
+ hasUtilityFramework: true,
477
+ hasDesignSystemLibrary: true,
478
+ totalDriftCount: 60,
479
+ hardcodedValueCount: 0,
480
+ }));
481
+ // driftPerComponent = 60/20 = 3 > 0.3, cap at 89
482
+ expect(result.score).toBeLessThanOrEqual(89);
483
+ });
484
+ });
485
+ describe('design system library detection', () => {
486
+ it('scores token health higher when hasDesignSystemLibrary is true', () => {
487
+ const without = calculateHealthScorePillar(makeMetrics({
488
+ componentCount: 50,
489
+ hardcodedValueCount: 10,
490
+ hasDesignSystemLibrary: false,
491
+ hasUtilityFramework: false,
492
+ }));
493
+ const with_ = calculateHealthScorePillar(makeMetrics({
494
+ componentCount: 50,
495
+ hardcodedValueCount: 10,
496
+ hasDesignSystemLibrary: true,
497
+ hasUtilityFramework: false,
498
+ }));
499
+ expect(with_.pillars.tokenHealth.score).toBeGreaterThan(without.pillars.tokenHealth.score);
500
+ });
501
+ });
502
+ describe('suggestions', () => {
503
+ it('suggests extracting hardcoded values when density > 0.5', () => {
504
+ const result = calculateHealthScorePillar(makeMetrics({
505
+ componentCount: 10,
506
+ hardcodedValueCount: 10,
507
+ }));
508
+ expect(result.suggestions.some(s => s.includes('hardcoded'))).toBe(true);
509
+ });
510
+ it('suggests wiring unused tokens', () => {
511
+ const result = calculateHealthScorePillar(makeMetrics({
512
+ tokenCount: 50,
513
+ unusedTokenCount: 20,
514
+ }));
515
+ expect(result.suggestions.some(s => s.includes('unused'))).toBe(true);
516
+ });
517
+ it('suggests adding token system when missing', () => {
518
+ const result = calculateHealthScorePillar(makeMetrics({
519
+ componentCount: 10,
520
+ hardcodedValueCount: 5,
521
+ }));
522
+ expect(result.suggestions.some(s => s.includes('token system'))).toBe(true);
523
+ });
524
+ it('provides aspirational suggestion for near-perfect project', () => {
525
+ const result = calculateHealthScorePillar(makeMetrics({
526
+ tokenCount: 50,
527
+ unusedTokenCount: 0,
528
+ }));
529
+ // Score is 90 (no utility framework or DS library), so gets aspirational suggestion
530
+ expect(result.suggestions).toHaveLength(1);
531
+ expect(result.suggestions[0]).toContain('to reach 100');
532
+ });
533
+ });
534
+ describe('rich suggestions', () => {
535
+ it('includes specific color in suggestion when topHardcodedColor provided', () => {
536
+ const result = calculateHealthScorePillar(makeMetrics({
537
+ componentCount: 10,
538
+ hardcodedValueCount: 20,
539
+ topHardcodedColor: { value: '#ff6b6b', count: 8 },
540
+ }));
541
+ expect(result.suggestions.some(s => s.includes('#ff6b6b') && s.includes('8'))).toBe(true);
542
+ });
543
+ it('includes worst file in suggestion when provided (moderate density)', () => {
544
+ const result = calculateHealthScorePillar(makeMetrics({
545
+ componentCount: 20,
546
+ hardcodedValueCount: 10, // density ~0.5 → moderate tier
547
+ worstFile: { path: 'src/components/Card.tsx', issueCount: 15 },
548
+ }));
549
+ expect(result.suggestions.some(s => s.includes('Card.tsx') && s.includes('15'))).toBe(true);
550
+ });
551
+ it('includes color in severe suggestion and worst file in moderate suggestion', () => {
552
+ // Severe tier: color is included
553
+ const severe = calculateHealthScorePillar(makeMetrics({
554
+ componentCount: 10,
555
+ hardcodedValueCount: 20,
556
+ topHardcodedColor: { value: '#ff6b6b', count: 8 },
557
+ }));
558
+ expect(severe.suggestions.some(s => s.includes('#ff6b6b') && s.includes('8'))).toBe(true);
559
+ // Moderate tier: worst file is included
560
+ const moderate = calculateHealthScorePillar(makeMetrics({
561
+ componentCount: 20,
562
+ hardcodedValueCount: 10,
563
+ worstFile: { path: 'src/components/Card.tsx', issueCount: 15 },
564
+ }));
565
+ expect(moderate.suggestions.some(s => s.includes('Card.tsx') && s.includes('15'))).toBe(true);
566
+ });
567
+ it('falls back to generic suggestion when no rich context', () => {
568
+ const result = calculateHealthScorePillar(makeMetrics({
569
+ componentCount: 10,
570
+ hardcodedValueCount: 20,
571
+ }));
572
+ expect(result.suggestions.some(s => s.includes('hardcoded values across your components'))).toBe(true);
573
+ });
574
+ it('suggests spacing consolidation when many unique values', () => {
575
+ const result = calculateHealthScorePillar(makeMetrics({
576
+ uniqueSpacingValues: 25,
577
+ }));
578
+ expect(result.suggestions.some(s => s.includes('25 unique spacing'))).toBe(true);
579
+ });
580
+ it('does not add spacing suggestion when few values', () => {
581
+ const result = calculateHealthScorePillar(makeMetrics({
582
+ uniqueSpacingValues: 5,
583
+ }));
584
+ expect(result.suggestions.every(s => !s.includes('spacing'))).toBe(true);
585
+ });
586
+ it('does not add spacing suggestion when undefined', () => {
587
+ const result = calculateHealthScorePillar(makeMetrics({}));
588
+ expect(result.suggestions.every(s => !s.includes('spacing'))).toBe(true);
589
+ });
590
+ it('appends worst file to moderate-density suggestion', () => {
591
+ const result = calculateHealthScorePillar(makeMetrics({
592
+ componentCount: 20,
593
+ hardcodedValueCount: 12, // density ~0.6 → moderate tier
594
+ worstFile: { path: 'src/App.tsx', issueCount: 12 },
595
+ }));
596
+ const suggestion = result.suggestions.find(s => s.includes('hardcoded value'));
597
+ expect(suggestion).toBeDefined();
598
+ expect(suggestion).toContain('App.tsx');
599
+ expect(suggestion).toContain('12 issues');
600
+ });
601
+ });
602
+ describe('tiered, framework-aware suggestions', () => {
603
+ it('severe tier: Tailwind-specific advice for high density', () => {
604
+ const result = calculateHealthScorePillar(makeMetrics({
605
+ componentCount: 10,
606
+ hardcodedValueCount: 30, // density 3.0 → severe
607
+ detectedFrameworkNames: ['react', 'tailwind'],
608
+ }));
609
+ expect(result.suggestions.some(s => s.includes('high density') && s.includes('Tailwind theme config'))).toBe(true);
610
+ });
611
+ it('severe tier: MUI-specific advice for high density', () => {
612
+ const result = calculateHealthScorePillar(makeMetrics({
613
+ componentCount: 10,
614
+ hardcodedValueCount: 30,
615
+ detectedFrameworkNames: ['react', 'mui'],
616
+ }));
617
+ expect(result.suggestions.some(s => s.includes('high density') && s.includes('theme.palette'))).toBe(true);
618
+ });
619
+ it('severe tier: generic advice when no framework detected', () => {
620
+ const result = calculateHealthScorePillar(makeMetrics({
621
+ componentCount: 10,
622
+ hardcodedValueCount: 30,
623
+ }));
624
+ expect(result.suggestions.some(s => s.includes('high density') && s.includes('design token file'))).toBe(true);
625
+ });
626
+ it('moderate tier: Tailwind + shadcn advice uses cn() utility', () => {
627
+ const result = calculateHealthScorePillar(makeMetrics({
628
+ componentCount: 20,
629
+ hardcodedValueCount: 10, // density ~0.5 → moderate
630
+ detectedFrameworkNames: ['react', 'tailwind', 'shadcn'],
631
+ }));
632
+ expect(result.suggestions.some(s => s.includes('cn()'))).toBe(true);
633
+ });
634
+ it('moderate tier: Tailwind-only advice extends config', () => {
635
+ const result = calculateHealthScorePillar(makeMetrics({
636
+ componentCount: 20,
637
+ hardcodedValueCount: 10,
638
+ detectedFrameworkNames: ['react', 'tailwind'],
639
+ }));
640
+ expect(result.suggestions.some(s => s.includes('tailwind.config'))).toBe(true);
641
+ });
642
+ it('low tier: encouraging message for near-clean codebases', () => {
643
+ const result = calculateHealthScorePillar(makeMetrics({
644
+ componentCount: 100,
645
+ hardcodedValueCount: 3, // density 0.03 → low
646
+ }));
647
+ expect(result.suggestions.some(s => s.includes('Nearly there'))).toBe(true);
648
+ });
649
+ it('low tier: includes worst file when it has multiple issues', () => {
650
+ const result = calculateHealthScorePillar(makeMetrics({
651
+ componentCount: 100,
652
+ hardcodedValueCount: 5,
653
+ worstFile: { path: 'src/Card.tsx', issueCount: 3 },
654
+ }));
655
+ expect(result.suggestions.some(s => s.includes('Nearly there') && s.includes('Card.tsx') && s.includes('3'))).toBe(true);
656
+ });
657
+ it('excludes vendored drift from user count', () => {
658
+ const result = calculateHealthScorePillar(makeMetrics({
659
+ componentCount: 20,
660
+ hardcodedValueCount: 15,
661
+ vendoredDriftCount: 5, // userCount = 10
662
+ detectedFrameworkNames: ['react', 'tailwind'],
663
+ }));
664
+ expect(result.suggestions.some(s => s.includes('10 hardcoded'))).toBe(true);
665
+ });
666
+ it('token health: Tailwind-specific suggestion when token coverage is low', () => {
667
+ const result = calculateHealthScorePillar(makeMetrics({
668
+ componentCount: 50,
669
+ hardcodedValueCount: 30,
670
+ detectedFrameworkNames: ['react', 'tailwind'],
671
+ }));
672
+ expect(result.suggestions.some(s => s.includes('Tailwind detected but token coverage is low'))).toBe(true);
673
+ });
674
+ it('token health: design system library suggestion for MUI', () => {
675
+ const result = calculateHealthScorePillar(makeMetrics({
676
+ componentCount: 50,
677
+ hardcodedValueCount: 30,
678
+ detectedFrameworkNames: ['react', 'mui'],
679
+ }));
680
+ expect(result.suggestions.some(s => s.includes('mui detected'))).toBe(true);
681
+ });
682
+ it('token health: generic suggestion when no framework', () => {
683
+ const result = calculateHealthScorePillar(makeMetrics({
684
+ componentCount: 50,
685
+ hardcodedValueCount: 30,
686
+ }));
687
+ expect(result.suggestions.some(s => s.includes('add CSS custom properties'))).toBe(true);
688
+ });
689
+ it('userCount never goes negative', () => {
690
+ const result = calculateHealthScorePillar(makeMetrics({
691
+ componentCount: 10,
692
+ hardcodedValueCount: 5,
693
+ vendoredDriftCount: 10, // vendored > hardcoded → userCount clamped to 0
694
+ }));
695
+ // With userCount = 0, hardcodedValueCount is still > 0 so we enter the block
696
+ // but userCount = Math.max(0, 5 - 10) = 0
697
+ const suggestion = result.suggestions.find(s => s.includes('hardcoded'));
698
+ if (suggestion) {
699
+ expect(suggestion).not.toMatch(/-\d+ hardcoded/);
700
+ }
701
+ });
702
+ });
703
+ describe('silent drift type mapping', () => {
704
+ it('penalizes value discipline for unused/orphaned/repeated components', () => {
705
+ const clean = calculateHealthScorePillar(makeMetrics({
706
+ componentCount: 100,
707
+ hardcodedValueCount: 0,
708
+ }));
709
+ const withDeadCode = calculateHealthScorePillar(makeMetrics({
710
+ componentCount: 100,
711
+ hardcodedValueCount: 0,
712
+ unusedComponentCount: 50,
713
+ orphanedComponentCount: 10,
714
+ repeatedPatternCount: 20,
715
+ }));
716
+ expect(withDeadCode.pillars.valueDiscipline.score).toBeLessThan(clean.pillars.valueDiscipline.score);
717
+ });
718
+ it('penalizes consistency for semantic-mismatch signals', () => {
719
+ const clean = calculateHealthScorePillar(makeMetrics({
720
+ componentCount: 100,
721
+ }));
722
+ const withMismatch = calculateHealthScorePillar(makeMetrics({
723
+ componentCount: 100,
724
+ semanticMismatchCount: 10,
725
+ }));
726
+ expect(withMismatch.pillars.consistency.score).toBeLessThan(clean.pillars.consistency.score);
727
+ });
728
+ it('penalizes critical issues for deprecated-pattern signals', () => {
729
+ const clean = calculateHealthScorePillar(makeMetrics({
730
+ componentCount: 100,
731
+ }));
732
+ const withDeprecated = calculateHealthScorePillar(makeMetrics({
733
+ componentCount: 100,
734
+ deprecatedPatternCount: 4,
735
+ }));
736
+ expect(withDeprecated.pillars.criticalIssues.score).toBeLessThan(clean.pillars.criticalIssues.score);
737
+ });
738
+ it('does not change scores when silent drift counts are zero or undefined', () => {
739
+ const withZero = calculateHealthScorePillar(makeMetrics({
740
+ componentCount: 100,
741
+ unusedComponentCount: 0,
742
+ repeatedPatternCount: 0,
743
+ orphanedComponentCount: 0,
744
+ semanticMismatchCount: 0,
745
+ deprecatedPatternCount: 0,
746
+ }));
747
+ const withUndefined = calculateHealthScorePillar(makeMetrics({
748
+ componentCount: 100,
749
+ }));
750
+ expect(withZero.score).toBe(withUndefined.score);
751
+ });
752
+ it('adds deprecated pattern suggestion', () => {
753
+ const result = calculateHealthScorePillar(makeMetrics({
754
+ deprecatedPatternCount: 3,
755
+ }));
756
+ expect(result.suggestions.some(s => s.includes('deprecated'))).toBe(true);
757
+ });
758
+ it('counts 2 deprecated patterns as 1 critical equivalent', () => {
759
+ // 2 deprecated = ceil(2/2) = 1 critical equivalent
760
+ // effectiveCriticalCount = 0 + 1 = 1 → score = 10 - 2 = 8
761
+ const result = calculateHealthScorePillar(makeMetrics({
762
+ criticalCount: 0,
763
+ deprecatedPatternCount: 2,
764
+ }));
765
+ expect(result.pillars.criticalIssues.score).toBe(8);
766
+ });
767
+ it('combines semantic-mismatch with naming-inconsistency for consistency score', () => {
768
+ // 5 naming + 5 semantic = 10 / 100 = 0.10 rate
769
+ // 1 - 0.10/0.25 = 0.6 → round(10 * 0.6) = 6
770
+ const result = calculateHealthScorePillar(makeMetrics({
771
+ componentCount: 100,
772
+ namingInconsistencyCount: 5,
773
+ semanticMismatchCount: 5,
774
+ }));
775
+ expect(result.pillars.consistency.score).toBe(6);
776
+ });
777
+ it('dead code density adds 30% penalty on top of hardcoded density', () => {
778
+ // hardcodedDensity = 10/100 = 0.1
779
+ // deadCodeDensity = 50/100 = 0.5
780
+ // density = max(0.1 + 0.5*0.3, 60/100*0.5) = max(0.25, 0.30) = 0.30
781
+ // valueDiscipline = round(60 * (1 - 0.30/2)) = round(60 * 0.85) = 51
782
+ const result = calculateHealthScorePillar(makeMetrics({
783
+ componentCount: 100,
784
+ hardcodedValueCount: 10,
785
+ unusedComponentCount: 50,
786
+ totalDriftCount: 60,
787
+ }));
788
+ expect(result.pillars.valueDiscipline.score).toBe(51);
789
+ });
790
+ });
791
+ describe('score distribution calibration', () => {
792
+ it('moderate drift repo scores Good, not Great', () => {
793
+ // A typical repo: 50 components, 30 hardcoded values, some naming issues
794
+ // density = 30/50 = 0.6 → valueDiscipline = round(60 * (1 - 0.6/2)) = round(60 * 0.7) = 42
795
+ // tokenHealth: utility(5) + usage(5) = 10
796
+ // namingRate = (3+5)/50 = 0.16 → consistency = round(10 * (1 - 0.16/0.25)) = round(10 * 0.36) = 4
797
+ // criticalScore = 10
798
+ // total = 42 + 10 + 4 + 10 = 66 → Good
799
+ const result = calculateHealthScorePillar(makeMetrics({
800
+ componentCount: 50,
801
+ hardcodedValueCount: 30, // density 0.6
802
+ namingInconsistencyCount: 3,
803
+ semanticMismatchCount: 5,
804
+ hasUtilityFramework: true,
805
+ }));
806
+ expect(result.tier).toBe('Good');
807
+ });
808
+ it('heavy drift repo scores OK or worse', () => {
809
+ // Lots of drift signals
810
+ // hardcodedDensity = 100/100 = 1, deadCode = 30/100 = 0.3 * 0.3 = 0.09
811
+ // totalDriftDensity = 200/100 = 2 * 0.5 = 1
812
+ // density = max(1 + 0.09, 1) = 1.09
813
+ // valueDiscipline = round(60 * (1 - 1.09/2)) = round(60 * clamp(0.455, 0, 1)) = round(27.3) = 27
814
+ // tokenHealth = 0
815
+ // namingRate = 25/100 = 0.25 → consistency = 0
816
+ // criticalScore = 10
817
+ // total = 27 + 0 + 0 + 10 = 37 → Bad
818
+ const result = calculateHealthScorePillar(makeMetrics({
819
+ componentCount: 100,
820
+ hardcodedValueCount: 100,
821
+ namingInconsistencyCount: 10,
822
+ semanticMismatchCount: 15,
823
+ unusedComponentCount: 30,
824
+ totalDriftCount: 200,
825
+ }));
826
+ expect(result.score).toBeLessThan(60); // OK or worse
827
+ });
828
+ });
829
+ describe('suggestions for all repos with drift', () => {
830
+ it('provides encouraging suggestion for low-density hardcoded values', () => {
831
+ const result = calculateHealthScorePillar(makeMetrics({
832
+ componentCount: 100,
833
+ hardcodedValueCount: 5, // density 0.05 — low tier
834
+ hasUtilityFramework: true,
835
+ hasDesignSystemLibrary: true,
836
+ tokenCount: 50,
837
+ unusedTokenCount: 0,
838
+ }));
839
+ expect(result.score).toBeGreaterThanOrEqual(80);
840
+ expect(result.suggestions.length).toBeGreaterThan(0);
841
+ expect(result.suggestions.some(s => s.includes('Nearly there'))).toBe(true);
842
+ });
843
+ it('provides severe suggestion for high-density hardcoded values', () => {
844
+ const result = calculateHealthScorePillar(makeMetrics({
845
+ componentCount: 10,
846
+ hardcodedValueCount: 20, // density 2.0 — severe tier
847
+ }));
848
+ expect(result.suggestions.some(s => s.includes('high density'))).toBe(true);
849
+ });
850
+ it('provides gentle consistency suggestion for few inconsistencies', () => {
851
+ const result = calculateHealthScorePillar(makeMetrics({
852
+ componentCount: 100,
853
+ namingInconsistencyCount: 6, // rate 0.06 > 0.05 threshold
854
+ }));
855
+ expect(result.suggestions.some(s => s.includes('naming inconsistencies'))).toBe(true);
856
+ });
857
+ it('suggests removing unused components', () => {
858
+ const result = calculateHealthScorePillar(makeMetrics({
859
+ unusedComponentCount: 15,
860
+ }));
861
+ expect(result.suggestions.some(s => s.includes('unused components'))).toBe(true);
862
+ });
863
+ it('suggests extracting repeated patterns', () => {
864
+ const result = calculateHealthScorePillar(makeMetrics({
865
+ repeatedPatternCount: 8,
866
+ }));
867
+ expect(result.suggestions.some(s => s.includes('repeated patterns'))).toBe(true);
868
+ });
869
+ it('provides congratulatory suggestion for truly perfect project', () => {
870
+ const result = calculateHealthScorePillar(makeMetrics({
871
+ componentCount: 50,
872
+ tokenCount: 50,
873
+ unusedTokenCount: 0,
874
+ hasUtilityFramework: true,
875
+ hasDesignSystemLibrary: true,
876
+ }));
877
+ expect(result.suggestions).toHaveLength(1);
878
+ expect(result.suggestions[0]).toContain('Perfect design system health');
879
+ });
880
+ });
881
+ });
882
+ describe('getHealthTier', () => {
883
+ it('returns correct tier for each range', () => {
884
+ expect(getHealthTier(100)).toBe('Great');
885
+ expect(getHealthTier(80)).toBe('Great');
886
+ expect(getHealthTier(79)).toBe('Good');
887
+ expect(getHealthTier(60)).toBe('Good');
888
+ expect(getHealthTier(59)).toBe('OK');
889
+ expect(getHealthTier(40)).toBe('OK');
890
+ expect(getHealthTier(39)).toBe('Bad');
891
+ expect(getHealthTier(20)).toBe('Bad');
892
+ expect(getHealthTier(19)).toBe('Terrible');
893
+ expect(getHealthTier(0)).toBe('Terrible');
894
+ });
895
+ });
896
+ //# sourceMappingURL=audit.test.sync-conflict-20260313-170321-6PCZ3ZU.js.map