aztrx-cli 0.4.2 → 0.4.4
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 +62 -4
- package/dist/cli.js +61 -1
- package/dist/core/browser.js +47 -0
- package/dist/core/classifier.js +92 -2
- package/dist/core/diagnose.js +91 -0
- package/dist/core/diff.js +137 -0
- package/dist/core/domWalker.js +1 -1
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/index.js +7 -5
- package/dist/core/heal/llm.js +16 -0
- package/dist/core/heal/redact.js +5 -1
- package/dist/core/heal/sandbox.js +5 -1
- package/dist/core/httpFuzzer.js +8 -4
- package/dist/core/orchestrator.js +33 -11
- package/dist/core/patrol/loop.js +218 -0
- package/dist/core/patrol/pr.js +233 -0
- package/dist/core/patrol/record.js +103 -0
- package/dist/core/patrol/state.js +78 -0
- package/dist/core/renderMarkdown.js +92 -0
- package/dist/core/replay.js +2 -2
- package/dist/core/report.js +6 -3
- package/dist/core/resolver.js +86 -25
- package/dist/core/summarize.js +2 -2
- package/dist/core/swarm.js +38 -18
- package/dist/core/ui.js +1 -0
- package/dist/ui/app.js +27 -2
- package/media/repro-demo/crash.gif +0 -0
- package/media/repro-demo/crash.png +0 -0
- package/media/repro-demo/frame_0.png +0 -0
- package/media/repro-demo/frame_1.png +0 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ npx aztrx-cli run http://localhost:3000 --fix # fix them — free for common
|
|
|
22
22
|
## Why Aztrx AI
|
|
23
23
|
|
|
24
24
|
- **Sees swallowed errors.** Error Boundaries and `window.onerror` miss the errors your app *catches*. Aztrx reads the real throw-site stack off the `Error` object — a crash you've never seen in your logs becomes a finding you can't ignore.
|
|
25
|
+
- **Explains the crash in one line.** Every crash/error ships with a one-sentence diagnosis — why it happened and what to change (e.g. `the value before `.cart` is undefined — guard with `?.`). Free, no key, right in the terminal and `report.html`.
|
|
25
26
|
- **Proves, not reports.** Every crash ships with an executable `.spec.ts` repro and a flake-rate verdict — `[deterministic 3/3]`, `[flaky 3/5]`, or `[unreliable]`.
|
|
26
27
|
- **Safe by default.** A deny-by-default network guard blocks off-origin calls, a destructive-action deny-list refuses to click "delete", "pay", or "logout", and nothing leaves your machine unless you opt in.
|
|
27
28
|
|
|
@@ -82,6 +83,7 @@ your test suite before you see it. Aztrx never commits. `--pr` opens a merge-rea
|
|
|
82
83
|
| `--swarm` / `--workers N` | parallel detection workers |
|
|
83
84
|
| `--login` | auto-login to test authenticated pages |
|
|
84
85
|
| `--badge` / `--pr-comment` / `--fail-on` | CI artifacts |
|
|
86
|
+
| `patrol <url>` | autonomous loop — re-scan, fix, open a PR per bug |
|
|
85
87
|
| `modernize <file>` | rewrite legacy JS/TS into modern idiomatic syntax |
|
|
86
88
|
| `studio` | live dashboard on `localhost:7331` |
|
|
87
89
|
|
|
@@ -89,6 +91,35 @@ Full list: `aztrx-cli run --help`, or the [CLI reference](#cli-reference).
|
|
|
89
91
|
|
|
90
92
|
---
|
|
91
93
|
|
|
94
|
+
## Autonomous patrol
|
|
95
|
+
|
|
96
|
+
`aztrx patrol` is the looped version of `run --fix`: point it at a running app and it
|
|
97
|
+
re-scans on an interval, fixes anything new, and opens a **PR per bug** — no human in
|
|
98
|
+
the middle. Each PR body carries a **recorded repro**: a short animated GIF that replays
|
|
99
|
+
the crash step-by-step, so a reviewer sees the bug happen before the fix.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
aztrx-cli patrol http://localhost:3000 # re-scan every 10 min, open a PR per new bug
|
|
103
|
+
aztrx-cli patrol http://localhost:3000 --once # one scan, then exit (great for CI/cron)
|
|
104
|
+
aztrx-cli patrol http://localhost:3000 --batch # group a cycle's fixes into one PR
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Flag | What it does | Default |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| `--interval <s>` | Seconds between scans | `600` |
|
|
110
|
+
| `--max-fixes <n>` | Max PRs to open per session | `5` |
|
|
111
|
+
| `--max-spend <n>` | Hard cap on paid LLM generations per session | unlimited |
|
|
112
|
+
| `--retry-after <s>` | Cooldown before an unfixable bug is retried | `1800` |
|
|
113
|
+
| `--batch` | Group all of a cycle's fixes into one PR | one PR per bug |
|
|
114
|
+
| `--once` | Run a single scan then exit | loop forever |
|
|
115
|
+
| `--fuzz` / `--workers <n>` | Detection mode / parallelism (pass-through to `run`) | — |
|
|
116
|
+
|
|
117
|
+
Guardrails keep the loop from running away: it only stages the files a patch touched
|
|
118
|
+
(never `git add -A`), dedups by crash fingerprint (a re-scan won't re-open the same PR),
|
|
119
|
+
backs off from unfixable bugs, and respects a session-wide LLM spend cap.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
92
123
|
## Security
|
|
93
124
|
|
|
94
125
|
- **Local-first.** Nothing leaves your machine unless you opt in.
|
|
@@ -114,7 +145,7 @@ jobs:
|
|
|
114
145
|
permissions: { contents: read, pull-requests: write }
|
|
115
146
|
steps:
|
|
116
147
|
- uses: actions/checkout@v4
|
|
117
|
-
- uses: Aztrx-AI/aztrx@
|
|
148
|
+
- uses: Aztrx-AI/aztrx@v0.4.3
|
|
118
149
|
with:
|
|
119
150
|
url: http://localhost:3000
|
|
120
151
|
start-command: npm run dev # optional — boot the app in the background
|
|
@@ -137,9 +168,12 @@ niche tuning knobs).
|
|
|
137
168
|
| --- | --- | --- |
|
|
138
169
|
| `--fuzz` | Seeded chaos fuzzing instead of the deterministic walk | — |
|
|
139
170
|
| `--http-fuzz` | Server-side mutation fuzzing — hostile requests against the target origin | — |
|
|
171
|
+
| `--http-fuzz-mutations` | With `--http-fuzz`: also send POST/PUT body mutations (default: GET-only) | — |
|
|
172
|
+
| `--allow-destructive` | Opt-in: test destructive controls/endpoints (delete/pay/logout/checkout) — can mutate real data | — |
|
|
140
173
|
| `--repro` | Minimize (ddmin) → emit Playwright spec → validate flake rate | — |
|
|
141
174
|
| `--heal` | Generate + verify a fix (implies `--repro`) | — |
|
|
142
175
|
| `--fix` | Find → explain → heal → apply — the one-command fix (free for null/undefined derefs) | — |
|
|
176
|
+
| `--magic-fix` | Hidden alias for `--fix` (deprecated) | — |
|
|
143
177
|
| `--explain` | Print a human-language summary of the findings | — |
|
|
144
178
|
| `--yes` / `-y` | Auto-apply verified fixes without prompting (with `--fix`) | — |
|
|
145
179
|
| `--pr` | Open a merge-ready PR with the verified fixes (with `--fix`) | — |
|
|
@@ -150,10 +184,10 @@ niche tuning knobs).
|
|
|
150
184
|
| `--max-actions <n>` | Max actions per pass | `100` |
|
|
151
185
|
| `--seed <n>` | PRNG seed for deterministic fuzz | `42` |
|
|
152
186
|
| `--workers <n>` | Number of parallel detection workers | `1` |
|
|
153
|
-
| `--swarm` |
|
|
187
|
+
| `--swarm` | Auto-size the swarm to CPU cores (capped at 8) | — |
|
|
154
188
|
| `--repro-runs <n>` | Flake-rate replay iterations | `3` |
|
|
155
189
|
| `--heal-model <model>` | Fallback LLM tier | `claude-sonnet-5` / `$AZTRX_MODEL` |
|
|
156
|
-
| `--heal-fast-model <model>` | Fast/cheap first tier | `claude-haiku-4-5` / `$AZTRX_FAST_MODEL` |
|
|
190
|
+
| `--heal-fast-model <model>` | Fast/cheap first tier | `claude-haiku-4-5-20251001` / `$AZTRX_FAST_MODEL` |
|
|
157
191
|
| `--test-command <cmd>` | Test command run against a healed patch | `npm test` (auto-detected) |
|
|
158
192
|
| `--test-timeout <ms>` | Timeout for the heal test gate | `300000` |
|
|
159
193
|
| `--no-test` | Skip the test gate during healing | — |
|
|
@@ -166,7 +200,11 @@ niche tuning knobs).
|
|
|
166
200
|
| `--repo <path>` | Root path for sourcemap → source resolution | cwd |
|
|
167
201
|
| `--allow-host <host>` | Add a host to the network allow-list (repeatable) | — |
|
|
168
202
|
| `--storage-state <path>` | Playwright storage-state for authenticated pages | — |
|
|
203
|
+
| `--auth <path>` | Hidden alias for `--storage-state` | — |
|
|
169
204
|
| `--login` | Auto-login before the pass (needs `AZTRX_AUTH_EMAIL`/`AZTRX_AUTH_PASSWORD`) | — |
|
|
205
|
+
| `--login-email <email>` | Email for `--login` (default: `$AZTRX_AUTH_EMAIL`) | — |
|
|
206
|
+
| `--login-password <pass>` | Password for `--login` (default: `$AZTRX_AUTH_PASSWORD`) | — |
|
|
207
|
+
| `--login-url <url>` | Explicit login page URL for `--login` (default: current page) | — |
|
|
170
208
|
| `--fail-on` | Exit `1` if any crash/error finding is present | — |
|
|
171
209
|
| `--dry-run` | Log planned actions without executing them | — |
|
|
172
210
|
| `--crash-test` | Throw a deliberate error to verify capture | — |
|
|
@@ -182,14 +220,34 @@ Every run writes self-contained artifacts inside `.aztrx/` (gitignored):
|
|
|
182
220
|
.aztrx/
|
|
183
221
|
├── report.html # interactive triage report
|
|
184
222
|
├── repro/<id>.spec.ts # minimal, executable Playwright repro
|
|
185
|
-
├── heal
|
|
223
|
+
├── heal/<id>.patch # gated, compiler-checked fix (one per finding)
|
|
186
224
|
├── events.jsonl # run log (streamed by `aztrx-cli studio`)
|
|
225
|
+
├── patrol.json # patrol cross-run memory (handled fingerprints)
|
|
187
226
|
├── pr-comment.md # GitHub PR markdown (with --pr-comment)
|
|
188
227
|
└── badge.svg # status badge (with --badge)
|
|
189
228
|
```
|
|
190
229
|
|
|
230
|
+
`aztrx patrol` also writes a `aztrx-media/<fingerprint>.gif` recorded repro next to the
|
|
231
|
+
project root — the animated proof inlined in each patrol PR body.
|
|
232
|
+
|
|
191
233
|
---
|
|
192
234
|
|
|
235
|
+
## Benchmarks
|
|
236
|
+
|
|
237
|
+
Aztrx is scored against two corpora — a framework-agnostic archetype baseline and a
|
|
238
|
+
corpus of real **Next.js 16 App Router** apps (Turbopack, client components), each with
|
|
239
|
+
one seeded runtime bug:
|
|
240
|
+
|
|
241
|
+
| corpus | detection | deterministic repro |
|
|
242
|
+
| --- | --- | --- |
|
|
243
|
+
| 13 Next.js 16 apps | **13/13 · 100% recall** | **12/12 · 100%** |
|
|
244
|
+
| 12 vanilla archetypes | **12/12 · 100% recall** | **10/11 · 91%** |
|
|
245
|
+
|
|
246
|
+
Reproduce it yourself: `npm run bench` (archetypes) and `cd bench/frameworks && npm run bench`
|
|
247
|
+
(Next.js corpus). Per-case results and scope notes live in
|
|
248
|
+
[`bench/frameworks/RESULTS.md`](bench/frameworks/RESULTS.md) and
|
|
249
|
+
[`bench/RESULTS.md`](bench/RESULTS.md).
|
|
250
|
+
|
|
193
251
|
## Contributing
|
|
194
252
|
|
|
195
253
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,8 @@ import { applyVerifiedPatches } from "./core/heal/apply.js";
|
|
|
20
20
|
import { openFixPr } from "./core/fixPr.js";
|
|
21
21
|
import { promptYesNo, promptInput } from "./core/prompt.js";
|
|
22
22
|
import { modernizeFile } from "./core/modernize.js";
|
|
23
|
+
import { renderMarkdown } from "./core/renderMarkdown.js";
|
|
24
|
+
import { patrol } from "./core/patrol/loop.js";
|
|
23
25
|
function collect(value, prev) {
|
|
24
26
|
prev.push(value);
|
|
25
27
|
return prev;
|
|
@@ -147,6 +149,7 @@ program
|
|
|
147
149
|
.addOption(opt("--fuzz", "chaos fuzzing instead of the deterministic walk (F5)", "detect"))
|
|
148
150
|
.addOption(opt("--http-fuzz", "HTTP-layer mutation fuzzing — hostile requests against the target origin (F5-http)", "detect"))
|
|
149
151
|
.addOption(opt("--http-fuzz-mutations", "with --http-fuzz: also send POST/PUT body mutations (default: GET-only)", "advanced"))
|
|
152
|
+
.addOption(opt("--allow-destructive", "opt-in: test destructive controls/endpoints (delete/pay/logout/checkout) — can mutate real data", "advanced"))
|
|
150
153
|
.addOption(opt("--seed <n>", "RNG seed for fuzz", "advanced").default("42"))
|
|
151
154
|
.addOption(opt("--workers <n>", "number of parallel detection workers (default 1)", "detect"))
|
|
152
155
|
.addOption(opt("--swarm", "auto-size the swarm to the machine's CPU cores (alias: --workers auto)").hideHelp())
|
|
@@ -223,6 +226,8 @@ program
|
|
|
223
226
|
fuzz: opts.fuzz,
|
|
224
227
|
httpFuzz: opts.httpFuzz,
|
|
225
228
|
httpFuzzMutations: opts.httpFuzzMutations,
|
|
229
|
+
allowDestructive: opts.allowDestructive,
|
|
230
|
+
lang: opts.lang,
|
|
226
231
|
repro: opts.repro || opts.heal || magicFix,
|
|
227
232
|
seed: parseInt(opts.seed, 10),
|
|
228
233
|
workers,
|
|
@@ -297,7 +302,7 @@ program
|
|
|
297
302
|
// shows the result. Never commits.
|
|
298
303
|
if (magicFix || opts.explain) {
|
|
299
304
|
const summary = await summarizeFindings(findings, { lang: opts.lang });
|
|
300
|
-
console.log("\n" + summary);
|
|
305
|
+
console.log("\n" + renderMarkdown(summary));
|
|
301
306
|
}
|
|
302
307
|
if (magicFix) {
|
|
303
308
|
const healed = findings.filter((f) => f.heal?.status === "healed");
|
|
@@ -340,4 +345,59 @@ program
|
|
|
340
345
|
}
|
|
341
346
|
process.exit(0);
|
|
342
347
|
});
|
|
348
|
+
program
|
|
349
|
+
.command("patrol")
|
|
350
|
+
.description("autonomously re-scan the app, fix new bugs, and open a PR per bug")
|
|
351
|
+
.argument("[url]", "app to patrol (auto-detected if omitted), e.g. http://localhost:3000")
|
|
352
|
+
.configureHelp({ formatHelp })
|
|
353
|
+
.addOption(opt("--repo <path>", "project root to inspect/watch (default: cwd)", "advanced"))
|
|
354
|
+
.addOption(opt("--interval <s>", "seconds between scans", "advanced").default("600"))
|
|
355
|
+
.addOption(opt("--max-fixes <n>", "max PRs to open per session", "advanced").default("5"))
|
|
356
|
+
.addOption(opt("--max-spend <n>", "hard cap on paid LLM generations per session", "advanced"))
|
|
357
|
+
.addOption(opt("--retry-after <s>", "cooldown before an unfixed bug is retried", "advanced").default("1800"))
|
|
358
|
+
.addOption(opt("--batch", "group all fixes of a cycle into one PR", "advanced"))
|
|
359
|
+
.addOption(opt("--once", "run a single scan then exit (no loop)", "advanced"))
|
|
360
|
+
.addOption(opt("--max-actions <n>", "max actions per pass", "advanced").default("100"))
|
|
361
|
+
.addOption(opt("--fuzz", "chaos fuzzing instead of the deterministic walk", "detect"))
|
|
362
|
+
.addOption(opt("--workers <n>", "number of parallel detection workers", "detect"))
|
|
363
|
+
.addOption(opt("--lang <en|ru>", "language for the diagnosis", "advanced").default("en"))
|
|
364
|
+
.addOption(opt("--login", "auto-login before each pass", "auth"))
|
|
365
|
+
.addOption(opt("--storage-state <path>", "Playwright storage-state JSON for authenticated pages", "auth"))
|
|
366
|
+
.addOption(opt("--heal-model <model>", "LLM model for healing (default: claude-sonnet-5)", "advanced"))
|
|
367
|
+
.addOption(opt("--test-command <cmd>", "test command run against a healed patch", "advanced"))
|
|
368
|
+
.addOption(opt("--no-test", "skip the test gate during healing", "advanced"))
|
|
369
|
+
.addOption(opt("--start-command <cmd>", "command to boot the app for server healing", "advanced"))
|
|
370
|
+
.action(async (url, opts) => {
|
|
371
|
+
const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
|
|
372
|
+
let targetUrl = url;
|
|
373
|
+
if (!targetUrl) {
|
|
374
|
+
targetUrl = await detectUrl(repoRoot);
|
|
375
|
+
if (!targetUrl) {
|
|
376
|
+
console.error(pc.red("No URL given and none auto-detected. Pass <url>, or run `aztrx-cli init` first."));
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
console.log(pc.dim(`Auto-detected ${targetUrl}`));
|
|
380
|
+
}
|
|
381
|
+
await patrol({
|
|
382
|
+
url: targetUrl,
|
|
383
|
+
repoRoot,
|
|
384
|
+
intervalMs: parseInt(opts.interval, 10) * 1000,
|
|
385
|
+
maxFixes: parseInt(opts.maxFixes, 10),
|
|
386
|
+
maxSpend: opts.maxSpend ? parseInt(opts.maxSpend, 10) : undefined,
|
|
387
|
+
retryAfterMs: parseInt(opts.retryAfter, 10) * 1000,
|
|
388
|
+
batch: Boolean(opts.batch),
|
|
389
|
+
once: Boolean(opts.once),
|
|
390
|
+
maxActions: parseInt(opts.maxActions, 10),
|
|
391
|
+
fuzz: opts.fuzz,
|
|
392
|
+
workers: opts.workers ? parseInt(opts.workers, 10) : undefined,
|
|
393
|
+
lang: opts.lang,
|
|
394
|
+
login: opts.login,
|
|
395
|
+
storageState: opts.storageState,
|
|
396
|
+
healModel: opts.healModel,
|
|
397
|
+
testCommand: opts.testCommand,
|
|
398
|
+
skipTest: opts.test === false,
|
|
399
|
+
startCommand: opts.startCommand,
|
|
400
|
+
});
|
|
401
|
+
process.exit(0);
|
|
402
|
+
});
|
|
343
403
|
program.parseAsync();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chromium launcher with a first-run safety net. Playwright no longer downloads
|
|
3
|
+
* its browser builds on `npm install`, so a fresh `npx aztrx-cli run` would
|
|
4
|
+
* otherwise die on a bare "Executable doesn't exist". This detects that specific
|
|
5
|
+
* failure, installs the Chromium build that matches the bundled Playwright
|
|
6
|
+
* version, and retries once — keeping the zero-setup promise.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
import { createRequire } from "module";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
import { chromium } from "playwright";
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
/** True when the driver can't find its browser build (the "run playwright install" case). */
|
|
15
|
+
function isMissingBrowser(err) {
|
|
16
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17
|
+
return /Executable doesn't exist|playwright install/i.test(msg);
|
|
18
|
+
}
|
|
19
|
+
/** Install the Chromium build that matches the bundled Playwright version. */
|
|
20
|
+
function installChromium() {
|
|
21
|
+
const pkgPath = require.resolve("playwright/package.json");
|
|
22
|
+
const cli = path.join(path.dirname(pkgPath), "cli.js");
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(process.execPath, [cli, "install", "chromium"], {
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
// Route progress to stderr so it never corrupts the Ink TUI on stdout.
|
|
28
|
+
child.stdout?.on("data", (d) => process.stderr.write(d));
|
|
29
|
+
child.stderr?.on("data", (d) => process.stderr.write(d));
|
|
30
|
+
child.on("error", reject);
|
|
31
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`playwright install exited ${code}`)));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Launch Chromium headlessly, installing its browser build first when absent. */
|
|
35
|
+
export async function launchChromium(options = {}) {
|
|
36
|
+
const launch = () => chromium.launch({ headless: true, ...options });
|
|
37
|
+
try {
|
|
38
|
+
return await launch();
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (!isMissingBrowser(err))
|
|
42
|
+
throw err;
|
|
43
|
+
process.stderr.write(pc.dim("First run: downloading the Chromium browser (~150 MB)…\n"));
|
|
44
|
+
await installChromium();
|
|
45
|
+
return await launch();
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/core/classifier.js
CHANGED
|
@@ -44,11 +44,14 @@ function normalize(message) {
|
|
|
44
44
|
.replace(/0x[0-9a-f]+/gi, "<HEX>")
|
|
45
45
|
.replace(/\s+/g, " ");
|
|
46
46
|
}
|
|
47
|
+
// Frame URLs in a V8 stack. `http(s)://` covers normal bundles; `about://React/Server/`
|
|
48
|
+
// is Next.js's client-side render of a Server Action throw site, whose URL encodes
|
|
49
|
+
// the compiled Turbopack chunk. Both carry a trailing `:line:col`.
|
|
50
|
+
const FRAME_URL_RE = /(?:https?:\/\/|about:\/\/React\/Server\/)[^\s)"']+?:\d+:\d+/g;
|
|
47
51
|
function extractFrameUrls(stack) {
|
|
48
52
|
const urls = [];
|
|
49
|
-
const re = /https?:\/\/[^\s)"']+?:\d+:\d+/g;
|
|
50
53
|
let m;
|
|
51
|
-
while ((m =
|
|
54
|
+
while ((m = FRAME_URL_RE.exec(stack)) !== null)
|
|
52
55
|
urls.push(m[0]);
|
|
53
56
|
return urls;
|
|
54
57
|
}
|
|
@@ -70,6 +73,46 @@ export function fingerprintOf(payload) {
|
|
|
70
73
|
const key = `${payload.type}|${normalize(payload.rawMessage)}|${own.join("|")}`;
|
|
71
74
|
return createHash("sha1").update(key).digest("hex").slice(0, 12);
|
|
72
75
|
}
|
|
76
|
+
/** Pull the resource URL out of a network finding's message ("HTTP 500 <url>"
|
|
77
|
+
* or "Request failed: <url> (...)"). Fragment is stripped; the URL is the root
|
|
78
|
+
* cause's identity, not its fragment. */
|
|
79
|
+
function extractUrlFromMessage(msg) {
|
|
80
|
+
const m = msg.match(/https?:\/\/[^\s)"']+/);
|
|
81
|
+
return m ? m[0].split("#")[0] : null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Cross-signal root-cause key. Distinct capture paths for the SAME fault
|
|
85
|
+
* collapse onto one key even though their `type` differs:
|
|
86
|
+
*
|
|
87
|
+
* - a thrown JS error surfaces via `pageerror` (`uncaught_exception`) AND the
|
|
88
|
+
* forwarded `unhandledrejection` hook (`unhandled_rejection`) → keyed by the
|
|
89
|
+
* type-independent fingerprint (normalized message + own frames). The
|
|
90
|
+
* leading `Error:` prefix is normalized away, so the two channels match.
|
|
91
|
+
* - a failed request surfaces via `response` (`network_5xx`), `requestfailed`
|
|
92
|
+
* (`network_timeout`), and the console's "Failed to load resource"
|
|
93
|
+
* (`console_error`) → keyed by the resource URL (for `console_error`, the
|
|
94
|
+
* URL is `msg.location().url`, the failed resource).
|
|
95
|
+
*
|
|
96
|
+
* Falls back to the exact fingerprint when no cross-signal rule applies, so a
|
|
97
|
+
* finding with no natural group still dedups only against itself.
|
|
98
|
+
*/
|
|
99
|
+
export function rootKeyOf(payload) {
|
|
100
|
+
if (payload.type === "uncaught_exception" || payload.type === "unhandled_rejection") {
|
|
101
|
+
const frames = extractFrameUrls(payload.rawStack);
|
|
102
|
+
const own = frames.filter((f) => !isDepFrame(f)).slice(0, 3).map(stripPos);
|
|
103
|
+
return `throw:${normalize(payload.rawMessage)}|${own.join("|")}`;
|
|
104
|
+
}
|
|
105
|
+
if (payload.type === "network_5xx" || payload.type === "network_timeout") {
|
|
106
|
+
const url = extractUrlFromMessage(payload.rawMessage);
|
|
107
|
+
if (url)
|
|
108
|
+
return `net:${url}`;
|
|
109
|
+
}
|
|
110
|
+
else if (payload.type === "console_error" && /Failed to load resource/i.test(payload.rawMessage)) {
|
|
111
|
+
if (payload.url)
|
|
112
|
+
return `net:${payload.url.split("#")[0]}`;
|
|
113
|
+
}
|
|
114
|
+
return fingerprintOf(payload);
|
|
115
|
+
}
|
|
73
116
|
function classifySeverity(payload, isOwnCode) {
|
|
74
117
|
if (DEV_TOOLING_NOISE.some((n) => payload.rawMessage.includes(n)))
|
|
75
118
|
return "noise";
|
|
@@ -113,6 +156,7 @@ export class SignalClassifier {
|
|
|
113
156
|
const finding = {
|
|
114
157
|
id: fingerprint,
|
|
115
158
|
fingerprint,
|
|
159
|
+
rootKey: rootKeyOf(payload),
|
|
116
160
|
occurrences: 1,
|
|
117
161
|
severity: classifySeverity(payload, isOwnCode),
|
|
118
162
|
type: payload.type,
|
|
@@ -128,6 +172,52 @@ export class SignalClassifier {
|
|
|
128
172
|
return [...this.seen.values()].filter((f) => f.severity !== "noise");
|
|
129
173
|
}
|
|
130
174
|
}
|
|
175
|
+
const SEVERITY_RANK = { crash: 3, error: 2, warning: 1, noise: 0 };
|
|
176
|
+
/** The richer of two findings: higher severity wins; ties break toward an
|
|
177
|
+
* own-code source location, then the longer action history. */
|
|
178
|
+
function richer(a, b) {
|
|
179
|
+
if (SEVERITY_RANK[a.severity] !== SEVERITY_RANK[b.severity]) {
|
|
180
|
+
return SEVERITY_RANK[a.severity] > SEVERITY_RANK[b.severity] ? a : b;
|
|
181
|
+
}
|
|
182
|
+
const aOwn = a.mappedLocation?.isOwnCode === true;
|
|
183
|
+
const bOwn = b.mappedLocation?.isOwnCode === true;
|
|
184
|
+
if (aOwn !== bOwn)
|
|
185
|
+
return aOwn ? a : b;
|
|
186
|
+
return a.actionHistory.length >= b.actionHistory.length ? a : b;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Collapse findings that share a root cause across capture paths into a single
|
|
190
|
+
* finding. Runs after fingerprint dedup + worker merge; groups by `rootKey`,
|
|
191
|
+
* keeps the richest representative, and sums occurrences. Safe by construction:
|
|
192
|
+
* the key only groups paths that are the same underlying fault (the same thrown
|
|
193
|
+
* error, or the same failing URL), never distinct bugs.
|
|
194
|
+
*/
|
|
195
|
+
export function collapseSignals(findings) {
|
|
196
|
+
const groups = new Map();
|
|
197
|
+
for (const f of findings) {
|
|
198
|
+
const key = f.rootKey ?? f.fingerprint;
|
|
199
|
+
const existing = groups.get(key);
|
|
200
|
+
if (!existing) {
|
|
201
|
+
groups.set(key, { ...f, actionHistory: [...f.actionHistory] });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const winner = richer(existing, f);
|
|
205
|
+
const merged = {
|
|
206
|
+
...winner,
|
|
207
|
+
occurrences: existing.occurrences + f.occurrences,
|
|
208
|
+
actionHistory: existing.actionHistory.length >= f.actionHistory.length
|
|
209
|
+
? existing.actionHistory
|
|
210
|
+
: f.actionHistory,
|
|
211
|
+
rawStack: (existing.rawStack?.length ?? 0) >= (f.rawStack?.length ?? 0)
|
|
212
|
+
? existing.rawStack
|
|
213
|
+
: f.rawStack,
|
|
214
|
+
mappedLocation: existing.mappedLocation ?? f.mappedLocation,
|
|
215
|
+
serverError: existing.serverError ?? f.serverError,
|
|
216
|
+
};
|
|
217
|
+
groups.set(key, merged);
|
|
218
|
+
}
|
|
219
|
+
return [...groups.values()];
|
|
220
|
+
}
|
|
131
221
|
export async function loadBaseline(repoRoot) {
|
|
132
222
|
const p = path.join(repoRoot, ".aztrx", "baseline.json");
|
|
133
223
|
try {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F14 — the per-finding "diagnosis headline": one sentence that says *why* a
|
|
3
|
+
* crash happened and *what to change*, rendered inline with every crash/error
|
|
4
|
+
* finding (terminal + report.html) on the free, no-key tier.
|
|
5
|
+
*
|
|
6
|
+
* It is deliberately deterministic — keyed on the V8 message shape — so it
|
|
7
|
+
* needs no network round-trip and can never fail the run. The suggested fix
|
|
8
|
+
* mirrors what `--fix` actually applies (optional chaining for null/undefined
|
|
9
|
+
* derefs), so the headline never over-promises a fix it can't deliver.
|
|
10
|
+
*/
|
|
11
|
+
function normalizeLang(lang) {
|
|
12
|
+
return lang === "ru" ? "ru" : "en";
|
|
13
|
+
}
|
|
14
|
+
const PHRASES = {
|
|
15
|
+
en: {
|
|
16
|
+
deref: (n, p) => `the value before \`.${p}\` is ${n} — guard with \`?.\` or a default`,
|
|
17
|
+
toFixed: `you're calling \`.toFixed()\` on a string, not a number — wrap it in \`Number()\` first`,
|
|
18
|
+
notAFunction: `a method was called on a value of the wrong type — check it's the object you expect`,
|
|
19
|
+
notDefined: `a variable is referenced before it's defined — check the name, scope, or import`,
|
|
20
|
+
notConstructor: `\`new\` was called on a non-constructor — check the export/import`,
|
|
21
|
+
notIterable: `you're iterating (spread/for..of) over a non-iterable — coerce it to an array first`,
|
|
22
|
+
jsonParse: `\`JSON.parse\` got malformed input — wrap in try/catch or validate the payload first`,
|
|
23
|
+
recursion: `unbounded recursion — add a base case or guard the recursive call`,
|
|
24
|
+
server5xx: `the server returned 5xx on this route — read the server stack and fix the handler`,
|
|
25
|
+
timeout: `the request hung past the timeout — check for a slow/deadlocked handler or a missing \`await\``,
|
|
26
|
+
unhandledRejection: `a promise rejected with nothing catching it — add \`.catch()\` or \`await\` inside try/catch`,
|
|
27
|
+
uncaught: `an uncaught error escaped — wrap in try/catch or guard the input`,
|
|
28
|
+
},
|
|
29
|
+
ru: {
|
|
30
|
+
deref: (n, p) => `значение перед \`.${p}\` равно ${n} — обезопась через \`?.\` или значение по умолчанию`,
|
|
31
|
+
toFixed: `ты зовёшь \`.toFixed()\` на строке, а не на числе — оберни в \`Number()\` сначала`,
|
|
32
|
+
notAFunction: `метод вызван на значении неверного типа — проверь, что это тот объект, который ты ждёшь`,
|
|
33
|
+
notDefined: `переменная используется до определения — проверь имя, область видимости или импорт`,
|
|
34
|
+
notConstructor: `\`new\` вызван на не-конструкторе — проверь экспорт/импорт`,
|
|
35
|
+
notIterable: `ты итерируешь (spread/for..of) не-итерируемое — сначала приведи к массиву`,
|
|
36
|
+
jsonParse: `\`JSON.parse\` получил битый ввод — оберни в try/catch или сначала провалидируй`,
|
|
37
|
+
recursion: `бесконечная рекурсия — добавь базовый случай или ограничь рекурсивный вызов`,
|
|
38
|
+
server5xx: `сервер вернул 5xx на этом маршруте — смотри серверный стек и чини обработчик`,
|
|
39
|
+
timeout: `запрос завис дольше таймаута — проверь на медленный/мёртвый обработчик или пропущенный \`await\``,
|
|
40
|
+
unhandledRejection: `промис отклонился, а ловить некому — добавь \`.catch()\` или \`await\` в try/catch`,
|
|
41
|
+
uncaught: `непойманная ошибка вырвалась — оберни в try/catch или проверь входные данные`,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
/** Match the two V8 null/undefined-deref shapes (modern and legacy) and return
|
|
45
|
+
* the normalized nullish value + the property being read. */
|
|
46
|
+
function matchDeref(line) {
|
|
47
|
+
let m = /cannot read properties of (undefined|null) \(reading '([^']*)'\)/i.exec(line);
|
|
48
|
+
if (m)
|
|
49
|
+
return { nullish: m[1].toLowerCase(), prop: m[2] };
|
|
50
|
+
m = /cannot read property '([^']*)' of (undefined|null)/i.exec(line);
|
|
51
|
+
if (m)
|
|
52
|
+
return { nullish: m[2].toLowerCase(), prop: m[1] };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One-line diagnosis for a finding, or "" when there is nothing actionable to
|
|
57
|
+
* say (noise, or a shape we don't recognize). Crash/error findings only — the
|
|
58
|
+
* headline is advice, and triaged-away noise deserves none.
|
|
59
|
+
*/
|
|
60
|
+
export function diagnoseFinding(f, lang) {
|
|
61
|
+
if (f.severity !== "crash" && f.severity !== "error")
|
|
62
|
+
return "";
|
|
63
|
+
const p = PHRASES[normalizeLang(lang)];
|
|
64
|
+
const line = (f.rawMessage || "").split("\n")[0].trim();
|
|
65
|
+
const deref = matchDeref(line);
|
|
66
|
+
if (deref)
|
|
67
|
+
return p.deref(deref.nullish, deref.prop);
|
|
68
|
+
if (/\.toFixed\s*is not a function/i.test(line))
|
|
69
|
+
return p.toFixed;
|
|
70
|
+
if (/\bis not a function\b/i.test(line))
|
|
71
|
+
return p.notAFunction;
|
|
72
|
+
if (/\bis not defined\b/i.test(line))
|
|
73
|
+
return p.notDefined;
|
|
74
|
+
if (/\bis not a constructor\b/i.test(line))
|
|
75
|
+
return p.notConstructor;
|
|
76
|
+
if (/\bis not iterable\b/i.test(line))
|
|
77
|
+
return p.notIterable;
|
|
78
|
+
if (/not valid json|unexpected token|unexpected end of json|\bjson\.parse\b/i.test(line))
|
|
79
|
+
return p.jsonParse;
|
|
80
|
+
if (/maximum call stack size exceeded/i.test(line))
|
|
81
|
+
return p.recursion;
|
|
82
|
+
if (f.type === "network_5xx")
|
|
83
|
+
return p.server5xx;
|
|
84
|
+
if (f.type === "network_timeout")
|
|
85
|
+
return p.timeout;
|
|
86
|
+
if (f.type === "unhandled_rejection")
|
|
87
|
+
return p.unhandledRejection;
|
|
88
|
+
if (f.type === "uncaught_exception")
|
|
89
|
+
return p.uncaught;
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-friendly, word-level diff rendering for healed patches.
|
|
3
|
+
*
|
|
4
|
+
* The heal pipeline already computes Search & Replace hunks (`PatchHunk`). This
|
|
5
|
+
* module turns those hunks into renderable lines — green for added, red for
|
|
6
|
+
* removed, with the exact changed *words* highlighted — so the terminal can show
|
|
7
|
+
* "what changed" the way a human reads a diff (Claude-Code-style) instead of a
|
|
8
|
+
* wall of hunks. Zero dependencies: a small LCS over lines plus a
|
|
9
|
+
* common-prefix/suffix word split.
|
|
10
|
+
*/
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
function splitLines(s) {
|
|
13
|
+
const r = s.split("\n");
|
|
14
|
+
// `split` emits a trailing "" when the source ends in "\n"; drop one so a
|
|
15
|
+
// trailing newline doesn't surface as a phantom blank line.
|
|
16
|
+
if (r.length > 1 && r[r.length - 1] === "")
|
|
17
|
+
r.pop();
|
|
18
|
+
return r;
|
|
19
|
+
}
|
|
20
|
+
/** Common-prefix/suffix word split of two (similar) lines into diff tokens. */
|
|
21
|
+
function wordTokens(oldLine, newLine) {
|
|
22
|
+
let i = 0;
|
|
23
|
+
const max = Math.min(oldLine.length, newLine.length);
|
|
24
|
+
while (i < max && oldLine[i] === newLine[i])
|
|
25
|
+
i++;
|
|
26
|
+
let j = 0;
|
|
27
|
+
const maxJ = Math.min(oldLine.length, newLine.length) - i;
|
|
28
|
+
while (j < maxJ && oldLine[oldLine.length - 1 - j] === newLine[newLine.length - 1 - j])
|
|
29
|
+
j++;
|
|
30
|
+
const prefix = oldLine.slice(0, i);
|
|
31
|
+
const removed = oldLine.slice(i, oldLine.length - j);
|
|
32
|
+
const added = newLine.slice(i, newLine.length - j);
|
|
33
|
+
const suffix = oldLine.slice(oldLine.length - j);
|
|
34
|
+
const del = [];
|
|
35
|
+
const add = [];
|
|
36
|
+
const push = (list, text, kind) => {
|
|
37
|
+
if (text)
|
|
38
|
+
list.push({ text, kind });
|
|
39
|
+
};
|
|
40
|
+
push(del, prefix, "ctx");
|
|
41
|
+
push(add, prefix, "ctx");
|
|
42
|
+
push(del, removed, "del");
|
|
43
|
+
push(add, added, "add");
|
|
44
|
+
push(del, suffix, "ctx");
|
|
45
|
+
push(add, suffix, "ctx");
|
|
46
|
+
return { del, add };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Line-level LCS diff between two texts, then word-level refinement inside each
|
|
50
|
+
* replacement block. Context (unchanged) lines are omitted — the diff shows
|
|
51
|
+
* only what changed, which is what matters for a fix review.
|
|
52
|
+
*/
|
|
53
|
+
export function diffText(oldText, newText) {
|
|
54
|
+
const a = splitLines(oldText);
|
|
55
|
+
const b = splitLines(newText);
|
|
56
|
+
const n = a.length;
|
|
57
|
+
const m = b.length;
|
|
58
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
59
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
60
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
61
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const lines = [];
|
|
65
|
+
const delBuf = [];
|
|
66
|
+
const addBuf = [];
|
|
67
|
+
const flush = () => {
|
|
68
|
+
const k = Math.max(delBuf.length, addBuf.length);
|
|
69
|
+
for (let x = 0; x < k; x++) {
|
|
70
|
+
const delLine = delBuf[x];
|
|
71
|
+
const addLine = addBuf[x];
|
|
72
|
+
if (delLine !== undefined && addLine !== undefined) {
|
|
73
|
+
const pair = wordTokens(delLine, addLine);
|
|
74
|
+
lines.push({ type: "del", tokens: pair.del });
|
|
75
|
+
lines.push({ type: "add", tokens: pair.add });
|
|
76
|
+
}
|
|
77
|
+
else if (delLine !== undefined) {
|
|
78
|
+
// Whole line removed — line color, no background.
|
|
79
|
+
lines.push({ type: "del", tokens: [{ text: delLine, kind: "ctx" }] });
|
|
80
|
+
}
|
|
81
|
+
else if (addLine !== undefined) {
|
|
82
|
+
lines.push({ type: "add", tokens: [{ text: addLine, kind: "ctx" }] });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
delBuf.length = 0;
|
|
86
|
+
addBuf.length = 0;
|
|
87
|
+
};
|
|
88
|
+
let i = 0;
|
|
89
|
+
let j = 0;
|
|
90
|
+
while (i < n && j < m) {
|
|
91
|
+
if (a[i] === b[j]) {
|
|
92
|
+
flush();
|
|
93
|
+
i++;
|
|
94
|
+
j++;
|
|
95
|
+
}
|
|
96
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
97
|
+
delBuf.push(a[i]);
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
addBuf.push(b[j]);
|
|
102
|
+
j++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
while (i < n)
|
|
106
|
+
delBuf.push(a[i++]);
|
|
107
|
+
while (j < m)
|
|
108
|
+
addBuf.push(b[j++]);
|
|
109
|
+
flush();
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
112
|
+
/** One group of diff lines per hunk (renderers space the groups apart). */
|
|
113
|
+
export function diffHunks(hunks) {
|
|
114
|
+
return hunks.map((h) => diffText(h.search, h.replace));
|
|
115
|
+
}
|
|
116
|
+
/** ANSI-colorized diff for the plain (non-TUI) log path. */
|
|
117
|
+
export function formatDiff(hunks) {
|
|
118
|
+
return diffHunks(hunks)
|
|
119
|
+
.map((group) => group
|
|
120
|
+
.map((l) => {
|
|
121
|
+
const add = l.type === "add";
|
|
122
|
+
const lineColor = add ? pc.green : pc.red;
|
|
123
|
+
const prefix = lineColor(add ? "+" : "-");
|
|
124
|
+
const body = l.tokens
|
|
125
|
+
.map((t) => {
|
|
126
|
+
if (t.kind === "del")
|
|
127
|
+
return pc.bgRed(pc.white(t.text));
|
|
128
|
+
if (t.kind === "add")
|
|
129
|
+
return pc.bgGreen(pc.black(t.text));
|
|
130
|
+
return lineColor(t.text);
|
|
131
|
+
})
|
|
132
|
+
.join("");
|
|
133
|
+
return " " + prefix + " " + body;
|
|
134
|
+
})
|
|
135
|
+
.join("\n"))
|
|
136
|
+
.join("\n");
|
|
137
|
+
}
|
package/dist/core/domWalker.js
CHANGED
|
@@ -67,7 +67,7 @@ export async function walkDom(page, bus, opts = {}) {
|
|
|
67
67
|
catch {
|
|
68
68
|
label = ""; // degraded — no label to filter on
|
|
69
69
|
}
|
|
70
|
-
if (DESTRUCTIVE.test(label))
|
|
70
|
+
if (!opts.allowDestructive && DESTRUCTIVE.test(label))
|
|
71
71
|
continue;
|
|
72
72
|
if (tag === "a") {
|
|
73
73
|
// Don't click links directly — queue internal ones for the crawl.
|
package/dist/core/fuzzer.js
CHANGED
|
@@ -125,7 +125,7 @@ export async function fuzz(page, bus, opts = {}) {
|
|
|
125
125
|
catch {
|
|
126
126
|
label = ""; // degraded — no label to filter on
|
|
127
127
|
}
|
|
128
|
-
if (DESTRUCTIVE.test(label))
|
|
128
|
+
if (!opts.allowDestructive && DESTRUCTIVE.test(label))
|
|
129
129
|
continue;
|
|
130
130
|
if (tag === "a") {
|
|
131
131
|
const href = (await h.getAttribute("href")) ?? "";
|