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
package/dist/core/llm.js CHANGED
@@ -44,14 +44,6 @@ export function fastModel() {
44
44
  return process.env.AZTRX_FAST_MODEL || "claude-haiku-4-5-20251001";
45
45
  return process.env.AZTRX_FAST_MODEL || undefined;
46
46
  }
47
- /** Human-readable description of the active provider + model, e.g. `grok-2 via https://api.x.ai/v1`. */
48
- export function describeLlm(model) {
49
- const s = resolveSettings();
50
- const m = model || primaryModel();
51
- if (s.provider === "anthropic")
52
- return `${m} (Anthropic)`;
53
- return `${m} via ${s.baseUrl}`;
54
- }
55
47
  // Announce the resolved model once per distinct (provider, model), so the two-tier
56
48
  // router shows each tier as it's tried without spamming. Written to stderr so it never
57
49
  // corrupts the Ink TUI (which renders on stdout).
@@ -81,6 +73,31 @@ export async function complete(opts) {
81
73
  ? anthropicComplete(s, model, opts)
82
74
  : openaiComplete(s, model, opts);
83
75
  }
76
+ /**
77
+ * Turn an empty completion into a diagnosable error.
78
+ *
79
+ * Returning `""` here is what makes a provider-side failure reach the caller as
80
+ * `Unexpected end of JSON input` — a message that points at *our* parser rather
81
+ * than at the model, and which the heal path then degrades to a bland "no-llm".
82
+ * The stop reason is the entire diagnosis, so it is carried into the message.
83
+ *
84
+ * Real cases this covers: a free/contended endpoint failing mid-flight
85
+ * (`finish_reason: "error"` on OpenRouter), a reasoning model spending the whole
86
+ * budget before emitting any text (`length`), and provider-side filters.
87
+ */
88
+ function emptyCompletion(provider, model, reason) {
89
+ const why = reason === "length" || reason === "max_tokens"
90
+ ? "the token limit was reached before any text was emitted — the model is most likely spending its whole budget on reasoning; pick a different one with --heal-model / AZTRX_MODEL"
91
+ : reason === "error"
92
+ ? "the provider failed mid-response"
93
+ : reason === "content_filter"
94
+ ? "the provider blocked the response"
95
+ : reason === "refusal"
96
+ ? "the model refused the request"
97
+ : "the provider returned no text";
98
+ const seen = reason ? `finish_reason: ${reason}` : "no finish_reason given";
99
+ return new Error(`${provider} returned no content (${seen}) — ${why}. Model: ${model}`);
100
+ }
84
101
  async function anthropicComplete(s, model, opts) {
85
102
  const headers = {
86
103
  "content-type": "application/json",
@@ -107,10 +124,13 @@ async function anthropicComplete(s, model, opts) {
107
124
  throw new Error(`LLM request failed (${res.status}): ${body.slice(0, 300)}`);
108
125
  }
109
126
  const data = (await res.json());
110
- return (data.content ?? [])
127
+ const text = (data.content ?? [])
111
128
  .filter((c) => c.type === "text")
112
129
  .map((c) => c.text ?? "")
113
130
  .join("\n");
131
+ if (!text.trim())
132
+ throw emptyCompletion("Anthropic", model, data.stop_reason);
133
+ return text;
114
134
  }
115
135
  async function openaiComplete(s, model, opts) {
116
136
  const res = await fetch(`${s.baseUrl}/chat/completions`, {
@@ -134,14 +154,21 @@ async function openaiComplete(s, model, opts) {
134
154
  throw new Error(`LLM request failed (${res.status}): ${body.slice(0, 300)}`);
135
155
  }
136
156
  const data = (await res.json());
137
- const content = data.choices?.[0]?.message?.content;
138
- if (typeof content === "string")
157
+ // OpenRouter reports upstream failures in the body with HTTP 200, so `res.ok`
158
+ // alone does not mean the model answered.
159
+ if (data.error)
160
+ throw new Error(`LLM request failed: ${data.error.message ?? "unknown error"}`);
161
+ const choice = data.choices?.[0];
162
+ const content = choice?.message?.content;
163
+ if (typeof content === "string" && content.trim())
139
164
  return content;
140
165
  if (Array.isArray(content)) {
141
- return content
166
+ const text = content
142
167
  .filter((c) => typeof c === "object" && c !== null && c.type === "text")
143
168
  .map((c) => c.text ?? "")
144
169
  .join("\n");
170
+ if (text.trim())
171
+ return text;
145
172
  }
146
- return "";
173
+ throw emptyCompletion("OpenAI-compatible endpoint", model, choice?.finish_reason);
147
174
  }
@@ -0,0 +1,13 @@
1
+ import type { RecordedAction } from "./types.js";
2
+ import type { ReplayEngine } from "./replay.js";
3
+ export interface MinimizeOptions {
4
+ url: string;
5
+ fingerprint: string;
6
+ maxReplays?: number;
7
+ }
8
+ /**
9
+ * F7 — ddmin (delta debugging). Shrinks the failing action sequence to a
10
+ * minimal subset that still reproduces `fingerprint`. Best-effort under a
11
+ * replay budget; a sequence shorter than 2 actions is returned as-is.
12
+ */
13
+ export declare function minimize(engine: ReplayEngine, actions: RecordedAction[], opts: MinimizeOptions): Promise<RecordedAction[]>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * F-modernize — the "code translator". Rewrites a legacy JS/TS file into modern
3
+ * idiomatic form (const/let over var, async/await over callbacks and promise
4
+ * chains, arrow functions, optional chaining) while preserving behavior. This is
5
+ * a *static* transform, unlike the rest of Aztrx's runtime detection, so it's its
6
+ * own command rather than a `run` flag.
7
+ *
8
+ * Safety model: the model's output is gated by a re-parse (`ts.transpileModule`
9
+ * reports syntax errors without running a full tsc), and the caller applies it to
10
+ * the working tree only after the user confirms — never automatically.
11
+ */
12
+ export type Lang = "ts" | "js";
13
+ export declare function detectLang(filePath: string): Lang | null;
14
+ export interface ParseGateResult {
15
+ ok: boolean;
16
+ errors: string[];
17
+ }
18
+ /** Syntax gate: does the output still parse? In-process (no tsc subprocess). */
19
+ export declare function parseGate(source: string): ParseGateResult;
20
+ export interface ModernizeResult {
21
+ ok: boolean;
22
+ original: string;
23
+ modernized?: string;
24
+ changes: string[];
25
+ lang?: Lang;
26
+ error?: string;
27
+ }
28
+ export declare function modernizeFile(repoRoot: string, filePath: string): Promise<ModernizeResult>;
@@ -22,7 +22,7 @@ export function detectLang(filePath) {
22
22
  return null;
23
23
  }
24
24
  /** Syntax gate: does the output still parse? In-process (no tsc subprocess). */
25
- export function parseGate(source, lang) {
25
+ export function parseGate(source) {
26
26
  const result = ts.transpileModule(source, {
27
27
  compilerOptions: {
28
28
  target: ts.ScriptTarget.ES2022,
@@ -110,7 +110,7 @@ export async function modernizeFile(repoRoot, filePath) {
110
110
  if (!parsed.modernized.trim()) {
111
111
  return { ok: false, original, changes: [], lang, error: "model returned an empty file" };
112
112
  }
113
- const gate = parseGate(parsed.modernized, lang);
113
+ const gate = parseGate(parsed.modernized);
114
114
  if (!gate.ok) {
115
115
  return {
116
116
  ok: false,
@@ -0,0 +1,13 @@
1
+ import type { Page } from "playwright";
2
+ export interface NetworkGuardOptions {
3
+ allowHosts: Set<string>;
4
+ onBlock?: (url: string) => void;
5
+ }
6
+ /**
7
+ * F6 — deny-by-default network guard (PRD §6.2). Aborts every request whose
8
+ * host isn't allow-listed, so a fuzz/replay pass can't reach payment, delete,
9
+ * or analytics endpoints. Loopback is always allowed (this is a local tool).
10
+ */
11
+ export declare function attachNetworkGuard(page: Page, opts: NetworkGuardOptions): Promise<void>;
12
+ /** Builds the allow-list: target origin + each `--allow-host`. */
13
+ export declare function allowHostsFrom(url: string, extra: string[]): Set<string>;
@@ -0,0 +1,86 @@
1
+ import { EventBus } from "./eventBus.js";
2
+ import type { Finding } from "./types.js";
3
+ import type { SpendBudget } from "./heal/types.js";
4
+ export interface RunOptions {
5
+ url: string;
6
+ repoRoot: string;
7
+ maxActions?: number;
8
+ dryRun?: boolean;
9
+ crashTest?: boolean;
10
+ fuzz?: boolean;
11
+ /** F5-http: mutate the target origin with hostile HTTP requests (server-side
12
+ * attack surface). Runs after the walk/fuzz pass, before repro/heal. */
13
+ httpFuzz?: boolean;
14
+ /** With `httpFuzz`, also send POST/PUT body mutations (default: GET-only). */
15
+ httpFuzzMutations?: boolean;
16
+ /** Opt-in: include destructive controls/endpoints (delete/pay/logout/checkout…).
17
+ * Off by default — these can mutate real state, so they're refused unless the
18
+ * caller explicitly accepts the risk (loudly warned). */
19
+ allowDestructive?: boolean;
20
+ /** Language for the per-finding diagnosis headline (en/ru). Default: en. */
21
+ lang?: string;
22
+ repro?: boolean;
23
+ seed?: number;
24
+ /** F-swarm: number of parallel detection workers (default 1). `--swarm` = auto. */
25
+ workers?: number;
26
+ allowHosts?: string[];
27
+ reproRuns?: number;
28
+ /** Path to a Playwright storage-state JSON (cookies + localStorage) so the
29
+ * session starts authenticated. Produced by `playwright codegen --save-storage`. */
30
+ storageState?: string;
31
+ /** F-auth: auto-login before the pass. Needs `loginEmail` + `loginPassword`. */
32
+ login?: boolean;
33
+ loginEmail?: string;
34
+ loginPassword?: string;
35
+ /** Explicit login page URL (default: the current page, after any auth-wall redirect). */
36
+ loginUrl?: string;
37
+ /** F10: attempt closed-loop healing for crash/error findings. Needs
38
+ * `ANTHROPIC_API_KEY` (or an injected patchFn) and `repro: true`. */
39
+ heal?: boolean;
40
+ /** Fingerprints already handled (PR opened / marked unfixable) by an
41
+ * autonomous supervisor — skip healing them so a re-scan doesn't re-burn the
42
+ * LLM on a bug we've already acted on. */
43
+ skipHealFingerprints?: string[];
44
+ /** Shared, mutable session cap on paid LLM generations (see `SpendBudget`).
45
+ * Passed through to every heal call; a patrol session creates one budget and
46
+ * reuses it across cycles. */
47
+ budget?: SpendBudget;
48
+ /** LLM model override for healing (the fallback tier). */
49
+ healModel?: string;
50
+ /** Fast/cheap first tier for the Smart Cloud Router (`AZTRX_FAST_MODEL`). */
51
+ healFastModel?: string;
52
+ /** Override the test command run against a healed patch (default: `npm test`). */
53
+ testCommand?: string;
54
+ /** Timeout for the heal test gate, ms. */
55
+ testTimeoutMs?: number;
56
+ /** Skip the heal test gate. */
57
+ skipTest?: boolean;
58
+ /** How to boot the app for server-side healing (network_5xx findings).
59
+ * Auto-detected from package.json `scripts.dev` / `scripts.start` when omitted. */
60
+ startCommand?: string;
61
+ /** F11: collect + persist anonymized telemetry locally (opt-in). */
62
+ telemetry?: boolean;
63
+ /** F11: additionally upload the sanitized tuple to the telemetry endpoint. */
64
+ shareData?: boolean;
65
+ /** Override the telemetry endpoint (`AZTRX_TELEMETRY_URL`). */
66
+ telemetryUrl?: string;
67
+ /** F12: upload the run's findings to the Aztrx AI cloud dashboard (opt-in). */
68
+ upload?: boolean;
69
+ /** API key for cloud + telemetry uploads (`AZTRX_CLOUD_API_KEY`). */
70
+ apiKey?: string;
71
+ /** Override the cloud ingest base URL (`AZTRX_CLOUD_URL`). */
72
+ cloudUrl?: string;
73
+ /** Inject an external bus (the TUI subscribes to it). */
74
+ bus?: EventBus;
75
+ /** When true, suppress console output — the caller renders from bus events. */
76
+ ui?: boolean;
77
+ }
78
+ /**
79
+ * The run's finite state machine: launch → (guard) → intercept → act (walk or
80
+ * fuzz) → classify → map → report → repro (minimize/compile/validate). Modules
81
+ * communicate only through the EventBus; the orchestrator is the single place
82
+ * that wires them together. In `ui` mode it emits structured events (phase,
83
+ * action, finding, repro, route, noise) and stays silent on stdout, so a
84
+ * terminal renderer (Ink) can draw the live panel instead of log lines.
85
+ */
86
+ export declare function run(options: RunOptions): Promise<Finding[]>;
@@ -355,7 +355,10 @@ export async function run(options) {
355
355
  // F12 — opt-in cloud sync. Streams the sanitized run results to the ingest
356
356
  // API for the team dashboard; dedup happens server-side by fingerprint.
357
357
  if (options.upload) {
358
- if (options.apiKey || process.env.AZTRX_API_KEY) {
358
+ // `AZTRX_CLOUD_API_KEY`, not `AZTRX_API_KEY`: the latter is a model provider
359
+ // credential, and treating its presence as consent to upload would put that
360
+ // key on the wire as the ingest auth header.
361
+ if (options.apiKey || process.env.AZTRX_CLOUD_API_KEY) {
359
362
  submitRun(findings, {
360
363
  repoRoot,
361
364
  url,
@@ -366,7 +369,7 @@ export async function run(options) {
366
369
  });
367
370
  }
368
371
  else {
369
- say(pc.yellow("Upload skipped: no API key. Set --api-key or AZTRX_API_KEY to stream findings to the dashboard."));
372
+ say(pc.yellow("Upload skipped: no API key. Set --api-key or AZTRX_CLOUD_API_KEY to stream findings to the dashboard."));
370
373
  }
371
374
  }
372
375
  runLog.append({ type: "run_end", counts, ts: Date.now() });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `aztrx patrol` — the autonomous bug-patrol loop. It wraps the existing
3
+ * `run()` detect → repro → heal pipeline in a supervisor loop with memory, so
4
+ * instead of "run it and read the output" the tool re-scans on an interval,
5
+ * fixes anything new, and opens a PR per bug — no human in the middle.
6
+ *
7
+ * Guardrails that keep an autonomous run from going off the rails:
8
+ * - memory (PatrolState) — a handled fingerprint is never re-fixed/re-PR'd;
9
+ * - per-session fix cap (`maxFixes`) — bounds LLM spend + PR spam;
10
+ * - scoped staging — PRs carry only the files the patch touched;
11
+ * - a liveness check — a dead target skips the cycle instead of burning a crawl.
12
+ */
13
+ export interface PatrolOptions {
14
+ url: string;
15
+ repoRoot: string;
16
+ intervalMs: number;
17
+ maxFixes?: number;
18
+ maxActions?: number;
19
+ /** Hard cap on paid LLM generations across the whole session (0/undefined = unlimited). */
20
+ maxSpend?: number;
21
+ /** Cooldown before an `unfixed` fingerprint is retried, ms. Default 30 min. */
22
+ retryAfterMs?: number;
23
+ /** Group all of a cycle's fixes into one PR instead of one PR per bug. */
24
+ batch?: boolean;
25
+ once?: boolean;
26
+ fuzz?: boolean;
27
+ workers?: number;
28
+ allowHosts?: string[];
29
+ storageState?: string;
30
+ login?: boolean;
31
+ loginEmail?: string;
32
+ loginPassword?: string;
33
+ loginUrl?: string;
34
+ healModel?: string;
35
+ healFastModel?: string;
36
+ testCommand?: string;
37
+ testTimeoutMs?: number;
38
+ skipTest?: boolean;
39
+ startCommand?: string;
40
+ lang?: string;
41
+ seed?: number;
42
+ allowDestructive?: boolean;
43
+ httpFuzz?: boolean;
44
+ httpFuzzMutations?: boolean;
45
+ }
46
+ export declare function patrol(opts: PatrolOptions): Promise<void>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * PR opener for `aztrx patrol`. Like `fixPr.openFixPr`, but built for an
3
+ * autonomous loop, so it adds two things a human-driven flow doesn't need:
4
+ *
5
+ * - **Dedup**: a fingerprint-stable branch name (`aztrx/fix-<fp8>`) plus a
6
+ * `gh pr list --head` check, so a re-scan never opens a second PR for a bug
7
+ * that already has one open.
8
+ * - **Scoped staging**: stages only the files the patch touched (`git add --
9
+ * <files>`), never `git add -A` — an autonomous run must not sweep up the
10
+ * user's unrelated uncommitted work into a PR.
11
+ *
12
+ * The caller is responsible for applying patches first (`applyVerifiedPatches`).
13
+ */
14
+ import type { Finding } from "../types.js";
15
+ export interface PatrolPrResult {
16
+ ok: boolean;
17
+ skipped?: boolean;
18
+ url?: string;
19
+ branch?: string;
20
+ error?: string;
21
+ }
22
+ export declare function branchFor(fp: string): string;
23
+ /** Stable branch name for a *set* of fingerprints — the same set always maps to
24
+ * the same branch, so a re-scan of an unchanged batch doesn't open a duplicate PR. */
25
+ export declare function branchForSet(fps: string[]): string;
26
+ /** A local/loopback/private target is a dev box; only a publicly-routable host is
27
+ * a deployed app, so a crash there is "live in production" and worth flagging.
28
+ * Covers loopback, RFC1918 private ranges (10/8, 172.16/12, 192.168/16),
29
+ * link-local (169.254/16), and mDNS/internal suffixes (.local, .internal). */
30
+ export declare function isLocalUrl(url: string): boolean;
31
+ /**
32
+ * Pure URL builder: turns a `git remote` origin URL into a raw-content URL for a
33
+ * repo-relative path, so the PR body can inline an image
34
+ * (`![…](https://github.com/<owner>/<repo>/raw/<branch>/<path>)`). Handles the
35
+ * three common remote shapes (`https://`, `git@github.com:`, `ssh://git@…`) and a
36
+ * trailing `.git`. Returns "" when the remote isn't GitHub.
37
+ */
38
+ export declare function githubRawUrl(remote: string, branch: string, repoPath: string): string;
39
+ export declare function openPatrolPr(repoRoot: string, finding: Finding, url: string, files: string[], mediaPath?: string | null): Promise<PatrolPrResult>;
40
+ /** One PR carrying a whole batch of fixes. Branch is stable across the *set*, so
41
+ * an unchanged batch re-scan dedups to the same PR. */
42
+ export declare function openPatrolBatchPr(repoRoot: string, findings: Finding[], url: string, files: string[], mediaPaths?: (string | null)[]): Promise<PatrolPrResult>;
@@ -144,17 +144,20 @@ async function rawUrlFor(repoRoot, branch, repoPath) {
144
144
  * files, commit, open the PR, and return to the original branch. The caller has
145
145
  * already done its own dedup + title/body. */
146
146
  async function createPr(repoRoot, branch, title, body, files) {
147
+ // Nothing to stage means no patch landed, so there is no fix to open a PR for.
148
+ // This used to fall back to `git add -A`, which would have swept the user's
149
+ // unrelated uncommitted work into an auto-generated commit under a "fix: …"
150
+ // title and pushed it. Refuse instead — and refuse before touching git, so a
151
+ // branch is never created for an empty fix set.
152
+ if (files.length === 0) {
153
+ return { ok: false, branch, error: "no files to stage — refusing to commit an empty fix set" };
154
+ }
147
155
  const originalBranch = await currentBranch(repoRoot);
148
156
  try {
149
157
  // `-B` (re)creates the branch at HEAD — idempotent against a stale local
150
158
  // branch left over from a previously failed PR attempt.
151
159
  await exec("git", ["-C", repoRoot, "checkout", "-B", branch]);
152
- if (files.length) {
153
- await exec("git", ["-C", repoRoot, "add", "--", ...files]);
154
- }
155
- else {
156
- await exec("git", ["-C", repoRoot, "add", "-A"]);
157
- }
160
+ await exec("git", ["-C", repoRoot, "add", "--", ...files]);
158
161
  await exec("git", ["-C", repoRoot, "commit", "-m", title]);
159
162
  }
160
163
  catch (e) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Recorded repro (Phase 3) — turns a reproducible bug into a shareable
3
+ * "before/after" proof: an animated GIF and a static crash screenshot, both
4
+ * destined for the patrol PR body.
5
+ *
6
+ * The capture is deliberately OFF the hot path. Verification replays a bug many
7
+ * times (ddmin + validator), but the recording runs once, only when a PR is about
8
+ * to open — so it launches its own browser instead of reusing ReplayEngine's.
9
+ *
10
+ * GIF encoding is pure-JS (`gifenc` + `pngjs`), no ffmpeg, so it works for every
11
+ * `npm install aztrx-cli` user rather than only machines with ffmpeg on PATH.
12
+ */
13
+ import type { Finding, RecordedAction } from "../types.js";
14
+ export interface CaptureOptions {
15
+ viewport?: {
16
+ width: number;
17
+ height: number;
18
+ };
19
+ /** Settle time after page load, before the first frame. */
20
+ settleMs?: number;
21
+ /** Settle time after each replayed action, before its screenshot. */
22
+ stepMs?: number;
23
+ }
24
+ /**
25
+ * Captures one PNG screenshot before the replay and one after each action, so
26
+ * the crash (or its absence) is visible in the sequence. Replays `replayActions`
27
+ * one action at a time — reusing the exact detection semantics, not a parallel
28
+ * reimplementation that could drift.
29
+ */
30
+ export declare function captureReproFrames(url: string, actions: RecordedAction[], opts?: CaptureOptions): Promise<Buffer[]>;
31
+ export interface GifOptions {
32
+ /** Frame delay in ms (default 400). */
33
+ delay?: number;
34
+ /** Quantized palette size, ≤ 256 (default 256). */
35
+ maxColors?: number;
36
+ }
37
+ /** Encodes a sequence of PNG frames into an animated GIF (pure JS, no ffmpeg). */
38
+ export declare function encodeGif(frames: Buffer[], opts?: GifOptions): Buffer;
39
+ /** The static "crash shot": the final frame, when the crash has fully rendered. */
40
+ export declare function crashFrame(frames: Buffer[]): Buffer;
41
+ /**
42
+ * Produces the recorded-repro GIF for a finding and writes it to
43
+ * `aztrx-media/<fp8>.gif`. That dir is deliberate: `media/` is in the npm
44
+ * `files` allowlist (so demo.gif/logo ship), but patrol GIFs must not publish —
45
+ * they're per-run artifacts. The path is committed to the PR branch so the PR
46
+ * body can inline it via a raw URL.
47
+ *
48
+ * Returns the repo-relative path, or null when there is nothing to record (no
49
+ * repro, or capture/encode failed) — the caller opens the PR regardless.
50
+ */
51
+ export declare function recordFindingGif(repoRoot: string, url: string, finding: Finding): Promise<string | null>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cross-run memory for `aztrx patrol`. Where `RunLog` is append-only and reset
3
+ * every run, this is a small index of "have I seen this fingerprint before, and
4
+ * what happened to it?" — the thing that stops an autonomous loop from re-fixing
5
+ * the same bug and re-opening the same PR on every cycle.
6
+ *
7
+ * Lives at `.aztrx/patrol.json` (already gitignored via `.aztrx/`). A fingerprint
8
+ * absent from the map is implicitly "new".
9
+ */
10
+ export type FingerprintStatus = "pr-opened" | "unfixed";
11
+ export interface FingerprintEntry {
12
+ status: FingerprintStatus;
13
+ firstSeen: string;
14
+ lastSeen: string;
15
+ prUrl?: string;
16
+ branch?: string;
17
+ attempts: number;
18
+ }
19
+ export declare class PatrolState {
20
+ private readonly file;
21
+ private readonly cooldownMs;
22
+ private data;
23
+ constructor(repoRoot: string, url: string, cooldownMs?: number);
24
+ private read;
25
+ /**
26
+ * A fingerprint is "handled" while its PR is open, or while an `unfixed` mark
27
+ * is still within its cooldown. Once the cooldown lapses, an `unfixed` bug
28
+ * becomes retry-eligible again — it stops being "handled" and `run()` heals it
29
+ * afresh rather than skipping it forever.
30
+ */
31
+ isHandled(fp: string, now?: number): boolean;
32
+ /** Every currently-handled fingerprint, so the supervisor can tell `run()` to skip healing them. */
33
+ handled(now?: number): string[];
34
+ markPr(fp: string, prUrl: string, branch: string): void;
35
+ markUnfixed(fp: string): void;
36
+ private firstSeen;
37
+ private attempts;
38
+ save(): void;
39
+ }
@@ -0,0 +1,3 @@
1
+ import type { Finding } from "./types.js";
2
+ export declare function renderPrComment(targetUrl: string, findings: Finding[]): string;
3
+ export declare function writePrComment(repoRoot: string, targetUrl: string, findings: Finding[], filePath?: string): string;
package/dist/core/pr.js CHANGED
@@ -9,12 +9,6 @@ import { sanitizeSecrets } from "./heal/redact.js";
9
9
  * shields.io badges, which GitHub renders natively.
10
10
  */
11
11
  const SEV_ORDER = ["crash", "error", "warning", "noise"];
12
- const SEV_BADGE = {
13
- crash: "ff5a5f",
14
- error: "ff5a5f",
15
- warning: "f5a623",
16
- noise: "5b6573",
17
- };
18
12
  /** Shields.io badge-path escaping: literal `-` → `--`, `/` → `%2F`, space → `_`. */
19
13
  function shield(s) {
20
14
  return s.replace(/-/g, "--").replace(/\//g, "%2F").replace(/ /g, "_");
@@ -44,6 +38,17 @@ function inlineCode(s) {
44
38
  function cleanInline(s) {
45
39
  return sanitizeSecrets(s).replace(/`/g, "").replace(/[\r\n]/g, " ");
46
40
  }
41
+ /** Prose that lands in the comment body — a model-written explanation, an error
42
+ * string. Escaping the HTML keeps it inside the surrounding `<details>`;
43
+ * defusing the link/image syntax stops a crafted PR from having the bot render
44
+ * a tracking pixel or a phishing link under the consumer's token. Secrets are
45
+ * scrubbed for the same reason every other inlined string is: the model read
46
+ * the app's own source to write this, so it can quote a key back out. */
47
+ function cleanProse(s) {
48
+ return escapeHtml(sanitizeSecrets(s))
49
+ .replace(/!\[/g, "!\\[")
50
+ .replace(/\]\(/g, "\\]\\(");
51
+ }
47
52
  /** Hard cap on the server body inlined into a PR comment — a 500 page can be
48
53
  * huge, and only the first line or two are ever diagnostic. */
49
54
  const SERVER_BODY_CAP = 2000;
@@ -84,13 +89,13 @@ function healBlock(f) {
84
89
  body.push(fence(diff, "diff"));
85
90
  }
86
91
  else if (h.explanation) {
87
- body.push(`> ${h.explanation}`);
92
+ body.push(`> ${cleanProse(h.explanation)}`);
88
93
  }
89
94
  if (h.test?.ran) {
90
95
  body.push(`**tests** ${inlineCode(h.test.command)} — ${h.test.ok ? "passed" : "failed"}`);
91
96
  }
92
97
  if (h.error)
93
- body.push(`\n_${escapeHtml(h.error)}_`);
98
+ body.push(`\n_${cleanProse(h.error)}_`);
94
99
  return `\n<details>\n<summary>${badge("heal", meta.text, meta.color)} proposed patch${via}${tiers}</summary>\n\n${body.join("\n")}\n</details>`;
95
100
  }
96
101
  function reproBlock(f) {
@@ -122,8 +127,7 @@ function findingBlock(f) {
122
127
  : "";
123
128
  return `<details open>\n<summary><code>${escapeHtml(sev)}</code> — ${escapeHtml(first)}</summary>\n${loc}${snippet}${serverErr}${reproBlock(f)}${healBlock(f)}\n</details>`;
124
129
  }
125
- export function renderPrComment(targetUrl, findings, opts = {}) {
126
- void opts;
130
+ export function renderPrComment(targetUrl, findings) {
127
131
  const sorted = [...findings].sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity));
128
132
  const counts = { crash: 0, error: 0, warning: 0, noise: 0 };
129
133
  for (const f of sorted)
@@ -160,6 +164,6 @@ ${body}
160
164
  export function writePrComment(repoRoot, targetUrl, findings, filePath) {
161
165
  const file = filePath ?? path.join(repoRoot, ".aztrx", "pr-comment.md");
162
166
  fs.mkdirSync(path.dirname(file), { recursive: true });
163
- fs.writeFileSync(file, renderPrComment(targetUrl, findings, { repoRoot }), "utf-8");
167
+ fs.writeFileSync(file, renderPrComment(targetUrl, findings), "utf-8");
164
168
  return file;
165
169
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A single yes/no confirmation for `--fix`. Kept tiny and side-effect free:
3
+ *
4
+ * - `--yes` short-circuits to `true` (scripts/CI).
5
+ * - a non-TTY stdout without `--yes` short-circuits to `false` — an unattended
6
+ * run never mutates the working tree, it just leaves the `.patch` files.
7
+ * - otherwise prompts on stdin, defaulting to "no".
8
+ */
9
+ export interface PromptOptions {
10
+ yes?: boolean;
11
+ }
12
+ export declare function promptYesNo(question: string, opts?: PromptOptions): Promise<boolean>;
13
+ /** A single-line text prompt. Returns "" when stdout isn't a TTY (unattended run). */
14
+ export declare function promptInput(question: string): Promise<string>;
@@ -0,0 +1,23 @@
1
+ import type { ElementHandle } from "playwright";
2
+ import type { RecordedAction } from "./types.js";
3
+ type AnyElement = SVGElement | HTMLElement;
4
+ /**
5
+ * F2 — ring buffer of the last 25 actions. The Repro Minimizer (F7) later
6
+ * shrinks this history; for now it's the action context attached to findings.
7
+ */
8
+ export declare class ActionRecorder {
9
+ private buffer;
10
+ readonly capacity = 25;
11
+ record(action: RecordedAction): void;
12
+ snapshot(): RecordedAction[];
13
+ }
14
+ /**
15
+ * Selector cascade, most-reliable first: data-testid → text → css path.
16
+ *
17
+ * Each `evaluate` is wrapped defensively: selector resolution is best-effort
18
+ * and must never take down a whole run if the page tears the node down
19
+ * mid-query or the browser rejects a serialized function. A failed probe just
20
+ * degrades to the next (weaker) selector in the cascade.
21
+ */
22
+ export declare function selectorCascade(handle: ElementHandle<AnyElement>): Promise<string[]>;
23
+ export {};
@@ -22,7 +22,7 @@ export class ActionRecorder {
22
22
  * mid-query or the browser rejects a serialized function. A failed probe just
23
23
  * degrades to the next (weaker) selector in the cascade.
24
24
  */
25
- export async function selectorCascade(page, handle) {
25
+ export async function selectorCascade(handle) {
26
26
  const out = [];
27
27
  try {
28
28
  const testId = await handle.evaluate((el) => {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Minimal Markdown → ANSI renderer for the `--explain` / `--fix` human-language
3
+ * summary. Handles the small dialect Aztrx emits: headings, bold/`code` inline,
4
+ * bullet and numbered lists, and fenced code blocks with a light JS/TS syntax
5
+ * highlight. Plain prose without markdown passes through unchanged.
6
+ */
7
+ export declare function renderMarkdown(md: string): string;
@@ -0,0 +1,33 @@
1
+ import type { Page } from "playwright";
2
+ import type { FindingType, RecordedAction } from "./types.js";
3
+ export interface ReplayEngineOptions {
4
+ attachGuard?: (page: Page) => Promise<void>;
5
+ /** Playwright storage-state JSON (path or object) for authenticated replays. */
6
+ storageState?: string;
7
+ }
8
+ export interface ReplayResult {
9
+ reproduced: boolean;
10
+ /** Did the target page actually load? A navigation that was refused, timed out,
11
+ * or answered non-2xx produces no telemetry — which is indistinguishable from
12
+ * "the bug is gone" unless the caller can tell the two apart. Verification
13
+ * depends on this: an unreachable page must never count as a passing run. */
14
+ loaded: boolean;
15
+ }
16
+ /** Replays a recorded action sequence against a page. Best-effort: a selector
17
+ * that no longer resolves is skipped, not fatal. */
18
+ export declare function replayActions(page: Page, actions: RecordedAction[]): Promise<void>;
19
+ /**
20
+ * Reuses one browser across replays (ddmin + validator each run many). Each
21
+ * `run` gets a fresh page; the interceptor collects telemetry fingerprints and
22
+ * reports whether `targetFingerprint` was seen.
23
+ */
24
+ export declare class ReplayEngine {
25
+ private opts;
26
+ private browser;
27
+ constructor(opts?: ReplayEngineOptions);
28
+ private getBrowser;
29
+ run(url: string, actions: RecordedAction[], targetFingerprint: string, opts?: {
30
+ targetType?: FindingType;
31
+ }): Promise<ReplayResult>;
32
+ close(): Promise<void>;
33
+ }