aztrx-cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -12
- package/dist/cli.js +20 -0
- package/dist/core/fixPr.js +50 -0
- package/dist/core/fuzzer.js +112 -62
- package/dist/core/heal/index.js +18 -2
- package/dist/core/heal/llm.js +13 -6
- package/dist/core/llm.js +21 -0
- package/dist/core/orchestrator.js +5 -2
- package/dist/core/specCompiler.js +31 -0
- 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
|
|
@@ -82,10 +98,12 @@ on your own test suite before you ever see it.
|
|
|
82
98
|
Everything else is optional. One line each — the full table is in the [CLI reference](#cli-reference).
|
|
83
99
|
|
|
84
100
|
### Fuzz harder
|
|
85
|
-
`--fuzz` breaks the *client
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
101
|
+
`--fuzz` breaks the *client* — a seeded chaos walk that also reads **V8 JS coverage** and
|
|
102
|
+
steers toward code it hasn't reached yet, so it explores rather than re-clicks. `--http-fuzz`
|
|
103
|
+
attacks the *server* — it harvests your app's real endpoints and throws hostile requests at
|
|
104
|
+
them (query overflow, JSON type-confusion, header injection), turning every `5xx` into an
|
|
105
|
+
executable repro. When a 500 body leaks a server stack, `--heal` can even fix it by booting
|
|
106
|
+
the patched app and replaying the repro.
|
|
89
107
|
|
|
90
108
|
### Parallel swarm
|
|
91
109
|
`--workers 4` fans detection into parallel workers (walk + several fuzz seeds + http-fuzz),
|
|
@@ -139,6 +157,7 @@ and niche tuning knobs).
|
|
|
139
157
|
| `--magic-fix` | Deprecated alias for `--fix` (hidden) | — |
|
|
140
158
|
| `--explain` | Print a human-language summary of the findings | — |
|
|
141
159
|
| `--yes` / `-y` | Auto-apply verified fixes without prompting (with `--fix`) | — |
|
|
160
|
+
| `--pr` | Open a merge-ready PR with the verified fixes (with `--fix`) | — |
|
|
142
161
|
| `--lang <code>` | Language for the human-language summary (`en`, `ru`) | `en` |
|
|
143
162
|
| `--upload` | Stream run findings to the cloud ingest backend | — |
|
|
144
163
|
| `--api-key <key>` | Auth key for `--upload` / `--share-data` | `$AZTRX_API_KEY` |
|
|
@@ -156,6 +175,7 @@ and niche tuning knobs).
|
|
|
156
175
|
| `--start-command <cmd>` | Command to boot the app for server healing | `scripts.dev` → `scripts.start` (auto-detected) |
|
|
157
176
|
| `--pr-comment [path]` | Write a GitHub PR markdown comment | `.aztrx/pr-comment.md` |
|
|
158
177
|
| `--badge [path]` | Write a self-contained SVG status badge | `.aztrx/badge.svg` |
|
|
178
|
+
| `--regression-test [dir]` | Copy validated repro specs into the project test dir | `e2e/` or `tests/` |
|
|
159
179
|
| `--telemetry` | Collect anonymized tuples locally (opt-in) | — |
|
|
160
180
|
| `--share-data` | Also upload the sanitized tuples (opt-in) | — |
|
|
161
181
|
| `--repo <path>` | Root path for sourcemap → source resolution | cwd |
|
|
@@ -206,7 +226,7 @@ jobs:
|
|
|
206
226
|
permissions: { contents: read, pull-requests: write }
|
|
207
227
|
steps:
|
|
208
228
|
- uses: actions/checkout@v4
|
|
209
|
-
- uses:
|
|
229
|
+
- uses: Aztrx-AI/aztrx@dc90680bca73343863da6e98c46e64be0223c6ee
|
|
210
230
|
with:
|
|
211
231
|
url: http://localhost:3000
|
|
212
232
|
start-command: npm run dev # optional — boot the app in the background
|
|
@@ -220,7 +240,7 @@ Or as a reusable workflow:
|
|
|
220
240
|
on: pull_request
|
|
221
241
|
jobs:
|
|
222
242
|
aztrx:
|
|
223
|
-
uses:
|
|
243
|
+
uses: Aztrx-AI/aztrx/.github/workflows/aztrx-pr.yml@dc90680bca73343863da6e98c46e64be0223c6ee
|
|
224
244
|
with:
|
|
225
245
|
url: http://localhost:3000
|
|
226
246
|
start-command: npm run dev
|
|
@@ -240,7 +260,7 @@ npx aztrx-cli run http://localhost:3000 --badge badge.svg
|
|
|
240
260
|
```
|
|
241
261
|
|
|
242
262
|
```markdown
|
|
243
|
-
[](https://github.com/
|
|
263
|
+
[](https://github.com/Aztrx-AI/aztrx)
|
|
244
264
|
```
|
|
245
265
|
|
|
246
266
|
The badge is a self-contained SVG generated from the run's findings — green
|
|
@@ -271,7 +291,7 @@ jobs:
|
|
|
271
291
|
sleep 2
|
|
272
292
|
done
|
|
273
293
|
- name: Generate badge
|
|
274
|
-
run: npx --yes aztrx-cli@0.
|
|
294
|
+
run: npx --yes aztrx-cli@0.4.0 run http://localhost:3000 --badge badge.svg
|
|
275
295
|
- name: Commit badge
|
|
276
296
|
run: |
|
|
277
297
|
git config user.name "github-actions[bot]"
|
|
@@ -331,7 +351,7 @@ LLM call.
|
|
|
331
351
|
CORS.
|
|
332
352
|
- **`.aztrx/` is gitignored** on `init` — repro specs, reports, and patches stay
|
|
333
353
|
out of history.
|
|
334
|
-
- **Pinned supply chain.** The GitHub Action pins `aztrx-cli@0.
|
|
354
|
+
- **Pinned supply chain.** The GitHub Action pins `aztrx-cli@0.4.0` (never
|
|
335
355
|
`@latest`).
|
|
336
356
|
|
|
337
357
|
## Telemetry & privacy
|
|
@@ -423,7 +443,7 @@ Aztrx AI is a decoupled, event-driven pipeline — modules talk only through an
|
|
|
423
443
|
## Contributing
|
|
424
444
|
|
|
425
445
|
```bash
|
|
426
|
-
git clone https://github.com/
|
|
446
|
+
git clone https://github.com/Aztrx-AI/aztrx
|
|
427
447
|
cd aztrx
|
|
428
448
|
npm install
|
|
429
449
|
npx playwright install chromium
|
package/dist/cli.js
CHANGED
|
@@ -12,10 +12,12 @@ import { initProject } from "./core/init.js";
|
|
|
12
12
|
import { startStudio } from "./core/studio.js";
|
|
13
13
|
import { writePrComment } from "./core/pr.js";
|
|
14
14
|
import { writeBadge } from "./core/badge.js";
|
|
15
|
+
import { writeRegressionSpecs } from "./core/specCompiler.js";
|
|
15
16
|
import { flushTelemetry } from "./core/telemetry/index.js";
|
|
16
17
|
import { flushCloud } from "./core/cloud/index.js";
|
|
17
18
|
import { summarizeFindings } from "./core/summarize.js";
|
|
18
19
|
import { applyVerifiedPatches } from "./core/heal/apply.js";
|
|
20
|
+
import { openFixPr } from "./core/fixPr.js";
|
|
19
21
|
import { promptYesNo } from "./core/prompt.js";
|
|
20
22
|
import { modernizeFile } from "./core/modernize.js";
|
|
21
23
|
function collect(value, prev) {
|
|
@@ -130,9 +132,11 @@ program
|
|
|
130
132
|
.addOption(opt("--start-command <cmd>", "command to boot the app for server healing (default: auto-detect scripts.dev/scripts.start)", "advanced"))
|
|
131
133
|
.addOption(opt("--explain", "print a human-language summary of the findings (no healing)", "fix"))
|
|
132
134
|
.addOption(opt("-y, --yes", "auto-apply verified fixes without prompting (with --fix)", "fix"))
|
|
135
|
+
.addOption(opt("--pr", "open a PR with the verified fixes (with --fix)", "fix"))
|
|
133
136
|
.addOption(opt("--lang <en|ru>", "language for the human-language summary", "advanced").default("en"))
|
|
134
137
|
.addOption(opt("--pr-comment [path]", "write a GitHub PR markdown comment (default .aztrx/pr-comment.md)", "ship"))
|
|
135
138
|
.addOption(opt("--badge [path]", "write a self-contained SVG badge (default .aztrx/badge.svg)", "ship"))
|
|
139
|
+
.addOption(opt("--regression-test [dir]", "copy validated repro specs into the project test dir (default: e2e/ or tests/)", "ship"))
|
|
136
140
|
.addOption(opt("--telemetry", "opt-in: collect anonymized crash→repro→patch tuples locally (.aztrx/telemetry)", "advanced"))
|
|
137
141
|
.addOption(opt("--share-data", "opt-in: also upload the sanitized tuples to the telemetry endpoint", "advanced"))
|
|
138
142
|
.addOption(opt("--upload", "opt-in: stream run results to the Aztrx AI cloud dashboard (needs --api-key)", "advanced"))
|
|
@@ -231,6 +235,13 @@ program
|
|
|
231
235
|
writeBadge(repoRoot, findings, badgePath);
|
|
232
236
|
console.log(pc.dim(`Badge: ${path.relative(repoRoot, badgePath)}`));
|
|
233
237
|
}
|
|
238
|
+
if (opts.regressionTest) {
|
|
239
|
+
const regDir = typeof opts.regressionTest === "string" ? opts.regressionTest : undefined;
|
|
240
|
+
const written = writeRegressionSpecs(repoRoot, findings, regDir);
|
|
241
|
+
for (const w of written) {
|
|
242
|
+
console.log(pc.green(" ✓ regression test") + ` ${path.relative(repoRoot, w)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
234
245
|
// F13 — human-language summary + opt-in apply (the "Senior Rescuer" flow).
|
|
235
246
|
// The run already printed its structured output; this layer explains it and,
|
|
236
247
|
// under `--fix`, offers to apply the verified patches so `git diff`
|
|
@@ -258,6 +269,15 @@ program
|
|
|
258
269
|
}
|
|
259
270
|
}
|
|
260
271
|
}
|
|
272
|
+
if (opts.pr) {
|
|
273
|
+
const prRes = await openFixPr(repoRoot, findings, url);
|
|
274
|
+
if (prRes.ok) {
|
|
275
|
+
console.log(pc.green(" ✓ PR opened") + ` ${prRes.url}`);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
console.log(pc.yellow(" ◐ PR skipped") + `: ${prRes.error}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
261
281
|
// Suggest the next flag (e.g. --fix) after the run, in both the live TUI
|
|
262
282
|
// and plain paths. In the TUI this prints after the panel has finished.
|
|
263
283
|
suggestNext(findings, opts);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Turn applied, verified fixes into a merge-ready PR: create a branch, commit,
|
|
6
|
+
* and open a PR via the `gh` CLI with the repro evidence in the body. Mirrors
|
|
7
|
+
* the "merge-ready PR" flow but for runtime crashes, not security vulns.
|
|
8
|
+
*/
|
|
9
|
+
export async function openFixPr(repoRoot, findings, url) {
|
|
10
|
+
const healed = findings.filter((f) => f.heal?.status === "healed");
|
|
11
|
+
if (healed.length === 0)
|
|
12
|
+
return { ok: false, error: "no verified fixes to open a PR for" };
|
|
13
|
+
const branch = `aztrx/fix-${Date.now().toString(36)}`;
|
|
14
|
+
const title = `fix: ${healed.length} runtime bug${healed.length === 1 ? "" : "s"} found by Aztrx`;
|
|
15
|
+
const bullets = healed.map((f) => {
|
|
16
|
+
const loc = f.mappedLocation
|
|
17
|
+
? `${f.mappedLocation.filePath}:${f.mappedLocation.line}`
|
|
18
|
+
: "unknown location";
|
|
19
|
+
const repro = f.repro?.verdict
|
|
20
|
+
? `repro: ${f.repro.verdict} ${f.repro.reproductions}/${f.repro.runs}`
|
|
21
|
+
: "";
|
|
22
|
+
return `- **${f.rawMessage.split("\n")[0].slice(0, 120)}** — \`${loc}\` ${repro}`.trim();
|
|
23
|
+
});
|
|
24
|
+
const body = [
|
|
25
|
+
"## Aztrx AI — verified fixes",
|
|
26
|
+
"",
|
|
27
|
+
`Found ${healed.length} runtime bug${healed.length === 1 ? "" : "s"} against ${url}:`,
|
|
28
|
+
"",
|
|
29
|
+
...bullets,
|
|
30
|
+
"",
|
|
31
|
+
"Each fix was gated (AST safety), compiled, run against the test suite, and replayed against the repro in an isolated worktree before this PR.",
|
|
32
|
+
].join("\n");
|
|
33
|
+
try {
|
|
34
|
+
await exec("git", ["-C", repoRoot, "checkout", "-b", branch]);
|
|
35
|
+
await exec("git", ["-C", repoRoot, "add", "-A"]);
|
|
36
|
+
await exec("git", ["-C", repoRoot, "commit", "-m", title]);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return { ok: false, error: `git failed: ${e.message}` };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await exec("gh", ["pr", "create", "--title", title, "--body", body], {
|
|
43
|
+
cwd: repoRoot,
|
|
44
|
+
});
|
|
45
|
+
return { ok: true, url: stdout.trim() };
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return { ok: false, error: `gh pr create failed (is gh installed and authenticated?): ${e.message}` };
|
|
49
|
+
}
|
|
50
|
+
}
|
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
|
@@ -141,6 +141,10 @@ export async function heal(finding, opts) {
|
|
|
141
141
|
let savedTest = null;
|
|
142
142
|
let savedGateOk = false;
|
|
143
143
|
let last = base;
|
|
144
|
+
// Multi-model consensus: with AZTRX_CONSENSUS, try every tier and keep the
|
|
145
|
+
// smallest-diff fix that actually heals, instead of the first one that works.
|
|
146
|
+
const consensus = Boolean(process.env.AZTRX_CONSENSUS);
|
|
147
|
+
let best = null;
|
|
144
148
|
try {
|
|
145
149
|
for (const tier of tiers) {
|
|
146
150
|
// 2. Generate (this tier).
|
|
@@ -269,8 +273,20 @@ export async function heal(finding, opts) {
|
|
|
269
273
|
verification: v,
|
|
270
274
|
model: tier.model,
|
|
271
275
|
};
|
|
272
|
-
if (v.fixed)
|
|
273
|
-
|
|
276
|
+
if (v.fixed) {
|
|
277
|
+
const diffSize = hunks.reduce((s, h) => s + h.replace.length, 0);
|
|
278
|
+
if (!best || diffSize < best.diffSize) {
|
|
279
|
+
best = { result: last, patch, verification: v, gateOk: gate.ok, diffSize };
|
|
280
|
+
}
|
|
281
|
+
if (!consensus)
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (best) {
|
|
286
|
+
last = best.result;
|
|
287
|
+
savedPatch = best.patch;
|
|
288
|
+
savedVerification = best.verification;
|
|
289
|
+
savedGateOk = best.gateOk;
|
|
274
290
|
}
|
|
275
291
|
// 5. Hand off a reviewable patch (the winning — or last — attempt only).
|
|
276
292
|
if (savedPatch && savedVerification) {
|
package/dist/core/heal/llm.js
CHANGED
|
@@ -19,12 +19,19 @@ export function modelTiers(fallbackModel, fastFallback) {
|
|
|
19
19
|
const primary = fallbackModel || primaryModel();
|
|
20
20
|
if (!primary)
|
|
21
21
|
return []; // no model configured — the caller reports no-llm
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
const tiers = [];
|
|
23
|
+
if (fast && fast !== primary)
|
|
24
|
+
tiers.push({ model: fast, label: "fast" });
|
|
25
|
+
tiers.push({ model: primary, label: "sonnet" });
|
|
26
|
+
// Extra models for multi-model consensus (`AZTRX_CONSENSUS_MODELS`), deduped.
|
|
27
|
+
for (const m of (process.env.AZTRX_CONSENSUS_MODELS || "")
|
|
28
|
+
.split(",")
|
|
29
|
+
.map((s) => s.trim())
|
|
30
|
+
.filter(Boolean)) {
|
|
31
|
+
if (!tiers.some((t) => t.model === m))
|
|
32
|
+
tiers.push({ model: m, label: "sonnet" });
|
|
33
|
+
}
|
|
34
|
+
return tiers;
|
|
28
35
|
}
|
|
29
36
|
const SYSTEM = `You are a meticulous bug-fixing engineer. You are given a single source file and a runtime error that occurs in it. Produce a MINIMAL fix as a Search & Replace diff.
|
|
30
37
|
|
package/dist/core/llm.js
CHANGED
|
@@ -44,6 +44,26 @@ export function fastModel() {
|
|
|
44
44
|
return process.env.AZTRX_FAST_MODEL || "claude-haiku-4-5-20251001";
|
|
45
45
|
return process.env.AZTRX_FAST_MODEL || undefined;
|
|
46
46
|
}
|
|
47
|
+
/** Human-readable description of the active provider + model, e.g. `grok-2 via https://api.x.ai/v1`. */
|
|
48
|
+
export function describeLlm(model) {
|
|
49
|
+
const s = resolveSettings();
|
|
50
|
+
const m = model || primaryModel();
|
|
51
|
+
if (s.provider === "anthropic")
|
|
52
|
+
return `${m} (Anthropic)`;
|
|
53
|
+
return `${m} via ${s.baseUrl}`;
|
|
54
|
+
}
|
|
55
|
+
// Announce the resolved model once per distinct (provider, model), so the two-tier
|
|
56
|
+
// router shows each tier as it's tried without spamming. Written to stderr so it never
|
|
57
|
+
// corrupts the Ink TUI (which renders on stdout).
|
|
58
|
+
const announced = new Set();
|
|
59
|
+
function announce(model, provider, baseUrl) {
|
|
60
|
+
const key = `${provider}:${model}`;
|
|
61
|
+
if (announced.has(key))
|
|
62
|
+
return;
|
|
63
|
+
announced.add(key);
|
|
64
|
+
const label = provider === "anthropic" ? `${model} (Anthropic)` : `${model} via ${baseUrl}`;
|
|
65
|
+
process.stderr.write(`LLM: ${label}\n`);
|
|
66
|
+
}
|
|
47
67
|
/** Run one completion against the active provider and return the text. */
|
|
48
68
|
export async function complete(opts) {
|
|
49
69
|
const s = resolveSettings();
|
|
@@ -56,6 +76,7 @@ export async function complete(opts) {
|
|
|
56
76
|
? "ANTHROPIC_API_KEY is not set"
|
|
57
77
|
: "AZTRX_API_KEY (or OPENAI_API_KEY) is not set");
|
|
58
78
|
}
|
|
79
|
+
announce(model, s.provider, s.baseUrl);
|
|
59
80
|
return s.provider === "anthropic"
|
|
60
81
|
? anthropicComplete(s, model, opts)
|
|
61
82
|
: openaiComplete(s, model, opts);
|
|
@@ -97,7 +97,7 @@ export async function run(options) {
|
|
|
97
97
|
else {
|
|
98
98
|
emitPhase("walk");
|
|
99
99
|
}
|
|
100
|
-
const { findings, replayStorageState: swarmAuthState, totalActions, workerCount, } = await swarmDetect({
|
|
100
|
+
const { findings, replayStorageState: swarmAuthState, totalActions, totalCoverage, workerCount, } = await swarmDetect({
|
|
101
101
|
url,
|
|
102
102
|
repoRoot,
|
|
103
103
|
maxActions,
|
|
@@ -130,8 +130,11 @@ export async function run(options) {
|
|
|
130
130
|
if (workerCount > 1) {
|
|
131
131
|
say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s).\n`));
|
|
132
132
|
}
|
|
133
|
+
else if (options.fuzz) {
|
|
134
|
+
say(pc.dim(`\nFuzzed ${totalActions} action(s) — covered ${totalCoverage} new code range(s).\n`));
|
|
135
|
+
}
|
|
133
136
|
else {
|
|
134
|
-
say(pc.dim(`\
|
|
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/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",
|