aztrx-cli 0.5.1 → 0.5.2

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 CHANGED
@@ -478,7 +478,7 @@ The commands that are not `run`:
478
478
  | `--regression-test [dir]` | Copy validated repro specs into the project test dir | first of `e2e/`, `tests/`, `test/`, `__tests__/`; else `.aztrx/regression/` |
479
479
  | `--telemetry` | Collect anonymized tuples locally (opt-in) | — |
480
480
  | `--share-data` | Also upload the sanitized tuples (opt-in) | — |
481
- | `--repo <path>` | Root path for sourcemap → source resolution | cwd |
481
+ | `--repo <path>` | Root path for sourcemap → source resolution — must already exist (a path that does not is refused, not created) | cwd |
482
482
  | `--allow-host <host>` | Add a host to the network allow-list (repeatable) | — |
483
483
  | `--storage-state <path>` | Playwright storage-state for authenticated pages | — |
484
484
  | `--auth <path>` | Hidden alias for `--storage-state` | — |
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `--repo` is checked, not trusted.
3
+ *
4
+ * Commander consumes a `<required>` option argument even when the argument
5
+ * *looks like a flag*: `aztrx run --repo --fix` sets `repo` to the literal
6
+ * string `--fix` — measured against commander 12 rather than assumed. From
7
+ * there `path.resolve` turns it into `<cwd>/--fix`, and the first
8
+ * `mkdirSync(..., { recursive: true })` on the run path is happy to create it.
9
+ * So a mistyped invocation does not fail, it *succeeds* — against a project
10
+ * directory the tool invented — and leaves its `.aztrx/` artifacts inside.
11
+ *
12
+ * That is not hypothetical: `C:\Users\dchap\--fix\` existed holding nothing but
13
+ * `.aztrx/`, which is exactly this. An empty directory nobody can explain is a
14
+ * worse failure than an error message, because nothing reports it.
15
+ */
16
+ /** Why `dir` cannot serve as a project root, or null when it can. Pure, so the
17
+ * wording is testable without spawning a process — the caller prints and exits. */
18
+ export declare function repoPathProblem(dir: string): string | null;
19
+ /** Absolute, existing project root — or exit 1 with the reason. */
20
+ export declare function resolveRepoRoot(raw: string | undefined, cwd?: string): string;
@@ -0,0 +1,50 @@
1
+ import * as fs from "fs";
2
+ import path from "path";
3
+ import pc from "picocolors";
4
+ /**
5
+ * `--repo` is checked, not trusted.
6
+ *
7
+ * Commander consumes a `<required>` option argument even when the argument
8
+ * *looks like a flag*: `aztrx run --repo --fix` sets `repo` to the literal
9
+ * string `--fix` — measured against commander 12 rather than assumed. From
10
+ * there `path.resolve` turns it into `<cwd>/--fix`, and the first
11
+ * `mkdirSync(..., { recursive: true })` on the run path is happy to create it.
12
+ * So a mistyped invocation does not fail, it *succeeds* — against a project
13
+ * directory the tool invented — and leaves its `.aztrx/` artifacts inside.
14
+ *
15
+ * That is not hypothetical: `C:\Users\dchap\--fix\` existed holding nothing but
16
+ * `.aztrx/`, which is exactly this. An empty directory nobody can explain is a
17
+ * worse failure than an error message, because nothing reports it.
18
+ */
19
+ /** Why `dir` cannot serve as a project root, or null when it can. Pure, so the
20
+ * wording is testable without spawning a process — the caller prints and exits. */
21
+ export function repoPathProblem(dir) {
22
+ let st;
23
+ try {
24
+ st = fs.statSync(dir);
25
+ }
26
+ catch {
27
+ // A path whose last segment is itself a flag is the signature of the option
28
+ // having eaten one, and saying so beats leaving the user to re-read their
29
+ // own command line for it.
30
+ const base = path.basename(dir);
31
+ if (base.startsWith("-")) {
32
+ return (`no such directory: ${dir}\n` +
33
+ ` \`${base}\` looks like a flag, not a path — did it get taken as the value of \`--repo\`?`);
34
+ }
35
+ return `no such directory: ${dir}`;
36
+ }
37
+ if (!st.isDirectory())
38
+ return `not a directory: ${dir}`;
39
+ return null;
40
+ }
41
+ /** Absolute, existing project root — or exit 1 with the reason. */
42
+ export function resolveRepoRoot(raw, cwd = process.cwd()) {
43
+ const dir = path.resolve(cwd, raw ?? ".");
44
+ const problem = repoPathProblem(dir);
45
+ if (problem) {
46
+ console.error(pc.red("✗ ") + problem);
47
+ process.exit(1);
48
+ }
49
+ return dir;
50
+ }
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import * as os from "os";
5
5
  import pc from "picocolors";
