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