aztrx-cli 0.2.4 → 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
@@ -47,7 +63,7 @@ aztrx-cli run http://localhost:3000
47
63
  ## Fix it, not just find it
48
64
 
49
65
  The only feature that needs a key. `--fix` hands the crash to an LLM and applies a
50
- verified patch to your working tree. Set `ANTHROPIC_API_KEY` and run:
66
+ verified patch to your working tree. Anthropic by default — set `ANTHROPIC_API_KEY`:
51
67
 
52
68
  ```bash
53
69
  export ANTHROPIC_API_KEY="your-api-key"
@@ -55,6 +71,16 @@ npx aztrx-cli run http://localhost:3000 --fix # find → explain → he
55
71
  npx aztrx-cli run http://localhost:3000 --fix --yes # non-interactive (CI)
56
72
  ```
57
73
 
74
+ Or use **any model** — Grok, DeepSeek, Gemini, GPT, Kimi, Mistral, OpenRouter, or a
75
+ local Ollama/vLLM. Point it at any OpenAI-compatible `/v1` endpoint:
76
+
77
+ ```bash
78
+ export AZTRX_API_BASE="https://openrouter.ai/api/v1" # or api.openai.com/v1, api.x.ai/v1, ...
79
+ export AZTRX_API_KEY="your-key"
80
+ export AZTRX_MODEL="anthropic/claude-sonnet-5" # any model the provider serves
81
+ npx aztrx-cli run http://localhost:3000 --fix
82
+ ```
83
+
58
84
  `--fix` chains find → explain → heal, then asks *"Apply the fix?"* (`y/N`). On yes, the
59
85
  verified patch lands in your working tree — `git diff` shows the result. Aztrx AI never
60
86
  commits.
@@ -72,10 +98,12 @@ on your own test suite before you ever see it.
72
98
  Everything else is optional. One line each — the full table is in the [CLI reference](#cli-reference).
73
99
 
74
100
  ### Fuzz harder
75
- `--fuzz` breaks the *client*; `--http-fuzz` attacks the *server* it harvests your app's
76
- real endpoints and throws hostile requests at them (query overflow, JSON type-confusion,
77
- header injection), turning every `5xx` into an executable repro. When a 500 body leaks a
78
- 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.
79
107
 
80
108
  ### Parallel swarm
81
109
  `--workers 4` fans detection into parallel workers (walk + several fuzz seeds + http-fuzz),
@@ -129,6 +157,7 @@ and niche tuning knobs).
129
157
  | `--magic-fix` | Deprecated alias for `--fix` (hidden) | — |
130
158
  | `--explain` | Print a human-language summary of the findings | — |
131
159
  | `--yes` / `-y` | Auto-apply verified fixes without prompting (with `--fix`) | — |
160
+ | `--pr` | Open a merge-ready PR with the verified fixes (with `--fix`) | — |
132
161
  | `--lang <code>` | Language for the human-language summary (`en`, `ru`) | `en` |
133
162
  | `--upload` | Stream run findings to the cloud ingest backend | — |
134
163
  | `--api-key <key>` | Auth key for `--upload` / `--share-data` | `$AZTRX_API_KEY` |
@@ -138,14 +167,15 @@ and niche tuning knobs).
138
167
  | `--workers <n>` | Number of parallel detection workers | `1` |
139
168
  | `--swarm` | Hidden alias for `--workers auto` | — |
140
169
  | `--repro-runs <n>` | Flake-rate replay iterations | `3` |
141
- | `--heal-model <model>` | Fallback LLM tier | `claude-sonnet-5` |
142
- | `--heal-fast-model <model>` | Fast/cheap first tier | `claude-haiku-4-5` |
170
+ | `--heal-model <model>` | Fallback LLM tier | `claude-sonnet-5` / `$AZTRX_MODEL` |
171
+ | `--heal-fast-model <model>` | Fast/cheap first tier | `claude-haiku-4-5` / `$AZTRX_FAST_MODEL` |
143
172
  | `--test-command <cmd>` | Test command run against a healed patch | `npm test` (auto-detected) |
