aztrx-cli 0.4.3 → 0.4.5
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 +47 -13
- 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 +40 -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
|
}
|
|
@@ -66,6 +72,14 @@ export async function run(options) {
|
|
|
66
72
|
if (!ui)
|
|
67
73
|
console.log(parts.join(" "));
|
|
68
74
|
};
|
|
75
|
+
// Important hints/warnings (destructive mode, login-wall, wrong --repo) must be
|
|
76
|
+
// visible in the TUI too — `say` is a no-op there. Emit on the bus always, and
|
|
77
|
+
// print to stdout only in the linear (non-UI) path so it isn't duplicated.
|
|
78
|
+
const notice = (message, level = "hint") => {
|
|
79
|
+
bus.emit("notice", { message, level, ts: Date.now() });
|
|
80
|
+
if (!ui)
|
|
81
|
+
console.log(level === "danger" ? pc.red(message) : pc.yellow(message));
|
|
82
|
+
};
|
|
69
83
|
const emitPhase = (phase, detail) => bus.emit("phase", { phase, detail, ts: Date.now() });
|
|
70
84
|
say(pc.cyan(`\nAztrx AI v${VERSION} — Runtime Detector`));
|
|
71
85
|
say(pc.dim(`Target: ${url}`));
|
|
@@ -78,6 +92,9 @@ export async function run(options) {
|
|
|
78
92
|
say(pc.dim(`Net: deny-by-default → allow ${[...allowHosts].join(", ") || "origin"}`));
|
|
79
93
|
if (options.storageState)
|
|
80
94
|
say(pc.dim(`Auth: ${options.storageState}`));
|
|
95
|
+
if (options.allowDestructive) {
|
|
96
|
+
notice("⚠ DESTRUCTIVE MODE — delete/pay/logout/checkout controls are ENABLED. This can mutate real data. Run only against a disposable/dev instance you own.", "danger");
|
|
97
|
+
}
|
|
81
98
|
say("");
|
|
82
99
|
emitPhase("launch", url);
|
|
83
100
|
const runLog = new RunLog(repoRoot);
|
|
@@ -85,10 +102,11 @@ export async function run(options) {
|
|
|
85
102
|
runLog.append({ type: "run_start", url, ts: Date.now() });
|
|
86
103
|
const baseline = await loadBaseline(repoRoot);
|
|
87
104
|
const workers = options.workers ?? 1;
|
|
88
|
-
// F-swarm — parallel detection. One worker is the legacy pass;
|
|
89
|
-
//
|
|
105
|
+
// F-swarm — parallel detection. One worker is the legacy pass; `workers > 1`
|
|
106
|
+
// fans out into a swarm. `--http-fuzz` folds into the walk/fuzz pass as a
|
|
107
|
+
// post-pass on the same page (no extra worker). Findings come back merged by
|
|
90
108
|
// fingerprint, with per-worker action history already attached.
|
|
91
|
-
if (workers > 1
|
|
109
|
+
if (workers > 1) {
|
|
92
110
|
emitPhase("swarm", `${workers} worker(s)`);
|
|
93
111
|
}
|
|
94
112
|
else if (options.fuzz) {
|
|
@@ -105,6 +123,7 @@ export async function run(options) {
|
|
|
105
123
|
fuzz: options.fuzz,
|
|
106
124
|
httpFuzz: options.httpFuzz,
|
|
107
125
|
httpFuzzMutations: options.httpFuzzMutations,
|
|
126
|
+
allowDestructive: options.allowDestructive,
|
|
108
127
|
seed: options.seed ?? 42,
|
|
109
128
|
workers,
|
|
110
129
|
allowHosts,
|
|
@@ -125,7 +144,7 @@ export async function run(options) {
|
|
|
125
144
|
for (const f of findings) {
|
|
126
145
|
bus.emit("finding", f);
|
|
127
146
|
runLog.append({ type: "finding", finding: f });
|
|
128
|
-
printFinding(f, say);
|
|
147
|
+
printFinding(f, say, options.lang);
|
|
129
148
|
}
|
|
130
149
|
if (workerCount > 1) {
|
|
131
150
|
say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s) — ${roles.join(", ")}.\n`));
|
|
@@ -137,7 +156,7 @@ export async function run(options) {
|
|
|
137
156
|
say(pc.dim(`\nWalked ${totalActions} action(s).\n`));
|
|
138
157
|
}
|
|
139
158
|
if (sawLoginForm && !options.login) {
|
|
140
|
-
|
|
159
|
+
notice("Hint: this app has a login form — re-run with --login to test the authenticated app.", "hint");
|
|
141
160
|
}
|
|
142
161
|
// F7 → F8 → F9: minimize each finding, compile an executable spec, validate
|
|
143
162
|
// the flake rate. Only crash/error findings with a recorded action history.
|
|
@@ -235,7 +254,9 @@ export async function run(options) {
|
|
|
235
254
|
f.type !== "network_timeout" &&
|
|
236
255
|
f.repro &&
|
|
237
256
|
f.repro.verdict !== "unreliable");
|
|
238
|
-
const
|
|
257
|
+
const skip = new Set(options.skipHealFingerprints ?? []);
|
|
258
|
+
const candidates = reproducible.filter((f) => !skip.has(f.fingerprint));
|
|
259
|
+
const healTargets = candidates.filter((f) => f.mappedLocation?.isOwnCode);
|
|
239
260
|
if (healTargets.length) {
|
|
240
261
|
say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → test → verify) —"));
|
|
241
262
|
emitPhase("heal");
|
|
@@ -254,6 +275,7 @@ export async function run(options) {
|
|
|
254
275
|
testTimeoutMs: options.testTimeoutMs,
|
|
255
276
|
skipTest: options.skipTest,
|
|
256
277
|
startCommand: options.startCommand,
|
|
278
|
+
budget: options.budget,
|
|
257
279
|
});
|
|
258
280
|
f.heal = result;
|
|
259
281
|
bus.emit("heal", {
|
|
@@ -273,8 +295,14 @@ export async function run(options) {
|
|
|
273
295
|
? pc.green(" ✓ healed")
|
|
274
296
|
: result.status === "unfixed"
|
|
275
297
|
? pc.yellow(" ◐ unfixed")
|
|
276
|
-
:
|
|
298
|
+
: result.status === "budget-exhausted"
|
|
299
|
+
? pc.dim(" ⏹ budget exhausted")
|
|
300
|
+
: pc.red(` ✗ ${result.status}`);
|
|
277
301
|
say(`${mark} ${pc.bold(f.rawMessage.split("\n")[0].slice(0, 60))}`);
|
|
302
|
+
if (result.status === "healed" && result.hunks.length > 0) {
|
|
303
|
+
say(pc.dim(` ${result.filePath}`));
|
|
304
|
+
say(formatDiff(result.hunks));
|
|
305
|
+
}
|
|
278
306
|
if (result.patchPath)
|
|
279
307
|
say(pc.dim(` patch: ${path.relative(repoRoot, result.patchPath)}`));
|
|
280
308
|
if (result.error)
|
|
@@ -295,13 +323,19 @@ export async function run(options) {
|
|
|
295
323
|
}
|
|
296
324
|
}
|
|
297
325
|
}
|
|
298
|
-
else if (
|
|
299
|
-
// Reproducible crashes, but none mapped to source (wrong
|
|
300
|
-
// user rather than silently doing nothing.
|
|
301
|
-
|
|
326
|
+
else if (candidates.length > 0) {
|
|
327
|
+
// Reproducible crashes, but none mapped to a readable source file (wrong
|
|
328
|
+
// --repo?) — tell the user rather than silently doing nothing. Already-handled
|
|
329
|
+
// fingerprints are excluded above, so this only fires for genuinely new
|
|
330
|
+
// findings. Name the files it looked for so the hint is actionable.
|
|
331
|
+
const missing = candidates
|
|
332
|
+
.map((f) => f.mappedLocation?.filePath || f.rawMessage.split("\n")[0].slice(0, 40))
|
|
333
|
+
.filter(Boolean)
|
|
334
|
+
.slice(0, 3);
|
|
335
|
+
notice(`Found ${candidates.length} reproducible crash(es) but couldn't read their source (${missing.join(", ")}). Run from your project root (or pass --repo <dir>) so --fix can read the code.`, "warning");
|
|
302
336
|
}
|
|
303
337
|
}
|
|
304
|
-
const reportPath = writeReport(repoRoot, url, findings);
|
|
338
|
+
const reportPath = writeReport(repoRoot, url, findings, options.lang);
|
|
305
339
|
say(pc.dim(`Report: ${path.relative(repoRoot, reportPath)}`));
|
|
306
340
|
const counts = {};
|
|
307
341
|
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
|
+
}
|