aztrx-cli 0.4.2 → 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 +62 -4
- package/dist/cli.js +61 -1
- package/dist/core/browser.js +47 -0
- 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/replay.js +2 -2
- 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 +38 -18
- 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 +6 -2
|
@@ -0,0 +1,233 @@
|
|
|
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 { execFile } from "child_process";
|
|
15
|
+
import { promisify } from "util";
|
|
16
|
+
import { createHash } from "crypto";
|
|
17
|
+
import { diagnoseFinding } from "../diagnose.js";
|
|
18
|
+
import { sanitizeSecrets } from "../heal/redact.js";
|
|
19
|
+
const exec = promisify(execFile);
|
|
20
|
+
export function branchFor(fp) {
|
|
21
|
+
return `aztrx/fix-${fp.slice(0, 8)}`;
|
|
22
|
+
}
|
|
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 function branchForSet(fps) {
|
|
26
|
+
const h = createHash("sha1").update([...fps].sort().join("\n")).digest("hex").slice(0, 8);
|
|
27
|
+
return `aztrx/fix-batch-${h}`;
|
|
28
|
+
}
|
|
29
|
+
function headTitle(f) {
|
|
30
|
+
return f.rawMessage.split("\n")[0].slice(0, 60);
|
|
31
|
+
}
|
|
32
|
+
/** A local/loopback/private target is a dev box; only a publicly-routable host is
|
|
33
|
+
* a deployed app, so a crash there is "live in production" and worth flagging.
|
|
34
|
+
* Covers loopback, RFC1918 private ranges (10/8, 172.16/12, 192.168/16),
|
|
35
|
+
* link-local (169.254/16), and mDNS/internal suffixes (.local, .internal). */
|
|
36
|
+
export function isLocalUrl(url) {
|
|
37
|
+
try {
|
|
38
|
+
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
39
|
+
if (host === "localhost" || host === "::1" || host === "::" || host === "0.0.0.0")
|
|
40
|
+
return true;
|
|
41
|
+
if (host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".localhost")) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const octets = host.split(".");
|
|
45
|
+
if (octets.length === 4 && octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)) {
|
|
46
|
+
const [a, b] = octets.map(Number);
|
|
47
|
+
if (a === 10)
|
|
48
|
+
return true; // 10.0.0.0/8
|
|
49
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
50
|
+
return true; // 172.16.0.0/12
|
|
51
|
+
if (a === 192 && b === 168)
|
|
52
|
+
return true; // 192.168.0.0/16
|
|
53
|
+
if (a === 127)
|
|
54
|
+
return true; // 127.0.0.0/8
|
|
55
|
+
if (a === 169 && b === 254)
|
|
56
|
+
return true; // 169.254.0.0/16 link-local
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return true; // unparseable target → assume local, don't scare-monger
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Per-finding "before/after" narrative: the crash ("before"), a one-line *why*
|
|
66
|
+
* from the deterministic diagnosis, and the healed fix explanation ("after").
|
|
67
|
+
* Plain words rather than a stack trace, so a reviewer who never ran the scan
|
|
68
|
+
* still understands the PR at a glance. Untrusted text is secret-scrubbed. */
|
|
69
|
+
function findingBlock(f, i) {
|
|
70
|
+
const head = sanitizeSecrets(f.rawMessage.split("\n")[0].trim());
|
|
71
|
+
const loc = f.mappedLocation
|
|
72
|
+
? `\`${f.mappedLocation.filePath}:${f.mappedLocation.line}\``
|
|
73
|
+
: "unknown location";
|
|
74
|
+
const why = diagnoseFinding(f);
|
|
75
|
+
const fix = f.heal?.status === "healed" && f.heal.explanation
|
|
76
|
+
? sanitizeSecrets(f.heal.explanation.trim())
|
|
77
|
+
: "";
|
|
78
|
+
const repro = f.repro?.verdict
|
|
79
|
+
? `_repro: ${f.repro.verdict} ${f.repro.reproductions}/${f.repro.runs}_`
|
|
80
|
+
: "";
|
|
81
|
+
const lines = [`### ${i}. ${head.length > 96 ? head.slice(0, 93) + "…" : head}`];
|
|
82
|
+
lines.push(`- **Where:** ${loc}`);
|
|
83
|
+
if (why)
|
|
84
|
+
lines.push(`- **Why:** ${why}`);
|
|
85
|
+
if (fix)
|
|
86
|
+
lines.push(`- **The fix:** ${fix.length > 400 ? fix.slice(0, 397) + "…" : fix}`);
|
|
87
|
+
if (repro)
|
|
88
|
+
lines.push(`- ${repro}`);
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
const PROD_BANNER = "> ⚠️ **This crash is live in production right now** — the target isn't a local dev server.";
|
|
92
|
+
const VERIFIED_NOTE = "Verified: AST-gated, compiled, run against the test suite, and replayed against the repro before this PR. Opened automatically by `aztrx patrol`.";
|
|
93
|
+
async function hasOpenPr(repoRoot, branch) {
|
|
94
|
+
try {
|
|
95
|
+
const { stdout } = await exec("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number"], { cwd: repoRoot });
|
|
96
|
+
const list = JSON.parse(stdout || "[]");
|
|
97
|
+
return Array.isArray(list) && list.length > 0;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// gh missing / unauthenticated — surface on the create step instead.
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function currentBranch(repoRoot) {
|
|
105
|
+
try {
|
|
106
|
+
const { stdout } = await exec("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
107
|
+
return stdout.trim() || "main";
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return "main";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function safeCheckout(repoRoot, branch) {
|
|
114
|
+
await exec("git", ["-C", repoRoot, "checkout", branch]).catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Pure URL builder: turns a `git remote` origin URL into a raw-content URL for a
|
|
118
|
+
* repo-relative path, so the PR body can inline an image
|
|
119
|
+
* (``). Handles the
|
|
120
|
+
* three common remote shapes (`https://`, `git@github.com:`, `ssh://git@…`) and a
|
|
121
|
+
* trailing `.git`. Returns "" when the remote isn't GitHub.
|
|
122
|
+
*/
|
|
123
|
+
export function githubRawUrl(remote, branch, repoPath) {
|
|
124
|
+
const m = /github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/.exec(remote.trim());
|
|
125
|
+
if (!m)
|
|
126
|
+
return "";
|
|
127
|
+
return `https://github.com/${m[1]}/${m[2]}/raw/${branch}/${repoPath}`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Reads the `origin` remote and delegates to {@link githubRawUrl}. Returns "" when
|
|
131
|
+
* `git` is missing or the remote isn't GitHub — the media is still committed to
|
|
132
|
+
* the branch, just not inlined.
|
|
133
|
+
*/
|
|
134
|
+
async function rawUrlFor(repoRoot, branch, repoPath) {
|
|
135
|
+
try {
|
|
136
|
+
const { stdout } = await exec("git", ["-C", repoRoot, "remote", "get-url", "origin"]);
|
|
137
|
+
return githubRawUrl(stdout, branch, repoPath);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Shared git plumbing: (re)create the branch at HEAD, stage only the touched
|
|
144
|
+
* files, commit, open the PR, and return to the original branch. The caller has
|
|
145
|
+
* already done its own dedup + title/body. */
|
|
146
|
+
async function createPr(repoRoot, branch, title, body, files) {
|
|
147
|
+
const originalBranch = await currentBranch(repoRoot);
|
|
148
|
+
try {
|
|
149
|
+
// `-B` (re)creates the branch at HEAD — idempotent against a stale local
|
|
150
|
+
// branch left over from a previously failed PR attempt.
|
|
151
|
+
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
|
+
}
|
|
158
|
+
await exec("git", ["-C", repoRoot, "commit", "-m", title]);
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
await safeCheckout(repoRoot, originalBranch);
|
|
162
|
+
return { ok: false, branch, error: `git failed: ${e.message}` };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const { stdout } = await exec("gh", ["pr", "create", "--title", title, "--body", body], {
|
|
166
|
+
cwd: repoRoot,
|
|
167
|
+
});
|
|
168
|
+
await safeCheckout(repoRoot, originalBranch);
|
|
169
|
+
return { ok: true, branch, url: stdout.trim() };
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
await safeCheckout(repoRoot, originalBranch);
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
branch,
|
|
176
|
+
error: `gh pr create failed (is gh installed and authenticated?): ${e.message}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export async function openPatrolPr(repoRoot, finding, url, files, mediaPath) {
|
|
181
|
+
const branch = branchFor(finding.fingerprint);
|
|
182
|
+
if (await hasOpenPr(repoRoot, branch)) {
|
|
183
|
+
return { ok: false, skipped: true, branch, error: "PR already open for this finding" };
|
|
184
|
+
}
|
|
185
|
+
const mediaBody = mediaPath ? await rawUrlFor(repoRoot, branch, mediaPath) : "";
|
|
186
|
+
const block = findingBlock(finding, 1);
|
|
187
|
+
const body = [
|
|
188
|
+
"## Aztrx AI — autonomous fix",
|
|
189
|
+
"",
|
|
190
|
+
...(isLocalUrl(url) ? [] : [PROD_BANNER, ""]),
|
|
191
|
+
`Found against ${url}:`,
|
|
192
|
+
"",
|
|
193
|
+
mediaBody ? `${block}\n\n` : block,
|
|
194
|
+
"",
|
|
195
|
+
VERIFIED_NOTE,
|
|
196
|
+
].join("\n");
|
|
197
|
+
return createPr(repoRoot, branch, `fix: ${headTitle(finding)}`, body, mediaPath ? [...files, mediaPath] : files);
|
|
198
|
+
}
|
|
199
|
+
/** One PR carrying a whole batch of fixes. Branch is stable across the *set*, so
|
|
200
|
+
* an unchanged batch re-scan dedups to the same PR. */
|
|
201
|
+
export async function openPatrolBatchPr(repoRoot, findings, url, files, mediaPaths) {
|
|
202
|
+
const branch = branchForSet(findings.map((f) => f.fingerprint));
|
|
203
|
+
if (await hasOpenPr(repoRoot, branch)) {
|
|
204
|
+
return { ok: false, skipped: true, branch, error: "batch PR already open" };
|
|
205
|
+
}
|
|
206
|
+
const n = findings.length;
|
|
207
|
+
// Resolve each finding's media to a raw URL ("" when absent / non-GitHub), kept
|
|
208
|
+
// parallel to `findings` so each inline image lands under its own bug's block.
|
|
209
|
+
const mediaBodies = [];
|
|
210
|
+
for (let i = 0; i < findings.length; i++) {
|
|
211
|
+
const mp = mediaPaths?.[i];
|
|
212
|
+
mediaBodies.push(mp ? await rawUrlFor(repoRoot, branch, mp) : "");
|
|
213
|
+
}
|
|
214
|
+
const blocks = findings.map((f, i) => {
|
|
215
|
+
const block = findingBlock(f, i + 1);
|
|
216
|
+
return mediaBodies[i] ? `${block}\n\n` : block;
|
|
217
|
+
});
|
|
218
|
+
const body = [
|
|
219
|
+
"## Aztrx AI — autonomous fix (batch)",
|
|
220
|
+
"",
|
|
221
|
+
...(isLocalUrl(url) ? [] : [PROD_BANNER, ""]),
|
|
222
|
+
`Found ${n} bug${n === 1 ? "" : "s"} against ${url}:`,
|
|
223
|
+
"",
|
|
224
|
+
blocks.join("\n\n"),
|
|
225
|
+
"",
|
|
226
|
+
VERIFIED_NOTE,
|
|
227
|
+
].join("\n");
|
|
228
|
+
const staged = mediaPaths
|
|
229
|
+
? [...files, ...mediaPaths.filter((p) => !!p)]
|
|
230
|
+
: files;
|
|
231
|
+
const title = `fix: ${n} bug${n === 1 ? "" : "s"} (${headTitle(findings[0])})`;
|
|
232
|
+
return createPr(repoRoot, branch, title, body, staged);
|
|
233
|
+
}
|
|
@@ -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/replay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { chromium } from "playwright";
|
|
2
1
|
import { EventBus } from "./eventBus.js";
|
|
2
|
+
import { launchChromium } from "./browser.js";
|
|
3
3
|
import { attachInterceptor } from "./interceptor.js";
|
|
4
4
|
import { fingerprintOf } from "./classifier.js";
|
|
5
5
|
/** Replays a recorded action sequence against a page. Best-effort: a selector
|
|
@@ -79,7 +79,7 @@ export class ReplayEngine {
|
|
|
79
79
|
}
|
|
80
80
|
async getBrowser() {
|
|
81
81
|
if (!this.browser)
|
|
82
|
-
this.browser = await
|
|
82
|
+
this.browser = await launchChromium();
|
|
83
83
|
return this.browser;
|
|
84
84
|
}
|
|
85
85
|
async run(url, actions, targetFingerprint, opts) {
|
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
|
}
|