144
173
  | `--test-timeout <ms>` | Timeout for the heal test gate | `300000` |
145
174
  | `--no-test` | Skip the test gate during healing | — |
146
175
  | `--start-command <cmd>` | Command to boot the app for server healing | `scripts.dev` → `scripts.start` (auto-detected) |
147
176
  | `--pr-comment [path]` | Write a GitHub PR markdown comment | `.aztrx/pr-comment.md` |
148
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/` |
149
179
  | `--telemetry` | Collect anonymized tuples locally (opt-in) | — |
150
180
  | `--share-data` | Also upload the sanitized tuples (opt-in) | — |
151
181
  | `--repo <path>` | Root path for sourcemap → source resolution | cwd |
@@ -196,7 +226,7 @@ jobs:
196
226
  permissions: { contents: read, pull-requests: write }
197
227
  steps:
198
228
  - uses: actions/checkout@v4
199
- - uses: DanisChaparov/aztrx@40570783592a768b5e012a984eee1ff334850a5d
229
+ - uses: Aztrx-AI/aztrx@dc90680bca73343863da6e98c46e64be0223c6ee
200
230
  with:
201
231
  url: http://localhost:3000
202
232
  start-command: npm run dev # optional — boot the app in the background
@@ -210,7 +240,7 @@ Or as a reusable workflow:
210
240
  on: pull_request
211
241
  jobs:
212
242
  aztrx:
213
- uses: DanisChaparov/aztrx/.github/workflows/aztrx-pr.yml@40570783592a768b5e012a984eee1ff334850a5d
243
+ uses: Aztrx-AI/aztrx/.github/workflows/aztrx-pr.yml@dc90680bca73343863da6e98c46e64be0223c6ee
214
244
  with:
215
245
  url: http://localhost:3000
216
246
  start-command: npm run dev
@@ -230,7 +260,7 @@ npx aztrx-cli run http://localhost:3000 --badge badge.svg
230
260
  ```
231
261
 
232
262
  ```markdown
233
- [![aztrx](badge.svg)](https://github.com/DanisChaparov/aztrx)
263
+ [![aztrx](badge.svg)](https://github.com/Aztrx-AI/aztrx)
234
264
  ```
235
265
 
236
266
  The badge is a self-contained SVG generated from the run's findings — green
@@ -261,7 +291,7 @@ jobs:
261
291
  sleep 2
262
292
  done
263
293
  - name: Generate badge
264
- run: npx --yes aztrx-cli@0.2.4 run http://localhost:3000 --badge badge.svg
294
+ run: npx --yes aztrx-cli@0.4.0 run http://localhost:3000 --badge badge.svg
265
295
  - name: Commit badge
266
296
  run: |
267
297
  git config user.name "github-actions[bot]"
@@ -279,12 +309,13 @@ are, and the run still exits 0 so the commit step always runs. Swap `push` for a
279
309
 
280
310
  ## Smart Cloud Router
281
311
 
282
- `--heal` is backed by a two-tier router. The fast/cheap model
283
- (`claude-haiku-4-5`) generates first; its patch is gated, compiled, and replayed
284
- against the repro. If the bug still reproduces or the patch fails a gate
285
- aztrx falls back to `claude-sonnet-5` and tries again. Most one-line fixes never
286
- pay for the big model. Tiers are configurable via `AZTRX_FAST_MODEL` /
287
- `AZTRX_MODEL` or `--heal-fast-model` / `--heal-model`.
312
+ `--heal` is backed by a two-tier router: a fast/cheap model generates first, its patch
313
+ is gated, compiled, and replayed against the repro, and if the bug still reproduces (or
314
+ the patch fails a gate) aztrx falls back to the stronger model and tries again. Most
315
+ one-line fixes never pay for the big model. With Anthropic the defaults are
316
+ `claude-haiku-4-5` `claude-sonnet-5`; with any other provider you pick both via
317
+ `AZTRX_FAST_MODEL` / `AZTRX_MODEL` (or `--heal-fast-model` / `--heal-model`). When only
318
+ one model is set, the router collapses to a single tier.
288
319
 
