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