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 +52 -21
- package/dist/cli.js +24 -4
- package/dist/core/fixPr.js +50 -0
- package/dist/core/fuzzer.js +112 -62
- package/dist/core/heal/index.js +23 -6
- package/dist/core/heal/llm.js +25 -41
- package/dist/core/llm.js +142 -0
- package/dist/core/modernize.js +9 -29
- package/dist/core/orchestrator.js +5 -2
- package/dist/core/specCompiler.js +31 -0
- package/dist/core/summarize.js +8 -28
- package/dist/core/swarm.js +7 -3
- package/media/logo.png +0 -0
- package/package.json +2 -2
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
|
-
> **
|
|
3
|
+
> **Catch the runtime crash your Error Boundary hid — and prove it with a test, not a log line.**
|
|
4
4
|
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
Aztrx AI
|
|
8
|
+
Aztrx AI finds **runtime** bugs — not 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
|

|
|
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.
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
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:
|
|
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
|
-
[](https://github.com/
|
|
263
|
+
[](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.
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
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/
|
|
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
|
|
126
|
-
.addOption(opt("--heal-fast-model <model>", "fast/cheap first tier
|
|
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 (
|
|
262
|
-
|
|
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
|
+
}
|
package/dist/core/fuzzer.js
CHANGED
|
@@ -22,17 +22,63 @@ function pick(rnd, arr) {
|
|
|
22
22
|
return arr[Math.floor(rnd() * arr.length)];
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* F5 — chaos fuzzer
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* F5 — chaos fuzzer with coverage guidance. A seeded random walk (click/hover/
|
|
26
|
+
* keypress/select/garbage input) that also reads V8 JS coverage and biases its
|
|
27
|
+
* picks toward elements whose action previously uncovered new code — the same
|
|
28
|
+
* "prefer the input that reaches new code" idea behind libFuzzer/AFL, applied to
|
|
29
|
+
* the DOM. Returns how many brand-new JS ranges this pass executed.
|
|
30
30
|
*/
|
|
31
31
|
export async function fuzz(page, bus, opts = {}) {
|
|
32
32
|
const max = opts.maxActions ?? 100;
|
|
33
33
|
const rnd = mulberry32(opts.seed ?? 42);
|
|
34
34
|
const startUrl = page.url();
|
|
35
35
|
let acted = 0;
|
|
36
|
+
let newCoverage = 0;
|
|
37
|
+
const seenRanges = new Set();
|
|
38
|
+
const interesting = new Set(); // labels whose action uncovered new code
|
|
39
|
+
try {
|
|
40
|
+
await page.coverage.startJSCoverage();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// coverage unsupported in this context — the fuzz still runs, just blind.
|
|
44
|
+
}
|
|
45
|
+
// Snapshot current JS coverage; returns the count of newly-seen ranges.
|
|
46
|
+
const snapshot = async () => {
|
|
47
|
+
let fresh = 0;
|
|
48
|
+
try {
|
|
49
|
+
const entries = await page.coverage.stopJSCoverage();
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const id = e.scriptId || e.url;
|
|
52
|
+
for (const fn of e.functions) {
|
|
53
|
+
for (const r of fn.ranges) {
|
|
54
|
+
const key = `${id}:${r.startOffset}:${r.endOffset}`;
|
|
55
|
+
if (!seenRanges.has(key)) {
|
|
56
|
+
seenRanges.add(key);
|
|
57
|
+
fresh++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
await page.coverage.startJSCoverage();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
return fresh;
|
|
73
|
+
};
|
|
74
|
+
const recordCoverage = async (label) => {
|
|
75
|
+
const fresh = await snapshot();
|
|
76
|
+
if (fresh > 0) {
|
|
77
|
+
newCoverage += fresh;
|
|
78
|
+
if (label)
|
|
79
|
+
interesting.add(label);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
36
82
|
for (let i = 0; i < max; i++) {
|
|
37
83
|
if (page.url() !== startUrl) {
|
|
38
84
|
await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
@@ -46,54 +92,63 @@ export async function fuzz(page, bus, opts = {}) {
|
|
|
46
92
|
await page.mouse.wheel(0, dir === "up" ? -600 : 600).catch(() => { });
|
|
47
93
|
acted++;
|
|
48
94
|
await page.waitForTimeout(40);
|
|
95
|
+
await recordCoverage("");
|
|
49
96
|
continue;
|
|
50
97
|
}
|
|
98
|
+
// Build the actionable list up front (skip destructive/external/non-text),
|
|
99
|
+
// so coverage guidance can bias the pick before we act.
|
|
51
100
|
const handles = await page.$$(SELECTOR);
|
|
52
|
-
const
|
|
101
|
+
const actionable = [];
|
|
53
102
|
for (const h of handles) {
|
|
54
103
|
const v = await h.isVisible().catch(() => false);
|
|
55
104
|
const e = await h.isEnabled().catch(() => false);
|
|
56
|
-
if (v
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
catch {
|
|
81
|
-
label = ""; // degraded — no label to filter on
|
|
82
|
-
}
|
|
83
|
-
if (DESTRUCTIVE.test(label))
|
|
84
|
-
continue;
|
|
85
|
-
if (tag === "a") {
|
|
86
|
-
const href = (await handle.getAttribute("href")) ?? "";
|
|
87
|
-
if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
|
|
105
|
+
if (!v || !e)
|
|
106
|
+
continue;
|
|
107
|
+
let tag;
|
|
108
|
+
try {
|
|
109
|
+
tag = await h.evaluate((el) => el.tagName.toLowerCase());
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue; // element detached/unreadable mid-query — skip
|
|
113
|
+
}
|
|
114
|
+
let label = "";
|
|
115
|
+
try {
|
|
116
|
+
label = await h.evaluate((el) => {
|
|
117
|
+
const t = el.innerText ||
|
|
118
|
+
el.getAttribute("aria-label") ||
|
|
119
|
+
el.getAttribute("value") ||
|
|
120
|
+
el.getAttribute("placeholder") ||
|
|
121
|
+
"";
|
|
122
|
+
return t.trim();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
label = ""; // degraded — no label to filter on
|
|
127
|
+
}
|
|
128
|
+
if (DESTRUCTIVE.test(label))
|
|
88
129
|
continue;
|
|
130
|
+
if (tag === "a") {
|
|
131
|
+
const href = (await h.getAttribute("href")) ?? "";
|
|
132
|
+
if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (tag === "input") {
|
|
136
|
+
const type = (await h.getAttribute("type")) ?? "";
|
|
137
|
+
if (!TEXT_INPUT_TYPES.has(type))
|
|
138
|
+
continue; // skip password/hidden/submit/checkbox/etc.
|
|
139
|
+
}
|
|
140
|
+
actionable.push({ handle: h, tag, label });
|
|
89
141
|
}
|
|
90
|
-
|
|
142
|
+
if (actionable.length === 0)
|
|
143
|
+
break;
|
|
144
|
+
// Coverage guidance: prefer elements that previously uncovered new code.
|
|
145
|
+
const known = actionable.filter((a) => a.label && interesting.has(a.label));
|
|
146
|
+
const chosen = known.length > 0 && rnd() < 0.5 ? pick(rnd, known) : pick(rnd, actionable);
|
|
147
|
+
const { handle, tag, label } = chosen;
|
|
148
|
+
const selectors = await selectorCascade(page, handle);
|
|
149
|
+
const roll = rnd();
|
|
150
|
+
let didAct = true;
|
|
91
151
|
if (tag === "input" || tag === "textarea") {
|
|
92
|
-
const type = (await handle.getAttribute("type")) ?? "";
|
|
93
|
-
if (!TEXT_INPUT_TYPES.has(type))
|
|
94
|
-
continue;
|
|
95
|
-
const selectors = await selectorCascade(page, handle);
|
|
96
|
-
const roll = rnd();
|
|
97
152
|
if (roll < 0.6) {
|
|
98
153
|
const value = pick(rnd, GARBAGE);
|
|
99
154
|
const action = { type: "input", selectors, value, timestamp: Date.now() };
|
|
@@ -116,35 +171,27 @@ export async function fuzz(page, bus, opts = {}) {
|
|
|
116
171
|
if (!opts.dryRun)
|
|
117
172
|
await handle.hover().catch(() => { });
|
|
118
173
|
}
|
|
119
|
-
acted++;
|
|
120
|
-
await page.waitForTimeout(60);
|
|
121
|
-
continue;
|
|
122
174
|
}
|
|
123
|
-
|
|
124
|
-
if (tag === "select") {
|
|
175
|
+
else if (tag === "select") {
|
|
125
176
|
let options = [];
|
|
126
177
|
try {
|
|
127
178
|
options = await handle.evaluate((el) => Array.from(el.options).map((o) => o.value || o.textContent?.trim() || ""));
|
|
128
179
|
}
|
|
129
180
|
catch {
|
|
130
|
-
|
|
181
|
+
didAct = false; // options unreadable — skip this select
|
|
131
182
|
}
|
|
132
|
-
if (options.length > 0) {
|
|
183
|
+
if (didAct && options.length > 0) {
|
|
133
184
|
const value = pick(rnd, options);
|
|
134
|
-
const selectors = await selectorCascade(page, handle);
|
|
135
185
|
const action = { type: "select", selectors, value, timestamp: Date.now() };
|
|
136
186
|
bus.emit("action", action);
|
|
137
187
|
if (!opts.dryRun)
|
|
138
188
|
await handle.selectOption(value).catch(() => { });
|
|
139
|
-
acted++;
|
|
140
|
-
await page.waitForTimeout(60);
|
|
141
189
|
}
|
|
142
|
-
|
|
190
|
+
else {
|
|
191
|
+
didAct = false;
|
|
192
|
+
}
|
|
143
193
|
}
|
|
144
|
-
|
|
145
|
-
const selectors = await selectorCascade(page, handle);
|
|
146
|
-
const roll = rnd();
|
|
147
|
-
if (roll < 0.55) {
|
|
194
|
+
else if (roll < 0.55) {
|
|
148
195
|
const action = { type: "click", selectors, timestamp: Date.now() };
|
|
149
196
|
bus.emit("action", action);
|
|
150
197
|
if (!opts.dryRun) {
|
|
@@ -168,8 +215,11 @@ export async function fuzz(page, bus, opts = {}) {
|
|
|
168
215
|
await page.keyboard.press(key).catch(() => { });
|
|
169
216
|
}
|
|
170
217
|
}
|
|
171
|
-
|
|
172
|
-
|
|
218
|
+
if (didAct) {
|
|
219
|
+
acted++;
|
|
220
|
+
await page.waitForTimeout(60);
|
|
221
|
+
await recordCoverage(label);
|
|
222
|
+
}
|
|
173
223
|
}
|
|
174
|
-
return acted;
|
|
224
|
+
return { actions: acted, newCoverage };
|
|
175
225
|
}
|
package/dist/core/heal/index.js
CHANGED
|
@@ -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 && !
|
|
125
|
+
if (!opts.patchFn && !hasLlmKey()) {
|
|
125
126
|
return {
|
|
126
127
|
...base,
|
|
127
128
|
status: "no-llm",
|
|
128
|
-
error: "heal:
|
|
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 ?? "
|
|
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
|
-
|
|
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) {
|
package/dist/core/heal/llm.js
CHANGED
|
@@ -7,26 +7,31 @@
|
|
|
7
7
|
* module's callers.
|
|
8
8
|
*/
|
|
9
9
|
import { redact } from "./redact.js";
|
|
10
|
-
|
|
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 =
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
25
|
-
return [
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{ model:
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
}
|
package/dist/core/llm.js
ADDED
|
@@ -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
|
+
}
|
package/dist/core/modernize.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
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(`\
|
|
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
|
+
}
|
package/dist/core/summarize.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 (!
|
|
145
|
+
if (!hasLlmKey())
|
|
166
146
|
return summarizeFindingsTemplate(findings, lang);
|
|
167
147
|
try {
|
|
168
148
|
return await summarizeFindingsLlm(findings, lang);
|
package/dist/core/swarm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
"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/
|
|
35
|
+
"url": "https://github.com/Aztrx-AI/aztrx.git"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc",
|