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.
- package/README.md +325 -28
- package/dist/cli/help.d.ts +22 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +278 -81
- package/dist/core/auth.d.ts +33 -0
- package/dist/core/badge.d.ts +9 -0
- package/dist/core/browser.d.ts +3 -0
- package/dist/core/classifier.d.ts +41 -0
- package/dist/core/cloud/index.d.ts +62 -0
- package/dist/core/cloud/index.js +24 -4
- package/dist/core/devServer.d.ts +90 -0
- package/dist/core/devServer.js +253 -0
- package/dist/core/diagnose.d.ts +18 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/domWalker.d.ts +24 -0
- package/dist/core/domWalker.js +1 -1
- package/dist/core/eventBus.d.ts +59 -0
- package/dist/core/events.d.ts +42 -0
- package/dist/core/fixPr.d.ts +16 -0
- package/dist/core/fixPr.js +11 -1
- package/dist/core/fuzzer.d.ts +24 -0
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/apply.d.ts +28 -0
- package/dist/core/heal/boot.d.ts +74 -0
- package/dist/core/heal/boot.js +126 -23
- package/dist/core/heal/childEnv.d.ts +15 -0
- package/dist/core/heal/gates.d.ts +11 -0
- package/dist/core/heal/index.d.ts +19 -0
- package/dist/core/heal/index.js +53 -23
- package/dist/core/heal/llm.d.ts +43 -0
- package/dist/core/heal/redact.d.ts +22 -0
- package/dist/core/heal/sandbox.d.ts +54 -0
- package/dist/core/heal/sandbox.js +20 -3
- package/dist/core/heal/types.d.ts +102 -0
- package/dist/core/heal/verify.d.ts +37 -0
- package/dist/core/heal/verify.js +28 -14
- package/dist/core/httpFuzzer.d.ts +33 -0
- package/dist/core/init.d.ts +21 -0
- package/dist/core/init.js +5 -6
- package/dist/core/interceptor.d.ts +21 -0
- package/dist/core/llm.d.ts +36 -0
- package/dist/core/llm.js +0 -8
- package/dist/core/minimizer.d.ts +13 -0
- package/dist/core/modernize.d.ts +28 -0
- package/dist/core/modernize.js +2 -2
- package/dist/core/networkGuard.d.ts +13 -0
- package/dist/core/orchestrator.d.ts +86 -0
- package/dist/core/orchestrator.js +24 -9
- package/dist/core/patrol/loop.d.ts +46 -0
- package/dist/core/patrol/pr.d.ts +42 -0
- package/dist/core/patrol/pr.js +9 -6
- package/dist/core/patrol/record.d.ts +51 -0
- package/dist/core/patrol/state.d.ts +39 -0
- package/dist/core/pr.d.ts +3 -0
- package/dist/core/pr.js +15 -11
- package/dist/core/prompt.d.ts +14 -0
- package/dist/core/recorder.d.ts +23 -0
- package/dist/core/recorder.js +1 -1
- package/dist/core/renderMarkdown.d.ts +7 -0
- package/dist/core/replay.d.ts +33 -0
- package/dist/core/replay.js +9 -2
- package/dist/core/report.d.ts +8 -0
- package/dist/core/resolver.d.ts +44 -0
- package/dist/core/resolver.js +51 -13
- package/dist/core/rng.d.ts +2 -0
- package/dist/core/specCompiler.d.ts +17 -0
- package/dist/core/studio.d.ts +6 -0
- package/dist/core/studio.js +0 -3
- package/dist/core/summarize.d.ts +26 -0
- package/dist/core/swarm.d.ts +97 -0
- package/dist/core/telemetry/index.d.ts +33 -0
- package/dist/core/telemetry/index.js +22 -4
- package/dist/core/telemetry/sanitize.d.ts +19 -0
- package/dist/core/telemetry/types.d.ts +26 -0
- package/dist/core/types.d.ts +88 -0
- package/dist/core/ui.d.ts +23 -0
- package/dist/core/ui.js +0 -5
- package/dist/core/validator.d.ts +14 -0
- package/dist/core/version.d.ts +1 -0
- package/dist/hooks/index.d.ts +106 -0
- package/dist/hooks/index.js +349 -0
- package/dist/mcp/index.d.ts +58 -0
- package/dist/mcp/index.js +295 -0
- package/dist/mcp/install.d.ts +52 -0
- package/dist/mcp/install.js +204 -0
- package/dist/mcp/protocol.d.ts +106 -0
- package/dist/mcp/protocol.js +156 -0
- package/dist/mcp/tools.d.ts +107 -0
- package/dist/mcp/tools.js +621 -0
- package/dist/next/index.d.ts +70 -0
- package/dist/next/index.js +103 -0
- package/dist/plugins/scan.d.ts +85 -0
- package/dist/plugins/scan.js +188 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/ui/app.js +14 -1
- package/dist/vite/index.d.ts +60 -0
- package/dist/vite/index.js +56 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
321
|
-
// user rather than silently doing nothing. Already-handled
|
|
322
|
-
// excluded above, so this only fires for genuinely new
|
|
323
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
* (``). 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>;
|
package/dist/core/patrol/pr.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
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_${
|
|
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
|
|
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
|
|
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 {};
|
package/dist/core/recorder.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/dist/core/replay.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/resolver.js
CHANGED
|
@@ -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
|
|
60
|
-
* dev-server schemes (`webpack-internal:///(ns)/./src/…`, `webpack://ns/src/…`)
|
|
61
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
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:
|
|
224
|
+
sourceFile: shown,
|
|
187
225
|
line: frame.line,
|
|
188
226
|
column: frame.column,
|
|
189
|
-
codeSnippet: `<file not accessible locally: ${
|
|
227
|
+
codeSnippet: `<file not accessible locally: ${shown}>`,
|
|
190
228
|
resolvedFrom: "unresolved",
|
|
191
229
|
};
|
|
192
230
|
}
|