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.
@@ -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.map((p) => path.resolve(repoRoot, p, cleaned));
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 = path.resolve(repoRoot, relative);
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
- out.push(` await expect.poll(() => errors.join("\\n"), { timeout: 5000 }).toContain(${js(needle)});`);
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
  }
@@ -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("\n⚡ Aztrx Studio"));
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
+ }