tend-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { ClaudeSession, EFFORT_LEVELS, EventBus, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, assertGitRepo, buildDiff, buildProgram, changedVsHead, createGit, detectBuildCommand, detectPackageManager, filesUnder, filterToChanged, formatClock, gateUnitChanges, loadConfig, makeDeterministicFixUnit, makeTheme, orchestrate, planRepair, planWorkFromRepairs, reasonLabel, renderSummary, resolveRetryTarget, restoreSnapshot, retryCommand, runEslintSonarjs, runScanner, scannerStatus, showCommand, snapshotUnitFiles, snapshotUnitNow, toRepoRelative, unitChanged, zeroUsage } from "./config-tbp_HMuZ.js";
2
+ import { ClaudeSession, EFFORT_LEVELS, EventBus, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, assertGitRepo, buildDiff, buildProgram, changedVsHead, createGit, detectBuildCommand, detectPackageManager, filesUnder, filterToChanged, formatClock, gateUnitChanges, loadConfig, makeDeterministicFixUnit, makeTheme, orchestrate, planRepair, planWorkFromRepairs, reasonLabel, renderSummary, resolveRetryTarget, restoreSnapshot, retryCommand, runEslintSonarjs, runScanner, scannerStatus, showCommand, snapshotUnitFiles, snapshotUnitNow, toRepoRelative, unitChanged, zeroUsage } from "./config-DPlVYfKX.js";
3
3
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { execa } from "execa";
6
+ import PQueue from "p-queue";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { tmpdir } from "node:os";
8
9
  import { createRequire } from "node:module";
@@ -390,13 +391,51 @@ async function runScanners(deps, files, loop) {
390
391
  files,
391
392
  loop
392
393
  };
393
- const spawned = await Promise.all(SPAWN_SCANNERS.map((scanner) => runScanner(scanner, ctx, {
394
- which: deps.which,
395
- spawn: deps.spawn,
396
- timeout: deps.timeoutMs
397
- })));
398
- const eslint = await runEslintSonarjs(ctx);
399
- const results = [...spawned, eslint];
394
+ const requested = deps.tools ? new Set(deps.tools) : void 0;
395
+ const shouldRun = (tool) => requested === void 0 || requested.has(tool);
396
+ const runWithEvents = async (scanner) => {
397
+ deps.bus?.emit({
398
+ type: "scanner-start",
399
+ loop,
400
+ tool: scanner.tool
401
+ });
402
+ const result = await runScanner(scanner, ctx, {
403
+ which: deps.which,
404
+ spawn: deps.spawn,
405
+ timeout: deps.timeoutMs
406
+ });
407
+ const status = scannerStatus(result);
408
+ deps.bus?.emit({
409
+ type: "scanner-result",
410
+ loop,
411
+ tool: scanner.tool,
412
+ status: status.status,
413
+ findings: result.findings.length,
414
+ reason: status.reason
415
+ });
416
+ return result;
417
+ };
418
+ const spawnedPromise = Promise.all(SPAWN_SCANNERS.filter((scanner) => shouldRun(scanner.tool)).map(runWithEvents));
419
+ const eslintPromise = shouldRun("sonarjs") ? (async () => {
420
+ deps.bus?.emit({
421
+ type: "scanner-start",
422
+ loop,
423
+ tool: "sonarjs"
424
+ });
425
+ const result = await runEslintSonarjs(ctx);
426
+ const status = scannerStatus(result);
427
+ deps.bus?.emit({
428
+ type: "scanner-result",
429
+ loop,
430
+ tool: "sonarjs",
431
+ status: status.status,
432
+ findings: result.findings.length,
433
+ reason: status.reason
434
+ });
435
+ return result;
436
+ })() : Promise.resolve(void 0);
437
+ const [spawned, eslint] = await Promise.all([spawnedPromise, eslintPromise]);
438
+ const results = eslint ? [...spawned, eslint] : spawned;
400
439
  return {
401
440
  results,
402
441
  scannerStatuses: results.map(scannerStatus)
@@ -729,8 +768,8 @@ function renderPrompt(unit) {
729
768
  });
730
769
  }
731
770
  function firstRelevantLines(output, max = 20) {
732
- const lines = output.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
733
- return lines.slice(0, max).join("\n") || "(none)";
771
+ const lines$1 = output.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
772
+ return lines$1.slice(0, max).join("\n") || "(none)";
734
773
  }
