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.
Files changed (102) hide show
  1. package/README.md +326 -29
  2. package/dist/cli/help.d.ts +22 -0
  3. package/dist/cli/repo.d.ts +20 -0
  4. package/dist/cli/repo.js +50 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +284 -86
  7. package/dist/core/auth.d.ts +33 -0
  8. package/dist/core/badge.d.ts +9 -0
  9. package/dist/core/browser.d.ts +3 -0
  10. package/dist/core/classifier.d.ts +41 -0
  11. package/dist/core/cloud/index.d.ts +62 -0
  12. package/dist/core/cloud/index.js +24 -4
  13. package/dist/core/devServer.d.ts +90 -0
  14. package/dist/core/devServer.js +253 -0
  15. package/dist/core/diagnose.d.ts +18 -0
  16. package/dist/core/diff.d.ts +31 -0
  17. package/dist/core/domWalker.d.ts +24 -0
  18. package/dist/core/domWalker.js +18 -2
  19. package/dist/core/eventBus.d.ts +59 -0
  20. package/dist/core/events.d.ts +42 -0
  21. package/dist/core/fixPr.d.ts +16 -0
  22. package/dist/core/fixPr.js +11 -1
  23. package/dist/core/fuzzer.d.ts +24 -0
  24. package/dist/core/fuzzer.js +1 -1
  25. package/dist/core/heal/apply.d.ts +28 -0
  26. package/dist/core/heal/boot.d.ts +74 -0
  27. package/dist/core/heal/boot.js +126 -23
  28. package/dist/core/heal/childEnv.d.ts +15 -0
  29. package/dist/core/heal/gates.d.ts +11 -0
  30. package/dist/core/heal/index.d.ts +19 -0
  31. package/dist/core/heal/index.js +53 -23
  32. package/dist/core/heal/llm.d.ts +43 -0
  33. package/dist/core/heal/llm.js +22 -2
  34. package/dist/core/heal/redact.d.ts +22 -0
  35. package/dist/core/heal/sandbox.d.ts +54 -0
  36. package/dist/core/heal/sandbox.js +63 -3
  37. package/dist/core/heal/types.d.ts +102 -0
  38. package/dist/core/heal/verify.d.ts +37 -0
  39. package/dist/core/heal/verify.js +28 -14
  40. package/dist/core/httpFuzzer.d.ts +33 -0
  41. package/dist/core/init.d.ts +21 -0
  42. package/dist/core/init.js +5 -6
  43. package/dist/core/interceptor.d.ts +21 -0
  44. package/dist/core/llm.d.ts +36 -0
  45. package/dist/core/llm.js +40 -13
  46. package/dist/core/minimizer.d.ts +13 -0
  47. package/dist/core/modernize.d.ts +28 -0
  48. package/dist/core/modernize.js +2 -2
  49. package/dist/core/networkGuard.d.ts +13 -0
  50. package/dist/core/orchestrator.d.ts +86 -0
  51. package/dist/core/orchestrator.js +5 -2
  52. package/dist/core/patrol/loop.d.ts +46 -0
  53. package/dist/core/patrol/pr.d.ts +42 -0
  54. package/dist/core/patrol/pr.js +9 -6
  55. package/dist/core/patrol/record.d.ts +51 -0
  56. package/dist/core/patrol/state.d.ts +39 -0
  57. package/dist/core/pr.d.ts +3 -0
  58. package/dist/core/pr.js +15 -11
  59. package/dist/core/prompt.d.ts +14 -0
  60. package/dist/core/recorder.d.ts +23 -0
  61. package/dist/core/recorder.js +1 -1
  62. package/dist/core/renderMarkdown.d.ts +7 -0
  63. package/dist/core/replay.d.ts +33 -0
  64. package/dist/core/replay.js +52 -7
  65. package/dist/core/report.d.ts +8 -0
  66. package/dist/core/resolver.d.ts +64 -0
  67. package/dist/core/resolver.js +135 -17
  68. package/dist/core/rng.d.ts +2 -0
  69. package/dist/core/specCompiler.d.ts +17 -0
  70. package/dist/core/studio.d.ts +6 -0
  71. package/dist/core/studio.js +0 -3
  72. package/dist/core/summarize.d.ts +26 -0
  73. package/dist/core/summarize.js +17 -6
  74. package/dist/core/swarm.d.ts +97 -0
  75. package/dist/core/swarm.js +25 -2
  76. package/dist/core/telemetry/index.d.ts +33 -0
  77. package/dist/core/telemetry/index.js +22 -4
  78. package/dist/core/telemetry/sanitize.d.ts +19 -0
  79. package/dist/core/telemetry/types.d.ts +26 -0
  80. package/dist/core/types.d.ts +88 -0
  81. package/dist/core/ui.d.ts +23 -0
  82. package/dist/core/ui.js +0 -5
  83. package/dist/core/validator.d.ts +14 -0
  84. package/dist/core/version.d.ts +1 -0
  85. package/dist/hooks/index.d.ts +106 -0
  86. package/dist/hooks/index.js +349 -0
  87. package/dist/mcp/index.d.ts +58 -0
  88. package/dist/mcp/index.js +295 -0
  89. package/dist/mcp/install.d.ts +52 -0
  90. package/dist/mcp/install.js +204 -0
  91. package/dist/mcp/protocol.d.ts +106 -0
  92. package/dist/mcp/protocol.js +156 -0
  93. package/dist/mcp/tools.d.ts +107 -0
  94. package/dist/mcp/tools.js +621 -0
  95. package/dist/next/index.d.ts +70 -0
  96. package/dist/next/index.js +103 -0
  97. package/dist/plugins/scan.d.ts +85 -0
  98. package/dist/plugins/scan.js +188 -0
  99. package/dist/ui/app.d.ts +11 -0
  100. package/dist/vite/index.d.ts +60 -0
  101. package/dist/vite/index.js +56 -0
  102. package/package.json +44 -6
@@ -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
  }
@@ -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
- await page.goto(url, { waitUntil: "load", timeout: 30000 }).catch(() => { });
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
- await page.waitForTimeout(300);
123
- const reproduced = opts?.targetType
124
- ? types.has(opts.targetType)
125
- : fingerprints.has(targetFingerprint);
126
- return { reproduced };
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
+ };
@@ -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 source path. Handles the
60
- * dev-server schemes (`webpack-internal:///(ns)/./src/…`, `webpack://ns/src/…`),
61
- * `file://`, and plain `https://host/path` bundle URLs. */
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
- return stripQuery(url)
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 cleaned = source
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(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
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: relative,
224
+ sourceFile: shown,
187
225
  line: frame.line,
188
226
  column: frame.column,
189
- codeSnippet: `<file not accessible locally: ${relative}>`,
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: frame.line,
197
- column: frame.column,
198
- codeSnippet: extractSnippet(directPath, frame.line),
199
- 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",
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,2 @@
1
+ /** Deterministic seeded PRNG (mulberry32) so fuzz runs are reproducible. */
2
+ export declare function mulberry32(seed: number): () => number;
@@ -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[];
@@ -0,0 +1,6 @@
1
+ import * as http from "http";
2
+ export interface StudioOptions {
3
+ repoRoot: string;
4
+ port?: number;
5
+ }
6
+ export declare function startStudio(opts: StudioOptions): http.Server;
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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>;
@@ -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
  /**
@@ -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>;
@@ -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) => {
@@ -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>;