6
6
  import { program } from "commander";
7
7
  import { opt, formatHelp } from "./cli/help.js";
8
+ import { resolveRepoRoot } from "./cli/repo.js";
8
9
  import { initProject } from "./core/init.js";
9
10
  import { VERSION } from "./core/version.js";
10
11
  import { installHook, runPrePush, uninstallHook } from "./hooks/index.js";
@@ -91,7 +92,7 @@ program
91
92
  .option("--framework <name>", "framework override (auto-detected if omitted)")
92
93
  .action(async (opts) => {
93
94
  const res = await initProject({
94
- repoRoot: path.resolve(program.opts().repo),
95
+ repoRoot: resolveRepoRoot(program.opts().repo),
95
96
  url: opts.url,
96
97
  framework: opts.framework,
97
98
  });
@@ -109,7 +110,7 @@ program
109
110
  .option("--force", "overwrite a pre-push hook that Aztrx did not write")
110
111
  .option("--always", "with `run`: scan even when no app code changed")
111
112
  .action(async (action, name, opts) => {
112
- const repoRoot = path.resolve(program.opts().repo);
113
+ const repoRoot = resolveRepoRoot(program.opts().repo);
113
114
  const hookName = name ?? "pre-push";
114
115
  if (hookName !== "pre-push") {
115
116
  console.error(pc.red(`unsupported hook: ${hookName}`) + " (only `pre-push` is wired up)");
@@ -156,7 +157,7 @@ program
156
157
  .argument("[action]", "install | uninstall — omit to serve on stdio")
157
158
  .option("--force", "with `install`: replace a config file that will not parse (a .bak is saved first)")
158
159
  .action(async (action, opts) => {
159
- const repoRoot = path.resolve(program.opts().repo);
160
+ const repoRoot = resolveRepoRoot(program.opts().repo);
160
161
  // No action means serve, because that is what an editor's config runs and it
161
162
  // must be the shortest thing to type. From here stdout is the protocol
162
163
  // channel: the server reroutes `console.log` to stderr itself, but anything
@@ -206,7 +207,7 @@ program
206
207
  .option("--port <n>", "port to listen on", "7331")
207
208
  .action(async (opts) => {
208
209
  const { startStudio } = await import("./core/studio.js");
209
- startStudio({ repoRoot: path.resolve(program.opts().repo), port: parseInt(opts.port, 10) });
210
+ startStudio({ repoRoot: resolveRepoRoot(program.opts().repo), port: parseInt(opts.port, 10) });
210
211
  });
211
212
  program
212
213
  .command("modernize")
@@ -214,7 +215,7 @@ program
214
215
  .argument("<file>", "path to the file to modernize")
215
216
  .option("-y, --yes", "apply without prompting")
216
217
  .action(async (file, opts) => {
217
- const repoRoot = path.resolve(program.opts().repo);
218
+ const repoRoot = resolveRepoRoot(program.opts().repo);
218
219
  const rel = path.relative(repoRoot, path.resolve(file));
219
220
  const [{ modernizeFile }, { promptYesNo }] = await Promise.all([
220
221
  import("./core/modernize.js"),
@@ -290,7 +291,7 @@ program
290
291
  .action(async (url, opts) => {
291
292
  // `--fix` is the memorable verb; `--magic-fix` is a hidden alias.
292
293
  const magicFix = opts.magicFix || opts.fix;
293
- const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
294
+ const repoRoot = resolveRepoRoot(opts.repo ?? program.opts().repo);
294
295
  // Defaults the scaffolded aztrx.config.ts supplies, read here (lazily, like
295
296
  // the target resolver) because only this command knows the repo root.
296
297
  const { configAllowHosts, configMaxActions } = await import("./core/devServer.js");
@@ -541,7 +542,7 @@ program
541
542
  .addOption(opt("--start-command <cmd>", "command to boot the app for server healing", "advanced"))
542
543
  .addOption(opt("--no-boot", "never start a dev server — only attach to one already running", "detect"))
543
544
  .action(async (url, opts) => {
544
- const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
545
+ const repoRoot = resolveRepoRoot(opts.repo ?? program.opts().repo);
545
546
  const { configAllowHosts, configMaxActions } = await import("./core/devServer.js");
546
547
  let booted;
547
548
  let targetUrl = url;
@@ -26,7 +26,7 @@ export async function walkDom(page, bus, opts = {}) {
26
26
  if (visited.has(url))
27
27
  continue;
28
28
  visited.add(url);
29
- if (page.url() !== url) {
29
+ if (!samePage(page.url(), url)) {
30
30
  await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => { });
31
31
  await page.waitForTimeout(300);
32
32
  }
@@ -130,3 +130,19 @@ export async function walkDom(page, bus, opts = {}) {
130
130
  export function originOf(url) {
131
131
  return url.match(/^https?:\/\/[^/]+/)?.[0] ?? "";
132
132
  }
133
+ /**
134
+ * Are these two strings the same page? Compared as parsed URLs, not as text,
135
+ * because the browser normalises what the crawler was handed: a run against
136
+ * `http://localhost:3000` has `page.url() === "http://localhost:3000/"`, so a
137
+ * raw string compare says "different" and the walk re-loads the start page it
138
+ * is already sitting on — once per run, re-firing every mount effect for
139
+ * nothing. `new URL(x).href` puts both sides in the browser's own spelling.
140
+ */
141
+ function samePage(current, target) {
142
+ try {
143
+ return new URL(current).href === new URL(target).href;
144
+ }
145
+ catch {
146
+ return current === target; // about:blank, or an unparseable href
147
+ }
148
+ }
@@ -111,7 +111,21 @@ export function generateRulePatch(ctx) {
111
111
  return null;
112
112
  // Optional-chain every `.identifier` access on the line (not just the failing
113
113
  // one) so a chain like `d.agents.map(…)` becomes `d?.agents?.map(…)`.
114
- const replace = src.replace(/\.(?=[a-zA-Z_$])/g, "?.");
114
+ //
115
+ // The lookbehind is the whole point, and it is not cosmetic. A bare
116
+ // `/\.(?=[a-zA-Z_$])/` also matches the dots in a spread and in chaining that
117
+ // is already optional, so `{ ...s, [id]: result.ok }` became
118
+ // `{ ..?.s, [id]: result.ok }` and `d?.agents` became `d??.agents`. Both are
119
+ // syntax errors, which the AST gate then refused — correctly, but the effect
120
+ // was that the free fixer declined every line containing a spread or an
121
+ // existing `?.`, which is most React code, and the finding was reported as
122
+ // `rejected` with no hint that the rule engine was at fault.
123
+ //
124
+ // Known limit: this is a regex on a line of source, not a lexer, so a `.name`
125
+ // *inside a string literal or regex* on the failing line is rewritten too.
126
+ // That cannot make the file unparseable (the gate above still runs), but it
127
+ // can change behaviour, and no gate here would catch it.
128
+ const replace = src.replace(/(?<![.?])\.(?=[a-zA-Z_$])/g, "?.");
115
129
  if (replace === src)
116
130
  return null;
117
131
  return {
@@ -138,7 +152,13 @@ export async function generatePatch(ctx, opts = {}) {
138
152
  system: SYSTEM,
139
153
  prompt: buildPrompt(ctx),
140
154
  model: opts.model,
141
- maxTokens: 2048,
155
+ // A patch is a few hundred tokens, but a reasoning model spends this budget
156
+ // on its thinking *first* — at 2048 a reasoner like cohere/north-mini-code
157
+ // hit the cap before emitting any text at all, so healing reported "no
158
+ // content (finish_reason: length)" and gave up without ever producing a
159
+ // patch. This is a ceiling, not a charge: cost is per token actually
160
+ // emitted, so the headroom is free for models that do not reason.
161
+ maxTokens: 8192,
142
162
  temperature: 0,
143
163
  });
144
164
  if (opts.budget)
@@ -12,6 +12,48 @@ import * as path from "path";
12
12
  import { buildChildEnv } from "./childEnv.js";
13
13
  const execFileP = promisify(execFile);
14
14
  const preview = (s) => JSON.stringify(s.length > 60 ? s.slice(0, 57) + "…" : s);
15
+ /**
16
+ * Remove directory links (POSIX symlinks / Windows junctions) from a worktree root.
17
+ *
18
+ * This MUST run before `git worktree remove`. Git's worktree teardown recurses
19
+ * *through* a junction and deletes its target, so the `node_modules` link that
20
+ * boot/verify create inside the worktree (below) turns an ordinary cleanup into a
21
+ * recursive delete of the user's real node_modules. Established by experiment,
22
+ * not by reading docs: a sentinel file inside the junction target did not survive
23
+ * `git worktree remove --force`. Node's own recursive `fs.rmSync` handles reparse
24
+ * points correctly and is safe either way; git's does not.
25
+ */
26
+ function unlinkDirLinks(dir) {
27
+ let entries;
28
+ try {
29
+ entries = fs.readdirSync(dir, { withFileTypes: true });
30
+ }
31
+ catch {
32
+ return;
33
+ }
34
+ for (const entry of entries) {
35
+ const p = path.join(dir, entry.name);
36
+ try {
37
+ if (!fs.lstatSync(p).isSymbolicLink())
38
+ continue;
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ // `unlink` covers POSIX dir symlinks; Windows junctions need `rmdir`.
44
+ try {
45
+ fs.unlinkSync(p);
46
+ }
47
+ catch {
48
+ try {
49
+ fs.rmdirSync(p);
50
+ }
51
+ catch {
52
+ /* best effort — a link that survives is still not followed by the rmSync below */
53
+ }
54
+ }
55
+ }
56
+ }
15
57
  /** Create a detached worktree at HEAD in a temp dir (outside the repo). */
16
58
  export async function createWorktree(repoRoot, label) {
17
59
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), `aztrx-heal-${label}-`));
@@ -19,6 +61,7 @@ export async function createWorktree(repoRoot, label) {
19
61
  return {
20
62
  dir,
21
63
  cleanup: async () => {
64
+ unlinkDirLinks(dir);
22
65
  await execFileP("git", ["-C", repoRoot, "worktree", "remove", "--force", dir]).catch(() => { });
23
66
  fs.rmSync(dir, { recursive: true, force: true });
24
67
  },
package/dist/core/llm.js CHANGED
@@ -73,6 +73,31 @@ export async function complete(opts) {
73
73
  ? anthropicComplete(s, model, opts)
74
74
  : openaiComplete(s, model, opts);
75
75
  }
76
+ /**
77
+ * Turn an empty completion into a diagnosable error.
78
+ *
79
+ * Returning `""` here is what makes a provider-side failure reach the caller as
80
+ * `Unexpected end of JSON input` — a message that points at *our* parser rather
81
+ * than at the model, and which the heal path then degrades to a bland "no-llm".
82
+ * The stop reason is the entire diagnosis, so it is carried into the message.
83
+ *
84
+ * Real cases this covers: a free/contended endpoint failing mid-flight
85
+ * (`finish_reason: "error"` on OpenRouter), a reasoning model spending the whole
86
+ * budget before emitting any text (`length`), and provider-side filters.
87
+ */
88
+ function emptyCompletion(provider, model, reason) {
89
+ const why = reason === "length" || reason === "max_tokens"
90
+ ? "the token limit was reached before any text was emitted — the model is most likely spending its whole budget on reasoning; pick a different one with --heal-model / AZTRX_MODEL"
91
+ : reason === "error"
92
+ ? "the provider failed mid-response"
93
+ : reason === "content_filter"
94
+ ? "the provider blocked the response"
95
+ : reason === "refusal"
96
+ ? "the model refused the request"
97
+ : "the provider returned no text";
98
+ const seen = reason ? `finish_reason: ${reason}` : "no finish_reason given";
99
+ return new Error(`${provider} returned no content (${seen}) — ${why}. Model: ${model}`);
100
+ }
76
101
  async function anthropicComplete(s, model, opts) {
77
102
  const headers = {
78
103
  "content-type": "application/json",
@@ -99,10 +124,13 @@ async function anthropicComplete(s, model, opts) {
99
124
  throw new Error(`LLM request failed (${res.status}): ${body.slice(0, 300)}`);
100
125
  }
101
126
  const data = (await res.json());
102
- return (data.content ?? [])
127
+ const text = (data.content ?? [])
103
128
  .filter((c) => c.type === "text")
104
129
  .map((c) => c.text ?? "")
105
130
  .join("\n");
131
+ if (!text.trim())
132
+ throw emptyCompletion("Anthropic", model, data.stop_reason);
133
+ return text;
106
134
  }
107
135
  async function openaiComplete(s, model, opts) {
108
136
  const res = await fetch(`${s.baseUrl}/chat/completions`, {
@@ -126,14 +154,21 @@ async function openaiComplete(s, model, opts) {
126
154
  throw new Error(`LLM request failed (${res.status}): ${body.slice(0, 300)}`);
127
155
  }
128
156
  const data = (await res.json());
129
- const content = data.choices?.[0]?.message?.content;
130
- if (typeof content === "string")
157
+ // OpenRouter reports upstream failures in the body with HTTP 200, so `res.ok`
158
+ // alone does not mean the model answered.
159
+ if (data.error)
160
+ throw new Error(`LLM request failed: ${data.error.message ?? "unknown error"}`);
161
+ const choice = data.choices?.[0];
162
+ const content = choice?.message?.content;
163
+ if (typeof content === "string" && content.trim())
131
164
  return content;
132
165
  if (Array.isArray(content)) {
133
- return content
166
+ const text = content
134
167
  .filter((c) => typeof c === "object" && c !== null && c.type === "text")
135
168
  .map((c) => c.text ?? "")
136
169
  .join("\n");
170
+ if (text.trim())
171
+ return text;
137
172
  }
138
- return "";
173
+ throw emptyCompletion("OpenAI-compatible endpoint", model, choice?.finish_reason);
139
174
  }
@@ -2,13 +2,39 @@ import { EventBus } from "./eventBus.js";
2
2
  import { launchChromium } from "./browser.js";
3
3
  import { attachInterceptor } from "./interceptor.js";
4
4
  import { fingerprintOf } from "./classifier.js";
5
+ /**
6
+ * How long to let a freshly-navigated page settle before acting on it.
7
+ *
8
+ * The walker waits this long after its own `page.goto` before it touches
9
+ * anything, so every recorded selector was resolved against a page that had
10
+ * been given 300ms to render — a replay that clicks the instant the navigation
11
+ * commits is asking for something the recording never was.
12
+ *
13
+ * Measured on a real Next.js app rather than guessed: with no settle, every
14
+ * action after a `navigate` was skipped — `count()` does not wait, so an
15
+ * element that is not in the DOM *yet* is indistinguishable from one that is
16
+ * gone, and the skip is silent — and a bug that fires on every single click
17
+ * came back `unreliable` 0/3. With 300ms it reproduced 3/3.
18
+ */
19
+ const NAV_SETTLE_MS = 300;
20
+ /**
21
+ * How long to keep watching after the last replayed action, for a bug whose
22
+ * trigger is asynchronous. See the poll in `run` — it exits the moment the
23
+ * fingerprint appears, so this is a ceiling, not a delay.
24
+ */
25
+ const POST_REPLAY_WINDOW_MS = 1500;
5
26
  /** Replays a recorded action sequence against a page. Best-effort: a selector
6
27
  * that no longer resolves is skipped, not fatal. */
7
28
  export async function replayActions(page, actions) {
8
29
  for (const a of actions) {
9
30
  if (a.type === "navigate") {
10
31
  if (a.value) {
11
- await page.goto(a.value, { waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
32
+ // Already there: a trace can carry the same URL twice, and re-loading
33
+ // the page it is already on costs a full load and resets its state.
34
+ if (page.url() !== a.value) {
35
+ await page.goto(a.value, { waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
36
+ await page.waitForTimeout(NAV_SETTLE_MS);
37
+ }
12
38
  }
13
39
  continue;
14
40
  }
@@ -126,11 +152,23 @@ export class ReplayEngine {
126
152
  if (opts?.targetType)
127
153
  collecting = true;
128
154
  await replayActions(page, actions);
129
- await page.waitForTimeout(300);
130
- const reproduced = opts?.targetType
131
- ? types.has(opts.targetType)
132
- : fingerprints.has(targetFingerprint);
133
- return { reproduced, loaded };
155
+ // An action can start work that throws later — a `fetch` behind a 300ms
156
+ // mock, a promise chain, a state update that re-renders into the bug —
157
+ // and the last action is not the last word. Waiting a fixed 300ms for
158
+ // that measured *exactly* on the boundary of a real app's own 300ms
159
+ // delay, so the same trace flipped between reproducing and not from one
160
+ // run to the next.
161
+ //
162
+ // Poll instead of sleeping: a bug that fires during the replay returns
163
+ // on the first check, so the window costs nothing where the verdict is
164
+ // already decided, and only a trace that is going to be called
165
+ // `unreliable` pays for the full wait — which is the case that must not
166
+ // be wrong.
167
+ const seen = () => opts?.targetType ? types.has(opts.targetType) : fingerprints.has(targetFingerprint);
168
+ const deadline = Date.now() + POST_REPLAY_WINDOW_MS;
169
+ while (!seen() && Date.now() < deadline)
170
+ await page.waitForTimeout(50);
171
+ return { reproduced: seen(), loaded };
134
172
  }
135
173
  catch (e) {
136
174
  lastError = e;
@@ -42,3 +42,23 @@ export declare function resolveFrame(frame: RawFrame, repoRoot: string): Promise
42
42
  */
43
43
  export declare function resolveServerFrame(frame: ServerFrame, repoRoot: string): MappedError;
44
44
  export declare function extractSnippet(filePath: string, targetLine: number, window?: number): string;
45
+ /**
46
+ * Re-anchor a position that a transpiled frame reported wrongly.
47
+ *
48
+ * `webpack-internal://` frames carry a position in the module webpack
49
+ * *generated*, not in the `.tsx` on disk. Next dev reported
50
+ * `app/page.tsx:29:21` for a crash that is on line 15 — and because the URL
51
+ * path is the real source path, the file is found and a confidently wrong line
52
+ * is shown. Worse, `generateRulePatch` looks for the property on the mapped
53
+ * line, finds a `</div>` instead, and declines — so the free no-key fix never
54
+ * fires on Next.js.
55
+ *
56
+ * The error message is the signal. `Cannot read properties of undefined
57
+ * (reading 'agents')` can only be thrown by a line that reads `.agents`, so a
58
+ * mapped line that does not read it is provably not the throw site, and the
59
+ * real one is findable. Ambiguity is left alone rather than guessed at.
60
+ */
61
+ export declare function reanchorPosition(content: string, line: number, column: number, message: string): {
62
+ line: number;
63
+ column: number;
64
+ };
@@ -228,13 +228,22 @@ export async function resolveFrame(frame, repoRoot) {
228
228
  resolvedFrom: "unresolved",
229
229
  };
230
230
  }
231
+ // The frame's line is only as good as the code it came from: for a
232
+ // `webpack-internal://` frame it indexes webpack's *generated* module, not the
233
+ // source on disk (see reanchorPosition), so re-anchor before showing it — and
234
+ // before `generateRulePatch` reads it, which is the difference between a free
235
+ // fix and a paid one.
236
+ const readable = isFile(directPath) && !isSensitive(directPath);
237
+ const at = readable
238
+ ? reanchorPosition(fs.readFileSync(directPath, "utf-8"), frame.line, frame.column, frame.message)
239
+ : { line: frame.line, column: frame.column };
231
240
  return {
232
241
  message: frame.message,
233
242
  sourceFile: path.relative(repoRoot, directPath),
234
- line: frame.line,
235
- column: frame.column,
236
- codeSnippet: extractSnippet(directPath, frame.line),
237
- resolvedFrom: isFile(directPath) && !isSensitive(directPath) ? "direct" : "unresolved",
243
+ line: at.line,
244
+ column: at.column,
245
+ codeSnippet: extractSnippet(directPath, at.line),
246
+ resolvedFrom: readable ? "direct" : "unresolved",
238
247
  };
239
248
  }
240
249
  /**
@@ -394,3 +403,74 @@ export function extractSnippet(filePath, targetLine, window = 4) {
394
403
  })
395
404
  .join("\n");
396
405
  }
406
+ /** The property a null-deref message was reading:
407
+ * `Cannot read properties of undefined (reading 'agents')` → `agents`.
408
+ * Null for every other error, which is what keeps the correction below scoped
409
+ * to the one message shape that proves where the throw happened. */
410
+ function readProperty(message) {
411
+ const m = message.match(/reading ['"]([^'"]+)['"]/);
412
+ return m ? m[1] : null;
413
+ }
414
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
415
+ /** Drop a trailing `//` comment. The `:` guard keeps `https://` intact. */
416
+ function stripComment(line) {
417
+ const i = line.indexOf("//");
418
+ if (i <= 0 || line[i - 1] === ":")
419
+ return line;
420
+ return line.slice(0, i);
421
+ }
422
+ /** Does this line read `.prop`? */
423
+ function readsProperty(line, prop) {
424
+ return new RegExp(`\\.${escapeRe(prop)}\\b`).test(line);
425
+ }
426
+ /** True when every read of `.prop` on this line is optional-chained. A guarded
427
+ * read cannot throw, so such a line is never the crash site — which is what
428
+ * separates `d?.agents ?? []` from the `d.agents.map(…)` that actually threw. */
429
+ function allReadsGuarded(line, prop) {
430
+ const re = new RegExp(`(\\?)?\\.${escapeRe(prop)}\\b`, "g");
431
+ let seen = false;
432
+ for (let m = re.exec(line); m; m = re.exec(line)) {
433
+ seen = true;
434
+ if (!m[1])
435
+ return false;
436
+ }
437
+ return seen;
438
+ }
439
+ /**
440
+ * Re-anchor a position that a transpiled frame reported wrongly.
441
+ *
442
+ * `webpack-internal://` frames carry a position in the module webpack
443
+ * *generated*, not in the `.tsx` on disk. Next dev reported
444
+ * `app/page.tsx:29:21` for a crash that is on line 15 — and because the URL
445
+ * path is the real source path, the file is found and a confidently wrong line
446
+ * is shown. Worse, `generateRulePatch` looks for the property on the mapped
447
+ * line, finds a `</div>` instead, and declines — so the free no-key fix never
448
+ * fires on Next.js.
449
+ *
450
+ * The error message is the signal. `Cannot read properties of undefined
451
+ * (reading 'agents')` can only be thrown by a line that reads `.agents`, so a
452
+ * mapped line that does not read it is provably not the throw site, and the
453
+ * real one is findable. Ambiguity is left alone rather than guessed at.
454
+ */
455
+ export function reanchorPosition(content, line, column, message) {
456
+ const prop = readProperty(message);
457
+ if (!prop)
458
+ return { line, column };
459
+ const lines = content.split("\n");
460
+ const mapped = lines[line - 1];
461
+ if (mapped && readsProperty(stripComment(mapped), prop))
462
+ return { line, column };
463
+ const candidates = [];
464
+ for (let i = 0; i < lines.length; i++) {
465
+ const text = stripComment(lines[i]);
466
+ if (!readsProperty(text, prop) || allReadsGuarded(text, prop))
467
+ continue;
468
+ candidates.push(i + 1);
469
+ }
470
+ if (candidates.length !== 1)
471
+ return { line, column }; // ambiguous — don't guess
472
+ const found = lines[candidates[0] - 1];
473
+ // Point the column at the property too: the frame's column is as transpiled
474
+ // as its line was.
475
+ return { line: candidates[0], column: found.indexOf(`.${prop}`) + 1 };
476
+ }
@@ -125,12 +125,23 @@ function buildLlmPrompt(findings, lang, hasHealed) {
125
125
  const SYSTEM = "You are the plain-spoken explainer for a QA tool called Aztrx AI. You turn raw runtime-finding data into a concise Markdown summary for a developer (headings, bold, bullet lists, short code snippets). Never invent details absent from the data. Respond in the requested language only.";
126
126
  async function summarizeFindingsLlm(findings, lang) {
127
127
  const hasHealed = findings.some((f) => f.heal?.status === "healed");
128
- const text = (await complete({
129
- system: SYSTEM,
130
- prompt: buildLlmPrompt(findings, lang, hasHealed),
131
- maxTokens: 1024,
132
- temperature: 0.2,
133
- })).trim();
128
+ // The template is the floor, not a fallback for one case: an explanation is a
129
+ // nice-to-have, so a provider that errors, truncates or filters must not take
130
+ // the report down with it. `complete()` throws on an empty completion rather
131
+ // than returning "", so this catch is what keeps that contract from becoming a
132
+ // crash — the `||` then covers a reply that was whitespace.
133
+ let text = "";
134
+ try {
135
+ text = (await complete({
136
+ system: SYSTEM,
137
+ prompt: buildLlmPrompt(findings, lang, hasHealed),
138
+ maxTokens: 1024,
139
+ temperature: 0.2,
140
+ })).trim();
141
+ }
142
+ catch {
143
+ text = "";
144
+ }
134
145
  return text || summarizeFindingsTemplate(findings, lang);
135
146
  }
136
147
  /**
@@ -95,9 +95,32 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
95
95
  onBlock: (u) => opts.log(`[guard] blocked ${u}`),
96
96
  });
97
97
  }
98
+ // The last URL this listener recorded as a `navigate`, to drop the duplicate.
99
+ // Playwright fires `framenavigated` twice for a single `page.goto` — measured
100
+ // against Chromium, not assumed: one goto to `/agents` produced two events
101
+ // for the same URL. Recording both would put a redundant full page load in
102
+ // every trace, and the buffer is only 25 actions deep.
103
+ let lastNavUrl = "";
98
104
  page.on("framenavigated", (frame) => {
99
- if (frame === page.mainFrame())
100
- workerBus.emit("route", { url: frame.url(), ts: Date.now() });
105
+ if (frame !== page.mainFrame())
106
+ return; // an iframe's URL is not the page's
107
+ const url = frame.url();
108
+ workerBus.emit("route", { url, ts: Date.now() });
109
+ // A trace has to say which page it was on, and nothing recorded that. The
110
+ // walker reaches each crawled route with `page.goto`, which emits no action
111
+ // at all, so a finding on `/agents` produced a trace of clicks that only
112
+ // mean anything *there* — replayed from the start URL every one of them
113
+ // resolved to nothing, the crash was never reached, and a bug that fires
114
+ // every single time came back `unreliable` (0/3 replays). `navigate` is
115
+ // already understood by everything that consumes a trace: `replayActions`
116
+ // and the spec compiler act on it, heal rewrites its origin to the booted
117
+ // server, and the patrol GIF skips it. Only the producer was missing.
118
+ if (!/^https?:/i.test(url))
119
+ return; // about:blank, and the first empty frame
120
+ if (url === lastNavUrl)
121
+ return; // the second of the pair described above
122
+ lastNavUrl = url;
123
+ workerBus.emit("action", { type: "navigate", selectors: [], value: url, timestamp: Date.now() });
101
124
  });
102
125
  let loaded = true;
103
126
  await page.goto(opts.url, { waitUntil: "load", timeout: 30000 }).catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aztrx-cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",
@@ -52,7 +52,7 @@
52
52
  ],
53
53
  "repository": {
54
54
  "type": "git",
55
- "url": "https://github.com/Aztrx-AI/aztrx.git"
55
+ "url": "git+https://github.com/Aztrx-AI/aztrx.git"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsc",
@@ -90,5 +90,13 @@
90
90
  "vite": {
91
91
  "optional": true
92
92
  }
93
- }
93
+ },
94
+ "directories": {
95
+ "doc": "docs",
96
+ "test": "tests"
97
+ },
98
+ "bugs": {
99
+ "url": "https://github.com/Aztrx-AI/aztrx/issues"
100
+ },
101
+ "homepage": "https://aztrx.app"
94
102
  }