aztrx-cli 0.3.0 → 0.4.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.
@@ -19,12 +19,19 @@ export function modelTiers(fallbackModel, fastFallback) {
19
19
  const primary = fallbackModel || primaryModel();
20
20
  if (!primary)
21
21
  return []; // no model configured — the caller reports no-llm
22
- if (!fast || fast === primary)
23
- return [{ model: primary, label: "sonnet" }];
24
- return [
25
- { model: fast, label: "fast" },
26
- { model: primary, label: "sonnet" },
27
- ];
22
+ const tiers = [];
23
+ if (fast && fast !== primary)
24
+ tiers.push({ model: fast, label: "fast" });
25
+ tiers.push({ model: primary, label: "sonnet" });
26
+ // Extra models for multi-model consensus (`AZTRX_CONSENSUS_MODELS`), deduped.
27
+ for (const m of (process.env.AZTRX_CONSENSUS_MODELS || "")
28
+ .split(",")
29
+ .map((s) => s.trim())
30
+ .filter(Boolean)) {
31
+ if (!tiers.some((t) => t.model === m))
32
+ tiers.push({ model: m, label: "sonnet" });
33
+ }
34
+ return tiers;
28
35
  }
29
36
  const SYSTEM = `You are a meticulous bug-fixing engineer. You are given a single source file and a runtime error that occurs in it. Produce a MINIMAL fix as a Search & Replace diff.
30
37
 
@@ -71,9 +78,48 @@ export function parsePatch(raw) {
71
78
  .map((e) => ({ search: e.search, replace: e.replace }));
72
79
  return { explanation: typeof data.explanation === "string" ? data.explanation : "", hunks };
73
80
  }
81
+ /** Sentinel model name for the free, no-key rule-based fixer. */
82
+ export const RULE_TIER = "__rule__";
83
+ // Matches "Cannot read properties of undefined|null (reading 'X')".
84
+ const NULL_DEREF = /Cannot read properties of (undefined|null)(?: \(reading '([^']+)'\))?/;
85
+ /**
86
+ * Rule-based fix for the most common crash — a null/undefined property access.
87
+ * Adds `?.` (optional chaining) at the failing access. Returns null when the
88
+ * error isn't a null/undefined deref or the line can't be located. Free and
89
+ * offline: no LLM, no key, no network — so `--fix` works out of the box for the
90
+ * most frequent frontend crashes.
91
+ */
92
+ export function generateRulePatch(ctx) {
93
+ const m = ctx.finding.rawMessage.match(NULL_DEREF);
94
+ if (!m)
95
+ return null;
96
+ const kind = m[1]; // "undefined" | "null"
97
+ const prop = m[2]; // the property that was read
98
+ const line = ctx.finding.mappedLocation?.line;
99
+ if (!prop || line == null)
100
+ return null;
101
+ const src = ctx.fileContent.split("\n")[line - 1];
102
+ if (!src || !src.includes("." + prop))
103
+ return null;
104
+ // Optional-chain every `.identifier` access on the line (not just the failing
105
+ // one) so a chain like `d.agents.map(…)` becomes `d?.agents?.map(…)`.
106
+ const replace = src.replace(/\.(?=[a-zA-Z_$])/g, "?.");
107
+ if (replace === src)
108
+ return null;
109
+ return {
110
+ explanation: `Guard against a ${kind} access on \`.${prop}\` with optional chaining.`,
111
+ hunks: [{ search: src, replace }],
112
+ };
113
+ }
74
114
  export async function generatePatch(ctx, opts = {}) {
75
115
  if (opts.patchFn)
76
116
  return opts.patchFn(ctx);
117
+ if (opts.model === RULE_TIER) {
118
+ const rulePatch = generateRulePatch(ctx);
119
+ if (rulePatch)
120
+ return rulePatch;
121
+ throw new Error("no rule-based fix applicable");
122
+ }
77
123
  const text = await complete({
78
124
  system: SYSTEM,
79
125
  prompt: buildPrompt(ctx),
@@ -32,18 +32,21 @@ export function attachInterceptor(page, bus) {
32
32
  if (msg.type() !== "error")
33
33
  return;
34
34
  const text = msg.text();
35
- // The init script routes `unhandledrejection` through console.error with a
36
- // fixed prefix; recover the true signal type so Server Action failures and
37
- // other promise rejections classify as "error", not "warning".
38
- const type = text.startsWith("Unhandled Promise Rejection:")
39
- ? "unhandled_rejection"
40
- : "console_error";
35
+ // Pull the real Error (and its stack) out of the console args. React 18 and
36
+ // Next.js log a thrown error as `console.error(error)` the Error object is
37
+ // an argument, not part of `msg.text()`. Match on `:line:col` (not "http")
38
+ // so Next.js dev stacks (`webpack-internal:///…`) are recognised too.
41
39
  let source = text;
40
+ let hasErrorArg = false;
42
41
  for (const arg of msg.args()) {
43
42
  try {
44
- const s = await arg.evaluate((a) => a instanceof Error ? a.stack || String(a) : String(a));
45
- if (s.includes("http")) {
46
- source = s;
43
+ const info = await arg.evaluate((a) => a instanceof Error
44
+ ? { isError: true, value: a.stack || String(a) }
45
+ : { isError: false, value: String(a) });
46
+ if (info.isError)
47
+ hasErrorArg = true;
48
+ if (/:\d+:\d+/.test(info.value)) {
49
+ source = info.value;
47
50
  break;
48
51
  }
49
52
  }
@@ -51,6 +54,11 @@ export function attachInterceptor(page, bus) {
51
54
  // non-serializable arg — keep msg.text()
52
55
  }
53
56
  }
57
+ // A rejection the init script forwarded, or a thrown Error logged by React —
58
+ // both are real errors, not benign console warnings.
59
+ const type = text.startsWith("Unhandled Promise Rejection:") || hasErrorArg
60
+ ? "unhandled_rejection"
61
+ : "console_error";
54
62
  const loc = msg.location();
55
63
  const frame = extractFrame(source) ??
56
64
  ({ url: loc.url, line: loc.lineNumber, column: loc.columnNumber, message: text.split("\n")[0].slice(0, 200) });
package/dist/core/llm.js CHANGED
@@ -44,6 +44,26 @@ export function fastModel() {
44
44
  return process.env.AZTRX_FAST_MODEL || "claude-haiku-4-5-20251001";
45
45
  return process.env.AZTRX_FAST_MODEL || undefined;
46
46
  }
47
+ /** Human-readable description of the active provider + model, e.g. `grok-2 via https://api.x.ai/v1`. */
48
+ export function describeLlm(model) {
49
+ const s = resolveSettings();
50
+ const m = model || primaryModel();
51
+ if (s.provider === "anthropic")
52
+ return `${m} (Anthropic)`;
53
+ return `${m} via ${s.baseUrl}`;
54
+ }
55
+ // Announce the resolved model once per distinct (provider, model), so the two-tier
56
+ // router shows each tier as it's tried without spamming. Written to stderr so it never
57
+ // corrupts the Ink TUI (which renders on stdout).
58
+ const announced = new Set();
59
+ function announce(model, provider, baseUrl) {
60
+ const key = `${provider}:${model}`;
61
+ if (announced.has(key))
62
+ return;
63
+ announced.add(key);
64
+ const label = provider === "anthropic" ? `${model} (Anthropic)` : `${model} via ${baseUrl}`;
65
+ process.stderr.write(`LLM: ${label}\n`);
66
+ }
47
67
  /** Run one completion against the active provider and return the text. */
48
68
  export async function complete(opts) {
49
69
  const s = resolveSettings();
@@ -56,18 +76,24 @@ export async function complete(opts) {
56
76
  ? "ANTHROPIC_API_KEY is not set"
57
77
  : "AZTRX_API_KEY (or OPENAI_API_KEY) is not set");
58
78
  }
79
+ announce(model, s.provider, s.baseUrl);
59
80
  return s.provider === "anthropic"
60
81
  ? anthropicComplete(s, model, opts)
61
82
  : openaiComplete(s, model, opts);
62
83
  }
63
84
  async function anthropicComplete(s, model, opts) {
85
+ const headers = {
86
+ "content-type": "application/json",
87
+ "x-api-key": s.apiKey,
88
+ "anthropic-version": "2023-06-01",
89
+ };
90
+ // Identity-linked API keys must name the workspace they act in.
91
+ const workspaceId = process.env.ANTHROPIC_WORKSPACE_ID;
92
+ if (workspaceId)
93
+ headers["anthropic-workspace-id"] = workspaceId;
64
94
  const res = await fetch("https://api.anthropic.com/v1/messages", {
65
95
  method: "POST",
66
- headers: {
67
- "content-type": "application/json",
68
- "x-api-key": s.apiKey,
69
- "anthropic-version": "2023-06-01",
70
- },
96
+ headers,
71
97
  body: JSON.stringify({
72
98
  model,
73
99
  max_tokens: opts.maxTokens ?? 2048,
@@ -86,7 +86,7 @@ export async function modernizeFile(repoRoot, filePath) {
86
86
  return { ok: false, original: "", changes: [], error: `cannot read ${filePath}: ${e.message}` };
87
87
  }
88
88
  if (!hasLlmKey()) {
89
- return { ok: false, original, changes: [], lang, error: "no LLM API key is set (set ANTHROPIC_API_KEY, AZTRX_API_KEY, or AZTRX_API_BASE)" };
89
+ return { ok: false, original, changes: [], lang, error: "modernize needs a model. Add a key: Anthropic ANTHROPIC_API_KEY, or any provider → AZTRX_API_BASE + AZTRX_API_KEY + AZTRX_MODEL" };
90
90
  }
91
91
  let reply;
92
92
  try {
@@ -97,7 +97,7 @@ export async function run(options) {
97
97
  else {
98
98
  emitPhase("walk");
99
99
  }
100
- const { findings, replayStorageState: swarmAuthState, totalActions, workerCount, } = await swarmDetect({
100
+ const { findings, replayStorageState: swarmAuthState, totalActions, totalCoverage, workerCount, roles, sawLoginForm, } = await swarmDetect({
101
101
  url,
102
102
  repoRoot,
103
103
  maxActions,
@@ -128,10 +128,16 @@ export async function run(options) {
128
128
  printFinding(f, say);
129
129
  }
130
130
  if (workerCount > 1) {
131
- say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s).\n`));
131
+ say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s) — ${roles.join(", ")}.\n`));
132
+ }
133
+ else if (options.fuzz) {
134
+ say(pc.dim(`\nFuzzed ${totalActions} action(s) — covered ${totalCoverage} new code range(s).\n`));
132
135
  }
133
136
  else {
134
- say(pc.dim(`\n${options.fuzz ? "Fuzzed" : "Walked"} ${totalActions} action(s).\n`));
137
+ say(pc.dim(`\nWalked ${totalActions} action(s).\n`));
138
+ }
139
+ if (sawLoginForm && !options.login) {
140
+ say(pc.yellow("Hint: this app has a login form — re-run with --login to test the authenticated app."));
135
141
  }
136
142
  // F7 → F8 → F9: minimize each finding, compile an executable spec, validate
137
143
  // the flake rate. Only crash/error findings with a recorded action history.
@@ -310,14 +316,19 @@ export async function run(options) {
310
316
  // F12 — opt-in cloud sync. Streams the sanitized run results to the ingest
311
317
  // API for the team dashboard; dedup happens server-side by fingerprint.
312
318
  if (options.upload) {
313
- submitRun(findings, {
314
- repoRoot,
315
- url,
316
- apiKey: options.apiKey,
317
- endpoint: options.cloudUrl,
318
- mode: runMode(options),
319
- counts,
320
- });
319
+ if (options.apiKey || process.env.AZTRX_API_KEY) {
320
+ submitRun(findings, {
321
+ repoRoot,
322
+ url,
323
+ apiKey: options.apiKey,
324
+ endpoint: options.cloudUrl,
325
+ mode: runMode(options),
326
+ counts,
327
+ });
328
+ }
329
+ else {
330
+ say(pc.yellow("Upload skipped: no API key. Set --api-key or AZTRX_API_KEY to stream findings to the dashboard."));
331
+ }
321
332
  }
322
333
  runLog.append({ type: "run_end", counts, ts: Date.now() });
323
334
  say(pc.dim("────────────────────────────────────────────"));
@@ -20,3 +20,15 @@ export function promptYesNo(question, opts = {}) {
20
20
  });
21
21
  });
22
22
  }
23
+ /** A single-line text prompt. Returns "" when stdout isn't a TTY (unattended run). */
24
+ export function promptInput(question) {
25
+ if (process.stdout.isTTY !== true)
26
+ return Promise.resolve("");
27
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
28
+ return new Promise((resolve) => {
29
+ rl.question(question + " ", (answer) => {
30
+ rl.close();
31
+ resolve(answer.trim());
32
+ });
33
+ });
34
+ }
@@ -1,20 +1,28 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { TraceMap, originalPositionFor, } from "@jridgewell/trace-mapping";
4
+ /** Framework-internal frames to skip when hunting the throw site. */
5
+ const FRAMEWORK_FRAME = /node_modules|webpack-runtime|\.next[\\/]|next[\\/]dist[\\/]/;
4
6
  /**
5
- * Pulls the first `url:line:col` frame out of a stack string. Handles React
6
- * Error Boundary console text, which embeds the original stack in its body.
7
+ * Pulls the first *user-code* frame out of a stack string. Iterates every line,
8
+ * skips framework internals (webpack runtime, node_modules, next/dist), and
9
+ * returns the first frame in the user's own code — so a Next.js dev stack like
10
+ * `webpack-internal:///(app-pages-browser)/./app/page.tsx:29:21` resolves to the
11
+ * user's file, not `intercept-console-error.js`.
7
12
  */
8
13
  export function extractFrame(text) {
9
- const match = text.match(/(https?:\/\/[^\s)"']+?):(\d+):(\d+)/);
10
- if (!match)
11
- return null;
12
- return {
13
- url: match[1],
14
- line: parseInt(match[2], 10),
15
- column: parseInt(match[3], 10),
16
- message: text.split("\n")[0].trim().slice(0, 200),
17
- };
14
+ const message = text.split("\n")[0].trim().slice(0, 200);
15
+ for (const raw of text.split("\n")) {
16
+ // V8 frame: "at fn (url:line:col)" or "at url:line:col".
17
+ const m = raw.trim().match(/^(?:at\s+)?(?:\S+\s+\()?(.+?):(\d+):(\d+)\)?$/);
18
+ if (!m)
19
+ continue;
20
+ const url = m[1];
21
+ if (!url || FRAMEWORK_FRAME.test(url))
22
+ continue;
23
+ return { url, line: parseInt(m[2], 10), column: parseInt(m[3], 10), message };
24
+ }
25
+ return null;
18
26
  }
19
27
  /** True if `p` looks like an absolute source path — a `file://` URL, a POSIX
20
28
  * absolute path, or a Windows drive path. Rejects bare relative tokens like
@@ -48,6 +56,20 @@ export function extractServerFrame(stack) {
48
56
  function stripQuery(url) {
49
57
  return url.split("?")[0];
50
58
  }
59
+ /** Normalize a stack-frame URL to a repo-relative source path. Handles the
60
+ * dev-server schemes (`webpack-internal:///(ns)/./src/…`, `webpack://ns/src/…`),
61
+ * `file://`, and plain `https://host/path` bundle URLs. */
62
+ function normalizeFrameUrl(url) {
63
+ return stripQuery(url)
64
+ .replace(/^webpack-internal:\/\/\/[^/]+\/\.\//, "")
65
+ .replace(/^webpack:\/\/[^/]+\//, "")
66
+ .replace(/^webpack:\/\//, "")
67
+ .replace(/^\/@fs\//, "")
68
+ .replace(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
69
+ .replace(/^file:\/\//, "")
70
+ .replace(/^https?:\/\/[^/]+\//, "")
71
+ .replace(/^\//, "");
72
+ }
51
73
  /** True only for a real, readable regular file — directories and unreadable
52
74
  * paths return false so readers never hit `EISDIR` / permission errors. */
53
75
  function isFile(p) {
@@ -131,11 +153,9 @@ export async function resolveFrame(frame, repoRoot) {
131
153
  const viaMap = await trySourceMap(frame, repoRoot);
132
154
  if (viaMap)
133
155
  return viaMap;
134
- // Fallback: Vite dev serves real source files at their URL path, so the
135
- // bundle URL is already the source path — no sourcemap needed.
136
- const relative = stripQuery(frame.url)
137
- .replace(/^https?:\/\/[^/]+\//, "")
138
- .replace(/^\//, "");
156
+ // Fallback: dev servers (Vite, Next) serve real source files at their URL
157
+ // path, so the bundle URL is already the source path — no sourcemap needed.
158
+ const relative = normalizeFrameUrl(frame.url);
139
159
  const directPath = resolveWithin(repoRoot, relative);
140
160
  if (!directPath) {
141
161
  return {
@@ -82,3 +82,34 @@ export function writeSpec(repoRoot, finding, actions, url) {
82
82
  fs.writeFileSync(file, compileSpec(finding, actions, url), "utf-8");
83
83
  return file;
84
84
  }
85
+ /**
86
+ * Install validated repro specs into the project's test directory so a fixed
87
+ * bug can't silently regress — the "immunity" step after find → prove → fix.
88
+ * Copies each deterministic/flaky repro into `<dir>/aztrx-<fingerprint>.spec.ts`
89
+ * and returns the written paths. `dir` defaults to an existing test dir, else a
90
+ * gitignored fallback.
91
+ */
92
+ export function writeRegressionSpecs(repoRoot, findings, dir) {
93
+ const target = path.resolve(repoRoot, dir || detectTestDir(repoRoot));
94
+ fs.mkdirSync(target, { recursive: true });
95
+ const written = [];
96
+ for (const f of findings) {
97
+ if (!f.repro?.specPath || f.repro.verdict === "unreliable")
98
+ continue;
99
+ const src = path.resolve(repoRoot, f.repro.specPath);
100
+ if (!fs.existsSync(src))
101
+ continue;
102
+ const dest = path.join(target, `aztrx-${f.fingerprint.slice(0, 8)}.spec.ts`);
103
+ fs.copyFileSync(src, dest);
104
+ written.push(dest);
105
+ }
106
+ return written;
107
+ }
108
+ /** Prefer an existing test dir, else a gitignored fallback. */
109
+ function detectTestDir(repoRoot) {
110
+ for (const d of ["e2e", "tests", "test", "__tests__"]) {
111
+ if (fs.existsSync(path.join(repoRoot, d)))
112
+ return d;
113
+ }
114
+ return ".aztrx/regression";
115
+ }
@@ -130,12 +130,18 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
130
130
  await page.waitForTimeout(800);
131
131
  }
132
132
  let actions = 0;
133
+ let newCoverage = 0;
134
+ let sawLoginForm = false;
133
135
  if (loaded) {
134
136
  if (strategy.kind === "walk") {
135
- actions = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
137
+ const wr = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
138
+ actions = wr.actions;
139
+ sawLoginForm = wr.sawLoginForm;
136
140
  }
137
141
  else if (strategy.kind === "fuzz") {
138
- actions = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
142
+ const fr = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
143
+ actions = fr.actions;
144
+ newCoverage = fr.newCoverage;
139
145
  }
140
146
  else {
141
147
  actions = await httpFuzz(page, opts.url, workerBus, {
@@ -148,7 +154,7 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
148
154
  }
149
155
  await page.waitForTimeout(500);
150
156
  await context.close();
151
- return { findings: classifier.findings(), actions, replayStorageState };
157
+ return { findings: classifier.findings(), actions, newCoverage, sawLoginForm, replayStorageState };
152
158
  }
153
159
  /** Dedup findings across workers by fingerprint: sum occurrences, keep the richest. */
154
160
  export function mergeFindings(arrays) {
@@ -191,6 +197,17 @@ function buildStrategies(opts) {
191
197
  }
192
198
  return strategies;
193
199
  }
200
+ /** Human-readable label for a worker's role in the swarm. */
201
+ function strategyLabel(s) {
202
+ switch (s.kind) {
203
+ case "walk":
204
+ return "walk";
205
+ case "http-fuzz":
206
+ return "http-fuzz";
207
+ case "fuzz":
208
+ return `fuzz seed ${s.seed}`;
209
+ }
210
+ }
194
211
  /** Launch one browser, run the worker roster concurrently, merge findings. */
195
212
  export async function swarmDetect(opts) {
196
213
  const strategies = buildStrategies(opts);
@@ -227,7 +244,16 @@ export async function swarmDetect(opts) {
227
244
  replayStorageState = r.replayStorageState;
228
245
  const findings = mergeFindings(results.map((r) => r.findings));
229
246
  const totalActions = results.reduce((sum, r) => sum + r.actions, 0);
230
- return { findings, replayStorageState, totalActions, workerCount: strategies.length };
247
+ const totalCoverage = results.reduce((sum, r) => sum + r.newCoverage, 0);
248
+ return {
249
+ findings,
250
+ replayStorageState,
251
+ totalActions,
252
+ totalCoverage,
253
+ workerCount: strategies.length,
254
+ roles: strategies.map(strategyLabel),
255
+ sawLoginForm: results.some((r) => r.sawLoginForm),
256
+ };
231
257
  }
232
258
  finally {
233
259
  await browser.close();
package/media/logo.png ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aztrx-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "description": "Aztrx AI — runtime stress-tester for web apps. Detect bugs, then prove them with an executable repro.",
6
6
  "license": "Apache-2.0",
@@ -32,7 +32,7 @@
32
32
  ],
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "https://github.com/DanisChaparov/aztrx.git"
35
+ "url": "https://github.com/Aztrx-AI/aztrx.git"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc",