aztrx-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * F-modernize — the "code translator". Rewrites a legacy JS/TS file into modern
3
+ * idiomatic form (const/let over var, async/await over callbacks and promise
4
+ * chains, arrow functions, optional chaining) while preserving behavior. This is
5
+ * a *static* transform, unlike the rest of Aztrx's runtime detection, so it's its
6
+ * own command rather than a `run` flag.
7
+ *
8
+ * Safety model: the model's output is gated by a re-parse (`ts.transpileModule`
9
+ * reports syntax errors without running a full tsc), and the caller applies it to
10
+ * the working tree only after the user confirms — never automatically.
11
+ */
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import * as ts from "typescript";
15
+ const MODEL = process.env.AZTRX_MODEL || "claude-sonnet-5";
16
+ const API_URL = "https://api.anthropic.com/v1/messages";
17
+ export function detectLang(filePath) {
18
+ const ext = path.extname(filePath).toLowerCase();
19
+ if ([".ts", ".tsx", ".mts", ".cts"].includes(ext))
20
+ return "ts";
21
+ if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext))
22
+ return "js";
23
+ return null;
24
+ }
25
+ /** Syntax gate: does the output still parse? In-process (no tsc subprocess). */
26
+ export function parseGate(source, lang) {
27
+ const result = ts.transpileModule(source, {
28
+ compilerOptions: {
29
+ target: ts.ScriptTarget.ES2022,
30
+ module: ts.ModuleKind.ESNext,
31
+ allowJs: true,
32
+ jsx: ts.JsxEmit.Preserve,
33
+ },
34
+ reportDiagnostics: true,
35
+ });
36
+ const errors = (result.diagnostics ?? [])
37
+ .filter((d) => d.category === ts.DiagnosticCategory.Error)
38
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
39
+ return { ok: errors.length === 0, errors };
40
+ }
41
+ const SYSTEM = "You are a careful code-modernization engineer. You rewrite legacy JavaScript/TypeScript into modern idiomatic form while preserving behavior exactly. You never change logic, control flow, or behavior — only syntax and idioms.";
42
+ function buildPrompt(source, lang) {
43
+ const language = lang === "ts" ? "TypeScript" : "JavaScript";
44
+ return [
45
+ `Rewrite the following ${language} file into modern idiomatic form:`,
46
+ `- prefer const/let over var`,
47
+ `- prefer async/await over callbacks and promise .then chains`,
48
+ `- prefer arrow functions, optional chaining, and nullish coalescing where they do not change behavior`,
49
+ `- do NOT change any logic, control flow, or behavior — only modernize syntax and idioms`,
50
+ `- do NOT add imports; only remove an import if it is genuinely unused`,
51
+ ``,
52
+ `Return ONLY a JSON object, no markdown fences, no prose. Shape:`,
53
+ `{ "modernized": "<the full modernized file content>", "changes": ["short human-readable change", "..."] }`,
54
+ ``,
55
+ `--- file (${language}) ---`,
56
+ source,
57
+ `--- end file ---`,
58
+ ].join("\n");
59
+ }
60
+ function parseReply(raw) {
61
+ let text = raw.trim();
62
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
63
+ if (fence)
64
+ text = fence[1].trim();
65
+ const start = text.indexOf("{");
66
+ const end = text.lastIndexOf("}");
67
+ if (start >= 0 && end > start)
68
+ text = text.slice(start, end + 1);
69
+ const data = JSON.parse(text);
70
+ const modernized = typeof data.modernized === "string" ? data.modernized : "";
71
+ const changes = Array.isArray(data.changes)
72
+ ? data.changes.filter((c) => typeof c === "string").slice(0, 20)
73
+ : [];
74
+ return { modernized, changes };
75
+ }
76
+ export async function modernizeFile(repoRoot, filePath) {
77
+ const lang = detectLang(filePath);
78
+ if (!lang) {
79
+ return { ok: false, original: "", changes: [], error: `unsupported file type (only JS/TS): ${filePath}` };
80
+ }
81
+ const abs = path.resolve(repoRoot, filePath);
82
+ let original;
83
+ try {
84
+ original = fs.readFileSync(abs, "utf-8");
85
+ }
86
+ catch (e) {
87
+ return { ok: false, original: "", changes: [], error: `cannot read ${filePath}: ${e.message}` };
88
+ }
89
+ const key = process.env.ANTHROPIC_API_KEY;
90
+ if (!key) {
91
+ return { ok: false, original, changes: [], lang, error: "ANTHROPIC_API_KEY is not set" };
92
+ }
93
+ let reply;
94
+ try {
95
+ const res = await fetch(API_URL, {
96
+ method: "POST",
97
+ headers: {
98
+ "content-type": "application/json",
99
+ "x-api-key": key,
100
+ "anthropic-version": "2023-06-01",
101
+ },
102
+ body: JSON.stringify({
103
+ model: MODEL,
104
+ max_tokens: 8192,
105
+ temperature: 0,
106
+ system: SYSTEM,
107
+ messages: [{ role: "user", content: buildPrompt(original, lang) }],
108
+ }),
109
+ });
110
+ if (!res.ok) {
111
+ const body = await res.text().catch(() => "");
112
+ return { ok: false, original, changes: [], lang, error: `LLM request failed (${res.status}): ${body.slice(0, 300)}` };
113
+ }
114
+ const data = (await res.json());
115
+ reply = (data.content ?? [])
116
+ .filter((c) => c.type === "text")
117
+ .map((c) => c.text ?? "")
118
+ .join("\n");
119
+ }
120
+ catch (e) {
121
+ return { ok: false, original, changes: [], lang, error: `LLM request failed: ${e.message}` };
122
+ }
123
+ let parsed;
124
+ try {
125
+ parsed = parseReply(reply);
126
+ }
127
+ catch {
128
+ return { ok: false, original, changes: [], lang, error: "could not parse the model reply" };
129
+ }
130
+ if (!parsed.modernized.trim()) {
131
+ return { ok: false, original, changes: [], lang, error: "model returned an empty file" };
132
+ }
133
+ const gate = parseGate(parsed.modernized, lang);
134
+ if (!gate.ok) {
135
+ return {
136
+ ok: false,
137
+ original,
138
+ changes: parsed.changes,
139
+ lang,
140
+ error: `modernized output does not parse: ${gate.errors[0] ?? "syntax error"}`,
141
+ };
142
+ }
143
+ return { ok: true, original, modernized: parsed.modernized, changes: parsed.changes, lang };
144
+ }
@@ -1,14 +1,9 @@
1
1
  import * as path from "path";
