@buoy-design/cli 0.3.3 → 0.3.4

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.
@@ -1,13 +1,16 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
+ import { existsSync } from "fs";
4
+ import { writeFileSync } from "fs";
3
5
  import { loadConfig, getConfigPath } from "../config/loader.js";
4
6
  import { buildAutoConfig } from "../config/auto-detect.js";
5
- import { spinner, error, setJsonMode, } from "../output/reporters.js";
7
+ import { spinner, error, info, success, header, keyValue, newline, setJsonMode, } from "../output/reporters.js";
8
+ import { formatDriftTable, formatDriftList, formatDriftTree, formatMarkdown, formatHtml, formatAgent, } from "../output/formatters.js";
6
9
  import { ScanOrchestrator } from "../scan/orchestrator.js";
7
10
  import { DriftAnalysisService } from "../services/drift-analysis.js";
8
11
  import { withOptionalCache } from "@buoy-design/scanners";
9
12
  import { formatUpgradeHint } from "../utils/upgrade-hints.js";
10
- import { generateAuditReport, DriftAggregator } from "@buoy-design/core";
13
+ import { generateAuditReport, findCloseMatches, DriftAggregator } from "@buoy-design/core";
11
14
  import { extractStyles, extractCssFileStyles } from "@buoy-design/scanners";
12
15
  import { parseCssValues } from "@buoy-design/core";
13
16
  import { glob } from "glob";
