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/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 { promptYesNo } from "./core/prompt.js";
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("<url>", "dev server to inspect, e.g. http://localhost:3000")
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: opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL,
193
- loginPassword: opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD,
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: url,
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, url, findings, prPath);
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);
@@ -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>")
@@ -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
- const seen = new Set();
18
- // Re-scan the DOM after every action (the page may have mutated or navigated)
19
- // rather than walking a stale snapshot. Mirrors the fuzzer's recover-and-iterate
20
- // loop, but deterministically (document order) and without re-clicking elements
21
- // it has already acted on.
22
- while (actions < max) {
23
- if (page.url() !== startUrl) {
24
- // A previous action navigated away — return to the starting page.
25
- await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
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
- // A navigation raced the re-scan reset to the start page and retry.
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 (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
72
- continue;
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
- break; // re-scan next iteration — the DOM may have changed
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
- if (!acted)
102
- break; // nothing left to act on
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
+ }
@@ -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. Seeded random walk with a richer vocabulary than the
26
- * deterministic walk: clicks (occasionally doubled), hover, keypresses, select
27
- * option changes, scrolls, and garbage-filled text inputstripping runtime
28
- * errors for the interceptor. Skips anything the destructive deny-list flags,
29
- * and never follows off-origin links.
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 visible = [];
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 && e)
57
- visible.push(h);
58
- }
59
- if (visible.length === 0)
60
- break;
61
- const handle = pick(rnd, visible);
62
- let tag;
63
- try {
64
- tag = await handle.evaluate((el) => el.tagName.toLowerCase());
65
- }
66
- catch {
67
- continue; // element detached/unreadable mid-query — skip
68
- }
69
- let label = "";
70
- try {
71
- label = await handle.evaluate((el) => {
72
- const t = el.innerText ||
73
- el.getAttribute("aria-label") ||
74
- el.getAttribute("value") ||
75
- el.getAttribute("placeholder") ||
76
- "";
77
- return t.trim();
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
- // Text inputs — mostly pour garbage in, sometimes hit keys or hover.
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
- // Select actually change the option, a common source of state bugs.
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
- continue; // options unreadable — skip this select
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
- continue;
190
+ else {
191
+ didAct = false;
192
+ }
143
193
  }
144
- // Clickable mostly click, otherwise hover or keyboard-navigate.
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
- acted++;
172
- await page.waitForTimeout(60);
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
  }
@@ -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
- // No transport configured (and no injected generator) nothing to try. A
124
- // higher model tier can't fix a missing key, so bail before paying anything.
125
- if (!opts.patchFn && !hasLlmKey()) {
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: "heal: no LLM API key is set (set ANTHROPIC_API_KEY, AZTRX_API_KEY, or AZTRX_API_BASE)",
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: fast/cheap first, Sonnet as the fallback.
133
- // An injected patchFn collapses to a single tier (there is no model to route).
134
- const tiers = opts.patchFn
135
- ? [{ model: opts.model ?? "default", label: "sonnet" }]
136
- : modelTiers(opts.model, opts.fastModel);
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
- break;
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) {