2
- import { chromium } from "playwright";
3
2
  import pc from "picocolors";
4
3
  import { EventBus } from "./eventBus.js";
5
- import { attachInterceptor } from "./interceptor.js";
6
- import { SignalClassifier, loadBaseline } from "./classifier.js";
7
- import { ActionRecorder } from "./recorder.js";
8
- import { walkDom } from "./domWalker.js";
9
- import { fuzz } from "./fuzzer.js";
4
+ import { loadBaseline } from "./classifier.js";
10
5
  import { attachNetworkGuard, allowHostsFrom } from "./networkGuard.js";
11
- import { resolveFrame } from "./resolver.js";
6
+ import { swarmDetect } from "./swarm.js";
12
7
  import { ReplayEngine } from "./replay.js";
13
8
  import { minimize } from "./minimizer.js";
14
9
  import { writeSpec } from "./specCompiler.js";
@@ -30,14 +25,21 @@ function printFinding(f, write) {
30
25
  write(pc.dim(` ${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}`));
31
26
  write(pc.dim(f.mappedLocation.codeContext));
32
27
  }
28
+ if (f.serverError) {
29
+ write(pc.dim(` server: ${f.serverError.message}`));
30
+ }
33
31
  if (f.occurrences > 1)
34
32
  write(pc.dim(` (×${f.occurrences})`));
35
33
  write("");
36
34
  }
37
35
  /** Human-readable run mode, surfaced in the cloud dashboard. */
