@alint-js/cli 0.5.0 → 0.6.0

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,24 +1,30 @@
1
1
  import process from "node:process";
2
2
  import { inspect } from "node:util";
3
3
  import { cac } from "cac";
4
- import { appendFile, mkdir, opendir, readFile, readdir, rm, stat } from "node:fs/promises";
5
- import { addProviderModels, getGlobalSetupConfigPath, getProjectSetupConfigPath, getStatsDir, installStaticPlugins, isAcpModel, loadAlintConfig, loadSetupConfig, mergeSetupConfigs, normalizeSetupConfig, pruneProviderModels, removeProviderModels, replaceSetupProvider, setProviderEndpoint, setProviderHeader, unsetProviderHeader, writeSetupConfig } from "@alint-js/config";
6
- import { AlintRunCancelledError, AlintRunError, benchmarkModels, hasDiscoveryFilePatterns, matchesDiscoveryFile, normalizeConfig, resolveConfigForDirectory, resolveConfigForFile, runAlint } from "@alint-js/core";
4
+ import { appendFile, chmod, mkdir, opendir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { addProviderModels, getGlobalSetupConfigPath, getProjectSetupConfigPath, getStatsDir, installStaticPlugins, isAcpModel, loadAlintConfig, loadAlintConfigWithMetadata, loadSetupConfig, mergeSetupConfigs, normalizeSetupConfig, pruneProviderModels, removeProviderModels, replaceSetupProvider, setProviderEndpoint, setProviderHeader, setStopGateConfig, unsetProviderHeader, writeSetupConfig } from "@alint-js/config";
6
+ import { AlintCachePersistenceError, AlintRunCancelledError, AlintRunError, benchmarkModels, defaultStopGateConfig, hasDiscoveryFilePatterns, matchesDiscoveryFile, maximumStopGateTimeoutMs, normalizeConfig, resolveConfigForDirectory, resolveConfigForFile, resolveStopGateConfig, runAlint, stableHash } from "@alint-js/core";
7
7
  import { isAbsolute, join, relative, resolve } from "pathe";
8
- import { errorMessageFrom } from "@moeru/std/error";
8
+ import { errorMessageFrom } from "@moeru/std";
9
+ import { errorMessageFrom as errorMessageFrom$1 } from "@moeru/std/error";
9
10
  import { getBorderCharacters, table } from "table";
10
11
  import { createColors } from "tinyrainbow";
11
12
  import { createCommandModel, startGateway } from "@alint-js/model-adapter-acp";
12
13
  import cliSpinners from "cli-spinners";
13
14
  import fastStringWidth from "fast-string-width";
14
- import { relative as relative$1 } from "node:path";
15
- import fastStringTruncatedWidth from "fast-string-truncated-width";
16
- import { formatDuration, intervalToDuration } from "date-fns";
15
+ import { parsePatch } from "diff";
16
+ import { x } from "tinyexec";
17
+ import { Buffer } from "node:buffer";
18
+ import { randomUUID } from "node:crypto";
19
+ import { tmpdir } from "node:os";
20
+ import { join as join$1, relative as relative$1 } from "node:path";
21
+ import { isNodeErrorCode } from "@alint-js/utils/node";
17
22
  import Gitignore from "gitignore-fs";
18
23
  import { Minimatch, minimatch } from "minimatch";
19
- import { errorMessageFrom as errorMessageFrom$1 } from "@moeru/std";
24
+ import fastStringTruncatedWidth from "fast-string-truncated-width";
25
+ import { formatDuration, intervalToDuration } from "date-fns";
20
26
  //#region package.json
21
- var version = "0.5.0";
27
+ var version = "0.6.0";
22
28
  //#endregion
23
29
  //#region src/cli/output.ts
24
30
  function escapeLineValue(value) {
@@ -263,6 +269,115 @@ async function runConfigInspectCommand(path, configPath, io) {
263
269
  return 0;
264
270
  }
265
271
  //#endregion
272
+ //#region src/cli/commands/config/integrations/stop-gate.ts
273
+ const set$1 = defineCommand({
274
+ async action(context, options) {
275
+ try {
276
+ let target;
277
+ if (options.target === void 0) target = void 0;
278
+ else if (options.target === "all" || options.target === "dirty-files") target = options.target;
279
+ else throw new Error("Stop Gate target must be \"all\" or \"dirty-files\".");
280
+ let timeoutMs;
281
+ if (options.timeoutMs !== void 0) {
282
+ timeoutMs = Number(options.timeoutMs);
283
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > maximumStopGateTimeoutMs) throw new Error(`Stop Gate timeout must be an integer from 1 to ${maximumStopGateTimeoutMs}.`);
284
+ }
285
+ const result = await setStopGateConfig({
286
+ configFile: options.config,
287
+ cwd: context.io.cwd,
288
+ target,
289
+ timeoutMs
290
+ });
291
+ context.io.stdout.write(`config: ${result.configFile}\n`);
292
+ writeResolvedConfig((chunk) => context.io.stdout.write(chunk), result.config);
293
+ return 0;
294
+ } catch (error) {
295
+ context.io.stderr.write(`${errorMessageFrom(error) ?? "Failed to write Stop Gate config."}\n`);
296
+ return 2;
297
+ }
298
+ },
299
+ description: "Set Stop Gate config overrides in TOML",
300
+ exactArguments: true,
301
+ name: "set",
302
+ options: [{
303
+ description: "Lint target mode",
304
+ flags: "--target <target>"
305
+ }, {
306
+ description: "Overall Stop Gate timeout in milliseconds",
307
+ flags: "--timeout-ms <ms>"
308
+ }]
309
+ });
310
+ const enable = defineCommand({
311
+ action: (context) => setActivation(context, true),
312
+ description: "Enable Stop Gate for this repository",
313
+ exactArguments: true,
314
+ name: "enable"
315
+ });
316
+ const disable = defineCommand({
317
+ action: (context) => setActivation(context, false),
318
+ description: "Disable Stop Gate for this repository",
319
+ exactArguments: true,
320
+ name: "disable"
321
+ });
322
+ const stopGate$1 = defineCommand({
323
+ children: [
324
+ defineCommand({
325
+ async action(context, options) {
326
+ try {
327
+ const loaded = await loadAlintConfigWithMetadata(context.io.cwd, options.config);
328
+ const resolved = resolveStopGateConfig(loaded.config, context.io.cwd);
329
+ context.io.stdout.write(`config: ${loaded.configFile ?? "<not found>"}\n`);
330
+ writeResolvedConfig((chunk) => context.io.stdout.write(chunk), resolved);
331
+ return 0;
332
+ } catch (error) {
333
+ context.io.stderr.write(`${errorMessageFrom(error) ?? "Failed to read Stop Gate config."}\n`);
334
+ return 2;
335
+ }
336
+ },
337
+ description: "Show resolved Stop Gate config",
338
+ exactArguments: true,
339
+ name: "show"
340
+ }),
341
+ set$1,
342
+ enable,
343
+ disable
344
+ ],
345
+ description: "Manage Stop Gate integration config",
346
+ examples: [
347
+ "alint config integrations stop-gate show",
348
+ "alint config integrations stop-gate enable",
349
+ "alint config integrations stop-gate disable",
350
+ "alint config integrations stop-gate set --target all --timeout-ms 1800000"
351
+ ],
352
+ name: "stop-gate"
353
+ });
354
+ async function setActivation(context, enabled) {
355
+ try {
356
+ const result = await setStopGateConfig({
357
+ cwd: context.io.cwd,
358
+ enabled
359
+ });
360
+ context.io.stdout.write(`config: ${result.configFile}\n`);
361
+ writeResolvedConfig((chunk) => context.io.stdout.write(chunk), result.config);
362
+ return 0;
363
+ } catch (error) {
364
+ context.io.stderr.write(`${errorMessageFrom(error) ?? "Failed to write Stop Gate activation."}\n`);
365
+ return 2;
366
+ }
367
+ }
368
+ function writeResolvedConfig(write, config) {
369
+ write(`enabled: ${config.enabled}${config.enabled === defaultStopGateConfig.enabled ? " (default)" : ""}\n`);
370
+ write(`target: ${config.target}${config.target === defaultStopGateConfig.target ? " (default)" : ""}\n`);
371
+ write(`timeoutMs: ${config.timeoutMs}${config.timeoutMs === defaultStopGateConfig.timeoutMs ? " (default)" : ""}\n`);
372
+ }
373
+ //#endregion
374
+ //#region src/cli/commands/config/integrations/integrations.ts
375
+ const integrations$1 = defineCommand({
376
+ children: [stopGate$1],
377
+ description: "Manage external integration configuration",
378
+ name: "integrations"
379
+ });
380
+ //#endregion
266
381
  //#region src/cli/reporters/progress/bar.ts
267
382
  const minBarWidth = 4;
268
383
  const maxBarWidth = 16;
@@ -986,7 +1101,7 @@ async function runListModelsCommand(context, options) {
986
1101
  try {
987
1102
  providerConcurrency = resolveProviderBenchmarkConcurrency(config, concurrencyOverrides);
988
1103
  } catch (error) {
989
- context.io.stderr.write(`${errorMessageFrom(error) ?? "Invalid benchmark concurrency override."}\n`);
1104
+ context.io.stderr.write(`${errorMessageFrom$1(error) ?? "Invalid benchmark concurrency override."}\n`);
990
1105
  return 2;
991
1106
  }
992
1107
  const runtime = await startModelAdapters(config, context.io);
@@ -1030,7 +1145,7 @@ const probe$1 = defineCommand({
1030
1145
  context.io.stdout.write(`${models.join("\n")}${models.length > 0 ? "\n" : ""}`);
1031
1146
  return 0;
1032
1147
  } catch (error) {
1033
- context.io.stderr.write(`failed to probe models: ${errorMessageFrom(error) ?? String(error)}\n`);
1148
+ context.io.stderr.write(`failed to probe models: ${errorMessageFrom$1(error) ?? String(error)}\n`);
1034
1149
  return 2;
1035
1150
  }
1036
1151
  },
@@ -1167,7 +1282,7 @@ function applyPlans(config, plans) {
1167
1282
  return plans.reduce((nextConfig, plan) => pruneProviderModels(nextConfig, plan.provider.id, plan.remoteModelIds), config);
1168
1283
  }
1169
1284
  function classifyProbeFailure(error) {
1170
- const message = errorMessageFrom(error);
1285
+ const message = errorMessageFrom$1(error);
1171
1286
  const httpStatus = message?.match(/^GET .+ returned ([1-5]\d\d)\.$/u)?.[1];
1172
1287
  if (httpStatus !== void 0) return `provider returned HTTP ${httpStatus}`;
1173
1288
  if (error instanceof TypeError && message === "Expected OpenAI-compatible models response with data array.") return "provider returned an invalid models response";
@@ -1377,7 +1492,7 @@ const probe = defineCommand({
1377
1492
  context.io.stdout.write(`endpoint: ${options.endpoint}\nmodels: ${models.length}\n`);
1378
1493
  return 0;
1379
1494
  } catch (error) {
1380
- context.io.stderr.write(`failed to probe provider: ${errorMessageFrom(error) ?? String(error)}\n`);
1495
+ context.io.stderr.write(`failed to probe provider: ${errorMessageFrom$1(error) ?? String(error)}\n`);
1381
1496
  return 2;
1382
1497
  }
1383
1498
  },
@@ -1795,7 +1910,7 @@ function createProviderEditorPrompts(prompts) {
1795
1910
  }
1796
1911
  function formatProbeModelsFailure(endpoint, error) {
1797
1912
  const hint = endpoint.startsWith("https://localhost:11434") ? " Ollama usually uses http://localhost:11434/v1." : "";
1798
- return `Could not probe models: ${errorMessageFrom(error)}.${hint}`;
1913
+ return `Could not probe models: ${errorMessageFrom$1(error)}.${hint}`;
1799
1914
  }
1800
1915
  function isBackInput(value) {
1801
1916
  return value.trim() === "..";
@@ -2177,7 +2292,7 @@ async function runNonInteractiveUpdate(context, options, providerEndpoint, exist
2177
2292
  try {
2178
2293
  parsedHeaders = parseHeaderList(toArray$1(options.providerHeader));
2179
2294
  } catch (error) {
2180
- context.io.stderr.write(`${errorMessageFrom(error) ?? "Invalid provider header."}\n`);
2295
+ context.io.stderr.write(`${errorMessageFrom$1(error) ?? "Invalid provider header."}\n`);
2181
2296
  return 2;
2182
2297
  }
2183
2298
  const draft = {
@@ -2189,7 +2304,7 @@ async function runNonInteractiveUpdate(context, options, providerEndpoint, exist
2189
2304
  try {
2190
2305
  remoteModelIds = await probeModels(endpoint, draft.headers);
2191
2306
  } catch (error) {
2192
- context.io.stderr.write(`failed to probe provider: ${errorMessageFrom(error) ?? "Unknown error."}\n`);
2307
+ context.io.stderr.write(`failed to probe provider: ${errorMessageFrom$1(error) ?? "Unknown error."}\n`);
2193
2308
  return 2;
2194
2309
  }
2195
2310
  const replaced = replaceSetupProvider(config, draft);
@@ -2222,7 +2337,7 @@ async function runUpdateProviderCommand(context, options) {
2222
2337
  providerId = scalarOption(options.provider, "--provider");
2223
2338
  providerEndpoint = scalarOption(options.providerEndpoint, "--provider-endpoint");
2224
2339
  } catch (error) {
2225
- context.io.stderr.write(`${errorMessageFrom(error) ?? "Invalid provider update option."}\n`);
2340
+ context.io.stderr.write(`${errorMessageFrom$1(error) ?? "Invalid provider update option."}\n`);
2226
2341
  return 2;
2227
2342
  }
2228
2343
  if (providerId === void 0) {
@@ -2270,6 +2385,7 @@ function toArray$1(value) {
2270
2385
  const config = defineCommand({
2271
2386
  children: [
2272
2387
  inspect$2,
2388
+ integrations$1,
2273
2389
  models,
2274
2390
  defineCommand({
2275
2391
  children: [
@@ -2332,1441 +2448,1925 @@ const config = defineCommand({
2332
2448
  name: "config"
2333
2449
  });
2334
2450
  //#endregion
2335
- //#region src/cli/reporters/json.ts
2336
- function formatJson(result) {
2337
- return `${JSON.stringify(result, null, 2)}\n`;
2451
+ //#region src/cli/git.ts
2452
+ const gitTimeoutMs = 6e4;
2453
+ const gitlinkMode = "160000";
2454
+ /**
2455
+ * Collects staged, unstaged, and untracked changes relative to HEAD.
2456
+ * Deleted files and Git-controlled submodule worktrees stay outside the lint boundary.
2457
+ */
2458
+ async function findDirtyChanges(cwd) {
2459
+ const [tracked, untracked, patch, stagedEntries] = await Promise.all([
2460
+ runGit(cwd, [
2461
+ "diff",
2462
+ "--name-only",
2463
+ "-z",
2464
+ "--diff-filter=ACMRTUXB",
2465
+ "--find-renames",
2466
+ "--ignore-submodules=none",
2467
+ "HEAD",
2468
+ "--"
2469
+ ]),
2470
+ runGit(cwd, [
2471
+ "ls-files",
2472
+ "--others",
2473
+ "--exclude-standard",
2474
+ "-z"
2475
+ ]),
2476
+ runGit(cwd, [
2477
+ "diff",
2478
+ "--no-color",
2479
+ "--no-ext-diff",
2480
+ "--unified=0",
2481
+ "--diff-filter=ACMRTUXB",
2482
+ "--find-renames",
2483
+ "--ignore-submodules=none",
2484
+ "--submodule=short",
2485
+ "HEAD",
2486
+ "--"
2487
+ ]),
2488
+ runGit(cwd, [
2489
+ "ls-files",
2490
+ "--stage",
2491
+ "-z"
2492
+ ])
2493
+ ]);
2494
+ const gitlinks = parseGitlinks(stagedEntries);
2495
+ const trackedFiles = withoutGitlinks(parseNullSeparatedPaths(tracked), gitlinks);
2496
+ const untrackedFiles = withoutGitlinks(parseNullSeparatedPaths(untracked), gitlinks);
2497
+ const files = [.../* @__PURE__ */ new Set([...trackedFiles, ...untrackedFiles])].sort();
2498
+ const changedLines = changedLinesFromPatch(patch, gitlinks);
2499
+ await Promise.all(untrackedFiles.map(async (file) => {
2500
+ const content = await readFile(resolve(cwd, file), "utf8");
2501
+ const lineCount = content.length === 0 ? 0 : content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
2502
+ if (lineCount > 0) changedLines.set(file, [{
2503
+ endLine: lineCount,
2504
+ startLine: 1
2505
+ }]);
2506
+ }));
2507
+ return {
2508
+ changedLines,
2509
+ files
2510
+ };
2338
2511
  }
2339
- //#endregion
2340
- //#region src/cli/reporters/stylish.ts
2341
- const colors$3 = createColors({ force: true });
2342
- function formatStylish(input, options = {}) {
2343
- const diagnostics = Array.isArray(input) ? input : input.diagnostics;
2344
- const result = Array.isArray(input) ? void 0 : input;
2345
- const style = createStyle(options.color === true);
2346
- if (diagnostics.length === 0) return result?.execution?.cached ? `${formatSummary(diagnostics, result, style)}\n` : "";
2347
- const diagnosticsByFile = /* @__PURE__ */ new Map();
2348
- for (const diagnostic of diagnostics) {
2349
- const fileDiagnostics = diagnosticsByFile.get(diagnostic.filePath);
2350
- if (fileDiagnostics) {
2351
- fileDiagnostics.push(diagnostic);
2352
- continue;
2512
+ async function findGitRoot(cwd) {
2513
+ const root = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
2514
+ if (root.length === 0) throw new Error("Git did not return a repository root.");
2515
+ return root;
2516
+ }
2517
+ function changedLinesFromPatch(patch, gitlinks) {
2518
+ const changedLines = /* @__PURE__ */ new Map();
2519
+ for (const file of parsePatch(patch)) {
2520
+ const path = file.newFileName === void 0 || file.newFileName === "/dev/null" ? void 0 : file.newFileName.startsWith("b/") ? file.newFileName.slice(2) : file.newFileName;
2521
+ if (path === void 0 || isWithinGitlink(path, gitlinks)) continue;
2522
+ const lines = [];
2523
+ for (const hunk of file.hunks) {
2524
+ let newLine = hunk.newStart;
2525
+ for (const line of hunk.lines) if (line.startsWith("+")) {
2526
+ lines.push(newLine);
2527
+ newLine += 1;
2528
+ } else if (!line.startsWith("-") && !line.startsWith("\\")) newLine += 1;
2353
2529
  }
2354
- diagnosticsByFile.set(diagnostic.filePath, [diagnostic]);
2530
+ const ranges = lineRanges(lines);
2531
+ if (ranges.length > 0) changedLines.set(path, ranges);
2355
2532
  }
2356
- const lines = [];
2357
- for (const [filePath, fileDiagnostics] of diagnosticsByFile) {
2358
- lines.push(style.file(filePath));
2359
- for (const diagnostic of fileDiagnostics) {
2360
- const line = diagnostic.loc?.start.line ?? 0;
2361
- const column = diagnostic.loc?.start.column ?? 0;
2362
- const severity = diagnostic.severity === "warn" ? style.warning("warning") : style.error("error");
2363
- lines.push(` ${style.location(`${line}:${column}`)} ${severity} ${diagnostic.message} ${style.ruleId(diagnostic.ruleId)}`);
2533
+ return changedLines;
2534
+ }
2535
+ function isWithinGitlink(path, gitlinks) {
2536
+ for (const gitlink of gitlinks) if (path === gitlink || path.startsWith(`${gitlink}/`)) return true;
2537
+ return false;
2538
+ }
2539
+ function lineRanges(lines) {
2540
+ const ranges = [];
2541
+ for (const line of lines) {
2542
+ const previous = ranges.at(-1);
2543
+ if (previous && line === previous.endLine + 1) previous.endLine = line;
2544
+ else ranges.push({
2545
+ endLine: line,
2546
+ startLine: line
2547
+ });
2548
+ }
2549
+ return ranges;
2550
+ }
2551
+ function parseGitlinks(output) {
2552
+ const gitlinks = /* @__PURE__ */ new Set();
2553
+ for (const entry of parseNullSeparatedPaths(output)) {
2554
+ const separator = entry.indexOf(" ");
2555
+ if (separator === -1 || !entry.startsWith(`${gitlinkMode} `)) continue;
2556
+ gitlinks.add(entry.slice(separator + 1));
2557
+ }
2558
+ return gitlinks;
2559
+ }
2560
+ function parseNullSeparatedPaths(output) {
2561
+ return output.split("\0").filter((path) => path.length > 0);
2562
+ }
2563
+ async function runGit(cwd, args) {
2564
+ return (await x("git", args, {
2565
+ nodeOptions: { cwd },
2566
+ nodePath: false,
2567
+ throwOnError: true,
2568
+ timeout: gitTimeoutMs
2569
+ })).stdout;
2570
+ }
2571
+ function withoutGitlinks(paths, gitlinks) {
2572
+ return paths.filter((path) => !isWithinGitlink(path, gitlinks));
2573
+ }
2574
+ //#endregion
2575
+ //#region src/cli/commands/integrations/stop-gate/envelope.ts
2576
+ function writeEnvelope(write, envelope) {
2577
+ write(`${JSON.stringify(envelope)}\n`);
2578
+ }
2579
+ //#endregion
2580
+ //#region src/cli/commands/integrations/stop-gate/findings.ts
2581
+ function fingerprintDiagnostics(diagnostics) {
2582
+ const diagnosticHashes = diagnostics.map((diagnostic) => stableHash({
2583
+ evidence: diagnostic.evidence,
2584
+ filePath: diagnostic.filePath,
2585
+ loc: diagnostic.loc,
2586
+ message: diagnostic.message,
2587
+ ruleId: diagnostic.ruleId,
2588
+ severity: diagnostic.severity
2589
+ })).sort();
2590
+ return stableHash({
2591
+ diagnosticHashes,
2592
+ schemaVersion: 1
2593
+ });
2594
+ }
2595
+ //#endregion
2596
+ //#region src/cli/commands/integrations/stop-gate/report.ts
2597
+ const reportFileName = "report.json";
2598
+ const reportLimitBytes = 104857600;
2599
+ const stopGateTempRoot = join$1(tmpdir(), "alint-stop-gate");
2600
+ var StopGateReportTooLargeError = class extends Error {
2601
+ constructor() {
2602
+ super("Stop Gate report exceeds the 100 MB limit.");
2603
+ this.name = "StopGateReportTooLargeError";
2604
+ }
2605
+ };
2606
+ async function removeStopGateReport(sessionId) {
2607
+ await rm(getReportPath(sessionId), { force: true });
2608
+ }
2609
+ async function writeStopGateReport(sessionId, report) {
2610
+ const content = `${JSON.stringify(report, null, 2)}\n`;
2611
+ if (Buffer.byteLength(content) > reportLimitBytes) throw new StopGateReportTooLargeError();
2612
+ const sessionDirectory = getSessionDirectory(sessionId);
2613
+ const reportPath = getReportPath(sessionId);
2614
+ const tempPath = join$1(sessionDirectory, `report-${randomUUID()}.tmp`);
2615
+ await mkdir(sessionDirectory, { recursive: true });
2616
+ await writeFile(tempPath, content, {
2617
+ encoding: "utf8",
2618
+ mode: 420
2619
+ });
2620
+ await chmod(tempPath, 420);
2621
+ await rename(tempPath, reportPath);
2622
+ await enforceReportBudget(reportPath);
2623
+ return reportPath;
2624
+ }
2625
+ async function enforceReportBudget(currentReportPath) {
2626
+ let entries;
2627
+ try {
2628
+ entries = await readdir(stopGateTempRoot, { withFileTypes: true });
2629
+ } catch (error) {
2630
+ if (isNodeErrorCode(error, "ENOENT")) return;
2631
+ throw error;
2632
+ }
2633
+ const reports = [];
2634
+ for (const entry of entries) {
2635
+ if (!entry.isDirectory()) continue;
2636
+ const path = join$1(stopGateTempRoot, entry.name, reportFileName);
2637
+ try {
2638
+ const stats = await stat(path);
2639
+ reports.push({
2640
+ mtimeMs: stats.mtimeMs,
2641
+ path,
2642
+ size: stats.size
2643
+ });
2644
+ } catch (error) {
2645
+ if (!isNodeErrorCode(error, "ENOENT")) throw error;
2364
2646
  }
2365
- lines.push("");
2366
2647
  }
2367
- lines.push("", formatSummary(diagnostics, result, style));
2368
- return `${lines.join("\n")}\n`;
2648
+ let total = reports.reduce((sum, report) => sum + report.size, 0);
2649
+ for (const report of reports.sort((left, right) => left.mtimeMs - right.mtimeMs)) {
2650
+ if (total <= reportLimitBytes) break;
2651
+ if (report.path === currentReportPath) continue;
2652
+ await rm(report.path, { force: true });
2653
+ total -= report.size;
2654
+ }
2369
2655
  }
2370
- function countCachedDiagnostics(diagnostics, severity) {
2371
- return diagnostics.filter((diagnostic) => diagnostic.severity === severity && diagnostic.cached === true).length;
2656
+ function getReportPath(sessionId) {
2657
+ return join$1(getSessionDirectory(sessionId), reportFileName);
2372
2658
  }
2373
- function countDiagnostics$1(diagnostics, severity) {
2374
- return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
2659
+ function getSessionDirectory(sessionId) {
2660
+ if (sessionId === "." || sessionId === ".." || !/^[\w.-]+$/u.test(sessionId)) throw new Error("Invalid Stop hook session id.");
2661
+ return join$1(stopGateTempRoot, sessionId);
2375
2662
  }
2376
- function createStyle(color) {
2377
- if (!color) return {
2378
- error: identity,
2379
- file: identity,
2380
- location: identity,
2381
- ruleId: identity,
2382
- summaryToken: identity,
2383
- warning: identity
2663
+ //#endregion
2664
+ //#region src/cli/commands/lint/discovery.ts
2665
+ const minimatchOptions = { dot: true };
2666
+ const directoryBufferSize = 256;
2667
+ var NoFilesFoundError = class extends Error {
2668
+ globInputPaths;
2669
+ pattern;
2670
+ constructor(pattern, options) {
2671
+ super(`No files matching "${pattern}" were found${options.globInputPaths ? "" : " (glob input is disabled)"}.`);
2672
+ this.name = "NoFilesFoundError";
2673
+ this.globInputPaths = options.globInputPaths;
2674
+ this.pattern = pattern;
2675
+ }
2676
+ };
2677
+ async function findDirtyLintTargets(config, cwd) {
2678
+ const changes = await findDirtyChanges(cwd);
2679
+ if (changes.files.length === 0) return {
2680
+ changedLines: changes.changedLines,
2681
+ directories: [],
2682
+ files: []
2384
2683
  };
2385
2684
  return {
2386
- error: colors$3.red,
2387
- file: colors$3.underline,
2388
- location: colors$3.dim,
2389
- ruleId: colors$3.dim,
2390
- summaryToken: colors$3.cyan,
2391
- warning: colors$3.yellow
2685
+ ...await findLintTargets({
2686
+ config,
2687
+ cwd,
2688
+ errorOnUnmatchedPattern: false,
2689
+ globInputPaths: false,
2690
+ inputs: changes.files
2691
+ }),
2692
+ changedLines: changes.changedLines
2392
2693
  };
2393
2694
  }
2394
- function formatDiagnosticCount(total, cached, label) {
2395
- return `${total} ${label}${cached > 0 ? ` (${cached} cached)` : ""}`;
2695
+ async function findLintTargets(options) {
2696
+ const { config, cwd, errorOnUnmatchedPattern = true, globInputPaths = true, inputs } = options;
2697
+ const gitignore = shouldFilterGitignoredFiles(config) ? new Gitignore() : void 0;
2698
+ const targets = await resolveInputFiles({
2699
+ config,
2700
+ cwd,
2701
+ errorOnUnmatchedPattern,
2702
+ gitignore,
2703
+ globInputPaths,
2704
+ inputs: inputs.length > 0 ? inputs : ["."]
2705
+ });
2706
+ if (!gitignore || targets.files.length === 0) return targets;
2707
+ const lintFiles = [];
2708
+ for (const file of targets.files) {
2709
+ if (await gitignore.ignores(resolve(cwd, file))) continue;
2710
+ lintFiles.push(file);
2711
+ }
2712
+ return {
2713
+ directories: targets.directories,
2714
+ files: lintFiles
2715
+ };
2396
2716
  }
2397
- function formatExecutionSummary(execution) {
2398
- if (!execution || execution.cached === 0) return;
2399
- return `${execution.cached}/${execution.planned} cached`;
2717
+ function collectGlobalIgnorePatterns(config) {
2718
+ return normalizeConfig(config).flatMap((item) => isGlobalIgnoreItem(item) ? [...item.ignores] : []);
2400
2719
  }
2401
- function formatSummary(diagnostics, result, style) {
2402
- const warnCount = countDiagnostics$1(diagnostics, "warn");
2403
- const errorCount = countDiagnostics$1(diagnostics, "error");
2404
- const cachedWarnCount = countCachedDiagnostics(diagnostics, "warn");
2405
- const cachedErrorCount = countCachedDiagnostics(diagnostics, "error");
2406
- const tokens = result === void 0 ? void 0 : formatTokenSummary(result);
2407
- const problemSummary = [style.warning(formatDiagnosticCount(warnCount, cachedWarnCount, "warn")), style.error(formatDiagnosticCount(errorCount, cachedErrorCount, "error"))].join(" / ");
2408
- if (tokens === void 0) return problemSummary;
2409
- const execution = formatExecutionSummary(result?.execution);
2410
- return [
2411
- problemSummary,
2412
- style.summaryToken(tokens),
2413
- execution
2414
- ].filter((segment) => segment !== void 0).join(" | ");
2720
+ function getGlobParent(input) {
2721
+ const segments = input.replaceAll("\\", "/").split("/");
2722
+ const parentSegments = [];
2723
+ for (const segment of segments) {
2724
+ if (isGlobPattern(segment)) break;
2725
+ parentSegments.push(segment);
2726
+ }
2727
+ const parent = parentSegments.join("/");
2728
+ return parent === "" ? "." : parent;
2415
2729
  }
2416
- function formatTokenSummary(result) {
2417
- const liveTokens = result.usage.totalTokens.toLocaleString("en-US");
2418
- const cachedTokens = result.usage.cached?.totalTokens ?? 0;
2419
- return `${liveTokens} tokens${cachedTokens > 0 ? ` (${cachedTokens.toLocaleString("en-US")} cached)` : ""}`;
2730
+ function isGlobalIgnoreItem(item) {
2731
+ const keys = Object.keys(item).filter((key) => item[key] !== void 0);
2732
+ return item.ignores !== void 0 && keys.every((key) => key === "ignores" || key === "name");
2420
2733
  }
2421
- function identity(value) {
2422
- return value;
2734
+ function isGlobPattern(input) {
2735
+ return new Minimatch(input, minimatchOptions).hasMagic();
2423
2736
  }
2424
- //#endregion
2425
- //#region src/cli/reporters/index.ts
2426
- function formatDiagnostics(format, result, options = {}) {
2427
- if (format === "json") return formatJson(result);
2428
- if (format === "stylish") return formatStylish(result, { color: options.color });
2429
- throw new Error(`Unknown reporter "${format}".`);
2737
+ function matchesGlob(filePath, pattern) {
2738
+ return minimatch(filePath, pattern.replaceAll("\\", "/"), minimatchOptions);
2430
2739
  }
2431
- //#endregion
2432
- //#region src/cli/reporters/progress/plain.ts
2433
- function createPlainProgressReporter(options) {
2434
- const writeLine = (line) => options.write(`${line}\n`);
2435
- return {
2436
- onJobRetry: () => {},
2437
- onJobStart: (payload) => {
2438
- const target = payload.job.target.name ? `${payload.job.target.kind} ${payload.job.target.name}` : payload.job.target.kind;
2439
- writeLine(`scan ${payload.job.inputPath} > ${target} > ${payload.job.ruleId}`);
2440
- },
2441
- onPrepareEnd: ({ filesTotal }) => writeLine(`alint prepared: ${filesTotal} files`),
2442
- onPrepareStart: () => writeLine("alint preparing"),
2443
- onRunEnd: (payload) => {
2444
- const warnCount = countDiagnostics(payload.diagnostics, "warn");
2445
- const errorCount = countDiagnostics(payload.diagnostics, "error");
2446
- const state = payload.execution.failed > 0 ? "failed" : "finished";
2447
- const { cached, cancelled, completed, failed, skipped } = payload.execution;
2448
- writeLine(`alint ${state}: ${warnCount} warn, ${errorCount} error, ${payload.usage.totalTokens} tokens, ${completed} completed, ${cached} cached, ${failed} failed, ${cancelled} cancelled, ${skipped} skipped`);
2449
- }
2450
- };
2740
+ function matchesIgnoredDirectory(relativePath, patterns) {
2741
+ return patterns.some((pattern) => minimatch(relativePath, pattern, minimatchOptions) || minimatch(`${relativePath}/`, pattern, minimatchOptions) || minimatch(`${relativePath}/__alint__`, pattern, minimatchOptions));
2451
2742
  }
2452
- function countDiagnostics(diagnostics, severity) {
2453
- return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
2743
+ function normalizeGlobPattern(cwd, pattern) {
2744
+ return isAbsolute(pattern) ? normalizeRelativePath(cwd, pattern) : pattern.replaceAll("\\", "/");
2454
2745
  }
2455
- //#endregion
2456
- //#region src/cli/reporters/progress/summary.ts
2457
- const colors$2 = createColors({ force: true });
2458
- const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2459
- function createSummaryProgressReporter(options) {
2460
- const now = Date.now;
2461
- const state = createInitialState();
2462
- return {
2463
- getRows: () => createRows(state, options, now()),
2464
- onDiagnostic: ({ diagnostic, progress }) => {
2465
- updateProgress(state, progress);
2466
- if (diagnostic.severity === "warn") state.warnCount += 1;
2467
- else state.errorCount += 1;
2468
- },
2469
- onExecuteEnd: ({ progress }) => {
2470
- updateProgress(state, progress);
2471
- },
2472
- onExecuteStart: ({ progress, startedAt }) => {
2473
- updateProgress(state, progress);
2474
- state.runStartedAt = startedAt ?? state.runStartedAt ?? now();
2475
- },
2476
- onFileReady: ({ progress }) => updateProgress(state, progress),
2477
- onJobEnd: (payload) => {
2478
- updateProgress(state, payload.progress);
2479
- const current = state.activeJobs.get(payload.job.id);
2480
- const group = state.ruleGroups.get(payload.job.ruleId);
2481
- if (!group) throw new Error(`Cannot finish unqueued progress job "${payload.job.id}".`);
2482
- group[payload.state] += 1;
2483
- if (payload.endedAt !== void 0 && current?.startedAt !== void 0 && payload.state !== "cached") group.terminalDurationMs += Math.max(payload.endedAt - current.startedAt, 0);
2484
- if (payload.state === "cached") {
2485
- const cachedTokens = state.jobTokens.get(payload.job.id) ?? 0;
2486
- state.totalTokens = Math.max(state.totalTokens - cachedTokens, 0);
2487
- state.cachedTokens += cachedTokens;
2746
+ function normalizeRelativePath(cwd, filePath) {
2747
+ return relative(cwd, filePath).replaceAll("\\", "/");
2748
+ }
2749
+ async function resolveInputFiles(options) {
2750
+ const hasFilePatterns = hasDiscoveryFilePatterns(options.config);
2751
+ const ignoredPatterns = collectGlobalIgnorePatterns(options.config);
2752
+ const candidates = [];
2753
+ const directories = [];
2754
+ for (const input of options.inputs) {
2755
+ const path = resolve(options.cwd, input);
2756
+ const stats = await statPath(path);
2757
+ if (stats?.isFile()) {
2758
+ candidates.push(input);
2759
+ continue;
2760
+ }
2761
+ if (stats?.isDirectory()) {
2762
+ if (await shouldPruneDirectory(path, {
2763
+ cwd: options.cwd,
2764
+ gitignore: options.gitignore,
2765
+ ignoredPatterns
2766
+ })) continue;
2767
+ directories.push(input);
2768
+ if (!hasFilePatterns) continue;
2769
+ const directoryFiles = [];
2770
+ for await (const file of walkFiles(path, {
2771
+ cwd: options.cwd,
2772
+ gitignore: options.gitignore,
2773
+ ignoredPatterns
2774
+ })) {
2775
+ const relativePath = normalizeRelativePath(options.cwd, file);
2776
+ if (hasFilePatterns && matchesDiscoveryFile(relativePath, options.config, { cwd: options.cwd })) directoryFiles.push(relativePath);
2488
2777
  }
2489
- state.activeJobs.delete(payload.job.id);
2490
- state.jobTokens.delete(payload.job.id);
2491
- },
2492
- onJobQueued: ({ job, progress }) => {
2493
- updateProgress(state, progress);
2494
- const group = state.ruleGroups.get(job.ruleId) ?? createRuleGroup(job);
2495
- group.planned += 1;
2496
- state.ruleGroups.set(job.ruleId, group);
2497
- state.activeJobs.set(job.id, {
2498
- job,
2499
- state: "queued"
2778
+ for (const file of directoryFiles.sort()) candidates.push(file);
2779
+ continue;
2780
+ }
2781
+ if (options.globInputPaths && isGlobPattern(input)) {
2782
+ const matches = await searchGlob({
2783
+ config: options.config,
2784
+ cwd: options.cwd,
2785
+ gitignore: options.gitignore,
2786
+ hasFilePatterns,
2787
+ ignoredPatterns,
2788
+ pattern: input
2500
2789
  });
2501
- },
2502
- onJobRetry: (payload) => {
2503
- updateProgress(state, payload.progress);
2504
- const current = state.activeJobs.get(payload.job.id);
2505
- state.activeJobs.set(payload.job.id, {
2506
- job: payload.job,
2507
- retry: {
2508
- attempt: payload.attempt,
2509
- maxAttempts: payload.maxAttempts,
2510
- startedAt: payload.startedAt
2511
- },
2512
- startedAt: current?.startedAt ?? payload.startedAt ?? now(),
2513
- state: current?.state ?? "running"
2790
+ if (matches.length === 0 && options.errorOnUnmatchedPattern) throw new NoFilesFoundError(input, { globInputPaths: options.globInputPaths });
2791
+ for (const match of matches) candidates.push(match);
2792
+ continue;
2793
+ }
2794
+ if (options.errorOnUnmatchedPattern) throw new NoFilesFoundError(input, { globInputPaths: options.globInputPaths });
2795
+ }
2796
+ return {
2797
+ directories: [...new Set(directories)],
2798
+ files: [...new Set(candidates)]
2799
+ };
2800
+ }
2801
+ async function searchGlob(options) {
2802
+ const root = resolve(options.cwd, getGlobParent(options.pattern));
2803
+ const rootStats = await statPath(root);
2804
+ const pattern = normalizeGlobPattern(options.cwd, options.pattern);
2805
+ if (!rootStats?.isDirectory()) return [];
2806
+ const candidates = [];
2807
+ for await (const file of walkFiles(root, {
2808
+ cwd: options.cwd,
2809
+ gitignore: options.gitignore,
2810
+ ignoredPatterns: options.ignoredPatterns
2811
+ })) {
2812
+ const relativePath = normalizeRelativePath(options.cwd, file);
2813
+ if (matchesGlob(relativePath, pattern) && shouldIncludeGlobCandidate(relativePath, options.config, {
2814
+ cwd: options.cwd,
2815
+ hasFilePatterns: options.hasFilePatterns
2816
+ })) candidates.push(relativePath);
2817
+ }
2818
+ return candidates.sort();
2819
+ }
2820
+ function shouldFilterGitignoredFiles(config) {
2821
+ return normalizeConfig(config).some((item) => item.ignore?.gitignore === true);
2822
+ }
2823
+ function shouldIncludeGlobCandidate(filePath, config, options) {
2824
+ if (options.hasFilePatterns) return matchesDiscoveryFile(filePath, config, { cwd: options.cwd });
2825
+ return !resolveConfigForFile(filePath, config, { cwd: options.cwd }).ignored;
2826
+ }
2827
+ async function shouldPruneDirectory(path, options) {
2828
+ if (matchesIgnoredDirectory(normalizeRelativePath(options.cwd, path), options.ignoredPatterns)) return true;
2829
+ return await options.gitignore?.ignores(`${path}/`) === true;
2830
+ }
2831
+ async function statPath(path) {
2832
+ try {
2833
+ return await stat(path);
2834
+ } catch (error) {
2835
+ if (isNodeErrorCode(error, "ENOENT") || isNodeErrorCode(error, "ENOTDIR")) return;
2836
+ throw error;
2837
+ }
2838
+ }
2839
+ /**
2840
+ * Streams directory entries so unmatched trees never accumulate into recursive child arrays.
2841
+ * The consumer owns result ordering because filesystem iteration order is not stable.
2842
+ */
2843
+ async function* walkFiles(root, options) {
2844
+ const pendingDirectories = [root];
2845
+ while (pendingDirectories.length > 0) {
2846
+ const currentDirectory = pendingDirectories.pop();
2847
+ if (currentDirectory === void 0) continue;
2848
+ const childDirectories = [];
2849
+ const directory = await opendir(currentDirectory, { bufferSize: directoryBufferSize });
2850
+ for await (const entry of directory) {
2851
+ const path = resolve(currentDirectory, entry.name);
2852
+ if (entry.isDirectory()) {
2853
+ if (!await shouldPruneDirectory(path, options)) childDirectories.push(path);
2854
+ continue;
2855
+ }
2856
+ if (entry.isFile()) yield path;
2857
+ }
2858
+ for (const path of childDirectories.reverse()) pendingDirectories.push(path);
2859
+ }
2860
+ }
2861
+ //#endregion
2862
+ //#region src/cli/runtime/runner.ts
2863
+ /**
2864
+ * Merges the setup config runner with the project config runner. No CLI flag is applied.
2865
+ *
2866
+ * `runner.cache` selects the cache file. The lint command and the language server must merge it
2867
+ * the same way, or the editor reads a different file and reports nothing.
2868
+ */
2869
+ function mergeRunnerConfigs(setupRunner, configRunner) {
2870
+ const runner = {
2871
+ ...setupRunner ?? {},
2872
+ ...configRunner ?? {},
2873
+ cache: mergeCacheConfig(setupRunner?.cache, configRunner?.cache),
2874
+ ruleConcurrency: configRunner?.ruleConcurrency ?? setupRunner?.ruleConcurrency,
2875
+ stats: mergeStatsConfig(setupRunner?.stats, configRunner?.stats),
2876
+ timeoutMs: configRunner?.timeoutMs ?? setupRunner?.timeoutMs
2877
+ };
2878
+ return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
2879
+ }
2880
+ /** The last `runner` block in the flattened config wins, key by key. */
2881
+ function resolveConfigRunner(config) {
2882
+ return normalizeConfig(config).reduce((merged, item) => item.runner ? {
2883
+ ...merged,
2884
+ ...item.runner
2885
+ } : merged, void 0);
2886
+ }
2887
+ function mergeCacheConfig(setupCache, configCache) {
2888
+ if (configCache === void 0) return setupCache;
2889
+ if (typeof configCache === "boolean") return configCache;
2890
+ if (typeof setupCache === "object") return {
2891
+ ...setupCache,
2892
+ ...configCache
2893
+ };
2894
+ return configCache;
2895
+ }
2896
+ function mergeStatsConfig(setupStats, configStats) {
2897
+ if (configStats === void 0) return setupStats;
2898
+ if (typeof configStats === "boolean") return configStats;
2899
+ if (typeof setupStats === "object") return {
2900
+ ...setupStats,
2901
+ ...configStats
2902
+ };
2903
+ return configStats;
2904
+ }
2905
+ //#endregion
2906
+ //#region src/cli/runtime/session.ts
2907
+ /**
2908
+ * Loads config, setup config, and model adapters once, then runs any number of times.
2909
+ *
2910
+ * The lint command and the language server both use this function. A second implementation would
2911
+ * resolve config or cwd differently, and the editor would then read a different cache file.
2912
+ */
2913
+ async function createRunSession(io, options = {}) {
2914
+ const [{ defaultModel, setupConfig: fileSetupConfig }, config] = await Promise.all([loadRunSetupConfig(io), options.config ?? loadAlintConfig(io.cwd, options.configPath)]);
2915
+ const runtime = await (options.startModelAdapters ?? startModelAdapters)(fileSetupConfig, io);
2916
+ const { setupConfig } = runtime;
2917
+ let shutdown;
2918
+ return {
2919
+ config,
2920
+ cwd: io.cwd,
2921
+ defaultModel,
2922
+ run: async (runOptions) => {
2923
+ const targets = runOptions.targets ?? await findLintTargets({
2924
+ config,
2925
+ cwd: io.cwd,
2926
+ errorOnUnmatchedPattern: true,
2927
+ globInputPaths: true,
2928
+ inputs: runOptions.inputs ?? []
2514
2929
  });
2515
- },
2516
- onJobStart: (payload) => {
2517
- updateProgress(state, payload.progress);
2518
- state.activeJobs.set(payload.job.id, {
2519
- job: payload.job,
2520
- startedAt: payload.startedAt ?? now(),
2521
- state: "running"
2930
+ return runAlint({
2931
+ cacheOnly: runOptions.cacheOnly,
2932
+ config,
2933
+ cwd: io.cwd,
2934
+ defaultModel,
2935
+ directories: targets.directories,
2936
+ files: targets.files,
2937
+ modelOverride: runOptions.modelOverride,
2938
+ outputLanguage: runOptions.outputLanguage,
2939
+ progress: runOptions.progress,
2940
+ projectTargets: runOptions.projectTargets,
2941
+ runner: runOptions.runner,
2942
+ setupConfig,
2943
+ signal: runOptions.signal
2522
2944
  });
2523
2945
  },
2524
- onPlanningEnd: ({ progress }) => updateProgress(state, progress),
2525
- onPrepareStart: ({ startedAt }) => {
2526
- state.activeJobs.clear();
2527
- state.errorCount = 0;
2528
- state.execution = createCounts();
2529
- state.progress = createProgress();
2530
- state.ruleGroups.clear();
2531
- state.runStartedAt = startedAt ?? now();
2532
- state.animationTick = 0;
2533
- state.cachedTokens = 0;
2534
- state.jobTokens.clear();
2535
- state.totalTokens = 0;
2536
- state.warnCount = 0;
2537
- },
2538
- onRunEnd: (payload) => {
2539
- state.warnCount = payload.diagnostics.filter((diagnostic) => diagnostic.severity === "warn").length;
2540
- state.errorCount = payload.diagnostics.length - state.warnCount;
2541
- updateProgress(state, payload.progress);
2542
- state.totalTokens = payload.usage.totalTokens;
2543
- state.cachedTokens = payload.usage.cached?.totalTokens ?? 0;
2544
- state.runStartedAt = payload.startedAt ?? state.runStartedAt;
2545
- },
2546
- onUsage: ({ job, progress, record }) => {
2547
- updateProgress(state, progress);
2548
- if (record.totalTokens != null && Number.isFinite(record.totalTokens)) {
2549
- state.jobTokens.set(job.id, (state.jobTokens.get(job.id) ?? 0) + record.totalTokens);
2550
- state.totalTokens += record.totalTokens;
2551
- }
2552
- },
2553
- /**
2554
- * Advances the summary animation on each renderer interval.
2555
- *
2556
- * Triggering workflow:
2557
- *
2558
- * `createTtyProgressRenderer`
2559
- * -> `createCliProgressReporter` `createInterval` callback
2560
- * -> `SummaryProgressReporter.tick` (this handler)
2561
- *
2562
- * Upstream:
2563
- * - `createCliProgressReporter` `createInterval` callback registered by `createTtyProgressRenderer`
2564
- *
2565
- * Downstream:
2566
- * - Increments `state.animationTick`, consumed by {@link createRows}, {@link formatSpinnerFrame}, and {@link formatMiniBarSegment}.
2567
- */
2568
- tick: () => {
2569
- state.animationTick += 1;
2570
- }
2946
+ runner: mergeRunnerConfigs(setupConfig.runner, resolveConfigRunner(config)),
2947
+ setupConfig,
2948
+ shutdown: () => shutdown ??= runtime.shutdown()
2571
2949
  };
2572
2950
  }
2573
- function compareRuleGroups(left, right) {
2574
- const leftActive = left.jobs.some((job) => job.state === "running") ? 1 : 0;
2575
- const rightActive = right.jobs.some((job) => job.state === "running") ? 1 : 0;
2576
- if (leftActive !== rightActive) return rightActive - leftActive;
2577
- if (left.failed !== right.failed) return right.failed - left.failed;
2578
- return left.firstJobIndex - right.firstJobIndex;
2951
+ //#endregion
2952
+ //#region src/cli/commands/lint/changed-lines.ts
2953
+ /** Keeps file-wide diagnostics and diagnostics whose locations intersect the current Git change. */
2954
+ function filterResultToChangedLines(result, options) {
2955
+ return {
2956
+ ...result,
2957
+ diagnostics: result.diagnostics.filter((diagnostic) => {
2958
+ if (diagnostic.loc === void 0) return true;
2959
+ const changedLinesPath = (isAbsolute(diagnostic.filePath) ? relative(options.cwd, diagnostic.filePath) : diagnostic.filePath).replaceAll("\\", "/");
2960
+ const ranges = options.changedLines.get(changedLinesPath);
2961
+ if (ranges === void 0) return false;
2962
+ const startLine = diagnostic.loc.start.line;
2963
+ const endLine = diagnostic.loc.end?.line ?? startLine;
2964
+ return ranges.some((range) => startLine <= range.endLine && endLine >= range.startLine);
2965
+ })
2966
+ };
2579
2967
  }
2580
- function compareRunningJobs(left, right) {
2581
- const leftStartedAt = left.startedAt ?? Number.POSITIVE_INFINITY;
2582
- const rightStartedAt = right.startedAt ?? Number.POSITIVE_INFINITY;
2583
- if (leftStartedAt !== rightStartedAt) return leftStartedAt - rightStartedAt;
2584
- return left.job.index - right.job.index;
2968
+ //#endregion
2969
+ //#region src/cli/commands/lint/runner.ts
2970
+ /**
2971
+ * Applies this invocation's flags on top of the project's resolved runner.
2972
+ *
2973
+ * The setup/config merge itself lives in `runtime/runner.ts` because the language server needs the
2974
+ * same base without any flags; only the overrides below are CLI-shaped.
2975
+ */
2976
+ function resolveRunnerConfig(baseRunner, options) {
2977
+ const ruleConcurrency = parsePositiveIntegerOption(options.ruleConcurrency, "--rule-concurrency");
2978
+ const timeoutMs = parsePositiveIntegerOption(options.timeoutMs, "--timeout-ms");
2979
+ const runner = {
2980
+ ...baseRunner ?? {},
2981
+ cache: resolveCacheOption(baseRunner?.cache, options),
2982
+ ruleConcurrency: ruleConcurrency ?? baseRunner?.ruleConcurrency,
2983
+ stats: options.stats === false ? false : baseRunner?.stats,
2984
+ timeoutMs: timeoutMs ?? baseRunner?.timeoutMs
2985
+ };
2986
+ return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
2585
2987
  }
2586
- function completeGraphemeBoundary(input, maximumIndex) {
2587
- let boundary = 0;
2588
- for (const segment of graphemeSegmenter.segment(input)) {
2589
- const end = segment.index + segment.segment.length;
2590
- if (end > maximumIndex) break;
2591
- boundary = end;
2988
+ function parsePositiveIntegerOption(value, label) {
2989
+ if (value === void 0) return;
2990
+ const parsed = Number(value);
2991
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer.`);
2992
+ return parsed;
2993
+ }
2994
+ function resolveCacheOption(baseCache, options) {
2995
+ if (options.cache === false) return false;
2996
+ if (options.cacheLocation !== void 0) return typeof baseCache === "object" ? {
2997
+ ...baseCache,
2998
+ location: options.cacheLocation
2999
+ } : { location: options.cacheLocation };
3000
+ return baseCache;
3001
+ }
3002
+ //#endregion
3003
+ //#region src/cli/stats/ci.ts
3004
+ function isCi(env = process.env) {
3005
+ const ci = env.CI;
3006
+ if (ci !== void 0 && ci !== "" && ci !== "0" && ci !== "false") return true;
3007
+ return Boolean(env.GITHUB_ACTIONS || env.GITLAB_CI || env.CIRCLECI || env.BUILDKITE || env.TF_BUILD);
3008
+ }
3009
+ //#endregion
3010
+ //#region src/cli/stats/since.ts
3011
+ /**
3012
+ * Parse a `--since` time into an inclusive lower-bound timestamp.
3013
+ *
3014
+ * Supported formats:
3015
+ * - `Nh`/`Nd`/`Nw`/`Nm`/`Ny` — now minus N hours/days/weeks/months/years.
3016
+ * Weeks, months, and years are fixed approximations (7/30/365 days). `m` is
3017
+ * months here, not minutes — a day is the finest window this tool tracks.
3018
+ * - `YYYY-MM`/`YYYY-MM-DD` — start of that UTC month/day.
3019
+ *
3020
+ * Edge cases:
3021
+ * - An empty time means "no lower bound".
3022
+ * - An unrecognized time throws.
3023
+ */
3024
+ const HOUR_MS = 36e5;
3025
+ const UNIT_MS = {
3026
+ d: 24 * HOUR_MS,
3027
+ h: HOUR_MS,
3028
+ m: 720 * HOUR_MS,
3029
+ w: 168 * HOUR_MS,
3030
+ y: 8760 * HOUR_MS
3031
+ };
3032
+ function parseSince(time, now) {
3033
+ if (time === void 0 || time === "") return;
3034
+ const relative = /^(\d+)([dhmwy])$/u.exec(time);
3035
+ if (relative) return now - Number(relative[1]) * UNIT_MS[relative[2]];
3036
+ const calendar = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/u.exec(time);
3037
+ if (calendar) {
3038
+ const year = Number(calendar[1]);
3039
+ const month = Number(calendar[2]);
3040
+ const day = calendar[3] === void 0 ? 1 : Number(calendar[3]);
3041
+ return Date.UTC(year, month - 1, day);
2592
3042
  }
2593
- return boundary;
3043
+ throw new Error(`Invalid --since "${time}": use e.g. 24h, 7d, 2w, 3m, 1y, 2025-01, or 2025-01-15.`);
2594
3044
  }
2595
- function createCounts(planned = 0) {
3045
+ //#endregion
3046
+ //#region src/cli/stats/jsonl.ts
3047
+ const DEFAULT_RETENTION_MONTHS = 12;
3048
+ const STATS_FILE_PATTERN = /^stats-(\d{4})-(\d{2})\.jsonl$/u;
3049
+ const DAY_MS = 864e5;
3050
+ const MONTH_LABELS$1 = [
3051
+ "Jan",
3052
+ "Feb",
3053
+ "Mar",
3054
+ "Apr",
3055
+ "May",
3056
+ "Jun",
3057
+ "Jul",
3058
+ "Aug",
3059
+ "Sep",
3060
+ "Oct",
3061
+ "Nov",
3062
+ "Dec"
3063
+ ];
3064
+ function createJsonlStatsStore(options) {
3065
+ const retentionMonths = options.retentionMonths ?? DEFAULT_RETENTION_MONTHS;
2596
3066
  return {
2597
- cached: 0,
2598
- cancelled: 0,
2599
- completed: 0,
2600
- failed: 0,
2601
- planned,
2602
- queued: 0,
2603
- running: 0,
2604
- skipped: 0
3067
+ query: (filter) => query(options.dir, filter ?? {}, Date.now),
3068
+ querySeries: (filter) => querySeries(options.dir, filter ?? {}, Date.now),
3069
+ record: (input) => record(options.dir, input, Date.now(), retentionMonths)
2605
3070
  };
2606
3071
  }
2607
- function createInitialState() {
2608
- return {
2609
- activeJobs: /* @__PURE__ */ new Map(),
2610
- animationTick: 0,
2611
- cachedTokens: 0,
2612
- errorCount: 0,
2613
- execution: createCounts(),
2614
- jobTokens: /* @__PURE__ */ new Map(),
2615
- progress: createProgress(),
2616
- ruleGroups: /* @__PURE__ */ new Map(),
2617
- totalTokens: 0,
2618
- warnCount: 0
3072
+ function accumulate(rows, key, inTok, outTok, totalTok, countRun) {
3073
+ const row = rows.get(key) ?? {
3074
+ inTok: 0,
3075
+ key,
3076
+ outTok: 0,
3077
+ runs: 0,
3078
+ totalTok: 0
2619
3079
  };
3080
+ row.inTok += inTok;
3081
+ row.outTok += outTok;
3082
+ row.totalTok += totalTok;
3083
+ if (countRun) row.runs += 1;
3084
+ rows.set(key, row);
3085
+ return row;
2620
3086
  }
2621
- function createProgress() {
2622
- return {
2623
- execution: createCounts(),
2624
- filesPlanned: 0,
2625
- filesTotal: 0,
2626
- jobsCompleted: 0,
2627
- jobsStarted: 0,
2628
- jobsTotal: 0,
2629
- planningComplete: false
2630
- };
3087
+ function bucketLabel(startMs, interval) {
3088
+ const date = new Date(startMs);
3089
+ if (interval === "month") return MONTH_LABELS$1[date.getUTCMonth()];
3090
+ return `${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
2631
3091
  }
2632
- function createRows(state, options, now) {
2633
- if (options.rows !== void 0 && options.rows <= 0) return [];
2634
- const groups = createRuleGroups(state);
2635
- const footerRows = options.rows === void 0 ? 3 : Math.min(options.rows, 3);
2636
- const separatorRows = options.rows === void 0 || options.rows - footerRows > 1 ? 1 : 0;
2637
- const visible = formatContentRows(groups, state, options, now, options.rows === void 0 ? Number.POSITIVE_INFINITY : Math.max(options.rows - footerRows - separatorRows, 0));
2638
- const footer = formatFooters(state, options, now);
2639
- const actualSeparatorRows = visible.length > 0 ? separatorRows : 0;
2640
- const visibleFooters = options.rows === void 0 ? footer : footer.slice(0, Math.max(options.rows - visible.length - actualSeparatorRows, 0));
2641
- if (actualSeparatorRows === 1) visible.push("");
2642
- visible.push(...visibleFooters);
2643
- return options.color ? visible.map((row) => styleRow(row, state, state.warnCount, state.errorCount, options)) : visible;
3092
+ function bucketStart(ts, interval) {
3093
+ const date = new Date(ts);
3094
+ if (interval === "month") return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
3095
+ const dayStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
3096
+ if (interval === "week") return dayStart - (new Date(dayStart).getUTCDay() + 6) % 7 * DAY_MS;
3097
+ return dayStart;
2644
3098
  }
2645
- function createRuleGroup(job) {
2646
- return {
2647
- cached: 0,
2648
- cancelled: 0,
2649
- completed: 0,
2650
- failed: 0,
2651
- firstJobIndex: job.index,
2652
- planned: 0,
2653
- ruleId: job.ruleId,
2654
- skipped: 0,
2655
- terminalDurationMs: 0
2656
- };
3099
+ function chooseInterval(since, nowMs, timestamps) {
3100
+ if (since !== void 0) return intervalForDuration(nowMs - since);
3101
+ if (timestamps.length < 2) return "day";
3102
+ return intervalForDuration(Math.max(...timestamps) - Math.min(...timestamps));
2657
3103
  }
2658
- function createRuleGroups(state) {
2659
- const groups = new Map([...state.ruleGroups].map(([ruleId, group]) => [ruleId, {
2660
- ...group,
2661
- jobs: []
2662
- }]));
2663
- for (const job of state.activeJobs.values()) groups.get(job.job.ruleId)?.jobs.push(job);
2664
- return [...groups.values()].sort(compareRuleGroups);
3104
+ function fillBuckets(buckets, interval) {
3105
+ if (buckets.size === 0) return [];
3106
+ const starts = [...buckets.keys()].sort((left, right) => left - right);
3107
+ const last = starts.at(-1);
3108
+ const filled = [];
3109
+ for (let startMs = starts[0]; startMs <= last; startMs = nextBucketStart(startMs, interval)) filled.push(buckets.get(startMs) ?? {
3110
+ inTok: 0,
3111
+ key: bucketLabel(startMs, interval),
3112
+ outTok: 0,
3113
+ runs: 0,
3114
+ startMs,
3115
+ totalTok: 0
3116
+ });
3117
+ return filled;
2665
3118
  }
2666
- function estimateEta(durationMs, terminal, planned) {
2667
- if (terminal <= 0 || planned <= 0 || durationMs <= 0) return "eta ?";
2668
- return `eta ~${formatDuration$1(durationMs * (planned - terminal) / terminal)}`;
3119
+ function intervalForDuration(ms) {
3120
+ const days = ms / DAY_MS;
3121
+ if (days <= 21) return "day";
3122
+ if (days <= 120) return "week";
3123
+ return "month";
2669
3124
  }
2670
- function fitRow(row, columns) {
2671
- if (columns <= 0) return "";
2672
- if (fastStringWidth(row) <= columns) return row;
2673
- let limit = columns;
2674
- while (limit > 0) {
2675
- const result = fastStringTruncatedWidth(row, {
2676
- ellipsis: "…",
2677
- limit
3125
+ function isEnoent(error) {
3126
+ return error instanceof Error && error.code === "ENOENT";
3127
+ }
3128
+ function monthIndexOf(ts) {
3129
+ const date = new Date(ts);
3130
+ return date.getUTCFullYear() * 12 + date.getUTCMonth();
3131
+ }
3132
+ function nextBucketStart(startMs, interval) {
3133
+ if (interval === "month") {
3134
+ const date = new Date(startMs);
3135
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
3136
+ }
3137
+ return startMs + (interval === "week" ? 7 * DAY_MS : DAY_MS);
3138
+ }
3139
+ function parseRunLine(line) {
3140
+ try {
3141
+ const value = JSON.parse(line);
3142
+ if (typeof value.ts === "number" && typeof value.cwd === "string" && value.usage != null) return value;
3143
+ return;
3144
+ } catch {
3145
+ return;
3146
+ }
3147
+ }
3148
+ async function prune(dir, ts, retentionMonths) {
3149
+ if (retentionMonths <= 0) return;
3150
+ const cutoff = monthIndexOf(ts) - (retentionMonths - 1);
3151
+ const files = await statsFiles(dir);
3152
+ for (const { month, name } of files) if (month < cutoff) await rm(join(dir, name));
3153
+ }
3154
+ async function query(dir, filter, now) {
3155
+ const dimension = filter.by ?? "model";
3156
+ const since = parseSince(filter.since, now());
3157
+ const rules = ruleSet(filter.rules);
3158
+ const runs = await readRuns(dir);
3159
+ const rows = /* @__PURE__ */ new Map();
3160
+ let totalRuns = 0;
3161
+ let totalIn = 0;
3162
+ let totalOut = 0;
3163
+ let totalTok = 0;
3164
+ for (const run of runs) {
3165
+ if (!runInWindow(run, since, filter.cwd)) continue;
3166
+ const slice = sliceRun(run, rules);
3167
+ if (!slice.touched) continue;
3168
+ totalRuns += 1;
3169
+ totalIn += slice.inTok;
3170
+ totalOut += slice.outTok;
3171
+ totalTok += slice.totalTok;
3172
+ if (dimension === "dir") {
3173
+ accumulate(rows, run.cwd, slice.inTok, slice.outTok, slice.totalTok, true);
3174
+ continue;
3175
+ }
3176
+ const seenKeys = /* @__PURE__ */ new Set();
3177
+ for (const usageRecord of slice.usageRecords) {
3178
+ const key = recordKey(usageRecord, dimension);
3179
+ accumulate(rows, key, usageRecord.inTok, usageRecord.outTok, usageRecord.totalTok, !seenKeys.has(key));
3180
+ seenKeys.add(key);
3181
+ }
3182
+ }
3183
+ return {
3184
+ dimension,
3185
+ rows: [...rows.values()].sort((left, right) => right.totalTok - left.totalTok),
3186
+ totalIn,
3187
+ totalOut,
3188
+ totalRuns,
3189
+ totalTok
3190
+ };
3191
+ }
3192
+ async function querySeries(dir, filter, now) {
3193
+ const nowMs = now();
3194
+ const since = parseSince(filter.since, nowMs);
3195
+ const rules = ruleSet(filter.rules);
3196
+ const runs = await readRuns(dir);
3197
+ const sliced = [];
3198
+ for (const run of runs) {
3199
+ if (!runInWindow(run, since, filter.cwd)) continue;
3200
+ const slice = sliceRun(run, rules);
3201
+ if (slice.touched) sliced.push({
3202
+ slice,
3203
+ ts: run.ts
2678
3204
  });
2679
- const index = completeGraphemeBoundary(row, result.index);
2680
- const visible = row.slice(0, index);
2681
- const fitted = `${visible}${visible.includes("\x1B") ? "\x1B[0m" : ""}${result.ellipsed ? "…" : ""}`;
2682
- const overflow = fastStringWidth(fitted) - columns;
2683
- if (overflow <= 0) return fitted;
2684
- limit -= overflow;
2685
3205
  }
2686
- return "";
2687
- }
2688
- function formatCollapsedRunningRow(hidden, options) {
2689
- return fitRow(` └─ ${hidden} more running`, options.columns);
3206
+ const interval = filter.interval ?? chooseInterval(since, nowMs, sliced.map((entry) => entry.ts));
3207
+ const buckets = /* @__PURE__ */ new Map();
3208
+ let totalRuns = 0;
3209
+ let totalIn = 0;
3210
+ let totalOut = 0;
3211
+ let totalTok = 0;
3212
+ for (const { slice, ts } of sliced) {
3213
+ const startMs = bucketStart(ts, interval);
3214
+ const bucket = buckets.get(startMs) ?? {
3215
+ inTok: 0,
3216
+ key: bucketLabel(startMs, interval),
3217
+ outTok: 0,
3218
+ runs: 0,
3219
+ startMs,
3220
+ totalTok: 0
3221
+ };
3222
+ bucket.runs += 1;
3223
+ bucket.inTok += slice.inTok;
3224
+ bucket.outTok += slice.outTok;
3225
+ bucket.totalTok += slice.totalTok;
3226
+ buckets.set(startMs, bucket);
3227
+ totalRuns += 1;
3228
+ totalIn += slice.inTok;
3229
+ totalOut += slice.outTok;
3230
+ totalTok += slice.totalTok;
3231
+ }
3232
+ return {
3233
+ buckets: fillBuckets(buckets, interval),
3234
+ interval,
3235
+ totalIn,
3236
+ totalOut,
3237
+ totalRuns,
3238
+ totalTok
3239
+ };
2690
3240
  }
2691
- function formatContentRows(groups, state, options, now, contentBudget) {
2692
- const rows = [];
2693
- const renderableGroups = groups.filter((group) => group.jobs.some((job) => job.state === "running") || group.failed > 0);
2694
- const runningCount = renderableGroups.reduce((sum, group) => sum + group.jobs.filter((job) => job.state === "running").length, 0);
2695
- if (contentBudget === 1) {
2696
- const failedGroup = renderableGroups.find((group) => group.failed > 0);
2697
- if (failedGroup) return [formatRuleRow(failedGroup, state, options)];
2698
- if (runningCount > 0) return [formatCollapsedRunningRow(runningCount, options)];
2699
- }
2700
- for (const [groupIndex, group] of renderableGroups.entries()) {
2701
- if (rows.length >= contentBudget) break;
2702
- const groupStart = rows.length;
2703
- rows.push(formatRuleRow(group, state, options));
2704
- const runningJobs = group.jobs.filter((job) => job.state === "running").sort(compareRunningJobs);
2705
- const remainingGroups = renderableGroups.length - groupIndex - 1;
2706
- for (const [index, job] of runningJobs.entries()) {
2707
- const rowsReservedForLaterGroups = Math.min(remainingGroups, Math.max(contentBudget - rows.length, 0));
2708
- if (rows.length >= contentBudget) {
2709
- const hidden = runningJobs.length - index;
2710
- if (rows.length > groupStart + 1 && hidden > 0) rows[rows.length - 1] = formatCollapsedRunningRow(hidden, options);
2711
- break;
2712
- }
2713
- if (rows.length + 1 + rowsReservedForLaterGroups >= contentBudget) {
2714
- if (runningJobs.length > 1) rows.push(formatCollapsedRunningRow(runningJobs.length - index, options));
2715
- break;
2716
- }
2717
- rows.push(formatTargetRow(job, options, now, index === runningJobs.length - 1));
3241
+ async function readRuns(dir) {
3242
+ const files = await statsFiles(dir);
3243
+ const runs = [];
3244
+ for (const { name } of files) {
3245
+ const content = await readFile(join(dir, name), "utf8");
3246
+ for (const line of content.split("\n")) {
3247
+ if (line.trim() === "") continue;
3248
+ const run = parseRunLine(line);
3249
+ if (run) runs.push(run);
2718
3250
  }
2719
3251
  }
2720
- return rows;
2721
- }
2722
- function formatDuration$1(ms) {
2723
- const durationMs = Math.max(ms, 0);
2724
- if (durationMs < 6e4) return `${(durationMs / 1e3).toFixed(1)}s`;
2725
- return formatDuration(intervalToDuration({
2726
- end: durationMs,
2727
- start: 0
2728
- }));
3252
+ return runs;
2729
3253
  }
2730
- function formatFooters(state, options, now) {
2731
- const terminal = terminalCount(state.execution);
2732
- const elapsed = state.runStartedAt === void 0 ? 0 : now - state.runStartedAt;
2733
- const progressBar = state.progress.planningComplete ? formatMiniBarSegment(terminal, state.execution.planned, state.animationTick, options) : "";
2734
- const eta = state.progress.planningComplete ? estimateEta(elapsed, terminal, state.execution.planned) : "eta ?";
2735
- const projectedTokens = state.progress.planningComplete && terminal > 0 && state.execution.planned > 0 ? Math.ceil((state.totalTokens + state.cachedTokens) * state.execution.planned / terminal) : void 0;
2736
- const discovering = state.progress.planningComplete ? "" : " jobs (discovering)";
2737
- return [
2738
- fitRow(`${terminal}/${state.execution.planned}${discovering}${progressBar} ${formatDuration$1(elapsed)} -> ${eta === "eta ?" ? "~?" : eta.replace("eta ", "")}`, options.columns),
2739
- fitRow(`${state.execution.running} concurrency / ${state.execution.queued} queued / ${state.execution.cached} cached / ${state.execution.failed} failed`, options.columns),
2740
- fitRow(`${state.totalTokens.toLocaleString("en-US")} tokens (${state.cachedTokens.toLocaleString("en-US")} cached) -> ${projectedTokens === void 0 ? "~?" : `~${projectedTokens.toLocaleString("en-US")} tokens`}`, options.columns)
2741
- ];
3254
+ async function record(dir, input, ts, retentionMonths) {
3255
+ const stat = {
3256
+ ts,
3257
+ ...input
3258
+ };
3259
+ await mkdir(dir, { recursive: true });
3260
+ await appendFile(join(dir, statsFileName(ts)), `${JSON.stringify(stat)}\n`, "utf8");
3261
+ await prune(dir, ts, retentionMonths);
2742
3262
  }
2743
- function formatMiniBarSegment(completed, planned, tick, options) {
2744
- if (options.columns < 60) return "";
2745
- return ` ${formatMiniBar({
2746
- completed,
2747
- planned,
2748
- tick,
2749
- width: 10
2750
- })}`;
3263
+ function recordKey(record, dimension) {
3264
+ if (dimension === "rule") return record.ruleId;
3265
+ if (dimension === "operation") return record.operation ?? record.ruleId;
3266
+ return `${record.providerId}/${record.modelId}`;
2751
3267
  }
2752
- function formatRuleRow(group, state, options) {
2753
- const spinner = formatSpinnerFrame(state.animationTick, options.spinnerFrames);
2754
- const terminal = group.completed + group.cached + group.failed + group.cancelled + group.skipped;
2755
- const percent = group.planned > 0 ? Math.floor(terminal / group.planned * 100) : 0;
2756
- const bar = formatMiniBarSegment(terminal, group.planned, state.animationTick, options);
2757
- const running = group.jobs.filter((job) => job.state === "running").length;
2758
- const failed = group.failed > 0 ? ` ${group.failed} failed` : "";
2759
- const eta = estimateEta(group.terminalDurationMs, terminal, group.planned);
2760
- return fitRow(`${spinner} ${group.ruleId} ${terminal}/${group.planned} ${percent}%${bar} ${eta} ${running} running${failed}`, options.columns);
3268
+ function ruleSet(rules) {
3269
+ return rules && rules.length > 0 ? new Set(rules) : void 0;
2761
3270
  }
2762
- function formatSpinnerFrame(animationTick, spinnerFrames) {
2763
- return spinnerFrames[animationTick % Math.max(spinnerFrames.length, 1)] ?? "";
3271
+ function runInWindow(run, since, cwd) {
3272
+ return (since === void 0 || run.ts >= since) && (cwd === void 0 || run.cwd === cwd);
2764
3273
  }
2765
- function formatTargetRow(jobState, options, now, last) {
2766
- const inputPath = options.cwd ? relative$1(options.cwd, jobState.job.inputPath) || "." : jobState.job.inputPath;
2767
- const target = jobState.job.target.name ? `${jobState.job.target.kind} ${jobState.job.target.name}` : jobState.job.target.kind;
2768
- const startedAt = jobState.retry?.startedAt ?? jobState.startedAt ?? now;
2769
- 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);
3274
+ function sliceRun(run, rules) {
3275
+ if (rules === void 0) return {
3276
+ inTok: run.usage.inTok,
3277
+ outTok: run.usage.outTok,
3278
+ totalTok: run.usage.totalTok,
3279
+ touched: true,
3280
+ usageRecords: run.usage.records
3281
+ };
3282
+ const usageRecords = run.usage.records.filter((record) => rules.has(record.ruleId));
3283
+ return {
3284
+ inTok: sumBy(usageRecords, (record) => record.inTok),
3285
+ outTok: sumBy(usageRecords, (record) => record.outTok),
3286
+ totalTok: sumBy(usageRecords, (record) => record.totalTok),
3287
+ touched: usageRecords.length > 0,
3288
+ usageRecords
3289
+ };
2770
3290
  }
2771
- function styleRow(row, state, warnCount, errorCount, options) {
2772
- let styledRow = row;
2773
- const spinner = formatSpinnerFrame(state.animationTick, options.spinnerFrames);
2774
- if (spinner) styledRow = styledRow.replace(spinner, colors$2.cyan(spinner));
2775
- return styledRow.replace(`${state.execution.running} concurrency`, colors$2.cyan(`${state.execution.running} concurrency`)).replace(`${state.totalTokens.toLocaleString("en-US")} tokens`, colors$2.cyan(`${state.totalTokens.toLocaleString("en-US")} tokens`)).replace(`(${state.cachedTokens.toLocaleString("en-US")} cached)`, colors$2.dim(`(${state.cachedTokens.toLocaleString("en-US")} cached)`)).replace(` / ${state.execution.cached} cached / `, ` / ${colors$2.dim(`${state.execution.cached} cached`)} / `).replace(`${warnCount} warn`, colors$2.yellow(`${warnCount} warn`)).replace(`${errorCount} error`, (errorCount > 0 ? colors$2.red : colors$2.gray)(`${errorCount} error`)).replace(`${state.execution.failed} failed`, (state.execution.failed > 0 ? colors$2.red : colors$2.gray)(`${state.execution.failed} failed`)).replace(/\[([█▓░]*?)(░+)\]/g, (_, active, pending) => `[${active}${colors$2.gray(pending)}]`).replace(/\//g, colors$2.gray("/"));
3291
+ function statsFileName(ts) {
3292
+ const date = new Date(ts);
3293
+ return `stats-${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}.jsonl`;
2776
3294
  }
2777
- function terminalCount(counts) {
2778
- return counts.completed + counts.failed + counts.cached + counts.skipped + counts.cancelled;
3295
+ async function statsFiles(dir) {
3296
+ let entries;
3297
+ try {
3298
+ entries = await readdir(dir);
3299
+ } catch (error) {
3300
+ if (isEnoent(error)) return [];
3301
+ throw error;
3302
+ }
3303
+ return entries.map((name) => {
3304
+ const match = STATS_FILE_PATTERN.exec(name);
3305
+ return match ? {
3306
+ month: Number(match[1]) * 12 + (Number(match[2]) - 1),
3307
+ name
3308
+ } : void 0;
3309
+ }).filter((entry) => entry !== void 0).sort((left, right) => left.month - right.month);
2779
3310
  }
2780
- function updateProgress(state, progress) {
2781
- state.execution = { ...progress.execution };
2782
- state.progress = {
2783
- ...progress,
2784
- execution: { ...progress.execution }
2785
- };
3311
+ function sumBy(items, select) {
3312
+ let total = 0;
3313
+ for (const item of items) total += select(item);
3314
+ return total;
2786
3315
  }
2787
3316
  //#endregion
2788
- //#region src/cli/reporters/progress/tty.ts
2789
- const clearCurrentLine = "\r\x1B[K";
2790
- const clearPreviousLine = "\r\x1B[1A\x1B[K";
2791
- function createTtyProgressRenderer(options) {
2792
- let interval;
2793
- let previousRows = 0;
2794
- const clearPreviousFrame = () => {
2795
- if (previousRows === 0) return;
2796
- let sequence = clearCurrentLine;
2797
- for (let row = 1; row < previousRows; row += 1) sequence += clearPreviousLine;
2798
- options.write(sequence);
2799
- previousRows = 0;
3317
+ //#region src/cli/stats/record.ts
3318
+ function createRunStat(input) {
3319
+ return {
3320
+ cwd: input.cwd,
3321
+ durationMs: input.durationMs,
3322
+ ruleCounts: input.ruleCounts,
3323
+ usage: {
3324
+ inTok: input.usage.inputTokens,
3325
+ outTok: input.usage.outputTokens,
3326
+ records: input.usage.records.map(toStatsUsageRecord),
3327
+ totalTok: input.usage.totalTokens
3328
+ }
2800
3329
  };
2801
- const render = () => {
2802
- clearPreviousFrame();
2803
- const rows = options.getRows();
2804
- if (rows.length === 0) return;
2805
- options.write(rows.join("\n"));
2806
- previousRows = rows.length;
3330
+ }
3331
+ function readOperation(metadata) {
3332
+ if (typeof metadata !== "object" || metadata === null || !("operation" in metadata)) return;
3333
+ const operation = metadata.operation;
3334
+ return typeof operation === "string" ? operation : void 0;
3335
+ }
3336
+ function toStatsUsageRecord(record) {
3337
+ return {
3338
+ filePath: record.filePath,
3339
+ inTok: record.inputTokens ?? 0,
3340
+ modelId: record.modelId,
3341
+ operation: readOperation(record.metadata),
3342
+ outTok: record.outputTokens ?? 0,
3343
+ providerId: record.providerId,
3344
+ ruleId: record.ruleId,
3345
+ totalTok: record.totalTokens ?? 0
2807
3346
  };
2808
- const write = (chunk) => {
2809
- const wasRendering = interval !== void 0;
2810
- clearPreviousFrame();
2811
- options.write(chunk);
2812
- if (wasRendering) render();
3347
+ }
3348
+ //#endregion
3349
+ //#region src/cli/commands/lint/stats.ts
3350
+ function createStatsCollector() {
3351
+ const counts = {
3352
+ cached: 0,
3353
+ cancelled: 0,
3354
+ completed: 0,
3355
+ failed: 0,
3356
+ planned: 0
2813
3357
  };
3358
+ let startedAt;
3359
+ let endedAt;
2814
3360
  return {
2815
- finish: () => {
2816
- if (interval) {
2817
- options.clearInterval(interval);
2818
- interval = void 0;
2819
- }
2820
- clearPreviousFrame();
2821
- },
2822
- render,
2823
- start: () => {
2824
- if (!interval) {
2825
- interval = options.createInterval(render, options.intervalMs);
2826
- if (isUnrefableInterval(interval)) interval.unref();
3361
+ counts,
3362
+ durationMs: () => startedAt !== void 0 && endedAt !== void 0 ? endedAt - startedAt : void 0,
3363
+ reporter: {
3364
+ onPrepareStart: (payload) => {
3365
+ startedAt = payload.startedAt;
3366
+ },
3367
+ onRunEnd: (payload) => {
3368
+ counts.cached = payload.execution.cached;
3369
+ counts.cancelled = payload.execution.cancelled;
3370
+ counts.completed = payload.execution.completed;
3371
+ counts.failed = payload.execution.failed;
3372
+ counts.planned = payload.execution.planned;
3373
+ endedAt = payload.endedAt;
2827
3374
  }
2828
- render();
2829
- },
2830
- write
3375
+ }
2831
3376
  };
2832
3377
  }
2833
- function isUnrefableInterval(interval) {
2834
- if (typeof interval !== "object" || interval === null || !("unref" in interval)) return false;
2835
- return typeof interval.unref === "function";
2836
- }
2837
- //#endregion
2838
- //#region src/cli/reporters/progress/index.ts
2839
- function createCliProgressReporter(options) {
2840
- if (!options.isTty) return {
2841
- dispose: () => {},
2842
- reporter: createPlainProgressReporter({ write: options.write }),
2843
- write: options.write
3378
+ function mergeProgressReporters(base, extra) {
3379
+ if (!base) return extra;
3380
+ if (!extra) return base;
3381
+ const baseState = {
3382
+ disabled: false,
3383
+ reporter: base
2844
3384
  };
2845
- const summary = createSummaryProgressReporter({
2846
- color: options.color,
2847
- columns: options.columns,
2848
- cwd: options.cwd,
2849
- rows: options.rows,
2850
- spinnerFrames: cliSpinners.dots.frames
2851
- });
2852
- const renderer = createTtyProgressRenderer({
2853
- clearInterval: (handle) => globalThis.clearInterval(handle),
2854
- createInterval: (callback, intervalMs) => globalThis.setInterval(() => {
2855
- summary.tick();
2856
- callback();
2857
- }, intervalMs),
2858
- getRows: summary.getRows,
2859
- intervalMs: 120,
2860
- write: options.write
2861
- });
2862
- const reporter = createRenderingProgressReporter(summary, renderer);
2863
- return {
2864
- dispose: renderer.finish,
2865
- reporter,
2866
- write: renderer.write
3385
+ const extraState = {
3386
+ disabled: false,
3387
+ reporter: extra
3388
+ };
3389
+ let firstFailure;
3390
+ const deliver = (state, callback) => {
3391
+ if (state.disabled) return;
3392
+ try {
3393
+ callback(state.reporter);
3394
+ } catch (error) {
3395
+ state.disabled = true;
3396
+ firstFailure ??= normalizeReporterError(error);
3397
+ }
3398
+ };
3399
+ const deliverBoth = (callback) => {
3400
+ deliver(baseState, callback);
3401
+ deliver(extraState, callback);
2867
3402
  };
2868
- }
2869
- function createRenderingProgressReporter(summary, renderer) {
2870
3403
  return {
2871
3404
  onDiagnostic: (payload) => {
2872
- summary.onDiagnostic?.(payload);
2873
- renderer.render();
3405
+ deliverBoth((reporter) => reporter.onDiagnostic?.(payload));
2874
3406
  },
2875
3407
  onExecuteEnd: (payload) => {
2876
- summary.onExecuteEnd?.(payload);
2877
- renderer.render();
3408
+ deliverBoth((reporter) => reporter.onExecuteEnd?.(payload));
2878
3409
  },
2879
3410
  onExecuteStart: (payload) => {
2880
- summary.onExecuteStart?.(payload);
2881
- renderer.render();
3411
+ deliverBoth((reporter) => reporter.onExecuteStart?.(payload));
2882
3412
  },
2883
3413
  onFileReady: (payload) => {
2884
- summary.onFileReady?.(payload);
2885
- renderer.render();
3414
+ deliverBoth((reporter) => reporter.onFileReady?.(payload));
2886
3415
  },
2887
3416
  onJobEnd: (payload) => {
2888
- summary.onJobEnd?.(payload);
2889
- renderer.render();
3417
+ deliverBoth((reporter) => reporter.onJobEnd?.(payload));
2890
3418
  },
2891
3419
  onJobQueued: (payload) => {
2892
- summary.onJobQueued?.(payload);
2893
- renderer.render();
3420
+ deliverBoth((reporter) => reporter.onJobQueued?.(payload));
2894
3421
  },
2895
3422
  onJobRetry: (payload) => {
2896
- summary.onJobRetry?.(payload);
2897
- renderer.render();
3423
+ deliverBoth((reporter) => reporter.onJobRetry?.(payload));
2898
3424
  },
2899
3425
  onJobStart: (payload) => {
2900
- summary.onJobStart?.(payload);
2901
- renderer.render();
3426
+ deliverBoth((reporter) => reporter.onJobStart?.(payload));
3427
+ },
3428
+ onPlanningEnd: (payload) => {
3429
+ deliverBoth((reporter) => reporter.onPlanningEnd?.(payload));
2902
3430
  },
2903
3431
  onPrepareEnd: (payload) => {
2904
- summary.onPrepareEnd?.(payload);
2905
- renderer.render();
3432
+ deliverBoth((reporter) => reporter.onPrepareEnd?.(payload));
2906
3433
  },
2907
3434
  onPrepareStart: (payload) => {
2908
- summary.onPrepareStart?.(payload);
2909
- renderer.start();
3435
+ deliverBoth((reporter) => reporter.onPrepareStart?.(payload));
2910
3436
  },
2911
3437
  onRunEnd: (payload) => {
2912
- summary.onRunEnd?.(payload);
2913
- renderer.render();
2914
- },
2915
- onUsage: (payload) => {
2916
- summary.onUsage?.(payload);
2917
- renderer.render();
2918
- }
2919
- };
2920
- }
2921
- //#endregion
2922
- //#region src/cli/commands/lint/discovery.ts
2923
- const minimatchOptions = { dot: true };
2924
- const directoryBufferSize = 256;
2925
- var NoFilesFoundError = class extends Error {
2926
- globInputPaths;
2927
- pattern;
2928
- constructor(pattern, options) {
2929
- super(`No files matching "${pattern}" were found${options.globInputPaths ? "" : " (glob input is disabled)"}.`);
2930
- this.name = "NoFilesFoundError";
2931
- this.globInputPaths = options.globInputPaths;
2932
- this.pattern = pattern;
2933
- }
2934
- };
2935
- async function findLintTargets(options) {
2936
- const { config, cwd, errorOnUnmatchedPattern = true, globInputPaths = true, inputs } = options;
2937
- const gitignore = shouldFilterGitignoredFiles(config) ? new Gitignore() : void 0;
2938
- const targets = await resolveInputFiles({
2939
- config,
2940
- cwd,
2941
- errorOnUnmatchedPattern,
2942
- gitignore,
2943
- globInputPaths,
2944
- inputs: inputs.length > 0 ? inputs : ["."]
2945
- });
2946
- if (!gitignore || targets.files.length === 0) return targets;
2947
- const lintFiles = [];
2948
- for (const file of targets.files) {
2949
- if (await gitignore.ignores(resolve(cwd, file))) continue;
2950
- lintFiles.push(file);
2951
- }
2952
- return {
2953
- directories: targets.directories,
2954
- files: lintFiles
2955
- };
2956
- }
2957
- function collectGlobalIgnorePatterns(config) {
2958
- return normalizeConfig(config).flatMap((item) => isGlobalIgnoreItem(item) ? [...item.ignores] : []);
2959
- }
2960
- function getGlobParent(input) {
2961
- const segments = input.replaceAll("\\", "/").split("/");
2962
- const parentSegments = [];
2963
- for (const segment of segments) {
2964
- if (isGlobPattern(segment)) break;
2965
- parentSegments.push(segment);
2966
- }
2967
- const parent = parentSegments.join("/");
2968
- return parent === "" ? "." : parent;
2969
- }
2970
- function isGlobalIgnoreItem(item) {
2971
- const keys = Object.keys(item).filter((key) => item[key] !== void 0);
2972
- return item.ignores !== void 0 && keys.every((key) => key === "ignores" || key === "name");
2973
- }
2974
- function isGlobPattern(input) {
2975
- return new Minimatch(input, minimatchOptions).hasMagic();
2976
- }
2977
- function isNodeError$1(error) {
2978
- return error instanceof Error && "code" in error;
2979
- }
2980
- function matchesGlob(filePath, pattern) {
2981
- return minimatch(filePath, pattern.replaceAll("\\", "/"), minimatchOptions);
2982
- }
2983
- function matchesIgnoredDirectory(relativePath, patterns) {
2984
- return patterns.some((pattern) => minimatch(relativePath, pattern, minimatchOptions) || minimatch(`${relativePath}/`, pattern, minimatchOptions) || minimatch(`${relativePath}/__alint__`, pattern, minimatchOptions));
2985
- }
2986
- function normalizeGlobPattern(cwd, pattern) {
2987
- return isAbsolute(pattern) ? normalizeRelativePath(cwd, pattern) : pattern.replaceAll("\\", "/");
2988
- }
2989
- function normalizeRelativePath(cwd, filePath) {
2990
- return relative(cwd, filePath).replaceAll("\\", "/");
2991
- }
2992
- async function resolveInputFiles(options) {
2993
- const hasFilePatterns = hasDiscoveryFilePatterns(options.config);
2994
- const ignoredPatterns = collectGlobalIgnorePatterns(options.config);
2995
- const candidates = [];
2996
- const directories = [];
2997
- for (const input of options.inputs) {
2998
- const path = resolve(options.cwd, input);
2999
- const stats = await statPath(path);
3000
- if (stats?.isFile()) {
3001
- candidates.push(input);
3002
- continue;
3003
- }
3004
- if (stats?.isDirectory()) {
3005
- if (await shouldPruneDirectory(path, {
3006
- cwd: options.cwd,
3007
- gitignore: options.gitignore,
3008
- ignoredPatterns
3009
- })) continue;
3010
- directories.push(input);
3011
- if (!hasFilePatterns) continue;
3012
- const directoryFiles = [];
3013
- for await (const file of walkFiles(path, {
3014
- cwd: options.cwd,
3015
- gitignore: options.gitignore,
3016
- ignoredPatterns
3017
- })) {
3018
- const relativePath = normalizeRelativePath(options.cwd, file);
3019
- if (hasFilePatterns && matchesDiscoveryFile(relativePath, options.config, { cwd: options.cwd })) directoryFiles.push(relativePath);
3020
- }
3021
- for (const file of directoryFiles.sort()) candidates.push(file);
3022
- continue;
3023
- }
3024
- if (options.globInputPaths && isGlobPattern(input)) {
3025
- const matches = await searchGlob({
3026
- config: options.config,
3027
- cwd: options.cwd,
3028
- gitignore: options.gitignore,
3029
- hasFilePatterns,
3030
- ignoredPatterns,
3031
- pattern: input
3032
- });
3033
- if (matches.length === 0 && options.errorOnUnmatchedPattern) throw new NoFilesFoundError(input, { globInputPaths: options.globInputPaths });
3034
- for (const match of matches) candidates.push(match);
3035
- continue;
3036
- }
3037
- if (options.errorOnUnmatchedPattern) throw new NoFilesFoundError(input, { globInputPaths: options.globInputPaths });
3038
- }
3039
- return {
3040
- directories: [...new Set(directories)],
3041
- files: [...new Set(candidates)]
3042
- };
3043
- }
3044
- async function searchGlob(options) {
3045
- const root = resolve(options.cwd, getGlobParent(options.pattern));
3046
- const rootStats = await statPath(root);
3047
- const pattern = normalizeGlobPattern(options.cwd, options.pattern);
3048
- if (!rootStats?.isDirectory()) return [];
3049
- const candidates = [];
3050
- for await (const file of walkFiles(root, {
3051
- cwd: options.cwd,
3052
- gitignore: options.gitignore,
3053
- ignoredPatterns: options.ignoredPatterns
3054
- })) {
3055
- const relativePath = normalizeRelativePath(options.cwd, file);
3056
- if (matchesGlob(relativePath, pattern) && shouldIncludeGlobCandidate(relativePath, options.config, {
3057
- cwd: options.cwd,
3058
- hasFilePatterns: options.hasFilePatterns
3059
- })) candidates.push(relativePath);
3060
- }
3061
- return candidates.sort();
3438
+ deliverBoth((reporter) => reporter.onRunEnd?.(payload));
3439
+ if (firstFailure) throw firstFailure;
3440
+ },
3441
+ onUsage: (payload) => {
3442
+ deliverBoth((reporter) => reporter.onUsage?.(payload));
3443
+ }
3444
+ };
3062
3445
  }
3063
- function shouldFilterGitignoredFiles(config) {
3064
- return normalizeConfig(config).some((item) => item.ignore?.gitignore === true);
3446
+ function resolveStatsWrite(stats, env) {
3447
+ if (isCi(env)) return;
3448
+ if (!(stats === false ? false : stats === void 0 || stats === true ? true : stats.enabled !== false)) return;
3449
+ const config = typeof stats === "object" ? stats : void 0;
3450
+ return {
3451
+ dir: config?.location ?? getStatsDir(env),
3452
+ retentionMonths: config?.retentionMonths
3453
+ };
3065
3454
  }
3066
- function shouldIncludeGlobCandidate(filePath, config, options) {
3067
- if (options.hasFilePatterns) return matchesDiscoveryFile(filePath, config, { cwd: options.cwd });
3068
- return !resolveConfigForFile(filePath, config, { cwd: options.cwd }).ignored;
3455
+ async function writeRunStats(target, collector, result, cwd) {
3456
+ const store = createJsonlStatsStore({
3457
+ dir: target.dir,
3458
+ retentionMonths: target.retentionMonths
3459
+ });
3460
+ try {
3461
+ await store.record(createRunStat({
3462
+ cwd,
3463
+ durationMs: collector.durationMs(),
3464
+ ruleCounts: {
3465
+ cached: result.execution.cached,
3466
+ cancelled: result.execution.cancelled,
3467
+ completed: result.execution.completed,
3468
+ failed: result.execution.failed,
3469
+ planned: result.execution.planned
3470
+ },
3471
+ usage: result.usage
3472
+ }));
3473
+ } catch {}
3069
3474
  }
3070
- async function shouldPruneDirectory(path, options) {
3071
- if (matchesIgnoredDirectory(normalizeRelativePath(options.cwd, path), options.ignoredPatterns)) return true;
3072
- return await options.gitignore?.ignores(`${path}/`) === true;
3475
+ function normalizeReporterError(error) {
3476
+ if (error instanceof Error) return error;
3477
+ return new Error(error != null ? String(error) : "Unknown progress reporter error.");
3073
3478
  }
3074
- async function statPath(path) {
3479
+ //#endregion
3480
+ //#region src/cli/commands/lint/execution.ts
3481
+ /** Runs one session while keeping CLI flags and stats behavior identical for every caller. */
3482
+ async function executeLint(options) {
3483
+ const runner = resolveRunnerConfig(options.session.runner, options.runnerOptions);
3484
+ const statsTarget = resolveStatsWrite(runner?.stats, options.io.env);
3485
+ const statsCollector = statsTarget ? createStatsCollector() : void 0;
3486
+ const persistStats = async (result) => {
3487
+ if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, result, options.session.cwd);
3488
+ };
3489
+ const targetSelection = options.targets === void 0 ? { inputs: options.inputs } : { targets: options.targets };
3490
+ let result;
3075
3491
  try {
3076
- return await stat(path);
3492
+ result = await options.session.run({
3493
+ ...targetSelection,
3494
+ cacheOnly: options.cacheOnly,
3495
+ modelOverride: options.modelOverride,
3496
+ outputLanguage: options.outputLanguage,
3497
+ progress: mergeProgressReporters(options.progress, statsCollector?.reporter),
3498
+ runner,
3499
+ signal: options.createSignal?.()
3500
+ });
3077
3501
  } catch (error) {
3078
- if (isNodeError$1(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) return;
3502
+ if (error instanceof AlintRunError || error instanceof AlintRunCancelledError || error instanceof AlintCachePersistenceError) await persistStats(error.result);
3079
3503
  throw error;
3080
3504
  }
3081
- }
3082
- /**
3083
- * Streams directory entries so unmatched trees never accumulate into recursive child arrays.
3084
- * The consumer owns result ordering because filesystem iteration order is not stable.
3085
- */
3086
- async function* walkFiles(root, options) {
3087
- const pendingDirectories = [root];
3088
- while (pendingDirectories.length > 0) {
3089
- const currentDirectory = pendingDirectories.pop();
3090
- if (currentDirectory === void 0) continue;
3091
- const childDirectories = [];
3092
- const directory = await opendir(currentDirectory, { bufferSize: directoryBufferSize });
3093
- for await (const entry of directory) {
3094
- const path = resolve(currentDirectory, entry.name);
3095
- if (entry.isDirectory()) {
3096
- if (!await shouldPruneDirectory(path, options)) childDirectories.push(path);
3097
- continue;
3098
- }
3099
- if (entry.isFile()) yield path;
3100
- }
3101
- for (const path of childDirectories.reverse()) pendingDirectories.push(path);
3102
- }
3505
+ await persistStats(result);
3506
+ return result;
3103
3507
  }
3104
3508
  //#endregion
3105
- //#region src/cli/runtime/runner.ts
3509
+ //#region src/cli/commands/integrations/stop-gate/timeout.ts
3510
+ const maximumTimerChunkMs = 2147483647;
3106
3511
  /**
3107
- * Merges the setup config runner with the project config runner. No CLI flag is applied.
3108
- *
3109
- * `runner.cache` selects the cache file. The lint command and the language server must merge it
3110
- * the same way, or the editor reads a different file and reports nothing.
3512
+ * Node shortens an overflowing timer to one millisecond. The public Stop Gate limit is currently
3513
+ * below that boundary, while chunking here keeps this process-level safety helper correct if the
3514
+ * host budget changes independently later.
3111
3515
  */
3112
- function mergeRunnerConfigs(setupRunner, configRunner) {
3113
- const runner = {
3114
- ...setupRunner ?? {},
3115
- ...configRunner ?? {},
3116
- cache: mergeCacheConfig(setupRunner?.cache, configRunner?.cache),
3117
- ruleConcurrency: configRunner?.ruleConcurrency ?? setupRunner?.ruleConcurrency,
3118
- stats: mergeStatsConfig(setupRunner?.stats, configRunner?.stats),
3119
- timeoutMs: configRunner?.timeoutMs ?? setupRunner?.timeoutMs
3516
+ function createTimeoutSignal(timeoutMs) {
3517
+ const controller = new AbortController();
3518
+ let remainingMs = timeoutMs;
3519
+ let timer;
3520
+ const schedule = () => {
3521
+ const delay = Math.min(remainingMs, maximumTimerChunkMs);
3522
+ timer = setTimeout(() => {
3523
+ remainingMs -= delay;
3524
+ if (remainingMs > 0) {
3525
+ schedule();
3526
+ return;
3527
+ }
3528
+ controller.abort(new DOMException("Stop Gate timed out.", "TimeoutError"));
3529
+ }, delay);
3120
3530
  };
3121
- return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
3122
- }
3123
- /** The last `runner` block in the flattened config wins, key by key. */
3124
- function resolveConfigRunner(config) {
3125
- return normalizeConfig(config).reduce((merged, item) => item.runner ? {
3126
- ...merged,
3127
- ...item.runner
3128
- } : merged, void 0);
3129
- }
3130
- function mergeCacheConfig(setupCache, configCache) {
3131
- if (configCache === void 0) return setupCache;
3132
- if (typeof configCache === "boolean") return configCache;
3133
- if (typeof setupCache === "object") return {
3134
- ...setupCache,
3135
- ...configCache
3531
+ schedule();
3532
+ return {
3533
+ dispose: () => clearTimeout(timer),
3534
+ signal: controller.signal
3136
3535
  };
3137
- return configCache;
3138
3536
  }
3139
- function mergeStatsConfig(setupStats, configStats) {
3140
- if (configStats === void 0) return setupStats;
3141
- if (typeof configStats === "boolean") return configStats;
3142
- if (typeof setupStats === "object") return {
3143
- ...setupStats,
3144
- ...configStats
3145
- };
3146
- return configStats;
3537
+ //#endregion
3538
+ //#region src/cli/commands/integrations/stop-gate/run.ts
3539
+ async function runStopGateLint(options) {
3540
+ const dirtyTargets = options.stopGate.target === "dirty-files" ? await findDirtyLintTargets(options.config, options.cwd) : void 0;
3541
+ const targets = dirtyTargets ?? await findLintTargets({
3542
+ config: options.config,
3543
+ cwd: options.cwd,
3544
+ errorOnUnmatchedPattern: true,
3545
+ globInputPaths: true,
3546
+ inputs: ["."]
3547
+ });
3548
+ if (options.stopGate.target === "dirty-files" && targets.files.length === 0) return;
3549
+ const session = await createRunSession({
3550
+ ...options.io,
3551
+ cwd: options.cwd
3552
+ }, { config: options.config });
3553
+ let timeout;
3554
+ try {
3555
+ let result;
3556
+ let runError;
3557
+ try {
3558
+ result = await executeLint({
3559
+ createSignal: () => {
3560
+ timeout = createTimeoutSignal(options.stopGate.timeoutMs);
3561
+ return timeout.signal;
3562
+ },
3563
+ io: options.io,
3564
+ runnerOptions: { format: "json" },
3565
+ session,
3566
+ targets
3567
+ });
3568
+ } catch (error) {
3569
+ if (!(error instanceof AlintRunError)) throw error;
3570
+ runError = error;
3571
+ result = error.result;
3572
+ }
3573
+ if (dirtyTargets !== void 0) result = filterResultToChangedLines(result, {
3574
+ changedLines: dirtyTargets.changedLines,
3575
+ cwd: options.cwd
3576
+ });
3577
+ return runError === void 0 ? {
3578
+ files: targets.files,
3579
+ result
3580
+ } : {
3581
+ error: runError,
3582
+ files: targets.files,
3583
+ result
3584
+ };
3585
+ } finally {
3586
+ timeout?.dispose();
3587
+ await session.shutdown();
3588
+ }
3147
3589
  }
3148
3590
  //#endregion
3149
- //#region src/cli/runtime/session.ts
3150
- /**
3151
- * Loads config, setup config, and model adapters once, then runs any number of times.
3152
- *
3153
- * The lint command and the language server both use this function. A second implementation would
3154
- * resolve config or cwd differently, and the editor would then read a different cache file.
3155
- */
3156
- async function createRunSession(io, options = {}) {
3157
- const [{ defaultModel, setupConfig: fileSetupConfig }, config] = await Promise.all([loadRunSetupConfig(io), loadAlintConfig(io.cwd, options.configPath)]);
3158
- const runtime = await (options.startModelAdapters ?? startModelAdapters)(fileSetupConfig, io);
3159
- const { setupConfig } = runtime;
3160
- let shutdown;
3161
- return {
3162
- config,
3163
- cwd: io.cwd,
3164
- defaultModel,
3165
- run: async (runOptions) => {
3166
- const targets = await findLintTargets({
3167
- config,
3168
- cwd: io.cwd,
3169
- errorOnUnmatchedPattern: true,
3170
- globInputPaths: true,
3171
- inputs: runOptions.files ?? []
3591
+ //#region src/cli/commands/integrations/stop-gate/stop-gate.ts
3592
+ const stopGate = defineCommand({
3593
+ action: (context, options) => runStopGateCommand(options, context.io),
3594
+ description: "Run the repository Stop Gate integration",
3595
+ exactArguments: true,
3596
+ name: "stop-gate",
3597
+ options: [{
3598
+ description: "Codex session id",
3599
+ flags: "--session-id <id>"
3600
+ }]
3601
+ });
3602
+ async function runStopGateCommand(options, io) {
3603
+ if (!options.sessionId) {
3604
+ io.stderr.write("Stop Gate requires --session-id.\n");
3605
+ return 2;
3606
+ }
3607
+ try {
3608
+ const cwd = await findGitRoot(io.cwd);
3609
+ const loaded = await loadAlintConfigWithMetadata(cwd);
3610
+ if (loaded.configFile === void 0) {
3611
+ writeEnvelope((chunk) => io.stdout.write(chunk), {
3612
+ errorCount: 0,
3613
+ schemaVersion: 2,
3614
+ status: "inactive",
3615
+ warningCount: 0
3172
3616
  });
3173
- return runAlint({
3174
- cacheOnly: runOptions.cacheOnly,
3175
- config,
3176
- cwd: io.cwd,
3177
- defaultModel,
3178
- directories: targets.directories,
3179
- files: targets.files,
3180
- modelOverride: runOptions.modelOverride,
3181
- outputLanguage: runOptions.outputLanguage,
3182
- progress: runOptions.progress,
3183
- projectTargets: runOptions.projectTargets,
3184
- runner: runOptions.runner,
3185
- setupConfig,
3186
- signal: runOptions.signal
3617
+ return 0;
3618
+ }
3619
+ const stopGate = resolveStopGateConfig(loaded.config, cwd);
3620
+ if (!stopGate.enabled) {
3621
+ await removeStopGateReport(options.sessionId);
3622
+ writeEnvelope((chunk) => io.stdout.write(chunk), {
3623
+ errorCount: 0,
3624
+ schemaVersion: 2,
3625
+ status: "inactive",
3626
+ warningCount: 0
3187
3627
  });
3188
- },
3189
- runner: mergeRunnerConfigs(setupConfig.runner, resolveConfigRunner(config)),
3190
- setupConfig,
3191
- shutdown: () => shutdown ??= runtime.shutdown()
3192
- };
3628
+ return 0;
3629
+ }
3630
+ const lint = await runStopGateLint({
3631
+ config: loaded.config,
3632
+ cwd,
3633
+ io,
3634
+ stopGate
3635
+ });
3636
+ if (lint === void 0) {
3637
+ await removeStopGateReport(options.sessionId);
3638
+ writeEnvelope((chunk) => io.stdout.write(chunk), {
3639
+ errorCount: 0,
3640
+ schemaVersion: 2,
3641
+ status: "no-dirty-files",
3642
+ warningCount: 0
3643
+ });
3644
+ return 0;
3645
+ }
3646
+ if (lint.error !== void 0) {
3647
+ const reportPath = await writeStopGateReport(options.sessionId, {
3648
+ cwd,
3649
+ diagnostics: lint.result.diagnostics,
3650
+ execution: lint.result.execution,
3651
+ failure: {
3652
+ details: lint.error.failures,
3653
+ message: lint.error.message
3654
+ },
3655
+ files: lint.files,
3656
+ schemaVersion: 1,
3657
+ target: stopGate.target,
3658
+ usage: lint.result.usage
3659
+ });
3660
+ writeEnvelope((chunk) => io.stdout.write(chunk), {
3661
+ errorCount: 0,
3662
+ message: lint.error.message,
3663
+ reportPath,
3664
+ schemaVersion: 2,
3665
+ status: "runtime-error",
3666
+ warningCount: 0
3667
+ });
3668
+ return 1;
3669
+ }
3670
+ const errorCount = lint.result.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
3671
+ const warningCount = lint.result.diagnostics.length - errorCount;
3672
+ if (errorCount === 0 && warningCount === 0) {
3673
+ await removeStopGateReport(options.sessionId);
3674
+ writeEnvelope((chunk) => io.stdout.write(chunk), {
3675
+ errorCount,
3676
+ schemaVersion: 2,
3677
+ status: "clean",
3678
+ warningCount
3679
+ });
3680
+ return 0;
3681
+ }
3682
+ const reportPath = await writeStopGateReport(options.sessionId, {
3683
+ cwd,
3684
+ diagnostics: lint.result.diagnostics,
3685
+ execution: lint.result.execution,
3686
+ files: lint.files,
3687
+ schemaVersion: 1,
3688
+ target: stopGate.target,
3689
+ usage: lint.result.usage
3690
+ });
3691
+ writeEnvelope((chunk) => io.stdout.write(chunk), {
3692
+ errorCount,
3693
+ findingsHash: fingerprintDiagnostics(lint.result.diagnostics),
3694
+ reportPath,
3695
+ schemaVersion: 2,
3696
+ status: errorCount > 0 ? "errors" : "warnings",
3697
+ warningCount
3698
+ });
3699
+ return 0;
3700
+ } catch (error) {
3701
+ writeEnvelope((chunk) => io.stdout.write(chunk), {
3702
+ errorCount: 0,
3703
+ message: error instanceof StopGateReportTooLargeError ? error.message : error instanceof AlintRunCancelledError ? "Stop Gate timed out before lint completed." : errorMessageFrom(error) ?? "unknown error",
3704
+ schemaVersion: 2,
3705
+ status: "runtime-error",
3706
+ warningCount: 0
3707
+ });
3708
+ return 1;
3709
+ }
3193
3710
  }
3194
3711
  //#endregion
3195
- //#region src/cli/commands/lint/errors.ts
3196
- const colors$1 = createColors({ force: true });
3197
- function formatCancelledError(error, color) {
3198
- return `${color ? colors$1.red("error") : "error"} ${error.message}\n`;
3199
- }
3200
- function formatRunError(error, color) {
3201
- const label = color ? colors$1.red("error") : "error";
3202
- const fileFailures = error.failures.filter((failure) => "file" in failure);
3203
- const groups = groupFailuresByRule(error.failures.filter((failure) => "job" in failure));
3204
- return [
3205
- `${label} ${error.message}`,
3206
- "",
3207
- ...fileFailures.length > 0 ? [
3208
- `Failed Files ${fileFailures.length}`,
3209
- "",
3210
- ...formatFileFailures(fileFailures, color)
3211
- ] : [],
3212
- ...groups.length > 0 ? [
3213
- `Failed Rules ${groups.length}`,
3214
- "",
3215
- ...formatFailureGroups(groups, color)
3216
- ] : []
3217
- ].join("\n");
3712
+ //#region src/cli/commands/integrations/integrations.ts
3713
+ const integrations = defineCommand({
3714
+ children: [stopGate],
3715
+ description: "Run external integrations",
3716
+ name: "integrations"
3717
+ });
3718
+ //#endregion
3719
+ //#region src/cli/reporters/json.ts
3720
+ function formatJson(result) {
3721
+ return `${JSON.stringify(result, null, 2)}\n`;
3218
3722
  }
3219
- function formatFailureGroups(groups, color) {
3723
+ //#endregion
3724
+ //#region src/cli/reporters/stylish.ts
3725
+ const colors$3 = createColors({ force: true });
3726
+ function formatStylish(input, options = {}) {
3727
+ const diagnostics = Array.isArray(input) ? input : input.diagnostics;
3728
+ const result = Array.isArray(input) ? void 0 : input;
3729
+ const style = createStyle(options.color === true);
3730
+ if (diagnostics.length === 0) return result?.execution?.cached ? `${formatSummary(diagnostics, result, style)}\n` : "";
3731
+ const diagnosticsByFile = /* @__PURE__ */ new Map();
3732
+ for (const diagnostic of diagnostics) {
3733
+ const fileDiagnostics = diagnosticsByFile.get(diagnostic.filePath);
3734
+ if (fileDiagnostics) {
3735
+ fileDiagnostics.push(diagnostic);
3736
+ continue;
3737
+ }
3738
+ diagnosticsByFile.set(diagnostic.filePath, [diagnostic]);
3739
+ }
3220
3740
  const lines = [];
3221
- for (const [ruleId, group] of groups) {
3222
- const failLabel = color ? colors$1.bgRed(colors$1.bold(" FAIL ")) : "FAIL";
3223
- const targetLabel = group.length === 1 ? "target" : "targets";
3224
- lines.push(`${failLabel} ${ruleId} ${group.length} ${targetLabel}`);
3225
- for (const failure of group) {
3226
- lines.push(` ${formatTarget(failure)}`);
3227
- lines.push(` [${failure.kind}] ${failure.message}`);
3228
- for (const cause of failure.causes) {
3229
- const code = cause.code === void 0 ? "" : `[${cause.code}] `;
3230
- lines.push(` Caused by: ${code}${cause.message}`);
3231
- }
3741
+ for (const [filePath, fileDiagnostics] of diagnosticsByFile) {
3742
+ lines.push(style.file(filePath));
3743
+ for (const diagnostic of fileDiagnostics) {
3744
+ const line = diagnostic.loc?.start.line ?? 0;
3745
+ const column = diagnostic.loc?.start.column ?? 0;
3746
+ const severity = diagnostic.severity === "warn" ? style.warning("warning") : style.error("error");
3747
+ lines.push(` ${style.location(`${line}:${column}`)} ${severity} ${diagnostic.message} ${style.ruleId(diagnostic.ruleId)}`);
3232
3748
  }
3233
3749
  lines.push("");
3234
3750
  }
3235
- return lines;
3751
+ lines.push("", formatSummary(diagnostics, result, style));
3752
+ return `${lines.join("\n")}\n`;
3236
3753
  }
3237
- function formatFileFailures(failures, color) {
3238
- const lines = [];
3239
- for (const failure of failures) {
3240
- const failLabel = color ? colors$1.bgRed(colors$1.bold(" FAIL ")) : "FAIL";
3241
- lines.push(`${failLabel} ${failure.file.path}`);
3242
- lines.push(` [${failure.kind}] ${failure.message}`);
3243
- lines.push("");
3244
- }
3245
- return lines;
3754
+ function countCachedDiagnostics(diagnostics, severity) {
3755
+ return diagnostics.filter((diagnostic) => diagnostic.severity === severity && diagnostic.cached === true).length;
3246
3756
  }
3247
- function formatTarget(failure) {
3248
- const target = failure.job.target.name ? `${failure.job.target.kind} ${failure.job.target.name}` : failure.job.target.kind;
3249
- return `${failure.job.inputPath} > ${target}`;
3757
+ function countDiagnostics$1(diagnostics, severity) {
3758
+ return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
3250
3759
  }
3251
- function groupFailuresByRule(failures) {
3252
- const groups = /* @__PURE__ */ new Map();
3253
- for (const failure of failures) {
3254
- const group = groups.get(failure.job.ruleId) ?? [];
3255
- group.push(failure);
3256
- groups.set(failure.job.ruleId, group);
3257
- }
3258
- return [...groups];
3760
+ function createStyle(color) {
3761
+ if (!color) return {
3762
+ error: identity,
3763
+ file: identity,
3764
+ location: identity,
3765
+ ruleId: identity,
3766
+ summaryToken: identity,
3767
+ warning: identity
3768
+ };
3769
+ return {
3770
+ error: colors$3.red,
3771
+ file: colors$3.underline,
3772
+ location: colors$3.dim,
3773
+ ruleId: colors$3.dim,
3774
+ summaryToken: colors$3.cyan,
3775
+ warning: colors$3.yellow
3776
+ };
3777
+ }
3778
+ function formatDiagnosticCount(total, cached, label) {
3779
+ return `${total} ${label}${cached > 0 ? ` (${cached} cached)` : ""}`;
3780
+ }
3781
+ function formatExecutionSummary(execution) {
3782
+ if (!execution || execution.cached === 0) return;
3783
+ return `${execution.cached}/${execution.planned} cached`;
3784
+ }
3785
+ function formatSummary(diagnostics, result, style) {
3786
+ const warnCount = countDiagnostics$1(diagnostics, "warn");
3787
+ const errorCount = countDiagnostics$1(diagnostics, "error");
3788
+ const cachedWarnCount = countCachedDiagnostics(diagnostics, "warn");
3789
+ const cachedErrorCount = countCachedDiagnostics(diagnostics, "error");
3790
+ const tokens = result === void 0 ? void 0 : formatTokenSummary(result);
3791
+ const problemSummary = [style.warning(formatDiagnosticCount(warnCount, cachedWarnCount, "warn")), style.error(formatDiagnosticCount(errorCount, cachedErrorCount, "error"))].join(" / ");
3792
+ if (tokens === void 0) return problemSummary;
3793
+ const execution = formatExecutionSummary(result?.execution);
3794
+ return [
3795
+ problemSummary,
3796
+ style.summaryToken(tokens),
3797
+ execution
3798
+ ].filter((segment) => segment !== void 0).join(" | ");
3799
+ }
3800
+ function formatTokenSummary(result) {
3801
+ const liveTokens = result.usage.totalTokens.toLocaleString("en-US");
3802
+ const cachedTokens = result.usage.cached?.totalTokens ?? 0;
3803
+ return `${liveTokens} tokens${cachedTokens > 0 ? ` (${cachedTokens.toLocaleString("en-US")} cached)` : ""}`;
3804
+ }
3805
+ function identity(value) {
3806
+ return value;
3259
3807
  }
3260
3808
  //#endregion
3261
- //#region src/cli/commands/lint/runner.ts
3262
- /**
3263
- * Applies this invocation's flags on top of the project's resolved runner.
3264
- *
3265
- * The setup/config merge itself lives in `runtime/runner.ts` because the language server needs the
3266
- * same base without any flags; only the overrides below are CLI-shaped.
3267
- */
3268
- function resolveRunnerConfig(baseRunner, options) {
3269
- const ruleConcurrency = parsePositiveIntegerOption(options.ruleConcurrency, "--rule-concurrency");
3270
- const timeoutMs = parsePositiveIntegerOption(options.timeoutMs, "--timeout-ms");
3271
- const runner = {
3272
- ...baseRunner ?? {},
3273
- cache: resolveCacheOption(baseRunner?.cache, options),
3274
- ruleConcurrency: ruleConcurrency ?? baseRunner?.ruleConcurrency,
3275
- stats: options.stats === false ? false : baseRunner?.stats,
3276
- timeoutMs: timeoutMs ?? baseRunner?.timeoutMs
3809
+ //#region src/cli/reporters/index.ts
3810
+ function formatDiagnostics(format, result, options = {}) {
3811
+ if (format === "json") return formatJson(result);
3812
+ if (format === "stylish") return formatStylish(result, { color: options.color });
3813
+ throw new Error(`Unknown reporter "${format}".`);
3814
+ }
3815
+ //#endregion
3816
+ //#region src/cli/reporters/progress/plain.ts
3817
+ function createPlainProgressReporter(options) {
3818
+ const writeLine = (line) => options.write(`${line}\n`);
3819
+ return {
3820
+ onJobRetry: () => {},
3821
+ onJobStart: (payload) => {
3822
+ const target = payload.job.target.name ? `${payload.job.target.kind} ${payload.job.target.name}` : payload.job.target.kind;
3823
+ writeLine(`scan ${payload.job.inputPath} > ${target} > ${payload.job.ruleId}`);
3824
+ },
3825
+ onPrepareEnd: ({ filesTotal }) => writeLine(`alint prepared: ${filesTotal} files`),
3826
+ onPrepareStart: () => writeLine("alint preparing"),
3827
+ onRunEnd: (payload) => {
3828
+ const warnCount = countDiagnostics(payload.diagnostics, "warn");
3829
+ const errorCount = countDiagnostics(payload.diagnostics, "error");
3830
+ const state = payload.execution.failed > 0 ? "failed" : "finished";
3831
+ const { cached, cancelled, completed, failed, skipped } = payload.execution;
3832
+ writeLine(`alint ${state}: ${warnCount} warn, ${errorCount} error, ${payload.usage.totalTokens} tokens, ${completed} completed, ${cached} cached, ${failed} failed, ${cancelled} cancelled, ${skipped} skipped`);
3833
+ }
3277
3834
  };
3278
- return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
3279
3835
  }
3280
- function parsePositiveIntegerOption(value, label) {
3281
- if (value === void 0) return;
3282
- const parsed = Number(value);
3283
- if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer.`);
3284
- return parsed;
3836
+ function countDiagnostics(diagnostics, severity) {
3837
+ return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
3285
3838
  }
3286
- function resolveCacheOption(baseCache, options) {
3287
- if (options.cache === false) return false;
3288
- if (options.cacheLocation !== void 0) return typeof baseCache === "object" ? {
3289
- ...baseCache,
3290
- location: options.cacheLocation
3291
- } : { location: options.cacheLocation };
3292
- return baseCache;
3839
+ //#endregion
3840
+ //#region src/cli/reporters/progress/summary.ts
3841
+ const colors$2 = createColors({ force: true });
3842
+ const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3843
+ function createSummaryProgressReporter(options) {
3844
+ const now = Date.now;
3845
+ const state = createInitialState();
3846
+ return {
3847
+ getRows: () => createRows(state, options, now()),
3848
+ onDiagnostic: ({ diagnostic, progress }) => {
3849
+ updateProgress(state, progress);
3850
+ if (diagnostic.severity === "warn") state.warnCount += 1;
3851
+ else state.errorCount += 1;
3852
+ },
3853
+ onExecuteEnd: ({ progress }) => {
3854
+ updateProgress(state, progress);
3855
+ },
3856
+ onExecuteStart: ({ progress, startedAt }) => {
3857
+ updateProgress(state, progress);
3858
+ state.runStartedAt = startedAt ?? state.runStartedAt ?? now();
3859
+ },
3860
+ onFileReady: ({ progress }) => updateProgress(state, progress),
3861
+ onJobEnd: (payload) => {
3862
+ updateProgress(state, payload.progress);
3863
+ const current = state.activeJobs.get(payload.job.id);
3864
+ const group = state.ruleGroups.get(payload.job.ruleId);
3865
+ if (!group) throw new Error(`Cannot finish unqueued progress job "${payload.job.id}".`);
3866
+ group[payload.state] += 1;
3867
+ if (payload.endedAt !== void 0 && current?.startedAt !== void 0 && payload.state !== "cached") group.terminalDurationMs += Math.max(payload.endedAt - current.startedAt, 0);
3868
+ if (payload.state === "cached") {
3869
+ const cachedTokens = state.jobTokens.get(payload.job.id) ?? 0;
3870
+ state.totalTokens = Math.max(state.totalTokens - cachedTokens, 0);
3871
+ state.cachedTokens += cachedTokens;
3872
+ }
3873
+ state.activeJobs.delete(payload.job.id);
3874
+ state.jobTokens.delete(payload.job.id);
3875
+ },
3876
+ onJobQueued: ({ job, progress }) => {
3877
+ updateProgress(state, progress);
3878
+ const group = state.ruleGroups.get(job.ruleId) ?? createRuleGroup(job);
3879
+ group.planned += 1;
3880
+ state.ruleGroups.set(job.ruleId, group);
3881
+ state.activeJobs.set(job.id, {
3882
+ job,
3883
+ state: "queued"
3884
+ });
3885
+ },
3886
+ onJobRetry: (payload) => {
3887
+ updateProgress(state, payload.progress);
3888
+ const current = state.activeJobs.get(payload.job.id);
3889
+ state.activeJobs.set(payload.job.id, {
3890
+ job: payload.job,
3891
+ retry: {
3892
+ attempt: payload.attempt,
3893
+ maxAttempts: payload.maxAttempts,
3894
+ startedAt: payload.startedAt
3895
+ },
3896
+ startedAt: current?.startedAt ?? payload.startedAt ?? now(),
3897
+ state: current?.state ?? "running"
3898
+ });
3899
+ },
3900
+ onJobStart: (payload) => {
3901
+ updateProgress(state, payload.progress);
3902
+ state.activeJobs.set(payload.job.id, {
3903
+ job: payload.job,
3904
+ startedAt: payload.startedAt ?? now(),
3905
+ state: "running"
3906
+ });
3907
+ },
3908
+ onPlanningEnd: ({ progress }) => updateProgress(state, progress),
3909
+ onPrepareStart: ({ startedAt }) => {
3910
+ state.activeJobs.clear();
3911
+ state.errorCount = 0;
3912
+ state.execution = createCounts();
3913
+ state.progress = createProgress();
3914
+ state.ruleGroups.clear();
3915
+ state.runStartedAt = startedAt ?? now();
3916
+ state.animationTick = 0;
3917
+ state.cachedTokens = 0;
3918
+ state.jobTokens.clear();
3919
+ state.totalTokens = 0;
3920
+ state.warnCount = 0;
3921
+ },
3922
+ onRunEnd: (payload) => {
3923
+ state.warnCount = payload.diagnostics.filter((diagnostic) => diagnostic.severity === "warn").length;
3924
+ state.errorCount = payload.diagnostics.length - state.warnCount;
3925
+ updateProgress(state, payload.progress);
3926
+ state.totalTokens = payload.usage.totalTokens;
3927
+ state.cachedTokens = payload.usage.cached?.totalTokens ?? 0;
3928
+ state.runStartedAt = payload.startedAt ?? state.runStartedAt;
3929
+ },
3930
+ onUsage: ({ job, progress, record }) => {
3931
+ updateProgress(state, progress);
3932
+ if (record.totalTokens != null && Number.isFinite(record.totalTokens)) {
3933
+ state.jobTokens.set(job.id, (state.jobTokens.get(job.id) ?? 0) + record.totalTokens);
3934
+ state.totalTokens += record.totalTokens;
3935
+ }
3936
+ },
3937
+ /**
3938
+ * Advances the summary animation on each renderer interval.
3939
+ *
3940
+ * Triggering workflow:
3941
+ *
3942
+ * `createTtyProgressRenderer`
3943
+ * -> `createCliProgressReporter` `createInterval` callback
3944
+ * -> `SummaryProgressReporter.tick` (this handler)
3945
+ *
3946
+ * Upstream:
3947
+ * - `createCliProgressReporter` `createInterval` callback registered by `createTtyProgressRenderer`
3948
+ *
3949
+ * Downstream:
3950
+ * - Increments `state.animationTick`, consumed by {@link createRows}, {@link formatSpinnerFrame}, and {@link formatMiniBarSegment}.
3951
+ */
3952
+ tick: () => {
3953
+ state.animationTick += 1;
3954
+ }
3955
+ };
3956
+ }
3957
+ function compareRuleGroups(left, right) {
3958
+ const leftActive = left.jobs.some((job) => job.state === "running") ? 1 : 0;
3959
+ const rightActive = right.jobs.some((job) => job.state === "running") ? 1 : 0;
3960
+ if (leftActive !== rightActive) return rightActive - leftActive;
3961
+ if (left.failed !== right.failed) return right.failed - left.failed;
3962
+ return left.firstJobIndex - right.firstJobIndex;
3293
3963
  }
3294
- //#endregion
3295
- //#region src/cli/stats/ci.ts
3296
- function isCi(env = process.env) {
3297
- const ci = env.CI;
3298
- if (ci !== void 0 && ci !== "" && ci !== "0" && ci !== "false") return true;
3299
- return Boolean(env.GITHUB_ACTIONS || env.GITLAB_CI || env.CIRCLECI || env.BUILDKITE || env.TF_BUILD);
3964
+ function compareRunningJobs(left, right) {
3965
+ const leftStartedAt = left.startedAt ?? Number.POSITIVE_INFINITY;
3966
+ const rightStartedAt = right.startedAt ?? Number.POSITIVE_INFINITY;
3967
+ if (leftStartedAt !== rightStartedAt) return leftStartedAt - rightStartedAt;
3968
+ return left.job.index - right.job.index;
3300
3969
  }
3301
- //#endregion
3302
- //#region src/cli/stats/since.ts
3303
- /**
3304
- * Parse a `--since` time into an inclusive lower-bound timestamp.
3305
- *
3306
- * Supported formats:
3307
- * - `Nh`/`Nd`/`Nw`/`Nm`/`Ny` — now minus N hours/days/weeks/months/years.
3308
- * Weeks, months, and years are fixed approximations (7/30/365 days). `m` is
3309
- * months here, not minutes — a day is the finest window this tool tracks.
3310
- * - `YYYY-MM`/`YYYY-MM-DD` — start of that UTC month/day.
3311
- *
3312
- * Edge cases:
3313
- * - An empty time means "no lower bound".
3314
- * - An unrecognized time throws.
3315
- */
3316
- const HOUR_MS = 36e5;
3317
- const UNIT_MS = {
3318
- d: 24 * HOUR_MS,
3319
- h: HOUR_MS,
3320
- m: 720 * HOUR_MS,
3321
- w: 168 * HOUR_MS,
3322
- y: 8760 * HOUR_MS
3323
- };
3324
- function parseSince(time, now) {
3325
- if (time === void 0 || time === "") return;
3326
- const relative = /^(\d+)([dhmwy])$/u.exec(time);
3327
- if (relative) return now - Number(relative[1]) * UNIT_MS[relative[2]];
3328
- const calendar = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/u.exec(time);
3329
- if (calendar) {
3330
- const year = Number(calendar[1]);
3331
- const month = Number(calendar[2]);
3332
- const day = calendar[3] === void 0 ? 1 : Number(calendar[3]);
3333
- return Date.UTC(year, month - 1, day);
3970
+ function completeGraphemeBoundary(input, maximumIndex) {
3971
+ let boundary = 0;
3972
+ for (const segment of graphemeSegmenter.segment(input)) {
3973
+ const end = segment.index + segment.segment.length;
3974
+ if (end > maximumIndex) break;
3975
+ boundary = end;
3334
3976
  }
3335
- throw new Error(`Invalid --since "${time}": use e.g. 24h, 7d, 2w, 3m, 1y, 2025-01, or 2025-01-15.`);
3977
+ return boundary;
3336
3978
  }
3337
- //#endregion
3338
- //#region src/cli/stats/jsonl.ts
3339
- const DEFAULT_RETENTION_MONTHS = 12;
3340
- const STATS_FILE_PATTERN = /^stats-(\d{4})-(\d{2})\.jsonl$/u;
3341
- const DAY_MS = 864e5;
3342
- const MONTH_LABELS$1 = [
3343
- "Jan",
3344
- "Feb",
3345
- "Mar",
3346
- "Apr",
3347
- "May",
3348
- "Jun",
3349
- "Jul",
3350
- "Aug",
3351
- "Sep",
3352
- "Oct",
3353
- "Nov",
3354
- "Dec"
3355
- ];
3356
- function createJsonlStatsStore(options) {
3357
- const retentionMonths = options.retentionMonths ?? DEFAULT_RETENTION_MONTHS;
3979
+ function createCounts(planned = 0) {
3358
3980
  return {
3359
- query: (filter) => query(options.dir, filter ?? {}, Date.now),
3360
- querySeries: (filter) => querySeries(options.dir, filter ?? {}, Date.now),
3361
- record: (input) => record(options.dir, input, Date.now(), retentionMonths)
3981
+ cached: 0,
3982
+ cancelled: 0,
3983
+ completed: 0,
3984
+ failed: 0,
3985
+ planned,
3986
+ queued: 0,
3987
+ running: 0,
3988
+ skipped: 0
3362
3989
  };
3363
3990
  }
3364
- function accumulate(rows, key, inTok, outTok, totalTok, countRun) {
3365
- const row = rows.get(key) ?? {
3366
- inTok: 0,
3367
- key,
3368
- outTok: 0,
3369
- runs: 0,
3370
- totalTok: 0
3991
+ function createInitialState() {
3992
+ return {
3993
+ activeJobs: /* @__PURE__ */ new Map(),
3994
+ animationTick: 0,
3995
+ cachedTokens: 0,
3996
+ errorCount: 0,
3997
+ execution: createCounts(),
3998
+ jobTokens: /* @__PURE__ */ new Map(),
3999
+ progress: createProgress(),
4000
+ ruleGroups: /* @__PURE__ */ new Map(),
4001
+ totalTokens: 0,
4002
+ warnCount: 0
3371
4003
  };
3372
- row.inTok += inTok;
3373
- row.outTok += outTok;
3374
- row.totalTok += totalTok;
3375
- if (countRun) row.runs += 1;
3376
- rows.set(key, row);
3377
- return row;
3378
- }
3379
- function bucketLabel(startMs, interval) {
3380
- const date = new Date(startMs);
3381
- if (interval === "month") return MONTH_LABELS$1[date.getUTCMonth()];
3382
- return `${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
3383
- }
3384
- function bucketStart(ts, interval) {
3385
- const date = new Date(ts);
3386
- if (interval === "month") return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
3387
- const dayStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
3388
- if (interval === "week") return dayStart - (new Date(dayStart).getUTCDay() + 6) % 7 * DAY_MS;
3389
- return dayStart;
3390
- }
3391
- function chooseInterval(since, nowMs, timestamps) {
3392
- if (since !== void 0) return intervalForDuration(nowMs - since);
3393
- if (timestamps.length < 2) return "day";
3394
- return intervalForDuration(Math.max(...timestamps) - Math.min(...timestamps));
3395
- }
3396
- function fillBuckets(buckets, interval) {
3397
- if (buckets.size === 0) return [];
3398
- const starts = [...buckets.keys()].sort((left, right) => left - right);
3399
- const last = starts.at(-1);
3400
- const filled = [];
3401
- for (let startMs = starts[0]; startMs <= last; startMs = nextBucketStart(startMs, interval)) filled.push(buckets.get(startMs) ?? {
3402
- inTok: 0,
3403
- key: bucketLabel(startMs, interval),
3404
- outTok: 0,
3405
- runs: 0,
3406
- startMs,
3407
- totalTok: 0
3408
- });
3409
- return filled;
3410
- }
3411
- function intervalForDuration(ms) {
3412
- const days = ms / DAY_MS;
3413
- if (days <= 21) return "day";
3414
- if (days <= 120) return "week";
3415
- return "month";
3416
- }
3417
- function isEnoent(error) {
3418
- return error instanceof Error && error.code === "ENOENT";
3419
- }
3420
- function monthIndexOf(ts) {
3421
- const date = new Date(ts);
3422
- return date.getUTCFullYear() * 12 + date.getUTCMonth();
3423
- }
3424
- function nextBucketStart(startMs, interval) {
3425
- if (interval === "month") {
3426
- const date = new Date(startMs);
3427
- return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
3428
- }
3429
- return startMs + (interval === "week" ? 7 * DAY_MS : DAY_MS);
3430
4004
  }
3431
- function parseRunLine(line) {
3432
- try {
3433
- const value = JSON.parse(line);
3434
- if (typeof value.ts === "number" && typeof value.cwd === "string" && value.usage != null) return value;
3435
- return;
3436
- } catch {
3437
- return;
3438
- }
4005
+ function createProgress() {
4006
+ return {
4007
+ execution: createCounts(),
4008
+ filesPlanned: 0,
4009
+ filesTotal: 0,
4010
+ jobsCompleted: 0,
4011
+ jobsStarted: 0,
4012
+ jobsTotal: 0,
4013
+ planningComplete: false
4014
+ };
3439
4015
  }
3440
- async function prune(dir, ts, retentionMonths) {
3441
- if (retentionMonths <= 0) return;
3442
- const cutoff = monthIndexOf(ts) - (retentionMonths - 1);
3443
- const files = await statsFiles(dir);
3444
- for (const { month, name } of files) if (month < cutoff) await rm(join(dir, name));
4016
+ function createRows(state, options, now) {
4017
+ if (options.rows !== void 0 && options.rows <= 0) return [];
4018
+ const groups = createRuleGroups(state);
4019
+ const footerRows = options.rows === void 0 ? 3 : Math.min(options.rows, 3);
4020
+ const separatorRows = options.rows === void 0 || options.rows - footerRows > 1 ? 1 : 0;
4021
+ const visible = formatContentRows(groups, state, options, now, options.rows === void 0 ? Number.POSITIVE_INFINITY : Math.max(options.rows - footerRows - separatorRows, 0));
4022
+ const footer = formatFooters(state, options, now);
4023
+ const actualSeparatorRows = visible.length > 0 ? separatorRows : 0;
4024
+ const visibleFooters = options.rows === void 0 ? footer : footer.slice(0, Math.max(options.rows - visible.length - actualSeparatorRows, 0));
4025
+ if (actualSeparatorRows === 1) visible.push("");
4026
+ visible.push(...visibleFooters);
4027
+ return options.color ? visible.map((row) => styleRow(row, state, state.warnCount, state.errorCount, options)) : visible;
3445
4028
  }
3446
- async function query(dir, filter, now) {
3447
- const dimension = filter.by ?? "model";
3448
- const since = parseSince(filter.since, now());
3449
- const rules = ruleSet(filter.rules);
3450
- const runs = await readRuns(dir);
3451
- const rows = /* @__PURE__ */ new Map();
3452
- let totalRuns = 0;
3453
- let totalIn = 0;
3454
- let totalOut = 0;
3455
- let totalTok = 0;
3456
- for (const run of runs) {
3457
- if (!runInWindow(run, since, filter.cwd)) continue;
3458
- const slice = sliceRun(run, rules);
3459
- if (!slice.touched) continue;
3460
- totalRuns += 1;
3461
- totalIn += slice.inTok;
3462
- totalOut += slice.outTok;
3463
- totalTok += slice.totalTok;
3464
- if (dimension === "dir") {
3465
- accumulate(rows, run.cwd, slice.inTok, slice.outTok, slice.totalTok, true);
3466
- continue;
3467
- }
3468
- const seenKeys = /* @__PURE__ */ new Set();
3469
- for (const usageRecord of slice.usageRecords) {
3470
- const key = recordKey(usageRecord, dimension);
3471
- accumulate(rows, key, usageRecord.inTok, usageRecord.outTok, usageRecord.totalTok, !seenKeys.has(key));
3472
- seenKeys.add(key);
3473
- }
3474
- }
4029
+ function createRuleGroup(job) {
3475
4030
  return {
3476
- dimension,
3477
- rows: [...rows.values()].sort((left, right) => right.totalTok - left.totalTok),
3478
- totalIn,
3479
- totalOut,
3480
- totalRuns,
3481
- totalTok
4031
+ cached: 0,
4032
+ cancelled: 0,
4033
+ completed: 0,
4034
+ failed: 0,
4035
+ firstJobIndex: job.index,
4036
+ planned: 0,
4037
+ ruleId: job.ruleId,
4038
+ skipped: 0,
4039
+ terminalDurationMs: 0
3482
4040
  };
3483
4041
  }
3484
- async function querySeries(dir, filter, now) {
3485
- const nowMs = now();
3486
- const since = parseSince(filter.since, nowMs);
3487
- const rules = ruleSet(filter.rules);
3488
- const runs = await readRuns(dir);
3489
- const sliced = [];
3490
- for (const run of runs) {
3491
- if (!runInWindow(run, since, filter.cwd)) continue;
3492
- const slice = sliceRun(run, rules);
3493
- if (slice.touched) sliced.push({
3494
- slice,
3495
- ts: run.ts
4042
+ function createRuleGroups(state) {
4043
+ const groups = new Map([...state.ruleGroups].map(([ruleId, group]) => [ruleId, {
4044
+ ...group,
4045
+ jobs: []
4046
+ }]));
4047
+ for (const job of state.activeJobs.values()) groups.get(job.job.ruleId)?.jobs.push(job);
4048
+ return [...groups.values()].sort(compareRuleGroups);
4049
+ }
4050
+ function estimateEta(durationMs, terminal, planned) {
4051
+ if (terminal <= 0 || planned <= 0 || durationMs <= 0) return "eta ?";
4052
+ return `eta ~${formatDuration$1(durationMs * (planned - terminal) / terminal)}`;
4053
+ }
4054
+ function fitRow(row, columns) {
4055
+ if (columns <= 0) return "";
4056
+ if (fastStringWidth(row) <= columns) return row;
4057
+ let limit = columns;
4058
+ while (limit > 0) {
4059
+ const result = fastStringTruncatedWidth(row, {
4060
+ ellipsis: "…",
4061
+ limit
3496
4062
  });
4063
+ const index = completeGraphemeBoundary(row, result.index);
4064
+ const visible = row.slice(0, index);
4065
+ const fitted = `${visible}${visible.includes("\x1B") ? "\x1B[0m" : ""}${result.ellipsed ? "…" : ""}`;
4066
+ const overflow = fastStringWidth(fitted) - columns;
4067
+ if (overflow <= 0) return fitted;
4068
+ limit -= overflow;
3497
4069
  }
3498
- const interval = filter.interval ?? chooseInterval(since, nowMs, sliced.map((entry) => entry.ts));
3499
- const buckets = /* @__PURE__ */ new Map();
3500
- let totalRuns = 0;
3501
- let totalIn = 0;
3502
- let totalOut = 0;
3503
- let totalTok = 0;
3504
- for (const { slice, ts } of sliced) {
3505
- const startMs = bucketStart(ts, interval);
3506
- const bucket = buckets.get(startMs) ?? {
3507
- inTok: 0,
3508
- key: bucketLabel(startMs, interval),
3509
- outTok: 0,
3510
- runs: 0,
3511
- startMs,
3512
- totalTok: 0
3513
- };
3514
- bucket.runs += 1;
3515
- bucket.inTok += slice.inTok;
3516
- bucket.outTok += slice.outTok;
3517
- bucket.totalTok += slice.totalTok;
3518
- buckets.set(startMs, bucket);
3519
- totalRuns += 1;
3520
- totalIn += slice.inTok;
3521
- totalOut += slice.outTok;
3522
- totalTok += slice.totalTok;
3523
- }
3524
- return {
3525
- buckets: fillBuckets(buckets, interval),
3526
- interval,
3527
- totalIn,
3528
- totalOut,
3529
- totalRuns,
3530
- totalTok
3531
- };
4070
+ return "";
3532
4071
  }
3533
- async function readRuns(dir) {
3534
- const files = await statsFiles(dir);
3535
- const runs = [];
3536
- for (const { name } of files) {
3537
- const content = await readFile(join(dir, name), "utf8");
3538
- for (const line of content.split("\n")) {
3539
- if (line.trim() === "") continue;
3540
- const run = parseRunLine(line);
3541
- if (run) runs.push(run);
4072
+ function formatCollapsedRunningRow(hidden, options) {
4073
+ return fitRow(` └─ ${hidden} more running`, options.columns);
4074
+ }
4075
+ function formatContentRows(groups, state, options, now, contentBudget) {
4076
+ const rows = [];
4077
+ const renderableGroups = groups.filter((group) => group.jobs.some((job) => job.state === "running") || group.failed > 0);
4078
+ const runningCount = renderableGroups.reduce((sum, group) => sum + group.jobs.filter((job) => job.state === "running").length, 0);
4079
+ if (contentBudget === 1) {
4080
+ const failedGroup = renderableGroups.find((group) => group.failed > 0);
4081
+ if (failedGroup) return [formatRuleRow(failedGroup, state, options)];
4082
+ if (runningCount > 0) return [formatCollapsedRunningRow(runningCount, options)];
4083
+ }
4084
+ for (const [groupIndex, group] of renderableGroups.entries()) {
4085
+ if (rows.length >= contentBudget) break;
4086
+ const groupStart = rows.length;
4087
+ rows.push(formatRuleRow(group, state, options));
4088
+ const runningJobs = group.jobs.filter((job) => job.state === "running").sort(compareRunningJobs);
4089
+ const remainingGroups = renderableGroups.length - groupIndex - 1;
4090
+ for (const [index, job] of runningJobs.entries()) {
4091
+ const rowsReservedForLaterGroups = Math.min(remainingGroups, Math.max(contentBudget - rows.length, 0));
4092
+ if (rows.length >= contentBudget) {
4093
+ const hidden = runningJobs.length - index;
4094
+ if (rows.length > groupStart + 1 && hidden > 0) rows[rows.length - 1] = formatCollapsedRunningRow(hidden, options);
4095
+ break;
4096
+ }
4097
+ if (rows.length + 1 + rowsReservedForLaterGroups >= contentBudget) {
4098
+ if (runningJobs.length > 1) rows.push(formatCollapsedRunningRow(runningJobs.length - index, options));
4099
+ break;
4100
+ }
4101
+ rows.push(formatTargetRow(job, options, now, index === runningJobs.length - 1));
3542
4102
  }
3543
4103
  }
3544
- return runs;
4104
+ return rows;
3545
4105
  }
3546
- async function record(dir, input, ts, retentionMonths) {
3547
- const stat = {
3548
- ts,
3549
- ...input
3550
- };
3551
- await mkdir(dir, { recursive: true });
3552
- await appendFile(join(dir, statsFileName(ts)), `${JSON.stringify(stat)}\n`, "utf8");
3553
- await prune(dir, ts, retentionMonths);
4106
+ function formatDuration$1(ms) {
4107
+ const durationMs = Math.max(ms, 0);
4108
+ if (durationMs < 6e4) return `${(durationMs / 1e3).toFixed(1)}s`;
4109
+ return formatDuration(intervalToDuration({
4110
+ end: durationMs,
4111
+ start: 0
4112
+ }));
3554
4113
  }
3555
- function recordKey(record, dimension) {
3556
- if (dimension === "rule") return record.ruleId;
3557
- if (dimension === "operation") return record.operation ?? record.ruleId;
3558
- return `${record.providerId}/${record.modelId}`;
4114
+ function formatFooters(state, options, now) {
4115
+ const terminal = terminalCount(state.execution);
4116
+ const elapsed = state.runStartedAt === void 0 ? 0 : now - state.runStartedAt;
4117
+ const progressBar = state.progress.planningComplete ? formatMiniBarSegment(terminal, state.execution.planned, state.animationTick, options) : "";
4118
+ const eta = state.progress.planningComplete ? estimateEta(elapsed, terminal, state.execution.planned) : "eta ?";
4119
+ const projectedTokens = state.progress.planningComplete && terminal > 0 && state.execution.planned > 0 ? Math.ceil((state.totalTokens + state.cachedTokens) * state.execution.planned / terminal) : void 0;
4120
+ const discovering = state.progress.planningComplete ? "" : " jobs (discovering)";
4121
+ return [
4122
+ fitRow(`${terminal}/${state.execution.planned}${discovering}${progressBar} ${formatDuration$1(elapsed)} -> ${eta === "eta ?" ? "~?" : eta.replace("eta ", "")}`, options.columns),
4123
+ fitRow(`${state.execution.running} concurrency / ${state.execution.queued} queued / ${state.execution.cached} cached / ${state.execution.failed} failed`, options.columns),
4124
+ fitRow(`${state.totalTokens.toLocaleString("en-US")} tokens (${state.cachedTokens.toLocaleString("en-US")} cached) -> ${projectedTokens === void 0 ? "~?" : `~${projectedTokens.toLocaleString("en-US")} tokens`}`, options.columns)
4125
+ ];
3559
4126
  }
3560
- function ruleSet(rules) {
3561
- return rules && rules.length > 0 ? new Set(rules) : void 0;
4127
+ function formatMiniBarSegment(completed, planned, tick, options) {
4128
+ if (options.columns < 60) return "";
4129
+ return ` ${formatMiniBar({
4130
+ completed,
4131
+ planned,
4132
+ tick,
4133
+ width: 10
4134
+ })}`;
3562
4135
  }
3563
- function runInWindow(run, since, cwd) {
3564
- return (since === void 0 || run.ts >= since) && (cwd === void 0 || run.cwd === cwd);
4136
+ function formatRuleRow(group, state, options) {
4137
+ const spinner = formatSpinnerFrame(state.animationTick, options.spinnerFrames);
4138
+ const terminal = group.completed + group.cached + group.failed + group.cancelled + group.skipped;
4139
+ const percent = group.planned > 0 ? Math.floor(terminal / group.planned * 100) : 0;
4140
+ const bar = formatMiniBarSegment(terminal, group.planned, state.animationTick, options);
4141
+ const running = group.jobs.filter((job) => job.state === "running").length;
4142
+ const failed = group.failed > 0 ? ` ${group.failed} failed` : "";
4143
+ const eta = estimateEta(group.terminalDurationMs, terminal, group.planned);
4144
+ return fitRow(`${spinner} ${group.ruleId} ${terminal}/${group.planned} ${percent}%${bar} ${eta} ${running} running${failed}`, options.columns);
3565
4145
  }
3566
- function sliceRun(run, rules) {
3567
- if (rules === void 0) return {
3568
- inTok: run.usage.inTok,
3569
- outTok: run.usage.outTok,
3570
- totalTok: run.usage.totalTok,
3571
- touched: true,
3572
- usageRecords: run.usage.records
3573
- };
3574
- const usageRecords = run.usage.records.filter((record) => rules.has(record.ruleId));
3575
- return {
3576
- inTok: sumBy(usageRecords, (record) => record.inTok),
3577
- outTok: sumBy(usageRecords, (record) => record.outTok),
3578
- totalTok: sumBy(usageRecords, (record) => record.totalTok),
3579
- touched: usageRecords.length > 0,
3580
- usageRecords
3581
- };
4146
+ function formatSpinnerFrame(animationTick, spinnerFrames) {
4147
+ return spinnerFrames[animationTick % Math.max(spinnerFrames.length, 1)] ?? "";
3582
4148
  }
3583
- function statsFileName(ts) {
3584
- const date = new Date(ts);
3585
- return `stats-${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}.jsonl`;
4149
+ function formatTargetRow(jobState, options, now, last) {
4150
+ const inputPath = options.cwd ? relative$1(options.cwd, jobState.job.inputPath) || "." : jobState.job.inputPath;
4151
+ const target = jobState.job.target.name ? `${jobState.job.target.kind} ${jobState.job.target.name}` : jobState.job.target.kind;
4152
+ const startedAt = jobState.retry?.startedAt ?? jobState.startedAt ?? now;
4153
+ 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);
3586
4154
  }
3587
- async function statsFiles(dir) {
3588
- let entries;
3589
- try {
3590
- entries = await readdir(dir);
3591
- } catch (error) {
3592
- if (isEnoent(error)) return [];
3593
- throw error;
3594
- }
3595
- return entries.map((name) => {
3596
- const match = STATS_FILE_PATTERN.exec(name);
3597
- return match ? {
3598
- month: Number(match[1]) * 12 + (Number(match[2]) - 1),
3599
- name
3600
- } : void 0;
3601
- }).filter((entry) => entry !== void 0).sort((left, right) => left.month - right.month);
4155
+ function styleRow(row, state, warnCount, errorCount, options) {
4156
+ let styledRow = row;
4157
+ const spinner = formatSpinnerFrame(state.animationTick, options.spinnerFrames);
4158
+ if (spinner) styledRow = styledRow.replace(spinner, colors$2.cyan(spinner));
4159
+ return styledRow.replace(`${state.execution.running} concurrency`, colors$2.cyan(`${state.execution.running} concurrency`)).replace(`${state.totalTokens.toLocaleString("en-US")} tokens`, colors$2.cyan(`${state.totalTokens.toLocaleString("en-US")} tokens`)).replace(`(${state.cachedTokens.toLocaleString("en-US")} cached)`, colors$2.dim(`(${state.cachedTokens.toLocaleString("en-US")} cached)`)).replace(` / ${state.execution.cached} cached / `, ` / ${colors$2.dim(`${state.execution.cached} cached`)} / `).replace(`${warnCount} warn`, colors$2.yellow(`${warnCount} warn`)).replace(`${errorCount} error`, (errorCount > 0 ? colors$2.red : colors$2.gray)(`${errorCount} error`)).replace(`${state.execution.failed} failed`, (state.execution.failed > 0 ? colors$2.red : colors$2.gray)(`${state.execution.failed} failed`)).replace(/\[([█▓░]*?)(░+)\]/g, (_, active, pending) => `[${active}${colors$2.gray(pending)}]`).replace(/\//g, colors$2.gray("/"));
3602
4160
  }
3603
- function sumBy(items, select) {
3604
- let total = 0;
3605
- for (const item of items) total += select(item);
3606
- return total;
4161
+ function terminalCount(counts) {
4162
+ return counts.completed + counts.failed + counts.cached + counts.skipped + counts.cancelled;
3607
4163
  }
3608
- //#endregion
3609
- //#region src/cli/stats/record.ts
3610
- function createRunStat(input) {
3611
- return {
3612
- cwd: input.cwd,
3613
- durationMs: input.durationMs,
3614
- ruleCounts: input.ruleCounts,
3615
- usage: {
3616
- inTok: input.usage.inputTokens,
3617
- outTok: input.usage.outputTokens,
3618
- records: input.usage.records.map(toStatsUsageRecord),
3619
- totalTok: input.usage.totalTokens
3620
- }
4164
+ function updateProgress(state, progress) {
4165
+ state.execution = { ...progress.execution };
4166
+ state.progress = {
4167
+ ...progress,
4168
+ execution: { ...progress.execution }
3621
4169
  };
3622
4170
  }
3623
- function readOperation(metadata) {
3624
- if (typeof metadata !== "object" || metadata === null || !("operation" in metadata)) return;
3625
- const operation = metadata.operation;
3626
- return typeof operation === "string" ? operation : void 0;
3627
- }
3628
- function toStatsUsageRecord(record) {
3629
- return {
3630
- filePath: record.filePath,
3631
- inTok: record.inputTokens ?? 0,
3632
- modelId: record.modelId,
3633
- operation: readOperation(record.metadata),
3634
- outTok: record.outputTokens ?? 0,
3635
- providerId: record.providerId,
3636
- ruleId: record.ruleId,
3637
- totalTok: record.totalTokens ?? 0
4171
+ //#endregion
4172
+ //#region src/cli/reporters/progress/tty.ts
4173
+ const clearCurrentLine = "\r\x1B[K";
4174
+ const clearPreviousLine = "\r\x1B[1A\x1B[K";
4175
+ function createTtyProgressRenderer(options) {
4176
+ let interval;
4177
+ let previousRows = 0;
4178
+ const clearPreviousFrame = () => {
4179
+ if (previousRows === 0) return;
4180
+ let sequence = clearCurrentLine;
4181
+ for (let row = 1; row < previousRows; row += 1) sequence += clearPreviousLine;
4182
+ options.write(sequence);
4183
+ previousRows = 0;
4184
+ };
4185
+ const render = () => {
4186
+ clearPreviousFrame();
4187
+ const rows = options.getRows();
4188
+ if (rows.length === 0) return;
4189
+ options.write(rows.join("\n"));
4190
+ previousRows = rows.length;
3638
4191
  };
3639
- }
3640
- //#endregion
3641
- //#region src/cli/commands/lint/stats.ts
3642
- function createStatsCollector() {
3643
- const counts = {
3644
- cached: 0,
3645
- cancelled: 0,
3646
- completed: 0,
3647
- failed: 0,
3648
- planned: 0
4192
+ const write = (chunk) => {
4193
+ const wasRendering = interval !== void 0;
4194
+ clearPreviousFrame();
4195
+ options.write(chunk);
4196
+ if (wasRendering) render();
3649
4197
  };
3650
- let startedAt;
3651
- let endedAt;
3652
4198
  return {
3653
- counts,
3654
- durationMs: () => startedAt !== void 0 && endedAt !== void 0 ? endedAt - startedAt : void 0,
3655
- reporter: {
3656
- onPrepareStart: (payload) => {
3657
- startedAt = payload.startedAt;
3658
- },
3659
- onRunEnd: (payload) => {
3660
- counts.cached = payload.execution.cached;
3661
- counts.cancelled = payload.execution.cancelled;
3662
- counts.completed = payload.execution.completed;
3663
- counts.failed = payload.execution.failed;
3664
- counts.planned = payload.execution.planned;
3665
- endedAt = payload.endedAt;
4199
+ finish: () => {
4200
+ if (interval) {
4201
+ options.clearInterval(interval);
4202
+ interval = void 0;
3666
4203
  }
3667
- }
4204
+ clearPreviousFrame();
4205
+ },
4206
+ render,
4207
+ start: () => {
4208
+ if (!interval) {
4209
+ interval = options.createInterval(render, options.intervalMs);
4210
+ if (isUnrefableInterval(interval)) interval.unref();
4211
+ }
4212
+ render();
4213
+ },
4214
+ write
3668
4215
  };
3669
4216
  }
3670
- function mergeProgressReporters(base, extra) {
3671
- if (!base) return extra;
3672
- if (!extra) return base;
3673
- const baseState = {
3674
- disabled: false,
3675
- reporter: base
3676
- };
3677
- const extraState = {
3678
- disabled: false,
3679
- reporter: extra
3680
- };
3681
- let firstFailure;
3682
- const deliver = (state, callback) => {
3683
- if (state.disabled) return;
3684
- try {
3685
- callback(state.reporter);
3686
- } catch (error) {
3687
- state.disabled = true;
3688
- firstFailure ??= normalizeReporterError(error);
3689
- }
4217
+ function isUnrefableInterval(interval) {
4218
+ if (typeof interval !== "object" || interval === null || !("unref" in interval)) return false;
4219
+ return typeof interval.unref === "function";
4220
+ }
4221
+ //#endregion
4222
+ //#region src/cli/reporters/progress/index.ts
4223
+ function createCliProgressReporter(options) {
4224
+ if (!options.isTty) return {
4225
+ dispose: () => {},
4226
+ reporter: createPlainProgressReporter({ write: options.write }),
4227
+ write: options.write
3690
4228
  };
3691
- const deliverBoth = (callback) => {
3692
- deliver(baseState, callback);
3693
- deliver(extraState, callback);
4229
+ const summary = createSummaryProgressReporter({
4230
+ color: options.color,
4231
+ columns: options.columns,
4232
+ cwd: options.cwd,
4233
+ rows: options.rows,
4234
+ spinnerFrames: cliSpinners.dots.frames
4235
+ });
4236
+ const renderer = createTtyProgressRenderer({
4237
+ clearInterval: (handle) => globalThis.clearInterval(handle),
4238
+ createInterval: (callback, intervalMs) => globalThis.setInterval(() => {
4239
+ summary.tick();
4240
+ callback();
4241
+ }, intervalMs),
4242
+ getRows: summary.getRows,
4243
+ intervalMs: 120,
4244
+ write: options.write
4245
+ });
4246
+ const reporter = createRenderingProgressReporter(summary, renderer);
4247
+ return {
4248
+ dispose: renderer.finish,
4249
+ reporter,
4250
+ write: renderer.write
3694
4251
  };
4252
+ }
4253
+ function createRenderingProgressReporter(summary, renderer) {
3695
4254
  return {
3696
4255
  onDiagnostic: (payload) => {
3697
- deliverBoth((reporter) => reporter.onDiagnostic?.(payload));
4256
+ summary.onDiagnostic?.(payload);
4257
+ renderer.render();
3698
4258
  },
3699
4259
  onExecuteEnd: (payload) => {
3700
- deliverBoth((reporter) => reporter.onExecuteEnd?.(payload));
4260
+ summary.onExecuteEnd?.(payload);
4261
+ renderer.render();
3701
4262
  },
3702
4263
  onExecuteStart: (payload) => {
3703
- deliverBoth((reporter) => reporter.onExecuteStart?.(payload));
4264
+ summary.onExecuteStart?.(payload);
4265
+ renderer.render();
3704
4266
  },
3705
4267
  onFileReady: (payload) => {
3706
- deliverBoth((reporter) => reporter.onFileReady?.(payload));
4268
+ summary.onFileReady?.(payload);
4269
+ renderer.render();
3707
4270
  },
3708
4271
  onJobEnd: (payload) => {
3709
- deliverBoth((reporter) => reporter.onJobEnd?.(payload));
4272
+ summary.onJobEnd?.(payload);
4273
+ renderer.render();
3710
4274
  },
3711
4275
  onJobQueued: (payload) => {
3712
- deliverBoth((reporter) => reporter.onJobQueued?.(payload));
4276
+ summary.onJobQueued?.(payload);
4277
+ renderer.render();
3713
4278
  },
3714
4279
  onJobRetry: (payload) => {
3715
- deliverBoth((reporter) => reporter.onJobRetry?.(payload));
4280
+ summary.onJobRetry?.(payload);
4281
+ renderer.render();
3716
4282
  },
3717
4283
  onJobStart: (payload) => {
3718
- deliverBoth((reporter) => reporter.onJobStart?.(payload));
3719
- },
3720
- onPlanningEnd: (payload) => {
3721
- deliverBoth((reporter) => reporter.onPlanningEnd?.(payload));
4284
+ summary.onJobStart?.(payload);
4285
+ renderer.render();
3722
4286
  },
3723
4287
  onPrepareEnd: (payload) => {
3724
- deliverBoth((reporter) => reporter.onPrepareEnd?.(payload));
4288
+ summary.onPrepareEnd?.(payload);
4289
+ renderer.render();
3725
4290
  },
3726
4291
  onPrepareStart: (payload) => {
3727
- deliverBoth((reporter) => reporter.onPrepareStart?.(payload));
4292
+ summary.onPrepareStart?.(payload);
4293
+ renderer.start();
3728
4294
  },
3729
4295
  onRunEnd: (payload) => {
3730
- deliverBoth((reporter) => reporter.onRunEnd?.(payload));
3731
- if (firstFailure) throw firstFailure;
4296
+ summary.onRunEnd?.(payload);
4297
+ renderer.render();
3732
4298
  },
3733
4299
  onUsage: (payload) => {
3734
- deliverBoth((reporter) => reporter.onUsage?.(payload));
4300
+ summary.onUsage?.(payload);
4301
+ renderer.render();
3735
4302
  }
3736
4303
  };
3737
4304
  }
3738
- function resolveStatsWrite(stats, env) {
3739
- if (isCi(env)) return;
3740
- if (!(stats === false ? false : stats === void 0 || stats === true ? true : stats.enabled !== false)) return;
3741
- const config = typeof stats === "object" ? stats : void 0;
3742
- return {
3743
- dir: config?.location ?? getStatsDir(env),
3744
- retentionMonths: config?.retentionMonths
3745
- };
4305
+ //#endregion
4306
+ //#region src/cli/commands/lint/errors.ts
4307
+ const colors$1 = createColors({ force: true });
4308
+ function formatCancelledError(error, color) {
4309
+ return `${color ? colors$1.red("error") : "error"} ${error.message}\n`;
3746
4310
  }
3747
- async function writeRunStats(target, collector, result, cwd) {
3748
- const store = createJsonlStatsStore({
3749
- dir: target.dir,
3750
- retentionMonths: target.retentionMonths
3751
- });
3752
- try {
3753
- await store.record(createRunStat({
3754
- cwd,
3755
- durationMs: collector.durationMs(),
3756
- ruleCounts: {
3757
- cached: result.execution.cached,
3758
- cancelled: result.execution.cancelled,
3759
- completed: result.execution.completed,
3760
- failed: result.execution.failed,
3761
- planned: result.execution.planned
3762
- },
3763
- usage: result.usage
3764
- }));
3765
- } catch {}
4311
+ function formatRunError(error, color) {
4312
+ const label = color ? colors$1.red("error") : "error";
4313
+ const fileFailures = error.failures.filter((failure) => "file" in failure);
4314
+ const groups = groupFailuresByRule(error.failures.filter((failure) => "job" in failure));
4315
+ return [
4316
+ `${label} ${error.message}`,
4317
+ "",
4318
+ ...fileFailures.length > 0 ? [
4319
+ `Failed Files ${fileFailures.length}`,
4320
+ "",
4321
+ ...formatFileFailures(fileFailures, color)
4322
+ ] : [],
4323
+ ...groups.length > 0 ? [
4324
+ `Failed Rules ${groups.length}`,
4325
+ "",
4326
+ ...formatFailureGroups(groups, color)
4327
+ ] : []
4328
+ ].join("\n");
3766
4329
  }
3767
- function normalizeReporterError(error) {
3768
- if (error instanceof Error) return error;
3769
- return new Error(error != null ? String(error) : "Unknown progress reporter error.");
4330
+ function formatFailureGroups(groups, color) {
4331
+ const lines = [];
4332
+ for (const [ruleId, group] of groups) {
4333
+ const failLabel = color ? colors$1.bgRed(colors$1.bold(" FAIL ")) : "FAIL";
4334
+ const targetLabel = group.length === 1 ? "target" : "targets";
4335
+ lines.push(`${failLabel} ${ruleId} ${group.length} ${targetLabel}`);
4336
+ for (const failure of group) {
4337
+ lines.push(` ${formatTarget(failure)}`);
4338
+ lines.push(` [${failure.kind}] ${failure.message}`);
4339
+ for (const cause of failure.causes) {
4340
+ const code = cause.code === void 0 ? "" : `[${cause.code}] `;
4341
+ lines.push(` Caused by: ${code}${cause.message}`);
4342
+ }
4343
+ }
4344
+ lines.push("");
4345
+ }
4346
+ return lines;
4347
+ }
4348
+ function formatFileFailures(failures, color) {
4349
+ const lines = [];
4350
+ for (const failure of failures) {
4351
+ const failLabel = color ? colors$1.bgRed(colors$1.bold(" FAIL ")) : "FAIL";
4352
+ lines.push(`${failLabel} ${failure.file.path}`);
4353
+ lines.push(` [${failure.kind}] ${failure.message}`);
4354
+ lines.push("");
4355
+ }
4356
+ return lines;
4357
+ }
4358
+ function formatTarget(failure) {
4359
+ const target = failure.job.target.name ? `${failure.job.target.kind} ${failure.job.target.name}` : failure.job.target.kind;
4360
+ return `${failure.job.inputPath} > ${target}`;
4361
+ }
4362
+ function groupFailuresByRule(failures) {
4363
+ const groups = /* @__PURE__ */ new Map();
4364
+ for (const failure of failures) {
4365
+ const group = groups.get(failure.job.ruleId) ?? [];
4366
+ group.push(failure);
4367
+ groups.set(failure.job.ruleId, group);
4368
+ }
4369
+ return [...groups];
3770
4370
  }
3771
4371
  //#endregion
3772
4372
  //#region src/cli/commands/lint/index.ts
@@ -3779,78 +4379,101 @@ const lint = defineCommand({
3779
4379
  arguments: "[...files]",
3780
4380
  default: true,
3781
4381
  description: "Run alint",
3782
- name: "lint"
4382
+ name: "lint",
4383
+ options: [{
4384
+ description: "Lint only staged, unstaged, and untracked files",
4385
+ flags: "--dirty"
4386
+ }]
3783
4387
  });
3784
4388
  async function assertConfigExists(cwd, configPath) {
3785
4389
  const resolvedConfigPath = resolve(cwd, configPath);
3786
4390
  try {
3787
4391
  if (!(await stat(resolvedConfigPath)).isFile()) throw new Error(`Config file "${configPath}" is not a file.`);
3788
4392
  } catch (error) {
3789
- if (isNodeError(error) && error.code === "ENOENT") throw new Error(`Config file "${configPath}" does not exist.`);
4393
+ if (isNodeErrorCode(error, "ENOENT")) throw new Error(`Config file "${configPath}" does not exist.`);
3790
4394
  throw error;
3791
4395
  }
3792
4396
  }
3793
- function isNodeError(error) {
3794
- return error instanceof Error && "code" in error;
3795
- }
3796
4397
  async function runLintCommand(files, options, io, interceptConsoleOutput) {
3797
- if (options.config) await assertConfigExists(io.cwd, options.config);
3798
- const session = await createRunSession(io, { configPath: options.config });
4398
+ if (options.dirty && files.length > 0) {
4399
+ io.stderr.write("The --dirty option does not accept file arguments.\n");
4400
+ return 2;
4401
+ }
4402
+ const cwd = options.dirty ? await findGitRoot(io.cwd) : io.cwd;
4403
+ const runIo = cwd === io.cwd ? io : {
4404
+ ...io,
4405
+ cwd
4406
+ };
4407
+ if (options.config) await assertConfigExists(cwd, options.config);
4408
+ let changedLines;
4409
+ let config;
4410
+ let targets;
4411
+ if (options.dirty) {
4412
+ config = await loadAlintConfig(cwd, options.config);
4413
+ const dirtyTargets = await findDirtyLintTargets(config, cwd);
4414
+ changedLines = dirtyTargets.changedLines;
4415
+ targets = dirtyTargets;
4416
+ if (targets.files.length === 0) return 0;
4417
+ }
4418
+ const session = await createRunSession(runIo, config === void 0 ? { configPath: options.config } : { config });
3799
4419
  try {
3800
- const runner = resolveRunnerConfig(session.runner, options);
3801
- const progress = shouldEnableProgress(options, io) ? createCliProgressReporter({
3802
- color: io.stderr.isTTY === true,
3803
- columns: io.stderr.columns ?? 80,
3804
- cwd: io.cwd,
3805
- isTty: io.stderr.isTTY === true,
3806
- rows: io.stderr.rows,
3807
- write: (chunk) => io.stderr.write(chunk)
4420
+ const progress = shouldEnableProgress(options, runIo) ? createCliProgressReporter({
4421
+ color: runIo.stderr.isTTY === true,
4422
+ columns: runIo.stderr.columns ?? 80,
4423
+ cwd,
4424
+ isTty: runIo.stderr.isTTY === true,
4425
+ rows: runIo.stderr.rows,
4426
+ write: (chunk) => runIo.stderr.write(chunk)
3808
4427
  }) : void 0;
3809
4428
  const restoreProgressConsole = progress ? interceptConsoleOutput({ write: progress.write }) : void 0;
3810
- const statsTarget = resolveStatsWrite(runner?.stats, io.env);
3811
- const statsCollector = statsTarget ? createStatsCollector() : void 0;
3812
- const persistStats = async (runResult) => {
3813
- if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, runResult, io.cwd);
3814
- };
4429
+ const visibleResult = (runResult) => changedLines === void 0 ? runResult : filterResultToChangedLines(runResult, {
4430
+ changedLines,
4431
+ cwd
4432
+ });
3815
4433
  const writeResult = (runResult) => {
3816
- io.stdout.write(formatDiagnostics(options.format, runResult, { color: io.stdout.isTTY === true }));
4434
+ runIo.stdout.write(formatDiagnostics(options.format, visibleResult(runResult), { color: runIo.stdout.isTTY === true }));
3817
4435
  };
4436
+ const targetSelection = targets === void 0 ? { inputs: files } : { targets };
3818
4437
  let result;
3819
4438
  try {
3820
- result = await session.run({
4439
+ result = await executeLint({
4440
+ ...targetSelection,
3821
4441
  cacheOnly: options.cacheOnly,
3822
- files,
4442
+ io: runIo,
3823
4443
  modelOverride: options.model,
3824
4444
  outputLanguage: options.outputLanguage,
3825
- progress: mergeProgressReporters(progress?.reporter, statsCollector?.reporter),
3826
- runner
4445
+ progress: progress?.reporter,
4446
+ runnerOptions: options,
4447
+ session
3827
4448
  });
3828
4449
  } catch (error) {
3829
- restoreProgressConsole?.();
3830
- progress?.dispose();
3831
4450
  if (error instanceof NoFilesFoundError) {
3832
- io.stderr.write(`${error.message}\n`);
4451
+ runIo.stderr.write(`${error.message}\n`);
3833
4452
  return 2;
3834
4453
  }
4454
+ if (error instanceof AlintCachePersistenceError) {
4455
+ writeResult(error.result);
4456
+ const message = errorMessageFrom(error.cause) ?? error.message;
4457
+ runIo.stderr.write(`Cache persistence failed: ${message}\n`);
4458
+ return 1;
4459
+ }
3835
4460
  if (error instanceof AlintRunError) {
3836
- await persistStats(error.result);
3837
4461
  writeResult(error.result);
3838
- io.stderr.write(formatRunError(error, io.stderr.isTTY === true));
4462
+ runIo.stderr.write(formatRunError(error, runIo.stderr.isTTY === true));
3839
4463
  return 2;
3840
4464
  }
3841
4465
  if (error instanceof AlintRunCancelledError) {
3842
- await persistStats(error.result);
3843
4466
  writeResult(error.result);
3844
- io.stderr.write(formatCancelledError(error, io.stderr.isTTY === true));
4467
+ runIo.stderr.write(formatCancelledError(error, runIo.stderr.isTTY === true));
3845
4468
  return 2;
3846
4469
  }
3847
4470
  throw error;
4471
+ } finally {
4472
+ restoreProgressConsole?.();
4473
+ progress?.dispose();
3848
4474
  }
3849
- restoreProgressConsole?.();
3850
- progress?.dispose();
3851
- await persistStats(result);
3852
4475
  writeResult(result);
3853
- return result.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
4476
+ return visibleResult(result).diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
3854
4477
  } finally {
3855
4478
  await session.shutdown();
3856
4479
  }
@@ -3862,7 +4485,7 @@ function shouldEnableProgress(options, io) {
3862
4485
  //#endregion
3863
4486
  //#region src/cli/commands/lsp/index.ts
3864
4487
  const lsp = defineCommand({
3865
- action: async (context) => (await import("./server-DgJa6R5l.mjs")).startLspServer(context.io),
4488
+ action: async (context) => (await import("./server-xx0w7UQL.mjs")).startLspServer(context.io),
3866
4489
  description: "Run alint as a language server over stdio",
3867
4490
  examples: [["# Point an editor at the project's own alint", "alint lsp"].join("\n")],
3868
4491
  help: ["Serve alint diagnostics over the Language Server Protocol on stdin/stdout.", "The server is cache-first: it publishes findings that are already cached and never calls a model on its own. Runs that spend tokens happen only through the `alint.runFile` and `alint.runWorkspace` commands."].join("\n\n"),
@@ -3901,14 +4524,14 @@ async function inspectOutputFile(io, file, options) {
3901
4524
  try {
3902
4525
  text = await readFile(filePath, "utf8");
3903
4526
  } catch (error) {
3904
- io.stderr.write(`Could not read output file "${file}": ${errorMessageFrom$1(error)}\n`);
4527
+ io.stderr.write(`Could not read output file "${file}": ${errorMessageFrom(error)}\n`);
3905
4528
  return 2;
3906
4529
  }
3907
4530
  let parsed;
3908
4531
  try {
3909
4532
  parsed = JSON.parse(text);
3910
4533
  } catch (error) {
3911
- io.stderr.write(`Could not parse output file "${file}": ${errorMessageFrom$1(error)}\n`);
4534
+ io.stderr.write(`Could not parse output file "${file}": ${errorMessageFrom(error)}\n`);
3912
4535
  return 2;
3913
4536
  }
3914
4537
  if (!isRunResult(parsed)) {
@@ -3918,7 +4541,7 @@ async function inspectOutputFile(io, file, options) {
3918
4541
  try {
3919
4542
  io.stdout.write(formatDiagnostics(options.format, parsed, { color: io.stdout.isTTY === true }));
3920
4543
  } catch (error) {
3921
- io.stderr.write(`${errorMessageFrom$1(error)}\n`);
4544
+ io.stderr.write(`${errorMessageFrom(error)}\n`);
3922
4545
  return 2;
3923
4546
  }
3924
4547
  return parsed.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
@@ -4443,7 +5066,7 @@ async function runStatsCommand(options, io) {
4443
5066
  }));
4444
5067
  return 0;
4445
5068
  } catch (error) {
4446
- io.stderr.write(`${errorMessageFrom$1(error) ?? "Failed to read stats."}\n`);
5069
+ io.stderr.write(`${errorMessageFrom(error) ?? "Failed to read stats."}\n`);
4447
5070
  return 2;
4448
5071
  }
4449
5072
  }
@@ -4479,6 +5102,7 @@ function statsLocation(stats) {
4479
5102
  const commandTree = [
4480
5103
  setup,
4481
5104
  config,
5105
+ integrations,
4482
5106
  plugin,
4483
5107
  output,
4484
5108
  defineCommand({
@@ -4619,4 +5243,4 @@ function shouldCaptureHelp(argv) {
4619
5243
  return argv.includes("--help") || argv.includes("-h");
4620
5244
  }
4621
5245
  //#endregion
4622
- export { formatJson as a, formatStylish as i, createRunSession as n, formatDiagnostics as r, executeCli as t };
5246
+ export { createRunSession as a, formatJson as i, formatDiagnostics as n, formatStylish as r, executeCli as t };