aztrx-cli 0.4.5 → 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 +326 -29
- package/dist/cli/help.d.ts +22 -0
- package/dist/cli/repo.d.ts +20 -0
- package/dist/cli/repo.js +50 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -86
- package/dist/core/auth.d.ts +33 -0
- package/dist/core/badge.d.ts +9 -0
- package/dist/core/browser.d.ts +3 -0
- package/dist/core/classifier.d.ts +41 -0
- package/dist/core/cloud/index.d.ts +62 -0
- package/dist/core/cloud/index.js +24 -4
- package/dist/core/devServer.d.ts +90 -0
- package/dist/core/devServer.js +253 -0
- package/dist/core/diagnose.d.ts +18 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/domWalker.d.ts +24 -0
- package/dist/core/domWalker.js +18 -2
- package/dist/core/eventBus.d.ts +59 -0
- package/dist/core/events.d.ts +42 -0
- package/dist/core/fixPr.d.ts +16 -0
- package/dist/core/fixPr.js +11 -1
- package/dist/core/fuzzer.d.ts +24 -0
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/apply.d.ts +28 -0
- package/dist/core/heal/boot.d.ts +74 -0
- package/dist/core/heal/boot.js +126 -23
- package/dist/core/heal/childEnv.d.ts +15 -0
- package/dist/core/heal/gates.d.ts +11 -0
- package/dist/core/heal/index.d.ts +19 -0
- package/dist/core/heal/index.js +53 -23
- package/dist/core/heal/llm.d.ts +43 -0
- package/dist/core/heal/llm.js +22 -2
- package/dist/core/heal/redact.d.ts +22 -0
- package/dist/core/heal/sandbox.d.ts +54 -0
- package/dist/core/heal/sandbox.js +63 -3
- package/dist/core/heal/types.d.ts +102 -0
- package/dist/core/heal/verify.d.ts +37 -0
- package/dist/core/heal/verify.js +28 -14
- package/dist/core/httpFuzzer.d.ts +33 -0
- package/dist/core/init.d.ts +21 -0
- package/dist/core/init.js +5 -6
- package/dist/core/interceptor.d.ts +21 -0
- package/dist/core/llm.d.ts +36 -0
- package/dist/core/llm.js +40 -13
- package/dist/core/minimizer.d.ts +13 -0
- package/dist/core/modernize.d.ts +28 -0
- package/dist/core/modernize.js +2 -2
- package/dist/core/networkGuard.d.ts +13 -0
- package/dist/core/orchestrator.d.ts +86 -0
- package/dist/core/orchestrator.js +5 -2
- package/dist/core/patrol/loop.d.ts +46 -0
- package/dist/core/patrol/pr.d.ts +42 -0
- package/dist/core/patrol/pr.js +9 -6
- package/dist/core/patrol/record.d.ts +51 -0
- package/dist/core/patrol/state.d.ts +39 -0
- package/dist/core/pr.d.ts +3 -0
- package/dist/core/pr.js +15 -11
- package/dist/core/prompt.d.ts +14 -0
- package/dist/core/recorder.d.ts +23 -0
- package/dist/core/recorder.js +1 -1
- package/dist/core/renderMarkdown.d.ts +7 -0
- package/dist/core/replay.d.ts +33 -0
- package/dist/core/replay.js +52 -7
- package/dist/core/report.d.ts +8 -0
- package/dist/core/resolver.d.ts +64 -0
- package/dist/core/resolver.js +135 -17
- package/dist/core/rng.d.ts +2 -0
- package/dist/core/specCompiler.d.ts +17 -0
- package/dist/core/studio.d.ts +6 -0
- package/dist/core/studio.js +0 -3
- package/dist/core/summarize.d.ts +26 -0
- package/dist/core/summarize.js +17 -6
- package/dist/core/swarm.d.ts +97 -0
- package/dist/core/swarm.js +25 -2
- package/dist/core/telemetry/index.d.ts +33 -0
- package/dist/core/telemetry/index.js +22 -4
- package/dist/core/telemetry/sanitize.d.ts +19 -0
- package/dist/core/telemetry/types.d.ts +26 -0
- package/dist/core/types.d.ts +88 -0
- package/dist/core/ui.d.ts +23 -0
- package/dist/core/ui.js +0 -5
- package/dist/core/validator.d.ts +14 -0
- package/dist/core/version.d.ts +1 -0
- package/dist/hooks/index.d.ts +106 -0
- package/dist/hooks/index.js +349 -0
- package/dist/mcp/index.d.ts +58 -0
- package/dist/mcp/index.js +295 -0
- package/dist/mcp/install.d.ts +52 -0
- package/dist/mcp/install.js +204 -0
- package/dist/mcp/protocol.d.ts +106 -0
- package/dist/mcp/protocol.js +156 -0
- package/dist/mcp/tools.d.ts +107 -0
- package/dist/mcp/tools.js +621 -0
- package/dist/next/index.d.ts +70 -0
- package/dist/next/index.js +103 -0
- package/dist/plugins/scan.d.ts +85 -0
- package/dist/plugins/scan.js +188 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/vite/index.d.ts +60 -0
- package/dist/vite/index.js +56 -0
- package/package.json +44 -6
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
|
}
|
|
@@ -111,7 +137,14 @@ export class ReplayEngine {
|
|
|
111
137
|
attachInterceptor(page, bus);
|
|
112
138
|
if (this.opts.attachGuard)
|
|
113
139
|
await this.opts.attachGuard(page);
|
|
114
|
-
|
|
140
|
+
// A rejected navigation (refused connection, DNS failure, timeout) and a
|
|
141
|
+
// non-2xx answer both leave a page that will never emit the telemetry we
|
|
142
|
+
// are looking for. Capture that here rather than letting the empty
|
|
143
|
+
// fingerprint set read as a passing verification.
|
|
144
|
+
const nav = await page
|
|
145
|
+
.goto(url, { waitUntil: "load", timeout: 30000 })
|
|
146
|
+
.catch(() => null);
|
|
147
|
+
const loaded = nav !== null && nav.ok();
|
|
115
148
|
// Settle for hydration before replaying — the detection pass waits on the
|
|
116
149
|
// `load` event plus a settle window, and a replay that clicks before React
|
|
117
150
|
// attaches its handlers won't reproduce the crash (false "unreliable").
|
|
@@ -119,11 +152,23 @@ export class ReplayEngine {
|
|
|
119
152
|
if (opts?.targetType)
|
|
120
153
|
collecting = true;
|
|
121
154
|
await replayActions(page, actions);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 };
|
|
127
172
|
}
|
|
128
173
|
catch (e) {
|
|
129
174
|
lastError = e;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Finding } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* F-report — standalone offline HTML report. Self-contained (inline CSS, no
|
|
4
|
+
* CDN), rendered in the shared "crash seismograph" identity: one red spike per
|
|
5
|
+
* crash, severity chips, and colored repro verdicts.
|
|
6
|
+
*/
|
|
7
|
+
export declare function renderReport(targetUrl: string, findings: Finding[], lang?: string): string;
|
|
8
|
+
export declare function writeReport(repoRoot: string, targetUrl: string, findings: Finding[], lang?: string): string;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ServerFrame } from "./types.js";
|
|
2
|
+
export type ResolvedFrom = "sourcemap" | "direct" | "unresolved";
|
|
3
|
+
export interface MappedError {
|
|
4
|
+
message: string;
|
|
5
|
+
/** Repo-relative path to the source file, e.g. `apps/web/src/App.tsx`. */
|
|
6
|
+
sourceFile: string;
|
|
7
|
+
line: number;
|
|
8
|
+
column: number;
|
|
9
|
+
codeSnippet: string;
|
|
10
|
+
resolvedFrom: ResolvedFrom;
|
|
11
|
+
}
|
|
12
|
+
export interface RawFrame {
|
|
13
|
+
url: string;
|
|
14
|
+
line: number;
|
|
15
|
+
column: number;
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Pulls the first *user-code* frame out of a stack string. Iterates every line,
|
|
20
|
+
* skips framework internals (webpack runtime, node_modules, next/dist), and
|
|
21
|
+
* returns the first frame in the user's own code — so a Next.js dev stack like
|
|
22
|
+
* `webpack-internal:///(app-pages-browser)/./app/page.tsx:29:21` resolves to the
|
|
23
|
+
* user's file, not `intercept-console-error.js`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractFrame(text: string): RawFrame | null;
|
|
26
|
+
/**
|
|
27
|
+
* Pull the first server-side source frame out of a raw server stack (a 500 body,
|
|
28
|
+
* a Next.js dev error page, etc.). V8 emits one frame per line as
|
|
29
|
+
* `at <fn> (<path>:<line>:<col>)` or `at <path>:<line>:<col>`; we take the first
|
|
30
|
+
* frame whose path is not inside node_modules. Best-effort — returns null when
|
|
31
|
+
* the body carries no stack trace (e.g. an explicit
|
|
32
|
+
* `NextResponse.json(..., { status: 500 })`).
|
|
33
|
+
*/
|
|
34
|
+
export declare function extractServerFrame(stack: string): ServerFrame | null;
|
|
35
|
+
export declare function resolveFrame(frame: RawFrame, repoRoot: string): Promise<MappedError>;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a server-side source frame (a filesystem path) to a repo-relative
|
|
38
|
+
* source location + snippet. Mirrors `resolveFrame`'s containment rules: a path
|
|
39
|
+
* outside the repo — via `..` traversal or a symlink pointing out — is never
|
|
40
|
+
* read. Server frames carry no sourcemap; a directly-readable file maps
|
|
41
|
+
* `resolvedFrom: "direct"`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveServerFrame(frame: ServerFrame, repoRoot: string): MappedError;
|
|
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
3
4
|
import { FlattenMap, originalPositionFor, } from "@jridgewell/trace-mapping";
|
|
4
5
|
/** Framework-internal frames to skip when hunting the throw site. */
|
|
5
6
|
const FRAMEWORK_FRAME = /node_modules|webpack-runtime|\.next[\\/]|next[\\/]dist[\\/]/;
|
|
@@ -56,20 +57,40 @@ export function extractServerFrame(stack) {
|
|
|
56
57
|
function stripQuery(url) {
|
|
57
58
|
return url.split("?")[0];
|
|
58
59
|
}
|
|
59
|
-
/** Normalize a stack-frame URL to a repo-relative
|
|
60
|
-
* dev-server schemes (`webpack-internal:///(ns)/./src/…`, `webpack://ns/src/…`)
|
|
61
|
-
*
|
|
60
|
+
/** Normalize a stack-frame URL to a path to probe: repo-relative for the
|
|
61
|
+
* dev-server schemes (`webpack-internal:///(ns)/./src/…`, `webpack://ns/src/…`)
|
|
62
|
+
* and plain `https://host/path` bundle URLs, **absolute** for `file://`.
|
|
63
|
+
*
|
|
64
|
+
* The asymmetry is deliberate. `file://` carries a real filesystem path, and on
|
|
65
|
+
* POSIX the leading slash is the root; the `^\//` strip below exists for URL
|
|
66
|
+
* paths (`/@fs/src/main.tsx`) and would eat it, turning `/tmp/p/app/actions.ts`
|
|
67
|
+
* into the relative `tmp/p/app/actions.ts` — a path under the repo that never
|
|
68
|
+
* exists. Every `file://` frame therefore fell through to "unresolved" on Linux
|
|
69
|
+
* and macOS. `resolveWithin` accepts absolute segments, so callers are unchanged;
|
|
70
|
+
* only display has to convert back (see `reportPath`). */
|
|
62
71
|
function normalizeFrameUrl(url) {
|
|
63
|
-
|
|
72
|
+
const raw = stripQuery(url);
|
|
73
|
+
if (/^file:\/\//i.test(raw)) {
|
|
74
|
+
try {
|
|
75
|
+
return fileURLToPath(raw); // handles drive-letter and UNC forms on Windows too
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return raw; // malformed URL — let the containment check reject it
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return raw
|
|
64
82
|
.replace(/^webpack-internal:\/\/\/[^/]+\/\.\//, "")
|
|
65
83
|
.replace(/^webpack:\/\/[^/]+\//, "")
|
|
66
84
|
.replace(/^webpack:\/\//, "")
|
|
67
85
|
.replace(/^\/@fs\//, "")
|
|
68
|
-
.replace(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
|
|
69
|
-
.replace(/^file:\/\//, "")
|
|
70
86
|
.replace(/^https?:\/\/[^/]+\//, "")
|
|
71
87
|
.replace(/^\//, "");
|
|
72
88
|
}
|
|
89
|
+
/** The repo-relative form of a normalized frame path, for anything user-facing.
|
|
90
|
+
* An absolute `file://` path is reported the same way every other frame is. */
|
|
91
|
+
function reportPath(p, repoRoot) {
|
|
92
|
+
return path.isAbsolute(p) ? path.relative(repoRoot, p) : p;
|
|
93
|
+
}
|
|
73
94
|
/** True only for a real, readable regular file — directories and unreadable
|
|
74
95
|
* paths return false so readers never hit `EISDIR` / permission errors. */
|
|
75
96
|
function isFile(p) {
|
|
@@ -140,14 +161,30 @@ function resolveWithin(root, ...segments) {
|
|
|
140
161
|
}
|
|
141
162
|
/** Turns a sourcemap `source` value into candidate absolute paths to probe. */
|
|
142
163
|
function sourceCandidates(source, repoRoot) {
|
|
143
|
-
const
|
|
164
|
+
const raw = source.split("?")[0];
|
|
165
|
+
// `file://` is an absolute *filesystem* path, not a URL path, and the two
|
|
166
|
+
// differ in exactly one place: on POSIX the leading slash is the root. The
|
|
167
|
+
// web-style stripping below exists for URL paths (`/@fs/src/main.tsx`) and
|
|
168
|
+
// would eat that slash, turning `/tmp/p/app/actions.ts` into the relative
|
|
169
|
+
// `tmp/p/app/actions.ts` — a path under the repo that never exists, so every
|
|
170
|
+
// Turbopack `file:///` source silently resolved to nothing on Linux and macOS.
|
|
171
|
+
// `fileURLToPath` also handles the drive-letter and UNC forms on Windows.
|
|
172
|
+
if (/^file:\/\//i.test(raw)) {
|
|
173
|
+
let abs;
|
|
174
|
+
try {
|
|
175
|
+
abs = fileURLToPath(raw);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return []; // malformed URL — nothing to probe
|
|
179
|
+
}
|
|
180
|
+
const inside = resolveWithin(repoRoot, abs);
|
|
181
|
+
return inside === null ? [] : [inside];
|
|
182
|
+
}
|
|
183
|
+
const cleaned = raw
|
|
144
184
|
.replace(/^webpack:\/\/[^/]+\//, "") // webpack://namespace/src/...
|
|
145
185
|
.replace(/^webpack:\/\//, "")
|
|
146
186
|
.replace(/^\/@fs\//, "")
|
|
147
|
-
.replace(
|
|
148
|
-
.replace(/^file:\/\//, "")
|
|
149
|
-
.replace(/^\//, "")
|
|
150
|
-
.split("?")[0];
|
|
187
|
+
.replace(/^\//, "");
|
|
151
188
|
const prefixes = ["", "apps/web/", "src/", "app/"];
|
|
152
189
|
return prefixes
|
|
153
190
|
.map((p) => resolveWithin(repoRoot, p, cleaned))
|
|
@@ -181,22 +218,32 @@ export async function resolveFrame(frame, repoRoot) {
|
|
|
181
218
|
directPath = withIndex;
|
|
182
219
|
}
|
|
183
220
|
if (!directPath) {
|
|
221
|
+
const shown = reportPath(relative, repoRoot);
|
|
184
222
|
return {
|
|
185
223
|
message: frame.message,
|
|
186
|
-
sourceFile:
|
|
224
|
+
sourceFile: shown,
|
|
187
225
|
line: frame.line,
|
|
188
226
|
column: frame.column,
|
|
189
|
-
codeSnippet: `<file not accessible locally: ${
|
|
227
|
+
codeSnippet: `<file not accessible locally: ${shown}>`,
|
|
190
228
|
resolvedFrom: "unresolved",
|
|
191
229
|
};
|
|
192
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 };
|
|
193
240
|
return {
|
|
194
241
|
message: frame.message,
|
|
195
242
|
sourceFile: path.relative(repoRoot, directPath),
|
|
196
|
-
line:
|
|
197
|
-
column:
|
|
198
|
-
codeSnippet: extractSnippet(directPath,
|
|
199
|
-
resolvedFrom:
|
|
243
|
+
line: at.line,
|
|
244
|
+
column: at.column,
|
|
245
|
+
codeSnippet: extractSnippet(directPath, at.line),
|
|
246
|
+
resolvedFrom: readable ? "direct" : "unresolved",
|
|
200
247
|
};
|
|
201
248
|
}
|
|
202
249
|
/**
|
|
@@ -356,3 +403,74 @@ export function extractSnippet(filePath, targetLine, window = 4) {
|
|
|
356
403
|
})
|
|
357
404
|
.join("\n");
|
|
358
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Finding, RecordedAction } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* F8 — spec compiler. Turns a minimal action sequence into an executable
|
|
4
|
+
* Playwright test that asserts the failure actually happens, so a human can
|
|
5
|
+
* run `npx playwright test` and see the bug with their own eyes — proof, not
|
|
6
|
+
* a log line.
|
|
7
|
+
*/
|
|
8
|
+
export declare function compileSpec(finding: Finding, actions: RecordedAction[], url: string): string;
|
|
9
|
+
export declare function writeSpec(repoRoot: string, finding: Finding, actions: RecordedAction[], url: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Install validated repro specs into the project's test directory so a fixed
|
|
12
|
+
* bug can't silently regress — the "immunity" step after find → prove → fix.
|
|
13
|
+
* Copies each deterministic/flaky repro into `<dir>/aztrx-<fingerprint>.spec.ts`
|
|
14
|
+
* and returns the written paths. `dir` defaults to an existing test dir, else a
|
|
15
|
+
* gitignored fallback.
|
|
16
|
+
*/
|
|
17
|
+
export declare function writeRegressionSpecs(repoRoot: string, findings: Finding[], dir?: string): string[];
|
package/dist/core/studio.js
CHANGED
|
@@ -3,9 +3,6 @@ import * as http from "http";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import { BASE_CSS, SEVERITY_COLOR, seismograph } from "./ui.js";
|
|
6
|
-
function escapeHtml(s) {
|
|
7
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
8
|
-
}
|
|
9
6
|
function send(res, status, contentType, body) {
|
|
10
7
|
res.writeHead(status, { "Content-Type": contentType });
|
|
11
8
|
res.end(body);
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
import type { Finding } from "./types.js";
|
|
15
|
+
export type Lang = "en" | "ru";
|
|
16
|
+
/** Deterministic, offline summary. Used as the no-key fallback and for empty runs. */
|
|
17
|
+
export declare function summarizeFindingsTemplate(findings: Finding[], lang?: Lang): string;
|
|
18
|
+
export interface SummarizeOptions {
|
|
19
|
+
lang?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Entry point. Empty run → template (no API cost). Otherwise LLM when a key is
|
|
23
|
+
* present; falls back to the deterministic template on any transport failure, so
|
|
24
|
+
* the report never crashes the run.
|
|
25
|
+
*/
|
|
26
|
+
export declare function summarizeFindings(findings: Finding[], opts?: SummarizeOptions): Promise<string>;
|
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
|
/**
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F-swarm — parallel detection. Runs N workers at once, each with its own
|
|
3
|
+
* browser context, event bus, action recorder, and classifier, so the action
|
|
4
|
+
* history attached to a finding belongs to the worker that saw it (never
|
|
5
|
+
* interleaved). Workers attack different sides: a deterministic walk, several
|
|
6
|
+
* chaos-fuzz seeds, and — optionally — the server-side HTTP fuzzer.
|
|
7
|
+
*
|
|
8
|
+
* Findings are merged by fingerprint at the end (occurrences summed, the richest
|
|
9
|
+
* action history / source mapping kept); the caller then runs repro/heal on the
|
|
10
|
+
* merged set as usual.
|
|
11
|
+
*/
|
|
12
|
+
import type { Browser } from "playwright";
|
|
13
|
+
import { EventBus } from "./eventBus.js";
|
|
14
|
+
import type { Finding } from "./types.js";
|
|
15
|
+
export type WorkerStrategy = {
|
|
16
|
+
kind: "walk";
|
|
17
|
+
} | {
|
|
18
|
+
kind: "fuzz";
|
|
19
|
+
seed: number;
|
|
20
|
+
};
|
|
21
|
+
export interface DetectResult {
|
|
22
|
+
findings: Finding[];
|
|
23
|
+
actions: number;
|
|
24
|
+
/** New JS code ranges covered by this worker's fuzz pass (0 for walk). */
|
|
25
|
+
newCoverage: number;
|
|
26
|
+
/** Whether the walk encountered a login form (a password input). */
|
|
27
|
+
sawLoginForm: boolean;
|
|
28
|
+
/** Auth-state path saved by worker 0 (used to authenticate replays). */
|
|
29
|
+
replayStorageState?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface DetectWorkerOptions {
|
|
32
|
+
url: string;
|
|
33
|
+
repoRoot: string;
|
|
34
|
+
allowHosts: Set<string>;
|
|
35
|
+
maxActions: number;
|
|
36
|
+
dryRun?: boolean;
|
|
37
|
+
guardOn: boolean;
|
|
38
|
+
storageState?: string;
|
|
39
|
+
login?: boolean;
|
|
40
|
+
loginEmail?: string;
|
|
41
|
+
loginPassword?: string;
|
|
42
|
+
loginUrl?: string;
|
|
43
|
+
crashTest?: boolean;
|
|
44
|
+
saveAuthState?: boolean;
|
|
45
|
+
httpFuzzMutations?: boolean;
|
|
46
|
+
/** Fold the HTTP fuzzer in as a post-pass on this worker's page. */
|
|
47
|
+
httpFuzz?: boolean;
|
|
48
|
+
/** Opt-in: include destructive controls/endpoints (delete/pay/logout/…). */
|
|
49
|
+
allowDestructive?: boolean;
|
|
50
|
+
baseline: string[];
|
|
51
|
+
log: (msg: string) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run one worker's detection pass and return its findings. All internal events
|
|
55
|
+
* flow through a local bus (isolation); only `action`/`route`/`noise` are
|
|
56
|
+
* forwarded to `forwardBus` so a live panel can aggregate, never per-worker
|
|
57
|
+
* findings (those are merged by the caller first).
|
|
58
|
+
*/
|
|
59
|
+
export declare function detectWorker(browser: Browser, opts: DetectWorkerOptions, strategy: WorkerStrategy, forwardBus?: EventBus): Promise<DetectResult>;
|
|
60
|
+
/** Dedup findings across workers by fingerprint: sum occurrences, keep the richest. */
|
|
61
|
+
export declare function mergeFindings(arrays: Finding[][]): Finding[];
|
|
62
|
+
export interface SwarmOptions {
|
|
63
|
+
url: string;
|
|
64
|
+
repoRoot: string;
|
|
65
|
+
maxActions: number;
|
|
66
|
+
dryRun?: boolean;
|
|
67
|
+
fuzz?: boolean;
|
|
68
|
+
httpFuzz?: boolean;
|
|
69
|
+
httpFuzzMutations?: boolean;
|
|
70
|
+
seed: number;
|
|
71
|
+
workers: number;
|
|
72
|
+
allowHosts: Set<string>;
|
|
73
|
+
storageState?: string;
|
|
74
|
+
login?: boolean;
|
|
75
|
+
loginEmail?: string;
|
|
76
|
+
loginPassword?: string;
|
|
77
|
+
loginUrl?: string;
|
|
78
|
+
crashTest?: boolean;
|
|
79
|
+
/** Opt-in: include destructive controls/endpoints (delete/pay/logout/…). */
|
|
80
|
+
allowDestructive?: boolean;
|
|
81
|
+
baseline: string[];
|
|
82
|
+
guardOn: boolean;
|
|
83
|
+
log: (msg: string) => void;
|
|
84
|
+
/** Forwarded to the orchestrator's bus so a live panel can aggregate action/route counts. */
|
|
85
|
+
forwardBus?: EventBus;
|
|
86
|
+
}
|
|
87
|
+
export interface SwarmResult {
|
|
88
|
+
findings: Finding[];
|
|
89
|
+
replayStorageState?: string;
|
|
90
|
+
totalActions: number;
|
|
91
|
+
totalCoverage: number;
|
|
92
|
+
workerCount: number;
|
|
93
|
+
roles: string[];
|
|
94
|
+
sawLoginForm: boolean;
|
|
95
|
+
}
|
|
96
|
+
/** Launch one browser, run the worker roster concurrently, merge findings. */
|
|
97
|
+
export declare function swarmDetect(opts: SwarmOptions): Promise<SwarmResult>;
|
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) => {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F11 — opt-in telemetry / data flywheel. Collects the anonymized
|
|
3
|
+
* `[crash_fingerprint, min_repro_spec, verified_patch, framework_metadata,
|
|
4
|
+
* model_tier_used]` tuple for each crash/error finding, appends it to a local
|
|
5
|
+
* JSONL dataset, and — only under `--share-data` — dispatches an envelope to the
|
|
6
|
+
* telemetry endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Privacy: everything is opt-in. `--telemetry` collects and persists locally
|
|
9
|
+
* only; `--share-data` additionally uploads. The dispatch is fire-and-forget,
|
|
10
|
+
* bounded by a 2s abort, and can never change the CLI exit code.
|
|
11
|
+
*/
|
|
12
|
+
import type { Finding } from "../types.js";
|
|
13
|
+
import type { TelemetryEnvelope } from "./types.js";
|
|
14
|
+
export interface SubmitOptions {
|
|
15
|
+
repoRoot: string;
|
|
16
|
+
url: string;
|
|
17
|
+
telemetry: boolean;
|
|
18
|
+
shareData: boolean;
|
|
19
|
+
endpoint?: string;
|
|
20
|
+
/** API key presented as `x-api-key` (falls back to `AZTRX_CLOUD_API_KEY`).
|
|
21
|
+
* Deliberately not `AZTRX_API_KEY` — that one is a model provider credential
|
|
22
|
+
* (`llm.ts:29,35`) and must never travel as an upload auth header. */
|
|
23
|
+
apiKey?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Fire-and-forget upload. Never rejects; bounded by a short abort. Failures are
|
|
26
|
+
* reported on stderr rather than discarded — silence here reads as success. */
|
|
27
|
+
export declare function dispatchTelemetry(envelope: TelemetryEnvelope, endpoint: string, apiKey?: string): Promise<void>;
|
|
28
|
+
/** Collect + sanitize + persist, and (under `--share-data`) dispatch. Sync on
|
|
29
|
+
* the local path; the upload is detached so the run never waits on the network. */
|
|
30
|
+
export declare function submitTelemetry(findings: Finding[], opts: SubmitOptions): void;
|
|
31
|
+
/** Await all in-flight uploads (each already bounded). Called right before the
|
|
32
|
+
* CLI exits so a pending upload isn't killed mid-flight; never affects exit code. */
|
|
33
|
+
export declare function flushTelemetry(): Promise<void>;
|