aztrx-cli 0.4.3 → 0.4.4

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.
@@ -90,7 +90,7 @@ function hostAllowed(url, allowHosts) {
90
90
  return false;
91
91
  }
92
92
  /** Harvest candidate endpoints the app actually uses — not blind probing. */
93
- async function collectEndpoints(page, origin) {
93
+ async function collectEndpoints(page, origin, allowDestructive, seedUrls = []) {
94
94
  const seen = new Map();
95
95
  const push = (raw) => {
96
96
  let u;
@@ -104,11 +104,15 @@ async function collectEndpoints(page, origin) {
104
104
  return;
105
105
  if (STATIC_EXT.test(u.pathname))
106
106
  return;
107
- if (DESTRUCTIVE_PATH.test(u.pathname))
107
+ if (!allowDestructive && DESTRUCTIVE_PATH.test(u.pathname))
108
108
  return;
109
109
  if (!seen.has(u.pathname))
110
110
  seen.set(u.pathname, u);
111
111
  };
112
+ // Endpoints observed live by the caller — e.g. a `fetch()` fired from a click
113
+ // handler that never appears in `performance` resources or the DOM.
114
+ for (const s of seedUrls)
115
+ push(s);
112
116
  // URLs the page already fetched (API calls, RSC/data endpoints).
113
117
  const resources = await page
114
118
  .evaluate(() => performance.getEntriesByType("resource").map((e) => e.name))
@@ -191,11 +195,11 @@ export async function httpFuzz(page, targetUrl, bus, opts = {}) {
191
195
  const allowHosts = opts.allowHosts ?? new Set();
192
196
  if (!hostAllowed(targetUrl, allowHosts))
193
197
  return 0;
194
- if (!opts.dryRun) {
198
+ if (!opts.dryRun && opts.navigate !== false) {
195
199
  await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => { });
196
200
  await page.waitForTimeout(500);
197
201
  }
198
- const endpoints = await collectEndpoints(page, new URL(targetUrl).origin);
202
+ const endpoints = await collectEndpoints(page, new URL(targetUrl).origin, Boolean(opts.allowDestructive), opts.seedUrls ?? []);
199
203
  endpoints.sort((a, b) => a.pathname.localeCompare(b.pathname));
200
204
  let sent = 0;
201
205
  outer: for (const endpoint of endpoints) {
@@ -3,6 +3,7 @@ import pc from "picocolors";
3
3
  import { VERSION } from "./version.js";
4
4
  import { EventBus } from "./eventBus.js";
5
5
  import { loadBaseline } from "./classifier.js";
6
+ import { diagnoseFinding } from "./diagnose.js";
6
7
  import { attachNetworkGuard, allowHostsFrom } from "./networkGuard.js";
7
8
  import { swarmDetect } from "./swarm.js";
8
9
  import { ReplayEngine } from "./replay.js";
@@ -14,18 +15,23 @@ import { RunLog } from "./events.js";
14
15
  import { heal } from "./heal/index.js";
15
16
  import { submitTelemetry } from "./telemetry/index.js";
16
17
  import { submitRun } from "./cloud/index.js";
18
+ import { formatDiff } from "./diff.js";
17
19
  const SEVERITY_MARK = {
18
20
  crash: pc.red("● crash "),
19
21
  error: pc.red("● error "),
20
22
  warning: pc.yellow("○ warning "),
21
23
  noise: pc.dim("○ noise "),
22
24
  };
23
- function printFinding(f, write) {
25
+ function printFinding(f, write, lang) {
24
26
  write(SEVERITY_MARK[f.severity] + pc.bold(f.rawMessage));
25
27
  if (f.mappedLocation) {
26
28
  write(pc.dim(` ${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}`));
27
29
  write(pc.dim(f.mappedLocation.codeContext));
28
30
  }
31
+ // F14 — the one-line "why + fix" diagnosis, inline with every crash/error.
32
+ const dx = diagnoseFinding(f, lang);
33
+ if (dx)
34
+ write(pc.cyan(` ↳ ${dx}`));
29
35
  if (f.serverError) {
30
36
  write(pc.dim(` server: ${f.serverError.message}`));
31
37
  }
@@ -78,6 +84,10 @@ export async function run(options) {
78
84
  say(pc.dim(`Net: deny-by-default → allow ${[...allowHosts].join(", ") || "origin"}`));
79
85
  if (options.storageState)
80
86
  say(pc.dim(`Auth: ${options.storageState}`));
87
+ if (options.allowDestructive) {
88
+ say(pc.yellow(pc.bold("⚠ DESTRUCTIVE MODE — delete/pay/logout/checkout controls are ENABLED.")));
89
+ say(pc.yellow(" This can mutate real data. Run only against a disposable/dev instance you own."));
90
+ }
81
91
  say("");
82
92
  emitPhase("launch", url);
83
93
  const runLog = new RunLog(repoRoot);
@@ -85,10 +95,11 @@ export async function run(options) {
85
95
  runLog.append({ type: "run_start", url, ts: Date.now() });
86
96
  const baseline = await loadBaseline(repoRoot);
87
97
  const workers = options.workers ?? 1;
88
- // F-swarm — parallel detection. One worker is the legacy pass; `--http-fuzz`
89
- // or `workers > 1` fan out into a swarm. Findings come back merged by
98
+ // F-swarm — parallel detection. One worker is the legacy pass; `workers > 1`
99
+ // fans out into a swarm. `--http-fuzz` folds into the walk/fuzz pass as a
100
+ // post-pass on the same page (no extra worker). Findings come back merged by
90
101
  // fingerprint, with per-worker action history already attached.
91
- if (workers > 1 || options.httpFuzz) {
102
+ if (workers > 1) {
92
103
  emitPhase("swarm", `${workers} worker(s)`);
93
104
  }
94
105
  else if (options.fuzz) {
@@ -105,6 +116,7 @@ export async function run(options) {
105
116
  fuzz: options.fuzz,
106
117
  httpFuzz: options.httpFuzz,
107
118
  httpFuzzMutations: options.httpFuzzMutations,
119
+ allowDestructive: options.allowDestructive,
108
120
  seed: options.seed ?? 42,
109
121
  workers,
110
122
  allowHosts,
@@ -125,7 +137,7 @@ export async function run(options) {
125
137
  for (const f of findings) {
126
138
  bus.emit("finding", f);
127
139
  runLog.append({ type: "finding", finding: f });
128
- printFinding(f, say);
140
+ printFinding(f, say, options.lang);
129
141
  }
130
142
  if (workerCount > 1) {
131
143
  say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s) — ${roles.join(", ")}.\n`));
@@ -235,7 +247,9 @@ export async function run(options) {
235
247
  f.type !== "network_timeout" &&
236
248
  f.repro &&
237
249
  f.repro.verdict !== "unreliable");
238
- const healTargets = reproducible.filter((f) => f.mappedLocation?.isOwnCode);
250
+ const skip = new Set(options.skipHealFingerprints ?? []);
251
+ const candidates = reproducible.filter((f) => !skip.has(f.fingerprint));
252
+ const healTargets = candidates.filter((f) => f.mappedLocation?.isOwnCode);
239
253
  if (healTargets.length) {
240
254
  say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → test → verify) —"));
241
255
  emitPhase("heal");
@@ -254,6 +268,7 @@ export async function run(options) {
254
268
  testTimeoutMs: options.testTimeoutMs,
255
269
  skipTest: options.skipTest,
256
270
  startCommand: options.startCommand,
271
+ budget: options.budget,
257
272
  });
258
273
  f.heal = result;
259
274
  bus.emit("heal", {
@@ -273,8 +288,14 @@ export async function run(options) {
273
288
  ? pc.green(" ✓ healed")
274
289
  : result.status === "unfixed"
275
290
  ? pc.yellow(" ◐ unfixed")
276
- : pc.red(` ✗ ${result.status}`);
291
+ : result.status === "budget-exhausted"
292
+ ? pc.dim(" ⏹ budget exhausted")
293
+ : pc.red(` ✗ ${result.status}`);
277
294
  say(`${mark} ${pc.bold(f.rawMessage.split("\n")[0].slice(0, 60))}`);
295
+ if (result.status === "healed" && result.hunks.length > 0) {
296
+ say(pc.dim(` ${result.filePath}`));
297
+ say(formatDiff(result.hunks));
298
+ }
278
299
  if (result.patchPath)
279
300
  say(pc.dim(` patch: ${path.relative(repoRoot, result.patchPath)}`));
280
301
  if (result.error)
@@ -295,13 +316,14 @@ export async function run(options) {
295
316
  }
296
317
  }
297
318
  }
298
- else if (reproducible.length > 0) {
319
+ else if (candidates.length > 0) {
299
320
  // Reproducible crashes, but none mapped to source (wrong --repo?) — tell the
300
- // user rather than silently doing nothing.
301
- say(pc.yellow(`Found ${reproducible.length} reproducible crash(es) but couldn't map them to source files. Run from your project root (or pass --repo <dir>) so --fix can read the code.`));
321
+ // user rather than silently doing nothing. Already-handled fingerprints are
322
+ // excluded above, so this only fires for genuinely new findings.
323
+ say(pc.yellow(`Found ${candidates.length} reproducible crash(es) but couldn't map them to source files. Run from your project root (or pass --repo <dir>) so --fix can read the code.`));
302
324
  }
303
325
  }
304
- const reportPath = writeReport(repoRoot, url, findings);
326
+ const reportPath = writeReport(repoRoot, url, findings, options.lang);
305
327
  say(pc.dim(`Report: ${path.relative(repoRoot, reportPath)}`));
306
328
  const counts = {};
307
329
  for (const f of findings)
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `aztrx patrol` — the autonomous bug-patrol loop. It wraps the existing
3
+ * `run()` detect → repro → heal pipeline in a supervisor loop with memory, so
4
+ * instead of "run it and read the output" the tool re-scans on an interval,
5
+ * fixes anything new, and opens a PR per bug — no human in the middle.
6
+ *
7
+ * Guardrails that keep an autonomous run from going off the rails:
8
+ * - memory (PatrolState) — a handled fingerprint is never re-fixed/re-PR'd;
9
+ * - per-session fix cap (`maxFixes`) — bounds LLM spend + PR spam;
10
+ * - scoped staging — PRs carry only the files the patch touched;
11
+ * - a liveness check — a dead target skips the cycle instead of burning a crawl.
12
+ */
13
+ import pc from "picocolors";
14
+ import { run } from "../orchestrator.js";
15
+ import { applyVerifiedPatches } from "../heal/apply.js";
16
+ import { PatrolState } from "./state.js";
17
+ import { openPatrolPr, openPatrolBatchPr } from "./pr.js";
18
+ import { recordFindingGif } from "./record.js";
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+ /** Any HTTP response means the server is up; only a network failure is "down". */
21
+ async function isAlive(url) {
22
+ try {
23
+ await fetch(url, { signal: AbortSignal.timeout(3000) });
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ function head(f) {
31
+ return f.rawMessage.split("\n")[0].slice(0, 50);
32
+ }
33
+ /** Best-effort GIF recording — a failed capture must never block a PR. */
34
+ async function recordGif(repoRoot, url, f) {
35
+ try {
36
+ return await recordFindingGif(repoRoot, url, f);
37
+ }
38
+ catch (e) {
39
+ console.log(pc.dim(` · recorded repro skipped: ${e.message}`));
40
+ return null;
41
+ }
42
+ }
43
+ export async function patrol(opts) {
44
+ const retryAfterMs = opts.retryAfterMs ?? 30 * 60 * 1000;
45
+ const state = new PatrolState(opts.repoRoot, opts.url, retryAfterMs);
46
+ const maxFixes = opts.maxFixes ?? 5;
47
+ let sessionFixes = 0;
48
+ let sessionPrs = 0;
49
+ const seenFp = new Set();
50
+ // One budget object is shared across every cycle so the cap spans the session,
51
+ // not just a single run.
52
+ const budget = opts.maxSpend && opts.maxSpend > 0 ? { remaining: opts.maxSpend } : undefined;
53
+ console.log(pc.cyan("Aztrx AI — patrol"));
54
+ console.log(pc.dim(`Target: ${opts.url} interval: ${Math.round(opts.intervalMs / 1000)}s max fixes/session: ${maxFixes}` +
55
+ ` retry unfixed after: ${Math.round(retryAfterMs / 1000)}s` +
56
+ (budget ? ` spend cap: ${budget.remaining} generations` : "")));
57
+ console.log("");
58
+ for (let cycle = 1;; cycle++) {
59
+ if (!(await isAlive(opts.url))) {
60
+ console.log(pc.dim(`[cycle ${cycle}] target not responding — skipping this pass.`));
61
+ if (opts.once)
62
+ break;
63
+ await sleep(opts.intervalMs);
64
+ continue;
65
+ }
66
+ console.log(pc.dim(`[cycle ${cycle}] scanning…`));
67
+ let findings;
68
+ try {
69
+ // `ui: true` silences the per-run console so patrol prints its own concise
70
+ // summary instead of the full pipeline log on every cycle.
71
+ findings = await run({
72
+ url: opts.url,
73
+ repoRoot: opts.repoRoot,
74
+ maxActions: opts.maxActions,
75
+ fuzz: opts.fuzz,
76
+ workers: opts.workers,
77
+ allowHosts: opts.allowHosts,
78
+ storageState: opts.storageState,
79
+ login: opts.login,
80
+ loginEmail: opts.loginEmail,
81
+ loginPassword: opts.loginPassword,
82
+ loginUrl: opts.loginUrl,
83
+ lang: opts.lang,
84
+ seed: opts.seed,
85
+ allowDestructive: opts.allowDestructive,
86
+ httpFuzz: opts.httpFuzz,
87
+ httpFuzzMutations: opts.httpFuzzMutations,
88
+ repro: true,
89
+ heal: true,
90
+ healModel: opts.healModel,
91
+ healFastModel: opts.healFastModel,
92
+ testCommand: opts.testCommand,
93
+ testTimeoutMs: opts.testTimeoutMs,
94
+ skipTest: opts.skipTest,
95
+ startCommand: opts.startCommand,
96
+ // Already-handled fingerprints from a prior cycle get skipped before heal,
97
+ // so a re-scan re-detects (to confirm they're still gone) without re-paying
98
+ // the LLM to re-fix them.
99
+ skipHealFingerprints: state.handled(),
100
+ budget,
101
+ ui: true,
102
+ });
103
+ }
104
+ catch (e) {
105
+ console.log(pc.red(`[cycle ${cycle}] run failed: ${e.message}`));
106
+ if (opts.once)
107
+ break;
108
+ await sleep(opts.intervalMs);
109
+ continue;
110
+ }
111
+ // "found" = distinct crash/error fingerprints ever seen this session.
112
+ for (const f of findings) {
113
+ if (f.severity === "crash" || f.severity === "error")
114
+ seenFp.add(f.fingerprint);
115
+ }
116
+ // Reproducible but not healed → mark unfixable so we don't re-burn the LLM on
117
+ // it every cycle; `PatrolState` backs off and retries it once the cooldown
118
+ // lapses. `budget-exhausted` / `no-llm` are NOT unfixable — they mean we never
119
+ // got a real attempt, so they must not be written into memory as "won't fix".
120
+ for (const f of findings) {
121
+ if ((f.severity === "crash" || f.severity === "error") &&
122
+ f.repro &&
123
+ f.heal &&
124
+ f.heal.status !== "healed" &&
125
+ f.heal.status !== "budget-exhausted" &&
126
+ f.heal.status !== "no-llm" &&
127
+ !state.isHandled(f.fingerprint)) {
128
+ state.markUnfixed(f.fingerprint);
129
+ }
130
+ }
131
+ const newHealed = findings.filter((f) => f.heal?.status === "healed" && !state.isHandled(f.fingerprint));
132
+ const toFix = newHealed.slice(0, Math.max(0, maxFixes - sessionFixes));
133
+ if (toFix.length === 0 && sessionFixes >= maxFixes && newHealed.length > 0) {
134
+ console.log(pc.yellow(`[cycle ${cycle}] reached max fixes (${maxFixes}) — no more this session.`));
135
+ }
136
+ if (opts.batch) {
137
+ // Batch: apply each patch, then open one PR carrying every fix that landed.
138
+ const landed = [];
139
+ const files = [];
140
+ for (const f of toFix) {
141
+ const applied = applyVerifiedPatches(opts.repoRoot, [f]);
142
+ if (applied.applied.length === 0) {
143
+ state.markUnfixed(f.fingerprint);
144
+ console.log(pc.yellow(` ◐ ${head(f)} — apply conflict, marked unfixable.`));
145
+ continue;
146
+ }
147
+ landed.push(f);
148
+ for (const a of applied.applied)
149
+ files.push(a.filePath);
150
+ }
151
+ if (landed.length > 0) {
152
+ const mediaPaths = [];
153
+ for (const f of landed)
154
+ mediaPaths.push(await recordGif(opts.repoRoot, opts.url, f));
155
+ const pr = await openPatrolBatchPr(opts.repoRoot, landed, opts.url, [...new Set(files)], mediaPaths);
156
+ if (pr.ok && pr.url) {
157
+ sessionPrs++;
158
+ sessionFixes += landed.length;
159
+ for (const f of landed)
160
+ state.markPr(f.fingerprint, pr.url, pr.branch ?? "");
161
+ console.log(pc.green(` ✓ batch PR opened ${pr.url} — ${landed.length} fix(es)`));
162
+ }
163
+ else if (pr.skipped) {
164
+ for (const f of landed)
165
+ state.markPr(f.fingerprint, "existing", pr.branch ?? "");
166
+ console.log(pc.dim(` — batch PR already exists (${pr.branch})`));
167
+ }
168
+ else {
169
+ for (const f of landed)
170
+ state.markUnfixed(f.fingerprint);
171
+ console.log(pc.red(` ✗ batch PR failed: ${pr.error}`));
172
+ }
173
+ }
174
+ }
175
+ else {
176
+ for (const f of toFix) {
177
+ const applied = applyVerifiedPatches(opts.repoRoot, [f]);
178
+ if (applied.applied.length === 0) {
179
+ state.markUnfixed(f.fingerprint);
180
+ console.log(pc.yellow(` ◐ ${head(f)} — apply conflict, marked unfixable.`));
181
+ continue;
182
+ }
183
+ const files = applied.applied.map((a) => a.filePath);
184
+ const mediaPath = await recordGif(opts.repoRoot, opts.url, f);
185
+ const pr = await openPatrolPr(opts.repoRoot, f, opts.url, files, mediaPath);
186
+ if (pr.ok && pr.url) {
187
+ sessionPrs++;
188
+ sessionFixes++;
189
+ state.markPr(f.fingerprint, pr.url, pr.branch ?? "");
190
+ console.log(pc.green(` ✓ PR opened ${pr.url} — ${head(f)}`));
191
+ }
192
+ else if (pr.skipped) {
193
+ state.markPr(f.fingerprint, "existing", pr.branch ?? "");
194
+ console.log(pc.dim(` — already has a PR (${pr.branch})`));
195
+ }
196
+ else {
197
+ state.markUnfixed(f.fingerprint);
198
+ console.log(pc.red(` ✗ PR failed: ${pr.error}`));
199
+ }
200
+ }
201
+ }
202
+ // Rolling status — the "found / fixed / PRs" tally that makes the loop read
203
+ // as alive rather than a stream of isolated lines.
204
+ console.log(pc.dim(`[cycle ${cycle}] found ${seenFp.size} · fixed ${sessionFixes} · PRs ${sessionPrs}` +
205
+ (newHealed.length === 0 ? " · nothing new to fix" : "")));
206
+ // Once the session's paid budget is spent there's nothing left to fix — stop
207
+ // rather than silently re-detecting without healing.
208
+ if (budget && budget.remaining <= 0) {
209
+ console.log(pc.yellow(`\nSpend budget exhausted — ${sessionPrs} PR(s) opened this session.`));
210
+ break;
211
+ }
212
+ state.save();
213
+ if (opts.once)
214
+ break;
215
+ await sleep(opts.intervalMs);
216
+ }
217
+ console.log(pc.cyan(`patrol session done — found ${seenFp.size} bug(s) · fixed ${sessionFixes} · PRs ${sessionPrs}`));
218
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * PR opener for `aztrx patrol`. Like `fixPr.openFixPr`, but built for an
3
+ * autonomous loop, so it adds two things a human-driven flow doesn't need:
4
+ *
5
+ * - **Dedup**: a fingerprint-stable branch name (`aztrx/fix-<fp8>`) plus a
6
+ * `gh pr list --head` check, so a re-scan never opens a second PR for a bug
7
+ * that already has one open.
8
+ * - **Scoped staging**: stages only the files the patch touched (`git add --
9
+ * <files>`), never `git add -A` — an autonomous run must not sweep up the
10
+ * user's unrelated uncommitted work into a PR.
11
+ *
12
+ * The caller is responsible for applying patches first (`applyVerifiedPatches`).
13
+ */
14
+ import { execFile } from "child_process";
15
+ import { promisify } from "util";
16
+ import { createHash } from "crypto";
17
+ import { diagnoseFinding } from "../diagnose.js";
18
+ import { sanitizeSecrets } from "../heal/redact.js";
19
+ const exec = promisify(execFile);
20
+ export function branchFor(fp) {
21
+ return `aztrx/fix-${fp.slice(0, 8)}`;
22
+ }
23
+ /** Stable branch name for a *set* of fingerprints — the same set always maps to
24
+ * the same branch, so a re-scan of an unchanged batch doesn't open a duplicate PR. */
25
+ export function branchForSet(fps) {
26
+ const h = createHash("sha1").update([...fps].sort().join("\n")).digest("hex").slice(0, 8);
27
+ return `aztrx/fix-batch-${h}`;
28
+ }
29
+ function headTitle(f) {
30
+ return f.rawMessage.split("\n")[0].slice(0, 60);
31
+ }
32
+ /** A local/loopback/private target is a dev box; only a publicly-routable host is
33
+ * a deployed app, so a crash there is "live in production" and worth flagging.
34
+ * Covers loopback, RFC1918 private ranges (10/8, 172.16/12, 192.168/16),
35
+ * link-local (169.254/16), and mDNS/internal suffixes (.local, .internal). */
36
+ export function isLocalUrl(url) {
37
+ try {
38
+ const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase();
39
+ if (host === "localhost" || host === "::1" || host === "::" || host === "0.0.0.0")
40
+ return true;
41
+ if (host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".localhost")) {
42
+ return true;
43
+ }
44
+ const octets = host.split(".");
45
+ if (octets.length === 4 && octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)) {
46
+ const [a, b] = octets.map(Number);
47
+ if (a === 10)
48
+ return true; // 10.0.0.0/8
49
+ if (a === 172 && b >= 16 && b <= 31)
50
+ return true; // 172.16.0.0/12
51
+ if (a === 192 && b === 168)
52
+ return true; // 192.168.0.0/16
53
+ if (a === 127)
54
+ return true; // 127.0.0.0/8
55
+ if (a === 169 && b === 254)
56
+ return true; // 169.254.0.0/16 link-local
57
+ return false;
58
+ }
59
+ return false;
60
+ }
61
+ catch {
62
+ return true; // unparseable target → assume local, don't scare-monger
63
+ }
64
+ }
65
+ /** Per-finding "before/after" narrative: the crash ("before"), a one-line *why*
66
+ * from the deterministic diagnosis, and the healed fix explanation ("after").
67
+ * Plain words rather than a stack trace, so a reviewer who never ran the scan
68
+ * still understands the PR at a glance. Untrusted text is secret-scrubbed. */
69
+ function findingBlock(f, i) {
70
+ const head = sanitizeSecrets(f.rawMessage.split("\n")[0].trim());
71
+ const loc = f.mappedLocation
72
+ ? `\`${f.mappedLocation.filePath}:${f.mappedLocation.line}\``
73
+ : "unknown location";
74
+ const why = diagnoseFinding(f);
75
+ const fix = f.heal?.status === "healed" && f.heal.explanation
76
+ ? sanitizeSecrets(f.heal.explanation.trim())
77
+ : "";
78
+ const repro = f.repro?.verdict
79
+ ? `_repro: ${f.repro.verdict} ${f.repro.reproductions}/${f.repro.runs}_`
80
+ : "";
81
+ const lines = [`### ${i}. ${head.length > 96 ? head.slice(0, 93) + "…" : head}`];
82
+ lines.push(`- **Where:** ${loc}`);
83
+ if (why)
84
+ lines.push(`- **Why:** ${why}`);
85
+ if (fix)
86
+ lines.push(`- **The fix:** ${fix.length > 400 ? fix.slice(0, 397) + "…" : fix}`);
87
+ if (repro)
88
+ lines.push(`- ${repro}`);
89
+ return lines.join("\n");
90
+ }
91
+ const PROD_BANNER = "> ⚠️ **This crash is live in production right now** — the target isn't a local dev server.";
92
+ const VERIFIED_NOTE = "Verified: AST-gated, compiled, run against the test suite, and replayed against the repro before this PR. Opened automatically by `aztrx patrol`.";
93
+ async function hasOpenPr(repoRoot, branch) {
94
+ try {
95
+ const { stdout } = await exec("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number"], { cwd: repoRoot });
96
+ const list = JSON.parse(stdout || "[]");
97
+ return Array.isArray(list) && list.length > 0;
98
+ }
99
+ catch {
100
+ // gh missing / unauthenticated — surface on the create step instead.
101
+ return false;
102
+ }
103
+ }
104
+ async function currentBranch(repoRoot) {
105
+ try {
106
+ const { stdout } = await exec("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
107
+ return stdout.trim() || "main";
108
+ }
109
+ catch {
110
+ return "main";
111
+ }
112
+ }
113
+ async function safeCheckout(repoRoot, branch) {
114
+ await exec("git", ["-C", repoRoot, "checkout", branch]).catch(() => { });
115
+ }
116
+ /**
117
+ * Pure URL builder: turns a `git remote` origin URL into a raw-content URL for a
118
+ * repo-relative path, so the PR body can inline an image
119
+ * (`![…](https://github.com/<owner>/<repo>/raw/<branch>/<path>)`). Handles the
120
+ * three common remote shapes (`https://`, `git@github.com:`, `ssh://git@…`) and a
121
+ * trailing `.git`. Returns "" when the remote isn't GitHub.
122
+ */
123
+ export function githubRawUrl(remote, branch, repoPath) {
124
+ const m = /github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/.exec(remote.trim());
125
+ if (!m)
126
+ return "";
127
+ return `https://github.com/${m[1]}/${m[2]}/raw/${branch}/${repoPath}`;
128
+ }
129
+ /**
130
+ * Reads the `origin` remote and delegates to {@link githubRawUrl}. Returns "" when
131
+ * `git` is missing or the remote isn't GitHub — the media is still committed to
132
+ * the branch, just not inlined.
133
+ */
134
+ async function rawUrlFor(repoRoot, branch, repoPath) {
135
+ try {
136
+ const { stdout } = await exec("git", ["-C", repoRoot, "remote", "get-url", "origin"]);
137
+ return githubRawUrl(stdout, branch, repoPath);
138
+ }
139
+ catch {
140
+ return "";
141
+ }
142
+ }
143
+ /** Shared git plumbing: (re)create the branch at HEAD, stage only the touched
144
+ * files, commit, open the PR, and return to the original branch. The caller has
145
+ * already done its own dedup + title/body. */
146
+ async function createPr(repoRoot, branch, title, body, files) {
147
+ const originalBranch = await currentBranch(repoRoot);
148
+ try {
149
+ // `-B` (re)creates the branch at HEAD — idempotent against a stale local
150
+ // branch left over from a previously failed PR attempt.
151
+ await exec("git", ["-C", repoRoot, "checkout", "-B", branch]);
152
+ if (files.length) {
153
+ await exec("git", ["-C", repoRoot, "add", "--", ...files]);
154
+ }
155
+ else {
156
+ await exec("git", ["-C", repoRoot, "add", "-A"]);
157
+ }
158
+ await exec("git", ["-C", repoRoot, "commit", "-m", title]);
159
+ }
160
+ catch (e) {
161
+ await safeCheckout(repoRoot, originalBranch);
162
+ return { ok: false, branch, error: `git failed: ${e.message}` };
163
+ }
164
+ try {
165
+ const { stdout } = await exec("gh", ["pr", "create", "--title", title, "--body", body], {
166
+ cwd: repoRoot,
167
+ });
168
+ await safeCheckout(repoRoot, originalBranch);
169
+ return { ok: true, branch, url: stdout.trim() };
170
+ }
171
+ catch (e) {
172
+ await safeCheckout(repoRoot, originalBranch);
173
+ return {
174
+ ok: false,
175
+ branch,
176
+ error: `gh pr create failed (is gh installed and authenticated?): ${e.message}`,
177
+ };
178
+ }
179
+ }
180
+ export async function openPatrolPr(repoRoot, finding, url, files, mediaPath) {
181
+ const branch = branchFor(finding.fingerprint);
182
+ if (await hasOpenPr(repoRoot, branch)) {
183
+ return { ok: false, skipped: true, branch, error: "PR already open for this finding" };
184
+ }
185
+ const mediaBody = mediaPath ? await rawUrlFor(repoRoot, branch, mediaPath) : "";
186
+ const block = findingBlock(finding, 1);
187
+ const body = [
188
+ "## Aztrx AI — autonomous fix",
189
+ "",
190
+ ...(isLocalUrl(url) ? [] : [PROD_BANNER, ""]),
191
+ `Found against ${url}:`,
192
+ "",
193
+ mediaBody ? `${block}\n\n![Recorded repro](${mediaBody})` : block,
194
+ "",
195
+ VERIFIED_NOTE,
196
+ ].join("\n");
197
+ return createPr(repoRoot, branch, `fix: ${headTitle(finding)}`, body, mediaPath ? [...files, mediaPath] : files);
198
+ }
199
+ /** One PR carrying a whole batch of fixes. Branch is stable across the *set*, so
200
+ * an unchanged batch re-scan dedups to the same PR. */
201
+ export async function openPatrolBatchPr(repoRoot, findings, url, files, mediaPaths) {
202
+ const branch = branchForSet(findings.map((f) => f.fingerprint));
203
+ if (await hasOpenPr(repoRoot, branch)) {
204
+ return { ok: false, skipped: true, branch, error: "batch PR already open" };
205
+ }
206
+ const n = findings.length;
207
+ // Resolve each finding's media to a raw URL ("" when absent / non-GitHub), kept
208
+ // parallel to `findings` so each inline image lands under its own bug's block.
209
+ const mediaBodies = [];
210
+ for (let i = 0; i < findings.length; i++) {
211
+ const mp = mediaPaths?.[i];
212
+ mediaBodies.push(mp ? await rawUrlFor(repoRoot, branch, mp) : "");
213
+ }
214
+ const blocks = findings.map((f, i) => {
215
+ const block = findingBlock(f, i + 1);
216
+ return mediaBodies[i] ? `${block}\n\n![Recorded repro](${mediaBodies[i]})` : block;
217
+ });
218
+ const body = [
219
+ "## Aztrx AI — autonomous fix (batch)",
220
+ "",
221
+ ...(isLocalUrl(url) ? [] : [PROD_BANNER, ""]),
222
+ `Found ${n} bug${n === 1 ? "" : "s"} against ${url}:`,
223
+ "",
224
+ blocks.join("\n\n"),
225
+ "",
226
+ VERIFIED_NOTE,
227
+ ].join("\n");
228
+ const staged = mediaPaths
229
+ ? [...files, ...mediaPaths.filter((p) => !!p)]
230
+ : files;
231
+ const title = `fix: ${n} bug${n === 1 ? "" : "s"} (${headTitle(findings[0])})`;
232
+ return createPr(repoRoot, branch, title, body, staged);
233
+ }