aztrx-cli 0.1.0

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.
@@ -0,0 +1,25 @@
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 { ReplayEngine } from "../replay.js";
8
+ export async function verifyFix(opts) {
9
+ const { url: serveUrl, close } = await opts.serve();
10
+ const engine = new ReplayEngine();
11
+ try {
12
+ const runs = opts.runs ?? 3;
13
+ let reproductions = 0;
14
+ for (let i = 0; i < runs; i++) {
15
+ const res = await engine.run(serveUrl, opts.actions, opts.fingerprint);
16
+ if (res.reproduced)
17
+ reproductions += 1;
18
+ }
19
+ return { runs, reproductions, fixed: reproductions === 0 };
20
+ }
21
+ finally {
22
+ await engine.close();
23
+ await close().catch(() => { });
24
+ }
25
+ }
@@ -0,0 +1,74 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ // Priority order for detection — first dependency match wins.
4
+ const DETECT_ORDER = [
5
+ ["next", "Next.js"],
6
+ ["nuxt", "Nuxt"],
7
+ ["remix", "Remix"],
8
+ ["astro", "Astro"],
9
+ ["sveltekit", "SvelteKit"],
10
+ ["svelte", "Svelte"],
11
+ ["vue", "Vue"],
12
+ ["vite", "Vite"],
13
+ ["react", "React"],
14
+ ];
15
+ /** Detect the framework name plus its installed version range. */
16
+ export function detectFrameworkMeta(repoRoot) {
17
+ try {
18
+ const raw = fs.readFileSync(path.join(repoRoot, "package.json"), "utf-8");
19
+ const pkg = JSON.parse(raw);
20
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
21
+ for (const [dep, name] of DETECT_ORDER) {
22
+ if (deps[dep])
23
+ return { framework: name, version: deps[dep] };
24
+ }
25
+ }
26
+ catch {
27
+ // no package.json — fall through to "unknown"
28
+ }
29
+ return { framework: "unknown" };
30
+ }
31
+ function detectFramework(repoRoot) {
32
+ return detectFrameworkMeta(repoRoot).framework;
33
+ }
34
+ function defaultPort(framework) {
35
+ if (["Vite", "Svelte", "SvelteKit", "Vue", "Astro"].includes(framework))
36
+ return 5173;
37
+ return 3000;
38
+ }
39
+ function configTemplate(framework, url) {
40
+ return `// aztrx.config.ts — generated by \`aztrx init\` (framework: ${framework})
41
+ // Docs: https://aztrx.app/docs/config
42
+
43
+ export default {
44
+ // Dev server Aztrx should attack.
45
+ url: ${JSON.stringify(url)},
46
+
47
+ // Repo root for sourcemap → source resolution (defaults to this directory).
48
+ repo: ".",
49
+
50
+ // Max interactions per pass.
51
+ maxActions: 100,
52
+
53
+ // Deny-by-default network allow-list for fuzz runs — add your API host here.
54
+ allowHosts: [],
55
+ };
56
+ `;
57
+ }
58
+ /** `aztrx init` — detect framework + port, scaffold aztrx.config.ts, seed .gitignore. */
59
+ export async function initProject(opts) {
60
+ const repoRoot = path.resolve(opts.repoRoot);
61
+ const framework = opts.framework ?? detectFramework(repoRoot);
62
+ const url = opts.url ?? `http://localhost:${defaultPort(framework)}`;
63
+ const configPath = path.join(repoRoot, "aztrx.config.ts");
64
+ fs.writeFileSync(configPath, configTemplate(framework, url), "utf-8");
65
+ const gitignorePath = path.join(repoRoot, ".gitignore");
66
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf-8") : "";
67
+ let gitignoreUpdated = false;
68
+ if (!/(^|\n)\.aztrx\/\s*\n?/.test(existing)) {
69
+ const sep = existing === "" || existing.endsWith("\n") ? "" : "\n";
70
+ fs.appendFileSync(gitignorePath, `${sep}.aztrx/\n`);
71
+ gitignoreUpdated = true;
72
+ }
73
+ return { framework, url, configPath, gitignoreUpdated };
74
+ }
@@ -0,0 +1,93 @@
1
+ import { extractFrame } from "./resolver.js";
2
+ // Route unhandled rejections through console.error so a single capture path
3
+ // handles them alongside React Error Boundary logs (both land in console.error).
4
+ const INIT_SCRIPT = `
5
+ window.addEventListener("unhandledrejection", (event) => {
6
+ const reason = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
7
+ console.error("Unhandled Promise Rejection:", reason);
8
+ });
9
+ `;
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 function attachInterceptor(page, bus) {
22
+ page.on("console", async (msg) => {
23
+ if (msg.type() !== "error")
24
+ return;
25
+ const text = msg.text();
26
+ // The init script routes `unhandledrejection` through console.error with a
27
+ // fixed prefix; recover the true signal type so Server Action failures and
28
+ // other promise rejections classify as "error", not "warning".
29
+ const type = text.startsWith("Unhandled Promise Rejection:")
30
+ ? "unhandled_rejection"
31
+ : "console_error";
32
+ let source = text;
33
+ for (const arg of msg.args()) {
34
+ try {
35
+ const s = await arg.evaluate((a) => a instanceof Error ? a.stack || String(a) : String(a));
36
+ if (s.includes("http")) {
37
+ source = s;
38
+ break;
39
+ }
40
+ }
41
+ catch {
42
+ // non-serializable arg — keep msg.text()
43
+ }
44
+ }
45
+ const loc = msg.location();
46
+ const frame = extractFrame(source) ??
47
+ ({ url: loc.url, line: loc.lineNumber, column: loc.columnNumber, message: text.split("\n")[0].slice(0, 200) });
48
+ bus.emit("telemetry", {
49
+ type,
50
+ rawMessage: frame.message,
51
+ rawStack: source,
52
+ url: frame.url,
53
+ line: frame.line,
54
+ column: frame.column,
55
+ });
56
+ });
57
+ page.on("crash", () => {
58
+ bus.emit("telemetry", {
59
+ type: "uncaught_exception",
60
+ rawMessage: "Renderer process crashed",
61
+ rawStack: "",
62
+ });
63
+ });
64
+ page.on("pageerror", (err) => {
65
+ const frame = extractFrame(err.stack ?? "");
66
+ bus.emit("telemetry", {
67
+ type: "uncaught_exception",
68
+ rawMessage: err.message,
69
+ rawStack: err.stack ?? err.message,
70
+ url: frame?.url,
71
+ line: frame?.line,
72
+ column: frame?.column,
73
+ });
74
+ });
75
+ page.on("requestfailed", (req) => {
76
+ const f = req.failure();
77
+ bus.emit("telemetry", {
78
+ type: "network_timeout",
79
+ rawMessage: `Request failed: ${req.url()} (${f?.errorText ?? "unknown"})`,
80
+ rawStack: "",
81
+ });
82
+ });
83
+ page.on("response", (res) => {
84
+ if (res.status() >= 500) {
85
+ bus.emit("telemetry", {
86
+ type: "network_5xx",
87
+ rawMessage: `HTTP ${res.status()} ${res.url()}`,
88
+ rawStack: "",
89
+ });
90
+ }
91
+ });
92
+ page.addInitScript(INIT_SCRIPT);
93
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * F7 — ddmin (delta debugging). Shrinks the failing action sequence to a
3
+ * minimal subset that still reproduces `fingerprint`. Best-effort under a
4
+ * replay budget; a sequence shorter than 2 actions is returned as-is.
5
+ */
6
+ export async function minimize(engine, actions, opts) {
7
+ let current = [...actions];
8
+ if (current.length < 2)
9
+ return current;
10
+ let budget = opts.maxReplays ?? 15;
11
+ const fails = async (subset) => {
12
+ if (budget <= 0)
13
+ return false;
14
+ budget -= 1;
15
+ const res = await engine.run(opts.url, subset, opts.fingerprint);
16
+ return res.reproduced;
17
+ };
18
+ // Sanity: does the full sequence even reproduce?
19
+ if (!(await fails(current)))
20
+ return actions;
21
+ while (current.length >= 2 && budget > 0) {
22
+ let n = 2;
23
+ let reducedThisPass = false;
24
+ while (n <= current.length && budget > 0) {
25
+ const chunkSize = Math.ceil(current.length / n);
26
+ let reduced = false;
27
+ for (let i = 0; i < current.length; i += chunkSize) {
28
+ const complement = current.slice(0, i).concat(current.slice(i + chunkSize));
29
+ if (complement.length === 0 || complement.length === current.length)
30
+ continue;
31
+ if (await fails(complement)) {
32
+ current = complement;
33
+ reduced = true;
34
+ reducedThisPass = true;
35
+ break; // restart partitioning from n=2
36
+ }
37
+ }
38
+ if (reduced)
39
+ break;
40
+ if (n >= current.length)
41
+ break;
42
+ n = Math.min(n * 2, current.length);
43
+ }
44
+ if (!reducedThisPass)
45
+ break;
46
+ }
47
+ return current;
48
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * F6 — deny-by-default network guard (PRD §6.2). Aborts every request whose
3
+ * host isn't allow-listed, so a fuzz/replay pass can't reach payment, delete,
4
+ * or analytics endpoints. Loopback is always allowed (this is a local tool).
5
+ */
6
+ export async function attachNetworkGuard(page, opts) {
7
+ await page.route("**/*", async (route) => {
8
+ const url = route.request().url();
9
+ let host;
10
+ try {
11
+ host = new URL(url).hostname.toLowerCase();
12
+ }
13
+ catch {
14
+ await route.abort();
15
+ return;
16
+ }
17
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
18
+ await route.continue();
19
+ return;
20
+ }
21
+ for (const h of opts.allowHosts) {
22
+ if (host === h || host.endsWith("." + h)) {
23
+ await route.continue();
24
+ return;
25
+ }
26
+ }
27
+ opts.onBlock?.(url);
28
+ await route.abort();
29
+ });
30
+ }
31
+ /** Builds the allow-list: target origin + each `--allow-host`. */
32
+ export function allowHostsFrom(url, extra) {
33
+ const hosts = new Set();
34
+ try {
35
+ hosts.add(new URL(url).hostname.toLowerCase());
36
+ }
37
+ catch {
38
+ // ignore malformed target
39
+ }
40
+ for (const h of extra) {
41
+ try {
42
+ hosts.add(new URL(h.includes("://") ? h : `http://${h}`).hostname.toLowerCase());
43
+ }
44
+ catch {
45
+ // ignore malformed host
46
+ }
47
+ }
48
+ return hosts;
49
+ }
@@ -0,0 +1,337 @@
1
+ import * as path from "path";
2
+ import { chromium } from "playwright";
3
+ import pc from "picocolors";
4
+ import { EventBus } from "./eventBus.js";
5
+ import { attachInterceptor } from "./interceptor.js";
6
+ import { SignalClassifier, loadBaseline } from "./classifier.js";
7
+ import { ActionRecorder } from "./recorder.js";
8
+ import { walkDom } from "./domWalker.js";
9
+ import { fuzz } from "./fuzzer.js";
10
+ import { attachNetworkGuard, allowHostsFrom } from "./networkGuard.js";
11
+ import { resolveFrame } from "./resolver.js";
12
+ import { ReplayEngine } from "./replay.js";
13
+ import { minimize } from "./minimizer.js";
14
+ import { writeSpec } from "./specCompiler.js";
15
+ import { validate } from "./validator.js";
16
+ import { writeReport } from "./report.js";
17
+ import { RunLog } from "./events.js";
18
+ import { heal } from "./heal/index.js";
19
+ import { submitTelemetry } from "./telemetry/index.js";
20
+ import { submitRun } from "./cloud/index.js";
21
+ const SEVERITY_MARK = {
22
+ crash: pc.red("● crash "),
23
+ error: pc.red("● error "),
24
+ warning: pc.yellow("○ warning "),
25
+ noise: pc.dim("○ noise "),
26
+ };
27
+ function printFinding(f, write) {
28
+ write(SEVERITY_MARK[f.severity] + pc.bold(f.rawMessage));
29
+ if (f.mappedLocation) {
30
+ write(pc.dim(` ${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}`));
31
+ write(pc.dim(f.mappedLocation.codeContext));
32
+ }
33
+ if (f.occurrences > 1)
34
+ write(pc.dim(` (×${f.occurrences})`));
35
+ write("");
36
+ }
37
+ /** Human-readable run mode, surfaced in the cloud dashboard. */
38
+ function runMode(o) {
39
+ if (o.fuzz)
40
+ return `fuzz (seed ${o.seed ?? 42})`;
41
+ if (o.heal)
42
+ return "repro → heal";
43
+ if (o.repro)
44
+ return "repro";
45
+ return "deterministic walk";
46
+ }
47
+ /**
48
+ * The run's finite state machine: launch → (guard) → intercept → act (walk or
49
+ * fuzz) → classify → map → report → repro (minimize/compile/validate). Modules
50
+ * communicate only through the EventBus; the orchestrator is the single place
51
+ * that wires them together. In `ui` mode it emits structured events (phase,
52
+ * action, finding, repro, route, noise) and stays silent on stdout, so a
53
+ * terminal renderer (Ink) can draw the live panel instead of log lines.
54
+ */
55
+ export async function run(options) {
56
+ const { url, repoRoot } = options;
57
+ const maxActions = options.maxActions ?? 100;
58
+ const guardOn = Boolean(options.fuzz || options.repro);
59
+ const allowHosts = allowHostsFrom(url, options.allowHosts ?? []);
60
+ const ui = options.ui === true;
61
+ const bus = options.bus ?? new EventBus();
62
+ const say = (...parts) => {
63
+ if (!ui)
64
+ console.log(parts.join(" "));
65
+ };
66
+ const emitPhase = (phase, detail) => bus.emit("phase", { phase, detail, ts: Date.now() });
67
+ say(pc.cyan("\n⚡ Aztrx v0.1.0 — Runtime Detector"));
68
+ say(pc.dim(`Target: ${url}`));
69
+ say(pc.dim(`Repo: ${repoRoot}`));
70
+ if (options.fuzz)
71
+ say(pc.dim(`Mode: fuzz (seed ${options.seed ?? 42})`));
72
+ if (options.repro)
73
+ say(pc.dim(`Mode: repro (${options.reproRuns ?? 3} runs)`));
74
+ if (guardOn)
75
+ say(pc.dim(`Net: deny-by-default → allow ${[...allowHosts].join(", ") || "origin"}`));
76
+ if (options.storageState)
77
+ say(pc.dim(`Auth: ${options.storageState}`));
78
+ say("");
79
+ emitPhase("launch", url);
80
+ const classifier = new SignalClassifier(await loadBaseline(repoRoot));
81
+ const recorder = new ActionRecorder();
82
+ const runLog = new RunLog(repoRoot);
83
+ runLog.reset();
84
+ runLog.append({ type: "run_start", url, ts: Date.now() });
85
+ bus.on("action", (a) => recorder.record(a));
86
+ bus.on("telemetry", async (payload) => {
87
+ const finding = classifier.classify(payload);
88
+ if (!finding)
89
+ return;
90
+ finding.actionHistory = recorder.snapshot();
91
+ if (finding.severity === "noise") {
92
+ bus.emit("noise", { ts: Date.now() });
93
+ return;
94
+ }
95
+ runLog.append({ type: "finding", finding });
96
+ if (payload.url && payload.line) {
97
+ const resolved = await resolveFrame({ url: payload.url, line: payload.line, column: payload.column ?? 0, message: payload.rawMessage }, repoRoot);
98
+ finding.mappedLocation = {
99
+ filePath: resolved.sourceFile,
100
+ line: resolved.line,
101
+ column: resolved.column,
102
+ codeContext: resolved.codeSnippet,
103
+ isOwnCode: resolved.resolvedFrom !== "unresolved",
104
+ };
105
+ }
106
+ bus.emit("finding", finding);
107
+ printFinding(finding, say);
108
+ });
109
+ const browser = await chromium.launch({ headless: true });
110
+ const context = await browser.newContext(options.storageState ? { storageState: options.storageState } : {});
111
+ const page = await context.newPage();
112
+ attachInterceptor(page, bus);
113
+ if (guardOn) {
114
+ await attachNetworkGuard(page, {
115
+ allowHosts,
116
+ onBlock: (u) => say(pc.dim(` [guard] blocked ${u}`)),
117
+ });
118
+ }
119
+ page.on("framenavigated", (frame) => {
120
+ if (frame === page.mainFrame())
121
+ bus.emit("route", { url: frame.url(), ts: Date.now() });
122
+ });
123
+ let loaded = true;
124
+ await page.goto(url, { waitUntil: "load", timeout: 30000 }).catch((e) => {
125
+ loaded = false;
126
+ say(pc.red("Failed to load target: ") + pc.dim(e.message));
127
+ });
128
+ if (loaded) {
129
+ // Settle: wait for hydration and mount-time effects (async fetches,
130
+ // unhandled rejections, React warnings) to fire before we act. A page whose
131
+ // only bugs are mount-time would otherwise be closed before they happen —
132
+ // and a page with no interactive elements fuzzes zero actions, so it relies
133
+ // on this window.
134
+ await page.waitForTimeout(2000);
135
+ }
136
+ if (loaded && options.crashTest) {
137
+ await page.evaluate(() => {
138
+ setTimeout(() => {
139
+ throw new Error("Aztrx test: Cannot read properties of undefined (reading 'token')");
140
+ }, 300);
141
+ });
142
+ await page.waitForTimeout(800);
143
+ }
144
+ if (loaded) {
145
+ emitPhase(options.fuzz ? "fuzz" : "walk");
146
+ const acted = options.fuzz
147
+ ? await fuzz(page, bus, { seed: options.seed, maxActions, dryRun: options.dryRun })
148
+ : await walkDom(page, bus, { maxActions, dryRun: options.dryRun });
149
+ say(pc.dim(`\n${options.fuzz ? "Fuzzed" : "Walked"} ${acted} action(s).\n`));
150
+ }
151
+ await page.waitForTimeout(500);
152
+ await browser.close();
153
+ const findings = classifier.findings();
154
+ // F7 → F8 → F9: minimize each finding, compile an executable spec, validate
155
+ // the flake rate. Only crash/error findings with a recorded action history.
156
+ if (options.repro) {
157
+ const engine = new ReplayEngine({
158
+ attachGuard: async (p) => attachNetworkGuard(p, { allowHosts }),
159
+ storageState: options.storageState,
160
+ });
161
+ try {
162
+ const targets = findings.filter((f) => (f.severity === "crash" || f.severity === "error") && f.actionHistory.length > 0);
163
+ if (targets.length)
164
+ say(pc.cyan("— Repro pipeline (minimize → compile → validate) —"));
165
+ emitPhase("repro");
166
+ for (const f of targets) {
167
+ try {
168
+ const minimal = await minimize(engine, f.actionHistory, { url, fingerprint: f.fingerprint });
169
+ const specPath = writeSpec(repoRoot, f, minimal, url);
170
+ const v = await validate(engine, url, f, minimal, options.reproRuns ?? 3);
171
+ f.repro = {
172
+ actions: minimal,
173
+ specPath,
174
+ verdict: v.verdict,
175
+ rate: v.rate,
176
+ runs: v.runs,
177
+ reproductions: v.reproductions,
178
+ };
179
+ bus.emit("repro", {
180
+ finding: f,
181
+ verdict: v.verdict,
182
+ runs: v.runs,
183
+ reproductions: v.reproductions,
184
+ steps: minimal.length,
185
+ totalSteps: f.actionHistory.length,
186
+ specPath: path.relative(repoRoot, specPath),
187
+ });
188
+ runLog.append({
189
+ type: "repro",
190
+ fingerprint: f.fingerprint,
191
+ verdict: v.verdict,
192
+ runs: v.runs,
193
+ reproductions: v.reproductions,
194
+ steps: minimal.length,
195
+ totalSteps: f.actionHistory.length,
196
+ specPath: path.relative(repoRoot, specPath),
197
+ });
198
+ const mark = v.verdict === "deterministic"
199
+ ? pc.green(" ✓ deterministic")
200
+ : v.verdict === "flaky"
201
+ ? pc.yellow(" ◐ flaky")
202
+ : pc.red(" ✗ unreliable");
203
+ say(`${mark} ${pc.bold(f.rawMessage.split("\n")[0].slice(0, 60))} (${minimal.length}/${f.actionHistory.length} steps, ${v.reproductions}/${v.runs} runs)`);
204
+ say(pc.dim(` spec: ${path.relative(repoRoot, specPath)}`));
205
+ }
206
+ catch (e) {
207
+ // One finding's repro failing must not abort the whole run — record it
208
+ // as unreliable and move on to the next target.
209
+ f.repro = { actions: [], specPath: "", verdict: "unreliable", rate: 0, runs: 0, reproductions: 0 };
210
+ bus.emit("repro", {
211
+ finding: f,
212
+ verdict: "unreliable",
213
+ runs: 0,
214
+ reproductions: 0,
215
+ steps: 0,
216
+ totalSteps: f.actionHistory.length,
217
+ specPath: "",
218
+ });
219
+ runLog.append({
220
+ type: "repro",
221
+ fingerprint: f.fingerprint,
222
+ verdict: "unreliable",
223
+ runs: 0,
224
+ reproductions: 0,
225
+ steps: 0,
226
+ totalSteps: f.actionHistory.length,
227
+ specPath: "",
228
+ });
229
+ say(pc.red(` ✗ repro failed: ${e.message.split("\n")[0]}`));
230
+ }
231
+ }
232
+ }
233
+ finally {
234
+ await engine.close();
235
+ }
236
+ }
237
+ // F10 — closed-loop healing. For each crash/error finding with an own-code
238
+ // source location and a deterministic repro, generate a patch, gate it, apply
239
+ // it in a sandboxed worktree, and verify the bug stops reproducing. Opt-in;
240
+ // the patch is only ever handed to a human for review, never committed.
241
+ if (options.heal) {
242
+ const healTargets = findings.filter((f) => (f.severity === "crash" || f.severity === "error") &&
243
+ f.mappedLocation?.isOwnCode &&
244
+ f.repro &&
245
+ f.repro.verdict !== "unreliable");
246
+ if (healTargets.length) {
247
+ say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → verify) —"));
248
+ emitPhase("heal");
249
+ for (const f of healTargets) {
250
+ say(pc.dim(` healing: ${f.rawMessage.split("\n")[0].slice(0, 60)}`));
251
+ try {
252
+ const result = await heal(f, {
253
+ repoRoot,
254
+ url,
255
+ actions: f.repro.actions,
256
+ fingerprint: f.fingerprint,
257
+ allowHosts: [...allowHosts],
258
+ model: options.healModel,
259
+ fastModel: options.healFastModel,
260
+ });
261
+ f.heal = result;
262
+ bus.emit("heal", {
263
+ finding: f,
264
+ status: result.status,
265
+ patchPath: result.patchPath,
266
+ error: result.error,
267
+ });
268
+ runLog.append({
269
+ type: "heal",
270
+ fingerprint: f.fingerprint,
271
+ status: result.status,
272
+ patchPath: result.patchPath,
273
+ error: result.error,
274
+ });
275
+ const mark = result.status === "healed"
276
+ ? pc.green(" ✓ healed")
277
+ : result.status === "unfixed"
278
+ ? pc.yellow(" ◐ unfixed")
279
+ : pc.red(` ✗ ${result.status}`);
280
+ say(`${mark} ${pc.bold(f.rawMessage.split("\n")[0].slice(0, 60))}`);
281
+ if (result.patchPath)
282
+ say(pc.dim(` patch: ${path.relative(repoRoot, result.patchPath)}`));
283
+ if (result.error)
284
+ say(pc.dim(` ${result.error}`));
285
+ }
286
+ catch (e) {
287
+ f.heal = {
288
+ status: "skipped",
289
+ findingId: f.id,
290
+ filePath: f.mappedLocation?.filePath ?? "",
291
+ hunks: [],
292
+ violations: [],
293
+ error: e.message,
294
+ };
295
+ bus.emit("heal", { finding: f, status: "skipped", error: e.message });
296
+ runLog.append({ type: "heal", fingerprint: f.fingerprint, status: "skipped", error: e.message });
297
+ say(pc.dim(` ✗ heal error: ${e.message}`));
298
+ }
299
+ }
300
+ }
301
+ }
302
+ const reportPath = writeReport(repoRoot, url, findings);
303
+ say(pc.dim(`Report: ${path.relative(repoRoot, reportPath)}`));
304
+ const counts = {};
305
+ for (const f of findings)
306
+ counts[f.severity] = (counts[f.severity] ?? 0) + 1;
307
+ // F11 — opt-in telemetry. Local-only under `--telemetry`; uploads under
308
+ // `--share-data`. Fire-and-forget, sanitized, and never affects exit codes.
309
+ if (options.telemetry || options.shareData) {
310
+ submitTelemetry(findings, {
311
+ repoRoot,
312
+ url,
313
+ telemetry: Boolean(options.telemetry),
314
+ shareData: Boolean(options.shareData),
315
+ endpoint: options.telemetryUrl,
316
+ apiKey: options.apiKey,
317
+ });
318
+ }
319
+ // F12 — opt-in cloud sync. Streams the sanitized run results to the ingest
320
+ // API for the team dashboard; dedup happens server-side by fingerprint.
321
+ if (options.upload) {
322
+ submitRun(findings, {
323
+ repoRoot,
324
+ url,
325
+ apiKey: options.apiKey,
326
+ endpoint: options.cloudUrl,
327
+ mode: runMode(options),
328
+ counts,
329
+ });
330
+ }
331
+ runLog.append({ type: "run_end", counts, ts: Date.now() });
332
+ say(pc.dim("────────────────────────────────────────────"));
333
+ say(pc.cyan(`${findings.length} unique finding(s)`));
334
+ say(pc.dim(`crash: ${counts.crash ?? 0} error: ${counts.error ?? 0} warning: ${counts.warning ?? 0}`));
335
+ emitPhase("done");
336
+ return findings;
337
+ }