aztrx-cli 0.4.5 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +326 -29
- package/dist/cli/help.d.ts +22 -0
- package/dist/cli/repo.d.ts +20 -0
- package/dist/cli/repo.js +50 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -86
- package/dist/core/auth.d.ts +33 -0
- package/dist/core/badge.d.ts +9 -0
- package/dist/core/browser.d.ts +3 -0
- package/dist/core/classifier.d.ts +41 -0
- package/dist/core/cloud/index.d.ts +62 -0
- package/dist/core/cloud/index.js +24 -4
- package/dist/core/devServer.d.ts +90 -0
- package/dist/core/devServer.js +253 -0
- package/dist/core/diagnose.d.ts +18 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/domWalker.d.ts +24 -0
- package/dist/core/domWalker.js +18 -2
- package/dist/core/eventBus.d.ts +59 -0
- package/dist/core/events.d.ts +42 -0
- package/dist/core/fixPr.d.ts +16 -0
- package/dist/core/fixPr.js +11 -1
- package/dist/core/fuzzer.d.ts +24 -0
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/apply.d.ts +28 -0
- package/dist/core/heal/boot.d.ts +74 -0
- package/dist/core/heal/boot.js +126 -23
- package/dist/core/heal/childEnv.d.ts +15 -0
- package/dist/core/heal/gates.d.ts +11 -0
- package/dist/core/heal/index.d.ts +19 -0
- package/dist/core/heal/index.js +53 -23
- package/dist/core/heal/llm.d.ts +43 -0
- package/dist/core/heal/llm.js +22 -2
- package/dist/core/heal/redact.d.ts +22 -0
- package/dist/core/heal/sandbox.d.ts +54 -0
- package/dist/core/heal/sandbox.js +63 -3
- package/dist/core/heal/types.d.ts +102 -0
- package/dist/core/heal/verify.d.ts +37 -0
- package/dist/core/heal/verify.js +28 -14
- package/dist/core/httpFuzzer.d.ts +33 -0
- package/dist/core/init.d.ts +21 -0
- package/dist/core/init.js +5 -6
- package/dist/core/interceptor.d.ts +21 -0
- package/dist/core/llm.d.ts +36 -0
- package/dist/core/llm.js +40 -13
- package/dist/core/minimizer.d.ts +13 -0
- package/dist/core/modernize.d.ts +28 -0
- package/dist/core/modernize.js +2 -2
- package/dist/core/networkGuard.d.ts +13 -0
- package/dist/core/orchestrator.d.ts +86 -0
- package/dist/core/orchestrator.js +5 -2
- package/dist/core/patrol/loop.d.ts +46 -0
- package/dist/core/patrol/pr.d.ts +42 -0
- package/dist/core/patrol/pr.js +9 -6
- package/dist/core/patrol/record.d.ts +51 -0
- package/dist/core/patrol/state.d.ts +39 -0
- package/dist/core/pr.d.ts +3 -0
- package/dist/core/pr.js +15 -11
- package/dist/core/prompt.d.ts +14 -0
- package/dist/core/recorder.d.ts +23 -0
- package/dist/core/recorder.js +1 -1
- package/dist/core/renderMarkdown.d.ts +7 -0
- package/dist/core/replay.d.ts +33 -0
- package/dist/core/replay.js +52 -7
- package/dist/core/report.d.ts +8 -0
- package/dist/core/resolver.d.ts +64 -0
- package/dist/core/resolver.js +135 -17
- package/dist/core/rng.d.ts +2 -0
- package/dist/core/specCompiler.d.ts +17 -0
- package/dist/core/studio.d.ts +6 -0
- package/dist/core/studio.js +0 -3
- package/dist/core/summarize.d.ts +26 -0
- package/dist/core/summarize.js +17 -6
- package/dist/core/swarm.d.ts +97 -0
- package/dist/core/swarm.js +25 -2
- package/dist/core/telemetry/index.d.ts +33 -0
- package/dist/core/telemetry/index.js +22 -4
- package/dist/core/telemetry/sanitize.d.ts +19 -0
- package/dist/core/telemetry/types.d.ts +26 -0
- package/dist/core/types.d.ts +88 -0
- package/dist/core/ui.d.ts +23 -0
- package/dist/core/ui.js +0 -5
- package/dist/core/validator.d.ts +14 -0
- package/dist/core/version.d.ts +1 -0
- package/dist/hooks/index.d.ts +106 -0
- package/dist/hooks/index.js +349 -0
- package/dist/mcp/index.d.ts +58 -0
- package/dist/mcp/index.js +295 -0
- package/dist/mcp/install.d.ts +52 -0
- package/dist/mcp/install.js +204 -0
- package/dist/mcp/protocol.d.ts +106 -0
- package/dist/mcp/protocol.js +156 -0
- package/dist/mcp/tools.d.ts +107 -0
- package/dist/mcp/tools.js +621 -0
- package/dist/next/index.d.ts +70 -0
- package/dist/next/index.js +103 -0
- package/dist/plugins/scan.d.ts +85 -0
- package/dist/plugins/scan.js +188 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/vite/index.d.ts +60 -0
- package/dist/vite/index.js +56 -0
- package/package.json +44 -6
package/dist/core/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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/core/modernize.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
* (``). 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>;
|
package/dist/core/patrol/pr.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
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_${
|
|
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
|
|
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
|
|
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 {};
|
package/dist/core/recorder.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|