aztrx-cli 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -328
- package/dist/cli.js +74 -7
- package/dist/core/classifier.js +1 -0
- package/dist/core/domWalker.js +45 -21
- package/dist/core/fixPr.js +50 -0
- package/dist/core/fuzzer.js +112 -62
- package/dist/core/heal/index.js +32 -12
- package/dist/core/heal/llm.js +52 -6
- package/dist/core/interceptor.js +17 -9
- package/dist/core/llm.js +31 -5
- package/dist/core/modernize.js +1 -1
- package/dist/core/orchestrator.js +22 -11
- package/dist/core/prompt.js +12 -0
- package/dist/core/resolver.js +36 -16
- package/dist/core/specCompiler.js +31 -0
- package/dist/core/swarm.js +30 -4
- package/media/logo.png +0 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -12,11 +12,13 @@ import { initProject } from "./core/init.js";
|
|
|
12
12
|
import { startStudio } from "./core/studio.js";
|
|
13
13
|
import { writePrComment } from "./core/pr.js";
|
|
14
14
|
import { writeBadge } from "./core/badge.js";
|
|
15
|
+
import { writeRegressionSpecs } from "./core/specCompiler.js";
|
|
15
16
|
import { flushTelemetry } from "./core/telemetry/index.js";
|
|
16
17
|
import { flushCloud } from "./core/cloud/index.js";
|
|
17
18
|
import { summarizeFindings } from "./core/summarize.js";
|
|
18
19
|
import { applyVerifiedPatches } from "./core/heal/apply.js";
|
|
19
|
-
import {
|
|
20
|
+
import { openFixPr } from "./core/fixPr.js";
|
|
21
|
+
import { promptYesNo, promptInput } from "./core/prompt.js";
|
|
20
22
|
import { modernizeFile } from "./core/modernize.js";
|
|
21
23
|
function collect(value, prev) {
|
|
22
24
|
prev.push(value);
|
|
@@ -27,6 +29,35 @@ function autoWorkers() {
|
|
|
27
29
|
const n = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
|
|
28
30
|
return Math.max(1, Math.min(n, 8));
|
|
29
31
|
}
|
|
32
|
+
/** Auto-detect the running dev server URL: `aztrx.config.ts`, the dev script's
|
|
33
|
+
* `--port`, then a probe of common ports. Returns null when nothing responds. */
|
|
34
|
+
async function detectUrl(repoRoot) {
|
|
35
|
+
const configPath = path.join(repoRoot, "aztrx.config.ts");
|
|
36
|
+
if (fs.existsSync(configPath)) {
|
|
37
|
+
const m = fs.readFileSync(configPath, "utf-8").match(/url\s*[=:]\s*["']([^"']+)["']/);
|
|
38
|
+
if (m)
|
|
39
|
+
return m[1];
|
|
40
|
+
}
|
|
41
|
+
const pkgPath = path.join(repoRoot, "package.json");
|
|
42
|
+
if (fs.existsSync(pkgPath)) {
|
|
43
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
44
|
+
const dev = pkg.scripts?.dev || pkg.scripts?.start || "";
|
|
45
|
+
const pm = dev.match(/(?:--port|-p)\s*[= ]?\s*(\d+)/);
|
|
46
|
+
if (pm)
|
|
47
|
+
return `http://localhost:${pm[1]}`;
|
|
48
|
+
}
|
|
49
|
+
for (const port of [3000, 5173, 8080, 3001, 4000, 8000]) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(300) });
|
|
52
|
+
if (res.status < 500)
|
|
53
|
+
return `http://localhost:${port}`;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// not listening — try the next port
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
30
61
|
/** Print one low-key "next flag" hint after a run, so users learn the advanced
|
|
31
62
|
* flags on demand instead of memorizing the whole surface. Fires only in the
|
|
32
63
|
* plain-log path when there's a finding worth acting on. */
|
|
@@ -104,7 +135,7 @@ program
|
|
|
104
135
|
program
|
|
105
136
|
.command("run", { isDefault: true })
|
|
106
137
|
.description("inspect a running app and prove its bugs with an executable repro")
|
|
107
|
-
.argument("
|
|
138
|
+
.argument("[url]", "dev server to inspect (auto-detected if omitted), e.g. http://localhost:3000")
|
|
108
139
|
.configureHelp({ formatHelp })
|
|
109
140
|
.addOption(opt("--repo <path>", "project root to inspect/watch (default: cwd)", "advanced"))
|
|
110
141
|
.addOption(opt("--max-actions <n>", "max actions per pass", "advanced").default("100"))
|
|
@@ -130,9 +161,11 @@ program
|
|
|
130
161
|
.addOption(opt("--start-command <cmd>", "command to boot the app for server healing (default: auto-detect scripts.dev/scripts.start)", "advanced"))
|
|
131
162
|
.addOption(opt("--explain", "print a human-language summary of the findings (no healing)", "fix"))
|
|
132
163
|
.addOption(opt("-y, --yes", "auto-apply verified fixes without prompting (with --fix)", "fix"))
|
|
164
|
+
.addOption(opt("--pr", "open a PR with the verified fixes (with --fix)", "fix"))
|
|
133
165
|
.addOption(opt("--lang <en|ru>", "language for the human-language summary", "advanced").default("en"))
|
|
134
166
|
.addOption(opt("--pr-comment [path]", "write a GitHub PR markdown comment (default .aztrx/pr-comment.md)", "ship"))
|
|
135
167
|
.addOption(opt("--badge [path]", "write a self-contained SVG badge (default .aztrx/badge.svg)", "ship"))
|
|
168
|
+
.addOption(opt("--regression-test [dir]", "copy validated repro specs into the project test dir (default: e2e/ or tests/)", "ship"))
|
|
136
169
|
.addOption(opt("--telemetry", "opt-in: collect anonymized crash→repro→patch tuples locally (.aztrx/telemetry)", "advanced"))
|
|
137
170
|
.addOption(opt("--share-data", "opt-in: also upload the sanitized tuples to the telemetry endpoint", "advanced"))
|
|
138
171
|
.addOption(opt("--upload", "opt-in: stream run results to the Aztrx AI cloud dashboard (needs --api-key)", "advanced"))
|
|
@@ -151,6 +184,16 @@ program
|
|
|
151
184
|
// `--fix` is the memorable verb; `--magic-fix` is a hidden alias.
|
|
152
185
|
const magicFix = opts.magicFix || opts.fix;
|
|
153
186
|
const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
|
|
187
|
+
// Auto-detect the target when no URL is given — one less thing to type.
|
|
188
|
+
let targetUrl = url;
|
|
189
|
+
if (!targetUrl) {
|
|
190
|
+
targetUrl = await detectUrl(repoRoot);
|
|
191
|
+
if (!targetUrl) {
|
|
192
|
+
console.error(pc.red("No URL given and none auto-detected. Pass <url>, or run `aztrx-cli init` first."));
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
console.log(pc.dim(`Auto-detected ${targetUrl}`));
|
|
196
|
+
}
|
|
154
197
|
const workers = opts.workers ? parseInt(opts.workers, 10) : opts.swarm ? autoWorkers() : undefined;
|
|
155
198
|
const mode = (workers ?? 1) > 1 || opts.httpFuzz
|
|
156
199
|
? `swarm (${workers ?? 1} worker${(workers ?? 1) === 1 ? "" : "s"})`
|
|
@@ -161,8 +204,16 @@ program
|
|
|
161
204
|
: opts.repro
|
|
162
205
|
? "repro"
|
|
163
206
|
: "deterministic walk";
|
|
207
|
+
// Interactive login: if --login was passed without credentials, ask for them
|
|
208
|
+
// so the user never has to remember the AZTRX_AUTH_* env vars.
|
|
209
|
+
let loginEmail = opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL;
|
|
210
|
+
let loginPassword = opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD;
|
|
211
|
+
if (opts.login && !loginEmail && !loginPassword) {
|
|
212
|
+
loginEmail = await promptInput("Email:");
|
|
213
|
+
loginPassword = await promptInput("Password:");
|
|
214
|
+
}
|
|
164
215
|
const runOpts = {
|
|
165
|
-
url,
|
|
216
|
+
url: targetUrl,
|
|
166
217
|
repoRoot,
|
|
167
218
|
maxActions: parseInt(opts.maxActions, 10),
|
|
168
219
|
dryRun: opts.dryRun,
|
|
@@ -189,8 +240,8 @@ program
|
|
|
189
240
|
cloudUrl: opts.cloudUrl,
|
|
190
241
|
storageState: opts.storageState ?? opts.auth,
|
|
191
242
|
login: opts.login,
|
|
192
|
-
loginEmail
|
|
193
|
-
loginPassword
|
|
243
|
+
loginEmail,
|
|
244
|
+
loginPassword,
|
|
194
245
|
loginUrl: opts.loginUrl,
|
|
195
246
|
};
|
|
196
247
|
const failOn = Boolean(opts.failOn);
|
|
@@ -202,7 +253,7 @@ program
|
|
|
202
253
|
await renderTui({
|
|
203
254
|
bus,
|
|
204
255
|
done: runPromise,
|
|
205
|
-
targetUrl
|
|
256
|
+
targetUrl,
|
|
206
257
|
repoRoot,
|
|
207
258
|
mode,
|
|
208
259
|
});
|
|
@@ -221,7 +272,7 @@ program
|
|
|
221
272
|
const prPath = typeof opts.prComment === "string"
|
|
222
273
|
? opts.prComment
|
|
223
274
|
: path.join(repoRoot, ".aztrx", "pr-comment.md");
|
|
224
|
-
writePrComment(repoRoot,
|
|
275
|
+
writePrComment(repoRoot, targetUrl, findings, prPath);
|
|
225
276
|
console.log(pc.dim(`PR comment: ${path.relative(repoRoot, prPath)}`));
|
|
226
277
|
}
|
|
227
278
|
if (opts.badge) {
|
|
@@ -231,6 +282,13 @@ program
|
|
|
231
282
|
writeBadge(repoRoot, findings, badgePath);
|
|
232
283
|
console.log(pc.dim(`Badge: ${path.relative(repoRoot, badgePath)}`));
|
|
233
284
|
}
|
|
285
|
+
if (opts.regressionTest) {
|
|
286
|
+
const regDir = typeof opts.regressionTest === "string" ? opts.regressionTest : undefined;
|
|
287
|
+
const written = writeRegressionSpecs(repoRoot, findings, regDir);
|
|
288
|
+
for (const w of written) {
|
|
289
|
+
console.log(pc.green(" ✓ regression test") + ` ${path.relative(repoRoot, w)}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
234
292
|
// F13 — human-language summary + opt-in apply (the "Senior Rescuer" flow).
|
|
235
293
|
// The run already printed its structured output; this layer explains it and,
|
|
236
294
|
// under `--fix`, offers to apply the verified patches so `git diff`
|
|
@@ -258,6 +316,15 @@ program
|
|
|
258
316
|
}
|
|
259
317
|
}
|
|
260
318
|
}
|
|
319
|
+
if (opts.pr) {
|
|
320
|
+
const prRes = await openFixPr(repoRoot, findings, targetUrl);
|
|
321
|
+
if (prRes.ok) {
|
|
322
|
+
console.log(pc.green(" ✓ PR opened") + ` ${prRes.url}`);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
console.log(pc.yellow(" ◐ PR skipped") + `: ${prRes.error}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
261
328
|
// Suggest the next flag (e.g. --fix) after the run, in both the live TUI
|
|
262
329
|
// and plain paths. In the TUI this prints after the panel has finished.
|
|
263
330
|
suggestNext(findings, opts);
|
package/dist/core/classifier.js
CHANGED
|
@@ -37,6 +37,7 @@ const HYDRATION_NOISE = [
|
|
|
37
37
|
];
|
|
38
38
|
function normalize(message) {
|
|
39
39
|
return message
|
|
40
|
+
.replace(/^(?:TypeError|ReferenceError|RangeError|SyntaxError|URIError|EvalError|Error):\s*/g, "")
|
|
40
41
|
.replace(/\b\d+\b/g, "<N>")
|
|
41
42
|
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<UUID>")
|
|
42
43
|
.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, "<TS>")
|
package/dist/core/domWalker.js
CHANGED
|
@@ -13,29 +13,32 @@ export const SELECTOR = 'a, button, input, select, textarea, [role="button"], [o
|
|
|
13
13
|
export async function walkDom(page, bus, opts = {}) {
|
|
14
14
|
const max = opts.maxActions ?? 100;
|
|
15
15
|
const startUrl = page.url();
|
|
16
|
+
const startOrigin = originOf(startUrl);
|
|
16
17
|
let actions = 0;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
let sawLoginForm = false;
|
|
19
|
+
const visited = new Set();
|
|
20
|
+
const queue = [startUrl];
|
|
21
|
+
// Breadth-first crawl: visit each internal page, walk its in-place controls
|
|
22
|
+
// (buttons/inputs/selects), and queue any internal links it reveals. This finds
|
|
23
|
+
// bugs on every route, not just the one you pointed at.
|
|
24
|
+
while (queue.length > 0 && actions < max) {
|
|
25
|
+
const url = queue.shift();
|
|
26
|
+
if (visited.has(url))
|
|
27
|
+
continue;
|
|
28
|
+
visited.add(url);
|
|
29
|
+
if (page.url() !== url) {
|
|
30
|
+
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
26
31
|
await page.waitForTimeout(300);
|
|
27
32
|
}
|
|
33
|
+
// Per-page "seen" set — the same button label on two pages is two targets.
|
|
34
|
+
const seen = new Set();
|
|
28
35
|
let handles;
|
|
29
36
|
try {
|
|
30
37
|
handles = await page.$$(SELECTOR);
|
|
31
38
|
}
|
|
32
39
|
catch {
|
|
33
|
-
//
|
|
34
|
-
await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
35
|
-
await page.waitForTimeout(300);
|
|
36
|
-
continue;
|
|
40
|
+
continue; // mid-navigation — the next queued URL is visited anyway
|
|
37
41
|
}
|
|
38
|
-
let acted = false;
|
|
39
42
|
for (const handle of handles) {
|
|
40
43
|
if (actions >= max)
|
|
41
44
|
break;
|
|
@@ -67,12 +70,25 @@ export async function walkDom(page, bus, opts = {}) {
|
|
|
67
70
|
if (DESTRUCTIVE.test(label))
|
|
68
71
|
continue;
|
|
69
72
|
if (tag === "a") {
|
|
73
|
+
// Don't click links directly — queue internal ones for the crawl.
|
|
70
74
|
const href = (await handle.getAttribute("href")) ?? "";
|
|
71
|
-
if (
|
|
72
|
-
|
|
75
|
+
if (href && !/^(javascript:|mailto:|tel:|#)/.test(href)) {
|
|
76
|
+
try {
|
|
77
|
+
const target = new URL(href, url).href.split("#")[0];
|
|
78
|
+
if (target.startsWith(startOrigin) && !visited.has(target) && queue.length < 20) {
|
|
79
|
+
queue.push(target);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// unparseable href — ignore
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
73
87
|
}
|
|
74
88
|
if (tag === "input") {
|
|
75
89
|
const type = (await handle.getAttribute("type")) ?? "";
|
|
90
|
+
if (type === "password")
|
|
91
|
+
sawLoginForm = true;
|
|
76
92
|
if (!TEXT_INPUT_TYPES.has(type))
|
|
77
93
|
continue; // skip password/hidden/submit/checkbox/etc.
|
|
78
94
|
}
|
|
@@ -94,14 +110,22 @@ export async function walkDom(page, bus, opts = {}) {
|
|
|
94
110
|
await handle.click({ timeout: 1500 }).catch(() => { });
|
|
95
111
|
}
|
|
96
112
|
actions++;
|
|
97
|
-
acted = true;
|
|
98
113
|
await page.waitForTimeout(120);
|
|
99
|
-
|
|
114
|
+
// A click may navigate (e.g. a submit) — queue the new URL and stop this
|
|
115
|
+
// page's walk; the queue visits it next.
|
|
116
|
+
if (page.url() !== url) {
|
|
117
|
+
const target = page.url().split("#")[0];
|
|
118
|
+
if (target.startsWith(startOrigin) && !visited.has(target) && queue.length < 20) {
|
|
119
|
+
queue.push(target);
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
100
123
|
}
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
// Give in-flight async work (fetches, timers) a moment to reject before we
|
|
125
|
+
// navigate to the next crawled page — otherwise a 300ms-later throw is lost.
|
|
126
|
+
await page.waitForTimeout(500);
|
|
103
127
|
}
|
|
104
|
-
return actions;
|
|
128
|
+
return { actions, sawLoginForm };
|
|
105
129
|
}
|
|
106
130
|
export function originOf(url) {
|
|
107
131
|
return url.match(/^https?:\/\/[^/]+/)?.[0] ?? "";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Turn applied, verified fixes into a merge-ready PR: create a branch, commit,
|
|
6
|
+
* and open a PR via the `gh` CLI with the repro evidence in the body. Mirrors
|
|
7
|
+
* the "merge-ready PR" flow but for runtime crashes, not security vulns.
|
|
8
|
+
*/
|
|
9
|
+
export async function openFixPr(repoRoot, findings, url) {
|
|
10
|
+
const healed = findings.filter((f) => f.heal?.status === "healed");
|
|
11
|
+
if (healed.length === 0)
|
|
12
|
+
return { ok: false, error: "no verified fixes to open a PR for" };
|
|
13
|
+
const branch = `aztrx/fix-${Date.now().toString(36)}`;
|
|
14
|
+
const title = `fix: ${healed.length} runtime bug${healed.length === 1 ? "" : "s"} found by Aztrx`;
|
|
15
|
+
const bullets = healed.map((f) => {
|
|
16
|
+
const loc = f.mappedLocation
|
|
17
|
+
? `${f.mappedLocation.filePath}:${f.mappedLocation.line}`
|
|
18
|
+
: "unknown location";
|
|
19
|
+
const repro = f.repro?.verdict
|
|
20
|
+
? `repro: ${f.repro.verdict} ${f.repro.reproductions}/${f.repro.runs}`
|
|
21
|
+
: "";
|
|
22
|
+
return `- **${f.rawMessage.split("\n")[0].slice(0, 120)}** — \`${loc}\` ${repro}`.trim();
|
|
23
|
+
});
|
|
24
|
+
const body = [
|
|
25
|
+
"## Aztrx AI — verified fixes",
|
|
26
|
+
"",
|
|
27
|
+
`Found ${healed.length} runtime bug${healed.length === 1 ? "" : "s"} against ${url}:`,
|
|
28
|
+
"",
|
|
29
|
+
...bullets,
|
|
30
|
+
"",
|
|
31
|
+
"Each fix was gated (AST safety), compiled, run against the test suite, and replayed against the repro in an isolated worktree before this PR.",
|
|
32
|
+
].join("\n");
|
|
33
|
+
try {
|
|
34
|
+
await exec("git", ["-C", repoRoot, "checkout", "-b", branch]);
|
|
35
|
+
await exec("git", ["-C", repoRoot, "add", "-A"]);
|
|
36
|
+
await exec("git", ["-C", repoRoot, "commit", "-m", title]);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return { ok: false, error: `git failed: ${e.message}` };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await exec("gh", ["pr", "create", "--title", title, "--body", body], {
|
|
43
|
+
cwd: repoRoot,
|
|
44
|
+
});
|
|
45
|
+
return { ok: true, url: stdout.trim() };
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return { ok: false, error: `gh pr create failed (is gh installed and authenticated?): ${e.message}` };
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/core/fuzzer.js
CHANGED
|
@@ -22,17 +22,63 @@ function pick(rnd, arr) {
|
|
|
22
22
|
return arr[Math.floor(rnd() * arr.length)];
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* F5 — chaos fuzzer
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* F5 — chaos fuzzer with coverage guidance. A seeded random walk (click/hover/
|
|
26
|
+
* keypress/select/garbage input) that also reads V8 JS coverage and biases its
|
|
27
|
+
* picks toward elements whose action previously uncovered new code — the same
|
|
28
|
+
* "prefer the input that reaches new code" idea behind libFuzzer/AFL, applied to
|
|
29
|
+
* the DOM. Returns how many brand-new JS ranges this pass executed.
|
|
30
30
|
*/
|
|
31
31
|
export async function fuzz(page, bus, opts = {}) {
|
|
32
32
|
const max = opts.maxActions ?? 100;
|
|
33
33
|
const rnd = mulberry32(opts.seed ?? 42);
|
|
34
34
|
const startUrl = page.url();
|
|
35
35
|
let acted = 0;
|
|
36
|
+
let newCoverage = 0;
|
|
37
|
+
const seenRanges = new Set();
|
|
38
|
+
const interesting = new Set(); // labels whose action uncovered new code
|
|
39
|
+
try {
|
|
40
|
+
await page.coverage.startJSCoverage();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// coverage unsupported in this context — the fuzz still runs, just blind.
|
|
44
|
+
}
|
|
45
|
+
// Snapshot current JS coverage; returns the count of newly-seen ranges.
|
|
46
|
+
const snapshot = async () => {
|
|
47
|
+
let fresh = 0;
|
|
48
|
+
try {
|
|
49
|
+
const entries = await page.coverage.stopJSCoverage();
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const id = e.scriptId || e.url;
|
|
52
|
+
for (const fn of e.functions) {
|
|
53
|
+
for (const r of fn.ranges) {
|
|
54
|
+
const key = `${id}:${r.startOffset}:${r.endOffset}`;
|
|
55
|
+
if (!seenRanges.has(key)) {
|
|
56
|
+
seenRanges.add(key);
|
|
57
|
+
fresh++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
await page.coverage.startJSCoverage();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
return fresh;
|
|
73
|
+
};
|
|
74
|
+
const recordCoverage = async (label) => {
|
|
75
|
+
const fresh = await snapshot();
|
|
76
|
+
if (fresh > 0) {
|
|
77
|
+
newCoverage += fresh;
|
|
78
|
+
if (label)
|
|
79
|
+
interesting.add(label);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
36
82
|
for (let i = 0; i < max; i++) {
|
|
37
83
|
if (page.url() !== startUrl) {
|
|
38
84
|
await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
@@ -46,54 +92,63 @@ export async function fuzz(page, bus, opts = {}) {
|
|
|
46
92
|
await page.mouse.wheel(0, dir === "up" ? -600 : 600).catch(() => { });
|
|
47
93
|
acted++;
|
|
48
94
|
await page.waitForTimeout(40);
|
|
95
|
+
await recordCoverage("");
|
|
49
96
|
continue;
|
|
50
97
|
}
|
|
98
|
+
// Build the actionable list up front (skip destructive/external/non-text),
|
|
99
|
+
// so coverage guidance can bias the pick before we act.
|
|
51
100
|
const handles = await page.$$(SELECTOR);
|
|
52
|
-
const
|
|
101
|
+
const actionable = [];
|
|
53
102
|
for (const h of handles) {
|
|
54
103
|
const v = await h.isVisible().catch(() => false);
|
|
55
104
|
const e = await h.isEnabled().catch(() => false);
|
|
56
|
-
if (v
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
catch {
|
|
81
|
-
label = ""; // degraded — no label to filter on
|
|
82
|
-
}
|
|
83
|
-
if (DESTRUCTIVE.test(label))
|
|
84
|
-
continue;
|
|
85
|
-
if (tag === "a") {
|
|
86
|
-
const href = (await handle.getAttribute("href")) ?? "";
|
|
87
|
-
if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
|
|
105
|
+
if (!v || !e)
|
|
106
|
+
continue;
|
|
107
|
+
let tag;
|
|
108
|
+
try {
|
|
109
|
+
tag = await h.evaluate((el) => el.tagName.toLowerCase());
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue; // element detached/unreadable mid-query — skip
|
|
113
|
+
}
|
|
114
|
+
let label = "";
|
|
115
|
+
try {
|
|
116
|
+
label = await h.evaluate((el) => {
|
|
117
|
+
const t = el.innerText ||
|
|
118
|
+
el.getAttribute("aria-label") ||
|
|
119
|
+
el.getAttribute("value") ||
|
|
120
|
+
el.getAttribute("placeholder") ||
|
|
121
|
+
"";
|
|
122
|
+
return t.trim();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
label = ""; // degraded — no label to filter on
|
|
127
|
+
}
|
|
128
|
+
if (DESTRUCTIVE.test(label))
|
|
88
129
|
continue;
|
|
130
|
+
if (tag === "a") {
|
|
131
|
+
const href = (await h.getAttribute("href")) ?? "";
|
|
132
|
+
if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (tag === "input") {
|
|
136
|
+
const type = (await h.getAttribute("type")) ?? "";
|
|
137
|
+
if (!TEXT_INPUT_TYPES.has(type))
|
|
138
|
+
continue; // skip password/hidden/submit/checkbox/etc.
|
|
139
|
+
}
|
|
140
|
+
actionable.push({ handle: h, tag, label });
|
|
89
141
|
}
|
|
90
|
-
|
|
142
|
+
if (actionable.length === 0)
|
|
143
|
+
break;
|
|
144
|
+
// Coverage guidance: prefer elements that previously uncovered new code.
|
|
145
|
+
const known = actionable.filter((a) => a.label && interesting.has(a.label));
|
|
146
|
+
const chosen = known.length > 0 && rnd() < 0.5 ? pick(rnd, known) : pick(rnd, actionable);
|
|
147
|
+
const { handle, tag, label } = chosen;
|
|
148
|
+
const selectors = await selectorCascade(page, handle);
|
|
149
|
+
const roll = rnd();
|
|
150
|
+
let didAct = true;
|
|
91
151
|
if (tag === "input" || tag === "textarea") {
|
|
92
|
-
const type = (await handle.getAttribute("type")) ?? "";
|
|
93
|
-
if (!TEXT_INPUT_TYPES.has(type))
|
|
94
|
-
continue;
|
|
95
|
-
const selectors = await selectorCascade(page, handle);
|
|
96
|
-
const roll = rnd();
|
|
97
152
|
if (roll < 0.6) {
|
|
98
153
|
const value = pick(rnd, GARBAGE);
|
|
99
154
|
const action = { type: "input", selectors, value, timestamp: Date.now() };
|
|
@@ -116,35 +171,27 @@ export async function fuzz(page, bus, opts = {}) {
|
|
|
116
171
|
if (!opts.dryRun)
|
|
117
172
|
await handle.hover().catch(() => { });
|
|
118
173
|
}
|
|
119
|
-
acted++;
|
|
120
|
-
await page.waitForTimeout(60);
|
|
121
|
-
continue;
|
|
122
174
|
}
|
|
123
|
-
|
|
124
|
-
if (tag === "select") {
|
|
175
|
+
else if (tag === "select") {
|
|
125
176
|
let options = [];
|
|
126
177
|
try {
|
|
127
178
|
options = await handle.evaluate((el) => Array.from(el.options).map((o) => o.value || o.textContent?.trim() || ""));
|
|
128
179
|
}
|
|
129
180
|
catch {
|
|
130
|
-
|
|
181
|
+
didAct = false; // options unreadable — skip this select
|
|
131
182
|
}
|
|
132
|
-
if (options.length > 0) {
|
|
183
|
+
if (didAct && options.length > 0) {
|
|
133
184
|
const value = pick(rnd, options);
|
|
134
|
-
const selectors = await selectorCascade(page, handle);
|
|
135
185
|
const action = { type: "select", selectors, value, timestamp: Date.now() };
|
|
136
186
|
bus.emit("action", action);
|
|
137
187
|
if (!opts.dryRun)
|
|
138
188
|
await handle.selectOption(value).catch(() => { });
|
|
139
|
-
acted++;
|
|
140
|
-
await page.waitForTimeout(60);
|
|
141
189
|
}
|
|
142
|
-
|
|
190
|
+
else {
|
|
191
|
+
didAct = false;
|
|
192
|
+
}
|
|
143
193
|
}
|
|
144
|
-
|
|
145
|
-
const selectors = await selectorCascade(page, handle);
|
|
146
|
-
const roll = rnd();
|
|
147
|
-
if (roll < 0.55) {
|
|
194
|
+
else if (roll < 0.55) {
|
|
148
195
|
const action = { type: "click", selectors, timestamp: Date.now() };
|
|
149
196
|
bus.emit("action", action);
|
|
150
197
|
if (!opts.dryRun) {
|
|
@@ -168,8 +215,11 @@ export async function fuzz(page, bus, opts = {}) {
|
|
|
168
215
|
await page.keyboard.press(key).catch(() => { });
|
|
169
216
|
}
|
|
170
217
|
}
|
|
171
|
-
|
|
172
|
-
|
|
218
|
+
if (didAct) {
|
|
219
|
+
acted++;
|
|
220
|
+
await page.waitForTimeout(60);
|
|
221
|
+
await recordCoverage(label);
|
|
222
|
+
}
|
|
173
223
|
}
|
|
174
|
-
return acted;
|
|
224
|
+
return { actions: acted, newCoverage };
|
|
175
225
|
}
|
package/dist/core/heal/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import * as fs from "fs";
|
|
|
19
19
|
import * as path from "path";
|
|
20
20
|
import { redact, unredact } from "./redact.js";
|
|
21
21
|
import { auditPatch } from "./gates.js";
|
|
22
|
-
import { generatePatch, modelTiers } from "./llm.js";
|
|
22
|
+
import { generatePatch, generateRulePatch, modelTiers, RULE_TIER } from "./llm.js";
|
|
23
23
|
import { hasLlmKey } from "../llm.js";
|
|
24
24
|
import { applyHunks, createWorktree, diffWorktree, runTests, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
|
|
25
25
|
import { bootServer, detectStartCommand } from "./boot.js";
|
|
@@ -120,20 +120,24 @@ export async function heal(finding, opts) {
|
|
|
120
120
|
// 1. Redact — only the redacted copy is shown to the model.
|
|
121
121
|
const red = redact(original);
|
|
122
122
|
const ctx = { finding, filePath, fileContent: original, redactedContent: red.text };
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
// A free, no-key rule-based fix (null/undefined deref) — tried before any LLM.
|
|
124
|
+
const rulePatch = !opts.patchFn ? generateRulePatch(ctx) : null;
|
|
125
|
+
// No transport configured, and no rule fix applies → nothing to try.
|
|
126
|
+
if (!opts.patchFn && !hasLlmKey() && !rulePatch) {
|
|
126
127
|
return {
|
|
127
128
|
...base,
|
|
128
129
|
status: "no-llm",
|
|
129
|
-
error: "
|
|
130
|
+
error: "this crash needs a model — the free fixer only handles null/undefined derefs. Add a key: Anthropic → ANTHROPIC_API_KEY, or any provider → AZTRX_API_BASE + AZTRX_API_KEY + AZTRX_MODEL (no Aztrx account needed)",
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
|
-
// The Smart Cloud Router tier plan:
|
|
133
|
-
// An injected patchFn collapses to a single tier
|
|
134
|
-
const tiers =
|
|
135
|
-
? [{ model:
|
|
136
|
-
|
|
133
|
+
// The Smart Cloud Router tier plan: the free rule fix first, then fast/cheap,
|
|
134
|
+
// then Sonnet. An injected patchFn collapses to a single tier.
|
|
135
|
+
const tiers = [
|
|
136
|
+
...(rulePatch ? [{ model: RULE_TIER, label: "fast" }] : []),
|
|
137
|
+
...(opts.patchFn
|
|
138
|
+
? [{ model: opts.model ?? "default", label: "sonnet" }]
|
|
139
|
+
: modelTiers(opts.model, opts.fastModel)),
|
|
140
|
+
];
|
|
137
141
|
const wt = await createWorktree(opts.repoRoot, finding.id);
|
|
138
142
|
// The winning (or last) patch + verification, held back for the final save.
|
|
139
143
|
let savedPatch = null;
|
|
@@ -141,6 +145,10 @@ export async function heal(finding, opts) {
|
|
|
141
145
|
let savedTest = null;
|
|
142
146
|
let savedGateOk = false;
|
|
143
147
|
let last = base;
|
|
148
|
+
// Multi-model consensus: with AZTRX_CONSENSUS, try every tier and keep the
|
|
149
|
+
// smallest-diff fix that actually heals, instead of the first one that works.
|
|
150
|
+
const consensus = Boolean(process.env.AZTRX_CONSENSUS);
|
|
151
|
+
let best = null;
|
|
144
152
|
try {
|
|
145
153
|
for (const tier of tiers) {
|
|
146
154
|
// 2. Generate (this tier).
|
|
@@ -269,8 +277,20 @@ export async function heal(finding, opts) {
|
|
|
269
277
|
verification: v,
|
|
270
278
|
model: tier.model,
|
|
271
279
|
};
|
|
272
|
-
if (v.fixed)
|
|
273
|
-
|
|
280
|
+
if (v.fixed) {
|
|
281
|
+
const diffSize = hunks.reduce((s, h) => s + h.replace.length, 0);
|
|
282
|
+
if (!best || diffSize < best.diffSize) {
|
|
283
|
+
best = { result: last, patch, verification: v, gateOk: gate.ok, diffSize };
|
|
284
|
+
}
|
|
285
|
+
if (!consensus)
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (best) {
|
|
290
|
+
last = best.result;
|
|
291
|
+
savedPatch = best.patch;
|
|
292
|
+
savedVerification = best.verification;
|
|
293
|
+
savedGateOk = best.gateOk;
|
|
274
294
|
}
|
|
275
295
|
// 5. Hand off a reviewable patch (the winning — or last — attempt only).
|
|
276
296
|
if (savedPatch && savedVerification) {
|