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.
- package/README.md +52 -0
- package/dist/cli.js +61 -1
- package/dist/core/classifier.js +92 -2
- package/dist/core/diagnose.js +91 -0
- package/dist/core/diff.js +137 -0
- package/dist/core/domWalker.js +1 -1
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/index.js +7 -5
- package/dist/core/heal/llm.js +16 -0
- package/dist/core/heal/redact.js +5 -1
- package/dist/core/heal/sandbox.js +5 -1
- package/dist/core/httpFuzzer.js +8 -4
- package/dist/core/orchestrator.js +33 -11
- package/dist/core/patrol/loop.js +218 -0
- package/dist/core/patrol/pr.js +233 -0
- package/dist/core/patrol/record.js +103 -0
- package/dist/core/patrol/state.js +78 -0
- package/dist/core/renderMarkdown.js +92 -0
- package/dist/core/report.js +6 -3
- package/dist/core/resolver.js +86 -25
- package/dist/core/summarize.js +2 -2
- package/dist/core/swarm.js +36 -16
- package/dist/core/ui.js +1 -0
- package/dist/ui/app.js +27 -2
- package/media/repro-demo/crash.gif +0 -0
- package/media/repro-demo/crash.png +0 -0
- package/media/repro-demo/frame_0.png +0 -0
- package/media/repro-demo/frame_1.png +0 -0
- package/package.json +5 -1
package/dist/core/httpFuzzer.js
CHANGED
|
@@ -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;
|
|
89
|
-
//
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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 (
|
|
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
|
-
|
|
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
|
+
* (``). 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` : 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` : 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
|
+
}
|