@@ -17,12 +20,16 @@ export function createShowCommand() {
17
20
  .description("Show design system information")
18
21
  .option("--json", "Output as JSON (default)")
19
22
  .option("--no-cache", "Disable incremental scanning cache");
20
- // show components
23
+ // show components [query]
21
24
  cmd
22
25
  .command("components")
23
- .description("Show components found in the codebase")
26
+ .description("Show components found in the codebase (with optional search)")
27
+ .argument("[query]", "Search query (component name or partial match)")
24
28
  .option("--json", "Output as JSON")
25
- .action(async (options, command) => {
29
+ .option("--prop <propName>", "Search by prop name (e.g., \"onClick\")")
30
+ .option("--pattern <pattern>", "Search by pattern (checks name, props, variants)")
31
+ .option("-n, --limit <number>", "Maximum results to show", "10")
32
+ .action(async (query, options, command) => {
26
33
  const parentOpts = command.parent?.opts() || {};
27
34
  const json = options.json || parentOpts.json !== false;
28
35
  if (json)
@@ -37,7 +44,117 @@ export function createShowCommand() {
37
44
  });
38
45
  });
39
46
  spin.stop();
40
- console.log(JSON.stringify({ components: scanResult.components }, null, 2));
47
+ const components = scanResult.components;
48
+ // If no search query/options, list all components
49
+ if (!query && !options.prop && !options.pattern) {
50
+ console.log(JSON.stringify({ components }, null, 2));
51
+ return;
52
+ }
53
+ // Search mode
54
+ let results = [];
55
+ if (options.prop) {
56
+ const lowerProp = options.prop.toLowerCase();
57
+ for (const component of components) {
58
+ const props = component.props || [];
59
+ const matchingProp = props.find((p) => p.name.toLowerCase().includes(lowerProp));
60
+ if (matchingProp) {
61
+ results.push({
62
+ component,
63
+ score: matchingProp.name.toLowerCase() === lowerProp ? 100 : 80,
64
+ matchType: "prop",
65
+ });
66
+ }
67
+ }
68
+ }
69
+ else if (options.pattern) {
70
+ const lowerPattern = options.pattern.toLowerCase();
71
+ for (const component of components) {
72
+ let score = 0;
73
+ const nameScore = fuzzyScore(options.pattern, component.name);
74
+ if (nameScore > 0)
75
+ score += nameScore * 0.5;
76
+ const props = component.props || [];
77
+ if (props.some((p) => p.name.toLowerCase().includes(lowerPattern) ||
78
+ (p.type && p.type.toLowerCase().includes(lowerPattern))))
79
+ score += 30;
80
+ const variants = component.variants || [];
81
+ if (variants.some((v) => v.name.toLowerCase().includes(lowerPattern)))
82
+ score += 20;
83
+ if (score >= 30) {
84
+ results.push({ component, score: Math.min(100, score), matchType: "pattern" });
85
+ }
86
+ }
87
+ }
88
+ else if (query) {
89
+ for (const component of components) {
90
+ const score = fuzzyScore(query, component.name);
91
+ if (score >= 30) {
92
+ results.push({
93
+ component,
94
+ score,
95
+ matchType: score === 100 ? "exact" : "fuzzy",
96
+ });
97
+ }
98
+ }
99
+ }
100
+ results.sort((a, b) => b.score - a.score);
101
+ const limit = parseInt(options.limit, 10);
102
+ results = results.slice(0, limit);
103
+ if (json) {
104
+ console.log(JSON.stringify({
105
+ query: query || null,
106
+ prop: options.prop || null,
107
+ pattern: options.pattern || null,
108
+ results: results.map(r => ({
109
+ name: r.component.name,
110
+ path: "path" in r.component.source ? r.component.source.path : "unknown",
111
+ props: r.component.props?.map((p) => ({
112
+ name: p.name, type: p.type, required: p.required,
113
+ })),
114
+ variants: r.component.variants?.map((v) => v.name),
115
+ score: Math.round(r.score),
116
+ matchType: r.matchType,
117
+ })),
118
+ totalComponents: components.length,
119
+ }, null, 2));
120
+ return;
121
+ }
122
+ if (results.length === 0) {
123
+ newline();
124
+ info("No matching components found");
125
+ if (query)
126
+ info("Try a different search term or use --pattern for broader search");
127
+ return;
128
+ }
129
+ newline();
130
+ header(`Found ${results.length} component${results.length === 1 ? "" : "s"}`);
131
+ newline();
132
+ for (const { component, score, matchType } of results) {
133
+ const scoreLabel = matchType === "exact" ? chalk.green("exact")
134
+ : matchType === "prop" ? chalk.cyan("prop match")
135
+ : matchType === "pattern" ? chalk.yellow("pattern")
136
+ : chalk.dim(`${Math.round(score)}%`);
137
+ console.log(` ${chalk.bold(component.name)} ${scoreLabel}`);
138
+ keyValue(" Path", "path" in component.source ? component.source.path : "unknown");
139
+ const props = component.props || [];
140
+ const propStr = props.length === 0 ? chalk.dim("(no props)") : props.slice(0, 5).map((p) => {
141
+ const req = p.required ? "*" : "";
142
+ const t = p.type ? chalk.dim(`: ${p.type}`) : "";
143
+ return `${p.name}${req}${t}`;
144
+ }).join(", ") + (props.length > 5 ? chalk.dim(` +${props.length - 5} more`) : "");
145
+ keyValue(" Props", propStr);
146
+ const variants = component.variants || [];
147
+ if (variants.length > 0) {
148
+ const variantNames = variants.slice(0, 4).map((v) => v.name).join(", ");
149
+ const more = variants.length > 4 ? chalk.dim(` +${variants.length - 4} more`) : "";
150
+ keyValue(" Variants", variantNames + more);
151
+ }
152
+ newline();
153
+ }
154
+ console.log(chalk.dim("─".repeat(40)));
155
+ info(`${components.length} total components in project`);
156
+ if (results.length === limit)
157
+ info(`Showing first ${limit} results. Use --limit to see more.`);
41
158
  }
42
159
  catch (err) {
43
160
  spin.stop();
@@ -79,13 +196,19 @@ export function createShowCommand() {
79
196
  .description("Show drift signals (design system violations)")
80
197
  .option("--json", "Output as JSON")
81
198
  .option("--raw", "Output raw signals without grouping")
199
+ .option("-f, --format <type>", "Output format: json, markdown, html, table, tree, agent")
82
200
  .option("-S, --severity <level>", "Filter by minimum severity (info, warning, critical)")
83
201
  .option("-t, --type <type>", "Filter by drift type")
202
+ .option("-v, --verbose", "Verbose output with full details")
203
+ .option("--include-baseline", "Include baselined drifts (show all)")
204
+ .option("--clear-cache", "Clear cache before scanning")
84
205
  .action(async (options, command) => {
85
206
  const parentOpts = command.parent?.opts() || {};
86
207
  const json = options.json || parentOpts.json !== false;
87
- if (json)
208
+ const useJson = json && !options.format && !options.verbose;
209
+ if (useJson || options.format === "json" || options.format === "agent") {
88
210
  setJsonMode(true);
211
+ }
89
212
  const spin = spinner("Analyzing drift...");
90
213
  try {
91
214
  const { config } = await loadConfig();
@@ -93,14 +216,37 @@ export function createShowCommand() {
93
216
  const service = new DriftAnalysisService(config);
94
217
  return service.analyze({
95
218
  onProgress: (msg) => { spin.text = msg; },
96
- includeBaseline: false,
219
+ includeBaseline: options.includeBaseline ?? false,
97
220
  minSeverity: options.severity,
98
221
  filterType: options.type,
99
222
  cache,
100
223
  });
224
+ }, {
225
+ clearCache: options.clearCache,
226
+ onVerbose: options.verbose ? info : undefined,
101
227
  });
228
+ const drifts = result.drifts;
229
+ const sourceComponents = result.components;
230
+ const baselinedCount = result.baselinedCount;
102
231
  spin.stop();
103
- // Raw mode: output signals without grouping (existing behavior)
232
+ // Determine output format
233
+ const format = options.format;
234
+ if (format === "agent") {
235
+ console.log(formatAgent(drifts));
236
+ return;
237
+ }
238
+ if (format === "markdown") {
239
+ console.log(formatMarkdown(drifts));
240
+ return;
241
+ }
242
+ if (format === "html") {
243
+ const htmlContent = formatHtml(drifts, { designerFriendly: true });
244
+ const filename = "drift-report.html";
245
+ writeFileSync(filename, htmlContent);
246
+ success(`HTML report saved to ${filename}`);
247
+ return;
248
+ }
249
+ // Raw mode: output signals without grouping
104
250
  if (options.raw) {
105
251
  const output = {
106
252
  drifts: result.drifts,
@@ -114,48 +260,109 @@ export function createShowCommand() {
114
260
  console.log(JSON.stringify(output, null, 2));
115
261
  return;
116
262
  }
117
- // Grouped mode (default): aggregate signals for actionability
118
- const aggregationConfig = config.drift?.aggregation ?? {};
119
- const aggregator = new DriftAggregator({
120
- strategies: aggregationConfig.strategies,
121
- minGroupSize: aggregationConfig.minGroupSize,
122
- pathPatterns: aggregationConfig.pathPatterns,
123
- });
124
- const aggregated = aggregator.aggregate(result.drifts);
125
- const output = {
126
- groups: aggregated.groups.map(g => ({
127
- id: g.id,
128
- strategy: g.groupingKey.strategy,
129
- key: g.groupingKey.value,
130
- summary: g.summary,
131
- count: g.totalCount,
132
- severity: g.bySeverity,
133
- representative: {
134
- type: g.representative.type,
135
- message: g.representative.message,
136
- location: g.representative.source.location,
137
- },
138
- })),
139
- ungrouped: aggregated.ungrouped.map(d => ({
140
- id: d.id,
141
- type: d.type,
142
- severity: d.severity,
143
- message: d.message,
144
- location: d.source.location,
145
- })),
146
- summary: {
147
- totalSignals: aggregated.totalSignals,
148
- totalGroups: aggregated.totalGroups,
149
- ungroupedCount: aggregated.ungrouped.length,
150
- reductionRatio: Math.round(aggregated.reductionRatio * 10) / 10,
151
- bySeverity: {
152
- critical: result.drifts.filter((d) => d.severity === "critical").length,
153
- warning: result.drifts.filter((d) => d.severity === "warning").length,
154
- info: result.drifts.filter((d) => d.severity === "info").length,
263
+ // JSON output (explicit --json or --format json)
264
+ if (format === "json" || useJson) {
265
+ // Grouped mode: aggregate signals for actionability
266
+ const aggregationConfig = config.drift?.aggregation ?? {};
267
+ const aggregator = new DriftAggregator({
268
+ strategies: aggregationConfig.strategies,
269
+ minGroupSize: aggregationConfig.minGroupSize,
270
+ pathPatterns: aggregationConfig.pathPatterns,
271
+ });
272
+ const aggregated = aggregator.aggregate(result.drifts);
273
+ const output = {
274
+ groups: aggregated.groups.map(g => ({
275
+ id: g.id,
276
+ strategy: g.groupingKey.strategy,
277
+ key: g.groupingKey.value,
278
+ summary: g.summary,
279
+ count: g.totalCount,
280
+ severity: g.bySeverity,
281
+ representative: {
282
+ type: g.representative.type,
283
+ message: g.representative.message,
284
+ location: g.representative.source.location,
285
+ },
286
+ })),
287
+ ungrouped: aggregated.ungrouped.map(d => ({
288
+ id: d.id,
289
+ type: d.type,
290
+ severity: d.severity,
291
+ message: d.message,
292
+ location: d.source.location,
293
+ })),
294
+ summary: {
295
+ totalSignals: aggregated.totalSignals,
296
+ totalGroups: aggregated.totalGroups,
297
+ ungroupedCount: aggregated.ungrouped.length,
298
+ reductionRatio: Math.round(aggregated.reductionRatio * 10) / 10,
299
+ bySeverity: {
300
+ critical: result.drifts.filter((d) => d.severity === "critical").length,
301
+ warning: result.drifts.filter((d) => d.severity === "warning").length,
302
+ info: result.drifts.filter((d) => d.severity === "info").length,
303
+ },
155
304
  },
156
- },
157
- };
158
- console.log(JSON.stringify(output, null, 2));
305
+ baselinedCount,
306
+ };
307
+ console.log(JSON.stringify(output, null, 2));
308
+ return;
309
+ }
310
+ // Human-readable output formats
311
+ const uniqueFiles = new Set(drifts.map(d => d.source.location?.split(':')[0] || d.source.entityName));
312
+ if (options.verbose) {
313
+ header("Drift Analysis");
314
+ newline();
315
+ const summary = getSummary(drifts);
316
+ keyValue("Components scanned", String(sourceComponents.length));
317
+ keyValue("Critical", String(summary.critical));
318
+ keyValue("Warning", String(summary.warning));
319
+ keyValue("Info", String(summary.info));
320
+ if (baselinedCount > 0) {
321
+ keyValue("Baselined (hidden)", String(baselinedCount));
322
+ }
323
+ newline();
324
+ console.log(formatDriftList(drifts));
325
+ }
326
+ else if (format === "table") {
327
+ header("Drift Analysis");
328
+ newline();
329
+ console.log(formatDriftTable(drifts));
330
+ }
331
+ else {
332
+ // Default: tree view
333
+ newline();
334
+ console.log(formatDriftTree(drifts, uniqueFiles.size));
335
+ }
336
+ // Handle empty results
337
+ if (drifts.length === 0) {
338
+ if (sourceComponents.length === 0) {
339
+ newline();
340
+ info("No components found to analyze.");
341
+ info("To find hardcoded inline styles:");
342
+ info(" " + chalk.cyan("buoy show health") + " # See all hardcoded values");
343
+ }
344
+ else {
345
+ const hasTokens = config.sources.tokens?.enabled &&
346
+ (config.sources.tokens.files?.length ?? 0) > 0;
347
+ const hasFigma = config.sources.figma?.enabled;
348
+ const hasStorybook = config.sources.storybook?.enabled;
349
+ const hasDesignTokensFile = existsSync('design-tokens.css') ||
350
+ existsSync('design-tokens.json');
351
+ if (!hasTokens && !hasFigma && !hasStorybook && !hasDesignTokensFile) {
352
+ newline();
353
+ info("No reference source configured.");
354
+ info("Run " + chalk.cyan("buoy tokens") + " to extract design tokens.");
355
+ }
356
+ }
357
+ }
358
+ // Show upgrade hint when drifts found
359
+ if (drifts.length > 0) {
360
+ const hint = formatUpgradeHint('after-drift-found');
361
+ if (hint) {
362
+ newline();
363
+ console.log(hint);
364
+ }
365
+ }
159
366
  }
160
367
  catch (err) {
161
368
  spin.stop();
@@ -168,6 +375,7 @@ export function createShowCommand() {
168
375
  .command("health")
169
376
  .description("Show design system health score")
170
377
  .option("--json", "Output as JSON")
378
+ .option("--tokens <path>", "Path to design tokens file for close-match detection")
171
379
  .action(async (options, command) => {
172
380
  const parentOpts = command.parent?.opts() || {};
173
381
  const json = options.json ?? parentOpts.json;
@@ -188,7 +396,7 @@ export function createShowCommand() {
188
396
  }
189
397
  else {
190
398
  console.log('');
191
- console.log(chalk.green.bold(' Health Score: 100/100'));
399
+ console.log(chalk.green.bold(' Health Score: 100/100 (Good)'));
192
400
  console.log('');
193
401
  console.log(chalk.dim(' No hardcoded design values found.'));
194
402
  console.log(chalk.dim(' Your codebase is using design tokens correctly!'));
@@ -197,54 +405,39 @@ export function createShowCommand() {
197
405
  return;
198
406
  }
199
407
  const report = generateAuditReport(extractedValues);
408
+ // Load design tokens for close-match detection if provided
409
+ if (options.tokens) {
410
+ try {
411
+ const tokenContent = await readFile(options.tokens, "utf-8");
412
+ const tokenData = JSON.parse(tokenContent);
413
+ const colorTokens = extractTokenValues(tokenData, "color");
414
+ const spacingTokens = extractTokenValues(tokenData, "spacing");
415
+ const colorValues = extractedValues
416
+ .filter((v) => v.category === "color")
417
+ .map((v) => v.value);
418
+ const spacingValues = extractedValues
419
+ .filter((v) => v.category === "spacing")
420
+ .map((v) => v.value);
421
+ report.closeMatches = [
422
+ ...findCloseMatches(colorValues, colorTokens, "color"),
423
+ ...findCloseMatches(spacingValues, spacingTokens, "spacing"),
424
+ ];
425
+ }
426
+ catch {
427
+ // Ignore token loading errors
428
+ }
429
+ }
200
430
  if (json) {
201
431
  console.log(JSON.stringify({
202
432
  score: report.score,
203
433
  categories: report.categories,
204
434
  worstFiles: report.worstFiles,
205
435
  totals: report.totals,
436
+ closeMatches: report.closeMatches,
206
437
  }, null, 2));
207
438
  }
208
439
  else {
209
- // Human-readable output
210
- console.log('');
211
- const scoreColor = report.score >= 80 ? chalk.green :
212
- report.score >= 50 ? chalk.yellow :
213
- chalk.red;
214
- console.log(` ${chalk.bold('Health Score:')} ${scoreColor.bold(report.score + '/100')}`);
215
- console.log('');
216
- // Categories
217
- console.log(chalk.bold(' By Category:'));
218
- for (const [category, data] of Object.entries(report.categories)) {
219
- const catData = data;
220
- console.log(` ${category}: ${catData.uniqueCount} unique values, ${catData.totalUsages} usages`);
221
- }
222
- console.log('');
223
- // Worst files
224
- if (report.worstFiles.length > 0) {
225
- console.log(chalk.bold(' Files with most hardcoded values:'));
226
- for (const file of report.worstFiles.slice(0, 5)) {
227
- console.log(` ${chalk.dim('•')} ${file.file}: ${file.issueCount} values`);
228
- }
229
- if (report.worstFiles.length > 5) {
230
- console.log(chalk.dim(` ...and ${report.worstFiles.length - 5} more files`));
231
- }
232
- console.log('');
233
- }
234
- // Totals
235
- console.log(chalk.dim(` Total: ${report.totals.uniqueValues} unique values across ${report.totals.filesAffected} files`));
236
- console.log('');
237
- // Suggestion
238
- if (report.score < 80) {
239
- console.log(chalk.dim(' Run `buoy tokens` to generate tokens from these values'));
240
- console.log('');
241
- }
242
- // Show upgrade hint after health score
243
- const hint = formatUpgradeHint('after-health-score');
244
- if (hint) {
245
- console.log(hint);
246
- console.log('');
247
- }
440
+ printHealthReport(report);
248
441
  }
249
442
  }
250
443
  catch (err) {
@@ -254,12 +447,14 @@ export function createShowCommand() {
254
447
  }
255
448
  });
256
449
  // show history
257
- cmd
450
+ const historyCmd = cmd
258
451
  .command("history")
259
- .description("Show scan history")
452
+ .description("Show scan history and trends")
453
+ .argument("[scan-id]", "Show details of a specific scan")
260
454
  .option("--json", "Output as JSON")
261
455
  .option("-n, --limit <number>", "Number of entries to show", "10")
262
- .action(async (options, command) => {
456
+ .option("-v, --verbose", "Show detailed information")
457
+ .action(async (scanId, options, command) => {
263
458
  const parentOpts = command.parent?.opts() || {};
264
459
  const json = options.json || parentOpts.json !== false;
265
460
  if (json)
@@ -267,30 +462,283 @@ export function createShowCommand() {
267
462
  try {
268
463
  // Import store dynamically to avoid circular deps
269
464
  const { createStore, getProjectName } = await import("../store/index.js");
465
+ // If a scan ID was provided, show details for that scan
466
+ if (scanId) {
467
+ const spin = spinner("Loading scan details...");
468
+ const store = createStore({ forceLocal: true });
469
+ const scan = await store.getScan(scanId);
470
+ if (!scan) {
471
+ spin.stop();
472
+ error(`Scan not found: ${scanId}`);
473
+ store.close();
474
+ process.exit(1);
475
+ }
476
+ const components = await store.getComponents(scanId);
477
+ const tokens = await store.getTokens(scanId);
478
+ const drifts = await store.getDriftSignals(scanId);
479
+ spin.stop();
480
+ if (json) {
481
+ console.log(JSON.stringify({
482
+ scan: {
483
+ id: scan.id,
484
+ status: scan.status,
485
+ sources: scan.sources,
486
+ stats: scan.stats,
487
+ startedAt: scan.startedAt,
488
+ completedAt: scan.completedAt,
489
+ },
490
+ components,
491
+ tokens,
492
+ drifts,
493
+ }, null, 2));
494
+ store.close();
495
+ return;
496
+ }
497
+ header(`Scan: ${scan.id}`);
498
+ newline();
499
+ keyValue("Status", scan.status);
500
+ keyValue("Sources", scan.sources.join(", "));
501
+ if (scan.startedAt) {
502
+ keyValue("Started", scan.startedAt.toLocaleString());
503
+ }
504
+ if (scan.completedAt) {
505
+ keyValue("Completed", scan.completedAt.toLocaleString());
506
+ }
507
+ if (scan.stats?.duration) {
508
+ keyValue("Duration", `${(scan.stats.duration / 1000).toFixed(1)}s`);
509
+ }
510
+ newline();
511
+ keyValue("Components", String(components.length));
512
+ keyValue("Tokens", String(tokens.length));
513
+ keyValue("Drift signals", String(drifts.length));
514
+ if (components.length > 0) {
515
+ const componentsWithDrift = new Set(drifts.map(d => d.source?.location).filter(Boolean)).size;
516
+ const coveragePercent = Math.round(((components.length - componentsWithDrift) / components.length) * 100);
517
+ const coverageColor = coveragePercent >= 80 ? chalk.green : coveragePercent >= 50 ? chalk.yellow : chalk.red;
518
+ keyValue("Coverage", coverageColor(`${coveragePercent}%`));
519
+ }
520
+ newline();
521
+ if (drifts.length > 0) {
522
+ const critical = drifts.filter((d) => d.severity === "critical").length;
523
+ const warning = drifts.filter((d) => d.severity === "warning").length;
524
+ const infoCount = drifts.filter((d) => d.severity === "info").length;
525
+ console.log(chalk.bold("Drift Breakdown"));
526
+ console.log(` ${chalk.red("Critical:")} ${critical} ` +
527
+ `${chalk.yellow("Warning:")} ${warning} ` +
528
+ `${chalk.blue("Info:")} ${infoCount}`);
529
+ }
530
+ store.close();
531
+ return;
532
+ }
533
+ // Default: list all scans
534
+ const spin = spinner("Loading scan history...");
270
535
  const store = createStore({ forceLocal: true });
271
536
  const projectName = getProjectName();
272
- const project = await store.getOrCreateProject(projectName);
273
- const limit = parseInt(options.limit, 10) || 10;
274
- const scans = await store.getScans(project.id, limit);
275
- store.close();
276
- console.log(JSON.stringify({
277
- project: projectName,
278
- scans: scans.map(s => ({
279
- id: s.id,
280
- startedAt: s.startedAt,
281
- completedAt: s.completedAt,
282
- status: s.status,
283
- componentCount: s.stats?.componentCount ?? 0,
284
- tokenCount: s.stats?.tokenCount ?? 0,
285
- driftCount: s.stats?.driftCount ?? 0,
286
- })),
287
- }, null, 2));
537
+ try {
538
+ const project = await store.getOrCreateProject(projectName);
539
+ const limit = parseInt(options.limit, 10) || 10;
540
+ const scans = await store.getScans(project.id, limit);
541
+ const snapshots = await store.getSnapshots(project.id, limit);
542
+ spin.stop();
543
+ if (json) {
544
+ console.log(JSON.stringify({
545
+ project: {
546
+ id: project.id,
547
+ name: project.name,
548
+ },
549
+ scans: scans.map((s) => ({
550
+ id: s.id,
551
+ status: s.status,
552
+ sources: s.sources,
553
+ stats: s.stats,
554
+ startedAt: s.startedAt,
555
+ completedAt: s.completedAt,
556
+ })),
557
+ snapshots: snapshots.map((s) => ({
558
+ scanId: s.scanId,
559
+ componentCount: s.componentCount,
560
+ tokenCount: s.tokenCount,
561
+ driftCount: s.driftCount,
562
+ summary: s.summary,
563
+ createdAt: s.createdAt,
564
+ })),
565
+ }, null, 2));
566
+ store.close();
567
+ return;
568
+ }
569
+ header("Scan History");
570
+ newline();
571
+ keyValue("Project", project.name);
572
+ keyValue("Total scans", String(scans.length));
573
+ newline();
574
+ if (scans.length === 0) {
575
+ info("No scans recorded yet. Run " + chalk.cyan("buoy show all") + " to start tracking.");
576
+ store.close();
577
+ return;
578
+ }
579
+ // Display scans in a table format
580
+ console.log(chalk.dim("ID".padEnd(15)) +
581
+ chalk.dim("Status".padEnd(12)) +
582
+ chalk.dim("Components".padEnd(12)) +
583
+ chalk.dim("Drift".padEnd(8)) +
584
+ chalk.dim("Coverage".padEnd(10)) +
585
+ chalk.dim("Date"));
586
+ console.log(chalk.dim("─".repeat(70)));
587
+ for (const scan of scans) {
588
+ const snapshot = snapshots.find((s) => s.scanId === scan.id);
589
+ const statusColor = scan.status === "completed"
590
+ ? chalk.green
591
+ : scan.status === "failed"
592
+ ? chalk.red
593
+ : chalk.yellow;
594
+ const date = scan.completedAt || scan.startedAt || scan.createdAt;
595
+ const dateStr = date ? formatRelativeDate(date) : "—";
596
+ const compCount = snapshot?.componentCount ?? scan.stats?.componentCount ?? "—";
597
+ const driftCount = snapshot?.driftCount ?? scan.stats?.driftCount ?? "—";
598
+ const coverage = snapshot?.coverageScore != null
599
+ ? `${snapshot.coverageScore}%`
600
+ : "—";
601
+ console.log(chalk.cyan(scan.id.padEnd(15)) +
602
+ statusColor(scan.status.padEnd(12)) +
603
+ String(compCount).padEnd(12) +
604
+ String(driftCount).padEnd(8) +
605
+ coverage.padEnd(10) +
606
+ chalk.dim(dateStr));
607
+ if (options.verbose && snapshot) {
608
+ console.log(chalk.dim(" ") +
609
+ `Critical: ${snapshot.summary.critical}, ` +
610
+ `Warning: ${snapshot.summary.warning}, ` +
611
+ `Info: ${snapshot.summary.info}`);
612
+ if (snapshot.summary.frameworks?.length > 0) {
613
+ console.log(chalk.dim(" Frameworks: ") +
614
+ snapshot.summary.frameworks.join(", "));
615
+ }
616
+ }
617
+ }
618
+ newline();
619
+ // Show trend summary
620
+ if (snapshots.length >= 2) {
621
+ const latest = snapshots[0];
622
+ const previous = snapshots[1];
623
+ const driftDelta = latest.driftCount - previous.driftCount;
624
+ const compDelta = latest.componentCount - previous.componentCount;
625
+ console.log(chalk.bold("Trend Summary"));
626
+ console.log(chalk.dim("─".repeat(30)));
627
+ if (driftDelta > 0) {
628
+ console.log(`Drift: ${chalk.red("+" + driftDelta)} since last scan`);
629
+ }
630
+ else if (driftDelta < 0) {
631
+ console.log(`Drift: ${chalk.green(driftDelta)} since last scan`);
632
+ }
633
+ else {
634
+ console.log(`Drift: ${chalk.dim("no change")}`);
635
+ }
636
+ if (compDelta > 0) {
637
+ console.log(`Components: ${chalk.green("+" + compDelta)} since last scan`);
638
+ }
639
+ else if (compDelta < 0) {
640
+ console.log(`Components: ${chalk.red(compDelta)} since last scan`);
641
+ }
642
+ else {
643
+ console.log(`Components: ${chalk.dim("no change")}`);
644
+ }
645
+ if (latest.coverageScore != null && previous.coverageScore != null) {
646
+ const covDelta = latest.coverageScore - previous.coverageScore;
647
+ if (covDelta > 0) {
648
+ console.log(`Coverage: ${chalk.green("+" + covDelta + "%")} since last scan`);
649
+ }
650
+ else if (covDelta < 0) {
651
+ console.log(`Coverage: ${chalk.red(covDelta + "%")} since last scan`);
652
+ }
653
+ else {
654
+ console.log(`Coverage: ${chalk.dim("no change")}`);
655
+ }
656
+ }
657
+ }
658
+ store.close();
659
+ }
660
+ catch (storeErr) {
661
+ spin.stop();
662
+ store.close();
663
+ const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
664
+ error(`Failed to load history: ${msg}`);
665
+ info("Run " + chalk.cyan("buoy show all") + " first to start tracking history.");
666
+ process.exit(1);
667
+ }
288
668
  }
289
669
  catch (err) {
290
670
  error(err instanceof Error ? err.message : String(err));
291
671
  process.exit(1);
292
672
  }
293
673
  });
674
+ // show history compare <scan1> <scan2>
675
+ historyCmd
676
+ .command("compare <scan1> <scan2>")
677
+ .description("Compare two scans")
678
+ .option("--json", "Output as JSON")
679
+ .action(async (scan1, scan2, options) => {
680
+ if (options.json) {
681
+ setJsonMode(true);
682
+ }
683
+ const spin = spinner("Comparing scans...");
684
+ try {
685
+ const { createStore } = await import("../store/index.js");
686
+ const store = createStore({ forceLocal: true });
687
+ const diff = await store.compareScan(scan1, scan2);
688
+ spin.stop();
689
+ if (options.json) {
690
+ console.log(JSON.stringify(diff, null, 2));
691
+ store.close();
692
+ return;
693
+ }
694
+ header(`Comparing ${scan1} → ${scan2}`);
695
+ newline();
696
+ console.log(chalk.bold("Components"));
697
+ console.log(` ${chalk.green("Added:")} ${diff.added.components.length} ` +
698
+ `${chalk.red("Removed:")} ${diff.removed.components.length} ` +
699
+ `${chalk.yellow("Modified:")} ${diff.modified.components.length}`);
700
+ if (diff.added.components.length > 0) {
701
+ console.log(chalk.green(" + ") + diff.added.components.map((c) => c.name).join(", "));
702
+ }
703
+ if (diff.removed.components.length > 0) {
704
+ console.log(chalk.red(" - ") + diff.removed.components.map((c) => c.name).join(", "));
705
+ }
706
+ newline();
707
+ console.log(chalk.bold("Tokens"));
708
+ console.log(` ${chalk.green("Added:")} ${diff.added.tokens.length} ` +
709
+ `${chalk.red("Removed:")} ${diff.removed.tokens.length} ` +
710
+ `${chalk.yellow("Modified:")} ${diff.modified.tokens.length}`);
711
+ newline();
712
+ console.log(chalk.bold("Drift Signals"));
713
+ console.log(` ${chalk.green("New:")} ${diff.added.drifts.length} ` +
714
+ `${chalk.red("Resolved:")} ${diff.removed.drifts.length}`);
715
+ if (diff.added.drifts.length > 0) {
716
+ newline();
717
+ console.log(chalk.yellow("New drift signals:"));
718
+ for (const d of diff.added.drifts.slice(0, 5)) {
719
+ console.log(` ${d.severity === "critical" ? chalk.red("!") : chalk.yellow("~")} ${d.message}`);
720
+ }
721
+ if (diff.added.drifts.length > 5) {
722
+ console.log(chalk.dim(` ... and ${diff.added.drifts.length - 5} more`));
723
+ }
724
+ }
725
+ newline();
726
+ console.log(chalk.bold("Summary"));
727
+ if (diff.removed.drifts.length > 0) {
728
+ console.log(` ${chalk.green("\u2193")} ${diff.removed.drifts.length} issues resolved`);
729
+ }
730
+ if (diff.added.drifts.length > 0) {
731
+ console.log(` ${chalk.red("\u2191")} ${diff.added.drifts.length} new issues introduced`);
732
+ }
733
+ store.close();
734
+ }
735
+ catch (err) {
736
+ spin.stop();
737
+ const message = err instanceof Error ? err.message : String(err);
738
+ error(`Compare failed: ${message}`);
739
+ process.exit(1);
740
+ }
741
+ });
294
742
  // show all
295
743
  cmd
296
744
  .command("all")
@@ -451,4 +899,148 @@ function mapCategory(property) {
451
899
  return "spacing";
452
900
  return "spacing";
453
901
  }
902
+ function getSummary(drifts) {
903
+ return {
904
+ critical: drifts.filter((d) => d.severity === "critical").length,
905
+ warning: drifts.filter((d) => d.severity === "warning").length,
906
+ info: drifts.filter((d) => d.severity === "info").length,
907
+ };
908
+ }
909
+ function printHealthReport(report) {
910
+ newline();
911
+ header("Design System Health Report");
912
+ newline();
913
+ const scoreColor = report.score >= 80 ? chalk.green :
914
+ report.score >= 50 ? chalk.yellow :
915
+ chalk.red;
916
+ const scoreLabel = report.score >= 80 ? "Good" :
917
+ report.score >= 50 ? "Fair" :
918
+ report.score < 30 ? "Poor" : "Needs Work";
919
+ console.log(`Overall Score: ${scoreColor.bold(`${report.score}/100`)} (${scoreLabel})`);
920
+ newline();
921
+ // Category breakdown
922
+ for (const [category, stats] of Object.entries(report.categories)) {
923
+ const expected = getExpectedCount(category);
924
+ const drift = stats.uniqueCount - expected;
925
+ const driftColor = drift > 5 ? chalk.red : drift > 0 ? chalk.yellow : chalk.green;
926
+ console.log(chalk.bold(capitalize(category)));
927
+ keyValue(" Found", `${stats.uniqueCount} unique values`);
928
+ keyValue(" Expected", `~${expected}`);
929
+ if (drift > 0) {
930
+ keyValue(" Drift", driftColor(`+${drift} extra values`));
931
+ }
932
+ if (stats.mostCommon.length > 0) {
933
+ console.log(chalk.dim(" Most common:"));
934
+ for (const { value, count } of stats.mostCommon.slice(0, 3)) {
935
+ console.log(chalk.dim(` ${value} (${count} usages)`));
936
+ }
937
+ }
938
+ newline();
939
+ }
940
+ // Close matches (typos)
941
+ if (report.closeMatches.length > 0) {
942
+ console.log(chalk.bold.yellow("Possible Typos"));
943
+ for (const match of report.closeMatches.slice(0, 5)) {
944
+ console.log(` ${chalk.yellow("⚠")} ${match.value} → close to ${chalk.cyan(match.closeTo)}`);
945
+ }
946
+ if (report.closeMatches.length > 5) {
947
+ console.log(chalk.dim(` ... and ${report.closeMatches.length - 5} more`));
948
+ }
949
+ newline();
950
+ }
951
+ // Worst files
952
+ if (report.worstFiles.length > 0) {
953
+ console.log(chalk.bold("Worst Offenders"));
954
+ for (const { file, issueCount } of report.worstFiles.slice(0, 5)) {
955
+ console.log(` ${chalk.red(issueCount.toString().padStart(3))} issues ${file}`);
956
+ }
957
+ newline();
958
+ }
959
+ // Summary
960
+ console.log(chalk.dim("─".repeat(50)));
961
+ keyValue("Total unique values", String(report.totals.uniqueValues));
962
+ keyValue("Total usages", String(report.totals.totalUsages));
963
+ keyValue("Files affected", String(report.totals.filesAffected));
964
+ newline();
965
+ if (report.score < 50) {
966
+ console.log(chalk.yellow("Run `buoy show drift` for detailed fixes."));
967
+ }
968
+ // Show upgrade hint after health score
969
+ const hint = formatUpgradeHint("after-health-score");
970
+ if (hint) {
971
+ console.log(hint);
972
+ console.log("");
973
+ }
974
+ }
975
+ function extractTokenValues(tokenData, _category) {
976
+ const values = [];
977
+ function traverse(obj) {
978
+ if (typeof obj !== "object" || obj === null)
979
+ return;
980
+ for (const [key, value] of Object.entries(obj)) {
981
+ if (key === "$value" || key === "value") {
982
+ if (typeof value === "string") {
983
+ values.push(value);
984
+ }
985
+ }
986
+ else if (typeof value === "object") {
987
+ traverse(value);
988
+ }
989
+ }
990
+ }
991
+ traverse(tokenData);
992
+ return values;
993
+ }
994
+ function getExpectedCount(category) {
995
+ const expected = {
996
+ color: 12,
997
+ spacing: 8,
998
+ typography: 6,
999
+ radius: 4,
1000
+ };
1001
+ return expected[category] || 10;
1002
+ }
1003
+ function capitalize(str) {
1004
+ return str.charAt(0).toUpperCase() + str.slice(1);
1005
+ }
1006
+ function fuzzyScore(query, target) {
1007
+ const q = query.toLowerCase();
1008
+ const t = target.toLowerCase();
1009
+ if (q === t)
1010
+ return 100;
1011
+ if (t.includes(q)) {
1012
+ const bonus = q.length / t.length * 50;
1013
+ return 70 + bonus;
1014
+ }
1015
+ const queryWords = q.split(/[-_\s]+/);
1016
+ const targetWords = t.split(/[-_\s]+/);
1017
+ const matchedWords = queryWords.filter(qw => targetWords.some(tw => tw.includes(qw) || qw.includes(tw)));
1018
+ if (matchedWords.length > 0) {
1019
+ return 50 + (matchedWords.length / queryWords.length * 30);
1020
+ }
1021
+ return 0;
1022
+ }
1023
+ function formatRelativeDate(date) {
1024
+ const now = new Date();
1025
+ const diff = now.getTime() - date.getTime();
1026
+ const seconds = Math.floor(diff / 1000);
1027
+ const minutes = Math.floor(seconds / 60);
1028
+ const hours = Math.floor(minutes / 60);
1029
+ const days = Math.floor(hours / 24);
1030
+ if (days > 7) {
1031
+ return date.toLocaleDateString();
1032
+ }
1033
+ else if (days > 0) {
1034
+ return `${days}d ago`;
1035
+ }
1036
+ else if (hours > 0) {
1037
+ return `${hours}h ago`;
1038
+ }
1039
+ else if (minutes > 0) {
1040
+ return `${minutes}m ago`;
1041
+ }
1042
+ else {
1043
+ return "just now";
1044
+ }
1045
+ }
454
1046
  //# sourceMappingURL=show.js.map