aztrx-cli 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,156 +5,141 @@
5
5
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg?style=flat-square)](https://nodejs.org)
6
6
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](LICENSE)
7
7
 
8
- Aztrx AI 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.
16
-
17
- ![aztrx demo](media/demo.gif)
8
+ Aztrx AI finds **runtime** bugs, not security holes. It drives your web app like a hostile
9
+ user and catches the crashes that ship to real users — *including ones a React Error Boundary
10
+ swallows* (the errors `window.onerror` never sees). Each crash comes back as an exact source
11
+ line plus an executable **Playwright repro** that fails `3/3` times. Then it fixes it.
18
12
 
19
13
  ```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)
14
+ npx aztrx-cli run http://localhost:3000 # find crashes — zero setup, no key
15
+ npx aztrx-cli run http://localhost:3000 --fix # fix them free for common bugs
22
16
  ```
23
17
 
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` |
18
+ ![aztrx demo](media/demo.gif)
27
19
 
28
20
  ---
29
21
 
30
- ## Run it
31
-
32
- ```bash
33
- npx aztrx-cli run http://localhost:3000
34
- ```
22
+ ## Why Aztrx AI
35
23
 
36
- That's the whole setupno install, no config, no account, no API key. Point it at your
37
- running dev server and it finds the crashes. It drives Chromium through Playwright (the
38
- first run downloads the browser automatically).
39
-
40
- What that one command gives you:
41
-
42
- - **Sees swallowed errors.** Error Boundaries and `window.onerror` miss the errors your app *catches*. Aztrx AI 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.
43
- - **Proves, not reports.** Every crash/error finding ships with an executable `.spec.ts` repro and a flake-rate verdict — `[deterministic 5/5]`, `[flaky 3/5]`, or `[unreliable]`.
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
+ - **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]`.
44
26
  - **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.
45
27
 
46
- More ways to run — all optional flags on top of the same command:
47
-
48
- ```bash
49
- npx aztrx-cli run http://localhost:3000 --fuzz # seeded chaos (replayable)
50
- npx aztrx-cli run http://localhost:3000 --repro # minimize → compile → validate
51
- npx aztrx-cli run http://localhost:3000 --http-fuzz # server-side 5xx hunt
52
- ```
28
+ ---
53
29
 
54
- Prefer a global install?
30
+ ## Quickstart
55
31
 
56
32
  ```bash
57
- npm i -g aztrx-cli
58
- aztrx-cli run http://localhost:3000
33
+ npm i -g aztrx-cli # or use npx — no install needed
34
+
35
+ aztrx-cli run http://localhost:3000 # 1. find the crashes (no key, no account)
36
+ aztrx-cli run http://localhost:3000 --repro # 2. prove them with an executable test
37
+ aztrx-cli run http://localhost:3000 --fix # 3. fix them
59
38
  ```
60
39
 
40
+ Point it at any running dev server. It drives Chromium through Playwright (the first run
41
+ downloads the browser automatically).
42
+
61
43
  ---
62
44
 
63
- ## Fix it, not just find it
45
+ ## Fix it free for common crashes
46
+
47
+ `--fix` has two engines:
64
48
 
65
- The only feature that needs a key. `--fix` hands the crash to an LLM and applies a
66
- verified patch to your working tree. Anthropic by default set `ANTHROPIC_API_KEY`:
49
+ **1. Free, no key.** For the most common crash `Cannot read properties of
50
+ undefined/null` a built-in rule adds `?.` (optional chaining) and applies the fix. No LLM,
51
+ no key, no cost:
67
52
 
68
53
  ```bash
69
- export ANTHROPIC_API_KEY="your-api-key"
70
- npx aztrx-cli run http://localhost:3000 --fix # find → explain → heal → apply
71
- npx aztrx-cli run http://localhost:3000 --fix --yes # non-interactive (CI)
54
+ aztrx-cli run http://localhost:3000 --fix # works out of the box for null/undefined derefs
72
55
  ```
73
56
 
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:
57
+ **2. Your model, for complex bugs.** Logic errors, races, and anything the rule can't handle
58
+ point it at any model:
76
59
 
77
60
  ```bash
78
- export AZTRX_API_BASE="https://openrouter.ai/api/v1" # or api.openai.com/v1, api.x.ai/v1, ...
61
+ # Anthropic
62
+ export ANTHROPIC_API_KEY="sk-ant-..."
63
+
64
+ # or any OpenAI-compatible provider: OpenAI, Grok, DeepSeek, Gemini, Kimi, OpenRouter, Ollama
65
+ export AZTRX_API_BASE="https://openrouter.ai/api/v1"
79
66
  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
67
+ export AZTRX_MODEL="anthropic/claude-sonnet-5"
82
68
  ```
83
69
 
84
- `--fix` chains find explain heal, then asks *"Apply the fix?"* (`y/N`). On yes, the
85
- verified patch lands in your working tree `git diff` shows the result. Aztrx AI never
86
- commits.
87
-
88
- - **`--heal`** — the same pipeline, but stops at a verified `.patch` file (no apply).
89
- - **`--explain`** — a human-language "X-ray" summary of what broke, where, and whether a fix is ready. No key required: it falls back to a deterministic offline summary.
90
-
91
- Every patch is redacted, sandboxed in a detached git worktree, compiler-checked, and gated
92
- on your own test suite before you ever see it.
70
+ Every fix is redacted, sandboxed in a detached git worktree, compiler-checked, and gated on
71
+ your test suite before you see it. Aztrx never commits. `--pr` opens a merge-ready PR;
72
+ `--regression-test` drops the repro into your test dir so the bug can't come back.
93
73
 
94
74
  ---
95
75
 
96
- ## Advanced
76
+ ## More ways to run
97
77
 
