aztrx-cli 0.3.0 → 0.4.0

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 CHANGED
@@ -1,14 +1,30 @@
1
1
  # <img src="media/logo.svg" width="28" height="32" alt="Aztrx logo" align="absmiddle" /> Aztrx AI
2
2
 
3
- > **Autonomous runtime stress-tester, deterministic bug minimizer, human-language explainer, and self-healing engine for web applications.**
3
+ > **Catch the runtime crash your Error Boundary hid and prove it with a test, not a log line.**
4
4
 
5
5
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg?style=flat-square)](https://nodejs.org)
6
6
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](LICENSE)
7
7
 
8
- Aztrx AI drives your web app like a hostile user clicking, entering boundary data, and racing asynchronous UI states. When a runtime crash occurs (even one swallowed by a React Error Boundary), Aztrx AI intercepts it via the Chrome DevTools Protocol, maps it back to the exact source line, shrinks the interaction trace to the bare minimum with **ddmin**, and emits an executable, standalone **Playwright test** that proves the bug — not a log line.
8
+ Aztrx AI finds **runtime** bugsnot security holes. It drives your web app like a
9
+ hostile user (clicking, entering boundary data, racing async state) and catches the
10
+ crashes that ship to real users, *including ones a React Error Boundary swallows* —
11
+ the errors `window.onerror` never sees. Every crash comes back as an exact source line
12
+ plus an executable **Playwright repro** that fails `3/3` times. Then it fixes it.
13
+
14
+ This is the reliability layer — the bug that crashed a real user, not the vuln a hacker
15
+ could exploit.
9
16
 
10
17
  ![aztrx demo](media/demo.gif)
11
18
 
19
+ ```bash
20
+ npx aztrx-cli run http://localhost:3000 # find the crashes — zero setup
21
+ npx aztrx-cli run http://localhost:3000 --fix # …and fix them (needs a key)
22
+ ```
23
+
24
+ | Find | Prove | Fix |
25
+ | --- | --- | --- |
26
+ | deterministic walk · coverage-guided fuzz · HTTP fuzz · swarm | ddmin → executable `.spec.ts` → `[deterministic 3/3]` | gated LLM patch → compile → test → replay → apply / `--pr` |
27
+
12
28
  ---
13
29
 
14
30
  ## Run it
@@ -82,10 +98,12 @@ on your own test suite before you ever see it.
82
98
  Everything else is optional. One line each — the full table is in the [CLI reference](#cli-reference).
83
99
 
84
100
  ### Fuzz harder
85
- `--fuzz` breaks the *client*; `--http-fuzz` attacks the *server* it harvests your app's
86
- real endpoints and throws hostile requests at them (query overflow, JSON type-confusion,
87
- header injection), turning every `5xx` into an executable repro. When a 500 body leaks a
88
- server stack, `--heal` can even fix it by booting the patched app and replaying the repro.
101
+ `--fuzz` breaks the *client* a seeded chaos walk that also reads **V8 JS coverage** and
102
+ steers toward code it hasn't reached yet, so it explores rather than re-clicks. `--http-fuzz`
103
+ attacks the *server* it harvests your app's real endpoints and throws hostile requests at
104
+ them (query overflow, JSON type-confusion, header injection), turning every `5xx` into an
105
+ executable repro. When a 500 body leaks a server stack, `--heal` can even fix it by booting
106
+ the patched app and replaying the repro.
89
107
 
90
108
  ### Parallel swarm
91
109
  `--workers 4` fans detection into parallel workers (walk + several fuzz seeds + http-fuzz),
@@ -139,6 +157,7 @@ and niche tuning knobs).
139
157
  | `--magic-fix` | Deprecated alias for `--fix` (hidden) | — |
140
158
  | `--explain` | Print a human-language summary of the findings | — |
141
159
  | `--yes` / `-y` | Auto-apply verified fixes without prompting (with `--fix`) | — |
160
+ | `--pr` | Open a merge-ready PR with the verified fixes (with `--fix`) | — |
142
161
  | `--lang <code>` | Language for the human-language summary (`en`, `ru`) | `en` |
143
162
  | `--upload` | Stream run findings to the cloud ingest backend | — |
144
163
  | `--api-key <key>` | Auth key for `--upload` / `--share-data` | `$AZTRX_API_KEY` |
