@blazediff/agent 0.0.1

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/dist/cli.js ADDED
@@ -0,0 +1,3205 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ var commander = require('commander');
5
+ var playwright = require('playwright');
6
+ var child_process = require('child_process');
7
+ var fs = require('fs');
8
+ var module$1 = require('module');
9
+ var path2 = require('path');
10
+ var promises = require('fs/promises');
11
+ var os = require('os');
12
+ var crypto$1 = require('crypto');
13
+ var coreNative = require('@blazediff/core-native');
14
+ var sharp = require('sharp');
15
+ var promises$1 = require('readline/promises');
16
+ var url = require('url');
17
+ var net = require('net');
18
+ var util = require('util');
19
+ var treeKill = require('tree-kill');
20
+ var langgraph = require('@langchain/langgraph');
21
+
22
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
23
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
24
+
25
+ var path2__default = /*#__PURE__*/_interopDefault(path2);
26
+ var sharp__default = /*#__PURE__*/_interopDefault(sharp);
27
+ var treeKill__default = /*#__PURE__*/_interopDefault(treeKill);
28
+
29
+ var FROZEN_NOW = Date.UTC(2025, 0, 1, 0, 0, 0);
30
+ var STABILITY_CSS = `
31
+ *,*::before,*::after{
32
+ animation-delay:-0.0001s !important;
33
+ animation-duration:0s !important;
34
+ animation-iteration-count:1 !important;
35
+ transition-delay:0s !important;
36
+ transition-duration:0s !important;
37
+ caret-color:transparent !important;
38
+ }
39
+ html{scroll-behavior:auto !important}
40
+ `;
41
+ var CHROMIUM_FLAGS = [
42
+ "--font-render-hinting=none",
43
+ "--disable-skia-runtime-opts",
44
+ "--force-color-profile=srgb",
45
+ "--disable-lcd-text",
46
+ "--disable-background-timer-throttling",
47
+ "--disable-renderer-backgrounding",
48
+ "--disable-backgrounding-occluded-windows",
49
+ "--hide-scrollbars"
50
+ ];
51
+ var cachedBrowser = null;
52
+ var launchInFlight = null;
53
+ async function getBrowser() {
54
+ if (cachedBrowser?.isConnected()) return cachedBrowser;
55
+ if (launchInFlight) return launchInFlight;
56
+ launchInFlight = playwright.chromium.launch({ headless: true, args: CHROMIUM_FLAGS }).then((b) => {
57
+ cachedBrowser = b;
58
+ return b;
59
+ }).finally(() => {
60
+ launchInFlight = null;
61
+ });
62
+ return launchInFlight;
63
+ }
64
+ async function closeBrowser() {
65
+ if (launchInFlight) {
66
+ await launchInFlight.catch(() => {
67
+ });
68
+ }
69
+ if (!cachedBrowser) return;
70
+ await cachedBrowser.close().catch(() => {
71
+ });
72
+ cachedBrowser = null;
73
+ }
74
+ async function openStableContext(opts) {
75
+ const browser = await getBrowser();
76
+ const context = await browser.newContext({
77
+ viewport: opts.viewport,
78
+ deviceScaleFactor: 1,
79
+ reducedMotion: "reduce",
80
+ forcedColors: "none",
81
+ colorScheme: "light",
82
+ bypassCSP: true
83
+ });
84
+ await context.addInitScript(
85
+ ({ frozenNow }) => {
86
+ Object.defineProperty(Date, "now", {
87
+ value: () => frozenNow,
88
+ writable: true,
89
+ configurable: true
90
+ });
91
+ let perfTick = 0;
92
+ Object.defineProperty(performance, "now", {
93
+ value: () => {
94
+ perfTick += 16.6667;
95
+ return perfTick;
96
+ },
97
+ writable: true,
98
+ configurable: true
99
+ });
100
+ let seed = 2654435769 | 0;
101
+ Math.random = () => {
102
+ seed = seed + 1831565813 | 0;
103
+ let t = seed;
104
+ t = Math.imul(t ^ t >>> 15, t | 1);
105
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
106
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
107
+ };
108
+ if (typeof crypto !== "undefined") {
109
+ let uuidCounter = 0;
110
+ Object.defineProperty(crypto, "randomUUID", {
111
+ value: () => {
112
+ uuidCounter += 1;
113
+ return `00000000-0000-4000-8000-${uuidCounter.toString(16).padStart(12, "0")}`;
114
+ },
115
+ writable: true,
116
+ configurable: true
117
+ });
118
+ }
119
+ },
120
+ { frozenNow: FROZEN_NOW }
121
+ );
122
+ const page = await context.newPage();
123
+ const injectStability = () => page.addStyleTag({ content: STABILITY_CSS }).catch(() => {
124
+ });
125
+ await injectStability();
126
+ page.on("load", injectStability);
127
+ return { context, page };
128
+ }
129
+ async function waitForStability(page, waitFor) {
130
+ for (const w of waitFor) {
131
+ if (w === "networkidle") {
132
+ await page.waitForLoadState("networkidle").catch(() => {
133
+ });
134
+ } else if (w === "fonts") {
135
+ await page.evaluate(
136
+ () => document.fonts && "ready" in document.fonts ? document.fonts.ready.then(() => void 0) : void 0
137
+ ).catch(() => {
138
+ });
139
+ } else {
140
+ await page.waitForSelector(w.selector, { timeout: w.timeoutMs ?? 5e3 }).catch(() => {
141
+ });
142
+ }
143
+ }
144
+ }
145
+ var DEFAULT_MASK_ATTR = "data-blazediff-agent-mask";
146
+ var DEFAULT_MASK_SELECTOR = `[${DEFAULT_MASK_ATTR}]`;
147
+ async function applyMaskOverlays(page, masks) {
148
+ const selectors = [DEFAULT_MASK_SELECTOR, ...masks];
149
+ await page.evaluate((selectors2) => {
150
+ for (const sel of selectors2) {
151
+ for (const el of Array.from(
152
+ document.querySelectorAll(sel)
153
+ )) {
154
+ const rect = el.getBoundingClientRect();
155
+ const overlay = document.createElement("div");
156
+ overlay.style.position = "absolute";
157
+ overlay.style.left = `${rect.left + window.scrollX}px`;
158
+ overlay.style.top = `${rect.top + window.scrollY}px`;
159
+ overlay.style.width = `${rect.width}px`;
160
+ overlay.style.height = `${rect.height}px`;
161
+ overlay.style.background = "#ff00ff";
162
+ overlay.style.zIndex = "2147483647";
163
+ overlay.style.pointerEvents = "none";
164
+ overlay.setAttribute("data-blazediff-mask", "1");
165
+ document.body.appendChild(overlay);
166
+ }
167
+ }
168
+ }, selectors);
169
+ }
170
+ function resolvePlaywrightCli() {
171
+ const require_ = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href)));
172
+ const pkgJson = require_.resolve("playwright/package.json");
173
+ const dir = path2__default.default.dirname(pkgJson);
174
+ const candidates = [
175
+ path2__default.default.join(dir, "cli.js"),
176
+ path2__default.default.join(dir, "lib", "cli.js")
177
+ ];
178
+ const found = candidates.find((p) => fs.existsSync(p));
179
+ if (!found) {
180
+ throw new Error(`could not locate playwright CLI under ${dir}`);
181
+ }
182
+ return found;
183
+ }
184
+ async function readExecutablePath() {
185
+ try {
186
+ const require_ = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href)));
187
+ const pw = require_("playwright");
188
+ const p = pw.chromium.executablePath();
189
+ return p && fs.existsSync(p) ? p : null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+ async function installBrowsers(opts = {}) {
195
+ const cliPath = resolvePlaywrightCli();
196
+ if (opts.check) {
197
+ const executablePath2 = await readExecutablePath();
198
+ return { installed: Boolean(executablePath2), executablePath: executablePath2, cliPath };
199
+ }
200
+ await new Promise((resolve, reject) => {
201
+ const child = child_process.spawn(process.execPath, [cliPath, "install", "chromium"], {
202
+ stdio: ["ignore", "inherit", "inherit"],
203
+ env: process.env
204
+ });
205
+ child.on("exit", (code) => {
206
+ if (code === 0) resolve();
207
+ else
208
+ reject(
209
+ new Error(`playwright install chromium exited with code ${code}`)
210
+ );
211
+ });
212
+ child.on("error", reject);
213
+ });
214
+ const executablePath = await readExecutablePath();
215
+ return { installed: Boolean(executablePath), executablePath, cliPath };
216
+ }
217
+
218
+ // src/cli/commands/browsers.ts
219
+ function registerBrowsers(program, out) {
220
+ const cmd = program.command("browsers").description("manage browser binaries");
221
+ cmd.addCommand(
222
+ new commander.Command("install").description(
223
+ "install Playwright Chromium using the bundled playwright (no sudo, no --with-deps)"
224
+ ).option("--check", "only check whether chromium is already installed").action(async (opts) => {
225
+ const result = await installBrowsers({ check: Boolean(opts.check) });
226
+ const human = result.installed ? `chromium ready at ${result.executablePath}` : opts.check ? "chromium not installed (run `blazediff-agent browsers install`)" : "chromium installed";
227
+ out.emit({ ok: true, ...result }, human);
228
+ })
229
+ );
230
+ }
231
+ var DEFAULT_VIEWPORT = { width: 1280, height: 800 };
232
+ var DEFAULT_WAIT_FOR = ["networkidle", "fonts"];
233
+ var DEFAULT_FULL_PAGE = true;
234
+ var DEFAULT_THRESHOLD = 0.1;
235
+ var DEFAULT_PORT = 3e3;
236
+ var DEFAULT_READY_TIMEOUT_MS = 6e4;
237
+ var MIN_AUTO_CONCURRENCY = 2;
238
+ var MAX_AUTO_CONCURRENCY = 8;
239
+ function defaultConcurrency() {
240
+ const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
241
+ if (!cores || !Number.isFinite(cores)) return MIN_AUTO_CONCURRENCY;
242
+ return Math.max(
243
+ MIN_AUTO_CONCURRENCY,
244
+ Math.min(MAX_AUTO_CONCURRENCY, cores - 1)
245
+ );
246
+ }
247
+ var BLAZEDIFF_DIR = ".blazediff";
248
+ var paths = (cwd = process.cwd()) => {
249
+ const root = path2__default.default.join(cwd, BLAZEDIFF_DIR);
250
+ return {
251
+ root,
252
+ config: path2__default.default.join(root, "config.json"),
253
+ manifest: path2__default.default.join(root, "manifest.json"),
254
+ baselines: path2__default.default.join(root, "baselines"),
255
+ actual: path2__default.default.join(root, "actual"),
256
+ judgments: path2__default.default.join(root, "judgments"),
257
+ summary: path2__default.default.join(root, "summary.md"),
258
+ gitignore: path2__default.default.join(root, ".gitignore"),
259
+ serverLog: path2__default.default.join(root, "dev-server.log"),
260
+ serverPid: path2__default.default.join(root, "dev-server.pid")
261
+ };
262
+ };
263
+
264
+ // src/browser/capture.ts
265
+ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
266
+ const viewport = opts.viewport ?? DEFAULT_VIEWPORT;
267
+ const waitFor = opts.waitFor ?? DEFAULT_WAIT_FOR;
268
+ const masks = opts.mask ?? [];
269
+ const fullPage = opts.fullPage ?? DEFAULT_FULL_PAGE;
270
+ const { context, page } = await openStableContext({ viewport});
271
+ try {
272
+ const url = new URL(opts.url, baseUrl).toString();
273
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
274
+ await waitForStability(page, waitFor);
275
+ await applyMaskOverlays(page, masks);
276
+ await page.evaluate(
277
+ () => new Promise((r) => requestAnimationFrame(() => r()))
278
+ );
279
+ const outputDir = opts.mode === "baseline" ? paths(cwd).baselines : paths(cwd).actual;
280
+ await promises.mkdir(outputDir, { recursive: true });
281
+ const outputPath = path2__default.default.join(outputDir, `${opts.id}.png`);
282
+ const buffer = await page.screenshot({
283
+ path: outputPath,
284
+ type: "png",
285
+ fullPage,
286
+ animations: "disabled",
287
+ caret: "hide",
288
+ scale: "device"
289
+ });
290
+ return { id: opts.id, outputPath, mode: opts.mode, bytes: buffer.length };
291
+ } finally {
292
+ await context.close().catch(() => {
293
+ });
294
+ }
295
+ }
296
+ async function loadConfig(cwd = process.cwd()) {
297
+ const file = paths(cwd).config;
298
+ if (!fs.existsSync(file)) return null;
299
+ return JSON.parse(await promises.readFile(file, "utf8"));
300
+ }
301
+ async function saveConfig(config, cwd = process.cwd()) {
302
+ const file = paths(cwd).config;
303
+ await promises.mkdir(path2__default.default.dirname(file), { recursive: true });
304
+ await promises.writeFile(file, `${JSON.stringify(config, null, 2)}
305
+ `, "utf8");
306
+ }
307
+ function configHash(config) {
308
+ return `sha256:${crypto$1.createHash("sha256").update(JSON.stringify(config)).digest("hex")}`;
309
+ }
310
+ function resolveBaseUrl(config, override) {
311
+ if (override) return override;
312
+ if (config?.baseUrl) return config.baseUrl;
313
+ if (config?.devServer) return `http://127.0.0.1:${config.devServer.port}`;
314
+ throw new Error("no baseUrl: pass --base-url or run `blazediff-agent init`");
315
+ }
316
+
317
+ // src/types.ts
318
+ var STABILITY_HOOKS_VERSION = 1;
319
+
320
+ // src/manifest.ts
321
+ async function loadManifest(cwd = process.cwd()) {
322
+ const file = paths(cwd).manifest;
323
+ if (!fs.existsSync(file)) return null;
324
+ return JSON.parse(await promises.readFile(file, "utf8"));
325
+ }
326
+ async function saveManifest(manifest, cwd = process.cwd()) {
327
+ const file = paths(cwd).manifest;
328
+ await promises.mkdir(path2__default.default.dirname(file), { recursive: true });
329
+ await promises.writeFile(file, `${JSON.stringify(manifest, null, 2)}
330
+ `, "utf8");
331
+ }
332
+ function emptyManifest(configHashValue) {
333
+ return {
334
+ version: 1,
335
+ configHash: configHashValue,
336
+ stabilityHooksVersion: STABILITY_HOOKS_VERSION,
337
+ entries: []
338
+ };
339
+ }
340
+ function hashMaterial(input) {
341
+ const material = {
342
+ url: input.url,
343
+ viewport: input.viewport,
344
+ mask: [...input.mask].sort(),
345
+ waitFor: input.waitFor,
346
+ auth: input.auth,
347
+ fullPage: input.fullPage,
348
+ hooks: STABILITY_HOOKS_VERSION
349
+ };
350
+ return `sha256:${crypto$1.createHash("sha256").update(JSON.stringify(material)).digest("hex")}`;
351
+ }
352
+ function makeEntry(input) {
353
+ const viewport = input.viewport ?? DEFAULT_VIEWPORT;
354
+ const waitFor = input.waitFor ?? DEFAULT_WAIT_FOR;
355
+ const mask = input.mask ?? [];
356
+ const auth = input.auth ?? null;
357
+ const fullPage = input.fullPage ?? DEFAULT_FULL_PAGE;
358
+ return {
359
+ id: input.id,
360
+ url: input.url,
361
+ viewport,
362
+ auth,
363
+ waitFor,
364
+ mask,
365
+ fullPage,
366
+ baselinePath: path2__default.default.posix.join(".blazediff", "baselines", `${input.id}.png`),
367
+ captureHash: hashMaterial({
368
+ url: input.url,
369
+ viewport,
370
+ mask,
371
+ waitFor,
372
+ auth,
373
+ fullPage
374
+ }),
375
+ createdBy: input.createdBy ?? "agent",
376
+ createdAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
377
+ };
378
+ }
379
+ function addOrReplaceEntry(manifest, entry) {
380
+ const entries = [
381
+ ...manifest.entries.filter((e) => e.id !== entry.id),
382
+ entry
383
+ ].sort((a, b) => a.id.localeCompare(b.id));
384
+ return { ...manifest, entries };
385
+ }
386
+ function removeEntry(manifest, id) {
387
+ return { ...manifest, entries: manifest.entries.filter((e) => e.id !== id) };
388
+ }
389
+ function findEntry(manifest, id) {
390
+ return manifest.entries.find((e) => e.id === id);
391
+ }
392
+ function isEntryStale(entry) {
393
+ const recomputed = hashMaterial({
394
+ url: entry.url,
395
+ viewport: entry.viewport,
396
+ mask: entry.mask,
397
+ waitFor: entry.waitFor,
398
+ auth: entry.auth,
399
+ fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE
400
+ });
401
+ return recomputed !== entry.captureHash;
402
+ }
403
+
404
+ // src/captures.ts
405
+ function validateRoute(route, i) {
406
+ if (!route || typeof route !== "object")
407
+ return `route[${i}] must be an object`;
408
+ if (!route.id || typeof route.id !== "string")
409
+ return `route[${i}] missing id`;
410
+ if (!route.url || typeof route.url !== "string")
411
+ return `route[${i}] missing url`;
412
+ return null;
413
+ }
414
+ async function loadOrCreateManifest(cwd) {
415
+ const existing = await loadManifest(cwd);
416
+ if (existing) return existing;
417
+ const config = await loadConfig(cwd);
418
+ return emptyManifest(config ? configHash(config) : "sha256:none");
419
+ }
420
+ async function runCaptures(opts) {
421
+ const cwd = opts.cwd ?? process.cwd();
422
+ const defaultMode = opts.mode ?? "baseline";
423
+ const writeManifest = opts.writeManifest ?? true;
424
+ const results = [];
425
+ const valid = [];
426
+ opts.routes.forEach((r, i) => {
427
+ const err = validateRoute(r, i);
428
+ if (err) {
429
+ results.push({
430
+ id: r?.id ?? `route[${i}]`,
431
+ url: r?.url ?? "",
432
+ mode: r?.mode ?? defaultMode,
433
+ ok: false,
434
+ error: err
435
+ });
436
+ } else {
437
+ valid.push(r);
438
+ }
439
+ });
440
+ let manifest = writeManifest ? await loadOrCreateManifest(cwd) : null;
441
+ let manifestUpdates = 0;
442
+ try {
443
+ for (const r of valid) {
444
+ const mode = r.mode ?? defaultMode;
445
+ try {
446
+ const shot = await captureScreenshot(
447
+ opts.baseUrl,
448
+ {
449
+ id: r.id,
450
+ url: r.url,
451
+ viewport: r.viewport,
452
+ mask: r.mask,
453
+ waitFor: r.waitFor,
454
+ fullPage: r.fullPage,
455
+ mode
456
+ },
457
+ cwd
458
+ );
459
+ results.push({
460
+ id: r.id,
461
+ url: r.url,
462
+ mode,
463
+ ok: true,
464
+ outputPath: shot.outputPath,
465
+ bytes: shot.bytes
466
+ });
467
+ if (manifest && mode === "baseline") {
468
+ manifest = addOrReplaceEntry(
469
+ manifest,
470
+ makeEntry({
471
+ id: r.id,
472
+ url: r.url,
473
+ viewport: r.viewport,
474
+ mask: r.mask,
475
+ waitFor: r.waitFor,
476
+ fullPage: r.fullPage
477
+ })
478
+ );
479
+ manifestUpdates += 1;
480
+ }
481
+ } catch (err) {
482
+ results.push({
483
+ id: r.id,
484
+ url: r.url,
485
+ mode,
486
+ ok: false,
487
+ error: err.message
488
+ });
489
+ }
490
+ }
491
+ if (manifest && manifestUpdates > 0) await saveManifest(manifest, cwd);
492
+ } finally {
493
+ await closeBrowser();
494
+ }
495
+ const succeeded = results.filter((r) => r.ok).length;
496
+ return {
497
+ total: results.length,
498
+ succeeded,
499
+ failed: results.length - succeeded,
500
+ manifestUpdates,
501
+ results
502
+ };
503
+ }
504
+
505
+ // src/cli/parsers.ts
506
+ function parseViewport(value) {
507
+ const [w, h] = value.split("x").map((n) => Number(n));
508
+ if (!w || !h) throw new Error(`invalid viewport: ${value} (expected WxH)`);
509
+ return { width: w, height: h };
510
+ }
511
+ function parseMaskList(value) {
512
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
513
+ }
514
+ function parseWaitFor(value) {
515
+ if (!value) return [];
516
+ return value.split(",").map((s) => s.trim()).filter(Boolean).map(
517
+ (part) => part === "networkidle" || part === "fonts" ? part : { selector: part }
518
+ );
519
+ }
520
+ function parseRoutesPayload(raw) {
521
+ const trimmed = raw.trim();
522
+ if (!trimmed) throw new Error("routes payload is empty");
523
+ const parsed = JSON.parse(trimmed);
524
+ if (!Array.isArray(parsed))
525
+ throw new Error("routes payload must be a JSON array");
526
+ return parsed;
527
+ }
528
+ function readStdin() {
529
+ return new Promise((resolve, reject) => {
530
+ const chunks = [];
531
+ process.stdin.on("data", (c) => chunks.push(c));
532
+ process.stdin.on(
533
+ "end",
534
+ () => resolve(Buffer.concat(chunks).toString("utf8"))
535
+ );
536
+ process.stdin.on("error", reject);
537
+ });
538
+ }
539
+
540
+ // src/cli/commands/capture.ts
541
+ async function resolveRoutes(opts) {
542
+ const sources = [opts.routes, opts.stdin, opts.id || opts.url].filter(
543
+ Boolean
544
+ ).length;
545
+ if (sources === 0) {
546
+ throw new Error(
547
+ "provide one of: --routes <file>, --stdin, or --id <id> --url <url>"
548
+ );
549
+ }
550
+ if (opts.routes && opts.stdin) {
551
+ throw new Error("--routes and --stdin are mutually exclusive");
552
+ }
553
+ if (opts.routes) {
554
+ return parseRoutesPayload(
555
+ await promises.readFile(path2__default.default.resolve(opts.routes), "utf8")
556
+ );
557
+ }
558
+ if (opts.stdin) {
559
+ return parseRoutesPayload(await readStdin());
560
+ }
561
+ if (!opts.id || !opts.url) {
562
+ throw new Error(
563
+ "--id and --url must both be provided for single-route capture"
564
+ );
565
+ }
566
+ return [
567
+ {
568
+ id: opts.id,
569
+ url: opts.url,
570
+ viewport: parseViewport(opts.viewport),
571
+ mask: parseMaskList(opts.mask),
572
+ waitFor: parseWaitFor(opts.waitFor),
573
+ fullPage: opts.fullPage
574
+ }
575
+ ];
576
+ }
577
+ function registerCapture(program, out) {
578
+ program.command("capture").description(
579
+ "capture one or more deterministic screenshots. Reads routes from --routes <file>, --stdin, or --id/--url for a single route."
580
+ ).option("--routes <file>", "JSON file with an array of route entries").option("--stdin", "read JSON array of route entries from stdin").option("--id <id>", "single-route id (used with --url)").option("--url <url>", "single-route URL (used with --id)").option(
581
+ "--viewport <WxH>",
582
+ "default viewport for inline single route",
583
+ "1280x800"
584
+ ).option("--mask <selectors>", "default mask for inline single route", "").option(
585
+ "--wait-for <list>",
586
+ "default wait list for inline single route",
587
+ "networkidle,fonts"
588
+ ).option("--no-full-page", "default: viewport-only (default is full page)").option(
589
+ "--mode <baseline|actual>",
590
+ "default mode (entries can override)",
591
+ "baseline"
592
+ ).option("--base-url <url>", "override base URL").option(
593
+ "--no-manifest",
594
+ "do not write manifest entries (baseline mode only)"
595
+ ).action(async (opts) => {
596
+ const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
597
+ const routes = await resolveRoutes(opts);
598
+ const report = await runCaptures({
599
+ baseUrl,
600
+ routes,
601
+ mode: opts.mode,
602
+ writeManifest: opts.manifest
603
+ });
604
+ const human = out.isTTY() ? `captured ${report.succeeded}/${report.total} (manifest: +${report.manifestUpdates})${report.failed ? `, ${report.failed} failed` : ""}` : ".";
605
+ out.emit(report, human);
606
+ if (report.failed > 0) process.exitCode = 1;
607
+ });
608
+ }
609
+ var ENTRIES = [
610
+ "actual/",
611
+ "judgments/",
612
+ "summary.md",
613
+ "dev-server.log",
614
+ "dev-server.pid",
615
+ "*.tmp"
616
+ ];
617
+ var STALE_ENTRIES = /* @__PURE__ */ new Set(["diffs/", "pending-judgments/", "report.json"]);
618
+ var HEADER = "# blazediff: generated artifacts (committed: config.json, manifest.json, baselines/)\n";
619
+ async function ensureGitignore(cwd) {
620
+ const file = paths(cwd).gitignore;
621
+ await promises.mkdir(path2__default.default.dirname(file), { recursive: true });
622
+ const existing = fs.existsSync(file) ? await promises.readFile(file, "utf8") : "";
623
+ const lines = existing.split("\n").map((l) => l.trim());
624
+ const hasStale = lines.some((l) => STALE_ENTRIES.has(l));
625
+ const missing = ENTRIES.filter((e) => !lines.includes(e));
626
+ if (!missing.length && !hasStale && existing) return;
627
+ if (hasStale) {
628
+ const kept = lines.filter(
629
+ (l) => !STALE_ENTRIES.has(l) && !ENTRIES.includes(l) && l !== ""
630
+ );
631
+ const body2 = `${kept.length ? `${kept.join("\n")}
632
+ ` : HEADER}${ENTRIES.join("\n")}
633
+ `;
634
+ await promises.writeFile(file, body2, "utf8");
635
+ return;
636
+ }
637
+ const body = existing ? `${existing.replace(/\n+$/, "")}
638
+ ${missing.join("\n")}
639
+ ` : `${HEADER}${ENTRIES.join("\n")}
640
+ `;
641
+ await promises.writeFile(file, body, "utf8");
642
+ }
643
+ async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.cwd()) {
644
+ if (!fs.existsSync(baselinePath) || !fs.existsSync(actualPath)) {
645
+ return {
646
+ id,
647
+ baselinePath,
648
+ actualPath,
649
+ match: false,
650
+ reason: "file-not-exists"
651
+ };
652
+ }
653
+ let diffPath;
654
+ if (opts.emitDiffPng) {
655
+ const actualDir = paths(cwd).actual;
656
+ await promises.mkdir(actualDir, { recursive: true });
657
+ diffPath = path2__default.default.join(actualDir, `${id}.diff.png`);
658
+ }
659
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
660
+ const antialiasing = opts.antialiasing ?? true;
661
+ const result = await coreNative.compare(baselinePath, actualPath, diffPath, {
662
+ threshold,
663
+ antialiasing
664
+ });
665
+ if (result.match) return { id, baselinePath, actualPath, match: true };
666
+ if (result.reason === "file-not-exists") {
667
+ return {
668
+ id,
669
+ baselinePath,
670
+ actualPath,
671
+ match: false,
672
+ reason: "file-not-exists"
673
+ };
674
+ }
675
+ if (result.reason === "layout-diff") {
676
+ return {
677
+ id,
678
+ baselinePath,
679
+ actualPath,
680
+ diffPath,
681
+ match: false,
682
+ reason: "layout-diff"
683
+ };
684
+ }
685
+ const interpretation = await coreNative.interpret(baselinePath, actualPath, {
686
+ threshold,
687
+ antialiasing
688
+ }).catch(() => void 0);
689
+ return {
690
+ id,
691
+ baselinePath,
692
+ actualPath,
693
+ diffPath,
694
+ match: false,
695
+ reason: "pixel-diff",
696
+ diffCount: result.diffCount,
697
+ diffPercentage: result.diffPercentage,
698
+ interpretation
699
+ };
700
+ }
701
+
702
+ // src/diff/verdict.ts
703
+ var REGRESSIVE_TYPES = /* @__PURE__ */ new Set(["content-change", "addition", "deletion"]);
704
+ var INTENTIONAL_TYPES = /* @__PURE__ */ new Set(["color-change", "shift"]);
705
+ var NOISE_TYPES = /* @__PURE__ */ new Set(["rendering-noise"]);
706
+ var ELEVATED_SEVERITY = /* @__PURE__ */ new Set(["medium", "high"]);
707
+ var SUB_PERCEPTUAL_PCT = 0.01;
708
+ function pctText(pct) {
709
+ if (typeof pct !== "number") return "?%";
710
+ return pct >= 0.01 ? `${pct.toFixed(2)}%` : `${pct.toFixed(3)}%`;
711
+ }
712
+ function countByType(regions) {
713
+ const counts = /* @__PURE__ */ new Map();
714
+ for (const r of regions)
715
+ counts.set(r.changeType, (counts.get(r.changeType) ?? 0) + 1);
716
+ return counts;
717
+ }
718
+ function dominantType(counts) {
719
+ let best = "";
720
+ let bestN = 0;
721
+ for (const [type, n] of counts) {
722
+ if (n > bestN) {
723
+ best = type;
724
+ bestN = n;
725
+ }
726
+ }
727
+ return best;
728
+ }
729
+ function topPosition(regions) {
730
+ let best;
731
+ for (const r of regions)
732
+ if (!best || r.pixelCount > best.pixelCount) best = r;
733
+ return best?.position;
734
+ }
735
+ function meanConfidence(regions) {
736
+ if (regions.length === 0) return 0;
737
+ return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
738
+ }
739
+ function formatBreakdown(counts) {
740
+ return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
741
+ }
742
+ function buildHeadline(input) {
743
+ const { reason, interpretation, diffCount, diffPercentage } = input;
744
+ if (reason === "layout-diff") return "image dimensions changed";
745
+ if (reason === "file-not-exists") return "baseline or actual capture missing";
746
+ if (!interpretation || interpretation.regions.length === 0) {
747
+ const px = diffCount?.toLocaleString() ?? "?";
748
+ return `${px} px (${pctText(diffPercentage)}) - no region analysis`;
749
+ }
750
+ const regions = interpretation.regions;
751
+ const counts = countByType(regions);
752
+ const pos = topPosition(regions);
753
+ const pct = pctText(diffPercentage ?? interpretation.diffPercentage);
754
+ const sev = interpretation.severity ?? "?";
755
+ if (regions.length === 1) {
756
+ return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
757
+ }
758
+ return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
759
+ }
760
+ function deriveVerdict(input) {
761
+ const { reason, interpretation, diffPercentage } = input;
762
+ const headline = buildHeadline(input);
763
+ if (reason === "layout-diff") {
764
+ return {
765
+ label: "ambiguous",
766
+ headline,
767
+ rationale: [
768
+ "baseline and actual image dimensions differ \u2014 page height likely shifted",
769
+ "could be intentional (content added/removed) or regression (broken layout)"
770
+ ],
771
+ action: "investigate"
772
+ };
773
+ }
774
+ if (reason === "file-not-exists") {
775
+ return {
776
+ label: "regression-likely",
777
+ headline,
778
+ rationale: ["baseline or actual capture is missing from disk"],
779
+ action: "investigate"
780
+ };
781
+ }
782
+ if (!interpretation || interpretation.regions.length === 0) {
783
+ return {
784
+ label: "ambiguous",
785
+ headline,
786
+ rationale: ["pixels differ but interpret returned no regions"],
787
+ action: "investigate"
788
+ };
789
+ }
790
+ const regions = interpretation.regions;
791
+ const severity = interpretation.severity;
792
+ const counts = countByType(regions);
793
+ const allNoise = regions.every((r) => NOISE_TYPES.has(r.changeType));
794
+ const allColor = regions.every((r) => r.changeType === "color-change");
795
+ const allMoved = regions.every((r) => INTENTIONAL_TYPES.has(r.changeType));
796
+ const hasRegressive = regions.some((r) => REGRESSIVE_TYPES.has(r.changeType));
797
+ const pct = typeof diffPercentage === "number" ? diffPercentage : interpretation.diffPercentage;
798
+ if (allNoise) {
799
+ return {
800
+ label: "noise-likely",
801
+ headline,
802
+ rationale: ["all regions classified as rendering-noise"],
803
+ action: "ignore-or-rewrite"
804
+ };
805
+ }
806
+ if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
807
+ return {
808
+ label: "noise-likely",
809
+ headline,
810
+ rationale: [
811
+ `delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
812
+ "sub-perceptual change - review optional"
813
+ ],
814
+ action: "ignore-or-rewrite"
815
+ };
816
+ }
817
+ if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
818
+ const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
819
+ return {
820
+ label: "regression-likely",
821
+ headline,
822
+ rationale: [
823
+ `severity ${severity} with structural changes (${types})`,
824
+ "likely affects content or layout, not just styling"
825
+ ],
826
+ action: "investigate"
827
+ };
828
+ }
829
+ if (allColor && meanConfidence(regions) > 0.7) {
830
+ return {
831
+ label: "intentional-likely",
832
+ headline,
833
+ rationale: [
834
+ `${regions.length} color-change region${regions.length === 1 ? "" : "s"} with mean confidence > 0.7`,
835
+ "edge structure preserved - looks like a theming / palette change"
836
+ ],
837
+ action: "rewrite-if-intended"
838
+ };
839
+ }
840
+ if (allMoved && !allColor) {
841
+ return {
842
+ label: "intentional-likely",
843
+ headline,
844
+ rationale: [
845
+ "all regions are shift/color-change - content moved or restyled, structure preserved"
846
+ ],
847
+ action: "rewrite-if-intended"
848
+ };
849
+ }
850
+ return {
851
+ label: "ambiguous",
852
+ headline,
853
+ rationale: [
854
+ `mix of change types (${formatBreakdown(counts)}) at "${severity ?? "?"}" severity`,
855
+ `${pctText(pct)} of image differs`
856
+ ],
857
+ action: "investigate"
858
+ };
859
+ }
860
+ var DEFAULT_TOP_N = 5;
861
+ var DEFAULT_PADDING = 16;
862
+ var DEFAULT_LOCATOR_MAX_WIDTH = 400;
863
+ var DEFAULT_GUTTER = 2;
864
+ var DEFAULT_ROW_GUTTER = 8;
865
+ var BG_WHITE = { r: 255, g: 255, b: 255 };
866
+ function padAndClamp(bbox, padding, imgWidth, imgHeight) {
867
+ const left = Math.max(0, Math.floor(bbox.x - padding));
868
+ const top = Math.max(0, Math.floor(bbox.y - padding));
869
+ const right = Math.min(imgWidth, Math.ceil(bbox.x + bbox.width + padding));
870
+ const bottom = Math.min(imgHeight, Math.ceil(bbox.y + bbox.height + padding));
871
+ return {
872
+ x: left,
873
+ y: top,
874
+ width: Math.max(1, right - left),
875
+ height: Math.max(1, bottom - top)
876
+ };
877
+ }
878
+ async function prepareTiles(opts) {
879
+ const topN = opts.topN ?? DEFAULT_TOP_N;
880
+ const padding = opts.padding ?? DEFAULT_PADDING;
881
+ const locatorMaxWidth = opts.locatorMaxWidth ?? DEFAULT_LOCATOR_MAX_WIDTH;
882
+ const gutter = opts.gutter ?? DEFAULT_GUTTER;
883
+ const rowGutter = opts.rowGutter ?? DEFAULT_ROW_GUTTER;
884
+ const diffMeta = await sharp__default.default(opts.diffPath).metadata();
885
+ const imgWidth = diffMeta.width ?? 0;
886
+ const imgHeight = diffMeta.height ?? 0;
887
+ if (!imgWidth || !imgHeight) {
888
+ throw new Error(`unable to read diff image dimensions: ${opts.diffPath}`);
889
+ }
890
+ const ranked = [...opts.regions].sort((a, b) => b.pixelCount - a.pixelCount).slice(0, topN);
891
+ const regionData = await Promise.all(
892
+ ranked.map(async (region) => {
893
+ const padded = padAndClamp(region.bbox, padding, imgWidth, imgHeight);
894
+ const extract = {
895
+ left: padded.x,
896
+ top: padded.y,
897
+ width: padded.width,
898
+ height: padded.height
899
+ };
900
+ const [base, actual] = await Promise.all([
901
+ sharp__default.default(opts.baselinePath).extract(extract).toBuffer(),
902
+ sharp__default.default(opts.actualPath).extract(extract).toBuffer()
903
+ ]);
904
+ return { region, padded, base, actual };
905
+ })
906
+ );
907
+ let tilesName;
908
+ if (regionData.length > 0) {
909
+ const canvasWidth = Math.max(
910
+ ...regionData.map((r) => r.padded.width * 2 + gutter)
911
+ );
912
+ const totalHeight = regionData.reduce(
913
+ (sum, r, i) => sum + r.padded.height + (i > 0 ? rowGutter : 0),
914
+ 0
915
+ );
916
+ const composites = [];
917
+ let y = 0;
918
+ for (let i = 0; i < regionData.length; i++) {
919
+ const r = regionData[i];
920
+ const w = r.padded.width;
921
+ composites.push(
922
+ { input: r.base, left: 0, top: y },
923
+ { input: r.actual, left: w + gutter, top: y }
924
+ );
925
+ y += r.padded.height;
926
+ if (i < regionData.length - 1) y += rowGutter;
927
+ }
928
+ tilesName = "regions.png";
929
+ await sharp__default.default({
930
+ create: {
931
+ width: canvasWidth,
932
+ height: totalHeight,
933
+ channels: 3,
934
+ background: BG_WHITE
935
+ }
936
+ }).composite(composites).png().toFile(path2__default.default.join(opts.outputDir, tilesName));
937
+ }
938
+ const scale = locatorMaxWidth / Math.max(imgWidth, imgHeight);
939
+ const locW = Math.max(1, Math.round(imgWidth * scale));
940
+ const locH = Math.max(1, Math.round(imgHeight * scale));
941
+ const rects = opts.regions.map((r) => {
942
+ const x = Math.round(r.bbox.x * scale);
943
+ const y = Math.round(r.bbox.y * scale);
944
+ const w = Math.max(1, Math.round(r.bbox.width * scale));
945
+ const h = Math.max(1, Math.round(r.bbox.height * scale));
946
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="red" stroke-width="2" />`;
947
+ }).join("");
948
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${locW}" height="${locH}">${rects}</svg>`;
949
+ const locatorName = "locator.png";
950
+ await sharp__default.default(opts.diffPath).resize(locW, locH, { fit: "fill" }).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toFile(path2__default.default.join(opts.outputDir, locatorName));
951
+ return {
952
+ locatorPath: locatorName,
953
+ tilesPath: tilesName,
954
+ regions: regionData.map((r) => ({
955
+ bbox: r.region.bbox,
956
+ pixelCount: r.region.pixelCount
957
+ }))
958
+ };
959
+ }
960
+
961
+ // src/judge/host-harness.ts
962
+ async function tryPrepareTiles(input, entryDir) {
963
+ if (!input.regions || input.regions.length === 0 || !input.diffPath) {
964
+ return null;
965
+ }
966
+ try {
967
+ return await prepareTiles({
968
+ regions: input.regions,
969
+ baselinePath: input.baselinePath,
970
+ actualPath: input.actualPath,
971
+ diffPath: input.diffPath,
972
+ outputDir: entryDir
973
+ });
974
+ } catch (err) {
975
+ const message = err instanceof Error ? err.message : String(err);
976
+ console.warn(
977
+ `[blazediff] tile generation failed for ${input.entry.id}: ${message}`
978
+ );
979
+ return null;
980
+ }
981
+ }
982
+ var hostHarnessJudge = {
983
+ name: "host",
984
+ async judge(input, cwd) {
985
+ const p = paths(cwd);
986
+ const entryDir = path2__default.default.join(p.judgments, input.entry.id);
987
+ await promises.mkdir(entryDir, { recursive: true });
988
+ await tryPrepareTiles(input, entryDir);
989
+ return { kind: "deferred", requestPath: entryDir };
990
+ }
991
+ };
992
+
993
+ // src/judge/none.ts
994
+ var noneJudge = {
995
+ name: "none",
996
+ async judge(input) {
997
+ return { kind: "judged", verdict: input.heuristicVerdict };
998
+ }
999
+ };
1000
+ var PREVIEW_WIDTH = 320;
1001
+ function escapeCell(s) {
1002
+ return s.replace(/\n/g, " ");
1003
+ }
1004
+ function toBlazediffRel(cwd, abs) {
1005
+ if (!abs) return void 0;
1006
+ const root = paths(cwd).root;
1007
+ const rel = path2__default.default.isAbsolute(abs) ? path2__default.default.relative(root, abs) : path2__default.default.relative(paths(cwd).root, path2__default.default.join(cwd, abs));
1008
+ return rel.split(path2__default.default.sep).join("/");
1009
+ }
1010
+ function img(src, alt) {
1011
+ return `<img src="${src}" width="${PREVIEW_WIDTH}" alt="${alt}">`;
1012
+ }
1013
+ function baselineCell(r, cwd) {
1014
+ const rel = toBlazediffRel(cwd, r.baselinePath) ?? `baselines/${r.id}.png`;
1015
+ return img(rel, `${r.id} baseline`);
1016
+ }
1017
+ function actualCell(r, cwd) {
1018
+ const actual = toBlazediffRel(cwd, r.actualPath);
1019
+ return actual ? img(actual, `${r.id} actual`) : "-";
1020
+ }
1021
+ function diffCell(r, cwd) {
1022
+ const diff = toBlazediffRel(cwd, r.diffPath);
1023
+ return diff ? img(diff, `${r.id} diff`) : "-";
1024
+ }
1025
+ function verdictCell(r) {
1026
+ if (r.status === "missing-baseline") {
1027
+ return `missing-baseline - ${r.message ?? "baseline missing"}`;
1028
+ }
1029
+ if (r.status === "stale-baseline") {
1030
+ return `stale-baseline - ${r.message ?? "manifest entry edited without re-capturing"}`;
1031
+ }
1032
+ if (r.status === "needs-judgment") {
1033
+ return `needs-judgment - see judgments/${r.id}/`;
1034
+ }
1035
+ if (!r.verdict) {
1036
+ return r.message ?? r.status;
1037
+ }
1038
+ return `${r.verdict.label} - ${r.verdict.headline} -> ${r.verdict.action}`;
1039
+ }
1040
+ function renderRow(r, cwd) {
1041
+ return `| ${r.id} | ${escapeCell(baselineCell(r, cwd))} | ${escapeCell(actualCell(r, cwd))} | ${escapeCell(diffCell(r, cwd))} | ${escapeCell(verdictCell(r))} |`;
1042
+ }
1043
+ function headerLine(report) {
1044
+ const { passed, failed, pendingJudgments, totalEntries } = report;
1045
+ const parts = [`${passed}/${totalEntries} passed`];
1046
+ if (failed > 0) parts.push(`${failed} failed`);
1047
+ if (pendingJudgments > 0) parts.push(`${pendingJudgments} pending judgment`);
1048
+ return parts.length > 1 ? `${parts[0]} (${parts.slice(1).join(", ")})` : parts[0];
1049
+ }
1050
+ function renderSummary(report, cwd = process.cwd()) {
1051
+ const nonPass = report.results.filter((r) => r.status !== "pass");
1052
+ const lines = [
1053
+ `# blazediff check - ${report.createdAt}`,
1054
+ "",
1055
+ headerLine(report),
1056
+ ""
1057
+ ];
1058
+ if (nonPass.length === 0) {
1059
+ lines.push("All entries passed.");
1060
+ return `${lines.join("\n")}
1061
+ `;
1062
+ }
1063
+ lines.push("| id | baseline | actual | diff | verdict |");
1064
+ lines.push("| --- | --- | --- | --- | --- |");
1065
+ for (const r of nonPass) lines.push(renderRow(r, cwd));
1066
+ return `${lines.join("\n")}
1067
+ `;
1068
+ }
1069
+ async function writeSummaryMarkdown(report, cwd = process.cwd()) {
1070
+ const file = paths(cwd).summary;
1071
+ await promises.mkdir(path2__default.default.dirname(file), { recursive: true });
1072
+ await promises.writeFile(file, renderSummary(report, cwd), "utf8");
1073
+ return file;
1074
+ }
1075
+
1076
+ // src/judge/apply.ts
1077
+ var VALID_LABELS = [
1078
+ "regression-likely",
1079
+ "intentional-likely",
1080
+ "noise-likely",
1081
+ "ambiguous"
1082
+ ];
1083
+ var VALID_ACTIONS = [
1084
+ "investigate",
1085
+ "rewrite-if-intended",
1086
+ "ignore-or-rewrite"
1087
+ ];
1088
+ function parseVerdict(raw) {
1089
+ if (!raw || typeof raw !== "object") return null;
1090
+ const r = raw;
1091
+ if (typeof r.id !== "string") return null;
1092
+ const v = r.verdict;
1093
+ if (!v || typeof v !== "object") return null;
1094
+ if (typeof v.label !== "string" || !VALID_LABELS.includes(v.label))
1095
+ return null;
1096
+ if (typeof v.headline !== "string") return null;
1097
+ if (typeof v.action !== "string" || !VALID_ACTIONS.includes(v.action))
1098
+ return null;
1099
+ const rationale = Array.isArray(v.rationale) ? v.rationale.filter((x) => typeof x === "string") : [];
1100
+ return {
1101
+ id: r.id,
1102
+ verdict: {
1103
+ label: v.label,
1104
+ headline: v.headline,
1105
+ rationale,
1106
+ action: v.action
1107
+ },
1108
+ rationale: typeof r.rationale === "string" ? r.rationale : void 0,
1109
+ confidence: typeof r.confidence === "number" ? r.confidence : void 0
1110
+ };
1111
+ }
1112
+ async function readJsonOrNull(file) {
1113
+ try {
1114
+ return JSON.parse(await promises.readFile(file, "utf8"));
1115
+ } catch {
1116
+ return null;
1117
+ }
1118
+ }
1119
+ async function readJudgmentDirs(root) {
1120
+ let names;
1121
+ try {
1122
+ names = await promises.readdir(root);
1123
+ } catch {
1124
+ return [];
1125
+ }
1126
+ const out = [];
1127
+ for (const name of names) {
1128
+ const dir = path2__default.default.join(root, name);
1129
+ let isDir = false;
1130
+ try {
1131
+ isDir = (await promises.stat(dir)).isDirectory();
1132
+ } catch {
1133
+ isDir = false;
1134
+ }
1135
+ if (!isDir) continue;
1136
+ const request = await readJsonOrNull(
1137
+ path2__default.default.join(dir, "request.json")
1138
+ );
1139
+ const verdictFile = path2__default.default.join(dir, "verdict.json");
1140
+ let verdict = null;
1141
+ let verdictInvalid = false;
1142
+ if (fs.existsSync(verdictFile)) {
1143
+ const raw = await readJsonOrNull(verdictFile);
1144
+ verdict = raw ? parseVerdict(raw) : null;
1145
+ if (raw && !verdict) verdictInvalid = true;
1146
+ }
1147
+ out.push({ id: name, request, verdict, verdictInvalid });
1148
+ }
1149
+ return out;
1150
+ }
1151
+ function toAbs(cwd, rel) {
1152
+ if (!rel) return void 0;
1153
+ return path2__default.default.isAbsolute(rel) ? rel : path2__default.default.join(cwd, rel);
1154
+ }
1155
+ function buildResult(cwd, dir, entry) {
1156
+ const req = dir.request;
1157
+ const finalVerdict = dir.verdict?.verdict ?? req?.heuristicVerdict;
1158
+ const status = req ? dir.verdict ? "fail" : req.status : "fail";
1159
+ const message = dir.verdict?.rationale ?? (dir.verdict?.confidence !== void 0 ? `judged (confidence ${dir.verdict.confidence.toFixed(2)})` : req?.message);
1160
+ return {
1161
+ id: dir.id,
1162
+ url: req?.url ?? entry?.url ?? "",
1163
+ status,
1164
+ diffPercentage: req?.diffPercentage,
1165
+ severity: req?.severity,
1166
+ regions: req?.regions,
1167
+ verdict: finalVerdict,
1168
+ diffPath: toAbs(cwd, req?.paths.diff),
1169
+ actualPath: toAbs(cwd, req?.paths.actual),
1170
+ baselinePath: toAbs(cwd, req?.paths.baseline),
1171
+ message
1172
+ };
1173
+ }
1174
+ function passResult(entry, cwd) {
1175
+ const baselineAbs = path2__default.default.join(paths(cwd).baselines, `${entry.id}.png`);
1176
+ const actualAbs = path2__default.default.join(paths(cwd).actual, `${entry.id}.png`);
1177
+ return {
1178
+ id: entry.id,
1179
+ url: entry.url,
1180
+ status: "pass",
1181
+ baselinePath: baselineAbs,
1182
+ actualPath: fs.existsSync(actualAbs) ? actualAbs : void 0
1183
+ };
1184
+ }
1185
+ async function applyJudgments(cwd = process.cwd()) {
1186
+ const p = paths(cwd);
1187
+ const manifest = await loadManifest(cwd);
1188
+ if (!manifest) {
1189
+ throw new Error(
1190
+ `no manifest at ${p.manifest}. Run \`blazediff-agent init\` first.`
1191
+ );
1192
+ }
1193
+ const dirs = await readJudgmentDirs(p.judgments);
1194
+ const dirById = new Map(dirs.map((d) => [d.id, d]));
1195
+ const applied = [];
1196
+ const missing = [];
1197
+ const invalid = [];
1198
+ for (const d of dirs) {
1199
+ if (d.verdictInvalid) invalid.push(path2__default.default.join(p.judgments, d.id));
1200
+ else if (d.verdict) applied.push(d.id);
1201
+ else missing.push(d.id);
1202
+ }
1203
+ const nonPassResults = [];
1204
+ for (const d of dirs) {
1205
+ const entry = manifest.entries.find((e) => e.id === d.id);
1206
+ nonPassResults.push(buildResult(cwd, d, entry));
1207
+ }
1208
+ const passResults = [];
1209
+ for (const entry of manifest.entries) {
1210
+ if (dirById.has(entry.id)) continue;
1211
+ passResults.push(passResult(entry, cwd));
1212
+ }
1213
+ const results = [...passResults, ...nonPassResults];
1214
+ const passed = results.filter((r) => r.status === "pass").length;
1215
+ const pendingJudgments = results.filter(
1216
+ (r) => r.status === "needs-judgment"
1217
+ ).length;
1218
+ const report = {
1219
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1220
+ totalEntries: results.length,
1221
+ passed,
1222
+ failed: results.length - passed - pendingJudgments,
1223
+ pendingJudgments,
1224
+ results
1225
+ };
1226
+ await writeSummaryMarkdown(report, cwd);
1227
+ return { report, applied, missing, invalid };
1228
+ }
1229
+ var HOST_INSTRUCTIONS = [
1230
+ "The visual-regression heuristic could not classify this diff confidently.",
1231
+ "Read `locator.png` AND `regions.png` in parallel - issue both Read calls in a single tool batch. locator.png is a small thumbnail of the diff with every change region outlined in red; regions.png is a vertical stack of [baseline | actual] pairs, one row per change region at native resolution. Row order matches the `regions[]` array (top = largest by pixelCount).",
1232
+ "Decide from the tile pairs. Only open the full diff / baseline / actual PNGs if the composite is itself ambiguous (e.g., a change clearly continues outside the cropped region).",
1233
+ "Decide whether the change is a regression, an intentional UI change, or rendering noise.",
1234
+ "Write your decision to `verdict.json` (next to this `request.json`) with shape:",
1235
+ ' { "id": string, "verdict": { "label": "regression-likely" | "intentional-likely" | "noise-likely", "headline": string, "rationale": string[], "action": "investigate" | "rewrite-if-intended" | "ignore-or-rewrite" }, "rationale": string, "confidence": number }',
1236
+ "Then re-run `blazediff-agent check --apply-judgments --json` to regenerate summary.md."
1237
+ ].join("\n");
1238
+ function relTo(cwd, abs) {
1239
+ if (!abs) return void 0;
1240
+ return path2__default.default.relative(cwd, abs).split(path2__default.default.sep).join("/");
1241
+ }
1242
+ function signatureOf(r) {
1243
+ const pct = typeof r.diffPercentage === "number" ? r.diffPercentage.toFixed(4) : "?";
1244
+ const regions = r.regions?.length ?? 0;
1245
+ const severity = r.severity ?? "?";
1246
+ return `${r.status}|diff:${pct}|regions:${regions}|severity:${severity}`;
1247
+ }
1248
+ async function readJsonOrNull2(file) {
1249
+ try {
1250
+ return JSON.parse(await promises.readFile(file, "utf8"));
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ }
1255
+ function entryById(manifest, id) {
1256
+ return manifest.entries.find((e) => e.id === id);
1257
+ }
1258
+ function buildRequest(result, entry, cwd, tiles) {
1259
+ const isAmbiguous = result.status === "needs-judgment" || result.verdict?.label === "ambiguous" && result.status === "fail";
1260
+ return {
1261
+ id: result.id,
1262
+ url: result.url,
1263
+ status: result.status,
1264
+ diffPercentage: result.diffPercentage,
1265
+ severity: result.severity,
1266
+ regions: result.regions,
1267
+ paths: {
1268
+ baseline: relTo(cwd, result.baselinePath),
1269
+ actual: relTo(cwd, result.actualPath),
1270
+ diff: relTo(cwd, result.diffPath),
1271
+ locator: tiles.locatorPath,
1272
+ tiles: tiles.tilesPath
1273
+ },
1274
+ heuristicVerdict: result.verdict,
1275
+ manifestEntry: {
1276
+ viewport: entry.viewport,
1277
+ mask: entry.mask,
1278
+ waitFor: entry.waitFor,
1279
+ fullPage: entry.fullPage
1280
+ },
1281
+ signature: signatureOf(result),
1282
+ message: result.message,
1283
+ instructions: isAmbiguous ? HOST_INSTRUCTIONS : void 0,
1284
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1285
+ };
1286
+ }
1287
+ function autoVerdict(result) {
1288
+ if (!result.verdict) return null;
1289
+ if (result.status === "needs-judgment") return null;
1290
+ if (result.verdict.label === "ambiguous") return null;
1291
+ return {
1292
+ id: result.id,
1293
+ verdict: result.verdict,
1294
+ rationale: result.verdict.rationale.join(" "),
1295
+ confidence: 1
1296
+ };
1297
+ }
1298
+ async function discoverTiles(dir) {
1299
+ const locatorAbs = path2__default.default.join(dir, "locator.png");
1300
+ const tilesAbs = path2__default.default.join(dir, "regions.png");
1301
+ return {
1302
+ locatorPath: fs.existsSync(locatorAbs) ? "locator.png" : void 0,
1303
+ tilesPath: fs.existsSync(tilesAbs) ? "regions.png" : void 0
1304
+ };
1305
+ }
1306
+ async function writeJudgments(opts) {
1307
+ const cwd = opts.cwd ?? process.cwd();
1308
+ const root = paths(cwd).judgments;
1309
+ await promises.mkdir(root, { recursive: true });
1310
+ const knownIds = /* @__PURE__ */ new Set();
1311
+ for (const r of opts.report.results) knownIds.add(r.id);
1312
+ for (const result of opts.report.results) {
1313
+ const dir = path2__default.default.join(root, result.id);
1314
+ if (result.status === "pass") {
1315
+ if (fs.existsSync(dir)) await promises.rm(dir, { recursive: true, force: true });
1316
+ continue;
1317
+ }
1318
+ const entry = entryById(opts.manifest, result.id);
1319
+ if (!entry) continue;
1320
+ await promises.mkdir(dir, { recursive: true });
1321
+ const tiles = await discoverTiles(dir);
1322
+ const request = buildRequest(result, entry, cwd, tiles);
1323
+ const requestFile = path2__default.default.join(dir, "request.json");
1324
+ const prior = await readJsonOrNull2(requestFile);
1325
+ const verdictFile = path2__default.default.join(dir, "verdict.json");
1326
+ const priorVerdict = fs.existsSync(verdictFile) ? await readJsonOrNull2(verdictFile) : null;
1327
+ const signatureMatches = prior !== null && prior.signature === request.signature;
1328
+ await promises.writeFile(
1329
+ requestFile,
1330
+ `${JSON.stringify(request, null, 2)}
1331
+ `,
1332
+ "utf8"
1333
+ );
1334
+ if (priorVerdict && signatureMatches) {
1335
+ continue;
1336
+ }
1337
+ const auto = autoVerdict(result);
1338
+ if (auto) {
1339
+ await promises.writeFile(
1340
+ verdictFile,
1341
+ `${JSON.stringify(auto, null, 2)}
1342
+ `,
1343
+ "utf8"
1344
+ );
1345
+ } else if (priorVerdict && !signatureMatches) {
1346
+ await promises.rm(verdictFile, { force: true });
1347
+ }
1348
+ }
1349
+ let entries;
1350
+ try {
1351
+ entries = await promises.readdir(root);
1352
+ } catch {
1353
+ return;
1354
+ }
1355
+ for (const name of entries) {
1356
+ if (knownIds.has(name)) continue;
1357
+ await promises.rm(path2__default.default.join(root, name), { recursive: true, force: true });
1358
+ }
1359
+ }
1360
+
1361
+ // src/judge/index.ts
1362
+ function resolveJudge(backend) {
1363
+ switch (backend) {
1364
+ case "none":
1365
+ return noneJudge;
1366
+ case "host":
1367
+ return hostHarnessJudge;
1368
+ }
1369
+ }
1370
+ function escapeXml(value) {
1371
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1372
+ }
1373
+ async function writeJunit(report, destPath) {
1374
+ await promises.mkdir(path2__default.default.dirname(destPath), { recursive: true });
1375
+ const cases = report.results.map((r) => {
1376
+ if (r.status === "pass") {
1377
+ return ` <testcase classname="blazediff" name="${escapeXml(r.id)}"/>`;
1378
+ }
1379
+ const message = r.message ?? r.status;
1380
+ return ` <testcase classname="blazediff" name="${escapeXml(r.id)}">
1381
+ <failure message="${escapeXml(message)}" type="${escapeXml(r.status)}">${escapeXml(message)}</failure>
1382
+ </testcase>`;
1383
+ });
1384
+ const failures = report.totalEntries - report.passed;
1385
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1386
+ <testsuites>
1387
+ <testsuite name="blazediff" tests="${report.totalEntries}" failures="${failures}">
1388
+ ${cases.join("\n")}
1389
+ </testsuite>
1390
+ </testsuites>
1391
+ `;
1392
+ await promises.writeFile(destPath, xml, "utf8");
1393
+ return destPath;
1394
+ }
1395
+
1396
+ // src/check.ts
1397
+ function narrowRegion(r) {
1398
+ return {
1399
+ bbox: r.bbox,
1400
+ pixelCount: r.pixelCount,
1401
+ percentage: r.percentage,
1402
+ changeType: r.changeType,
1403
+ confidence: r.confidence
1404
+ };
1405
+ }
1406
+ async function pool(items, limit, fn) {
1407
+ const results = new Array(items.length);
1408
+ let next = 0;
1409
+ const workerCount = Math.max(1, Math.min(limit, items.length));
1410
+ const workers = Array.from({ length: workerCount }, async () => {
1411
+ while (true) {
1412
+ const i = next++;
1413
+ if (i >= items.length) return;
1414
+ results[i] = await fn(items[i], i);
1415
+ }
1416
+ });
1417
+ await Promise.all(workers);
1418
+ return results;
1419
+ }
1420
+ function passResult2(entry, baselinePath, actualPath) {
1421
+ return {
1422
+ id: entry.id,
1423
+ url: entry.url,
1424
+ status: "pass",
1425
+ baselinePath,
1426
+ actualPath
1427
+ };
1428
+ }
1429
+ function skipResult(entry, message) {
1430
+ return { id: entry.id, url: entry.url, status: "pass", message };
1431
+ }
1432
+ function staleResult(entry) {
1433
+ return {
1434
+ id: entry.id,
1435
+ url: entry.url,
1436
+ status: "stale-baseline",
1437
+ message: "captureHash mismatch: entry was edited without re-capturing"
1438
+ };
1439
+ }
1440
+ function missingBaselineResult(entry, baselinePath) {
1441
+ return {
1442
+ id: entry.id,
1443
+ url: entry.url,
1444
+ status: "missing-baseline",
1445
+ message: `baseline missing at ${baselinePath}`
1446
+ };
1447
+ }
1448
+ function failResult(entry, outcome, actualPath, baselinePath, verdict) {
1449
+ return {
1450
+ id: entry.id,
1451
+ url: entry.url,
1452
+ status: "fail",
1453
+ diffCount: outcome.diffCount,
1454
+ diffPercentage: outcome.diffPercentage,
1455
+ severity: outcome.interpretation?.severity,
1456
+ regions: outcome.interpretation?.regions?.map(narrowRegion),
1457
+ verdict,
1458
+ diffPath: outcome.diffPath,
1459
+ baselinePath,
1460
+ actualPath,
1461
+ message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
1462
+ };
1463
+ }
1464
+ async function judgeAmbiguous(result, entry, judge, cwd) {
1465
+ if (result.status !== "fail" || !result.verdict || result.verdict.label !== "ambiguous" || !result.baselinePath || !result.actualPath) {
1466
+ return result;
1467
+ }
1468
+ const output = await judge.judge(
1469
+ {
1470
+ entry,
1471
+ baselinePath: result.baselinePath,
1472
+ actualPath: result.actualPath,
1473
+ diffPath: result.diffPath,
1474
+ regions: result.regions,
1475
+ diffPercentage: result.diffPercentage,
1476
+ severity: result.severity,
1477
+ heuristicVerdict: result.verdict
1478
+ },
1479
+ cwd
1480
+ );
1481
+ if (output.kind === "judged") {
1482
+ return { ...result, verdict: output.verdict };
1483
+ }
1484
+ return {
1485
+ ...result,
1486
+ status: "needs-judgment",
1487
+ message: `awaiting judgment in ${output.requestPath}`
1488
+ };
1489
+ }
1490
+ async function checkEntry(entry, opts, cwd, baselinesDir, judge) {
1491
+ if (entry.auth === "required") {
1492
+ return skipResult(entry, "skipped: auth required (deferred to v0.2)");
1493
+ }
1494
+ if (isEntryStale(entry)) {
1495
+ return staleResult(entry);
1496
+ }
1497
+ const baselinePath = path2__default.default.join(baselinesDir, `${entry.id}.png`);
1498
+ const capture = await captureScreenshot(
1499
+ opts.baseUrl,
1500
+ {
1501
+ id: entry.id,
1502
+ url: entry.url,
1503
+ viewport: entry.viewport,
1504
+ mask: entry.mask,
1505
+ waitFor: entry.waitFor,
1506
+ fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
1507
+ mode: "actual"
1508
+ },
1509
+ cwd
1510
+ );
1511
+ const outcome = await diffEntry(
1512
+ entry.id,
1513
+ baselinePath,
1514
+ capture.outputPath,
1515
+ { threshold: opts.threshold, emitDiffPng: opts.emitDiffPng ?? true },
1516
+ cwd
1517
+ );
1518
+ if (outcome.match) return passResult2(entry, baselinePath, capture.outputPath);
1519
+ if (outcome.reason === "file-not-exists")
1520
+ return missingBaselineResult(entry, baselinePath);
1521
+ const verdict = deriveVerdict({
1522
+ reason: outcome.reason,
1523
+ interpretation: outcome.interpretation,
1524
+ diffCount: outcome.diffCount,
1525
+ diffPercentage: outcome.diffPercentage
1526
+ });
1527
+ const failed = failResult(
1528
+ entry,
1529
+ outcome,
1530
+ capture.outputPath,
1531
+ baselinePath,
1532
+ verdict
1533
+ );
1534
+ return judgeAmbiguous(failed, entry, judge, cwd);
1535
+ }
1536
+ async function runCheck(opts) {
1537
+ const cwd = opts.cwd ?? process.cwd();
1538
+ const manifest = await loadManifest(cwd);
1539
+ if (!manifest) {
1540
+ throw new Error(
1541
+ `no manifest found at ${paths(cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
1542
+ );
1543
+ }
1544
+ const baselinesDir = paths(cwd).baselines;
1545
+ const concurrency = opts.concurrency ?? defaultConcurrency();
1546
+ const judge = resolveJudge(opts.judge ?? "none");
1547
+ let results;
1548
+ try {
1549
+ results = await pool(
1550
+ manifest.entries,
1551
+ concurrency,
1552
+ (entry) => checkEntry(entry, opts, cwd, baselinesDir, judge)
1553
+ );
1554
+ } finally {
1555
+ await closeBrowser();
1556
+ }
1557
+ const passed = results.filter((r) => r.status === "pass").length;
1558
+ const pendingJudgments = results.filter(
1559
+ (r) => r.status === "needs-judgment"
1560
+ ).length;
1561
+ const report = {
1562
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1563
+ totalEntries: results.length,
1564
+ passed,
1565
+ failed: results.length - passed - pendingJudgments,
1566
+ pendingJudgments,
1567
+ results
1568
+ };
1569
+ await writeJudgments({ report, manifest, cwd });
1570
+ await writeSummaryMarkdown(report, cwd);
1571
+ await ensureGitignore(cwd);
1572
+ if (opts.junitPath) {
1573
+ const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
1574
+ await writeJunit(report, target);
1575
+ }
1576
+ return report;
1577
+ }
1578
+
1579
+ // src/cli/commands/check.ts
1580
+ function slimResult(r) {
1581
+ return {
1582
+ id: r.id,
1583
+ url: r.url,
1584
+ status: r.status,
1585
+ verdict: r.verdict ? {
1586
+ label: r.verdict.label,
1587
+ headline: r.verdict.headline,
1588
+ action: r.verdict.action
1589
+ } : void 0
1590
+ };
1591
+ }
1592
+ function slimReport(report, summaryPath) {
1593
+ return {
1594
+ summaryPath,
1595
+ createdAt: report.createdAt,
1596
+ totalEntries: report.totalEntries,
1597
+ passed: report.passed,
1598
+ failed: report.failed,
1599
+ pendingJudgments: report.pendingJudgments,
1600
+ results: report.results.filter((r) => r.status !== "pass").map(slimResult)
1601
+ };
1602
+ }
1603
+ function failureLines(results) {
1604
+ return results.filter((r) => r.status !== "pass").flatMap((r) => {
1605
+ const lines = [];
1606
+ const prefix = r.status === "needs-judgment" ? "?" : "\u2717";
1607
+ if (r.verdict) {
1608
+ lines.push(
1609
+ ` ${prefix} ${r.id} [${r.verdict.label}] ${r.verdict.headline}`
1610
+ );
1611
+ lines.push(` \u2192 ${r.verdict.action}`);
1612
+ } else {
1613
+ const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
1614
+ lines.push(` ${prefix} ${r.id}: ${detail}`);
1615
+ }
1616
+ if (r.status === "needs-judgment" && r.message) {
1617
+ lines.push(` ${r.message}`);
1618
+ }
1619
+ if (r.diffPath) lines.push(` diff: ${r.diffPath}`);
1620
+ return lines;
1621
+ });
1622
+ }
1623
+ function parseJudge(input) {
1624
+ if (input === "host" || input === "none") return input;
1625
+ throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
1626
+ }
1627
+ function registerCheck(program, out) {
1628
+ program.command("check").description("run the visual regression check (CI verb)").option("--base-url <url>", "override base URL").option(
1629
+ "--threshold <n>",
1630
+ "color threshold (0-1)",
1631
+ String(DEFAULT_THRESHOLD)
1632
+ ).option(
1633
+ "--concurrency <n>",
1634
+ "max entries checked in parallel (default: auto based on CPU cores, capped at 8)"
1635
+ ).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
1636
+ "--judge <backend>",
1637
+ "judge backend for ambiguous diffs (host | none)",
1638
+ "none"
1639
+ ).option(
1640
+ "--apply-judgments",
1641
+ "regenerate summary.md from .blazediff/judgments/<id>/verdict.json files (no re-check)"
1642
+ ).action(async (opts) => {
1643
+ if (opts.applyJudgments) {
1644
+ const { report: report2, applied, missing, invalid } = await applyJudgments();
1645
+ const summaryPath2 = paths().summary;
1646
+ const human2 = applied.length === 0 && missing.length === 0 && invalid.length === 0 ? `no judgments to apply
1647
+ summary: ${summaryPath2}` : [
1648
+ `applied ${applied.length} judgment(s)`,
1649
+ missing.length ? ` ${missing.length} pending without judgment: ${missing.join(", ")}` : void 0,
1650
+ invalid.length ? ` ${invalid.length} invalid judgment file(s): ${invalid.join(", ")}` : void 0,
1651
+ ` ${report2.passed}/${report2.totalEntries} passed (${report2.failed} failed, ${report2.pendingJudgments} pending)`,
1652
+ ` summary: ${summaryPath2}`
1653
+ ].filter(Boolean).join("\n");
1654
+ out.emit(
1655
+ {
1656
+ ok: true,
1657
+ applied,
1658
+ missing,
1659
+ invalid,
1660
+ ...slimReport(report2, summaryPath2)
1661
+ },
1662
+ human2
1663
+ );
1664
+ if (report2.failed > 0) process.exitCode = 1;
1665
+ return;
1666
+ }
1667
+ const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
1668
+ const report = await runCheck({
1669
+ baseUrl,
1670
+ threshold: Number(opts.threshold),
1671
+ concurrency: opts.concurrency ? Number(opts.concurrency) : void 0,
1672
+ emitDiffPng: opts.diffPng,
1673
+ junitPath: opts.junit,
1674
+ judge: parseJudge(opts.judge)
1675
+ });
1676
+ const summaryPath = paths().summary;
1677
+ const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
1678
+ const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
1679
+ summary: ${summaryPath}` : [
1680
+ `${summary}:`,
1681
+ ...failureLines(report.results),
1682
+ ` summary: ${summaryPath}`,
1683
+ report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
1684
+ ].filter(Boolean).join("\n");
1685
+ out.emit(slimReport(report, summaryPath), human);
1686
+ if (report.failed > 0) process.exitCode = 1;
1687
+ });
1688
+ }
1689
+
1690
+ // src/cli/commands/diff.ts
1691
+ function registerDiff(program, out) {
1692
+ program.command("diff <id>").description("diff a route's baseline against its actual capture").option(
1693
+ "--threshold <n>",
1694
+ "color threshold (0-1)",
1695
+ String(DEFAULT_THRESHOLD)
1696
+ ).option("--emit-diff-png", "write diff PNG to .blazediff/diffs/").action(async (id, opts) => {
1697
+ const manifest = await loadManifest();
1698
+ if (!manifest) throw new Error("no manifest");
1699
+ const entry = findEntry(manifest, id);
1700
+ if (!entry) throw new Error(`no entry with id ${id}`);
1701
+ const baselinePath = `${paths().baselines}/${id}.png`;
1702
+ const actualPath = `${paths().actual}/${id}.png`;
1703
+ const outcome = await diffEntry(id, baselinePath, actualPath, {
1704
+ threshold: Number(opts.threshold),
1705
+ emitDiffPng: Boolean(opts.emitDiffPng)
1706
+ });
1707
+ out.emit(
1708
+ outcome,
1709
+ outcome.match ? `${id}: match` : `${id}: ${outcome.reason ?? "diff"}`
1710
+ );
1711
+ if (!outcome.match) process.exitCode = 1;
1712
+ });
1713
+ }
1714
+
1715
+ // src/discover/crawl.ts
1716
+ function extractInternalLinks(base, target, hrefs) {
1717
+ const out = [];
1718
+ for (const href of hrefs) {
1719
+ if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
1720
+ continue;
1721
+ }
1722
+ try {
1723
+ const u = new URL(href, target);
1724
+ if (u.origin !== base.origin) continue;
1725
+ const path23 = u.pathname + u.search;
1726
+ if (path23.startsWith("/api/")) continue;
1727
+ out.push(path23);
1728
+ } catch {
1729
+ }
1730
+ }
1731
+ return out;
1732
+ }
1733
+ async function crawlRoutes(opts) {
1734
+ const maxRoutes = opts.maxRoutes ?? 50;
1735
+ const maxDepth = opts.maxDepth ?? 2;
1736
+ const base = new URL(opts.baseUrl);
1737
+ const visited = /* @__PURE__ */ new Set();
1738
+ const queue = [{ url: "/", depth: 0 }];
1739
+ visited.add("/");
1740
+ const discovered = [];
1741
+ const browser = await getBrowser();
1742
+ const context = await browser.newContext({
1743
+ viewport: { width: 1024, height: 768 },
1744
+ deviceScaleFactor: 1
1745
+ });
1746
+ try {
1747
+ while (queue.length && discovered.length < maxRoutes) {
1748
+ const { url, depth } = queue.shift();
1749
+ const page = await context.newPage();
1750
+ try {
1751
+ const target = new URL(url, base).toString();
1752
+ await page.goto(target, {
1753
+ waitUntil: "domcontentloaded",
1754
+ timeout: 15e3
1755
+ });
1756
+ discovered.push({ url, source: "crawl" });
1757
+ if (depth >= maxDepth) continue;
1758
+ const hrefs = await page.evaluate(
1759
+ () => Array.from(
1760
+ document.querySelectorAll("a[href]")
1761
+ ).map((a) => a.getAttribute("href") ?? "")
1762
+ );
1763
+ for (const path23 of extractInternalLinks(base, target, hrefs)) {
1764
+ if (visited.has(path23)) continue;
1765
+ visited.add(path23);
1766
+ queue.push({ url: path23, depth: depth + 1 });
1767
+ }
1768
+ } catch {
1769
+ } finally {
1770
+ await page.close().catch(() => {
1771
+ });
1772
+ }
1773
+ }
1774
+ } finally {
1775
+ await context.close().catch(() => {
1776
+ });
1777
+ }
1778
+ return discovered;
1779
+ }
1780
+ var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
1781
+ async function readJson(file) {
1782
+ if (!fs.existsSync(file)) return null;
1783
+ try {
1784
+ return JSON.parse(await promises.readFile(file, "utf8"));
1785
+ } catch {
1786
+ return null;
1787
+ }
1788
+ }
1789
+ function isPublicRoute(route) {
1790
+ if (DYNAMIC_SEGMENT.test(route)) return false;
1791
+ if (route === "/api" || route.startsWith("/api/")) return false;
1792
+ return true;
1793
+ }
1794
+ async function discoverFromNextManifest(cwd = process.cwd()) {
1795
+ const nextDir = path2__default.default.join(cwd, ".next");
1796
+ if (!fs.existsSync(nextDir)) return [];
1797
+ const seen = /* @__PURE__ */ new Set();
1798
+ const out = [];
1799
+ const add = (url) => {
1800
+ if (seen.has(url)) return;
1801
+ seen.add(url);
1802
+ out.push({ url, source: "next-manifest" });
1803
+ };
1804
+ const routes = await readJson(
1805
+ path2__default.default.join(nextDir, "routes-manifest.json")
1806
+ );
1807
+ for (const r of routes?.staticRoutes ?? []) {
1808
+ if (isPublicRoute(r.page)) add(r.page);
1809
+ }
1810
+ const appPaths = await readJson(
1811
+ path2__default.default.join(nextDir, "server", "app-paths-manifest.json")
1812
+ );
1813
+ for (const route of Object.keys(appPaths ?? {})) {
1814
+ const normalized = route.replace(/\/page$/, "") || "/";
1815
+ if (isPublicRoute(normalized)) add(normalized);
1816
+ }
1817
+ return out;
1818
+ }
1819
+
1820
+ // src/discover/sitemap.ts
1821
+ var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
1822
+ var LOC_RE = /<loc>([^<]+)<\/loc>/g;
1823
+ async function discoverFromSitemap(baseUrl) {
1824
+ for (const candidate of CANDIDATES) {
1825
+ try {
1826
+ const res = await fetch(new URL(candidate, baseUrl));
1827
+ if (!res.ok) continue;
1828
+ const text = await res.text();
1829
+ const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
1830
+ if (!urls.length) continue;
1831
+ return urls.map((u) => {
1832
+ const url = new URL(u);
1833
+ return { url: url.pathname + url.search, source: "sitemap" };
1834
+ });
1835
+ } catch {
1836
+ }
1837
+ }
1838
+ return [];
1839
+ }
1840
+
1841
+ // src/discover/index.ts
1842
+ function normalizePath(url) {
1843
+ const [pathPart, query = ""] = url.split("?", 2);
1844
+ const trimmed = pathPart.replace(/\/+$/, "");
1845
+ const normalizedPath = trimmed === "" ? "/" : trimmed;
1846
+ return query ? `${normalizedPath}?${query}` : normalizedPath;
1847
+ }
1848
+ function mergeBy(routes, into) {
1849
+ for (const r of routes) {
1850
+ const key = normalizePath(r.url);
1851
+ if (!into.has(key)) into.set(key, { ...r, url: key });
1852
+ }
1853
+ }
1854
+ async function discover(opts) {
1855
+ const cwd = opts.cwd ?? process.cwd();
1856
+ const merged = /* @__PURE__ */ new Map();
1857
+ mergeBy(await discoverFromNextManifest(cwd), merged);
1858
+ mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
1859
+ if (!opts.skipCrawl) {
1860
+ const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
1861
+ if (crawlMax > 0) {
1862
+ mergeBy(
1863
+ await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
1864
+ merged
1865
+ );
1866
+ }
1867
+ }
1868
+ return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
1869
+ }
1870
+
1871
+ // src/cli/commands/discover.ts
1872
+ function registerDiscover(program, out) {
1873
+ program.command("discover").description(
1874
+ "enumerate candidate routes via BFS crawl + Next manifest + sitemap"
1875
+ ).option("--base-url <url>", "override base URL").option("--max-routes <n>", "cap on routes returned", "50").option("--no-crawl", "skip BFS crawl fallback").action(async (opts) => {
1876
+ const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
1877
+ const routes = await discover({
1878
+ baseUrl,
1879
+ maxRoutes: Number(opts.maxRoutes),
1880
+ skipCrawl: !opts.crawl
1881
+ });
1882
+ await closeBrowser();
1883
+ out.emit(
1884
+ { ok: true, baseUrl, routes },
1885
+ routes.length ? routes.map((r) => `${r.source.padEnd(14)} ${r.url}`).join("\n") : "no routes discovered"
1886
+ );
1887
+ });
1888
+ }
1889
+
1890
+ // src/introspect/framework.ts
1891
+ var SIGNALS = [
1892
+ ["next", ["next"]],
1893
+ ["remix", ["@remix-run/dev", "@remix-run/serve"]],
1894
+ ["sveltekit", ["@sveltejs/kit"]],
1895
+ ["nuxt", ["nuxt", "nuxt3"]],
1896
+ ["astro", ["astro"]],
1897
+ ["gatsby", ["gatsby"]],
1898
+ ["vite-react", ["vite", "react"]]
1899
+ ];
1900
+ function detectFramework(pkg) {
1901
+ const deps = pkg.allDependencies;
1902
+ for (const [framework, required] of SIGNALS) {
1903
+ if (required.every((d) => d in deps)) return framework;
1904
+ }
1905
+ return "unknown";
1906
+ }
1907
+ async function readPackageJson(cwd = process.cwd()) {
1908
+ const file = path2__default.default.join(cwd, "package.json");
1909
+ if (!fs.existsSync(file)) return null;
1910
+ return JSON.parse(await promises.readFile(file, "utf8"));
1911
+ }
1912
+ function detectPackageManager(cwd = process.cwd()) {
1913
+ let dir = cwd;
1914
+ const { root } = path2__default.default.parse(dir);
1915
+ while (true) {
1916
+ if (fs.existsSync(path2__default.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
1917
+ if (fs.existsSync(path2__default.default.join(dir, "bun.lockb")) || fs.existsSync(path2__default.default.join(dir, "bun.lock")))
1918
+ return "bun";
1919
+ if (fs.existsSync(path2__default.default.join(dir, "yarn.lock"))) return "yarn";
1920
+ if (fs.existsSync(path2__default.default.join(dir, "package-lock.json"))) return "npm";
1921
+ if (dir === root) return "npm";
1922
+ dir = path2__default.default.dirname(dir);
1923
+ }
1924
+ }
1925
+ var DEFAULT_PORTS = {
1926
+ next: 3e3,
1927
+ "react-scripts": 3e3,
1928
+ vite: 5173,
1929
+ remix: 3e3,
1930
+ "@remix-run/dev": 3e3,
1931
+ astro: 4321,
1932
+ svelte: 5173,
1933
+ vue: 5173,
1934
+ nuxt: 3e3,
1935
+ gatsby: 8e3,
1936
+ parcel: 1234
1937
+ };
1938
+ var DEV_SCRIPT_CANDIDATES = ["dev", "start", "serve", "develop"];
1939
+ function inferPort(script, deps) {
1940
+ const portArg = script.match(/(?:--port[\s=]|-p\s+)(\d+)/);
1941
+ if (portArg) return Number(portArg[1]);
1942
+ const portEnv = script.match(/PORT[\s=]+(\d+)/);
1943
+ if (portEnv) return Number(portEnv[1]);
1944
+ const depNames = Object.keys(deps);
1945
+ for (const [pkg, port] of Object.entries(DEFAULT_PORTS)) {
1946
+ if (depNames.some((d) => d.startsWith(pkg))) return port;
1947
+ }
1948
+ return DEFAULT_PORT;
1949
+ }
1950
+ function runnerFor(pm, scriptName) {
1951
+ if (pm === "npm") return `npm run ${scriptName}`;
1952
+ if (pm === "yarn") return `yarn ${scriptName}`;
1953
+ if (pm === "bun") return `bun run ${scriptName}`;
1954
+ return `pnpm ${scriptName}`;
1955
+ }
1956
+ function collectCandidates(scripts, deps, pm) {
1957
+ const out = [];
1958
+ for (const name of DEV_SCRIPT_CANDIDATES) {
1959
+ if (scripts[name]) {
1960
+ out.push({
1961
+ name,
1962
+ body: scripts[name],
1963
+ command: runnerFor(pm, name),
1964
+ port: inferPort(scripts[name], deps)
1965
+ });
1966
+ }
1967
+ }
1968
+ return out;
1969
+ }
1970
+ async function introspectPackage(cwd = process.cwd()) {
1971
+ const pkg = await readPackageJson(cwd);
1972
+ if (!pkg) return null;
1973
+ const packageManager = detectPackageManager(cwd);
1974
+ const scripts = pkg.scripts ?? {};
1975
+ const devDependencies = pkg.devDependencies ?? {};
1976
+ const dependencies = pkg.dependencies ?? {};
1977
+ const allDependencies = { ...devDependencies, ...dependencies };
1978
+ const candidates = collectCandidates(
1979
+ scripts,
1980
+ allDependencies,
1981
+ packageManager
1982
+ );
1983
+ if (!candidates.length) return null;
1984
+ const chosen = candidates[0];
1985
+ return {
1986
+ packageManager,
1987
+ devScript: chosen.name,
1988
+ devCommand: chosen.command,
1989
+ port: chosen.port,
1990
+ candidates,
1991
+ devDependencies,
1992
+ dependencies,
1993
+ allDependencies
1994
+ };
1995
+ }
1996
+
1997
+ // src/cli/commands/init.ts
1998
+ async function buildConfig(opts) {
1999
+ if (opts.url) {
2000
+ if (opts.devCommand || opts.port || opts.devScript) {
2001
+ throw new Error(
2002
+ "--url is mutually exclusive with --dev-command/--port/--dev-script"
2003
+ );
2004
+ }
2005
+ const baseUrl = new URL(opts.url).toString().replace(/\/$/, "");
2006
+ return { devServer: null, baseUrl };
2007
+ }
2008
+ if (opts.devCommand) {
2009
+ const port2 = opts.port ? Number(opts.port) : DEFAULT_PORT;
2010
+ return {
2011
+ devServer: {
2012
+ command: opts.devCommand,
2013
+ port: port2,
2014
+ readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
2015
+ },
2016
+ baseUrl: `http://127.0.0.1:${port2}`
2017
+ };
2018
+ }
2019
+ const pkg = await introspectPackage();
2020
+ if (!pkg) {
2021
+ throw new Error(
2022
+ "no package.json with a dev/start script in cwd. Pass --url <baseUrl> or --dev-command <cmd>."
2023
+ );
2024
+ }
2025
+ let chosen = pkg.candidates[0];
2026
+ if (pkg.candidates.length > 1) {
2027
+ if (!opts.devScript) {
2028
+ const names = pkg.candidates.map((c) => `${c.name} (${c.command})`).join(", ");
2029
+ throw new Error(
2030
+ `multiple dev-script candidates: ${names}. Pass --dev-script <name> or --dev-command <cmd>.`
2031
+ );
2032
+ }
2033
+ const match = pkg.candidates.find((c) => c.name === opts.devScript);
2034
+ if (!match) {
2035
+ const names = pkg.candidates.map((c) => c.name).join(", ");
2036
+ throw new Error(
2037
+ `--dev-script "${opts.devScript}" not found among candidates: ${names}`
2038
+ );
2039
+ }
2040
+ chosen = match;
2041
+ }
2042
+ const port = opts.port ? Number(opts.port) : chosen.port;
2043
+ return {
2044
+ devServer: {
2045
+ command: chosen.command,
2046
+ port,
2047
+ readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
2048
+ },
2049
+ framework: detectFramework(pkg),
2050
+ packageManager: pkg.packageManager,
2051
+ baseUrl: `http://127.0.0.1:${port}`
2052
+ };
2053
+ }
2054
+ function registerInit(program, out) {
2055
+ program.command("init").description("write .blazediff/config.json and .gitignore").option("--force", "overwrite existing config").option(
2056
+ "--url <baseUrl>",
2057
+ "point at an already-running server / external URL"
2058
+ ).option("--dev-command <cmd>", "override detected dev-server command").option("--port <n>", "override detected port").option(
2059
+ "--dev-script <name>",
2060
+ "select a dev script by name when multiple candidates exist"
2061
+ ).action(async (opts) => {
2062
+ const existing = await loadConfig();
2063
+ if (existing && !opts.force) {
2064
+ await ensureGitignore(process.cwd());
2065
+ out.emit(
2066
+ {
2067
+ ok: true,
2068
+ created: false,
2069
+ config: existing,
2070
+ configHash: configHash(existing)
2071
+ },
2072
+ `config exists at ${paths().config} (use --force to overwrite)`
2073
+ );
2074
+ return;
2075
+ }
2076
+ const config = await buildConfig(opts);
2077
+ await saveConfig(config);
2078
+ await ensureGitignore(process.cwd());
2079
+ const human = config.devServer ? `wrote ${paths().config}
2080
+ baseUrl: ${config.baseUrl}
2081
+ dev: ${config.devServer.command} (port ${config.devServer.port})` : `wrote ${paths().config}
2082
+ baseUrl: ${config.baseUrl}
2083
+ external server (no devServer managed)`;
2084
+ out.emit(
2085
+ { ok: true, created: true, config, configHash: configHash(config) },
2086
+ human
2087
+ );
2088
+ });
2089
+ }
2090
+
2091
+ // src/cli/commands/manifest.ts
2092
+ function registerManifest(program, out) {
2093
+ const cmd = program.command("manifest").description("manage .blazediff/manifest.json");
2094
+ cmd.command("add <id>").requiredOption("--url <url>").option("--viewport <WxH>", "viewport", "1280x800").option("--mask <selectors>", "selectors", "").option("--wait-for <list>", "wait list", "networkidle,fonts").option("--no-full-page", "viewport-only (default: full page)").option("--auth <required|none>", "mark auth-gated", "none").option("--created-by <agent|human>", "provenance", "agent").action(async (id, opts) => {
2095
+ const config = await loadConfig();
2096
+ if (!config)
2097
+ throw new Error("no config. Run `blazediff-agent init` first.");
2098
+ const manifest = await loadManifest() ?? emptyManifest(configHash(config));
2099
+ const entry = makeEntry({
2100
+ id,
2101
+ url: opts.url,
2102
+ viewport: parseViewport(opts.viewport),
2103
+ mask: parseMaskList(opts.mask),
2104
+ waitFor: parseWaitFor(opts.waitFor),
2105
+ fullPage: opts.fullPage,
2106
+ auth: opts.auth === "required" ? "required" : null,
2107
+ createdBy: opts.createdBy
2108
+ });
2109
+ await saveManifest(addOrReplaceEntry(manifest, entry));
2110
+ out.emit(
2111
+ { ok: true, entry },
2112
+ out.isTTY() ? `manifest: added ${id} (${entry.url})` : "."
2113
+ );
2114
+ });
2115
+ cmd.command("remove <id>").action(async (id) => {
2116
+ const manifest = await loadManifest();
2117
+ if (!manifest) throw new Error("no manifest");
2118
+ await saveManifest(removeEntry(manifest, id));
2119
+ out.emit({ ok: true, removed: id }, `manifest: removed ${id}`);
2120
+ });
2121
+ cmd.command("list").action(async () => {
2122
+ const manifest = await loadManifest();
2123
+ if (!manifest) {
2124
+ out.emit({ entries: [] }, "no manifest");
2125
+ return;
2126
+ }
2127
+ out.emit(
2128
+ { entries: manifest.entries },
2129
+ manifest.entries.map((e) => `${e.id.padEnd(30)} ${e.url}`).join("\n") || "no entries"
2130
+ );
2131
+ });
2132
+ }
2133
+ var someExists = (paths2) => paths2.some((p) => fs.existsSync(p));
2134
+ var HARNESSES = {
2135
+ claude: {
2136
+ id: "claude",
2137
+ label: "Claude Code",
2138
+ detect: (cwd) => someExists([
2139
+ path2.join(cwd, ".claude"),
2140
+ path2.join(cwd, "CLAUDE.md"),
2141
+ path2.join(cwd, "AGENTS.md")
2142
+ ]),
2143
+ target: (cwd) => path2.join(cwd, ".claude", "skills", "blazediff", "SKILL.md"),
2144
+ format: "skill-file",
2145
+ scope: "project"
2146
+ },
2147
+ codex: {
2148
+ id: "codex",
2149
+ label: "Codex",
2150
+ detect: (cwd) => someExists([
2151
+ path2.join(cwd, "AGENTS.md"),
2152
+ path2.join(cwd, ".codex"),
2153
+ path2.join(os.homedir(), ".codex")
2154
+ ]),
2155
+ target: () => path2.join(os.homedir(), ".codex", "skills", "blazediff", "SKILL.md"),
2156
+ format: "skill-file",
2157
+ scope: "user"
2158
+ },
2159
+ cursor: {
2160
+ id: "cursor",
2161
+ label: "Cursor",
2162
+ detect: (cwd) => someExists([path2.join(cwd, ".cursor"), path2.join(cwd, ".cursorrules")]),
2163
+ target: (cwd) => path2.join(cwd, ".cursor", "rules", "blazediff.mdc"),
2164
+ format: "cursor-rule",
2165
+ scope: "project"
2166
+ }
2167
+ };
2168
+ var ALL_HARNESSES = ["claude", "codex", "cursor"];
2169
+ function detectHarnesses(cwd) {
2170
+ return ALL_HARNESSES.filter((id) => HARNESSES[id].detect(cwd));
2171
+ }
2172
+ function parseHarnessList(input) {
2173
+ const tokens = input.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
2174
+ if (tokens.includes("all")) return [...ALL_HARNESSES];
2175
+ const out = [];
2176
+ for (const t of tokens) {
2177
+ if (!(t in HARNESSES)) {
2178
+ throw new Error(
2179
+ `unknown harness "${t}". valid: ${[...ALL_HARNESSES, "all"].join(", ")}`
2180
+ );
2181
+ }
2182
+ if (!out.includes(t)) out.push(t);
2183
+ }
2184
+ return out;
2185
+ }
2186
+ var SKILL_FILES = ["SKILL.md", "JUDGING.md", "MASKING.md"];
2187
+ var cachedDir = null;
2188
+ var cachedFiles = null;
2189
+ function moduleDir() {
2190
+ return path2.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href))));
2191
+ }
2192
+ function resolveSkillDir() {
2193
+ if (cachedDir !== null) return cachedDir;
2194
+ const here = moduleDir();
2195
+ const candidates = [
2196
+ path2.join(here, ".."),
2197
+ path2.join(here, "..", ".."),
2198
+ path2.join(here, "..", "..", "..", "skill", "blazediff"),
2199
+ path2.join(here, "..", "..", "..", "..", "skill", "blazediff")
2200
+ ];
2201
+ for (const dir of candidates) {
2202
+ if (fs.existsSync(path2.join(dir, "SKILL.md"))) {
2203
+ cachedDir = dir;
2204
+ return cachedDir;
2205
+ }
2206
+ }
2207
+ throw new Error(
2208
+ `could not locate bundled SKILL.md (looked in: ${candidates.join(", ")}). reinstall @blazediff/agent.`
2209
+ );
2210
+ }
2211
+ function loadSkillFiles() {
2212
+ if (cachedFiles !== null) return cachedFiles;
2213
+ const dir = resolveSkillDir();
2214
+ cachedFiles = SKILL_FILES.filter((name) => fs.existsSync(path2.join(dir, name))).map(
2215
+ (name) => ({ name, content: fs.readFileSync(path2.join(dir, name), "utf8") })
2216
+ );
2217
+ return cachedFiles;
2218
+ }
2219
+ function skillBodyOnly(content) {
2220
+ const lines = content.split("\n");
2221
+ if (lines[0]?.startsWith("---")) {
2222
+ let end = -1;
2223
+ for (let i = 1; i < lines.length; i++) {
2224
+ if (lines[i]?.startsWith("---")) {
2225
+ end = i;
2226
+ break;
2227
+ }
2228
+ }
2229
+ if (end > 0)
2230
+ return lines.slice(end + 1).join("\n").trimStart();
2231
+ }
2232
+ return content;
2233
+ }
2234
+
2235
+ // src/onboard/install.ts
2236
+ function ensureTrailingNewline(s) {
2237
+ return s.endsWith("\n") ? s : `${s}
2238
+ `;
2239
+ }
2240
+ function renderCursorRule(files) {
2241
+ const skill = files.find((f) => f.name === "SKILL.md")?.content ?? "";
2242
+ const sidecars = files.filter((f) => f.name !== "SKILL.md");
2243
+ const body = skillBodyOnly(skill).trim();
2244
+ const frontmatter = [
2245
+ "---",
2246
+ 'description: "Run, author, or update BlazeDiff visual regression tests. Trigger on visual test, screenshot regression, blazediff, /blazediff."',
2247
+ "alwaysApply: false",
2248
+ "---",
2249
+ ""
2250
+ ].join("\n");
2251
+ const sidecarBlocks = sidecars.map((f) => `
2252
+
2253
+ ---
2254
+
2255
+ <!-- ${f.name} -->
2256
+
2257
+ ${f.content.trim()}`).join("");
2258
+ return `${frontmatter}${body}${sidecarBlocks}
2259
+ `;
2260
+ }
2261
+ async function writeIfChanged(target, content, force) {
2262
+ const stat2 = await promises.lstat(target).catch(() => null);
2263
+ const isSymlink = stat2?.isSymbolicLink() ?? false;
2264
+ const exists = stat2 !== null;
2265
+ if (isSymlink) {
2266
+ await promises.unlink(target);
2267
+ await promises.mkdir(path2.dirname(target), { recursive: true });
2268
+ await promises.writeFile(target, content, "utf8");
2269
+ return "updated";
2270
+ }
2271
+ if (exists) {
2272
+ const current = fs.readFileSync(target, "utf8");
2273
+ if (current === content) return "unchanged";
2274
+ if (!force) return "skipped-exists";
2275
+ }
2276
+ await promises.mkdir(path2.dirname(target), { recursive: true });
2277
+ await promises.writeFile(target, content, "utf8");
2278
+ return exists ? "updated" : "created";
2279
+ }
2280
+ function combineStatuses(statuses) {
2281
+ if (statuses.some((s) => s === "skipped-exists")) return "skipped-exists";
2282
+ if (statuses.some((s) => s === "created")) return "created";
2283
+ if (statuses.some((s) => s === "updated")) return "updated";
2284
+ return "unchanged";
2285
+ }
2286
+ async function installHarness(harness, cwd, opts = {}) {
2287
+ const info = HARNESSES[harness];
2288
+ const target = info.target(cwd);
2289
+ const files = loadSkillFiles();
2290
+ if (info.format === "cursor-rule") {
2291
+ const content = renderCursorRule(files);
2292
+ const status = await writeIfChanged(target, content, opts.force);
2293
+ return { harness, path: target, status };
2294
+ }
2295
+ const targetDir = path2.dirname(target);
2296
+ const statuses = [];
2297
+ for (const file of files) {
2298
+ const filePath = path2.join(targetDir, file.name);
2299
+ const status = await writeIfChanged(
2300
+ filePath,
2301
+ ensureTrailingNewline(file.content),
2302
+ opts.force
2303
+ );
2304
+ statuses.push(status);
2305
+ }
2306
+ return { harness, path: target, status: combineStatuses(statuses) };
2307
+ }
2308
+
2309
+ // src/cli/commands/onboard.ts
2310
+ function suggestOtherHarnesses(installed) {
2311
+ const missing = ALL_HARNESSES.filter((h) => !installed.includes(h));
2312
+ if (missing.length === 0) return "";
2313
+ const labels = missing.map((h) => HARNESSES[h].label).join(" / ");
2314
+ const ids = missing.join(",");
2315
+ return `
2316
+ Also use ${labels}? Run: blazediff-agent onboard --harness ${ids}`;
2317
+ }
2318
+ async function promptForHarnesses() {
2319
+ const rl = promises$1.createInterface({
2320
+ input: process.stdin,
2321
+ output: process.stderr
2322
+ });
2323
+ try {
2324
+ const lines = [
2325
+ "No coding-agent harness detected. Which one(s) do you use?",
2326
+ ...ALL_HARNESSES.map(
2327
+ (id, i) => ` [${i + 1}] ${HARNESSES[id].label.padEnd(12)} ${HARNESSES[id].target(process.cwd())}`
2328
+ ),
2329
+ " [a] all three",
2330
+ ""
2331
+ ];
2332
+ process.stderr.write(`${lines.join("\n")}`);
2333
+ const answer = (await rl.question("Choice (1/2/3/a): ")).trim().toLowerCase();
2334
+ if (!answer) throw new Error("no harness selected; aborting");
2335
+ if (answer === "a" || answer === "all") return [...ALL_HARNESSES];
2336
+ const idx = Number(answer);
2337
+ if (!Number.isInteger(idx) || idx < 1 || idx > ALL_HARNESSES.length) {
2338
+ throw new Error(
2339
+ `invalid choice "${answer}"; expected 1-${ALL_HARNESSES.length} or "a"`
2340
+ );
2341
+ }
2342
+ return [ALL_HARNESSES[idx - 1]];
2343
+ } finally {
2344
+ rl.close();
2345
+ }
2346
+ }
2347
+ function humanizeResults(results) {
2348
+ const lines = results.map((r) => {
2349
+ const verb = r.status === "created" ? "wrote" : r.status === "updated" ? "updated" : r.status === "unchanged" ? "unchanged" : "skipped (exists; pass --force to overwrite)";
2350
+ const info = HARNESSES[r.harness];
2351
+ const scopeTag = info.scope === "user" ? " [user-global]" : "";
2352
+ return ` ${info.label.padEnd(12)} ${verb}: ${r.path}${scopeTag}`;
2353
+ });
2354
+ const installed = results.map((r) => r.harness);
2355
+ const hint = suggestOtherHarnesses(installed);
2356
+ return ["BlazeDiff playbook installed:", ...lines].join("\n") + hint;
2357
+ }
2358
+ function registerOnboard(program, out) {
2359
+ program.command("onboard").description(
2360
+ "install the BlazeDiff playbook into the coding-agent harness in cwd. Auto-detects Claude Code (.claude/), Codex (AGENTS.md), and Cursor (.cursor/). Prompts on TTY when none detected."
2361
+ ).option(
2362
+ "--harness <list>",
2363
+ 'comma-separated harness ids, or "all". valid: claude,codex,cursor,all'
2364
+ ).option(
2365
+ "--force",
2366
+ "overwrite existing playbook files (idempotent without --force only when content matches)"
2367
+ ).action(async (opts) => {
2368
+ const cwd = process.cwd();
2369
+ let targets;
2370
+ if (opts.harness) {
2371
+ targets = parseHarnessList(opts.harness);
2372
+ } else {
2373
+ const detected = detectHarnesses(cwd);
2374
+ if (detected.length > 0) {
2375
+ targets = detected;
2376
+ } else if (out.isTTY() && !out.isJson()) {
2377
+ targets = await promptForHarnesses();
2378
+ } else {
2379
+ throw new Error(
2380
+ "no coding-agent harness detected in cwd. pass --harness <claude|codex|cursor|all> explicitly."
2381
+ );
2382
+ }
2383
+ }
2384
+ const results = [];
2385
+ for (const t of targets) {
2386
+ results.push(await installHarness(t, cwd, { force: opts.force }));
2387
+ }
2388
+ out.emit(
2389
+ {
2390
+ ok: true,
2391
+ detected: detectHarnesses(cwd),
2392
+ installed: results
2393
+ },
2394
+ humanizeResults(results)
2395
+ );
2396
+ });
2397
+ }
2398
+ var execFileP = util.promisify(child_process.execFile);
2399
+ async function isPortOpen(port, host = "127.0.0.1") {
2400
+ return new Promise((resolve) => {
2401
+ const socket = net.createConnection({ port, host });
2402
+ socket.setTimeout(500);
2403
+ socket.once("connect", () => {
2404
+ socket.destroy();
2405
+ resolve(true);
2406
+ });
2407
+ socket.once("error", () => resolve(false));
2408
+ socket.once("timeout", () => {
2409
+ socket.destroy();
2410
+ resolve(false);
2411
+ });
2412
+ });
2413
+ }
2414
+ async function waitForPort(port, timeoutMs = 6e4) {
2415
+ const deadline = Date.now() + timeoutMs;
2416
+ while (Date.now() < deadline) {
2417
+ if (await isPortOpen(port)) return;
2418
+ await new Promise((r) => setTimeout(r, 250));
2419
+ }
2420
+ throw new Error(`dev server did not open port ${port} within ${timeoutMs}ms`);
2421
+ }
2422
+ async function findPidByPort(port) {
2423
+ const platform = process.platform;
2424
+ try {
2425
+ if (platform === "darwin" || platform === "linux") {
2426
+ const { stdout } = await execFileP("lsof", [
2427
+ "-ti",
2428
+ `tcp:${port}`,
2429
+ "-sTCP:LISTEN"
2430
+ ]);
2431
+ const pid = Number(stdout.trim().split("\n")[0]);
2432
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
2433
+ }
2434
+ if (platform === "win32") {
2435
+ const { stdout } = await execFileP("netstat", ["-ano"]);
2436
+ const line = stdout.split(/\r?\n/).find((l) => l.includes(`:${port} `) && l.includes("LISTENING"));
2437
+ if (!line) return null;
2438
+ const parts = line.trim().split(/\s+/);
2439
+ const pid = Number(parts[parts.length - 1]);
2440
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
2441
+ }
2442
+ } catch {
2443
+ return null;
2444
+ }
2445
+ return null;
2446
+ }
2447
+ async function startServer(opts) {
2448
+ const cwd = opts.cwd ?? process.cwd();
2449
+ const logPath = opts.logPath ?? paths(cwd).serverLog;
2450
+ const pidPath = opts.pidPath ?? paths(cwd).serverPid;
2451
+ await promises.mkdir(path2__default.default.dirname(logPath), { recursive: true });
2452
+ if (await isPortOpen(opts.port)) {
2453
+ const discoveredPid = await findPidByPort(opts.port);
2454
+ if (discoveredPid) {
2455
+ await promises.writeFile(pidPath, String(discoveredPid), "utf8").catch(() => {
2456
+ });
2457
+ }
2458
+ return {
2459
+ pid: discoveredPid ?? 0,
2460
+ port: opts.port,
2461
+ url: `http://127.0.0.1:${opts.port}`,
2462
+ attached: true
2463
+ };
2464
+ }
2465
+ const [bin, ...args] = parseCommand(opts.command);
2466
+ const child = child_process.spawn(bin, args, {
2467
+ cwd,
2468
+ stdio: ["ignore", "pipe", "pipe"],
2469
+ detached: true,
2470
+ env: { ...process.env, FORCE_COLOR: "0", CI: "1" }
2471
+ });
2472
+ const logStream = await import('fs').then(
2473
+ (m) => m.createWriteStream(logPath, { flags: "a" })
2474
+ );
2475
+ child.stdout?.pipe(logStream);
2476
+ child.stderr?.pipe(logStream);
2477
+ if (!child.pid) throw new Error("failed to spawn dev server");
2478
+ await promises.writeFile(pidPath, String(child.pid), "utf8");
2479
+ installSignalHandlers(child);
2480
+ try {
2481
+ await waitForPort(opts.port, opts.readyTimeoutMs ?? 6e4);
2482
+ } catch (err) {
2483
+ await stopProcess(child.pid);
2484
+ throw err;
2485
+ }
2486
+ return {
2487
+ pid: child.pid,
2488
+ port: opts.port,
2489
+ url: `http://127.0.0.1:${opts.port}`
2490
+ };
2491
+ }
2492
+ async function stopServer(cwd = process.cwd(), portFallback) {
2493
+ const pidPath = paths(cwd).serverPid;
2494
+ let pid = null;
2495
+ let via = "none";
2496
+ if (fs.existsSync(pidPath)) {
2497
+ const raw = (await promises.readFile(pidPath, "utf8")).trim();
2498
+ const parsed = Number(raw);
2499
+ if (Number.isFinite(parsed) && parsed > 0 && processExists(parsed)) {
2500
+ pid = parsed;
2501
+ via = "pidfile";
2502
+ }
2503
+ }
2504
+ if (!pid && portFallback) {
2505
+ pid = await findPidByPort(portFallback);
2506
+ if (pid) via = "port";
2507
+ }
2508
+ if (!pid) {
2509
+ await promises.writeFile(pidPath, "", "utf8").catch(() => {
2510
+ });
2511
+ return { killed: false, pid: null, via: "none" };
2512
+ }
2513
+ await stopProcess(pid);
2514
+ await promises.writeFile(pidPath, "", "utf8").catch(() => {
2515
+ });
2516
+ return { killed: true, pid, via };
2517
+ }
2518
+ function processExists(pid) {
2519
+ try {
2520
+ process.kill(pid, 0);
2521
+ return true;
2522
+ } catch {
2523
+ return false;
2524
+ }
2525
+ }
2526
+ async function stopProcess(pid) {
2527
+ if (!pid) return;
2528
+ await new Promise((resolve) => {
2529
+ treeKill__default.default(pid, "SIGTERM", (err) => {
2530
+ if (err) {
2531
+ treeKill__default.default(pid, "SIGKILL", () => resolve());
2532
+ return;
2533
+ }
2534
+ resolve();
2535
+ });
2536
+ });
2537
+ }
2538
+ function parseCommand(command) {
2539
+ const out = [];
2540
+ let current = "";
2541
+ let inQuote = false;
2542
+ for (const ch of command) {
2543
+ if (ch === '"') {
2544
+ inQuote = !inQuote;
2545
+ continue;
2546
+ }
2547
+ if (ch === " " && !inQuote) {
2548
+ if (current) {
2549
+ out.push(current);
2550
+ current = "";
2551
+ }
2552
+ continue;
2553
+ }
2554
+ current += ch;
2555
+ }
2556
+ if (current) out.push(current);
2557
+ return out;
2558
+ }
2559
+ var signalsInstalled = false;
2560
+ function installSignalHandlers(child) {
2561
+ if (signalsInstalled) return;
2562
+ signalsInstalled = true;
2563
+ const cleanup = () => {
2564
+ if (child.pid) {
2565
+ try {
2566
+ process.kill(-child.pid, "SIGTERM");
2567
+ } catch {
2568
+ }
2569
+ treeKill__default.default(child.pid, "SIGKILL", () => {
2570
+ });
2571
+ }
2572
+ };
2573
+ process.on("SIGINT", () => {
2574
+ cleanup();
2575
+ process.exit(130);
2576
+ });
2577
+ process.on("SIGTERM", () => {
2578
+ cleanup();
2579
+ process.exit(143);
2580
+ });
2581
+ process.on("exit", cleanup);
2582
+ }
2583
+
2584
+ // src/cli/commands/reset.ts
2585
+ async function stopTrackedServer() {
2586
+ const config = await loadConfig();
2587
+ if (!config?.devServer) return { stopped: false };
2588
+ try {
2589
+ const result = await stopServer(process.cwd(), config.devServer.port);
2590
+ return { stopped: result.killed, via: result.via, pid: result.pid };
2591
+ } catch {
2592
+ return { stopped: false };
2593
+ }
2594
+ }
2595
+ function registerReset(program, out) {
2596
+ program.command("reset").description(
2597
+ "wipe .blazediff/ entirely - config, manifest, baselines, actual, judgments, summary, pid/log (stops the dev server first if one is tracked). Re-run /blazediff or `init` afterward to start from scratch."
2598
+ ).option("--yes", "do not prompt; required when stdin is a TTY").action(async (opts) => {
2599
+ const root = paths().root;
2600
+ if (!fs.existsSync(root)) {
2601
+ out.emit(
2602
+ { ok: true, removed: false, root },
2603
+ `nothing to reset (no ${root})`
2604
+ );
2605
+ return;
2606
+ }
2607
+ if (out.isTTY() && !opts.yes && !out.isJson()) {
2608
+ throw new Error(
2609
+ `refusing to wipe ${root} without --yes (interactive run)`
2610
+ );
2611
+ }
2612
+ const stopOutcome = await stopTrackedServer();
2613
+ await promises.rm(root, { recursive: true, force: true });
2614
+ out.emit(
2615
+ { ok: true, removed: true, root, devServer: stopOutcome },
2616
+ stopOutcome.stopped ? `stopped dev server (pid ${stopOutcome.pid} via ${stopOutcome.via}) and removed ${root}` : `removed ${root}`
2617
+ );
2618
+ });
2619
+ }
2620
+ async function resolveTargets(manifest, ids, opts) {
2621
+ const exclusive = [
2622
+ ids.length > 0,
2623
+ Boolean(opts.failed),
2624
+ Boolean(opts.all)
2625
+ ].filter(Boolean).length;
2626
+ if (exclusive === 0) throw new Error("provide ids, --failed, or --all");
2627
+ if (exclusive > 1)
2628
+ throw new Error("ids / --failed / --all are mutually exclusive");
2629
+ if (opts.all) return new Set(manifest.entries.map((e) => e.id));
2630
+ if (opts.failed) {
2631
+ const judgmentsDir = paths().judgments;
2632
+ if (!fs.existsSync(judgmentsDir)) {
2633
+ throw new Error(
2634
+ `no judgments at ${judgmentsDir}. Run \`blazediff-agent check\` first.`
2635
+ );
2636
+ }
2637
+ const names = await promises.readdir(judgmentsDir);
2638
+ const failed = /* @__PURE__ */ new Set();
2639
+ for (const name of names) {
2640
+ const full = path2__default.default.join(judgmentsDir, name);
2641
+ if (fs.statSync(full).isDirectory()) failed.add(name);
2642
+ }
2643
+ return failed;
2644
+ }
2645
+ const targets = new Set(ids);
2646
+ const missing = ids.filter(
2647
+ (id) => !manifest.entries.some((e) => e.id === id)
2648
+ );
2649
+ if (missing.length) throw new Error(`unknown ids: ${missing.join(", ")}`);
2650
+ return targets;
2651
+ }
2652
+ function registerRewrite(program, out) {
2653
+ program.command("rewrite [ids...]").description(
2654
+ "rewrite baselines for existing manifest entries, preserving mask/viewport/etc. Pick targets via positional ids, --failed (uses .blazediff/judgments/ from last check), or --all."
2655
+ ).option("--failed", "rewrite entries that failed the most recent check").option("--all", "rewrite every manifest entry").option("--base-url <url>", "override base URL").action(async (ids, opts) => {
2656
+ const manifest = await loadManifest();
2657
+ if (!manifest) throw new Error("no manifest. Run authoring first.");
2658
+ const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
2659
+ const targets = await resolveTargets(manifest, ids, opts);
2660
+ if (targets.size === 0 && opts.failed) {
2661
+ out.emit({ ok: true, rewritten: 0 }, "no failed entries to rewrite");
2662
+ return;
2663
+ }
2664
+ const routes = manifest.entries.filter((e) => targets.has(e.id)).map((e) => ({
2665
+ id: e.id,
2666
+ url: e.url,
2667
+ mask: e.mask,
2668
+ viewport: e.viewport,
2669
+ waitFor: e.waitFor,
2670
+ fullPage: e.fullPage
2671
+ }));
2672
+ const report = await runCaptures({
2673
+ baseUrl,
2674
+ routes,
2675
+ mode: "baseline",
2676
+ writeManifest: true
2677
+ });
2678
+ const failureLines3 = report.results.filter((r) => !r.ok).map((r) => ` \u2717 ${r.id}: ${r.error ?? "failed"}`);
2679
+ const human = report.failed === 0 ? `rewrote ${report.succeeded}/${report.total} baseline${report.total === 1 ? "" : "s"}` : [
2680
+ `rewrote ${report.succeeded}/${report.total} (${report.failed} failed):`,
2681
+ ...failureLines3
2682
+ ].join("\n");
2683
+ out.emit(report, human);
2684
+ if (report.failed > 0) process.exitCode = 1;
2685
+ });
2686
+ }
2687
+
2688
+ // src/graph/nodes/aggregate.ts
2689
+ async function aggregateNode(state) {
2690
+ const options = state.options;
2691
+ if (!options) throw new Error("aggregateNode: options missing");
2692
+ const manifest = state.manifest;
2693
+ if (!manifest) throw new Error("aggregateNode: manifest missing");
2694
+ const results = state.results;
2695
+ const passed = results.filter((r) => r.status === "pass").length;
2696
+ const pendingJudgments = results.filter(
2697
+ (r) => r.status === "needs-judgment"
2698
+ ).length;
2699
+ const report = {
2700
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2701
+ totalEntries: results.length,
2702
+ passed,
2703
+ failed: results.length - passed - pendingJudgments,
2704
+ pendingJudgments,
2705
+ results
2706
+ };
2707
+ await writeJudgments({ report, manifest, cwd: options.cwd });
2708
+ await writeSummaryMarkdown(report, options.cwd);
2709
+ await ensureGitignore(options.cwd);
2710
+ return { report };
2711
+ }
2712
+
2713
+ // src/graph/nodes/load.ts
2714
+ async function loadNode(state) {
2715
+ if (!state.options) {
2716
+ throw new Error("loadNode: graph options missing");
2717
+ }
2718
+ const manifest = await loadManifest(state.options.cwd);
2719
+ if (!manifest) {
2720
+ throw new Error(
2721
+ `no manifest found at ${paths(state.options.cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
2722
+ );
2723
+ }
2724
+ return { entries: manifest.entries, manifest };
2725
+ }
2726
+ function narrowRegion2(r) {
2727
+ return {
2728
+ bbox: r.bbox,
2729
+ pixelCount: r.pixelCount,
2730
+ percentage: r.percentage,
2731
+ changeType: r.changeType,
2732
+ confidence: r.confidence
2733
+ };
2734
+ }
2735
+ function skipResult2(entry, message) {
2736
+ return { id: entry.id, url: entry.url, status: "pass", message };
2737
+ }
2738
+ function staleResult2(entry) {
2739
+ return {
2740
+ id: entry.id,
2741
+ url: entry.url,
2742
+ status: "stale-baseline",
2743
+ message: "captureHash mismatch: entry was edited without re-capturing"
2744
+ };
2745
+ }
2746
+ function passResult3(entry, baselinePath, actualPath) {
2747
+ return {
2748
+ id: entry.id,
2749
+ url: entry.url,
2750
+ status: "pass",
2751
+ baselinePath,
2752
+ actualPath
2753
+ };
2754
+ }
2755
+ function missingBaselineResult2(entry, baselinePath) {
2756
+ return {
2757
+ id: entry.id,
2758
+ url: entry.url,
2759
+ status: "missing-baseline",
2760
+ message: `baseline missing at ${baselinePath}`
2761
+ };
2762
+ }
2763
+ function failResult2(entry, outcome, actualPath, baselinePath, verdict) {
2764
+ return {
2765
+ id: entry.id,
2766
+ url: entry.url,
2767
+ status: "fail",
2768
+ diffCount: outcome.diffCount,
2769
+ diffPercentage: outcome.diffPercentage,
2770
+ severity: outcome.interpretation?.severity,
2771
+ regions: outcome.interpretation?.regions?.map(narrowRegion2),
2772
+ verdict,
2773
+ diffPath: outcome.diffPath,
2774
+ baselinePath,
2775
+ actualPath,
2776
+ message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
2777
+ };
2778
+ }
2779
+ function makeProcessNode(semaphore) {
2780
+ return async function processNode(state) {
2781
+ const entry = state.entry;
2782
+ const options = state.options;
2783
+ if (!entry || !options) {
2784
+ throw new Error("processNode: entry or options missing");
2785
+ }
2786
+ if (entry.auth === "required") {
2787
+ return {
2788
+ results: [
2789
+ skipResult2(entry, "skipped: auth required (deferred to v0.2)")
2790
+ ]
2791
+ };
2792
+ }
2793
+ if (isEntryStale(entry)) {
2794
+ return { results: [staleResult2(entry)] };
2795
+ }
2796
+ const capture = await semaphore.run(
2797
+ () => captureScreenshot(
2798
+ options.baseUrl,
2799
+ {
2800
+ id: entry.id,
2801
+ url: entry.url,
2802
+ viewport: entry.viewport,
2803
+ mask: entry.mask,
2804
+ waitFor: entry.waitFor,
2805
+ fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
2806
+ mode: "actual"
2807
+ },
2808
+ options.cwd
2809
+ )
2810
+ );
2811
+ const baselinePath = path2__default.default.join(options.baselinesDir, `${entry.id}.png`);
2812
+ const outcome = await diffEntry(
2813
+ entry.id,
2814
+ baselinePath,
2815
+ capture.outputPath,
2816
+ { threshold: options.threshold, emitDiffPng: options.emitDiffPng },
2817
+ options.cwd
2818
+ );
2819
+ if (outcome.match) {
2820
+ return { results: [passResult3(entry, baselinePath, capture.outputPath)] };
2821
+ }
2822
+ if (outcome.reason === "file-not-exists") {
2823
+ return { results: [missingBaselineResult2(entry, baselinePath)] };
2824
+ }
2825
+ const verdict = deriveVerdict({
2826
+ reason: outcome.reason,
2827
+ interpretation: outcome.interpretation,
2828
+ diffCount: outcome.diffCount,
2829
+ diffPercentage: outcome.diffPercentage
2830
+ });
2831
+ let result = failResult2(
2832
+ entry,
2833
+ outcome,
2834
+ capture.outputPath,
2835
+ baselinePath,
2836
+ verdict
2837
+ );
2838
+ if (result.verdict?.label === "ambiguous" && result.baselinePath && result.actualPath) {
2839
+ const judge = resolveJudge(options.judge);
2840
+ const output = await judge.judge(
2841
+ {
2842
+ entry,
2843
+ baselinePath: result.baselinePath,
2844
+ actualPath: result.actualPath,
2845
+ diffPath: result.diffPath,
2846
+ regions: result.regions,
2847
+ diffPercentage: result.diffPercentage,
2848
+ severity: result.severity,
2849
+ heuristicVerdict: result.verdict
2850
+ },
2851
+ options.cwd
2852
+ );
2853
+ if (output.kind === "judged") {
2854
+ result = { ...result, verdict: output.verdict };
2855
+ } else {
2856
+ result = {
2857
+ ...result,
2858
+ status: "needs-judgment",
2859
+ message: `awaiting judgment in ${output.requestPath}`
2860
+ };
2861
+ }
2862
+ }
2863
+ return { results: [result] };
2864
+ };
2865
+ }
2866
+
2867
+ // src/graph/semaphore.ts
2868
+ var Semaphore = class {
2869
+ constructor(limit) {
2870
+ this.limit = limit;
2871
+ this.current = 0;
2872
+ this.queue = [];
2873
+ if (limit < 1)
2874
+ throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
2875
+ }
2876
+ async run(fn) {
2877
+ if (this.current >= this.limit) {
2878
+ await new Promise((resolve) => this.queue.push(resolve));
2879
+ }
2880
+ this.current++;
2881
+ try {
2882
+ return await fn();
2883
+ } finally {
2884
+ this.current--;
2885
+ const next = this.queue.shift();
2886
+ if (next) next();
2887
+ }
2888
+ }
2889
+ };
2890
+ var GraphState = langgraph.Annotation.Root({
2891
+ options: langgraph.Annotation({
2892
+ reducer: (acc, next) => next ?? acc,
2893
+ default: () => void 0
2894
+ }),
2895
+ entries: langgraph.Annotation({
2896
+ reducer: (acc, next) => next ?? acc,
2897
+ default: () => []
2898
+ }),
2899
+ entry: langgraph.Annotation({
2900
+ reducer: (_, next) => next,
2901
+ default: () => void 0
2902
+ }),
2903
+ results: langgraph.Annotation({
2904
+ reducer: (acc, next) => [...acc, ...next],
2905
+ default: () => []
2906
+ }),
2907
+ manifest: langgraph.Annotation({
2908
+ reducer: (acc, next) => next ?? acc,
2909
+ default: () => void 0
2910
+ }),
2911
+ report: langgraph.Annotation({
2912
+ reducer: (acc, next) => next ?? acc,
2913
+ default: () => void 0
2914
+ })
2915
+ });
2916
+
2917
+ // src/graph/index.ts
2918
+ function buildGraph(semaphore) {
2919
+ return new langgraph.StateGraph(GraphState).addNode("load", loadNode).addNode("process", makeProcessNode(semaphore)).addNode("aggregate", aggregateNode).addEdge(langgraph.START, "load").addConditionalEdges(
2920
+ "load",
2921
+ (state) => state.entries.map(
2922
+ (entry) => new langgraph.Send("process", { entry, options: state.options })
2923
+ ),
2924
+ ["process"]
2925
+ ).addEdge("process", "aggregate").addEdge("aggregate", langgraph.END).compile();
2926
+ }
2927
+ async function runGraph(opts) {
2928
+ const cwd = opts.cwd ?? process.cwd();
2929
+ const concurrency = opts.concurrency ?? defaultConcurrency();
2930
+ const semaphore = new Semaphore(concurrency);
2931
+ const baselinesDir = paths(cwd).baselines;
2932
+ const graph = buildGraph(semaphore);
2933
+ let finalState;
2934
+ try {
2935
+ finalState = await graph.invoke({
2936
+ options: {
2937
+ baseUrl: opts.baseUrl,
2938
+ cwd,
2939
+ threshold: opts.threshold,
2940
+ concurrency,
2941
+ emitDiffPng: opts.emitDiffPng ?? true,
2942
+ judge: opts.judge ?? "none",
2943
+ baselinesDir
2944
+ }
2945
+ });
2946
+ } finally {
2947
+ await closeBrowser();
2948
+ }
2949
+ const report = finalState.report;
2950
+ if (!report) {
2951
+ throw new Error("runGraph: graph completed without producing a report");
2952
+ }
2953
+ if (opts.junitPath) {
2954
+ const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
2955
+ await writeJunit(report, target);
2956
+ }
2957
+ return report;
2958
+ }
2959
+
2960
+ // src/cli/commands/run.ts
2961
+ function slimResult2(r) {
2962
+ return {
2963
+ id: r.id,
2964
+ url: r.url,
2965
+ status: r.status,
2966
+ verdict: r.verdict ? {
2967
+ label: r.verdict.label,
2968
+ headline: r.verdict.headline,
2969
+ action: r.verdict.action
2970
+ } : void 0
2971
+ };
2972
+ }
2973
+ function slimReport2(report, summaryPath) {
2974
+ return {
2975
+ summaryPath,
2976
+ createdAt: report.createdAt,
2977
+ totalEntries: report.totalEntries,
2978
+ passed: report.passed,
2979
+ failed: report.failed,
2980
+ pendingJudgments: report.pendingJudgments,
2981
+ results: report.results.filter((r) => r.status !== "pass").map(slimResult2)
2982
+ };
2983
+ }
2984
+ function failureLines2(results) {
2985
+ return results.filter((r) => r.status !== "pass").flatMap((r) => {
2986
+ const lines = [];
2987
+ const prefix = r.status === "needs-judgment" ? "?" : "\u2717";
2988
+ if (r.verdict) {
2989
+ lines.push(
2990
+ ` ${prefix} ${r.id} [${r.verdict.label}] ${r.verdict.headline}`
2991
+ );
2992
+ lines.push(` \u2192 ${r.verdict.action}`);
2993
+ } else {
2994
+ const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
2995
+ lines.push(` ${prefix} ${r.id}: ${detail}`);
2996
+ }
2997
+ if (r.status === "needs-judgment" && r.message) {
2998
+ lines.push(` ${r.message}`);
2999
+ }
3000
+ if (r.diffPath) lines.push(` diff: ${r.diffPath}`);
3001
+ return lines;
3002
+ });
3003
+ }
3004
+ function parseJudge2(input) {
3005
+ if (input === "host" || input === "none") return input;
3006
+ throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
3007
+ }
3008
+ function parseMode(input) {
3009
+ if (input === "actual") return input;
3010
+ if (input === "baseline") {
3011
+ throw new Error(
3012
+ "`run --mode baseline` is not yet implemented. Use `init` + `capture` for authoring."
3013
+ );
3014
+ }
3015
+ throw new Error(`unknown --mode: ${input} (expected: actual)`);
3016
+ }
3017
+ function registerRun(program, out) {
3018
+ program.command("run").description(
3019
+ "streaming check pipeline via LangGraph (alternative to `check`)"
3020
+ ).option("--mode <mode>", "pipeline mode (actual)", "actual").option("--base-url <url>", "override base URL").option(
3021
+ "--threshold <n>",
3022
+ "color threshold (0-1)",
3023
+ String(DEFAULT_THRESHOLD)
3024
+ ).option(
3025
+ "--concurrency <n>",
3026
+ "max entries captured in parallel (default: auto based on CPU cores, capped at 8)"
3027
+ ).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
3028
+ "--judge <backend>",
3029
+ "judge backend for ambiguous diffs (host | none)",
3030
+ "none"
3031
+ ).action(async (opts) => {
3032
+ parseMode(opts.mode);
3033
+ const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
3034
+ const report = await runGraph({
3035
+ baseUrl,
3036
+ threshold: Number(opts.threshold),
3037
+ concurrency: opts.concurrency ? Number(opts.concurrency) : void 0,
3038
+ emitDiffPng: opts.diffPng,
3039
+ junitPath: opts.junit,
3040
+ judge: parseJudge2(opts.judge)
3041
+ });
3042
+ const summaryPath = paths().summary;
3043
+ const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
3044
+ const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
3045
+ summary: ${summaryPath}` : [
3046
+ `${summary}:`,
3047
+ ...failureLines2(report.results),
3048
+ ` summary: ${summaryPath}`,
3049
+ report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
3050
+ ].filter(Boolean).join("\n");
3051
+ out.emit(slimReport2(report, summaryPath), human);
3052
+ if (report.failed > 0) process.exitCode = 1;
3053
+ });
3054
+ }
3055
+
3056
+ // src/cli/commands/serve-status.ts
3057
+ function registerServeStatus(program, out) {
3058
+ program.command("serve-status").description("start/stop the configured dev server").option("--start", "start (default)").option(
3059
+ "--detach",
3060
+ "alias for --start; waits up to readyTimeoutMs for the port"
3061
+ ).option("--kill", "stop the dev server").option("--port <n>", "override port from config").action(async (opts) => {
3062
+ const config = await loadConfig();
3063
+ if (!config) {
3064
+ throw new Error(
3065
+ "no .blazediff/config.json. Run `blazediff-agent init` first."
3066
+ );
3067
+ }
3068
+ if (!config.devServer) {
3069
+ out.emit(
3070
+ { ok: true, external: true, baseUrl: config.baseUrl },
3071
+ `external base URL (${config.baseUrl}); no dev server managed`
3072
+ );
3073
+ return;
3074
+ }
3075
+ const port = opts.port ? Number(opts.port) : config.devServer.port;
3076
+ if (opts.kill) {
3077
+ const result = await stopServer(process.cwd(), port);
3078
+ const human = result.killed ? `dev server stopped (pid ${result.pid} via ${result.via})` : `no dev server found to stop on :${port}`;
3079
+ out.emit({ ok: true, stopped: result.killed, ...result }, human);
3080
+ return;
3081
+ }
3082
+ if (await isPortOpen(port)) {
3083
+ out.emit(
3084
+ { ok: true, attached: true, port },
3085
+ `dev server already up on :${port}`
3086
+ );
3087
+ return;
3088
+ }
3089
+ const handle = await startServer({
3090
+ command: config.devServer.command,
3091
+ port,
3092
+ readyTimeoutMs: config.devServer.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS
3093
+ });
3094
+ out.emit(
3095
+ { ok: true, ...handle },
3096
+ `dev server up on :${handle.port} (pid ${handle.pid})`
3097
+ );
3098
+ });
3099
+ }
3100
+ function extractCwdArg(argv) {
3101
+ for (let i = 0; i < argv.length; i++) {
3102
+ const a = argv[i];
3103
+ if (a === "-C" || a === "--cwd") return argv[i + 1] ?? null;
3104
+ if (a.startsWith("--cwd=")) return a.slice("--cwd=".length);
3105
+ if (a.startsWith("-C=")) return a.slice("-C=".length);
3106
+ }
3107
+ return null;
3108
+ }
3109
+ function applyCwdFromArgv() {
3110
+ const value = extractCwdArg(process.argv.slice(2));
3111
+ if (!value) return;
3112
+ const resolved = path2__default.default.resolve(value);
3113
+ if (fs.existsSync(resolved)) {
3114
+ process.chdir(resolved);
3115
+ return;
3116
+ }
3117
+ const cwdBase = path2__default.default.basename(process.cwd());
3118
+ const inputBase = path2__default.default.basename(value);
3119
+ const doubled = cwdBase && inputBase && cwdBase === inputBase && resolved.endsWith(path2__default.default.join(cwdBase, cwdBase));
3120
+ if (doubled) {
3121
+ throw new Error(
3122
+ `--cwd "${value}" resolves to ${resolved} which does not exist. Looks like you may already be inside that directory - re-run with --cwd as an absolute path, or from the parent.`
3123
+ );
3124
+ }
3125
+ throw new Error(`--cwd path does not exist: ${resolved}`);
3126
+ }
3127
+ function maybeDefaultToCheck() {
3128
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith("-"));
3129
+ if (positional.length) return;
3130
+ if (!fs.existsSync(paths().manifest)) return;
3131
+ process.argv = [
3132
+ process.argv[0],
3133
+ process.argv[1],
3134
+ "check",
3135
+ ...process.argv.slice(2)
3136
+ ];
3137
+ }
3138
+
3139
+ // src/cli/output.ts
3140
+ function makeOutput(getRootOpts) {
3141
+ const isJson = () => Boolean(getRootOpts().json);
3142
+ const isQuiet = () => Boolean(getRootOpts().quiet);
3143
+ const isTTY = () => Boolean(process.stdout.isTTY);
3144
+ const emit = (payload, human) => {
3145
+ if (isQuiet()) return;
3146
+ if (isJson()) {
3147
+ process.stdout.write(`${JSON.stringify(payload)}
3148
+ `);
3149
+ return;
3150
+ }
3151
+ if (human) process.stdout.write(`${human}
3152
+ `);
3153
+ };
3154
+ return { isJson, isQuiet, isTTY, emit };
3155
+ }
3156
+
3157
+ // src/cli.ts
3158
+ function buildProgram() {
3159
+ const program = new commander.Command().name("blazediff-agent").description("Agentic visual regression for BlazeDiff").version("0.1.2").option("-C, --cwd <path>", "operate on a different directory").option("--json", "emit JSON to stdout where applicable").option("--quiet", "suppress non-error output");
3160
+ const out = makeOutput(() => program.opts());
3161
+ registerOnboard(program, out);
3162
+ registerInit(program, out);
3163
+ registerDiscover(program, out);
3164
+ registerServeStatus(program, out);
3165
+ registerCapture(program, out);
3166
+ registerBrowsers(program, out);
3167
+ registerDiff(program, out);
3168
+ registerManifest(program, out);
3169
+ registerCheck(program, out);
3170
+ registerRun(program, out);
3171
+ registerRewrite(program, out);
3172
+ registerReset(program, out);
3173
+ return program;
3174
+ }
3175
+ async function main() {
3176
+ try {
3177
+ applyCwdFromArgv();
3178
+ } catch (err) {
3179
+ process.stderr.write(`${err.message}
3180
+ `);
3181
+ process.exitCode = 1;
3182
+ return;
3183
+ }
3184
+ maybeDefaultToCheck();
3185
+ const program = buildProgram();
3186
+ try {
3187
+ await program.parseAsync();
3188
+ } catch (err) {
3189
+ const message = err instanceof Error ? err.message : String(err);
3190
+ const json = Boolean(program.opts().json);
3191
+ if (json) {
3192
+ process.stdout.write(
3193
+ `${JSON.stringify({ ok: false, error: message })}
3194
+ `
3195
+ );
3196
+ } else {
3197
+ process.stderr.write(`error: ${message}
3198
+ `);
3199
+ }
3200
+ process.exitCode = 1;
3201
+ } finally {
3202
+ await closeBrowser();
3203
+ }
3204
+ }
3205
+ void main();