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