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.
- package/LICENSE +201 -0
- package/README.md +292 -0
- package/dist/cli.js +136 -0
- package/dist/core/classifier.js +139 -0
- package/dist/core/cloud/index.js +103 -0
- package/dist/core/domWalker.js +80 -0
- package/dist/core/eventBus.js +22 -0
- package/dist/core/events.js +25 -0
- package/dist/core/fuzzer.js +175 -0
- package/dist/core/heal/gates.js +165 -0
- package/dist/core/heal/index.js +242 -0
- package/dist/core/heal/llm.js +108 -0
- package/dist/core/heal/redact.js +63 -0
- package/dist/core/heal/sandbox.js +99 -0
- package/dist/core/heal/types.js +1 -0
- package/dist/core/heal/verify.js +25 -0
- package/dist/core/init.js +74 -0
- package/dist/core/interceptor.js +93 -0
- package/dist/core/minimizer.js +48 -0
- package/dist/core/networkGuard.js +49 -0
- package/dist/core/orchestrator.js +337 -0
- package/dist/core/pr.js +141 -0
- package/dist/core/recorder.js +93 -0
- package/dist/core/replay.js +112 -0
- package/dist/core/report.js +84 -0
- package/dist/core/resolver.js +124 -0
- package/dist/core/rng.js +11 -0
- package/dist/core/specCompiler.js +69 -0
- package/dist/core/studio.js +219 -0
- package/dist/core/telemetry/index.js +103 -0
- package/dist/core/telemetry/sanitize.js +57 -0
- package/dist/core/telemetry/types.js +4 -0
- package/dist/core/types.js +1 -0
- package/dist/core/ui.js +93 -0
- package/dist/core/validator.js +16 -0
- package/dist/ui/app.js +131 -0
- package/package.json +41 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as http from "http";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import { BASE_CSS, SEVERITY_COLOR, seismograph } from "./ui.js";
|
|
6
|
+
function escapeHtml(s) {
|
|
7
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
8
|
+
}
|
|
9
|
+
function send(res, status, contentType, body) {
|
|
10
|
+
res.writeHead(status, { "Content-Type": contentType });
|
|
11
|
+
res.end(body);
|
|
12
|
+
}
|
|
13
|
+
function sendFile(res, filePath, contentType) {
|
|
14
|
+
try {
|
|
15
|
+
const body = fs.readFileSync(filePath, "utf-8");
|
|
16
|
+
send(res, 200, contentType, body);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
send(res, 404, "text/plain; charset=utf-8", "not found");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* SSE tail of the run log. Replays the current log from byte 0 (so a studio
|
|
24
|
+
* opened after a run still shows its history), then pushes new lines as they
|
|
25
|
+
* land, every 500ms.
|
|
26
|
+
*/
|
|
27
|
+
function sseHandler(req, res, eventsFile) {
|
|
28
|
+
res.writeHead(200, {
|
|
29
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
30
|
+
"Cache-Control": "no-cache",
|
|
31
|
+
Connection: "keep-alive",
|
|
32
|
+
"Access-Control-Allow-Origin": "*",
|
|
33
|
+
});
|
|
34
|
+
res.write(`data: ${JSON.stringify({ type: "hello" })}\n\n`);
|
|
35
|
+
let offset = 0;
|
|
36
|
+
let closed = false;
|
|
37
|
+
const tick = () => {
|
|
38
|
+
if (closed)
|
|
39
|
+
return;
|
|
40
|
+
let text = "";
|
|
41
|
+
try {
|
|
42
|
+
text = fs.readFileSync(eventsFile, "utf-8");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
text = "";
|
|
46
|
+
}
|
|
47
|
+
if (text.length > offset) {
|
|
48
|
+
const chunk = text.slice(offset);
|
|
49
|
+
offset = text.length;
|
|
50
|
+
for (const line of chunk.split("\n")) {
|
|
51
|
+
if (line.trim())
|
|
52
|
+
res.write(`data: ${line}\n\n`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const timer = setInterval(tick, 500);
|
|
57
|
+
tick();
|
|
58
|
+
req.on("close", () => {
|
|
59
|
+
closed = true;
|
|
60
|
+
clearInterval(timer);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function dashboardScript() {
|
|
64
|
+
return `const list = document.getElementById("list");
|
|
65
|
+
const target = document.getElementById("target");
|
|
66
|
+
const foot = document.getElementById("foot");
|
|
67
|
+
const spikes = document.getElementById("spikes");
|
|
68
|
+
const counts = { crash: 0, error: 0, warning: 0 };
|
|
69
|
+
const sevColor = ${JSON.stringify(SEVERITY_COLOR)};
|
|
70
|
+
const byFingerprint = new Map();
|
|
71
|
+
const pendingRepro = new Map();
|
|
72
|
+
let first = true;
|
|
73
|
+
|
|
74
|
+
function setCount(k, v) {
|
|
75
|
+
counts[k] = v;
|
|
76
|
+
const b = document.getElementById("c-" + k);
|
|
77
|
+
if (b) b.textContent = String(v);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function addSpike() {
|
|
81
|
+
if (!spikes) return;
|
|
82
|
+
const n = spikes.children.length + 1;
|
|
83
|
+
const x = 80 + (((n - 1) % 8) + 0.5) / 8 * 640;
|
|
84
|
+
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
85
|
+
p.setAttribute("d", "M " + x.toFixed(1) + " 32 L " + x.toFixed(1) + " 12");
|
|
86
|
+
p.setAttribute("stroke", "#ff5a5f");
|
|
87
|
+
p.setAttribute("stroke-width", "2");
|
|
88
|
+
p.setAttribute("stroke-linecap", "round");
|
|
89
|
+
p.setAttribute("fill", "none");
|
|
90
|
+
p.setAttribute("style", "filter:drop-shadow(0 0 4px #ff5a5f)");
|
|
91
|
+
spikes.appendChild(p);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function applyRepro(fp, r) {
|
|
95
|
+
const card = byFingerprint.get(fp);
|
|
96
|
+
if (!card) { pendingRepro.set(fp, r); return; }
|
|
97
|
+
let el = card.querySelector(".repro");
|
|
98
|
+
if (!el) {
|
|
99
|
+
el = document.createElement("div");
|
|
100
|
+
card.appendChild(el);
|
|
101
|
+
}
|
|
102
|
+
el.className = "repro " + r.verdict;
|
|
103
|
+
el.textContent = r.verdict + " · " + r.reproductions + "/" + r.runs + " runs · spec " + r.specPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function addFinding(f) {
|
|
107
|
+
if (counts[f.severity] !== undefined) setCount(f.severity, counts[f.severity] + 1);
|
|
108
|
+
if (f.severity === "crash") addSpike();
|
|
109
|
+
|
|
110
|
+
const card = document.createElement("article");
|
|
111
|
+
card.className = "finding";
|
|
112
|
+
card.style.setProperty("--sev", sevColor[f.severity] || "#4cc2ff");
|
|
113
|
+
|
|
114
|
+
const header = document.createElement("header");
|
|
115
|
+
const chip = document.createElement("span");
|
|
116
|
+
chip.className = "sev";
|
|
117
|
+
chip.textContent = f.severity;
|
|
118
|
+
const h2 = document.createElement("h2");
|
|
119
|
+
h2.textContent = f.rawMessage.split("\\n")[0];
|
|
120
|
+
header.appendChild(chip);
|
|
121
|
+
header.appendChild(h2);
|
|
122
|
+
card.appendChild(header);
|
|
123
|
+
|
|
124
|
+
if (f.mappedLocation) {
|
|
125
|
+
const loc = document.createElement("div");
|
|
126
|
+
loc.className = "loc";
|
|
127
|
+
loc.textContent = f.mappedLocation.filePath + ":" + f.mappedLocation.line + ":" + f.mappedLocation.column;
|
|
128
|
+
card.appendChild(loc);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
byFingerprint.set(f.fingerprint, card);
|
|
132
|
+
if (pendingRepro.has(f.fingerprint)) {
|
|
133
|
+
applyRepro(f.fingerprint, pendingRepro.get(f.fingerprint));
|
|
134
|
+
pendingRepro.delete(f.fingerprint);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (first) { list.innerHTML = ""; first = false; }
|
|
138
|
+
list.insertBefore(card, list.firstChild);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const es = new EventSource("/events");
|
|
142
|
+
es.onmessage = (e) => {
|
|
143
|
+
const evt = JSON.parse(e.data);
|
|
144
|
+
if (evt.type === "run_start") {
|
|
145
|
+
target.textContent = evt.url;
|
|
146
|
+
list.innerHTML = "";
|
|
147
|
+
foot.textContent = "";
|
|
148
|
+
byFingerprint.clear();
|
|
149
|
+
pendingRepro.clear();
|
|
150
|
+
first = true;
|
|
151
|
+
setCount("crash", 0); setCount("error", 0); setCount("warning", 0);
|
|
152
|
+
while (spikes && spikes.firstChild) spikes.removeChild(spikes.firstChild);
|
|
153
|
+
} else if (evt.type === "finding") {
|
|
154
|
+
addFinding(evt.finding);
|
|
155
|
+
} else if (evt.type === "repro") {
|
|
156
|
+
applyRepro(evt.fingerprint, evt);
|
|
157
|
+
} else if (evt.type === "run_end") {
|
|
158
|
+
const c = evt.counts || {};
|
|
159
|
+
foot.textContent = "run complete — " + (c.crash || 0) + " crash · " + (c.error || 0) + " error · " + (c.warning || 0) + " warning";
|
|
160
|
+
}
|
|
161
|
+
};`;
|
|
162
|
+
}
|
|
163
|
+
function dashboardHtml() {
|
|
164
|
+
return `<!doctype html>
|
|
165
|
+
<html lang="en">
|
|
166
|
+
<head>
|
|
167
|
+
<meta charset="utf-8">
|
|
168
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
169
|
+
<title>Aztrx Studio</title>
|
|
170
|
+
<style>${BASE_CSS}</style>
|
|
171
|
+
</head>
|
|
172
|
+
<body>
|
|
173
|
+
<main>
|
|
174
|
+
<div class="hero">
|
|
175
|
+
${seismograph(0)}
|
|
176
|
+
<div class="brand-row">
|
|
177
|
+
<h1><span class="brand">aztrx</span> <span class="brand-sub">studio</span></h1>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="target" id="target">waiting for a run…</div>
|
|
180
|
+
<div class="live"><span class="live-dot"></span> streaming .aztrx/events.jsonl</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="bar">
|
|
183
|
+
<span class="count">crash <b class="crash" id="c-crash">0</b></span>
|
|
184
|
+
<span class="count">error <b class="error" id="c-error">0</b></span>
|
|
185
|
+
<span class="count">warning <b class="warning" id="c-warning">0</b></span>
|
|
186
|
+
</div>
|
|
187
|
+
<div id="list"><p class="empty">Run <code>aztrx <url> --repo .</code> to stream findings here live.</p></div>
|
|
188
|
+
<p class="foot" id="foot"></p>
|
|
189
|
+
<p class="foot">Full report: <a href="/report">.aztrx/report.html</a></p>
|
|
190
|
+
</main>
|
|
191
|
+
<script>${dashboardScript()}</script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>`;
|
|
194
|
+
}
|
|
195
|
+
export function startStudio(opts) {
|
|
196
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
197
|
+
const port = opts.port ?? 7331;
|
|
198
|
+
const aztrxDir = path.join(repoRoot, ".aztrx");
|
|
199
|
+
const eventsFile = path.join(aztrxDir, "events.jsonl");
|
|
200
|
+
const server = http.createServer((req, res) => {
|
|
201
|
+
const url = (req.url ?? "/").split("?")[0];
|
|
202
|
+
if (url === "/events")
|
|
203
|
+
return sseHandler(req, res, eventsFile);
|
|
204
|
+
if (url === "/" || url === "/index.html")
|
|
205
|
+
return send(res, 200, "text/html; charset=utf-8", dashboardHtml());
|
|
206
|
+
if (url === "/report" || url === "/report.html")
|
|
207
|
+
return sendFile(res, path.join(aztrxDir, "report.html"), "text/html; charset=utf-8");
|
|
208
|
+
if (url.startsWith("/repro/"))
|
|
209
|
+
return sendFile(res, path.join(aztrxDir, "repro", path.basename(url)), "text/plain; charset=utf-8");
|
|
210
|
+
return send(res, 404, "text/plain; charset=utf-8", "not found");
|
|
211
|
+
});
|
|
212
|
+
server.listen(port, () => {
|
|
213
|
+
console.log(pc.cyan("\n⚡ Aztrx Studio"));
|
|
214
|
+
console.log(pc.dim(` → http://localhost:${port}`));
|
|
215
|
+
console.log(pc.dim(` watching ${path.relative(process.cwd(), eventsFile)}`));
|
|
216
|
+
console.log(pc.dim(" Ctrl+C to stop\n"));
|
|
217
|
+
});
|
|
218
|
+
return server;
|
|
219
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F11 — opt-in telemetry / data flywheel. Collects the anonymized
|
|
3
|
+
* `[crash_fingerprint, min_repro_spec, verified_patch, framework_metadata,
|
|
4
|
+
* model_tier_used]` tuple for each crash/error finding, appends it to a local
|
|
5
|
+
* JSONL dataset, and — only under `--share-data` — dispatches an envelope to the
|
|
6
|
+
* telemetry endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Privacy: everything is opt-in. `--telemetry` collects and persists locally
|
|
9
|
+
* only; `--share-data` additionally uploads. The dispatch is fire-and-forget,
|
|
10
|
+
* bounded by a 2s abort, and can never change the CLI exit code.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { detectFrameworkMeta } from "../init.js";
|
|
15
|
+
import { createSanitizer } from "./sanitize.js";
|
|
16
|
+
const DEFAULT_ENDPOINT = process.env.AZTRX_TELEMETRY_URL || "https://api.aztrx.app/api/telemetry";
|
|
17
|
+
const UPLOAD_TIMEOUT_MS = 2000;
|
|
18
|
+
/** In-flight uploads, drained by `flushTelemetry()` before the CLI exits. */
|
|
19
|
+
const pendingUploads = [];
|
|
20
|
+
function readFileIfExists(p) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readFileSync(p, "utf-8");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function buildTuples(findings, repoRoot) {
|
|
29
|
+
const sanitize = createSanitizer(repoRoot);
|
|
30
|
+
const framework_metadata = detectFrameworkMeta(repoRoot);
|
|
31
|
+
const tuples = [];
|
|
32
|
+
for (const f of findings) {
|
|
33
|
+
if (f.severity !== "crash" && f.severity !== "error")
|
|
34
|
+
continue;
|
|
35
|
+
const specRaw = f.repro?.specPath ? readFileIfExists(f.repro.specPath) : null;
|
|
36
|
+
const patchRaw = f.heal?.status === "healed" && f.heal.patchPath
|
|
37
|
+
? readFileIfExists(f.heal.patchPath)
|
|
38
|
+
: null;
|
|
39
|
+
tuples.push({
|
|
40
|
+
crash_fingerprint: f.fingerprint,
|
|
41
|
+
min_repro_spec: specRaw ? sanitize.text(specRaw) : null,
|
|
42
|
+
verified_patch: patchRaw ? sanitize.text(patchRaw) : null,
|
|
43
|
+
framework_metadata,
|
|
44
|
+
model_tier_used: f.heal?.model ?? null,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return tuples;
|
|
48
|
+
}
|
|
49
|
+
function persistDataset(repoRoot, tuples) {
|
|
50
|
+
if (tuples.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
const dir = path.join(repoRoot, ".aztrx", "telemetry");
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
const file = path.join(dir, "dataset.jsonl");
|
|
55
|
+
const lines = tuples.map((t) => JSON.stringify(t)).join("\n") + "\n";
|
|
56
|
+
fs.appendFileSync(file, lines, "utf-8");
|
|
57
|
+
return file;
|
|
58
|
+
}
|
|
59
|
+
/** Fire-and-forget upload. Never rejects; bounded by a short abort. */
|
|
60
|
+
export function dispatchTelemetry(envelope, endpoint, apiKey) {
|
|
61
|
+
const ctrl = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => ctrl.abort(), UPLOAD_TIMEOUT_MS);
|
|
63
|
+
const headers = { "content-type": "application/json" };
|
|
64
|
+
if (apiKey)
|
|
65
|
+
headers["x-api-key"] = apiKey;
|
|
66
|
+
return fetch(endpoint, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers,
|
|
69
|
+
body: JSON.stringify(envelope),
|
|
70
|
+
signal: ctrl.signal,
|
|
71
|
+
})
|
|
72
|
+
.then(() => { })
|
|
73
|
+
.catch(() => { })
|
|
74
|
+
.finally(() => clearTimeout(timer));
|
|
75
|
+
}
|
|
76
|
+
/** Collect + sanitize + persist, and (under `--share-data`) dispatch. Sync on
|
|
77
|
+
* the local path; the upload is detached so the run never waits on the network. */
|
|
78
|
+
export function submitTelemetry(findings, opts) {
|
|
79
|
+
const share = Boolean(opts.shareData);
|
|
80
|
+
if (!opts.telemetry && !share)
|
|
81
|
+
return;
|
|
82
|
+
const tuples = buildTuples(findings, opts.repoRoot);
|
|
83
|
+
if (tuples.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
persistDataset(opts.repoRoot, tuples);
|
|
86
|
+
if (share) {
|
|
87
|
+
const envelope = {
|
|
88
|
+
schema: "aztrx.telemetry/1",
|
|
89
|
+
sentAt: new Date().toISOString(),
|
|
90
|
+
tuples,
|
|
91
|
+
};
|
|
92
|
+
const apiKey = opts.apiKey ?? process.env.AZTRX_API_KEY;
|
|
93
|
+
pendingUploads.push(dispatchTelemetry(envelope, opts.endpoint ?? DEFAULT_ENDPOINT, apiKey));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Await all in-flight uploads (each already bounded). Called right before the
|
|
97
|
+
* CLI exits so a pending upload isn't killed mid-flight; never affects exit code. */
|
|
98
|
+
export async function flushTelemetry() {
|
|
99
|
+
while (pendingUploads.length) {
|
|
100
|
+
const batch = pendingUploads.splice(0);
|
|
101
|
+
await Promise.allSettled(batch);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry sanitizer — the privacy gate before any byte is packaged. A strict
|
|
3
|
+
* superset of the heal redaction layer, applied irreversibly: secrets first
|
|
4
|
+
* (the redaction map is discarded), then URLs, webpack namespaces, and
|
|
5
|
+
* repo-absolute paths. The output must be safe to leave the machine even if the
|
|
6
|
+
* user's app, routes, and file layout are proprietary.
|
|
7
|
+
*/
|
|
8
|
+
import { redact } from "../heal/redact.js";
|
|
9
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
|
|
10
|
+
function escapeRe(s) {
|
|
11
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Anonymize a single URL: strip query/fragment/userinfo; keep only a localhost
|
|
15
|
+
* authority (already anonymous) or a literal `<host>` placeholder. The route
|
|
16
|
+
* path is kept — it's structural, not identifying.
|
|
17
|
+
*/
|
|
18
|
+
export function sanitizeUrl(raw) {
|
|
19
|
+
const noQuery = raw.split(/[?#]/)[0];
|
|
20
|
+
const m = noQuery.match(/^([a-z][a-z0-9+.-]*:\/\/)([^/]*)(\/.*)?$/i);
|
|
21
|
+
if (!m)
|
|
22
|
+
return noQuery;
|
|
23
|
+
const scheme = m[1];
|
|
24
|
+
const authority = m[2];
|
|
25
|
+
const pathPart = m[3] ?? "";
|
|
26
|
+
const hostPort = authority.includes("@")
|
|
27
|
+
? authority.slice(authority.lastIndexOf("@") + 1)
|
|
28
|
+
: authority;
|
|
29
|
+
const host = hostPort.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
30
|
+
if (LOCAL_HOSTS.has(host))
|
|
31
|
+
return `${scheme}${hostPort}${pathPart}`;
|
|
32
|
+
return `${scheme}<host>${pathPart}`;
|
|
33
|
+
}
|
|
34
|
+
const URL_RE = /https?:\/\/[^\s"')\]]+/g;
|
|
35
|
+
const WEBPACK_RE = /webpack:\/\/[^/\s]+/g;
|
|
36
|
+
/** Bound to a repo root so repo-absolute paths and the repo name can be scrubbed. */
|
|
37
|
+
export function createSanitizer(repoRoot) {
|
|
38
|
+
const rootFwd = repoRoot.replace(/\\/g, "/");
|
|
39
|
+
const rootBk = repoRoot.replace(/\//g, "\\");
|
|
40
|
+
const rootFwdRe = new RegExp(escapeRe(rootFwd), "g");
|
|
41
|
+
const rootBkRe = new RegExp(escapeRe(rootBk), "g");
|
|
42
|
+
return {
|
|
43
|
+
url: sanitizeUrl,
|
|
44
|
+
text(raw) {
|
|
45
|
+
// 1. Secrets — irreversibly (the placeholder→secret map is discarded).
|
|
46
|
+
let out = redact(raw).text;
|
|
47
|
+
// 2. URLs — anonymize the authority, drop query-string secrets.
|
|
48
|
+
out = out.replace(URL_RE, (u) => sanitizeUrl(u));
|
|
49
|
+
// 3. Webpack namespaces embed the repo name.
|
|
50
|
+
out = out.replace(WEBPACK_RE, "webpack://<repo>");
|
|
51
|
+
// 4. Repo-absolute paths (both separators) → `<repo>`.
|
|
52
|
+
out = out.replace(rootFwdRe, "<repo>");
|
|
53
|
+
out = out.replace(rootBkRe, "<repo>");
|
|
54
|
+
return out;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/core/ui.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The single "crash seismograph" identity — shared by the HTML report, Local
|
|
2
|
+
// Studio, and (later) the cloud dashboard so they can't drift apart. Tokens
|
|
3
|
+
// mirror web/app/globals.css.
|
|
4
|
+
export const PALETTE = {
|
|
5
|
+
bg: "#07090d",
|
|
6
|
+
surface: "#0d1117",
|
|
7
|
+
surface2: "#12161e",
|
|
8
|
+
border: "#232a36",
|
|
9
|
+
fg: "#e9edf4",
|
|
10
|
+
muted: "#a6aebb",
|
|
11
|
+
dim: "#5b6573",
|
|
12
|
+
azure: "#4cc2ff",
|
|
13
|
+
azureBright: "#8ad9ff",
|
|
14
|
+
red: "#ff5a5f",
|
|
15
|
+
amber: "#f5a623",
|
|
16
|
+
green: "#43e58a",
|
|
17
|
+
};
|
|
18
|
+
export const SEVERITY_COLOR = {
|
|
19
|
+
crash: PALETTE.red,
|
|
20
|
+
error: PALETTE.red,
|
|
21
|
+
warning: PALETTE.amber,
|
|
22
|
+
noise: PALETTE.dim,
|
|
23
|
+
};
|
|
24
|
+
export const REPRO_COLOR = {
|
|
25
|
+
deterministic: PALETTE.green,
|
|
26
|
+
flaky: PALETTE.amber,
|
|
27
|
+
unreliable: PALETTE.red,
|
|
28
|
+
};
|
|
29
|
+
export const BASE_CSS = `
|
|
30
|
+
:root{color-scheme:dark;--bg:#07090d;--surface:#0d1117;--surface-2:#12161e;--border:#232a36;--fg:#e9edf4;--muted:#a6aebb;--dim:#5b6573;--azure:#4cc2ff;--azure-bright:#8ad9ff;--red:#ff5a5f;--amber:#f5a623;--green:#43e58a}
|
|
31
|
+
*{box-sizing:border-box}
|
|
32
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.65 ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;background-image:linear-gradient(rgba(76,194,255,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(76,194,255,.035) 1px,transparent 1px);background-size:44px 44px}
|
|
33
|
+
main{max-width:920px;margin:0 auto;padding:40px 24px 80px}
|
|
34
|
+
.hero{margin-bottom:26px}
|
|
35
|
+
.hero svg{width:100%;height:64px;display:block}
|
|
36
|
+
.brand-row{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-top:16px}
|
|
37
|
+
h1{margin:0;font-size:20px;font-weight:700;letter-spacing:.02em}
|
|
38
|
+
h1 .brand{color:var(--azure)}
|
|
39
|
+
h1 .brand-sub{color:var(--dim)}
|
|
40
|
+
.target{color:var(--dim);font-size:13px;margin-top:4px;word-break:break-all}
|
|
41
|
+
.live{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);margin-top:6px}
|
|
42
|
+
.live-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);animation:pulse 1.8s ease-in-out infinite}
|
|
43
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
44
|
+
.bar{display:flex;gap:10px;margin:0 0 24px;flex-wrap:wrap}
|
|
45
|
+
.count{font:600 13px/1 ui-monospace,monospace;border:1px solid var(--border);border-radius:8px;padding:7px 12px;color:var(--dim);background:var(--surface)}
|
|
46
|
+
.count b{color:var(--fg);font-weight:600}
|
|
47
|
+
.count b.crash{color:var(--red)}
|
|
48
|
+
.count b.error{color:var(--red)}
|
|
49
|
+
.count b.warning{color:var(--amber)}
|
|
50
|
+
.finding{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--sev,var(--azure));border-radius:10px;padding:16px 18px;margin-bottom:14px;animation:line-in .35s ease both}
|
|
51
|
+
.finding header{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
52
|
+
.sev{font:600 11px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--sev);border:1px solid var(--sev);border-radius:999px;padding:3px 9px;flex:none}
|
|
53
|
+
h2{font-size:15px;margin:0;font-weight:600;word-break:break-word}
|
|
54
|
+
.loc{color:var(--dim);font-size:12.5px;margin-top:8px}
|
|
55
|
+
.snippet{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12px/1.6 ui-monospace,monospace;color:var(--muted);margin:12px 0 0;white-space:pre}
|
|
56
|
+
.occ{color:var(--dim);font-size:12px;margin-top:8px}
|
|
57
|
+
.repro{display:inline-flex;align-items:center;gap:8px;font-size:12px;margin-top:12px;padding:4px 10px;border-radius:6px;border:1px solid}
|
|
58
|
+
.repro.deterministic{color:var(--green);border-color:rgba(67,229,138,.35);background:rgba(67,229,138,.07)}
|
|
59
|
+
.repro.flaky{color:var(--amber);border-color:rgba(245,166,35,.35);background:rgba(245,166,35,.07)}
|
|
60
|
+
.repro.unreliable{color:var(--red);border-color:rgba(255,90,95,.35);background:rgba(255,90,95,.07)}
|
|
61
|
+
details{margin-top:12px;color:var(--dim);font-size:12.5px;border-top:1px solid var(--border);padding-top:10px}
|
|
62
|
+
summary{cursor:pointer;color:var(--muted)}
|
|
63
|
+
details ol{margin:8px 0 0;padding-left:22px;display:flex;flex-direction:column;gap:4px}
|
|
64
|
+
details code{color:var(--fg)}
|
|
65
|
+
.empty{color:var(--dim);border:1px dashed var(--border);border-radius:10px;padding:24px;text-align:center}
|
|
66
|
+
.foot{color:var(--dim);font-size:12.5px;margin-top:24px}
|
|
67
|
+
a{color:var(--azure)}
|
|
68
|
+
@keyframes line-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
|
69
|
+
`;
|
|
70
|
+
/**
|
|
71
|
+
* The crash seismograph: an azure trace with one red spike per crash. The
|
|
72
|
+
* spikes live inside `<g id="spikes">` so the Studio can append them live as
|
|
73
|
+
* crashes stream in; the report pre-fills them.
|
|
74
|
+
*/
|
|
75
|
+
export function seismograph(crashes) {
|
|
76
|
+
const pts = [];
|
|
77
|
+
for (let i = 0; i <= 400; i++) {
|
|
78
|
+
const x = (i / 400) * 800;
|
|
79
|
+
const y = 32 + Math.sin(i * 0.12) * 4 + Math.sin(i * 0.045) * 3 + Math.sin(i * 0.27) * 1.2;
|
|
80
|
+
pts.push(`${x.toFixed(1)},${y.toFixed(1)}`);
|
|
81
|
+
}
|
|
82
|
+
const spikes = [];
|
|
83
|
+
const n = Math.max(0, Math.min(crashes, 8));
|
|
84
|
+
for (let k = 0; k < n; k++) {
|
|
85
|
+
const x = 80 + ((k + 0.5) / n) * 640;
|
|
86
|
+
spikes.push(`<path d="M ${x.toFixed(1)} 32 L ${x.toFixed(1)} 12" stroke="${PALETTE.red}" stroke-width="2" stroke-linecap="round" fill="none" style="filter:drop-shadow(0 0 4px ${PALETTE.red})"/>`);
|
|
87
|
+
}
|
|
88
|
+
return `<svg viewBox="0 0 800 64" preserveAspectRatio="none" role="img" aria-label="crash seismograph">
|
|
89
|
+
<line x1="0" y1="32" x2="800" y2="32" stroke="${PALETTE.azure}" stroke-opacity="0.14" stroke-width="1"/>
|
|
90
|
+
<polyline points="${pts.join(" ")}" fill="none" stroke="${PALETTE.azure}" stroke-opacity="0.5" stroke-width="1.5"/>
|
|
91
|
+
<g id="spikes">${spikes.join("")}</g>
|
|
92
|
+
</svg>`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F9 — repro validator. Replays the minimized sequence `runs` times and gates
|
|
3
|
+
* on the flake rate: 100% → deterministic, ≥60% → flaky, else unreliable. This
|
|
4
|
+
* is the difference between "we saw an error" and "we proved the bug".
|
|
5
|
+
*/
|
|
6
|
+
export async function validate(engine, url, finding, actions, runs = 3) {
|
|
7
|
+
let reproductions = 0;
|
|
8
|
+
for (let i = 0; i < runs; i++) {
|
|
9
|
+
const res = await engine.run(url, actions, finding.fingerprint);
|
|
10
|
+
if (res.reproduced)
|
|
11
|
+
reproductions += 1;
|
|
12
|
+
}
|
|
13
|
+
const rate = runs === 0 ? 0 : reproductions / runs;
|
|
14
|
+
const verdict = rate === 1 ? "deterministic" : rate >= 0.6 ? "flaky" : "unreliable";
|
|
15
|
+
return { runs, reproductions, rate, verdict };
|
|
16
|
+
}
|
package/dist/ui/app.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
3
|
+
import { render, Box, Text, useApp } from "ink";
|
|
4
|
+
// Palette — mirrors web/app/globals.css "crash seismograph" tokens, mapped to
|
|
5
|
+
// the nearest ANSI colors so the terminal panel reads as the same instrument.
|
|
6
|
+
const C = {
|
|
7
|
+
azure: "cyan",
|
|
8
|
+
azureBright: "cyanBright",
|
|
9
|
+
red: "red",
|
|
10
|
+
green: "green",
|
|
11
|
+
amber: "yellow",
|
|
12
|
+
dim: "gray",
|
|
13
|
+
muted: "white",
|
|
14
|
+
fg: "white",
|
|
15
|
+
};
|
|
16
|
+
const PHASE_LABEL = {
|
|
17
|
+
launch: { text: "◉ launching browser…", color: C.azure },
|
|
18
|
+
walk: { text: "◉ walking the DOM…", color: C.azure },
|
|
19
|
+
fuzz: { text: "◉ fuzzing (chaos)…", color: C.azure },
|
|
20
|
+
repro: { text: "◉ minimize → compile → validate…", color: C.azure },
|
|
21
|
+
heal: { text: "◉ healing (redact → generate → gate → sandbox → verify)…", color: C.azure },
|
|
22
|
+
done: { text: "✓ done", color: C.green },
|
|
23
|
+
};
|
|
24
|
+
// Actions that mutate app state — the ones that "count" toward ops/sec.
|
|
25
|
+
const EFFECTIVE = new Set(["click", "input", "select", "keypress"]);
|
|
26
|
+
function reducer(state, msg) {
|
|
27
|
+
switch (msg.type) {
|
|
28
|
+
case "phase":
|
|
29
|
+
return { ...state, phase: msg.phase, done: msg.phase === "done" };
|
|
30
|
+
case "action":
|
|
31
|
+
return {
|
|
32
|
+
...state,
|
|
33
|
+
actions: state.actions + 1,
|
|
34
|
+
clicks: state.clicks + (msg.action.type === "click" ? 1 : 0),
|
|
35
|
+
};
|
|
36
|
+
case "finding":
|
|
37
|
+
return { ...state, findings: [...state.findings, msg.finding] };
|
|
38
|
+
case "noise":
|
|
39
|
+
return { ...state, noise: state.noise + 1 };
|
|
40
|
+
case "route": {
|
|
41
|
+
const last = state.routes[state.routes.length - 1];
|
|
42
|
+
const next = last === msg.url ? state.routes : [...state.routes, msg.url];
|
|
43
|
+
return { ...state, routes: next.slice(-7), navigations: state.navigations + 1 };
|
|
44
|
+
}
|
|
45
|
+
case "repro":
|
|
46
|
+
return { ...state, repros: { ...state.repros, [msg.repro.finding.fingerprint]: msg.repro } };
|
|
47
|
+
default:
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const initialState = {
|
|
52
|
+
phase: "launch",
|
|
53
|
+
actions: 0,
|
|
54
|
+
clicks: 0,
|
|
55
|
+
findings: [],
|
|
56
|
+
repros: {},
|
|
57
|
+
noise: 0,
|
|
58
|
+
routes: [],
|
|
59
|
+
navigations: 0,
|
|
60
|
+
done: false,
|
|
61
|
+
};
|
|
62
|
+
function useAztrx(bus) {
|
|
63
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
64
|
+
const effectiveTs = useRef([]);
|
|
65
|
+
const [now, setNow] = useState(0);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const offs = [
|
|
68
|
+
bus.on("phase", (p) => dispatch({ type: "phase", phase: p.phase })),
|
|
69
|
+
bus.on("action", (a) => {
|
|
70
|
+
dispatch({ type: "action", action: a });
|
|
71
|
+
if (EFFECTIVE.has(a.type))
|
|
72
|
+
effectiveTs.current.push(a.timestamp);
|
|
73
|
+
}),
|
|
74
|
+
bus.on("finding", (f) => dispatch({ type: "finding", finding: f })),
|
|
75
|
+
bus.on("noise", () => dispatch({ type: "noise" })),
|
|
76
|
+
bus.on("route", (r) => dispatch({ type: "route", url: r.url })),
|
|
77
|
+
bus.on("repro", (r) => dispatch({ type: "repro", repro: r })),
|
|
78
|
+
];
|
|
79
|
+
return () => offs.forEach((off) => off());
|
|
80
|
+
}, [bus]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const id = setInterval(() => setNow(Date.now()), 250);
|
|
83
|
+
return () => clearInterval(id);
|
|
84
|
+
}, []);
|
|
85
|
+
const rate = useMemo(() => {
|
|
86
|
+
if (now === 0)
|
|
87
|
+
return 0;
|
|
88
|
+
const cutoff = now - 5000;
|
|
89
|
+
effectiveTs.current = effectiveTs.current.filter((t) => t >= cutoff);
|
|
90
|
+
return effectiveTs.current.length / 5;
|
|
91
|
+
}, [now]);
|
|
92
|
+
return { state, rate };
|
|
93
|
+
}
|
|
94
|
+
function firstLine(s) {
|
|
95
|
+
return s.split("\n")[0].slice(0, 68);
|
|
96
|
+
}
|
|
97
|
+
function SeverityMark({ severity }) {
|
|
98
|
+
const color = severity === "warning" ? C.amber : C.red;
|
|
99
|
+
const glyph = severity === "warning" ? "○" : "●";
|
|
100
|
+
return (_jsxs(Text, { color: color, children: [glyph, " ", severity] }));
|
|
101
|
+
}
|
|
102
|
+
function ReproBadge({ repro }) {
|
|
103
|
+
const color = repro.verdict === "deterministic" ? C.green : repro.verdict === "flaky" ? C.amber : C.red;
|
|
104
|
+
return (_jsxs(Text, { color: color, children: ["[", repro.verdict, " ", repro.reproductions, "/", repro.runs, "]"] }));
|
|
105
|
+
}
|
|
106
|
+
function FindingRow({ finding, repro }) {
|
|
107
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(SeverityMark, { severity: finding.severity }), _jsx(Text, { color: C.dim, children: " " }), _jsx(Text, { color: C.fg, children: firstLine(finding.rawMessage) })] }), finding.mappedLocation ? (_jsxs(Text, { color: C.dim, children: [" ", finding.mappedLocation.filePath, ":", finding.mappedLocation.line, ":", finding.mappedLocation.column] })) : null, repro ? (_jsxs(Box, { children: [_jsx(Text, { color: C.dim, children: " " }), _jsx(ReproBadge, { repro: repro }), _jsxs(Text, { color: C.dim, children: [" ", "spec ", repro.specPath, " \u00B7 ", repro.steps, "/", repro.totalSteps, " steps"] })] })) : null] }));
|
|
108
|
+
}
|
|
109
|
+
function AztrxApp({ bus, done, targetUrl, repoRoot, mode }) {
|
|
110
|
+
const { exit } = useApp();
|
|
111
|
+
const { state, rate } = useAztrx(bus);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
let cancelled = false;
|
|
114
|
+
const finish = () => {
|
|
115
|
+
if (!cancelled)
|
|
116
|
+
setTimeout(() => exit(), 400);
|
|
117
|
+
};
|
|
118
|
+
done.then(finish, finish);
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
};
|
|
122
|
+
}, [done, exit]);
|
|
123
|
+
const phase = PHASE_LABEL[state.phase];
|
|
124
|
+
const currentRoute = state.routes[state.routes.length - 1] ?? targetUrl;
|
|
125
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: C.azure, bold: true, children: "\u26A1 Aztrx" }), _jsx(Text, { color: C.dim, children: " \u2014 Runtime Detector" }), _jsx(Text, { color: C.dim, children: " v0.1.0" })] }), _jsxs(Text, { color: C.dim, children: [" target ", targetUrl, " repo ", repoRoot] }), _jsxs(Text, { color: C.dim, children: [" mode ", mode] }), _jsx(Text, { color: C.dim, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: phase.color, children: phase.text }), _jsx(Text, { color: C.dim, children: " " }), _jsx(Text, { color: C.azureBright, bold: true, children: rate.toFixed(1) }), _jsx(Text, { color: C.dim, children: " ops/s \u00B7 " }), _jsx(Text, { color: C.fg, children: state.actions }), _jsx(Text, { color: C.dim, children: " actions \u00B7 " }), _jsx(Text, { color: C.fg, children: state.clicks }), _jsx(Text, { color: C.dim, children: " clicks" })] }), _jsxs(Box, { children: [_jsx(Text, { color: C.dim, children: " route " }), _jsx(Text, { color: C.muted, children: currentRoute }), _jsxs(Text, { color: C.dim, children: [" \u00B7 ", state.routes.length, " route(s)"] })] }), state.findings.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: C.muted, bold: true, children: ["findings (", state.findings.length, ")"] }), state.findings.map((f) => (_jsx(FindingRow, { finding: f, repro: state.repros[f.fingerprint] }, f.fingerprint)))] })) : null, state.noise > 0 ? (_jsxs(Text, { color: C.dim, children: [" \u25B8 ", state.noise, " noise event(s) suppressed"] })) : null] }));
|
|
126
|
+
}
|
|
127
|
+
/** Mount the live terminal panel and resolve once the run (or a failure) ends. */
|
|
128
|
+
export function renderTui(props) {
|
|
129
|
+
const { waitUntilExit } = render(_jsx(AztrxApp, { ...props }));
|
|
130
|
+
return waitUntilExit();
|
|
131
|
+
}
|