tend-cli 0.5.0 → 0.6.1
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.
- package/dist/bin.js +233 -451
- package/dist/config-Dz4byqjU.js +3594 -0
- package/dist/index.d.ts +1085 -391
- package/dist/index.js +9 -3
- package/package.json +2 -4
- package/prompts/dead-code-cleanup.md +48 -0
- package/prompts/fix.md +25 -21
- package/prompts/generated-source-repair.md +48 -0
- package/prompts/multi-file-duplicate-refactor.md +49 -0
- package/prompts/regression-repair.md +67 -0
- package/prompts/single-file-ai-edit.md +46 -0
- package/prompts/test-file-repair.md +46 -0
- package/dist/config-LHbm_R36.js +0 -1875
|
@@ -0,0 +1,3594 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { ESLint } from "eslint";
|
|
5
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import PQueue from "p-queue";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { simpleGit } from "simple-git";
|
|
12
|
+
import ts from "typescript";
|
|
13
|
+
import { customAlphabet } from "nanoid";
|
|
14
|
+
import Table from "cli-table3";
|
|
15
|
+
import { Chalk, supportsColor } from "chalk";
|
|
16
|
+
import gradient from "gradient-string";
|
|
17
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
18
|
+
|
|
19
|
+
//#region src/cli.ts
|
|
20
|
+
const COMMAND_NAMES = new Set([
|
|
21
|
+
"diff",
|
|
22
|
+
"help",
|
|
23
|
+
"retry",
|
|
24
|
+
"run",
|
|
25
|
+
"show",
|
|
26
|
+
"undo"
|
|
27
|
+
]);
|
|
28
|
+
const RUN_OPTION_NAMES = new Set([
|
|
29
|
+
"--all",
|
|
30
|
+
"--effort",
|
|
31
|
+
"--include-tests",
|
|
32
|
+
"--max-loops",
|
|
33
|
+
"--max-sessions",
|
|
34
|
+
"--model",
|
|
35
|
+
"--no-color",
|
|
36
|
+
"--plain",
|
|
37
|
+
"--verbose"
|
|
38
|
+
]);
|
|
39
|
+
function addRunOptions(command) {
|
|
40
|
+
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");
|
|
41
|
+
}
|
|
42
|
+
function looksLikePath(value) {
|
|
43
|
+
return value.includes("/") || value.includes("\\") || value.startsWith(".") || existsSync(value);
|
|
44
|
+
}
|
|
45
|
+
function isRunOption(value) {
|
|
46
|
+
const name = value.split("=")[0] ?? value;
|
|
47
|
+
return RUN_OPTION_NAMES.has(name);
|
|
48
|
+
}
|
|
49
|
+
function shouldInsertRun(args) {
|
|
50
|
+
const first = args[0];
|
|
51
|
+
if (!first) return true;
|
|
52
|
+
if (first === "--help" || first === "-h" || COMMAND_NAMES.has(first)) return false;
|
|
53
|
+
return isRunOption(first) || looksLikePath(first);
|
|
54
|
+
}
|
|
55
|
+
function withDefaultRun(argv, from) {
|
|
56
|
+
const prefixLength = from === "user" ? 0 : 2;
|
|
57
|
+
const prefix = argv.slice(0, prefixLength);
|
|
58
|
+
const args = argv.slice(prefixLength);
|
|
59
|
+
return shouldInsertRun(args) ? [
|
|
60
|
+
...prefix,
|
|
61
|
+
"run",
|
|
62
|
+
...args
|
|
63
|
+
] : [...argv];
|
|
64
|
+
}
|
|
65
|
+
function enableDefaultRun(program) {
|
|
66
|
+
const parse = program.parse.bind(program);
|
|
67
|
+
const parseAsync = program.parseAsync.bind(program);
|
|
68
|
+
program.parse = (argv, options) => parse(withDefaultRun(argv ?? process.argv, options?.from), options);
|
|
69
|
+
program.parseAsync = (argv, options) => parseAsync(withDefaultRun(argv ?? process.argv, options?.from), options);
|
|
70
|
+
}
|
|
71
|
+
/** Build the commander program wiring each subcommand to a handler. */
|
|
72
|
+
function buildProgram(handlers) {
|
|
73
|
+
const program = new Command();
|
|
74
|
+
program.name("tend").description("Audit a JS/TS repo and fix findings with AI in a safe loop.");
|
|
75
|
+
program.exitOverride();
|
|
76
|
+
program.addHelpCommand(true);
|
|
77
|
+
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)");
|
|
78
|
+
addRunOptions(run).action((paths, opts) => handlers.run({
|
|
79
|
+
...opts,
|
|
80
|
+
paths
|
|
81
|
+
}));
|
|
82
|
+
program.command("diff").description("show only the tool's edits").action(() => handlers.diff());
|
|
83
|
+
program.command("undo").description("restore the pre-run snapshot").action(() => handlers.undo());
|
|
84
|
+
program.command("show <id>").description("full detail on one finding").action((id) => handlers.show(id));
|
|
85
|
+
program.command("retry <id>").description("re-attempt a stubborn finding with a larger budget").action((id) => handlers.retry(id));
|
|
86
|
+
enableDefaultRun(program);
|
|
87
|
+
return program;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/scanners/scope.ts
|
|
92
|
+
/**
|
|
93
|
+
* Files changed vs `HEAD` (tracked modifications/additions/renames plus untracked),
|
|
94
|
+
* scoped and re-based to `git`'s working directory — see `changedVsHead` in git/repo.ts
|
|
95
|
+
* for why this matters when tend runs from a subdirectory of the repo.
|
|
96
|
+
*/
|
|
97
|
+
async function changedFiles(git) {
|
|
98
|
+
const prefix = (await git.revparse(["--show-prefix"])).trim();
|
|
99
|
+
const status = await git.status();
|
|
100
|
+
const files = new Set();
|
|
101
|
+
for (const file of status.files) {
|
|
102
|
+
const path = file.path.includes(" -> ") ? file.path.split(" -> ")[1] : file.path;
|
|
103
|
+
if (!prefix) files.add(path);
|
|
104
|
+
else if (path.startsWith(prefix)) files.add(path.slice(prefix.length));
|
|
105
|
+
}
|
|
106
|
+
return [...files];
|
|
107
|
+
}
|
|
108
|
+
/** Keep only findings whose file is in the changed set. */
|
|
109
|
+
function filterToChanged(findings, changed) {
|
|
110
|
+
const set = new Set(changed);
|
|
111
|
+
return findings.filter((f) => {
|
|
112
|
+
if (set.has(f.file)) return true;
|
|
113
|
+
if (f.category === "duplication") return (f.flowPath ?? []).some((p) => set.has(p.file));
|
|
114
|
+
return false;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** Apply the fix scope: `--all` fixes everything, otherwise only changed files. */
|
|
118
|
+
function scopeFindings(findings, opts) {
|
|
119
|
+
return opts.all ? findings : filterToChanged(findings, opts.changed);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/scanners/scope-policy.ts
|
|
124
|
+
const OUT_OF_SCOPE_SEGMENTS = new Set(["node_modules", ".git"]);
|
|
125
|
+
const GENERATED_SEGMENTS = new Set([
|
|
126
|
+
".tend",
|
|
127
|
+
".turbo",
|
|
128
|
+
".next",
|
|
129
|
+
".vercel",
|
|
130
|
+
"coverage",
|
|
131
|
+
"dist",
|
|
132
|
+
"build",
|
|
133
|
+
"out",
|
|
134
|
+
"generated",
|
|
135
|
+
"__generated__"
|
|
136
|
+
]);
|
|
137
|
+
const TEST_FILE_RE$1 = /(^|[/\\])[^/\\]+\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
138
|
+
function normalizePath$1(path) {
|
|
139
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
140
|
+
}
|
|
141
|
+
function pathSegments$1(path) {
|
|
142
|
+
return normalizePath$1(path).split("/").filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
function hasGlob(pattern) {
|
|
145
|
+
return /[*?[\]{}]/.test(pattern);
|
|
146
|
+
}
|
|
147
|
+
function escapeRegex(char) {
|
|
148
|
+
return char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
149
|
+
}
|
|
150
|
+
function globToRegex(pattern) {
|
|
151
|
+
const normalized = normalizePath$1(pattern);
|
|
152
|
+
let source = "^";
|
|
153
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
154
|
+
const char = normalized[i];
|
|
155
|
+
const next = normalized[i + 1];
|
|
156
|
+
if (char === "*") if (next === "*") {
|
|
157
|
+
const after = normalized[i + 2];
|
|
158
|
+
if (after === "/") {
|
|
159
|
+
source += "(?:.*/)?";
|
|
160
|
+
i += 2;
|
|
161
|
+
} else {
|
|
162
|
+
source += ".*";
|
|
163
|
+
i += 1;
|
|
164
|
+
}
|
|
165
|
+
} else source += "[^/]*";
|
|
166
|
+
else if (char === "?") source += "[^/]";
|
|
167
|
+
else source += escapeRegex(char);
|
|
168
|
+
}
|
|
169
|
+
source += "$";
|
|
170
|
+
return new RegExp(source);
|
|
171
|
+
}
|
|
172
|
+
function matchesPattern(path, pattern) {
|
|
173
|
+
const normalizedPath = normalizePath$1(path);
|
|
174
|
+
const normalizedPattern = normalizePath$1(pattern);
|
|
175
|
+
if (!hasGlob(normalizedPattern)) return normalizedPath === normalizedPattern || normalizedPath.startsWith(`${normalizedPattern}/`);
|
|
176
|
+
return globToRegex(normalizedPattern).test(normalizedPath);
|
|
177
|
+
}
|
|
178
|
+
function matchesAnyPath(paths, patterns) {
|
|
179
|
+
if (!patterns || patterns.length === 0) return false;
|
|
180
|
+
return paths.some((path) => patterns.some((pattern) => matchesPattern(path, pattern)));
|
|
181
|
+
}
|
|
182
|
+
function isFixturePath(path) {
|
|
183
|
+
const segments = pathSegments$1(path);
|
|
184
|
+
if (segments.includes("__fixtures__")) return true;
|
|
185
|
+
return segments.some((segment, index) => (segment === "test" || segment === "tests") && segments[index + 1] === "fixtures");
|
|
186
|
+
}
|
|
187
|
+
function isGeneratedPath(path) {
|
|
188
|
+
const segments = pathSegments$1(path);
|
|
189
|
+
if (segments.some((segment) => GENERATED_SEGMENTS.has(segment))) return true;
|
|
190
|
+
return /\.(d\.ts|d\.ts\.map)$/.test(normalizePath$1(path)) && segments.some((segment) => segment === "generated" || segment === "build");
|
|
191
|
+
}
|
|
192
|
+
function isOutOfScopePath(path) {
|
|
193
|
+
return pathSegments$1(path).some((segment) => OUT_OF_SCOPE_SEGMENTS.has(segment));
|
|
194
|
+
}
|
|
195
|
+
function isTestPath(path) {
|
|
196
|
+
return TEST_FILE_RE$1.test(normalizePath$1(path));
|
|
197
|
+
}
|
|
198
|
+
function findingPaths(finding) {
|
|
199
|
+
return [finding.file, ...(finding.flowPath ?? []).map((step) => step.file)].map(normalizePath$1);
|
|
200
|
+
}
|
|
201
|
+
function defaultExclusionReason(paths) {
|
|
202
|
+
if (paths.some(isOutOfScopePath)) return "out-of-scope";
|
|
203
|
+
if (paths.some(isFixturePath)) return "fixtures";
|
|
204
|
+
if (paths.some(isGeneratedPath)) return "generated";
|
|
205
|
+
if (paths.some(isTestPath)) return "tests";
|
|
206
|
+
return void 0;
|
|
207
|
+
}
|
|
208
|
+
/** Decide report/fix scope for a finding without mutating it. */
|
|
209
|
+
function classifyScope(finding, options = {}) {
|
|
210
|
+
const paths = findingPaths(finding);
|
|
211
|
+
if (options.inChangedScope === false) return {
|
|
212
|
+
inReportScope: true,
|
|
213
|
+
inFixScope: false,
|
|
214
|
+
scopeExclusionReason: "out-of-scope"
|
|
215
|
+
};
|
|
216
|
+
if (matchesAnyPath(paths, options.exclude)) return {
|
|
217
|
+
inReportScope: true,
|
|
218
|
+
inFixScope: false,
|
|
219
|
+
scopeExclusionReason: "out-of-scope"
|
|
220
|
+
};
|
|
221
|
+
if (matchesAnyPath(paths, options.include)) return {
|
|
222
|
+
inReportScope: true,
|
|
223
|
+
inFixScope: true
|
|
224
|
+
};
|
|
225
|
+
const reason = defaultExclusionReason(paths);
|
|
226
|
+
if (reason === "generated" && options.includeGenerated) return {
|
|
227
|
+
inReportScope: true,
|
|
228
|
+
inFixScope: true
|
|
229
|
+
};
|
|
230
|
+
if (reason === "fixtures" && options.includeFixtures) return {
|
|
231
|
+
inReportScope: true,
|
|
232
|
+
inFixScope: true
|
|
233
|
+
};
|
|
234
|
+
if (reason === "tests" && options.includeTests) return {
|
|
235
|
+
inReportScope: true,
|
|
236
|
+
inFixScope: true
|
|
237
|
+
};
|
|
238
|
+
if (reason) return {
|
|
239
|
+
inReportScope: true,
|
|
240
|
+
inFixScope: false,
|
|
241
|
+
scopeExclusionReason: reason
|
|
242
|
+
};
|
|
243
|
+
return {
|
|
244
|
+
inReportScope: true,
|
|
245
|
+
inFixScope: true
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Apply scope metadata in place so store/report state stays marked consistently. */
|
|
249
|
+
function markScope(finding, options = {}) {
|
|
250
|
+
const decision = classifyScope(finding, options);
|
|
251
|
+
finding.inReportScope = decision.inReportScope;
|
|
252
|
+
finding.inFixScope = decision.inFixScope;
|
|
253
|
+
if (decision.scopeExclusionReason) finding.scopeExclusionReason = decision.scopeExclusionReason;
|
|
254
|
+
else delete finding.scopeExclusionReason;
|
|
255
|
+
return finding;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/fixing/dispatch.ts
|
|
260
|
+
const TEST_FILE_RE = /^(.*)\.(test|spec)\.([cm]?[jt]sx?)$/;
|
|
261
|
+
/** Whether a repo-relative path is a test file (`*.test.*` / `*.spec.*`). */
|
|
262
|
+
const isTestFile = (file) => TEST_FILE_RE.test(file);
|
|
263
|
+
/** A test file's owning code file (so both go to the same worker); else the file itself. */
|
|
264
|
+
function ownerOf(file) {
|
|
265
|
+
const m = file.match(TEST_FILE_RE);
|
|
266
|
+
return m ? `${m[1]}.${m[3]}` : file;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Group findings into work units so each worker owns a disjoint set of files
|
|
270
|
+
* (a code file plus its sibling test). No two sessions ever touch the same file.
|
|
271
|
+
*/
|
|
272
|
+
function planWork(findings) {
|
|
273
|
+
const byOwner = new Map();
|
|
274
|
+
for (const finding of findings) {
|
|
275
|
+
const owner = ownerOf(finding.file);
|
|
276
|
+
let unit = byOwner.get(owner);
|
|
277
|
+
if (!unit) {
|
|
278
|
+
unit = {
|
|
279
|
+
file: owner,
|
|
280
|
+
files: [],
|
|
281
|
+
findings: []
|
|
282
|
+
};
|
|
283
|
+
byOwner.set(owner, unit);
|
|
284
|
+
}
|
|
285
|
+
unit.findings.push(finding);
|
|
286
|
+
if (!unit.files.includes(finding.file)) unit.files.push(finding.file);
|
|
287
|
+
if (!unit.files.includes(owner)) unit.files.push(owner);
|
|
288
|
+
}
|
|
289
|
+
return [...byOwner.values()];
|
|
290
|
+
}
|
|
291
|
+
function strategyPriority(strategy) {
|
|
292
|
+
switch (strategy) {
|
|
293
|
+
case "multi-file-duplicate-refactor": return 6;
|
|
294
|
+
case "generated-source-repair": return 5;
|
|
295
|
+
case "test-file-repair": return 4;
|
|
296
|
+
case "dead-code-cleanup":
|
|
297
|
+
case "single-file-ai-edit": return 3;
|
|
298
|
+
case "deterministic-package-json-cleanup":
|
|
299
|
+
case "deterministic-ts-organize-imports":
|
|
300
|
+
case "deterministic-eslint-fix": return 2;
|
|
301
|
+
default: return 0;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function addUnique(target, values) {
|
|
305
|
+
for (const value of values) if (!target.includes(value)) target.push(value);
|
|
306
|
+
}
|
|
307
|
+
function ownerFiles(plan) {
|
|
308
|
+
const files = plan.editableFiles.length > 0 ? plan.editableFiles : [plan.finding.file];
|
|
309
|
+
if (plan.strategy === "multi-file-duplicate-refactor" || plan.strategy === "generated-source-repair") return files;
|
|
310
|
+
return uniqueValues(files.flatMap((file) => [file, ownerOf(file)]));
|
|
311
|
+
}
|
|
312
|
+
function uniqueValues(files) {
|
|
313
|
+
return [...new Set(files)];
|
|
314
|
+
}
|
|
315
|
+
function unitForPlan(plan) {
|
|
316
|
+
const files = ownerFiles(plan);
|
|
317
|
+
return {
|
|
318
|
+
file: plan.strategy === "test-file-repair" ? ownerOf(plan.finding.file) : files[0] ?? plan.finding.file,
|
|
319
|
+
files,
|
|
320
|
+
findings: [plan.finding],
|
|
321
|
+
strategy: plan.strategy,
|
|
322
|
+
strategies: [plan.strategy],
|
|
323
|
+
verificationTargets: uniqueValues(plan.verificationTargets)
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function overlaps(a, b) {
|
|
327
|
+
const files = new Set(a.files);
|
|
328
|
+
return b.files.some((file) => files.has(file));
|
|
329
|
+
}
|
|
330
|
+
function mergeUnits(a, b) {
|
|
331
|
+
const strategies = uniqueValues([...a.strategies ?? (a.strategy ? [a.strategy] : []), ...b.strategies ?? (b.strategy ? [b.strategy] : [])]);
|
|
332
|
+
addUnique(a.files, b.files);
|
|
333
|
+
addUnique(a.findings, b.findings);
|
|
334
|
+
addUnique(a.verificationTargets ?? (a.verificationTargets = []), b.verificationTargets ?? []);
|
|
335
|
+
a.strategy = strategies.sort((left, right) => strategyPriority(right) - strategyPriority(left))[0];
|
|
336
|
+
a.strategies = strategies;
|
|
337
|
+
if (!a.files.includes(a.file)) a.file = a.files[0] ?? a.file;
|
|
338
|
+
return a;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Group planned repairs into disjoint work units. Unlike `planWork`, this honors
|
|
342
|
+
* planner-selected editable files, so cross-file duplicate plans reserve both clone sites.
|
|
343
|
+
*/
|
|
344
|
+
function planWorkFromRepairs(plans) {
|
|
345
|
+
const units = [];
|
|
346
|
+
for (const plan of plans) {
|
|
347
|
+
let next = unitForPlan(plan);
|
|
348
|
+
for (let index = 0; index < units.length; index++) {
|
|
349
|
+
if (!overlaps(units[index], next)) continue;
|
|
350
|
+
next = mergeUnits(units[index], next);
|
|
351
|
+
units.splice(index, 1);
|
|
352
|
+
index = -1;
|
|
353
|
+
}
|
|
354
|
+
units.push(next);
|
|
355
|
+
}
|
|
356
|
+
return units;
|
|
357
|
+
}
|
|
358
|
+
/** Run each work unit through `runUnit`, capped at `concurrency` concurrent sessions. */
|
|
359
|
+
async function dispatch(units, runUnit, opts) {
|
|
360
|
+
const queue = new PQueue({ concurrency: opts.concurrency });
|
|
361
|
+
return Promise.all(units.map((unit) => queue.add(() => runUnit(unit))));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/fixing/generated-source.ts
|
|
366
|
+
const GENERATED_OUTPUT_SEGMENTS = new Set([
|
|
367
|
+
"dist",
|
|
368
|
+
"build",
|
|
369
|
+
"out"
|
|
370
|
+
]);
|
|
371
|
+
const SOURCE_EXTENSIONS = [
|
|
372
|
+
".ts",
|
|
373
|
+
".tsx",
|
|
374
|
+
".js",
|
|
375
|
+
".jsx",
|
|
376
|
+
".mts",
|
|
377
|
+
".cts",
|
|
378
|
+
".mjs",
|
|
379
|
+
".cjs"
|
|
380
|
+
];
|
|
381
|
+
const GENERATED_EXTENSIONS = [
|
|
382
|
+
".d.ts.map",
|
|
383
|
+
".d.ts",
|
|
384
|
+
".js.map",
|
|
385
|
+
".mjs.map",
|
|
386
|
+
".cjs.map",
|
|
387
|
+
".js",
|
|
388
|
+
".mjs",
|
|
389
|
+
".cjs"
|
|
390
|
+
];
|
|
391
|
+
function normalizePath(path) {
|
|
392
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
393
|
+
}
|
|
394
|
+
function toRepoRelative$1(cwd, file) {
|
|
395
|
+
const rel = isAbsolute(file) ? relative(cwd, file) : file;
|
|
396
|
+
return normalizePath(rel);
|
|
397
|
+
}
|
|
398
|
+
function absolutePath(cwd, file) {
|
|
399
|
+
return isAbsolute(file) ? file : join(cwd, file);
|
|
400
|
+
}
|
|
401
|
+
function pathSegments(file) {
|
|
402
|
+
return normalizePath(file).split("/").filter(Boolean);
|
|
403
|
+
}
|
|
404
|
+
function isGeneratedOutputPath(file) {
|
|
405
|
+
return pathSegments(file).some((segment) => GENERATED_OUTPUT_SEGMENTS.has(segment));
|
|
406
|
+
}
|
|
407
|
+
function isDeclarationArtifact(file) {
|
|
408
|
+
return /\.d\.ts(?:\.map)?$/.test(normalizePath(file));
|
|
409
|
+
}
|
|
410
|
+
function stripGeneratedExtension(file) {
|
|
411
|
+
const normalized = normalizePath(file);
|
|
412
|
+
const extension = GENERATED_EXTENSIONS.find((ext) => normalized.endsWith(ext));
|
|
413
|
+
return extension ? normalized.slice(0, -extension.length) : normalized.replace(/\.[^/.]+$/, "");
|
|
414
|
+
}
|
|
415
|
+
function sourceMappingUrl(contents) {
|
|
416
|
+
const match = contents.match(/[#@]\s*sourceMappingURL=([^\s]+)/);
|
|
417
|
+
if (!match?.[1] || match[1].startsWith("data:")) return void 0;
|
|
418
|
+
return decodeURIComponent(match[1]);
|
|
419
|
+
}
|
|
420
|
+
function readJson(path) {
|
|
421
|
+
try {
|
|
422
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
423
|
+
} catch {
|
|
424
|
+
return void 0;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
function existingRelative(cwd, abs) {
|
|
428
|
+
if (!existsSync(abs)) return void 0;
|
|
429
|
+
const rel = toRepoRelative$1(cwd, abs);
|
|
430
|
+
if (rel.startsWith("../")) return void 0;
|
|
431
|
+
return rel;
|
|
432
|
+
}
|
|
433
|
+
function sourceMapPathFor(cwd, file) {
|
|
434
|
+
const abs = absolutePath(cwd, file);
|
|
435
|
+
if (normalizePath(file).endsWith(".map") && existsSync(abs)) return abs;
|
|
436
|
+
if (existsSync(abs)) {
|
|
437
|
+
const url = sourceMappingUrl(readFileSync(abs, "utf8"));
|
|
438
|
+
if (url) {
|
|
439
|
+
const resolved = resolve(dirname(abs), url);
|
|
440
|
+
if (existsSync(resolved)) return resolved;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const sibling = `${abs}.map`;
|
|
444
|
+
if (existsSync(sibling)) return sibling;
|
|
445
|
+
return void 0;
|
|
446
|
+
}
|
|
447
|
+
function sourceOwnerFromMap(cwd, mapPath) {
|
|
448
|
+
const map = readJson(mapPath);
|
|
449
|
+
if (!map || !Array.isArray(map.sources)) return void 0;
|
|
450
|
+
for (const source of map.sources) {
|
|
451
|
+
if (typeof source !== "string" || source.startsWith("webpack://")) continue;
|
|
452
|
+
const candidates = [resolve(dirname(mapPath), map.sourceRoot ?? "", source), resolve(cwd, map.sourceRoot ?? "", source)];
|
|
453
|
+
const rel = candidates.map((candidate) => existingRelative(cwd, candidate)).find(Boolean);
|
|
454
|
+
if (rel && !isGeneratedOutputPath(rel)) return {
|
|
455
|
+
generatedFile: "",
|
|
456
|
+
sourceOwner: rel,
|
|
457
|
+
sourceMap: toRepoRelative$1(cwd, mapPath)
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
generatedFile: "",
|
|
462
|
+
sourceMap: toRepoRelative$1(cwd, mapPath)
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function packageJson(cwd) {
|
|
466
|
+
return readJson(join(cwd, "package.json"));
|
|
467
|
+
}
|
|
468
|
+
function tsdownEntries(cwd) {
|
|
469
|
+
const configPath = join(cwd, "tsdown.config.ts");
|
|
470
|
+
if (!existsSync(configPath)) return [];
|
|
471
|
+
const contents = readFileSync(configPath, "utf8");
|
|
472
|
+
const entries = new Set();
|
|
473
|
+
const arrayMatch = contents.match(/entry\s*:\s*\[([^\]]+)\]/s);
|
|
474
|
+
if (arrayMatch?.[1]) {
|
|
475
|
+
for (const match of arrayMatch[1].matchAll(/["']([^"']+)["']/g)) if (match[1]) entries.add(normalizePath(match[1]));
|
|
476
|
+
}
|
|
477
|
+
const stringMatch = contents.match(/entry\s*:\s*["']([^"']+)["']/);
|
|
478
|
+
if (stringMatch?.[1]) entries.add(normalizePath(stringMatch[1]));
|
|
479
|
+
return [...entries];
|
|
480
|
+
}
|
|
481
|
+
function collectExportTargets(value, out) {
|
|
482
|
+
if (typeof value === "string") {
|
|
483
|
+
out.push(value);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (Array.isArray(value)) {
|
|
487
|
+
for (const item of value) collectExportTargets(item, out);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (value && typeof value === "object") for (const item of Object.values(value)) collectExportTargets(item, out);
|
|
491
|
+
}
|
|
492
|
+
function packageTargets(pkg) {
|
|
493
|
+
if (!pkg) return [];
|
|
494
|
+
const targets = [];
|
|
495
|
+
if (pkg.main) targets.push(pkg.main);
|
|
496
|
+
if (pkg.module) targets.push(pkg.module);
|
|
497
|
+
if (pkg.types) targets.push(pkg.types);
|
|
498
|
+
if (pkg.typings) targets.push(pkg.typings);
|
|
499
|
+
if (typeof pkg.bin === "string") targets.push(pkg.bin);
|
|
500
|
+
else if (pkg.bin) targets.push(...Object.values(pkg.bin));
|
|
501
|
+
collectExportTargets(pkg.exports, targets);
|
|
502
|
+
return targets.map(normalizePath);
|
|
503
|
+
}
|
|
504
|
+
function sourceBasename(file) {
|
|
505
|
+
const base = stripGeneratedExtension(file).split("/").pop() ?? "";
|
|
506
|
+
return base === "index" ? "index" : base;
|
|
507
|
+
}
|
|
508
|
+
function sourceOwnerFromBuildConfig(cwd, file) {
|
|
509
|
+
const normalized = normalizePath(file);
|
|
510
|
+
const targets = new Set(packageTargets(packageJson(cwd)));
|
|
511
|
+
const entries = tsdownEntries(cwd);
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
const entryBase = sourceBasename(entry);
|
|
514
|
+
const matchesPackageTarget = targets.has(normalized);
|
|
515
|
+
if ((matchesPackageTarget || sourceBasename(normalized) === entryBase) && existsSync(join(cwd, entry))) return entry;
|
|
516
|
+
}
|
|
517
|
+
return void 0;
|
|
518
|
+
}
|
|
519
|
+
function candidateSourcesForGenerated(file) {
|
|
520
|
+
const normalized = normalizePath(file);
|
|
521
|
+
const parts = pathSegments(stripGeneratedExtension(normalized));
|
|
522
|
+
const generatedIndex = parts.findIndex((part) => GENERATED_OUTPUT_SEGMENTS.has(part));
|
|
523
|
+
if (generatedIndex < 0) return [];
|
|
524
|
+
const suffix = parts.slice(generatedIndex + 1).join("/");
|
|
525
|
+
if (!suffix) return [];
|
|
526
|
+
return SOURCE_EXTENSIONS.map((ext) => `src/${suffix}${ext}`);
|
|
527
|
+
}
|
|
528
|
+
function sourceOwnerFromPathShape(cwd, file) {
|
|
529
|
+
return candidateSourcesForGenerated(file).find((candidate) => existsSync(join(cwd, candidate)));
|
|
530
|
+
}
|
|
531
|
+
function isGeneratedArtifact(cwd, file) {
|
|
532
|
+
const normalized = toRepoRelative$1(cwd, file);
|
|
533
|
+
if (isGeneratedOutputPath(normalized)) return true;
|
|
534
|
+
if (isDeclarationArtifact(normalized) && isGeneratedOutputPath(normalized)) return true;
|
|
535
|
+
return sourceMapPathFor(cwd, normalized) !== void 0;
|
|
536
|
+
}
|
|
537
|
+
function resolveGeneratedSourceOwner(cwd, file) {
|
|
538
|
+
const generatedFile = toRepoRelative$1(cwd, file);
|
|
539
|
+
if (!isGeneratedArtifact(cwd, generatedFile)) return void 0;
|
|
540
|
+
const mapPath = sourceMapPathFor(cwd, generatedFile);
|
|
541
|
+
let sourceMap;
|
|
542
|
+
if (mapPath) {
|
|
543
|
+
const fromMap = sourceOwnerFromMap(cwd, mapPath);
|
|
544
|
+
if (fromMap?.sourceOwner) return {
|
|
545
|
+
generatedFile,
|
|
546
|
+
sourceOwner: fromMap.sourceOwner,
|
|
547
|
+
sourceMap: fromMap.sourceMap
|
|
548
|
+
};
|
|
549
|
+
sourceMap = fromMap?.sourceMap ?? toRepoRelative$1(cwd, mapPath);
|
|
550
|
+
}
|
|
551
|
+
const sourceOwner = sourceOwnerFromBuildConfig(cwd, generatedFile) ?? sourceOwnerFromPathShape(cwd, generatedFile);
|
|
552
|
+
return sourceOwner ? {
|
|
553
|
+
generatedFile,
|
|
554
|
+
sourceOwner,
|
|
555
|
+
sourceMap
|
|
556
|
+
} : {
|
|
557
|
+
generatedFile,
|
|
558
|
+
sourceMap
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function detectBuildCommand(cwd) {
|
|
562
|
+
const pkg = packageJson(cwd);
|
|
563
|
+
if (!pkg?.scripts?.build) return void 0;
|
|
564
|
+
return ["run", "build"];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
//#endregion
|
|
568
|
+
//#region src/fixing/repair-strategy.ts
|
|
569
|
+
const REPAIR_STRATEGIES = [
|
|
570
|
+
"deterministic-eslint-fix",
|
|
571
|
+
"deterministic-ts-organize-imports",
|
|
572
|
+
"deterministic-package-json-cleanup",
|
|
573
|
+
"single-file-ai-edit",
|
|
574
|
+
"multi-file-duplicate-refactor",
|
|
575
|
+
"generated-source-repair",
|
|
576
|
+
"test-file-repair",
|
|
577
|
+
"dead-code-cleanup",
|
|
578
|
+
"unsupported"
|
|
579
|
+
];
|
|
580
|
+
const AUTO_FIX_RE = /\b(auto-?fix(?:able)?|eslint\s+--fix|fixable)\b/i;
|
|
581
|
+
const UNUSED_IMPORT_RE = /(^|[/@])no-unused-imports?$|unused[- ]imports?/i;
|
|
582
|
+
function unique(files) {
|
|
583
|
+
return [...new Set(files)];
|
|
584
|
+
}
|
|
585
|
+
function flowFiles(input) {
|
|
586
|
+
return unique([input.file ?? input.finding.file, ...(input.flowPath ?? input.finding.flowPath ?? []).map((step) => step.file)]);
|
|
587
|
+
}
|
|
588
|
+
function defaultPathReason(file) {
|
|
589
|
+
return classifyScope({ file }).scopeExclusionReason;
|
|
590
|
+
}
|
|
591
|
+
function configuredPathReason(file, config) {
|
|
592
|
+
return classifyScope({ file }, config).scopeExclusionReason;
|
|
593
|
+
}
|
|
594
|
+
function unsupported(input, reason) {
|
|
595
|
+
return {
|
|
596
|
+
finding: input.finding,
|
|
597
|
+
strategy: "unsupported",
|
|
598
|
+
editableFiles: [],
|
|
599
|
+
verificationTargets: flowFiles(input),
|
|
600
|
+
reason
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function isGeneratedFile(file) {
|
|
604
|
+
return defaultPathReason(file) === "generated";
|
|
605
|
+
}
|
|
606
|
+
function sourceOwnerForGenerated(input) {
|
|
607
|
+
const generatedFile = input.file ?? input.finding.file;
|
|
608
|
+
if (input.cwd) {
|
|
609
|
+
const resolved = resolveGeneratedSourceOwner(input.cwd, generatedFile);
|
|
610
|
+
if (resolved?.sourceOwner) return resolved.sourceOwner;
|
|
611
|
+
}
|
|
612
|
+
return flowFiles(input).find((file) => file !== generatedFile && !isGeneratedFile(file));
|
|
613
|
+
}
|
|
614
|
+
function isGeneratedFinding(input) {
|
|
615
|
+
const file = input.file ?? input.finding.file;
|
|
616
|
+
if (input.cwd && isGeneratedArtifact(input.cwd, file)) return true;
|
|
617
|
+
return isGeneratedFile(file) || input.scope?.scopeExclusionReason === "generated";
|
|
618
|
+
}
|
|
619
|
+
function isJscpdDuplicate(input) {
|
|
620
|
+
return (input.tool ?? input.finding.tool) === "jscpd" && (input.rule ?? input.finding.rule) === "duplicate-code";
|
|
621
|
+
}
|
|
622
|
+
function isCrossFileDuplicate(input) {
|
|
623
|
+
return isJscpdDuplicate(input) && flowFiles(input).length > 1;
|
|
624
|
+
}
|
|
625
|
+
function isPackageJsonUnusedDependency(input) {
|
|
626
|
+
const file = input.file ?? input.finding.file;
|
|
627
|
+
const rule = input.rule ?? input.finding.rule;
|
|
628
|
+
const message = input.finding.message;
|
|
629
|
+
return /(^|\/)package\.json$/.test(file) && (rule === "unused-dependency" || /unused .*dependency/i.test(message));
|
|
630
|
+
}
|
|
631
|
+
function isUnusedImport(input) {
|
|
632
|
+
const rule = input.rule ?? input.finding.rule;
|
|
633
|
+
return UNUSED_IMPORT_RE.test(rule) || UNUSED_IMPORT_RE.test(input.finding.message);
|
|
634
|
+
}
|
|
635
|
+
function isEslintAutofixable(input) {
|
|
636
|
+
const tool = input.tool ?? input.finding.tool;
|
|
637
|
+
const rule = input.rule ?? input.finding.rule;
|
|
638
|
+
return tool === "sonarjs" && (input.finding.autofixable === true || input.config?.eslintAutofixableRules?.includes(rule) === true || AUTO_FIX_RE.test(input.finding.message) || AUTO_FIX_RE.test(input.finding.remediation ?? ""));
|
|
639
|
+
}
|
|
640
|
+
function firstExcludedReason(files, config) {
|
|
641
|
+
for (const file of files) {
|
|
642
|
+
const reason = configuredPathReason(file, config);
|
|
643
|
+
if (reason) return reason;
|
|
644
|
+
}
|
|
645
|
+
return void 0;
|
|
646
|
+
}
|
|
647
|
+
function planRepair(input) {
|
|
648
|
+
const file = input.file ?? input.finding.file;
|
|
649
|
+
const category = input.category ?? input.finding.category;
|
|
650
|
+
const scope = input.scope ?? input.finding;
|
|
651
|
+
const files = flowFiles(input);
|
|
652
|
+
if (category === "secret" || input.finding.track === "report-only") return unsupported(input, "report-only");
|
|
653
|
+
if (isCrossFileDuplicate(input)) {
|
|
654
|
+
if (scope.inScope === false) return unsupported(input, "out-of-scope");
|
|
655
|
+
const excluded = firstExcludedReason(files, input.config);
|
|
656
|
+
if (excluded) return unsupported(input, excluded);
|
|
657
|
+
if (scope.inFixScope === false) return unsupported(input, scope.scopeExclusionReason ?? "out-of-scope");
|
|
658
|
+
return {
|
|
659
|
+
finding: input.finding,
|
|
660
|
+
strategy: "multi-file-duplicate-refactor",
|
|
661
|
+
editableFiles: files,
|
|
662
|
+
verificationTargets: files
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (isGeneratedFinding(input)) {
|
|
666
|
+
const owner = sourceOwnerForGenerated(input);
|
|
667
|
+
if (!owner) return unsupported(input, "generated-source-not-found");
|
|
668
|
+
return {
|
|
669
|
+
finding: input.finding,
|
|
670
|
+
strategy: "generated-source-repair",
|
|
671
|
+
editableFiles: [owner],
|
|
672
|
+
verificationTargets: unique([file, owner])
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
if (isTestFile(file) && input.config?.includeTests && (scope.scopeExclusionReason === void 0 || scope.scopeExclusionReason === "tests")) return {
|
|
676
|
+
finding: input.finding,
|
|
677
|
+
strategy: "test-file-repair",
|
|
678
|
+
editableFiles: [file],
|
|
679
|
+
verificationTargets: [file]
|
|
680
|
+
};
|
|
681
|
+
if (scope.inFixScope === false) return unsupported(input, scope.scopeExclusionReason ?? "out-of-scope");
|
|
682
|
+
if (isPackageJsonUnusedDependency(input)) return {
|
|
683
|
+
finding: input.finding,
|
|
684
|
+
strategy: "deterministic-package-json-cleanup",
|
|
685
|
+
editableFiles: [file],
|
|
686
|
+
verificationTargets: [file],
|
|
687
|
+
reason: "deterministic-not-dispatched"
|
|
688
|
+
};
|
|
689
|
+
if (isUnusedImport(input)) return {
|
|
690
|
+
finding: input.finding,
|
|
691
|
+
strategy: "deterministic-ts-organize-imports",
|
|
692
|
+
editableFiles: [file],
|
|
693
|
+
verificationTargets: [file],
|
|
694
|
+
reason: "deterministic-not-dispatched"
|
|
695
|
+
};
|
|
696
|
+
if (isEslintAutofixable(input)) return {
|
|
697
|
+
finding: input.finding,
|
|
698
|
+
strategy: "deterministic-eslint-fix",
|
|
699
|
+
editableFiles: [file],
|
|
700
|
+
verificationTargets: [file],
|
|
701
|
+
reason: "deterministic-not-dispatched"
|
|
702
|
+
};
|
|
703
|
+
if (category === "dead-code") return {
|
|
704
|
+
finding: input.finding,
|
|
705
|
+
strategy: "dead-code-cleanup",
|
|
706
|
+
editableFiles: [file],
|
|
707
|
+
verificationTargets: [file]
|
|
708
|
+
};
|
|
709
|
+
return {
|
|
710
|
+
finding: input.finding,
|
|
711
|
+
strategy: "single-file-ai-edit",
|
|
712
|
+
editableFiles: [file],
|
|
713
|
+
verificationTargets: [file]
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
function applyRepairPlanToFinding(plan) {
|
|
717
|
+
plan.finding.repairStrategy = plan.strategy;
|
|
718
|
+
if (plan.reason) plan.finding.repairStrategyReason = plan.reason;
|
|
719
|
+
else delete plan.finding.repairStrategyReason;
|
|
720
|
+
return plan.finding;
|
|
721
|
+
}
|
|
722
|
+
function isAiDispatchStrategy(strategy) {
|
|
723
|
+
return strategy === "single-file-ai-edit" || strategy === "multi-file-duplicate-refactor" || strategy === "generated-source-repair" || strategy === "test-file-repair" || strategy === "dead-code-cleanup";
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
//#endregion
|
|
727
|
+
//#region src/findings/finding.ts
|
|
728
|
+
const TOOLS = [
|
|
729
|
+
"sonarjs",
|
|
730
|
+
"knip",
|
|
731
|
+
"jscpd",
|
|
732
|
+
"semgrep",
|
|
733
|
+
"osv",
|
|
734
|
+
"gitleaks"
|
|
735
|
+
];
|
|
736
|
+
const SCOPE_EXCLUSION_REASONS = [
|
|
737
|
+
"generated",
|
|
738
|
+
"fixtures",
|
|
739
|
+
"tests",
|
|
740
|
+
"out-of-scope"
|
|
741
|
+
];
|
|
742
|
+
const FAILURE_CLASSES = [
|
|
743
|
+
"tool-timeout",
|
|
744
|
+
"rate-limit",
|
|
745
|
+
"model-tool-failure",
|
|
746
|
+
"no-edit",
|
|
747
|
+
"no-op",
|
|
748
|
+
"regression",
|
|
749
|
+
"typecheck",
|
|
750
|
+
"broke-test",
|
|
751
|
+
"suppression",
|
|
752
|
+
"needs-lockfile-update"
|
|
753
|
+
];
|
|
754
|
+
const RangeSchema = z.object({
|
|
755
|
+
startLine: z.number(),
|
|
756
|
+
startCol: z.number(),
|
|
757
|
+
endLine: z.number(),
|
|
758
|
+
endCol: z.number()
|
|
759
|
+
});
|
|
760
|
+
const FindingSchema = z.object({
|
|
761
|
+
id: z.string(),
|
|
762
|
+
retryId: z.string().optional(),
|
|
763
|
+
tool: z.enum(TOOLS),
|
|
764
|
+
rule: z.string(),
|
|
765
|
+
category: z.enum([
|
|
766
|
+
"bug",
|
|
767
|
+
"smell",
|
|
768
|
+
"dead-code",
|
|
769
|
+
"duplication",
|
|
770
|
+
"security",
|
|
771
|
+
"secret",
|
|
772
|
+
"vuln-dep"
|
|
773
|
+
]),
|
|
774
|
+
severity: z.enum([
|
|
775
|
+
"error",
|
|
776
|
+
"warning",
|
|
777
|
+
"info"
|
|
778
|
+
]),
|
|
779
|
+
file: z.string(),
|
|
780
|
+
range: RangeSchema,
|
|
781
|
+
message: z.string(),
|
|
782
|
+
helpUri: z.string().optional(),
|
|
783
|
+
flowPath: z.array(z.object({
|
|
784
|
+
file: z.string(),
|
|
785
|
+
line: z.number(),
|
|
786
|
+
range: RangeSchema.optional()
|
|
787
|
+
})).optional(),
|
|
788
|
+
remediation: z.string().optional(),
|
|
789
|
+
autofixable: z.boolean().optional(),
|
|
790
|
+
repairStrategy: z.enum(REPAIR_STRATEGIES).optional(),
|
|
791
|
+
repairStrategyReason: z.string().optional(),
|
|
792
|
+
track: z.enum([
|
|
793
|
+
"ai-fix",
|
|
794
|
+
"deterministic",
|
|
795
|
+
"report-only"
|
|
796
|
+
]),
|
|
797
|
+
status: z.enum([
|
|
798
|
+
"pending",
|
|
799
|
+
"fixing",
|
|
800
|
+
"fixed",
|
|
801
|
+
"reverted",
|
|
802
|
+
"unfixable",
|
|
803
|
+
"skipped"
|
|
804
|
+
]),
|
|
805
|
+
attempts: z.number(),
|
|
806
|
+
revertReason: z.enum([
|
|
807
|
+
"broke-test",
|
|
808
|
+
"suppression",
|
|
809
|
+
"regression",
|
|
810
|
+
"typecheck",
|
|
811
|
+
"session-error",
|
|
812
|
+
"needs-lockfile-update"
|
|
813
|
+
]).optional(),
|
|
814
|
+
revertDetail: z.string().optional(),
|
|
815
|
+
finalFailureClass: z.enum(FAILURE_CLASSES).optional(),
|
|
816
|
+
firstSeenLoop: z.number(),
|
|
817
|
+
lastSeenLoop: z.number(),
|
|
818
|
+
inScope: z.boolean().optional(),
|
|
819
|
+
inReportScope: z.boolean().default(true),
|
|
820
|
+
inFixScope: z.boolean().default(true),
|
|
821
|
+
scopeExclusionReason: z.enum(SCOPE_EXCLUSION_REASONS).optional()
|
|
822
|
+
});
|
|
823
|
+
/**
|
|
824
|
+
* Stable identity for a finding: hash(tool | rule | file | line | message).
|
|
825
|
+
* Same components → same fingerprint, across loops and runs.
|
|
826
|
+
*/
|
|
827
|
+
function fingerprint(input) {
|
|
828
|
+
const key = [
|
|
829
|
+
input.tool,
|
|
830
|
+
input.rule,
|
|
831
|
+
input.file,
|
|
832
|
+
input.line,
|
|
833
|
+
input.message
|
|
834
|
+
].join("|");
|
|
835
|
+
return createHash("sha256").update(key).digest("hex");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region src/findings/normalize.ts
|
|
840
|
+
const TRACK_BY_TOOL = {
|
|
841
|
+
sonarjs: "ai-fix",
|
|
842
|
+
knip: "ai-fix",
|
|
843
|
+
jscpd: "ai-fix",
|
|
844
|
+
semgrep: "ai-fix",
|
|
845
|
+
osv: "deterministic",
|
|
846
|
+
gitleaks: "report-only"
|
|
847
|
+
};
|
|
848
|
+
/** Which track a tool's findings flow into. */
|
|
849
|
+
function trackForTool(tool) {
|
|
850
|
+
return TRACK_BY_TOOL[tool];
|
|
851
|
+
}
|
|
852
|
+
/** Turn a raw scanner record into a tracked `Finding` for the given loop. */
|
|
853
|
+
function normalize(raw, loop) {
|
|
854
|
+
const id = fingerprint({
|
|
855
|
+
tool: raw.tool,
|
|
856
|
+
rule: raw.rule,
|
|
857
|
+
file: raw.file,
|
|
858
|
+
line: raw.range.startLine,
|
|
859
|
+
message: raw.message
|
|
860
|
+
});
|
|
861
|
+
const finding = {
|
|
862
|
+
id,
|
|
863
|
+
tool: raw.tool,
|
|
864
|
+
rule: raw.rule,
|
|
865
|
+
category: raw.category,
|
|
866
|
+
severity: raw.severity,
|
|
867
|
+
file: raw.file,
|
|
868
|
+
range: raw.range,
|
|
869
|
+
message: raw.message,
|
|
870
|
+
track: trackForTool(raw.tool),
|
|
871
|
+
status: "pending",
|
|
872
|
+
attempts: 0,
|
|
873
|
+
firstSeenLoop: loop,
|
|
874
|
+
lastSeenLoop: loop,
|
|
875
|
+
inReportScope: true,
|
|
876
|
+
inFixScope: true
|
|
877
|
+
};
|
|
878
|
+
if (raw.helpUri !== void 0) finding.helpUri = raw.helpUri;
|
|
879
|
+
if (raw.flowPath !== void 0) finding.flowPath = raw.flowPath;
|
|
880
|
+
if (raw.remediation !== void 0) finding.remediation = raw.remediation;
|
|
881
|
+
if (raw.autofixable !== void 0) finding.autofixable = raw.autofixable;
|
|
882
|
+
return finding;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/scanners/eslint-default-config.ts
|
|
887
|
+
/** Walk up from this module to tend's own package root (dir of its package.json named tend-cli). */
|
|
888
|
+
function tendPackageRoot() {
|
|
889
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
890
|
+
for (let i = 0; i < 8; i++) {
|
|
891
|
+
const pkgJson = join(dir, "package.json");
|
|
892
|
+
if (existsSync(pkgJson)) try {
|
|
893
|
+
if (JSON.parse(readFileSync(pkgJson, "utf8")).name === "tend-cli") return dir;
|
|
894
|
+
} catch {}
|
|
895
|
+
const parent = dirname(dir);
|
|
896
|
+
if (parent === dir) break;
|
|
897
|
+
dir = parent;
|
|
898
|
+
}
|
|
899
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
900
|
+
}
|
|
901
|
+
/** Absolute path to tend's bundled default config (eslint recommended + sonarjs). */
|
|
902
|
+
function defaultEslintConfigPath() {
|
|
903
|
+
return join(tendPackageRoot(), "configs", "default.eslint.config.mjs");
|
|
904
|
+
}
|
|
905
|
+
const ESLINT_CONFIG_FILES = [
|
|
906
|
+
"eslint.config.js",
|
|
907
|
+
"eslint.config.mjs",
|
|
908
|
+
"eslint.config.cjs",
|
|
909
|
+
"eslint.config.ts",
|
|
910
|
+
"eslint.config.mts",
|
|
911
|
+
"eslint.config.cts",
|
|
912
|
+
".eslintrc.js",
|
|
913
|
+
".eslintrc.cjs",
|
|
914
|
+
".eslintrc.yaml",
|
|
915
|
+
".eslintrc.yml",
|
|
916
|
+
".eslintrc.json",
|
|
917
|
+
".eslintrc"
|
|
918
|
+
];
|
|
919
|
+
function readPackageJson(cwd) {
|
|
920
|
+
const p = join(cwd, "package.json");
|
|
921
|
+
if (!existsSync(p)) return null;
|
|
922
|
+
try {
|
|
923
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
924
|
+
} catch {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/** Does the project have any eslint config (a config file, or an `eslintConfig` key in package.json)? */
|
|
929
|
+
function projectHasEslintConfig(cwd) {
|
|
930
|
+
if (ESLINT_CONFIG_FILES.some((name) => existsSync(join(cwd, name)))) return true;
|
|
931
|
+
return Boolean(readPackageJson(cwd)?.["eslintConfig"]);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Nearest directory at or above `startDir`, up to and including `boundaryDir`, that holds an
|
|
935
|
+
* eslint config — or null if none. Lets tend resolve each scoped file's governing config by
|
|
936
|
+
* walking upward from the file, so a monorepo package keeps its own config even when tend is
|
|
937
|
+
* invoked from the repo root (where there may be no config at all).
|
|
938
|
+
*/
|
|
939
|
+
function findEslintConfigDir(startDir, boundaryDir) {
|
|
940
|
+
const boundary = resolve(boundaryDir);
|
|
941
|
+
let dir = resolve(startDir);
|
|
942
|
+
for (;;) {
|
|
943
|
+
if (projectHasEslintConfig(dir)) return dir;
|
|
944
|
+
if (dir === boundary) return null;
|
|
945
|
+
const parent = dirname(dir);
|
|
946
|
+
if (parent === dir) return null;
|
|
947
|
+
dir = parent;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
function dependsOnSonarjs(cwd) {
|
|
951
|
+
const pkg = readPackageJson(cwd);
|
|
952
|
+
if (!pkg) return false;
|
|
953
|
+
for (const field of [
|
|
954
|
+
"dependencies",
|
|
955
|
+
"devDependencies",
|
|
956
|
+
"peerDependencies",
|
|
957
|
+
"optionalDependencies"
|
|
958
|
+
]) {
|
|
959
|
+
const deps = pkg[field];
|
|
960
|
+
if (deps?.["eslint-plugin-sonarjs"]) return true;
|
|
961
|
+
}
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
function configMentionsSonarjs(cwd) {
|
|
965
|
+
for (const name of ESLINT_CONFIG_FILES) {
|
|
966
|
+
const p = join(cwd, name);
|
|
967
|
+
if (existsSync(p)) try {
|
|
968
|
+
if (readFileSync(p, "utf8").includes("sonarjs")) return true;
|
|
969
|
+
} catch {}
|
|
970
|
+
}
|
|
971
|
+
const eslintConfig = readPackageJson(cwd)?.["eslintConfig"];
|
|
972
|
+
return eslintConfig ? JSON.stringify(eslintConfig).includes("sonarjs") : false;
|
|
973
|
+
}
|
|
974
|
+
/** Project configures sonarjs = plugin is a dependency AND a config references it. */
|
|
975
|
+
function projectConfiguresSonarjs(cwd) {
|
|
976
|
+
return dependsOnSonarjs(cwd) && configMentionsSonarjs(cwd);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* How tend should run eslint+sonarjs for a project:
|
|
980
|
+
* - `default` — no project eslint config → use tend's config (eslint recommended + sonarjs)
|
|
981
|
+
* - `layer` — project eslint config without sonarjs → use theirs + sonarjs layered on top
|
|
982
|
+
* - `defer` — project eslint config already includes sonarjs → use theirs untouched
|
|
983
|
+
*/
|
|
984
|
+
function eslintMode(cwd) {
|
|
985
|
+
if (!projectHasEslintConfig(cwd)) return "default";
|
|
986
|
+
return projectConfiguresSonarjs(cwd) ? "defer" : "layer";
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
//#endregion
|
|
990
|
+
//#region src/scanners/paths.ts
|
|
991
|
+
/** Make a scanner-reported path repo-relative (POSIX separators); pass relatives through. */
|
|
992
|
+
function toRepoRelative(cwd, file) {
|
|
993
|
+
const rel = isAbsolute(file) ? relative(cwd, file) : file;
|
|
994
|
+
return rel.split("\\").join("/");
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
//#endregion
|
|
998
|
+
//#region src/scanners/eslint-sonarjs.ts
|
|
999
|
+
/** Map ESLint results (CLI JSON or Node-API LintResult[]) into tend's RawFindings. */
|
|
1000
|
+
function mapEslintResults(results, ctx) {
|
|
1001
|
+
const findings = [];
|
|
1002
|
+
for (const result$1 of results) {
|
|
1003
|
+
const file = toRepoRelative(ctx.cwd, result$1.filePath);
|
|
1004
|
+
for (const msg of result$1.messages) {
|
|
1005
|
+
if (msg.ruleId === null) continue;
|
|
1006
|
+
findings.push({
|
|
1007
|
+
tool: "sonarjs",
|
|
1008
|
+
rule: msg.ruleId,
|
|
1009
|
+
category: "smell",
|
|
1010
|
+
severity: msg.severity === 2 ? "error" : "warning",
|
|
1011
|
+
file,
|
|
1012
|
+
range: {
|
|
1013
|
+
startLine: msg.line,
|
|
1014
|
+
startCol: msg.column,
|
|
1015
|
+
endLine: msg.endLine ?? msg.line,
|
|
1016
|
+
endCol: msg.endColumn ?? msg.column
|
|
1017
|
+
},
|
|
1018
|
+
message: msg.message,
|
|
1019
|
+
autofixable: msg.fix !== void 0
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return findings;
|
|
1024
|
+
}
|
|
1025
|
+
function relativeLintTarget(from, to) {
|
|
1026
|
+
return relative(from, to) || ".";
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Group scoped files by their governing eslint config. Each file's config is resolved by walking
|
|
1030
|
+
* up from the file's directory (bounded by ctx.cwd) — NOT from ctx.cwd alone — so files in a
|
|
1031
|
+
* monorepo package use that package's config even when tend runs from the repo root.
|
|
1032
|
+
*/
|
|
1033
|
+
function groupByConfig(ctx) {
|
|
1034
|
+
const boundary = resolve(ctx.cwd);
|
|
1035
|
+
const byDir = new Map();
|
|
1036
|
+
for (const file of ctx.files) {
|
|
1037
|
+
const abs = resolve(ctx.cwd, file);
|
|
1038
|
+
const configDir = findEslintConfigDir(dirname(abs), boundary);
|
|
1039
|
+
const key = configDir ?? "";
|
|
1040
|
+
(byDir.get(key) ?? byDir.set(key, []).get(key)).push(abs);
|
|
1041
|
+
}
|
|
1042
|
+
return [...byDir.entries()].map(([key, absFiles]) => {
|
|
1043
|
+
if (key === "") return {
|
|
1044
|
+
configDir: null,
|
|
1045
|
+
mode: "default",
|
|
1046
|
+
cwd: ctx.cwd,
|
|
1047
|
+
targets: absFiles.map((f) => relativeLintTarget(ctx.cwd, f))
|
|
1048
|
+
};
|
|
1049
|
+
return {
|
|
1050
|
+
configDir: key,
|
|
1051
|
+
mode: projectConfiguresSonarjs(key) ? "defer" : "layer",
|
|
1052
|
+
cwd: key,
|
|
1053
|
+
targets: absFiles.map((f) => relativeLintTarget(key, f))
|
|
1054
|
+
};
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
/** Lint one group through the Node API; ESLint returns absolute filePaths regardless of cwd. */
|
|
1058
|
+
function eslintOptionsForGroup(group) {
|
|
1059
|
+
const options = {
|
|
1060
|
+
cwd: group.cwd,
|
|
1061
|
+
errorOnUnmatchedPattern: false
|
|
1062
|
+
};
|
|
1063
|
+
if (group.mode === "default") options.overrideConfigFile = defaultEslintConfigPath();
|
|
1064
|
+
else if (group.mode === "layer") options.overrideConfig = [sonarjs.configs.recommended];
|
|
1065
|
+
return options;
|
|
1066
|
+
}
|
|
1067
|
+
async function lintGroup(group) {
|
|
1068
|
+
const eslint = new ESLint(eslintOptionsForGroup(group));
|
|
1069
|
+
return await eslint.lintFiles(group.targets);
|
|
1070
|
+
}
|
|
1071
|
+
function messageMatchesFinding(message, finding) {
|
|
1072
|
+
return message.ruleId === finding.rule && message.line === finding.range.startLine;
|
|
1073
|
+
}
|
|
1074
|
+
async function fixGroup(group, findings) {
|
|
1075
|
+
const options = {
|
|
1076
|
+
...eslintOptionsForGroup(group),
|
|
1077
|
+
fix: (message) => findings.some((finding) => messageMatchesFinding(message, finding)),
|
|
1078
|
+
fixTypes: [
|
|
1079
|
+
"problem",
|
|
1080
|
+
"suggestion",
|
|
1081
|
+
"layout"
|
|
1082
|
+
]
|
|
1083
|
+
};
|
|
1084
|
+
const eslint = new ESLint(options);
|
|
1085
|
+
return await eslint.lintFiles(group.targets);
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Apply ESLint's own autofixes for the exact findings Tend has assigned to the current unit.
|
|
1089
|
+
* Each file is linted separately so the fix predicate's rule/line match is scoped to that file.
|
|
1090
|
+
*/
|
|
1091
|
+
async function applyEslintFixesForFindings(ctx, findings) {
|
|
1092
|
+
const files = [...new Set(findings.map((finding) => finding.file))];
|
|
1093
|
+
if (files.length === 0) return { changed: false };
|
|
1094
|
+
try {
|
|
1095
|
+
const results = [];
|
|
1096
|
+
for (const file of files) {
|
|
1097
|
+
const fileFindings = findings.filter((finding) => finding.file === file);
|
|
1098
|
+
for (const group of groupByConfig({
|
|
1099
|
+
...ctx,
|
|
1100
|
+
files: [file]
|
|
1101
|
+
})) results.push(...await fixGroup(group, fileFindings));
|
|
1102
|
+
}
|
|
1103
|
+
await ESLint.outputFixes(results);
|
|
1104
|
+
return { changed: results.some((result$1) => "output" in result$1) };
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
return {
|
|
1107
|
+
changed: false,
|
|
1108
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Run eslint+sonarjs via the Node API (eslint is bundled). Resolves the applicable config PER
|
|
1114
|
+
* FILE and runs one pass per config group, so monorepo packages are linted under their own
|
|
1115
|
+
* config. Three modes per group:
|
|
1116
|
+
* default → tend's config · layer → project config + sonarjs · defer → project config.
|
|
1117
|
+
* Output paths stay relative to the original ctx.cwd so finding IDs/filtering are unaffected.
|
|
1118
|
+
*/
|
|
1119
|
+
async function runEslintSonarjs(ctx) {
|
|
1120
|
+
const groups = ctx.files.length === 0 || ctx.files.includes(".") ? [{
|
|
1121
|
+
configDir: null,
|
|
1122
|
+
mode: eslintMode(ctx.cwd),
|
|
1123
|
+
cwd: ctx.cwd,
|
|
1124
|
+
targets: ["."]
|
|
1125
|
+
}] : groupByConfig(ctx);
|
|
1126
|
+
try {
|
|
1127
|
+
const results = [];
|
|
1128
|
+
for (const group of groups) results.push(...await lintGroup(group));
|
|
1129
|
+
const findings = mapEslintResults(results, ctx).map((r) => normalize(r, ctx.loop));
|
|
1130
|
+
return {
|
|
1131
|
+
tool: "sonarjs",
|
|
1132
|
+
findings,
|
|
1133
|
+
skipped: false
|
|
1134
|
+
};
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
return {
|
|
1137
|
+
tool: "sonarjs",
|
|
1138
|
+
findings: [],
|
|
1139
|
+
skipped: false,
|
|
1140
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
//#endregion
|
|
1146
|
+
//#region src/scanners/scanner.ts
|
|
1147
|
+
/** Collapse a ScanResult to its reportable status: skipped → ran → failed (error present). */
|
|
1148
|
+
function scannerStatus(result$1) {
|
|
1149
|
+
if (result$1.skipped) return {
|
|
1150
|
+
tool: result$1.tool,
|
|
1151
|
+
status: "skipped"
|
|
1152
|
+
};
|
|
1153
|
+
if (result$1.error !== void 0) return {
|
|
1154
|
+
tool: result$1.tool,
|
|
1155
|
+
status: "failed",
|
|
1156
|
+
reason: result$1.error
|
|
1157
|
+
};
|
|
1158
|
+
return {
|
|
1159
|
+
tool: result$1.tool,
|
|
1160
|
+
status: "ran"
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
async function isAvailable(scanner, which) {
|
|
1164
|
+
return which(scanner.binary);
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Shared run sequence for every scanner:
|
|
1168
|
+
* availability → args → spawn → parse → normalize.
|
|
1169
|
+
* Missing binary → skipped (not fatal). Timeout/spawn error or malformed output → error result.
|
|
1170
|
+
*/
|
|
1171
|
+
async function runScanner(scanner, ctx, deps) {
|
|
1172
|
+
if (!await isAvailable(scanner, deps.which)) return {
|
|
1173
|
+
tool: scanner.tool,
|
|
1174
|
+
findings: [],
|
|
1175
|
+
skipped: true
|
|
1176
|
+
};
|
|
1177
|
+
const args = scanner.buildArgs(ctx);
|
|
1178
|
+
let raw;
|
|
1179
|
+
try {
|
|
1180
|
+
raw = await deps.spawn(scanner.binary, args, {
|
|
1181
|
+
cwd: ctx.cwd,
|
|
1182
|
+
timeout: deps.timeout
|
|
1183
|
+
});
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
return {
|
|
1186
|
+
tool: scanner.tool,
|
|
1187
|
+
findings: [],
|
|
1188
|
+
skipped: false,
|
|
1189
|
+
error: errorMessage(err)
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
try {
|
|
1193
|
+
const findings = scanner.parse(raw, ctx).map((r) => normalize(r, ctx.loop));
|
|
1194
|
+
return {
|
|
1195
|
+
tool: scanner.tool,
|
|
1196
|
+
findings,
|
|
1197
|
+
skipped: false
|
|
1198
|
+
};
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
const reason = raw.exitCode !== 0 ? raw.stderr.trim() || errorMessage(err) : errorMessage(err);
|
|
1201
|
+
return {
|
|
1202
|
+
tool: scanner.tool,
|
|
1203
|
+
findings: [],
|
|
1204
|
+
skipped: false,
|
|
1205
|
+
error: reason
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
function errorMessage(err) {
|
|
1210
|
+
return err instanceof Error ? err.message : String(err);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
//#endregion
|
|
1214
|
+
//#region src/git/repo.ts
|
|
1215
|
+
/** Refuse to run outside a git repo — the snapshot/restore safety net needs it. */
|
|
1216
|
+
async function assertGitRepo(git) {
|
|
1217
|
+
if (!await git.checkIsRepo()) throw new Error("not a git repository — run tend inside a git repo");
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* `git status` reports paths relative to the repo root and for the whole repo, even
|
|
1221
|
+
* when run from a subdirectory. Scanners run from `git`'s working dir and report paths
|
|
1222
|
+
* relative to it, so we scope changed files to that subtree and re-base them onto it
|
|
1223
|
+
* using git's own cwd→root prefix (empty when run from the repo root).
|
|
1224
|
+
*/
|
|
1225
|
+
function scopeToCwd(repoPath, prefix) {
|
|
1226
|
+
if (!prefix) return repoPath;
|
|
1227
|
+
if (!repoPath.startsWith(prefix)) return null;
|
|
1228
|
+
return repoPath.slice(prefix.length);
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Files changed vs `HEAD`: tracked modifications/additions/renames plus untracked files,
|
|
1232
|
+
* scoped and re-based to `git`'s working directory (so a run from `apps/foo` only sees
|
|
1233
|
+
* `apps/foo`'s changes, pathed as the scanners path them).
|
|
1234
|
+
*/
|
|
1235
|
+
async function changedVsHead(git) {
|
|
1236
|
+
const prefix = (await git.revparse(["--show-prefix"])).trim();
|
|
1237
|
+
const status = await git.status();
|
|
1238
|
+
const files = new Set();
|
|
1239
|
+
for (const file of status.files) {
|
|
1240
|
+
const repoPath = file.path.includes(" -> ") ? file.path.split(" -> ")[1] : file.path;
|
|
1241
|
+
const rel = scopeToCwd(repoPath, prefix);
|
|
1242
|
+
if (rel !== null) files.add(rel);
|
|
1243
|
+
}
|
|
1244
|
+
return [...files];
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Concrete files under the given path(s) — tracked plus untracked (so newly-added files
|
|
1248
|
+
* are scoped too, mirroring `changedVsHead`). `git ls-files` reports paths relative to
|
|
1249
|
+
* `git`'s working directory and interprets the pathspecs the same way, so the result is
|
|
1250
|
+
* already in the coordinate system the scanners and `filterToChanged` expect. Expanding to
|
|
1251
|
+
* concrete files (not bare directories) matters: `filterToChanged` matches exact paths.
|
|
1252
|
+
*/
|
|
1253
|
+
async function filesUnder(git, paths) {
|
|
1254
|
+
if (paths.length === 0) return [];
|
|
1255
|
+
const list = async (args) => (await git.raw([
|
|
1256
|
+
"ls-files",
|
|
1257
|
+
...args,
|
|
1258
|
+
"--",
|
|
1259
|
+
...paths
|
|
1260
|
+
])).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1261
|
+
const files = new Set([...await list([]), ...await list(["-o", "--exclude-standard"])]);
|
|
1262
|
+
return [...files];
|
|
1263
|
+
}
|
|
1264
|
+
/** Revert a single file to its snapshot state. */
|
|
1265
|
+
function revertFile(snapshot, file) {
|
|
1266
|
+
return snapshot.restoreFile(file);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
//#endregion
|
|
1270
|
+
//#region src/git/client.ts
|
|
1271
|
+
const UNSAFE_GIT_ENV_KEYS = [
|
|
1272
|
+
"EDITOR",
|
|
1273
|
+
"VISUAL",
|
|
1274
|
+
"GIT_EDITOR",
|
|
1275
|
+
"GIT_SEQUENCE_EDITOR",
|
|
1276
|
+
"GIT_PAGER",
|
|
1277
|
+
"PAGER"
|
|
1278
|
+
];
|
|
1279
|
+
function gitEnv(extra = {}) {
|
|
1280
|
+
const env = {
|
|
1281
|
+
...process.env,
|
|
1282
|
+
...extra
|
|
1283
|
+
};
|
|
1284
|
+
for (const key of UNSAFE_GIT_ENV_KEYS) delete env[key];
|
|
1285
|
+
return env;
|
|
1286
|
+
}
|
|
1287
|
+
function createGit(root, extraEnv = {}) {
|
|
1288
|
+
return simpleGit(root).env(gitEnv(extraEnv));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
//#endregion
|
|
1292
|
+
//#region src/git/snapshot.ts
|
|
1293
|
+
const SNAP_MSG = "tend snapshot";
|
|
1294
|
+
/** A private ref pins the snapshot commit so `git gc` can't prune it (it's on no branch). */
|
|
1295
|
+
const SNAP_REF = "refs/tend/snapshot";
|
|
1296
|
+
let indexCounter = 0;
|
|
1297
|
+
/**
|
|
1298
|
+
* Write the entire current working tree (tracked + untracked, honoring .gitignore) into a git
|
|
1299
|
+
* tree object, using a throwaway index so the user's real staging area is never touched.
|
|
1300
|
+
* Returns the tree's object id. Git stores only new blobs and reuses the rest — near-instant,
|
|
1301
|
+
* a few KB, not a full copy of every file.
|
|
1302
|
+
*/
|
|
1303
|
+
async function writeWorkingTree(root) {
|
|
1304
|
+
const idxPath = join(tmpdir(), `tend-index-${process.pid}-${indexCounter++}`);
|
|
1305
|
+
try {
|
|
1306
|
+
const g = createGit(root, { GIT_INDEX_FILE: idxPath });
|
|
1307
|
+
await g.raw(["add", "-A"]);
|
|
1308
|
+
return (await g.raw(["write-tree"])).trim();
|
|
1309
|
+
} finally {
|
|
1310
|
+
rmSync(idxPath, { force: true });
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
/** Keep tend's own `.tend/` artifacts out of snapshots and the user's `git status`. */
|
|
1314
|
+
function ensureTendIgnored(gitDir) {
|
|
1315
|
+
const excludePath = join(gitDir, "info", "exclude");
|
|
1316
|
+
const line = ".tend/";
|
|
1317
|
+
let current = "";
|
|
1318
|
+
try {
|
|
1319
|
+
current = readFileSync(excludePath, "utf8");
|
|
1320
|
+
} catch {}
|
|
1321
|
+
if (current.split("\n").some((l) => l.trim() === line)) return;
|
|
1322
|
+
mkdirSync(dirname(excludePath), { recursive: true });
|
|
1323
|
+
const sep$1 = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
1324
|
+
writeFileSync(excludePath, `${current}${sep$1}${line}\n`);
|
|
1325
|
+
}
|
|
1326
|
+
const lines = (raw) => raw.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1327
|
+
/** Files currently in the repo: tracked + untracked (non-ignored), repo-root-relative. */
|
|
1328
|
+
async function currentFiles(root) {
|
|
1329
|
+
const g = createGit(root);
|
|
1330
|
+
const tracked = await g.raw(["ls-files"]);
|
|
1331
|
+
const untracked = await g.raw([
|
|
1332
|
+
"ls-files",
|
|
1333
|
+
"--others",
|
|
1334
|
+
"--exclude-standard"
|
|
1335
|
+
]);
|
|
1336
|
+
return [...new Set([...lines(tracked), ...lines(untracked)])];
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* A silent restore point for the working tree, stored as a git commit object pinned by a private
|
|
1340
|
+
* ref (`refs/tend/snapshot`) — nothing committed to any branch, the editor sees no change. Backs
|
|
1341
|
+
* `tend undo` (exact restore) and `tend diff` (only the tool's edits). Reuses git's content store,
|
|
1342
|
+
* so the on-disk record is a 40-char id rather than a copy of every file.
|
|
1343
|
+
*/
|
|
1344
|
+
var Snapshot = class Snapshot {
|
|
1345
|
+
constructor(cwd, root, sha) {
|
|
1346
|
+
this.cwd = cwd;
|
|
1347
|
+
this.root = root;
|
|
1348
|
+
this.sha = sha;
|
|
1349
|
+
}
|
|
1350
|
+
static async capture(_git, cwd) {
|
|
1351
|
+
const git = createGit(cwd);
|
|
1352
|
+
const root = (await git.revparse(["--show-toplevel"])).trim();
|
|
1353
|
+
const gitDir = (await git.revparse(["--absolute-git-dir"])).trim();
|
|
1354
|
+
ensureTendIgnored(gitDir);
|
|
1355
|
+
const rg = createGit(root);
|
|
1356
|
+
const tree = await writeWorkingTree(root);
|
|
1357
|
+
let parent = null;
|
|
1358
|
+
try {
|
|
1359
|
+
parent = (await rg.revparse(["HEAD"])).trim();
|
|
1360
|
+
} catch {
|
|
1361
|
+
parent = null;
|
|
1362
|
+
}
|
|
1363
|
+
const commitArgs = parent ? [
|
|
1364
|
+
"commit-tree",
|
|
1365
|
+
tree,
|
|
1366
|
+
"-p",
|
|
1367
|
+
parent,
|
|
1368
|
+
"-m",
|
|
1369
|
+
SNAP_MSG
|
|
1370
|
+
] : [
|
|
1371
|
+
"commit-tree",
|
|
1372
|
+
tree,
|
|
1373
|
+
"-m",
|
|
1374
|
+
SNAP_MSG
|
|
1375
|
+
];
|
|
1376
|
+
const sha = (await rg.raw(commitArgs)).trim();
|
|
1377
|
+
await rg.raw([
|
|
1378
|
+
"update-ref",
|
|
1379
|
+
SNAP_REF,
|
|
1380
|
+
sha
|
|
1381
|
+
]);
|
|
1382
|
+
return new Snapshot(cwd, root, sha);
|
|
1383
|
+
}
|
|
1384
|
+
/** Serialize to a tiny object for `.tend/snapshot.json` (powers `undo` across invocations). */
|
|
1385
|
+
toJSON() {
|
|
1386
|
+
return {
|
|
1387
|
+
cwd: this.cwd,
|
|
1388
|
+
root: this.root,
|
|
1389
|
+
sha: this.sha
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
static fromJSON(data) {
|
|
1393
|
+
return new Snapshot(data.cwd, data.root, data.sha);
|
|
1394
|
+
}
|
|
1395
|
+
/** Files whose contents differ from the snapshot, or that are new/deleted since it (sorted). */
|
|
1396
|
+
async changedSince(_git) {
|
|
1397
|
+
const currentTree = await writeWorkingTree(this.root);
|
|
1398
|
+
const diff = await createGit(this.root).raw([
|
|
1399
|
+
"diff",
|
|
1400
|
+
"--name-only",
|
|
1401
|
+
this.sha,
|
|
1402
|
+
currentTree
|
|
1403
|
+
]);
|
|
1404
|
+
return lines(diff).sort();
|
|
1405
|
+
}
|
|
1406
|
+
/** Restore a single file to its captured contents (worktree only — the user's index is untouched). */
|
|
1407
|
+
async restoreFile(rel) {
|
|
1408
|
+
await createGit(this.root).raw([
|
|
1409
|
+
"restore",
|
|
1410
|
+
"--source",
|
|
1411
|
+
this.sha,
|
|
1412
|
+
"--worktree",
|
|
1413
|
+
"--",
|
|
1414
|
+
rel
|
|
1415
|
+
]);
|
|
1416
|
+
}
|
|
1417
|
+
/** Restore the working tree exactly to the captured state (incl. deleting files created since). */
|
|
1418
|
+
async restore(_git) {
|
|
1419
|
+
const rg = createGit(this.root);
|
|
1420
|
+
await rg.raw([
|
|
1421
|
+
"restore",
|
|
1422
|
+
"--source",
|
|
1423
|
+
this.sha,
|
|
1424
|
+
"--worktree",
|
|
1425
|
+
"--",
|
|
1426
|
+
":/"
|
|
1427
|
+
]);
|
|
1428
|
+
const inSnapshot = new Set(lines(await rg.raw([
|
|
1429
|
+
"ls-tree",
|
|
1430
|
+
"-r",
|
|
1431
|
+
"--name-only",
|
|
1432
|
+
this.sha
|
|
1433
|
+
])));
|
|
1434
|
+
for (const rel of await currentFiles(this.root)) if (!inSnapshot.has(rel)) rmSync(join(this.root, rel), { force: true });
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
//#endregion
|
|
1439
|
+
//#region src/detect/package-manager.ts
|
|
1440
|
+
const LOCKFILES$1 = [
|
|
1441
|
+
{
|
|
1442
|
+
file: "pnpm-lock.yaml",
|
|
1443
|
+
pm: "pnpm"
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
file: "yarn.lock",
|
|
1447
|
+
pm: "yarn"
|
|
1448
|
+
},
|
|
1449
|
+
{
|
|
1450
|
+
file: "bun.lockb",
|
|
1451
|
+
pm: "bun"
|
|
1452
|
+
},
|
|
1453
|
+
{
|
|
1454
|
+
file: "bun.lock",
|
|
1455
|
+
pm: "bun"
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
file: "package-lock.json",
|
|
1459
|
+
pm: "npm"
|
|
1460
|
+
}
|
|
1461
|
+
];
|
|
1462
|
+
/** Detect the package manager from the lockfile present; defaults to npm. */
|
|
1463
|
+
function detectPackageManager(cwd) {
|
|
1464
|
+
for (const { file, pm } of LOCKFILES$1) if (existsSync(join(cwd, file))) return pm;
|
|
1465
|
+
return "npm";
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
//#endregion
|
|
1469
|
+
//#region src/session/types.ts
|
|
1470
|
+
/** A usage record with everything zeroed. */
|
|
1471
|
+
const zeroUsage = () => ({
|
|
1472
|
+
estimatedCostUsd: 0,
|
|
1473
|
+
inputTokens: 0,
|
|
1474
|
+
outputTokens: 0,
|
|
1475
|
+
cacheCreationInputTokens: 0,
|
|
1476
|
+
cacheReadInputTokens: 0,
|
|
1477
|
+
sessions: 0
|
|
1478
|
+
});
|
|
1479
|
+
/** Sum two usage records field-by-field (used to roll usage up through the run). */
|
|
1480
|
+
function addUsage(a, b) {
|
|
1481
|
+
return {
|
|
1482
|
+
estimatedCostUsd: a.estimatedCostUsd + b.estimatedCostUsd,
|
|
1483
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
1484
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
1485
|
+
cacheCreationInputTokens: a.cacheCreationInputTokens + b.cacheCreationInputTokens,
|
|
1486
|
+
cacheReadInputTokens: a.cacheReadInputTokens + b.cacheReadInputTokens,
|
|
1487
|
+
sessions: a.sessions + b.sessions
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
//#endregion
|
|
1492
|
+
//#region src/gate/check.ts
|
|
1493
|
+
const pass = () => ({ ok: true });
|
|
1494
|
+
const reject = (reason, detail) => ({
|
|
1495
|
+
ok: false,
|
|
1496
|
+
reason,
|
|
1497
|
+
detail
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
//#endregion
|
|
1501
|
+
//#region src/gate/checks/anti-regression.ts
|
|
1502
|
+
/**
|
|
1503
|
+
* Reject if the fix introduced any finding that wasn't present before — no lateral
|
|
1504
|
+
* moves. A fix must strictly reduce findings; trading one issue for another is what
|
|
1505
|
+
* would let the loop oscillate instead of converge.
|
|
1506
|
+
*/
|
|
1507
|
+
function antiRegression(before, after, opts = {}) {
|
|
1508
|
+
const knownIds = new Set(before.map((f) => f.id));
|
|
1509
|
+
if (opts.requireResolved) {
|
|
1510
|
+
const unresolved = after.filter((f) => knownIds.has(f.id));
|
|
1511
|
+
if (unresolved.length > 0) {
|
|
1512
|
+
const detail = unresolved.map((f) => `${f.file}:${f.range.startLine} ${f.rule}`).join(", ");
|
|
1513
|
+
return reject("regression", `Fix did not clear target finding(s): ${detail}`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const introduced = after.filter((f) => !knownIds.has(f.id));
|
|
1517
|
+
if (introduced.length > 0) {
|
|
1518
|
+
const detail = introduced.map((f) => `${f.file}:${f.range.startLine} ${f.rule}`).join(", ");
|
|
1519
|
+
return reject("regression", `Fix introduced new finding(s): ${detail}`);
|
|
1520
|
+
}
|
|
1521
|
+
return pass();
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
//#endregion
|
|
1525
|
+
//#region src/gate/checks/anti-suppression.ts
|
|
1526
|
+
const SUPPRESSION_PATTERNS = [
|
|
1527
|
+
{
|
|
1528
|
+
re: /eslint-disable/,
|
|
1529
|
+
what: "eslint-disable"
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
re: /@ts-ignore/,
|
|
1533
|
+
what: "@ts-ignore"
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
re: /@ts-nocheck/,
|
|
1537
|
+
what: "@ts-nocheck"
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
re: /\bas\s+any\b/,
|
|
1541
|
+
what: "cast to any"
|
|
1542
|
+
},
|
|
1543
|
+
{
|
|
1544
|
+
re: /:\s*any\b/,
|
|
1545
|
+
what: "any type annotation"
|
|
1546
|
+
},
|
|
1547
|
+
{
|
|
1548
|
+
re: /<any>/,
|
|
1549
|
+
what: "cast to any"
|
|
1550
|
+
}
|
|
1551
|
+
];
|
|
1552
|
+
function splitDiff(diff) {
|
|
1553
|
+
const added = [];
|
|
1554
|
+
const removed = [];
|
|
1555
|
+
for (const line of diff.split("\n")) {
|
|
1556
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
1557
|
+
if (line.startsWith("+")) added.push(line.slice(1));
|
|
1558
|
+
else if (line.startsWith("-")) removed.push(line.slice(1));
|
|
1559
|
+
}
|
|
1560
|
+
return {
|
|
1561
|
+
added,
|
|
1562
|
+
removed
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
const nonBlank = (lines$1) => lines$1.filter((l) => l.trim().length > 0);
|
|
1566
|
+
/**
|
|
1567
|
+
* Reject a change-set that cheats the scanner rather than fixing the code:
|
|
1568
|
+
* newly-added suppression comments / any-casts, or code deleted instead of fixed.
|
|
1569
|
+
* Only NEW (added) lines are inspected — pre-existing suppressions in context are ignored.
|
|
1570
|
+
*/
|
|
1571
|
+
function antiSuppression(diff, options = {}) {
|
|
1572
|
+
const { added, removed } = splitDiff(diff);
|
|
1573
|
+
for (const line of added) for (const { re, what } of SUPPRESSION_PATTERNS) if (re.test(line)) return reject("suppression", `Fix added ${what}`);
|
|
1574
|
+
if (!options.allowDeleteOnly && nonBlank(removed).length > 0 && nonBlank(added).length === 0) return reject("suppression", "Code was deleted instead of fixed");
|
|
1575
|
+
return pass();
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
//#endregion
|
|
1579
|
+
//#region src/gate/checks/typecheck.ts
|
|
1580
|
+
/** Reject a fix that breaks `tsc --noEmit`. Skipped (pass) when there's no tsconfig. */
|
|
1581
|
+
async function typecheck(deps) {
|
|
1582
|
+
if (!await deps.hasTsconfig()) return pass();
|
|
1583
|
+
const { exitCode, output } = await deps.runTsc();
|
|
1584
|
+
if (exitCode === 0) return pass();
|
|
1585
|
+
return reject("typecheck", output.trim() || "tsc --noEmit failed");
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
//#endregion
|
|
1589
|
+
//#region src/gate/checks/tests.ts
|
|
1590
|
+
/** Baseline-green tests that are red now. */
|
|
1591
|
+
function regressions(baseline, outcomes) {
|
|
1592
|
+
return outcomes.filter((o) => o.status === "fail" && baseline.has(o.name));
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Apply→test→repair flow. A red previously-green test opens a bounded repair window
|
|
1596
|
+
* rather than an instant revert; exhausting it without going green is a reject.
|
|
1597
|
+
*/
|
|
1598
|
+
async function runTestPhase(deps) {
|
|
1599
|
+
if (deps.hasTestRunner === false) return {
|
|
1600
|
+
ok: true,
|
|
1601
|
+
warning: "No test suite detected — behavior can't be verified"
|
|
1602
|
+
};
|
|
1603
|
+
let regressed = regressions(deps.baseline, await deps.runRelated());
|
|
1604
|
+
if (regressed.length === 0) return pass();
|
|
1605
|
+
for (let attempt = 1; attempt <= deps.maxRepairs; attempt++) {
|
|
1606
|
+
await deps.repair(attempt, regressed);
|
|
1607
|
+
regressed = regressions(deps.baseline, await deps.runRelated());
|
|
1608
|
+
if (regressed.length === 0) return pass();
|
|
1609
|
+
}
|
|
1610
|
+
const names = regressed.map((o) => o.name).join(", ");
|
|
1611
|
+
return reject("broke-test", `Fix left previously-green test(s) red: ${names}`);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
//#endregion
|
|
1615
|
+
//#region src/fixing/unit-gate.ts
|
|
1616
|
+
/** A file's current contents, or null if it doesn't exist. */
|
|
1617
|
+
const snapshotFile = (abs) => existsSync(abs) ? readFileSync(abs, "utf8") : null;
|
|
1618
|
+
function snapshotUnitFiles(cwd, files) {
|
|
1619
|
+
return new Map(files.map((f) => [f, snapshotFile(join(cwd, f))]));
|
|
1620
|
+
}
|
|
1621
|
+
function restoreSnapshot(cwd, before) {
|
|
1622
|
+
for (const [f, original] of before) {
|
|
1623
|
+
const p = join(cwd, f);
|
|
1624
|
+
if (original === null) {
|
|
1625
|
+
if (existsSync(p)) rmSync(p, { force: true });
|
|
1626
|
+
} else writeFileSync(p, original);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
function snapshotUnitNow(cwd, files) {
|
|
1630
|
+
return snapshotUnitFiles(cwd, files);
|
|
1631
|
+
}
|
|
1632
|
+
function unitChanged(cwd, files, before) {
|
|
1633
|
+
return files.some((f) => snapshotFile(join(cwd, f)) !== before.get(f));
|
|
1634
|
+
}
|
|
1635
|
+
/** Build a minimal unified diff from captured before/after contents. */
|
|
1636
|
+
function buildDiff(before, after) {
|
|
1637
|
+
const out = [];
|
|
1638
|
+
for (const [path, afterContent] of after) {
|
|
1639
|
+
const beforeLines = (before.get(path) ?? "").split("\n");
|
|
1640
|
+
const afterLines = (afterContent ?? "").split("\n");
|
|
1641
|
+
for (const l of beforeLines) if (!afterLines.includes(l)) out.push(`-${l}`);
|
|
1642
|
+
for (const l of afterLines) if (!beforeLines.includes(l)) out.push(`+${l}`);
|
|
1643
|
+
}
|
|
1644
|
+
return out.join("\n");
|
|
1645
|
+
}
|
|
1646
|
+
function isDeadCodeFinding(finding) {
|
|
1647
|
+
return finding.category === "dead-code" || finding.tool === "knip" && finding.rule.startsWith("unused-");
|
|
1648
|
+
}
|
|
1649
|
+
function allowsDeleteOnly(unit) {
|
|
1650
|
+
return unit.findings.length > 0 && unit.findings.every(isDeadCodeFinding);
|
|
1651
|
+
}
|
|
1652
|
+
async function gateUnitChanges(unit, before, deps, opts = {}) {
|
|
1653
|
+
const usage = opts.usage ?? zeroUsage();
|
|
1654
|
+
const after = snapshotUnitNow(deps.cwd, unit.files);
|
|
1655
|
+
const supp = antiSuppression(buildDiff(before, after), { allowDeleteOnly: allowsDeleteOnly(unit) });
|
|
1656
|
+
if (!supp.ok) return {
|
|
1657
|
+
kept: false,
|
|
1658
|
+
reason: supp.reason,
|
|
1659
|
+
detail: supp.detail,
|
|
1660
|
+
usage
|
|
1661
|
+
};
|
|
1662
|
+
const tc = await typecheck({
|
|
1663
|
+
hasTsconfig: () => deps.typescript,
|
|
1664
|
+
runTsc: deps.runTsc
|
|
1665
|
+
});
|
|
1666
|
+
if (!tc.ok) return {
|
|
1667
|
+
kept: false,
|
|
1668
|
+
reason: tc.reason,
|
|
1669
|
+
detail: tc.detail,
|
|
1670
|
+
usage
|
|
1671
|
+
};
|
|
1672
|
+
if (unit.strategy === "generated-source-repair" && deps.runBuild) {
|
|
1673
|
+
const build = await deps.runBuild();
|
|
1674
|
+
if (build.exitCode !== 0) return {
|
|
1675
|
+
kept: false,
|
|
1676
|
+
reason: "typecheck",
|
|
1677
|
+
detail: `Build failed while regenerating generated artifact.\n${build.output}`.trim(),
|
|
1678
|
+
usage
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
const phase = await runTestPhase({
|
|
1682
|
+
baseline: deps.baseline,
|
|
1683
|
+
runRelated: () => deps.runRelated(unit.files),
|
|
1684
|
+
repair: opts.repair ?? (async () => {}),
|
|
1685
|
+
maxRepairs: opts.maxRepairs ?? 0,
|
|
1686
|
+
hasTestRunner: deps.hasTestRunner
|
|
1687
|
+
});
|
|
1688
|
+
if (!phase.ok) return {
|
|
1689
|
+
kept: false,
|
|
1690
|
+
reason: phase.reason,
|
|
1691
|
+
detail: opts.repairFailureDetail?.() ?? phase.detail,
|
|
1692
|
+
usage
|
|
1693
|
+
};
|
|
1694
|
+
const verificationTargets = unit.verificationTargets ?? unit.files;
|
|
1695
|
+
const afterFindings = await deps.scanFindings(verificationTargets);
|
|
1696
|
+
const regression = antiRegression(unit.findings, afterFindings, { requireResolved: opts.requireResolved || unit.strategy === "multi-file-duplicate-refactor" });
|
|
1697
|
+
if (!regression.ok) return {
|
|
1698
|
+
kept: false,
|
|
1699
|
+
reason: regression.reason,
|
|
1700
|
+
detail: regression.detail,
|
|
1701
|
+
usage
|
|
1702
|
+
};
|
|
1703
|
+
return {
|
|
1704
|
+
kept: true,
|
|
1705
|
+
usage
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
//#endregion
|
|
1710
|
+
//#region src/fixing/deterministic.ts
|
|
1711
|
+
const ZERO_AI_USAGE$2 = zeroUsage();
|
|
1712
|
+
const LOCKFILES = [
|
|
1713
|
+
"pnpm-lock.yaml",
|
|
1714
|
+
"package-lock.json",
|
|
1715
|
+
"yarn.lock",
|
|
1716
|
+
"bun.lockb"
|
|
1717
|
+
];
|
|
1718
|
+
function strategiesFor(unit) {
|
|
1719
|
+
return (unit.strategies ?? (unit.strategy ? [unit.strategy] : [])).filter((strategy) => strategy.startsWith("deterministic-"));
|
|
1720
|
+
}
|
|
1721
|
+
function packageNameFromFindingMessage(message) {
|
|
1722
|
+
const match = message.match(/:\s*(@?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?)(?:\s|$)/);
|
|
1723
|
+
return match?.[1];
|
|
1724
|
+
}
|
|
1725
|
+
function hasLockfile(cwd) {
|
|
1726
|
+
return LOCKFILES.some((file) => existsSync(join(cwd, file)));
|
|
1727
|
+
}
|
|
1728
|
+
function applyPackageJsonCleanup(cwd, unit) {
|
|
1729
|
+
const packageFiles = [...new Set(unit.findings.map((finding) => finding.file).filter((file) => /(^|\/)package\.json$/.test(file)))];
|
|
1730
|
+
for (const file of packageFiles) {
|
|
1731
|
+
const abs = join(cwd, file);
|
|
1732
|
+
const json = JSON.parse(readFileSync(abs, "utf8"));
|
|
1733
|
+
let removed = false;
|
|
1734
|
+
for (const finding of unit.findings.filter((f) => f.file === file)) {
|
|
1735
|
+
const name = packageNameFromFindingMessage(finding.message);
|
|
1736
|
+
if (!name) return {
|
|
1737
|
+
ok: false,
|
|
1738
|
+
reason: "session-error",
|
|
1739
|
+
detail: `Could not parse unused dependency name from finding: ${finding.message}`
|
|
1740
|
+
};
|
|
1741
|
+
if (json.dependencies?.[name] !== void 0) {
|
|
1742
|
+
delete json.dependencies[name];
|
|
1743
|
+
removed = true;
|
|
1744
|
+
} else if (json.devDependencies?.[name] !== void 0) {
|
|
1745
|
+
delete json.devDependencies[name];
|
|
1746
|
+
removed = true;
|
|
1747
|
+
} else return {
|
|
1748
|
+
ok: false,
|
|
1749
|
+
reason: "session-error",
|
|
1750
|
+
detail: `Dependency "${name}" was not found in dependencies or devDependencies`
|
|
1751
|
+
};
|
|
1752
|
+
if (json.dependencies && Object.keys(json.dependencies).length === 0) delete json.dependencies;
|
|
1753
|
+
if (json.devDependencies && Object.keys(json.devDependencies).length === 0) delete json.devDependencies;
|
|
1754
|
+
}
|
|
1755
|
+
if (removed) writeFileSync(abs, `${JSON.stringify(json, null, 2)}\n`);
|
|
1756
|
+
}
|
|
1757
|
+
if (hasLockfile(cwd)) return {
|
|
1758
|
+
ok: false,
|
|
1759
|
+
reason: "needs-lockfile-update",
|
|
1760
|
+
detail: "package.json dependency cleanup requires a lockfile update, which is not implemented yet"
|
|
1761
|
+
};
|
|
1762
|
+
return { ok: true };
|
|
1763
|
+
}
|
|
1764
|
+
function applyTextChanges(fileName, changes) {
|
|
1765
|
+
let text = readFileSync(fileName, "utf8");
|
|
1766
|
+
for (const change of [...changes].sort((a, b) => b.span.start - a.span.start)) text = `${text.slice(0, change.span.start)}${change.newText}${text.slice(change.span.start + change.span.length)}`;
|
|
1767
|
+
writeFileSync(fileName, text);
|
|
1768
|
+
}
|
|
1769
|
+
function organizeImports(cwd, files) {
|
|
1770
|
+
const fileNames = [...new Set(files)].filter((file) => /\.[cm]?[jt]sx?$/.test(file)).map((file) => resolve(cwd, file)).filter(existsSync);
|
|
1771
|
+
if (fileNames.length === 0) return { ok: true };
|
|
1772
|
+
const compilerOptions = {
|
|
1773
|
+
allowJs: true,
|
|
1774
|
+
checkJs: false,
|
|
1775
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
1776
|
+
module: ts.ModuleKind.NodeNext,
|
|
1777
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
1778
|
+
target: ts.ScriptTarget.ES2022
|
|
1779
|
+
};
|
|
1780
|
+
const host = {
|
|
1781
|
+
getCompilationSettings: () => compilerOptions,
|
|
1782
|
+
getScriptFileNames: () => fileNames,
|
|
1783
|
+
getScriptVersion: () => "0",
|
|
1784
|
+
getScriptSnapshot: (fileName) => {
|
|
1785
|
+
if (!existsSync(fileName)) return void 0;
|
|
1786
|
+
return ts.ScriptSnapshot.fromString(readFileSync(fileName, "utf8"));
|
|
1787
|
+
},
|
|
1788
|
+
getCurrentDirectory: () => cwd,
|
|
1789
|
+
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
|
|
1790
|
+
fileExists: ts.sys.fileExists,
|
|
1791
|
+
readFile: ts.sys.readFile,
|
|
1792
|
+
readDirectory: ts.sys.readDirectory,
|
|
1793
|
+
directoryExists: ts.sys.directoryExists,
|
|
1794
|
+
getDirectories: ts.sys.getDirectories
|
|
1795
|
+
};
|
|
1796
|
+
const service = ts.createLanguageService(host);
|
|
1797
|
+
try {
|
|
1798
|
+
for (const fileName of fileNames) {
|
|
1799
|
+
const changes = service.organizeImports({
|
|
1800
|
+
type: "file",
|
|
1801
|
+
fileName
|
|
1802
|
+
}, {}, {});
|
|
1803
|
+
for (const fileChanges of changes) applyTextChanges(fileChanges.fileName, fileChanges.textChanges);
|
|
1804
|
+
}
|
|
1805
|
+
return { ok: true };
|
|
1806
|
+
} finally {
|
|
1807
|
+
service.dispose();
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
async function applyEslint(cwd, unit) {
|
|
1811
|
+
const result$1 = await applyEslintFixesForFindings({
|
|
1812
|
+
cwd,
|
|
1813
|
+
files: unit.findings.map((finding) => finding.file),
|
|
1814
|
+
loop: 0
|
|
1815
|
+
}, unit.findings.filter((finding) => finding.tool === "sonarjs"));
|
|
1816
|
+
if (!result$1.error) return { ok: true };
|
|
1817
|
+
return {
|
|
1818
|
+
ok: false,
|
|
1819
|
+
reason: "session-error",
|
|
1820
|
+
detail: result$1.error
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
async function applyStrategy(strategy, cwd, unit) {
|
|
1824
|
+
switch (strategy) {
|
|
1825
|
+
case "deterministic-package-json-cleanup": return applyPackageJsonCleanup(cwd, unit);
|
|
1826
|
+
case "deterministic-ts-organize-imports": return organizeImports(cwd, unit.findings.map((finding) => finding.file));
|
|
1827
|
+
case "deterministic-eslint-fix": return applyEslint(cwd, unit);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
function makeDeterministicFixer(deps) {
|
|
1831
|
+
return { async fix(unit) {
|
|
1832
|
+
const strategies = strategiesFor(unit);
|
|
1833
|
+
if (strategies.length === 0) return {
|
|
1834
|
+
kept: false,
|
|
1835
|
+
reason: "session-error",
|
|
1836
|
+
detail: "No deterministic strategy was planned for this unit",
|
|
1837
|
+
usage: ZERO_AI_USAGE$2
|
|
1838
|
+
};
|
|
1839
|
+
const before = snapshotUnitFiles(deps.cwd, unit.files);
|
|
1840
|
+
for (const strategy of strategies) {
|
|
1841
|
+
const applied = await applyStrategy(strategy, deps.cwd, unit);
|
|
1842
|
+
if (!applied.ok) {
|
|
1843
|
+
restoreSnapshot(deps.cwd, before);
|
|
1844
|
+
return {
|
|
1845
|
+
kept: false,
|
|
1846
|
+
reason: applied.reason,
|
|
1847
|
+
detail: applied.detail,
|
|
1848
|
+
usage: ZERO_AI_USAGE$2
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (!unitChanged(deps.cwd, unit.files, before)) return {
|
|
1853
|
+
kept: false,
|
|
1854
|
+
reason: "session-error",
|
|
1855
|
+
detail: "Deterministic fixer completed without changing owned files",
|
|
1856
|
+
usage: ZERO_AI_USAGE$2
|
|
1857
|
+
};
|
|
1858
|
+
const outcome = await gateUnitChanges(unit, before, deps, {
|
|
1859
|
+
usage: ZERO_AI_USAGE$2,
|
|
1860
|
+
requireResolved: true
|
|
1861
|
+
});
|
|
1862
|
+
if (!outcome.kept) {
|
|
1863
|
+
restoreSnapshot(deps.cwd, before);
|
|
1864
|
+
return {
|
|
1865
|
+
...outcome,
|
|
1866
|
+
usage: ZERO_AI_USAGE$2
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
return {
|
|
1870
|
+
kept: true,
|
|
1871
|
+
usage: ZERO_AI_USAGE$2
|
|
1872
|
+
};
|
|
1873
|
+
} };
|
|
1874
|
+
}
|
|
1875
|
+
const makeDeterministicFixUnit = (deps) => makeDeterministicFixer(deps).fix;
|
|
1876
|
+
|
|
1877
|
+
//#endregion
|
|
1878
|
+
//#region src/session/stream-json.ts
|
|
1879
|
+
const RATE_LIMIT_RE = /rate.?limit|overloaded|\b429\b/i;
|
|
1880
|
+
/** A finite, non-negative number, or 0 for anything else (missing/NaN/negative). */
|
|
1881
|
+
function num(v) {
|
|
1882
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
1883
|
+
}
|
|
1884
|
+
const zeroCost = () => ({
|
|
1885
|
+
estimatedCostUsd: 0,
|
|
1886
|
+
inputTokens: 0,
|
|
1887
|
+
outputTokens: 0,
|
|
1888
|
+
cacheCreationInputTokens: 0,
|
|
1889
|
+
cacheReadInputTokens: 0
|
|
1890
|
+
});
|
|
1891
|
+
function parseEvent(line) {
|
|
1892
|
+
const trimmed = line.trim();
|
|
1893
|
+
if (!trimmed) return null;
|
|
1894
|
+
try {
|
|
1895
|
+
return JSON.parse(trimmed);
|
|
1896
|
+
} catch {
|
|
1897
|
+
return null;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
function extractUsage(event) {
|
|
1901
|
+
const rawCost = event.total_cost_usd ?? event.cost_usd ?? event.costUSD;
|
|
1902
|
+
const u = event.usage;
|
|
1903
|
+
if (rawCost == null && u == null) return null;
|
|
1904
|
+
return {
|
|
1905
|
+
estimatedCostUsd: num(rawCost),
|
|
1906
|
+
inputTokens: num(u?.input_tokens),
|
|
1907
|
+
outputTokens: num(u?.output_tokens),
|
|
1908
|
+
cacheCreationInputTokens: num(u?.cache_creation_input_tokens),
|
|
1909
|
+
cacheReadInputTokens: num(u?.cache_read_input_tokens)
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
function extractEdits(event) {
|
|
1913
|
+
const edits = [];
|
|
1914
|
+
for (const block of event.message?.content ?? []) if (block.type === "tool_use" && block.name === "Write") {
|
|
1915
|
+
const path = block.input?.["file_path"];
|
|
1916
|
+
const contents = block.input?.["content"];
|
|
1917
|
+
if (typeof path === "string" && typeof contents === "string") edits.push({
|
|
1918
|
+
path,
|
|
1919
|
+
contents
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
return edits;
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Parse Claude Code `--output-format stream-json` (newline-delimited JSON) into the
|
|
1926
|
+
* file edits it produced. `Write` tool uses carry full file contents. Malformed lines
|
|
1927
|
+
* are skipped. Rate-limit / error signals are surfaced for backoff.
|
|
1928
|
+
*/
|
|
1929
|
+
function parseStreamJson(raw) {
|
|
1930
|
+
const edits = [];
|
|
1931
|
+
let rateLimited = false;
|
|
1932
|
+
let errored = false;
|
|
1933
|
+
let usage = null;
|
|
1934
|
+
for (const line of raw.split("\n")) {
|
|
1935
|
+
const event = parseEvent(line);
|
|
1936
|
+
if (!event) continue;
|
|
1937
|
+
if (event.is_error) {
|
|
1938
|
+
errored = true;
|
|
1939
|
+
if (event.error && RATE_LIMIT_RE.test(event.error)) rateLimited = true;
|
|
1940
|
+
}
|
|
1941
|
+
if (event.type === "result") {
|
|
1942
|
+
const u = extractUsage(event);
|
|
1943
|
+
if (u) usage = u;
|
|
1944
|
+
}
|
|
1945
|
+
edits.push(...extractEdits(event));
|
|
1946
|
+
}
|
|
1947
|
+
return {
|
|
1948
|
+
edits,
|
|
1949
|
+
rateLimited,
|
|
1950
|
+
errored,
|
|
1951
|
+
usage: usage ?? zeroCost()
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
//#endregion
|
|
1956
|
+
//#region src/session/claude.ts
|
|
1957
|
+
/** Drives a real `claude -p` session and parses its stream-json into edits. */
|
|
1958
|
+
var ClaudeSession = class {
|
|
1959
|
+
constructor(deps) {
|
|
1960
|
+
this.deps = deps;
|
|
1961
|
+
}
|
|
1962
|
+
async run(request) {
|
|
1963
|
+
let stdout;
|
|
1964
|
+
let exitCode;
|
|
1965
|
+
try {
|
|
1966
|
+
({stdout, exitCode} = await this.deps.spawn(request));
|
|
1967
|
+
} catch (err) {
|
|
1968
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1969
|
+
return {
|
|
1970
|
+
ok: false,
|
|
1971
|
+
error,
|
|
1972
|
+
rateLimited: false,
|
|
1973
|
+
failureClass: classifySessionFailure(error),
|
|
1974
|
+
usage: zeroUsage()
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
const parsed = parseStreamJson(stdout);
|
|
1978
|
+
const usage = {
|
|
1979
|
+
...parsed.usage,
|
|
1980
|
+
sessions: 1
|
|
1981
|
+
};
|
|
1982
|
+
if (parsed.rateLimited) return {
|
|
1983
|
+
ok: false,
|
|
1984
|
+
error: "Claude session rate-limited",
|
|
1985
|
+
rateLimited: true,
|
|
1986
|
+
failureClass: "rate-limit",
|
|
1987
|
+
usage
|
|
1988
|
+
};
|
|
1989
|
+
if (exitCode !== 0 || parsed.errored) {
|
|
1990
|
+
const error = `Claude session failed (exit ${exitCode})`;
|
|
1991
|
+
return {
|
|
1992
|
+
ok: false,
|
|
1993
|
+
error,
|
|
1994
|
+
rateLimited: false,
|
|
1995
|
+
failureClass: classifySessionFailure(error, exitCode),
|
|
1996
|
+
usage
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
return {
|
|
2000
|
+
ok: true,
|
|
2001
|
+
edits: parsed.edits,
|
|
2002
|
+
usage
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
};
|
|
2006
|
+
const TIMEOUT_OR_KILLED_RE = /\b(timed?\s*out|timeout|killed|terminated|sigterm|sigkill|exit\s+143)\b/i;
|
|
2007
|
+
function classifySessionFailure(error, exitCode) {
|
|
2008
|
+
if (/rate.?limit|overloaded|\b429\b/i.test(error)) return "rate-limit";
|
|
2009
|
+
if (exitCode === 143 || exitCode === 124 || TIMEOUT_OR_KILLED_RE.test(error)) return "tool-timeout";
|
|
2010
|
+
return "model-tool-failure";
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
//#endregion
|
|
2014
|
+
//#region src/findings/revert-detail.ts
|
|
2015
|
+
const MAX_REVERT_DETAIL_LENGTH = 500;
|
|
2016
|
+
/** Keep persisted diagnostics readable and bounded for report.json. */
|
|
2017
|
+
function normalizeRevertDetail(detail) {
|
|
2018
|
+
const trimmed = detail?.replace(/\r\n?/g, "\n").split("\n").map((line) => line.replace(/[ \t\f\v]+/g, " ").trim()).join("\n").trim();
|
|
2019
|
+
if (!trimmed) return void 0;
|
|
2020
|
+
if (trimmed.length <= MAX_REVERT_DETAIL_LENGTH) return trimmed;
|
|
2021
|
+
return `${trimmed.slice(0, MAX_REVERT_DETAIL_LENGTH - 3)}...`;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
//#endregion
|
|
2025
|
+
//#region src/findings/store.ts
|
|
2026
|
+
const StoreSchema = z.array(FindingSchema);
|
|
2027
|
+
/** Holds Finding records keyed by fingerprint and tracks their state across loops. */
|
|
2028
|
+
var FindingStore = class FindingStore {
|
|
2029
|
+
findings = new Map();
|
|
2030
|
+
add(finding) {
|
|
2031
|
+
this.findings.set(finding.id, finding);
|
|
2032
|
+
}
|
|
2033
|
+
get(id) {
|
|
2034
|
+
return this.findings.get(id);
|
|
2035
|
+
}
|
|
2036
|
+
all() {
|
|
2037
|
+
return [...this.findings.values()];
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Diff a fresh audit against what the store knows, by fingerprint:
|
|
2041
|
+
* - known but absent now → marked `fixed`
|
|
2042
|
+
* - present both loops → stays as-is, carries attempts/history, bumps lastSeenLoop
|
|
2043
|
+
* - new fingerprint → added `pending`, firstSeenLoop = loop
|
|
2044
|
+
*/
|
|
2045
|
+
reconcile(fresh, loop) {
|
|
2046
|
+
const freshIds = new Set(fresh.map((f) => f.id));
|
|
2047
|
+
for (const known of this.findings.values()) if (!freshIds.has(known.id)) {
|
|
2048
|
+
known.status = "fixed";
|
|
2049
|
+
delete known.revertReason;
|
|
2050
|
+
delete known.revertDetail;
|
|
2051
|
+
delete known.finalFailureClass;
|
|
2052
|
+
}
|
|
2053
|
+
for (const incoming of fresh) {
|
|
2054
|
+
const known = this.findings.get(incoming.id);
|
|
2055
|
+
if (known) {
|
|
2056
|
+
known.lastSeenLoop = loop;
|
|
2057
|
+
if (known.status === "fixed" || known.status === "reverted") known.status = "pending";
|
|
2058
|
+
} else this.findings.set(incoming.id, {
|
|
2059
|
+
...incoming,
|
|
2060
|
+
firstSeenLoop: loop,
|
|
2061
|
+
lastSeenLoop: loop
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
/** Findings matching every provided filter (track / status / file). */
|
|
2066
|
+
query(filter) {
|
|
2067
|
+
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));
|
|
2068
|
+
}
|
|
2069
|
+
/** Record a failed fix attempt against a finding's fingerprint. */
|
|
2070
|
+
recordFailedAttempt(id, reason, detail, failureClass) {
|
|
2071
|
+
const finding = this.findings.get(id);
|
|
2072
|
+
if (!finding) return;
|
|
2073
|
+
finding.attempts += 1;
|
|
2074
|
+
finding.revertReason = reason;
|
|
2075
|
+
if (failureClass) finding.finalFailureClass = failureClass;
|
|
2076
|
+
const normalizedDetail = normalizeRevertDetail(detail);
|
|
2077
|
+
if (normalizedDetail) finding.revertDetail = normalizedDetail;
|
|
2078
|
+
else delete finding.revertDetail;
|
|
2079
|
+
}
|
|
2080
|
+
recordFailureWithoutAttempt(id, reason, detail, failureClass) {
|
|
2081
|
+
const finding = this.findings.get(id);
|
|
2082
|
+
if (!finding) return;
|
|
2083
|
+
finding.revertReason = reason;
|
|
2084
|
+
finding.finalFailureClass = failureClass;
|
|
2085
|
+
const normalizedDetail = normalizeRevertDetail(detail);
|
|
2086
|
+
if (normalizedDetail) finding.revertDetail = normalizedDetail;
|
|
2087
|
+
else delete finding.revertDetail;
|
|
2088
|
+
}
|
|
2089
|
+
/** A finding's per-issue budget is exhausted once it has used `budget` attempts. */
|
|
2090
|
+
isBudgetExhausted(id, budget) {
|
|
2091
|
+
const finding = this.findings.get(id);
|
|
2092
|
+
if (!finding) return false;
|
|
2093
|
+
return finding.attempts >= budget;
|
|
2094
|
+
}
|
|
2095
|
+
/** Serialize to a plain array — `report.json`'s findings section. */
|
|
2096
|
+
toJSON() {
|
|
2097
|
+
return this.all();
|
|
2098
|
+
}
|
|
2099
|
+
/** Rebuild a store from serialized findings, validating each against the schema. */
|
|
2100
|
+
static fromJSON(data) {
|
|
2101
|
+
const findings = StoreSchema.parse(data);
|
|
2102
|
+
const store = new FindingStore();
|
|
2103
|
+
for (const finding of findings) store.add(finding);
|
|
2104
|
+
return store;
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
|
|
2108
|
+
//#endregion
|
|
2109
|
+
//#region src/findings/router.ts
|
|
2110
|
+
function isKnownTool(tool) {
|
|
2111
|
+
return TOOLS.includes(tool);
|
|
2112
|
+
}
|
|
2113
|
+
/** Split findings into their assigned fix tracks; unknown tools are skipped with a warning. */
|
|
2114
|
+
function route(findings, opts = {}) {
|
|
2115
|
+
const result$1 = {
|
|
2116
|
+
aiFix: [],
|
|
2117
|
+
deterministic: [],
|
|
2118
|
+
reportOnly: [],
|
|
2119
|
+
skipped: []
|
|
2120
|
+
};
|
|
2121
|
+
for (const finding of findings) {
|
|
2122
|
+
if (!isKnownTool(finding.tool)) {
|
|
2123
|
+
opts.warn?.(`Skipping finding from unknown tool "${finding.tool}"`);
|
|
2124
|
+
result$1.skipped.push(finding);
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
switch (finding.track) {
|
|
2128
|
+
case "ai-fix":
|
|
2129
|
+
result$1.aiFix.push(finding);
|
|
2130
|
+
break;
|
|
2131
|
+
case "deterministic":
|
|
2132
|
+
result$1.deterministic.push(finding);
|
|
2133
|
+
break;
|
|
2134
|
+
case "report-only":
|
|
2135
|
+
result$1.reportOnly.push(finding);
|
|
2136
|
+
break;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
return result$1;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
//#endregion
|
|
2143
|
+
//#region src/output/events.ts
|
|
2144
|
+
/** Minimal synchronous event bus. With no listener, emit is a no-op (silent mode). */
|
|
2145
|
+
var EventBus = class {
|
|
2146
|
+
listeners = [];
|
|
2147
|
+
on(listener) {
|
|
2148
|
+
this.listeners.push(listener);
|
|
2149
|
+
return () => {
|
|
2150
|
+
const i = this.listeners.indexOf(listener);
|
|
2151
|
+
if (i >= 0) this.listeners.splice(i, 1);
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
emit(event) {
|
|
2155
|
+
for (const listener of this.listeners) listener(event);
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
//#endregion
|
|
2160
|
+
//#region src/report/retry-id.ts
|
|
2161
|
+
const RETRY_ID_LENGTH = 6;
|
|
2162
|
+
const RETRY_ID_ALPHABET = "23456789abcdefghijkmnpqrstuvwxyz";
|
|
2163
|
+
const makeRetryId = customAlphabet(RETRY_ID_ALPHABET, RETRY_ID_LENGTH);
|
|
2164
|
+
function hasUsableRetryId(finding) {
|
|
2165
|
+
return typeof finding.retryId === "string" && finding.retryId.length > 0;
|
|
2166
|
+
}
|
|
2167
|
+
function nextUniqueRetryId(used, generate) {
|
|
2168
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
2169
|
+
const retryId = generate();
|
|
2170
|
+
if (!used.has(retryId)) return retryId;
|
|
2171
|
+
}
|
|
2172
|
+
throw new Error("Could not generate a unique retry id");
|
|
2173
|
+
}
|
|
2174
|
+
/** Ensure every finding in a persisted report has a human-facing id unique to that report. */
|
|
2175
|
+
function assignRetryIds(findings, generate = makeRetryId) {
|
|
2176
|
+
const counts = new Map();
|
|
2177
|
+
for (const finding of findings) {
|
|
2178
|
+
if (!hasUsableRetryId(finding)) continue;
|
|
2179
|
+
counts.set(finding.retryId, (counts.get(finding.retryId) ?? 0) + 1);
|
|
2180
|
+
}
|
|
2181
|
+
const used = new Set();
|
|
2182
|
+
return findings.map((finding) => {
|
|
2183
|
+
if (hasUsableRetryId(finding) && counts.get(finding.retryId) === 1) {
|
|
2184
|
+
used.add(finding.retryId);
|
|
2185
|
+
return finding;
|
|
2186
|
+
}
|
|
2187
|
+
const retryId = nextUniqueRetryId(used, generate);
|
|
2188
|
+
used.add(retryId);
|
|
2189
|
+
return {
|
|
2190
|
+
...finding,
|
|
2191
|
+
retryId
|
|
2192
|
+
};
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
//#endregion
|
|
2197
|
+
//#region src/report/schema.ts
|
|
2198
|
+
const DepBumpSchema = z.object({
|
|
2199
|
+
findingId: z.string(),
|
|
2200
|
+
remediation: z.string()
|
|
2201
|
+
});
|
|
2202
|
+
/** Per-scanner outcome for a run: did it run clean, get skipped, or fail (with a reason). */
|
|
2203
|
+
const ScannerStatusSchema = z.object({
|
|
2204
|
+
tool: z.enum(TOOLS),
|
|
2205
|
+
status: z.enum([
|
|
2206
|
+
"ran",
|
|
2207
|
+
"skipped",
|
|
2208
|
+
"failed"
|
|
2209
|
+
]),
|
|
2210
|
+
reason: z.string().optional()
|
|
2211
|
+
});
|
|
2212
|
+
const BehaviorChangeSchema = z.object({
|
|
2213
|
+
findingId: z.string(),
|
|
2214
|
+
file: z.string(),
|
|
2215
|
+
note: z.string()
|
|
2216
|
+
});
|
|
2217
|
+
/**
|
|
2218
|
+
* Estimated AI cost/usage for a run. `estimatedCostUsd` is Claude's client-side
|
|
2219
|
+
* `total_cost_usd` estimate — never authoritative billing.
|
|
2220
|
+
*/
|
|
2221
|
+
const AiUsageSchema = z.object({
|
|
2222
|
+
estimatedCostUsd: z.number().nonnegative(),
|
|
2223
|
+
inputTokens: z.number().nonnegative(),
|
|
2224
|
+
outputTokens: z.number().nonnegative(),
|
|
2225
|
+
cacheCreationInputTokens: z.number().nonnegative(),
|
|
2226
|
+
cacheReadInputTokens: z.number().nonnegative(),
|
|
2227
|
+
sessions: z.number().int().nonnegative()
|
|
2228
|
+
});
|
|
2229
|
+
const RunScopeSchema = z.object({
|
|
2230
|
+
type: z.enum(["all", "scoped"]),
|
|
2231
|
+
fileCount: z.number().int().nonnegative().optional()
|
|
2232
|
+
}).default({ type: "scoped" });
|
|
2233
|
+
const FixPolicySchema = z.object({
|
|
2234
|
+
includeTests: z.boolean().default(false),
|
|
2235
|
+
include: z.array(z.string()).default([]),
|
|
2236
|
+
exclude: z.array(z.string()).default([]),
|
|
2237
|
+
includeGenerated: z.boolean().default(false),
|
|
2238
|
+
includeFixtures: z.boolean().default(false)
|
|
2239
|
+
}).default({
|
|
2240
|
+
includeTests: false,
|
|
2241
|
+
include: [],
|
|
2242
|
+
exclude: [],
|
|
2243
|
+
includeGenerated: false,
|
|
2244
|
+
includeFixtures: false
|
|
2245
|
+
});
|
|
2246
|
+
const FailureSummarySchema = z.object({
|
|
2247
|
+
blockingSecrets: z.number().int().nonnegative(),
|
|
2248
|
+
unresolvedEligible: z.number().int().nonnegative(),
|
|
2249
|
+
toolFailures: z.number().int().nonnegative(),
|
|
2250
|
+
failedDeterministic: z.number().int().nonnegative(),
|
|
2251
|
+
sessionErrors: z.number().int().nonnegative(),
|
|
2252
|
+
regressions: z.number().int().nonnegative(),
|
|
2253
|
+
typecheckFailures: z.number().int().nonnegative(),
|
|
2254
|
+
testFailures: z.number().int().nonnegative()
|
|
2255
|
+
});
|
|
2256
|
+
const ZERO_AI_USAGE$1 = {
|
|
2257
|
+
estimatedCostUsd: 0,
|
|
2258
|
+
inputTokens: 0,
|
|
2259
|
+
outputTokens: 0,
|
|
2260
|
+
cacheCreationInputTokens: 0,
|
|
2261
|
+
cacheReadInputTokens: 0,
|
|
2262
|
+
sessions: 0
|
|
2263
|
+
};
|
|
2264
|
+
const ZERO_FAILURE_SUMMARY = {
|
|
2265
|
+
blockingSecrets: 0,
|
|
2266
|
+
unresolvedEligible: 0,
|
|
2267
|
+
toolFailures: 0,
|
|
2268
|
+
failedDeterministic: 0,
|
|
2269
|
+
sessionErrors: 0,
|
|
2270
|
+
regressions: 0,
|
|
2271
|
+
typecheckFailures: 0,
|
|
2272
|
+
testFailures: 0
|
|
2273
|
+
};
|
|
2274
|
+
const ReportSchema = z.object({
|
|
2275
|
+
findings: z.array(FindingSchema),
|
|
2276
|
+
secrets: z.array(FindingSchema),
|
|
2277
|
+
reportOnly: z.array(FindingSchema).default([]),
|
|
2278
|
+
depBumps: z.array(DepBumpSchema),
|
|
2279
|
+
flaggedBehaviorChanges: z.array(BehaviorChangeSchema),
|
|
2280
|
+
scannerStatuses: z.array(ScannerStatusSchema).default([]),
|
|
2281
|
+
runScope: RunScopeSchema,
|
|
2282
|
+
fixPolicy: FixPolicySchema,
|
|
2283
|
+
aiUsage: AiUsageSchema.default(ZERO_AI_USAGE$1),
|
|
2284
|
+
failureSummary: FailureSummarySchema.default(ZERO_FAILURE_SUMMARY),
|
|
2285
|
+
unresolvedEligibleCount: z.number().int().nonnegative().default(0),
|
|
2286
|
+
loops: z.number().int().nonnegative(),
|
|
2287
|
+
durationMs: z.number().nonnegative(),
|
|
2288
|
+
exitStatus: z.number().int()
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
//#endregion
|
|
2292
|
+
//#region src/report/builder.ts
|
|
2293
|
+
const ZERO_AI_USAGE = {
|
|
2294
|
+
estimatedCostUsd: 0,
|
|
2295
|
+
inputTokens: 0,
|
|
2296
|
+
outputTokens: 0,
|
|
2297
|
+
cacheCreationInputTokens: 0,
|
|
2298
|
+
cacheReadInputTokens: 0,
|
|
2299
|
+
sessions: 0
|
|
2300
|
+
};
|
|
2301
|
+
const DEFAULT_FIX_POLICY = {
|
|
2302
|
+
includeTests: false,
|
|
2303
|
+
include: [],
|
|
2304
|
+
exclude: [],
|
|
2305
|
+
includeGenerated: false,
|
|
2306
|
+
includeFixtures: false
|
|
2307
|
+
};
|
|
2308
|
+
function isUnresolvedEligibleFinding(finding, fixPolicy = DEFAULT_FIX_POLICY) {
|
|
2309
|
+
return finding.category !== "secret" && finding.track === "ai-fix" && finding.status !== "fixed" && finding.inScope !== false && finding.inReportScope !== false && finding.inFixScope !== false && (fixPolicy.includeTests || !isTestFile(finding.file));
|
|
2310
|
+
}
|
|
2311
|
+
function deriveReportFields(findings, scannerStatuses, fixPolicy = DEFAULT_FIX_POLICY) {
|
|
2312
|
+
const unresolvedEligible = findings.filter((f) => isUnresolvedEligibleFinding(f, fixPolicy));
|
|
2313
|
+
const blockingUnresolved = unresolvedEligible.filter((f) => f.status === "reverted" || f.status === "unfixable");
|
|
2314
|
+
const pendingUnresolved = unresolvedEligible.filter((f) => f.status !== "reverted" && f.status !== "unfixable");
|
|
2315
|
+
const failureSummary = {
|
|
2316
|
+
blockingSecrets: findings.filter((f) => f.category === "secret" && f.status !== "fixed").length,
|
|
2317
|
+
unresolvedEligible: pendingUnresolved.length,
|
|
2318
|
+
toolFailures: scannerStatuses.filter((s) => s.status === "failed").length,
|
|
2319
|
+
failedDeterministic: findings.filter((f) => f.track === "deterministic" && f.status !== "fixed").length,
|
|
2320
|
+
sessionErrors: blockingUnresolved.filter((f) => f.revertReason === "session-error").length,
|
|
2321
|
+
regressions: blockingUnresolved.filter((f) => f.revertReason === "regression").length,
|
|
2322
|
+
typecheckFailures: blockingUnresolved.filter((f) => f.revertReason === "typecheck").length,
|
|
2323
|
+
testFailures: blockingUnresolved.filter((f) => f.revertReason === "broke-test").length
|
|
2324
|
+
};
|
|
2325
|
+
return {
|
|
2326
|
+
secrets: findings.filter((f) => f.category === "secret"),
|
|
2327
|
+
reportOnly: findings.filter((f) => f.track === "report-only" && f.category !== "secret"),
|
|
2328
|
+
depBumps: findings.filter((f) => f.track === "deterministic" && f.remediation !== void 0).map((f) => ({
|
|
2329
|
+
findingId: f.id,
|
|
2330
|
+
remediation: f.remediation
|
|
2331
|
+
})),
|
|
2332
|
+
failureSummary,
|
|
2333
|
+
unresolvedEligibleCount: pendingUnresolved.length
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
/** Accumulates per-finding outcomes and run metadata into a validated report.json. */
|
|
2337
|
+
var ReportBuilder = class {
|
|
2338
|
+
outcomes = new Map();
|
|
2339
|
+
flagged = [];
|
|
2340
|
+
scannerStatuses = [];
|
|
2341
|
+
constructor(generateRetryId) {
|
|
2342
|
+
this.generateRetryId = generateRetryId;
|
|
2343
|
+
}
|
|
2344
|
+
/** Record (or update) a finding's final outcome by fingerprint. */
|
|
2345
|
+
recordOutcome(finding) {
|
|
2346
|
+
const normalized = normalizeRevertDetail(finding.revertDetail);
|
|
2347
|
+
const outcome = { ...finding };
|
|
2348
|
+
if (normalized) outcome.revertDetail = normalized;
|
|
2349
|
+
else delete outcome.revertDetail;
|
|
2350
|
+
this.outcomes.set(finding.id, outcome);
|
|
2351
|
+
}
|
|
2352
|
+
recordOutcomes(findings) {
|
|
2353
|
+
for (const f of findings) this.recordOutcome(f);
|
|
2354
|
+
}
|
|
2355
|
+
/** Flag a semantic test change for human review. */
|
|
2356
|
+
flagBehaviorChange(entry) {
|
|
2357
|
+
this.flagged.push(entry);
|
|
2358
|
+
}
|
|
2359
|
+
/** Record per-scanner run outcomes (ran / skipped / failed) for the scanner-status line. */
|
|
2360
|
+
recordScannerStatuses(statuses) {
|
|
2361
|
+
this.scannerStatuses = statuses;
|
|
2362
|
+
}
|
|
2363
|
+
build(meta) {
|
|
2364
|
+
const findings = assignRetryIds([...this.outcomes.values()], this.generateRetryId);
|
|
2365
|
+
const fixPolicy = meta.fixPolicy ?? DEFAULT_FIX_POLICY;
|
|
2366
|
+
const derived = deriveReportFields(findings, this.scannerStatuses, fixPolicy);
|
|
2367
|
+
const report = {
|
|
2368
|
+
findings,
|
|
2369
|
+
secrets: derived.secrets,
|
|
2370
|
+
reportOnly: derived.reportOnly,
|
|
2371
|
+
depBumps: derived.depBumps,
|
|
2372
|
+
flaggedBehaviorChanges: this.flagged,
|
|
2373
|
+
scannerStatuses: this.scannerStatuses,
|
|
2374
|
+
runScope: meta.runScope ?? { type: "scoped" },
|
|
2375
|
+
fixPolicy,
|
|
2376
|
+
aiUsage: meta.aiUsage ?? ZERO_AI_USAGE,
|
|
2377
|
+
failureSummary: derived.failureSummary,
|
|
2378
|
+
unresolvedEligibleCount: derived.unresolvedEligibleCount,
|
|
2379
|
+
loops: meta.loops,
|
|
2380
|
+
durationMs: meta.durationMs,
|
|
2381
|
+
exitStatus: meta.exitStatus
|
|
2382
|
+
};
|
|
2383
|
+
return ReportSchema.parse(report);
|
|
2384
|
+
}
|
|
2385
|
+
};
|
|
2386
|
+
|
|
2387
|
+
//#endregion
|
|
2388
|
+
//#region src/orchestrator.ts
|
|
2389
|
+
/** AI-fixable findings still pending and under their retry budget. */
|
|
2390
|
+
function pendingUnderBudget(store, budget) {
|
|
2391
|
+
return store.query({
|
|
2392
|
+
track: "ai-fix",
|
|
2393
|
+
status: "pending"
|
|
2394
|
+
}).filter((f) => f.attempts < budget);
|
|
2395
|
+
}
|
|
2396
|
+
function repairConfig(config) {
|
|
2397
|
+
return {
|
|
2398
|
+
...config.fix,
|
|
2399
|
+
includeTests: config.includeTests
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
function plannedRepairs(findings, config, cwd) {
|
|
2403
|
+
return findings.map((finding) => planRepair({
|
|
2404
|
+
finding,
|
|
2405
|
+
cwd,
|
|
2406
|
+
scope: finding,
|
|
2407
|
+
config: repairConfig(config),
|
|
2408
|
+
flowPath: finding.flowPath,
|
|
2409
|
+
file: finding.file,
|
|
2410
|
+
category: finding.category,
|
|
2411
|
+
rule: finding.rule,
|
|
2412
|
+
tool: finding.tool
|
|
2413
|
+
}));
|
|
2414
|
+
}
|
|
2415
|
+
function repairFiles(finding) {
|
|
2416
|
+
return [...new Set([finding.file, ...(finding.flowPath ?? []).map((step) => step.file)])];
|
|
2417
|
+
}
|
|
2418
|
+
function needsAllRepairFilesInScope(finding) {
|
|
2419
|
+
return finding.tool === "jscpd" && finding.rule === "duplicate-code" && repairFiles(finding).length > 1;
|
|
2420
|
+
}
|
|
2421
|
+
function allRepairFilesInScope(finding, inScope) {
|
|
2422
|
+
return repairFiles(finding).every((file) => inScope([{
|
|
2423
|
+
...finding,
|
|
2424
|
+
file,
|
|
2425
|
+
flowPath: void 0
|
|
2426
|
+
}]).length > 0);
|
|
2427
|
+
}
|
|
2428
|
+
function dispatchableUnits(plans) {
|
|
2429
|
+
return planWorkFromRepairs(plans).filter((unit) => unit.strategy !== void 0 && isAiDispatchStrategy(unit.strategy));
|
|
2430
|
+
}
|
|
2431
|
+
function isDeterministicStrategy(strategy) {
|
|
2432
|
+
return strategy?.startsWith("deterministic-") === true;
|
|
2433
|
+
}
|
|
2434
|
+
function deterministicUnits(plans) {
|
|
2435
|
+
return planWorkFromRepairs(plans.filter((plan) => isDeterministicStrategy(plan.strategy)));
|
|
2436
|
+
}
|
|
2437
|
+
function statusAttemptSnapshot(store) {
|
|
2438
|
+
return store.all().map((f) => `${f.id}:${f.status}:${f.attempts}`).sort().join("|");
|
|
2439
|
+
}
|
|
2440
|
+
function classFromOutcome(outcome) {
|
|
2441
|
+
if (outcome.failureClass) return outcome.failureClass;
|
|
2442
|
+
switch (outcome.reason) {
|
|
2443
|
+
case "regression": return "regression";
|
|
2444
|
+
case "typecheck": return "typecheck";
|
|
2445
|
+
case "broke-test": return "broke-test";
|
|
2446
|
+
case "suppression": return "suppression";
|
|
2447
|
+
case "needs-lockfile-update": return "needs-lockfile-update";
|
|
2448
|
+
case "session-error": return "model-tool-failure";
|
|
2449
|
+
default: return void 0;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
function isTerminalNoBurnFailure(outcome) {
|
|
2453
|
+
return outcome.failureClass === "tool-timeout" || outcome.failureClass === "no-op";
|
|
2454
|
+
}
|
|
2455
|
+
function applyOutcome(store, unit, outcome, budget) {
|
|
2456
|
+
for (const finding of unit.findings) if (outcome.kept) {
|
|
2457
|
+
finding.status = "fixed";
|
|
2458
|
+
delete finding.revertReason;
|
|
2459
|
+
delete finding.revertDetail;
|
|
2460
|
+
delete finding.finalFailureClass;
|
|
2461
|
+
} else {
|
|
2462
|
+
const reason = outcome.reason ?? "session-error";
|
|
2463
|
+
const failureClass = classFromOutcome(outcome);
|
|
2464
|
+
if (failureClass === "rate-limit") store.recordFailureWithoutAttempt(finding.id, reason, outcome.detail, failureClass);
|
|
2465
|
+
else if (isTerminalNoBurnFailure(outcome)) {
|
|
2466
|
+
store.recordFailureWithoutAttempt(finding.id, reason, outcome.detail, failureClass);
|
|
2467
|
+
finding.status = "unfixable";
|
|
2468
|
+
} else {
|
|
2469
|
+
store.recordFailedAttempt(finding.id, reason, outcome.detail, failureClass);
|
|
2470
|
+
if (store.isBudgetExhausted(finding.id, budget)) finding.status = "unfixable";
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
function splitUnit(unit) {
|
|
2475
|
+
if (unit.findings.length <= 1) return [];
|
|
2476
|
+
return unit.findings.map((finding) => ({
|
|
2477
|
+
...unit,
|
|
2478
|
+
findings: [finding],
|
|
2479
|
+
files: [...unit.files],
|
|
2480
|
+
verificationTargets: unit.verificationTargets ? [...unit.verificationTargets] : void 0,
|
|
2481
|
+
strategies: unit.strategies ? [...unit.strategies] : void 0
|
|
2482
|
+
}));
|
|
2483
|
+
}
|
|
2484
|
+
function shouldSplitAfterFailure(unit, outcome) {
|
|
2485
|
+
return unit.findings.length > 1 && (outcome.failureClass === "tool-timeout" || outcome.failureClass === "regression");
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* The scan → fix → re-audit loop. Terminates on the first of: converged (0 fixable),
|
|
2489
|
+
* no-progress (no dispatchable units or an attempted loop changed no attempt/status
|
|
2490
|
+
* state), per-issue budget exhaustion (mark unfixable, keep going), or max-loops.
|
|
2491
|
+
*/
|
|
2492
|
+
async function orchestrate(deps) {
|
|
2493
|
+
const { config } = deps;
|
|
2494
|
+
const inScope = deps.inScope ?? ((f) => f);
|
|
2495
|
+
const bus = deps.bus ?? new EventBus();
|
|
2496
|
+
const store = new FindingStore();
|
|
2497
|
+
const secrets = new Map();
|
|
2498
|
+
const reportOnly = new Map();
|
|
2499
|
+
const deterministic = new Map();
|
|
2500
|
+
let loop = 0;
|
|
2501
|
+
let fixingLoops = 0;
|
|
2502
|
+
let termination = "converged";
|
|
2503
|
+
let scannerStatuses = [];
|
|
2504
|
+
let runScope = { type: "scoped" };
|
|
2505
|
+
let usage = zeroUsage();
|
|
2506
|
+
while (true) {
|
|
2507
|
+
loop++;
|
|
2508
|
+
bus.emit({
|
|
2509
|
+
type: "scan-start",
|
|
2510
|
+
loop
|
|
2511
|
+
});
|
|
2512
|
+
const audited = await deps.audit(loop);
|
|
2513
|
+
scannerStatuses = audited.scannerStatuses ?? scannerStatuses;
|
|
2514
|
+
if (loop === 1) runScope = audited.scanned == null ? { type: "all" } : {
|
|
2515
|
+
type: "scoped",
|
|
2516
|
+
fileCount: audited.scanned
|
|
2517
|
+
};
|
|
2518
|
+
if (loop === 1 && audited.allScannersMissing) {
|
|
2519
|
+
bus.emit({
|
|
2520
|
+
type: "done",
|
|
2521
|
+
exitStatus: 1
|
|
2522
|
+
});
|
|
2523
|
+
return result("no-scanners", fixingLoops, 1, store, secrets, reportOnly, deterministic, scannerStatuses, runScope, usage);
|
|
2524
|
+
}
|
|
2525
|
+
store.reconcile(audited.findings, loop);
|
|
2526
|
+
const scopedIds = new Set(inScope(store.all()).map((f) => f.id));
|
|
2527
|
+
for (const f of store.all()) {
|
|
2528
|
+
f.inScope = scopedIds.has(f.id);
|
|
2529
|
+
markScope(f, {
|
|
2530
|
+
...config.fix,
|
|
2531
|
+
includeTests: config.includeTests,
|
|
2532
|
+
inChangedScope: f.inScope === true && (!needsAllRepairFilesInScope(f) || allRepairFilesInScope(f, inScope))
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
for (const plan of plannedRepairs(store.all(), config, deps.cwd)) applyRepairPlanToFinding(plan);
|
|
2536
|
+
const scopedFindings = inScope(audited.findings);
|
|
2537
|
+
const routed = route(audited.findings);
|
|
2538
|
+
for (const finding of routed.reportOnly) if (finding.category === "secret") secrets.set(finding.id, finding);
|
|
2539
|
+
else reportOnly.set(finding.id, finding);
|
|
2540
|
+
for (const finding of routed.deterministic) deterministic.set(finding.id, finding);
|
|
2541
|
+
bus.emit({
|
|
2542
|
+
type: "audit",
|
|
2543
|
+
loop,
|
|
2544
|
+
findings: scopedFindings.length,
|
|
2545
|
+
files: new Set(scopedFindings.map((f) => f.file)).size,
|
|
2546
|
+
scanned: audited.scanned
|
|
2547
|
+
});
|
|
2548
|
+
if (loop === 1 && audited.findings.length === 0) {
|
|
2549
|
+
termination = "converged";
|
|
2550
|
+
break;
|
|
2551
|
+
}
|
|
2552
|
+
const pending = pendingUnderBudget(store, config.perIssueBudget);
|
|
2553
|
+
if (pending.length === 0) {
|
|
2554
|
+
termination = "converged";
|
|
2555
|
+
break;
|
|
2556
|
+
}
|
|
2557
|
+
const firstPlans = plannedRepairs(pending, config, deps.cwd);
|
|
2558
|
+
const deterministicWork = deterministicUnits(firstPlans);
|
|
2559
|
+
const aiWork = dispatchableUnits(firstPlans);
|
|
2560
|
+
if (deterministicWork.length === 0 && aiWork.length === 0) {
|
|
2561
|
+
termination = "no-progress";
|
|
2562
|
+
break;
|
|
2563
|
+
}
|
|
2564
|
+
if (fixingLoops >= config.maxLoops) {
|
|
2565
|
+
termination = "max-loops";
|
|
2566
|
+
break;
|
|
2567
|
+
}
|
|
2568
|
+
fixingLoops++;
|
|
2569
|
+
const beforeAttemptState = statusAttemptSnapshot(store);
|
|
2570
|
+
if (deterministicWork.length > 0) {
|
|
2571
|
+
bus.emit({
|
|
2572
|
+
type: "loop-start",
|
|
2573
|
+
loop,
|
|
2574
|
+
files: deterministicWork.map((u) => u.file),
|
|
2575
|
+
concurrency: 1
|
|
2576
|
+
});
|
|
2577
|
+
const deterministicFixUnit = deps.deterministicFixUnit ?? (async () => ({
|
|
2578
|
+
kept: false,
|
|
2579
|
+
reason: "session-error",
|
|
2580
|
+
detail: "No deterministic fixer configured",
|
|
2581
|
+
usage: zeroUsage()
|
|
2582
|
+
}));
|
|
2583
|
+
const outcomes$1 = [];
|
|
2584
|
+
for (const unit of deterministicWork) {
|
|
2585
|
+
bus.emit({
|
|
2586
|
+
type: "file-start",
|
|
2587
|
+
loop,
|
|
2588
|
+
file: unit.file,
|
|
2589
|
+
rule: unit.findings[0]?.rule
|
|
2590
|
+
});
|
|
2591
|
+
const outcome = await deterministicFixUnit(unit, loop);
|
|
2592
|
+
bus.emit({
|
|
2593
|
+
type: "file-result",
|
|
2594
|
+
loop,
|
|
2595
|
+
file: unit.file,
|
|
2596
|
+
outcome: outcome.kept ? "fixed" : "reverted",
|
|
2597
|
+
reason: outcome.reason
|
|
2598
|
+
});
|
|
2599
|
+
outcomes$1.push({
|
|
2600
|
+
unit,
|
|
2601
|
+
outcome
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
for (const { unit, outcome } of outcomes$1) {
|
|
2605
|
+
applyOutcome(store, unit, outcome, config.perIssueBudget);
|
|
2606
|
+
if (outcome.usage) usage = addUsage(usage, outcome.usage);
|
|
2607
|
+
}
|
|
2608
|
+
bus.emit({
|
|
2609
|
+
type: "loop-complete",
|
|
2610
|
+
loop,
|
|
2611
|
+
fixed: outcomes$1.filter((o) => o.outcome.kept).length
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
const units = dispatchableUnits(plannedRepairs(pendingUnderBudget(store, config.perIssueBudget), config, deps.cwd));
|
|
2615
|
+
if (units.length === 0) {
|
|
2616
|
+
if (statusAttemptSnapshot(store) === beforeAttemptState) {
|
|
2617
|
+
termination = "no-progress";
|
|
2618
|
+
break;
|
|
2619
|
+
}
|
|
2620
|
+
continue;
|
|
2621
|
+
}
|
|
2622
|
+
bus.emit({
|
|
2623
|
+
type: "loop-start",
|
|
2624
|
+
loop,
|
|
2625
|
+
files: units.map((u) => u.file),
|
|
2626
|
+
concurrency: config.maxSessions
|
|
2627
|
+
});
|
|
2628
|
+
const outcomesNested = await dispatch(units, async (unit) => {
|
|
2629
|
+
bus.emit({
|
|
2630
|
+
type: "file-start",
|
|
2631
|
+
loop,
|
|
2632
|
+
file: unit.file,
|
|
2633
|
+
rule: unit.findings[0]?.rule
|
|
2634
|
+
});
|
|
2635
|
+
const outcome = await deps.fixUnit(unit, loop);
|
|
2636
|
+
bus.emit({
|
|
2637
|
+
type: "file-result",
|
|
2638
|
+
loop,
|
|
2639
|
+
file: unit.file,
|
|
2640
|
+
outcome: outcome.kept ? "fixed" : "reverted",
|
|
2641
|
+
reason: outcome.reason
|
|
2642
|
+
});
|
|
2643
|
+
const smaller = shouldSplitAfterFailure(unit, outcome) ? splitUnit(unit) : [];
|
|
2644
|
+
if (smaller.length === 0) return [{
|
|
2645
|
+
unit,
|
|
2646
|
+
outcome,
|
|
2647
|
+
apply: true
|
|
2648
|
+
}];
|
|
2649
|
+
const splitOutcomes = [{
|
|
2650
|
+
unit,
|
|
2651
|
+
outcome,
|
|
2652
|
+
apply: false
|
|
2653
|
+
}];
|
|
2654
|
+
for (const split of smaller) {
|
|
2655
|
+
bus.emit({
|
|
2656
|
+
type: "file-start",
|
|
2657
|
+
loop,
|
|
2658
|
+
file: split.file,
|
|
2659
|
+
rule: split.findings[0]?.rule
|
|
2660
|
+
});
|
|
2661
|
+
const splitOutcome = await deps.fixUnit(split, loop);
|
|
2662
|
+
bus.emit({
|
|
2663
|
+
type: "file-result",
|
|
2664
|
+
loop,
|
|
2665
|
+
file: split.file,
|
|
2666
|
+
outcome: splitOutcome.kept ? "fixed" : "reverted",
|
|
2667
|
+
reason: splitOutcome.reason
|
|
2668
|
+
});
|
|
2669
|
+
splitOutcomes.push({
|
|
2670
|
+
unit: split,
|
|
2671
|
+
outcome: splitOutcome,
|
|
2672
|
+
apply: true
|
|
2673
|
+
});
|
|
2674
|
+
if (splitOutcome.failureClass === "rate-limit") break;
|
|
2675
|
+
}
|
|
2676
|
+
return splitOutcomes;
|
|
2677
|
+
}, { concurrency: config.maxSessions });
|
|
2678
|
+
const outcomes = outcomesNested.flat();
|
|
2679
|
+
for (const { unit, outcome, apply } of outcomes) {
|
|
2680
|
+
if (apply) applyOutcome(store, unit, outcome, config.perIssueBudget);
|
|
2681
|
+
if (outcome.usage) usage = addUsage(usage, outcome.usage);
|
|
2682
|
+
}
|
|
2683
|
+
bus.emit({
|
|
2684
|
+
type: "loop-complete",
|
|
2685
|
+
loop,
|
|
2686
|
+
fixed: outcomes.filter((o) => o.apply && o.outcome.kept).length
|
|
2687
|
+
});
|
|
2688
|
+
if (outcomes.some((o) => o.outcome.failureClass === "rate-limit")) {
|
|
2689
|
+
termination = "retryable-infrastructure";
|
|
2690
|
+
break;
|
|
2691
|
+
}
|
|
2692
|
+
if (statusAttemptSnapshot(store) === beforeAttemptState) {
|
|
2693
|
+
termination = "no-progress";
|
|
2694
|
+
break;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
const derived = deriveReportFields(store.all(), scannerStatuses, {
|
|
2698
|
+
includeTests: Boolean(config.includeTests),
|
|
2699
|
+
include: config.fix?.include ?? [],
|
|
2700
|
+
exclude: config.fix?.exclude ?? [],
|
|
2701
|
+
includeGenerated: Boolean(config.fix?.includeGenerated),
|
|
2702
|
+
includeFixtures: Boolean(config.fix?.includeFixtures)
|
|
2703
|
+
});
|
|
2704
|
+
const hasBlockingFailure = derived.failureSummary.blockingSecrets > 0 || derived.failureSummary.unresolvedEligible > 0 || derived.failureSummary.toolFailures > 0 || derived.failureSummary.failedDeterministic > 0 || derived.failureSummary.sessionErrors > 0 || derived.failureSummary.regressions > 0 || derived.failureSummary.typecheckFailures > 0 || derived.failureSummary.testFailures > 0;
|
|
2705
|
+
const exitStatus = termination === "retryable-infrastructure" ? 75 : hasBlockingFailure ? 1 : 0;
|
|
2706
|
+
bus.emit({
|
|
2707
|
+
type: "done",
|
|
2708
|
+
exitStatus
|
|
2709
|
+
});
|
|
2710
|
+
return result(termination, fixingLoops, exitStatus, store, secrets, reportOnly, deterministic, scannerStatuses, runScope, usage);
|
|
2711
|
+
}
|
|
2712
|
+
function result(termination, loops, exitStatus, store, secrets, reportOnly, deterministic, scannerStatuses, runScope, usage) {
|
|
2713
|
+
return {
|
|
2714
|
+
termination,
|
|
2715
|
+
loops,
|
|
2716
|
+
exitStatus: termination === "no-scanners" ? 1 : exitStatus,
|
|
2717
|
+
findings: store.all(),
|
|
2718
|
+
secrets: [...secrets.values()],
|
|
2719
|
+
reportOnly: [...reportOnly.values()],
|
|
2720
|
+
deterministic: [...deterministic.values()],
|
|
2721
|
+
depBumps: [...deterministic.values()],
|
|
2722
|
+
scannerStatuses,
|
|
2723
|
+
runScope,
|
|
2724
|
+
usage
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
//#endregion
|
|
2729
|
+
//#region src/output/format.ts
|
|
2730
|
+
/**
|
|
2731
|
+
* Human duration for the summary: sub-minute reads as "2.4s", longer as "3m 12s".
|
|
2732
|
+
* Deterministic — no locale, no rounding surprises.
|
|
2733
|
+
*/
|
|
2734
|
+
function formatDuration(ms) {
|
|
2735
|
+
const totalSeconds = ms / 1e3;
|
|
2736
|
+
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
|
|
2737
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
2738
|
+
const seconds = Math.round(totalSeconds % 60);
|
|
2739
|
+
if (seconds === 60) return `${minutes + 1}m 0s`;
|
|
2740
|
+
return `${minutes}m ${seconds}s`;
|
|
2741
|
+
}
|
|
2742
|
+
/** Stopwatch form for the live per-file timer: "0:42", "1:05". */
|
|
2743
|
+
function formatClock(ms) {
|
|
2744
|
+
const totalSeconds = Math.floor(ms / 1e3);
|
|
2745
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
2746
|
+
const seconds = totalSeconds % 60;
|
|
2747
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
2748
|
+
}
|
|
2749
|
+
/** Plain-language reason a fix was reverted — the most useful thing for a human. */
|
|
2750
|
+
function reasonLabel(reason) {
|
|
2751
|
+
switch (reason) {
|
|
2752
|
+
case "broke-test": return "broke tests";
|
|
2753
|
+
case "typecheck": return "broke typecheck";
|
|
2754
|
+
case "suppression": return "added a suppression";
|
|
2755
|
+
case "regression": return "introduced a new issue";
|
|
2756
|
+
case "session-error": return "the fix session failed";
|
|
2757
|
+
case "needs-lockfile-update": return "needs lockfile update";
|
|
2758
|
+
default: return "couldn't fix";
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
//#endregion
|
|
2763
|
+
//#region src/output/theme.ts
|
|
2764
|
+
const PALETTE = {
|
|
2765
|
+
accent: "#7AA2F7",
|
|
2766
|
+
accentTo: "#9D7CD8",
|
|
2767
|
+
green: "#9ECE6A",
|
|
2768
|
+
amber: "#E0AF68",
|
|
2769
|
+
red: "#E88388"
|
|
2770
|
+
};
|
|
2771
|
+
const UNICODE_GLYPHS = {
|
|
2772
|
+
fixed: "✔",
|
|
2773
|
+
reverted: "↩",
|
|
2774
|
+
left: "–",
|
|
2775
|
+
scanned: "✔",
|
|
2776
|
+
bullet: "·",
|
|
2777
|
+
rule: "─",
|
|
2778
|
+
arrow: "→",
|
|
2779
|
+
spinner: [
|
|
2780
|
+
"⠋",
|
|
2781
|
+
"⠙",
|
|
2782
|
+
"⠹",
|
|
2783
|
+
"⠸",
|
|
2784
|
+
"⠼",
|
|
2785
|
+
"⠴",
|
|
2786
|
+
"⠦",
|
|
2787
|
+
"⠧",
|
|
2788
|
+
"⠇",
|
|
2789
|
+
"⠏"
|
|
2790
|
+
]
|
|
2791
|
+
};
|
|
2792
|
+
const ASCII_GLYPHS = {
|
|
2793
|
+
fixed: "+",
|
|
2794
|
+
reverted: "<",
|
|
2795
|
+
left: "-",
|
|
2796
|
+
scanned: "+",
|
|
2797
|
+
bullet: "·",
|
|
2798
|
+
rule: "-",
|
|
2799
|
+
arrow: ">",
|
|
2800
|
+
spinner: [
|
|
2801
|
+
"-",
|
|
2802
|
+
"\\",
|
|
2803
|
+
"|",
|
|
2804
|
+
"/"
|
|
2805
|
+
]
|
|
2806
|
+
};
|
|
2807
|
+
/** Resolve the chalk color level: 0 (off) when color is disabled, else the terminal's. */
|
|
2808
|
+
function chalkLevel(env) {
|
|
2809
|
+
if (!env.color) return 0;
|
|
2810
|
+
const detected = supportsColor ? supportsColor.level : 0;
|
|
2811
|
+
return detected > 0 ? detected : 1;
|
|
2812
|
+
}
|
|
2813
|
+
function makeTheme(env) {
|
|
2814
|
+
const c = new Chalk({ level: chalkLevel(env) });
|
|
2815
|
+
const glyph = env.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
|
|
2816
|
+
const accent = (s) => c.hex(PALETTE.accent)(s);
|
|
2817
|
+
const wordmark = () => {
|
|
2818
|
+
if (!env.color) return "tend";
|
|
2819
|
+
if (c.level >= 3) return gradient([PALETTE.accent, PALETTE.accentTo])("tend");
|
|
2820
|
+
return accent("tend");
|
|
2821
|
+
};
|
|
2822
|
+
return {
|
|
2823
|
+
accent,
|
|
2824
|
+
fixed: (s) => c.hex(PALETTE.green)(s),
|
|
2825
|
+
reverted: (s) => c.hex(PALETTE.amber)(s),
|
|
2826
|
+
error: (s) => c.hex(PALETTE.red)(s),
|
|
2827
|
+
dim: (s) => c.dim(s),
|
|
2828
|
+
bold: (s) => c.bold(s),
|
|
2829
|
+
plain: (s) => s,
|
|
2830
|
+
wordmark,
|
|
2831
|
+
glyph
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
//#endregion
|
|
2836
|
+
//#region src/output/summary.ts
|
|
2837
|
+
const RULE_WIDTH = 49;
|
|
2838
|
+
const PLAIN_THEME = makeTheme({
|
|
2839
|
+
color: false,
|
|
2840
|
+
interactive: false,
|
|
2841
|
+
unicode: true
|
|
2842
|
+
});
|
|
2843
|
+
/** A finding the developer can't read as part of their changes (scanned wide, out of scope). */
|
|
2844
|
+
function isOutOfScope(f) {
|
|
2845
|
+
return f.inScope === false && f.category !== "secret";
|
|
2846
|
+
}
|
|
2847
|
+
function bucket(report) {
|
|
2848
|
+
const fixed = [];
|
|
2849
|
+
const skippedTests = [];
|
|
2850
|
+
const generated = [];
|
|
2851
|
+
const fixtures = [];
|
|
2852
|
+
const outOfScope = [];
|
|
2853
|
+
const reportOnly = [];
|
|
2854
|
+
const timedOutSessionError = [];
|
|
2855
|
+
const regressed = [];
|
|
2856
|
+
const typecheckFailed = [];
|
|
2857
|
+
const testFailed = [];
|
|
2858
|
+
const retryExhausted = [];
|
|
2859
|
+
const unresolvedEligible = [];
|
|
2860
|
+
const secrets = [];
|
|
2861
|
+
for (const f of report.findings) if (f.category === "secret") secrets.push(f);
|
|
2862
|
+
else if (isOutOfScope(f)) outOfScope.push(f);
|
|
2863
|
+
else if (f.inReportScope === false) continue;
|
|
2864
|
+
else if (f.status === "fixed") fixed.push(f);
|
|
2865
|
+
else if (f.status === "reverted" || f.status === "unfixable") if (f.revertReason === "session-error" || f.finalFailureClass === "tool-timeout") timedOutSessionError.push(f);
|
|
2866
|
+
else if (f.revertReason === "regression") regressed.push(f);
|
|
2867
|
+
else if (f.revertReason === "typecheck") typecheckFailed.push(f);
|
|
2868
|
+
else if (f.revertReason === "broke-test") testFailed.push(f);
|
|
2869
|
+
else retryExhausted.push(f);
|
|
2870
|
+
else {
|
|
2871
|
+
const pending = classifyPending(f, report);
|
|
2872
|
+
if (pending === "skippedTests") skippedTests.push(f);
|
|
2873
|
+
else if (pending === "generated") generated.push(f);
|
|
2874
|
+
else if (pending === "fixtures") fixtures.push(f);
|
|
2875
|
+
else if (pending === "outOfScope") outOfScope.push(f);
|
|
2876
|
+
else if (pending === "reportOnly") reportOnly.push(f);
|
|
2877
|
+
else unresolvedEligible.push(f);
|
|
2878
|
+
}
|
|
2879
|
+
return {
|
|
2880
|
+
fixed,
|
|
2881
|
+
skippedTests,
|
|
2882
|
+
generated,
|
|
2883
|
+
fixtures,
|
|
2884
|
+
outOfScope,
|
|
2885
|
+
reportOnly,
|
|
2886
|
+
timedOutSessionError,
|
|
2887
|
+
regressed,
|
|
2888
|
+
typecheckFailed,
|
|
2889
|
+
testFailed,
|
|
2890
|
+
retryExhausted,
|
|
2891
|
+
unresolvedEligible,
|
|
2892
|
+
secrets
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
function classifyPending(f, report) {
|
|
2896
|
+
if (f.inFixScope === false) {
|
|
2897
|
+
if (f.scopeExclusionReason === "generated") return "generated";
|
|
2898
|
+
if (f.scopeExclusionReason === "fixtures") return "fixtures";
|
|
2899
|
+
if (f.scopeExclusionReason === "tests") return "skippedTests";
|
|
2900
|
+
return "outOfScope";
|
|
2901
|
+
}
|
|
2902
|
+
if (f.track === "report-only") return "reportOnly";
|
|
2903
|
+
if (f.track === "ai-fix" && !report.fixPolicy.includeTests && isTestFile(f.file)) return "skippedTests";
|
|
2904
|
+
return "left";
|
|
2905
|
+
}
|
|
2906
|
+
function couldntFixFindings(b) {
|
|
2907
|
+
return [
|
|
2908
|
+
...b.timedOutSessionError,
|
|
2909
|
+
...b.regressed,
|
|
2910
|
+
...b.typecheckFailed,
|
|
2911
|
+
...b.testFailed,
|
|
2912
|
+
...b.retryExhausted
|
|
2913
|
+
];
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* The final summary: a real headline (fixed / couldn't-fix / left / secrets + elapsed),
|
|
2917
|
+
* grouped by what the user must do, with revert reasons surfaced per file, and ending in
|
|
2918
|
+
* next-step affordances. Brief by default; `--verbose` adds the full per-finding listing.
|
|
2919
|
+
*/
|
|
2920
|
+
function renderSummary(report, opts = {}) {
|
|
2921
|
+
const theme = opts.theme ?? PLAIN_THEME;
|
|
2922
|
+
const { glyph } = theme;
|
|
2923
|
+
const b = bucket(report);
|
|
2924
|
+
if (opts.plain) return renderPlainSummary(report, b, theme, Boolean(opts.verbose));
|
|
2925
|
+
const lines$1 = [];
|
|
2926
|
+
lines$1.push(theme.dim(glyph.rule.repeat(RULE_WIDTH)));
|
|
2927
|
+
lines$1.push(`done ${theme.dim(`${glyph.bullet} ${report.loops} fix passes ${glyph.bullet} ${formatDuration(report.durationMs)}`)}`);
|
|
2928
|
+
lines$1.push("");
|
|
2929
|
+
lines$1.push(theme.bold("run summary"));
|
|
2930
|
+
lines$1.push(renderOverallTable(report, b, theme));
|
|
2931
|
+
lines$1.push("");
|
|
2932
|
+
lines$1.push(theme.bold("scanner breakdown"));
|
|
2933
|
+
lines$1.push(renderScannerBreakdownTable(report, theme));
|
|
2934
|
+
const couldntFix = couldntFixFindings(b);
|
|
2935
|
+
if (couldntFix.length > 0) {
|
|
2936
|
+
lines$1.push("");
|
|
2937
|
+
lines$1.push(theme.bold("couldn't fix"));
|
|
2938
|
+
lines$1.push(renderCouldntFixSummaryTable(couldntFix));
|
|
2939
|
+
if (opts.verbose) {
|
|
2940
|
+
lines$1.push("");
|
|
2941
|
+
lines$1.push(theme.bold("couldn't fix retry details"));
|
|
2942
|
+
lines$1.push(renderCouldntFixDetailTable(couldntFix, theme));
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
if (b.secrets.length > 0) {
|
|
2946
|
+
lines$1.push("");
|
|
2947
|
+
lines$1.push(theme.bold("secrets"));
|
|
2948
|
+
lines$1.push(renderSecretsTable(b.secrets, theme));
|
|
2949
|
+
}
|
|
2950
|
+
if (opts.verbose) lines$1.push("", renderVerbose(report, theme));
|
|
2951
|
+
lines$1.push("");
|
|
2952
|
+
lines$1.push(theme.bold("next commands"));
|
|
2953
|
+
lines$1.push(renderNextCommandsTable());
|
|
2954
|
+
return lines$1.join("\n");
|
|
2955
|
+
}
|
|
2956
|
+
function renderPlainSummary(report, b, theme, verbose) {
|
|
2957
|
+
const lines$1 = [
|
|
2958
|
+
`done ${theme.glyph.bullet} ${report.loops} fix passes ${theme.glyph.bullet} ${formatDuration(report.durationMs)}`,
|
|
2959
|
+
[
|
|
2960
|
+
"summary",
|
|
2961
|
+
`fixed=${b.fixed.length}`,
|
|
2962
|
+
`couldntFix=${couldntFixFindings(b).length}`,
|
|
2963
|
+
`skippedTests=${b.skippedTests.length}`,
|
|
2964
|
+
`reportOnly=${b.reportOnly.length}`,
|
|
2965
|
+
`left=${b.unresolvedEligible.length}`,
|
|
2966
|
+
`secrets=${b.secrets.length}`,
|
|
2967
|
+
`generated=${b.generated.length}`,
|
|
2968
|
+
`fixtures=${b.fixtures.length}`,
|
|
2969
|
+
`outOfScope=${b.outOfScope.length}`,
|
|
2970
|
+
`unresolvedEligible=${b.unresolvedEligible.length}`,
|
|
2971
|
+
`timedOutSessionError=${b.timedOutSessionError.length}`,
|
|
2972
|
+
`regressed=${b.regressed.length}`,
|
|
2973
|
+
`typecheckFailed=${b.typecheckFailed.length}`,
|
|
2974
|
+
`testFailed=${b.testFailed.length}`
|
|
2975
|
+
].join(" "),
|
|
2976
|
+
[
|
|
2977
|
+
"aiUsage",
|
|
2978
|
+
`estimatedCostUsd=${report.aiUsage.estimatedCostUsd.toFixed(2)}`,
|
|
2979
|
+
`sessions=${report.aiUsage.sessions}`,
|
|
2980
|
+
`inputTokens=${report.aiUsage.inputTokens}`,
|
|
2981
|
+
`outputTokens=${report.aiUsage.outputTokens}`,
|
|
2982
|
+
`cacheReadInputTokens=${report.aiUsage.cacheReadInputTokens}`,
|
|
2983
|
+
`cacheCreationInputTokens=${report.aiUsage.cacheCreationInputTokens}`
|
|
2984
|
+
].join(" ")
|
|
2985
|
+
];
|
|
2986
|
+
const strategyCounts = repairStrategyCounts(report);
|
|
2987
|
+
if (strategyCounts.size > 0) lines$1.push(["repairStrategies", ...[...strategyCounts.entries()].map(([strategy, count]) => `${strategy}=${count}`)].join(" "));
|
|
2988
|
+
if (report.exitStatus !== 0 || hasFailureSummary(report)) lines$1.push([
|
|
2989
|
+
"failureSummary",
|
|
2990
|
+
`blockingSecrets=${report.failureSummary.blockingSecrets}`,
|
|
2991
|
+
`unresolvedEligible=${report.failureSummary.unresolvedEligible}`,
|
|
2992
|
+
`toolFailures=${report.failureSummary.toolFailures}`,
|
|
2993
|
+
`failedDeterministic=${report.failureSummary.failedDeterministic}`,
|
|
2994
|
+
`sessionErrors=${report.failureSummary.sessionErrors}`,
|
|
2995
|
+
`regressions=${report.failureSummary.regressions}`,
|
|
2996
|
+
`typecheckFailures=${report.failureSummary.typecheckFailures}`,
|
|
2997
|
+
`testFailures=${report.failureSummary.testFailures}`
|
|
2998
|
+
].join(" "));
|
|
2999
|
+
const counts = inScopeByTool(report);
|
|
3000
|
+
const statusByTool = new Map(report.scannerStatuses.map((s) => [s.tool, s]));
|
|
3001
|
+
const tools = TOOLS.filter((t) => statusByTool.has(t) || counts.has(t));
|
|
3002
|
+
for (const tool of tools) {
|
|
3003
|
+
const status = statusByTool.get(tool);
|
|
3004
|
+
const c = counts.get(tool) ?? {
|
|
3005
|
+
total: 0,
|
|
3006
|
+
fixed: 0,
|
|
3007
|
+
couldntFix: 0,
|
|
3008
|
+
skippedTests: 0,
|
|
3009
|
+
reportOnly: 0,
|
|
3010
|
+
left: 0,
|
|
3011
|
+
generated: 0,
|
|
3012
|
+
fixtures: 0,
|
|
3013
|
+
outOfScope: 0
|
|
3014
|
+
};
|
|
3015
|
+
const reason = status?.status === "failed" && status.reason ? ` reason=${JSON.stringify(firstLine(status.reason))}` : status?.status === "skipped" ? " reason=not-installed" : "";
|
|
3016
|
+
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} unresolvedEligible=${c.left} generated=${c.generated} fixtures=${c.fixtures} outOfScope=${c.outOfScope}${reason}`);
|
|
3017
|
+
}
|
|
3018
|
+
for (const f of couldntFixFindings(b)) {
|
|
3019
|
+
const id = retryTarget(f);
|
|
3020
|
+
lines$1.push(`couldnt-fix retryId=${f.retryId ?? "(none)"} file=${JSON.stringify(f.file)} rule=${JSON.stringify(f.rule)} reason=${JSON.stringify(findingReason(f))} detail=${JSON.stringify(firstLine(f.revertDetail ?? ""))} command=${JSON.stringify(`tend retry ${id}`)}`);
|
|
3021
|
+
}
|
|
3022
|
+
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")}`);
|
|
3023
|
+
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...>"`);
|
|
3024
|
+
if (b.generated.length > 0) lines$1.push(`generated count=${b.generated.length} reason="generated/cache/build output is excluded from fixes by default"`);
|
|
3025
|
+
if (b.fixtures.length > 0) lines$1.push(`fixtures count=${b.fixtures.length} reason="fixture files are excluded from fixes by default"`);
|
|
3026
|
+
if (b.outOfScope.length > 0) lines$1.push(`out-of-scope count=${b.outOfScope.length} reason="outside the current fix scope or explicitly excluded"`);
|
|
3027
|
+
if (b.reportOnly.length > 0) lines$1.push(`report-only count=${b.reportOnly.length} reason="unsupported or report-only findings"`);
|
|
3028
|
+
if (verbose) for (const f of report.findings) lines$1.push(`finding retryId=${f.retryId ?? ""} status=${f.status} strategy=${f.repairStrategy ?? ""} tool=${f.tool} location=${JSON.stringify(`${f.file}:${f.range.startLine}`)} rule=${JSON.stringify(f.rule)} reason=${JSON.stringify(f.status === "fixed" ? "" : findingReason(f))}`);
|
|
3029
|
+
lines$1.push("next command=\"tend diff\" command=\"git add -p\" command=\"tend undo\"");
|
|
3030
|
+
return lines$1.join("\n");
|
|
3031
|
+
}
|
|
3032
|
+
function repairStrategyCounts(report) {
|
|
3033
|
+
const counts = new Map();
|
|
3034
|
+
for (const finding of report.findings) {
|
|
3035
|
+
if (!finding.repairStrategy) continue;
|
|
3036
|
+
counts.set(finding.repairStrategy, (counts.get(finding.repairStrategy) ?? 0) + 1);
|
|
3037
|
+
}
|
|
3038
|
+
return counts;
|
|
3039
|
+
}
|
|
3040
|
+
function renderTable(head, rows) {
|
|
3041
|
+
const table = new Table({
|
|
3042
|
+
head,
|
|
3043
|
+
wordWrap: true,
|
|
3044
|
+
style: {
|
|
3045
|
+
head: [],
|
|
3046
|
+
border: []
|
|
3047
|
+
}
|
|
3048
|
+
});
|
|
3049
|
+
table.push(...rows);
|
|
3050
|
+
return table.toString();
|
|
3051
|
+
}
|
|
3052
|
+
function renderOverallTable(report, b, theme) {
|
|
3053
|
+
const couldntFix = couldntFixFindings(b);
|
|
3054
|
+
const clean = b.fixed.length === 0 && couldntFix.length === 0 && b.skippedTests.length === 0 && b.generated.length === 0 && b.fixtures.length === 0 && b.outOfScope.length === 0 && b.reportOnly.length === 0 && b.unresolvedEligible.length === 0 && b.secrets.length === 0 && !hasFailureSummary(report);
|
|
3055
|
+
const status = report.exitStatus === 0 ? clean ? theme.fixed("success (nothing to fix)") : theme.fixed("success") : theme.error(`needs attention (exit ${report.exitStatus})`);
|
|
3056
|
+
const rows = [
|
|
3057
|
+
["status", status],
|
|
3058
|
+
["fix passes", String(report.loops)],
|
|
3059
|
+
["elapsed", formatDuration(report.durationMs)],
|
|
3060
|
+
["fixed", `${theme.fixed(theme.glyph.fixed)} ${b.fixed.length}`],
|
|
3061
|
+
["timed out/session error", `${theme.reverted(theme.glyph.reverted)} ${b.timedOutSessionError.length}`],
|
|
3062
|
+
["regressed", `${theme.reverted(theme.glyph.reverted)} ${b.regressed.length}`],
|
|
3063
|
+
["typecheck failed", `${theme.reverted(theme.glyph.reverted)} ${b.typecheckFailed.length}`],
|
|
3064
|
+
["test failed", `${theme.reverted(theme.glyph.reverted)} ${b.testFailed.length}`],
|
|
3065
|
+
["retries exhausted", `${theme.reverted(theme.glyph.reverted)} ${b.retryExhausted.length}`],
|
|
3066
|
+
["skipped tests", `${theme.dim(theme.glyph.left)} ${b.skippedTests.length} (pass --include-tests)`],
|
|
3067
|
+
["skipped generated", `${theme.dim(theme.glyph.left)} ${b.generated.length}`],
|
|
3068
|
+
["skipped fixtures", `${theme.dim(theme.glyph.left)} ${b.fixtures.length}`],
|
|
3069
|
+
["out of scope", `${theme.dim(theme.glyph.left)} ${b.outOfScope.length}`],
|
|
3070
|
+
["report only", `${theme.dim(theme.glyph.left)} ${b.reportOnly.length}`],
|
|
3071
|
+
["unresolved eligible", `${theme.dim(theme.glyph.left)} ${b.unresolvedEligible.length}`],
|
|
3072
|
+
["secrets", b.secrets.length > 0 ? theme.error(String(b.secrets.length)) : "0"],
|
|
3073
|
+
["tool failures", report.failureSummary.toolFailures > 0 ? theme.error(String(report.failureSummary.toolFailures)) : "0"],
|
|
3074
|
+
["failed deterministic fixes", report.failureSummary.failedDeterministic > 0 ? theme.error(String(report.failureSummary.failedDeterministic)) : "0"],
|
|
3075
|
+
["estimated AI cost", formatCost(report.aiUsage.estimatedCostUsd)],
|
|
3076
|
+
["AI sessions", String(report.aiUsage.sessions)],
|
|
3077
|
+
["tokens", formatTokens(report.aiUsage)]
|
|
3078
|
+
];
|
|
3079
|
+
if (report.exitStatus !== 0 && !hasFailureSummary(report) && couldntFix.length === 0) rows.splice(1, 0, ["failure reason", theme.error("exit status set without recorded blocking findings")]);
|
|
3080
|
+
return renderTable(["metric", "value"], rows);
|
|
3081
|
+
}
|
|
3082
|
+
function hasFailureSummary(report) {
|
|
3083
|
+
const summary = report.failureSummary;
|
|
3084
|
+
return summary.blockingSecrets > 0 || summary.unresolvedEligible > 0 || summary.toolFailures > 0 || summary.failedDeterministic > 0 || summary.sessionErrors > 0 || summary.regressions > 0 || summary.typecheckFailures > 0 || summary.testFailures > 0;
|
|
3085
|
+
}
|
|
3086
|
+
/** Estimated AI cost as `$X.XX` — always two decimals, never called a "bill". */
|
|
3087
|
+
function formatCost(usd) {
|
|
3088
|
+
return `$${usd.toFixed(2)}`;
|
|
3089
|
+
}
|
|
3090
|
+
/** `<input> in · <output> out · <cache read> cache read · <cache write> cache write`. */
|
|
3091
|
+
function formatTokens(u) {
|
|
3092
|
+
return [
|
|
3093
|
+
`${u.inputTokens} in`,
|
|
3094
|
+
`${u.outputTokens} out`,
|
|
3095
|
+
`${u.cacheReadInputTokens} cache read`,
|
|
3096
|
+
`${u.cacheCreationInputTokens} cache write`
|
|
3097
|
+
].join(" · ");
|
|
3098
|
+
}
|
|
3099
|
+
/** Per-tool tally over the in-scope findings only. */
|
|
3100
|
+
function inScopeByTool(report) {
|
|
3101
|
+
const counts = new Map();
|
|
3102
|
+
for (const f of report.findings) {
|
|
3103
|
+
if (f.inScope === false) continue;
|
|
3104
|
+
const row = counts.get(f.tool) ?? {
|
|
3105
|
+
total: 0,
|
|
3106
|
+
fixed: 0,
|
|
3107
|
+
couldntFix: 0,
|
|
3108
|
+
skippedTests: 0,
|
|
3109
|
+
reportOnly: 0,
|
|
3110
|
+
left: 0,
|
|
3111
|
+
generated: 0,
|
|
3112
|
+
fixtures: 0,
|
|
3113
|
+
outOfScope: 0
|
|
3114
|
+
};
|
|
3115
|
+
row.total += 1;
|
|
3116
|
+
if (f.status === "fixed") row.fixed += 1;
|
|
3117
|
+
else if (f.status === "reverted" || f.status === "unfixable") row.couldntFix += 1;
|
|
3118
|
+
else {
|
|
3119
|
+
const pending = classifyPending(f, report);
|
|
3120
|
+
if (pending === "skippedTests") row.skippedTests += 1;
|
|
3121
|
+
else if (pending === "generated") row.generated += 1;
|
|
3122
|
+
else if (pending === "fixtures") row.fixtures += 1;
|
|
3123
|
+
else if (pending === "outOfScope") row.outOfScope += 1;
|
|
3124
|
+
else if (pending === "reportOnly") row.reportOnly += 1;
|
|
3125
|
+
else row.left += 1;
|
|
3126
|
+
}
|
|
3127
|
+
counts.set(f.tool, row);
|
|
3128
|
+
}
|
|
3129
|
+
return counts;
|
|
3130
|
+
}
|
|
3131
|
+
function renderScannerBreakdownTable(report, theme) {
|
|
3132
|
+
const counts = inScopeByTool(report);
|
|
3133
|
+
const statusByTool = new Map(report.scannerStatuses.map((s) => [s.tool, s]));
|
|
3134
|
+
const tools = TOOLS.filter((t) => statusByTool.has(t) || counts.has(t));
|
|
3135
|
+
if (tools.length === 0) return renderTable(scannerBreakdownHeaders(), [[
|
|
3136
|
+
"(none)",
|
|
3137
|
+
"not recorded",
|
|
3138
|
+
scopeLabel(report),
|
|
3139
|
+
"0",
|
|
3140
|
+
"0",
|
|
3141
|
+
"0",
|
|
3142
|
+
"0",
|
|
3143
|
+
"0",
|
|
3144
|
+
"0",
|
|
3145
|
+
"0",
|
|
3146
|
+
"0",
|
|
3147
|
+
"0",
|
|
3148
|
+
""
|
|
3149
|
+
]]);
|
|
3150
|
+
const rows = tools.map((tool) => {
|
|
3151
|
+
const status = statusByTool.get(tool);
|
|
3152
|
+
const c = counts.get(tool) ?? {
|
|
3153
|
+
total: 0,
|
|
3154
|
+
fixed: 0,
|
|
3155
|
+
couldntFix: 0,
|
|
3156
|
+
skippedTests: 0,
|
|
3157
|
+
reportOnly: 0,
|
|
3158
|
+
left: 0,
|
|
3159
|
+
generated: 0,
|
|
3160
|
+
fixtures: 0,
|
|
3161
|
+
outOfScope: 0
|
|
3162
|
+
};
|
|
3163
|
+
const label = scannerLabel(tool);
|
|
3164
|
+
const statusText = status?.status === "ran" ? `${theme.fixed(theme.glyph.fixed)} ran` : status?.status === "failed" ? `${theme.error("!")} failed` : status?.status === "skipped" ? "skipped" : "not recorded";
|
|
3165
|
+
const reason = status?.status === "failed" && status.reason ? firstLine(status.reason) : status?.status === "skipped" ? "not installed" : "";
|
|
3166
|
+
return [
|
|
3167
|
+
label,
|
|
3168
|
+
statusText,
|
|
3169
|
+
scopeLabel(report),
|
|
3170
|
+
String(c.total),
|
|
3171
|
+
String(c.fixed),
|
|
3172
|
+
String(c.couldntFix),
|
|
3173
|
+
String(c.skippedTests),
|
|
3174
|
+
String(c.reportOnly),
|
|
3175
|
+
String(c.left),
|
|
3176
|
+
String(c.generated),
|
|
3177
|
+
String(c.fixtures),
|
|
3178
|
+
String(c.outOfScope),
|
|
3179
|
+
reason
|
|
3180
|
+
];
|
|
3181
|
+
});
|
|
3182
|
+
return renderTable(scannerBreakdownHeaders(), rows);
|
|
3183
|
+
}
|
|
3184
|
+
function scannerBreakdownHeaders() {
|
|
3185
|
+
return [
|
|
3186
|
+
"scanner",
|
|
3187
|
+
"status",
|
|
3188
|
+
"scope",
|
|
3189
|
+
"total",
|
|
3190
|
+
"fixed",
|
|
3191
|
+
"couldn't fix",
|
|
3192
|
+
"skipped tests",
|
|
3193
|
+
"report only",
|
|
3194
|
+
"unresolved eligible",
|
|
3195
|
+
"generated",
|
|
3196
|
+
"fixtures",
|
|
3197
|
+
"out of scope",
|
|
3198
|
+
"reason"
|
|
3199
|
+
];
|
|
3200
|
+
}
|
|
3201
|
+
function scopeLabel(report) {
|
|
3202
|
+
if (report.runScope.type === "all") return "whole repo";
|
|
3203
|
+
if (report.runScope.fileCount !== void 0) return `${report.runScope.fileCount} scoped ${report.runScope.fileCount === 1 ? "file" : "files"}`;
|
|
3204
|
+
return "in your changes";
|
|
3205
|
+
}
|
|
3206
|
+
function plainScopeLabel(report) {
|
|
3207
|
+
return scopeLabel(report).replaceAll(" ", "-");
|
|
3208
|
+
}
|
|
3209
|
+
function findingReason(f) {
|
|
3210
|
+
if (f.finalFailureClass === "tool-timeout") return "timeout/session error";
|
|
3211
|
+
if (f.finalFailureClass === "rate-limit") return "rate limited";
|
|
3212
|
+
if (f.finalFailureClass === "no-op") return "no-op";
|
|
3213
|
+
switch (f.revertReason) {
|
|
3214
|
+
case "session-error": return "timeout/session error";
|
|
3215
|
+
case "regression": return "regression introduced";
|
|
3216
|
+
case "typecheck": return "typecheck failed";
|
|
3217
|
+
case "broke-test": return "tests failed";
|
|
3218
|
+
case "suppression": return "added a suppression";
|
|
3219
|
+
default: return "retries exhausted";
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
function retryTarget(f) {
|
|
3223
|
+
return f.retryId ?? f.id;
|
|
3224
|
+
}
|
|
3225
|
+
const COULDNT_FIX_REASON_ORDER = [
|
|
3226
|
+
"session error",
|
|
3227
|
+
"regression",
|
|
3228
|
+
"typecheck failed",
|
|
3229
|
+
"test failed",
|
|
3230
|
+
"retries exhausted",
|
|
3231
|
+
"unsupported / report-only"
|
|
3232
|
+
];
|
|
3233
|
+
function couldntFixReason(f) {
|
|
3234
|
+
if (f.track === "report-only") return "unsupported / report-only";
|
|
3235
|
+
if (f.revertReason === "session-error" || f.finalFailureClass === "tool-timeout" || f.finalFailureClass === "rate-limit" || f.finalFailureClass === "model-tool-failure" || f.finalFailureClass === "no-edit") return "session error";
|
|
3236
|
+
if (f.revertReason === "regression" || f.finalFailureClass === "regression") return "regression";
|
|
3237
|
+
if (f.revertReason === "typecheck" || f.finalFailureClass === "typecheck") return "typecheck failed";
|
|
3238
|
+
if (f.revertReason === "broke-test" || f.finalFailureClass === "broke-test") return "test failed";
|
|
3239
|
+
return "retries exhausted";
|
|
3240
|
+
}
|
|
3241
|
+
function groupCouldntFixByReason(findings) {
|
|
3242
|
+
const groups = new Map();
|
|
3243
|
+
for (const finding of findings) {
|
|
3244
|
+
const reason = couldntFixReason(finding);
|
|
3245
|
+
const group = groups.get(reason) ?? [];
|
|
3246
|
+
group.push(finding);
|
|
3247
|
+
groups.set(reason, group);
|
|
3248
|
+
}
|
|
3249
|
+
return COULDNT_FIX_REASON_ORDER.flatMap((reason) => {
|
|
3250
|
+
const group = groups.get(reason);
|
|
3251
|
+
return group && group.length > 0 ? [{
|
|
3252
|
+
reason,
|
|
3253
|
+
findings: group
|
|
3254
|
+
}] : [];
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
function truncateCell(value, maxLength = 64) {
|
|
3258
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
3259
|
+
if (normalized.length <= maxLength) return normalized;
|
|
3260
|
+
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
3261
|
+
}
|
|
3262
|
+
function uniqueExampleFiles(findings, maxFiles = 3) {
|
|
3263
|
+
const files = [];
|
|
3264
|
+
const seen = new Set();
|
|
3265
|
+
for (const finding of findings) {
|
|
3266
|
+
if (seen.has(finding.file)) continue;
|
|
3267
|
+
seen.add(finding.file);
|
|
3268
|
+
files.push(finding.file);
|
|
3269
|
+
if (files.length >= maxFiles) break;
|
|
3270
|
+
}
|
|
3271
|
+
return files;
|
|
3272
|
+
}
|
|
3273
|
+
function nextActionForCouldntFixGroup(findings) {
|
|
3274
|
+
if (findings.length === 1) return `tend retry ${retryTarget(findings[0])}`;
|
|
3275
|
+
return "run with --verbose";
|
|
3276
|
+
}
|
|
3277
|
+
function renderCouldntFixSummaryTable(findings) {
|
|
3278
|
+
const rows = groupCouldntFixByReason(findings).map((group) => [
|
|
3279
|
+
group.reason,
|
|
3280
|
+
String(group.findings.length),
|
|
3281
|
+
uniqueExampleFiles(group.findings).map((file) => truncateCell(file, 56)).join(", "),
|
|
3282
|
+
nextActionForCouldntFixGroup(group.findings)
|
|
3283
|
+
]);
|
|
3284
|
+
return renderTable([
|
|
3285
|
+
"reason",
|
|
3286
|
+
"count",
|
|
3287
|
+
"examples",
|
|
3288
|
+
"next action"
|
|
3289
|
+
], rows);
|
|
3290
|
+
}
|
|
3291
|
+
function renderCouldntFixDetailTable(findings, theme) {
|
|
3292
|
+
const rows = findings.map((f) => [
|
|
3293
|
+
f.retryId ?? "(none)",
|
|
3294
|
+
f.file,
|
|
3295
|
+
String(f.range.startLine),
|
|
3296
|
+
f.rule,
|
|
3297
|
+
findingReason(f),
|
|
3298
|
+
firstLine(f.revertDetail ?? ""),
|
|
3299
|
+
`${theme.glyph.arrow} tend retry ${retryTarget(f)}`
|
|
3300
|
+
]);
|
|
3301
|
+
return renderTable([
|
|
3302
|
+
"retryId",
|
|
3303
|
+
"file",
|
|
3304
|
+
"line",
|
|
3305
|
+
"rule",
|
|
3306
|
+
"reason",
|
|
3307
|
+
"detail",
|
|
3308
|
+
"command"
|
|
3309
|
+
], rows);
|
|
3310
|
+
}
|
|
3311
|
+
function renderSecretsTable(findings, theme) {
|
|
3312
|
+
const rows = findings.map((f) => [
|
|
3313
|
+
f.retryId ?? "(none)",
|
|
3314
|
+
f.file,
|
|
3315
|
+
f.rule,
|
|
3316
|
+
theme.error("rotate + scrub history")
|
|
3317
|
+
]);
|
|
3318
|
+
return renderTable([
|
|
3319
|
+
"retryId",
|
|
3320
|
+
"file",
|
|
3321
|
+
"rule",
|
|
3322
|
+
"action"
|
|
3323
|
+
], rows);
|
|
3324
|
+
}
|
|
3325
|
+
function renderNextCommandsTable() {
|
|
3326
|
+
return renderTable(["action", "command"], [
|
|
3327
|
+
["review edits", "tend diff"],
|
|
3328
|
+
["stage deliberately", "git add -p"],
|
|
3329
|
+
["undo run", "tend undo"]
|
|
3330
|
+
]);
|
|
3331
|
+
}
|
|
3332
|
+
/** First line of a (possibly multi-line) scanner error reason, trimmed. */
|
|
3333
|
+
function firstLine(s) {
|
|
3334
|
+
return s.split("\n")[0].trim();
|
|
3335
|
+
}
|
|
3336
|
+
function scannerLabel(tool) {
|
|
3337
|
+
return tool === "sonarjs" ? "sonarjs (bundled)" : tool;
|
|
3338
|
+
}
|
|
3339
|
+
function perToolCounts(findings) {
|
|
3340
|
+
const counts = new Map();
|
|
3341
|
+
for (const f of findings) {
|
|
3342
|
+
const row = counts.get(f.tool) ?? {
|
|
3343
|
+
fixed: 0,
|
|
3344
|
+
reverted: 0,
|
|
3345
|
+
left: 0
|
|
3346
|
+
};
|
|
3347
|
+
if (f.status === "fixed") row.fixed += 1;
|
|
3348
|
+
else if (f.status === "reverted") row.reverted += 1;
|
|
3349
|
+
else row.left += 1;
|
|
3350
|
+
counts.set(f.tool, row);
|
|
3351
|
+
}
|
|
3352
|
+
return counts;
|
|
3353
|
+
}
|
|
3354
|
+
/** The exhaustive view behind `--verbose`: per-tool breakdown + every finding. */
|
|
3355
|
+
function renderVerbose(report, theme) {
|
|
3356
|
+
const table = new Table({
|
|
3357
|
+
head: [
|
|
3358
|
+
"tool",
|
|
3359
|
+
"fixed",
|
|
3360
|
+
"reverted",
|
|
3361
|
+
"left"
|
|
3362
|
+
],
|
|
3363
|
+
style: {
|
|
3364
|
+
head: [],
|
|
3365
|
+
border: []
|
|
3366
|
+
}
|
|
3367
|
+
});
|
|
3368
|
+
for (const [tool, c] of perToolCounts(report.findings)) table.push([
|
|
3369
|
+
tool,
|
|
3370
|
+
String(c.fixed),
|
|
3371
|
+
String(c.reverted),
|
|
3372
|
+
String(c.left)
|
|
3373
|
+
]);
|
|
3374
|
+
const findingRows = report.findings.map((f) => [
|
|
3375
|
+
f.retryId ?? "",
|
|
3376
|
+
f.status,
|
|
3377
|
+
f.repairStrategy ?? "",
|
|
3378
|
+
f.tool,
|
|
3379
|
+
`${f.file}:${f.range.startLine}`,
|
|
3380
|
+
f.rule,
|
|
3381
|
+
f.status === "fixed" ? "" : findingReason(f)
|
|
3382
|
+
]);
|
|
3383
|
+
return [
|
|
3384
|
+
theme.bold("verbose totals"),
|
|
3385
|
+
table.toString(),
|
|
3386
|
+
theme.bold("verbose findings"),
|
|
3387
|
+
renderTable([
|
|
3388
|
+
"retryId",
|
|
3389
|
+
"status",
|
|
3390
|
+
"strategy",
|
|
3391
|
+
"tool",
|
|
3392
|
+
"location",
|
|
3393
|
+
"rule",
|
|
3394
|
+
"reason"
|
|
3395
|
+
], findingRows)
|
|
3396
|
+
].join("\n");
|
|
3397
|
+
}
|
|
3398
|
+
/**
|
|
3399
|
+
* Group the issues that still need a human, ordered by urgency:
|
|
3400
|
+
* secrets → security → couldn't-fix → needs-review. Empty groups are omitted.
|
|
3401
|
+
*/
|
|
3402
|
+
function groupRemaining(report) {
|
|
3403
|
+
const unfixed = report.findings.filter((f) => f.status !== "fixed");
|
|
3404
|
+
const secrets = unfixed.filter((f) => f.category === "secret");
|
|
3405
|
+
const security = unfixed.filter((f) => f.category === "security");
|
|
3406
|
+
const couldntFix = unfixed.filter((f) => f.status === "unfixable" && f.category !== "secret" && f.category !== "security");
|
|
3407
|
+
const groups = [
|
|
3408
|
+
{
|
|
3409
|
+
key: "secrets",
|
|
3410
|
+
title: "SECRETS — rotate now (never auto-fixed)",
|
|
3411
|
+
count: secrets.length
|
|
3412
|
+
},
|
|
3413
|
+
{
|
|
3414
|
+
key: "security",
|
|
3415
|
+
title: "SECURITY",
|
|
3416
|
+
count: security.length
|
|
3417
|
+
},
|
|
3418
|
+
{
|
|
3419
|
+
key: "couldnt-fix",
|
|
3420
|
+
title: "COULDN'T FIX",
|
|
3421
|
+
count: couldntFix.length
|
|
3422
|
+
},
|
|
3423
|
+
{
|
|
3424
|
+
key: "review",
|
|
3425
|
+
title: "NEEDS YOUR REVIEW — behavior changed",
|
|
3426
|
+
count: report.flaggedBehaviorChanges.length
|
|
3427
|
+
}
|
|
3428
|
+
];
|
|
3429
|
+
return groups.filter((g) => g.count > 0);
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
//#endregion
|
|
3433
|
+
//#region src/commands/resolve-finding.ts
|
|
3434
|
+
function displayId(finding) {
|
|
3435
|
+
return finding.retryId ?? finding.id;
|
|
3436
|
+
}
|
|
3437
|
+
function resolveFindingId(id, findings) {
|
|
3438
|
+
const retryMatch = findings.find((f) => f.retryId === id);
|
|
3439
|
+
if (retryMatch) return retryMatch;
|
|
3440
|
+
const exact = findings.find((f) => f.id === id);
|
|
3441
|
+
if (exact) return exact;
|
|
3442
|
+
const prefixMatches = findings.filter((f) => f.id.startsWith(id));
|
|
3443
|
+
if (prefixMatches.length === 0) return { error: `No finding with id "${id}"` };
|
|
3444
|
+
if (prefixMatches.length > 1) {
|
|
3445
|
+
const matches = prefixMatches.map(displayId).join(", ");
|
|
3446
|
+
return { error: `Finding id "${id}" is ambiguous; matches ${matches}. Use the retry id or full fingerprint.` };
|
|
3447
|
+
}
|
|
3448
|
+
return prefixMatches[0];
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
//#endregion
|
|
3452
|
+
//#region src/commands/show.ts
|
|
3453
|
+
/** `tend show <id>` — full detail on one finding: attempts, revert reason, taint flow path. */
|
|
3454
|
+
function showCommand(id, findings) {
|
|
3455
|
+
const resolved = resolveFindingId(id, findings);
|
|
3456
|
+
if ("error" in resolved) return resolved.error;
|
|
3457
|
+
const finding = resolved;
|
|
3458
|
+
const lines$1 = [
|
|
3459
|
+
`${finding.tool} ${finding.rule} [${finding.status}]`,
|
|
3460
|
+
`retry id: ${finding.retryId ?? "(none)"}`,
|
|
3461
|
+
`fingerprint: ${finding.id}`,
|
|
3462
|
+
`${finding.file}:${finding.range.startLine}`,
|
|
3463
|
+
finding.message,
|
|
3464
|
+
`attempts: ${finding.attempts}`
|
|
3465
|
+
];
|
|
3466
|
+
if (finding.revertReason) lines$1.push(`last revert reason: ${finding.revertReason}`);
|
|
3467
|
+
if (finding.revertDetail) lines$1.push(`last revert detail: ${finding.revertDetail}`);
|
|
3468
|
+
if (finding.flowPath?.length) {
|
|
3469
|
+
lines$1.push("flow path:");
|
|
3470
|
+
for (const step of finding.flowPath) lines$1.push(` → ${step.file}:${step.line}`);
|
|
3471
|
+
}
|
|
3472
|
+
if (finding.helpUri) lines$1.push(`docs: ${finding.helpUri}`);
|
|
3473
|
+
return lines$1.join("\n");
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
//#endregion
|
|
3477
|
+
//#region src/commands/retry.ts
|
|
3478
|
+
function findingsOf(deps) {
|
|
3479
|
+
return deps.report?.findings ?? deps.findings ?? [];
|
|
3480
|
+
}
|
|
3481
|
+
function syncDerivedReportFields(report) {
|
|
3482
|
+
const derived = deriveReportFields(report.findings, report.scannerStatuses, report.fixPolicy);
|
|
3483
|
+
report.secrets = derived.secrets;
|
|
3484
|
+
report.reportOnly = derived.reportOnly;
|
|
3485
|
+
report.depBumps = derived.depBumps;
|
|
3486
|
+
report.failureSummary = derived.failureSummary;
|
|
3487
|
+
report.unresolvedEligibleCount = derived.unresolvedEligibleCount;
|
|
3488
|
+
const hasBlockingFailure = derived.failureSummary.blockingSecrets > 0 || derived.failureSummary.unresolvedEligible > 0 || derived.failureSummary.toolFailures > 0 || derived.failureSummary.failedDeterministic > 0 || derived.failureSummary.sessionErrors > 0 || derived.failureSummary.regressions > 0 || derived.failureSummary.typecheckFailures > 0 || derived.failureSummary.testFailures > 0;
|
|
3489
|
+
report.exitStatus = hasBlockingFailure ? 1 : 0;
|
|
3490
|
+
}
|
|
3491
|
+
function resolveRetryTarget(id, findings) {
|
|
3492
|
+
const resolved = resolveFindingId(id, findings);
|
|
3493
|
+
if ("error" in resolved) return resolved;
|
|
3494
|
+
if (resolved.status === "fixed") return { error: `Finding ${resolved.id} is already fixed` };
|
|
3495
|
+
if (resolved.track !== "ai-fix") return { error: `Finding ${resolved.id} is not AI-fixable` };
|
|
3496
|
+
return resolved;
|
|
3497
|
+
}
|
|
3498
|
+
/** `tend retry <id>` — re-attempt a stubborn finding with a larger attempt budget. */
|
|
3499
|
+
async function retryCommand(id, deps) {
|
|
3500
|
+
const resolved = resolveRetryTarget(id, findingsOf(deps));
|
|
3501
|
+
if ("error" in resolved) return resolved;
|
|
3502
|
+
const finding = resolved;
|
|
3503
|
+
const largerBudget = Math.max(deps.baseBudget * 2, finding.attempts + 1);
|
|
3504
|
+
finding.status = "fixing";
|
|
3505
|
+
const outcome = await deps.runFix(finding, largerBudget);
|
|
3506
|
+
if (outcome.kept) {
|
|
3507
|
+
finding.status = "fixed";
|
|
3508
|
+
delete finding.revertReason;
|
|
3509
|
+
delete finding.revertDetail;
|
|
3510
|
+
delete finding.finalFailureClass;
|
|
3511
|
+
if (deps.report) syncDerivedReportFields(deps.report);
|
|
3512
|
+
return {
|
|
3513
|
+
outcome: "fixed",
|
|
3514
|
+
finding,
|
|
3515
|
+
budget: largerBudget
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
const reason = outcome.reason ?? "session-error";
|
|
3519
|
+
finding.attempts += 1;
|
|
3520
|
+
finding.revertReason = reason;
|
|
3521
|
+
finding.finalFailureClass = outcome.failureClass;
|
|
3522
|
+
const detail = normalizeRevertDetail(outcome.detail);
|
|
3523
|
+
if (detail) finding.revertDetail = detail;
|
|
3524
|
+
else delete finding.revertDetail;
|
|
3525
|
+
finding.status = finding.attempts >= largerBudget ? "unfixable" : "reverted";
|
|
3526
|
+
if (deps.report) syncDerivedReportFields(deps.report);
|
|
3527
|
+
return {
|
|
3528
|
+
outcome: "reverted",
|
|
3529
|
+
finding,
|
|
3530
|
+
budget: largerBudget,
|
|
3531
|
+
reason
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
//#endregion
|
|
3536
|
+
//#region src/config/config.ts
|
|
3537
|
+
const EFFORT_LEVELS = [
|
|
3538
|
+
"low",
|
|
3539
|
+
"medium",
|
|
3540
|
+
"high",
|
|
3541
|
+
"xhigh",
|
|
3542
|
+
"max"
|
|
3543
|
+
];
|
|
3544
|
+
const ToolConfigSchema = z.object({
|
|
3545
|
+
enabled: z.boolean().default(true),
|
|
3546
|
+
configPath: z.string().optional()
|
|
3547
|
+
});
|
|
3548
|
+
const FixScopeConfigSchema = z.object({
|
|
3549
|
+
include: z.array(z.string()).default([]),
|
|
3550
|
+
exclude: z.array(z.string()).default([]),
|
|
3551
|
+
includeGenerated: z.boolean().default(false),
|
|
3552
|
+
includeFixtures: z.boolean().default(false)
|
|
3553
|
+
}).default({
|
|
3554
|
+
include: [],
|
|
3555
|
+
exclude: [],
|
|
3556
|
+
includeGenerated: false,
|
|
3557
|
+
includeFixtures: false
|
|
3558
|
+
});
|
|
3559
|
+
const ConfigSchema = z.object({
|
|
3560
|
+
maxSessions: z.number().int().positive().default(4),
|
|
3561
|
+
maxLoops: z.number().int().positive().default(5),
|
|
3562
|
+
perIssueBudget: z.number().int().positive().default(3),
|
|
3563
|
+
test: z.string().optional(),
|
|
3564
|
+
teethCheck: z.boolean().default(true),
|
|
3565
|
+
includeTests: z.boolean().default(false),
|
|
3566
|
+
model: z.string().default("sonnet"),
|
|
3567
|
+
effort: z.enum(EFFORT_LEVELS).optional(),
|
|
3568
|
+
fix: FixScopeConfigSchema,
|
|
3569
|
+
tools: z.record(z.string(), ToolConfigSchema).default({})
|
|
3570
|
+
});
|
|
3571
|
+
/**
|
|
3572
|
+
* Load config via cosmiconfig (searching from `cwd`), validate with zod, and apply
|
|
3573
|
+
* zero-config defaults when no file is found. Invalid config throws a clear message.
|
|
3574
|
+
*/
|
|
3575
|
+
async function loadConfig(cwd) {
|
|
3576
|
+
const explorer = cosmiconfig("tend", { stopDir: cwd });
|
|
3577
|
+
const found = await explorer.search(cwd);
|
|
3578
|
+
const raw = found?.config ?? {};
|
|
3579
|
+
const parsed = ConfigSchema.safeParse(raw);
|
|
3580
|
+
if (!parsed.success) {
|
|
3581
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`);
|
|
3582
|
+
throw new Error(`Invalid tend config:\n ${issues.join("\n ")}`);
|
|
3583
|
+
}
|
|
3584
|
+
return parsed.data;
|
|
3585
|
+
}
|
|
3586
|
+
/** Overlay CLI flags onto a loaded config (flags win). */
|
|
3587
|
+
function applyCliOverrides(config, overrides) {
|
|
3588
|
+
const result$1 = { ...config };
|
|
3589
|
+
for (const [key, value] of Object.entries(overrides)) if (value !== void 0) result$1[key] = value;
|
|
3590
|
+
return result$1;
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
//#endregion
|
|
3594
|
+
export { ClaudeSession, ConfigSchema, EFFORT_LEVELS, EventBus, FindingSchema, FindingStore, REPAIR_STRATEGIES, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, applyRepairPlanToFinding, assertGitRepo, buildDiff, buildProgram, changedFiles, changedVsHead, createGit, detectBuildCommand, detectPackageManager, dispatch, filesUnder, filterToChanged, fingerprint, formatClock, gateUnitChanges, groupRemaining, isAiDispatchStrategy, isAvailable, loadConfig, makeDeterministicFixUnit, makeDeterministicFixer, makeTheme, normalize, orchestrate, planRepair, planWork, planWorkFromRepairs, reasonLabel, renderSummary, resolveRetryTarget, restoreSnapshot, retryCommand, revertFile, route, runEslintSonarjs, runScanner, scannerStatus, scopeFindings, showCommand, snapshotUnitFiles, snapshotUnitNow, toRepoRelative, trackForTool, unitChanged, zeroUsage };
|