@@ -156,6 +175,7 @@ and niche tuning knobs).
156
175
  | `--start-command <cmd>` | Command to boot the app for server healing | `scripts.dev` → `scripts.start` (auto-detected) |
157
176
  | `--pr-comment [path]` | Write a GitHub PR markdown comment | `.aztrx/pr-comment.md` |
158
177
  | `--badge [path]` | Write a self-contained SVG status badge | `.aztrx/badge.svg` |
178
+ | `--regression-test [dir]` | Copy validated repro specs into the project test dir | `e2e/` or `tests/` |
159
179
  | `--telemetry` | Collect anonymized tuples locally (opt-in) | — |
160
180
  | `--share-data` | Also upload the sanitized tuples (opt-in) | — |
161
181
  | `--repo <path>` | Root path for sourcemap → source resolution | cwd |
@@ -206,7 +226,7 @@ jobs:
206
226
  permissions: { contents: read, pull-requests: write }
207
227
  steps:
208
228
  - uses: actions/checkout@v4
209
- - uses: DanisChaparov/aztrx@ee586a4410240efb7ed211ab0e1bf9d188361efa
229
+ - uses: Aztrx-AI/aztrx@dc90680bca73343863da6e98c46e64be0223c6ee
210
230
  with:
211
231
  url: http://localhost:3000
212
232
  start-command: npm run dev # optional — boot the app in the background
@@ -220,7 +240,7 @@ Or as a reusable workflow:
220
240
  on: pull_request
221
241
  jobs:
222
242
  aztrx:
223
- uses: DanisChaparov/aztrx/.github/workflows/aztrx-pr.yml@ee586a4410240efb7ed211ab0e1bf9d188361efa
243
+ uses: Aztrx-AI/aztrx/.github/workflows/aztrx-pr.yml@dc90680bca73343863da6e98c46e64be0223c6ee
224
244
  with:
225
245
  url: http://localhost:3000
226
246
  start-command: npm run dev
@@ -240,7 +260,7 @@ npx aztrx-cli run http://localhost:3000 --badge badge.svg
240
260
  ```
241
261
 
242
262
  ```markdown