38
36
  function runMode(o) {
37
+ if ((o.workers ?? 1) > 1)
38
+ return `swarm (${o.workers} workers)`;
39
39
  if (o.fuzz)
40
40
  return `fuzz (seed ${o.seed ?? 42})`;
41
+ if (o.httpFuzz)
42
+ return "http fuzz";
41
43
  if (o.heal)
42
44
  return "repro → heal";
43
45
  if (o.repro)
@@ -55,7 +57,7 @@ function runMode(o) {
55
57
  export async function run(options) {
56
58
  const { url, repoRoot } = options;
57
59
  const maxActions = options.maxActions ?? 100;
58
- const guardOn = Boolean(options.fuzz || options.repro);
60
+ const guardOn = Boolean(options.fuzz || options.repro || (options.workers ?? 1) > 1);
59
61
  const allowHosts = allowHostsFrom(url, options.allowHosts ?? []);
60
62
  const ui = options.ui === true;
61
63
  const bus = options.bus ?? new EventBus();
@@ -64,7 +66,7 @@ export async function run(options) {
64
66
  console.log(parts.join(" "));
65
67
  };
66
68
  const emitPhase = (phase, detail) => bus.emit("phase", { phase, detail, ts: Date.now() });
67
- say(pc.cyan("\n⚡ Aztrx v0.1.0 — Runtime Detector"));
69
+ say(pc.cyan("\nAztrx AI v0.1.1 — Runtime Detector"));
68
70
  say(pc.dim(`Target: ${url}`));
69
71
  say(pc.dim(`Repo: ${repoRoot}`));
70
72
  if (options.fuzz)
@@ -77,86 +79,64 @@ export async function run(options) {
77
79
  say(pc.dim(`Auth: ${options.storageState}`));
78
80
  say("");
79
81
  emitPhase("launch", url);
80
- const classifier = new SignalClassifier(await loadBaseline(repoRoot));
81
- const recorder = new ActionRecorder();
82
82
  const runLog = new RunLog(repoRoot);
83
83
  runLog.reset();
84
84
  runLog.append({ type: "run_start", url, ts: Date.now() });
85
- bus.on("action", (a) => recorder.record(a));
86
- bus.on("telemetry", async (payload) => {
87
- const finding = classifier.classify(payload);
88
- if (!finding)
89
- return;
90
- finding.actionHistory = recorder.snapshot();
91
- if (finding.severity === "noise") {
92
- bus.emit("noise", { ts: Date.now() });
93
- return;
94
- }
95
- runLog.append({ type: "finding", finding });
96
- if (payload.url && payload.line) {
97
- const resolved = await resolveFrame({ url: payload.url, line: payload.line, column: payload.column ?? 0, message: payload.rawMessage }, repoRoot);
98
- finding.mappedLocation = {
99
- filePath: resolved.sourceFile,
100
- line: resolved.line,
101
- column: resolved.column,
102
- codeContext: resolved.codeSnippet,
103
- isOwnCode: resolved.resolvedFrom !== "unresolved",
104
- };
105
- }
106
- bus.emit("finding", finding);
107
- printFinding(finding, say);
108
- });
109
- const browser = await chromium.launch({ headless: true });
110
- const context = await browser.newContext(options.storageState ? { storageState: options.storageState } : {});
111
- const page = await context.newPage();
112
- attachInterceptor(page, bus);
113
- if (guardOn) {
114
- await attachNetworkGuard(page, {
115
- allowHosts,
116
- onBlock: (u) => say(pc.dim(` [guard] blocked ${u}`)),
117
- });
85
+ const baseline = await loadBaseline(repoRoot);
86
+ const workers = options.workers ?? 1;
87
+ // F-swarm parallel detection. One worker is the legacy pass; `--http-fuzz`
88
+ // or `workers > 1` fan out into a swarm. Findings come back merged by
89
+ // fingerprint, with per-worker action history already attached.
90
+ if (workers > 1 || options.httpFuzz) {
91
+ emitPhase("swarm", `${workers} worker(s)`);
118
92
  }
119
- page.on("framenavigated", (frame) => {
120
- if (frame === page.mainFrame())
121
- bus.emit("route", { url: frame.url(), ts: Date.now() });
122
- });
123
- let loaded = true;
124
- await page.goto(url, { waitUntil: "load", timeout: 30000 }).catch((e) => {
125
- loaded = false;
126
- say(pc.red("Failed to load target: ") + pc.dim(e.message));
93
+ else if (options.fuzz) {
94
+ emitPhase("fuzz");
95
+ }
96
+ else {
97
+ emitPhase("walk");
98
+ }
99
+ const { findings, replayStorageState: swarmAuthState, totalActions, workerCount, } = await swarmDetect({
100
+ url,
101
+ repoRoot,
102
+ maxActions,
103
+ dryRun: options.dryRun,
104
+ fuzz: options.fuzz,
105
+ httpFuzz: options.httpFuzz,
106
+ httpFuzzMutations: options.httpFuzzMutations,
107
+ seed: options.seed ?? 42,
108
+ workers,
109
+ allowHosts,
110
+ storageState: options.storageState,
111
+ login: options.login,
112
+ loginEmail: options.loginEmail,
113
+ loginPassword: options.loginPassword,
114
+ loginUrl: options.loginUrl,
115
+ crashTest: options.crashTest,
116
+ baseline,
117
+ guardOn,
118
+ log: say,
127
119
  });
128
- if (loaded) {
129
- // Settle: wait for hydration and mount-time effects (async fetches,
130
- // unhandled rejections, React warnings) to fire before we act. A page whose
131
- // only bugs are mount-time would otherwise be closed before they happen —
132
- // and a page with no interactive elements fuzzes zero actions, so it relies
133
- // on this window.
134
- await page.waitForTimeout(2000);
120
+ // Replays reuse the swarm-captured auth state, or the explicit --storage-state.
121
+ const replayStorageState = swarmAuthState ?? options.storageState;
122
+ // Surface the merged findings to the live panel, run log, and console.
123
+ for (const f of findings) {
124
+ bus.emit("finding", f);
125
+ runLog.append({ type: "finding", finding: f });
126
+ printFinding(f, say);
135
127
  }
136
- if (loaded && options.crashTest) {
137
- await page.evaluate(() => {
138
- setTimeout(() => {
139
- throw new Error("Aztrx test: Cannot read properties of undefined (reading 'token')");
140
- }, 300);
141
- });
142
- await page.waitForTimeout(800);
128
+ if (workerCount > 1) {
129
+ say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s).\n`));
143
130
  }
144
- if (loaded) {
145
- emitPhase(options.fuzz ? "fuzz" : "walk");
146
- const acted = options.fuzz
147
- ? await fuzz(page, bus, { seed: options.seed, maxActions, dryRun: options.dryRun })
148
- : await walkDom(page, bus, { maxActions, dryRun: options.dryRun });
149
- say(pc.dim(`\n${options.fuzz ? "Fuzzed" : "Walked"} ${acted} action(s).\n`));
131
+ else {
132
+ say(pc.dim(`\n${options.fuzz ? "Fuzzed" : "Walked"} ${totalActions} action(s).\n`));
150
133
  }
151
- await page.waitForTimeout(500);
152
- await browser.close();
153
- const findings = classifier.findings();
154
134
  // F7 → F8 → F9: minimize each finding, compile an executable spec, validate
155
135
  // the flake rate. Only crash/error findings with a recorded action history.
156
136
  if (options.repro) {
157
137
  const engine = new ReplayEngine({
158
138
  attachGuard: async (p) => attachNetworkGuard(p, { allowHosts }),
159
- storageState: options.storageState,
139
+ storageState: replayStorageState,
160
140
  });
161
141
  try {
162
142
  const targets = findings.filter((f) => (f.severity === "crash" || f.severity === "error") && f.actionHistory.length > 0);
@@ -240,11 +220,16 @@ export async function run(options) {
240
220
  // the patch is only ever handed to a human for review, never committed.
241
221
  if (options.heal) {
242
222
  const healTargets = findings.filter((f) => (f.severity === "crash" || f.severity === "error") &&
223
+ // A `network_5xx` finding heals only when its 500 body leaked a server
224
+ // stack (→ an own-code source location); heal then boots the patched app
225
+ // to verify. `network_timeout` stays excluded — it needs a time-based
226
+ // repro first.
227
+ f.type !== "network_timeout" &&
243
228
  f.mappedLocation?.isOwnCode &&
244
229
  f.repro &&
245
230
  f.repro.verdict !== "unreliable");
246
231
  if (healTargets.length) {
247
- say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → verify) —"));
232
+ say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → test → verify) —"));
248
233
  emitPhase("heal");
249
234
  for (const f of healTargets) {
250
235
  say(pc.dim(` healing: ${f.rawMessage.split("\n")[0].slice(0, 60)}`));
@@ -257,6 +242,10 @@ export async function run(options) {
257
242
  allowHosts: [...allowHosts],
258
243
  model: options.healModel,
259
244
  fastModel: options.healFastModel,
245
+ testCommand: options.testCommand,
246
+ testTimeoutMs: options.testTimeoutMs,
247
+ skipTest: options.skipTest,
248
+ startCommand: options.startCommand,
260
249
  });
261
250
  f.heal = result;
262
251
  bus.emit("heal", {
package/dist/core/pr.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ import { sanitizeSecrets } from "./heal/redact.js";
3
4
  /**
4
5
  * F-report — the PR bot's markdown comment. Same findings as the HTML report,
5
6
  * reshaped for a GitHub PR: a status badge, one `<details>` per finding, the
@@ -14,12 +15,6 @@ const SEV_BADGE = {
14
15
  warning: "f5a623",
15
16
  noise: "5b6573",
16
17
  };
17
- const SEV_ICON = {
18
- crash: "💥",
19
- error: "🚨",
20
- warning: "⚠️",
21
- noise: "·",
22
- };
23
18
  /** Shields.io badge-path escaping: literal `-` → `--`, `/` → `%2F`, space → `_`. */
24
19
  function shield(s) {
25
20
  return s.replace(/-/g, "--").replace(/\//g, "%2F").replace(/ /g, "_");
@@ -30,6 +25,28 @@ function badge(label, value, color) {
30
25
  function escapeHtml(s) {
31
26
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
32
27
  }
28
+ /** Wrap `code` in a backtick fence one longer than any run inside it, so the
29
+ * content can never close the fence (blocks markdown breakout from a hostile
30
+ * sourcemap snippet or patch). */
31
+ function fence(code, lang = "") {
32
+ const runs = code.match(/`+/g) ?? [];
33
+ const maxRun = runs.reduce((m, r) => Math.max(m, r.length), 0);
34
+ const delim = "`".repeat(Math.max(3, maxRun + 1));
35
+ return `${delim}${lang}\n${code.trimEnd()}\n${delim}`;
36
+ }
37
+ /** Inline code that can't be broken out of — backticks/newlines are stripped. */
38
+ function inlineCode(s) {
39
+ return "`" + s.replace(/`/g, "").replace(/[\r\n]/g, " ") + "`";
40
+ }
41
+ /** Scrub secrets and strip markdown-breakout chars for anything inlined into a
42
+ * heading or inline-code span. The PR comment renders untrusted stack/snippet
43
+ * text — a secret in it (or a stray backtick) must never leak or break out. */
44
+ function cleanInline(s) {
45
+ return sanitizeSecrets(s).replace(/`/g, "").replace(/[\r\n]/g, " ");
46
+ }
47
+ /** Hard cap on the server body inlined into a PR comment — a 500 page can be
48
+ * huge, and only the first line or two are ever diagnostic. */
49
+ const SERVER_BODY_CAP = 2000;
33
50
  function readIfExists(p) {
34
51
  try {
35
52
  return fs.readFileSync(p, "utf-8");
@@ -39,8 +56,8 @@ function readIfExists(p) {
39
56
  }
40
57
  }
41
58
  function describeAction(a) {
42
- const sel = a.selectors[0] ? ` \`${a.selectors[0]}\`` : "";
43
- const val = a.value ? ` \`${a.value}\`` : "";
59
+ const sel = a.selectors[0] ? ` ${inlineCode(sanitizeSecrets(a.selectors[0]))}` : "";
60
+ const val = a.value ? ` ${inlineCode(sanitizeSecrets(a.value))}` : "";
44
61
  return `**${a.type}**${val}${sel}`;
45
62
  }
46
63
  function healBlock(f) {
@@ -52,6 +69,7 @@ function healBlock(f) {
52
69
  unfixed: { text: "unfixed", color: "f5a623" },
53
70
  rejected: { text: "rejected", color: "ff5a5f" },
54
71
  "compile-failed": { text: "compile-failed", color: "ff5a5f" },
72
+ "test-failed": { text: "test-failed", color: "ff5a5f" },
55
73
  "apply-failed": { text: "apply-failed", color: "ff5a5f" },
56
74
  skipped: { text: "skipped", color: "5b6573" },
57
75
  "no-llm": { text: "no-llm", color: "5b6573" },
@@ -63,13 +81,14 @@ function healBlock(f) {
63
81
  // The saved unified diff is only produced for a patch that reached verify.
64
82
  const diff = h.patchPath ? readIfExists(path.resolve(h.patchPath)) : null;
65
83
  if (diff) {
66
- body.push("```diff");
67
- body.push(diff.trimEnd());
68
- body.push("```");
84
+ body.push(fence(diff, "diff"));
69
85
  }
70
86
  else if (h.explanation) {
71
87
  body.push(`> ${h.explanation}`);
72
88
  }
89
+ if (h.test?.ran) {
90
+ body.push(`**tests** ${inlineCode(h.test.command)} — ${h.test.ok ? "passed" : "failed"}`);
91
+ }
73
92
  if (h.error)
74
93
  body.push(`\n_${escapeHtml(h.error)}_`);
75
94
  return `\n<details>\n<summary>${badge("heal", meta.text, meta.color)} proposed patch${via}${tiers}</summary>\n\n${body.join("\n")}\n</details>`;
@@ -83,20 +102,25 @@ function reproBlock(f) {
83
102
  ? `\n${r.actions.map((a, i) => `${i + 1}. ${describeAction(a)}`).join("\n")}`
84
103
  : "";
85
104
  const spec = readIfExists(path.resolve(r.specPath));
86
- const specBlock = spec ? `\n\`\`\`ts\n${spec.trimEnd()}\n\`\`\`` : "";
105
+ const specBlock = spec ? `\n${fence(spec, "ts")}` : "";
87
106
  return `\n<details>\n<summary>▶ ${verdict} · ${r.actions.length} step(s)</summary>\n\n**Reproduce**${steps}${specBlock}\n</details>`;
88
107
  }
89
108
  function findingBlock(f) {
90
109
  const sev = f.severity;
91
- const icon = SEV_ICON[sev] ?? "·";
92
- const first = f.rawMessage.split("\n")[0];
110
+ const first = sanitizeSecrets(f.rawMessage.split("\n")[0]);
93
111
  const loc = f.mappedLocation
94
- ? `\n**Location** \`${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}\``
112
+ ? `\n**Location** \`${cleanInline(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}\``
95
113
  : "";
96
114
  const snippet = f.mappedLocation?.codeContext
97
- ? `\n\n\`\`\`${path.extname(f.mappedLocation.filePath).replace(".", "") || "ts"}\n${f.mappedLocation.codeContext.trimEnd()}\n\`\`\``
115
+ ? `\n\n${fence(sanitizeSecrets(f.mappedLocation.codeContext), path.extname(f.mappedLocation.filePath).replace(".", "") || "ts")}`
116
+ : "";
117
+ const serverErr = f.serverError
118
+ ? `\n**Server** ${escapeHtml(sanitizeSecrets(f.serverError.message))}` +
119
+ (f.serverError.body
120
+ ? `\n\n${fence(sanitizeSecrets(f.serverError.body.slice(0, SERVER_BODY_CAP)), "text")}`
121
+ : "")
98
122
  : "";
99
- return `<details open>\n<summary>${icon} <code>${escapeHtml(sev)}</code> — ${escapeHtml(first)}</summary>\n${loc}${snippet}${reproBlock(f)}${healBlock(f)}\n</details>`;
123
+ return `<details open>\n<summary><code>${escapeHtml(sev)}</code> — ${escapeHtml(first)}</summary>\n${loc}${snippet}${serverErr}${reproBlock(f)}${healBlock(f)}\n</details>`;
100
124
  }
101
125
  export function renderPrComment(targetUrl, findings, opts = {}) {
102
126
  void opts;
@@ -120,13 +144,13 @@ export function renderPrComment(targetUrl, findings, opts = {}) {
120
144
  .join(" ");
121
145
  const body = sorted.length
122
146
  ? sorted.map(findingBlock).join("\n\n")
123
- : "> No crash, error, or warning surfaced — the app survived this pass.";
147
+ : "> No crash, error, or warning surfaced — the app survived this pass.";
124
148
  return `<!-- aztrx -->
125
- ## Aztrx — runtime stress-test
149
+ ## Aztrx AI — runtime stress-test
126
150
 
127
151
  ${summaryBadges}
128
152
 
129
- **Target** \`${targetUrl}\` · **${counts.crash ?? 0} crash** · **${counts.error ?? 0} error** · **${counts.warning ?? 0} warning**
153
+ **Target** \`${cleanInline(targetUrl)}\` · **${counts.crash ?? 0} crash** · **${counts.error ?? 0} error** · **${counts.warning ?? 0} warning**
130
154
 
131
155
  ---
132
156
 
@@ -0,0 +1,22 @@
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
+ import * as readline from "readline";
10
+ export function promptYesNo(question, opts = {}) {
11
+ if (opts.yes === true)
12
+ return Promise.resolve(true);
13
+ if (process.stdout.isTTY !== true)
14
+ return Promise.resolve(false);
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
+ return new Promise((resolve) => {
17
+ rl.question(question + " ", (answer) => {
18
+ rl.close();
19
+ resolve(/^y(es)?$/i.test(answer.trim()));
20
+ });
21
+ });
22
+ }
@@ -26,6 +26,22 @@ export async function replayActions(page, actions) {
26
26
  await page.waitForTimeout(30);
27
27
  continue;
28
28
  }
29
+ if (a.type === "request" && a.request) {
30
+ // Issue the request in-page so the attached interceptor sees the response
31
+ // and emits `network_5xx` — that is how a server finding reproduces here.
32
+ await page
33
+ .evaluate(async (r) => {
34
+ try {
35
+ await fetch(r.url, { method: r.method, headers: r.headers, body: r.body });
36
+ }
37
+ catch {
38
+ // ignore — the 5xx (or its absence) is observed by the interceptor
39
+ }
40
+ }, a.request)
41
+ .catch(() => { });
42
+ await page.waitForTimeout(50);
43
+ continue;
44
+ }
29
45
  for (const sel of a.selectors) {
30
46
  const loc = page.locator(sel).first();
31
47
  const n = await loc.count().catch(() => 0);
@@ -66,7 +82,7 @@ export class ReplayEngine {
66
82
  this.browser = await chromium.launch({ headless: true });
67
83
  return this.browser;
68
84
  }
69
- async run(url, actions, targetFingerprint) {
85
+ async run(url, actions, targetFingerprint, opts) {
70
86
  // The browser is reused across replays for speed, but after enough page
71
87
  // loads a renderer can crash. Relaunch once and retry so a single crash
72
88
  // doesn't take down the whole repro pipeline.
@@ -80,7 +96,18 @@ export class ReplayEngine {
80
96
  page = await context.newPage();
81
97
  const bus = new EventBus();
82
98
  const fingerprints = new Set();
83
- bus.on("telemetry", (p) => fingerprints.add(fingerprintOf(p)));
99
+ const types = new Set();
100
+ // For type-based verification (server findings), ignore telemetry from the
101
+ // initial load + settle window — only the replayed requests count. Client
102
+ // verification stays fingerprint-exact and keeps collecting from page
103
+ // attach, so a mount-time client bug still verifies.
104
+ let collecting = !opts?.targetType;
105
+ bus.on("telemetry", (p) => {
106
+ if (!collecting)
107
+ return;
108
+ fingerprints.add(fingerprintOf(p));
109
+ types.add(p.type);
110
+ });
84
111
  attachInterceptor(page, bus);
85
112
  if (this.opts.attachGuard)
86
113
  await this.opts.attachGuard(page);
@@ -89,9 +116,14 @@ export class ReplayEngine {
89
116
  // `load` event plus a settle window, and a replay that clicks before React
90
117
  // attaches its handlers won't reproduce the crash (false "unreliable").
91
118
  await page.waitForTimeout(2000);
119
+ if (opts?.targetType)
120
+ collecting = true;
92
121
  await replayActions(page, actions);
93
122
  await page.waitForTimeout(300);
94
- return { reproduced: fingerprints.has(targetFingerprint) };
123
+ const reproduced = opts?.targetType
124
+ ? types.has(opts.targetType)
125
+ : fingerprints.has(targetFingerprint);
126
+ return { reproduced };
95
127
  }
96
128
  catch (e) {
97
129
  lastError = e;
@@ -1,9 +1,15 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { BASE_CSS, SEVERITY_COLOR, seismograph } from "./ui.js";
4
+ import { sanitizeSecrets } from "./heal/redact.js";
4
5
  function escapeHtml(s) {
5
6
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6
7
  }
8
+ /** HTML-escape AND scrub secrets — the report renders untrusted stack/snippet
9
+ * text, so a secret that survives into it must not reach the file. */
10
+ function clean(s) {
11
+ return escapeHtml(sanitizeSecrets(s));
12
+ }
7
13
  const SEV_ORDER = ["crash", "error", "warning", "noise"];
8
14
  /**
9
15
  * F-report — standalone offline HTML report. Self-contained (inline CSS, no
@@ -19,17 +25,23 @@ export function renderReport(targetUrl, findings) {
19
25
  const cards = sorted
20
26
  .map((f) => {
21
27
  const loc = f.mappedLocation
22
- ? `${escapeHtml(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}`
28
+ ? `${clean(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}`
29
+ : "";
30
+ const snippet = f.mappedLocation ? clean(f.mappedLocation.codeContext) : "";
31
+ const serverErr = f.serverError
32
+ ? `<div class="server">server: ${clean(f.serverError.message)}</div>` +
33
+ (f.serverError.body
34
+ ? `<pre class="server-body">${clean(f.serverError.body)}</pre>`
35
+ : "")
23
36
  : "";
24
- const snippet = f.mappedLocation ? escapeHtml(f.mappedLocation.codeContext) : "";
25
37
  const repro = f.repro
26
38
  ? `<div class="repro ${f.repro.verdict}">${f.repro.verdict} · ${f.repro.reproductions}/${f.repro.runs} runs · ${f.repro.actions.length} step(s) · <code>${escapeHtml(path.basename(f.repro.specPath))}</code></div>`
27
39
  : "";
28
40
  const steps = f.actionHistory.length
29
41
  ? `<details><summary>action history (${f.actionHistory.length})</summary><ol>${f.actionHistory
30
42
  .map((a) => {
31
- const detail = a.value ? ` <span>${escapeHtml(a.value)}</span>` : "";
32
- const sel = a.selectors[0] ? ` ${escapeHtml(a.selectors[0])}` : "";
43
+ const detail = a.value ? ` <span>${clean(a.value)}</span>` : "";
44
+ const sel = a.selectors[0] ? ` ${clean(a.selectors[0])}` : "";
33
45
  return `<li><code>${escapeHtml(a.type)}${detail}</code>${sel}</li>`;
34
46
  })
35
47
  .join("")}</ol></details>`
@@ -38,10 +50,11 @@ export function renderReport(targetUrl, findings) {
38
50
  <article class="finding">
39
51
  <header>
40
52
  <span class="sev" style="--sev:${SEVERITY_COLOR[f.severity]}">${escapeHtml(f.severity)}</span>
41
- <h2>${escapeHtml(f.rawMessage.split("\n")[0])}</h2>
53
+ <h2>${clean(f.rawMessage.split("\n")[0])}</h2>
42
54
  </header>
43
55
  ${loc ? `<div class="loc">${loc}</div>` : ""}
44
56
  ${snippet ? `<pre class="snippet">${snippet}</pre>` : ""}
57
+ ${serverErr}
45
58
  ${f.occurrences > 1 ? `<div class="occ">seen ×${f.occurrences}</div>` : ""}
46
59
  ${repro}
47
60
  ${steps}
@@ -63,7 +76,7 @@ export function renderReport(targetUrl, findings) {
63
76
  <div class="brand-row">
64
77
  <h1><span class="brand">aztrx</span> <span class="brand-sub">report</span></h1>
65
78
  </div>
66
- <div class="target">${escapeHtml(targetUrl)}</div>
79
+ <div class="target">${clean(targetUrl)}</div>
67
80
  </div>
68
81
  <div class="bar">
69
82
  <span class="count">crash <b class="crash">${counts.crash ?? 0}</b></span>