@alint-js/cli 0.0.8 → 0.0.10

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-D7cUTZnE.mjs";
2
+ import { t as executeCli } from "../cli-D4YC9Jnb.mjs";
3
3
  import process from "node:process";
4
4
  //#region src/bin/index.ts
5
5
  executeCli(process.argv, {
@@ -1,17 +1,17 @@
1
1
  import process from "node:process";
2
2
  import { inspect } from "node:util";
3
3
  import { cac } from "cac";
4
- import { getGlobalSetupConfigPath, getProjectSetupConfigPath, loadAlintConfig, loadSetupConfig, mergeSetupConfigs, writeSetupConfig } from "@alint-js/config";
4
+ import { getGlobalSetupConfigPath, getProjectSetupConfigPath, getStatsDir, loadAlintConfig, loadSetupConfig, mergeSetupConfigs, writeSetupConfig } from "@alint-js/config";
5
5
  import { AlintRunError, hasDiscoveryFilePatterns, matchesDiscoveryFile, normalizeConfig, resolveConfigForFile, runAlint } from "@alint-js/core";
6
- import { relative, resolve } from "pathe";
6
+ import { isAbsolute, join, relative, resolve } from "pathe";
7
7
  import { getBorderCharacters, table } from "table";
8
8
  import { errorMessageFrom } from "@moeru/std/error";
9
- import { readFile, readdir, stat } from "node:fs/promises";
9
+ import { appendFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
10
10
  import c, { createColors } from "tinyrainbow";
11
11
  import cliSpinners from "cli-spinners";
12
12
  import { relative as relative$1 } from "node:path";
13
13
  import Gitignore from "gitignore-fs";
14
- import { minimatch } from "minimatch";
14
+ import { Minimatch, minimatch } from "minimatch";
15
15
  import { errorMessageFrom as errorMessageFrom$1 } from "@moeru/std";
16
16
  //#region src/cli/commands/command.ts
17
17
  function defineCommand(node) {
@@ -183,8 +183,10 @@ function rewriteUsageSection(sections, path, node) {
183
183
  function shouldSkipOptionValue(arg) {
184
184
  if (arg.includes("=")) return false;
185
185
  return [
186
+ "--by",
186
187
  "--cache-location",
187
188
  "--config",
189
+ "--cwd",
188
190
  "--file-concurrency",
189
191
  "--format",
190
192
  "--model",
@@ -193,6 +195,7 @@ function shouldSkipOptionValue(arg) {
193
195
  "--provider-id",
194
196
  "--provider-model",
195
197
  "--rule-concurrency",
198
+ "--since",
196
199
  "--timeout-ms",
197
200
  "-f",
198
201
  "-l"
@@ -499,7 +502,7 @@ function formatJson(result) {
499
502
  }
500
503
  //#endregion
501
504
  //#region src/cli/reporters/stylish.ts
502
- const colors$1 = createColors({ force: true });
505
+ const colors$2 = createColors({ force: true });
503
506
  function formatStylish(input, options = {}) {
504
507
  const diagnostics = Array.isArray(input) ? input : input.diagnostics;
505
508
  const totalTokens = Array.isArray(input) ? void 0 : input.usage.totalTokens;
@@ -541,12 +544,12 @@ function createStyle(color) {
541
544
  warning: identity
542
545
  };
543
546
  return {
544
- error: colors$1.red,
545
- file: colors$1.underline,
546
- location: colors$1.dim,
547
- ruleId: colors$1.dim,
548
- summaryToken: colors$1.cyan,
549
- warning: colors$1.yellow
547
+ error: colors$2.red,
548
+ file: colors$2.underline,
549
+ location: colors$2.dim,
550
+ ruleId: colors$2.dim,
551
+ summaryToken: colors$2.cyan,
552
+ warning: colors$2.yellow
550
553
  };
551
554
  }
552
555
  function formatSummary(diagnostics, totalTokens, style) {
@@ -594,7 +597,7 @@ function countDiagnostics$1(diagnostics, severity) {
594
597
  }
595
598
  //#endregion
596
599
  //#region src/cli/reporters/progress/summary.ts
597
- const colors = createColors({ force: true });
600
+ const colors$1 = createColors({ force: true });
598
601
  function createSummaryProgressReporter(options) {
599
602
  const now = () => options.clock?.() ?? Date.now();
600
603
  const state = {
@@ -802,8 +805,8 @@ function replaceFirst(row, search, replacement) {
802
805
  function styleRow(row, state, warnCount, errorCount, options) {
803
806
  let styledRow = row;
804
807
  const spinner = options.spinnerFrames[state.spinnerIndex] ?? "";
805
- if (spinner) styledRow = replaceFirst(styledRow, spinner, colors.cyan(spinner));
806
- styledRow = styledRow.replace(/\|/g, colors.gray("|")).replace(`${warnCount} warn`, colors.yellow(`${warnCount} warn`)).replace(`${errorCount} error`, (errorCount > 0 ? colors.red : colors.gray)(`${errorCount} error`)).replace(/(\d+\/\d+\/\d+\/\d+)/, (match) => state.errored > 0 ? colors.red(match) : colors.gray(match)).replace(/(\d[\d,]* tokens)/g, (match) => colors.cyan(match));
808
+ if (spinner) styledRow = replaceFirst(styledRow, spinner, colors$1.cyan(spinner));
809
+ styledRow = styledRow.replace(/\|/g, colors$1.gray("|")).replace(`${warnCount} warn`, colors$1.yellow(`${warnCount} warn`)).replace(`${errorCount} error`, (errorCount > 0 ? colors$1.red : colors$1.gray)(`${errorCount} error`)).replace(/(\d+\/\d+\/\d+\/\d+)/, (match) => state.errored > 0 ? colors$1.red(match) : colors$1.gray(match)).replace(/(\d[\d,]* tokens)/g, (match) => colors$1.cyan(match));
807
810
  return styledRow;
808
811
  }
809
812
  //#endregion
@@ -933,9 +936,28 @@ function createRenderingProgressReporter(summary, renderer) {
933
936
  }
934
937
  //#endregion
935
938
  //#region src/cli/commands/lint/discovery.ts
936
- async function resolveLintFiles(files, config, cwd) {
939
+ const minimatchOptions = { dot: true };
940
+ var NoFilesFoundError = class extends Error {
941
+ globInputPaths;
942
+ pattern;
943
+ constructor(pattern, options) {
944
+ super(`No files matching "${pattern}" were found${options.globInputPaths ? "" : " (glob input is disabled)"}.`);
945
+ this.name = "NoFilesFoundError";
946
+ this.globInputPaths = options.globInputPaths;
947
+ this.pattern = pattern;
948
+ }
949
+ };
950
+ async function findFiles(options) {
951
+ const { config, cwd, errorOnUnmatchedPattern = true, globInputPaths = true, inputs } = options;
937
952
  const gitignore = shouldFilterGitignoredFiles(config) ? new Gitignore() : void 0;
938
- const candidates = files.length > 0 ? files : await discoverLintFiles(config, cwd, gitignore);
953
+ const candidates = await resolveInputFiles({
954
+ config,
955
+ cwd,
956
+ errorOnUnmatchedPattern,
957
+ gitignore,
958
+ globInputPaths,
959
+ inputs: inputs.length > 0 ? inputs : ["."]
960
+ });
939
961
  if (!gitignore || candidates.length === 0) return candidates;
940
962
  const lintFiles = [];
941
963
  for (const file of candidates) {
@@ -947,31 +969,122 @@ async function resolveLintFiles(files, config, cwd) {
947
969
  function collectGlobalIgnorePatterns(config) {
948
970
  return normalizeConfig(config).flatMap((item) => isGlobalIgnoreItem(item) ? [...item.ignores] : []);
949
971
  }
950
- async function discoverLintFiles(config, cwd, gitignore) {
951
- if (!hasDiscoveryFilePatterns(config)) return [];
952
- const candidates = (await walkFiles(cwd, {
953
- cwd,
954
- gitignore,
955
- ignoredPatterns: collectGlobalIgnorePatterns(config)
956
- })).map((file) => normalizeRelativePath(cwd, file)).filter((file) => matchesDiscoveryFile(file, config, { cwd }));
957
- return [...new Set(candidates)].sort();
972
+ function getGlobParent(input) {
973
+ const segments = input.replaceAll("\\", "/").split("/");
974
+ const parentSegments = [];
975
+ for (const segment of segments) {
976
+ if (isGlobPattern(segment)) break;
977
+ parentSegments.push(segment);
978
+ }
979
+ const parent = parentSegments.join("/");
980
+ return parent === "" ? "." : parent;
958
981
  }
959
982
  function isGlobalIgnoreItem(item) {
960
983
  const keys = Object.keys(item).filter((key) => item[key] !== void 0);
961
984
  return item.ignores !== void 0 && keys.every((key) => key === "ignores" || key === "name");
962
985
  }
986
+ function isGlobPattern(input) {
987
+ return new Minimatch(input, minimatchOptions).hasMagic();
988
+ }
989
+ function isNodeError$1(error) {
990
+ return error instanceof Error && "code" in error;
991
+ }
992
+ function matchesGlob(filePath, pattern) {
993
+ return minimatch(filePath, pattern.replaceAll("\\", "/"), minimatchOptions);
994
+ }
963
995
  function matchesIgnoredDirectory(relativePath, patterns) {
964
- return patterns.some((pattern) => minimatch(relativePath, pattern, { dot: true }) || minimatch(`${relativePath}/`, pattern, { dot: true }) || minimatch(`${relativePath}/__alint__`, pattern, { dot: true }));
996
+ return patterns.some((pattern) => minimatch(relativePath, pattern, minimatchOptions) || minimatch(`${relativePath}/`, pattern, minimatchOptions) || minimatch(`${relativePath}/__alint__`, pattern, minimatchOptions));
997
+ }
998
+ function normalizeGlobPattern(cwd, pattern) {
999
+ return isAbsolute(pattern) ? normalizeRelativePath(cwd, pattern) : pattern.replaceAll("\\", "/");
965
1000
  }
966
1001
  function normalizeRelativePath(cwd, filePath) {
967
1002
  return relative(cwd, filePath).replaceAll("\\", "/");
968
1003
  }
1004
+ async function resolveInputFiles(options) {
1005
+ const hasFilePatterns = hasDiscoveryFilePatterns(options.config);
1006
+ const ignoredPatterns = collectGlobalIgnorePatterns(options.config);
1007
+ const candidates = [];
1008
+ for (const input of options.inputs) {
1009
+ const path = resolve(options.cwd, input);
1010
+ const stats = await statPath(path);
1011
+ if (stats?.isFile()) {
1012
+ candidates.push(input);
1013
+ continue;
1014
+ }
1015
+ if (stats?.isDirectory()) {
1016
+ if (!hasFilePatterns) continue;
1017
+ if (await shouldPruneDirectory(path, {
1018
+ cwd: options.cwd,
1019
+ gitignore: options.gitignore,
1020
+ ignoredPatterns
1021
+ })) continue;
1022
+ const directoryFiles = (await walkFiles(path, {
1023
+ cwd: options.cwd,
1024
+ gitignore: options.gitignore,
1025
+ ignoredPatterns
1026
+ })).sort();
1027
+ for (const directoryFile of directoryFiles) {
1028
+ const relativePath = normalizeRelativePath(options.cwd, directoryFile);
1029
+ if (hasFilePatterns && matchesDiscoveryFile(relativePath, options.config, { cwd: options.cwd })) candidates.push(relativePath);
1030
+ }
1031
+ continue;
1032
+ }
1033
+ if (options.globInputPaths && isGlobPattern(input)) {
1034
+ const matches = await searchGlob({
1035
+ config: options.config,
1036
+ cwd: options.cwd,
1037
+ gitignore: options.gitignore,
1038
+ hasFilePatterns,
1039
+ ignoredPatterns,
1040
+ pattern: input
1041
+ });
1042
+ if (matches.length === 0 && options.errorOnUnmatchedPattern) throw new NoFilesFoundError(input, { globInputPaths: options.globInputPaths });
1043
+ candidates.push(...matches);
1044
+ continue;
1045
+ }
1046
+ if (options.errorOnUnmatchedPattern) throw new NoFilesFoundError(input, { globInputPaths: options.globInputPaths });
1047
+ }
1048
+ return [...new Set(candidates)];
1049
+ }
1050
+ async function searchGlob(options) {
1051
+ const root = resolve(options.cwd, getGlobParent(options.pattern));
1052
+ const rootStats = await statPath(root);
1053
+ const pattern = normalizeGlobPattern(options.cwd, options.pattern);
1054
+ if (!rootStats?.isDirectory()) return [];
1055
+ const files = (await walkFiles(root, {
1056
+ cwd: options.cwd,
1057
+ gitignore: options.gitignore,
1058
+ ignoredPatterns: options.ignoredPatterns
1059
+ })).sort();
1060
+ const candidates = [];
1061
+ for (const file of files) {
1062
+ const relativePath = normalizeRelativePath(options.cwd, file);
1063
+ if (matchesGlob(relativePath, pattern) && shouldIncludeGlobCandidate(relativePath, options.config, {
1064
+ cwd: options.cwd,
1065
+ hasFilePatterns: options.hasFilePatterns
1066
+ })) candidates.push(relativePath);
1067
+ }
1068
+ return candidates;
1069
+ }
969
1070
  function shouldFilterGitignoredFiles(config) {
970
1071
  return normalizeConfig(config).some((item) => item.ignore?.gitignore === true);
971
1072
  }
1073
+ function shouldIncludeGlobCandidate(filePath, config, options) {
1074
+ if (options.hasFilePatterns) return matchesDiscoveryFile(filePath, config, { cwd: options.cwd });
1075
+ return !resolveConfigForFile(filePath, config, { cwd: options.cwd }).ignored;
1076
+ }
972
1077
  async function shouldPruneDirectory(path, options) {
973
1078
  if (matchesIgnoredDirectory(normalizeRelativePath(options.cwd, path), options.ignoredPatterns)) return true;
974
- return await options.gitignore?.ignores(path) === true;
1079
+ return await options.gitignore?.ignores(`${path}/`) === true;
1080
+ }
1081
+ async function statPath(path) {
1082
+ try {
1083
+ return await stat(path);
1084
+ } catch (error) {
1085
+ if (isNodeError$1(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) return;
1086
+ throw error;
1087
+ }
975
1088
  }
976
1089
  async function walkFiles(root, options) {
977
1090
  const entries = await readdir(root, { withFileTypes: true });
@@ -1011,6 +1124,7 @@ function resolveConfigRunner(config) {
1011
1124
  }
1012
1125
  function resolveRunnerConfig(setupConfig, config, options) {
1013
1126
  const cache = resolveRunnerCacheConfig(setupConfig.runner?.cache, config.runner?.cache, options);
1127
+ const stats = resolveRunnerStatsConfig(setupConfig.runner?.stats, config.runner?.stats, options);
1014
1128
  const fileConcurrency = parsePositiveIntegerOption(options.fileConcurrency, "--file-concurrency");
1015
1129
  const ruleConcurrency = parsePositiveIntegerOption(options.ruleConcurrency, "--rule-concurrency");
1016
1130
  const timeoutMs = parsePositiveIntegerOption(options.timeoutMs, "--timeout-ms");
@@ -1020,6 +1134,7 @@ function resolveRunnerConfig(setupConfig, config, options) {
1020
1134
  cache,
1021
1135
  fileConcurrency: fileConcurrency ?? config.runner?.fileConcurrency ?? setupConfig.runner?.fileConcurrency,
1022
1136
  ruleConcurrency: ruleConcurrency ?? config.runner?.ruleConcurrency ?? setupConfig.runner?.ruleConcurrency,
1137
+ stats,
1023
1138
  timeoutMs: timeoutMs ?? config.runner?.timeoutMs ?? setupConfig.runner?.timeoutMs
1024
1139
  };
1025
1140
  return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
@@ -1033,6 +1148,15 @@ function mergeRunnerCacheConfig(setupCache, configCache) {
1033
1148
  };
1034
1149
  return configCache;
1035
1150
  }
1151
+ function mergeRunnerStatsConfig(setupStats, configStats) {
1152
+ if (configStats === void 0) return setupStats;
1153
+ if (typeof configStats === "boolean") return configStats;
1154
+ if (typeof setupStats === "object") return {
1155
+ ...setupStats,
1156
+ ...configStats
1157
+ };
1158
+ return configStats;
1159
+ }
1036
1160
  function parsePositiveIntegerOption(value, label) {
1037
1161
  if (value === void 0) return;
1038
1162
  const parsed = Number(value);
@@ -1048,6 +1172,304 @@ function resolveRunnerCacheConfig(setupCache, configCache, options) {
1048
1172
  } : { location: options.cacheLocation };
1049
1173
  return configuredCache;
1050
1174
  }
1175
+ function resolveRunnerStatsConfig(setupStats, configStats, options) {
1176
+ if (options.stats === false) return false;
1177
+ return mergeRunnerStatsConfig(setupStats, configStats);
1178
+ }
1179
+ //#endregion
1180
+ //#region src/cli/stats/ci.ts
1181
+ function isCi(env = process.env) {
1182
+ const ci = env.CI;
1183
+ if (ci !== void 0 && ci !== "" && ci !== "0" && ci !== "false") return true;
1184
+ return Boolean(env.GITHUB_ACTIONS || env.GITLAB_CI || env.CIRCLECI || env.BUILDKITE || env.TF_BUILD);
1185
+ }
1186
+ //#endregion
1187
+ //#region src/cli/stats/since.ts
1188
+ /**
1189
+ * Parse a `--since` time into an inclusive lower-bound timestamp.
1190
+ * This is a tiny helper only to meet the needs.
1191
+ *
1192
+ * Supported formats:
1193
+ * - `Nd`/`Nh` — Now minus N days/hours.
1194
+ * - `YYYY-MM`/`YYYY-MM-DD` — Start of that UTC month/day.
1195
+ *
1196
+ * Edge cases:
1197
+ * - An empty time means "no lower bound".
1198
+ * - An unrecognized time will cause an error to be thrown.
1199
+ */
1200
+ function parseSince(time, now) {
1201
+ if (time === void 0 || time === "") return;
1202
+ const relative = /^(\d+)([dh])$/u.exec(time);
1203
+ if (relative) return now - Number(relative[1]) * (relative[2] === "d" ? 864e5 : 36e5);
1204
+ const calendar = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/u.exec(time);
1205
+ if (calendar) {
1206
+ const year = Number(calendar[1]);
1207
+ const month = Number(calendar[2]);
1208
+ const day = calendar[3] === void 0 ? 1 : Number(calendar[3]);
1209
+ return Date.UTC(year, month - 1, day);
1210
+ }
1211
+ throw new Error(`Invalid --since "${time}": use e.g. 7d, 24h, 2025-01, or 2025-01-15.`);
1212
+ }
1213
+ //#endregion
1214
+ //#region src/cli/stats/jsonl.ts
1215
+ const DEFAULT_RETENTION_MONTHS = 12;
1216
+ const STATS_FILE_PATTERN = /^stats-(\d{4})-(\d{2})\.jsonl$/u;
1217
+ function createJsonlStatsStore(options) {
1218
+ const now = options.clock ?? Date.now;
1219
+ const retentionMonths = options.retentionMonths ?? DEFAULT_RETENTION_MONTHS;
1220
+ return {
1221
+ query: (filter) => query(options.dir, filter ?? {}, now),
1222
+ record: (input) => record(options.dir, input, now(), retentionMonths)
1223
+ };
1224
+ }
1225
+ function accumulate(rows, key, inTok, outTok, totalTok, countRun) {
1226
+ const row = rows.get(key) ?? {
1227
+ inTok: 0,
1228
+ key,
1229
+ outTok: 0,
1230
+ runs: 0,
1231
+ totalTok: 0
1232
+ };
1233
+ row.inTok += inTok;
1234
+ row.outTok += outTok;
1235
+ row.totalTok += totalTok;
1236
+ if (countRun) row.runs += 1;
1237
+ rows.set(key, row);
1238
+ }
1239
+ function isEnoent(error) {
1240
+ return error instanceof Error && error.code === "ENOENT";
1241
+ }
1242
+ function monthIndexOf(ts) {
1243
+ const date = new Date(ts);
1244
+ return date.getUTCFullYear() * 12 + date.getUTCMonth();
1245
+ }
1246
+ function parseRunLine(line) {
1247
+ try {
1248
+ const value = JSON.parse(line);
1249
+ if (typeof value.ts === "number" && typeof value.cwd === "string" && value.usage != null) return value;
1250
+ return;
1251
+ } catch {
1252
+ return;
1253
+ }
1254
+ }
1255
+ async function prune(dir, ts, retentionMonths) {
1256
+ if (retentionMonths <= 0) return;
1257
+ const cutoff = monthIndexOf(ts) - (retentionMonths - 1);
1258
+ const files = await statsFiles(dir);
1259
+ for (const { month, name } of files) if (month < cutoff) await rm(join(dir, name));
1260
+ }
1261
+ async function query(dir, filter, now) {
1262
+ const dimension = filter.by ?? "model";
1263
+ const since = parseSince(filter.since, now());
1264
+ const runs = await readRuns(dir);
1265
+ const rows = /* @__PURE__ */ new Map();
1266
+ let totalRuns = 0;
1267
+ let totalIn = 0;
1268
+ let totalOut = 0;
1269
+ let totalTok = 0;
1270
+ for (const run of runs) {
1271
+ if (since !== void 0 && run.ts < since) continue;
1272
+ if (filter.cwd !== void 0 && run.cwd !== filter.cwd) continue;
1273
+ totalRuns += 1;
1274
+ totalIn += run.usage.inTok;
1275
+ totalOut += run.usage.outTok;
1276
+ totalTok += run.usage.totalTok;
1277
+ if (dimension === "dir") {
1278
+ accumulate(rows, run.cwd, run.usage.inTok, run.usage.outTok, run.usage.totalTok, true);
1279
+ continue;
1280
+ }
1281
+ const seenKeys = /* @__PURE__ */ new Set();
1282
+ for (const usageRecord of run.usage.records) {
1283
+ const key = recordKey(usageRecord, dimension);
1284
+ accumulate(rows, key, usageRecord.inTok, usageRecord.outTok, usageRecord.totalTok, !seenKeys.has(key));
1285
+ seenKeys.add(key);
1286
+ }
1287
+ }
1288
+ return {
1289
+ dimension,
1290
+ rows: [...rows.values()].sort((left, right) => right.totalTok - left.totalTok),
1291
+ totalIn,
1292
+ totalOut,
1293
+ totalRuns,
1294
+ totalTok
1295
+ };
1296
+ }
1297
+ async function readRuns(dir) {
1298
+ const files = await statsFiles(dir);
1299
+ const runs = [];
1300
+ for (const { name } of files) {
1301
+ const content = await readFile(join(dir, name), "utf8");
1302
+ for (const line of content.split("\n")) {
1303
+ if (line.trim() === "") continue;
1304
+ const run = parseRunLine(line);
1305
+ if (run) runs.push(run);
1306
+ }
1307
+ }
1308
+ return runs;
1309
+ }
1310
+ async function record(dir, input, ts, retentionMonths) {
1311
+ const stat = {
1312
+ ts,
1313
+ ...input
1314
+ };
1315
+ await mkdir(dir, { recursive: true });
1316
+ await appendFile(join(dir, statsFileName(ts)), `${JSON.stringify(stat)}\n`, "utf8");
1317
+ await prune(dir, ts, retentionMonths);
1318
+ }
1319
+ function recordKey(record, dimension) {
1320
+ if (dimension === "rule") return record.ruleId;
1321
+ if (dimension === "operation") return record.operation ?? record.ruleId;
1322
+ return `${record.providerId}/${record.modelId}`;
1323
+ }
1324
+ function statsFileName(ts) {
1325
+ const date = new Date(ts);
1326
+ return `stats-${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}.jsonl`;
1327
+ }
1328
+ async function statsFiles(dir) {
1329
+ let entries;
1330
+ try {
1331
+ entries = await readdir(dir);
1332
+ } catch (error) {
1333
+ if (isEnoent(error)) return [];
1334
+ throw error;
1335
+ }
1336
+ return entries.map((name) => {
1337
+ const match = STATS_FILE_PATTERN.exec(name);
1338
+ return match ? {
1339
+ month: Number(match[1]) * 12 + (Number(match[2]) - 1),
1340
+ name
1341
+ } : void 0;
1342
+ }).filter((entry) => entry !== void 0).sort((left, right) => left.month - right.month);
1343
+ }
1344
+ //#endregion
1345
+ //#region src/cli/stats/record.ts
1346
+ function createRunStat(input) {
1347
+ return {
1348
+ cwd: input.cwd,
1349
+ durationMs: input.durationMs,
1350
+ ruleCounts: input.ruleCounts,
1351
+ usage: {
1352
+ inTok: input.usage.inputTokens,
1353
+ outTok: input.usage.outputTokens,
1354
+ records: input.usage.records.map(toStatsUsageRecord),
1355
+ totalTok: input.usage.totalTokens
1356
+ }
1357
+ };
1358
+ }
1359
+ function readOperation(metadata) {
1360
+ if (typeof metadata !== "object" || metadata === null || !("operation" in metadata)) return;
1361
+ const operation = metadata.operation;
1362
+ return typeof operation === "string" ? operation : void 0;
1363
+ }
1364
+ function toStatsUsageRecord(record) {
1365
+ return {
1366
+ filePath: record.filePath,
1367
+ inTok: record.inputTokens ?? 0,
1368
+ modelId: record.modelId,
1369
+ operation: readOperation(record.metadata),
1370
+ outTok: record.outputTokens ?? 0,
1371
+ providerId: record.providerId,
1372
+ ruleId: record.ruleId,
1373
+ totalTok: record.totalTokens ?? 0
1374
+ };
1375
+ }
1376
+ //#endregion
1377
+ //#region src/cli/commands/lint/stats.ts
1378
+ function createStatsCollector() {
1379
+ const counts = {
1380
+ cached: 0,
1381
+ completed: 0,
1382
+ errored: 0,
1383
+ planned: 0
1384
+ };
1385
+ let startedAt;
1386
+ let endedAt;
1387
+ return {
1388
+ counts,
1389
+ durationMs: () => startedAt !== void 0 && endedAt !== void 0 ? endedAt - startedAt : void 0,
1390
+ reporter: {
1391
+ onRunEnd: (payload) => {
1392
+ counts.cached = payload.cached;
1393
+ counts.completed = payload.completed;
1394
+ counts.errored = payload.errored;
1395
+ counts.planned = payload.planned;
1396
+ endedAt = payload.endedAt;
1397
+ },
1398
+ onRunStart: (payload) => {
1399
+ startedAt = payload.startedAt;
1400
+ }
1401
+ }
1402
+ };
1403
+ }
1404
+ function mergeProgressReporters(base, extra) {
1405
+ if (!base) return extra;
1406
+ if (!extra) return base;
1407
+ return {
1408
+ onDiagnostic: (payload) => {
1409
+ base.onDiagnostic?.(payload);
1410
+ extra.onDiagnostic?.(payload);
1411
+ },
1412
+ onFileEnd: (payload) => {
1413
+ base.onFileEnd?.(payload);
1414
+ extra.onFileEnd?.(payload);
1415
+ },
1416
+ onFileStart: (payload) => {
1417
+ base.onFileStart?.(payload);
1418
+ extra.onFileStart?.(payload);
1419
+ },
1420
+ onRuleEnd: (payload) => {
1421
+ base.onRuleEnd?.(payload);
1422
+ extra.onRuleEnd?.(payload);
1423
+ },
1424
+ onRuleStart: (payload) => {
1425
+ base.onRuleStart?.(payload);
1426
+ extra.onRuleStart?.(payload);
1427
+ },
1428
+ onRunEnd: (payload) => {
1429
+ base.onRunEnd?.(payload);
1430
+ extra.onRunEnd?.(payload);
1431
+ },
1432
+ onRunStart: (payload) => {
1433
+ base.onRunStart?.(payload);
1434
+ extra.onRunStart?.(payload);
1435
+ },
1436
+ onTargetEnd: (payload) => {
1437
+ base.onTargetEnd?.(payload);
1438
+ extra.onTargetEnd?.(payload);
1439
+ },
1440
+ onTargetStart: (payload) => {
1441
+ base.onTargetStart?.(payload);
1442
+ extra.onTargetStart?.(payload);
1443
+ },
1444
+ onUsage: (payload) => {
1445
+ base.onUsage?.(payload);
1446
+ extra.onUsage?.(payload);
1447
+ }
1448
+ };
1449
+ }
1450
+ function resolveStatsWrite(stats, env) {
1451
+ if (isCi(env)) return;
1452
+ if (!(stats === false ? false : stats === void 0 || stats === true ? true : stats.enabled !== false)) return;
1453
+ const config = typeof stats === "object" ? stats : void 0;
1454
+ return {
1455
+ dir: config?.location ?? getStatsDir(env),
1456
+ retentionMonths: config?.retentionMonths
1457
+ };
1458
+ }
1459
+ async function writeRunStats(target, collector, result, cwd) {
1460
+ const store = createJsonlStatsStore({
1461
+ dir: target.dir,
1462
+ retentionMonths: target.retentionMonths
1463
+ });
1464
+ try {
1465
+ await store.record(createRunStat({
1466
+ cwd,
1467
+ durationMs: collector.durationMs(),
1468
+ ruleCounts: collector.counts,
1469
+ usage: result.usage
1470
+ }));
1471
+ } catch {}
1472
+ }
1051
1473
  //#endregion
1052
1474
  //#region src/cli/commands/lint/index.ts
1053
1475
  const lint = defineCommand({
@@ -1076,7 +1498,22 @@ function isNodeError(error) {
1076
1498
  async function runLintCommand(files, options, io, interceptConsoleOutput) {
1077
1499
  if (options.config) await assertConfigExists(io.cwd, options.config);
1078
1500
  const [setupConfig, config] = await Promise.all([loadMergedSetupConfig(io), loadAlintConfig(io.cwd, options.config)]);
1079
- const lintFiles = await resolveLintFiles(files, config, io.cwd);
1501
+ let lintFiles;
1502
+ try {
1503
+ lintFiles = await findFiles({
1504
+ config,
1505
+ cwd: io.cwd,
1506
+ errorOnUnmatchedPattern: true,
1507
+ globInputPaths: true,
1508
+ inputs: files
1509
+ });
1510
+ } catch (error) {
1511
+ if (error instanceof NoFilesFoundError) {
1512
+ io.stderr.write(`${error.message}\n`);
1513
+ return 2;
1514
+ }
1515
+ throw error;
1516
+ }
1080
1517
  const runner = resolveRunnerConfig(setupConfig, { runner: resolveConfigRunner(config) }, options);
1081
1518
  const progress = shouldEnableProgress(options, io) ? createCliProgressReporter({
1082
1519
  color: io.stderr.isTTY === true,
@@ -1086,6 +1523,8 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1086
1523
  write: (chunk) => io.stderr.write(chunk)
1087
1524
  }) : void 0;
1088
1525
  const restoreProgressConsole = progress ? interceptConsoleOutput({ write: progress.write }) : void 0;
1526
+ const statsTarget = resolveStatsWrite(runner?.stats, io.env);
1527
+ const statsCollector = statsTarget ? createStatsCollector() : void 0;
1089
1528
  let result;
1090
1529
  try {
1091
1530
  result = await runAlint({
@@ -1094,7 +1533,7 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1094
1533
  files: lintFiles,
1095
1534
  modelOverride: options.model,
1096
1535
  outputLanguage: options.outputLanguage,
1097
- progress: progress?.reporter,
1536
+ progress: mergeProgressReporters(progress?.reporter, statsCollector?.reporter),
1098
1537
  runner,
1099
1538
  setupConfig
1100
1539
  });
@@ -1109,6 +1548,7 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1109
1548
  }
1110
1549
  restoreProgressConsole?.();
1111
1550
  progress?.dispose();
1551
+ if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, result, io.cwd);
1112
1552
  io.stdout.write(formatDiagnostics(options.format, result, { color: io.stdout.isTTY === true }));
1113
1553
  return result.diagnostics.length > 0 ? 1 : 0;
1114
1554
  }
@@ -1531,11 +1971,112 @@ function toArray(value) {
1531
1971
  return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string");
1532
1972
  }
1533
1973
  //#endregion
1974
+ //#region src/cli/commands/stats/format.ts
1975
+ const colors = createColors({ force: true });
1976
+ const DIMENSION_LABEL = {
1977
+ dir: "directory",
1978
+ model: "model",
1979
+ operation: "operation",
1980
+ rule: "rule"
1981
+ };
1982
+ function formatStatsAggregate(aggregate, options = {}) {
1983
+ const paint = (text, style) => options.color === true ? style(text) : text;
1984
+ const runLabel = aggregate.totalRuns === 1 ? "run" : "runs";
1985
+ const lines = [`${aggregate.totalRuns} ${runLabel} — ${formatTokens(aggregate.totalIn)} in / ${formatTokens(aggregate.totalOut)} out / ${paint(`${formatTokens(aggregate.totalTok)} total`, colors.cyan)}`, ""];
1986
+ if (aggregate.rows.length === 0) {
1987
+ lines.push("No stats recorded yet.");
1988
+ return `${lines.join("\n")}\n`;
1989
+ }
1990
+ const header = [
1991
+ DIMENSION_LABEL[aggregate.dimension],
1992
+ "runs",
1993
+ "input",
1994
+ "output",
1995
+ "total"
1996
+ ];
1997
+ const rows = aggregate.rows.map((row) => [
1998
+ row.key,
1999
+ String(row.runs),
2000
+ formatTokens(row.inTok),
2001
+ formatTokens(row.outTok),
2002
+ formatTokens(row.totalTok)
2003
+ ]);
2004
+ const widths = header.map((cell, index) => Math.max(cell.length, ...rows.map((row) => row[index].length)));
2005
+ const renderRow = (cells, isHeader) => cells.map((cell, index) => {
2006
+ const aligned = index === 0 ? cell.padEnd(widths[index]) : cell.padStart(widths[index]);
2007
+ return isHeader ? paint(aligned, colors.bold) : aligned;
2008
+ }).join(" ");
2009
+ lines.push(renderRow(header, true), ...rows.map((row) => renderRow(row, false)));
2010
+ return `${lines.join("\n")}\n`;
2011
+ }
2012
+ function formatTokens(value) {
2013
+ return value.toLocaleString("en-US");
2014
+ }
2015
+ //#endregion
2016
+ //#region src/cli/commands/stats/stats.ts
2017
+ const DIMENSIONS = /* @__PURE__ */ new Set([
2018
+ "dir",
2019
+ "model",
2020
+ "operation",
2021
+ "rule"
2022
+ ]);
2023
+ async function runStatsCommand(options, io) {
2024
+ try {
2025
+ const dimension = parseDimension(options.by);
2026
+ const [setupConfig, config] = await Promise.all([loadMergedSetupConfig(io), loadAlintConfig(io.cwd)]);
2027
+ const aggregate = await createJsonlStatsStore({ dir: statsLocation(resolveConfigRunner(config)?.stats) ?? statsLocation(setupConfig.runner?.stats) ?? getStatsDir(io.env) }).query({
2028
+ by: dimension,
2029
+ cwd: options.here === true ? io.cwd : options.cwd,
2030
+ since: options.since
2031
+ });
2032
+ io.stdout.write(options.json === true ? `${JSON.stringify(aggregate, null, 2)}\n` : formatStatsAggregate(aggregate, { color: io.stdout.isTTY === true }));
2033
+ return 0;
2034
+ } catch (error) {
2035
+ io.stderr.write(`${errorMessageFrom$1(error) ?? "Failed to read stats."}\n`);
2036
+ return 2;
2037
+ }
2038
+ }
2039
+ function parseDimension(value) {
2040
+ if (value === void 0) return "model";
2041
+ if (DIMENSIONS.has(value)) return value;
2042
+ throw new Error(`Invalid --by "${value}": use rule, operation, model, or dir.`);
2043
+ }
2044
+ function statsLocation(stats) {
2045
+ return typeof stats === "object" ? stats.location : void 0;
2046
+ }
2047
+ //#endregion
1534
2048
  //#region src/cli/commands/index.ts
1535
2049
  const commandTree = [
1536
2050
  setup,
1537
2051
  config,
1538
2052
  output,
2053
+ defineCommand({
2054
+ action: (context, options) => runStatsCommand(options, context.io),
2055
+ description: "Show recorded run stats",
2056
+ name: "stats",
2057
+ options: [
2058
+ {
2059
+ description: "Group by rule, operation, model, or dir",
2060
+ flags: "--by <dimension>"
2061
+ },
2062
+ {
2063
+ description: "Only include runs since this time (e.g. 7d, 24h, 2025-01)",
2064
+ flags: "--since <time>"
2065
+ },
2066
+ {
2067
+ description: "Only include runs from this directory",
2068
+ flags: "--cwd <path>"
2069
+ },
2070
+ {
2071
+ description: "Only include runs from the current directory",
2072
+ flags: "--here"
2073
+ },
2074
+ {
2075
+ description: "Output JSON",
2076
+ flags: "--json"
2077
+ }
2078
+ ]
2079
+ }),
1539
2080
  lint
1540
2081
  ];
1541
2082
  //#endregion
@@ -1549,7 +2090,7 @@ async function executeCli(argv, io) {
1549
2090
  pendingResult = result;
1550
2091
  return result;
1551
2092
  };
1552
- cli.option("--no-cache", "Disable cache for this run").option("--cache-location <path>", "Path to the alint cache file or directory").option("--config <path>", "Path to alint config file").option("--file-concurrency <count>", "Number of files to lint concurrently").option("--format <format>", "Reporter format", { default: "stylish" }).option("--model <model>", "Force a model override").option("-l, --lang <language>", "Ask model-backed rules to write diagnostics in this language").option("--progress", "Show run progress").option("--rule-concurrency <count>", "Number of rules to run concurrently within a file").option("--timeout-ms <ms>", "Rule execution timeout in milliseconds").help();
2093
+ cli.option("--no-cache", "Disable cache for this run").option("--cache-location <path>", "Path to the alint cache file or directory").option("--config <path>", "Path to alint config file").option("--file-concurrency <count>", "Number of files to lint concurrently").option("--format <format>", "Reporter format", { default: "stylish" }).option("--model <model>", "Force a model override").option("-l, --lang <language>", "Ask model-backed rules to write diagnostics in this language").option("--progress", "Show run progress").option("--rule-concurrency <count>", "Number of rules to run concurrently within a file").option("--no-stats", "Do not record run stats for this run").option("--timeout-ms <ms>", "Rule execution timeout in milliseconds").help();
1553
2094
  registerCommandTree(cli, commandTree, {
1554
2095
  globalOptions,
1555
2096
  interceptConsoleOutput,
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { i as formatJson, n as formatDiagnostics, r as formatStylish, t as executeCli } from "./cli-D7cUTZnE.mjs";
1
+ import { i as formatJson, n as formatDiagnostics, r as formatStylish, t as executeCli } from "./cli-D4YC9Jnb.mjs";
2
2
  export { executeCli, formatDiagnostics, formatJson, formatStylish };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alint-js/cli",
3
3
  "type": "module",
4
- "version": "0.0.8",
4
+ "version": "0.0.10",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.mts",
@@ -25,8 +25,8 @@
25
25
  "pathe": "^2.0.3",
26
26
  "table": "^6.9.0",
27
27
  "tinyrainbow": "^3.1.0",
28
- "@alint-js/core": "0.0.8",
29
- "@alint-js/config": "0.0.8"
28
+ "@alint-js/core": "0.0.10",
29
+ "@alint-js/config": "0.0.10"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^26.0.1",