98
- Everything else is optional. One line each — the full table is in the [CLI reference](#cli-reference).
78
+ | Flag | What it does |
79
+ | --- | --- |
80
+ | `--fuzz` | coverage-guided chaos fuzz — steers toward code it hasn't reached |
81
+ | `--http-fuzz` | attack the server's endpoints (turns every `5xx` into a repro) |
82
+ | `--swarm` / `--workers N` | parallel detection workers |
83
+ | `--login` | auto-login to test authenticated pages |
84
+ | `--badge` / `--pr-comment` / `--fail-on` | CI artifacts |
85
+ | `modernize <file>` | rewrite legacy JS/TS into modern idiomatic syntax |
86
+ | `studio` | live dashboard on `localhost:7331` |
99
87
 
100
- ### Fuzz harder
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.
88
+ Full list: `aztrx-cli run --help`, or the [CLI reference](#cli-reference).
107
89
 
108
- ### Parallel swarm
109
- `--workers 4` fans detection into parallel workers (walk + several fuzz seeds + http-fuzz),
110
- merged by fingerprint. `--swarm` is a hidden alias for `--workers auto`.
90
+ ---
111
91
 
112
- ### Authenticated testing
113
- `--login` detects the login form and signs in before the pass, so every repro runs
114
- authenticated:
92
+ ## Security
115
93
 
116
- ```bash
117
- AZTRX_AUTH_EMAIL=you@example.com AZTRX_AUTH_PASSWORD=secret \
118
- npx aztrx-cli run http://localhost:3000 --login
119
- ```
94
+ - **Local-first.** Nothing leaves your machine unless you opt in.
95
+ - **Never commits.** Fixes land in a detached worktree for your review.
96
+ - **Redacted.** Secrets are stripped from the file, error, and stack before any LLM call.
97
+ - **Deny-by-default network.** Off-origin calls are blocked; destructive clicks (delete/pay/logout) are refused.
98
+ - **`.aztrx/` is gitignored** — repros, reports, and patches stay out of history.
120
99
 
121
- Or `--storage-state <path>` with a saved Playwright state. Use `--login-url` for an
122
- explicit login page, `--allow-host auth.example.com` for a third-party auth backend.
100
+ ---
123
101
 
124
- ### Code modernizer
125
- `npx aztrx-cli modernize src/legacy.js` rewrites legacy JS/TS into modern idiomatic syntax
126
- (`var` → `const`, callbacks → `async`/`await`), applied only after you confirm.
102
+ ## Continuous Integration (GitHub Action)
127
103
 
128
- ### Live studio
129
- `npx aztrx-cli studio` triage findings in a localhost dashboard (binds `127.0.0.1`).
104
+ Runtime gate on every PR — boots your dev server, runs
105
+ `aztrx-cli run --fail-on --repro --heal`, posts a comment with the repro + patch, and fails on
106
+ a crash/error.
130
107
 
131
- ### Config & CI
132
- `npx aztrx-cli init` scaffolds `aztrx.config.ts` and gitignores `.aztrx/`. For a per-PR
133
- runtime gate, use the [GitHub Action](#continuous-integration-github-action). `--pr-comment`
134
- / `--badge` write a PR comment / status badge; `--upload --api-key` streams findings to your
135
- cloud dashboard.
108
+ ```yaml
109
+ # .github/workflows/ci.yml composite action, inline
110
+ on: pull_request
111
+ jobs:
112
+ aztrx:
113
+ runs-on: ubuntu-latest
114
+ permissions: { contents: read, pull-requests: write }
115
+ steps:
116
+ - uses: actions/checkout@v4
117
+ - uses: Aztrx-AI/aztrx@98dbad0f7b6681c7670f7068d01dc0d54813a55f
118
+ with:
119
+ url: http://localhost:3000
120
+ start-command: npm run dev # optional — boot the app in the background
121
+ token: ${{ github.token }}
122
+ anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # optional — enables --heal
123
+ ```
136
124
 
137
- ### Privacy
138
- Off by default and strictly opt-in. `--telemetry` collects an anonymized tuple locally;
139
- `--share-data` uploads it. `--upload` streams sanitized findings to the cloud. See
140
- [Security & data flow](#security--data-flow) for the invariants.
125
+ A status badge (`--badge`) and PR comment (`--pr-comment`) work the same way — regenerate in
126
+ CI on every push.
141
127
 
142
128
  ---
143
129
 
144
130
  ## CLI reference
145
131
 
146
132
  `aztrx-cli run --help` is grouped by intent (Detect / Prove / Fix / Report & ship / Auth);
147
- the table below is the complete reference — including flags hidden from `--help` (aliases
148
- and niche tuning knobs).
133
+ the table below is the complete reference — including flags hidden from `--help` (aliases and
134
+ niche tuning knobs).
149
135
 
150
136
  | Flag | Description | Default |
151
137
  | --- | --- | --- |
152
138
  | `--fuzz` | Seeded chaos fuzzing instead of the deterministic walk | — |
153
139
  | `--http-fuzz` | Server-side mutation fuzzing — hostile requests against the target origin | — |
154
140
  | `--repro` | Minimize (ddmin) → emit Playwright spec → validate flake rate | — |
155
- | `--heal` | Generate + verify an LLM patch (implies `--repro`) | — |
156
- | `--fix` | Find → explain → heal → apply — the one-command fix (implies `--heal`) | — |
157
- | `--magic-fix` | Deprecated alias for `--fix` (hidden) | — |
141
+ | `--heal` | Generate + verify a fix (implies `--repro`) | — |
142
+ | `--fix` | Find → explain → heal → apply — the one-command fix (free for null/undefined derefs) | — |
158
143
  | `--explain` | Print a human-language summary of the findings | — |
159
144
  | `--yes` / `-y` | Auto-apply verified fixes without prompting (with `--fix`) | — |
160
145
  | `--pr` | Open a merge-ready PR with the verified fixes (with `--fix`) | — |
@@ -172,7 +157,7 @@ and niche tuning knobs).
172
157
  | `--test-command <cmd>` | Test command run against a healed patch | `npm test` (auto-detected) |
173
158
  | `--test-timeout <ms>` | Timeout for the heal test gate | `300000` |
174
159
  | `--no-test` | Skip the test gate during healing | — |
175
- | `--start-command <cmd>` | Command to boot the app for server healing | `scripts.dev` → `scripts.start` (auto-detected) |
160
+ | `--start-command <cmd>` | Command to boot the app for server healing | `scripts.dev` → `scripts.start` |
176
161
  | `--pr-comment [path]` | Write a GitHub PR markdown comment | `.aztrx/pr-comment.md` |
177
162
  | `--badge [path]` | Write a self-contained SVG status badge | `.aztrx/badge.svg` |
178
163
  | `--regression-test [dir]` | Copy validated repro specs into the project test dir | `e2e/` or `tests/` |
@@ -180,11 +165,8 @@ and niche tuning knobs).
180
165
  | `--share-data` | Also upload the sanitized tuples (opt-in) | — |
181
166
  | `--repo <path>` | Root path for sourcemap → source resolution | cwd |
182
167
  | `--allow-host <host>` | Add a host to the network allow-list (repeatable) | — |
183
- | `--storage-state <path>` | Playwright storage-state for authenticated pages (`--auth` is a hidden alias) | — |
168
+ | `--storage-state <path>` | Playwright storage-state for authenticated pages | — |
184
169
  | `--login` | Auto-login before the pass (needs `AZTRX_AUTH_EMAIL`/`AZTRX_AUTH_PASSWORD`) | — |
185
- | `--login-email <email>` | Email for `--login` (hidden — prefer `$AZTRX_AUTH_EMAIL`) | `$AZTRX_AUTH_EMAIL` |
186
- | `--login-password <pass>` | Password for `--login` (hidden — prefer `$AZTRX_AUTH_PASSWORD`) | `$AZTRX_AUTH_PASSWORD` |
187
- | `--login-url <url>` | Explicit login page URL for `--login` (hidden) | current page |
188
170
  | `--fail-on` | Exit `1` if any crash/error finding is present | — |
189
171
  | `--dry-run` | Log planned actions without executing them | — |
190
172
  | `--crash-test` | Throw a deliberate error to verify capture | — |
@@ -198,248 +180,16 @@ Every run writes self-contained artifacts inside `.aztrx/` (gitignored):
198
180
 
199
181
  ```
200
182
  .aztrx/
201
- ├── report.html # zero-CDN interactive triage report
202
- ├── repro/
203
- │ └── 458f6bf71977.spec.ts # minimal, executable Playwright repro
204
- ├── heal/
205
- │ └── fix.patch # gated, compiler-checked fix
206
- ├── events.jsonl # run log, streamed by `aztrx-cli studio`
207
- ├── telemetry/dataset.jsonl # opt-in anonymized tuple dataset
183
+ ├── report.html # interactive triage report
184
+ ├── repro/<id>.spec.ts # minimal, executable Playwright repro
185
+ ├── heal/fix.patch # gated, compiler-checked fix
186
+ ├── events.jsonl # run log (streamed by `aztrx-cli studio`)
208
187
  ├── pr-comment.md # GitHub PR markdown (with --pr-comment)
209
188
  └── badge.svg # status badge (with --badge)
210
189
  ```
211
190
 
212
191
  ---
213
192
 
214
- ## Continuous Integration (GitHub Action)
215
-
216
- Runtime gate on every PR. The action boots your dev server, runs
217
- `aztrx-cli run --fail-on --repro --heal`, posts a markdown comment with the
218
- deterministic repro + gated patch, and fails the check on a crash/error.
219
-
220
- ```yaml
221
- # .github/workflows/ci.yml — composite action, inline
222
- on: pull_request
223
- jobs:
224
- aztrx:
225
- runs-on: ubuntu-latest
226
- permissions: { contents: read, pull-requests: write }
227
- steps:
228
- - uses: actions/checkout@v4
229
- - uses: Aztrx-AI/aztrx@dc90680bca73343863da6e98c46e64be0223c6ee
230
- with:
231
- url: http://localhost:3000
232
- start-command: npm run dev # optional — boot the app in the background
233
- token: ${{ github.token }}
234
- anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # optional — enables --heal
235
- ```
236
-
237
- Or as a reusable workflow:
238
-
239
- ```yaml
240
- on: pull_request
241
- jobs:
242
- aztrx:
243
- uses: Aztrx-AI/aztrx/.github/workflows/aztrx-pr.yml@dc90680bca73343863da6e98c46e64be0223c6ee
244
- with:
245
- url: http://localhost:3000
246
- start-command: npm run dev
247
- secrets:
248
- anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
249
- ```
250
-
251
- ---
252
-
253
- ## Status badge
254
-
255
- Hang a live badge in your README that reflects your *actual* crash/error state —
256
- not a static "protected" sticker.
257
-
258
- ```bash
259
- npx aztrx-cli run http://localhost:3000 --badge badge.svg
260
- ```
261
-
262
- ```markdown
263
- [![aztrx](badge.svg)](https://github.com/Aztrx-AI/aztrx)
264
- ```
265
-
266
- The badge is a self-contained SVG generated from the run's findings — green
267
- `crash-free` or red `N findings`. It's honest because it's *earned*: regenerate it
268
- in CI on every push to `main` and commit it back.
269
-
270
- ```yaml
271
- # .github/workflows/badge.yml — keep the badge honest on every push to main
272
- on:
273
- push:
274
- branches: [main]
275
- jobs:
276
- badge:
277
- runs-on: ubuntu-latest
278
- permissions:
279
- contents: write
280
- steps:
281
- - uses: actions/checkout@v4
282
- - uses: actions/setup-node@v4
283
- with:
284
- node-version: 20
285
- - run: npm ci
286
- - name: Boot dev server
287
- run: |
288
- nohup npm run dev > /tmp/dev.log 2>&1 &
289
- for i in $(seq 1 60); do
290
- curl -sS -o /dev/null http://localhost:3000 && break
291
- sleep 2
292
- done
293
- - name: Generate badge
294
- run: npx --yes aztrx-cli@0.4.0 run http://localhost:3000 --badge badge.svg
295
- - name: Commit badge
296
- run: |
297
- git config user.name "github-actions[bot]"
298
- git config user.email "github-actions[bot]@users.noreply.github.com"
299
- git add badge.svg
300
- git commit -m "chore: update aztrx badge" || echo "no change"
301
- git push
302
- ```
303
-
304
- No `--fail-on` here on purpose: the badge reflects the findings whatever they
305
- are, and the run still exits 0 so the commit step always runs. Swap `push` for a
306
- `schedule` cron if you'd rather regenerate daily than on every push.
307
-
308
- ---
309
-
310
- ## Smart Cloud Router
311
-
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.
319
-
320
- ## Security & data flow
321
-
322
- **Local-first by default.** A run never phones home unless you pass an opt-in
323
- flag. By default nothing leaves your machine — no telemetry, no cloud sync, no
324
- LLM call.
325
-
326
- | What | Leaves your machine | When |
327
- | --- | --- | --- |
328
- | `run` (default) | nothing | — |
329
- | `--heal` | redacted file + redacted error/stack, to the LLM API | only with `--heal` + `ANTHROPIC_API_KEY` |
330
- | `--share-data` | a sanitized crash→repro→patch tuple | explicit opt-in |
331
- | `--upload` | sanitized findings + counts | explicit opt-in |
332
-
333
- ### Invariants
334
-
335
- - **Sourcemap containment.** A hostile sourcemap or stack URL can't read outside
336
- your repo: every path is resolved against the repo root and rejected if it
337
- escapes it — including through symlinks. Secret-bearing files (`.env`, `.npmrc`,
338
- private keys) are never read into a report or PR comment.
339
- - **Redaction before the model.** `--heal` redacts common secret patterns (keys,
340
- tokens, credentials) from the file, error, and stack before they're sent; only
341
- the repo-relative path and line/column are visible. Redaction is heuristic — it
342
- is not a substitute for not committing secrets.
343
- - **Isolated sandbox, no commits.** Patches land in a detached `git worktree` in
344
- the OS temp dir — never your working tree. Aztrx AI never commits. A patch must
345
- parse, add no new imports / `eval` / `child_process`, typecheck, *and* pass your
346
- own test suite before it's offered as a `.patch` for you to review.
347
- - **Deny-by-default network.** Only the target origin (plus explicit
348
- `--allow-host`) is reachable; off-origin calls are blocked.
349
- - **Destructive-action deny-list.** Never clicks delete / pay / logout.
350
- - **Studio is localhost-only.** The dashboard binds `127.0.0.1` with no wildcard
351
- CORS.
352
- - **`.aztrx/` is gitignored** on `init` — repro specs, reports, and patches stay
353
- out of history.
354
- - **Pinned supply chain.** The GitHub Action pins `aztrx-cli@0.4.0` (never
355
- `@latest`).
356
-
357
- ## Telemetry & privacy
358
-
359
- Off by default and strictly opt-in. `--telemetry` collects the anonymized tuple
360
- `[crash_fingerprint, min_repro_spec, verified_patch, framework_metadata,
361
- model_tier_used]` locally (nothing leaves the machine); `--share-data` uploads it
362
- to the telemetry endpoint. Every field passes a sanitizer that irreversibly
363
- strips secrets, anonymizes URLs to `<host>`, and scrubs repo paths to `<repo>`.
364
- Uploads are fire-and-forget, bounded by a 2s timeout, and never affect the exit
365
- code.
366
-
367
- ---
368
-
369
- ## Open benchmark
370
-
371
- The detector is scored against a 13-target corpus of **real Next.js App Router
372
- apps** — one seeded runtime bug per app across the archetype matrix (null deref,
373
- async race, JSON parse, stack overflow, Server Action, route transition, and more).
374
-
375
- | metric | value |
376
- | --- | --- |
377
- | seeded bugs | 13 |
378
- | detection recall | **100%** (13 / 13) |
379
- | deterministic repros | **100%** (12 / 12) |
380
- | repro not attempted | 1 (mount-time bug, no action history) |
381
- | unseeded findings | 5 — two root causes (`/api/cart` → 500, Server Action → 500) |
382
-
383
- ```bash
384
- cd bench/frameworks
385
- npm install # once — installs next/react for the target apps
386
- npm run bench # detection
387
- npm run bench:repro # detection + repro scoring
388
- ```
389
-
390
- Full per-case table and scoring notes live in
391
- [`bench/frameworks/RESULTS.md`](bench/frameworks/RESULTS.md).
392
-
393
- ---
394
-
395
- ## Architecture
396
-
397
- Aztrx AI is a decoupled, event-driven pipeline — modules talk only through an
398
- `EventBus`; the orchestrator wires them together.
399
-
400
- ```
401
- [ CDP interceptor ] ──▶ [ action ring buffer ] ──▶ [ classifier (fingerprint) ]
402
-
403
- [ verified .patch ] ◀── [ LLM healer ] ◀── [ ddmin minimizer ] ◀── [ sourcemap resolver ]
404
-
405
- [ Playwright spec (.spec.ts) ] ──▶ [ flake-rate validator ]
406
- ```
407
-
408
- | Stage | Module | Role |
409
- | --- | --- | --- |
410
- | F1 | `interceptor.ts` | CDP interceptor — captures raw runtime errors and console/network events |
411
- | F2 | `recorder.ts` | Ring buffer of the last 25 actions; selector cascade `data-testid → text → CSS path` |
412
- | F3 | `classifier.ts` | Fingerprints + dedups findings, assigns severity; suppresses `.aztrx/baseline.json` (input) |
413
- | F4 | `resolver.ts` | Maps minified frames to source files, lines, and snippets via sourcemaps |
414
- | F5 | `fuzzer.ts` + `domWalker.ts` | Seeded chaos fuzzer; `domWalker` (F5-lite) discovers interactive elements |
415
- | F6 | `networkGuard.ts` + `domWalker.ts` | Deny-by-default network policy + destructive-action deny-list |
416
- | F7 | `minimizer.ts` | ddmin delta-debugging — eliminates irrelevant actions |
417
- | F8 | `specCompiler.ts` | Emits standalone, clean Playwright `.spec.ts` repro |
418
- | F9 | `validator.ts` | Multi-pass replays → `deterministic` / `flaky` / `unreliable` |
419
- | F10 | `heal/` | Closed-loop healing — redact → generate → AST gate → sandbox → `tsc` → verify |
420
- | F11 | `telemetry/` | Opt-in anonymized crash→repro→patch tuple collection (data flywheel) |
421
- | F12 | `cloud/` | Opt-in cloud sync — streams sanitized findings to the ingest dashboard |
422
- | F13 | `summarize.ts` + `heal/apply.ts` | Human-language "X-ray" report + opt-in apply of verified patches (`--fix`) |
423
-
424
- ---
425
-
426
- ## Roadmap
427
-
428
- - [x] Closed-loop healing — redact → generate → gate → sandbox → verify (F10)
429
- - [ ] Open-source launch — npm publish, `npx aztrx-cli run`, hero screencast
430
- - [x] Hardening — `--auth`/`--storage-state`, tsc compile fast-fail, React 19/Next.js 15 triage
431
- - [x] Real-project benchmark — 13 Next.js App Router targets (100% recall, 100% deterministic repro)
432
- - [x] B2B ($29/mo) — GitHub Action (`action.yml` + reusable workflow), PR bot markdown comment
433
- - [x] B2B ($29/mo) — Smart Cloud Router (haiku fast-tier → verify → Sonnet fallback)
434
- - [x] B2B ($29/mo) — Cloud dashboard (api.aztrx.app)
435
- - [x] Data flywheel — opt-in anonymized patch-tuple collection (F11)
436
- - [x] Server-side healing — heal server `5xx` findings (verify a patch by booting the patched server; requires a leaked server stack + a resolvable start command)
437
- - [x] Human-language "X-ray" report — `--explain` / `--lang` (LLM + offline fallback)
438
- - [x] One-click heal & apply — `--fix` (verified patch → working tree, `y/N`, no commit)
439
- - [x] Autonomous Swarm — parallel detection: walk + multi-seed fuzz + http-fuzz workers, merged by fingerprint
440
- - [x] Auth auto-login — `--login` walks login forms (synthesize test tokens — next)
441
- - [x] Code modernizer — LLM-rewrite legacy JS/TS (`modernize`; Python — next)
442
-
443
193
  ## Contributing
444
194
 
445
195
  ```bash
@@ -455,10 +205,10 @@ node dist/cli.js http://localhost:8901/crash.html --repo fixtures --repro
455
205
  # → one ● crash mapped to crash.html:13:15, minimized to 1 step
456
206
  ```
457
207
 
458
- ## Support the project
208
+ ## Support
459
209
 
460
- If Aztrx AI saved you hours of debugging or helped you ship a clean release, you
461
- can support the author directly — name a fair price on Polar.sh:
210
+ If Aztrx AI saved you hours of debugging, you can support the author directly
211
+ name a fair price on Polar.sh:
462
212
 
463
213
  **[Donate on Polar.sh →](https://buy.polar.sh/polar_cl_f1vBaxUv3S4fJ0o28GfgzQz7gHDHXkecCQtxY0WqeFs)**
464
214
 
package/dist/cli.js CHANGED
@@ -18,7 +18,7 @@ import { flushCloud } from "./core/cloud/index.js";
18
18
  import { summarizeFindings } from "./core/summarize.js";
19
19
  import { applyVerifiedPatches } from "./core/heal/apply.js";
20
20
  import { openFixPr } from "./core/fixPr.js";
21
- import { promptYesNo } from "./core/prompt.js";
21
+ import { promptYesNo, promptInput } from "./core/prompt.js";
22
22
  import { modernizeFile } from "./core/modernize.js";
23
23
  function collect(value, prev) {
24
24
  prev.push(value);
@@ -29,14 +29,50 @@ function autoWorkers() {
29
29
  const n = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
30
30
  return Math.max(1, Math.min(n, 8));
31
31
  }
32
+ /** Auto-detect the running dev server URL: `aztrx.config.ts`, the dev script's
33
+ * `--port`, then a probe of common ports. Returns null when nothing responds. */
34
+ async function detectUrl(repoRoot) {
35
+ const configPath = path.join(repoRoot, "aztrx.config.ts");
36
+ if (fs.existsSync(configPath)) {
37
+ const m = fs.readFileSync(configPath, "utf-8").match(/url\s*[=:]\s*["']([^"']+)["']/);
38
+ if (m)
39
+ return m[1];
40
+ }
41
+ const pkgPath = path.join(repoRoot, "package.json");
42
+ if (fs.existsSync(pkgPath)) {
43
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
44
+ const dev = pkg.scripts?.dev || pkg.scripts?.start || "";
45
+ const pm = dev.match(/(?:--port|-p)\s*[= ]?\s*(\d+)/);
46
+ if (pm)
47
+ return `http://localhost:${pm[1]}`;
48
+ }
49
+ for (const port of [3000, 5173, 8080, 3001, 4000, 8000]) {
50
+ try {
51
+ const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(300) });
52
+ if (res.status < 500)
53
+ return `http://localhost:${port}`;
54
+ }
55
+ catch {
56
+ // not listening — try the next port
57
+ }
58
+ }
59
+ return undefined;
60
+ }
32
61
  /** Print one low-key "next flag" hint after a run, so users learn the advanced
33
62
  * flags on demand instead of memorizing the whole surface. Fires only in the
34
63
  * plain-log path when there's a finding worth acting on. */
35
64
  function suggestNext(findings, opts) {
36
65
  if (opts.dryRun || opts.crashTest || findings.length === 0)
37
66
  return;
38
- const crashOrError = findings.some((f) => f.severity === "crash" || f.severity === "error");
39
67
  const alreadyFixing = opts.heal || opts.fix || opts.magicFix;
68
+ // Server-side 5xx → point at the server fuzzer. `--fix` patches frontend code,
69
+ // so it can't help with a 502/500 from the server.
70
+ const serverError = findings.some((f) => f.type === "network_5xx");
71
+ if (serverError && !opts.httpFuzz) {
72
+ console.log(pc.dim("Tip: these are server-side 5xx — run with --http-fuzz to map the server attack surface"));
73
+ return;
74
+ }
75
+ const crashOrError = findings.some((f) => f.severity === "crash" || f.severity === "error");
40
76
  if (crashOrError && !alreadyFixing) {
41
77
  if (opts.repro) {
42
78
  console.log(pc.dim("Tip: run with --fix to attempt a closed-loop fix"));
@@ -44,11 +80,6 @@ function suggestNext(findings, opts) {
44
80
  else {
45
81
  console.log(pc.dim("Tip: run with --repro to prove these with a runnable spec, or --fix to fix them end-to-end"));
46
82
  }
47
- return;
48
- }
49
- const serverError = findings.some((f) => f.type === "network_5xx");
50
- if (serverError && !opts.httpFuzz) {
51
- console.log(pc.dim("Tip: run with --http-fuzz to map the server-side attack surface"));
52
83
  }
53
84
  }
54
85
  program
@@ -106,7 +137,7 @@ program
106
137
  program
107
138
  .command("run", { isDefault: true })
108
139
  .description("inspect a running app and prove its bugs with an executable repro")
109
- .argument("<url>", "dev server to inspect, e.g. http://localhost:3000")
140
+ .argument("[url]", "dev server to inspect (auto-detected if omitted), e.g. http://localhost:3000")
110
141
  .configureHelp({ formatHelp })
111
142
  .addOption(opt("--repo <path>", "project root to inspect/watch (default: cwd)", "advanced"))
112
143
  .addOption(opt("--max-actions <n>", "max actions per pass", "advanced").default("100"))
@@ -155,6 +186,16 @@ program
155
186
  // `--fix` is the memorable verb; `--magic-fix` is a hidden alias.
156
187
  const magicFix = opts.magicFix || opts.fix;
157
188
  const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
189
+ // Auto-detect the target when no URL is given — one less thing to type.
190
+ let targetUrl = url;
191
+ if (!targetUrl) {
192
+ targetUrl = await detectUrl(repoRoot);
193
+ if (!targetUrl) {
194
+ console.error(pc.red("No URL given and none auto-detected. Pass <url>, or run `aztrx-cli init` first."));
195
+ process.exit(1);
196
+ }
197
+ console.log(pc.dim(`Auto-detected ${targetUrl}`));
198
+ }
158
199
  const workers = opts.workers ? parseInt(opts.workers, 10) : opts.swarm ? autoWorkers() : undefined;
159
200
  const mode = (workers ?? 1) > 1 || opts.httpFuzz
160
201
  ? `swarm (${workers ?? 1} worker${(workers ?? 1) === 1 ? "" : "s"})`
@@ -165,8 +206,16 @@ program
165
206
  : opts.repro
166
207
  ? "repro"
167
208
  : "deterministic walk";
209
+ // Interactive login: if --login was passed without credentials, ask for them
210
+ // so the user never has to remember the AZTRX_AUTH_* env vars.
211
+ let loginEmail = opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL;
212
+ let loginPassword = opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD;
213
+ if (opts.login && !loginEmail && !loginPassword) {
214
+ loginEmail = await promptInput("Email:");
215
+ loginPassword = await promptInput("Password:");
216
+ }
168
217
  const runOpts = {
169
- url,
218
+ url: targetUrl,
170
219
  repoRoot,
171
220
  maxActions: parseInt(opts.maxActions, 10),
172
221
  dryRun: opts.dryRun,
@@ -193,8 +242,8 @@ program
193
242
  cloudUrl: opts.cloudUrl,
194
243
  storageState: opts.storageState ?? opts.auth,
195
244
  login: opts.login,
196
- loginEmail: opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL,
197
- loginPassword: opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD,
245
+ loginEmail,
246
+ loginPassword,
198
247
  loginUrl: opts.loginUrl,
199
248
  };
200
249
  const failOn = Boolean(opts.failOn);
@@ -206,7 +255,7 @@ program
206
255
  await renderTui({
207
256
  bus,
208
257
  done: runPromise,
209
- targetUrl: url,
258
+ targetUrl,
210
259
  repoRoot,
211
260
  mode,
212
261
  });
@@ -225,7 +274,7 @@ program
225
274
  const prPath = typeof opts.prComment === "string"
226
275
  ? opts.prComment
227
276
  : path.join(repoRoot, ".aztrx", "pr-comment.md");
228
- writePrComment(repoRoot, url, findings, prPath);
277
+ writePrComment(repoRoot, targetUrl, findings, prPath);
229
278
  console.log(pc.dim(`PR comment: ${path.relative(repoRoot, prPath)}`));
230
279
  }
231
280
  if (opts.badge) {
@@ -270,7 +319,7 @@ program
270
319
  }
271
320
  }
272
321
  if (opts.pr) {
273
- const prRes = await openFixPr(repoRoot, findings, url);
322
+ const prRes = await openFixPr(repoRoot, findings, targetUrl);
274
323
  if (prRes.ok) {
275
324
  console.log(pc.green(" ✓ PR opened") + ` ${prRes.url}`);
276
325
  }
@@ -37,6 +37,7 @@ const HYDRATION_NOISE = [
37
37
  ];
38
38
  function normalize(message) {
39
39
  return message
40
+ .replace(/^(?:TypeError|ReferenceError|RangeError|SyntaxError|URIError|EvalError|Error):\s*/g, "")
40
41
  .replace(/\b\d+\b/g, "<N>")
41
42
  .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<UUID>")
42
43
  .replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, "<TS>")
@@ -13,29 +13,32 @@ export const SELECTOR = 'a, button, input, select, textarea, [role="button"], [o
13
13
  export async function walkDom(page, bus, opts = {}) {
14
14
  const max = opts.maxActions ?? 100;
15
15
  const startUrl = page.url();
16
+ const startOrigin = originOf(startUrl);
16
17
  let actions = 0;
17
- const seen = new Set();
18
- // Re-scan the DOM after every action (the page may have mutated or navigated)
19
- // rather than walking a stale snapshot. Mirrors the fuzzer's recover-and-iterate
20
- // loop, but deterministically (document order) and without re-clicking elements
21
- // it has already acted on.
22
- while (actions < max) {
23
- if (page.url() !== startUrl) {
24
- // A previous action navigated away — return to the starting page.
25
- await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
18
+ let sawLoginForm = false;
19
+ const visited = new Set();
20
+ const queue = [startUrl];
21
+ // Breadth-first crawl: visit each internal page, walk its in-place controls
22
+ // (buttons/inputs/selects), and queue any internal links it reveals. This finds
23
+ // bugs on every route, not just the one you pointed at.
24
+ while (queue.length > 0 && actions < max) {
25
+ const url = queue.shift();
26
+ if (visited.has(url))
27
+ continue;
28
+ visited.add(url);
29
+ if (page.url() !== url) {
30
+ await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => { });
26
31
  await page.waitForTimeout(300);
27
32
  }
33
+ // Per-page "seen" set — the same button label on two pages is two targets.
34
+ const seen = new Set();
28
35
  let handles;
29
36
  try {
30
37
  handles = await page.$$(SELECTOR);
31
38
  }
32
39
  catch {
33
- // A navigation raced the re-scan reset to the start page and retry.
34
- await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
35
- await page.waitForTimeout(300);
36
- continue;
40
+ continue; // mid-navigation the next queued URL is visited anyway
37
41
  }
38
- let acted = false;
39
42
  for (const handle of handles) {
40
43
  if (actions >= max)
41
44
  break;
@@ -67,12 +70,25 @@ export async function walkDom(page, bus, opts = {}) {
67
70
  if (DESTRUCTIVE.test(label))
68
71
  continue;
69
72
  if (tag === "a") {
73
+ // Don't click links directly — queue internal ones for the crawl.
70
74
  const href = (await handle.getAttribute("href")) ?? "";
71
- if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
72
- continue;
75
+ if (href && !/^(javascript:|mailto:|tel:|#)/.test(href)) {
76
+ try {
77
+ const target = new URL(href, url).href.split("#")[0];
78
+ if (target.startsWith(startOrigin) && !visited.has(target) && queue.length < 20) {
79
+ queue.push(target);
80
+ }
81
+ }
82
+ catch {
83
+ // unparseable href — ignore
84
+ }
85
+ }
86
+ continue;
73
87
  }
74
88
  if (tag === "input") {
75
89
  const type = (await handle.getAttribute("type")) ?? "";
90
+ if (type === "password")
91
+ sawLoginForm = true;
76
92
  if (!TEXT_INPUT_TYPES.has(type))
77
93
  continue; // skip password/hidden/submit/checkbox/etc.
78
94
  }
@@ -94,14 +110,22 @@ export async function walkDom(page, bus, opts = {}) {
94
110
  await handle.click({ timeout: 1500 }).catch(() => { });
95
111
  }
96
112
  actions++;
97
- acted = true;
98
113
  await page.waitForTimeout(120);
99
- break; // re-scan next iteration — the DOM may have changed
114
+ // A click may navigate (e.g. a submit) queue the new URL and stop this
115
+ // page's walk; the queue visits it next.
116
+ if (page.url() !== url) {
117
+ const target = page.url().split("#")[0];
118
+ if (target.startsWith(startOrigin) && !visited.has(target) && queue.length < 20) {
119
+ queue.push(target);
120
+ }
121
+ break;
122
+ }
100
123
  }
101
- if (!acted)
102
- break; // nothing left to act on
124
+ // Give in-flight async work (fetches, timers) a moment to reject before we
125
+ // navigate to the next crawled page — otherwise a 300ms-later throw is lost.
126
+ await page.waitForTimeout(500);
103
127
  }
104
- return actions;
128
+ return { actions, sawLoginForm };
105
129
  }
106
130
  export function originOf(url) {
107
131
  return url.match(/^https?:\/\/[^/]+/)?.[0] ?? "";
@@ -19,7 +19,7 @@ import * as fs from "fs";
19
19
  import * as path from "path";
20
20
  import { redact, unredact } from "./redact.js";
21
21
  import { auditPatch } from "./gates.js";
22
- import { generatePatch, modelTiers } from "./llm.js";
22
+ import { generatePatch, generateRulePatch, modelTiers, RULE_TIER } from "./llm.js";
23
23
  import { hasLlmKey } from "../llm.js";
24
24
  import { applyHunks, createWorktree, diffWorktree, runTests, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
25
25
  import { bootServer, detectStartCommand } from "./boot.js";
@@ -120,20 +120,24 @@ export async function heal(finding, opts) {
120
120
  // 1. Redact — only the redacted copy is shown to the model.
121
121
  const red = redact(original);
122
122
  const ctx = { finding, filePath, fileContent: original, redactedContent: red.text };
123
- // No transport configured (and no injected generator) nothing to try. A
124
- // higher model tier can't fix a missing key, so bail before paying anything.
125
- if (!opts.patchFn && !hasLlmKey()) {
123
+ // A free, no-key rule-based fix (null/undefined deref) tried before any LLM.
124
+ const rulePatch = !opts.patchFn ? generateRulePatch(ctx) : null;
125
+ // No transport configured, and no rule fix applies → nothing to try.
126
+ if (!opts.patchFn && !hasLlmKey() && !rulePatch) {
126
127
  return {
127
128
  ...base,
128
129
  status: "no-llm",
129
- error: "heal: no LLM API key is set (set ANTHROPIC_API_KEY, AZTRX_API_KEY, or AZTRX_API_BASE)",
130
+ error: "this crash needs a model — the free fixer only handles null/undefined derefs. Add a key: Anthropic ANTHROPIC_API_KEY, or any provider → AZTRX_API_BASE + AZTRX_API_KEY + AZTRX_MODEL (no Aztrx account needed)",
130
131
  };
131
132
  }
132
- // The Smart Cloud Router tier plan: fast/cheap first, Sonnet as the fallback.
133
- // An injected patchFn collapses to a single tier (there is no model to route).
134
- const tiers = opts.patchFn
135
- ? [{ model: opts.model ?? "default", label: "sonnet" }]
136
- : modelTiers(opts.model, opts.fastModel);
133
+ // The Smart Cloud Router tier plan: the free rule fix first, then fast/cheap,
134
+ // then Sonnet. An injected patchFn collapses to a single tier.
135
+ const tiers = [
136
+ ...(rulePatch ? [{ model: RULE_TIER, label: "fast" }] : []),
137
+ ...(opts.patchFn
138
+ ? [{ model: opts.model ?? "default", label: "sonnet" }]
139
+ : modelTiers(opts.model, opts.fastModel)),
140
+ ];
137
141
  const wt = await createWorktree(opts.repoRoot, finding.id);
138
142
  // The winning (or last) patch + verification, held back for the final save.
139
143
  let savedPatch = null;
@@ -78,9 +78,48 @@ export function parsePatch(raw) {
78
78
  .map((e) => ({ search: e.search, replace: e.replace }));
79
79
  return { explanation: typeof data.explanation === "string" ? data.explanation : "", hunks };
80
80
  }
81
+ /** Sentinel model name for the free, no-key rule-based fixer. */
82
+ export const RULE_TIER = "__rule__";
83
+ // Matches "Cannot read properties of undefined|null (reading 'X')".
84
+ const NULL_DEREF = /Cannot read properties of (undefined|null)(?: \(reading '([^']+)'\))?/;
85
+ /**
86
+ * Rule-based fix for the most common crash — a null/undefined property access.
87
+ * Adds `?.` (optional chaining) at the failing access. Returns null when the
88
+ * error isn't a null/undefined deref or the line can't be located. Free and
89
+ * offline: no LLM, no key, no network — so `--fix` works out of the box for the
90
+ * most frequent frontend crashes.
91
+ */
92
+ export function generateRulePatch(ctx) {
93
+ const m = ctx.finding.rawMessage.match(NULL_DEREF);
94
+ if (!m)
95
+ return null;
96
+ const kind = m[1]; // "undefined" | "null"
97
+ const prop = m[2]; // the property that was read
98
+ const line = ctx.finding.mappedLocation?.line;
99
+ if (!prop || line == null)
100
+ return null;
101
+ const src = ctx.fileContent.split("\n")[line - 1];
102
+ if (!src || !src.includes("." + prop))
103
+ return null;
104
+ // Optional-chain every `.identifier` access on the line (not just the failing
105
+ // one) so a chain like `d.agents.map(…)` becomes `d?.agents?.map(…)`.
106
+ const replace = src.replace(/\.(?=[a-zA-Z_$])/g, "?.");
107
+ if (replace === src)
108
+ return null;
109
+ return {
110
+ explanation: `Guard against a ${kind} access on \`.${prop}\` with optional chaining.`,
111
+ hunks: [{ search: src, replace }],
112
+ };
113
+ }
81
114
  export async function generatePatch(ctx, opts = {}) {
82
115
  if (opts.patchFn)
83
116
  return opts.patchFn(ctx);
117
+ if (opts.model === RULE_TIER) {
118
+ const rulePatch = generateRulePatch(ctx);
119
+ if (rulePatch)
120
+ return rulePatch;
121
+ throw new Error("no rule-based fix applicable");
122
+ }
84
123
  const text = await complete({
85
124
  system: SYSTEM,
86
125
  prompt: buildPrompt(ctx),
@@ -32,18 +32,21 @@ export function attachInterceptor(page, bus) {
32
32
  if (msg.type() !== "error")
33
33
  return;
34
34
  const text = msg.text();
35
- // The init script routes `unhandledrejection` through console.error with a
36
- // fixed prefix; recover the true signal type so Server Action failures and
37
- // other promise rejections classify as "error", not "warning".
38
- const type = text.startsWith("Unhandled Promise Rejection:")
39
- ? "unhandled_rejection"
40
- : "console_error";
35
+ // Pull the real Error (and its stack) out of the console args. React 18 and
36
+ // Next.js log a thrown error as `console.error(error)` the Error object is
37
+ // an argument, not part of `msg.text()`. Match on `:line:col` (not "http")
38
+ // so Next.js dev stacks (`webpack-internal:///…`) are recognised too.
41
39
  let source = text;
40
+ let hasErrorArg = false;
42
41
  for (const arg of msg.args()) {
43
42
  try {
44
- const s = await arg.evaluate((a) => a instanceof Error ? a.stack || String(a) : String(a));
45
- if (s.includes("http")) {
46
- source = s;
43
+ const info = await arg.evaluate((a) => a instanceof Error
44
+ ? { isError: true, value: a.stack || String(a) }
45
+ : { isError: false, value: String(a) });
46
+ if (info.isError)
47
+ hasErrorArg = true;
48
+ if (/:\d+:\d+/.test(info.value)) {
49
+ source = info.value;
47
50
  break;
48
51
  }
49
52
  }
@@ -51,6 +54,11 @@ export function attachInterceptor(page, bus) {
51
54
  // non-serializable arg — keep msg.text()
52
55
  }
53
56
  }
57
+ // A rejection the init script forwarded, or a thrown Error logged by React —
58
+ // both are real errors, not benign console warnings.
59
+ const type = text.startsWith("Unhandled Promise Rejection:") || hasErrorArg
60
+ ? "unhandled_rejection"
61
+ : "console_error";
54
62
  const loc = msg.location();
55
63
  const frame = extractFrame(source) ??
56
64
  ({ url: loc.url, line: loc.lineNumber, column: loc.columnNumber, message: text.split("\n")[0].slice(0, 200) });
package/dist/core/llm.js CHANGED
@@ -82,13 +82,18 @@ export async function complete(opts) {
82
82
  : openaiComplete(s, model, opts);
83
83
  }
84
84
  async function anthropicComplete(s, model, opts) {
85
+ const headers = {
86
+ "content-type": "application/json",
87
+ "x-api-key": s.apiKey,
88
+ "anthropic-version": "2023-06-01",
89
+ };
90
+ // Identity-linked API keys must name the workspace they act in.
91
+ const workspaceId = process.env.ANTHROPIC_WORKSPACE_ID;
92
+ if (workspaceId)
93
+ headers["anthropic-workspace-id"] = workspaceId;
85
94
  const res = await fetch("https://api.anthropic.com/v1/messages", {
86
95
  method: "POST",
87
- headers: {
88
- "content-type": "application/json",
89
- "x-api-key": s.apiKey,
90
- "anthropic-version": "2023-06-01",
91
- },
96
+ headers,
92
97
  body: JSON.stringify({
93
98
  model,
94
99
  max_tokens: opts.maxTokens ?? 2048,
@@ -86,7 +86,7 @@ export async function modernizeFile(repoRoot, filePath) {
86
86
  return { ok: false, original: "", changes: [], error: `cannot read ${filePath}: ${e.message}` };
87
87
  }
88
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)" };
89
+ return { ok: false, original, changes: [], lang, error: "modernize needs a model. Add a key: Anthropic ANTHROPIC_API_KEY, or any provider → AZTRX_API_BASE + AZTRX_API_KEY + AZTRX_MODEL" };
90
90
  }
91
91
  let reply;
92
92
  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, totalCoverage, workerCount, } = await swarmDetect({
100
+ const { findings, replayStorageState: swarmAuthState, totalActions, totalCoverage, workerCount, roles, sawLoginForm, } = await swarmDetect({
101
101
  url,
102
102
  repoRoot,
103
103
  maxActions,
@@ -128,7 +128,7 @@ export async function run(options) {
128
128
  printFinding(f, say);
129
129
  }
130
130
  if (workerCount > 1) {
131
- say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s).\n`));
131
+ say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s) — ${roles.join(", ")}.\n`));
132
132
  }
133
133
  else if (options.fuzz) {
134
134
  say(pc.dim(`\nFuzzed ${totalActions} action(s) — covered ${totalCoverage} new code range(s).\n`));
@@ -136,6 +136,9 @@ export async function run(options) {
136
136
  else {
137
137
  say(pc.dim(`\nWalked ${totalActions} action(s).\n`));
138
138
  }
139
+ if (sawLoginForm && !options.login) {
140
+ say(pc.yellow("Hint: this app has a login form — re-run with --login to test the authenticated app."));
141
+ }
139
142
  // F7 → F8 → F9: minimize each finding, compile an executable spec, validate
140
143
  // the flake rate. Only crash/error findings with a recorded action history.
141
144
  if (options.repro) {
@@ -224,15 +227,15 @@ export async function run(options) {
224
227
  // it in a sandboxed worktree, and verify the bug stops reproducing. Opt-in;
225
228
  // the patch is only ever handed to a human for review, never committed.
226
229
  if (options.heal) {
227
- const healTargets = findings.filter((f) => (f.severity === "crash" || f.severity === "error") &&
230
+ const reproducible = findings.filter((f) => (f.severity === "crash" || f.severity === "error") &&
228
231
  // A `network_5xx` finding heals only when its 500 body leaked a server
229
232
  // stack (→ an own-code source location); heal then boots the patched app
230
233
  // to verify. `network_timeout` stays excluded — it needs a time-based
231
234
  // repro first.
232
235
  f.type !== "network_timeout" &&
233
- f.mappedLocation?.isOwnCode &&
234
236
  f.repro &&
235
237
  f.repro.verdict !== "unreliable");
238
+ const healTargets = reproducible.filter((f) => f.mappedLocation?.isOwnCode);
236
239
  if (healTargets.length) {
237
240
  say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → test → verify) —"));
238
241
  emitPhase("heal");
@@ -292,6 +295,11 @@ export async function run(options) {
292
295
  }
293
296
  }
294
297
  }
298
+ else if (reproducible.length > 0) {
299
+ // Reproducible crashes, but none mapped to source (wrong --repo?) — tell the
300
+ // user rather than silently doing nothing.
301
+ say(pc.yellow(`Found ${reproducible.length} reproducible crash(es) but couldn't map them to source files. Run from your project root (or pass --repo <dir>) so --fix can read the code.`));
302
+ }
295
303
  }
296
304
  const reportPath = writeReport(repoRoot, url, findings);
297
305
  say(pc.dim(`Report: ${path.relative(repoRoot, reportPath)}`));
@@ -313,14 +321,19 @@ export async function run(options) {
313
321
  // F12 — opt-in cloud sync. Streams the sanitized run results to the ingest
314
322
  // API for the team dashboard; dedup happens server-side by fingerprint.
315
323
  if (options.upload) {
316
- submitRun(findings, {
317
- repoRoot,
318
- url,
319
- apiKey: options.apiKey,
320
- endpoint: options.cloudUrl,
321
- mode: runMode(options),
322
- counts,
323
- });
324
+ if (options.apiKey || process.env.AZTRX_API_KEY) {
325
+ submitRun(findings, {
326
+ repoRoot,
327
+ url,
328
+ apiKey: options.apiKey,
329
+ endpoint: options.cloudUrl,
330
+ mode: runMode(options),
331
+ counts,
332
+ });
333
+ }
334
+ else {
335
+ say(pc.yellow("Upload skipped: no API key. Set --api-key or AZTRX_API_KEY to stream findings to the dashboard."));
336
+ }
324
337
  }
325
338
  runLog.append({ type: "run_end", counts, ts: Date.now() });
326
339
  say(pc.dim("────────────────────────────────────────────"));
@@ -20,3 +20,15 @@ export function promptYesNo(question, opts = {}) {
20
20
  });
21
21
  });
22
22
  }
23
+ /** A single-line text prompt. Returns "" when stdout isn't a TTY (unattended run). */
24
+ export function promptInput(question) {
25
+ if (process.stdout.isTTY !== true)
26
+ return Promise.resolve("");
27
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
28
+ return new Promise((resolve) => {
29
+ rl.question(question + " ", (answer) => {
30
+ rl.close();
31
+ resolve(answer.trim());
32
+ });
33
+ });
34
+ }
@@ -1,20 +1,28 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { TraceMap, originalPositionFor, } from "@jridgewell/trace-mapping";
4
+ /** Framework-internal frames to skip when hunting the throw site. */
5
+ const FRAMEWORK_FRAME = /node_modules|webpack-runtime|\.next[\\/]|next[\\/]dist[\\/]/;
4
6
  /**
5
- * Pulls the first `url:line:col` frame out of a stack string. Handles React
6
- * Error Boundary console text, which embeds the original stack in its body.
7
+ * Pulls the first *user-code* frame out of a stack string. Iterates every line,
8
+ * skips framework internals (webpack runtime, node_modules, next/dist), and
9
+ * returns the first frame in the user's own code — so a Next.js dev stack like
10
+ * `webpack-internal:///(app-pages-browser)/./app/page.tsx:29:21` resolves to the
11
+ * user's file, not `intercept-console-error.js`.
7
12
  */
8
13
  export function extractFrame(text) {
9
- const match = text.match(/(https?:\/\/[^\s)"']+?):(\d+):(\d+)/);
10
- if (!match)
11
- return null;
12
- return {
13
- url: match[1],
14
- line: parseInt(match[2], 10),
15
- column: parseInt(match[3], 10),
16
- message: text.split("\n")[0].trim().slice(0, 200),
17
- };
14
+ const message = text.split("\n")[0].trim().slice(0, 200);
15
+ for (const raw of text.split("\n")) {
16
+ // V8 frame: "at fn (url:line:col)" or "at url:line:col".
17
+ const m = raw.trim().match(/^(?:at\s+)?(?:\S+\s+\()?(.+?):(\d+):(\d+)\)?$/);
18
+ if (!m)
19
+ continue;
20
+ const url = m[1];
21
+ if (!url || FRAMEWORK_FRAME.test(url))
22
+ continue;
23
+ return { url, line: parseInt(m[2], 10), column: parseInt(m[3], 10), message };
24
+ }
25
+ return null;
18
26
  }
19
27
  /** True if `p` looks like an absolute source path — a `file://` URL, a POSIX
20
28
  * absolute path, or a Windows drive path. Rejects bare relative tokens like
@@ -48,6 +56,20 @@ export function extractServerFrame(stack) {
48
56
  function stripQuery(url) {
49
57
  return url.split("?")[0];
50
58
  }
59
+ /** Normalize a stack-frame URL to a repo-relative source path. Handles the
60
+ * dev-server schemes (`webpack-internal:///(ns)/./src/…`, `webpack://ns/src/…`),
61
+ * `file://`, and plain `https://host/path` bundle URLs. */
62
+ function normalizeFrameUrl(url) {
63
+ return stripQuery(url)
64
+ .replace(/^webpack-internal:\/\/\/[^/]+\/\.\//, "")
65
+ .replace(/^webpack:\/\/[^/]+\//, "")
66
+ .replace(/^webpack:\/\//, "")
67
+ .replace(/^\/@fs\//, "")
68
+ .replace(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
69
+ .replace(/^file:\/\//, "")
70
+ .replace(/^https?:\/\/[^/]+\//, "")
71
+ .replace(/^\//, "");
72
+ }
51
73
  /** True only for a real, readable regular file — directories and unreadable
52
74
  * paths return false so readers never hit `EISDIR` / permission errors. */
53
75
  function isFile(p) {
@@ -131,11 +153,9 @@ export async function resolveFrame(frame, repoRoot) {
131
153
  const viaMap = await trySourceMap(frame, repoRoot);
132
154
  if (viaMap)
133
155
  return viaMap;
134
- // Fallback: Vite dev serves real source files at their URL path, so the
135
- // bundle URL is already the source path — no sourcemap needed.
136
- const relative = stripQuery(frame.url)
137
- .replace(/^https?:\/\/[^/]+\//, "")
138
- .replace(/^\//, "");
156
+ // Fallback: dev servers (Vite, Next) serve real source files at their URL
157
+ // path, so the bundle URL is already the source path — no sourcemap needed.
158
+ const relative = normalizeFrameUrl(frame.url);
139
159
  const directPath = resolveWithin(repoRoot, relative);
140
160
  if (!directPath) {
141
161
  return {
@@ -131,9 +131,12 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
131
131
  }
132
132
  let actions = 0;
133
133
  let newCoverage = 0;
134
+ let sawLoginForm = false;
134
135
  if (loaded) {
135
136
  if (strategy.kind === "walk") {
136
- actions = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
137
+ const wr = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
138
+ actions = wr.actions;
139
+ sawLoginForm = wr.sawLoginForm;
137
140
  }
138
141
  else if (strategy.kind === "fuzz") {
139
142
  const fr = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
@@ -151,7 +154,7 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
151
154
  }
152
155
  await page.waitForTimeout(500);
153
156
  await context.close();
154
- return { findings: classifier.findings(), actions, newCoverage, replayStorageState };
157
+ return { findings: classifier.findings(), actions, newCoverage, sawLoginForm, replayStorageState };
155
158
  }
156
159
  /** Dedup findings across workers by fingerprint: sum occurrences, keep the richest. */
157
160
  export function mergeFindings(arrays) {
@@ -194,6 +197,17 @@ function buildStrategies(opts) {
194
197
  }
195
198
  return strategies;
196
199
  }
200
+ /** Human-readable label for a worker's role in the swarm. */
201
+ function strategyLabel(s) {
202
+ switch (s.kind) {
203
+ case "walk":
204
+ return "walk";
205
+ case "http-fuzz":
206
+ return "http-fuzz";
207
+ case "fuzz":
208
+ return `fuzz seed ${s.seed}`;
209
+ }
210
+ }
197
211
  /** Launch one browser, run the worker roster concurrently, merge findings. */
198
212
  export async function swarmDetect(opts) {
199
213
  const strategies = buildStrategies(opts);
@@ -231,7 +245,15 @@ export async function swarmDetect(opts) {
231
245
  const findings = mergeFindings(results.map((r) => r.findings));
232
246
  const totalActions = results.reduce((sum, r) => sum + r.actions, 0);
233
247
  const totalCoverage = results.reduce((sum, r) => sum + r.newCoverage, 0);
234
- return { findings, replayStorageState, totalActions, totalCoverage, workerCount: strategies.length };
248
+ return {
249
+ findings,
250
+ replayStorageState,
251
+ totalActions,
252
+ totalCoverage,
253
+ workerCount: strategies.length,
254
+ roles: strategies.map(strategyLabel),
255
+ sawLoginForm: results.some((r) => r.sawLoginForm),
256
+ };
235
257
  }
236
258
  finally {
237
259
  await browser.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aztrx-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",