@blazediff/agent 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promises';
1
+ import { mkdir, readFile, writeFile, access, readdir, stat, rm, rename } from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { availableParallelism, cpus } from 'os';
4
4
  import { chromium } from 'playwright';
@@ -6,9 +6,10 @@ import { spawn } from 'child_process';
6
6
  import { existsSync } from 'fs';
7
7
  import { createRequire } from 'module';
8
8
  import { createHash } from 'crypto';
9
- import { compare, interpret } from '@blazediff/core-native';
9
+ import { compare } from '@blazediff/core-native';
10
+ import { Annotation, Command, StateGraph, START, Send, END, interrupt } from '@langchain/langgraph';
10
11
  import sharp from 'sharp';
11
- import { Annotation, StateGraph, START, Send, END } from '@langchain/langgraph';
12
+ import { BaseCheckpointSaver, TASKS, maxChannelVersion, getCheckpointId, copyCheckpoint, WRITES_IDX_MAP } from '@langchain/langgraph-checkpoint';
12
13
 
13
14
  // src/browser/capture.ts
14
15
  var DEFAULT_VIEWPORT = { width: 1280, height: 800 };
@@ -35,6 +36,7 @@ var paths = (cwd = process.cwd()) => {
35
36
  baselines: path.join(root, "baselines"),
36
37
  actual: path.join(root, "actual"),
37
38
  judgments: path.join(root, "judgments"),
39
+ checkpoints: path.join(root, "checkpoints"),
38
40
  summary: path.join(root, "summary.md"),
39
41
  gitignore: path.join(root, ".gitignore"),
40
42
  serverLog: path.join(root, "dev-server.log"),
@@ -65,6 +67,7 @@ var CHROMIUM_FLAGS = [
65
67
  ];
66
68
  var cachedBrowser = null;
67
69
  var launchInFlight = null;
70
+ var contextPool = /* @__PURE__ */ new Map();
68
71
  async function getBrowser() {
69
72
  if (cachedBrowser?.isConnected()) return cachedBrowser;
70
73
  if (launchInFlight) return launchInFlight;
@@ -81,15 +84,22 @@ async function closeBrowser() {
81
84
  await launchInFlight.catch(() => {
82
85
  });
83
86
  }
87
+ const ctxs = Array.from(contextPool.values()).flat();
88
+ contextPool.clear();
89
+ await Promise.all(ctxs.map((c) => c.close().catch(() => {
90
+ })));
84
91
  if (!cachedBrowser) return;
85
92
  await cachedBrowser.close().catch(() => {
86
93
  });
87
94
  cachedBrowser = null;
88
95
  }
89
- async function openStableContext(opts) {
96
+ function viewportKey(v) {
97
+ return `${v.width}x${v.height}`;
98
+ }
99
+ async function createStableContext(viewport) {
90
100
  const browser = await getBrowser();
91
101
  const context = await browser.newContext({
92
- viewport: opts.viewport,
102
+ viewport,
93
103
  deviceScaleFactor: 1,
94
104
  reducedMotion: "reduce",
95
105
  forcedColors: "none",
@@ -134,12 +144,39 @@ async function openStableContext(opts) {
134
144
  },
135
145
  { frozenNow: FROZEN_NOW }
136
146
  );
137
- const page = await context.newPage();
147
+ return context;
148
+ }
149
+ async function acquireStableContext(viewport) {
150
+ const key = viewportKey(viewport);
151
+ const pool = contextPool.get(key);
152
+ if (pool && pool.length > 0) {
153
+ const context2 = pool.pop();
154
+ return { context: context2, viewport };
155
+ }
156
+ const context = await createStableContext(viewport);
157
+ return { context, viewport };
158
+ }
159
+ async function releaseStableContext(handle) {
160
+ const key = viewportKey(handle.viewport);
161
+ if (!cachedBrowser?.isConnected()) {
162
+ await handle.context.close().catch(() => {
163
+ });
164
+ return;
165
+ }
166
+ const pool = contextPool.get(key);
167
+ if (pool) {
168
+ pool.push(handle.context);
169
+ } else {
170
+ contextPool.set(key, [handle.context]);
171
+ }
172
+ }
173
+ async function openStablePage(handle) {
174
+ const page = await handle.context.newPage();
138
175
  const injectStability = () => page.addStyleTag({ content: STABILITY_CSS }).catch(() => {
139
176
  });
140
177
  await injectStability();
141
178
  page.on("load", injectStability);
142
- return { context, page };
179
+ return page;
143
180
  }
144
181
  async function waitForStability(page, waitFor) {
145
182
  for (const w of waitFor) {
@@ -189,7 +226,8 @@ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
189
226
  const waitFor = opts.waitFor ?? DEFAULT_WAIT_FOR;
190
227
  const masks = opts.mask ?? [];
191
228
  const fullPage = opts.fullPage ?? DEFAULT_FULL_PAGE;
192
- const { context, page } = await openStableContext({ viewport});
229
+ const handle = await acquireStableContext(viewport);
230
+ const page = await openStablePage(handle);
193
231
  try {
194
232
  const url = new URL(opts.url, baseUrl).toString();
195
233
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
@@ -211,8 +249,9 @@ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
211
249
  });
212
250
  return { id: opts.id, outputPath, mode: opts.mode, bytes: buffer.length };
213
251
  } finally {
214
- await context.close().catch(() => {
252
+ await page.close().catch(() => {
215
253
  });
254
+ await releaseStableContext(handle);
216
255
  }
217
256
  }
218
257
  function resolvePlaywrightCli() {
@@ -283,6 +322,30 @@ function resolveBaseUrl(config, override) {
283
322
  throw new Error("no baseUrl: pass --base-url or run `blazediff-agent init`");
284
323
  }
285
324
 
325
+ // src/graph/semaphore.ts
326
+ var Semaphore = class {
327
+ constructor(limit) {
328
+ this.limit = limit;
329
+ this.current = 0;
330
+ this.queue = [];
331
+ if (limit < 1)
332
+ throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
333
+ }
334
+ async run(fn) {
335
+ if (this.current >= this.limit) {
336
+ await new Promise((resolve) => this.queue.push(resolve));
337
+ }
338
+ this.current++;
339
+ try {
340
+ return await fn();
341
+ } finally {
342
+ this.current--;
343
+ const next = this.queue.shift();
344
+ if (next) next();
345
+ }
346
+ }
347
+ };
348
+
286
349
  // src/types.ts
287
350
  var STABILITY_HOOKS_VERSION = 1;
288
351
 
@@ -402,55 +465,62 @@ async function runCaptures(opts) {
402
465
  });
403
466
  let manifest = writeManifest ? await loadOrCreateManifest(cwd) : null;
404
467
  let manifestUpdates = 0;
468
+ const slot = new Array(valid.length);
469
+ const semaphore = new Semaphore(opts.concurrency ?? defaultConcurrency());
405
470
  try {
406
- for (const r of valid) {
407
- const mode = r.mode ?? defaultMode;
408
- try {
409
- const shot = await captureScreenshot(
410
- opts.baseUrl,
411
- {
412
- id: r.id,
413
- url: r.url,
414
- viewport: r.viewport,
415
- mask: r.mask,
416
- waitFor: r.waitFor,
417
- fullPage: r.fullPage,
418
- mode
419
- },
420
- cwd
421
- );
422
- results.push({
423
- id: r.id,
424
- url: r.url,
425
- mode,
426
- ok: true,
427
- outputPath: shot.outputPath,
428
- bytes: shot.bytes
429
- });
430
- if (manifest && mode === "baseline") {
431
- manifest = addOrReplaceEntry(
432
- manifest,
433
- makeEntry({
471
+ await Promise.all(
472
+ valid.map(
473
+ (r, i) => semaphore.run(async () => {
474
+ const mode = r.mode ?? defaultMode;
475
+ try {
476
+ const shot = await captureScreenshot(
477
+ opts.baseUrl,
478
+ {
479
+ id: r.id,
480
+ url: r.url,
481
+ viewport: r.viewport,
482
+ mask: r.mask,
483
+ waitFor: r.waitFor,
484
+ fullPage: r.fullPage,
485
+ mode
486
+ },
487
+ cwd
488
+ );
489
+ slot[i] = {
434
490
  id: r.id,
435
491
  url: r.url,
436
- viewport: r.viewport,
437
- mask: r.mask,
438
- waitFor: r.waitFor,
439
- fullPage: r.fullPage
440
- })
441
- );
442
- manifestUpdates += 1;
443
- }
444
- } catch (err) {
445
- results.push({
446
- id: r.id,
447
- url: r.url,
448
- mode,
449
- ok: false,
450
- error: err.message
451
- });
452
- }
453
- }
492
+ mode,
493
+ ok: true,
494
+ outputPath: shot.outputPath,
495
+ bytes: shot.bytes
496
+ };
497
+ if (mode === "baseline" && manifest) {
498
+ manifest = addOrReplaceEntry(
499
+ manifest,
500
+ makeEntry({
501
+ id: r.id,
502
+ url: r.url,
503
+ viewport: r.viewport,
504
+ mask: r.mask,
505
+ waitFor: r.waitFor,
506
+ fullPage: r.fullPage
507
+ })
508
+ );
509
+ manifestUpdates += 1;
510
+ }
511
+ } catch (err) {
512
+ slot[i] = {
513
+ id: r.id,
514
+ url: r.url,
515
+ mode,
516
+ ok: false,
517
+ error: err.message
518
+ };
519
+ }
520
+ })
521
+ )
522
+ );
523
+ for (const r of slot) results.push(r);
454
524
  if (manifest && manifestUpdates > 0) await saveManifest(manifest, cwd);
455
525
  } finally {
456
526
  await closeBrowser();
@@ -464,42 +534,20 @@ async function runCaptures(opts) {
464
534
  results
465
535
  };
466
536
  }
467
- var ENTRIES = [
468
- "actual/",
469
- "judgments/",
470
- "summary.md",
471
- "dev-server.log",
472
- "dev-server.pid",
473
- "*.tmp"
474
- ];
475
- var STALE_ENTRIES = /* @__PURE__ */ new Set(["diffs/", "pending-judgments/", "report.json"]);
476
- var HEADER = "# blazediff: generated artifacts (committed: config.json, manifest.json, baselines/)\n";
477
- async function ensureGitignore(cwd) {
478
- const file = paths(cwd).gitignore;
479
- await mkdir(path.dirname(file), { recursive: true });
480
- const existing = existsSync(file) ? await readFile(file, "utf8") : "";
481
- const lines = existing.split("\n").map((l) => l.trim());
482
- const hasStale = lines.some((l) => STALE_ENTRIES.has(l));
483
- const missing = ENTRIES.filter((e) => !lines.includes(e));
484
- if (!missing.length && !hasStale && existing) return;
485
- if (hasStale) {
486
- const kept = lines.filter(
487
- (l) => !STALE_ENTRIES.has(l) && !ENTRIES.includes(l) && l !== ""
488
- );
489
- const body2 = `${kept.length ? `${kept.join("\n")}
490
- ` : HEADER}${ENTRIES.join("\n")}
491
- `;
492
- await writeFile(file, body2, "utf8");
493
- return;
537
+ async function fileExists(p) {
538
+ try {
539
+ await access(p);
540
+ return true;
541
+ } catch {
542
+ return false;
494
543
  }
495
- const body = existing ? `${existing.replace(/\n+$/, "")}
496
- ${missing.join("\n")}
497
- ` : `${HEADER}${ENTRIES.join("\n")}
498
- `;
499
- await writeFile(file, body, "utf8");
500
544
  }
501
545
  async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.cwd()) {
502
- if (!existsSync(baselinePath) || !existsSync(actualPath)) {
546
+ const [hasBaseline, hasActual] = await Promise.all([
547
+ fileExists(baselinePath),
548
+ fileExists(actualPath)
549
+ ]);
550
+ if (!hasBaseline || !hasActual) {
503
551
  return {
504
552
  id,
505
553
  baselinePath,
@@ -518,7 +566,8 @@ async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.
518
566
  const antialiasing = opts.antialiasing ?? true;
519
567
  const result = await compare(baselinePath, actualPath, diffPath, {
520
568
  threshold,
521
- antialiasing
569
+ antialiasing,
570
+ interpret: true
522
571
  });
523
572
  if (result.match) return { id, baselinePath, actualPath, match: true };
524
573
  if (result.reason === "file-not-exists") {
@@ -540,10 +589,6 @@ async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.
540
589
  reason: "layout-diff"
541
590
  };
542
591
  }
543
- const interpretation = await interpret(baselinePath, actualPath, {
544
- threshold,
545
- antialiasing
546
- }).catch(() => void 0);
547
592
  return {
548
593
  id,
549
594
  baselinePath,
@@ -553,167 +598,200 @@ async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.
553
598
  reason: "pixel-diff",
554
599
  diffCount: result.diffCount,
555
600
  diffPercentage: result.diffPercentage,
556
- interpretation
601
+ interpretation: result.interpretation
557
602
  };
558
603
  }
559
604
 
560
- // src/diff/verdict.ts
561
- var REGRESSIVE_TYPES = /* @__PURE__ */ new Set(["content-change", "addition", "deletion"]);
562
- var INTENTIONAL_TYPES = /* @__PURE__ */ new Set(["color-change", "shift"]);
563
- var NOISE_TYPES = /* @__PURE__ */ new Set(["rendering-noise"]);
564
- var ELEVATED_SEVERITY = /* @__PURE__ */ new Set(["medium", "high"]);
565
- var SUB_PERCEPTUAL_PCT = 0.01;
566
- function pctText(pct) {
567
- if (typeof pct !== "number") return "?%";
568
- return pct >= 0.01 ? `${pct.toFixed(2)}%` : `${pct.toFixed(3)}%`;
569
- }
570
- function countByType(regions) {
571
- const counts = /* @__PURE__ */ new Map();
572
- for (const r of regions)
573
- counts.set(r.changeType, (counts.get(r.changeType) ?? 0) + 1);
574
- return counts;
575
- }
576
- function dominantType(counts) {
577
- let best = "";
578
- let bestN = 0;
579
- for (const [type, n] of counts) {
580
- if (n > bestN) {
581
- best = type;
582
- bestN = n;
605
+ // src/discover/crawl.ts
606
+ function extractInternalLinks(base, target, hrefs) {
607
+ const out = [];
608
+ for (const href of hrefs) {
609
+ if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
610
+ continue;
611
+ }
612
+ try {
613
+ const u = new URL(href, target);
614
+ if (u.origin !== base.origin) continue;
615
+ const path18 = u.pathname + u.search;
616
+ if (path18.startsWith("/api/")) continue;
617
+ out.push(path18);
618
+ } catch {
583
619
  }
584
620
  }
585
- return best;
621
+ return out;
586
622
  }
587
- function topPosition(regions) {
588
- let best;
589
- for (const r of regions)
590
- if (!best || r.pixelCount > best.pixelCount) best = r;
591
- return best?.position;
623
+ var CRAWL_VIEWPORT = { width: 1024, height: 768 };
624
+ var CRAWL_WORKERS = 4;
625
+ async function crawlRoutes(opts) {
626
+ const maxRoutes = opts.maxRoutes ?? 50;
627
+ const maxDepth = opts.maxDepth ?? 2;
628
+ const base = new URL(opts.baseUrl);
629
+ const visited = /* @__PURE__ */ new Set(["/"]);
630
+ const queue = [{ url: "/", depth: 0 }];
631
+ const discovered = [];
632
+ const handle = await acquireStableContext(CRAWL_VIEWPORT);
633
+ const fetchOne = async () => {
634
+ while (queue.length && discovered.length < maxRoutes) {
635
+ const item = queue.shift();
636
+ if (!item) return;
637
+ if (discovered.length >= maxRoutes) return;
638
+ discovered.push({ url: item.url, source: "crawl" });
639
+ if (item.depth >= maxDepth) continue;
640
+ const page = await handle.context.newPage();
641
+ try {
642
+ const target = new URL(item.url, base).toString();
643
+ await page.goto(target, {
644
+ waitUntil: "domcontentloaded",
645
+ timeout: 15e3
646
+ });
647
+ const hrefs = await page.evaluate(
648
+ () => Array.from(
649
+ document.querySelectorAll("a[href]")
650
+ ).map((a) => a.getAttribute("href") ?? "")
651
+ );
652
+ for (const p of extractInternalLinks(base, target, hrefs)) {
653
+ if (visited.has(p)) continue;
654
+ visited.add(p);
655
+ queue.push({ url: p, depth: item.depth + 1 });
656
+ }
657
+ } catch {
658
+ } finally {
659
+ await page.close().catch(() => {
660
+ });
661
+ }
662
+ }
663
+ };
664
+ try {
665
+ await Promise.all(Array.from({ length: CRAWL_WORKERS }, () => fetchOne()));
666
+ } finally {
667
+ await releaseStableContext(handle);
668
+ }
669
+ return discovered.slice(0, maxRoutes);
592
670
  }
593
- function meanConfidence(regions) {
594
- if (regions.length === 0) return 0;
595
- return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
671
+ var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
672
+ async function readJson(file) {
673
+ if (!existsSync(file)) return null;
674
+ try {
675
+ return JSON.parse(await readFile(file, "utf8"));
676
+ } catch {
677
+ return null;
678
+ }
596
679
  }
597
- function formatBreakdown(counts) {
598
- return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
680
+ function isPublicRoute(route) {
681
+ if (DYNAMIC_SEGMENT.test(route)) return false;
682
+ if (route === "/api" || route.startsWith("/api/")) return false;
683
+ return true;
599
684
  }
600
- function buildHeadline(input) {
601
- const { reason, interpretation, diffCount, diffPercentage } = input;
602
- if (reason === "layout-diff") return "image dimensions changed";
603
- if (reason === "file-not-exists") return "baseline or actual capture missing";
604
- if (!interpretation || interpretation.regions.length === 0) {
605
- const px = diffCount?.toLocaleString() ?? "?";
606
- return `${px} px (${pctText(diffPercentage)}) - no region analysis`;
685
+ async function discoverFromNextManifest(cwd = process.cwd()) {
686
+ const nextDir = path.join(cwd, ".next");
687
+ if (!existsSync(nextDir)) return [];
688
+ const seen = /* @__PURE__ */ new Set();
689
+ const out = [];
690
+ const add = (url) => {
691
+ if (seen.has(url)) return;
692
+ seen.add(url);
693
+ out.push({ url, source: "next-manifest" });
694
+ };
695
+ const routes = await readJson(
696
+ path.join(nextDir, "routes-manifest.json")
697
+ );
698
+ for (const r of routes?.staticRoutes ?? []) {
699
+ if (isPublicRoute(r.page)) add(r.page);
607
700
  }
608
- const regions = interpretation.regions;
609
- const counts = countByType(regions);
610
- const pos = topPosition(regions);
611
- const pct = pctText(diffPercentage ?? interpretation.diffPercentage);
612
- const sev = interpretation.severity ?? "?";
613
- if (regions.length === 1) {
614
- return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
701
+ const appPaths = await readJson(
702
+ path.join(nextDir, "server", "app-paths-manifest.json")
703
+ );
704
+ for (const route of Object.keys(appPaths ?? {})) {
705
+ const normalized = route.replace(/\/page$/, "") || "/";
706
+ if (isPublicRoute(normalized)) add(normalized);
615
707
  }
616
- return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
708
+ return out;
617
709
  }
618
- function deriveVerdict(input) {
619
- const { reason, interpretation, diffPercentage } = input;
620
- const headline = buildHeadline(input);
621
- if (reason === "layout-diff") {
622
- return {
623
- label: "ambiguous",
624
- headline,
625
- rationale: [
626
- "baseline and actual image dimensions differ \u2014 page height likely shifted",
627
- "could be intentional (content added/removed) or regression (broken layout)"
628
- ],
629
- action: "investigate"
630
- };
710
+
711
+ // src/discover/sitemap.ts
712
+ var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
713
+ var LOC_RE = /<loc>([^<]+)<\/loc>/g;
714
+ async function discoverFromSitemap(baseUrl) {
715
+ for (const candidate of CANDIDATES) {
716
+ try {
717
+ const res = await fetch(new URL(candidate, baseUrl));
718
+ if (!res.ok) continue;
719
+ const text = await res.text();
720
+ const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
721
+ if (!urls.length) continue;
722
+ return urls.map((u) => {
723
+ const url = new URL(u);
724
+ return { url: url.pathname + url.search, source: "sitemap" };
725
+ });
726
+ } catch {
727
+ }
631
728
  }
632
- if (reason === "file-not-exists") {
633
- return {
634
- label: "regression-likely",
635
- headline,
636
- rationale: ["baseline or actual capture is missing from disk"],
637
- action: "investigate"
638
- };
729
+ return [];
730
+ }
731
+
732
+ // src/discover/index.ts
733
+ function normalizePath(url) {
734
+ const [pathPart, query = ""] = url.split("?", 2);
735
+ const trimmed = pathPart.replace(/\/+$/, "");
736
+ const normalizedPath = trimmed === "" ? "/" : trimmed;
737
+ return query ? `${normalizedPath}?${query}` : normalizedPath;
738
+ }
739
+ function mergeBy(routes, into) {
740
+ for (const r of routes) {
741
+ const key = normalizePath(r.url);
742
+ if (!into.has(key)) into.set(key, { ...r, url: key });
639
743
  }
640
- if (!interpretation || interpretation.regions.length === 0) {
641
- return {
642
- label: "ambiguous",
643
- headline,
644
- rationale: ["pixels differ but interpret returned no regions"],
645
- action: "investigate"
646
- };
647
- }
648
- const regions = interpretation.regions;
649
- const severity = interpretation.severity;
650
- const counts = countByType(regions);
651
- const allNoise = regions.every((r) => NOISE_TYPES.has(r.changeType));
652
- const allColor = regions.every((r) => r.changeType === "color-change");
653
- const allMoved = regions.every((r) => INTENTIONAL_TYPES.has(r.changeType));
654
- const hasRegressive = regions.some((r) => REGRESSIVE_TYPES.has(r.changeType));
655
- const pct = typeof diffPercentage === "number" ? diffPercentage : interpretation.diffPercentage;
656
- if (allNoise) {
657
- return {
658
- label: "noise-likely",
659
- headline,
660
- rationale: ["all regions classified as rendering-noise"],
661
- action: "ignore-or-rewrite"
662
- };
663
- }
664
- if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
665
- return {
666
- label: "noise-likely",
667
- headline,
668
- rationale: [
669
- `delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
670
- "sub-perceptual change - review optional"
671
- ],
672
- action: "ignore-or-rewrite"
673
- };
674
- }
675
- if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
676
- const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
677
- return {
678
- label: "regression-likely",
679
- headline,
680
- rationale: [
681
- `severity ${severity} with structural changes (${types})`,
682
- "likely affects content or layout, not just styling"
683
- ],
684
- action: "investigate"
685
- };
686
- }
687
- if (allColor && meanConfidence(regions) > 0.7) {
688
- return {
689
- label: "intentional-likely",
690
- headline,
691
- rationale: [
692
- `${regions.length} color-change region${regions.length === 1 ? "" : "s"} with mean confidence > 0.7`,
693
- "edge structure preserved - looks like a theming / palette change"
694
- ],
695
- action: "rewrite-if-intended"
696
- };
744
+ }
745
+ async function discover(opts) {
746
+ const cwd = opts.cwd ?? process.cwd();
747
+ const merged = /* @__PURE__ */ new Map();
748
+ mergeBy(await discoverFromNextManifest(cwd), merged);
749
+ mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
750
+ if (!opts.skipCrawl) {
751
+ const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
752
+ if (crawlMax > 0) {
753
+ mergeBy(
754
+ await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
755
+ merged
756
+ );
757
+ }
697
758
  }
698
- if (allMoved && !allColor) {
699
- return {
700
- label: "intentional-likely",
701
- headline,
702
- rationale: [
703
- "all regions are shift/color-change - content moved or restyled, structure preserved"
704
- ],
705
- action: "rewrite-if-intended"
706
- };
759
+ return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
760
+ }
761
+ var ENTRIES = [
762
+ "actual/",
763
+ "judgments/",
764
+ "checkpoints/",
765
+ "summary.md",
766
+ "dev-server.log",
767
+ "dev-server.pid",
768
+ "*.tmp"
769
+ ];
770
+ var STALE_ENTRIES = /* @__PURE__ */ new Set(["diffs/", "pending-judgments/", "report.json"]);
771
+ var HEADER = "# blazediff: generated artifacts (committed: config.json, manifest.json, baselines/)\n";
772
+ async function ensureGitignore(cwd) {
773
+ const file = paths(cwd).gitignore;
774
+ await mkdir(path.dirname(file), { recursive: true });
775
+ const existing = existsSync(file) ? await readFile(file, "utf8") : "";
776
+ const lines = existing.split("\n").map((l) => l.trim());
777
+ const hasStale = lines.some((l) => STALE_ENTRIES.has(l));
778
+ const missing = ENTRIES.filter((e) => !lines.includes(e));
779
+ if (!missing.length && !hasStale && existing) return;
780
+ if (hasStale) {
781
+ const kept = lines.filter(
782
+ (l) => !STALE_ENTRIES.has(l) && !ENTRIES.includes(l) && l !== ""
783
+ );
784
+ const body2 = `${kept.length ? `${kept.join("\n")}
785
+ ` : HEADER}${ENTRIES.join("\n")}
786
+ `;
787
+ await writeFile(file, body2, "utf8");
788
+ return;
707
789
  }
708
- return {
709
- label: "ambiguous",
710
- headline,
711
- rationale: [
712
- `mix of change types (${formatBreakdown(counts)}) at "${severity ?? "?"}" severity`,
713
- `${pctText(pct)} of image differs`
714
- ],
715
- action: "investigate"
716
- };
790
+ const body = existing ? `${existing.replace(/\n+$/, "")}
791
+ ${missing.join("\n")}
792
+ ` : `${HEADER}${ENTRIES.join("\n")}
793
+ `;
794
+ await writeFile(file, body, "utf8");
717
795
  }
718
796
  var DEFAULT_TOP_N = 5;
719
797
  var DEFAULT_PADDING = 16;
@@ -855,6 +933,283 @@ var noneJudge = {
855
933
  return { kind: "judged", verdict: input.heuristicVerdict };
856
934
  }
857
935
  };
936
+ var ROOT_NS_SENTINEL = "_root";
937
+ function nsDir(ns) {
938
+ return ns === "" ? ROOT_NS_SENTINEL : ns;
939
+ }
940
+ function encode(buf) {
941
+ return Buffer.from(buf).toString("base64");
942
+ }
943
+ function decode(s) {
944
+ return Buffer.from(s, "base64");
945
+ }
946
+ async function readJson2(file) {
947
+ try {
948
+ const raw = await readFile(file, "utf8");
949
+ return JSON.parse(raw);
950
+ } catch (err) {
951
+ if (err.code === "ENOENT") return void 0;
952
+ throw err;
953
+ }
954
+ }
955
+ async function writeJsonAtomic(file, value) {
956
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
957
+ await writeFile(tmp, JSON.stringify(value), "utf8");
958
+ await rename(tmp, file);
959
+ }
960
+ var FsCheckpointSaver = class extends BaseCheckpointSaver {
961
+ constructor(root, serde) {
962
+ super(serde);
963
+ this.locks = /* @__PURE__ */ new Map();
964
+ this.root = root;
965
+ }
966
+ threadDir(thread) {
967
+ return path.join(this.root, thread);
968
+ }
969
+ nsPath(thread, ns) {
970
+ return path.join(this.threadDir(thread), nsDir(ns));
971
+ }
972
+ ckptFile(thread, ns, id) {
973
+ return path.join(this.nsPath(thread, ns), `${id}.ckpt.json`);
974
+ }
975
+ writesFile(thread, ns, id) {
976
+ return path.join(this.nsPath(thread, ns), `${id}.writes.json`);
977
+ }
978
+ async withLock(key, fn) {
979
+ const prev = this.locks.get(key) ?? Promise.resolve();
980
+ const next = prev.then(fn, fn);
981
+ this.locks.set(key, next);
982
+ try {
983
+ return await next;
984
+ } finally {
985
+ if (this.locks.get(key) === next) this.locks.delete(key);
986
+ }
987
+ }
988
+ async listCheckpointIds(thread, ns) {
989
+ try {
990
+ const names = await readdir(this.nsPath(thread, ns));
991
+ const suffix = ".ckpt.json";
992
+ return names.filter((n) => n.endsWith(suffix)).map((n) => n.slice(0, -suffix.length));
993
+ } catch (err) {
994
+ if (err.code === "ENOENT") return [];
995
+ throw err;
996
+ }
997
+ }
998
+ async listNamespaces(thread) {
999
+ try {
1000
+ return await readdir(this.threadDir(thread));
1001
+ } catch (err) {
1002
+ if (err.code === "ENOENT") return [];
1003
+ throw err;
1004
+ }
1005
+ }
1006
+ async listThreads() {
1007
+ try {
1008
+ return await readdir(this.root);
1009
+ } catch (err) {
1010
+ if (err.code === "ENOENT") return [];
1011
+ throw err;
1012
+ }
1013
+ }
1014
+ decodeNs(nsDirName) {
1015
+ return nsDirName === ROOT_NS_SENTINEL ? "" : nsDirName;
1016
+ }
1017
+ async loadPendingWrites(thread, ns, ckptId) {
1018
+ const data = await readJson2(
1019
+ this.writesFile(thread, ns, ckptId)
1020
+ );
1021
+ if (!data) return [];
1022
+ const out = [];
1023
+ for (const [taskId, channel, serialized] of Object.values(data)) {
1024
+ const value = await this.serde.loadsTyped("json", decode(serialized));
1025
+ out.push([taskId, channel, value]);
1026
+ }
1027
+ return out;
1028
+ }
1029
+ async migratePendingSends(checkpoint, thread, ns, parentCheckpointId) {
1030
+ const data = await readJson2(
1031
+ this.writesFile(thread, ns, parentCheckpointId)
1032
+ );
1033
+ const pendingSends = data ? await Promise.all(
1034
+ Object.values(data).filter(([, channel]) => channel === TASKS).map(
1035
+ ([, , serialized]) => this.serde.loadsTyped("json", decode(serialized))
1036
+ )
1037
+ ) : [];
1038
+ const m = checkpoint;
1039
+ m.channel_values ?? (m.channel_values = {});
1040
+ m.channel_values[TASKS] = pendingSends;
1041
+ m.channel_versions ?? (m.channel_versions = {});
1042
+ const versions = Object.values(m.channel_versions);
1043
+ m.channel_versions[TASKS] = versions.length > 0 ? maxChannelVersion(...versions) : this.getNextVersion(void 0);
1044
+ }
1045
+ async readTuple(thread, ns, ckptId, config) {
1046
+ const data = await readJson2(this.ckptFile(thread, ns, ckptId));
1047
+ if (!data) return void 0;
1048
+ const checkpoint = await this.serde.loadsTyped(
1049
+ "json",
1050
+ decode(data.checkpoint)
1051
+ );
1052
+ const metadata = await this.serde.loadsTyped(
1053
+ "json",
1054
+ decode(data.metadata)
1055
+ );
1056
+ if (checkpoint.v < 4 && data.parentCheckpointId !== void 0) {
1057
+ await this.migratePendingSends(
1058
+ checkpoint,
1059
+ thread,
1060
+ ns,
1061
+ data.parentCheckpointId
1062
+ );
1063
+ }
1064
+ const pendingWrites = await this.loadPendingWrites(thread, ns, ckptId);
1065
+ const tuple = {
1066
+ config,
1067
+ checkpoint,
1068
+ metadata,
1069
+ pendingWrites
1070
+ };
1071
+ if (data.parentCheckpointId !== void 0) {
1072
+ tuple.parentConfig = {
1073
+ configurable: {
1074
+ thread_id: thread,
1075
+ checkpoint_ns: ns,
1076
+ checkpoint_id: data.parentCheckpointId
1077
+ }
1078
+ };
1079
+ }
1080
+ return tuple;
1081
+ }
1082
+ async getTuple(config) {
1083
+ const thread = config.configurable?.thread_id;
1084
+ if (!thread) return void 0;
1085
+ const ns = config.configurable?.checkpoint_ns ?? "";
1086
+ const explicitId = getCheckpointId(config);
1087
+ if (explicitId) {
1088
+ return this.readTuple(thread, ns, explicitId, config);
1089
+ }
1090
+ const ids = (await this.listCheckpointIds(thread, ns)).sort(
1091
+ (a, b) => b.localeCompare(a)
1092
+ );
1093
+ if (ids.length === 0) return void 0;
1094
+ const ckptId = ids[0];
1095
+ return this.readTuple(thread, ns, ckptId, {
1096
+ configurable: {
1097
+ thread_id: thread,
1098
+ checkpoint_ns: ns,
1099
+ checkpoint_id: ckptId
1100
+ }
1101
+ });
1102
+ }
1103
+ async *list(config, options) {
1104
+ const filter = options?.filter;
1105
+ const before = options?.before;
1106
+ let limit = options?.limit;
1107
+ const configThread = config.configurable?.thread_id;
1108
+ const configNs = config.configurable?.checkpoint_ns;
1109
+ const configCkptId = config.configurable?.checkpoint_id;
1110
+ const threads = configThread ? [configThread] : await this.listThreads();
1111
+ for (const thread of threads) {
1112
+ const nsNames = await this.listNamespaces(thread);
1113
+ for (const nsName of nsNames) {
1114
+ const ns = this.decodeNs(nsName);
1115
+ if (configNs !== void 0 && ns !== configNs) continue;
1116
+ const ids = (await this.listCheckpointIds(thread, ns)).sort(
1117
+ (a, b) => b.localeCompare(a)
1118
+ );
1119
+ for (const ckptId of ids) {
1120
+ if (configCkptId && ckptId !== configCkptId) continue;
1121
+ if (before?.configurable?.checkpoint_id && ckptId >= before.configurable.checkpoint_id)
1122
+ continue;
1123
+ const tuple = await this.readTuple(thread, ns, ckptId, {
1124
+ configurable: {
1125
+ thread_id: thread,
1126
+ checkpoint_ns: ns,
1127
+ checkpoint_id: ckptId
1128
+ }
1129
+ });
1130
+ if (!tuple) continue;
1131
+ if (filter) {
1132
+ const md = tuple.metadata;
1133
+ const matches = Object.entries(filter).every(
1134
+ ([k, v]) => md?.[k] === v
1135
+ );
1136
+ if (!matches) continue;
1137
+ }
1138
+ if (limit !== void 0) {
1139
+ if (limit <= 0) return;
1140
+ limit -= 1;
1141
+ }
1142
+ yield tuple;
1143
+ }
1144
+ }
1145
+ }
1146
+ }
1147
+ async put(config, checkpoint, metadata) {
1148
+ const thread = config.configurable?.thread_id;
1149
+ if (!thread) {
1150
+ throw new Error(
1151
+ 'FsCheckpointSaver: missing "thread_id" in configurable. Pass `{ configurable: { thread_id } }` when streaming.'
1152
+ );
1153
+ }
1154
+ const ns = config.configurable?.checkpoint_ns ?? "";
1155
+ const parentCheckpointId = config.configurable?.checkpoint_id;
1156
+ const prepared = copyCheckpoint(checkpoint);
1157
+ const [[, serializedCheckpoint], [, serializedMetadata]] = await Promise.all([
1158
+ this.serde.dumpsTyped(prepared),
1159
+ this.serde.dumpsTyped(metadata)
1160
+ ]);
1161
+ await mkdir(this.nsPath(thread, ns), { recursive: true });
1162
+ const file = this.ckptFile(thread, ns, checkpoint.id);
1163
+ const body = {
1164
+ checkpoint: encode(serializedCheckpoint),
1165
+ metadata: encode(serializedMetadata),
1166
+ parentCheckpointId
1167
+ };
1168
+ await writeJsonAtomic(file, body);
1169
+ return {
1170
+ configurable: {
1171
+ thread_id: thread,
1172
+ checkpoint_ns: ns,
1173
+ checkpoint_id: checkpoint.id
1174
+ }
1175
+ };
1176
+ }
1177
+ async putWrites(config, writes, taskId) {
1178
+ const thread = config.configurable?.thread_id;
1179
+ if (!thread) {
1180
+ throw new Error(
1181
+ 'FsCheckpointSaver: missing "thread_id" in configurable for putWrites.'
1182
+ );
1183
+ }
1184
+ const ns = config.configurable?.checkpoint_ns ?? "";
1185
+ const ckptId = config.configurable?.checkpoint_id;
1186
+ if (!ckptId) {
1187
+ throw new Error(
1188
+ 'FsCheckpointSaver: missing "checkpoint_id" in configurable for putWrites.'
1189
+ );
1190
+ }
1191
+ const key = `${thread}|${ns}|${ckptId}`;
1192
+ await this.withLock(key, async () => {
1193
+ const existing = await readJson2(this.writesFile(thread, ns, ckptId)) ?? {};
1194
+ let mutated = false;
1195
+ for (let idx = 0; idx < writes.length; idx++) {
1196
+ const [channel, value] = writes[idx];
1197
+ const writeIdx = WRITES_IDX_MAP[channel] ?? idx;
1198
+ const innerKey = `${taskId},${writeIdx}`;
1199
+ if (writeIdx >= 0 && innerKey in existing) continue;
1200
+ const [, serialized] = await this.serde.dumpsTyped(value);
1201
+ existing[innerKey] = [taskId, channel, encode(serialized)];
1202
+ mutated = true;
1203
+ }
1204
+ if (!mutated) return;
1205
+ await mkdir(this.nsPath(thread, ns), { recursive: true });
1206
+ await writeJsonAtomic(this.writesFile(thread, ns, ckptId), existing);
1207
+ });
1208
+ }
1209
+ async deleteThread(threadId) {
1210
+ await rm(this.threadDir(threadId), { recursive: true, force: true });
1211
+ }
1212
+ };
858
1213
  var PREVIEW_WIDTH = 320;
859
1214
  function escapeCell(s) {
860
1215
  return s.replace(/\n/g, " ");
@@ -967,6 +1322,14 @@ function parseVerdict(raw) {
967
1322
  confidence: typeof r.confidence === "number" ? r.confidence : void 0
968
1323
  };
969
1324
  }
1325
+ async function fileExists2(p) {
1326
+ try {
1327
+ await access(p);
1328
+ return true;
1329
+ } catch {
1330
+ return false;
1331
+ }
1332
+ }
970
1333
  async function readJsonOrNull(file) {
971
1334
  try {
972
1335
  return JSON.parse(await readFile(file, "utf8"));
@@ -997,7 +1360,7 @@ async function readJudgmentDirs(root) {
997
1360
  const verdictFile = path.join(dir, "verdict.json");
998
1361
  let verdict = null;
999
1362
  let verdictInvalid = false;
1000
- if (existsSync(verdictFile)) {
1363
+ if (await fileExists2(verdictFile)) {
1001
1364
  const raw = await readJsonOrNull(verdictFile);
1002
1365
  verdict = raw ? parseVerdict(raw) : null;
1003
1366
  if (raw && !verdict) verdictInvalid = true;
@@ -1010,7 +1373,7 @@ function toAbs(cwd, rel) {
1010
1373
  if (!rel) return void 0;
1011
1374
  return path.isAbsolute(rel) ? rel : path.join(cwd, rel);
1012
1375
  }
1013
- function buildResult(cwd, dir, entry) {
1376
+ function fromDiskResult(cwd, dir, entry) {
1014
1377
  const req = dir.request;
1015
1378
  const finalVerdict = dir.verdict?.verdict ?? req?.heuristicVerdict;
1016
1379
  const status = req ? dir.verdict ? "fail" : req.status : "fail";
@@ -1029,7 +1392,7 @@ function buildResult(cwd, dir, entry) {
1029
1392
  message
1030
1393
  };
1031
1394
  }
1032
- function passResult(entry, cwd) {
1395
+ async function passResultFromDisk(entry, cwd) {
1033
1396
  const baselineAbs = path.join(paths(cwd).baselines, `${entry.id}.png`);
1034
1397
  const actualAbs = path.join(paths(cwd).actual, `${entry.id}.png`);
1035
1398
  return {
@@ -1037,37 +1400,27 @@ function passResult(entry, cwd) {
1037
1400
  url: entry.url,
1038
1401
  status: "pass",
1039
1402
  baselinePath: baselineAbs,
1040
- actualPath: existsSync(actualAbs) ? actualAbs : void 0
1403
+ actualPath: await fileExists2(actualAbs) ? actualAbs : void 0
1041
1404
  };
1042
1405
  }
1043
- async function applyJudgments(cwd = process.cwd()) {
1044
- const p = paths(cwd);
1406
+ async function reconstructFromDisk(cwd, dirs) {
1045
1407
  const manifest = await loadManifest(cwd);
1046
1408
  if (!manifest) {
1047
1409
  throw new Error(
1048
- `no manifest at ${p.manifest}. Run \`blazediff-agent init\` first.`
1410
+ `no manifest at ${paths(cwd).manifest}. Run \`blazediff-agent init\` first.`
1049
1411
  );
1050
1412
  }
1051
- const dirs = await readJudgmentDirs(p.judgments);
1052
1413
  const dirById = new Map(dirs.map((d) => [d.id, d]));
1053
- const applied = [];
1054
- const missing = [];
1055
- const invalid = [];
1056
- for (const d of dirs) {
1057
- if (d.verdictInvalid) invalid.push(path.join(p.judgments, d.id));
1058
- else if (d.verdict) applied.push(d.id);
1059
- else missing.push(d.id);
1060
- }
1061
- const nonPassResults = [];
1062
- for (const d of dirs) {
1063
- const entry = manifest.entries.find((e) => e.id === d.id);
1064
- nonPassResults.push(buildResult(cwd, d, entry));
1065
- }
1066
- const passResults = [];
1067
- for (const entry of manifest.entries) {
1068
- if (dirById.has(entry.id)) continue;
1069
- passResults.push(passResult(entry, cwd));
1070
- }
1414
+ const nonPassResults = dirs.map(
1415
+ (d) => fromDiskResult(
1416
+ cwd,
1417
+ d,
1418
+ manifest.entries.find((e) => e.id === d.id)
1419
+ )
1420
+ );
1421
+ const passResults = await Promise.all(
1422
+ manifest.entries.filter((entry) => !dirById.has(entry.id)).map((entry) => passResultFromDisk(entry, cwd))
1423
+ );
1071
1424
  const results = [...passResults, ...nonPassResults];
1072
1425
  const passed = results.filter((r) => r.status === "pass").length;
1073
1426
  const pendingJudgments = results.filter(
@@ -1082,6 +1435,57 @@ async function applyJudgments(cwd = process.cwd()) {
1082
1435
  results
1083
1436
  };
1084
1437
  await writeSummaryMarkdown(report, cwd);
1438
+ return report;
1439
+ }
1440
+ async function hasCheckpoint(cwd, threadId) {
1441
+ const dir = path.join(paths(cwd).checkpoints, threadId);
1442
+ try {
1443
+ const names = await readdir(dir);
1444
+ return names.length > 0;
1445
+ } catch {
1446
+ return false;
1447
+ }
1448
+ }
1449
+ async function applyJudgments(opts = process.cwd()) {
1450
+ const options = typeof opts === "string" ? { cwd: opts } : opts;
1451
+ const cwd = options.cwd ?? process.cwd();
1452
+ const manifest = await loadManifest(cwd);
1453
+ if (!manifest) {
1454
+ throw new Error(
1455
+ `no manifest at ${paths(cwd).manifest}. Run \`blazediff-agent init\` first.`
1456
+ );
1457
+ }
1458
+ const dirs = await readJudgmentDirs(paths(cwd).judgments);
1459
+ const applied = [];
1460
+ const missing = [];
1461
+ const invalid = [];
1462
+ const verdicts = {};
1463
+ for (const d of dirs) {
1464
+ if (d.verdictInvalid) {
1465
+ invalid.push(path.join(paths(cwd).judgments, d.id));
1466
+ continue;
1467
+ }
1468
+ if (d.verdict) {
1469
+ verdicts[d.id] = d.verdict.verdict;
1470
+ applied.push(d.id);
1471
+ continue;
1472
+ }
1473
+ missing.push(d.id);
1474
+ }
1475
+ const threadId = threadIdFor(cwd);
1476
+ const checkpointExists = await hasCheckpoint(cwd, threadId);
1477
+ if (!checkpointExists) {
1478
+ const report2 = await reconstructFromDisk(cwd, dirs);
1479
+ return { report: report2, applied, missing, invalid };
1480
+ }
1481
+ const report = await resumeGraph({
1482
+ cwd,
1483
+ verdicts,
1484
+ threadId,
1485
+ onEvent: options.onEvent,
1486
+ junitPath: options.junitPath
1487
+ });
1488
+ await new FsCheckpointSaver(paths(cwd).checkpoints).deleteThread(threadId).catch(() => void 0);
1085
1489
  return { report, applied, missing, invalid };
1086
1490
  }
1087
1491
  var HOST_INSTRUCTIONS = [
@@ -1103,6 +1507,14 @@ function signatureOf(r) {
1103
1507
  const severity = r.severity ?? "?";
1104
1508
  return `${r.status}|diff:${pct}|regions:${regions}|severity:${severity}`;
1105
1509
  }
1510
+ async function fileExists3(p) {
1511
+ try {
1512
+ await access(p);
1513
+ return true;
1514
+ } catch {
1515
+ return false;
1516
+ }
1517
+ }
1106
1518
  async function readJsonOrNull2(file) {
1107
1519
  try {
1108
1520
  return JSON.parse(await readFile(file, "utf8"));
@@ -1156,9 +1568,13 @@ function autoVerdict(result) {
1156
1568
  async function discoverTiles(dir) {
1157
1569
  const locatorAbs = path.join(dir, "locator.png");
1158
1570
  const tilesAbs = path.join(dir, "regions.png");
1571
+ const [locator, tiles] = await Promise.all([
1572
+ fileExists3(locatorAbs),
1573
+ fileExists3(tilesAbs)
1574
+ ]);
1159
1575
  return {
1160
- locatorPath: existsSync(locatorAbs) ? "locator.png" : void 0,
1161
- tilesPath: existsSync(tilesAbs) ? "regions.png" : void 0
1576
+ locatorPath: locator ? "locator.png" : void 0,
1577
+ tilesPath: tiles ? "regions.png" : void 0
1162
1578
  };
1163
1579
  }
1164
1580
  async function writeJudgments(opts) {
@@ -1167,53 +1583,61 @@ async function writeJudgments(opts) {
1167
1583
  await mkdir(root, { recursive: true });
1168
1584
  const knownIds = /* @__PURE__ */ new Set();
1169
1585
  for (const r of opts.report.results) knownIds.add(r.id);
1170
- for (const result of opts.report.results) {
1171
- const dir = path.join(root, result.id);
1172
- if (result.status === "pass") {
1173
- if (existsSync(dir)) await rm(dir, { recursive: true, force: true });
1174
- continue;
1175
- }
1176
- const entry = entryById(opts.manifest, result.id);
1177
- if (!entry) continue;
1178
- await mkdir(dir, { recursive: true });
1179
- const tiles = await discoverTiles(dir);
1180
- const request = buildRequest(result, entry, cwd, tiles);
1181
- const requestFile = path.join(dir, "request.json");
1182
- const prior = await readJsonOrNull2(requestFile);
1183
- const verdictFile = path.join(dir, "verdict.json");
1184
- const priorVerdict = existsSync(verdictFile) ? await readJsonOrNull2(verdictFile) : null;
1185
- const signatureMatches = prior !== null && prior.signature === request.signature;
1186
- await writeFile(
1187
- requestFile,
1188
- `${JSON.stringify(request, null, 2)}
1189
- `,
1190
- "utf8"
1191
- );
1192
- if (priorVerdict && signatureMatches) {
1193
- continue;
1194
- }
1195
- const auto = autoVerdict(result);
1196
- if (auto) {
1586
+ await Promise.all(
1587
+ opts.report.results.map(async (result) => {
1588
+ const dir = path.join(root, result.id);
1589
+ if (result.status === "pass") {
1590
+ if (await fileExists3(dir))
1591
+ await rm(dir, { recursive: true, force: true });
1592
+ return;
1593
+ }
1594
+ const entry = entryById(opts.manifest, result.id);
1595
+ if (!entry) return;
1596
+ await mkdir(dir, { recursive: true });
1597
+ const tiles = await discoverTiles(dir);
1598
+ const request = buildRequest(result, entry, cwd, tiles);
1599
+ const requestFile = path.join(dir, "request.json");
1600
+ const verdictFile = path.join(dir, "verdict.json");
1601
+ const [prior, priorVerdict] = await Promise.all([
1602
+ readJsonOrNull2(requestFile),
1603
+ fileExists3(verdictFile).then(
1604
+ (exists) => exists ? readJsonOrNull2(verdictFile) : null
1605
+ )
1606
+ ]);
1607
+ const signatureMatches = prior !== null && prior.signature === request.signature;
1197
1608
  await writeFile(
1198
- verdictFile,
1199
- `${JSON.stringify(auto, null, 2)}
1609
+ requestFile,
1610
+ `${JSON.stringify(request, null, 2)}
1200
1611
  `,
1201
1612
  "utf8"
1202
1613
  );
1203
- } else if (priorVerdict && !signatureMatches) {
1204
- await rm(verdictFile, { force: true });
1205
- }
1206
- }
1614
+ if (priorVerdict && signatureMatches) {
1615
+ return;
1616
+ }
1617
+ const auto = autoVerdict(result);
1618
+ if (auto) {
1619
+ await writeFile(
1620
+ verdictFile,
1621
+ `${JSON.stringify(auto, null, 2)}
1622
+ `,
1623
+ "utf8"
1624
+ );
1625
+ } else if (priorVerdict && !signatureMatches) {
1626
+ await rm(verdictFile, { force: true });
1627
+ }
1628
+ })
1629
+ );
1207
1630
  let entries;
1208
1631
  try {
1209
1632
  entries = await readdir(root);
1210
1633
  } catch {
1211
1634
  return;
1212
1635
  }
1213
- for (const name of entries) {
1214
- if (knownIds.has(name)) continue;
1215
- await rm(path.join(root, name), { recursive: true, force: true });
1216
- }
1636
+ await Promise.all(
1637
+ entries.filter((name) => !knownIds.has(name)).map(
1638
+ (name) => rm(path.join(root, name), { recursive: true, force: true })
1639
+ )
1640
+ );
1217
1641
  }
1218
1642
 
1219
1643
  // src/judge/index.ts
@@ -1251,7 +1675,7 @@ ${cases.join("\n")}
1251
1675
  return destPath;
1252
1676
  }
1253
1677
 
1254
- // src/check.ts
1678
+ // src/graph/nodes/results.ts
1255
1679
  function narrowRegion(r) {
1256
1680
  return {
1257
1681
  bbox: r.bbox,
@@ -1261,38 +1685,24 @@ function narrowRegion(r) {
1261
1685
  confidence: r.confidence
1262
1686
  };
1263
1687
  }
1264
- async function pool(items, limit, fn) {
1265
- const results = new Array(items.length);
1266
- let next = 0;
1267
- const workerCount = Math.max(1, Math.min(limit, items.length));
1268
- const workers = Array.from({ length: workerCount }, async () => {
1269
- while (true) {
1270
- const i = next++;
1271
- if (i >= items.length) return;
1272
- results[i] = await fn(items[i], i);
1273
- }
1274
- });
1275
- await Promise.all(workers);
1276
- return results;
1688
+ function skipResult(entry, message) {
1689
+ return { id: entry.id, url: entry.url, status: "pass", message };
1277
1690
  }
1278
- function passResult2(entry, baselinePath, actualPath) {
1691
+ function staleResult(entry) {
1279
1692
  return {
1280
1693
  id: entry.id,
1281
1694
  url: entry.url,
1282
- status: "pass",
1283
- baselinePath,
1284
- actualPath
1695
+ status: "stale-baseline",
1696
+ message: "captureHash mismatch: entry was edited without re-capturing"
1285
1697
  };
1286
1698
  }
1287
- function skipResult(entry, message) {
1288
- return { id: entry.id, url: entry.url, status: "pass", message };
1289
- }
1290
- function staleResult(entry) {
1699
+ function passResult(entry, baselinePath, actualPath) {
1291
1700
  return {
1292
1701
  id: entry.id,
1293
1702
  url: entry.url,
1294
- status: "stale-baseline",
1295
- message: "captureHash mismatch: entry was edited without re-capturing"
1703
+ status: "pass",
1704
+ baselinePath,
1705
+ actualPath
1296
1706
  };
1297
1707
  }
1298
1708
  function missingBaselineResult(entry, baselinePath) {
@@ -1319,300 +1729,334 @@ function failResult(entry, outcome, actualPath, baselinePath, verdict) {
1319
1729
  message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
1320
1730
  };
1321
1731
  }
1322
- async function judgeAmbiguous(result, entry, judge, cwd) {
1323
- if (result.status !== "fail" || !result.verdict || result.verdict.label !== "ambiguous" || !result.baselinePath || !result.actualPath) {
1324
- return result;
1325
- }
1326
- const output = await judge.judge(
1327
- {
1328
- entry,
1329
- baselinePath: result.baselinePath,
1330
- actualPath: result.actualPath,
1331
- diffPath: result.diffPath,
1332
- regions: result.regions,
1333
- diffPercentage: result.diffPercentage,
1334
- severity: result.severity,
1335
- heuristicVerdict: result.verdict
1336
- },
1337
- cwd
1338
- );
1339
- if (output.kind === "judged") {
1340
- return { ...result, verdict: output.verdict };
1341
- }
1342
- return {
1343
- ...result,
1344
- status: "needs-judgment",
1345
- message: `awaiting judgment in ${output.requestPath}`
1346
- };
1347
- }
1348
- async function checkEntry(entry, opts, cwd, baselinesDir, judge) {
1349
- if (entry.auth === "required") {
1350
- return skipResult(entry, "skipped: auth required (deferred to v0.2)");
1351
- }
1352
- if (isEntryStale(entry)) {
1353
- return staleResult(entry);
1354
- }
1355
- const baselinePath = path.join(baselinesDir, `${entry.id}.png`);
1356
- const capture = await captureScreenshot(
1357
- opts.baseUrl,
1358
- {
1359
- id: entry.id,
1360
- url: entry.url,
1361
- viewport: entry.viewport,
1362
- mask: entry.mask,
1363
- waitFor: entry.waitFor,
1364
- fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
1365
- mode: "actual"
1366
- },
1367
- cwd
1368
- );
1369
- const outcome = await diffEntry(
1370
- entry.id,
1371
- baselinePath,
1372
- capture.outputPath,
1373
- { threshold: opts.threshold, emitDiffPng: opts.emitDiffPng ?? true },
1374
- cwd
1375
- );
1376
- if (outcome.match) return passResult2(entry, baselinePath, capture.outputPath);
1377
- if (outcome.reason === "file-not-exists")
1378
- return missingBaselineResult(entry, baselinePath);
1379
- const verdict = deriveVerdict({
1380
- reason: outcome.reason,
1381
- interpretation: outcome.interpretation,
1382
- diffCount: outcome.diffCount,
1383
- diffPercentage: outcome.diffPercentage
1384
- });
1385
- const failed = failResult(
1386
- entry,
1387
- outcome,
1388
- capture.outputPath,
1389
- baselinePath,
1390
- verdict
1391
- );
1392
- return judgeAmbiguous(failed, entry, judge, cwd);
1393
- }
1394
- async function runCheck(opts) {
1395
- const cwd = opts.cwd ?? process.cwd();
1396
- const manifest = await loadManifest(cwd);
1397
- if (!manifest) {
1398
- throw new Error(
1399
- `no manifest found at ${paths(cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
1400
- );
1401
- }
1402
- const baselinesDir = paths(cwd).baselines;
1403
- const concurrency = opts.concurrency ?? defaultConcurrency();
1404
- const judge = resolveJudge(opts.judge ?? "none");
1405
- let results;
1406
- try {
1407
- results = await pool(
1408
- manifest.entries,
1409
- concurrency,
1410
- (entry) => checkEntry(entry, opts, cwd, baselinesDir, judge)
1732
+
1733
+ // src/graph/nodes/capture.ts
1734
+ function makeCaptureNode(semaphore) {
1735
+ return async function captureNode(state) {
1736
+ const entry = state.entry;
1737
+ const options = state.options;
1738
+ if (!entry || !options) {
1739
+ throw new Error("captureNode: entry or options missing");
1740
+ }
1741
+ if (entry.auth === "required") {
1742
+ return {
1743
+ captureOutput: {
1744
+ id: entry.id,
1745
+ skipResult: skipResult(
1746
+ entry,
1747
+ "skipped: auth required (deferred to v0.2)"
1748
+ )
1749
+ }
1750
+ };
1751
+ }
1752
+ if (isEntryStale(entry)) {
1753
+ return {
1754
+ captureOutput: { id: entry.id, skipResult: staleResult(entry) }
1755
+ };
1756
+ }
1757
+ const baselinePath = path.join(options.baselinesDir, `${entry.id}.png`);
1758
+ const capture = await semaphore.run(
1759
+ () => captureScreenshot(
1760
+ options.baseUrl,
1761
+ {
1762
+ id: entry.id,
1763
+ url: entry.url,
1764
+ viewport: entry.viewport,
1765
+ mask: entry.mask,
1766
+ waitFor: entry.waitFor,
1767
+ fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
1768
+ mode: "actual"
1769
+ },
1770
+ options.cwd
1771
+ )
1411
1772
  );
1412
- } finally {
1413
- await closeBrowser();
1414
- }
1415
- const passed = results.filter((r) => r.status === "pass").length;
1416
- const pendingJudgments = results.filter(
1417
- (r) => r.status === "needs-judgment"
1418
- ).length;
1419
- const report = {
1420
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1421
- totalEntries: results.length,
1422
- passed,
1423
- failed: results.length - passed - pendingJudgments,
1424
- pendingJudgments,
1425
- results
1773
+ return {
1774
+ captureOutput: {
1775
+ id: entry.id,
1776
+ captureOutputPath: capture.outputPath,
1777
+ baselinePath
1778
+ }
1779
+ };
1426
1780
  };
1427
- await writeJudgments({ report, manifest, cwd });
1428
- await writeSummaryMarkdown(report, cwd);
1429
- await ensureGitignore(cwd);
1430
- if (opts.junitPath) {
1431
- const target = path.isAbsolute(opts.junitPath) ? opts.junitPath : path.join(cwd, opts.junitPath);
1432
- await writeJunit(report, target);
1433
- }
1434
- return report;
1435
1781
  }
1436
1782
 
1437
- // src/discover/crawl.ts
1438
- function extractInternalLinks(base, target, hrefs) {
1439
- const out = [];
1440
- for (const href of hrefs) {
1441
- if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
1442
- continue;
1783
+ // src/graph/nodes/diff.ts
1784
+ function makeDiffNode(semaphore) {
1785
+ return async function diffNode(state) {
1786
+ const entry = state.entry;
1787
+ const options = state.options;
1788
+ const capture = state.captureOutput;
1789
+ if (!entry || !options || !capture) {
1790
+ throw new Error("diffNode: entry, options, or capture missing");
1443
1791
  }
1444
- try {
1445
- const u = new URL(href, target);
1446
- if (u.origin !== base.origin) continue;
1447
- const path18 = u.pathname + u.search;
1448
- if (path18.startsWith("/api/")) continue;
1449
- out.push(path18);
1450
- } catch {
1792
+ if (capture.skipResult) {
1793
+ return {
1794
+ diffOutput: { id: capture.id, skipResult: capture.skipResult }
1795
+ };
1451
1796
  }
1452
- }
1453
- return out;
1454
- }
1455
- async function crawlRoutes(opts) {
1456
- const maxRoutes = opts.maxRoutes ?? 50;
1457
- const maxDepth = opts.maxDepth ?? 2;
1458
- const base = new URL(opts.baseUrl);
1459
- const visited = /* @__PURE__ */ new Set();
1460
- const queue = [{ url: "/", depth: 0 }];
1461
- visited.add("/");
1462
- const discovered = [];
1463
- const browser = await getBrowser();
1464
- const context = await browser.newContext({
1465
- viewport: { width: 1024, height: 768 },
1466
- deviceScaleFactor: 1
1467
- });
1468
- try {
1469
- while (queue.length && discovered.length < maxRoutes) {
1470
- const { url, depth } = queue.shift();
1471
- const page = await context.newPage();
1472
- try {
1473
- const target = new URL(url, base).toString();
1474
- await page.goto(target, {
1475
- waitUntil: "domcontentloaded",
1476
- timeout: 15e3
1477
- });
1478
- discovered.push({ url, source: "crawl" });
1479
- if (depth >= maxDepth) continue;
1480
- const hrefs = await page.evaluate(
1481
- () => Array.from(
1482
- document.querySelectorAll("a[href]")
1483
- ).map((a) => a.getAttribute("href") ?? "")
1484
- );
1485
- for (const path18 of extractInternalLinks(base, target, hrefs)) {
1486
- if (visited.has(path18)) continue;
1487
- visited.add(path18);
1488
- queue.push({ url: path18, depth: depth + 1 });
1797
+ if (!capture.captureOutputPath || !capture.baselinePath) {
1798
+ throw new Error("diffNode: capture output paths missing");
1799
+ }
1800
+ const outcome = await semaphore.run(
1801
+ () => diffEntry(
1802
+ entry.id,
1803
+ capture.baselinePath,
1804
+ capture.captureOutputPath,
1805
+ { threshold: options.threshold, emitDiffPng: options.emitDiffPng },
1806
+ options.cwd
1807
+ )
1808
+ );
1809
+ if (outcome.reason === "file-not-exists") {
1810
+ return {
1811
+ diffOutput: {
1812
+ id: entry.id,
1813
+ skipResult: missingBaselineResult(entry, capture.baselinePath)
1489
1814
  }
1490
- } catch {
1491
- } finally {
1492
- await page.close().catch(() => {
1493
- });
1815
+ };
1816
+ }
1817
+ return {
1818
+ diffOutput: {
1819
+ id: entry.id,
1820
+ outcome,
1821
+ baselinePath: capture.baselinePath,
1822
+ captureOutputPath: capture.captureOutputPath
1494
1823
  }
1824
+ };
1825
+ };
1826
+ }
1827
+
1828
+ // src/diff/verdict.ts
1829
+ var REGRESSIVE_TYPES = /* @__PURE__ */ new Set(["content-change", "addition", "deletion"]);
1830
+ var INTENTIONAL_TYPES = /* @__PURE__ */ new Set(["color-change", "shift"]);
1831
+ var NOISE_TYPES = /* @__PURE__ */ new Set(["rendering-noise"]);
1832
+ var ELEVATED_SEVERITY = /* @__PURE__ */ new Set(["medium", "high"]);
1833
+ var SUB_PERCEPTUAL_PCT = 0.01;
1834
+ function pctText(pct) {
1835
+ if (typeof pct !== "number") return "?%";
1836
+ return pct >= 0.01 ? `${pct.toFixed(2)}%` : `${pct.toFixed(3)}%`;
1837
+ }
1838
+ function countByType(regions) {
1839
+ const counts = /* @__PURE__ */ new Map();
1840
+ for (const r of regions)
1841
+ counts.set(r.changeType, (counts.get(r.changeType) ?? 0) + 1);
1842
+ return counts;
1843
+ }
1844
+ function dominantType(counts) {
1845
+ let best = "";
1846
+ let bestN = 0;
1847
+ for (const [type, n] of counts) {
1848
+ if (n > bestN) {
1849
+ best = type;
1850
+ bestN = n;
1495
1851
  }
1496
- } finally {
1497
- await context.close().catch(() => {
1498
- });
1499
1852
  }
1500
- return discovered;
1853
+ return best;
1501
1854
  }
1502
- var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
1503
- async function readJson(file) {
1504
- if (!existsSync(file)) return null;
1505
- try {
1506
- return JSON.parse(await readFile(file, "utf8"));
1507
- } catch {
1508
- return null;
1509
- }
1855
+ function topPosition(regions) {
1856
+ let best;
1857
+ for (const r of regions)
1858
+ if (!best || r.pixelCount > best.pixelCount) best = r;
1859
+ return best?.position;
1510
1860
  }
1511
- function isPublicRoute(route) {
1512
- if (DYNAMIC_SEGMENT.test(route)) return false;
1513
- if (route === "/api" || route.startsWith("/api/")) return false;
1514
- return true;
1861
+ function meanConfidence(regions) {
1862
+ if (regions.length === 0) return 0;
1863
+ return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
1515
1864
  }
1516
- async function discoverFromNextManifest(cwd = process.cwd()) {
1517
- const nextDir = path.join(cwd, ".next");
1518
- if (!existsSync(nextDir)) return [];
1519
- const seen = /* @__PURE__ */ new Set();
1520
- const out = [];
1521
- const add = (url) => {
1522
- if (seen.has(url)) return;
1523
- seen.add(url);
1524
- out.push({ url, source: "next-manifest" });
1525
- };
1526
- const routes = await readJson(
1527
- path.join(nextDir, "routes-manifest.json")
1528
- );
1529
- for (const r of routes?.staticRoutes ?? []) {
1530
- if (isPublicRoute(r.page)) add(r.page);
1865
+ function formatBreakdown(counts) {
1866
+ return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
1867
+ }
1868
+ function buildHeadline(input) {
1869
+ const { reason, interpretation, diffCount, diffPercentage } = input;
1870
+ if (reason === "layout-diff") return "image dimensions changed";
1871
+ if (reason === "file-not-exists") return "baseline or actual capture missing";
1872
+ if (!interpretation || interpretation.regions.length === 0) {
1873
+ const px = diffCount?.toLocaleString() ?? "?";
1874
+ return `${px} px (${pctText(diffPercentage)}) - no region analysis`;
1531
1875
  }
1532
- const appPaths = await readJson(
1533
- path.join(nextDir, "server", "app-paths-manifest.json")
1534
- );
1535
- for (const route of Object.keys(appPaths ?? {})) {
1536
- const normalized = route.replace(/\/page$/, "") || "/";
1537
- if (isPublicRoute(normalized)) add(normalized);
1876
+ const regions = interpretation.regions;
1877
+ const counts = countByType(regions);
1878
+ const pos = topPosition(regions);
1879
+ const pct = pctText(diffPercentage ?? interpretation.diffPercentage);
1880
+ const sev = interpretation.severity ?? "?";
1881
+ if (regions.length === 1) {
1882
+ return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
1538
1883
  }
1539
- return out;
1884
+ return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
1540
1885
  }
1541
-
1542
- // src/discover/sitemap.ts
1543
- var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
1544
- var LOC_RE = /<loc>([^<]+)<\/loc>/g;
1545
- async function discoverFromSitemap(baseUrl) {
1546
- for (const candidate of CANDIDATES) {
1547
- try {
1548
- const res = await fetch(new URL(candidate, baseUrl));
1549
- if (!res.ok) continue;
1550
- const text = await res.text();
1551
- const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
1552
- if (!urls.length) continue;
1553
- return urls.map((u) => {
1554
- const url = new URL(u);
1555
- return { url: url.pathname + url.search, source: "sitemap" };
1556
- });
1557
- } catch {
1558
- }
1886
+ function deriveVerdict(input) {
1887
+ const { reason, interpretation, diffPercentage } = input;
1888
+ const headline = buildHeadline(input);
1889
+ if (reason === "layout-diff") {
1890
+ return {
1891
+ label: "ambiguous",
1892
+ headline,
1893
+ rationale: [
1894
+ "baseline and actual image dimensions differ \u2014 page height likely shifted",
1895
+ "could be intentional (content added/removed) or regression (broken layout)"
1896
+ ],
1897
+ action: "investigate"
1898
+ };
1899
+ }
1900
+ if (reason === "file-not-exists") {
1901
+ return {
1902
+ label: "regression-likely",
1903
+ headline,
1904
+ rationale: ["baseline or actual capture is missing from disk"],
1905
+ action: "investigate"
1906
+ };
1907
+ }
1908
+ if (!interpretation || interpretation.regions.length === 0) {
1909
+ return {
1910
+ label: "ambiguous",
1911
+ headline,
1912
+ rationale: ["pixels differ but interpret returned no regions"],
1913
+ action: "investigate"
1914
+ };
1915
+ }
1916
+ const regions = interpretation.regions;
1917
+ const severity = interpretation.severity;
1918
+ const counts = countByType(regions);
1919
+ const allNoise = regions.every((r) => NOISE_TYPES.has(r.changeType));
1920
+ const allColor = regions.every((r) => r.changeType === "color-change");
1921
+ const allMoved = regions.every((r) => INTENTIONAL_TYPES.has(r.changeType));
1922
+ const hasRegressive = regions.some((r) => REGRESSIVE_TYPES.has(r.changeType));
1923
+ const pct = typeof diffPercentage === "number" ? diffPercentage : interpretation.diffPercentage;
1924
+ if (allNoise) {
1925
+ return {
1926
+ label: "noise-likely",
1927
+ headline,
1928
+ rationale: ["all regions classified as rendering-noise"],
1929
+ action: "ignore-or-rewrite"
1930
+ };
1931
+ }
1932
+ if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
1933
+ return {
1934
+ label: "noise-likely",
1935
+ headline,
1936
+ rationale: [
1937
+ `delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
1938
+ "sub-perceptual change - review optional"
1939
+ ],
1940
+ action: "ignore-or-rewrite"
1941
+ };
1942
+ }
1943
+ if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
1944
+ const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
1945
+ return {
1946
+ label: "regression-likely",
1947
+ headline,
1948
+ rationale: [
1949
+ `severity ${severity} with structural changes (${types})`,
1950
+ "likely affects content or layout, not just styling"
1951
+ ],
1952
+ action: "investigate"
1953
+ };
1954
+ }
1955
+ if (allColor && meanConfidence(regions) > 0.7) {
1956
+ return {
1957
+ label: "intentional-likely",
1958
+ headline,
1959
+ rationale: [
1960
+ `${regions.length} color-change region${regions.length === 1 ? "" : "s"} with mean confidence > 0.7`,
1961
+ "edge structure preserved - looks like a theming / palette change"
1962
+ ],
1963
+ action: "rewrite-if-intended"
1964
+ };
1559
1965
  }
1560
- return [];
1561
- }
1562
-
1563
- // src/discover/index.ts
1564
- function normalizePath(url) {
1565
- const [pathPart, query = ""] = url.split("?", 2);
1566
- const trimmed = pathPart.replace(/\/+$/, "");
1567
- const normalizedPath = trimmed === "" ? "/" : trimmed;
1568
- return query ? `${normalizedPath}?${query}` : normalizedPath;
1569
- }
1570
- function mergeBy(routes, into) {
1571
- for (const r of routes) {
1572
- const key = normalizePath(r.url);
1573
- if (!into.has(key)) into.set(key, { ...r, url: key });
1966
+ if (allMoved && !allColor) {
1967
+ return {
1968
+ label: "intentional-likely",
1969
+ headline,
1970
+ rationale: [
1971
+ "all regions are shift/color-change - content moved or restyled, structure preserved"
1972
+ ],
1973
+ action: "rewrite-if-intended"
1974
+ };
1574
1975
  }
1976
+ return {
1977
+ label: "ambiguous",
1978
+ headline,
1979
+ rationale: [
1980
+ `mix of change types (${formatBreakdown(counts)}) at "${severity ?? "?"}" severity`,
1981
+ `${pctText(pct)} of image differs`
1982
+ ],
1983
+ action: "investigate"
1984
+ };
1575
1985
  }
1576
- async function discover(opts) {
1577
- const cwd = opts.cwd ?? process.cwd();
1578
- const merged = /* @__PURE__ */ new Map();
1579
- mergeBy(await discoverFromNextManifest(cwd), merged);
1580
- mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
1581
- if (!opts.skipCrawl) {
1582
- const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
1583
- if (crawlMax > 0) {
1584
- mergeBy(
1585
- await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
1586
- merged
1587
- );
1588
- }
1589
- }
1590
- return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
1986
+ function interruptForJudgment(payload) {
1987
+ const resume = interrupt(payload);
1988
+ if (!resume || typeof resume !== "object") return void 0;
1989
+ return resume[payload.entryId];
1591
1990
  }
1592
1991
 
1593
- // src/graph/nodes/aggregate.ts
1594
- async function aggregateNode(state) {
1992
+ // src/graph/nodes/judge.ts
1993
+ async function judgeNode(state) {
1994
+ const entry = state.entry;
1595
1995
  const options = state.options;
1596
- if (!options) throw new Error("aggregateNode: options missing");
1597
- const manifest = state.manifest;
1598
- if (!manifest) throw new Error("aggregateNode: manifest missing");
1599
- const results = state.results;
1600
- const passed = results.filter((r) => r.status === "pass").length;
1601
- const pendingJudgments = results.filter(
1602
- (r) => r.status === "needs-judgment"
1603
- ).length;
1604
- const report = {
1605
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1606
- totalEntries: results.length,
1607
- passed,
1608
- failed: results.length - passed - pendingJudgments,
1609
- pendingJudgments,
1610
- results
1611
- };
1612
- await writeJudgments({ report, manifest, cwd: options.cwd });
1613
- await writeSummaryMarkdown(report, options.cwd);
1614
- await ensureGitignore(options.cwd);
1615
- return { report };
1996
+ const diff = state.diffOutput;
1997
+ if (!entry || !options || !diff) {
1998
+ throw new Error("judgeNode: entry, options, or diff missing");
1999
+ }
2000
+ if (diff.skipResult) {
2001
+ return { results: [diff.skipResult] };
2002
+ }
2003
+ const outcome = diff.outcome;
2004
+ const baselinePath = diff.baselinePath;
2005
+ const captureOutputPath = diff.captureOutputPath;
2006
+ if (!outcome || !baselinePath || !captureOutputPath) {
2007
+ throw new Error("judgeNode: diff outputs missing");
2008
+ }
2009
+ if (outcome.match) {
2010
+ return { results: [passResult(entry, baselinePath, captureOutputPath)] };
2011
+ }
2012
+ const verdict = deriveVerdict({
2013
+ reason: outcome.reason,
2014
+ interpretation: outcome.interpretation,
2015
+ diffCount: outcome.diffCount,
2016
+ diffPercentage: outcome.diffPercentage
2017
+ });
2018
+ let result = failResult(
2019
+ entry,
2020
+ outcome,
2021
+ captureOutputPath,
2022
+ baselinePath,
2023
+ verdict
2024
+ );
2025
+ if (result.baselinePath && result.actualPath) {
2026
+ const judge = resolveJudge(options.judge);
2027
+ const output = await judge.judge(
2028
+ {
2029
+ entry,
2030
+ baselinePath: result.baselinePath,
2031
+ actualPath: result.actualPath,
2032
+ diffPath: result.diffPath,
2033
+ regions: result.regions,
2034
+ diffPercentage: result.diffPercentage,
2035
+ severity: result.severity,
2036
+ heuristicVerdict: result.verdict ?? verdict
2037
+ },
2038
+ options.cwd
2039
+ );
2040
+ if (output.kind === "judged") {
2041
+ result = { ...result, verdict: output.verdict };
2042
+ } else {
2043
+ const pending = {
2044
+ ...result,
2045
+ status: "needs-judgment",
2046
+ message: `awaiting judgment in ${output.requestPath}`
2047
+ };
2048
+ const resumed = interruptForJudgment({
2049
+ kind: "host-judgment-required",
2050
+ entryId: entry.id,
2051
+ url: entry.url,
2052
+ requestPath: output.requestPath,
2053
+ signature: signatureOf(result),
2054
+ pendingResult: pending
2055
+ });
2056
+ result = resumed ? { ...result, verdict: resumed } : pending;
2057
+ }
2058
+ }
2059
+ return { results: [result] };
1616
2060
  }
1617
2061
 
1618
2062
  // src/graph/nodes/load.ts
@@ -1628,187 +2072,39 @@ async function loadNode(state) {
1628
2072
  }
1629
2073
  return { entries: manifest.entries, manifest };
1630
2074
  }
1631
- function narrowRegion2(r) {
1632
- return {
1633
- bbox: r.bbox,
1634
- pixelCount: r.pixelCount,
1635
- percentage: r.percentage,
1636
- changeType: r.changeType,
1637
- confidence: r.confidence
1638
- };
1639
- }
1640
- function skipResult2(entry, message) {
1641
- return { id: entry.id, url: entry.url, status: "pass", message };
1642
- }
1643
- function staleResult2(entry) {
1644
- return {
1645
- id: entry.id,
1646
- url: entry.url,
1647
- status: "stale-baseline",
1648
- message: "captureHash mismatch: entry was edited without re-capturing"
1649
- };
1650
- }
1651
- function passResult3(entry, baselinePath, actualPath) {
1652
- return {
1653
- id: entry.id,
1654
- url: entry.url,
1655
- status: "pass",
1656
- baselinePath,
1657
- actualPath
1658
- };
1659
- }
1660
- function missingBaselineResult2(entry, baselinePath) {
1661
- return {
1662
- id: entry.id,
1663
- url: entry.url,
1664
- status: "missing-baseline",
1665
- message: `baseline missing at ${baselinePath}`
1666
- };
1667
- }
1668
- function failResult2(entry, outcome, actualPath, baselinePath, verdict) {
1669
- return {
1670
- id: entry.id,
1671
- url: entry.url,
1672
- status: "fail",
1673
- diffCount: outcome.diffCount,
1674
- diffPercentage: outcome.diffPercentage,
1675
- severity: outcome.interpretation?.severity,
1676
- regions: outcome.interpretation?.regions?.map(narrowRegion2),
1677
- verdict,
1678
- diffPath: outcome.diffPath,
1679
- baselinePath,
1680
- actualPath,
1681
- message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
1682
- };
1683
- }
1684
- function makeProcessNode(semaphore) {
1685
- return async function processNode(state) {
1686
- const entry = state.entry;
1687
- const options = state.options;
1688
- if (!entry || !options) {
1689
- throw new Error("processNode: entry or options missing");
1690
- }
1691
- if (entry.auth === "required") {
1692
- return {
1693
- results: [
1694
- skipResult2(entry, "skipped: auth required (deferred to v0.2)")
1695
- ]
1696
- };
1697
- }
1698
- if (isEntryStale(entry)) {
1699
- return { results: [staleResult2(entry)] };
1700
- }
1701
- const capture = await semaphore.run(
1702
- () => captureScreenshot(
1703
- options.baseUrl,
1704
- {
1705
- id: entry.id,
1706
- url: entry.url,
1707
- viewport: entry.viewport,
1708
- mask: entry.mask,
1709
- waitFor: entry.waitFor,
1710
- fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
1711
- mode: "actual"
1712
- },
1713
- options.cwd
1714
- )
1715
- );
1716
- const baselinePath = path.join(options.baselinesDir, `${entry.id}.png`);
1717
- const outcome = await diffEntry(
1718
- entry.id,
1719
- baselinePath,
1720
- capture.outputPath,
1721
- { threshold: options.threshold, emitDiffPng: options.emitDiffPng },
1722
- options.cwd
1723
- );
1724
- if (outcome.match) {
1725
- return { results: [passResult3(entry, baselinePath, capture.outputPath)] };
1726
- }
1727
- if (outcome.reason === "file-not-exists") {
1728
- return { results: [missingBaselineResult2(entry, baselinePath)] };
1729
- }
1730
- const verdict = deriveVerdict({
1731
- reason: outcome.reason,
1732
- interpretation: outcome.interpretation,
1733
- diffCount: outcome.diffCount,
1734
- diffPercentage: outcome.diffPercentage
1735
- });
1736
- let result = failResult2(
1737
- entry,
1738
- outcome,
1739
- capture.outputPath,
1740
- baselinePath,
1741
- verdict
1742
- );
1743
- if (result.verdict?.label === "ambiguous" && result.baselinePath && result.actualPath) {
1744
- const judge = resolveJudge(options.judge);
1745
- const output = await judge.judge(
1746
- {
1747
- entry,
1748
- baselinePath: result.baselinePath,
1749
- actualPath: result.actualPath,
1750
- diffPath: result.diffPath,
1751
- regions: result.regions,
1752
- diffPercentage: result.diffPercentage,
1753
- severity: result.severity,
1754
- heuristicVerdict: result.verdict
1755
- },
1756
- options.cwd
1757
- );
1758
- if (output.kind === "judged") {
1759
- result = { ...result, verdict: output.verdict };
1760
- } else {
1761
- result = {
1762
- ...result,
1763
- status: "needs-judgment",
1764
- message: `awaiting judgment in ${output.requestPath}`
1765
- };
1766
- }
1767
- }
1768
- return { results: [result] };
1769
- };
1770
- }
1771
-
1772
- // src/graph/semaphore.ts
1773
- var Semaphore = class {
1774
- constructor(limit) {
1775
- this.limit = limit;
1776
- this.current = 0;
1777
- this.queue = [];
1778
- if (limit < 1)
1779
- throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
1780
- }
1781
- async run(fn) {
1782
- if (this.current >= this.limit) {
1783
- await new Promise((resolve) => this.queue.push(resolve));
1784
- }
1785
- this.current++;
1786
- try {
1787
- return await fn();
1788
- } finally {
1789
- this.current--;
1790
- const next = this.queue.shift();
1791
- if (next) next();
1792
- }
1793
- }
1794
- };
1795
- var GraphState = Annotation.Root({
2075
+ var resultsChannel = Annotation({
2076
+ reducer: (acc, next) => [...acc, ...next],
2077
+ default: () => []
2078
+ });
2079
+ var BranchState = Annotation.Root({
2080
+ entry: Annotation({
2081
+ reducer: (_, next) => next,
2082
+ default: () => void 0
2083
+ }),
1796
2084
  options: Annotation({
1797
2085
  reducer: (acc, next) => next ?? acc,
1798
2086
  default: () => void 0
1799
2087
  }),
1800
- entries: Annotation({
1801
- reducer: (acc, next) => next ?? acc,
1802
- default: () => []
2088
+ captureOutput: Annotation({
2089
+ reducer: (_, next) => next,
2090
+ default: () => void 0
1803
2091
  }),
1804
- entry: Annotation({
2092
+ diffOutput: Annotation({
1805
2093
  reducer: (_, next) => next,
1806
2094
  default: () => void 0
1807
2095
  }),
1808
- results: Annotation({
1809
- reducer: (acc, next) => [...acc, ...next],
2096
+ results: resultsChannel
2097
+ });
2098
+ var GraphState = Annotation.Root({
2099
+ options: Annotation({
2100
+ reducer: (acc, next) => next ?? acc,
2101
+ default: () => void 0
2102
+ }),
2103
+ entries: Annotation({
2104
+ reducer: (acc, next) => next ?? acc,
1810
2105
  default: () => []
1811
2106
  }),
2107
+ results: resultsChannel,
1812
2108
  manifest: Annotation({
1813
2109
  reducer: (acc, next) => next ?? acc,
1814
2110
  default: () => void 0
@@ -1820,46 +2116,143 @@ var GraphState = Annotation.Root({
1820
2116
  });
1821
2117
 
1822
2118
  // src/graph/index.ts
1823
- function buildGraph(semaphore) {
1824
- return new StateGraph(GraphState).addNode("load", loadNode).addNode("process", makeProcessNode(semaphore)).addNode("aggregate", aggregateNode).addEdge(START, "load").addConditionalEdges(
2119
+ function cpuParallelism() {
2120
+ const cores = typeof availableParallelism === "function" ? availableParallelism() : cpus().length;
2121
+ if (!cores || !Number.isFinite(cores)) return 2;
2122
+ return Math.max(2, cores);
2123
+ }
2124
+ function threadIdFor(cwd) {
2125
+ return createHash("sha1").update(paths(cwd).manifest).digest("hex").slice(0, 16);
2126
+ }
2127
+ function buildBranchGraph(captureSemaphore, diffSemaphore) {
2128
+ return new StateGraph(BranchState).addNode("capture", makeCaptureNode(captureSemaphore)).addNode("diff", makeDiffNode(diffSemaphore)).addNode("judge", judgeNode).addEdge(START, "capture").addEdge("capture", "diff").addEdge("diff", "judge").addEdge("judge", END).compile();
2129
+ }
2130
+ function buildGraph(captureSemaphore, diffSemaphore, checkpointer) {
2131
+ const branch = buildBranchGraph(captureSemaphore, diffSemaphore);
2132
+ return new StateGraph(GraphState).addNode("load", loadNode).addNode("branch", branch).addEdge(START, "load").addConditionalEdges(
1825
2133
  "load",
1826
2134
  (state) => state.entries.map(
1827
- (entry) => new Send("process", { entry, options: state.options })
2135
+ (entry) => new Send("branch", { entry, options: state.options })
1828
2136
  ),
1829
- ["process"]
1830
- ).addEdge("process", "aggregate").addEdge("aggregate", END).compile();
2137
+ ["branch"]
2138
+ ).addEdge("branch", END).compile({ checkpointer });
2139
+ }
2140
+ async function streamGraph(graph, input, threadId, onEvent) {
2141
+ const collect = { results: [], interrupts: [] };
2142
+ const stream = await graph.stream(input, {
2143
+ streamMode: "updates",
2144
+ configurable: { thread_id: threadId }
2145
+ });
2146
+ for await (const chunk of stream) {
2147
+ if (!chunk || typeof chunk !== "object") continue;
2148
+ for (const [node, partial] of Object.entries(
2149
+ chunk
2150
+ )) {
2151
+ if (node === "__interrupt__") {
2152
+ const arr = partial;
2153
+ if (!arr) continue;
2154
+ for (const i of arr) {
2155
+ const v = i?.value;
2156
+ if (!v || v.kind !== "host-judgment-required") continue;
2157
+ collect.interrupts.push(v);
2158
+ onEvent?.({ type: "interrupt", interrupt: v });
2159
+ }
2160
+ continue;
2161
+ }
2162
+ const part = partial;
2163
+ if (!part) continue;
2164
+ if (part.results) {
2165
+ for (const r of part.results) {
2166
+ collect.results.push(r);
2167
+ onEvent?.({ type: "result", result: r });
2168
+ }
2169
+ }
2170
+ if (part.manifest) collect.manifest = part.manifest;
2171
+ if (part.report) {
2172
+ collect.report = part.report;
2173
+ onEvent?.({ type: "report", report: part.report });
2174
+ }
2175
+ }
2176
+ }
2177
+ return collect;
2178
+ }
2179
+ async function buildPartialReport(collect, cwd) {
2180
+ const manifest = collect.manifest ?? await loadManifest(cwd);
2181
+ const synthesized = collect.interrupts.map((i) => ({
2182
+ ...i.pendingResult,
2183
+ status: "needs-judgment",
2184
+ message: i.pendingResult.message ?? `awaiting judgment in ${i.requestPath}`
2185
+ }));
2186
+ const results = [...collect.results, ...synthesized];
2187
+ const passed = results.filter((r) => r.status === "pass").length;
2188
+ const pendingJudgments = results.filter(
2189
+ (r) => r.status === "needs-judgment"
2190
+ ).length;
2191
+ const report = {
2192
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2193
+ totalEntries: results.length,
2194
+ passed,
2195
+ failed: results.length - passed - pendingJudgments,
2196
+ pendingJudgments,
2197
+ results
2198
+ };
2199
+ if (manifest) {
2200
+ await writeJudgments({ report, manifest, cwd });
2201
+ }
2202
+ await writeSummaryMarkdown(report, cwd);
2203
+ await ensureGitignore(cwd);
2204
+ return report;
1831
2205
  }
1832
2206
  async function runGraph(opts) {
1833
2207
  const cwd = opts.cwd ?? process.cwd();
1834
2208
  const concurrency = opts.concurrency ?? defaultConcurrency();
1835
- const semaphore = new Semaphore(concurrency);
2209
+ const captureSemaphore = new Semaphore(concurrency);
2210
+ const diffSemaphore = new Semaphore(cpuParallelism());
1836
2211
  const baselinesDir = paths(cwd).baselines;
1837
- const graph = buildGraph(semaphore);
1838
- let finalState;
2212
+ const checkpointer = new FsCheckpointSaver(paths(cwd).checkpoints);
2213
+ const graph = buildGraph(captureSemaphore, diffSemaphore, checkpointer);
2214
+ const threadId = opts.threadId ?? threadIdFor(cwd);
2215
+ if (!opts.resume) {
2216
+ await checkpointer.deleteThread(threadId);
2217
+ }
2218
+ const input = opts.resume ? new Command({ resume: opts.resume }) : {
2219
+ options: {
2220
+ baseUrl: opts.baseUrl ?? "",
2221
+ cwd,
2222
+ threshold: opts.threshold,
2223
+ concurrency,
2224
+ emitDiffPng: opts.emitDiffPng ?? true,
2225
+ judge: opts.judge ?? "none",
2226
+ baselinesDir
2227
+ }
2228
+ };
2229
+ let collect;
1839
2230
  try {
1840
- finalState = await graph.invoke({
1841
- options: {
1842
- baseUrl: opts.baseUrl,
1843
- cwd,
1844
- threshold: opts.threshold,
1845
- concurrency,
1846
- emitDiffPng: opts.emitDiffPng ?? true,
1847
- judge: opts.judge ?? "none",
1848
- baselinesDir
1849
- }
1850
- });
2231
+ collect = await streamGraph(graph, input, threadId, opts.onEvent);
1851
2232
  } finally {
1852
2233
  await closeBrowser();
1853
2234
  }
1854
- const report = finalState.report;
1855
- if (!report) {
1856
- throw new Error("runGraph: graph completed without producing a report");
1857
- }
2235
+ const report = collect.report ?? await buildPartialReport(collect, cwd);
1858
2236
  if (opts.junitPath) {
1859
2237
  const target = path.isAbsolute(opts.junitPath) ? opts.junitPath : path.join(cwd, opts.junitPath);
1860
2238
  await writeJunit(report, target);
1861
2239
  }
2240
+ if (collect.interrupts.length === 0) {
2241
+ await checkpointer.deleteThread(threadId).catch(() => void 0);
2242
+ }
1862
2243
  return report;
1863
2244
  }
2245
+ async function resumeGraph(opts) {
2246
+ return runGraph({
2247
+ cwd: opts.cwd,
2248
+ threadId: opts.threadId,
2249
+ resume: opts.verdicts,
2250
+ onEvent: opts.onEvent,
2251
+ junitPath: opts.junitPath
2252
+ });
2253
+ }
2254
+ function runCheck(opts) {
2255
+ return runGraph(opts);
2256
+ }
1864
2257
 
1865
- export { STABILITY_HOOKS_VERSION, applyJudgments, captureScreenshot, configHash, diffEntry, discover, installBrowsers, loadConfig, loadManifest, paths, resolveBaseUrl, resolveJudge, runCaptures, runCheck, runGraph, saveConfig, saveManifest };
2258
+ export { STABILITY_HOOKS_VERSION, applyJudgments, captureScreenshot, configHash, diffEntry, discover, installBrowsers, loadConfig, loadManifest, paths, resolveBaseUrl, resolveJudge, resumeGraph, runCaptures, runCheck, runGraph, saveConfig, saveManifest, threadIdFor };