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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F10 — LLM patch generator. Turns a redacted bug report into a minimal
|
|
3
|
+
* Search & Replace diff. The transport is Anthropic's Messages API (key from
|
|
4
|
+
* `ANTHROPIC_API_KEY`, model from `AZTRX_MODEL` or a sensible default); a
|
|
5
|
+
* `patchFn` can be injected instead, which is how the loop is unit-tested and
|
|
6
|
+
* how a future provider (local model, proxy) plugs in without touching this
|
|
7
|
+
* module's callers.
|
|
8
|
+
*/
|
|
9
|
+
import type { HealContext, Patch, SpendBudget } from "./types.js";
|
|
10
|
+
export interface ModelTier {
|
|
11
|
+
model: string;
|
|
12
|
+
label: "fast" | "sonnet";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The Smart Cloud Router's tier plan: fast/cheap first, then the capable model
|
|
16
|
+
* as the fallback. Collapses to a single tier when the two resolve to the same
|
|
17
|
+
* model (e.g. `AZTRX_FAST_MODEL=claude-sonnet-5`). Consumers loop over this in
|
|
18
|
+
* order and stop at the first `healed` result.
|
|
19
|
+
*/
|
|
20
|
+
export declare function modelTiers(fallbackModel?: string, fastFallback?: string): ModelTier[];
|
|
21
|
+
export interface GenerateOptions {
|
|
22
|
+
model?: string;
|
|
23
|
+
patchFn?: (ctx: HealContext) => Promise<Patch>;
|
|
24
|
+
budget?: SpendBudget;
|
|
25
|
+
}
|
|
26
|
+
/** Thrown when the shared session budget has no paid generations left. Heal
|
|
27
|
+
* maps this to a `budget-exhausted` status rather than a transport error. */
|
|
28
|
+
export declare class BudgetExhaustedError extends Error {
|
|
29
|
+
constructor();
|
|
30
|
+
}
|
|
31
|
+
/** Parse a model reply into a Patch. Tolerates markdown fences and leading text. */
|
|
32
|
+
export declare function parsePatch(raw: string): Patch;
|
|
33
|
+
/** Sentinel model name for the free, no-key rule-based fixer. */
|
|
34
|
+
export declare const RULE_TIER = "__rule__";
|
|
35
|
+
/**
|
|
36
|
+
* Rule-based fix for the most common crash — a null/undefined property access.
|
|
37
|
+
* Adds `?.` (optional chaining) at the failing access. Returns null when the
|
|
38
|
+
* error isn't a null/undefined deref or the line can't be located. Free and
|
|
39
|
+
* offline: no LLM, no key, no network — so `--fix` works out of the box for the
|
|
40
|
+
* most frequent frontend crashes.
|
|
41
|
+
*/
|
|
42
|
+
export declare function generateRulePatch(ctx: HealContext): Patch | null;
|
|
43
|
+
export declare function generatePatch(ctx: HealContext, opts?: GenerateOptions): Promise<Patch>;
|
package/dist/core/heal/llm.js
CHANGED
|
@@ -111,7 +111,21 @@ export function generateRulePatch(ctx) {
|
|
|
111
111
|
return null;
|
|
112
112
|
// Optional-chain every `.identifier` access on the line (not just the failing
|
|
113
113
|
// one) so a chain like `d.agents.map(…)` becomes `d?.agents?.map(…)`.
|
|
114
|
-
|
|
114
|
+
//
|
|
115
|
+
// The lookbehind is the whole point, and it is not cosmetic. A bare
|
|
116
|
+
// `/\.(?=[a-zA-Z_$])/` also matches the dots in a spread and in chaining that
|
|
117
|
+
// is already optional, so `{ ...s, [id]: result.ok }` became
|
|
118
|
+
// `{ ..?.s, [id]: result.ok }` and `d?.agents` became `d??.agents`. Both are
|
|
119
|
+
// syntax errors, which the AST gate then refused — correctly, but the effect
|
|
120
|
+
// was that the free fixer declined every line containing a spread or an
|
|
121
|
+
// existing `?.`, which is most React code, and the finding was reported as
|
|
122
|
+
// `rejected` with no hint that the rule engine was at fault.
|
|
123
|
+
//
|
|
124
|
+
// Known limit: this is a regex on a line of source, not a lexer, so a `.name`
|
|
125
|
+
// *inside a string literal or regex* on the failing line is rewritten too.
|
|
126
|
+
// That cannot make the file unparseable (the gate above still runs), but it
|
|
127
|
+
// can change behaviour, and no gate here would catch it.
|
|
128
|
+
const replace = src.replace(/(?<![.?])\.(?=[a-zA-Z_$])/g, "?.");
|
|
115
129
|
if (replace === src)
|
|
116
130
|
return null;
|
|
117
131
|
return {
|
|
@@ -138,7 +152,13 @@ export async function generatePatch(ctx, opts = {}) {
|
|
|
138
152
|
system: SYSTEM,
|
|
139
153
|
prompt: buildPrompt(ctx),
|
|
140
154
|
model: opts.model,
|
|
141
|
-
|
|
155
|
+
// A patch is a few hundred tokens, but a reasoning model spends this budget
|
|
156
|
+
// on its thinking *first* — at 2048 a reasoner like cohere/north-mini-code
|
|
157
|
+
// hit the cap before emitting any text at all, so healing reported "no
|
|
158
|
+
// content (finish_reason: length)" and gave up without ever producing a
|
|
159
|
+
// patch. This is a ceiling, not a charge: cost is per token actually
|
|
160
|
+
// emitted, so the headroom is free for models that do not reason.
|
|
161
|
+
maxTokens: 8192,
|
|
142
162
|
temperature: 0,
|
|
143
163
|
});
|
|
144
164
|
if (opts.budget)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction layer — F10 gate #1. Before any byte leaves the machine
|
|
3
|
+
* (sent to an LLM, written to a log, streamed to a dashboard) it passes through
|
|
4
|
+
* here. Redaction is *reversible*: the caller keeps the placeholder→secret map
|
|
5
|
+
* so a generated Search & Replace diff (which references placeholders) can be
|
|
6
|
+
* unredacted back onto the raw source before it is applied.
|
|
7
|
+
*/
|
|
8
|
+
export interface Redaction {
|
|
9
|
+
text: string;
|
|
10
|
+
map: Map<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export declare function redact(input: string): Redaction;
|
|
13
|
+
/** Reverse a redaction: swap every placeholder back to its original secret. */
|
|
14
|
+
export declare function unredact(input: string, map: Map<string, string>): string;
|
|
15
|
+
/**
|
|
16
|
+
* Irreversible secret scrub for anything rendered to a human or written to a
|
|
17
|
+
* report / PR comment. Runs the same patterns as `redact()` but discards the
|
|
18
|
+
* map — every secret collapses to a fixed `[REDACTED]` token — then scrubs
|
|
19
|
+
* emails, which `redact()` deliberately leaves alone (they aren't secrets and
|
|
20
|
+
* the reversible layer must not mangle non-secret code it may need to patch).
|
|
21
|
+
*/
|
|
22
|
+
export declare function sanitizeSecrets(input: string): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F10 gate #3 — isolated Git worktree sandbox. A patch is never applied to the
|
|
3
|
+
* user's working tree: it lands in a detached `git worktree`, is verified there,
|
|
4
|
+
* and the only artifact that escapes is a `.patch` file for a human to review
|
|
5
|
+
* and apply. Aztrx never commits — humans do.
|
|
6
|
+
*/
|
|
7
|
+
import type { PatchHunk, TestGateResult } from "./types.js";
|
|
8
|
+
export interface Worktree {
|
|
9
|
+
dir: string;
|
|
10
|
+
cleanup: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface ApplyResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
patched: string;
|
|
15
|
+
applied: number;
|
|
16
|
+
errors: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Create a detached worktree at HEAD in a temp dir (outside the repo). */
|
|
19
|
+
export declare function createWorktree(repoRoot: string, label: string): Promise<Worktree>;
|
|
20
|
+
/** Apply Search & Replace hunks to an in-memory file. Each `search` must match
|
|
21
|
+
* exactly once; ambiguous or missing matches fail the whole apply (no partial
|
|
22
|
+
* writes). Pure — the caller decides where the result lands. */
|
|
23
|
+
export declare function applyHunks(content: string, hunks: PatchHunk[]): ApplyResult;
|
|
24
|
+
/** Write the patched file into the worktree, refusing to escape it. */
|
|
25
|
+
export declare function writeWorktreeFile(worktreeDir: string, repoRelativePath: string, content: string): string | null;
|
|
26
|
+
/** Produce a unified diff of the patched file against HEAD in the worktree. */
|
|
27
|
+
export declare function diffWorktree(worktreeDir: string, repoRelativePath: string): Promise<string>;
|
|
28
|
+
/** Run `tsc --noEmit` against the patched worktree — the full type check that
|
|
29
|
+
* follows the AST syntax gate. Best-effort: passes (skips) when the repo has no
|
|
30
|
+
* TypeScript or the worktree has no tsconfig, so non-TS projects aren't blocked.
|
|
31
|
+
* The worktree has no node_modules; a symlink to the root's is created first and
|
|
32
|
+
* removed with the worktree on cleanup.
|
|
33
|
+
*
|
|
34
|
+
* Unlike every other gate here, this one is whole-project work: `tsc` type-checks
|
|
35
|
+
* the entire repository, so its cost scales with the repo, and on a very large
|
|
36
|
+
* one it is minutes. It therefore carries a ceiling — and a timeout is reported
|
|
37
|
+
* as *skipped*, not failed. "The compiler never reached a verdict" is a different
|
|
38
|
+
* sentence from "the patch is broken", and conflating them would silently discard
|
|
39
|
+
* good fixes on exactly the large repos this ceiling exists to protect. */
|
|
40
|
+
export declare function typecheckWorktree(worktreeDir: string, repoRoot: string, opts?: {
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
ok: boolean;
|
|
44
|
+
ran: boolean;
|
|
45
|
+
output: string;
|
|
46
|
+
}>;
|
|
47
|
+
/** Run the repo's own test suite inside the patched worktree. Best-effort: skips
|
|
48
|
+
* (passes by omission) when there is no `test` script to run, so untested or
|
|
49
|
+
* non-JS projects are never blocked. `CI=true` is set so watch-mode runners exit
|
|
50
|
+
* instead of hanging until the timeout. */
|
|
51
|
+
export declare function runTests(worktreeDir: string, repoRoot: string, opts?: {
|
|
52
|
+
command?: string;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
}): Promise<TestGateResult>;
|
|
@@ -12,6 +12,48 @@ import * as path from "path";
|
|
|
12
12
|
import { buildChildEnv } from "./childEnv.js";
|
|
13
13
|
const execFileP = promisify(execFile);
|
|
14
14
|
const preview = (s) => JSON.stringify(s.length > 60 ? s.slice(0, 57) + "…" : s);
|
|
15
|
+
/**
|
|
16
|
+
* Remove directory links (POSIX symlinks / Windows junctions) from a worktree root.
|
|
17
|
+
*
|
|
18
|
+
* This MUST run before `git worktree remove`. Git's worktree teardown recurses
|
|
19
|
+
* *through* a junction and deletes its target, so the `node_modules` link that
|
|
20
|
+
* boot/verify create inside the worktree (below) turns an ordinary cleanup into a
|
|
21
|
+
* recursive delete of the user's real node_modules. Established by experiment,
|
|
22
|
+
* not by reading docs: a sentinel file inside the junction target did not survive
|
|
23
|
+
* `git worktree remove --force`. Node's own recursive `fs.rmSync` handles reparse
|
|
24
|
+
* points correctly and is safe either way; git's does not.
|
|
25
|
+
*/
|
|
26
|
+
function unlinkDirLinks(dir) {
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const p = path.join(dir, entry.name);
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.lstatSync(p).isSymbolicLink())
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// `unlink` covers POSIX dir symlinks; Windows junctions need `rmdir`.
|
|
44
|
+
try {
|
|
45
|
+
fs.unlinkSync(p);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
try {
|
|
49
|
+
fs.rmdirSync(p);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* best effort — a link that survives is still not followed by the rmSync below */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
15
57
|
/** Create a detached worktree at HEAD in a temp dir (outside the repo). */
|
|
16
58
|
export async function createWorktree(repoRoot, label) {
|
|
17
59
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `aztrx-heal-${label}-`));
|
|
@@ -19,6 +61,7 @@ export async function createWorktree(repoRoot, label) {
|
|
|
19
61
|
return {
|
|
20
62
|
dir,
|
|
21
63
|
cleanup: async () => {
|
|
64
|
+
unlinkDirLinks(dir);
|
|
22
65
|
await execFileP("git", ["-C", repoRoot, "worktree", "remove", "--force", dir]).catch(() => { });
|
|
23
66
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
67
|
},
|
|
@@ -76,8 +119,15 @@ export async function diffWorktree(worktreeDir, repoRelativePath) {
|
|
|
76
119
|
* follows the AST syntax gate. Best-effort: passes (skips) when the repo has no
|
|
77
120
|
* TypeScript or the worktree has no tsconfig, so non-TS projects aren't blocked.
|
|
78
121
|
* The worktree has no node_modules; a symlink to the root's is created first and
|
|
79
|
-
* removed with the worktree on cleanup.
|
|
80
|
-
|
|
122
|
+
* removed with the worktree on cleanup.
|
|
123
|
+
*
|
|
124
|
+
* Unlike every other gate here, this one is whole-project work: `tsc` type-checks
|
|
125
|
+
* the entire repository, so its cost scales with the repo, and on a very large
|
|
126
|
+
* one it is minutes. It therefore carries a ceiling — and a timeout is reported
|
|
127
|
+
* as *skipped*, not failed. "The compiler never reached a verdict" is a different
|
|
128
|
+
* sentence from "the patch is broken", and conflating them would silently discard
|
|
129
|
+
* good fixes on exactly the large repos this ceiling exists to protect. */
|
|
130
|
+
export async function typecheckWorktree(worktreeDir, repoRoot, opts = {}) {
|
|
81
131
|
const tscBin = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc");
|
|
82
132
|
const hasTsconfig = fs.existsSync(path.join(worktreeDir, "tsconfig.json"));
|
|
83
133
|
if (!fs.existsSync(tscBin) || !hasTsconfig) {
|
|
@@ -93,12 +143,22 @@ export async function typecheckWorktree(worktreeDir, repoRoot) {
|
|
|
93
143
|
/* symlink failed — tsc reports its own resolution errors below */
|
|
94
144
|
}
|
|
95
145
|
}
|
|
146
|
+
const timeoutMs = opts.timeoutMs ?? 300000;
|
|
96
147
|
try {
|
|
97
|
-
const { stdout } = await execFileP(process.execPath, [tscBin, "--noEmit", "-p", worktreeDir], { cwd: worktreeDir, maxBuffer: 10 * 1024 * 1024, env: buildChildEnv() });
|
|
148
|
+
const { stdout } = await execFileP(process.execPath, [tscBin, "--noEmit", "-p", worktreeDir], { cwd: worktreeDir, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, env: buildChildEnv() });
|
|
98
149
|
return { ok: true, ran: true, output: stdout.trim() };
|
|
99
150
|
}
|
|
100
151
|
catch (e) {
|
|
101
152
|
const err = e;
|
|
153
|
+
// A timeout kills the child; that is not a compile error. Skip the gate and
|
|
154
|
+
// say why, rather than reporting a verdict the compiler never gave.
|
|
155
|
+
if (err.killed || err.signal) {
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
ran: false,
|
|
159
|
+
output: `tsc did not finish within ${timeoutMs}ms — type check skipped`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
102
162
|
return { ok: false, ran: true, output: ((err.stdout ?? "") + (err.stderr ?? "")).trim() };
|
|
103
163
|
}
|
|
104
164
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Finding, RecordedAction } from "../types.js";
|
|
2
|
+
export type HealStatus = "healed" | "unfixed" | "rejected" | "compile-failed" | "apply-failed" | "test-failed" | "skipped" | "no-llm" | "budget-exhausted";
|
|
3
|
+
/** One Search & Replace edit. `search` is an exact, unique substring of the
|
|
4
|
+
* source file; `replace` is its replacement. */
|
|
5
|
+
export interface PatchHunk {
|
|
6
|
+
search: string;
|
|
7
|
+
replace: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Patch {
|
|
10
|
+
explanation: string;
|
|
11
|
+
hunks: PatchHunk[];
|
|
12
|
+
}
|
|
13
|
+
export interface GateViolation {
|
|
14
|
+
rule: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
}
|
|
17
|
+
export interface GateResult {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
violations: GateViolation[];
|
|
20
|
+
}
|
|
21
|
+
/** Context handed to the patch generator. `redactedContent` is what may leave
|
|
22
|
+
* the machine; `fileContent` is the raw bytes used for applying the edit. */
|
|
23
|
+
export interface HealContext {
|
|
24
|
+
finding: Finding;
|
|
25
|
+
filePath: string;
|
|
26
|
+
fileContent: string;
|
|
27
|
+
redactedContent: string;
|
|
28
|
+
}
|
|
29
|
+
/** Shared, mutable cap on paid LLM generations across a whole patrol session.
|
|
30
|
+
* `generatePatch` decrements `remaining` each time it pays for a completion; once
|
|
31
|
+
* it hits 0 generation stops and heal reports `budget-exhausted`. The free
|
|
32
|
+
* rule-based fixer never touches it. One object is threaded through every
|
|
33
|
+
* `run()`/`heal()` of a session so the cap spans cycles, not just one run. */
|
|
34
|
+
export interface SpendBudget {
|
|
35
|
+
remaining: number;
|
|
36
|
+
}
|
|
37
|
+
export interface VerifyResult {
|
|
38
|
+
runs: number;
|
|
39
|
+
reproductions: number;
|
|
40
|
+
/** Of `runs`, how many actually loaded the app. `fixed` requires this to be
|
|
41
|
+
* non-zero: a run whose page never loaded proves nothing, and counting it as a
|
|
42
|
+
* non-reproduction is how a patch gets declared verified without being tried. */
|
|
43
|
+
loaded: number;
|
|
44
|
+
fixed: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface TestGateResult {
|
|
47
|
+
/** False when there was no test script to run — the gate passes by omission. */
|
|
48
|
+
ran: boolean;
|
|
49
|
+
ok: boolean;
|
|
50
|
+
command: string;
|
|
51
|
+
/** Tail of the test output, already truncated. */
|
|
52
|
+
output: string;
|
|
53
|
+
}
|
|
54
|
+
export interface HealOptions {
|
|
55
|
+
repoRoot: string;
|
|
56
|
+
url: string;
|
|
57
|
+
/** Minimized repro actions (F7 output). */
|
|
58
|
+
actions: RecordedAction[];
|
|
59
|
+
fingerprint: string;
|
|
60
|
+
allowHosts: string[];
|
|
61
|
+
/** Fallback model (the last tier tried). Defaults to `claude-sonnet-5`. */
|
|
62
|
+
model?: string;
|
|
63
|
+
/** Fast/cheap first tier. Defaults to `claude-haiku-4-5` (`AZTRX_FAST_MODEL`). */
|
|
64
|
+
fastModel?: string;
|
|
65
|
+
/** Inject a patch generator for testing/demo (bypasses the network LLM). */
|
|
66
|
+
patchFn?: (ctx: HealContext) => Promise<Patch>;
|
|
67
|
+
/** Shared session budget for paid generations (see `SpendBudget`). */
|
|
68
|
+
budget?: SpendBudget;
|
|
69
|
+
/** Inject an app server for the patched code. Default: static file server. */
|
|
70
|
+
serve?: (worktreeDir: string, filePath: string) => Promise<{
|
|
71
|
+
url: string;
|
|
72
|
+
close: () => Promise<void>;
|
|
73
|
+
}>;
|
|
74
|
+
/** How to boot the patched app for server (network_5xx) findings. Auto-detected
|
|
75
|
+
* from package.json `scripts.dev` / `scripts.start` when omitted. */
|
|
76
|
+
startCommand?: string;
|
|
77
|
+
verifyRuns?: number;
|
|
78
|
+
/** Override the test command run against a healed patch (default: `npm test`,
|
|
79
|
+
* auto-detected from package.json). */
|
|
80
|
+
testCommand?: string;
|
|
81
|
+
/** Timeout for the test gate, ms (default 300000). */
|
|
82
|
+
testTimeoutMs?: number;
|
|
83
|
+
/** Skip the test gate entirely. */
|
|
84
|
+
skipTest?: boolean;
|
|
85
|
+
}
|
|
86
|
+
export interface HealResult {
|
|
87
|
+
status: HealStatus;
|
|
88
|
+
findingId: string;
|
|
89
|
+
filePath: string;
|
|
90
|
+
explanation?: string;
|
|
91
|
+
hunks: PatchHunk[];
|
|
92
|
+
violations: GateViolation[];
|
|
93
|
+
verification?: VerifyResult;
|
|
94
|
+
test?: TestGateResult;
|
|
95
|
+
/** Path to the saved unified-diff patch artifact (.aztrx/heal/…, gitignored). */
|
|
96
|
+
patchPath?: string;
|
|
97
|
+
error?: string;
|
|
98
|
+
/** Model that produced the returned patch (the winning tier). */
|
|
99
|
+
model?: string;
|
|
100
|
+
/** All tiers attempted, in order, for observability in the PR bot. */
|
|
101
|
+
tiers?: string[];
|
|
102
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F10 — automated verification. Reuses the repro engine (F9) but inverts the
|
|
3
|
+
* question: the bug must *stop* reproducing against the patched code. If the
|
|
4
|
+
* fingerprint is still seen after the fix, the loop rejects the patch rather
|
|
5
|
+
* than handing the human a lie.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReplayResult } from "../replay.js";
|
|
8
|
+
import type { FindingType, RecordedAction } from "../types.js";
|
|
9
|
+
import type { VerifyResult } from "./types.js";
|
|
10
|
+
/** The slice of the replay engine verification depends on. Production passes a
|
|
11
|
+
* real browser-backed `ReplayEngine`; tests inject a stub so the pass/fail logic
|
|
12
|
+
* is exercised without launching Chromium. */
|
|
13
|
+
export interface Verifier {
|
|
14
|
+
run(url: string, actions: RecordedAction[], targetFingerprint: string, opts?: {
|
|
15
|
+
targetType?: FindingType;
|
|
16
|
+
}): Promise<ReplayResult>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface VerifyOptions {
|
|
20
|
+
/** The URL the finding was recorded against. Retained for callers/reporting;
|
|
21
|
+
* replays run against the served URL, never this origin. */
|
|
22
|
+
url: string;
|
|
23
|
+
actions: RecordedAction[];
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
runs?: number;
|
|
26
|
+
/** Start the patched app and return its URL + a close hook. */
|
|
27
|
+
serve: () => Promise<{
|
|
28
|
+
url: string;
|
|
29
|
+
close: () => Promise<void>;
|
|
30
|
+
}>;
|
|
31
|
+
/** For network findings: verify by signal type (origin-agnostic) instead of the
|
|
32
|
+
* exact fingerprint. */
|
|
33
|
+
targetType?: FindingType;
|
|
34
|
+
/** Replay engine override — tests only. Defaults to a real browser-backed one. */
|
|
35
|
+
engine?: Verifier;
|
|
36
|
+
}
|
|
37
|
+
export declare function verifyFix(opts: VerifyOptions): Promise<VerifyResult>;
|
package/dist/core/heal/verify.js
CHANGED
|
@@ -23,29 +23,43 @@ function rewriteOrigin(raw, serveUrl) {
|
|
|
23
23
|
}
|
|
24
24
|
export async function verifyFix(opts) {
|
|
25
25
|
const { url: serveUrl, close } = await opts.serve();
|
|
26
|
-
const engine = new ReplayEngine();
|
|
26
|
+
const engine = opts.engine ?? new ReplayEngine();
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
// Never zero. `fixed` is derived from the reproduction count, so a caller that
|
|
29
|
+
// asks for 0 runs would otherwise get "fixed: true" out of *no attempts at
|
|
30
|
+
// all* — a patch declared verified on the strength of never having been run.
|
|
31
|
+
const runs = Math.max(1, Math.trunc(opts.runs ?? 3));
|
|
29
32
|
let reproductions = 0;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
let loaded = 0;
|
|
34
|
+
// Every recorded URL is rewritten to the served origin — for client findings
|
|
35
|
+
// too, not just network ones. The repro was recorded against the original
|
|
36
|
+
// app, so a `navigate` action left as-is would send the replay straight back
|
|
37
|
+
// to the *unpatched* server, where the bug reproduces no matter what the
|
|
38
|
+
// patch says. That reads as "unfixed" at best; when the fingerprint happens
|
|
39
|
+
// to be absent there, it reads as a verified fix that was never exercised.
|
|
40
|
+
const actions = opts.actions.map((a) => {
|
|
41
|
+
if (a.type === "request" && a.request) {
|
|
42
|
+
return { ...a, request: { ...a.request, url: rewriteOrigin(a.request.url, serveUrl) } };
|
|
43
|
+
}
|
|
44
|
+
if (a.type === "navigate" && a.value) {
|
|
45
|
+
return { ...a, value: rewriteOrigin(a.value, serveUrl) };
|
|
46
|
+
}
|
|
47
|
+
return a;
|
|
48
|
+
});
|
|
41
49
|
for (let i = 0; i < runs; i++) {
|
|
42
50
|
const res = opts.targetType
|
|
43
51
|
? await engine.run(serveUrl, actions, opts.fingerprint, { targetType: opts.targetType })
|
|
44
52
|
: await engine.run(serveUrl, actions, opts.fingerprint);
|
|
53
|
+
if (res.loaded)
|
|
54
|
+
loaded += 1;
|
|
45
55
|
if (res.reproduced)
|
|
46
56
|
reproductions += 1;
|
|
47
57
|
}
|
|
48
|
-
|
|
58
|
+
// "Did not reproduce" only means "fixed" if the app was actually there to
|
|
59
|
+
// reproduce against. If no run loaded, this verification proved nothing about
|
|
60
|
+
// the patch — so it must not report success, or the caller writes an
|
|
61
|
+
// unverified patch into the user's working tree on the strength of it.
|
|
62
|
+
return { runs, reproductions, loaded, fixed: loaded > 0 && reproductions === 0 };
|
|
49
63
|
}
|
|
50
64
|
finally {
|
|
51
65
|
await engine.close();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { EventBus } from "./eventBus.js";
|
|
3
|
+
export interface HttpFuzzOptions {
|
|
4
|
+
maxRequests?: number;
|
|
5
|
+
dryRun?: boolean;
|
|
6
|
+
/** Hostnames the fuzzer may target — the Node-side self-enforcement of the
|
|
7
|
+
* deny-by-default policy (the browser `networkGuard` only wraps the `Page`). */
|
|
8
|
+
allowHosts?: Set<string>;
|
|
9
|
+
/** Opt-in for POST/PUT body mutations. Default false: the fuzzer sends only
|
|
10
|
+
* GET requests (query mutations + hostile GET headers), which can't mutate
|
|
11
|
+
* server state. Set this to also probe JSON-body type-confusion and method
|
|
12
|
+
* confusion — only on endpoints you own and trust to be non-destructive. */
|
|
13
|
+
mutations?: boolean;
|
|
14
|
+
/** Opt-in: also probe endpoints the deny-list skips (delete/pay/logout/…).
|
|
15
|
+
* Off by default — these can mutate real state. */
|
|
16
|
+
allowDestructive?: boolean;
|
|
17
|
+
/** Endpoints observed live during a walk/fuzz pass (e.g. a `fetch()` fired
|
|
18
|
+
* from a click handler) that `collectEndpoints` can't see on a fresh page.
|
|
19
|
+
* Each is pushed through the same origin/static/destructive filters. */
|
|
20
|
+
seedUrls?: string[];
|
|
21
|
+
/** When false, skip the `page.goto` — the page is already loaded and
|
|
22
|
+
* positioned by the caller (folded post-walk pass). Default true. */
|
|
23
|
+
navigate?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* F5-http — server-side mutation fuzzer. Harvests the endpoints the app really
|
|
27
|
+
* uses, then throws seeded hostile requests at them (query overflow, JSON type
|
|
28
|
+
* confusion, header injection, method confusion). A response `status >= 500`
|
|
29
|
+
* becomes a `network_5xx` finding — same type and message format the interceptor
|
|
30
|
+
* emits for in-browser requests — so it flows through the existing
|
|
31
|
+
* ddmin → spec → validate → heal pipeline unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export declare function httpFuzz(page: Page, targetUrl: string, bus: EventBus, opts?: HttpFuzzOptions): Promise<number>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface InitOptions {
|
|
2
|
+
repoRoot: string;
|
|
3
|
+
url?: string;
|
|
4
|
+
framework?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface InitResult {
|
|
7
|
+
framework: string;
|
|
8
|
+
url: string;
|
|
9
|
+
configPath: string;
|
|
10
|
+
gitignoreUpdated: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface FrameworkMeta {
|
|
13
|
+
framework: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Detect the framework name plus its installed version range. */
|
|
17
|
+
export declare function detectFrameworkMeta(repoRoot: string): FrameworkMeta;
|
|
18
|
+
/** The port a framework's dev server listens on by default. */
|
|
19
|
+
export declare function defaultPort(framework: string): number;
|
|
20
|
+
/** `aztrx-cli init` — detect framework + port, scaffold aztrx.config.ts, seed .gitignore. */
|
|
21
|
+
export declare function initProject(opts: InitOptions): Promise<InitResult>;
|
package/dist/core/init.js
CHANGED
|
@@ -31,7 +31,8 @@ export function detectFrameworkMeta(repoRoot) {
|
|
|
31
31
|
function detectFramework(repoRoot) {
|
|
32
32
|
return detectFrameworkMeta(repoRoot).framework;
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/** The port a framework's dev server listens on by default. */
|
|
35
|
+
export function defaultPort(framework) {
|
|
35
36
|
if (["Vite", "Svelte", "SvelteKit", "Vue", "Astro"].includes(framework))
|
|
36
37
|
return 5173;
|
|
37
38
|
return 3000;
|
|
@@ -44,13 +45,11 @@ export default {
|
|
|
44
45
|
// Dev server Aztrx should attack.
|
|
45
46
|
url: ${JSON.stringify(url)},
|
|
46
47
|
|
|
47
|
-
//
|
|
48
|
-
repo: ".",
|
|
49
|
-
|
|
50
|
-
// Max interactions per pass.
|
|
48
|
+
// Max interactions per pass (default: 100). --max-actions overrides this.
|
|
51
49
|
maxActions: 100,
|
|
52
50
|
|
|
53
|
-
// Deny-by-default network allow-list
|
|
51
|
+
// Deny-by-default network allow-list — add your API host here, or every
|
|
52
|
+
// cross-origin request the app makes is refused. --allow-host adds to this.
|
|
54
53
|
allowHosts: [],
|
|
55
54
|
};
|
|
56
55
|
`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { EventBus } from "./eventBus.js";
|
|
3
|
+
/**
|
|
4
|
+
* Canonical 5xx finding message. Shared by the interceptor (passive in-browser
|
|
5
|
+
* capture) and the HTTP mutation fuzzer (active Node-side capture) so the two
|
|
6
|
+
* produce byte-identical `rawMessage`s — otherwise a finding's fingerprint and
|
|
7
|
+
* its replay fingerprint would diverge and repro would never match.
|
|
8
|
+
*/
|
|
9
|
+
export declare function network5xxMessage(status: number, url: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* F1 — CDP interceptor. Attaches capture to a page and emits typed
|
|
12
|
+
* `telemetry` events on the bus. No framework hooks: works on React
|
|
13
|
+
* (17/18/19), Next.js, Vite, Svelte, Remix, and Vue alike.
|
|
14
|
+
*
|
|
15
|
+
* The subtle part: `console.error` is the ONLY runtime-level way to see
|
|
16
|
+
* errors a React Error Boundary swallows, because the boundary logs them
|
|
17
|
+
* there instead of rethrowing — so `window.onerror` / `pageerror` never fire
|
|
18
|
+
* for them. We pull the real throw-site stack off the Error *object* via
|
|
19
|
+
* `msg.args()`, not `msg.text()` (which is just the message, no stack).
|
|
20
|
+
*/
|
|
21
|
+
export declare function attachInterceptor(page: Page, bus: EventBus): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic LLM client. Two transports behind one `complete()`:
|
|
3
|
+
*
|
|
4
|
+
* - Anthropic Messages API (default) — key from `ANTHROPIC_API_KEY` (or
|
|
5
|
+
* `AZTRX_API_KEY`), primary model defaults to `claude-sonnet-5`.
|
|
6
|
+
* - Any OpenAI-compatible `/chat/completions` endpoint — selected by setting
|
|
7
|
+
* `AZTRX_API_BASE`. That single flag unlocks OpenAI, Grok, DeepSeek, Gemini,
|
|
8
|
+
* Kimi, Mistral, OpenRouter, and local models (Ollama / vLLM / LM Studio)
|
|
9
|
+
* with their existing keys.
|
|
10
|
+
*
|
|
11
|
+
* `AZTRX_MODEL` / `AZTRX_FAST_MODEL` pick the model(s); they default sensibly
|
|
12
|
+
* for Anthropic and must be set explicitly when a custom base URL is used.
|
|
13
|
+
*/
|
|
14
|
+
export type Provider = "anthropic" | "openai";
|
|
15
|
+
export interface LlmSettings {
|
|
16
|
+
provider: Provider;
|
|
17
|
+
apiKey?: string;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Resolve the active provider from the environment. */
|
|
21
|
+
export declare function resolveSettings(): LlmSettings;
|
|
22
|
+
/** Is any provider configured? Used to decide whether to attempt an LLM call. */
|
|
23
|
+
export declare function hasLlmKey(): boolean;
|
|
24
|
+
/** The primary model for the active provider. */
|
|
25
|
+
export declare function primaryModel(): string;
|
|
26
|
+
/** The cheap/fast first tier, or undefined when the provider has none. */
|
|
27
|
+
export declare function fastModel(): string | undefined;
|
|
28
|
+
export interface CompleteOptions {
|
|
29
|
+
system: string;
|
|
30
|
+
prompt: string;
|
|
31
|
+
model?: string;
|
|
32
|
+
maxTokens?: number;
|
|
33
|
+
temperature?: number;
|
|
34
|
+
}
|
|
35
|
+
/** Run one completion against the active provider and return the text. */
|
|
36
|
+
export declare function complete(opts: CompleteOptions): Promise<string>;
|