@alint-js/cli 0.0.35 → 0.0.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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as executeCli } from "../cli-CZbrV2gX.mjs";
2
+ import { t as executeCli } from "../cli-Dm3rTcjL.mjs";
3
3
  import process from "node:process";
4
4
  //#region src/bin/index.ts
5
5
  executeCli(process.argv, {
@@ -16,7 +16,7 @@ import Gitignore from "gitignore-fs";
16
16
  import { Minimatch, minimatch } from "minimatch";
17
17
  import { errorMessageFrom as errorMessageFrom$1 } from "@moeru/std";
18
18
  //#region package.json
19
- var version = "0.0.35";
19
+ var version = "0.0.37";
20
20
  //#endregion
21
21
  //#region src/cli/commands/command.ts
22
22
  function defineCommand(node) {
@@ -67,13 +67,14 @@ function formatCommandHelp(sections, nodes, argv, rootHelp) {
67
67
  body: commands.map((command) => ` ${command.pattern.padEnd(longestCommand)} ${command.description}`).join("\n"),
68
68
  title: "Commands"
69
69
  };
70
+ const optionSection = formatOptionsSection(node.options ?? []);
70
71
  const usageIndex = normalizedSections.findIndex((section) => section.title === "Usage");
71
- if (usageIndex === -1) return [commandSection, ...normalizedSections];
72
- return [
72
+ const withCommands = usageIndex === -1 ? [commandSection, ...normalizedSections] : [
73
73
  ...normalizedSections.slice(0, usageIndex + 1),
74
74
  commandSection,
75
75
  ...normalizedSections.slice(usageIndex + 1)
76
76
  ];
77
+ return optionSection ? [...withCommands, optionSection] : withCommands;
77
78
  }
78
79
  function formatExamples(examples) {
79
80
  return examples.map((example) => example.split("\n").map((line) => ` ${line}`).join("\n")).join("\n\n");
@@ -193,11 +194,14 @@ function shouldSkipOptionValue(arg) {
193
194
  "--config",
194
195
  "--cwd",
195
196
  "--format",
197
+ "--interval",
198
+ "--metric",
196
199
  "--model",
197
200
  "--provider-endpoint",
198
201
  "--provider-header",
199
202
  "--provider-id",
200
203
  "--provider-model",
204
+ "--rule",
201
205
  "--rule-concurrency",
202
206
  "--since",
203
207
  "--timeout-ms",
@@ -1386,20 +1390,29 @@ function isCi(env = process.env) {
1386
1390
  //#region src/cli/stats/since.ts
1387
1391
  /**
1388
1392
  * Parse a `--since` time into an inclusive lower-bound timestamp.
1389
- * This is a tiny helper only to meet the needs.
1390
1393
  *
1391
1394
  * Supported formats:
1392
- * - `Nd`/`Nh` — Now minus N days/hours.
1393
- * - `YYYY-MM`/`YYYY-MM-DD` — Start of that UTC month/day.
1395
+ * - `Nh`/`Nd`/`Nw`/`Nm`/`Ny` — now minus N hours/days/weeks/months/years.
1396
+ * Weeks, months, and years are fixed approximations (7/30/365 days). `m` is
1397
+ * months here, not minutes — a day is the finest window this tool tracks.
1398
+ * - `YYYY-MM`/`YYYY-MM-DD` — start of that UTC month/day.
1394
1399
  *
1395
1400
  * Edge cases:
1396
1401
  * - An empty time means "no lower bound".
1397
- * - An unrecognized time will cause an error to be thrown.
1402
+ * - An unrecognized time throws.
1398
1403
  */
1404
+ const HOUR_MS = 36e5;
1405
+ const UNIT_MS = {
1406
+ d: 24 * HOUR_MS,
1407
+ h: HOUR_MS,
1408
+ m: 720 * HOUR_MS,
1409
+ w: 168 * HOUR_MS,
1410
+ y: 365 * 24 * HOUR_MS
1411
+ };
1399
1412
  function parseSince(time, now) {
1400
1413
  if (time === void 0 || time === "") return;
1401
- const relative = /^(\d+)([dh])$/u.exec(time);
1402
- if (relative) return now - Number(relative[1]) * (relative[2] === "d" ? 864e5 : 36e5);
1414
+ const relative = /^(\d+)([dhmwy])$/u.exec(time);
1415
+ if (relative) return now - Number(relative[1]) * UNIT_MS[relative[2]];
1403
1416
  const calendar = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/u.exec(time);
1404
1417
  if (calendar) {
1405
1418
  const year = Number(calendar[1]);
@@ -1407,16 +1420,32 @@ function parseSince(time, now) {
1407
1420
  const day = calendar[3] === void 0 ? 1 : Number(calendar[3]);
1408
1421
  return Date.UTC(year, month - 1, day);
1409
1422
  }
1410
- throw new Error(`Invalid --since "${time}": use e.g. 7d, 24h, 2025-01, or 2025-01-15.`);
1423
+ throw new Error(`Invalid --since "${time}": use e.g. 24h, 7d, 2w, 3m, 1y, 2025-01, or 2025-01-15.`);
1411
1424
  }
1412
1425
  //#endregion
1413
1426
  //#region src/cli/stats/jsonl.ts
1414
1427
  const DEFAULT_RETENTION_MONTHS = 12;
1415
1428
  const STATS_FILE_PATTERN = /^stats-(\d{4})-(\d{2})\.jsonl$/u;
1429
+ const DAY_MS = 864e5;
1430
+ const MONTH_LABELS$1 = [
1431
+ "Jan",
1432
+ "Feb",
1433
+ "Mar",
1434
+ "Apr",
1435
+ "May",
1436
+ "Jun",
1437
+ "Jul",
1438
+ "Aug",
1439
+ "Sep",
1440
+ "Oct",
1441
+ "Nov",
1442
+ "Dec"
1443
+ ];
1416
1444
  function createJsonlStatsStore(options) {
1417
1445
  const retentionMonths = options.retentionMonths ?? DEFAULT_RETENTION_MONTHS;
1418
1446
  return {
1419
1447
  query: (filter) => query(options.dir, filter ?? {}, Date.now),
1448
+ querySeries: (filter) => querySeries(options.dir, filter ?? {}, Date.now),
1420
1449
  record: (input) => record(options.dir, input, Date.now(), retentionMonths)
1421
1450
  };
1422
1451
  }
@@ -1433,6 +1462,45 @@ function accumulate(rows, key, inTok, outTok, totalTok, countRun) {
1433
1462
  row.totalTok += totalTok;
1434
1463
  if (countRun) row.runs += 1;
1435
1464
  rows.set(key, row);
1465
+ return row;
1466
+ }
1467
+ function bucketLabel(startMs, interval) {
1468
+ const date = new Date(startMs);
1469
+ if (interval === "month") return MONTH_LABELS$1[date.getUTCMonth()];
1470
+ return `${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
1471
+ }
1472
+ function bucketStart(ts, interval) {
1473
+ const date = new Date(ts);
1474
+ if (interval === "month") return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
1475
+ const dayStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
1476
+ if (interval === "week") return dayStart - (new Date(dayStart).getUTCDay() + 6) % 7 * DAY_MS;
1477
+ return dayStart;
1478
+ }
1479
+ function chooseInterval(since, nowMs, timestamps) {
1480
+ if (since !== void 0) return intervalForDuration(nowMs - since);
1481
+ if (timestamps.length < 2) return "day";
1482
+ return intervalForDuration(Math.max(...timestamps) - Math.min(...timestamps));
1483
+ }
1484
+ function fillBuckets(buckets, interval) {
1485
+ if (buckets.size === 0) return [];
1486
+ const starts = [...buckets.keys()].sort((left, right) => left - right);
1487
+ const last = starts.at(-1);
1488
+ const filled = [];
1489
+ for (let startMs = starts[0]; startMs <= last; startMs = nextBucketStart(startMs, interval)) filled.push(buckets.get(startMs) ?? {
1490
+ inTok: 0,
1491
+ key: bucketLabel(startMs, interval),
1492
+ outTok: 0,
1493
+ runs: 0,
1494
+ startMs,
1495
+ totalTok: 0
1496
+ });
1497
+ return filled;
1498
+ }
1499
+ function intervalForDuration(ms) {
1500
+ const days = ms / DAY_MS;
1501
+ if (days <= 21) return "day";
1502
+ if (days <= 120) return "week";
1503
+ return "month";
1436
1504
  }
1437
1505
  function isEnoent(error) {
1438
1506
  return error instanceof Error && error.code === "ENOENT";
@@ -1441,6 +1509,13 @@ function monthIndexOf(ts) {
1441
1509
  const date = new Date(ts);
1442
1510
  return date.getUTCFullYear() * 12 + date.getUTCMonth();
1443
1511
  }
1512
+ function nextBucketStart(startMs, interval) {
1513
+ if (interval === "month") {
1514
+ const date = new Date(startMs);
1515
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
1516
+ }
1517
+ return startMs + (interval === "week" ? 7 * DAY_MS : DAY_MS);
1518
+ }
1444
1519
  function parseRunLine(line) {
1445
1520
  try {
1446
1521
  const value = JSON.parse(line);
@@ -1459,6 +1534,7 @@ async function prune(dir, ts, retentionMonths) {
1459
1534
  async function query(dir, filter, now) {
1460
1535
  const dimension = filter.by ?? "model";
1461
1536
  const since = parseSince(filter.since, now());
1537
+ const rules = ruleSet(filter.rules);
1462
1538
  const runs = await readRuns(dir);
1463
1539
  const rows = /* @__PURE__ */ new Map();
1464
1540
  let totalRuns = 0;
@@ -1466,18 +1542,19 @@ async function query(dir, filter, now) {
1466
1542
  let totalOut = 0;
1467
1543
  let totalTok = 0;
1468
1544
  for (const run of runs) {
1469
- if (since !== void 0 && run.ts < since) continue;
1470
- if (filter.cwd !== void 0 && run.cwd !== filter.cwd) continue;
1545
+ if (!runInWindow(run, since, filter.cwd)) continue;
1546
+ const slice = sliceRun(run, rules);
1547
+ if (!slice.touched) continue;
1471
1548
  totalRuns += 1;
1472
- totalIn += run.usage.inTok;
1473
- totalOut += run.usage.outTok;
1474
- totalTok += run.usage.totalTok;
1549
+ totalIn += slice.inTok;
1550
+ totalOut += slice.outTok;
1551
+ totalTok += slice.totalTok;
1475
1552
  if (dimension === "dir") {
1476
- accumulate(rows, run.cwd, run.usage.inTok, run.usage.outTok, run.usage.totalTok, true);
1553
+ accumulate(rows, run.cwd, slice.inTok, slice.outTok, slice.totalTok, true);
1477
1554
  continue;
1478
1555
  }
1479
1556
  const seenKeys = /* @__PURE__ */ new Set();
1480
- for (const usageRecord of run.usage.records) {
1557
+ for (const usageRecord of slice.usageRecords) {
1481
1558
  const key = recordKey(usageRecord, dimension);
1482
1559
  accumulate(rows, key, usageRecord.inTok, usageRecord.outTok, usageRecord.totalTok, !seenKeys.has(key));
1483
1560
  seenKeys.add(key);
@@ -1492,6 +1569,55 @@ async function query(dir, filter, now) {
1492
1569
  totalTok
1493
1570
  };
1494
1571
  }
1572
+ async function querySeries(dir, filter, now) {
1573
+ const nowMs = now();
1574
+ const since = parseSince(filter.since, nowMs);
1575
+ const rules = ruleSet(filter.rules);
1576
+ const runs = await readRuns(dir);
1577
+ const sliced = [];
1578
+ for (const run of runs) {
1579
+ if (!runInWindow(run, since, filter.cwd)) continue;
1580
+ const slice = sliceRun(run, rules);
1581
+ if (slice.touched) sliced.push({
1582
+ slice,
1583
+ ts: run.ts
1584
+ });
1585
+ }
1586
+ const interval = filter.interval ?? chooseInterval(since, nowMs, sliced.map((entry) => entry.ts));
1587
+ const buckets = /* @__PURE__ */ new Map();
1588
+ let totalRuns = 0;
1589
+ let totalIn = 0;
1590
+ let totalOut = 0;
1591
+ let totalTok = 0;
1592
+ for (const { slice, ts } of sliced) {
1593
+ const startMs = bucketStart(ts, interval);
1594
+ const bucket = buckets.get(startMs) ?? {
1595
+ inTok: 0,
1596
+ key: bucketLabel(startMs, interval),
1597
+ outTok: 0,
1598
+ runs: 0,
1599
+ startMs,
1600
+ totalTok: 0
1601
+ };
1602
+ bucket.runs += 1;
1603
+ bucket.inTok += slice.inTok;
1604
+ bucket.outTok += slice.outTok;
1605
+ bucket.totalTok += slice.totalTok;
1606
+ buckets.set(startMs, bucket);
1607
+ totalRuns += 1;
1608
+ totalIn += slice.inTok;
1609
+ totalOut += slice.outTok;
1610
+ totalTok += slice.totalTok;
1611
+ }
1612
+ return {
1613
+ buckets: fillBuckets(buckets, interval),
1614
+ interval,
1615
+ totalIn,
1616
+ totalOut,
1617
+ totalRuns,
1618
+ totalTok
1619
+ };
1620
+ }
1495
1621
  async function readRuns(dir) {
1496
1622
  const files = await statsFiles(dir);
1497
1623
  const runs = [];
@@ -1519,6 +1645,29 @@ function recordKey(record, dimension) {
1519
1645
  if (dimension === "operation") return record.operation ?? record.ruleId;
1520
1646
  return `${record.providerId}/${record.modelId}`;
1521
1647
  }
1648
+ function ruleSet(rules) {
1649
+ return rules && rules.length > 0 ? new Set(rules) : void 0;
1650
+ }
1651
+ function runInWindow(run, since, cwd) {
1652
+ return (since === void 0 || run.ts >= since) && (cwd === void 0 || run.cwd === cwd);
1653
+ }
1654
+ function sliceRun(run, rules) {
1655
+ if (rules === void 0) return {
1656
+ inTok: run.usage.inTok,
1657
+ outTok: run.usage.outTok,
1658
+ totalTok: run.usage.totalTok,
1659
+ touched: true,
1660
+ usageRecords: run.usage.records
1661
+ };
1662
+ const usageRecords = run.usage.records.filter((record) => rules.has(record.ruleId));
1663
+ return {
1664
+ inTok: sumBy(usageRecords, (record) => record.inTok),
1665
+ outTok: sumBy(usageRecords, (record) => record.outTok),
1666
+ totalTok: sumBy(usageRecords, (record) => record.totalTok),
1667
+ touched: usageRecords.length > 0,
1668
+ usageRecords
1669
+ };
1670
+ }
1522
1671
  function statsFileName(ts) {
1523
1672
  const date = new Date(ts);
1524
1673
  return `stats-${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}.jsonl`;
@@ -1539,6 +1688,11 @@ async function statsFiles(dir) {
1539
1688
  } : void 0;
1540
1689
  }).filter((entry) => entry !== void 0).sort((left, right) => left.month - right.month);
1541
1690
  }
1691
+ function sumBy(items, select) {
1692
+ let total = 0;
1693
+ for (const item of items) total += select(item);
1694
+ return total;
1695
+ }
1542
1696
  //#endregion
1543
1697
  //#region src/cli/stats/record.ts
1544
1698
  function createRunStat(input) {
@@ -2332,10 +2486,17 @@ const DIMENSION_LABEL = {
2332
2486
  operation: "operation",
2333
2487
  rule: "rule"
2334
2488
  };
2489
+ function formatMetric(row, metric, exact = false) {
2490
+ return formatMetricValue(metricValue(row, metric), metric, exact);
2491
+ }
2492
+ function formatMetricValue(value, metric, exact = false) {
2493
+ return metric === "runs" ? String(value) : formatTokens(value, exact);
2494
+ }
2335
2495
  function formatStatsAggregate(aggregate, options = {}) {
2336
2496
  const paint = (text, style) => options.color === true ? style(text) : text;
2337
- const runLabel = aggregate.totalRuns === 1 ? "run" : "runs";
2338
- const lines = [`${aggregate.totalRuns} ${runLabel} — ${formatTokens(aggregate.totalIn)} in / ${formatTokens(aggregate.totalOut)} out / ${paint(`${formatTokens(aggregate.totalTok)} total`, colors.cyan)}`, ""];
2497
+ const metric = options.metric ?? "tokens";
2498
+ const exact = options.exact === true;
2499
+ const lines = [summaryLine(aggregate, paint, exact), ""];
2339
2500
  if (aggregate.rows.length === 0) {
2340
2501
  lines.push("No stats recorded yet.");
2341
2502
  return `${lines.join("\n")}\n`;
@@ -2347,12 +2508,12 @@ function formatStatsAggregate(aggregate, options = {}) {
2347
2508
  "output",
2348
2509
  "total"
2349
2510
  ];
2350
- const rows = aggregate.rows.map((row) => [
2511
+ const rows = sortRows(aggregate.rows, metric).map((row) => [
2351
2512
  row.key,
2352
2513
  String(row.runs),
2353
- formatTokens(row.inTok),
2354
- formatTokens(row.outTok),
2355
- formatTokens(row.totalTok)
2514
+ formatTokens(row.inTok, exact),
2515
+ formatTokens(row.outTok, exact),
2516
+ formatTokens(row.totalTok, exact)
2356
2517
  ]);
2357
2518
  const widths = header.map((cell, index) => Math.max(cell.length, ...rows.map((row) => row[index].length)));
2358
2519
  const renderRow = (cells, isHeader) => cells.map((cell, index) => {
@@ -2362,14 +2523,39 @@ function formatStatsAggregate(aggregate, options = {}) {
2362
2523
  lines.push(renderRow(header, true), ...rows.map((row) => renderRow(row, false)));
2363
2524
  return `${lines.join("\n")}\n`;
2364
2525
  }
2365
- function formatTokens(value) {
2366
- return value.toLocaleString("en-US");
2526
+ function formatTokens(value, exact = false) {
2527
+ if (exact || value < 1e4) return value.toLocaleString("en-US");
2528
+ if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
2529
+ if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
2530
+ return `${(value / 1e3).toFixed(2)}k`;
2531
+ }
2532
+ function metricValue(row, metric) {
2533
+ return metric === "runs" ? row.runs : row.totalTok;
2534
+ }
2535
+ function sortRows(rows, metric) {
2536
+ return [...rows].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
2537
+ }
2538
+ function summaryLine(totals, paint, exact = false) {
2539
+ const runLabel = totals.totalRuns === 1 ? "run" : "runs";
2540
+ return `${totals.totalRuns} ${runLabel} — ${formatTokens(totals.totalIn, exact)} in / ${formatTokens(totals.totalOut, exact)} out / ${paint(`${formatTokens(totals.totalTok, exact)} total`, colors.cyan)}`;
2367
2541
  }
2368
2542
  //#endregion
2369
- //#region src/cli/commands/stats/chart.ts
2370
- const BAR_WIDTH = 32;
2371
- const FULL_BLOCK = "█";
2372
- const PARTIAL_BLOCKS = [
2543
+ //#region src/cli/commands/stats/timeline.ts
2544
+ const CHART_HEIGHT = 6;
2545
+ const BAR_GAP = 2;
2546
+ const COLUMN_STRIDE = 3;
2547
+ const VERTICAL_BLOCKS = [
2548
+ " ",
2549
+ "▁",
2550
+ "▂",
2551
+ "▃",
2552
+ "▄",
2553
+ "▅",
2554
+ "▆",
2555
+ "▇",
2556
+ "█"
2557
+ ];
2558
+ const HORIZONTAL_BLOCKS = [
2373
2559
  "",
2374
2560
  "▏",
2375
2561
  "▎",
@@ -2379,32 +2565,143 @@ const PARTIAL_BLOCKS = [
2379
2565
  "▊",
2380
2566
  "▉"
2381
2567
  ];
2382
- function formatStatsChart(aggregate, options = {}) {
2568
+ const FULL_BLOCK = "█";
2569
+ const HORIZONTAL_BAR_MIN = 8;
2570
+ const HORIZONTAL_BAR_MAX = 40;
2571
+ const SUMMARY_GAP = 3;
2572
+ const SUMMARY_MAX_COLUMNS = 4;
2573
+ const MONTH_LABELS = [
2574
+ "Jan",
2575
+ "Feb",
2576
+ "Mar",
2577
+ "Apr",
2578
+ "May",
2579
+ "Jun",
2580
+ "Jul",
2581
+ "Aug",
2582
+ "Sep",
2583
+ "Oct",
2584
+ "Nov",
2585
+ "Dec"
2586
+ ];
2587
+ function formatStatsTimeline(series, options = {}) {
2383
2588
  const paint = (text, style) => options.color === true ? style(text) : text;
2384
- const runLabel = aggregate.totalRuns === 1 ? "run" : "runs";
2385
- const lines = [`${aggregate.totalRuns} ${runLabel} — ${formatTokens(aggregate.totalIn)} in / ${formatTokens(aggregate.totalOut)} out / ${paint(`${formatTokens(aggregate.totalTok)} total`, colors.cyan)}`, ""];
2386
- if (aggregate.rows.length === 0) {
2589
+ const metric = options.metric ?? "tokens";
2590
+ const exact = options.exact === true;
2591
+ const ctx = {
2592
+ exact,
2593
+ interval: series.interval,
2594
+ metric,
2595
+ paint
2596
+ };
2597
+ const lines = [
2598
+ summaryLine(series, paint, exact),
2599
+ "",
2600
+ caption(series, metric, options.rules)
2601
+ ];
2602
+ if (series.buckets.length === 0) {
2387
2603
  lines.push("No stats recorded yet.");
2388
2604
  return `${lines.join("\n")}\n`;
2389
2605
  }
2390
- const maxTok = Math.max(...aggregate.rows.map((row) => row.totalTok));
2391
- const label = DIMENSION_LABEL[aggregate.dimension];
2392
- const keyWidth = Math.max(label.length, ...aggregate.rows.map((row) => row.key.length));
2393
- const values = aggregate.rows.map((row) => formatTokens(row.totalTok));
2394
- const valueWidth = Math.max(5, ...values.map((value) => value.length));
2395
- lines.push(paint(`${label.padEnd(keyWidth)} ${" ".repeat(BAR_WIDTH)} ${"total".padStart(valueWidth)} share`, colors.bold));
2396
- aggregate.rows.forEach((row, index) => {
2397
- const bar = renderBar(maxTok === 0 ? 0 : row.totalTok / maxTok);
2398
- const share = aggregate.totalTok === 0 ? 0 : row.totalTok / aggregate.totalTok * 100;
2399
- lines.push(`${row.key.padEnd(keyWidth)} ${paint(bar, colors.cyan)} ${values[index].padStart(valueWidth)} ${share.toFixed(1).padStart(5)}%`);
2606
+ const values = series.buckets.map((bucket) => metricValue(bucket, metric));
2607
+ const maxValue = Math.max(...values);
2608
+ const axisWidth = Math.max(formatMetricValue(maxValue, metric, exact).length, 1);
2609
+ const verticalWidth = axisWidth + 2 + series.buckets.length * COLUMN_STRIDE;
2610
+ const width = options.columns ?? 80;
2611
+ if (options.vertical === true && verticalWidth <= width) {
2612
+ const chart = renderVertical(series.buckets, values, maxValue, axisWidth, ctx);
2613
+ const list = summaryList(series.buckets, width, ctx);
2614
+ return `${[
2615
+ ...lines,
2616
+ ...chart,
2617
+ "",
2618
+ ...list
2619
+ ].join("\n")}\n`;
2620
+ }
2621
+ return `${[...lines, ...renderHorizontal(series.buckets, values, maxValue, width, ctx)].join("\n")}\n`;
2622
+ }
2623
+ function axisRows(buckets, interval, chartWidth) {
2624
+ if (interval === "month") return [endpointAxis(buckets, chartWidth)];
2625
+ const days = Array.from({ length: chartWidth + 4 }).fill(" ");
2626
+ const months = Array.from({ length: chartWidth + 4 }).fill(" ");
2627
+ buckets.forEach((bucket, index) => {
2628
+ const date = new Date(bucket.startMs);
2629
+ const at = BAR_GAP + index * COLUMN_STRIDE;
2630
+ const previous = index === 0 ? void 0 : new Date(buckets[index - 1].startMs);
2631
+ writeAt(days, at, String(date.getUTCDate()).padStart(2, "0"));
2632
+ if (startsNewMonth(previous, date)) writeAt(months, at, MONTH_LABELS[date.getUTCMonth()]);
2633
+ });
2634
+ return [days.join("").replace(/\s+$/u, ""), months.join("").replace(/\s+$/u, "")];
2635
+ }
2636
+ function caption(series, metric, rules) {
2637
+ const filter = rules && rules.length > 0 ? ` — ${rules.join(", ")}` : "";
2638
+ return `${metric} by ${series.interval}${filter}`;
2639
+ }
2640
+ function endpointAxis(buckets, chartWidth) {
2641
+ const first = buckets[0].key;
2642
+ if (buckets.length === 1) return `${" ".repeat(BAR_GAP)}${first}`;
2643
+ const last = buckets.at(-1).key;
2644
+ const lastStart = chartWidth - last.length;
2645
+ const firstEnd = BAR_GAP + first.length;
2646
+ return lastStart > firstEnd ? `${" ".repeat(BAR_GAP)}${first}${" ".repeat(lastStart - firstEnd)}${last}` : `${" ".repeat(BAR_GAP)}${first} … ${last}`;
2647
+ }
2648
+ function renderHorizontal(buckets, values, maxValue, width, ctx) {
2649
+ const labels = buckets.map((bucket) => formatMetric(bucket, ctx.metric, ctx.exact));
2650
+ const keyWidth = Math.max(...buckets.map((bucket) => bucket.key.length));
2651
+ const valueWidth = Math.max(...labels.map((label) => label.length));
2652
+ const barWidth = Math.max(HORIZONTAL_BAR_MIN, Math.min(HORIZONTAL_BAR_MAX, width - keyWidth - valueWidth - 6));
2653
+ return buckets.map((bucket, index) => {
2654
+ const bar = renderHorizontalBar(maxValue === 0 ? 0 : values[index] / maxValue, barWidth);
2655
+ return `${bucket.key.padEnd(keyWidth)} ${ctx.paint(bar, colors.cyan)} ${labels[index].padStart(valueWidth)}`;
2400
2656
  });
2401
- return `${lines.join("\n")}\n`;
2402
2657
  }
2403
- function renderBar(fraction) {
2404
- const eighths = fraction <= 0 ? 0 : Math.max(1, Math.round(fraction * BAR_WIDTH * 8));
2658
+ function renderHorizontalBar(fraction, width) {
2659
+ const eighths = fraction <= 0 ? 0 : Math.max(1, Math.round(fraction * width * 8));
2405
2660
  const full = Math.floor(eighths / 8);
2406
- const remainder = eighths % 8;
2407
- return `${FULL_BLOCK.repeat(full)}${PARTIAL_BLOCKS[remainder]}`.padEnd(BAR_WIDTH);
2661
+ return `${FULL_BLOCK.repeat(full)}${HORIZONTAL_BLOCKS[eighths % 8]}`.padEnd(width);
2662
+ }
2663
+ function renderVertical(buckets, values, maxValue, axisWidth, ctx) {
2664
+ const columnAt = (index) => BAR_GAP + index * COLUMN_STRIDE;
2665
+ const chartWidth = columnAt(buckets.length - 1) + 1;
2666
+ const eighths = values.map((value) => {
2667
+ if (value <= 0 || maxValue === 0) return 0;
2668
+ return Math.max(1, Math.round(value / maxValue * CHART_HEIGHT * 8));
2669
+ });
2670
+ const gutter = " ".repeat(axisWidth);
2671
+ const lines = [];
2672
+ for (let row = CHART_HEIGHT - 1; row >= 0; row -= 1) {
2673
+ const cells = Array.from({ length: chartWidth }).fill(" ");
2674
+ eighths.forEach((total, index) => {
2675
+ cells[columnAt(index)] = VERTICAL_BLOCKS[Math.max(0, Math.min(8, total - row * 8))];
2676
+ });
2677
+ const label = row === CHART_HEIGHT - 1 ? formatMetricValue(maxValue, ctx.metric, ctx.exact).padStart(axisWidth) : gutter;
2678
+ const axis = row === CHART_HEIGHT - 1 ? "┤" : "│";
2679
+ lines.push(`${label} ${axis}${ctx.paint(cells.join("").replace(/\s+$/u, ""), colors.cyan)}`);
2680
+ }
2681
+ const baseline = Array.from({ length: chartWidth }).fill("─");
2682
+ buckets.forEach((_, index) => {
2683
+ baseline[columnAt(index)] = "┴";
2684
+ });
2685
+ lines.push(`${"0".padStart(axisWidth)} ┼${baseline.join("")}`);
2686
+ for (const labelRow of axisRows(buckets, ctx.interval, chartWidth)) lines.push(`${gutter} ${labelRow}`);
2687
+ return lines;
2688
+ }
2689
+ function startsNewMonth(previous, date) {
2690
+ if (previous === void 0) return true;
2691
+ return previous.getUTCFullYear() !== date.getUTCFullYear() || previous.getUTCMonth() !== date.getUTCMonth();
2692
+ }
2693
+ function summaryList(buckets, width, ctx) {
2694
+ const labels = buckets.map((bucket) => formatMetric(bucket, ctx.metric, ctx.exact));
2695
+ const keyWidth = Math.max(...buckets.map((bucket) => bucket.key.length));
2696
+ const valueWidth = Math.max(...labels.map((label) => label.length));
2697
+ const cellWidth = keyWidth + 2 + valueWidth;
2698
+ const perRow = Math.max(1, Math.min(SUMMARY_MAX_COLUMNS, Math.floor((width + SUMMARY_GAP) / (cellWidth + SUMMARY_GAP))));
2699
+ const rows = [];
2700
+ for (let start = 0; start < buckets.length; start += perRow) rows.push(buckets.slice(start, start + perRow).map((bucket, offset) => `${bucket.key.padEnd(keyWidth)} ${labels[start + offset].padStart(valueWidth)}`).join(" ".repeat(SUMMARY_GAP)));
2701
+ return rows;
2702
+ }
2703
+ function writeAt(cells, at, text) {
2704
+ for (let index = 0; index < text.length && at + index < cells.length; index += 1) cells[at + index] = text[index];
2408
2705
  }
2409
2706
  //#endregion
2410
2707
  //#region src/cli/commands/stats/stats.ts
@@ -2414,16 +2711,48 @@ const DIMENSIONS = /* @__PURE__ */ new Set([
2414
2711
  "operation",
2415
2712
  "rule"
2416
2713
  ]);
2714
+ const METRICS = /* @__PURE__ */ new Set(["runs", "tokens"]);
2715
+ const INTERVALS = /* @__PURE__ */ new Set([
2716
+ "day",
2717
+ "month",
2718
+ "week"
2719
+ ]);
2417
2720
  async function runStatsCommand(options, io) {
2418
2721
  try {
2419
- const dimension = parseDimension(options.by);
2420
- const [setupConfig, config] = await Promise.all([loadMergedSetupConfig(io), loadAlintConfig(io.cwd)]);
2421
- const aggregate = await createJsonlStatsStore({ dir: statsLocation(resolveConfigRunner(config)?.stats) ?? statsLocation(setupConfig.runner?.stats) ?? getStatsDir(io.env) }).query({
2422
- by: dimension,
2423
- cwd: options.here === true ? io.cwd : options.cwd,
2722
+ const metric = parseMetric(options.metric);
2723
+ const rules = parseRules(options.rule);
2724
+ const cwd = options.here === true ? io.cwd : options.cwd;
2725
+ const color = io.stdout.isTTY === true;
2726
+ const exact = options.exactNumbers === true;
2727
+ const store = await resolveStore(io);
2728
+ if (options.chart === true) {
2729
+ const series = await store.querySeries({
2730
+ cwd,
2731
+ interval: parseInterval(options.interval),
2732
+ rules,
2733
+ since: options.since
2734
+ });
2735
+ io.stdout.write(options.json === true ? `${JSON.stringify(series, null, 2)}\n` : formatStatsTimeline(series, {
2736
+ color,
2737
+ columns: io.stdout.columns,
2738
+ exact,
2739
+ metric,
2740
+ rules,
2741
+ vertical: options.vertical === true
2742
+ }));
2743
+ return 0;
2744
+ }
2745
+ const aggregate = await store.query({
2746
+ by: parseDimension(options.by),
2747
+ cwd,
2748
+ rules,
2424
2749
  since: options.since
2425
2750
  });
2426
- io.stdout.write(render(aggregate, options, io.stdout.isTTY === true));
2751
+ io.stdout.write(options.json === true ? `${JSON.stringify(aggregate, null, 2)}\n` : formatStatsAggregate(aggregate, {
2752
+ color,
2753
+ exact,
2754
+ metric
2755
+ }));
2427
2756
  return 0;
2428
2757
  } catch (error) {
2429
2758
  io.stderr.write(`${errorMessageFrom$1(error) ?? "Failed to read stats."}\n`);
@@ -2435,10 +2764,24 @@ function parseDimension(value) {
2435
2764
  if (DIMENSIONS.has(value)) return value;
2436
2765
  throw new Error(`Invalid --by "${value}": use rule, operation, model, or dir.`);
2437
2766
  }
2438
- function render(aggregate, options, color) {
2439
- if (options.json === true) return `${JSON.stringify(aggregate, null, 2)}\n`;
2440
- if (options.chart === true) return formatStatsChart(aggregate, { color });
2441
- return formatStatsAggregate(aggregate, { color });
2767
+ function parseInterval(value) {
2768
+ if (value === void 0) return;
2769
+ if (INTERVALS.has(value)) return value;
2770
+ throw new Error(`Invalid --interval "${value}": use day, week, or month.`);
2771
+ }
2772
+ function parseMetric(value) {
2773
+ if (value === void 0) return "tokens";
2774
+ if (METRICS.has(value)) return value;
2775
+ throw new Error(`Invalid --metric "${value}": use tokens or runs.`);
2776
+ }
2777
+ function parseRules(value) {
2778
+ if (value === void 0) return;
2779
+ const rules = value.split(",").map((rule) => rule.trim()).filter(Boolean);
2780
+ return rules.length > 0 ? rules : void 0;
2781
+ }
2782
+ async function resolveStore(io) {
2783
+ const [setupConfig, config] = await Promise.all([loadMergedSetupConfig(io), loadAlintConfig(io.cwd)]);
2784
+ return createJsonlStatsStore({ dir: statsLocation(resolveConfigRunner(config)?.stats) ?? statsLocation(setupConfig.runner?.stats) ?? getStatsDir(io.env) });
2442
2785
  }
2443
2786
  function statsLocation(stats) {
2444
2787
  return typeof stats === "object" ? stats.location : void 0;
@@ -2456,11 +2799,31 @@ const commandTree = [
2456
2799
  name: "stats",
2457
2800
  options: [
2458
2801
  {
2459
- description: "Group by rule, operation, model, or dir",
2802
+ description: "Render a usage timeline instead of the table",
2803
+ flags: "--chart"
2804
+ },
2805
+ {
2806
+ description: "Group the table by rule, operation, model, or dir",
2460
2807
  flags: "--by <dimension>"
2461
2808
  },
2462
2809
  {
2463
- description: "Only include runs since this time (e.g. 7d, 24h, 2025-01)",
2810
+ description: "Chart bucket: day, week, or month (default: auto)",
2811
+ flags: "--interval <interval>"
2812
+ },
2813
+ {
2814
+ description: "Draw the chart as vertical bars instead of horizontal",
2815
+ flags: "--vertical"
2816
+ },
2817
+ {
2818
+ description: "Rank/plot by tokens or runs",
2819
+ flags: "--metric <metric>"
2820
+ },
2821
+ {
2822
+ description: "Only include these rules (comma-separated)",
2823
+ flags: "--rule <ids>"
2824
+ },
2825
+ {
2826
+ description: "Only include runs since this time (e.g. 24h, 7d, 4w, 3m, 2025-01)",
2464
2827
  flags: "--since <time>"
2465
2828
  },
2466
2829
  {
@@ -2472,8 +2835,8 @@ const commandTree = [
2472
2835
  flags: "--here"
2473
2836
  },
2474
2837
  {
2475
- description: "Render grouped rows as a bar chart",
2476
- flags: "--chart"
2838
+ description: "Show full counts instead of compact k/M/B",
2839
+ flags: "--exact-numbers"
2477
2840
  },
2478
2841
  {
2479
2842
  description: "Output JSON",
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as formatJson, n as formatDiagnostics, r as formatStylish, t as executeCli } from "./cli-CZbrV2gX.mjs";
1
+ import { i as formatJson, n as formatDiagnostics, r as formatStylish, t as executeCli } from "./cli-Dm3rTcjL.mjs";
2
2
  import { ignorePatternsAIAgents, ignorePatternsBuildOutputs, ignorePatternsCaches, ignorePatternsCommon, ignorePatternsEslintDefaults, ignorePatternsGenerated } from "@alint-js/config";
3
3
  import { defineConfig } from "@alint-js/core";
4
4
  export { defineConfig, executeCli, formatDiagnostics, formatJson, formatStylish, ignorePatternsAIAgents, ignorePatternsBuildOutputs, ignorePatternsCaches, ignorePatternsCommon, ignorePatternsEslintDefaults, ignorePatternsGenerated };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alint-js/cli",
3
3
  "type": "module",
4
- "version": "0.0.35",
4
+ "version": "0.0.37",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.mts",
@@ -27,8 +27,8 @@
27
27
  "pathe": "^2.0.3",
28
28
  "table": "^6.9.0",
29
29
  "tinyrainbow": "^3.1.0",
30
- "@alint-js/core": "0.0.35",
31
- "@alint-js/config": "0.0.35"
30
+ "@alint-js/config": "0.0.37",
31
+ "@alint-js/core": "0.0.37"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@pnpm/find-workspace-dir": "^1000.1.5",