aztrx-cli 0.4.4 → 0.5.1

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.
Files changed (98) hide show
  1. package/README.md +325 -28
  2. package/dist/cli/help.d.ts +22 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +278 -81
  5. package/dist/core/auth.d.ts +33 -0
  6. package/dist/core/badge.d.ts +9 -0
  7. package/dist/core/browser.d.ts +3 -0
  8. package/dist/core/classifier.d.ts +41 -0
  9. package/dist/core/cloud/index.d.ts +62 -0
  10. package/dist/core/cloud/index.js +24 -4
  11. package/dist/core/devServer.d.ts +90 -0
  12. package/dist/core/devServer.js +253 -0
  13. package/dist/core/diagnose.d.ts +18 -0
  14. package/dist/core/diff.d.ts +31 -0
  15. package/dist/core/domWalker.d.ts +24 -0
  16. package/dist/core/domWalker.js +1 -1
  17. package/dist/core/eventBus.d.ts +59 -0
  18. package/dist/core/events.d.ts +42 -0
  19. package/dist/core/fixPr.d.ts +16 -0
  20. package/dist/core/fixPr.js +11 -1
  21. package/dist/core/fuzzer.d.ts +24 -0
  22. package/dist/core/fuzzer.js +1 -1
  23. package/dist/core/heal/apply.d.ts +28 -0
  24. package/dist/core/heal/boot.d.ts +74 -0
  25. package/dist/core/heal/boot.js +126 -23
  26. package/dist/core/heal/childEnv.d.ts +15 -0
  27. package/dist/core/heal/gates.d.ts +11 -0
  28. package/dist/core/heal/index.d.ts +19 -0
  29. package/dist/core/heal/index.js +53 -23
  30. package/dist/core/heal/llm.d.ts +43 -0
  31. package/dist/core/heal/redact.d.ts +22 -0
  32. package/dist/core/heal/sandbox.d.ts +54 -0
  33. package/dist/core/heal/sandbox.js +20 -3
  34. package/dist/core/heal/types.d.ts +102 -0
  35. package/dist/core/heal/verify.d.ts +37 -0
  36. package/dist/core/heal/verify.js +28 -14
  37. package/dist/core/httpFuzzer.d.ts +33 -0
  38. package/dist/core/init.d.ts +21 -0
  39. package/dist/core/init.js +5 -6
  40. package/dist/core/interceptor.d.ts +21 -0
  41. package/dist/core/llm.d.ts +36 -0
  42. package/dist/core/llm.js +0 -8
  43. package/dist/core/minimizer.d.ts +13 -0
  44. package/dist/core/modernize.d.ts +28 -0
  45. package/dist/core/modernize.js +2 -2
  46. package/dist/core/networkGuard.d.ts +13 -0
  47. package/dist/core/orchestrator.d.ts +86 -0
  48. package/dist/core/orchestrator.js +24 -9
  49. package/dist/core/patrol/loop.d.ts +46 -0
  50. package/dist/core/patrol/pr.d.ts +42 -0
  51. package/dist/core/patrol/pr.js +9 -6
  52. package/dist/core/patrol/record.d.ts +51 -0
  53. package/dist/core/patrol/state.d.ts +39 -0
  54. package/dist/core/pr.d.ts +3 -0
  55. package/dist/core/pr.js +15 -11
  56. package/dist/core/prompt.d.ts +14 -0
  57. package/dist/core/recorder.d.ts +23 -0
  58. package/dist/core/recorder.js +1 -1
  59. package/dist/core/renderMarkdown.d.ts +7 -0
  60. package/dist/core/replay.d.ts +33 -0
  61. package/dist/core/replay.js +9 -2
  62. package/dist/core/report.d.ts +8 -0
  63. package/dist/core/resolver.d.ts +44 -0
  64. package/dist/core/resolver.js +51 -13
  65. package/dist/core/rng.d.ts +2 -0
  66. package/dist/core/specCompiler.d.ts +17 -0
  67. package/dist/core/studio.d.ts +6 -0
  68. package/dist/core/studio.js +0 -3
  69. package/dist/core/summarize.d.ts +26 -0
  70. package/dist/core/swarm.d.ts +97 -0
  71. package/dist/core/telemetry/index.d.ts +33 -0
  72. package/dist/core/telemetry/index.js +22 -4
  73. package/dist/core/telemetry/sanitize.d.ts +19 -0
  74. package/dist/core/telemetry/types.d.ts +26 -0
  75. package/dist/core/types.d.ts +88 -0
  76. package/dist/core/ui.d.ts +23 -0
  77. package/dist/core/ui.js +0 -5
  78. package/dist/core/validator.d.ts +14 -0
  79. package/dist/core/version.d.ts +1 -0
  80. package/dist/hooks/index.d.ts +106 -0
  81. package/dist/hooks/index.js +349 -0
  82. package/dist/mcp/index.d.ts +58 -0
  83. package/dist/mcp/index.js +295 -0
  84. package/dist/mcp/install.d.ts +52 -0
  85. package/dist/mcp/install.js +204 -0
  86. package/dist/mcp/protocol.d.ts +106 -0
  87. package/dist/mcp/protocol.js +156 -0
  88. package/dist/mcp/tools.d.ts +107 -0
  89. package/dist/mcp/tools.js +621 -0
  90. package/dist/next/index.d.ts +70 -0
  91. package/dist/next/index.js +103 -0
  92. package/dist/plugins/scan.d.ts +85 -0
  93. package/dist/plugins/scan.js +188 -0
  94. package/dist/ui/app.d.ts +11 -0
  95. package/dist/ui/app.js +14 -1
  96. package/dist/vite/index.d.ts +60 -0
  97. package/dist/vite/index.js +56 -0
  98. package/package.json +34 -4
