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
package/dist/core/resolver.js
CHANGED
|
@@ -16,6 +16,35 @@ export function extractFrame(text) {
|
|
|
16
16
|
message: text.split("\n")[0].trim().slice(0, 200),
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
/** True if `p` looks like an absolute source path — a `file://` URL, a POSIX
|
|
20
|
+
* absolute path, or a Windows drive path. Rejects bare relative tokens like
|
|
21
|
+
* `route.ts` so a stray `foo:12:34` in a stack body is never mistaken for a file. */
|
|
22
|
+
function isServerPath(p) {
|
|
23
|
+
return p.startsWith("file://") || p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pull the first server-side source frame out of a raw server stack (a 500 body,
|
|
27
|
+
* a Next.js dev error page, etc.). V8 emits one frame per line as
|
|
28
|
+
* `at <fn> (<path>:<line>:<col>)` or `at <path>:<line>:<col>`; we take the first
|
|
29
|
+
* frame whose path is not inside node_modules. Best-effort — returns null when
|
|
30
|
+
* the body carries no stack trace (e.g. an explicit
|
|
31
|
+
* `NextResponse.json(..., { status: 500 })`).
|
|
32
|
+
*/
|
|
33
|
+
export function extractServerFrame(stack) {
|
|
34
|
+
for (const raw of stack.split("\n")) {
|
|
35
|
+
const line = raw.trim();
|
|
36
|
+
const m = line.match(/\(?([^\s()"']+):(\d+):(\d+)\)?$/);
|
|
37
|
+
if (!m)
|
|
38
|
+
continue;
|
|
39
|
+
const filePath = m[1];
|
|
40
|
+
if (!isServerPath(filePath))
|
|
41
|
+
continue;
|
|
42
|
+
if (filePath.includes("node_modules"))
|
|
43
|
+
continue;
|
|
44
|
+
return { filePath, line: parseInt(m[2], 10), column: parseInt(m[3], 10) };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
19
48
|
function stripQuery(url) {
|
|
20
49
|
return url.split("?")[0];
|
|
21
50
|
}
|
|
@@ -29,6 +58,55 @@ function isFile(p) {
|
|
|
29
58
|
return false;
|
|
30
59
|
}
|
|
31
60
|
}
|
|
61
|
+
/** Secret-bearing filenames that must never be read, even inside the repo — a
|
|
62
|
+
* hostile sourcemap could otherwise point `source` at `.env`, an npmrc, or a
|
|
63
|
+
* private key and exfiltrate it into the report / PR comment. */
|
|
64
|
+
function isSensitive(p) {
|
|
65
|
+
const name = path.basename(p).toLowerCase();
|
|
66
|
+
// Dotfiles that hold secrets.
|
|
67
|
+
if (name === ".env" ||
|
|
68
|
+
name.startsWith(".env.") ||
|
|
69
|
+
name === ".npmrc" ||
|
|
70
|
+
name === ".yarnrc" ||
|
|
71
|
+
name === ".netrc" ||
|
|
72
|
+
name === ".htpasswd" ||
|
|
73
|
+
name === ".git-credentials") {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
// SSH / private keys.
|
|
77
|
+
if (/^id_(rsa|ed25519|ecdsa|dsa)(\..*)?$/.test(name))
|
|
78
|
+
return true;
|
|
79
|
+
// Certificate and keystore material.
|
|
80
|
+
if (/\.(pem|key|p12|pfx|jks|keystore|p8)$/.test(name))
|
|
81
|
+
return true;
|
|
82
|
+
// Names that advertise secrets.
|
|
83
|
+
if (/(credential|secret|service[-_]?account|private[-_]?key)/.test(name))
|
|
84
|
+
return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
/** Resolve `segments` under `root`, returning null if the result escapes the
|
|
88
|
+
* root — via `..` traversal or a symlink pointing outside it. This is the
|
|
89
|
+
* boundary that keeps sourcemap- and URL-derived paths from reading (or,
|
|
90
|
+
* downstream, healing) arbitrary files outside the repo. */
|
|
91
|
+
function resolveWithin(root, ...segments) {
|
|
92
|
+
const candidate = path.resolve(root, ...segments);
|
|
93
|
+
const rel = path.relative(root, candidate);
|
|
94
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
95
|
+
return null;
|
|
96
|
+
// Symlink escape: when the file exists, its real path must also stay inside.
|
|
97
|
+
try {
|
|
98
|
+
const realRoot = fs.realpathSync(root);
|
|
99
|
+
const realTarget = fs.realpathSync(candidate);
|
|
100
|
+
const realRel = path.relative(realRoot, realTarget);
|
|
101
|
+
if (realRel.startsWith("..") || path.isAbsolute(realRel))
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// realpath fails for not-yet-existing candidates — the lexical check above
|
|
106
|
+
// already ran and is sufficient for those.
|
|
107
|
+
}
|
|
108
|
+
return candidate;
|
|
109
|
+
}
|
|
32
110
|
/** Turns a sourcemap `source` value into candidate absolute paths to probe. */
|
|
33
111
|
function sourceCandidates(source, repoRoot) {
|
|
34
112
|
const cleaned = source
|
|
@@ -38,11 +116,13 @@ function sourceCandidates(source, repoRoot) {
|
|
|
38
116
|
.replace(/^\//, "")
|
|
39
117
|
.split("?")[0];
|
|
40
118
|
const prefixes = ["", "apps/web/", "src/", "app/"];
|
|
41
|
-
return prefixes
|
|
119
|
+
return prefixes
|
|
120
|
+
.map((p) => resolveWithin(repoRoot, p, cleaned))
|
|
121
|
+
.filter((c) => c !== null);
|
|
42
122
|
}
|
|
43
123
|
function locateFile(candidates) {
|
|
44
124
|
for (const c of candidates) {
|
|
45
|
-
if (isFile(c))
|
|
125
|
+
if (isFile(c) && !isSensitive(c))
|
|
46
126
|
return c;
|
|
47
127
|
}
|
|
48
128
|
return null;
|
|
@@ -56,18 +136,91 @@ export async function resolveFrame(frame, repoRoot) {
|
|
|
56
136
|
const relative = stripQuery(frame.url)
|
|
57
137
|
.replace(/^https?:\/\/[^/]+\//, "")
|
|
58
138
|
.replace(/^\//, "");
|
|
59
|
-
const directPath =
|
|
139
|
+
const directPath = resolveWithin(repoRoot, relative);
|
|
140
|
+
if (!directPath) {
|
|
141
|
+
return {
|
|
142
|
+
message: frame.message,
|
|
143
|
+
sourceFile: relative,
|
|
144
|
+
line: frame.line,
|
|
145
|
+
column: frame.column,
|
|
146
|
+
codeSnippet: `<file not accessible locally: ${relative}>`,
|
|
147
|
+
resolvedFrom: "unresolved",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
60
150
|
return {
|
|
61
151
|
message: frame.message,
|
|
62
152
|
sourceFile: path.relative(repoRoot, directPath),
|
|
63
153
|
line: frame.line,
|
|
64
154
|
column: frame.column,
|
|
65
155
|
codeSnippet: extractSnippet(directPath, frame.line),
|
|
66
|
-
resolvedFrom: isFile(directPath) ? "direct" : "unresolved",
|
|
156
|
+
resolvedFrom: isFile(directPath) && !isSensitive(directPath) ? "direct" : "unresolved",
|
|
67
157
|
};
|
|
68
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Resolve a server-side source frame (a filesystem path) to a repo-relative
|
|
161
|
+
* source location + snippet. Mirrors `resolveFrame`'s containment rules: a path
|
|
162
|
+
* outside the repo — via `..` traversal or a symlink pointing out — is never
|
|
163
|
+
* read. Server frames carry no sourcemap; a directly-readable file maps
|
|
164
|
+
* `resolvedFrom: "direct"`.
|
|
165
|
+
*/
|
|
166
|
+
export function resolveServerFrame(frame, repoRoot) {
|
|
167
|
+
let p = frame.filePath.replace(/^file:\/\//, "");
|
|
168
|
+
if (/^\/[A-Za-z]:[\\/]/.test(p))
|
|
169
|
+
p = p.slice(1); // /C:/x → C:/x (Windows-on-POSIX)
|
|
170
|
+
const abs = path.resolve(p);
|
|
171
|
+
const rel = path.relative(repoRoot, abs);
|
|
172
|
+
let contained = !(rel.startsWith("..") || path.isAbsolute(rel));
|
|
173
|
+
if (contained) {
|
|
174
|
+
try {
|
|
175
|
+
const realRoot = fs.realpathSync(repoRoot);
|
|
176
|
+
const realTarget = fs.realpathSync(abs);
|
|
177
|
+
const realRel = path.relative(realRoot, realTarget);
|
|
178
|
+
if (realRel.startsWith("..") || path.isAbsolute(realRel))
|
|
179
|
+
contained = false;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// realpath fails for a not-yet-existing candidate — the lexical check above suffices.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!contained) {
|
|
186
|
+
return {
|
|
187
|
+
message: frame.filePath,
|
|
188
|
+
sourceFile: abs,
|
|
189
|
+
line: frame.line,
|
|
190
|
+
column: frame.column,
|
|
191
|
+
codeSnippet: `<file not accessible locally: ${abs}>`,
|
|
192
|
+
resolvedFrom: "unresolved",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const resolvedFrom = isFile(abs) && !isSensitive(abs) ? "direct" : "unresolved";
|
|
196
|
+
return {
|
|
197
|
+
message: frame.filePath,
|
|
198
|
+
sourceFile: rel,
|
|
199
|
+
line: frame.line,
|
|
200
|
+
column: frame.column,
|
|
201
|
+
codeSnippet: extractSnippet(abs, frame.line),
|
|
202
|
+
resolvedFrom,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** True for a loopback hostname — the only place a sourcemap URL may point. */
|
|
206
|
+
function isLoopback(host) {
|
|
207
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
208
|
+
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0";
|
|
209
|
+
}
|
|
69
210
|
async function trySourceMap(frame, repoRoot) {
|
|
70
211
|
const mapUrl = stripQuery(frame.url) + ".map";
|
|
212
|
+
// SSRF guard: the sourcemap URL is derived from an untrusted stack frame, so
|
|
213
|
+
// refuse to fetch anything that isn't the local machine (this tool inspects
|
|
214
|
+
// local dev servers) before a single byte leaves the process.
|
|
215
|
+
let host;
|
|
216
|
+
try {
|
|
217
|
+
host = new URL(mapUrl).hostname;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (!isLoopback(host))
|
|
223
|
+
return null;
|
|
71
224
|
let rawMap;
|
|
72
225
|
try {
|
|
73
226
|
const res = await fetch(mapUrl);
|
|
@@ -108,7 +261,7 @@ async function trySourceMap(frame, repoRoot) {
|
|
|
108
261
|
}
|
|
109
262
|
}
|
|
110
263
|
export function extractSnippet(filePath, targetLine, window = 4) {
|
|
111
|
-
if (!isFile(filePath))
|
|
264
|
+
if (!isFile(filePath) || isSensitive(filePath))
|
|
112
265
|
return `<file not accessible locally: ${filePath}>`;
|
|
113
266
|
const lines = fs.readFileSync(filePath, "utf-8").split("\n");
|
|
114
267
|
const start = Math.max(0, targetLine - window - 1);
|
|
@@ -10,10 +10,11 @@ const js = (s) => JSON.stringify(s);
|
|
|
10
10
|
export function compileSpec(finding, actions, url) {
|
|
11
11
|
const title = finding.rawMessage.split("\n")[0].slice(0, 80) || "unknown error";
|
|
12
12
|
const needle = finding.rawMessage.split("\n")[0].slice(0, 120);
|
|
13
|
+
const hasRequest = actions.some((a) => a.type === "request");
|
|
13
14
|
const out = [];
|
|
14
15
|
out.push(`import { test, expect } from "@playwright/test";`);
|
|
15
16
|
out.push(``);
|
|
16
|
-
out.push(`// Aztrx repro — ${finding.id}`);
|
|
17
|
+
out.push(`// Aztrx AI repro — ${finding.id}`);
|
|
17
18
|
out.push(`// severity: ${finding.severity} type: ${finding.type}`);
|
|
18
19
|
if (finding.mappedLocation) {
|
|
19
20
|
out.push(`// source: ${finding.mappedLocation.filePath}:${finding.mappedLocation.line}:${finding.mappedLocation.column}`);
|
|
@@ -32,6 +33,16 @@ export function compileSpec(finding, actions, url) {
|
|
|
32
33
|
out.push(` await page.mouse.wheel(0, ${a.value === "up" ? -600 : 600});`);
|
|
33
34
|
continue;
|
|
34
35
|
}
|
|
36
|
+
if (a.type === "request" && a.request) {
|
|
37
|
+
out.push(` {`);
|
|
38
|
+
out.push(` const status = await page.evaluate(async (r) => {`);
|
|
39
|
+
out.push(` try { const resp = await fetch(r.url, { method: r.method, headers: r.headers, body: r.body }); return resp.status; }`);
|
|
40
|
+
out.push(` catch { return 0; }`);
|
|
41
|
+
out.push(` }, ${JSON.stringify(a.request)});`);
|
|
42
|
+
out.push(` expect(status).toBeGreaterThanOrEqual(500);`);
|
|
43
|
+
out.push(` }`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
35
46
|
const sel = a.selectors[0];
|
|
36
47
|
if (!sel) {
|
|
37
48
|
out.push(` // (skipped — no reliable selector for this step)`);
|
|
@@ -56,7 +67,11 @@ export function compileSpec(finding, actions, url) {
|
|
|
56
67
|
break;
|
|
57
68
|
}
|
|
58
69
|
}
|
|
59
|
-
|
|
70
|
+
if (!hasRequest) {
|
|
71
|
+
// A server-side 5xx produces no `pageerror`/`console.error` — the per-request
|
|
72
|
+
// status assertion above is the proof instead, so skip the error poll.
|
|
73
|
+
out.push(` await expect.poll(() => errors.join("\\n"), { timeout: 5000 }).toContain(${js(needle)});`);
|
|
74
|
+
}
|
|
60
75
|
out.push(`});`);
|
|
61
76
|
return out.join("\n") + "\n";
|
|
62
77
|
}
|
package/dist/core/studio.js
CHANGED
|
@@ -29,7 +29,6 @@ function sseHandler(req, res, eventsFile) {
|
|
|
29
29
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
30
30
|
"Cache-Control": "no-cache",
|
|
31
31
|
Connection: "keep-alive",
|
|
32
|
-
"Access-Control-Allow-Origin": "*",
|
|
33
32
|
});
|
|
34
33
|
res.write(`data: ${JSON.stringify({ type: "hello" })}\n\n`);
|
|
35
34
|
let offset = 0;
|
|
@@ -166,7 +165,7 @@ function dashboardHtml() {
|
|
|
166
165
|
<head>
|
|
167
166
|
<meta charset="utf-8">
|
|
168
167
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
169
|
-
<title>Aztrx Studio</title>
|
|
168
|
+
<title>Aztrx AI Studio</title>
|
|
170
169
|
<style>${BASE_CSS}</style>
|
|
171
170
|
</head>
|
|
172
171
|
<body>
|
|
@@ -209,8 +208,8 @@ export function startStudio(opts) {
|
|
|
209
208
|
return sendFile(res, path.join(aztrxDir, "repro", path.basename(url)), "text/plain; charset=utf-8");
|
|
210
209
|
return send(res, 404, "text/plain; charset=utf-8", "not found");
|
|
211
210
|
});
|
|
212
|
-
server.listen(port, () => {
|
|
213
|
-
console.log(pc.cyan("\
|
|
211
|
+
server.listen(port, "127.0.0.1", () => {
|
|
212
|
+
console.log(pc.cyan("\nAztrx AI Studio"));
|
|
214
213
|
console.log(pc.dim(` → http://localhost:${port}`));
|
|
215
214
|
console.log(pc.dim(` watching ${path.relative(process.cwd(), eventsFile)}`));
|
|
216
215
|
console.log(pc.dim(" Ctrl+C to stop\n"));
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F13 — the "X-ray report": a plain-language summary of a run's findings,
|
|
3
|
+
* instead of a wall of stack traces. Two engines behind one entry point:
|
|
4
|
+
*
|
|
5
|
+
* - LLM (Anthropic Messages API) when `ANTHROPIC_API_KEY` is set — friendly
|
|
6
|
+
* prose in the requested language, mirroring the transport in `heal/llm.ts`.
|
|
7
|
+
* - deterministic template when there is no key (or the call fails) — a
|
|
8
|
+
* readable, structured list that needs no network.
|
|
9
|
+
*
|
|
10
|
+
* An empty run short-circuits to the template (no reason to pay for "all clear").
|
|
11
|
+
* The offer-to-apply line is emitted only when verified fixes are actually ready,
|
|
12
|
+
* so `--explain` (no healing) never promises a fix it doesn't have.
|
|
13
|
+
*/
|
|
14
|
+
const MODEL = process.env.AZTRX_MODEL || "claude-sonnet-5";
|
|
15
|
+
const API_URL = "https://api.anthropic.com/v1/messages";
|
|
16
|
+
function normalizeLang(lang) {
|
|
17
|
+
return lang === "ru" ? "ru" : "en";
|
|
18
|
+
}
|
|
19
|
+
/** Russian plural form picker: [one, few, many]. */
|
|
20
|
+
function ruPlural(n, forms) {
|
|
21
|
+
const mod10 = n % 10;
|
|
22
|
+
const mod100 = n % 100;
|
|
23
|
+
if (mod10 === 1 && mod100 !== 11)
|
|
24
|
+
return forms[0];
|
|
25
|
+
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
|
|
26
|
+
return forms[1];
|
|
27
|
+
return forms[2];
|
|
28
|
+
}
|
|
29
|
+
const PHRASES = {
|
|
30
|
+
en: {
|
|
31
|
+
none: "No problems found — the app survived this pass.",
|
|
32
|
+
found: (n) => `I scanned your code and found ${n} problem${n === 1 ? "" : "s"}:`,
|
|
33
|
+
offer: (n) => `I've prepared ${n} verified fix${n === 1 ? "" : "es"}.`,
|
|
34
|
+
severity: { crash: "crash", error: "error", warning: "warning", noise: "noise" },
|
|
35
|
+
type: {
|
|
36
|
+
uncaught_exception: "uncaught exception",
|
|
37
|
+
unhandled_rejection: "unhandled promise rejection",
|
|
38
|
+
console_error: "console error",
|
|
39
|
+
network_5xx: "server error (5xx)",
|
|
40
|
+
network_timeout: "network timeout",
|
|
41
|
+
},
|
|
42
|
+
repro: "repro",
|
|
43
|
+
fix: "fix",
|
|
44
|
+
healed: "healed",
|
|
45
|
+
},
|
|
46
|
+
ru: {
|
|
47
|
+
none: "Проблем не найдено — приложение пережило этот проход.",
|
|
48
|
+
found: (n) => `Привет! Я просканировал твой код и нашёл ${n} ${ruPlural(n, ["проблему", "проблемы", "проблем"])}:`,
|
|
49
|
+
offer: (n) => `Я подготовил ${n} ${ruPlural(n, ["исправление", "исправления", "исправлений"])}, каждое проверено тестами.`,
|
|
50
|
+
severity: { crash: "краш", error: "ошибка", warning: "предупреждение", noise: "шум" },
|
|
51
|
+
type: {
|
|
52
|
+
uncaught_exception: "необработанное исключение",
|
|
53
|
+
unhandled_rejection: "необработанный reject промиса",
|
|
54
|
+
console_error: "ошибка в консоли",
|
|
55
|
+
network_5xx: "ошибка сервера (5xx)",
|
|
56
|
+
network_timeout: "таймаут сети",
|
|
57
|
+
},
|
|
58
|
+
repro: "воспроизведение",
|
|
59
|
+
fix: "фикс",
|
|
60
|
+
healed: "вылечено",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
function location(f) {
|
|
64
|
+
const m = f.mappedLocation;
|
|
65
|
+
if (!m || !m.filePath)
|
|
66
|
+
return "";
|
|
67
|
+
return `${m.filePath}:${m.line}:${m.column}`;
|
|
68
|
+
}
|
|
69
|
+
function shortMessage(f) {
|
|
70
|
+
const line = f.rawMessage.split("\n")[0].trim();
|
|
71
|
+
return line.length > 120 ? line.slice(0, 117) + "…" : line;
|
|
72
|
+
}
|
|
73
|
+
/** One bullet for the template engine: location, kind, and the one-line message. */
|
|
74
|
+
function describeFinding(f, p) {
|
|
75
|
+
const loc = location(f);
|
|
76
|
+
const head = loc ? `${loc} — ` : "";
|
|
77
|
+
const kind = `${p.type[f.type]} (${p.severity[f.severity]})`;
|
|
78
|
+
const lines = [`${head}${kind}: "${shortMessage(f)}"`];
|
|
79
|
+
const tail = [];
|
|
80
|
+
if (f.repro && f.repro.verdict !== "unreliable") {
|
|
81
|
+
tail.push(`${p.repro}: ${f.repro.verdict} (${f.repro.reproductions}/${f.repro.runs})`);
|
|
82
|
+
}
|
|
83
|
+
if (f.heal) {
|
|
84
|
+
tail.push(`${p.fix}: ${f.heal.status === "healed" ? p.healed : f.heal.status}`);
|
|
85
|
+
}
|
|
86
|
+
if (tail.length)
|
|
87
|
+
lines.push(` ${tail.join(" · ")}`);
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
/** Deterministic, offline summary. Used as the no-key fallback and for empty runs. */
|
|
91
|
+
export function summarizeFindingsTemplate(findings, lang = "en") {
|
|
92
|
+
const p = PHRASES[lang];
|
|
93
|
+
if (findings.length === 0)
|
|
94
|
+
return p.none;
|
|
95
|
+
const healed = findings.filter((f) => f.heal?.status === "healed");
|
|
96
|
+
const bullets = findings
|
|
97
|
+
.map((f, i) => ` ${i + 1}. ${describeFinding(f, p)}`)
|
|
98
|
+
.join("\n");
|
|
99
|
+
const lines = [p.found(findings.length), "", bullets];
|
|
100
|
+
if (healed.length)
|
|
101
|
+
lines.push("", p.offer(healed.length));
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
function buildLlmPrompt(findings, lang, hasHealed) {
|
|
105
|
+
const rows = findings
|
|
106
|
+
.map((f) => {
|
|
107
|
+
const loc = location(f) || "(no source location)";
|
|
108
|
+
const repro = f.repro ? `${f.repro.verdict} ${f.repro.reproductions}/${f.repro.runs}` : "none";
|
|
109
|
+
const heal = f.heal
|
|
110
|
+
? f.heal.status + (f.heal.explanation ? ` — ${f.heal.explanation}` : "")
|
|
111
|
+
: "n/a";
|
|
112
|
+
return `- ${loc} | ${f.type} | severity=${f.severity} | "${shortMessage(f)}" | repro=${repro} | heal=${heal}`;
|
|
113
|
+
})
|
|
114
|
+
.join("\n");
|
|
115
|
+
const fixLine = hasHealed
|
|
116
|
+
? "Verified fixes ARE ready to apply — mention that they are prepared."
|
|
117
|
+
: "No fixes were prepared — do not offer to apply anything.";
|
|
118
|
+
return [
|
|
119
|
+
`A QA tool scanned a web app and found the following findings:`,
|
|
120
|
+
rows,
|
|
121
|
+
"",
|
|
122
|
+
`Write a short, friendly, plain-language summary for a developer (${lang}): what was found, what each problem means in simple words, and — per the note below — whether fixes are ready. Do not invent details that are not listed. Keep it to a few short paragraphs or a tight bullet list.`,
|
|
123
|
+
fixLine,
|
|
124
|
+
].join("\n");
|
|
125
|
+
}
|
|
126
|
+
const SYSTEM = "You are the plain-spoken explainer for a QA tool called Aztrx AI. You turn raw runtime-finding data into a concise, human-language summary for a developer. Never invent details absent from the data. Respond in the requested language only.";
|
|
127
|
+
async function summarizeFindingsLlm(findings, lang) {
|
|
128
|
+
const hasHealed = findings.some((f) => f.heal?.status === "healed");
|
|
129
|
+
const res = await fetch(API_URL, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: {
|
|
132
|
+
"content-type": "application/json",
|
|
133
|
+
"x-api-key": process.env.ANTHROPIC_API_KEY,
|
|
134
|
+
"anthropic-version": "2023-06-01",
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
model: MODEL,
|
|
138
|
+
max_tokens: 1024,
|
|
139
|
+
temperature: 0.2,
|
|
140
|
+
system: SYSTEM,
|
|
141
|
+
messages: [{ role: "user", content: buildLlmPrompt(findings, lang, hasHealed) }],
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const body = await res.text().catch(() => "");
|
|
146
|
+
throw new Error(`summarize: LLM request failed (${res.status}): ${body.slice(0, 300)}`);
|
|
147
|
+
}
|
|
148
|
+
const data = (await res.json());
|
|
149
|
+
const text = (data.content ?? [])
|
|
150
|
+
.filter((c) => c.type === "text")
|
|
151
|
+
.map((c) => c.text ?? "")
|
|
152
|
+
.join("\n")
|
|
153
|
+
.trim();
|
|
154
|
+
return text || summarizeFindingsTemplate(findings, lang);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Entry point. Empty run → template (no API cost). Otherwise LLM when a key is
|
|
158
|
+
* present; falls back to the deterministic template on any transport failure, so
|
|
159
|
+
* the report never crashes the run.
|
|
160
|
+
*/
|
|
161
|
+
export async function summarizeFindings(findings, opts = {}) {
|
|
162
|
+
const lang = normalizeLang(opts.lang);
|
|
163
|
+
if (findings.length === 0)
|
|
164
|
+
return summarizeFindingsTemplate(findings, lang);
|
|
165
|
+
if (!process.env.ANTHROPIC_API_KEY)
|
|
166
|
+
return summarizeFindingsTemplate(findings, lang);
|
|
167
|
+
try {
|
|
168
|
+
return await summarizeFindingsLlm(findings, lang);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return summarizeFindingsTemplate(findings, lang);
|
|
172
|
+
}
|
|
173
|
+
}
|