aztrx-cli 0.4.5 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +326 -29
  2. package/dist/cli/help.d.ts +22 -0
  3. package/dist/cli/repo.d.ts +20 -0
  4. package/dist/cli/repo.js +50 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +284 -86
  7. package/dist/core/auth.d.ts +33 -0
  8. package/dist/core/badge.d.ts +9 -0
  9. package/dist/core/browser.d.ts +3 -0
  10. package/dist/core/classifier.d.ts +41 -0
  11. package/dist/core/cloud/index.d.ts +62 -0
  12. package/dist/core/cloud/index.js +24 -4
  13. package/dist/core/devServer.d.ts +90 -0
  14. package/dist/core/devServer.js +253 -0
  15. package/dist/core/diagnose.d.ts +18 -0
  16. package/dist/core/diff.d.ts +31 -0
  17. package/dist/core/domWalker.d.ts +24 -0
  18. package/dist/core/domWalker.js +18 -2
  19. package/dist/core/eventBus.d.ts +59 -0
  20. package/dist/core/events.d.ts +42 -0
  21. package/dist/core/fixPr.d.ts +16 -0
  22. package/dist/core/fixPr.js +11 -1
  23. package/dist/core/fuzzer.d.ts +24 -0
  24. package/dist/core/fuzzer.js +1 -1
  25. package/dist/core/heal/apply.d.ts +28 -0
  26. package/dist/core/heal/boot.d.ts +74 -0
  27. package/dist/core/heal/boot.js +126 -23
  28. package/dist/core/heal/childEnv.d.ts +15 -0
  29. package/dist/core/heal/gates.d.ts +11 -0
  30. package/dist/core/heal/index.d.ts +19 -0
  31. package/dist/core/heal/index.js +53 -23
  32. package/dist/core/heal/llm.d.ts +43 -0
  33. package/dist/core/heal/llm.js +22 -2
  34. package/dist/core/heal/redact.d.ts +22 -0
  35. package/dist/core/heal/sandbox.d.ts +54 -0
  36. package/dist/core/heal/sandbox.js +63 -3
  37. package/dist/core/heal/types.d.ts +102 -0
  38. package/dist/core/heal/verify.d.ts +37 -0
  39. package/dist/core/heal/verify.js +28 -14
  40. package/dist/core/httpFuzzer.d.ts +33 -0
  41. package/dist/core/init.d.ts +21 -0
  42. package/dist/core/init.js +5 -6
  43. package/dist/core/interceptor.d.ts +21 -0
  44. package/dist/core/llm.d.ts +36 -0
  45. package/dist/core/llm.js +40 -13
  46. package/dist/core/minimizer.d.ts +13 -0
  47. package/dist/core/modernize.d.ts +28 -0
  48. package/dist/core/modernize.js +2 -2
  49. package/dist/core/networkGuard.d.ts +13 -0
  50. package/dist/core/orchestrator.d.ts +86 -0
  51. package/dist/core/orchestrator.js +5 -2
  52. package/dist/core/patrol/loop.d.ts +46 -0
  53. package/dist/core/patrol/pr.d.ts +42 -0
  54. package/dist/core/patrol/pr.js +9 -6
  55. package/dist/core/patrol/record.d.ts +51 -0
  56. package/dist/core/patrol/state.d.ts +39 -0
  57. package/dist/core/pr.d.ts +3 -0
  58. package/dist/core/pr.js +15 -11
  59. package/dist/core/prompt.d.ts +14 -0
  60. package/dist/core/recorder.d.ts +23 -0
  61. package/dist/core/recorder.js +1 -1
  62. package/dist/core/renderMarkdown.d.ts +7 -0
  63. package/dist/core/replay.d.ts +33 -0
  64. package/dist/core/replay.js +52 -7
  65. package/dist/core/report.d.ts +8 -0
  66. package/dist/core/resolver.d.ts +64 -0
  67. package/dist/core/resolver.js +135 -17
  68. package/dist/core/rng.d.ts +2 -0
  69. package/dist/core/specCompiler.d.ts +17 -0
  70. package/dist/core/studio.d.ts +6 -0
  71. package/dist/core/studio.js +0 -3
  72. package/dist/core/summarize.d.ts +26 -0
  73. package/dist/core/summarize.js +17 -6
  74. package/dist/core/swarm.d.ts +97 -0
  75. package/dist/core/swarm.js +25 -2
  76. package/dist/core/telemetry/index.d.ts +33 -0
  77. package/dist/core/telemetry/index.js +22 -4
  78. package/dist/core/telemetry/sanitize.d.ts +19 -0
  79. package/dist/core/telemetry/types.d.ts +26 -0
  80. package/dist/core/types.d.ts +88 -0
  81. package/dist/core/ui.d.ts +23 -0
  82. package/dist/core/ui.js +0 -5
  83. package/dist/core/validator.d.ts +14 -0
  84. package/dist/core/version.d.ts +1 -0
  85. package/dist/hooks/index.d.ts +106 -0
  86. package/dist/hooks/index.js +349 -0
  87. package/dist/mcp/index.d.ts +58 -0
  88. package/dist/mcp/index.js +295 -0
  89. package/dist/mcp/install.d.ts +52 -0
  90. package/dist/mcp/install.js +204 -0
  91. package/dist/mcp/protocol.d.ts +106 -0
  92. package/dist/mcp/protocol.js +156 -0
  93. package/dist/mcp/tools.d.ts +107 -0
  94. package/dist/mcp/tools.js +621 -0
  95. package/dist/next/index.d.ts +70 -0
  96. package/dist/next/index.js +103 -0
  97. package/dist/plugins/scan.d.ts +85 -0
  98. package/dist/plugins/scan.js +188 -0
  99. package/dist/ui/app.d.ts +11 -0
  100. package/dist/vite/index.d.ts +60 -0
  101. package/dist/vite/index.js +56 -0
  102. package/package.json +44 -6
@@ -0,0 +1,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>;
@@ -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
- const replace = src.replace(/\.(?=[a-zA-Z_$])/g, "?.");
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
- maxTokens: 2048,
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
- export async function typecheckWorktree(worktreeDir, repoRoot) {
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>;
@@ -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
- const runs = opts.runs ?? 3;
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
- const actions = opts.targetType
31
- ? opts.actions.map((a) => {
32
- if (a.type === "request" && a.request) {
33
- return { ...a, request: { ...a.request, url: rewriteOrigin(a.request.url, serveUrl) } };
34
- }
35
- if (a.type === "navigate" && a.value) {
36
- return { ...a, value: rewriteOrigin(a.value, serveUrl) };
37
- }
38
- return a;
39
- })
40
- : opts.actions;
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
- return { runs, reproductions, fixed: reproductions === 0 };
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
- function defaultPort(framework) {
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
- // Repo root for sourcemap source resolution (defaults to this directory).
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 for fuzz runs — add your API host here.
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>;