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
@@ -0,0 +1,621 @@
1
+ /**
2
+ * The three tools, their schemas, and the handlers behind them.
3
+ *
4
+ * `aztrx_scan` finds and proves; `aztrx_repro` shows one finding's steps;
5
+ * `aztrx_fix` patches and verifies. The split is about the agent's context
6
+ * budget, not about the code: a `Finding` carries a whole minimized action
7
+ * sequence and a compiled spec, and a fix carries a diff — inlining all of that
8
+ * into every scan response is how a useful call becomes an expensive one. The
9
+ * scan returns a compact projection and a handle; detail is fetched by handle.
10
+ *
11
+ * Sessions are gone from the protocol in `2026-07-28` and never existed in this
12
+ * design. Cross-call state is an explicit, opaque, server-minted `scanId` held in
13
+ * a bounded in-memory map — which is easier to reason about than hidden state, and
14
+ * is exactly what the spec recommends for stateful tools.
15
+ *
16
+ * Nothing here imports the orchestrator, Playwright, or the heal stack. Those are
17
+ * `await import()`ed on first use, because an editor kills a server that is slow
18
+ * to answer `initialize` and `core/orchestrator.js` costs ~1.7s to load.
19
+ */
20
+ import { randomUUID } from "crypto";
21
+ import * as fs from "fs";
22
+ import * as path from "path";
23
+ import { ProtocolError, INVALID_PARAMS } from "./protocol.js";
24
+ /** How many scans stay addressable. An editor session is one developer and a
25
+ * handful of scans; older handles fall off the end, oldest first, and a call
26
+ * against an evicted one says so and asks for a re-scan. */
27
+ const MAX_RECORDS = 8;
28
+ /** Boot can legitimately take a while (cold Next.js compile). Matches the CLI's
29
+ * own ceiling in `core/devServer.ts`. */
30
+ const BOOT_TIMEOUT_MS = 90_000;
31
+ // ---------------------------------------------------------------------------
32
+ // Definitions
33
+ // ---------------------------------------------------------------------------
34
+ const SCAN_ID_DESC = "The `scanId` returned by aztrx_scan.";
35
+ export const TOOLS = [
36
+ {
37
+ name: "aztrx_scan",
38
+ title: "Scan a running web app for runtime crashes",
39
+ description: "Drive the app in a real browser, interact with it, and report the runtime crashes and " +
40
+ "errors it produced. With no `url`, aztrx detects the framework, boots the dev server " +
41
+ "itself, and stops it again afterwards. Returns a compact list; each finding has an `id` " +
42
+ "that aztrx_repro and aztrx_fix take. A scan that could not run returns an error — it " +
43
+ "never reports 'no findings' for an app it never reached. Takes tens of seconds. Results " +
44
+ "are held in memory for the last 8 scans.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ repoPath: {
49
+ type: "string",
50
+ description: "Project root to scan (defaults to the directory the server was started in).",
51
+ },
52
+ url: {
53
+ type: "string",
54
+ description: "Dev server to scan, e.g. http://localhost:3000. Omit and aztrx finds a running " +
55
+ "server or boots the project's own.",
56
+ },
57
+ maxActions: {
58
+ type: "integer",
59
+ minimum: 1,
60
+ maximum: 5000,
61
+ description: "How many interactions to attempt in one pass (default 100).",
62
+ },
63
+ runs: {
64
+ type: "integer",
65
+ minimum: 1,
66
+ maximum: 20,
67
+ description: "Replay iterations used to judge each finding reproducible (default 3).",
68
+ },
69
+ seed: {
70
+ type: "integer",
71
+ minimum: 0,
72
+ maximum: 2147483647,
73
+ description: "RNG seed. The same seed walks the app the same way (default 42).",
74
+ },
75
+ },
76
+ additionalProperties: false,
77
+ },
78
+ },
79
+ {
80
+ name: "aztrx_repro",
81
+ title: "Show how to reproduce one finding",
82
+ description: "The minimized action sequence that reproduces one finding from a scan, the reproducibility " +
83
+ "verdict (deterministic | flaky | unreliable), and the compiled Playwright spec. Reads the " +
84
+ "scan that already ran — it re-runs nothing, and is free.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ scanId: { type: "string", description: SCAN_ID_DESC },
89
+ findingId: { type: "string", description: "A finding `id` from that scan." },
90
+ },
91
+ required: ["scanId", "findingId"],
92
+ additionalProperties: false,
93
+ },
94
+ },
95
+ {
96
+ name: "aztrx_fix",
97
+ title: "Patch a finding and verify the crash is gone",
98
+ description: "Closed loop for one finding: generate a patch, apply it inside a git worktree, then replay " +
99
+ "the repro against the patched app to check the crash is actually gone. Your working tree is " +
100
+ "touched only when you pass `apply: true`, and aztrx never commits. Needs ANTHROPIC_API_KEY — " +
101
+ "without one the status comes back `no-llm` and nothing is attempted. Costs a model call and " +
102
+ "takes minutes; the result is cached, so a second call with `apply: true` reuses it.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ scanId: { type: "string", description: SCAN_ID_DESC },
107
+ findingId: { type: "string", description: "A finding `id` from that scan." },
108
+ apply: {
109
+ type: "boolean",
110
+ description: "Write the verified patch into your working tree (default false — the diff is returned " +
111
+ "either way). Only a patch that verified is ever applied.",
112
+ },
113
+ },
114
+ required: ["scanId", "findingId"],
115
+ additionalProperties: false,
116
+ },
117
+ },
118
+ ];
119
+ // ---------------------------------------------------------------------------
120
+ // Results
121
+ // ---------------------------------------------------------------------------
122
+ /** A tool result carrying both the readable summary and the machine shape.
123
+ *
124
+ * The serialized JSON also goes into a text block because that is what the spec
125
+ * asks of any tool returning `structuredContent` — a client on an older revision
126
+ * has no `structuredContent` to read and would otherwise get the summary alone. */
127
+ function ok(summary, structured) {
128
+ return {
129
+ content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(structured, null, 2)}` }],
130
+ structuredContent: structured,
131
+ };
132
+ }
133
+ /** A tool execution error: actionable feedback the model can retry on.
134
+ *
135
+ * Deliberately carries no `structuredContent`. A failed scan must never be
136
+ * readable as a result — an agent that sees `counts: {crash: 0}` and an error
137
+ * message will act on the zero. "Nothing is broken" and "nothing was looked at"
138
+ * are different sentences, and this is the place that keeps them apart. */
139
+ function fail(message) {
140
+ return { content: [{ type: "text", text: message }], isError: true };
141
+ }
142
+ function readText(file) {
143
+ if (!file)
144
+ return null;
145
+ try {
146
+ return fs.readFileSync(file, "utf-8");
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ function argString(args, key, max = 4096) {
153
+ const raw = args[key];
154
+ if (raw === undefined || raw === null)
155
+ return { ok: true, value: undefined };
156
+ if (typeof raw !== "string") {
157
+ return { ok: false, error: `\`${key}\` must be a string, got ${typeof raw}.` };
158
+ }
159
+ if (raw.length > max)
160
+ return { ok: false, error: `\`${key}\` is longer than ${max} characters.` };
161
+ return { ok: true, value: raw };
162
+ }
163
+ function argInt(args, key, min, max) {
164
+ const raw = args[key];
165
+ if (raw === undefined || raw === null)
166
+ return { ok: true, value: undefined };
167
+ if (typeof raw !== "number" || !Number.isInteger(raw)) {
168
+ return { ok: false, error: `\`${key}\` must be an integer.` };
169
+ }
170
+ if (raw < min || raw > max) {
171
+ return { ok: false, error: `\`${key}\` must be between ${min} and ${max}.` };
172
+ }
173
+ return { ok: true, value: raw };
174
+ }
175
+ function argBool(args, key) {
176
+ const raw = args[key];
177
+ if (raw === undefined || raw === null)
178
+ return { ok: true, value: undefined };
179
+ if (typeof raw !== "boolean")
180
+ return { ok: false, error: `\`${key}\` must be true or false.` };
181
+ return { ok: true, value: raw };
182
+ }
183
+ export class McpRuntime {
184
+ deps;
185
+ records = new Map();
186
+ /** Dev servers this process booted and has not yet stopped. The server tears
187
+ * these down on shutdown — a scan interrupted by a closing editor must not
188
+ * leave a dev server holding its port. */
189
+ openTargets = new Set();
190
+ /** Scans and fixes run one at a time; see `serialize`. */
191
+ queue = Promise.resolve();
192
+ constructor(deps = {}) {
193
+ this.deps = deps;
194
+ }
195
+ async call(name, args) {
196
+ switch (name) {
197
+ case "aztrx_scan":
198
+ return this.scan(args);
199
+ case "aztrx_repro":
200
+ return this.repro(args);
201
+ case "aztrx_fix":
202
+ return this.fix(args);
203
+ default:
204
+ throw new ProtocolError(INVALID_PARAMS, `Unknown tool: ${name}`);
205
+ }
206
+ }
207
+ /** Stop every dev server this process started. Idempotent. */
208
+ async closeAll() {
209
+ const pending = [...this.openTargets];
210
+ this.openTargets.clear();
211
+ await Promise.all(pending.map((close) => close().catch(() => { })));
212
+ }
213
+ /** One scanning run at a time.
214
+ *
215
+ * `run()` truncates `.aztrx/events.jsonl` and rewrites `.aztrx/report.html` and
216
+ * `.aztrx/repro/<id>.spec.ts`, and a zero-config scan boots a dev server on a
217
+ * port it picked. Two overlapping runs in one repo therefore corrupt each
218
+ * other's artifacts and race for the port — and an MCP client can pipeline
219
+ * requests, so this is a real case rather than a theoretical one. `heal`
220
+ * creates git worktrees and boots servers of its own, so it shares the queue. */
221
+ serialize(work) {
222
+ const next = this.queue.then(work, work);
223
+ this.queue = next.catch(() => { });
224
+ return next;
225
+ }
226
+ defaultRepo() {
227
+ return this.deps.defaultRepoRoot ?? process.cwd();
228
+ }
229
+ async loadRun() {
230
+ if (this.deps.loadRun)
231
+ return this.deps.loadRun();
232
+ return (await import("../core/orchestrator.js")).run;
233
+ }
234
+ async loadHeal() {
235
+ if (this.deps.loadHeal)
236
+ return this.deps.loadHeal();
237
+ return (await import("../core/heal/index.js")).heal;
238
+ }
239
+ remember(record) {
240
+ this.records.set(record.scanId, record);
241
+ while (this.records.size > MAX_RECORDS) {
242
+ const oldest = [...this.records.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
243
+ this.records.delete(oldest.scanId);
244
+ }
245
+ }
246
+ // -------------------------------------------------------------------------
247
+ // aztrx_scan
248
+ // -------------------------------------------------------------------------
249
+ async scan(args) {
250
+ const repoArg = argString(args, "repoPath");
251
+ if (!repoArg.ok)
252
+ return fail(repoArg.error);
253
+ const urlArg = argString(args, "url", 2048);
254
+ if (!urlArg.ok)
255
+ return fail(urlArg.error);
256
+ const maxActions = argInt(args, "maxActions", 1, 5000);
257
+ if (!maxActions.ok)
258
+ return fail(maxActions.error);
259
+ const runs = argInt(args, "runs", 1, 20);
260
+ if (!runs.ok)
261
+ return fail(runs.error);
262
+ const seed = argInt(args, "seed", 0, 2147483647);
263
+ if (!seed.ok)
264
+ return fail(seed.error);
265
+ const repoRoot = path.resolve(repoArg.value ?? this.defaultRepo());
266
+ if (!fs.existsSync(repoRoot))
267
+ return fail(`repoPath does not exist: ${repoRoot}`);
268
+ const url = urlArg.value;
269
+ if (url !== undefined) {
270
+ try {
271
+ new URL(url);
272
+ }
273
+ catch {
274
+ return fail(`\`url\` is not a valid URL: ${url}`);
275
+ }
276
+ }
277
+ return this.serialize(async () => {
278
+ let targetUrl = url;
279
+ let bootedClose;
280
+ let how = "given";
281
+ try {
282
+ if (targetUrl === undefined) {
283
+ const resolved = await this.bootOrAttach(repoRoot);
284
+ if (!resolved.ok) {
285
+ // Not a clean scan. The caller gets an error, never an empty finding
286
+ // list — see the note on `fail`.
287
+ return fail(resolved.error);
288
+ }
289
+ targetUrl = resolved.url;
290
+ how = resolved.close ? "booted" : "attached";
291
+ if (resolved.close) {
292
+ bootedClose = resolved.close;
293
+ this.openTargets.add(resolved.close);
294
+ }
295
+ }
296
+ const run = await this.loadRun();
297
+ const findings = await run({
298
+ url: targetUrl,
299
+ repoRoot,
300
+ // "Emit nothing; the caller renders" — which here means "the caller
301
+ // speaks a protocol on stdout". See the purity guard in index.ts.
302
+ ui: true,
303
+ // Repro is on because `aztrx_repro` and `aztrx_fix` read what this
304
+ // produced. Healing is off: it is a separate, paid, minutes-long call.
305
+ repro: true,
306
+ maxActions: maxActions.value,
307
+ reproRuns: runs.value,
308
+ seed: seed.value,
309
+ });
310
+ const scanId = randomUUID();
311
+ this.remember({
312
+ scanId,
313
+ repoRoot,
314
+ url: targetUrl,
315
+ findings,
316
+ heals: new Map(),
317
+ createdAt: Date.now(),
318
+ });
319
+ const counts = {
320
+ crash: findings.filter((f) => f.severity === "crash").length,
321
+ error: findings.filter((f) => f.severity === "error").length,
322
+ warning: findings.filter((f) => f.severity === "warning").length,
323
+ };
324
+ const structured = {
325
+ scanId,
326
+ url: targetUrl,
327
+ repoRoot,
328
+ counts,
329
+ findings: findings.map(projectFinding),
330
+ };
331
+ const critical = counts.crash + counts.error;
332
+ const where = how === "attached" ? "attached to" : how === "booted" ? "booted" : "scanned";
333
+ const head = critical === 0
334
+ ? `Clean scan — ${where} ${targetUrl}, no crash or error.`
335
+ : `Found ${counts.crash} crash and ${counts.error} error (${counts.warning} warning) — ${where} ${targetUrl}.`;
336
+ const next = findings.length > 0
337
+ ? `\nNext: aztrx_repro { scanId: "${scanId}", findingId } for the steps, aztrx_fix for a patch.`
338
+ : "";
339
+ return ok(head + next, structured);
340
+ }
341
+ catch (e) {
342
+ return fail(`The scan failed: ${e.message}`);
343
+ }
344
+ finally {
345
+ // The app was only needed for the scan. `heal` boots the *worktree*, not
346
+ // this server, so holding it open between calls would just be an orphan
347
+ // dev server on the developer's machine.
348
+ if (bootedClose)
349
+ await this.release(bootedClose);
350
+ }
351
+ });
352
+ }
353
+ /** Find a running app, or start the project's own.
354
+ *
355
+ * `core/devServer.ts`'s `resolveTarget` does this too, but it also arms
356
+ * `process.once("SIGINT"/"SIGTERM")` handlers that are never removed and that
357
+ * call `process.exit` themselves. That is right for a CLI about to exit, and
358
+ * wrong for a process that will serve an editor for hours: the sixth scan trips
359
+ * MaxListenersExceededWarning, and the first Ctrl-C kills the server before it
360
+ * can tear the dev server down. So the same three steps are run here, and the
361
+ * close hook is handed back instead of wired to a signal. */
362
+ async bootOrAttach(repoRoot) {
363
+ const [{ planBoot, findRunning }, { bootServer }] = await Promise.all([
364
+ import("../core/devServer.js"),
365
+ import("../core/heal/boot.js"),
366
+ ]);
367
+ const plan = planBoot(repoRoot);
368
+ const running = await findRunning(repoRoot, plan);
369
+ if (running)
370
+ return { ok: true, url: running };
371
+ if (!plan) {
372
+ return {
373
+ ok: false,
374
+ error: "No dev server is running and package.json has no `dev` or `start` script to boot. " +
375
+ "Pass `url`, or run `aztrx-cli init` in the project first.",
376
+ };
377
+ }
378
+ try {
379
+ const server = await bootServer({
380
+ worktreeDir: repoRoot,
381
+ repoRoot,
382
+ startCommand: plan.startCommand,
383
+ timeoutMs: BOOT_TIMEOUT_MS,
384
+ port: plan.port,
385
+ // The developer's own app on their own machine — it gets the real
386
+ // environment, exactly as the CLI's zero-config boot does. `heal` keeps
387
+ // the isolated default, because it runs a patch a model wrote.
388
+ env: "inherit",
389
+ });
390
+ return { ok: true, url: server.url, close: server.close };
391
+ }
392
+ catch (e) {
393
+ return { ok: false, error: `Could not start \`${plan.startCommand}\`: ${e.message}` };
394
+ }
395
+ }
396
+ async release(close) {
397
+ this.openTargets.delete(close);
398
+ await close().catch(() => { });
399
+ }
400
+ // -------------------------------------------------------------------------
401
+ // aztrx_repro / aztrx_fix
402
+ // -------------------------------------------------------------------------
403
+ /** Resolve a `{scanId, findingId}` pair to the finding, or to the message the
404
+ * model needs. A stale handle is an ordinary, recoverable answer. */
405
+ resolve(args) {
406
+ const scanId = argString(args, "scanId", 128);
407
+ if (!scanId.ok)
408
+ return { failure: fail(scanId.error) };
409
+ const findingId = argString(args, "findingId", 256);
410
+ if (!findingId.ok)
411
+ return { failure: fail(findingId.error) };
412
+ if (!scanId.value)
413
+ return { failure: fail("`scanId` is required — call aztrx_scan first.") };
414
+ if (!findingId.value)
415
+ return { failure: fail("`findingId` is required — ids come from aztrx_scan.") };
416
+ const record = this.records.get(scanId.value);
417
+ if (!record) {
418
+ const known = [...this.records.keys()];
419
+ return {
420
+ failure: fail(`Unknown scanId ${scanId.value}. ` +
421
+ (known.length ? `Known: ${known.join(", ")}. ` : "") +
422
+ `Scans are held in memory for the last ${MAX_RECORDS} runs — call aztrx_scan again.`),
423
+ };
424
+ }
425
+ const finding = record.findings.find((f) => f.id === findingId.value);
426
+ if (!finding) {
427
+ const ids = record.findings.map((f) => f.id);
428
+ return {
429
+ failure: fail(`Scan ${scanId.value} has no finding ${findingId.value}. ` +
430
+ (ids.length ? `It has: ${ids.join(", ")}.` : "It found nothing.") +
431
+ " Note that a clean scan has no findings to fetch."),
432
+ };
433
+ }
434
+ return { record, finding };
435
+ }
436
+ repro(args) {
437
+ const resolved = this.resolve(args);
438
+ if ("failure" in resolved)
439
+ return resolved.failure;
440
+ const { record, finding } = resolved;
441
+ const r = finding.repro;
442
+ if (!r) {
443
+ return fail(`${finding.id} carries no repro report, so there are no steps to show. ` +
444
+ "Re-scan: aztrx always requests one, so this means the report was lost.");
445
+ }
446
+ const spec = readText(r.specPath);
447
+ const structured = {
448
+ scanId: record.scanId,
449
+ findingId: finding.id,
450
+ verdict: r.verdict,
451
+ rate: r.rate,
452
+ runs: r.runs,
453
+ reproductions: r.reproductions,
454
+ actions: r.actions,
455
+ specPath: r.specPath ? path.relative(record.repoRoot, r.specPath).replace(/\\/g, "/") : null,
456
+ spec,
457
+ };
458
+ const steps = r.actions.map((a, i) => `${i + 1}. ${describeAction(a)}`).join("\n");
459
+ const summary = `${finding.id}: ${r.verdict} — reproduced in ${r.reproductions}/${r.runs} replays.\n` +
460
+ (steps ? `\n${steps}` : "\nNo actions — the fault happens on load.") +
461
+ (spec ? `\n\nSpec: ${structured.specPath}` : "");
462
+ return ok(summary, structured);
463
+ }
464
+ async fix(args) {
465
+ const resolved = this.resolve(args);
466
+ if ("failure" in resolved)
467
+ return resolved.failure;
468
+ const applyArg = argBool(args, "apply");
469
+ if (!applyArg.ok)
470
+ return fail(applyArg.error);
471
+ const apply = applyArg.value === true;
472
+ const { record, finding } = resolved;
473
+ let healResult = record.heals.get(finding.id);
474
+ if (!healResult) {
475
+ // The gates `heal` applies internally, checked here so the answer is a
476
+ // sentence about the finding rather than a `skipped` status the model has
477
+ // to decode.
478
+ if (!finding.mappedLocation?.isOwnCode) {
479
+ return fail(`${finding.id} has no own-code source location to patch — the stack maps into a ` +
480
+ "dependency or was never resolved to a file. Nothing to edit.");
481
+ }
482
+ if (!finding.repro || finding.repro.verdict === "unreliable") {
483
+ return fail(`${finding.id} has no deterministic repro, so there is nothing to prove a patch ` +
484
+ "against. A fix that cannot be verified is not a fix.");
485
+ }
486
+ return this.serialize(async () => {
487
+ const healFn = await this.loadHeal();
488
+ try {
489
+ healResult = await healFn(finding, await this.healOptions(record, finding));
490
+ }
491
+ catch (e) {
492
+ // `heal` creates its git worktree outside its own try/finally, so a
493
+ // failure there rejects rather than coming back as a status. Say which
494
+ // it was instead of letting it look like a broken tool.
495
+ return fail(`Healing ${finding.id} failed before it could report a status: ${e.message}`);
496
+ }
497
+ record.heals.set(finding.id, healResult);
498
+ return this.finishFix(record, finding, healResult, apply);
499
+ });
500
+ }
501
+ return this.finishFix(record, finding, healResult, apply);
502
+ }
503
+ async healOptions(record, finding) {
504
+ const { bootServer, detectStartCommand } = await import("../core/heal/boot.js");
505
+ const startCommand = detectStartCommand(record.repoRoot);
506
+ return {
507
+ repoRoot: record.repoRoot,
508
+ url: record.url,
509
+ // The minimized actions the verdict was measured on, not a re-derivation.
510
+ actions: finding.repro?.actions ?? [],
511
+ fingerprint: finding.fingerprint,
512
+ // Required by the type, unread by heal today. Kept because dropping a field
513
+ // from a caller's obligation is a decision for `heal`, not for its caller.
514
+ allowHosts: [],
515
+ // Heal's built-in default serves the worktree as static files and addresses
516
+ // the edited file by its repo-relative path — correct for a static fixture
517
+ // (`index.html`), wrong for everything else, where `src/App.tsx` is not a
518
+ // URL. So when the project has a dev script, boot the patched worktree the
519
+ // way the app is really started; otherwise leave the default to do the job
520
+ // it was written for. `env` stays at bootServer's isolated default.
521
+ serve: startCommand
522
+ ? (dir) => bootServer({ worktreeDir: dir, repoRoot: record.repoRoot, startCommand })
523
+ : undefined,
524
+ };
525
+ }
526
+ async finishFix(record, finding, healResult, apply) {
527
+ let applied = [];
528
+ let conflicts = [];
529
+ if (apply) {
530
+ if (healResult.status !== "healed") {
531
+ // Not a tool error: the answer to "fix it" is that the patch did not
532
+ // verify, and the status names the gate that stopped it. Applying an
533
+ // unverified patch is the one thing the gate exists to prevent.
534
+ return this.reportFix(record, finding, healResult, [], [], apply);
535
+ }
536
+ const { applyVerifiedPatches } = await import("../core/heal/apply.js");
537
+ const res = applyVerifiedPatches(record.repoRoot, [{ ...finding, heal: healResult }]);
538
+ applied = res.applied;
539
+ conflicts = res.conflicts;
540
+ }
541
+ return this.reportFix(record, finding, healResult, applied, conflicts, apply);
542
+ }
543
+ reportFix(record, finding, healResult, applied, conflicts, apply) {
544
+ const diff = readText(healResult.patchPath);
545
+ const structured = {
546
+ scanId: record.scanId,
547
+ findingId: finding.id,
548
+ status: healResult.status,
549
+ filePath: healResult.filePath,
550
+ explanation: healResult.explanation ?? null,
551
+ diff,
552
+ applied: applied.length ? applied : null,
553
+ conflicts: conflicts.length ? conflicts : null,
554
+ tests: healResult.test ?? null,
555
+ model: healResult.model ?? null,
556
+ error: healResult.error ?? null,
557
+ };
558
+ const hunks = healResult.hunks.length;
559
+ const file = healResult.filePath ? healResult.filePath.replace(/\\/g, "/") : "(no file)";
560
+ let summary;
561
+ if (healResult.status === "healed" && applied.length) {
562
+ summary =
563
+ `Applied ${hunks} edit${hunks === 1 ? "" : "s"} to ${file} — the crash no longer ` +
564
+ `reproduces under replay. Review with \`git diff\`; aztrx never commits.`;
565
+ }
566
+ else if (healResult.status === "healed" && apply) {
567
+ summary = `The patch verified but could not be applied to your working tree — see conflicts.`;
568
+ }
569
+ else if (healResult.status === "healed") {
570
+ summary =
571
+ `Patch verified: ${hunks} edit${hunks === 1 ? "" : "s"} to ${file}, and the crash no ` +
572
+ `longer reproduces under replay. Call again with \`apply: true\` to write it into your ` +
573
+ `working tree.\n\n${diff ?? ""}`;
574
+ }
575
+ else {
576
+ const why = healResult.error ? `: ${healResult.error}` : "";
577
+ summary =
578
+ `No patch — status \`${healResult.status}\`${why}.` +
579
+ (healResult.status === "no-llm"
580
+ ? " Set ANTHROPIC_API_KEY and call again to attempt a fix."
581
+ : "");
582
+ }
583
+ if (conflicts.length) {
584
+ summary +=
585
+ `\nConflicts (skipped, the .patch artifact is kept for review):\n` +
586
+ conflicts.map((c) => ` ${c.filePath}: ${c.error}`).join("\n");
587
+ }
588
+ return ok(summary, structured);
589
+ }
590
+ }
591
+ // ---------------------------------------------------------------------------
592
+ // Projections
593
+ // ---------------------------------------------------------------------------
594
+ /** What a scan returns per finding: enough to decide, nothing more. */
595
+ function projectFinding(f) {
596
+ return {
597
+ id: f.id,
598
+ severity: f.severity,
599
+ type: f.type,
600
+ // First line only. The rest of a stack is noise at this level, and the repro
601
+ // has the detail.
602
+ message: f.rawMessage.split("\n")[0].slice(0, 500),
603
+ occurrences: f.occurrences,
604
+ location: f.mappedLocation
605
+ ? {
606
+ file: f.mappedLocation.filePath.replace(/\\/g, "/"),
607
+ line: f.mappedLocation.line,
608
+ column: f.mappedLocation.column,
609
+ ownCode: f.mappedLocation.isOwnCode,
610
+ }
611
+ : null,
612
+ reproVerdict: f.repro?.verdict ?? null,
613
+ hasRepro: Boolean(f.repro),
614
+ };
615
+ }
616
+ /** One repro step, in the shape a person would type into a terminal. */
617
+ function describeAction(a) {
618
+ const value = a.value ? ` "${a.value}"` : "";
619
+ const selector = a.selectors[0] ? ` → ${a.selectors[0]}` : "";
620
+ return `${a.type}${value}${selector}`;
621
+ }