@@ -72,6 +72,14 @@ export async function run(options) {
72
72
  if (!ui)
73
73
  console.log(parts.join(" "));
74
74
  };
75
+ // Important hints/warnings (destructive mode, login-wall, wrong --repo) must be
76
+ // visible in the TUI too — `say` is a no-op there. Emit on the bus always, and
77
+ // print to stdout only in the linear (non-UI) path so it isn't duplicated.
78
+ const notice = (message, level = "hint") => {
79
+ bus.emit("notice", { message, level, ts: Date.now() });
80
+ if (!ui)
81
+ console.log(level === "danger" ? pc.red(message) : pc.yellow(message));
82
+ };
75
83
  const emitPhase = (phase, detail) => bus.emit("phase", { phase, detail, ts: Date.now() });
76
84
  say(pc.cyan(`\nAztrx AI v${VERSION} — Runtime Detector`));
77
85
  say(pc.dim(`Target: ${url}`));
@@ -85,8 +93,7 @@ export async function run(options) {
85
93
  if (options.storageState)
86
94
  say(pc.dim(`Auth: ${options.storageState}`));
87
95
  if (options.allowDestructive) {
88
- say(pc.yellow(pc.bold("⚠ DESTRUCTIVE MODE — delete/pay/logout/checkout controls are ENABLED.")));
89
- say(pc.yellow(" This can mutate real data. Run only against a disposable/dev instance you own."));
96
+ notice("⚠ DESTRUCTIVE MODE — delete/pay/logout/checkout controls are ENABLED. This can mutate real data. Run only against a disposable/dev instance you own.", "danger");
90
97
  }
91
98
  say("");
92
99
  emitPhase("launch", url);
@@ -149,7 +156,7 @@ export async function run(options) {
149
156
  say(pc.dim(`\nWalked ${totalActions} action(s).\n`));
150
157
  }
151
158
  if (sawLoginForm && !options.login) {
152
- say(pc.yellow("Hint: this app has a login form — re-run with --login to test the authenticated app."));
159
+ notice("Hint: this app has a login form — re-run with --login to test the authenticated app.", "hint");
153
160
  }
154
161
  // F7 → F8 → F9: minimize each finding, compile an executable spec, validate
155
162
  // the flake rate. Only crash/error findings with a recorded action history.
@@ -317,10 +324,15 @@ export async function run(options) {
317
324
  }
318
325
  }
319
326
  else if (candidates.length > 0) {
320
- // Reproducible crashes, but none mapped to source (wrong --repo?) — tell the
321
- // user rather than silently doing nothing. Already-handled fingerprints are
322
- // excluded above, so this only fires for genuinely new findings.
323
- say(pc.yellow(`Found ${candidates.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.`));
327
+ // Reproducible crashes, but none mapped to a readable source file (wrong
328
+ // --repo?) — tell the user rather than silently doing nothing. Already-handled
329
+ // fingerprints are excluded above, so this only fires for genuinely new
330
+ // findings. Name the files it looked for so the hint is actionable.
331
+ const missing = candidates
332
+ .map((f) => f.mappedLocation?.filePath || f.rawMessage.split("\n")[0].slice(0, 40))
333
+ .filter(Boolean)
334
+ .slice(0, 3);
335
+ notice(`Found ${candidates.length} reproducible crash(es) but couldn't read their source (${missing.join(", ")}). Run from your project root (or pass --repo <dir>) so --fix can read the code.`, "warning");
324
336
  }
325
337
  }
326
338
  const reportPath = writeReport(repoRoot, url, findings, options.lang);
