aztrx-cli 0.4.3 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/cli.js +61 -1
- package/dist/core/classifier.js +92 -2
- package/dist/core/diagnose.js +91 -0
- package/dist/core/diff.js +137 -0
- package/dist/core/domWalker.js +1 -1
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/index.js +7 -5
- package/dist/core/heal/llm.js +16 -0
- package/dist/core/heal/redact.js +5 -1
- package/dist/core/heal/sandbox.js +5 -1
- package/dist/core/httpFuzzer.js +8 -4
- package/dist/core/orchestrator.js +33 -11
- package/dist/core/patrol/loop.js +218 -0
- package/dist/core/patrol/pr.js +233 -0
- package/dist/core/patrol/record.js +103 -0
- package/dist/core/patrol/state.js +78 -0
- package/dist/core/renderMarkdown.js +92 -0
- package/dist/core/report.js +6 -3
- package/dist/core/resolver.js +86 -25
- package/dist/core/summarize.js +2 -2
- package/dist/core/swarm.js +36 -16
- package/dist/core/ui.js +1 -0
- package/dist/ui/app.js +27 -2
- package/media/repro-demo/crash.gif +0 -0
- package/media/repro-demo/crash.png +0 -0
- package/media/repro-demo/frame_0.png +0 -0
- package/media/repro-demo/frame_1.png +0 -0
- package/package.json +5 -1
|
@@ -0,0 +1,103 @@
|
|
|
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 { createRequire } from "node:module";
|
|
14
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { chromium } from "playwright";
|
|
17
|
+
import { PNG } from "pngjs";
|
|
18
|
+
import { replayActions } from "../replay.js";
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { GIFEncoder, quantize, applyPalette } = require("gifenc");
|
|
21
|
+
/**
|
|
22
|
+
* Captures one PNG screenshot before the replay and one after each action, so
|
|
23
|
+
* the crash (or its absence) is visible in the sequence. Replays `replayActions`
|
|
24
|
+
* one action at a time — reusing the exact detection semantics, not a parallel
|
|
25
|
+
* reimplementation that could drift.
|
|
26
|
+
*/
|
|
27
|
+
export async function captureReproFrames(url, actions, opts = {}) {
|
|
28
|
+
const browser = await chromium.launch({ headless: true });
|
|
29
|
+
try {
|
|
30
|
+
const page = await browser.newPage({
|
|
31
|
+
viewport: opts.viewport ?? { width: 720, height: 430 },
|
|
32
|
+
});
|
|
33
|
+
await page.goto(url, { waitUntil: "load", timeout: 30000 }).catch(() => { });
|
|
34
|
+
await page.waitForTimeout(opts.settleMs ?? 1200);
|
|
35
|
+
const frames = [await page.screenshot()];
|
|
36
|
+
for (const action of actions) {
|
|
37
|
+
await replayActions(page, [action]);
|
|
38
|
+
await page.waitForTimeout(opts.stepMs ?? 300);
|
|
39
|
+
frames.push(await page.screenshot());
|
|
40
|
+
}
|
|
41
|
+
return frames;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await browser.close().catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Encodes a sequence of PNG frames into an animated GIF (pure JS, no ffmpeg). */
|
|
48
|
+
export function encodeGif(frames, opts = {}) {
|
|
49
|
+
if (frames.length === 0)
|
|
50
|
+
return Buffer.alloc(0);
|
|
51
|
+
const delay = opts.delay ?? 400;
|
|
52
|
+
const maxColors = opts.maxColors ?? 256;
|
|
53
|
+
const decoded = frames.map((f) => PNG.sync.read(f));
|
|
54
|
+
const { width, height } = decoded[0];
|
|
55
|
+
// One global palette across every frame → compact file, no per-frame flicker.
|
|
56
|
+
const combined = new Uint8Array(decoded.reduce((n, d) => n + d.data.length, 0));
|
|
57
|
+
let off = 0;
|
|
58
|
+
for (const d of decoded) {
|
|
59
|
+
combined.set(d.data, off);
|
|
60
|
+
off += d.data.length;
|
|
61
|
+
}
|
|
62
|
+
const palette = quantize(combined, maxColors);
|
|
63
|
+
const indexes = decoded.map((d) => applyPalette(d.data, palette));
|
|
64
|
+
const gif = GIFEncoder();
|
|
65
|
+
indexes.forEach((index, i) => {
|
|
66
|
+
gif.writeFrame(index, width, height, {
|
|
67
|
+
palette: i === 0 ? palette : undefined,
|
|
68
|
+
delay,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
gif.finish();
|
|
72
|
+
return Buffer.from(gif.bytes());
|
|
73
|
+
}
|
|
74
|
+
/** The static "crash shot": the final frame, when the crash has fully rendered. */
|
|
75
|
+
export function crashFrame(frames) {
|
|
76
|
+
return frames[frames.length - 1];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Produces the recorded-repro GIF for a finding and writes it to
|
|
80
|
+
* `aztrx-media/<fp8>.gif`. That dir is deliberate: `media/` is in the npm
|
|
81
|
+
* `files` allowlist (so demo.gif/logo ship), but patrol GIFs must not publish —
|
|
82
|
+
* they're per-run artifacts. The path is committed to the PR branch so the PR
|
|
83
|
+
* body can inline it via a raw URL.
|
|
84
|
+
*
|
|
85
|
+
* Returns the repo-relative path, or null when there is nothing to record (no
|
|
86
|
+
* repro, or capture/encode failed) — the caller opens the PR regardless.
|
|
87
|
+
*/
|
|
88
|
+
export async function recordFindingGif(repoRoot, url, finding) {
|
|
89
|
+
// Skip `navigate` actions — captureReproFrames navigates itself, so replaying a
|
|
90
|
+
// leading navigate would just add a duplicate frame of the same page.
|
|
91
|
+
const actions = (finding.repro?.actions ?? []).filter((a) => a.type !== "navigate");
|
|
92
|
+
if (actions.length === 0)
|
|
93
|
+
return null;
|
|
94
|
+
const frames = await captureReproFrames(url, actions);
|
|
95
|
+
if (frames.length < 2)
|
|
96
|
+
return null; // a single frame isn't an animation
|
|
97
|
+
const gif = encodeGif(frames, { delay: 700 });
|
|
98
|
+
const rel = path.join("aztrx-media", `${finding.fingerprint.slice(0, 8)}.gif`);
|
|
99
|
+
const abs = path.resolve(repoRoot, rel);
|
|
100
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
101
|
+
writeFileSync(abs, gif);
|
|
102
|
+
return rel;
|
|
103
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
export class PatrolState {
|
|
13
|
+
file;
|
|
14
|
+
cooldownMs;
|
|
15
|
+
data;
|
|
16
|
+
constructor(repoRoot, url, cooldownMs = 30 * 60 * 1000) {
|
|
17
|
+
const dir = path.join(repoRoot, ".aztrx");
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
this.file = path.join(dir, "patrol.json");
|
|
20
|
+
this.cooldownMs = cooldownMs;
|
|
21
|
+
this.data = this.read();
|
|
22
|
+
this.data.url = url;
|
|
23
|
+
}
|
|
24
|
+
read() {
|
|
25
|
+
try {
|
|
26
|
+
const raw = JSON.parse(fs.readFileSync(this.file, "utf-8"));
|
|
27
|
+
return { url: raw.url ?? "", fingerprints: raw.fingerprints ?? {} };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { url: "", fingerprints: {} };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A fingerprint is "handled" while its PR is open, or while an `unfixed` mark
|
|
35
|
+
* is still within its cooldown. Once the cooldown lapses, an `unfixed` bug
|
|
36
|
+
* becomes retry-eligible again — it stops being "handled" and `run()` heals it
|
|
37
|
+
* afresh rather than skipping it forever.
|
|
38
|
+
*/
|
|
39
|
+
isHandled(fp, now = Date.now()) {
|
|
40
|
+
const e = this.data.fingerprints[fp];
|
|
41
|
+
if (!e)
|
|
42
|
+
return false;
|
|
43
|
+
if (e.status === "pr-opened")
|
|
44
|
+
return true;
|
|
45
|
+
return now - Date.parse(e.lastSeen) < this.cooldownMs;
|
|
46
|
+
}
|
|
47
|
+
/** Every currently-handled fingerprint, so the supervisor can tell `run()` to skip healing them. */
|
|
48
|
+
handled(now = Date.now()) {
|
|
49
|
+
return Object.keys(this.data.fingerprints).filter((fp) => this.isHandled(fp, now));
|
|
50
|
+
}
|
|
51
|
+
markPr(fp, prUrl, branch) {
|
|
52
|
+
this.data.fingerprints[fp] = {
|
|
53
|
+
status: "pr-opened",
|
|
54
|
+
firstSeen: this.firstSeen(fp),
|
|
55
|
+
lastSeen: new Date().toISOString(),
|
|
56
|
+
prUrl,
|
|
57
|
+
branch,
|
|
58
|
+
attempts: this.attempts(fp) + 1,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
markUnfixed(fp) {
|
|
62
|
+
this.data.fingerprints[fp] = {
|
|
63
|
+
status: "unfixed",
|
|
64
|
+
firstSeen: this.firstSeen(fp),
|
|
65
|
+
lastSeen: new Date().toISOString(),
|
|
66
|
+
attempts: this.attempts(fp) + 1,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
firstSeen(fp) {
|
|
70
|
+
return this.data.fingerprints[fp]?.firstSeen ?? new Date().toISOString();
|
|
71
|
+
}
|
|
72
|
+
attempts(fp) {
|
|
73
|
+
return this.data.fingerprints[fp]?.attempts ?? 0;
|
|
74
|
+
}
|
|
75
|
+
save() {
|
|
76
|
+
fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2) + "\n", "utf-8");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
import pc from "picocolors";
|
|
8
|
+
const KEYWORDS = new Set([
|
|
9
|
+
"const", "let", "var", "function", "return", "if", "else", "for", "while", "do",
|
|
10
|
+
"new", "class", "extends", "super", "import", "export", "from", "async", "await",
|
|
11
|
+
"try", "catch", "finally", "throw", "switch", "case", "break", "continue",
|
|
12
|
+
"default", "typeof", "instanceof", "in", "of", "delete", "void", "this", "null",
|
|
13
|
+
"undefined", "true", "false", "static", "get", "set", "interface", "type",
|
|
14
|
+
"enum", "readonly", "as", "satisfies", "yield",
|
|
15
|
+
]);
|
|
16
|
+
/** Single-pass tokenizer. Classify by first char: `/` comment, quote/backtick
|
|
17
|
+
* string, digit number, letter keyword, else plain identifier. */
|
|
18
|
+
const TOKEN = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b[A-Za-z_$][\w$]*\b)/g;
|
|
19
|
+
function highlight(code) {
|
|
20
|
+
return code.replace(TOKEN, (raw) => {
|
|
21
|
+
const c = raw[0];
|
|
22
|
+
if (c === "/")
|
|
23
|
+
return pc.dim(pc.gray(raw));
|
|
24
|
+
if (c === "'" || c === '"' || c === "`")
|
|
25
|
+
return pc.green(raw);
|
|
26
|
+
if (c >= "0" && c <= "9")
|
|
27
|
+
return pc.yellow(raw);
|
|
28
|
+
if (KEYWORDS.has(raw))
|
|
29
|
+
return pc.magenta(raw);
|
|
30
|
+
return raw;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Inline formatting: `code`, **bold**. */
|
|
34
|
+
function renderInline(text) {
|
|
35
|
+
return text
|
|
36
|
+
.replace(/`([^`\n]+)`/g, (_, s) => pc.cyan(s))
|
|
37
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, s) => pc.bold(s))
|
|
38
|
+
.replace(/__([^_]+)__/g, (_, s) => pc.bold(s));
|
|
39
|
+
}
|
|
40
|
+
export function renderMarkdown(md) {
|
|
41
|
+
const lines = md.split("\n");
|
|
42
|
+
const out = [];
|
|
43
|
+
let inCode = false;
|
|
44
|
+
let code = [];
|
|
45
|
+
const flushCode = () => {
|
|
46
|
+
if (!code.length)
|
|
47
|
+
return;
|
|
48
|
+
for (const l of code)
|
|
49
|
+
out.push(" " + pc.dim("│") + " " + highlight(l));
|
|
50
|
+
out.push("");
|
|
51
|
+
code = [];
|
|
52
|
+
};
|
|
53
|
+
for (const raw of lines) {
|
|
54
|
+
if (raw.trim().startsWith("```")) {
|
|
55
|
+
if (inCode) {
|
|
56
|
+
flushCode();
|
|
57
|
+
inCode = false;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
inCode = true;
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (inCode) {
|
|
65
|
+
code.push(raw);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const heading = raw.match(/^(#{1,4})\s+(.*)$/);
|
|
69
|
+
if (heading) {
|
|
70
|
+
out.push(pc.bold(pc.underline(renderInline(heading[2]))));
|
|
71
|
+
out.push("");
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const bullet = raw.match(/^\s*[-*+]\s+(.*)$/);
|
|
75
|
+
if (bullet) {
|
|
76
|
+
out.push(" " + pc.cyan("•") + " " + renderInline(bullet[1]));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const numbered = raw.match(/^\s*(\d+)[.)]\s+(.*)$/);
|
|
80
|
+
if (numbered) {
|
|
81
|
+
out.push(" " + pc.cyan(numbered[1] + ".") + " " + renderInline(numbered[2]));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
out.push(renderInline(raw));
|
|
85
|
+
}
|
|
86
|
+
flushCode();
|
|
87
|
+
while (out.length && out[0] === "")
|
|
88
|
+
out.shift();
|
|
89
|
+
while (out.length && out[out.length - 1] === "")
|
|
90
|
+
out.pop();
|
|
91
|
+
return out.join("\n");
|
|
92
|
+
}
|
package/dist/core/report.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { BASE_CSS, SEVERITY_COLOR, seismograph } from "./ui.js";
|
|
4
4
|
import { sanitizeSecrets } from "./heal/redact.js";
|
|
5
|
+
import { diagnoseFinding } from "./diagnose.js";
|
|
5
6
|
function escapeHtml(s) {
|
|
6
7
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
7
8
|
}
|
|
@@ -16,7 +17,7 @@ const SEV_ORDER = ["crash", "error", "warning", "noise"];
|
|
|
16
17
|
* CDN), rendered in the shared "crash seismograph" identity: one red spike per
|
|
17
18
|
* crash, severity chips, and colored repro verdicts.
|
|
18
19
|
*/
|
|
19
|
-
export function renderReport(targetUrl, findings) {
|
|
20
|
+
export function renderReport(targetUrl, findings, lang) {
|
|
20
21
|
const sorted = [...findings].sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity));
|
|
21
22
|
const counts = { crash: 0, error: 0, warning: 0 };
|
|
22
23
|
for (const f of sorted)
|
|
@@ -28,6 +29,7 @@ export function renderReport(targetUrl, findings) {
|
|
|
28
29
|
? `${clean(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}`
|
|
29
30
|
: "";
|
|
30
31
|
const snippet = f.mappedLocation ? clean(f.mappedLocation.codeContext) : "";
|
|
32
|
+
const dx = diagnoseFinding(f, lang);
|
|
31
33
|
const serverErr = f.serverError
|
|
32
34
|
? `<div class="server">server: ${clean(f.serverError.message)}</div>` +
|
|
33
35
|
(f.serverError.body
|
|
@@ -53,6 +55,7 @@ export function renderReport(targetUrl, findings) {
|
|
|
53
55
|
<h2>${clean(f.rawMessage.split("\n")[0])}</h2>
|
|
54
56
|
</header>
|
|
55
57
|
${loc ? `<div class="loc">${loc}</div>` : ""}
|
|
58
|
+
${dx ? `<div class="dx">↳ ${clean(dx)}</div>` : ""}
|
|
56
59
|
${snippet ? `<pre class="snippet">${snippet}</pre>` : ""}
|
|
57
60
|
${serverErr}
|
|
58
61
|
${f.occurrences > 1 ? `<div class="occ">seen ×${f.occurrences}</div>` : ""}
|
|
@@ -88,10 +91,10 @@ export function renderReport(targetUrl, findings) {
|
|
|
88
91
|
</body>
|
|
89
92
|
</html>`;
|
|
90
93
|
}
|
|
91
|
-
export function writeReport(repoRoot, targetUrl, findings) {
|
|
94
|
+
export function writeReport(repoRoot, targetUrl, findings, lang) {
|
|
92
95
|
const dir = path.join(repoRoot, ".aztrx");
|
|
93
96
|
fs.mkdirSync(dir, { recursive: true });
|
|
94
97
|
const file = path.join(dir, "report.html");
|
|
95
|
-
fs.writeFileSync(file, renderReport(targetUrl, findings), "utf-8");
|
|
98
|
+
fs.writeFileSync(file, renderReport(targetUrl, findings, lang), "utf-8");
|
|
96
99
|
return file;
|
|
97
100
|
}
|
package/dist/core/resolver.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { FlattenMap, originalPositionFor, } from "@jridgewell/trace-mapping";
|
|
4
4
|
/** Framework-internal frames to skip when hunting the throw site. */
|
|
5
5
|
const FRAMEWORK_FRAME = /node_modules|webpack-runtime|\.next[\\/]|next[\\/]dist[\\/]/;
|
|
6
6
|
/**
|
|
@@ -80,6 +80,15 @@ function isFile(p) {
|
|
|
80
80
|
return false;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
/** True only for a real directory. */
|
|
84
|
+
function isDirectory(p) {
|
|
85
|
+
try {
|
|
86
|
+
return fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
83
92
|
/** Secret-bearing filenames that must never be read, even inside the repo — a
|
|
84
93
|
* hostile sourcemap could otherwise point `source` at `.env`, an npmrc, or a
|
|
85
94
|
* private key and exfiltrate it into the report / PR comment. */
|
|
@@ -135,6 +144,8 @@ function sourceCandidates(source, repoRoot) {
|
|
|
135
144
|
.replace(/^webpack:\/\/[^/]+\//, "") // webpack://namespace/src/...
|
|
136
145
|
.replace(/^webpack:\/\//, "")
|
|
137
146
|
.replace(/^\/@fs\//, "")
|
|
147
|
+
.replace(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
|
|
148
|
+
.replace(/^file:\/\//, "")
|
|
138
149
|
.replace(/^\//, "")
|
|
139
150
|
.split("?")[0];
|
|
140
151
|
const prefixes = ["", "apps/web/", "src/", "app/"];
|
|
@@ -150,13 +161,25 @@ function locateFile(candidates) {
|
|
|
150
161
|
return null;
|
|
151
162
|
}
|
|
152
163
|
export async function resolveFrame(frame, repoRoot) {
|
|
164
|
+
const viaServerAction = await tryServerActionSourceMap(frame, repoRoot);
|
|
165
|
+
if (viaServerAction)
|
|
166
|
+
return viaServerAction;
|
|
153
167
|
const viaMap = await trySourceMap(frame, repoRoot);
|
|
154
168
|
if (viaMap)
|
|
155
169
|
return viaMap;
|
|
156
170
|
// Fallback: dev servers (Vite, Next) serve real source files at their URL
|
|
157
171
|
// path, so the bundle URL is already the source path — no sourcemap needed.
|
|
158
172
|
const relative = normalizeFrameUrl(frame.url);
|
|
159
|
-
|
|
173
|
+
let directPath = resolveWithin(repoRoot, relative);
|
|
174
|
+
// A frame URL pointing at a directory — e.g. an inline `<script>` whose V8
|
|
175
|
+
// frame carries the page URL (`http://localhost:3000/`, normalized to "") —
|
|
176
|
+
// resolves to the repo root. Map it to `index.html`, mirroring the static
|
|
177
|
+
// serve fallback, so the crash gets a real filename + snippet + own-code flag.
|
|
178
|
+
if (directPath && isDirectory(directPath)) {
|
|
179
|
+
const withIndex = resolveWithin(repoRoot, relative, "index.html");
|
|
180
|
+
if (withIndex)
|
|
181
|
+
directPath = withIndex;
|
|
182
|
+
}
|
|
160
183
|
if (!directPath) {
|
|
161
184
|
return {
|
|
162
185
|
message: frame.message,
|
|
@@ -227,6 +250,35 @@ function isLoopback(host) {
|
|
|
227
250
|
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
228
251
|
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0";
|
|
229
252
|
}
|
|
253
|
+
/** Shared tail of sourcemap resolution: run `originalPositionFor` through a
|
|
254
|
+
* (possibly sectioned) map, resolve the `source` it names to a repo file, and
|
|
255
|
+
* build the `MappedError`. Returns null when the position maps to no known
|
|
256
|
+
* source. */
|
|
257
|
+
function resolveFromMap(rawMap, line, column, message, repoRoot) {
|
|
258
|
+
const map = new FlattenMap(rawMap);
|
|
259
|
+
const pos = originalPositionFor(map, { line, column });
|
|
260
|
+
if (!pos.source || pos.line == null)
|
|
261
|
+
return null;
|
|
262
|
+
const absolute = locateFile(sourceCandidates(pos.source, repoRoot));
|
|
263
|
+
if (!absolute) {
|
|
264
|
+
return {
|
|
265
|
+
message,
|
|
266
|
+
sourceFile: pos.source,
|
|
267
|
+
line: pos.line,
|
|
268
|
+
column: pos.column ?? 0,
|
|
269
|
+
codeSnippet: `<file not accessible locally: ${pos.source}>`,
|
|
270
|
+
resolvedFrom: "unresolved",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
message,
|
|
275
|
+
sourceFile: path.relative(repoRoot, absolute),
|
|
276
|
+
line: pos.line,
|
|
277
|
+
column: pos.column ?? 0,
|
|
278
|
+
codeSnippet: extractSnippet(absolute, pos.line),
|
|
279
|
+
resolvedFrom: "sourcemap",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
230
282
|
async function trySourceMap(frame, repoRoot) {
|
|
231
283
|
const mapUrl = stripQuery(frame.url) + ".map";
|
|
232
284
|
// SSRF guard: the sourcemap URL is derived from an untrusted stack frame, so
|
|
@@ -252,29 +304,38 @@ async function trySourceMap(frame, repoRoot) {
|
|
|
252
304
|
return null;
|
|
253
305
|
}
|
|
254
306
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
307
|
+
return resolveFromMap(rawMap, frame.line, frame.column, frame.message, repoRoot);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Map a Next.js Server Action throw site to its original source. The browser
|
|
315
|
+
* sees the throw as `about://React/Server/<url-encoded chunk path>?<n>:<line>:<col>`,
|
|
316
|
+
* where the encoded path is the compiled Turbopack chunk on disk. That chunk's
|
|
317
|
+
* `.map` is a *sectioned* (indexed) sourcemap whose sections point at the real
|
|
318
|
+
* source (e.g. `app/actions.ts`); `FlattenMap` walks the sections for us.
|
|
319
|
+
*/
|
|
320
|
+
async function tryServerActionSourceMap(frame, repoRoot) {
|
|
321
|
+
const marker = "about://React/Server/";
|
|
322
|
+
if (!frame.url.startsWith(marker))
|
|
323
|
+
return null;
|
|
324
|
+
let chunkPath;
|
|
325
|
+
try {
|
|
326
|
+
chunkPath = stripQuery(decodeURIComponent(frame.url.slice(marker.length)));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
if (!isFile(chunkPath))
|
|
332
|
+
return null;
|
|
333
|
+
const mapPath = chunkPath + ".map";
|
|
334
|
+
if (!isFile(mapPath) || isSensitive(mapPath))
|
|
335
|
+
return null;
|
|
336
|
+
try {
|
|
337
|
+
const rawMap = JSON.parse(fs.readFileSync(mapPath, "utf-8"));
|
|
338
|
+
return resolveFromMap(rawMap, frame.line, frame.column, frame.message, repoRoot);
|
|
278
339
|
}
|
|
279
340
|
catch {
|
|
280
341
|
return null;
|
package/dist/core/summarize.js
CHANGED
|
@@ -118,11 +118,11 @@ function buildLlmPrompt(findings, lang, hasHealed) {
|
|
|
118
118
|
`A QA tool scanned a web app and found the following findings:`,
|
|
119
119
|
rows,
|
|
120
120
|
"",
|
|
121
|
-
`Write a short, friendly
|
|
121
|
+
`Write a short, friendly summary for a developer (${lang}) in Markdown — use headings, bold, bullet lists, and short \`\`\`code\`\`\` snippets where helpful. Explain what was found, what each problem means in simple words, and — per the note below — whether fixes are ready. Do not invent details that are not listed. Keep it concise.`,
|
|
122
122
|
fixLine,
|
|
123
123
|
].join("\n");
|
|
124
124
|
}
|
|
125
|
-
const SYSTEM = "You are the plain-spoken explainer for a QA tool called Aztrx AI. You turn raw runtime-finding data into a concise
|
|
125
|
+
const SYSTEM = "You are the plain-spoken explainer for a QA tool called Aztrx AI. You turn raw runtime-finding data into a concise Markdown summary for a developer (headings, bold, bullet lists, short code snippets). Never invent details absent from the data. Respond in the requested language only.";
|
|
126
126
|
async function summarizeFindingsLlm(findings, lang) {
|
|
127
127
|
const hasHealed = findings.some((f) => f.heal?.status === "healed");
|
|
128
128
|
const text = (await complete({
|
package/dist/core/swarm.js
CHANGED
|
@@ -15,7 +15,7 @@ import { launchChromium } from "./browser.js";
|
|
|
15
15
|
import { EventBus } from "./eventBus.js";
|
|
16
16
|
import { attachInterceptor } from "./interceptor.js";
|
|
17
17
|
import { establishLogin } from "./auth.js";
|
|
18
|
-
import { SignalClassifier } from "./classifier.js";
|
|
18
|
+
import { SignalClassifier, collapseSignals } from "./classifier.js";
|
|
19
19
|
import { ActionRecorder } from "./recorder.js";
|
|
20
20
|
import { walkDom } from "./domWalker.js";
|
|
21
21
|
import { fuzz } from "./fuzzer.js";
|
|
@@ -75,6 +75,20 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
|
|
|
75
75
|
const context = await browser.newContext(opts.storageState ? { storageState: opts.storageState } : {});
|
|
76
76
|
const page = await context.newPage();
|
|
77
77
|
attachInterceptor(page, workerBus);
|
|
78
|
+
// Collect every same-origin URL the page issues — including `fetch()` fired
|
|
79
|
+
// from click handlers — so the folded HTTP fuzzer can probe endpoints a fresh
|
|
80
|
+
// page never sees (performance resources only capture on-load fetches).
|
|
81
|
+
const observedUrls = new Set();
|
|
82
|
+
const targetOrigin = new URL(opts.url).origin;
|
|
83
|
+
page.on("request", (req) => {
|
|
84
|
+
try {
|
|
85
|
+
if (new URL(req.url()).origin === targetOrigin)
|
|
86
|
+
observedUrls.add(req.url());
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// malformed URL — skip
|
|
90
|
+
}
|
|
91
|
+
});
|
|
78
92
|
if (opts.guardOn) {
|
|
79
93
|
await attachNetworkGuard(page, {
|
|
80
94
|
allowHosts: opts.allowHosts,
|
|
@@ -94,10 +108,9 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
|
|
|
94
108
|
// Settle for hydration and mount-time effects before acting.
|
|
95
109
|
await page.waitForTimeout(2000);
|
|
96
110
|
}
|
|
97
|
-
// Auto-login (best-effort).
|
|
98
|
-
// so it doesn't benefit from a browser session — skip it there.
|
|
111
|
+
// Auto-login (best-effort).
|
|
99
112
|
let replayStorageState;
|
|
100
|
-
if (loaded &&
|
|
113
|
+
if (loaded && opts.login && opts.loginEmail && opts.loginPassword) {
|
|
101
114
|
const res = await establishLogin(page, {
|
|
102
115
|
email: opts.loginEmail,
|
|
103
116
|
password: opts.loginPassword,
|
|
@@ -134,21 +147,27 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
|
|
|
134
147
|
let sawLoginForm = false;
|
|
135
148
|
if (loaded) {
|
|
136
149
|
if (strategy.kind === "walk") {
|
|
137
|
-
const wr = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
|
|
150
|
+
const wr = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun, allowDestructive: opts.allowDestructive });
|
|
138
151
|
actions = wr.actions;
|
|
139
152
|
sawLoginForm = wr.sawLoginForm;
|
|
140
153
|
}
|
|
141
|
-
else
|
|
142
|
-
const fr = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
|
|
154
|
+
else {
|
|
155
|
+
const fr = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun, allowDestructive: opts.allowDestructive });
|
|
143
156
|
actions = fr.actions;
|
|
144
157
|
newCoverage = fr.newCoverage;
|
|
145
158
|
}
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
// Folded HTTP fuzzer: post-pass on this same page, seeded with every URL the
|
|
160
|
+
// walk/fuzz actually issued — including JS-fetch-only endpoints a standalone
|
|
161
|
+
// worker (snapshot before clicks) would never discover.
|
|
162
|
+
if (opts.httpFuzz) {
|
|
163
|
+
actions += await httpFuzz(page, opts.url, workerBus, {
|
|
148
164
|
maxRequests: opts.maxActions,
|
|
149
165
|
dryRun: opts.dryRun,
|
|
150
166
|
allowHosts: opts.allowHosts,
|
|
151
167
|
mutations: opts.httpFuzzMutations,
|
|
168
|
+
allowDestructive: opts.allowDestructive,
|
|
169
|
+
seedUrls: [...observedUrls],
|
|
170
|
+
navigate: false,
|
|
152
171
|
});
|
|
153
172
|
}
|
|
154
173
|
}
|
|
@@ -175,12 +194,11 @@ export function mergeFindings(arrays) {
|
|
|
175
194
|
}
|
|
176
195
|
return [...byFingerprint.values()];
|
|
177
196
|
}
|
|
178
|
-
/** Build the worker roster for a run. `workers = 1`
|
|
179
|
-
*
|
|
197
|
+
/** Build the worker roster for a run. `workers = 1` is the legacy single pass;
|
|
198
|
+
* `workers > 1` fans out. `--http-fuzz` is not a worker here — it folds into
|
|
199
|
+
* whichever pass runs (see `detectWorker`). */
|
|
180
200
|
function buildStrategies(opts) {
|
|
181
201
|
const strategies = [];
|
|
182
|
-
if (opts.httpFuzz)
|
|
183
|
-
strategies.push({ kind: "http-fuzz" });
|
|
184
202
|
const w = Math.max(1, opts.workers);
|
|
185
203
|
if (w === 1) {
|
|
186
204
|
strategies.push(opts.fuzz ? { kind: "fuzz", seed: opts.seed } : { kind: "walk" });
|
|
@@ -202,8 +220,6 @@ function strategyLabel(s) {
|
|
|
202
220
|
switch (s.kind) {
|
|
203
221
|
case "walk":
|
|
204
222
|
return "walk";
|
|
205
|
-
case "http-fuzz":
|
|
206
|
-
return "http-fuzz";
|
|
207
223
|
case "fuzz":
|
|
208
224
|
return `fuzz seed ${s.seed}`;
|
|
209
225
|
}
|
|
@@ -228,6 +244,8 @@ export async function swarmDetect(opts) {
|
|
|
228
244
|
crashTest: i === 0 ? opts.crashTest : false,
|
|
229
245
|
saveAuthState: i === 0,
|
|
230
246
|
httpFuzzMutations: opts.httpFuzzMutations,
|
|
247
|
+
httpFuzz: opts.httpFuzz,
|
|
248
|
+
allowDestructive: opts.allowDestructive,
|
|
231
249
|
baseline: opts.baseline,
|
|
232
250
|
log: (m) => opts.log(strategies.length > 1 ? `[w${i}] ${m}` : m),
|
|
233
251
|
}, strategy, opts.forwardBus)));
|
|
@@ -242,7 +260,9 @@ export async function swarmDetect(opts) {
|
|
|
242
260
|
for (const r of results)
|
|
243
261
|
if (r.replayStorageState)
|
|
244
262
|
replayStorageState = r.replayStorageState;
|
|
245
|
-
|
|
263
|
+
// Merge identical fingerprints across workers, then collapse distinct
|
|
264
|
+
// capture paths of the same fault (5xx + console + timeout + throw) into one.
|
|
265
|
+
const findings = collapseSignals(mergeFindings(results.map((r) => r.findings)));
|
|
246
266
|
const totalActions = results.reduce((sum, r) => sum + r.actions, 0);
|
|
247
267
|
const totalCoverage = results.reduce((sum, r) => sum + r.newCoverage, 0);
|
|
248
268
|
return {
|
package/dist/core/ui.js
CHANGED
|
@@ -52,6 +52,7 @@ h1 .brand-sub{color:var(--dim)}
|
|
|
52
52
|
.sev{font:600 11px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--sev);border:1px solid var(--sev);border-radius:999px;padding:3px 9px;flex:none}
|
|
53
53
|
h2{font-size:15px;margin:0;font-weight:600;word-break:break-word}
|
|
54
54
|
.loc{color:var(--dim);font-size:12.5px;margin-top:8px}
|
|
55
|
+
.dx{color:var(--azure);font-size:12.5px;margin-top:6px}
|
|
55
56
|
.snippet{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12px/1.6 ui-monospace,monospace;color:var(--muted);margin:12px 0 0;white-space:pre}
|
|
56
57
|
.server{color:var(--amber);font-size:12.5px;margin-top:8px}
|
|
57
58
|
.server-body{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12px/1.6 ui-monospace,monospace;color:var(--muted);margin:8px 0 0;white-space:pre;max-height:240px;overflow-y:auto}
|