aztrx-cli 0.4.3 → 0.4.5

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
@@ -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.
@@ -138,6 +169,7 @@ niche tuning knobs).
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 | — |
140
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 | — |
141
173
  | `--repro` | Minimize (ddmin) → emit Playwright spec → validate flake rate | — |
142
174
  | `--heal` | Generate + verify a fix (implies `--repro`) | — |
143
175
  | `--fix` | Find → explain → heal → apply — the one-command fix (free for null/undefined derefs) | — |
@@ -190,12 +222,32 @@ Every run writes self-contained artifacts inside `.aztrx/` (gitignored):
190
222
  ├── repro/<id>.spec.ts # minimal, executable Playwright repro
191
223
  ├── heal/<id>.patch # gated, compiler-checked fix (one per finding)
192
224
  ├── events.jsonl # run log (streamed by `aztrx-cli studio`)
225
+ ├── patrol.json # patrol cross-run memory (handled fingerprints)
193
226
  ├── pr-comment.md # GitHub PR markdown (with --pr-comment)
194
227
  └── badge.svg # status badge (with --badge)
195
228
  ```
196
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
+
197
233
  ---
198
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
+
199
251
  ## Contributing
200
252
 
201
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();
@@ -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 = re.exec(stack)) !== null)
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
+ }
@@ -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.
@@ -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")) ?? "";
@@ -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, generateRulePatch, modelTiers, RULE_TIER } from "./llm.js";
22
+ import { generatePatch, generateRulePatch, modelTiers, RULE_TIER, BudgetExhaustedError } 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";
@@ -154,12 +154,14 @@ export async function heal(finding, opts) {
154
154
  // 2. Generate (this tier).
155
155
  let patch;
156
156
  try {
157
- patch = await generatePatch(ctx, { model: tier.model, patchFn: opts.patchFn });
157
+ patch = await generatePatch(ctx, { model: tier.model, patchFn: opts.patchFn, budget: opts.budget });
158
158
  }
159
159
  catch (e) {
160
- // A transport/config failure isn't a model-quality failure a pricier
161
- // tier won't fix a dead endpoint or a missing key, so stop here.
162
- last = { ...base, status: "no-llm", error: e.message, model: tier.model };
160
+ // A spent session budget stops everything paid; a transport/config failure
161
+ // won't be fixed by a pricier tier either, so both break out here.
162
+ last = e instanceof BudgetExhaustedError
163
+ ? { ...base, status: "budget-exhausted", error: e.message, model: tier.model }
164
+ : { ...base, status: "no-llm", error: e.message, model: tier.model };
163
165
  break;
164
166
  }
165
167
  if (patch.hunks.length === 0) {
@@ -45,6 +45,14 @@ Hard rules:
45
45
  - Do NOT add any new import/require/import(). Do NOT use eval or new Function. Do NOT write an empty catch block (catch {}). Do NOT touch child_process, exec, spawn, fork, process.exit.
46
46
  - If you see __AZTRX_REDACTED_N__ placeholders, treat them as opaque tokens and carry them through unchanged — do not invent values for them.
47
47
  - If you cannot fix the bug, return { "explanation": "cannot fix", "edits": [] }.`;
48
+ /** Thrown when the shared session budget has no paid generations left. Heal
49
+ * maps this to a `budget-exhausted` status rather than a transport error. */
50
+ export class BudgetExhaustedError extends Error {
51
+ constructor() {
52
+ super("spend budget exhausted");
53
+ this.name = "BudgetExhaustedError";
54
+ }
55
+ }
48
56
  function buildPrompt(ctx) {
49
57
  const loc = ctx.finding.mappedLocation;
50
58
  const msg = redact(ctx.finding.rawMessage).text;
@@ -120,6 +128,12 @@ export async function generatePatch(ctx, opts = {}) {
120
128
  return rulePatch;
121
129
  throw new Error("no rule-based fix applicable");
122
130
  }
131
+ // Paid path — enforce the shared session budget before spending, and charge it
132
+ // on success. The free rule tier above never reaches this, so a spent budget
133
+ // still lets free fixes through.
134
+ if (opts.budget && opts.budget.remaining <= 0) {
135
+ throw new BudgetExhaustedError();
136
+ }
123
137
  const text = await complete({
124
138
  system: SYSTEM,
125
139
  prompt: buildPrompt(ctx),
@@ -127,5 +141,7 @@ export async function generatePatch(ctx, opts = {}) {
127
141
  maxTokens: 2048,
128
142
  temperature: 0,
129
143
  });
144
+ if (opts.budget)
145
+ opts.budget.remaining -= 1;
130
146
  return parsePatch(text);
131
147
  }
@@ -42,7 +42,11 @@ const VALUE_MATCH = [
42
42
  label: "secret_value",
43
43
  },
44
44
  {
45
- re: /((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|amqps):\/\/[^:/\s]+:)[^@\s]+(@)/gi,
45
+ // Captures the password as group 2 and stops at `@` with a lookahead so the
46
+ // `@` is never consumed. (The old form captured `(@)` instead — group 2 was
47
+ // the literal `@` — so the password was dropped and `unredact` restored `@`
48
+ // in its place, corrupting the URL.)
49
+ re: /((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|amqps):\/\/[^:/\s]+:)([^@\s]+)(?=@)/gi,
46
50
  label: "url_password",
47
51
  },
48
52
  ];
@@ -43,7 +43,11 @@ export function applyHunks(content, hunks) {
43
43
  return { ok: false, patched: content, applied: 0, errors };
44
44
  let patched = content;
45
45
  for (const h of hunks) {
46
- patched = patched.replace(h.search, h.replace);
46
+ // Function replacer: a plain `replace(search, replace)` treats `$&`, `$1`,
47
+ // `` $` ``, `$'`, `$$` in `replace` as substitution tokens, so a fix that
48
+ // introduces a literal `$5` (a price, a template fragment, a regex capture)
49
+ // would be silently mangled. The function form inserts `replace` verbatim.
50
+ patched = patched.replace(h.search, () => h.replace);
47
51
  }
48
52
  return { ok: true, patched, applied: hunks.length, errors: [] };
49
53
  }