735
774
  function renderFindingsJson(findings) {
736
775
  const data = findings.map((f) => ({
@@ -789,12 +828,21 @@ function classFromOutcome(reason, fallback) {
789
828
  * session error reverts the files to the snapshot. Nothing changed → not a fix.
790
829
  */
791
830
  function makeFixUnit(deps) {
792
- return async (unit) => {
831
+ return async (unit, loop = 0) => {
832
+ const progress = (stage, detail) => {
833
+ deps.onProgress?.({
834
+ loop,
835
+ file: unit.file,
836
+ stage,
837
+ detail
838
+ });
839
+ };
793
840
  const snapshotFiles = unit.strategy === "generated-source-repair" ? [...new Set([...unit.files, ...unit.verificationTargets ?? []])] : unit.files;
794
841
  const before = snapshotUnitFiles(deps.cwd, snapshotFiles);
795
842
  const restore = () => restoreSnapshot(deps.cwd, before);
796
843
  const changedOnDisk = () => unitChanged(deps.cwd, unit.files, before);
797
844
  let usage = zeroUsage();
845
+ progress("ai-edit");
798
846
  const res = await deps.session.run({
799
847
  file: unit.file,
800
848
  findings: unit.findings,
@@ -812,6 +860,7 @@ function makeFixUnit(deps) {
812
860
  };
813
861
  }
814
862
  if (!changedOnDisk()) {
863
+ progress("ai-no-edit-retry");
815
864
  const retry = await deps.session.run({
816
865
  file: unit.file,
817
866
  findings: unit.findings,
@@ -837,14 +886,17 @@ function makeFixUnit(deps) {
837
886
  };
838
887
  }
839
888
  async function scanNewFindings() {
889
+ progress("rescan");
840
890
  const verificationTargets = unit.verificationTargets ?? unit.files;
841
- const afterFindings = await deps.scanFindings(verificationTargets);
891
+ const scannerTools = [...new Set(unit.findings.map((finding) => finding.tool))];
892
+ const afterFindings = await deps.scanFindings(verificationTargets, scannerTools);
842
893
  const originalIds = new Set(unit.findings.map((f) => f.id));
843
894
  return afterFindings.filter((f) => !originalIds.has(f.id));
844
895
  }
845
896
  async function runRegressionRepair(outcome$1) {
846
897
  if (outcome$1.reason !== "regression" && outcome$1.reason !== "typecheck") return false;
847
898
  const after = snapshotUnitNow(deps.cwd, snapshotFiles);
899
+ progress("regression-repair");
848
900
  const repair = await deps.session.run({
849
901
  file: unit.file,
850
902
  findings: unit.findings,
@@ -867,6 +919,7 @@ function makeFixUnit(deps) {
867
919
  async function gateCurrent() {
868
920
  return gateUnitChanges(unit, before, deps, {
869
921
  usage,
922
+ onProgress: progress,
870
923
  repair: async (_attempt, regressed) => {
871
924
  const after = snapshotUnitNow(deps.cwd, snapshotFiles);
872
925
  const repair = await deps.session.run({
@@ -904,6 +957,244 @@ function makeFixUnit(deps) {
904
957
  };
905
958
  }
906
959
 
960
+ //#endregion
961
+ //#region src/fixing/worker-sandbox.ts
962
+ var SandboxSetupError = class extends Error {
963
+ constructor(message) {
964
+ super(message);
965
+ this.name = "SandboxSetupError";
966
+ }
967
+ };
968
+ const cleanExcludes = [
969
+ "node_modules",
970
+ "node_modules/**",
971
+ "**/node_modules",
972
+ "**/node_modules/**"
973
+ ];
974
+ function normalizeRel(path) {
975
+ return path.replaceAll("\\", "/").replace(/^\.?\//, "");
976
+ }
977
+ function lines(raw) {
978
+ return raw.split("\n").map((line) => normalizeRel(line.trim())).filter(Boolean);
979
+ }
980
+ function unique(values) {
981
+ return [...new Set(values.map(normalizeRel))];
982
+ }
983
+ function isGeneratedRepair(unit) {
984
+ return unit.strategy === "generated-source-repair" || unit.strategies?.includes("generated-source-repair") === true;
985
+ }
986
+ function allowedPatchFiles(unit) {
987
+ return unique([...unit.files, ...isGeneratedRepair(unit) ? unit.verificationTargets ?? [] : []]);
988
+ }
989
+ function installArgs(pm) {
990
+ switch (pm) {
991
+ case "pnpm": return [
992
+ "install",
993
+ "--frozen-lockfile",
994
+ "--prefer-offline"
995
+ ];
996
+ case "yarn": return [
997
+ "install",
998
+ "--frozen-lockfile",
999
+ "--prefer-offline"
1000
+ ];
1001
+ case "bun": return ["install", "--frozen-lockfile"];
1002
+ case "npm": return ["ci", "--prefer-offline"];
1003
+ }
1004
+ }
1005
+ var GitWorkerSandbox = class {
1006
+ prepared = false;
1007
+ constructor(cwd$1, deps) {
1008
+ this.cwd = cwd$1;
1009
+ this.deps = deps;
1010
+ }
1011
+ async reset() {
1012
+ const git = createGit(this.cwd);
1013
+ await git.raw([
1014
+ "reset",
1015
+ "--hard",
1016
+ this.deps.snapshotSha
1017
+ ]);
1018
+ await git.raw([
1019
+ "clean",
1020
+ "-ffdx",
1021
+ ...cleanExcludes.flatMap((pattern) => ["-e", pattern])
1022
+ ]);
1023
+ }
1024
+ async prepare() {
1025
+ if (this.prepared || this.deps.prepareDependencies === false) return;
1026
+ const args = installArgs(this.deps.packageManager);
1027
+ const result = await this.deps.exec(this.deps.packageManager, args, {
1028
+ cwd: this.cwd,
1029
+ reject: false,
1030
+ timeout: 10 * 6e4
1031
+ });
1032
+ if ((result.exitCode ?? 1) !== 0) throw new SandboxSetupError(`sandbox dependency install failed: ${result.stderr || result.stdout || `exit ${result.exitCode ?? 1}`}`);
1033
+ this.prepared = true;
1034
+ }
1035
+ async collectPatch(unit) {
1036
+ const git = createGit(this.cwd);
1037
+ const tracked = lines(await git.raw([
1038
+ "diff",
1039
+ "--name-only",
1040
+ this.deps.snapshotSha
1041
+ ]));
1042
+ const untracked = lines(await git.raw([
1043
+ "ls-files",
1044
+ "--others",
1045
+ "--exclude-standard"
1046
+ ]));
1047
+ const changedFiles = unique([...tracked, ...untracked]).sort();
1048
+ const allowed = new Set(allowedPatchFiles(unit));
1049
+ const unowned = changedFiles.filter((file) => !allowed.has(file));
1050
+ if (unowned.length > 0) return {
1051
+ ok: false,
1052
+ reason: "unowned-patch",
1053
+ detail: `Worker modified unowned files: ${unowned.join(", ")}`,
1054
+ changedFiles
1055
+ };
1056
+ const allowedFiles = [...allowed].sort();
1057
+ if (allowedFiles.length === 0 || changedFiles.length === 0) return {
1058
+ ok: true,
1059
+ patch: "",
1060
+ changedFiles
1061
+ };
1062
+ if (untracked.length > 0) await git.raw([
1063
+ "add",
1064
+ "-N",
1065
+ "--",
1066
+ ...untracked.filter((file) => allowed.has(file))
1067
+ ]);
1068
+ const patch = await git.raw([
1069
+ "diff",
1070
+ "--binary",
1071
+ this.deps.snapshotSha,
1072
+ "--",
1073
+ ...allowedFiles
1074
+ ]);
1075
+ return {
1076
+ ok: true,
1077
+ patch,
1078
+ changedFiles
1079
+ };
1080
+ }
1081
+ async dispose() {
1082
+ const git = createGit(this.deps.mainRoot);
1083
+ try {
1084
+ await git.raw([
1085
+ "worktree",
1086
+ "remove",
1087
+ "--force",
1088
+ this.cwd
1089
+ ]);
1090
+ } finally {
1091
+ rmSync(this.cwd, {
1092
+ recursive: true,
1093
+ force: true
1094
+ });
1095
+ }
1096
+ }
1097
+ };
1098
+ var WorkerSandboxPool = class {
1099
+ queue;
1100
+ applyQueue = new PQueue({ concurrency: 1 });
1101
+ idle = [];
1102
+ sandboxes = new Set();
1103
+ counter = 0;
1104
+ disposed = false;
1105
+ exec;
1106
+ constructor(deps) {
1107
+ this.deps = deps;
1108
+ this.queue = new PQueue({ concurrency: deps.maxSandboxes });
1109
+ this.exec = deps.exec ?? execa;
1110
+ }
1111
+ async withSandbox(run) {
1112
+ return this.queue.add(async () => {
1113
+ let sandbox;
1114
+ try {
1115
+ sandbox = await this.acquire();
1116
+ } catch (error) {
1117
+ throw new SandboxSetupError(error instanceof Error ? error.message : String(error));
1118
+ }
1119
+ try {
1120
+ await sandbox.reset();
1121
+ await sandbox.prepare();
1122
+ } catch (error) {
1123
+ this.idle.push(sandbox);
1124
+ throw error instanceof SandboxSetupError ? error : new SandboxSetupError(error instanceof Error ? error.message : String(error));
1125
+ }
1126
+ try {
1127
+ return await run(sandbox);
1128
+ } finally {
1129
+ this.idle.push(sandbox);
1130
+ }
1131
+ });
1132
+ }
1133
+ async applyPatchToMain(patch) {
1134
+ return this.applyQueue.add(async () => {
1135
+ if (patch.trim() === "") return { ok: true };
1136
+ const check = await this.exec("git", [
1137
+ "apply",
1138
+ "--check",
1139
+ "--3way"
1140
+ ], {
1141
+ cwd: this.deps.mainRoot,
1142
+ input: patch,
1143
+ reject: false
1144
+ });
1145
+ if ((check.exitCode ?? 1) !== 0) return {
1146
+ ok: false,
1147
+ reason: "patch-conflict",
1148
+ detail: check.stderr || check.stdout || "git apply --check --3way failed"
1149
+ };
1150
+ const applied = await this.exec("git", ["apply", "--3way"], {
1151
+ cwd: this.deps.mainRoot,
1152
+ input: patch,
1153
+ reject: false
1154
+ });
1155
+ if ((applied.exitCode ?? 1) !== 0) return {
1156
+ ok: false,
1157
+ reason: "patch-conflict",
1158
+ detail: applied.stderr || applied.stdout || "git apply --3way failed"
1159
+ };
1160
+ return { ok: true };
1161
+ });
1162
+ }
1163
+ async dispose() {
1164
+ if (this.disposed) return;
1165
+ this.disposed = true;
1166
+ await this.queue.onIdle();
1167
+ await Promise.all([...this.sandboxes].map((sandbox) => sandbox.dispose()));
1168
+ this.idle.length = 0;
1169
+ this.sandboxes.clear();
1170
+ }
1171
+ async acquire() {
1172
+ const existing = this.idle.pop();
1173
+ if (existing) return existing;
1174
+ const parent = this.deps.tempRoot ?? tmpdir();
1175
+ mkdirSync(parent, { recursive: true });
1176
+ const path = `${parent}/tend-worker-${process.pid}-${this.counter++}`;
1177
+ const mainGit = createGit(this.deps.mainRoot);
1178
+ await mainGit.raw([
1179
+ "worktree",
1180
+ "add",
1181
+ "--detach",
1182
+ path,
1183
+ this.deps.snapshotSha
1184
+ ]);
1185
+ const sandbox = new GitWorkerSandbox(path, {
1186
+ ...this.deps,
1187
+ exec: this.exec
1188
+ });
1189
+ this.sandboxes.add(sandbox);
1190
+ return sandbox;
1191
+ }
1192
+ };
1193
+ function mapOwnerRoot(mainRoot, mainOwnerRoot, sandboxRoot) {
1194
+ const rel = normalizeRel(relative(mainRoot, mainOwnerRoot));
1195
+ return rel === "" ? sandboxRoot : `${sandboxRoot}/${rel}`;
1196
+ }
1197
+
907
1198
  //#endregion
908
1199
  //#region src/output/env.ts
909
1200
  /** Truthy in the env-var sense: present and not an explicit off value. */
@@ -939,6 +1230,28 @@ function detectOutputEnv(input = {}) {
939
1230
  };
940
1231
  }
941
1232
 
1233
+ //#endregion
1234
+ //#region src/fixing/progress.ts
1235
+ const LABELS = {
1236
+ "ai-edit": "AI edit",
1237
+ "ai-no-edit-retry": "AI retry",
1238
+ "anti-suppression": "suppression check",
1239
+ typecheck: "typecheck",
1240
+ build: "build",
1241
+ "related-tests": "related tests",
1242
+ "test-repair": "test repair",
1243
+ rescan: "rescan",
1244
+ "regression-check": "regression check",
1245
+ "regression-repair": "regression repair",
1246
+ "patch-apply": "patch apply",
1247
+ "patch-conflict": "patch conflict",
1248
+ "sandbox-setup": "sandbox setup",
1249
+ "final-integration": "final integration"
1250
+ };
1251
+ function fixStageLabel(stage) {
1252
+ return LABELS[stage];
1253
+ }
1254
+
942
1255
  //#endregion
943
1256
  //#region src/output/base-reporter.ts
944
1257
  var BaseReporter = class {
@@ -987,6 +1300,7 @@ var LiveReporter = class extends BaseReporter {
987
1300
  audits = new Channel();
988
1301
  phases = new Channel();
989
1302
  fixTicks = new Channel();
1303
+ loopCompletions = new Channel();
990
1304
  closed = false;
991
1305
  resolveClosed;
992
1306
  closedSignal = new Promise((resolve$1) => {
@@ -1002,7 +1316,11 @@ var LiveReporter = class extends BaseReporter {
1002
1316
  currentFile;
1003
1317
  currentConcurrency;
1004
1318
  rules = new Map();
1319
+ stages = new Map();
1320
+ scannerStates = new Map();
1321
+ currentScanLoop;
1005
1322
  header;
1323
+ scanHeader;
1006
1324
  labelWidth = 0;
1007
1325
  constructor(deps) {
1008
1326
  super(deps);
@@ -1018,6 +1336,26 @@ var LiveReporter = class extends BaseReporter {
1018
1336
  scanned: event.scanned
1019
1337
  });
1020
1338
  break;
1339
+ case "scan-start":
1340
+ this.currentScanLoop = event.loop;
1341
+ this.scannerStates.clear();
1342
+ this.scanStarts.push(event.loop);
1343
+ this.refreshScanHeader();
1344
+ break;
1345
+ case "scanner-start":
1346
+ if (event.loop !== this.currentScanLoop) break;
1347
+ this.scannerStates.set(event.tool, { status: "running" });
1348
+ this.refreshScanHeader();
1349
+ break;
1350
+ case "scanner-result":
1351
+ if (event.loop !== this.currentScanLoop) break;
1352
+ this.scannerStates.set(event.tool, {
1353
+ status: event.status,
1354
+ findings: event.findings,
1355
+ reason: event.reason
1356
+ });
1357
+ this.refreshScanHeader();
1358
+ break;
1021
1359
  case "loop-start":
1022
1360
  this.currentLoop = event.loop;
1023
1361
  this.fixTotal = event.files.length;
@@ -1029,6 +1367,7 @@ var LiveReporter = class extends BaseReporter {
1029
1367
  this.currentFile = void 0;
1030
1368
  this.currentConcurrency = event.concurrency;
1031
1369
  this.rules.clear();
1370
+ this.stages.clear();
1032
1371
  this.labelWidth = Math.max(0, ...event.files.map((f) => basename(f).length));
1033
1372
  this.phases.push({
1034
1373
  kind: "fix",
@@ -1041,12 +1380,19 @@ var LiveReporter = class extends BaseReporter {
1041
1380
  break;
1042
1381
  case "file-start":
1043
1382
  this.started += 1;
1383
+ this.fixTotal = Math.max(this.fixTotal, this.started);
1044
1384
  this.currentFile = event.file;
1045
1385
  if (event.rule) this.rules.set(event.file, event.rule);
1046
1386
  this.refreshHeader();
1047
1387
  break;
1388
+ case "file-stage":
1389
+ this.currentFile = event.file;
1390
+ this.stages.set(event.file, event.stage);
1391
+ this.refreshHeader();
1392
+ break;
1048
1393
  case "file-result":
1049
1394
  this.finished += 1;
1395
+ this.fixTotal = Math.max(this.fixTotal, this.started, this.finished);
1050
1396
  if (event.outcome === "fixed") this.fixed += 1;
1051
1397
  else if (event.outcome === "reverted") this.reverted += 1;
1052
1398
  else this.notAttempted += 1;
@@ -1054,15 +1400,15 @@ var LiveReporter = class extends BaseReporter {
1054
1400
  this.refreshHeader();
1055
1401
  this.fixTicks.push();
1056
1402
  break;
1403
+ case "loop-complete":
1404
+ this.loopCompletions.push(event.loop);
1405
+ this.refreshHeader();
1406
+ break;
1057
1407
  case "done":
1058
1408
  this.phases.push({ kind: "done" });
1059
1409
  break;
1060
- case "scan-start":
1061
- this.scanStarts.push(event.loop);
1062
- break;
1063
1410
  case "snapshot":
1064
- case "detected":
1065
- case "loop-complete": break;
1411
+ case "detected": break;
1066
1412
  }
1067
1413
  }
1068
1414
  close() {
@@ -1090,6 +1436,7 @@ var LiveReporter = class extends BaseReporter {
1090
1436
  const list = new Listr([{
1091
1437
  title: this.theme.dim("scanning…"),
1092
1438
  task: async (_ctx, task) => {
1439
+ this.scanHeader = task;
1093
1440
  const loop = await this.race(this.scanStarts.take());
1094
1441
  if (loop === CLOSED) {
1095
1442
  live = false;
@@ -1102,6 +1449,7 @@ var LiveReporter = class extends BaseReporter {
1102
1449
  return;
1103
1450
  }
1104
1451
  task.title = this.scannedTitle(audit);
1452
+ this.scanHeader = void 0;
1105
1453
  }
1106
1454
  }], this.listrOptions());
1107
1455
  await list.run();
@@ -1116,10 +1464,11 @@ var LiveReporter = class extends BaseReporter {
1116
1464
  this.currentLoop = info.loop;
1117
1465
  this.currentConcurrency = info.concurrency;
1118
1466
  task.title = this.headerTitle();
1119
- while (this.finished < this.fixTotal) {
1120
- const tick = await this.race(this.fixTicks.take());
1121
- if (tick === CLOSED) return;
1467
+ while (true) {
1468
+ const tickOrComplete = await this.race(Promise.race([this.fixTicks.take().then(() => "tick"), this.loopCompletions.take().then(() => "complete")]));
1469
+ if (tickOrComplete === CLOSED) return;
1122
1470
  task.title = this.headerTitle();
1471
+ if (tickOrComplete === "complete") break;
1123
1472
  }
1124
1473
  }
1125
1474
  }], this.listrOptions());
@@ -1158,7 +1507,17 @@ var LiveReporter = class extends BaseReporter {
1158
1507
  return meta;
1159
1508
  }
1160
1509
  scanTitle(loop) {
1161
- return this.theme.dim(loop === 1 ? "initial audit: scanning…" : `re-audit after fix pass ${loop - 1}: scanning…`);
1510
+ const detail = this.scannerDetail();
1511
+ return this.theme.dim(loop === 1 ? `initial audit: scanning…${detail}` : `re-audit after fix pass ${loop - 1}: scanning…${detail}`);
1512
+ }
1513
+ scannerDetail() {
1514
+ const entries = [...this.scannerStates.entries()];
1515
+ if (entries.length === 0) return "";
1516
+ const running = entries.filter(([, info]) => info.status === "running");
1517
+ const done = entries.length - running.length;
1518
+ if (running.length === 0) return ` ${this.theme.glyph.bullet} scanners ${done}/${entries.length} done`;
1519
+ const runningTools = running.map(([tool]) => tool).join(", ");
1520
+ return ` ${this.theme.glyph.bullet} running ${runningTools} ${this.theme.glyph.bullet} ${done}/${entries.length} done`;
1162
1521
  }
1163
1522
  headerTitle() {
1164
1523
  const running = Math.max(0, this.started - this.finished);
@@ -1173,12 +1532,17 @@ var LiveReporter = class extends BaseReporter {
1173
1532
  refreshHeader() {
1174
1533
  if (this.header) this.header.title = this.headerTitle();
1175
1534
  }
1535
+ refreshScanHeader() {
1536
+ if (this.scanHeader && this.currentScanLoop !== void 0) this.scanHeader.title = this.scanTitle(this.currentScanLoop);
1537
+ }
1176
1538
  fileLabel(file) {
1177
1539
  return basename(file).padEnd(this.labelWidth);
1178
1540
  }
1179
1541
  fileTitle(file) {
1180
1542
  const rule = this.rules.get(file);
1181
- const suffix = rule ? ` ${this.theme.dim(rule)}` : "";
1543
+ const stage = this.stages.get(file);
1544
+ const detail = [rule, stage ? fixStageLabel(stage) : void 0].filter(Boolean).join(" · ");
1545
+ const suffix = detail ? ` ${this.theme.dim(detail)}` : "";
1182
1546
  return `${this.fileLabel(file)}${suffix}`;
1183
1547
  }
1184
1548
  };
@@ -1200,6 +1564,15 @@ var PlainReporter = class extends BaseReporter {
1200
1564
  case "scan-start":
1201
1565
  this.write(event.loop === 1 ? "initial audit: scanning…" : `re-audit after fix pass ${event.loop - 1}: scanning…`);
1202
1566
  break;
1567
+ case "scanner-start":
1568
+ this.write(`scanner ${event.tool}: running`);
1569
+ break;
1570
+ case "scanner-result": {
1571
+ const count = event.status === "ran" ? ` ${event.findings} findings` : "";
1572
+ const reason = event.reason ? ` — ${event.reason}` : "";
1573
+ this.write(`scanner ${event.tool}: ${event.status}${count}${reason}`);
1574
+ break;
1575
+ }
1203
1576
  case "audit": {
1204
1577
  const scope = event.scanned != null ? `${event.scanned} files eligible for fixes` : "whole repo";
1205
1578
  const phase = event.loop === 1 ? "initial audit" : `re-audit after fix pass ${event.loop - 1}`;
@@ -1214,6 +1587,9 @@ var PlainReporter = class extends BaseReporter {
1214
1587
  else if (event.outcome === "reverted") this.write(`${glyph.reverted} reverted ${event.file} — ${reasonLabel(event.reason)}`);
1215
1588
  else this.write(`${glyph.left} not attempted ${event.file}`);
1216
1589
  break;
1590
+ case "file-stage":
1591
+ this.write(`progress ${event.file}: ${fixStageLabel(event.stage)}${event.detail ? ` (${event.detail})` : ""}`);
1592
+ break;
1217
1593
  case "snapshot":
1218
1594
  case "detected":
1219
1595
  case "file-start":
@@ -1264,8 +1640,8 @@ function loadReport() {
1264
1640
  * executes in). Files are re-based onto `root` so `vitest related` / `jest
1265
1641
  * --findRelatedTests` resolve them inside the owning package, not the repo root.
1266
1642
  */
1267
- async function runTests(runner, files, root) {
1268
- const targets = toOwnerRelative(files, cwd, root);
1643
+ async function runTests(runner, files, root, repoRoot = cwd) {
1644
+ const targets = toOwnerRelative(files, repoRoot, root);
1269
1645
  const args = runner === "vitest" ? [
1270
1646
  "vitest",
1271
1647
  "related",
@@ -1295,79 +1671,181 @@ async function runTests(runner, files, root) {
1295
1671
  return [];
1296
1672
  }
1297
1673
  }
1298
- async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd) {
1299
- const typescript = detectTypeScript(ownerRoot);
1300
- const runner = detectTestRunner(ownerRoot) ?? null;
1674
+ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, bus, detected, sandboxPool) {
1675
+ const typescript = detected?.typescript ?? detectTypeScript(ownerRoot);
1676
+ const runner = detected?.runner ?? detectTestRunner(ownerRoot) ?? null;
1301
1677
  const buildArgs = detectBuildCommand(ownerRoot);
1302
1678
  const pm = detectPackageManager(ownerRoot);
1303
- const baseline = new Set(runner && baselineTargets.length > 0 ? (await runTests(runner, baselineTargets, ownerRoot)).filter((t) => t.status === "pass").map((t) => t.name) : []);
1304
- const session = new ClaudeSession({ spawn: async (req) => {
1305
- const r = await execa("claude", [
1306
- "-p",
1307
- req.prompt,
1308
- "--model",
1309
- config.model,
1310
- ...config.effort ? ["--effort", config.effort] : [],
1311
- "--output-format",
1312
- "stream-json",
1313
- "--verbose",
1314
- "--allowedTools",
1315
- "Read,Write,Edit"
1316
- ], {
1317
- cwd,
1318
- reject: false,
1319
- timeout: CLAUDE_TIMEOUT_MS
1320
- });
1321
- const exitCode = r.exitCode ?? (r.timedOut ? 143 : r.failed ? 1 : 0);
1322
- return {
1323
- stdout: typeof r.stdout === "string" ? r.stdout : "",
1324
- exitCode
1325
- };
1326
- } });
1327
- const gateDeps = {
1328
- cwd,
1329
- typescript,
1330
- runTsc: async () => {
1331
- const r = await execa("npx", ["tsc", "--noEmit"], {
1332
- cwd: ownerRoot,
1679
+ const baseline = new Set(runner && baselineTargets.length > 0 ? (await runTests(runner, baselineTargets, ownerRoot, cwd)).filter((t) => t.status === "pass").map((t) => t.name) : []);
1680
+ const makeGateDeps = (sandbox) => {
1681
+ const repoRoot = sandbox?.cwd ?? cwd;
1682
+ const gateOwnerRoot = sandbox ? mapOwnerRoot(cwd, ownerRoot, sandbox.cwd) : ownerRoot;
1683
+ const session = new ClaudeSession({ spawn: async (req) => {
1684
+ const r = await execa("claude", [
1685
+ "-p",
1686
+ req.prompt,
1687
+ "--model",
1688
+ config.model,
1689
+ ...config.effort ? ["--effort", config.effort] : [],
1690
+ "--output-format",
1691
+ "stream-json",
1692
+ "--verbose",
1693
+ "--allowedTools",
1694
+ "Read,Write,Edit"
1695
+ ], {
1696
+ cwd: repoRoot,
1333
1697
  reject: false,
1334
- timeout: TSC_TIMEOUT_MS
1698
+ timeout: CLAUDE_TIMEOUT_MS
1335
1699
  });
1700
+ const exitCode = r.exitCode ?? (r.timedOut ? 143 : r.failed ? 1 : 0);
1336
1701
  return {
1337
- exitCode: r.exitCode ?? 1,
1338
- output: `${r.stdout}\n${r.stderr}`
1702
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
1703
+ exitCode
1339
1704
  };
1340
- },
1341
- runBuild: buildArgs ? async () => {
1342
- const r = await execa(pm, buildArgs, {
1343
- cwd: ownerRoot,
1344
- reject: false,
1345
- timeout: BUILD_TIMEOUT_MS
1705
+ } });
1706
+ return {
1707
+ cwd: repoRoot,
1708
+ typescript,
1709
+ runTsc: async () => {
1710
+ const r = await execa("npx", ["tsc", "--noEmit"], {
1711
+ cwd: gateOwnerRoot,
1712
+ reject: false,
1713
+ timeout: TSC_TIMEOUT_MS
1714
+ });
1715
+ return {
1716
+ exitCode: r.exitCode ?? 1,
1717
+ output: `${r.stdout}\n${r.stderr}`
1718
+ };
1719
+ },
1720
+ runBuild: buildArgs ? async () => {
1721
+ const r = await execa(pm, buildArgs, {
1722
+ cwd: gateOwnerRoot,
1723
+ reject: false,
1724
+ timeout: BUILD_TIMEOUT_MS
1725
+ });
1726
+ return {
1727
+ exitCode: r.exitCode ?? 1,
1728
+ output: `${r.stdout}\n${r.stderr}`
1729
+ };
1730
+ } : void 0,
1731
+ hasTestRunner: Boolean(runner),
1732
+ runRelated: (files) => runner ? runTests(runner, files, gateOwnerRoot, repoRoot) : Promise.resolve([]),
1733
+ scanFindings: async (files, tools) => (await scanFiles({
1734
+ cwd: repoRoot,
1735
+ which: realWhich,
1736
+ spawn: realSpawn,
1737
+ timeoutMs: 12e4,
1738
+ tools
1739
+ }, files, 0)).findings,
1740
+ baseline,
1741
+ session
1742
+ };
1743
+ };
1744
+ const mainGateDeps = makeGateDeps();
1745
+ const acceptedFiles = new Set();
1746
+ const acceptedTools = new Set();
1747
+ const buildFixUnit = (sandbox) => makeFixUnit({
1748
+ ...makeGateDeps(sandbox),
1749
+ maxRepairs: 3,
1750
+ onProgress: (event) => bus?.emit({
1751
+ type: "file-stage",
1752
+ ...event
1753
+ })
1754
+ });
1755
+ const fixUnit = async (unit, loop) => {
1756
+ if (!sandboxPool) return buildFixUnit()(unit, loop);
1757
+ try {
1758
+ return await sandboxPool.withSandbox(async (sandbox) => {
1759
+ const outcome = await buildFixUnit(sandbox)(unit, loop);
1760
+ if (!outcome.kept) return outcome;
1761
+ bus?.emit({
1762
+ type: "file-stage",
1763
+ loop,
1764
+ file: unit.file,
1765
+ stage: "patch-apply"
1766
+ });
1767
+ const patch = await sandbox.collectPatch(unit);
1768
+ if (!patch.ok) return {
1769
+ kept: false,
1770
+ reason: "unowned-patch",
1771
+ detail: patch.detail,
1772
+ failureClass: "unowned-patch",
1773
+ usage: outcome.usage
1774
+ };
1775
+ const applied = await sandboxPool.applyPatchToMain(patch.patch);
1776
+ if (!applied.ok) {
1777
+ bus?.emit({
1778
+ type: "file-stage",
1779
+ loop,
1780
+ file: unit.file,
1781
+ stage: "patch-conflict"
1782
+ });
1783
+ return {
1784
+ kept: false,
1785
+ reason: "patch-conflict",
1786
+ detail: applied.detail,
1787
+ failureClass: "patch-conflict",
1788
+ usage: outcome.usage
1789
+ };
1790
+ }
1791
+ for (const file of patch.changedFiles) acceptedFiles.add(file);
1792
+ for (const finding of unit.findings) acceptedTools.add(finding.tool);
1793
+ return outcome;
1346
1794
  });
1795
+ } catch (error) {
1796
+ const detail = error instanceof Error ? error.message : String(error);
1797
+ const setupFailed = error instanceof SandboxSetupError;
1347
1798
  return {
1348
- exitCode: r.exitCode ?? 1,
1349
- output: `${r.stdout}\n${r.stderr}`
1799
+ kept: false,
1800
+ reason: setupFailed ? "sandbox-setup-failed" : "session-error",
1801
+ detail,
1802
+ failureClass: setupFailed ? "sandbox-setup-failed" : "model-tool-failure",
1803
+ usage: zeroUsage()
1350
1804
  };
1351
- } : void 0,
1352
- hasTestRunner: Boolean(runner),
1353
- runRelated: (files) => runner ? runTests(runner, files, ownerRoot) : Promise.resolve([]),
1354
- scanFindings: async (files) => (await scanFiles({
1355
- cwd,
1356
- which: realWhich,
1357
- spawn: realSpawn,
1358
- timeoutMs: 12e4
1359
- }, files, 0)).findings,
1360
- baseline
1805
+ }
1361
1806
  };
1362
1807
  return {
1363
1808
  typescript,
1364
1809
  runner,
1365
- fixUnit: makeFixUnit({
1366
- ...gateDeps,
1367
- session,
1368
- maxRepairs: 3
1369
- }),
1370
- deterministicFixUnit: makeDeterministicFixUnit(gateDeps)
1810
+ fixUnit,
1811
+ deterministicFixUnit: makeDeterministicFixUnit(mainGateDeps),
1812
+ finalIntegration: async () => {
1813
+ const files = [...acceptedFiles].sort();
1814
+ if (files.length === 0) return {
1815
+ ok: true,
1816
+ files
1817
+ };
1818
+ if (typescript) {
1819
+ const tc = await mainGateDeps.runTsc();
1820
+ if (tc.exitCode !== 0) return {
1821
+ ok: false,
1822
+ files,
1823
+ detail: `final integration typecheck failed: ${tc.output}`
1824
+ };
1825
+ }
1826
+ if (runner) {
1827
+ const tests = await mainGateDeps.runRelated(files);
1828
+ const failed = tests.filter((test) => test.status === "fail");
1829
+ if (failed.length > 0) return {
1830
+ ok: false,
1831
+ files,
1832
+ detail: `final integration related tests failed: ${failed.map((test) => test.name).join(", ")}`
1833
+ };
1834
+ }
1835
+ const tools = [...acceptedTools];
1836
+ if (tools.length > 0) {
1837
+ const findings = await mainGateDeps.scanFindings(files, tools);
1838
+ if (findings.length > 0) return {
1839
+ ok: false,
1840
+ files,
1841
+ detail: `final integration scanner rescan found ${findings.length} finding${findings.length === 1 ? "" : "s"}`
1842
+ };
1843
+ }
1844
+ return {
1845
+ ok: true,
1846
+ files
1847
+ };
1848
+ }
1371
1849
  };
1372
1850
  }
1373
1851
  function describeScopeNote(all, paths, scope) {
@@ -1389,6 +1867,8 @@ async function runRun(opts) {
1389
1867
  write: out
1390
1868
  });
1391
1869
  reporter.start();
1870
+ const bus = new EventBus();
1871
+ bus.on((e) => reporter.onEvent(e));
1392
1872
  const git = createGit(cwd);
1393
1873
  await assertGitRepo(git);
1394
1874
  if (opts.effort && !EFFORT_LEVELS.includes(opts.effort)) {
@@ -1427,16 +1907,27 @@ async function runRun(opts) {
1427
1907
  } else scope = await changedVsHead(git);
1428
1908
  const baselineTargets = scope ?? ["."];
1429
1909
  const ownerRoot = scope ? resolveOwnerRoot(cwd, scope) : cwd;
1430
- const { fixUnit, deterministicFixUnit, runner, typescript } = await makeProductionFixUnit(config, baselineTargets, ownerRoot);
1910
+ const typescript = detectTypeScript(ownerRoot);
1911
+ const runner = detectTestRunner(ownerRoot) ?? null;
1431
1912
  const pm = detectPackageManager(cwd);
1432
1913
  reporter.note(`${pm} · ${typescript ? "TypeScript" : "JavaScript"} · ${runner ?? "no test runner"} · ${modelLabel}`);
1433
1914
  const scopeNote = describeScopeNote(opts.all, paths, scope);
1434
1915
  reporter.note(`${scopeNote} · ${plural(available.length, "scanner")}`);
1435
- const bus = new EventBus();
1436
- bus.on((e) => reporter.onEvent(e));
1916
+ if (runner && baselineTargets.length > 0) reporter.note(`baseline: ${runner} related ${describeScopeNote(opts.all, paths, scope)} (one-time)`);
1917
+ const sandboxPool = new WorkerSandboxPool({
1918
+ mainRoot: snapshot.repoRoot(),
1919
+ snapshotSha: snapshot.commitSha(),
1920
+ maxSandboxes: config.maxSessions,
1921
+ packageManager: pm
1922
+ });
1923
+ const { fixUnit, deterministicFixUnit, finalIntegration } = await makeProductionFixUnit(config, baselineTargets, ownerRoot, bus, {
1924
+ typescript,
1925
+ runner
1926
+ }, sandboxPool);
1437
1927
  const start = Date.now();
1438
1928
  const drawing = reporter.run();
1439
1929
  let result;
1930
+ let finalIntegrationResult;
1440
1931
  try {
1441
1932
  result = await orchestrate({
1442
1933
  cwd,
@@ -1445,7 +1936,8 @@ async function runRun(opts) {
1445
1936
  which: realWhich,
1446
1937
  spawn: realSpawn,
1447
1938
  scope,
1448
- timeoutMs: 12e4
1939
+ timeoutMs: 12e4,
1940
+ bus
1449
1941
  }),
1450
1942
  fixUnit,
1451
1943
  deterministicFixUnit,
@@ -1453,7 +1945,10 @@ async function runRun(opts) {
1453
1945
  inScope: scope ? (fs) => filterToChanged(fs, scope) : void 0,
1454
1946
  bus
1455
1947
  });
1948
+ finalIntegrationResult = await finalIntegration();
1949
+ if (!finalIntegrationResult.ok) result.exitStatus = 1;
1456
1950
  } finally {
1951
+ await sandboxPool.dispose();
1457
1952
  reporter.close();
1458
1953
  }
1459
1954
  await drawing;
@@ -1473,7 +1968,8 @@ async function runRun(opts) {
1473
1968
  exclude: config.fix.exclude,
1474
1969
  includeGenerated: config.fix.includeGenerated,
1475
1970
  includeFixtures: config.fix.includeFixtures
1476
- }
1971
+ },
1972
+ finalIntegration: finalIntegrationResult
1477
1973
  });
1478
1974
  persist(REPORT_PATH, report);
1479
1975
  out("");
@@ -1496,6 +1992,7 @@ async function runRetry(id) {
1496
1992
  }
1497
1993
  const config = await loadConfig(cwd);
1498
1994
  let snapshotSaved = false;
1995
+ let retrySandboxPool;
1499
1996
  const result = await retryCommand(id, {
1500
1997
  report,
1501
1998
  baseBudget: config.perIssueBudget,
@@ -1517,12 +2014,20 @@ async function runRetry(id) {
1517
2014
  if (!snapshotSaved) {
1518
2015
  const snapshot = await Snapshot.capture(git, cwd);
1519
2016
  persist(SNAPSHOT_PATH, snapshot.toJSON());
2017
+ retrySandboxPool = new WorkerSandboxPool({
2018
+ mainRoot: snapshot.repoRoot(),
2019
+ snapshotSha: snapshot.commitSha(),
2020
+ maxSandboxes: config.maxSessions,
2021
+ packageManager: detectPackageManager(cwd)
2022
+ });
1520
2023
  snapshotSaved = true;
1521
2024
  }
1522
2025
  const ownerRoot = resolveOwnerRoot(cwd, unit.files);
1523
- const { fixUnit } = await makeProductionFixUnit(config, unit.files, ownerRoot);
2026
+ const { fixUnit } = await makeProductionFixUnit(config, unit.files, ownerRoot, void 0, void 0, retrySandboxPool);
1524
2027
  return fixUnit(unit, 1);
1525
2028
  }
2029
+ }).finally(async () => {
2030
+ await retrySandboxPool?.dispose();
1526
2031
  });
1527
2032
  if ("error" in result) {
1528
2033
  err(`✖ ${result.error}`);