@alint-js/cli 0.0.36 → 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-Ct9DDkxy.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.36";
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",
@@ -873,7 +877,7 @@ function createRuleGroups(state) {
873
877
  }
874
878
  function estimateEta(durationMs, terminal, planned) {
875
879
  if (terminal <= 0 || planned <= 0 || durationMs <= 0) return "eta ?";
876
- return `eta ~${formatDuration$1(durationMs * (planned - terminal) / terminal)}`;
880
+ return `eta ~${formatDuration(durationMs * (planned - terminal) / terminal)}`;
877
881
  }
878
882
  function fitRow(row, columns) {
879
883
  if (columns <= 0) return "";
@@ -927,7 +931,7 @@ function formatContentRows(groups, state, options, now, contentBudget) {
927
931
  }
928
932
  return rows;
929
933
  }
930
- function formatDuration$1(ms) {
934
+ function formatDuration(ms) {
931
935
  return `${(Math.max(ms, 0) / 1e3).toFixed(1)}s`;
932
936
  }
933
937
  function formatFooters(state, options, now) {
@@ -937,7 +941,7 @@ function formatFooters(state, options, now) {
937
941
  const eta = estimateEta(elapsed, terminal, state.execution.planned);
938
942
  const projectedTokens = terminal > 0 && state.execution.planned > 0 ? Math.ceil((state.totalTokens + state.cachedTokens) * state.execution.planned / terminal) : void 0;
939
943
  return [
940
- fitRow(`${terminal}/${state.execution.planned}${progressBar} ${formatDuration$1(elapsed)} -> ${eta === "eta ?" ? "~?" : eta.replace("eta ", "")}`, options.columns),
944
+ fitRow(`${terminal}/${state.execution.planned}${progressBar} ${formatDuration(elapsed)} -> ${eta === "eta ?" ? "~?" : eta.replace("eta ", "")}`, options.columns),
941
945
  fitRow(`${state.execution.running} concurrency / ${state.execution.queued} queued / ${state.execution.cached} cached / ${state.execution.failed} failed`, options.columns),
942
946
  fitRow(`${state.totalTokens.toLocaleString("en-US")} tokens (${state.cachedTokens.toLocaleString("en-US")} cached) -> ${projectedTokens === void 0 ? "~?" : `~${projectedTokens.toLocaleString("en-US")} tokens`}`, options.columns)
943
947
  ];
@@ -965,7 +969,7 @@ function formatTargetRow(jobState, options, now, last) {
965
969
  const inputPath = options.cwd ? relative$1(options.cwd, jobState.job.inputPath) || "." : jobState.job.inputPath;
966
970
  const target = jobState.job.target.name ? `${jobState.job.target.kind} ${jobState.job.target.name}` : jobState.job.target.kind;
967
971
  const startedAt = jobState.retry?.startedAt ?? jobState.startedAt ?? now;
968
- return fitRow(`${last ? " └─" : " ├─"} ${inputPath} > ${target} ${jobState.retry ? `${jobState.retry.attempt}/${jobState.retry.maxAttempts} retrying elapsed ${formatDuration$1(now - startedAt)}` : `running ${formatDuration$1(now - startedAt)}`}`, options.columns);
972
+ return fitRow(`${last ? " └─" : " ├─"} ${inputPath} > ${target} ${jobState.retry ? `${jobState.retry.attempt}/${jobState.retry.maxAttempts} retrying elapsed ${formatDuration(now - startedAt)}` : `running ${formatDuration(now - startedAt)}`}`, options.columns);
969
973
  }
970
974
  function styleRow(row, state, warnCount, errorCount, options) {
971
975
  let styledRow = row;
@@ -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
  }
@@ -1435,6 +1464,44 @@ function accumulate(rows, key, inTok, outTok, totalTok, countRun) {
1435
1464
  rows.set(key, row);
1436
1465
  return row;
1437
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";
1504
+ }
1438
1505
  function isEnoent(error) {
1439
1506
  return error instanceof Error && error.code === "ENOENT";
1440
1507
  }
@@ -1442,6 +1509,13 @@ function monthIndexOf(ts) {
1442
1509
  const date = new Date(ts);
1443
1510
  return date.getUTCFullYear() * 12 + date.getUTCMonth();
1444
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
+ }
1445
1519
  function parseRunLine(line) {
1446
1520
  try {
1447
1521
  const value = JSON.parse(line);
@@ -1460,42 +1534,84 @@ async function prune(dir, ts, retentionMonths) {
1460
1534
  async function query(dir, filter, now) {
1461
1535
  const dimension = filter.by ?? "model";
1462
1536
  const since = parseSince(filter.since, now());
1537
+ const rules = ruleSet(filter.rules);
1463
1538
  const runs = await readRuns(dir);
1464
1539
  const rows = /* @__PURE__ */ new Map();
1465
1540
  let totalRuns = 0;
1466
1541
  let totalIn = 0;
1467
1542
  let totalOut = 0;
1468
1543
  let totalTok = 0;
1469
- let totalDuration;
1470
1544
  for (const run of runs) {
1471
- if (since !== void 0 && run.ts < since) continue;
1472
- 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;
1473
1548
  totalRuns += 1;
1474
- totalIn += run.usage.inTok;
1475
- totalOut += run.usage.outTok;
1476
- totalTok += run.usage.totalTok;
1549
+ totalIn += slice.inTok;
1550
+ totalOut += slice.outTok;
1551
+ totalTok += slice.totalTok;
1477
1552
  if (dimension === "dir") {
1478
- 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);
1479
1554
  continue;
1480
1555
  }
1481
1556
  const seenKeys = /* @__PURE__ */ new Set();
1482
- for (const usageRecord of run.usage.records) {
1557
+ for (const usageRecord of slice.usageRecords) {
1483
1558
  const key = recordKey(usageRecord, dimension);
1484
1559
  accumulate(rows, key, usageRecord.inTok, usageRecord.outTok, usageRecord.totalTok, !seenKeys.has(key));
1485
1560
  seenKeys.add(key);
1486
1561
  }
1487
- if (dimension !== "rule") continue;
1488
- for (const { durationMs, ruleId } of run.ruleDurations ?? []) {
1489
- const row = accumulate(rows, ruleId, 0, 0, 0, !seenKeys.has(ruleId));
1490
- seenKeys.add(ruleId);
1491
- row.durationMs = (row.durationMs ?? 0) + durationMs;
1492
- totalDuration = (totalDuration ?? 0) + durationMs;
1493
- }
1494
1562
  }
1495
1563
  return {
1496
1564
  dimension,
1497
1565
  rows: [...rows.values()].sort((left, right) => right.totalTok - left.totalTok),
1498
- totalDuration,
1566
+ totalIn,
1567
+ totalOut,
1568
+ totalRuns,
1569
+ totalTok
1570
+ };
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,
1499
1615
  totalIn,
1500
1616
  totalOut,
1501
1617
  totalRuns,
@@ -1529,6 +1645,29 @@ function recordKey(record, dimension) {
1529
1645
  if (dimension === "operation") return record.operation ?? record.ruleId;
1530
1646
  return `${record.providerId}/${record.modelId}`;
1531
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
+ }
1532
1671
  function statsFileName(ts) {
1533
1672
  const date = new Date(ts);
1534
1673
  return `stats-${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}.jsonl`;
@@ -1549,6 +1688,11 @@ async function statsFiles(dir) {
1549
1688
  } : void 0;
1550
1689
  }).filter((entry) => entry !== void 0).sort((left, right) => left.month - right.month);
1551
1690
  }
1691
+ function sumBy(items, select) {
1692
+ let total = 0;
1693
+ for (const item of items) total += select(item);
1694
+ return total;
1695
+ }
1552
1696
  //#endregion
1553
1697
  //#region src/cli/stats/record.ts
1554
1698
  function createRunStat(input) {
@@ -1556,7 +1700,6 @@ function createRunStat(input) {
1556
1700
  cwd: input.cwd,
1557
1701
  durationMs: input.durationMs,
1558
1702
  ruleCounts: input.ruleCounts,
1559
- ruleDurations: input.ruleDurations,
1560
1703
  usage: {
1561
1704
  inTok: input.usage.inputTokens,
1562
1705
  outTok: input.usage.outputTokens,
@@ -1594,16 +1737,10 @@ function createStatsCollector() {
1594
1737
  };
1595
1738
  let startedAt;
1596
1739
  let endedAt;
1597
- const ruleMs = /* @__PURE__ */ new Map();
1598
1740
  return {
1599
1741
  counts,
1600
1742
  durationMs: () => startedAt !== void 0 && endedAt !== void 0 ? endedAt - startedAt : void 0,
1601
1743
  reporter: {
1602
- onJobEnd: (payload) => {
1603
- if (payload.startedAt === void 0 || payload.endedAt === void 0) return;
1604
- const elapsed = Math.max(payload.endedAt - payload.startedAt, 0);
1605
- ruleMs.set(payload.job.ruleId, (ruleMs.get(payload.job.ruleId) ?? 0) + elapsed);
1606
- },
1607
1744
  onRunEnd: (payload) => {
1608
1745
  counts.cached = payload.execution.cached;
1609
1746
  counts.cancelled = payload.execution.cancelled;
@@ -1615,11 +1752,7 @@ function createStatsCollector() {
1615
1752
  onRunStart: (payload) => {
1616
1753
  startedAt = payload.startedAt;
1617
1754
  }
1618
- },
1619
- ruleDurations: () => ruleMs.size === 0 ? void 0 : [...ruleMs].map(([ruleId, durationMs]) => ({
1620
- durationMs,
1621
- ruleId
1622
- }))
1755
+ }
1623
1756
  };
1624
1757
  }
1625
1758
  function mergeProgressReporters(base, extra) {
@@ -1700,7 +1833,6 @@ async function writeRunStats(target, collector, result, cwd) {
1700
1833
  failed: result.execution.failed,
1701
1834
  planned: result.execution.planned
1702
1835
  },
1703
- ruleDurations: collector.ruleDurations(),
1704
1836
  usage: result.usage
1705
1837
  }));
1706
1838
  } catch {}
@@ -2354,25 +2486,21 @@ const DIMENSION_LABEL = {
2354
2486
  operation: "operation",
2355
2487
  rule: "rule"
2356
2488
  };
2357
- const METRIC_LABEL = {
2358
- duration: "time",
2359
- runs: "runs",
2360
- tokens: "total"
2361
- };
2362
- function formatMetric(row, metric) {
2363
- if (metric === "runs") return String(row.runs);
2364
- if (metric === "duration") return formatDuration(row.durationMs);
2365
- return formatTokens(row.totalTok);
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);
2366
2494
  }
2367
2495
  function formatStatsAggregate(aggregate, options = {}) {
2368
2496
  const paint = (text, style) => options.color === true ? style(text) : text;
2369
2497
  const metric = options.metric ?? "tokens";
2370
- const lines = [summaryLine(aggregate, paint), ""];
2498
+ const exact = options.exact === true;
2499
+ const lines = [summaryLine(aggregate, paint, exact), ""];
2371
2500
  if (aggregate.rows.length === 0) {
2372
2501
  lines.push("No stats recorded yet.");
2373
2502
  return `${lines.join("\n")}\n`;
2374
2503
  }
2375
- const withTime = aggregate.dimension === "rule";
2376
2504
  const header = [
2377
2505
  DIMENSION_LABEL[aggregate.dimension],
2378
2506
  "runs",
@@ -2380,18 +2508,13 @@ function formatStatsAggregate(aggregate, options = {}) {
2380
2508
  "output",
2381
2509
  "total"
2382
2510
  ];
2383
- if (withTime) header.push("time");
2384
- const rows = sortRows(aggregate.rows, metric).map((row) => {
2385
- const cells = [
2386
- row.key,
2387
- String(row.runs),
2388
- formatTokens(row.inTok),
2389
- formatTokens(row.outTok),
2390
- formatTokens(row.totalTok)
2391
- ];
2392
- if (withTime) cells.push(formatDuration(row.durationMs));
2393
- return cells;
2394
- });
2511
+ const rows = sortRows(aggregate.rows, metric).map((row) => [
2512
+ row.key,
2513
+ String(row.runs),
2514
+ formatTokens(row.inTok, exact),
2515
+ formatTokens(row.outTok, exact),
2516
+ formatTokens(row.totalTok, exact)
2517
+ ]);
2395
2518
  const widths = header.map((cell, index) => Math.max(cell.length, ...rows.map((row) => row[index].length)));
2396
2519
  const renderRow = (cells, isHeader) => cells.map((cell, index) => {
2397
2520
  const aligned = index === 0 ? cell.padEnd(widths[index]) : cell.padStart(widths[index]);
@@ -2400,30 +2523,39 @@ function formatStatsAggregate(aggregate, options = {}) {
2400
2523
  lines.push(renderRow(header, true), ...rows.map((row) => renderRow(row, false)));
2401
2524
  return `${lines.join("\n")}\n`;
2402
2525
  }
2403
- function formatTokens(value) {
2404
- 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`;
2405
2531
  }
2406
2532
  function metricValue(row, metric) {
2407
- if (metric === "runs") return row.runs;
2408
- if (metric === "duration") return row.durationMs ?? 0;
2409
- return row.totalTok;
2533
+ return metric === "runs" ? row.runs : row.totalTok;
2410
2534
  }
2411
2535
  function sortRows(rows, metric) {
2412
2536
  return [...rows].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
2413
2537
  }
2414
- function summaryLine(aggregate, paint) {
2415
- const runLabel = aggregate.totalRuns === 1 ? "run" : "runs";
2416
- const time = aggregate.totalDuration === void 0 ? "" : ` / ${formatDuration(aggregate.totalDuration)} time`;
2417
- return `${aggregate.totalRuns} ${runLabel} — ${formatTokens(aggregate.totalIn)} in / ${formatTokens(aggregate.totalOut)} out / ${paint(`${formatTokens(aggregate.totalTok)} total`, colors.cyan)}${time}`;
2418
- }
2419
- function formatDuration(ms) {
2420
- return ms === void 0 ? "—" : `${(Math.max(ms, 0) / 1e3).toFixed(1)}s`;
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)}`;
2421
2541
  }
2422
2542
  //#endregion
2423
- //#region src/cli/commands/stats/chart.ts
2424
- const BAR_WIDTH = 32;
2425
- const FULL_BLOCK = "█";
2426
- 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 = [
2427
2559
  "",
2428
2560
  "▏",
2429
2561
  "▎",
@@ -2433,36 +2565,143 @@ const PARTIAL_BLOCKS = [
2433
2565
  "▊",
2434
2566
  "▉"
2435
2567
  ];
2436
- 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 = {}) {
2437
2588
  const paint = (text, style) => options.color === true ? style(text) : text;
2438
2589
  const metric = options.metric ?? "tokens";
2439
- const lines = [summaryLine(aggregate, paint), ""];
2440
- if (aggregate.rows.length === 0) {
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) {
2441
2603
  lines.push("No stats recorded yet.");
2442
2604
  return `${lines.join("\n")}\n`;
2443
2605
  }
2444
- const rows = sortRows(aggregate.rows, metric);
2445
- const maxValue = Math.max(...rows.map((row) => metricValue(row, metric)));
2446
- const columnTotal = rows.reduce((sum, row) => sum + metricValue(row, metric), 0);
2447
- const label = DIMENSION_LABEL[aggregate.dimension];
2448
- const valueLabel = METRIC_LABEL[metric];
2449
- const keyWidth = Math.max(label.length, ...rows.map((row) => row.key.length));
2450
- const values = rows.map((row) => formatMetric(row, metric));
2451
- const valueWidth = Math.max(valueLabel.length, ...values.map((value) => value.length));
2452
- lines.push(paint(`${label.padEnd(keyWidth)} ${" ".repeat(BAR_WIDTH)} ${valueLabel.padStart(valueWidth)} share`, colors.bold));
2453
- rows.forEach((row, index) => {
2454
- const value = metricValue(row, metric);
2455
- const bar = renderBar(maxValue === 0 ? 0 : value / maxValue);
2456
- const share = columnTotal === 0 ? 0 : value / columnTotal * 100;
2457
- 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)}`;
2458
2656
  });
2459
- return `${lines.join("\n")}\n`;
2460
2657
  }
2461
- function renderBar(fraction) {
2462
- 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));
2463
2660
  const full = Math.floor(eighths / 8);
2464
- const remainder = eighths % 8;
2465
- 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];
2466
2705
  }
2467
2706
  //#endregion
2468
2707
  //#region src/cli/commands/stats/stats.ts
@@ -2472,23 +2711,48 @@ const DIMENSIONS = /* @__PURE__ */ new Set([
2472
2711
  "operation",
2473
2712
  "rule"
2474
2713
  ]);
2475
- const METRICS = /* @__PURE__ */ new Set([
2476
- "duration",
2477
- "runs",
2478
- "tokens"
2714
+ const METRICS = /* @__PURE__ */ new Set(["runs", "tokens"]);
2715
+ const INTERVALS = /* @__PURE__ */ new Set([
2716
+ "day",
2717
+ "month",
2718
+ "week"
2479
2719
  ]);
2480
2720
  async function runStatsCommand(options, io) {
2481
2721
  try {
2482
- const dimension = parseDimension(options.by);
2483
2722
  const metric = parseMetric(options.metric);
2484
- if (metric === "duration" && dimension !== "rule") throw new Error("The duration metric is only available with --by rule.");
2485
- const [setupConfig, config] = await Promise.all([loadMergedSetupConfig(io), loadAlintConfig(io.cwd)]);
2486
- const aggregate = await createJsonlStatsStore({ dir: statsLocation(resolveConfigRunner(config)?.stats) ?? statsLocation(setupConfig.runner?.stats) ?? getStatsDir(io.env) }).query({
2487
- by: dimension,
2488
- cwd: options.here === true ? io.cwd : options.cwd,
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,
2489
2749
  since: options.since
2490
2750
  });
2491
- io.stdout.write(render(aggregate, options, metric, 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
+ }));
2492
2756
  return 0;
2493
2757
  } catch (error) {
2494
2758
  io.stderr.write(`${errorMessageFrom$1(error) ?? "Failed to read stats."}\n`);
@@ -2500,21 +2764,24 @@ function parseDimension(value) {
2500
2764
  if (DIMENSIONS.has(value)) return value;
2501
2765
  throw new Error(`Invalid --by "${value}": use rule, operation, model, or dir.`);
2502
2766
  }
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
+ }
2503
2772
  function parseMetric(value) {
2504
2773
  if (value === void 0) return "tokens";
2505
2774
  if (METRICS.has(value)) return value;
2506
- throw new Error(`Invalid --metric "${value}": use tokens, runs, or duration.`);
2775
+ throw new Error(`Invalid --metric "${value}": use tokens or runs.`);
2507
2776
  }
2508
- function render(aggregate, options, metric, color) {
2509
- if (options.json === true) return `${JSON.stringify(aggregate, null, 2)}\n`;
2510
- if (options.chart === true) return formatStatsChart(aggregate, {
2511
- color,
2512
- metric
2513
- });
2514
- return formatStatsAggregate(aggregate, {
2515
- color,
2516
- metric
2517
- });
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) });
2518
2785
  }
2519
2786
  function statsLocation(stats) {
2520
2787
  return typeof stats === "object" ? stats.location : void 0;
@@ -2532,11 +2799,31 @@ const commandTree = [
2532
2799
  name: "stats",
2533
2800
  options: [
2534
2801
  {
2535
- 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",
2536
2807
  flags: "--by <dimension>"
2537
2808
  },
2538
2809
  {
2539
- 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)",
2540
2827
  flags: "--since <time>"
2541
2828
  },
2542
2829
  {
@@ -2548,12 +2835,8 @@ const commandTree = [
2548
2835
  flags: "--here"
2549
2836
  },
2550
2837
  {
2551
- description: "Render grouped rows as a bar chart",
2552
- flags: "--chart"
2553
- },
2554
- {
2555
- description: "Rank by tokens, runs, or duration (duration needs --by rule)",
2556
- flags: "--metric <metric>"
2838
+ description: "Show full counts instead of compact k/M/B",
2839
+ flags: "--exact-numbers"
2557
2840
  },
2558
2841
  {
2559
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-Ct9DDkxy.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.36",
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.36",
31
- "@alint-js/config": "0.0.36"
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",