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 +1 -1
- package/dist/cli/repo.d.ts +20 -0
- package/dist/cli/repo.js +50 -0
- package/dist/cli.js +8 -7
- package/dist/core/domWalker.js +17 -1
- package/dist/core/heal/llm.js +22 -2
- package/dist/core/heal/sandbox.js +43 -0
- package/dist/core/llm.js +40 -5
- package/dist/core/replay.js +44 -6
- package/dist/core/resolver.d.ts +20 -0
- package/dist/core/resolver.js +84 -4
- package/dist/core/summarize.js +17 -6
- package/dist/core/swarm.js +25 -2
- package/package.json +11 -3
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;
|
package/dist/cli/repo.js
ADDED
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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;
|
package/dist/core/domWalker.js
CHANGED
|
@@ -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()
|
|
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
|
+
}
|
package/dist/core/heal/llm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
throw emptyCompletion("OpenAI-compatible endpoint", model, choice?.finish_reason);
|
|
139
174
|
}
|
package/dist/core/replay.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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;
|
package/dist/core/resolver.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/core/resolver.js
CHANGED
|
@@ -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:
|
|
235
|
-
column:
|
|
236
|
-
codeSnippet: extractSnippet(directPath,
|
|
237
|
-
resolvedFrom:
|
|
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
|
+
}
|
package/dist/core/summarize.js
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
/**
|
package/dist/core/swarm.js
CHANGED
|
@@ -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
|
|
100
|
-
|
|
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.
|
|
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
|
}
|