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,139 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { createHash } from "crypto";
4
+ // Unambiguous framework files. NOTE: `/_next/static/chunks/` is NOT here —
5
+ // in Next.js dev, user code is served from there too, so URL path alone can't
6
+ // distinguish it. This is an approximation; the sourcemap resolver does the
7
+ // precise own-code mapping separately.
8
+ const DEP_FRAGMENTS = [
9
+ "node_modules",
10
+ "react-dom",
11
+ "react.development",
12
+ "react.production",
13
+ "scheduler.development",
14
+ "scheduler.production",
15
+ "next/dist",
16
+ "webpack-internal",
17
+ ];
18
+ const NOISE_FRAGMENTS = [
19
+ "Download the React DevTools",
20
+ "react_devtools_backend",
21
+ ];
22
+ // Dev-tooling artifacts that are not the app under test — the Next.js dev
23
+ // overlay's "launch editor" source-resolver request, etc. Suppressed regardless
24
+ // of signal type so they neither count as findings nor enter the repro pipeline.
25
+ const DEV_TOOLING_NOISE = ["__nextjs_launch-editor"];
26
+ // React 18/19 hydration mismatches (Next.js 15 App Router included). These are
27
+ // boundary-level divergences between server and client render that stress runs
28
+ // surface constantly but that aren't deterministic bugs in the app's logic —
29
+ // letting them through would poison the repro pipeline. Suppressed as noise.
30
+ const HYDRATION_NOISE = [
31
+ "Hydration failed",
32
+ "There was an error while hydrating",
33
+ "Text content does not match server-rendered HTML",
34
+ "A tree hydrated but some attributes of the server rendered HTML",
35
+ "An error occurred during hydration",
36
+ "Hydration completed but contained mismatches",
37
+ ];
38
+ function normalize(message) {
39
+ return message
40
+ .replace(/\b\d+\b/g, "<N>")
41
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<UUID>")
42
+ .replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, "<TS>")
43
+ .replace(/0x[0-9a-f]+/gi, "<HEX>")
44
+ .replace(/\s+/g, " ");
45
+ }
46
+ function extractFrameUrls(stack) {
47
+ const urls = [];
48
+ const re = /https?:\/\/[^\s)"']+?:\d+:\d+/g;
49
+ let m;
50
+ while ((m = re.exec(stack)) !== null)
51
+ urls.push(m[0]);
52
+ return urls;
53
+ }
54
+ function isDepFrame(frame) {
55
+ return DEP_FRAGMENTS.some((d) => frame.includes(d));
56
+ }
57
+ function stripPos(frame) {
58
+ return frame.replace(/:\d+:\d+$/, "");
59
+ }
60
+ function hasOwnFrame(stack) {
61
+ const frames = extractFrameUrls(stack);
62
+ if (frames.length === 0)
63
+ return false;
64
+ return frames.some((f) => !isDepFrame(f));
65
+ }
66
+ export function fingerprintOf(payload) {
67
+ const frames = extractFrameUrls(payload.rawStack);
68
+ const own = frames.filter((f) => !isDepFrame(f)).slice(0, 3).map(stripPos);
69
+ const key = `${payload.type}|${normalize(payload.rawMessage)}|${own.join("|")}`;
70
+ return createHash("sha1").update(key).digest("hex").slice(0, 12);
71
+ }
72
+ function classifySeverity(payload, isOwnCode) {
73
+ if (DEV_TOOLING_NOISE.some((n) => payload.rawMessage.includes(n)))
74
+ return "noise";
75
+ if (HYDRATION_NOISE.some((n) => payload.rawMessage.includes(n)))
76
+ return "noise";
77
+ switch (payload.type) {
78
+ case "uncaught_exception":
79
+ return isOwnCode ? "crash" : "error";
80
+ case "unhandled_rejection":
81
+ case "network_5xx":
82
+ case "network_timeout":
83
+ return "error";
84
+ case "console_error":
85
+ if (NOISE_FRAGMENTS.some((n) => payload.rawMessage.includes(n)))
86
+ return "noise";
87
+ return "warning";
88
+ }
89
+ }
90
+ /**
91
+ * F3 — Signal classifier. Fingerprints, dedups, assigns severity, and
92
+ * suppresses a baseline of already-known fingerprints. Returns a Finding on
93
+ * first sight, `null` on duplicate or suppressed.
94
+ */
95
+ export class SignalClassifier {
96
+ seen = new Map();
97
+ baseline = new Set();
98
+ constructor(baselineFingerprints = []) {
99
+ for (const f of baselineFingerprints)
100
+ this.baseline.add(f);
101
+ }
102
+ classify(payload) {
103
+ const fingerprint = fingerprintOf(payload);
104
+ if (this.baseline.has(fingerprint))
105
+ return null;
106
+ const existing = this.seen.get(fingerprint);
107
+ if (existing) {
108
+ existing.occurrences += 1;
109
+ return null; // dedup — count, don't re-emit
110
+ }
111
+ const isOwnCode = hasOwnFrame(payload.rawStack);
112
+ const finding = {
113
+ id: fingerprint,
114
+ fingerprint,
115
+ occurrences: 1,
116
+ severity: classifySeverity(payload, isOwnCode),
117
+ type: payload.type,
118
+ rawMessage: payload.rawMessage,
119
+ rawStack: payload.rawStack,
120
+ actionHistory: [],
121
+ };
122
+ this.seen.set(fingerprint, finding);
123
+ return finding;
124
+ }
125
+ /** Unique findings, noise suppressed. */
126
+ findings() {
127
+ return [...this.seen.values()].filter((f) => f.severity !== "noise");
128
+ }
129
+ }
130
+ export async function loadBaseline(repoRoot) {
131
+ const p = path.join(repoRoot, ".aztrx", "baseline.json");
132
+ try {
133
+ const arr = JSON.parse(fs.readFileSync(p, "utf-8"));
134
+ return Array.isArray(arr) ? arr.filter((x) => typeof x === "string") : [];
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * F12 — opt-in cloud sync. Streams a completed run's findings to the Aztrx
3
+ * ingest API (`POST /api/runs`) for the team dashboard and server-side
4
+ * deduplication by crash fingerprint. Mirrors the telemetry module's contract:
5
+ * fire-and-forget, bounded by a short abort, and never affects the exit code.
6
+ * Everything is sanitized before packaging (secrets, URLs, repo paths).
7
+ */
8
+ import * as fs from "fs";
9
+ import { detectFrameworkMeta } from "../init.js";
10
+ import { createSanitizer } from "../telemetry/sanitize.js";
11
+ const DEFAULT_CLOUD_URL = process.env.AZTRX_CLOUD_URL || "https://api.aztrx.app";
12
+ const UPLOAD_TIMEOUT_MS = 2000;
13
+ /** In-flight uploads, drained by `flushCloud()` before the CLI exits. */
14
+ const pendingUploads = [];
15
+ function readFileIfExists(p) {
16
+ try {
17
+ return fs.readFileSync(p, "utf-8");
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function buildPayload(findings, opts) {
24
+ const sanitize = createSanitizer(opts.repoRoot);
25
+ const meta = detectFrameworkMeta(opts.repoRoot);
26
+ const cloudFindings = [];
27
+ for (const f of findings) {
28
+ if (f.severity !== "crash" && f.severity !== "error")
29
+ continue;
30
+ const specRaw = f.repro?.specPath ? readFileIfExists(f.repro.specPath) : null;
31
+ const patchRaw = f.heal?.status === "healed" && f.heal.patchPath
32
+ ? readFileIfExists(f.heal.patchPath)
33
+ : null;
34
+ cloudFindings.push({
35
+ fingerprint: f.fingerprint,
36
+ severity: f.severity,
37
+ type: f.type,
38
+ message: sanitize.text(f.rawMessage),
39
+ location: f.mappedLocation
40
+ ? {
41
+ file: sanitize.text(f.mappedLocation.filePath),
42
+ line: f.mappedLocation.line,
43
+ column: f.mappedLocation.column,
44
+ }
45
+ : undefined,
46
+ repro: f.repro
47
+ ? {
48
+ verdict: f.repro.verdict,
49
+ rate: f.repro.rate,
50
+ runs: f.repro.runs,
51
+ reproductions: f.repro.reproductions,
52
+ spec: specRaw ? sanitize.text(specRaw) : null,
53
+ }
54
+ : undefined,
55
+ patch: patchRaw ? sanitize.text(patchRaw) : null,
56
+ model_tier: f.heal?.model ?? null,
57
+ });
58
+ }
59
+ return {
60
+ schema: "aztrx.run/1",
61
+ sentAt: new Date().toISOString(),
62
+ framework: meta.framework,
63
+ framework_version: meta.version,
64
+ target: sanitize.url(opts.url),
65
+ mode: opts.mode,
66
+ counts: opts.counts,
67
+ findings: cloudFindings,
68
+ };
69
+ }
70
+ /** Fire-and-forget upload. Never rejects; bounded by a short abort. */
71
+ export function dispatchUpload(payload, endpoint, apiKey) {
72
+ const ctrl = new AbortController();
73
+ const timer = setTimeout(() => ctrl.abort(), UPLOAD_TIMEOUT_MS);
74
+ const headers = { "content-type": "application/json" };
75
+ if (apiKey)
76
+ headers["x-api-key"] = apiKey;
77
+ return fetch(`${endpoint}/api/runs`, {
78
+ method: "POST",
79
+ headers,
80
+ body: JSON.stringify(payload),
81
+ signal: ctrl.signal,
82
+ })
83
+ .then(() => { })
84
+ .catch(() => { })
85
+ .finally(() => clearTimeout(timer));
86
+ }
87
+ /** Build the sanitized payload and detach an upload. A clean run (zero
88
+ * findings) still uploads — the dashboard tracks green runs too. */
89
+ export function submitRun(findings, opts) {
90
+ const endpoint = opts.endpoint ?? DEFAULT_CLOUD_URL;
91
+ const apiKey = opts.apiKey ?? process.env.AZTRX_API_KEY;
92
+ const payload = buildPayload(findings, opts);
93
+ pendingUploads.push(dispatchUpload(payload, endpoint, apiKey));
94
+ }
95
+ /** Await all in-flight uploads (each already bounded). Called right before the
96
+ * CLI exits so a pending `--upload` isn't killed mid-flight; never affects exit
97
+ * code. */
98
+ export async function flushCloud() {
99
+ while (pendingUploads.length) {
100
+ const batch = pendingUploads.splice(0);
101
+ await Promise.allSettled(batch);
102
+ }
103
+ }
@@ -0,0 +1,80 @@
1
+ import { selectorCascade } from "./recorder.js";
2
+ // F6 guard-rail (part 1): never click anything that looks destructive. The
3
+ // full deny-list (config regexps, data-aztrx-skip) is a later pass.
4
+ export const DESTRUCTIVE = /(delete|remove|logout|sign\s?out|log\s?out|pay|checkout|submit\s?order|purchase|buy|удалить|оплатить|выйти|выход)/i;
5
+ export const TEXT_INPUT_TYPES = new Set(["text", "search", "email", "tel", "url", "number", ""]);
6
+ export const SELECTOR = 'a, button, input, select, textarea, [role="button"], [onclick]';
7
+ /**
8
+ * F5-lite — discover interactive elements and act on them, tripping runtime
9
+ * errors for the interceptor to catch. This is the deterministic "walk every
10
+ * button" seed of the Chaos Fuzzer; rage-clicks, form fuzzing, and network
11
+ * jitter come next.
12
+ */
13
+ export async function walkDom(page, bus, opts = {}) {
14
+ const max = opts.maxActions ?? 100;
15
+ const startUrl = page.url();
16
+ let actions = 0;
17
+ const handles = await page.$$(SELECTOR);
18
+ for (const handle of handles) {
19
+ if (actions >= max)
20
+ break;
21
+ if (page.url() !== startUrl)
22
+ break; // navigated — bail this pass
23
+ const visible = await handle.isVisible().catch(() => false);
24
+ const enabled = await handle.isEnabled().catch(() => false);
25
+ if (!visible || !enabled)
26
+ continue;
27
+ let tag;
28
+ try {
29
+ tag = await handle.evaluate((el) => el.tagName.toLowerCase());
30
+ }
31
+ catch {
32
+ continue; // element unreadable mid-query — skip
33
+ }
34
+ let label = "";
35
+ try {
36
+ label = await handle.evaluate((el) => {
37
+ const t = el.innerText ||
38
+ el.getAttribute("aria-label") ||
39
+ el.getAttribute("value") ||
40
+ el.getAttribute("placeholder") ||
41
+ "";
42
+ return t.trim();
43
+ });
44
+ }
45
+ catch {
46
+ label = ""; // degraded — no label to filter on
47
+ }
48
+ if (DESTRUCTIVE.test(label))
49
+ continue;
50
+ if (tag === "a") {
51
+ const href = (await handle.getAttribute("href")) ?? "";
52
+ if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
53
+ continue;
54
+ }
55
+ if (tag === "input") {
56
+ const type = (await handle.getAttribute("type")) ?? "";
57
+ if (!TEXT_INPUT_TYPES.has(type))
58
+ continue; // skip password/hidden/submit/checkbox/etc.
59
+ }
60
+ const selectors = await selectorCascade(page, handle);
61
+ if (tag === "input" || tag === "textarea") {
62
+ const action = { type: "input", selectors, value: "test", timestamp: Date.now() };
63
+ bus.emit("action", action);
64
+ if (!opts.dryRun)
65
+ await handle.fill("test").catch(() => { });
66
+ }
67
+ else {
68
+ const action = { type: "click", selectors, timestamp: Date.now() };
69
+ bus.emit("action", action);
70
+ if (!opts.dryRun)
71
+ await handle.click({ timeout: 1500 }).catch(() => { });
72
+ }
73
+ actions++;
74
+ await page.waitForTimeout(120);
75
+ }
76
+ return actions;
77
+ }
78
+ export function originOf(url) {
79
+ return url.match(/^https?:\/\/[^/]+/)?.[0] ?? "";
80
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Minimal typed event bus — the single backbone the modules communicate over,
3
+ * so the Orchestrator can be swapped for a cloud sink later without touching
4
+ * any module (hexagonal, per PRD §2.2).
5
+ */
6
+ export class EventBus {
7
+ handlers = new Map();
8
+ on(event, handler) {
9
+ let set = this.handlers.get(event);
10
+ if (!set) {
11
+ set = new Set();
12
+ this.handlers.set(event, set);
13
+ }
14
+ set.add(handler);
15
+ return () => {
16
+ set.delete(handler);
17
+ };
18
+ }
19
+ emit(event, payload) {
20
+ this.handlers.get(event)?.forEach((h) => h(payload));
21
+ }
22
+ }
@@ -0,0 +1,25 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ /**
4
+ * Append-only JSON-lines run log. Every run writes its lifecycle + findings
5
+ * here so a live surface (Local Studio, later the cloud sink) can tail it
6
+ * without coupling to the orchestrator's console output.
7
+ */
8
+ export class RunLog {
9
+ file;
10
+ constructor(repoRoot) {
11
+ const dir = path.join(repoRoot, ".aztrx");
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ this.file = path.join(dir, "events.jsonl");
14
+ }
15
+ append(event) {
16
+ fs.appendFileSync(this.file, JSON.stringify(event) + "\n", "utf-8");
17
+ }
18
+ /** Truncate so the log represents the latest run only (live-tail semantics). */
19
+ reset() {
20
+ fs.writeFileSync(this.file, "", "utf-8");
21
+ }
22
+ get path() {
23
+ return this.file;
24
+ }
25
+ }
@@ -0,0 +1,175 @@
1
+ import { selectorCascade } from "./recorder.js";
2
+ import { SELECTOR, DESTRUCTIVE, TEXT_INPUT_TYPES, originOf } from "./domWalker.js";
3
+ import { mulberry32 } from "./rng.js";
4
+ const GARBAGE = [
5
+ "<script>alert(1)</script>",
6
+ "'; DROP TABLE users;--",
7
+ "a".repeat(4096),
8
+ "😀".repeat(64),
9
+ String.fromCharCode(0) + "null-byte",
10
+ "NaN",
11
+ "-1",
12
+ "0",
13
+ "99999999999999999999",
14
+ "\n\t\r whitespace",
15
+ "\"'`${}[]()",
16
+ "undefined",
17
+ ];
18
+ // Keys most likely to trip state-dependent bugs: Enter submits, Escape closes
19
+ // modals, Backspace/Tab mutate focused fields, arrows scroll/select.
20
+ const KEYS = ["Enter", "Escape", "Backspace", "Tab", "ArrowDown", "ArrowUp", " ", "Delete"];
21
+ function pick(rnd, arr) {
22
+ return arr[Math.floor(rnd() * arr.length)];
23
+ }
24
+ /**
25
+ * F5 — chaos fuzzer. Seeded random walk with a richer vocabulary than the
26
+ * deterministic walk: clicks (occasionally doubled), hover, keypresses, select
27
+ * option changes, scrolls, and garbage-filled text inputs — tripping runtime
28
+ * errors for the interceptor. Skips anything the destructive deny-list flags,
29
+ * and never follows off-origin links.
30
+ */
31
+ export async function fuzz(page, bus, opts = {}) {
32
+ const max = opts.maxActions ?? 100;
33
+ const rnd = mulberry32(opts.seed ?? 42);
34
+ const startUrl = page.url();
35
+ let acted = 0;
36
+ for (let i = 0; i < max; i++) {
37
+ if (page.url() !== startUrl) {
38
+ await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
39
+ }
40
+ // Occasional page-level scroll — reaches lazy-loaded / intersection-gated UI.
41
+ if (rnd() < 0.08) {
42
+ const dir = rnd() < 0.5 ? "up" : "down";
43
+ const action = { type: "scroll", selectors: [], value: dir, timestamp: Date.now() };
44
+ bus.emit("action", action);
45
+ if (!opts.dryRun)
46
+ await page.mouse.wheel(0, dir === "up" ? -600 : 600).catch(() => { });
47
+ acted++;
48
+ await page.waitForTimeout(40);
49
+ continue;
50
+ }
51
+ const handles = await page.$$(SELECTOR);
52
+ const visible = [];
53
+ for (const h of handles) {
54
+ const v = await h.isVisible().catch(() => false);
55
+ const e = await h.isEnabled().catch(() => false);
56
+ if (v && e)
57
+ visible.push(h);
58
+ }
59
+ if (visible.length === 0)
60
+ break;
61
+ const handle = pick(rnd, visible);
62
+ let tag;
63
+ try {
64
+ tag = await handle.evaluate((el) => el.tagName.toLowerCase());
65
+ }
66
+ catch {
67
+ continue; // element detached/unreadable mid-query — skip
68
+ }
69
+ let label = "";
70
+ try {
71
+ label = await handle.evaluate((el) => {
72
+ const t = el.innerText ||
73
+ el.getAttribute("aria-label") ||
74
+ el.getAttribute("value") ||
75
+ el.getAttribute("placeholder") ||
76
+ "";
77
+ return t.trim();
78
+ });
79
+ }
80
+ catch {
81
+ label = ""; // degraded — no label to filter on
82
+ }
83
+ if (DESTRUCTIVE.test(label))
84
+ continue;
85
+ if (tag === "a") {
86
+ const href = (await handle.getAttribute("href")) ?? "";
87
+ if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
88
+ continue;
89
+ }
90
+ // Text inputs — mostly pour garbage in, sometimes hit keys or hover.
91
+ if (tag === "input" || tag === "textarea") {
92
+ const type = (await handle.getAttribute("type")) ?? "";
93
+ if (!TEXT_INPUT_TYPES.has(type))
94
+ continue;
95
+ const selectors = await selectorCascade(page, handle);
96
+ const roll = rnd();
97
+ if (roll < 0.6) {
98
+ const value = pick(rnd, GARBAGE);
99
+ const action = { type: "input", selectors, value, timestamp: Date.now() };
100
+ bus.emit("action", action);
101
+ if (!opts.dryRun)
102
+ await handle.fill(value).catch(() => { });
103
+ }
104
+ else if (roll < 0.85) {
105
+ const key = pick(rnd, KEYS);
106
+ const action = { type: "keypress", selectors, value: key, timestamp: Date.now() };
107
+ bus.emit("action", action);
108
+ if (!opts.dryRun) {
109
+ await handle.focus().catch(() => { });
110
+ await page.keyboard.press(key).catch(() => { });
111
+ }
112
+ }
113
+ else {
114
+ const action = { type: "hover", selectors, timestamp: Date.now() };
115
+ bus.emit("action", action);
116
+ if (!opts.dryRun)
117
+ await handle.hover().catch(() => { });
118
+ }
119
+ acted++;
120
+ await page.waitForTimeout(60);
121
+ continue;
122
+ }
123
+ // Select — actually change the option, a common source of state bugs.
124
+ if (tag === "select") {
125
+ let options = [];
126
+ try {
127
+ options = await handle.evaluate((el) => Array.from(el.options).map((o) => o.value || o.textContent?.trim() || ""));
128
+ }
129
+ catch {
130
+ continue; // options unreadable — skip this select
131
+ }
132
+ if (options.length > 0) {
133
+ const value = pick(rnd, options);
134
+ const selectors = await selectorCascade(page, handle);
135
+ const action = { type: "select", selectors, value, timestamp: Date.now() };
136
+ bus.emit("action", action);
137
+ if (!opts.dryRun)
138
+ await handle.selectOption(value).catch(() => { });
139
+ acted++;
140
+ await page.waitForTimeout(60);
141
+ }
142
+ continue;
143
+ }
144
+ // Clickable — mostly click, otherwise hover or keyboard-navigate.
145
+ const selectors = await selectorCascade(page, handle);
146
+ const roll = rnd();
147
+ if (roll < 0.55) {
148
+ const action = { type: "click", selectors, timestamp: Date.now() };
149
+ bus.emit("action", action);
150
+ if (!opts.dryRun) {
151
+ await handle.click({ timeout: 1000 }).catch(() => { });
152
+ if (rnd() < 0.15)
153
+ await handle.click({ timeout: 1000 }).catch(() => { }); // double-click
154
+ }
155
+ }
156
+ else if (roll < 0.85) {
157
+ const action = { type: "hover", selectors, timestamp: Date.now() };
158
+ bus.emit("action", action);
159
+ if (!opts.dryRun)
160
+ await handle.hover().catch(() => { });
161
+ }
162
+ else {
163
+ const key = pick(rnd, KEYS);
164
+ const action = { type: "keypress", selectors, value: key, timestamp: Date.now() };
165
+ bus.emit("action", action);
166
+ if (!opts.dryRun) {
167
+ await handle.focus().catch(() => { });
168
+ await page.keyboard.press(key).catch(() => { });
169
+ }
170
+ }
171
+ acted++;
172
+ await page.waitForTimeout(60);
173
+ }
174
+ return acted;
175
+ }