tend-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,1875 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { Command } from "commander";
4
- import { createHash } from "node:crypto";
5
- import { z } from "zod";
6
- import { tmpdir } from "node:os";
7
- import { simpleGit } from "simple-git";
8
- import PQueue from "p-queue";
9
- import { customAlphabet } from "nanoid";
10
- import Table from "cli-table3";
11
- import { Chalk, supportsColor } from "chalk";
12
- import gradient from "gradient-string";
13
- import { cosmiconfig } from "cosmiconfig";
14
-
15
- //#region src/cli.ts
16
- const COMMAND_NAMES = new Set([
17
- "diff",
18
- "help",
19
- "retry",
20
- "run",
21
- "show",
22
- "undo"
23
- ]);
24
- const RUN_OPTION_NAMES = new Set([
25
- "--all",
26
- "--effort",
27
- "--include-tests",
28
- "--max-loops",
29
- "--max-sessions",
30
- "--model",
31
- "--no-color",
32
- "--plain",
33
- "--verbose"
34
- ]);
35
- function addRunOptions(command) {
36
- return command.option("--all", "fix the entire backlog, not just changed files").option("--max-loops <n>", "cap on fix loops", (v) => parseInt(v, 10)).option("--max-sessions <n>", "concurrent AI sessions", (v) => parseInt(v, 10)).option("--model <model>", "model for fixes: sonnet (default), opus, haiku, or a full model id").option("--effort <level>", "reasoning effort for fixes: low | medium | high | xhigh | max").option("--include-tests", "also fix findings in test files (excluded by default)").option("--plain", "plain one-line-per-event output for pipes/CI (no color, no spinners)").option("--no-color", "disable color output").option("--verbose", "show the full per-tool / per-finding breakdown in the summary");
37
- }
38
- function looksLikePath(value) {
39
- return value.includes("/") || value.includes("\\") || value.startsWith(".") || existsSync(value);
40
- }
41
- function isRunOption(value) {
42
- const name = value.split("=")[0] ?? value;
43
- return RUN_OPTION_NAMES.has(name);
44
- }
45
- function shouldInsertRun(args) {
46
- const first = args[0];
47
- if (!first) return true;
48
- if (first === "--help" || first === "-h" || COMMAND_NAMES.has(first)) return false;
49
- return isRunOption(first) || looksLikePath(first);
50
- }
51
- function withDefaultRun(argv, from) {
52
- const prefixLength = from === "user" ? 0 : 2;
53
- const prefix = argv.slice(0, prefixLength);
54
- const args = argv.slice(prefixLength);
55
- return shouldInsertRun(args) ? [
56
- ...prefix,
57
- "run",
58
- ...args
59
- ] : [...argv];
60
- }
61
- function enableDefaultRun(program) {
62
- const parse = program.parse.bind(program);
63
- const parseAsync = program.parseAsync.bind(program);
64
- program.parse = (argv, options) => parse(withDefaultRun(argv ?? process.argv, options?.from), options);
65
- program.parseAsync = (argv, options) => parseAsync(withDefaultRun(argv ?? process.argv, options?.from), options);
66
- }
67
- /** Build the commander program wiring each subcommand to a handler. */
68
- function buildProgram(handlers) {
69
- const program = new Command();
70
- program.name("tend").description("Audit a JS/TS repo and fix findings with AI in a safe loop.");
71
- program.exitOverride();
72
- program.addHelpCommand(true);
73
- const run = program.command("run").description("snapshot → audit → fix loop → report (changed files)").argument("[paths...]", "fix only findings under these files/dirs (committed or not)");
74
- addRunOptions(run).action((paths, opts) => handlers.run({
75
- ...opts,
76
- paths
77
- }));
78
- program.command("diff").description("show only the tool's edits").action(() => handlers.diff());
79
- program.command("undo").description("restore the pre-run snapshot").action(() => handlers.undo());
80
- program.command("show <id>").description("full detail on one finding").action((id) => handlers.show(id));
81
- program.command("retry <id>").description("re-attempt a stubborn finding with a larger budget").action((id) => handlers.retry(id));
82
- enableDefaultRun(program);
83
- return program;
84
- }
85
-
86
- //#endregion
87
- //#region src/scanners/scope.ts
88
- /**
89
- * Files changed vs `HEAD` (tracked modifications/additions/renames plus untracked),
90
- * scoped and re-based to `git`'s working directory — see `changedVsHead` in git/repo.ts
91
- * for why this matters when tend runs from a subdirectory of the repo.
92
- */
93
- async function changedFiles(git) {
94
- const prefix = (await git.revparse(["--show-prefix"])).trim();
95
- const status = await git.status();
96
- const files = new Set();
97
- for (const file of status.files) {
98
- const path = file.path.includes(" -> ") ? file.path.split(" -> ")[1] : file.path;
99
- if (!prefix) files.add(path);
100
- else if (path.startsWith(prefix)) files.add(path.slice(prefix.length));
101
- }
102
- return [...files];
103
- }
104
- /** Keep only findings whose file is in the changed set. */
105
- function filterToChanged(findings, changed) {
106
- const set = new Set(changed);
107
- return findings.filter((f) => {
108
- if (set.has(f.file)) return true;
109
- if (f.category === "duplication") return (f.flowPath ?? []).some((p) => set.has(p.file));
110
- return false;
111
- });
112
- }
113
- /** Apply the fix scope: `--all` fixes everything, otherwise only changed files. */
114
- function scopeFindings(findings, opts) {
115
- return opts.all ? findings : filterToChanged(findings, opts.changed);
116
- }
117
-
118
- //#endregion
119
- //#region src/findings/finding.ts
120
- const TOOLS = [
121
- "sonarjs",
122
- "knip",
123
- "jscpd",
124
- "semgrep",
125
- "osv",
126
- "gitleaks"
127
- ];
128
- const RangeSchema = z.object({
129
- startLine: z.number(),
130
- startCol: z.number(),
131
- endLine: z.number(),
132
- endCol: z.number()
133
- });
134
- const FindingSchema = z.object({
135
- id: z.string(),
136
- retryId: z.string().optional(),
137
- tool: z.enum(TOOLS),
138
- rule: z.string(),
139
- category: z.enum([
140
- "bug",
141
- "smell",
142
- "dead-code",
143
- "duplication",
144
- "security",
145
- "secret",
146
- "vuln-dep"
147
- ]),
148
- severity: z.enum([
149
- "error",
150
- "warning",
151
- "info"
152
- ]),
153
- file: z.string(),
154
- range: RangeSchema,
155
- message: z.string(),
156
- helpUri: z.string().optional(),
157
- flowPath: z.array(z.object({
158
- file: z.string(),
159
- line: z.number()
160
- })).optional(),
161
- remediation: z.string().optional(),
162
- track: z.enum([
163
- "ai-fix",
164
- "deterministic",
165
- "report-only"
166
- ]),
167
- status: z.enum([
168
- "pending",
169
- "fixing",
170
- "fixed",
171
- "reverted",
172
- "unfixable",
173
- "skipped"
174
- ]),
175
- attempts: z.number(),
176
- revertReason: z.enum([
177
- "broke-test",
178
- "suppression",
179
- "regression",
180
- "typecheck",
181
- "session-error"
182
- ]).optional(),
183
- revertDetail: z.string().optional(),
184
- firstSeenLoop: z.number(),
185
- lastSeenLoop: z.number(),
186
- inScope: z.boolean().optional()
187
- });
188
- /**
189
- * Stable identity for a finding: hash(tool | rule | file | line | message).
190
- * Same components → same fingerprint, across loops and runs.
191
- */
192
- function fingerprint(input) {
193
- const key = [
194
- input.tool,
195
- input.rule,
196
- input.file,
197
- input.line,
198
- input.message
199
- ].join("|");
200
- return createHash("sha256").update(key).digest("hex");
201
- }
202
-
203
- //#endregion
204
- //#region src/findings/normalize.ts
205
- const TRACK_BY_TOOL = {
206
- sonarjs: "ai-fix",
207
- knip: "ai-fix",
208
- jscpd: "ai-fix",
209
- semgrep: "ai-fix",
210
- osv: "deterministic",
211
- gitleaks: "report-only"
212
- };
213
- const CROSS_FILE_JSCPD_REMEDIATION = "Requires a multi-file refactor across the duplicated clone sites; Tend does not run multi-file AI fixers for jscpd duplicates yet.";
214
- /** Which track a tool's findings flow into. */
215
- function trackForTool(tool) {
216
- return TRACK_BY_TOOL[tool];
217
- }
218
- function isCrossFileJscpdDuplicate(raw) {
219
- if (raw.tool !== "jscpd" || raw.rule !== "duplicate-code") return false;
220
- return new Set((raw.flowPath ?? []).map((step) => step.file)).size > 1;
221
- }
222
- /** Which track a scanner finding flows into after rule-specific routing. */
223
- function trackForRawFinding(raw) {
224
- if (isCrossFileJscpdDuplicate(raw)) return "report-only";
225
- return trackForTool(raw.tool);
226
- }
227
- /** Turn a raw scanner record into a tracked `Finding` for the given loop. */
228
- function normalize(raw, loop) {
229
- const id = fingerprint({
230
- tool: raw.tool,
231
- rule: raw.rule,
232
- file: raw.file,
233
- line: raw.range.startLine,
234
- message: raw.message
235
- });
236
- const finding = {
237
- id,
238
- tool: raw.tool,
239
- rule: raw.rule,
240
- category: raw.category,
241
- severity: raw.severity,
242
- file: raw.file,
243
- range: raw.range,
244
- message: raw.message,
245
- track: trackForRawFinding(raw),
246
- status: "pending",
247
- attempts: 0,
248
- firstSeenLoop: loop,
249
- lastSeenLoop: loop
250
- };
251
- if (raw.helpUri !== void 0) finding.helpUri = raw.helpUri;
252
- if (raw.flowPath !== void 0) finding.flowPath = raw.flowPath;
253
- if (raw.remediation !== void 0) finding.remediation = raw.remediation;
254
- else if (isCrossFileJscpdDuplicate(raw)) finding.remediation = CROSS_FILE_JSCPD_REMEDIATION;
255
- return finding;
256
- }
257
-
258
- //#endregion
259
- //#region src/scanners/scanner.ts
260
- /** Collapse a ScanResult to its reportable status: skipped → ran → failed (error present). */
261
- function scannerStatus(result$1) {
262
- if (result$1.skipped) return {
263
- tool: result$1.tool,
264
- status: "skipped"
265
- };
266
- if (result$1.error !== void 0) return {
267
- tool: result$1.tool,
268
- status: "failed",
269
- reason: result$1.error
270
- };
271
- return {
272
- tool: result$1.tool,
273
- status: "ran"
274
- };
275
- }
276
- async function isAvailable(scanner, which) {
277
- return which(scanner.binary);
278
- }
279
- /**
280
- * Shared run sequence for every scanner:
281
- * availability → args → spawn → parse → normalize.
282
- * Missing binary → skipped (not fatal). Timeout/spawn error or malformed output → error result.
283
- */
284
- async function runScanner(scanner, ctx, deps) {
285
- if (!await isAvailable(scanner, deps.which)) return {
286
- tool: scanner.tool,
287
- findings: [],
288
- skipped: true
289
- };
290
- const args = scanner.buildArgs(ctx);
291
- let raw;
292
- try {
293
- raw = await deps.spawn(scanner.binary, args, {
294
- cwd: ctx.cwd,
295
- timeout: deps.timeout
296
- });
297
- } catch (err) {
298
- return {
299
- tool: scanner.tool,
300
- findings: [],
301
- skipped: false,
302
- error: errorMessage(err)
303
- };
304
- }
305
- try {
306
- const findings = scanner.parse(raw, ctx).map((r) => normalize(r, ctx.loop));
307
- return {
308
- tool: scanner.tool,
309
- findings,
310
- skipped: false
311
- };
312
- } catch (err) {
313
- const reason = raw.exitCode !== 0 ? raw.stderr.trim() || errorMessage(err) : errorMessage(err);
314
- return {
315
- tool: scanner.tool,
316
- findings: [],
317
- skipped: false,
318
- error: reason
319
- };
320
- }
321
- }
322
- function errorMessage(err) {
323
- return err instanceof Error ? err.message : String(err);
324
- }
325
-
326
- //#endregion
327
- //#region src/git/repo.ts
328
- /** Refuse to run outside a git repo — the snapshot/restore safety net needs it. */
329
- async function assertGitRepo(git) {
330
- if (!await git.checkIsRepo()) throw new Error("not a git repository — run tend inside a git repo");
331
- }
332
- /**
333
- * `git status` reports paths relative to the repo root and for the whole repo, even
334
- * when run from a subdirectory. Scanners run from `git`'s working dir and report paths
335
- * relative to it, so we scope changed files to that subtree and re-base them onto it
336
- * using git's own cwd→root prefix (empty when run from the repo root).
337
- */
338
- function scopeToCwd(repoPath, prefix) {
339
- if (!prefix) return repoPath;
340
- if (!repoPath.startsWith(prefix)) return null;
341
- return repoPath.slice(prefix.length);
342
- }
343
- /**
344
- * Files changed vs `HEAD`: tracked modifications/additions/renames plus untracked files,
345
- * scoped and re-based to `git`'s working directory (so a run from `apps/foo` only sees
346
- * `apps/foo`'s changes, pathed as the scanners path them).
347
- */
348
- async function changedVsHead(git) {
349
- const prefix = (await git.revparse(["--show-prefix"])).trim();
350
- const status = await git.status();
351
- const files = new Set();
352
- for (const file of status.files) {
353
- const repoPath = file.path.includes(" -> ") ? file.path.split(" -> ")[1] : file.path;
354
- const rel = scopeToCwd(repoPath, prefix);
355
- if (rel !== null) files.add(rel);
356
- }
357
- return [...files];
358
- }
359
- /**
360
- * Concrete files under the given path(s) — tracked plus untracked (so newly-added files
361
- * are scoped too, mirroring `changedVsHead`). `git ls-files` reports paths relative to
362
- * `git`'s working directory and interprets the pathspecs the same way, so the result is
363
- * already in the coordinate system the scanners and `filterToChanged` expect. Expanding to
364
- * concrete files (not bare directories) matters: `filterToChanged` matches exact paths.
365
- */
366
- async function filesUnder(git, paths) {
367
- if (paths.length === 0) return [];
368
- const list = async (args) => (await git.raw([
369
- "ls-files",
370
- ...args,
371
- "--",
372
- ...paths
373
- ])).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
374
- const files = new Set([...await list([]), ...await list(["-o", "--exclude-standard"])]);
375
- return [...files];
376
- }
377
- /** Revert a single file to its snapshot state. */
378
- function revertFile(snapshot, file) {
379
- return snapshot.restoreFile(file);
380
- }
381
-
382
- //#endregion
383
- //#region src/git/client.ts
384
- const UNSAFE_GIT_ENV_KEYS = [
385
- "EDITOR",
386
- "VISUAL",
387
- "GIT_EDITOR",
388
- "GIT_SEQUENCE_EDITOR",
389
- "GIT_PAGER",
390
- "PAGER"
391
- ];
392
- function gitEnv(extra = {}) {
393
- const env = {
394
- ...process.env,
395
- ...extra
396
- };
397
- for (const key of UNSAFE_GIT_ENV_KEYS) delete env[key];
398
- return env;
399
- }
400
- function createGit(root, extraEnv = {}) {
401
- return simpleGit(root).env(gitEnv(extraEnv));
402
- }
403
-
404
- //#endregion
405
- //#region src/git/snapshot.ts
406
- const SNAP_MSG = "tend snapshot";
407
- /** A private ref pins the snapshot commit so `git gc` can't prune it (it's on no branch). */
408
- const SNAP_REF = "refs/tend/snapshot";
409
- let indexCounter = 0;
410
- /**
411
- * Write the entire current working tree (tracked + untracked, honoring .gitignore) into a git
412
- * tree object, using a throwaway index so the user's real staging area is never touched.
413
- * Returns the tree's object id. Git stores only new blobs and reuses the rest — near-instant,
414
- * a few KB, not a full copy of every file.
415
- */
416
- async function writeWorkingTree(root) {
417
- const idxPath = join(tmpdir(), `tend-index-${process.pid}-${indexCounter++}`);
418
- try {
419
- const g = createGit(root, { GIT_INDEX_FILE: idxPath });
420
- await g.raw(["add", "-A"]);
421
- return (await g.raw(["write-tree"])).trim();
422
- } finally {
423
- rmSync(idxPath, { force: true });
424
- }
425
- }
426
- /** Keep tend's own `.tend/` artifacts out of snapshots and the user's `git status`. */
427
- function ensureTendIgnored(gitDir) {
428
- const excludePath = join(gitDir, "info", "exclude");
429
- const line = ".tend/";
430
- let current = "";
431
- try {
432
- current = readFileSync(excludePath, "utf8");
433
- } catch {}
434
- if (current.split("\n").some((l) => l.trim() === line)) return;
435
- mkdirSync(dirname(excludePath), { recursive: true });
436
- const sep$1 = current === "" || current.endsWith("\n") ? "" : "\n";
437
- writeFileSync(excludePath, `${current}${sep$1}${line}\n`);
438
- }
439
- const lines = (raw) => raw.split("\n").map((l) => l.trim()).filter(Boolean);
440
- /** Files currently in the repo: tracked + untracked (non-ignored), repo-root-relative. */
441
- async function currentFiles(root) {
442
- const g = createGit(root);
443
- const tracked = await g.raw(["ls-files"]);
444
- const untracked = await g.raw([
445
- "ls-files",
446
- "--others",
447
- "--exclude-standard"
448
- ]);
449
- return [...new Set([...lines(tracked), ...lines(untracked)])];
450
- }
451
- /**
452
- * A silent restore point for the working tree, stored as a git commit object pinned by a private
453
- * ref (`refs/tend/snapshot`) — nothing committed to any branch, the editor sees no change. Backs
454
- * `tend undo` (exact restore) and `tend diff` (only the tool's edits). Reuses git's content store,
455
- * so the on-disk record is a 40-char id rather than a copy of every file.
456
- */
457
- var Snapshot = class Snapshot {
458
- constructor(cwd, root, sha) {
459
- this.cwd = cwd;
460
- this.root = root;
461
- this.sha = sha;
462
- }
463
- static async capture(_git, cwd) {
464
- const git = createGit(cwd);
465
- const root = (await git.revparse(["--show-toplevel"])).trim();
466
- const gitDir = (await git.revparse(["--absolute-git-dir"])).trim();
467
- ensureTendIgnored(gitDir);
468
- const rg = createGit(root);
469
- const tree = await writeWorkingTree(root);
470
- let parent = null;
471
- try {
472
- parent = (await rg.revparse(["HEAD"])).trim();
473
- } catch {
474
- parent = null;
475
- }
476
- const commitArgs = parent ? [
477
- "commit-tree",
478
- tree,
479
- "-p",
480
- parent,
481
- "-m",
482
- SNAP_MSG
483
- ] : [
484
- "commit-tree",
485
- tree,
486
- "-m",
487
- SNAP_MSG
488
- ];
489
- const sha = (await rg.raw(commitArgs)).trim();
490
- await rg.raw([
491
- "update-ref",
492
- SNAP_REF,
493
- sha
494
- ]);
495
- return new Snapshot(cwd, root, sha);
496
- }
497
- /** Serialize to a tiny object for `.tend/snapshot.json` (powers `undo` across invocations). */
498
- toJSON() {
499
- return {
500
- cwd: this.cwd,
501
- root: this.root,
502
- sha: this.sha
503
- };
504
- }
505
- static fromJSON(data) {
506
- return new Snapshot(data.cwd, data.root, data.sha);
507
- }
508
- /** Files whose contents differ from the snapshot, or that are new/deleted since it (sorted). */
509
- async changedSince(_git) {
510
- const currentTree = await writeWorkingTree(this.root);
511
- const diff = await createGit(this.root).raw([
512
- "diff",
513
- "--name-only",
514
- this.sha,
515
- currentTree
516
- ]);
517
- return lines(diff).sort();
518
- }
519
- /** Restore a single file to its captured contents (worktree only — the user's index is untouched). */
520
- async restoreFile(rel) {
521
- await createGit(this.root).raw([
522
- "restore",
523
- "--source",
524
- this.sha,
525
- "--worktree",
526
- "--",
527
- rel
528
- ]);
529
- }
530
- /** Restore the working tree exactly to the captured state (incl. deleting files created since). */
531
- async restore(_git) {
532
- const rg = createGit(this.root);
533
- await rg.raw([
534
- "restore",
535
- "--source",
536
- this.sha,
537
- "--worktree",
538
- "--",
539
- ":/"
540
- ]);
541
- const inSnapshot = new Set(lines(await rg.raw([
542
- "ls-tree",
543
- "-r",
544
- "--name-only",
545
- this.sha
546
- ])));
547
- for (const rel of await currentFiles(this.root)) if (!inSnapshot.has(rel)) rmSync(join(this.root, rel), { force: true });
548
- }
549
- };
550
-
551
- //#endregion
552
- //#region src/detect/package-manager.ts
553
- const LOCKFILES = [
554
- {
555
- file: "pnpm-lock.yaml",
556
- pm: "pnpm"
557
- },
558
- {
559
- file: "yarn.lock",
560
- pm: "yarn"
561
- },
562
- {
563
- file: "bun.lockb",
564
- pm: "bun"
565
- },
566
- {
567
- file: "bun.lock",
568
- pm: "bun"
569
- },
570
- {
571
- file: "package-lock.json",
572
- pm: "npm"
573
- }
574
- ];
575
- /** Detect the package manager from the lockfile present; defaults to npm. */
576
- function detectPackageManager(cwd) {
577
- for (const { file, pm } of LOCKFILES) if (existsSync(join(cwd, file))) return pm;
578
- return "npm";
579
- }
580
-
581
- //#endregion
582
- //#region src/session/types.ts
583
- /** A usage record with everything zeroed. */
584
- const zeroUsage = () => ({
585
- estimatedCostUsd: 0,
586
- inputTokens: 0,
587
- outputTokens: 0,
588
- cacheCreationInputTokens: 0,
589
- cacheReadInputTokens: 0,
590
- sessions: 0
591
- });
592
- /** Sum two usage records field-by-field (used to roll usage up through the run). */
593
- function addUsage(a, b) {
594
- return {
595
- estimatedCostUsd: a.estimatedCostUsd + b.estimatedCostUsd,
596
- inputTokens: a.inputTokens + b.inputTokens,
597
- outputTokens: a.outputTokens + b.outputTokens,
598
- cacheCreationInputTokens: a.cacheCreationInputTokens + b.cacheCreationInputTokens,
599
- cacheReadInputTokens: a.cacheReadInputTokens + b.cacheReadInputTokens,
600
- sessions: a.sessions + b.sessions
601
- };
602
- }
603
-
604
- //#endregion
605
- //#region src/fixing/dispatch.ts
606
- const TEST_FILE_RE = /^(.*)\.(test|spec)\.([cm]?[jt]sx?)$/;
607
- /** Whether a repo-relative path is a test file (`*.test.*` / `*.spec.*`). */
608
- const isTestFile = (file) => TEST_FILE_RE.test(file);
609
- /** A test file's owning code file (so both go to the same worker); else the file itself. */
610
- function ownerOf(file) {
611
- const m = file.match(TEST_FILE_RE);
612
- return m ? `${m[1]}.${m[3]}` : file;
613
- }
614
- /**
615
- * Group findings into work units so each worker owns a disjoint set of files
616
- * (a code file plus its sibling test). No two sessions ever touch the same file.
617
- */
618
- function planWork(findings) {
619
- const byOwner = new Map();
620
- for (const finding of findings) {
621
- const owner = ownerOf(finding.file);
622
- let unit = byOwner.get(owner);
623
- if (!unit) {
624
- unit = {
625
- file: owner,
626
- files: [],
627
- findings: []
628
- };
629
- byOwner.set(owner, unit);
630
- }
631
- unit.findings.push(finding);
632
- if (!unit.files.includes(finding.file)) unit.files.push(finding.file);
633
- if (!unit.files.includes(owner)) unit.files.push(owner);
634
- }
635
- return [...byOwner.values()];
636
- }
637
- /** Run each work unit through `runUnit`, capped at `concurrency` concurrent sessions. */
638
- async function dispatch(units, runUnit, opts) {
639
- const queue = new PQueue({ concurrency: opts.concurrency });
640
- return Promise.all(units.map((unit) => queue.add(() => runUnit(unit))));
641
- }
642
-
643
- //#endregion
644
- //#region src/session/stream-json.ts
645
- const RATE_LIMIT_RE = /rate.?limit|overloaded|\b429\b/i;
646
- /** A finite, non-negative number, or 0 for anything else (missing/NaN/negative). */
647
- function num(v) {
648
- return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
649
- }
650
- const zeroCost = () => ({
651
- estimatedCostUsd: 0,
652
- inputTokens: 0,
653
- outputTokens: 0,
654
- cacheCreationInputTokens: 0,
655
- cacheReadInputTokens: 0
656
- });
657
- function parseEvent(line) {
658
- const trimmed = line.trim();
659
- if (!trimmed) return null;
660
- try {
661
- return JSON.parse(trimmed);
662
- } catch {
663
- return null;
664
- }
665
- }
666
- function extractUsage(event) {
667
- const rawCost = event.total_cost_usd ?? event.cost_usd ?? event.costUSD;
668
- const u = event.usage;
669
- if (rawCost == null && u == null) return null;
670
- return {
671
- estimatedCostUsd: num(rawCost),
672
- inputTokens: num(u?.input_tokens),
673
- outputTokens: num(u?.output_tokens),
674
- cacheCreationInputTokens: num(u?.cache_creation_input_tokens),
675
- cacheReadInputTokens: num(u?.cache_read_input_tokens)
676
- };
677
- }
678
- function extractEdits(event) {
679
- const edits = [];
680
- for (const block of event.message?.content ?? []) if (block.type === "tool_use" && block.name === "Write") {
681
- const path = block.input?.["file_path"];
682
- const contents = block.input?.["content"];
683
- if (typeof path === "string" && typeof contents === "string") edits.push({
684
- path,
685
- contents
686
- });
687
- }
688
- return edits;
689
- }
690
- /**
691
- * Parse Claude Code `--output-format stream-json` (newline-delimited JSON) into the
692
- * file edits it produced. `Write` tool uses carry full file contents. Malformed lines
693
- * are skipped. Rate-limit / error signals are surfaced for backoff.
694
- */
695
- function parseStreamJson(raw) {
696
- const edits = [];
697
- let rateLimited = false;
698
- let errored = false;
699
- let usage = null;
700
- for (const line of raw.split("\n")) {
701
- const event = parseEvent(line);
702
- if (!event) continue;
703
- if (event.is_error) {
704
- errored = true;
705
- if (event.error && RATE_LIMIT_RE.test(event.error)) rateLimited = true;
706
- }
707
- if (event.type === "result") {
708
- const u = extractUsage(event);
709
- if (u) usage = u;
710
- }
711
- edits.push(...extractEdits(event));
712
- }
713
- return {
714
- edits,
715
- rateLimited,
716
- errored,
717
- usage: usage ?? zeroCost()
718
- };
719
- }
720
-
721
- //#endregion
722
- //#region src/session/claude.ts
723
- /** Drives a real `claude -p` session and parses its stream-json into edits. */
724
- var ClaudeSession = class {
725
- constructor(deps) {
726
- this.deps = deps;
727
- }
728
- async run(request) {
729
- let stdout;
730
- let exitCode;
731
- try {
732
- ({stdout, exitCode} = await this.deps.spawn(request));
733
- } catch (err) {
734
- return {
735
- ok: false,
736
- error: err instanceof Error ? err.message : String(err),
737
- rateLimited: false,
738
- usage: zeroUsage()
739
- };
740
- }
741
- const parsed = parseStreamJson(stdout);
742
- const usage = {
743
- ...parsed.usage,
744
- sessions: 1
745
- };
746
- if (parsed.rateLimited) return {
747
- ok: false,
748
- error: "Claude session rate-limited",
749
- rateLimited: true,
750
- usage
751
- };
752
- if (exitCode !== 0 || parsed.errored) return {
753
- ok: false,
754
- error: `Claude session failed (exit ${exitCode})`,
755
- rateLimited: false,
756
- usage
757
- };
758
- return {
759
- ok: true,
760
- edits: parsed.edits,
761
- usage
762
- };
763
- }
764
- };
765
-
766
- //#endregion
767
- //#region src/findings/revert-detail.ts
768
- const MAX_REVERT_DETAIL_LENGTH = 500;
769
- /** Keep persisted diagnostics readable and bounded for report.json. */
770
- function normalizeRevertDetail(detail) {
771
- const trimmed = detail?.replace(/\s+/g, " ").trim();
772
- if (!trimmed) return void 0;
773
- if (trimmed.length <= MAX_REVERT_DETAIL_LENGTH) return trimmed;
774
- return `${trimmed.slice(0, MAX_REVERT_DETAIL_LENGTH - 3)}...`;
775
- }
776
-
777
- //#endregion
778
- //#region src/findings/store.ts
779
- const StoreSchema = z.array(FindingSchema);
780
- /** Holds Finding records keyed by fingerprint and tracks their state across loops. */
781
- var FindingStore = class FindingStore {
782
- findings = new Map();
783
- add(finding) {
784
- this.findings.set(finding.id, finding);
785
- }
786
- get(id) {
787
- return this.findings.get(id);
788
- }
789
- all() {
790
- return [...this.findings.values()];
791
- }
792
- /**
793
- * Diff a fresh audit against what the store knows, by fingerprint:
794
- * - known but absent now → marked `fixed`
795
- * - present both loops → stays as-is, carries attempts/history, bumps lastSeenLoop
796
- * - new fingerprint → added `pending`, firstSeenLoop = loop
797
- */
798
- reconcile(fresh, loop) {
799
- const freshIds = new Set(fresh.map((f) => f.id));
800
- for (const known of this.findings.values()) if (!freshIds.has(known.id)) {
801
- known.status = "fixed";
802
- delete known.revertReason;
803
- delete known.revertDetail;
804
- }
805
- for (const incoming of fresh) {
806
- const known = this.findings.get(incoming.id);
807
- if (known) {
808
- known.lastSeenLoop = loop;
809
- if (known.status === "fixed" || known.status === "reverted") known.status = "pending";
810
- } else this.findings.set(incoming.id, {
811
- ...incoming,
812
- firstSeenLoop: loop,
813
- lastSeenLoop: loop
814
- });
815
- }
816
- }
817
- /** Findings matching every provided filter (track / status / file). */
818
- query(filter) {
819
- return this.all().filter((f) => (filter.track === void 0 || f.track === filter.track) && (filter.status === void 0 || f.status === filter.status) && (filter.file === void 0 || f.file === filter.file));
820
- }
821
- /** Record a failed fix attempt against a finding's fingerprint. */
822
- recordFailedAttempt(id, reason, detail) {
823
- const finding = this.findings.get(id);
824
- if (!finding) return;
825
- finding.attempts += 1;
826
- finding.revertReason = reason;
827
- const normalizedDetail = normalizeRevertDetail(detail);
828
- if (normalizedDetail) finding.revertDetail = normalizedDetail;
829
- else delete finding.revertDetail;
830
- }
831
- /** A finding's per-issue budget is exhausted once it has used `budget` attempts. */
832
- isBudgetExhausted(id, budget) {
833
- const finding = this.findings.get(id);
834
- if (!finding) return false;
835
- return finding.attempts >= budget;
836
- }
837
- /** Serialize to a plain array — `report.json`'s findings section. */
838
- toJSON() {
839
- return this.all();
840
- }
841
- /** Rebuild a store from serialized findings, validating each against the schema. */
842
- static fromJSON(data) {
843
- const findings = StoreSchema.parse(data);
844
- const store = new FindingStore();
845
- for (const finding of findings) store.add(finding);
846
- return store;
847
- }
848
- };
849
-
850
- //#endregion
851
- //#region src/findings/router.ts
852
- function isKnownTool(tool) {
853
- return TOOLS.includes(tool);
854
- }
855
- /** Split findings into their assigned fix tracks; unknown tools are skipped with a warning. */
856
- function route(findings, opts = {}) {
857
- const result$1 = {
858
- aiFix: [],
859
- deterministic: [],
860
- reportOnly: [],
861
- skipped: []
862
- };
863
- for (const finding of findings) {
864
- if (!isKnownTool(finding.tool)) {
865
- opts.warn?.(`Skipping finding from unknown tool "${finding.tool}"`);
866
- result$1.skipped.push(finding);
867
- continue;
868
- }
869
- switch (finding.track) {
870
- case "ai-fix":
871
- result$1.aiFix.push(finding);
872
- break;
873
- case "deterministic":
874
- result$1.deterministic.push(finding);
875
- break;
876
- case "report-only":
877
- result$1.reportOnly.push(finding);
878
- break;
879
- }
880
- }
881
- return result$1;
882
- }
883
-
884
- //#endregion
885
- //#region src/output/events.ts
886
- /** Minimal synchronous event bus. With no listener, emit is a no-op (silent mode). */
887
- var EventBus = class {
888
- listeners = [];
889
- on(listener) {
890
- this.listeners.push(listener);
891
- return () => {
892
- const i = this.listeners.indexOf(listener);
893
- if (i >= 0) this.listeners.splice(i, 1);
894
- };
895
- }
896
- emit(event) {
897
- for (const listener of this.listeners) listener(event);
898
- }
899
- };
900
-
901
- //#endregion
902
- //#region src/orchestrator.ts
903
- /** AI-fixable findings still pending and under their retry budget. */
904
- function pendingUnderBudget(store, budget) {
905
- return store.query({
906
- track: "ai-fix",
907
- status: "pending"
908
- }).filter((f) => f.attempts < budget);
909
- }
910
- function dispatchableUnits(findings, includeTests) {
911
- return planWork(findings).filter((u) => includeTests || u.findings.some((f) => !isTestFile(f.file)));
912
- }
913
- function statusAttemptSnapshot(store) {
914
- return store.all().map((f) => `${f.id}:${f.status}:${f.attempts}`).sort().join("|");
915
- }
916
- function applyOutcome(store, unit, outcome, budget) {
917
- for (const finding of unit.findings) if (outcome.kept) {
918
- finding.status = "fixed";
919
- delete finding.revertReason;
920
- delete finding.revertDetail;
921
- } else {
922
- store.recordFailedAttempt(finding.id, outcome.reason ?? "session-error", outcome.detail);
923
- if (store.isBudgetExhausted(finding.id, budget)) finding.status = "unfixable";
924
- }
925
- }
926
- /**
927
- * The scan → fix → re-audit loop. Terminates on the first of: converged (0 fixable),
928
- * no-progress (no dispatchable units or an attempted loop changed no attempt/status
929
- * state), per-issue budget exhaustion (mark unfixable, keep going), or max-loops.
930
- */
931
- async function orchestrate(deps) {
932
- const { config } = deps;
933
- const inScope = deps.inScope ?? ((f) => f);
934
- const bus = deps.bus ?? new EventBus();
935
- const store = new FindingStore();
936
- const secrets = new Map();
937
- const depBumps = new Map();
938
- let loop = 0;
939
- let fixingLoops = 0;
940
- let termination = "converged";
941
- let scannerStatuses = [];
942
- let runScope = { type: "scoped" };
943
- let usage = zeroUsage();
944
- while (true) {
945
- loop++;
946
- bus.emit({
947
- type: "scan-start",
948
- loop
949
- });
950
- const audited = await deps.audit(loop);
951
- scannerStatuses = audited.scannerStatuses ?? scannerStatuses;
952
- if (loop === 1) runScope = audited.scanned == null ? { type: "all" } : {
953
- type: "scoped",
954
- fileCount: audited.scanned
955
- };
956
- if (loop === 1 && audited.allScannersMissing) {
957
- bus.emit({
958
- type: "done",
959
- exitStatus: 1
960
- });
961
- return result("no-scanners", fixingLoops, 1, store, secrets, depBumps, scannerStatuses, runScope, usage);
962
- }
963
- store.reconcile(audited.findings, loop);
964
- const scopedIds = new Set(inScope(store.all()).map((f) => f.id));
965
- for (const f of store.all()) f.inScope = scopedIds.has(f.id);
966
- const scopedFindings = inScope(audited.findings);
967
- const routed = route(audited.findings);
968
- for (const s of routed.reportOnly) secrets.set(s.id, s);
969
- for (const d of routed.deterministic) depBumps.set(d.id, d);
970
- bus.emit({
971
- type: "audit",
972
- loop,
973
- findings: scopedFindings.length,
974
- files: new Set(scopedFindings.map((f) => f.file)).size,
975
- scanned: audited.scanned
976
- });
977
- if (loop === 1 && audited.findings.length === 0) {
978
- termination = "converged";
979
- break;
980
- }
981
- const pending = pendingUnderBudget(store, config.perIssueBudget);
982
- const fixable = inScope(pending);
983
- if (pending.length === 0) {
984
- termination = "converged";
985
- break;
986
- }
987
- const units = dispatchableUnits(fixable, config.includeTests);
988
- if (units.length === 0) {
989
- termination = "no-progress";
990
- break;
991
- }
992
- if (fixingLoops >= config.maxLoops) {
993
- termination = "max-loops";
994
- break;
995
- }
996
- fixingLoops++;
997
- const beforeAttemptState = statusAttemptSnapshot(store);
998
- bus.emit({
999
- type: "loop-start",
1000
- loop,
1001
- files: units.map((u) => u.file),
1002
- concurrency: config.maxSessions
1003
- });
1004
- const outcomes = await dispatch(units, async (unit) => {
1005
- bus.emit({
1006
- type: "file-start",
1007
- loop,
1008
- file: unit.file,
1009
- rule: unit.findings[0]?.rule
1010
- });
1011
- const outcome = await deps.fixUnit(unit, loop);
1012
- bus.emit({
1013
- type: "file-result",
1014
- loop,
1015
- file: unit.file,
1016
- outcome: outcome.kept ? "fixed" : "reverted",
1017
- reason: outcome.reason
1018
- });
1019
- return {
1020
- unit,
1021
- outcome
1022
- };
1023
- }, { concurrency: config.maxSessions });
1024
- for (const { unit, outcome } of outcomes) {
1025
- applyOutcome(store, unit, outcome, config.perIssueBudget);
1026
- if (outcome.usage) usage = addUsage(usage, outcome.usage);
1027
- }
1028
- bus.emit({
1029
- type: "loop-complete",
1030
- loop,
1031
- fixed: outcomes.filter((o) => o.outcome.kept).length
1032
- });
1033
- if (statusAttemptSnapshot(store) === beforeAttemptState) {
1034
- termination = "no-progress";
1035
- break;
1036
- }
1037
- }
1038
- const exitStatus = secrets.size > 0 ? 1 : 0;
1039
- bus.emit({
1040
- type: "done",
1041
- exitStatus
1042
- });
1043
- return result(termination, fixingLoops, exitStatus, store, secrets, depBumps, scannerStatuses, runScope, usage);
1044
- }
1045
- function result(termination, loops, exitStatus, store, secrets, depBumps, scannerStatuses, runScope, usage) {
1046
- return {
1047
- termination,
1048
- loops,
1049
- exitStatus: termination === "no-scanners" ? 1 : exitStatus,
1050
- findings: store.all(),
1051
- secrets: [...secrets.values()],
1052
- depBumps: [...depBumps.values()],
1053
- scannerStatuses,
1054
- runScope,
1055
- usage
1056
- };
1057
- }
1058
-
1059
- //#endregion
1060
- //#region src/report/retry-id.ts
1061
- const RETRY_ID_LENGTH = 6;
1062
- const RETRY_ID_ALPHABET = "23456789abcdefghijkmnpqrstuvwxyz";
1063
- const makeRetryId = customAlphabet(RETRY_ID_ALPHABET, RETRY_ID_LENGTH);
1064
- function hasUsableRetryId(finding) {
1065
- return typeof finding.retryId === "string" && finding.retryId.length > 0;
1066
- }
1067
- function nextUniqueRetryId(used, generate) {
1068
- for (let attempt = 0; attempt < 100; attempt++) {
1069
- const retryId = generate();
1070
- if (!used.has(retryId)) return retryId;
1071
- }
1072
- throw new Error("Could not generate a unique retry id");
1073
- }
1074
- /** Ensure every finding in a persisted report has a human-facing id unique to that report. */
1075
- function assignRetryIds(findings, generate = makeRetryId) {
1076
- const counts = new Map();
1077
- for (const finding of findings) {
1078
- if (!hasUsableRetryId(finding)) continue;
1079
- counts.set(finding.retryId, (counts.get(finding.retryId) ?? 0) + 1);
1080
- }
1081
- const used = new Set();
1082
- return findings.map((finding) => {
1083
- if (hasUsableRetryId(finding) && counts.get(finding.retryId) === 1) {
1084
- used.add(finding.retryId);
1085
- return finding;
1086
- }
1087
- const retryId = nextUniqueRetryId(used, generate);
1088
- used.add(retryId);
1089
- return {
1090
- ...finding,
1091
- retryId
1092
- };
1093
- });
1094
- }
1095
-
1096
- //#endregion
1097
- //#region src/report/schema.ts
1098
- const DepBumpSchema = z.object({
1099
- findingId: z.string(),
1100
- remediation: z.string()
1101
- });
1102
- /** Per-scanner outcome for a run: did it run clean, get skipped, or fail (with a reason). */
1103
- const ScannerStatusSchema = z.object({
1104
- tool: z.enum(TOOLS),
1105
- status: z.enum([
1106
- "ran",
1107
- "skipped",
1108
- "failed"
1109
- ]),
1110
- reason: z.string().optional()
1111
- });
1112
- const BehaviorChangeSchema = z.object({
1113
- findingId: z.string(),
1114
- file: z.string(),
1115
- note: z.string()
1116
- });
1117
- /**
1118
- * Estimated AI cost/usage for a run. `estimatedCostUsd` is Claude's client-side
1119
- * `total_cost_usd` estimate — never authoritative billing.
1120
- */
1121
- const AiUsageSchema = z.object({
1122
- estimatedCostUsd: z.number().nonnegative(),
1123
- inputTokens: z.number().nonnegative(),
1124
- outputTokens: z.number().nonnegative(),
1125
- cacheCreationInputTokens: z.number().nonnegative(),
1126
- cacheReadInputTokens: z.number().nonnegative(),
1127
- sessions: z.number().int().nonnegative()
1128
- });
1129
- const RunScopeSchema = z.object({
1130
- type: z.enum(["all", "scoped"]),
1131
- fileCount: z.number().int().nonnegative().optional()
1132
- }).default({ type: "scoped" });
1133
- const FixPolicySchema = z.object({ includeTests: z.boolean().default(false) }).default({ includeTests: false });
1134
- const ZERO_AI_USAGE$1 = {
1135
- estimatedCostUsd: 0,
1136
- inputTokens: 0,
1137
- outputTokens: 0,
1138
- cacheCreationInputTokens: 0,
1139
- cacheReadInputTokens: 0,
1140
- sessions: 0
1141
- };
1142
- const ReportSchema = z.object({
1143
- findings: z.array(FindingSchema),
1144
- secrets: z.array(FindingSchema),
1145
- depBumps: z.array(DepBumpSchema),
1146
- flaggedBehaviorChanges: z.array(BehaviorChangeSchema),
1147
- scannerStatuses: z.array(ScannerStatusSchema).default([]),
1148
- runScope: RunScopeSchema,
1149
- fixPolicy: FixPolicySchema,
1150
- aiUsage: AiUsageSchema.default(ZERO_AI_USAGE$1),
1151
- loops: z.number().int().nonnegative(),
1152
- durationMs: z.number().nonnegative(),
1153
- exitStatus: z.number().int()
1154
- });
1155
-
1156
- //#endregion
1157
- //#region src/report/builder.ts
1158
- const ZERO_AI_USAGE = {
1159
- estimatedCostUsd: 0,
1160
- inputTokens: 0,
1161
- outputTokens: 0,
1162
- cacheCreationInputTokens: 0,
1163
- cacheReadInputTokens: 0,
1164
- sessions: 0
1165
- };
1166
- /** Accumulates per-finding outcomes and run metadata into a validated report.json. */
1167
- var ReportBuilder = class {
1168
- outcomes = new Map();
1169
- flagged = [];
1170
- scannerStatuses = [];
1171
- constructor(generateRetryId) {
1172
- this.generateRetryId = generateRetryId;
1173
- }
1174
- /** Record (or update) a finding's final outcome by fingerprint. */
1175
- recordOutcome(finding) {
1176
- const normalized = normalizeRevertDetail(finding.revertDetail);
1177
- const outcome = { ...finding };
1178
- if (normalized) outcome.revertDetail = normalized;
1179
- else delete outcome.revertDetail;
1180
- this.outcomes.set(finding.id, outcome);
1181
- }
1182
- recordOutcomes(findings) {
1183
- for (const f of findings) this.recordOutcome(f);
1184
- }
1185
- /** Flag a semantic test change for human review. */
1186
- flagBehaviorChange(entry) {
1187
- this.flagged.push(entry);
1188
- }
1189
- /** Record per-scanner run outcomes (ran / skipped / failed) for the scanner-status line. */
1190
- recordScannerStatuses(statuses) {
1191
- this.scannerStatuses = statuses;
1192
- }
1193
- build(meta) {
1194
- const findings = assignRetryIds([...this.outcomes.values()], this.generateRetryId);
1195
- const report = {
1196
- findings,
1197
- secrets: findings.filter((f) => f.category === "secret"),
1198
- depBumps: findings.filter((f) => f.remediation !== void 0).map((f) => ({
1199
- findingId: f.id,
1200
- remediation: f.remediation
1201
- })),
1202
- flaggedBehaviorChanges: this.flagged,
1203
- scannerStatuses: this.scannerStatuses,
1204
- runScope: meta.runScope ?? { type: "scoped" },
1205
- fixPolicy: meta.fixPolicy ?? { includeTests: false },
1206
- aiUsage: meta.aiUsage ?? ZERO_AI_USAGE,
1207
- loops: meta.loops,
1208
- durationMs: meta.durationMs,
1209
- exitStatus: meta.exitStatus
1210
- };
1211
- return ReportSchema.parse(report);
1212
- }
1213
- };
1214
-
1215
- //#endregion
1216
- //#region src/output/format.ts
1217
- /**
1218
- * Human duration for the summary: sub-minute reads as "2.4s", longer as "3m 12s".
1219
- * Deterministic — no locale, no rounding surprises.
1220
- */
1221
- function formatDuration(ms) {
1222
- const totalSeconds = ms / 1e3;
1223
- if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
1224
- const minutes = Math.floor(totalSeconds / 60);
1225
- const seconds = Math.round(totalSeconds % 60);
1226
- if (seconds === 60) return `${minutes + 1}m 0s`;
1227
- return `${minutes}m ${seconds}s`;
1228
- }
1229
- /** Stopwatch form for the live per-file timer: "0:42", "1:05". */
1230
- function formatClock(ms) {
1231
- const totalSeconds = Math.floor(ms / 1e3);
1232
- const minutes = Math.floor(totalSeconds / 60);
1233
- const seconds = totalSeconds % 60;
1234
- return `${minutes}:${String(seconds).padStart(2, "0")}`;
1235
- }
1236
- /** Plain-language reason a fix was reverted — the most useful thing for a human. */
1237
- function reasonLabel(reason) {
1238
- switch (reason) {
1239
- case "broke-test": return "broke tests";
1240
- case "typecheck": return "broke typecheck";
1241
- case "suppression": return "added a suppression";
1242
- case "regression": return "introduced a new issue";
1243
- case "session-error": return "the fix session failed";
1244
- default: return "couldn't fix";
1245
- }
1246
- }
1247
-
1248
- //#endregion
1249
- //#region src/output/theme.ts
1250
- const PALETTE = {
1251
- accent: "#7AA2F7",
1252
- accentTo: "#9D7CD8",
1253
- green: "#9ECE6A",
1254
- amber: "#E0AF68",
1255
- red: "#E88388"
1256
- };
1257
- const UNICODE_GLYPHS = {
1258
- fixed: "✔",
1259
- reverted: "↩",
1260
- left: "–",
1261
- scanned: "✔",
1262
- bullet: "·",
1263
- rule: "─",
1264
- arrow: "→",
1265
- spinner: [
1266
- "⠋",
1267
- "⠙",
1268
- "⠹",
1269
- "⠸",
1270
- "⠼",
1271
- "⠴",
1272
- "⠦",
1273
- "⠧",
1274
- "⠇",
1275
- "⠏"
1276
- ]
1277
- };
1278
- const ASCII_GLYPHS = {
1279
- fixed: "+",
1280
- reverted: "<",
1281
- left: "-",
1282
- scanned: "+",
1283
- bullet: "·",
1284
- rule: "-",
1285
- arrow: ">",
1286
- spinner: [
1287
- "-",
1288
- "\\",
1289
- "|",
1290
- "/"
1291
- ]
1292
- };
1293
- /** Resolve the chalk color level: 0 (off) when color is disabled, else the terminal's. */
1294
- function chalkLevel(env) {
1295
- if (!env.color) return 0;
1296
- const detected = supportsColor ? supportsColor.level : 0;
1297
- return detected > 0 ? detected : 1;
1298
- }
1299
- function makeTheme(env) {
1300
- const c = new Chalk({ level: chalkLevel(env) });
1301
- const glyph = env.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
1302
- const accent = (s) => c.hex(PALETTE.accent)(s);
1303
- const wordmark = () => {
1304
- if (!env.color) return "tend";
1305
- if (c.level >= 3) return gradient([PALETTE.accent, PALETTE.accentTo])("tend");
1306
- return accent("tend");
1307
- };
1308
- return {
1309
- accent,
1310
- fixed: (s) => c.hex(PALETTE.green)(s),
1311
- reverted: (s) => c.hex(PALETTE.amber)(s),
1312
- error: (s) => c.hex(PALETTE.red)(s),
1313
- dim: (s) => c.dim(s),
1314
- bold: (s) => c.bold(s),
1315
- plain: (s) => s,
1316
- wordmark,
1317
- glyph
1318
- };
1319
- }
1320
-
1321
- //#endregion
1322
- //#region src/output/summary.ts
1323
- const RULE_WIDTH = 49;
1324
- const PLAIN_THEME = makeTheme({
1325
- color: false,
1326
- interactive: false,
1327
- unicode: true
1328
- });
1329
- /** A finding the developer can't read as part of their changes (scanned wide, out of scope). */
1330
- function isOutOfScope(f) {
1331
- return f.inScope === false && f.category !== "secret";
1332
- }
1333
- function bucket(report) {
1334
- const fixed = [];
1335
- const couldntFix = [];
1336
- const skippedTests = [];
1337
- const reportOnly = [];
1338
- const left = [];
1339
- const secrets = [];
1340
- for (const f of report.findings) if (f.category === "secret") secrets.push(f);
1341
- else if (isOutOfScope(f)) continue;
1342
- else if (f.status === "fixed") fixed.push(f);
1343
- else if (f.status === "reverted" || f.status === "unfixable") couldntFix.push(f);
1344
- else {
1345
- const pending = classifyPending(f, report);
1346
- if (pending === "skippedTests") skippedTests.push(f);
1347
- else if (pending === "reportOnly") reportOnly.push(f);
1348
- else left.push(f);
1349
- }
1350
- return {
1351
- fixed,
1352
- couldntFix,
1353
- skippedTests,
1354
- reportOnly,
1355
- left,
1356
- secrets
1357
- };
1358
- }
1359
- function classifyPending(f, report) {
1360
- if (f.track === "report-only") return "reportOnly";
1361
- if (f.track === "ai-fix" && !report.fixPolicy.includeTests && isTestFile(f.file)) return "skippedTests";
1362
- return "left";
1363
- }
1364
- /**
1365
- * The final summary: a real headline (fixed / couldn't-fix / left / secrets + elapsed),
1366
- * grouped by what the user must do, with revert reasons surfaced per file, and ending in
1367
- * next-step affordances. Brief by default; `--verbose` adds the full per-finding listing.
1368
- */
1369
- function renderSummary(report, opts = {}) {
1370
- const theme = opts.theme ?? PLAIN_THEME;
1371
- const { glyph } = theme;
1372
- const b = bucket(report);
1373
- if (opts.plain) return renderPlainSummary(report, b, theme, Boolean(opts.verbose));
1374
- const lines$1 = [];
1375
- lines$1.push(theme.dim(glyph.rule.repeat(RULE_WIDTH)));
1376
- lines$1.push(`done ${theme.dim(`${glyph.bullet} ${report.loops} fix passes ${glyph.bullet} ${formatDuration(report.durationMs)}`)}`);
1377
- lines$1.push("");
1378
- lines$1.push(theme.bold("run summary"));
1379
- lines$1.push(renderOverallTable(report, b, theme));
1380
- lines$1.push("");
1381
- lines$1.push(theme.bold("scanner breakdown"));
1382
- lines$1.push(renderScannerBreakdownTable(report, theme));
1383
- if (b.couldntFix.length > 0) {
1384
- lines$1.push("");
1385
- lines$1.push(theme.bold("couldn't fix"));
1386
- lines$1.push(renderCouldntFixTable(b.couldntFix, theme));
1387
- }
1388
- if (b.secrets.length > 0) {
1389
- lines$1.push("");
1390
- lines$1.push(theme.bold("secrets"));
1391
- lines$1.push(renderSecretsTable(b.secrets, theme));
1392
- }
1393
- if (opts.verbose) lines$1.push("", renderVerbose(report, theme));
1394
- lines$1.push("");
1395
- lines$1.push(theme.bold("next commands"));
1396
- lines$1.push(renderNextCommandsTable());
1397
- return lines$1.join("\n");
1398
- }
1399
- function renderPlainSummary(report, b, theme, verbose) {
1400
- const lines$1 = [
1401
- `done ${theme.glyph.bullet} ${report.loops} fix passes ${theme.glyph.bullet} ${formatDuration(report.durationMs)}`,
1402
- [
1403
- "summary",
1404
- `fixed=${b.fixed.length}`,
1405
- `couldntFix=${b.couldntFix.length}`,
1406
- `skippedTests=${b.skippedTests.length}`,
1407
- `reportOnly=${b.reportOnly.length}`,
1408
- `left=${b.left.length}`,
1409
- `secrets=${b.secrets.length}`
1410
- ].join(" "),
1411
- [
1412
- "aiUsage",
1413
- `estimatedCostUsd=${report.aiUsage.estimatedCostUsd.toFixed(2)}`,
1414
- `sessions=${report.aiUsage.sessions}`,
1415
- `inputTokens=${report.aiUsage.inputTokens}`,
1416
- `outputTokens=${report.aiUsage.outputTokens}`,
1417
- `cacheReadInputTokens=${report.aiUsage.cacheReadInputTokens}`,
1418
- `cacheCreationInputTokens=${report.aiUsage.cacheCreationInputTokens}`
1419
- ].join(" ")
1420
- ];
1421
- const counts = inScopeByTool(report);
1422
- const statusByTool = new Map(report.scannerStatuses.map((s) => [s.tool, s]));
1423
- const tools = TOOLS.filter((t) => statusByTool.has(t) || counts.has(t));
1424
- for (const tool of tools) {
1425
- const status = statusByTool.get(tool);
1426
- const c = counts.get(tool) ?? {
1427
- total: 0,
1428
- fixed: 0,
1429
- couldntFix: 0,
1430
- skippedTests: 0,
1431
- reportOnly: 0,
1432
- left: 0
1433
- };
1434
- const reason = status?.status === "failed" && status.reason ? ` reason=${JSON.stringify(firstLine(status.reason))}` : status?.status === "skipped" ? " reason=not-installed" : "";
1435
- lines$1.push(`scanner tool=${tool} status=${status?.status ?? "not-recorded"} scope=${plainScopeLabel(report)} total=${c.total} fixed=${c.fixed} couldntFix=${c.couldntFix} skippedTests=${c.skippedTests} reportOnly=${c.reportOnly} left=${c.left}${reason}`);
1436
- }
1437
- for (const f of b.couldntFix) {
1438
- const id = retryTarget(f);
1439
- lines$1.push(`couldnt-fix retryId=${f.retryId ?? "(none)"} file=${JSON.stringify(f.file)} rule=${JSON.stringify(f.rule)} reason=${JSON.stringify(findingReason(f))} command=${JSON.stringify(`tend retry ${id}`)}`);
1440
- }
1441
- for (const f of b.secrets) lines$1.push(`secret retryId=${f.retryId ?? "(none)"} file=${JSON.stringify(f.file)} rule=${JSON.stringify(f.rule)} action=${JSON.stringify("rotate + scrub history")}`);
1442
- if (b.skippedTests.length > 0) lines$1.push(`skipped-tests count=${b.skippedTests.length} reason="test files are excluded by default" command="tend run --include-tests <path...>"`);
1443
- if (b.reportOnly.length > 0) lines$1.push(`report-only count=${b.reportOnly.length} reason="unsupported or report-only findings"`);
1444
- if (verbose) for (const f of report.findings) lines$1.push(`finding retryId=${f.retryId ?? ""} status=${f.status} tool=${f.tool} location=${JSON.stringify(`${f.file}:${f.range.startLine}`)} rule=${JSON.stringify(f.rule)} reason=${JSON.stringify(f.status === "fixed" ? "" : findingReason(f))}`);
1445
- lines$1.push("next command=\"tend diff\" command=\"git add -p\" command=\"tend undo\"");
1446
- return lines$1.join("\n");
1447
- }
1448
- function renderTable(head, rows) {
1449
- const table = new Table({
1450
- head,
1451
- wordWrap: true,
1452
- style: {
1453
- head: [],
1454
- border: []
1455
- }
1456
- });
1457
- table.push(...rows);
1458
- return table.toString();
1459
- }
1460
- function renderOverallTable(report, b, theme) {
1461
- const clean = b.fixed.length === 0 && b.couldntFix.length === 0 && b.skippedTests.length === 0 && b.reportOnly.length === 0 && b.left.length === 0 && b.secrets.length === 0;
1462
- const status = report.exitStatus === 0 ? clean ? theme.fixed("success (nothing to fix)") : theme.fixed("success") : theme.error(`needs attention (exit ${report.exitStatus})`);
1463
- const rows = [
1464
- ["status", status],
1465
- ["fix passes", String(report.loops)],
1466
- ["elapsed", formatDuration(report.durationMs)],
1467
- ["fixed", `${theme.fixed(theme.glyph.fixed)} ${b.fixed.length}`],
1468
- ["couldn't fix", `${theme.reverted(theme.glyph.reverted)} ${b.couldntFix.length}`],
1469
- ["skipped tests", `${theme.dim(theme.glyph.left)} ${b.skippedTests.length} (pass --include-tests)`],
1470
- ["report only", `${theme.dim(theme.glyph.left)} ${b.reportOnly.length}`],
1471
- ["left", `${theme.dim(theme.glyph.left)} ${b.left.length}`],
1472
- ["secrets", b.secrets.length > 0 ? theme.error(String(b.secrets.length)) : "0"],
1473
- ["estimated AI cost", formatCost(report.aiUsage.estimatedCostUsd)],
1474
- ["AI sessions", String(report.aiUsage.sessions)],
1475
- ["tokens", formatTokens(report.aiUsage)]
1476
- ];
1477
- return renderTable(["metric", "value"], rows);
1478
- }
1479
- /** Estimated AI cost as `$X.XX` — always two decimals, never called a "bill". */
1480
- function formatCost(usd) {
1481
- return `$${usd.toFixed(2)}`;
1482
- }
1483
- /** `<input> in · <output> out · <cache read> cache read · <cache write> cache write`. */
1484
- function formatTokens(u) {
1485
- return [
1486
- `${u.inputTokens} in`,
1487
- `${u.outputTokens} out`,
1488
- `${u.cacheReadInputTokens} cache read`,
1489
- `${u.cacheCreationInputTokens} cache write`
1490
- ].join(" · ");
1491
- }
1492
- /** Per-tool tally over the in-scope findings only. */
1493
- function inScopeByTool(report) {
1494
- const counts = new Map();
1495
- for (const f of report.findings) {
1496
- if (f.inScope === false) continue;
1497
- const row = counts.get(f.tool) ?? {
1498
- total: 0,
1499
- fixed: 0,
1500
- couldntFix: 0,
1501
- skippedTests: 0,
1502
- reportOnly: 0,
1503
- left: 0
1504
- };
1505
- row.total += 1;
1506
- if (f.status === "fixed") row.fixed += 1;
1507
- else if (f.status === "reverted" || f.status === "unfixable") row.couldntFix += 1;
1508
- else {
1509
- const pending = classifyPending(f, report);
1510
- if (pending === "skippedTests") row.skippedTests += 1;
1511
- else if (pending === "reportOnly") row.reportOnly += 1;
1512
- else row.left += 1;
1513
- }
1514
- counts.set(f.tool, row);
1515
- }
1516
- return counts;
1517
- }
1518
- function renderScannerBreakdownTable(report, theme) {
1519
- const counts = inScopeByTool(report);
1520
- const statusByTool = new Map(report.scannerStatuses.map((s) => [s.tool, s]));
1521
- const tools = TOOLS.filter((t) => statusByTool.has(t) || counts.has(t));
1522
- if (tools.length === 0) return renderTable(scannerBreakdownHeaders(), [[
1523
- "(none)",
1524
- "not recorded",
1525
- scopeLabel(report),
1526
- "0",
1527
- "0",
1528
- "0",
1529
- "0",
1530
- "0",
1531
- "0",
1532
- ""
1533
- ]]);
1534
- const rows = tools.map((tool) => {
1535
- const status = statusByTool.get(tool);
1536
- const c = counts.get(tool) ?? {
1537
- total: 0,
1538
- fixed: 0,
1539
- couldntFix: 0,
1540
- skippedTests: 0,
1541
- reportOnly: 0,
1542
- left: 0
1543
- };
1544
- const label = scannerLabel(tool);
1545
- const statusText = status?.status === "ran" ? `${theme.fixed(theme.glyph.fixed)} ran` : status?.status === "failed" ? `${theme.error("!")} failed` : status?.status === "skipped" ? "skipped" : "not recorded";
1546
- const reason = status?.status === "failed" && status.reason ? firstLine(status.reason) : status?.status === "skipped" ? "not installed" : "";
1547
- return [
1548
- label,
1549
- statusText,
1550
- scopeLabel(report),
1551
- String(c.total),
1552
- String(c.fixed),
1553
- String(c.couldntFix),
1554
- String(c.skippedTests),
1555
- String(c.reportOnly),
1556
- String(c.left),
1557
- reason
1558
- ];
1559
- });
1560
- return renderTable(scannerBreakdownHeaders(), rows);
1561
- }
1562
- function scannerBreakdownHeaders() {
1563
- return [
1564
- "scanner",
1565
- "status",
1566
- "scope",
1567
- "total",
1568
- "fixed",
1569
- "couldn't fix",
1570
- "skipped tests",
1571
- "report only",
1572
- "left",
1573
- "reason"
1574
- ];
1575
- }
1576
- function scopeLabel(report) {
1577
- if (report.runScope.type === "all") return "whole repo";
1578
- if (report.runScope.fileCount !== void 0) return `${report.runScope.fileCount} scoped ${report.runScope.fileCount === 1 ? "file" : "files"}`;
1579
- return "in your changes";
1580
- }
1581
- function plainScopeLabel(report) {
1582
- return scopeLabel(report).replaceAll(" ", "-");
1583
- }
1584
- function findingReason(f) {
1585
- return f.status === "reverted" ? reasonLabel(f.revertReason) : "exhausted retries";
1586
- }
1587
- function retryTarget(f) {
1588
- return f.retryId ?? f.id;
1589
- }
1590
- function renderCouldntFixTable(findings, theme) {
1591
- const rows = findings.map((f) => [
1592
- f.retryId ?? "(none)",
1593
- f.file,
1594
- String(f.range.startLine),
1595
- f.rule,
1596
- f.message,
1597
- findingReason(f),
1598
- `${theme.glyph.arrow} tend retry ${retryTarget(f)}`
1599
- ]);
1600
- return renderTable([
1601
- "retryId",
1602
- "file",
1603
- "line",
1604
- "rule",
1605
- "message",
1606
- "reason",
1607
- "command"
1608
- ], rows);
1609
- }
1610
- function renderSecretsTable(findings, theme) {
1611
- const rows = findings.map((f) => [
1612
- f.retryId ?? "(none)",
1613
- f.file,
1614
- f.rule,
1615
- theme.error("rotate + scrub history")
1616
- ]);
1617
- return renderTable([
1618
- "retryId",
1619
- "file",
1620
- "rule",
1621
- "action"
1622
- ], rows);
1623
- }
1624
- function renderNextCommandsTable() {
1625
- return renderTable(["action", "command"], [
1626
- ["review edits", "tend diff"],
1627
- ["stage deliberately", "git add -p"],
1628
- ["undo run", "tend undo"]
1629
- ]);
1630
- }
1631
- /** First line of a (possibly multi-line) scanner error reason, trimmed. */
1632
- function firstLine(s) {
1633
- return s.split("\n")[0].trim();
1634
- }
1635
- function scannerLabel(tool) {
1636
- return tool === "sonarjs" ? "sonarjs (bundled)" : tool;
1637
- }
1638
- function perToolCounts(findings) {
1639
- const counts = new Map();
1640
- for (const f of findings) {
1641
- const row = counts.get(f.tool) ?? {
1642
- fixed: 0,
1643
- reverted: 0,
1644
- left: 0
1645
- };
1646
- if (f.status === "fixed") row.fixed += 1;
1647
- else if (f.status === "reverted") row.reverted += 1;
1648
- else row.left += 1;
1649
- counts.set(f.tool, row);
1650
- }
1651
- return counts;
1652
- }
1653
- /** The exhaustive view behind `--verbose`: per-tool breakdown + every finding. */
1654
- function renderVerbose(report, theme) {
1655
- const table = new Table({
1656
- head: [
1657
- "tool",
1658
- "fixed",
1659
- "reverted",
1660
- "left"
1661
- ],
1662
- style: {
1663
- head: [],
1664
- border: []
1665
- }
1666
- });
1667
- for (const [tool, c] of perToolCounts(report.findings)) table.push([
1668
- tool,
1669
- String(c.fixed),
1670
- String(c.reverted),
1671
- String(c.left)
1672
- ]);
1673
- const findingRows = report.findings.map((f) => [
1674
- f.retryId ?? "",
1675
- f.status,
1676
- f.tool,
1677
- `${f.file}:${f.range.startLine}`,
1678
- f.rule,
1679
- f.status === "fixed" ? "" : findingReason(f)
1680
- ]);
1681
- return [
1682
- theme.bold("verbose totals"),
1683
- table.toString(),
1684
- theme.bold("verbose findings"),
1685
- renderTable([
1686
- "retryId",
1687
- "status",
1688
- "tool",
1689
- "location",
1690
- "rule",
1691
- "reason"
1692
- ], findingRows)
1693
- ].join("\n");
1694
- }
1695
- /**
1696
- * Group the issues that still need a human, ordered by urgency:
1697
- * secrets → security → couldn't-fix → needs-review. Empty groups are omitted.
1698
- */
1699
- function groupRemaining(report) {
1700
- const unfixed = report.findings.filter((f) => f.status !== "fixed");
1701
- const secrets = unfixed.filter((f) => f.category === "secret");
1702
- const security = unfixed.filter((f) => f.category === "security");
1703
- const couldntFix = unfixed.filter((f) => f.status === "unfixable" && f.category !== "secret" && f.category !== "security");
1704
- const groups = [
1705
- {
1706
- key: "secrets",
1707
- title: "SECRETS — rotate now (never auto-fixed)",
1708
- count: secrets.length
1709
- },
1710
- {
1711
- key: "security",
1712
- title: "SECURITY",
1713
- count: security.length
1714
- },
1715
- {
1716
- key: "couldnt-fix",
1717
- title: "COULDN'T FIX",
1718
- count: couldntFix.length
1719
- },
1720
- {
1721
- key: "review",
1722
- title: "NEEDS YOUR REVIEW — behavior changed",
1723
- count: report.flaggedBehaviorChanges.length
1724
- }
1725
- ];
1726
- return groups.filter((g) => g.count > 0);
1727
- }
1728
-
1729
- //#endregion
1730
- //#region src/commands/resolve-finding.ts
1731
- function displayId(finding) {
1732
- return finding.retryId ?? finding.id;
1733
- }
1734
- function resolveFindingId(id, findings) {
1735
- const retryMatch = findings.find((f) => f.retryId === id);
1736
- if (retryMatch) return retryMatch;
1737
- const exact = findings.find((f) => f.id === id);
1738
- if (exact) return exact;
1739
- const prefixMatches = findings.filter((f) => f.id.startsWith(id));
1740
- if (prefixMatches.length === 0) return { error: `No finding with id "${id}"` };
1741
- if (prefixMatches.length > 1) {
1742
- const matches = prefixMatches.map(displayId).join(", ");
1743
- return { error: `Finding id "${id}" is ambiguous; matches ${matches}. Use the retry id or full fingerprint.` };
1744
- }
1745
- return prefixMatches[0];
1746
- }
1747
-
1748
- //#endregion
1749
- //#region src/commands/show.ts
1750
- /** `tend show <id>` — full detail on one finding: attempts, revert reason, taint flow path. */
1751
- function showCommand(id, findings) {
1752
- const resolved = resolveFindingId(id, findings);
1753
- if ("error" in resolved) return resolved.error;
1754
- const finding = resolved;
1755
- const lines$1 = [
1756
- `${finding.tool} ${finding.rule} [${finding.status}]`,
1757
- `retry id: ${finding.retryId ?? "(none)"}`,
1758
- `fingerprint: ${finding.id}`,
1759
- `${finding.file}:${finding.range.startLine}`,
1760
- finding.message,
1761
- `attempts: ${finding.attempts}`
1762
- ];
1763
- if (finding.revertReason) lines$1.push(`last revert reason: ${finding.revertReason}`);
1764
- if (finding.revertDetail) lines$1.push(`last revert detail: ${finding.revertDetail}`);
1765
- if (finding.flowPath?.length) {
1766
- lines$1.push("flow path:");
1767
- for (const step of finding.flowPath) lines$1.push(` → ${step.file}:${step.line}`);
1768
- }
1769
- if (finding.helpUri) lines$1.push(`docs: ${finding.helpUri}`);
1770
- return lines$1.join("\n");
1771
- }
1772
-
1773
- //#endregion
1774
- //#region src/commands/retry.ts
1775
- function findingsOf(deps) {
1776
- return deps.report?.findings ?? deps.findings ?? [];
1777
- }
1778
- function syncDerivedReportFields(report) {
1779
- report.secrets = report.findings.filter((f) => f.category === "secret");
1780
- report.depBumps = report.findings.filter((f) => f.remediation !== void 0).map((f) => ({
1781
- findingId: f.id,
1782
- remediation: f.remediation
1783
- }));
1784
- report.exitStatus = report.secrets.length > 0 ? 1 : 0;
1785
- }
1786
- function resolveRetryTarget(id, findings) {
1787
- const resolved = resolveFindingId(id, findings);
1788
- if ("error" in resolved) return resolved;
1789
- if (resolved.status === "fixed") return { error: `Finding ${resolved.id} is already fixed` };
1790
- if (resolved.track !== "ai-fix") return { error: `Finding ${resolved.id} is not AI-fixable` };
1791
- return resolved;
1792
- }
1793
- /** `tend retry <id>` — re-attempt a stubborn finding with a larger attempt budget. */
1794
- async function retryCommand(id, deps) {
1795
- const resolved = resolveRetryTarget(id, findingsOf(deps));
1796
- if ("error" in resolved) return resolved;
1797
- const finding = resolved;
1798
- const largerBudget = Math.max(deps.baseBudget * 2, finding.attempts + 1);
1799
- finding.status = "fixing";
1800
- const outcome = await deps.runFix(finding, largerBudget);
1801
- if (outcome.kept) {
1802
- finding.status = "fixed";
1803
- delete finding.revertReason;
1804
- delete finding.revertDetail;
1805
- if (deps.report) syncDerivedReportFields(deps.report);
1806
- return {
1807
- outcome: "fixed",
1808
- finding,
1809
- budget: largerBudget
1810
- };
1811
- }
1812
- const reason = outcome.reason ?? "session-error";
1813
- finding.attempts += 1;
1814
- finding.revertReason = reason;
1815
- const detail = normalizeRevertDetail(outcome.detail);
1816
- if (detail) finding.revertDetail = detail;
1817
- else delete finding.revertDetail;
1818
- finding.status = finding.attempts >= largerBudget ? "unfixable" : "reverted";
1819
- if (deps.report) syncDerivedReportFields(deps.report);
1820
- return {
1821
- outcome: "reverted",
1822
- finding,
1823
- budget: largerBudget,
1824
- reason
1825
- };
1826
- }
1827
-
1828
- //#endregion
1829
- //#region src/config/config.ts
1830
- const EFFORT_LEVELS = [
1831
- "low",
1832
- "medium",
1833
- "high",
1834
- "xhigh",
1835
- "max"
1836
- ];
1837
- const ToolConfigSchema = z.object({
1838
- enabled: z.boolean().default(true),
1839
- configPath: z.string().optional()
1840
- });
1841
- const ConfigSchema = z.object({
1842
- maxSessions: z.number().int().positive().default(4),
1843
- maxLoops: z.number().int().positive().default(5),
1844
- perIssueBudget: z.number().int().positive().default(3),
1845
- test: z.string().optional(),
1846
- teethCheck: z.boolean().default(true),
1847
- includeTests: z.boolean().default(false),
1848
- model: z.string().default("sonnet"),
1849
- effort: z.enum(EFFORT_LEVELS).optional(),
1850
- tools: z.record(z.string(), ToolConfigSchema).default({})
1851
- });
1852
- /**
1853
- * Load config via cosmiconfig (searching from `cwd`), validate with zod, and apply
1854
- * zero-config defaults when no file is found. Invalid config throws a clear message.
1855
- */
1856
- async function loadConfig(cwd) {
1857
- const explorer = cosmiconfig("tend", { stopDir: cwd });
1858
- const found = await explorer.search(cwd);
1859
- const raw = found?.config ?? {};
1860
- const parsed = ConfigSchema.safeParse(raw);
1861
- if (!parsed.success) {
1862
- const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`);
1863
- throw new Error(`Invalid tend config:\n ${issues.join("\n ")}`);
1864
- }
1865
- return parsed.data;
1866
- }
1867
- /** Overlay CLI flags onto a loaded config (flags win). */
1868
- function applyCliOverrides(config, overrides) {
1869
- const result$1 = { ...config };
1870
- for (const [key, value] of Object.entries(overrides)) if (value !== void 0) result$1[key] = value;
1871
- return result$1;
1872
- }
1873
-
1874
- //#endregion
1875
- export { ClaudeSession, ConfigSchema, EFFORT_LEVELS, EventBus, FindingSchema, FindingStore, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, assertGitRepo, buildProgram, changedFiles, changedVsHead, createGit, detectPackageManager, dispatch, filesUnder, filterToChanged, fingerprint, formatClock, groupRemaining, isAvailable, loadConfig, makeTheme, normalize, orchestrate, planWork, reasonLabel, renderSummary, resolveRetryTarget, retryCommand, revertFile, route, runScanner, scannerStatus, scopeFindings, showCommand, trackForTool, zeroUsage };