243
- [![aztrx](badge.svg)](https://github.com/DanisChaparov/aztrx)
263
+ [![aztrx](badge.svg)](https://github.com/Aztrx-AI/aztrx)
244
264
  ```
245
265
 
246
266
  The badge is a self-contained SVG generated from the run's findings — green
@@ -271,7 +291,7 @@ jobs:
271
291
  sleep 2
272
292
  done
273
293
  - name: Generate badge
274
- run: npx --yes aztrx-cli@0.3.0 run http://localhost:3000 --badge badge.svg
294
+ run: npx --yes aztrx-cli@0.4.0 run http://localhost:3000 --badge badge.svg
275
295
  - name: Commit badge
276
296
  run: |
277
297
  git config user.name "github-actions[bot]"
@@ -331,7 +351,7 @@ LLM call.
331
351
  CORS.
332
352
  - **`.aztrx/` is gitignored** on `init` — repro specs, reports, and patches stay
333
353
  out of history.
334
- - **Pinned supply chain.** The GitHub Action pins `aztrx-cli@0.3.0` (never
354
+ - **Pinned supply chain.** The GitHub Action pins `aztrx-cli@0.4.0` (never
335
355
  `@latest`).
336
356
 
337
357
  ## Telemetry & privacy
@@ -423,7 +443,7 @@ Aztrx AI is a decoupled, event-driven pipeline — modules talk only through an
423
443
  ## Contributing
424
444
 
425
445
  ```bash
426
- git clone https://github.com/DanisChaparov/aztrx
446
+ git clone https://github.com/Aztrx-AI/aztrx
427
447
  cd aztrx
428
448
  npm install
429
449
  npx playwright install chromium
package/dist/cli.js CHANGED
@@ -12,10 +12,12 @@ 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";
20
+ import { openFixPr } from "./core/fixPr.js";
19
21
  import { promptYesNo } from "./core/prompt.js";
20
22
  import { modernizeFile } from "./core/modernize.js";
21
23
  function collect(value, prev) {
@@ -130,9 +132,11 @@ program
130
132
  .addOption(opt("--start-command <cmd>", "command to boot the app for server healing (default: auto-detect scripts.dev/scripts.start)", "advanced"))
131
133
  .addOption(opt("--explain", "print a human-language summary of the findings (no healing)", "fix"))
132
134
  .addOption(opt("-y, --yes", "auto-apply verified fixes without prompting (with --fix)", "fix"))
135
+ .addOption(opt("--pr", "open a PR with the verified fixes (with --fix)", "fix"))
133
136
  .addOption(opt("--lang <en|ru>", "language for the human-language summary", "advanced").default("en"))
134
137
  .addOption(opt("--pr-comment [path]", "write a GitHub PR markdown comment (default .aztrx/pr-comment.md)", "ship"))
135
138
  .addOption(opt("--badge [path]", "write a self-contained SVG badge (default .aztrx/badge.svg)", "ship"))
139
+ .addOption(opt("--regression-test [dir]", "copy validated repro specs into the project test dir (default: e2e/ or tests/)", "ship"))
136
140
  .addOption(opt("--telemetry", "opt-in: collect anonymized crash→repro→patch tuples locally (.aztrx/telemetry)", "advanced"))
137
141
  .addOption(opt("--share-data", "opt-in: also upload the sanitized tuples to the telemetry endpoint", "advanced"))
138
142
  .addOption(opt("--upload", "opt-in: stream run results to the Aztrx AI cloud dashboard (needs --api-key)", "advanced"))
@@ -231,6 +235,13 @@ program
231
235
  writeBadge(repoRoot, findings, badgePath);
232
236
  console.log(pc.dim(`Badge: ${path.relative(repoRoot, badgePath)}`));
233
237
  }
238
+ if (opts.regressionTest) {
239
+ const regDir = typeof opts.regressionTest === "string" ? opts.regressionTest : undefined;
240
+ const written = writeRegressionSpecs(repoRoot, findings, regDir);
241
+ for (const w of written) {
242
+ console.log(pc.green(" ✓ regression test") + ` ${path.relative(repoRoot, w)}`);
243
+ }
244
+ }
234
245
  // F13 — human-language summary + opt-in apply (the "Senior Rescuer" flow).
235
246
  // The run already printed its structured output; this layer explains it and,
236
247
  // under `--fix`, offers to apply the verified patches so `git diff`
@@ -258,6 +269,15 @@ program
258
269
  }
259
270
  }
260
271
  }
272
+ if (opts.pr) {
273
+ const prRes = await openFixPr(repoRoot, findings, url);
274
+ if (prRes.ok) {
275
+ console.log(pc.green(" ✓ PR opened") + ` ${prRes.url}`);
276
+ }
277
+ else {
278
+ console.log(pc.yellow(" ◐ PR skipped") + `: ${prRes.error}`);
279
+ }
280
+ }
261
281
  // Suggest the next flag (e.g. --fix) after the run, in both the live TUI
262
282
  // and plain paths. In the TUI this prints after the panel has finished.
263
283
  suggestNext(findings, opts);
@@ -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
  }