289
320
  ## Security & data flow
290
321
 
@@ -320,7 +351,7 @@ LLM call.
320
351
  CORS.
321
352
  - **`.aztrx/` is gitignored** on `init` — repro specs, reports, and patches stay
322
353
  out of history.
323
- - **Pinned supply chain.** The GitHub Action pins `aztrx-cli@0.2.4` (never
354
+ - **Pinned supply chain.** The GitHub Action pins `aztrx-cli@0.4.0` (never
324
355
  `@latest`).
325
356
 
326
357
  ## Telemetry & privacy
@@ -412,7 +443,7 @@ Aztrx AI is a decoupled, event-driven pipeline — modules talk only through an
412
443
  ## Contributing
413
444
 
414
445
  ```bash
415
- git clone https://github.com/DanisChaparov/aztrx
446
+ git clone https://github.com/Aztrx-AI/aztrx
416
447
  cd aztrx
417
448
  npm install
418
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) {
@@ -122,17 +124,19 @@ program
122
124
  .addOption(opt("--fix", "find → explain → heal → apply: one-command fix", "fix"))
123
125
  .addOption(opt("--heal", "closed-loop healing for crash/error findings (implies --repro)", "fix"))
124
126
  .addOption(opt("--magic-fix", "alias for --fix (deprecated)").hideHelp())
125
- .addOption(opt("--heal-model <model>", "LLM model for healing — the fallback tier (default claude-sonnet-5)", "advanced"))
126
- .addOption(opt("--heal-fast-model <model>", "fast/cheap first tier for the smart router (default claude-haiku-4-5)", "advanced"))
127
+ .addOption(opt("--heal-model <model>", "LLM model for healing (default: claude-sonnet-5 or $AZTRX_MODEL)", "advanced"))
128
+ .addOption(opt("--heal-fast-model <model>", "fast/cheap first tier (default: claude-haiku-4-5 or $AZTRX_FAST_MODEL)", "advanced"))
127
129
  .addOption(opt("--test-command <cmd>", "test command run against a healed patch (default: npm test, auto-detected)", "advanced"))
128
130
  .addOption(opt("--test-timeout <ms>", "timeout for the heal test gate, ms", "advanced").default("300000"))
129
131
  .addOption(opt("--no-test", "skip the test gate during healing", "advanced"))
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,9 +269,18 @@ program
258
269
  }
259
270
  }
260
271
  }
261
- if (!useUi) {
262
- suggestNext(findings, opts);
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
+ }
263
280
  }
281
+ // Suggest the next flag (e.g. --fix) after the run, in both the live TUI
282
+ // and plain paths. In the TUI this prints after the panel has finished.
283
+ suggestNext(findings, opts);
264
284
  // Drain any in-flight telemetry uploads (each bounded) before exit, so a
265
285
  // pending `--share-data` dispatch isn't killed mid-flight. Never affects
266
286
  // the exit code.
@@ -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
  }
@@ -20,6 +20,7 @@ import * as path from "path";
20
20
  import { redact, unredact } from "./redact.js";
21
21
  import { auditPatch } from "./gates.js";
22
22
  import { generatePatch, modelTiers } from "./llm.js";
23
+ import { hasLlmKey } from "../llm.js";
23
24
  import { applyHunks, createWorktree, diffWorktree, runTests, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
24
25
  import { bootServer, detectStartCommand } from "./boot.js";
25
26
  import { verifyFix } from "./verify.js";
@@ -121,18 +122,18 @@ export async function heal(finding, opts) {
121
122
  const ctx = { finding, filePath, fileContent: original, redactedContent: red.text };
122
123
  // No transport configured (and no injected generator) → nothing to try. A
123
124
  // higher model tier can't fix a missing key, so bail before paying anything.
124
- if (!opts.patchFn && !process.env.ANTHROPIC_API_KEY) {
125
+ if (!opts.patchFn && !hasLlmKey()) {
125
126
  return {
126
127
  ...base,
127
128
  status: "no-llm",
128
- error: "heal: ANTHROPIC_API_KEY is not set (and no patchFn was injected)",
129
+ error: "heal: no LLM API key is set (set ANTHROPIC_API_KEY, AZTRX_API_KEY, or AZTRX_API_BASE)",
129
130
  };
130
131
  }
131
132
  // The Smart Cloud Router tier plan: fast/cheap first, Sonnet as the fallback.
132
133
  // An injected patchFn collapses to a single tier (there is no model to route).
133
134
  const tiers = opts.patchFn
134
- ? [{ model: opts.model ?? "claude-sonnet-5", label: "sonnet" }]
135
- : modelTiers(opts.model);
135
+ ? [{ model: opts.model ?? "default", label: "sonnet" }]
136
+ : modelTiers(opts.model, opts.fastModel);
136
137
  const wt = await createWorktree(opts.repoRoot, finding.id);
137
138
  // The winning (or last) patch + verification, held back for the final save.
138
139
  let savedPatch = null;
@@ -140,6 +141,10 @@ export async function heal(finding, opts) {
140
141
  let savedTest = null;
141
142
  let savedGateOk = false;
142
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;
143
148
  try {
144
149
  for (const tier of tiers) {
145
150
  // 2. Generate (this tier).
@@ -268,8 +273,20 @@ export async function heal(finding, opts) {
268
273
  verification: v,
269
274
  model: tier.model,
270
275
  };
271
- if (v.fixed)
272
- 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;
273
290
  }
274
291
  // 5. Hand off a reviewable patch (the winning — or last — attempt only).
275
292
  if (savedPatch && savedVerification) {
@@ -7,26 +7,31 @@
7
7
  * module's callers.
8
8
  */
9
9
  import { redact } from "./redact.js";
10
- const DEFAULT_MODEL = process.env.AZTRX_MODEL || "claude-sonnet-5";
11
- /** Cheap/fast first tier for the Smart Cloud Router. The idea: most one-line
12
- * fixes are trivial, so try the small model before paying for the big one. */
13
- const FAST_MODEL = process.env.AZTRX_FAST_MODEL || "claude-haiku-4-5-20251001";
14
- const API_URL = "https://api.anthropic.com/v1/messages";
10
+ import { complete, primaryModel, fastModel } from "../llm.js";
15
11
  /**
16
12
  * The Smart Cloud Router's tier plan: fast/cheap first, then the capable model
17
13
  * as the fallback. Collapses to a single tier when the two resolve to the same
18
14
  * model (e.g. `AZTRX_FAST_MODEL=claude-sonnet-5`). Consumers loop over this in
19
15
  * order and stop at the first `healed` result.
20
16
  */
21
- export function modelTiers(fallbackModel) {
22
- const fast = process.env.AZTRX_FAST_MODEL || FAST_MODEL;
23
- const sonnet = fallbackModel || DEFAULT_MODEL;
24
- if (fast === sonnet)
25
- return [{ model: sonnet, label: "sonnet" }];
26
- return [
27
- { model: fast, label: "fast" },
28
- { model: sonnet, label: "sonnet" },
29
- ];
17
+ export function modelTiers(fallbackModel, fastFallback) {
18
+ const fast = fastFallback || fastModel();
19
+ const primary = fallbackModel || primaryModel();
20
+ if (!primary)
21
+ return []; // no model configured the caller reports no-llm
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;
30
35
  }
31
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.
32
37
 
@@ -76,33 +81,12 @@ export function parsePatch(raw) {
76
81
  export async function generatePatch(ctx, opts = {}) {
77
82
  if (opts.patchFn)
78
83
  return opts.patchFn(ctx);
79
- const key = process.env.ANTHROPIC_API_KEY;
80
- if (!key) {
81
- throw new Error("heal: ANTHROPIC_API_KEY is not set (and no patchFn was injected)");
82
- }
83
- const res = await fetch(API_URL, {
84
- method: "POST",
85
- headers: {
86
- "content-type": "application/json",
87
- "x-api-key": key,
88
- "anthropic-version": "2023-06-01",
89
- },
90
- body: JSON.stringify({
91
- model: opts.model ?? DEFAULT_MODEL,
92
- max_tokens: 2048,
93
- temperature: 0,
94
- system: SYSTEM,
95
- messages: [{ role: "user", content: buildPrompt(ctx) }],
96
- }),
84
+ const text = await complete({
85
+ system: SYSTEM,
86
+ prompt: buildPrompt(ctx),
87
+ model: opts.model,
88
+ maxTokens: 2048,
89
+ temperature: 0,
97
90
  });
98
- if (!res.ok) {
99
- const body = await res.text().catch(() => "");
100
- throw new Error(`heal: LLM request failed (${res.status}): ${body.slice(0, 300)}`);
101
- }
102
- const data = (await res.json());
103
- const text = (data.content ?? [])
104
- .filter((c) => c.type === "text")
105
- .map((c) => c.text ?? "")
106
- .join("\n");
107
91
  return parsePatch(text);
108
92
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Provider-agnostic LLM client. Two transports behind one `complete()`:
3
+ *
4
+ * - Anthropic Messages API (default) — key from `ANTHROPIC_API_KEY` (or
5
+ * `AZTRX_API_KEY`), primary model defaults to `claude-sonnet-5`.
6
+ * - Any OpenAI-compatible `/chat/completions` endpoint — selected by setting
7
+ * `AZTRX_API_BASE`. That single flag unlocks OpenAI, Grok, DeepSeek, Gemini,
8
+ * Kimi, Mistral, OpenRouter, and local models (Ollama / vLLM / LM Studio)
9
+ * with their existing keys.
10
+ *
11
+ * `AZTRX_MODEL` / `AZTRX_FAST_MODEL` pick the model(s); they default sensibly
12
+ * for Anthropic and must be set explicitly when a custom base URL is used.
13
+ */
14
+ /** Resolve the active provider from the environment. */
15
+ export function resolveSettings() {
16
+ const base = process.env.AZTRX_API_BASE?.trim();
17
+ if (base) {
18
+ return {
19
+ provider: "openai",
20
+ apiKey: process.env.AZTRX_API_KEY || process.env.OPENAI_API_KEY,
21
+ baseUrl: base.replace(/\/+$/, ""),
22
+ };
23
+ }
24
+ return {
25
+ provider: "anthropic",
26
+ apiKey: process.env.ANTHROPIC_API_KEY || process.env.AZTRX_API_KEY,
27
+ };
28
+ }
29
+ /** Is any provider configured? Used to decide whether to attempt an LLM call. */
30
+ export function hasLlmKey() {
31
+ return Boolean(resolveSettings().apiKey);
32
+ }
33
+ /** The primary model for the active provider. */
34
+ export function primaryModel() {
35
+ const s = resolveSettings();
36
+ if (s.provider === "anthropic")
37
+ return process.env.AZTRX_MODEL || "claude-sonnet-5";
38
+ return process.env.AZTRX_MODEL || "";
39
+ }
40
+ /** The cheap/fast first tier, or undefined when the provider has none. */
41
+ export function fastModel() {
42
+ const s = resolveSettings();
43
+ if (s.provider === "anthropic")
44
+ return process.env.AZTRX_FAST_MODEL || "claude-haiku-4-5-20251001";
45
+ return process.env.AZTRX_FAST_MODEL || undefined;
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
+ }
67
+ /** Run one completion against the active provider and return the text. */
68
+ export async function complete(opts) {
69
+ const s = resolveSettings();
70
+ const model = opts.model || primaryModel();
71
+ if (!model) {
72
+ throw new Error("no model configured — set AZTRX_MODEL (e.g. AZTRX_MODEL=gpt-4o)");
73
+ }
74
+ if (!s.apiKey) {
75
+ throw new Error(s.provider === "anthropic"
76
+ ? "ANTHROPIC_API_KEY is not set"
77
+ : "AZTRX_API_KEY (or OPENAI_API_KEY) is not set");
78
+ }
79
+ announce(model, s.provider, s.baseUrl);
80
+ return s.provider === "anthropic"
81
+ ? anthropicComplete(s, model, opts)
82
+ : openaiComplete(s, model, opts);
83
+ }
84
+ async function anthropicComplete(s, model, opts) {
85
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
86
+ method: "POST",
87
+ headers: {
88
+ "content-type": "application/json",
89
+ "x-api-key": s.apiKey,
90
+ "anthropic-version": "2023-06-01",
91
+ },
92
+ body: JSON.stringify({
93
+ model,
94
+ max_tokens: opts.maxTokens ?? 2048,
95
+ temperature: opts.temperature ?? 0,
96
+ system: opts.system,
97
+ messages: [{ role: "user", content: opts.prompt }],
98
+ }),
99
+ });
100
+ if (!res.ok) {
101
+ const body = await res.text().catch(() => "");
102
+ throw new Error(`LLM request failed (${res.status}): ${body.slice(0, 300)}`);
103
+ }
104
+ const data = (await res.json());
105
+ return (data.content ?? [])
106
+ .filter((c) => c.type === "text")
107
+ .map((c) => c.text ?? "")
108
+ .join("\n");
109
+ }
110
+ async function openaiComplete(s, model, opts) {
111
+ const res = await fetch(`${s.baseUrl}/chat/completions`, {
112
+ method: "POST",
113
+ headers: {
114
+ "content-type": "application/json",
115
+ authorization: `Bearer ${s.apiKey}`,
116
+ },
117
+ body: JSON.stringify({
118
+ model,
119
+ max_tokens: opts.maxTokens ?? 2048,
120
+ temperature: opts.temperature ?? 0,
121
+ messages: [
122
+ { role: "system", content: opts.system },
123
+ { role: "user", content: opts.prompt },
124
+ ],
125
+ }),
126
+ });
127
+ if (!res.ok) {
128
+ const body = await res.text().catch(() => "");
129
+ throw new Error(`LLM request failed (${res.status}): ${body.slice(0, 300)}`);
130
+ }
131
+ const data = (await res.json());
132
+ const content = data.choices?.[0]?.message?.content;
133
+ if (typeof content === "string")
134
+ return content;
135
+ if (Array.isArray(content)) {
136
+ return content
137
+ .filter((c) => typeof c === "object" && c !== null && c.type === "text")
138
+ .map((c) => c.text ?? "")
139
+ .join("\n");
140
+ }
141
+ return "";
142
+ }
@@ -12,8 +12,7 @@
12
12
  import * as fs from "fs";
13
13
  import * as path from "path";
14
14
  import * as ts from "typescript";
15
- const MODEL = process.env.AZTRX_MODEL || "claude-sonnet-5";
16
- const API_URL = "https://api.anthropic.com/v1/messages";
15
+ import { complete, hasLlmKey } from "./llm.js";
17
16
  export function detectLang(filePath) {
18
17
  const ext = path.extname(filePath).toLowerCase();
19
18
  if ([".ts", ".tsx", ".mts", ".cts"].includes(ext))
@@ -86,39 +85,20 @@ export async function modernizeFile(repoRoot, filePath) {
86
85
  catch (e) {
87
86
  return { ok: false, original: "", changes: [], error: `cannot read ${filePath}: ${e.message}` };
88
87
  }
89
- const key = process.env.ANTHROPIC_API_KEY;
90
- if (!key) {
91
- return { ok: false, original, changes: [], lang, error: "ANTHROPIC_API_KEY is not set" };
88
+ if (!hasLlmKey()) {
89
+ return { ok: false, original, changes: [], lang, error: "no LLM API key is set (set ANTHROPIC_API_KEY, AZTRX_API_KEY, or AZTRX_API_BASE)" };
92
90
  }
93
91
  let reply;
94
92
  try {
95
- const res = await fetch(API_URL, {
96
- method: "POST",
97
- headers: {
98
- "content-type": "application/json",
99
- "x-api-key": key,
100
- "anthropic-version": "2023-06-01",
101
- },
102
- body: JSON.stringify({
103
- model: MODEL,
104
- max_tokens: 8192,
105
- temperature: 0,
106
- system: SYSTEM,
107
- messages: [{ role: "user", content: buildPrompt(original, lang) }],
108
- }),
93
+ reply = await complete({
94
+ system: SYSTEM,
95
+ prompt: buildPrompt(original, lang),
96
+ maxTokens: 8192,
97
+ temperature: 0,
109
98
  });
110
- if (!res.ok) {
111
- const body = await res.text().catch(() => "");
112
- return { ok: false, original, changes: [], lang, error: `LLM request failed (${res.status}): ${body.slice(0, 300)}` };
113
- }
114
- const data = (await res.json());
115
- reply = (data.content ?? [])
116
- .filter((c) => c.type === "text")
117
- .map((c) => c.text ?? "")
118
- .join("\n");
119
99
  }
120
100
  catch (e) {
121
- return { ok: false, original, changes: [], lang, error: `LLM request failed: ${e.message}` };
101
+ return { ok: false, original, changes: [], lang, error: e.message };
122
102
  }
123
103
  let parsed;
124
104
  try {
@@ -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
+ }
@@ -11,8 +11,7 @@
11
11
  * The offer-to-apply line is emitted only when verified fixes are actually ready,
12
12
  * so `--explain` (no healing) never promises a fix it doesn't have.
13
13
  */
14
- const MODEL = process.env.AZTRX_MODEL || "claude-sonnet-5";
15
- const API_URL = "https://api.anthropic.com/v1/messages";
14
+ import { complete, hasLlmKey } from "./llm.js";
16
15
  function normalizeLang(lang) {
17
16
  return lang === "ru" ? "ru" : "en";
18
17
  }
@@ -126,31 +125,12 @@ function buildLlmPrompt(findings, lang, hasHealed) {
126
125
  const SYSTEM = "You are the plain-spoken explainer for a QA tool called Aztrx AI. You turn raw runtime-finding data into a concise, human-language summary for a developer. Never invent details absent from the data. Respond in the requested language only.";
127
126
  async function summarizeFindingsLlm(findings, lang) {
128
127
  const hasHealed = findings.some((f) => f.heal?.status === "healed");
129
- const res = await fetch(API_URL, {
130
- method: "POST",
131
- headers: {
132
- "content-type": "application/json",
133
- "x-api-key": process.env.ANTHROPIC_API_KEY,
134
- "anthropic-version": "2023-06-01",
135
- },
136
- body: JSON.stringify({
137
- model: MODEL,
138
- max_tokens: 1024,
139
- temperature: 0.2,
140
- system: SYSTEM,
141
- messages: [{ role: "user", content: buildLlmPrompt(findings, lang, hasHealed) }],
142
- }),
143
- });
144
- if (!res.ok) {
145
- const body = await res.text().catch(() => "");
146
- throw new Error(`summarize: LLM request failed (${res.status}): ${body.slice(0, 300)}`);
147
- }
148
- const data = (await res.json());
149
- const text = (data.content ?? [])
150
- .filter((c) => c.type === "text")
151
- .map((c) => c.text ?? "")
152
- .join("\n")
153
- .trim();
128
+ const text = (await complete({
129
+ system: SYSTEM,
130
+ prompt: buildLlmPrompt(findings, lang, hasHealed),
131
+ maxTokens: 1024,
132
+ temperature: 0.2,
133
+ })).trim();
154
134
  return text || summarizeFindingsTemplate(findings, lang);
155
135
  }
156
136
  /**
@@ -162,7 +142,7 @@ export async function summarizeFindings(findings, opts = {}) {
162
142
  const lang = normalizeLang(opts.lang);
163
143
  if (findings.length === 0)
164
144
  return summarizeFindingsTemplate(findings, lang);
165
- if (!process.env.ANTHROPIC_API_KEY)
145
+ if (!hasLlmKey())
166
146
  return summarizeFindingsTemplate(findings, lang);
167
147
  try {
168
148
  return await summarizeFindingsLlm(findings, lang);
@@ -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.2.4",
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",