@@ -343,7 +355,10 @@ export async function run(options) {
343
355
  // F12 — opt-in cloud sync. Streams the sanitized run results to the ingest
344
356
  // API for the team dashboard; dedup happens server-side by fingerprint.
345
357
  if (options.upload) {
346
- if (options.apiKey || process.env.AZTRX_API_KEY) {
358
+ // `AZTRX_CLOUD_API_KEY`, not `AZTRX_API_KEY`: the latter is a model provider
359
+ // credential, and treating its presence as consent to upload would put that
360
+ // key on the wire as the ingest auth header.
361
+ if (options.apiKey || process.env.AZTRX_CLOUD_API_KEY) {
347
362
  submitRun(findings, {
348
363
  repoRoot,
349
364
  url,
@@ -354,7 +369,7 @@ export async function run(options) {
354
369
  });
355
370
  }
356
371
  else {
357
- say(pc.yellow("Upload skipped: no API key. Set --api-key or AZTRX_API_KEY to stream findings to the dashboard."));
372
+ say(pc.yellow("Upload skipped: no API key. Set --api-key or AZTRX_CLOUD_API_KEY to stream findings to the dashboard."));
358
373
  }
359
374
  }
360
375
  runLog.append({ type: "run_end", counts, ts: Date.now() });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `aztrx patrol` — the autonomous bug-patrol loop. It wraps the existing
3
+ * `run()` detect → repro → heal pipeline in a supervisor loop with memory, so
4
+ * instead of "run it and read the output" the tool re-scans on an interval,
5
+ * fixes anything new, and opens a PR per bug — no human in the middle.
6
+ *
7
+ * Guardrails that keep an autonomous run from going off the rails:
8
+ * - memory (PatrolState) — a handled fingerprint is never re-fixed/re-PR'd;
9
+ * - per-session fix cap (`maxFixes`) — bounds LLM spend + PR spam;
10
+ * - scoped staging — PRs carry only the files the patch touched;
11
+ * - a liveness check — a dead target skips the cycle instead of burning a crawl.
12
+ */
13
+ export interface PatrolOptions {
14
+ url: string;
15
+ repoRoot: string;
16
+ intervalMs: number;
17
+ maxFixes?: number;
18
+ maxActions?: number;
19
+ /** Hard cap on paid LLM generations across the whole session (0/undefined = unlimited). */
20
+ maxSpend?: number;
21
+ /** Cooldown before an `unfixed` fingerprint is retried, ms. Default 30 min. */
22
+ retryAfterMs?: number;
23
+ /** Group all of a cycle's fixes into one PR instead of one PR per bug. */
24
+ batch?: boolean;
25
+ once?: boolean;
26
+ fuzz?: boolean;
27
+ workers?: number;
28
+ allowHosts?: string[];
29
+ storageState?: string;
30
+ login?: boolean;
31
+ loginEmail?: string;
32
+ loginPassword?: string;
33
+ loginUrl?: string;
34
+ healModel?: string;
35
+ healFastModel?: string;
36
+ testCommand?: string;
37
+ testTimeoutMs?: number;
38
+ skipTest?: boolean;
39
+ startCommand?: string;
40
+ lang?: string;
41
+ seed?: number;
42
+ allowDestructive?: boolean;
43
+ httpFuzz?: boolean;
44
+ httpFuzzMutations?: boolean;
45
+ }
46
+ export declare function patrol(opts: PatrolOptions): Promise<void>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * PR opener for `aztrx patrol`. Like `fixPr.openFixPr`, but built for an
3
+ * autonomous loop, so it adds two things a human-driven flow doesn't need:
4
+ *
5
+ * - **Dedup**: a fingerprint-stable branch name (`aztrx/fix-<fp8>`) plus a
6
+ * `gh pr list --head` check, so a re-scan never opens a second PR for a bug
7
+ * that already has one open.
8
+ * - **Scoped staging**: stages only the files the patch touched (`git add --
9
+ * <files>`), never `git add -A` — an autonomous run must not sweep up the
10
+ * user's unrelated uncommitted work into a PR.
11
+ *
12
+ * The caller is responsible for applying patches first (`applyVerifiedPatches`).
13
+ */
14
+ import type { Finding } from "../types.js";
15
+ export interface PatrolPrResult {
16
+ ok: boolean;
17
+ skipped?: boolean;
18
+ url?: string;
19
+ branch?: string;
20
+ error?: string;
21
+ }
22
+ export declare function branchFor(fp: string): string;
23
+ /** Stable branch name for a *set* of fingerprints — the same set always maps to
24
+ * the same branch, so a re-scan of an unchanged batch doesn't open a duplicate PR. */
25
+ export declare function branchForSet(fps: string[]): string;
26
+ /** A local/loopback/private target is a dev box; only a publicly-routable host is
27
+ * a deployed app, so a crash there is "live in production" and worth flagging.
28
+ * Covers loopback, RFC1918 private ranges (10/8, 172.16/12, 192.168/16),
29
+ * link-local (169.254/16), and mDNS/internal suffixes (.local, .internal). */
30
+ export declare function isLocalUrl(url: string): boolean;
31
+ /**
32
+ * Pure URL builder: turns a `git remote` origin URL into a raw-content URL for a
33
+ * repo-relative path, so the PR body can inline an image
34
+ * (`![…](https://github.com/<owner>/<repo>/raw/<branch>/<path>)`). Handles the
35
+ * three common remote shapes (`https://`, `git@github.com:`, `ssh://git@…`) and a
36
+ * trailing `.git`. Returns "" when the remote isn't GitHub.
37
+ */
38
+ export declare function githubRawUrl(remote: string, branch: string, repoPath: string): string;
39
+ export declare function openPatrolPr(repoRoot: string, finding: Finding, url: string, files: string[], mediaPath?: string | null): Promise<PatrolPrResult>;
40
+ /** One PR carrying a whole batch of fixes. Branch is stable across the *set*, so
41
+ * an unchanged batch re-scan dedups to the same PR. */
42
+ export declare function openPatrolBatchPr(repoRoot: string, findings: Finding[], url: string, files: string[], mediaPaths?: (string | null)[]): Promise<PatrolPrResult>;
@@ -144,17 +144,20 @@ async function rawUrlFor(repoRoot, branch, repoPath) {
144
144
  * files, commit, open the PR, and return to the original branch. The caller has
145
145
  * already done its own dedup + title/body. */
146
146
  async function createPr(repoRoot, branch, title, body, files) {
147
+ // Nothing to stage means no patch landed, so there is no fix to open a PR for.
148
+ // This used to fall back to `git add -A`, which would have swept the user's
149
+ // unrelated uncommitted work into an auto-generated commit under a "fix: …"
150
+ // title and pushed it. Refuse instead — and refuse before touching git, so a
151
+ // branch is never created for an empty fix set.
152
+ if (files.length === 0) {
153
+ return { ok: false, branch, error: "no files to stage — refusing to commit an empty fix set" };
154
+ }
147
155
  const originalBranch = await currentBranch(repoRoot);
148
156
  try {
149
157
  // `-B` (re)creates the branch at HEAD — idempotent against a stale local
150
158
  // branch left over from a previously failed PR attempt.
151
159
  await exec("git", ["-C", repoRoot, "checkout", "-B", branch]);
152
- if (files.length) {
153
- await exec("git", ["-C", repoRoot, "add", "--", ...files]);
154
- }
155
- else {
156
- await exec("git", ["-C", repoRoot, "add", "-A"]);
157
- }
160
+ await exec("git", ["-C", repoRoot, "add", "--", ...files]);
158
161
  await exec("git", ["-C", repoRoot, "commit", "-m", title]);
159
162
  }
160
163
  catch (e) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Recorded repro (Phase 3) — turns a reproducible bug into a shareable
3
+ * "before/after" proof: an animated GIF and a static crash screenshot, both
4
+ * destined for the patrol PR body.
5
+ *
6
+ * The capture is deliberately OFF the hot path. Verification replays a bug many
7
+ * times (ddmin + validator), but the recording runs once, only when a PR is about
8
+ * to open — so it launches its own browser instead of reusing ReplayEngine's.
9
+ *
10
+ * GIF encoding is pure-JS (`gifenc` + `pngjs`), no ffmpeg, so it works for every
11
+ * `npm install aztrx-cli` user rather than only machines with ffmpeg on PATH.
12
+ */
13
+ import type { Finding, RecordedAction } from "../types.js";
14
+ export interface CaptureOptions {
15
+ viewport?: {
16
+ width: number;
17
+ height: number;
18
+ };
19
+ /** Settle time after page load, before the first frame. */
20
+ settleMs?: number;
21
+ /** Settle time after each replayed action, before its screenshot. */
22
+ stepMs?: number;
23
+ }
24
+ /**
25
+ * Captures one PNG screenshot before the replay and one after each action, so
26
+ * the crash (or its absence) is visible in the sequence. Replays `replayActions`
27
+ * one action at a time — reusing the exact detection semantics, not a parallel
28
+ * reimplementation that could drift.
29
+ */
30
+ export declare function captureReproFrames(url: string, actions: RecordedAction[], opts?: CaptureOptions): Promise<Buffer[]>;
31
+ export interface GifOptions {
32
+ /** Frame delay in ms (default 400). */
33
+ delay?: number;
34
+ /** Quantized palette size, ≤ 256 (default 256). */
35
+ maxColors?: number;
36
+ }
37
+ /** Encodes a sequence of PNG frames into an animated GIF (pure JS, no ffmpeg). */
38
+ export declare function encodeGif(frames: Buffer[], opts?: GifOptions): Buffer;
39
+ /** The static "crash shot": the final frame, when the crash has fully rendered. */
40
+ export declare function crashFrame(frames: Buffer[]): Buffer;
41
+ /**
42
+ * Produces the recorded-repro GIF for a finding and writes it to
43
+ * `aztrx-media/<fp8>.gif`. That dir is deliberate: `media/` is in the npm
44
+ * `files` allowlist (so demo.gif/logo ship), but patrol GIFs must not publish —
45
+ * they're per-run artifacts. The path is committed to the PR branch so the PR
46
+ * body can inline it via a raw URL.
47
+ *
48
+ * Returns the repo-relative path, or null when there is nothing to record (no
49
+ * repro, or capture/encode failed) — the caller opens the PR regardless.
50
+ */
51
+ export declare function recordFindingGif(repoRoot: string, url: string, finding: Finding): Promise<string | null>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cross-run memory for `aztrx patrol`. Where `RunLog` is append-only and reset
3
+ * every run, this is a small index of "have I seen this fingerprint before, and
4
+ * what happened to it?" — the thing that stops an autonomous loop from re-fixing
5
+ * the same bug and re-opening the same PR on every cycle.
6
+ *
7
+ * Lives at `.aztrx/patrol.json` (already gitignored via `.aztrx/`). A fingerprint
8
+ * absent from the map is implicitly "new".
9
+ */
10
+ export type FingerprintStatus = "pr-opened" | "unfixed";
11
+ export interface FingerprintEntry {
12
+ status: FingerprintStatus;
13
+ firstSeen: string;
14
+ lastSeen: string;
15
+ prUrl?: string;
16
+ branch?: string;
17
+ attempts: number;
18
+ }
19
+ export declare class PatrolState {
20
+ private readonly file;
21
+ private readonly cooldownMs;
22
+ private data;
23
+ constructor(repoRoot: string, url: string, cooldownMs?: number);
24
+ private read;
25
+ /**
26
+ * A fingerprint is "handled" while its PR is open, or while an `unfixed` mark
27
+ * is still within its cooldown. Once the cooldown lapses, an `unfixed` bug
28
+ * becomes retry-eligible again — it stops being "handled" and `run()` heals it
29
+ * afresh rather than skipping it forever.
30
+ */
31
+ isHandled(fp: string, now?: number): boolean;
32
+ /** Every currently-handled fingerprint, so the supervisor can tell `run()` to skip healing them. */
33
+ handled(now?: number): string[];
34
+ markPr(fp: string, prUrl: string, branch: string): void;
35
+ markUnfixed(fp: string): void;
36
+ private firstSeen;
37
+ private attempts;
38
+ save(): void;
39
+ }
@@ -0,0 +1,3 @@
1
+ import type { Finding } from "./types.js";
2
+ export declare function renderPrComment(targetUrl: string, findings: Finding[]): string;
3
+ export declare function writePrComment(repoRoot: string, targetUrl: string, findings: Finding[], filePath?: string): string;
package/dist/core/pr.js CHANGED
@@ -9,12 +9,6 @@ import { sanitizeSecrets } from "./heal/redact.js";
9
9
  * shields.io badges, which GitHub renders natively.
10
10
  */
11
11
  const SEV_ORDER = ["crash", "error", "warning", "noise"];
12
- const SEV_BADGE = {
13
- crash: "ff5a5f",
14
- error: "ff5a5f",
15
- warning: "f5a623",
16
- noise: "5b6573",
17
- };
18
12
  /** Shields.io badge-path escaping: literal `-` → `--`, `/` → `%2F`, space → `_`. */
19
13
  function shield(s) {
20
14
  return s.replace(/-/g, "--").replace(/\//g, "%2F").replace(/ /g, "_");
@@ -44,6 +38,17 @@ function inlineCode(s) {
44
38
  function cleanInline(s) {
45
39
  return sanitizeSecrets(s).replace(/`/g, "").replace(/[\r\n]/g, " ");
46
40
  }
41
+ /** Prose that lands in the comment body — a model-written explanation, an error
42
+ * string. Escaping the HTML keeps it inside the surrounding `<details>`;
43
+ * defusing the link/image syntax stops a crafted PR from having the bot render
44
+ * a tracking pixel or a phishing link under the consumer's token. Secrets are
45
+ * scrubbed for the same reason every other inlined string is: the model read
46
+ * the app's own source to write this, so it can quote a key back out. */
47
+ function cleanProse(s) {
48
+ return escapeHtml(sanitizeSecrets(s))
49
+ .replace(/!\[/g, "!\\[")
50
+ .replace(/\]\(/g, "\\]\\(");
51
+ }
47
52
  /** Hard cap on the server body inlined into a PR comment — a 500 page can be
48
53
  * huge, and only the first line or two are ever diagnostic. */
49
54
  const SERVER_BODY_CAP = 2000;
@@ -84,13 +89,13 @@ function healBlock(f) {
84
89
  body.push(fence(diff, "diff"));
85
90
  }
86
91
  else if (h.explanation) {
87
- body.push(`> ${h.explanation}`);
92
+ body.push(`> ${cleanProse(h.explanation)}`);
88
93
  }
89
94
  if (h.test?.ran) {
90
95
  body.push(`**tests** ${inlineCode(h.test.command)} — ${h.test.ok ? "passed" : "failed"}`);
91
96
  }
92
97
  if (h.error)
93
- body.push(`\n_${escapeHtml(h.error)}_`);
98
+ body.push(`\n_${cleanProse(h.error)}_`);
94
99
  return `\n<details>\n<summary>${badge("heal", meta.text, meta.color)} proposed patch${via}${tiers}</summary>\n\n${body.join("\n")}\n</details>`;
95
100
  }
96
101
  function reproBlock(f) {
@@ -122,8 +127,7 @@ function findingBlock(f) {
122
127
  : "";
123
128
  return `<details open>\n<summary><code>${escapeHtml(sev)}</code> — ${escapeHtml(first)}</summary>\n${loc}${snippet}${serverErr}${reproBlock(f)}${healBlock(f)}\n</details>`;
124
129
  }
125
- export function renderPrComment(targetUrl, findings, opts = {}) {
126
- void opts;
130
+ export function renderPrComment(targetUrl, findings) {
127
131
  const sorted = [...findings].sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity));
128
132
  const counts = { crash: 0, error: 0, warning: 0, noise: 0 };
129
133
  for (const f of sorted)
@@ -160,6 +164,6 @@ ${body}
160
164
  export function writePrComment(repoRoot, targetUrl, findings, filePath) {
161
165
  const file = filePath ?? path.join(repoRoot, ".aztrx", "pr-comment.md");
162
166
  fs.mkdirSync(path.dirname(file), { recursive: true });
163
- fs.writeFileSync(file, renderPrComment(targetUrl, findings, { repoRoot }), "utf-8");
167
+ fs.writeFileSync(file, renderPrComment(targetUrl, findings), "utf-8");
164
168
  return file;
165
169
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A single yes/no confirmation for `--fix`. Kept tiny and side-effect free:
3
+ *
4
+ * - `--yes` short-circuits to `true` (scripts/CI).
5
+ * - a non-TTY stdout without `--yes` short-circuits to `false` — an unattended
6
+ * run never mutates the working tree, it just leaves the `.patch` files.
7
+ * - otherwise prompts on stdin, defaulting to "no".
8
+ */
9
+ export interface PromptOptions {
10
+ yes?: boolean;
11
+ }
12
+ export declare function promptYesNo(question: string, opts?: PromptOptions): Promise<boolean>;
13
+ /** A single-line text prompt. Returns "" when stdout isn't a TTY (unattended run). */
14
+ export declare function promptInput(question: string): Promise<string>;
@@ -0,0 +1,23 @@
1
+ import type { ElementHandle } from "playwright";
2
+ import type { RecordedAction } from "./types.js";
3
+ type AnyElement = SVGElement | HTMLElement;
4
+ /**
5
+ * F2 — ring buffer of the last 25 actions. The Repro Minimizer (F7) later
6
+ * shrinks this history; for now it's the action context attached to findings.
7
+ */
8
+ export declare class ActionRecorder {
9
+ private buffer;
10
+ readonly capacity = 25;
11
+ record(action: RecordedAction): void;
12
+ snapshot(): RecordedAction[];
13
+ }
14
+ /**
15
+ * Selector cascade, most-reliable first: data-testid → text → css path.
16
+ *
17
+ * Each `evaluate` is wrapped defensively: selector resolution is best-effort
18
+ * and must never take down a whole run if the page tears the node down
19
+ * mid-query or the browser rejects a serialized function. A failed probe just
20
+ * degrades to the next (weaker) selector in the cascade.
21
+ */
22
+ export declare function selectorCascade(handle: ElementHandle<AnyElement>): Promise<string[]>;
23
+ export {};
@@ -22,7 +22,7 @@ export class ActionRecorder {
22
22
  * mid-query or the browser rejects a serialized function. A failed probe just
23
23
  * degrades to the next (weaker) selector in the cascade.
24
24
  */
25
- export async function selectorCascade(page, handle) {
25
+ export async function selectorCascade(handle) {
26
26
  const out = [];
27
27
  try {
28
28
  const testId = await handle.evaluate((el) => {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Minimal Markdown → ANSI renderer for the `--explain` / `--fix` human-language
3
+ * summary. Handles the small dialect Aztrx emits: headings, bold/`code` inline,
4
+ * bullet and numbered lists, and fenced code blocks with a light JS/TS syntax
5
+ * highlight. Plain prose without markdown passes through unchanged.
6
+ */
7
+ export declare function renderMarkdown(md: string): string;
@@ -0,0 +1,33 @@
1
+ import type { Page } from "playwright";
2
+ import type { FindingType, RecordedAction } from "./types.js";
3
+ export interface ReplayEngineOptions {
4
+ attachGuard?: (page: Page) => Promise<void>;
5
+ /** Playwright storage-state JSON (path or object) for authenticated replays. */
6
+ storageState?: string;
7
+ }
8
+ export interface ReplayResult {
9
+ reproduced: boolean;
10
+ /** Did the target page actually load? A navigation that was refused, timed out,
11
+ * or answered non-2xx produces no telemetry — which is indistinguishable from
12
+ * "the bug is gone" unless the caller can tell the two apart. Verification
13
+ * depends on this: an unreachable page must never count as a passing run. */
14
+ loaded: boolean;
15
+ }
16
+ /** Replays a recorded action sequence against a page. Best-effort: a selector
17
+ * that no longer resolves is skipped, not fatal. */
18
+ export declare function replayActions(page: Page, actions: RecordedAction[]): Promise<void>;
19
+ /**
20
+ * Reuses one browser across replays (ddmin + validator each run many). Each
21
+ * `run` gets a fresh page; the interceptor collects telemetry fingerprints and
22
+ * reports whether `targetFingerprint` was seen.
23
+ */
24
+ export declare class ReplayEngine {
25
+ private opts;
26
+ private browser;
27
+ constructor(opts?: ReplayEngineOptions);
28
+ private getBrowser;
29
+ run(url: string, actions: RecordedAction[], targetFingerprint: string, opts?: {
30
+ targetType?: FindingType;
31
+ }): Promise<ReplayResult>;
32
+ close(): Promise<void>;
33
+ }
@@ -111,7 +111,14 @@ export class ReplayEngine {
111
111
  attachInterceptor(page, bus);
112
112
  if (this.opts.attachGuard)
113
113
  await this.opts.attachGuard(page);
114
- await page.goto(url, { waitUntil: "load", timeout: 30000 }).catch(() => { });
114
+ // A rejected navigation (refused connection, DNS failure, timeout) and a
115
+ // non-2xx answer both leave a page that will never emit the telemetry we
116
+ // are looking for. Capture that here rather than letting the empty
117
+ // fingerprint set read as a passing verification.
118
+ const nav = await page
119
+ .goto(url, { waitUntil: "load", timeout: 30000 })
120
+ .catch(() => null);
121
+ const loaded = nav !== null && nav.ok();
115
122
  // Settle for hydration before replaying — the detection pass waits on the
116
123
  // `load` event plus a settle window, and a replay that clicks before React
117
124
  // attaches its handlers won't reproduce the crash (false "unreliable").
@@ -123,7 +130,7 @@ export class ReplayEngine {
123
130
  const reproduced = opts?.targetType
124
131
  ? types.has(opts.targetType)
125
132
  : fingerprints.has(targetFingerprint);
126
- return { reproduced };
133
+ return { reproduced, loaded };
127
134
  }
128
135
  catch (e) {
129
136
  lastError = e;
@@ -0,0 +1,8 @@
1
+ import type { Finding } from "./types.js";
2
+ /**
3
+ * F-report — standalone offline HTML report. Self-contained (inline CSS, no
4
+ * CDN), rendered in the shared "crash seismograph" identity: one red spike per
5
+ * crash, severity chips, and colored repro verdicts.
6
+ */
7
+ export declare function renderReport(targetUrl: string, findings: Finding[], lang?: string): string;
8
+ export declare function writeReport(repoRoot: string, targetUrl: string, findings: Finding[], lang?: string): string;
@@ -0,0 +1,44 @@
1
+ import type { ServerFrame } from "./types.js";
2
+ export type ResolvedFrom = "sourcemap" | "direct" | "unresolved";
3
+ export interface MappedError {
4
+ message: string;
5
+ /** Repo-relative path to the source file, e.g. `apps/web/src/App.tsx`. */
6
+ sourceFile: string;
7
+ line: number;
8
+ column: number;
9
+ codeSnippet: string;
10
+ resolvedFrom: ResolvedFrom;
11
+ }
12
+ export interface RawFrame {
13
+ url: string;
14
+ line: number;
15
+ column: number;
16
+ message: string;
17
+ }
18
+ /**
19
+ * Pulls the first *user-code* frame out of a stack string. Iterates every line,
20
+ * skips framework internals (webpack runtime, node_modules, next/dist), and
21
+ * returns the first frame in the user's own code — so a Next.js dev stack like
22
+ * `webpack-internal:///(app-pages-browser)/./app/page.tsx:29:21` resolves to the
23
+ * user's file, not `intercept-console-error.js`.
24
+ */
25
+ export declare function extractFrame(text: string): RawFrame | null;
26
+ /**
27
+ * Pull the first server-side source frame out of a raw server stack (a 500 body,
28
+ * a Next.js dev error page, etc.). V8 emits one frame per line as
29
+ * `at <fn> (<path>:<line>:<col>)` or `at <path>:<line>:<col>`; we take the first
30
+ * frame whose path is not inside node_modules. Best-effort — returns null when
31
+ * the body carries no stack trace (e.g. an explicit
32
+ * `NextResponse.json(..., { status: 500 })`).
33
+ */
34
+ export declare function extractServerFrame(stack: string): ServerFrame | null;
35
+ export declare function resolveFrame(frame: RawFrame, repoRoot: string): Promise<MappedError>;
36
+ /**
37
+ * Resolve a server-side source frame (a filesystem path) to a repo-relative
38
+ * source location + snippet. Mirrors `resolveFrame`'s containment rules: a path
39
+ * outside the repo — via `..` traversal or a symlink pointing out — is never
40
+ * read. Server frames carry no sourcemap; a directly-readable file maps
41
+ * `resolvedFrom: "direct"`.
42
+ */
43
+ export declare function resolveServerFrame(frame: ServerFrame, repoRoot: string): MappedError;
44
+ export declare function extractSnippet(filePath: string, targetLine: number, window?: number): string;
@@ -1,5 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ import { fileURLToPath } from "url";
3
4
  import { FlattenMap, originalPositionFor, } from "@jridgewell/trace-mapping";
4
5
  /** Framework-internal frames to skip when hunting the throw site. */
5
6
  const FRAMEWORK_FRAME = /node_modules|webpack-runtime|\.next[\\/]|next[\\/]dist[\\/]/;
@@ -56,20 +57,40 @@ export function extractServerFrame(stack) {
56
57
  function stripQuery(url) {
57
58
  return url.split("?")[0];
58
59
  }
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. */
60
+ /** Normalize a stack-frame URL to a path to probe: repo-relative for the
61
+ * dev-server schemes (`webpack-internal:///(ns)/./src/…`, `webpack://ns/src/…`)
62
+ * and plain `https://host/path` bundle URLs, **absolute** for `file://`.
63
+ *
64
+ * The asymmetry is deliberate. `file://` carries a real filesystem path, and on
65
+ * POSIX the leading slash is the root; the `^\//` strip below exists for URL
66
+ * paths (`/@fs/src/main.tsx`) and would eat it, turning `/tmp/p/app/actions.ts`
67
+ * into the relative `tmp/p/app/actions.ts` — a path under the repo that never
68
+ * exists. Every `file://` frame therefore fell through to "unresolved" on Linux
69
+ * and macOS. `resolveWithin` accepts absolute segments, so callers are unchanged;
70
+ * only display has to convert back (see `reportPath`). */
62
71
  function normalizeFrameUrl(url) {
63
- return stripQuery(url)
72
+ const raw = stripQuery(url);
73
+ if (/^file:\/\//i.test(raw)) {
74
+ try {
75
+ return fileURLToPath(raw); // handles drive-letter and UNC forms on Windows too
76
+ }
77
+ catch {
78
+ return raw; // malformed URL — let the containment check reject it
79
+ }
80
+ }
81
+ return raw
64
82
  .replace(/^webpack-internal:\/\/\/[^/]+\/\.\//, "")
65
83
  .replace(/^webpack:\/\/[^/]+\//, "")
66
84
  .replace(/^webpack:\/\//, "")
67
85
  .replace(/^\/@fs\//, "")
68
- .replace(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
69
- .replace(/^file:\/\//, "")
70
86
  .replace(/^https?:\/\/[^/]+\//, "")
71
87
  .replace(/^\//, "");
72
88
  }
89
+ /** The repo-relative form of a normalized frame path, for anything user-facing.
90
+ * An absolute `file://` path is reported the same way every other frame is. */
91
+ function reportPath(p, repoRoot) {
92
+ return path.isAbsolute(p) ? path.relative(repoRoot, p) : p;
93
+ }
73
94
  /** True only for a real, readable regular file — directories and unreadable
74
95
  * paths return false so readers never hit `EISDIR` / permission errors. */
75
96
  function isFile(p) {
@@ -140,14 +161,30 @@ function resolveWithin(root, ...segments) {
140
161
  }
141
162
  /** Turns a sourcemap `source` value into candidate absolute paths to probe. */
142
163
  function sourceCandidates(source, repoRoot) {
143
- const cleaned = source
164
+ const raw = source.split("?")[0];
165
+ // `file://` is an absolute *filesystem* path, not a URL path, and the two
166
+ // differ in exactly one place: on POSIX the leading slash is the root. The
167
+ // web-style stripping below exists for URL paths (`/@fs/src/main.tsx`) and
168
+ // would eat that slash, turning `/tmp/p/app/actions.ts` into the relative
169
+ // `tmp/p/app/actions.ts` — a path under the repo that never exists, so every
170
+ // Turbopack `file:///` source silently resolved to nothing on Linux and macOS.
171
+ // `fileURLToPath` also handles the drive-letter and UNC forms on Windows.
172
+ if (/^file:\/\//i.test(raw)) {
173
+ let abs;
174
+ try {
175
+ abs = fileURLToPath(raw);
176
+ }
177
+ catch {
178
+ return []; // malformed URL — nothing to probe
179
+ }
180
+ const inside = resolveWithin(repoRoot, abs);
181
+ return inside === null ? [] : [inside];
182
+ }
183
+ const cleaned = raw
144
184
  .replace(/^webpack:\/\/[^/]+\//, "") // webpack://namespace/src/...
145
185
  .replace(/^webpack:\/\//, "")
146
186
  .replace(/^\/@fs\//, "")
147
- .replace(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
148
- .replace(/^file:\/\//, "")
149
- .replace(/^\//, "")
150
- .split("?")[0];
187
+ .replace(/^\//, "");
151
188
  const prefixes = ["", "apps/web/", "src/", "app/"];
152
189
  return prefixes
153
190
  .map((p) => resolveWithin(repoRoot, p, cleaned))
@@ -181,12 +218,13 @@ export async function resolveFrame(frame, repoRoot) {
181
218
  directPath = withIndex;
182
219
  }
183
220
  if (!directPath) {
221
+ const shown = reportPath(relative, repoRoot);
184
222
  return {
185
223
  message: frame.message,
186
- sourceFile: relative,
224
+ sourceFile: shown,
187
225
  line: frame.line,
188
226
  column: frame.column,
189
- codeSnippet: `<file not accessible locally: ${relative}>`,
227
+ codeSnippet: `<file not accessible locally: ${shown}>`,
190
228
  resolvedFrom: "unresolved",
191
229
  };
192
230
  }
@@ -0,0 +1,2 @@
1
+ /** Deterministic seeded PRNG (mulberry32) so fuzz runs are reproducible. */
2
+ export declare function mulberry32(seed: number): () => number;