@@ -141,6 +141,10 @@ export async function heal(finding, opts) {
141
141
  let savedTest = null;
142
142
  let savedGateOk = false;
143
143
  let last = base;
144
+ // Multi-model consensus: with AZTRX_CONSENSUS, try every tier and keep the
145
+ // smallest-diff fix that actually heals, instead of the first one that works.
146
+ const consensus = Boolean(process.env.AZTRX_CONSENSUS);
147
+ let best = null;
144
148
  try {
145
149
  for (const tier of tiers) {
146
150
  // 2. Generate (this tier).
@@ -269,8 +273,20 @@ export async function heal(finding, opts) {
269
273
  verification: v,
270
274
  model: tier.model,
271
275
  };
272
- if (v.fixed)
273
- break;
276
+ if (v.fixed) {
277
+ const diffSize = hunks.reduce((s, h) => s + h.replace.length, 0);
278
+ if (!best || diffSize < best.diffSize) {
279
+ best = { result: last, patch, verification: v, gateOk: gate.ok, diffSize };
280
+ }
281
+ if (!consensus)
282
+ break;
283
+ }
284
+ }
285
+ if (best) {
286
+ last = best.result;
287
+ savedPatch = best.patch;
288
+ savedVerification = best.verification;
289
+ savedGateOk = best.gateOk;
274
290
  }
275
291
  // 5. Hand off a reviewable patch (the winning — or last — attempt only).
276
292
  if (savedPatch && savedVerification) {
@@ -19,12 +19,19 @@ export function modelTiers(fallbackModel, fastFallback) {
19
19
  const primary = fallbackModel || primaryModel();
20
20
  if (!primary)
21
21
  return []; // no model configured — the caller reports no-llm
22
- if (!fast || fast === primary)
23
- return [{ model: primary, label: "sonnet" }];
24
- return [
25
- { model: fast, label: "fast" },
26
- { model: primary, label: "sonnet" },
27
- ];
22
+ const tiers = [];
23
+ if (fast && fast !== primary)
24
+ tiers.push({ model: fast, label: "fast" });
25
+ tiers.push({ model: primary, label: "sonnet" });
26
+ // Extra models for multi-model consensus (`AZTRX_CONSENSUS_MODELS`), deduped.
27
+ for (const m of (process.env.AZTRX_CONSENSUS_MODELS || "")
28
+ .split(",")
29
+ .map((s) => s.trim())
30
+ .filter(Boolean)) {
31
+ if (!tiers.some((t) => t.model === m))
32
+ tiers.push({ model: m, label: "sonnet" });
33
+ }
34
+ return tiers;
28
35
  }
29
36
  const SYSTEM = `You are a meticulous bug-fixing engineer. You are given a single source file and a runtime error that occurs in it. Produce a MINIMAL fix as a Search & Replace diff.
30
37
 
package/dist/core/llm.js CHANGED
@@ -44,6 +44,26 @@ export function fastModel() {
44
44
  return process.env.AZTRX_FAST_MODEL || "claude-haiku-4-5-20251001";
45
45
  return process.env.AZTRX_FAST_MODEL || undefined;
46
46
  }
47
+ /** Human-readable description of the active provider + model, e.g. `grok-2 via https://api.x.ai/v1`. */
48
+ export function describeLlm(model) {
49
+ const s = resolveSettings();
50
+ const m = model || primaryModel();
51
+ if (s.provider === "anthropic")
52
+ return `${m} (Anthropic)`;
53
+ return `${m} via ${s.baseUrl}`;
54
+ }
55
+ // Announce the resolved model once per distinct (provider, model), so the two-tier
56
+ // router shows each tier as it's tried without spamming. Written to stderr so it never
57
+ // corrupts the Ink TUI (which renders on stdout).
58
+ const announced = new Set();
59
+ function announce(model, provider, baseUrl) {
60
+ const key = `${provider}:${model}`;
61
+ if (announced.has(key))
62
+ return;
63
+ announced.add(key);
64
+ const label = provider === "anthropic" ? `${model} (Anthropic)` : `${model} via ${baseUrl}`;
65
+ process.stderr.write(`LLM: ${label}\n`);
66
+ }
47
67
  /** Run one completion against the active provider and return the text. */
48
68
  export async function complete(opts) {
49
69
  const s = resolveSettings();
@@ -56,6 +76,7 @@ export async function complete(opts) {
56
76
  ? "ANTHROPIC_API_KEY is not set"
57
77
  : "AZTRX_API_KEY (or OPENAI_API_KEY) is not set");
58
78
  }
79
+ announce(model, s.provider, s.baseUrl);
59
80
  return s.provider === "anthropic"
60
81
  ? anthropicComplete(s, model, opts)
61
82
  : openaiComplete(s, model, opts);
@@ -97,7 +97,7 @@ export async function run(options) {
97
97
  else {
98
98
  emitPhase("walk");
99
99
  }
100
- const { findings, replayStorageState: swarmAuthState, totalActions, workerCount, } = await swarmDetect({
100
+ const { findings, replayStorageState: swarmAuthState, totalActions, totalCoverage, workerCount, } = await swarmDetect({
101
101
  url,
102
102
  repoRoot,
103
103
  maxActions,
@@ -130,8 +130,11 @@ export async function run(options) {
130
130
  if (workerCount > 1) {
131
131
  say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s).\n`));
132
132
  }
133
+ else if (options.fuzz) {
134
+ say(pc.dim(`\nFuzzed ${totalActions} action(s) — covered ${totalCoverage} new code range(s).\n`));
135
+ }
133
136
  else {
134
- say(pc.dim(`\n${options.fuzz ? "Fuzzed" : "Walked"} ${totalActions} action(s).\n`));
137
+ say(pc.dim(`\nWalked ${totalActions} action(s).\n`));
135
138
  }
136
139
  // F7 → F8 → F9: minimize each finding, compile an executable spec, validate
137
140
  // the flake rate. Only crash/error findings with a recorded action history.
@@ -82,3 +82,34 @@ export function writeSpec(repoRoot, finding, actions, url) {
82
82
  fs.writeFileSync(file, compileSpec(finding, actions, url), "utf-8");
83
83
  return file;
84
84
  }
85
+ /**
86
+ * Install validated repro specs into the project's test directory so a fixed
87
+ * bug can't silently regress — the "immunity" step after find → prove → fix.
88
+ * Copies each deterministic/flaky repro into `<dir>/aztrx-<fingerprint>.spec.ts`
89
+ * and returns the written paths. `dir` defaults to an existing test dir, else a
90
+ * gitignored fallback.
91
+ */
92
+ export function writeRegressionSpecs(repoRoot, findings, dir) {
93
+ const target = path.resolve(repoRoot, dir || detectTestDir(repoRoot));
94
+ fs.mkdirSync(target, { recursive: true });
95
+ const written = [];
96
+ for (const f of findings) {
97
+ if (!f.repro?.specPath || f.repro.verdict === "unreliable")
98
+ continue;
99
+ const src = path.resolve(repoRoot, f.repro.specPath);
100
+ if (!fs.existsSync(src))
101
+ continue;
102
+ const dest = path.join(target, `aztrx-${f.fingerprint.slice(0, 8)}.spec.ts`);
103
+ fs.copyFileSync(src, dest);
104
+ written.push(dest);
105
+ }
106
+ return written;
107
+ }
108
+ /** Prefer an existing test dir, else a gitignored fallback. */
109
+ function detectTestDir(repoRoot) {
110
+ for (const d of ["e2e", "tests", "test", "__tests__"]) {
111
+ if (fs.existsSync(path.join(repoRoot, d)))
112
+ return d;
113
+ }
114
+ return ".aztrx/regression";
115
+ }
@@ -130,12 +130,15 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
130
130
  await page.waitForTimeout(800);
131
131
  }
132
132
  let actions = 0;
133
+ let newCoverage = 0;
133
134
  if (loaded) {
134
135
  if (strategy.kind === "walk") {
135
136
  actions = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
136
137
  }
137
138
  else if (strategy.kind === "fuzz") {
138
- actions = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
139
+ const fr = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
140
+ actions = fr.actions;
141
+ newCoverage = fr.newCoverage;
139
142
  }
140
143
  else {
141
144
  actions = await httpFuzz(page, opts.url, workerBus, {
@@ -148,7 +151,7 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
148
151
  }
149
152
  await page.waitForTimeout(500);
150
153
  await context.close();
151
- return { findings: classifier.findings(), actions, replayStorageState };
154
+ return { findings: classifier.findings(), actions, newCoverage, replayStorageState };
152
155
  }
153
156
  /** Dedup findings across workers by fingerprint: sum occurrences, keep the richest. */
154
157
  export function mergeFindings(arrays) {
@@ -227,7 +230,8 @@ export async function swarmDetect(opts) {
227
230
  replayStorageState = r.replayStorageState;
228
231
  const findings = mergeFindings(results.map((r) => r.findings));
229
232
  const totalActions = results.reduce((sum, r) => sum + r.actions, 0);
230
- return { findings, replayStorageState, totalActions, workerCount: strategies.length };
233
+ const totalCoverage = results.reduce((sum, r) => sum + r.newCoverage, 0);
234
+ return { findings, replayStorageState, totalActions, totalCoverage, workerCount: strategies.length };
231
235
  }
232
236
  finally {
233
237
  await browser.close();
package/media/logo.png ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aztrx-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Aztrx AI — runtime stress-tester for web apps. Detect bugs, then prove them with an executable repro.",
6
6
  "license": "Apache-2.0",
@@ -32,7 +32,7 @@
32
32
  ],
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "https://github.com/DanisChaparov/aztrx.git"
35
+ "url": "https://github.com/Aztrx-AI/aztrx.git"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc",