@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/cli.js CHANGED
@@ -10,14 +10,15 @@ var path2 = require('path');
10
10
  var promises = require('fs/promises');
11
11
  var os = require('os');
12
12
  var crypto$1 = require('crypto');
13
- var coreNative = require('@blazediff/core-native');
13
+ var langgraph = require('@langchain/langgraph');
14
14
  var sharp = require('sharp');
15
+ var langgraphCheckpoint = require('@langchain/langgraph-checkpoint');
16
+ var coreNative = require('@blazediff/core-native');
15
17
  var promises$1 = require('readline/promises');
16
18
  var url = require('url');
17
19
  var net = require('net');
18
20
  var util = require('util');
19
21
  var treeKill = require('tree-kill');
20
- var langgraph = require('@langchain/langgraph');
21
22
 
22
23
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
23
24
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -50,6 +51,7 @@ var CHROMIUM_FLAGS = [
50
51
  ];
51
52
  var cachedBrowser = null;
52
53
  var launchInFlight = null;
54
+ var contextPool = /* @__PURE__ */ new Map();
53
55
  async function getBrowser() {
54
56
  if (cachedBrowser?.isConnected()) return cachedBrowser;
55
57
  if (launchInFlight) return launchInFlight;
@@ -66,15 +68,22 @@ async function closeBrowser() {
66
68
  await launchInFlight.catch(() => {
67
69
  });
68
70
  }
71
+ const ctxs = Array.from(contextPool.values()).flat();
72
+ contextPool.clear();
73
+ await Promise.all(ctxs.map((c) => c.close().catch(() => {
74
+ })));
69
75
  if (!cachedBrowser) return;
70
76
  await cachedBrowser.close().catch(() => {
71
77
  });
72
78
  cachedBrowser = null;
73
79
  }
74
- async function openStableContext(opts) {
80
+ function viewportKey(v) {
81
+ return `${v.width}x${v.height}`;
82
+ }
83
+ async function createStableContext(viewport) {
75
84
  const browser = await getBrowser();
76
85
  const context = await browser.newContext({
77
- viewport: opts.viewport,
86
+ viewport,
78
87
  deviceScaleFactor: 1,
79
88
  reducedMotion: "reduce",
80
89
  forcedColors: "none",
@@ -119,12 +128,39 @@ async function openStableContext(opts) {
119
128
  },
120
129
  { frozenNow: FROZEN_NOW }
121
130
  );
122
- const page = await context.newPage();
131
+ return context;
132
+ }
133
+ async function acquireStableContext(viewport) {
134
+ const key = viewportKey(viewport);
135
+ const pool = contextPool.get(key);
136
+ if (pool && pool.length > 0) {
137
+ const context2 = pool.pop();
138
+ return { context: context2, viewport };
139
+ }
140
+ const context = await createStableContext(viewport);
141
+ return { context, viewport };
142
+ }
143
+ async function releaseStableContext(handle) {
144
+ const key = viewportKey(handle.viewport);
145
+ if (!cachedBrowser?.isConnected()) {
146
+ await handle.context.close().catch(() => {
147
+ });
148
+ return;
149
+ }
150
+ const pool = contextPool.get(key);
151
+ if (pool) {
152
+ pool.push(handle.context);
153
+ } else {
154
+ contextPool.set(key, [handle.context]);
155
+ }
156
+ }
157
+ async function openStablePage(handle) {
158
+ const page = await handle.context.newPage();
123
159
  const injectStability = () => page.addStyleTag({ content: STABILITY_CSS }).catch(() => {
124
160
  });
125
161
  await injectStability();
126
162
  page.on("load", injectStability);
127
- return { context, page };
163
+ return page;
128
164
  }
129
165
  async function waitForStability(page, waitFor) {
130
166
  for (const w of waitFor) {
@@ -254,6 +290,7 @@ var paths = (cwd = process.cwd()) => {
254
290
  baselines: path2__default.default.join(root, "baselines"),
255
291
  actual: path2__default.default.join(root, "actual"),
256
292
  judgments: path2__default.default.join(root, "judgments"),
293
+ checkpoints: path2__default.default.join(root, "checkpoints"),
257
294
  summary: path2__default.default.join(root, "summary.md"),
258
295
  gitignore: path2__default.default.join(root, ".gitignore"),
259
296
  serverLog: path2__default.default.join(root, "dev-server.log"),
@@ -267,7 +304,8 @@ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
267
304
  const waitFor = opts.waitFor ?? DEFAULT_WAIT_FOR;
268
305
  const masks = opts.mask ?? [];
269
306
  const fullPage = opts.fullPage ?? DEFAULT_FULL_PAGE;
270
- const { context, page } = await openStableContext({ viewport});
307
+ const handle = await acquireStableContext(viewport);
308
+ const page = await openStablePage(handle);
271
309
  try {
272
310
  const url = new URL(opts.url, baseUrl).toString();
273
311
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
@@ -289,8 +327,9 @@ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
289
327
  });
290
328
  return { id: opts.id, outputPath, mode: opts.mode, bytes: buffer.length };
291
329
  } finally {
292
- await context.close().catch(() => {
330
+ await page.close().catch(() => {
293
331
  });
332
+ await releaseStableContext(handle);
294
333
  }
295
334
  }
296
335
  async function loadConfig(cwd = process.cwd()) {
@@ -314,6 +353,30 @@ function resolveBaseUrl(config, override) {
314
353
  throw new Error("no baseUrl: pass --base-url or run `blazediff-agent init`");
315
354
  }
316
355
 
356
+ // src/graph/semaphore.ts
357
+ var Semaphore = class {
358
+ constructor(limit) {
359
+ this.limit = limit;
360
+ this.current = 0;
361
+ this.queue = [];
362
+ if (limit < 1)
363
+ throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
364
+ }
365
+ async run(fn) {
366
+ if (this.current >= this.limit) {
367
+ await new Promise((resolve) => this.queue.push(resolve));
368
+ }
369
+ this.current++;
370
+ try {
371
+ return await fn();
372
+ } finally {
373
+ this.current--;
374
+ const next = this.queue.shift();
375
+ if (next) next();
376
+ }
377
+ }
378
+ };
379
+
317
380
  // src/types.ts
318
381
  var STABILITY_HOOKS_VERSION = 1;
319
382
 
@@ -439,55 +502,62 @@ async function runCaptures(opts) {
439
502
  });
440
503
  let manifest = writeManifest ? await loadOrCreateManifest(cwd) : null;
441
504
  let manifestUpdates = 0;
505
+ const slot = new Array(valid.length);
506
+ const semaphore = new Semaphore(opts.concurrency ?? defaultConcurrency());
442
507
  try {
443
- for (const r of valid) {
444
- const mode = r.mode ?? defaultMode;
445
- try {
446
- const shot = await captureScreenshot(
447
- opts.baseUrl,
448
- {
449
- id: r.id,
450
- url: r.url,
451
- viewport: r.viewport,
452
- mask: r.mask,
453
- waitFor: r.waitFor,
454
- fullPage: r.fullPage,
455
- mode
456
- },
457
- cwd
458
- );
459
- results.push({
460
- id: r.id,
461
- url: r.url,
462
- mode,
463
- ok: true,
464
- outputPath: shot.outputPath,
465
- bytes: shot.bytes
466
- });
467
- if (manifest && mode === "baseline") {
468
- manifest = addOrReplaceEntry(
469
- manifest,
470
- makeEntry({
508
+ await Promise.all(
509
+ valid.map(
510
+ (r, i) => semaphore.run(async () => {
511
+ const mode = r.mode ?? defaultMode;
512
+ try {
513
+ const shot = await captureScreenshot(
514
+ opts.baseUrl,
515
+ {
516
+ id: r.id,
517
+ url: r.url,
518
+ viewport: r.viewport,
519
+ mask: r.mask,
520
+ waitFor: r.waitFor,
521
+ fullPage: r.fullPage,
522
+ mode
523
+ },
524
+ cwd
525
+ );
526
+ slot[i] = {
471
527
  id: r.id,
472
528
  url: r.url,
473
- viewport: r.viewport,
474
- mask: r.mask,
475
- waitFor: r.waitFor,
476
- fullPage: r.fullPage
477
- })
478
- );
479
- manifestUpdates += 1;
480
- }
481
- } catch (err) {
482
- results.push({
483
- id: r.id,
484
- url: r.url,
485
- mode,
486
- ok: false,
487
- error: err.message
488
- });
489
- }
490
- }
529
+ mode,
530
+ ok: true,
531
+ outputPath: shot.outputPath,
532
+ bytes: shot.bytes
533
+ };
534
+ if (mode === "baseline" && manifest) {
535
+ manifest = addOrReplaceEntry(
536
+ manifest,
537
+ makeEntry({
538
+ id: r.id,
539
+ url: r.url,
540
+ viewport: r.viewport,
541
+ mask: r.mask,
542
+ waitFor: r.waitFor,
543
+ fullPage: r.fullPage
544
+ })
545
+ );
546
+ manifestUpdates += 1;
547
+ }
548
+ } catch (err) {
549
+ slot[i] = {
550
+ id: r.id,
551
+ url: r.url,
552
+ mode,
553
+ ok: false,
554
+ error: err.message
555
+ };
556
+ }
557
+ })
558
+ )
559
+ );
560
+ for (const r of slot) results.push(r);
491
561
  if (manifest && manifestUpdates > 0) await saveManifest(manifest, cwd);
492
562
  } finally {
493
563
  await closeBrowser();
@@ -508,6 +578,29 @@ function parseViewport(value) {
508
578
  if (!w || !h) throw new Error(`invalid viewport: ${value} (expected WxH)`);
509
579
  return { width: w, height: h };
510
580
  }
581
+ function parsePositiveInteger(value, flagName) {
582
+ const parsed = Number(value);
583
+ if (!Number.isInteger(parsed) || parsed < 1) {
584
+ throw new Error(`invalid ${flagName}: ${value} (expected integer >= 1)`);
585
+ }
586
+ return parsed;
587
+ }
588
+ function parsePort(value, flagName = "--port") {
589
+ const parsed = parsePositiveInteger(value, flagName);
590
+ if (parsed > 65535) {
591
+ throw new Error(
592
+ `invalid ${flagName}: ${value} (expected integer <= 65535)`
593
+ );
594
+ }
595
+ return parsed;
596
+ }
597
+ function parseThreshold(value, flagName = "--threshold") {
598
+ const parsed = Number(value);
599
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
600
+ throw new Error(`invalid ${flagName}: ${value} (expected number 0-1)`);
601
+ }
602
+ return parsed;
603
+ }
511
604
  function parseMaskList(value) {
512
605
  return value.split(",").map((s) => s.trim()).filter(Boolean);
513
606
  }
@@ -609,6 +702,7 @@ function registerCapture(program, out) {
609
702
  var ENTRIES = [
610
703
  "actual/",
611
704
  "judgments/",
705
+ "checkpoints/",
612
706
  "summary.md",
613
707
  "dev-server.log",
614
708
  "dev-server.pid",
@@ -640,343 +734,126 @@ ${missing.join("\n")}
640
734
  `;
641
735
  await promises.writeFile(file, body, "utf8");
642
736
  }
643
- async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.cwd()) {
644
- if (!fs.existsSync(baselinePath) || !fs.existsSync(actualPath)) {
645
- return {
646
- id,
647
- baselinePath,
648
- actualPath,
649
- match: false,
650
- reason: "file-not-exists"
651
- };
652
- }
653
- let diffPath;
654
- if (opts.emitDiffPng) {
655
- const actualDir = paths(cwd).actual;
656
- await promises.mkdir(actualDir, { recursive: true });
657
- diffPath = path2__default.default.join(actualDir, `${id}.diff.png`);
658
- }
659
- const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
660
- const antialiasing = opts.antialiasing ?? true;
661
- const result = await coreNative.compare(baselinePath, actualPath, diffPath, {
662
- threshold,
663
- antialiasing
664
- });
665
- if (result.match) return { id, baselinePath, actualPath, match: true };
666
- if (result.reason === "file-not-exists") {
667
- return {
668
- id,
669
- baselinePath,
670
- actualPath,
671
- match: false,
672
- reason: "file-not-exists"
673
- };
737
+ var DEFAULT_TOP_N = 5;
738
+ var DEFAULT_PADDING = 16;
739
+ var DEFAULT_LOCATOR_MAX_WIDTH = 400;
740
+ var DEFAULT_GUTTER = 2;
741
+ var DEFAULT_ROW_GUTTER = 8;
742
+ var BG_WHITE = { r: 255, g: 255, b: 255 };
743
+ function padAndClamp(bbox, padding, imgWidth, imgHeight) {
744
+ const left = Math.max(0, Math.floor(bbox.x - padding));
745
+ const top = Math.max(0, Math.floor(bbox.y - padding));
746
+ const right = Math.min(imgWidth, Math.ceil(bbox.x + bbox.width + padding));
747
+ const bottom = Math.min(imgHeight, Math.ceil(bbox.y + bbox.height + padding));
748
+ return {
749
+ x: left,
750
+ y: top,
751
+ width: Math.max(1, right - left),
752
+ height: Math.max(1, bottom - top)
753
+ };
754
+ }
755
+ async function prepareTiles(opts) {
756
+ const topN = opts.topN ?? DEFAULT_TOP_N;
757
+ const padding = opts.padding ?? DEFAULT_PADDING;
758
+ const locatorMaxWidth = opts.locatorMaxWidth ?? DEFAULT_LOCATOR_MAX_WIDTH;
759
+ const gutter = opts.gutter ?? DEFAULT_GUTTER;
760
+ const rowGutter = opts.rowGutter ?? DEFAULT_ROW_GUTTER;
761
+ const diffMeta = await sharp__default.default(opts.diffPath).metadata();
762
+ const imgWidth = diffMeta.width ?? 0;
763
+ const imgHeight = diffMeta.height ?? 0;
764
+ if (!imgWidth || !imgHeight) {
765
+ throw new Error(`unable to read diff image dimensions: ${opts.diffPath}`);
674
766
  }
675
- if (result.reason === "layout-diff") {
676
- return {
677
- id,
678
- baselinePath,
679
- actualPath,
680
- diffPath,
681
- match: false,
682
- reason: "layout-diff"
683
- };
767
+ const ranked = [...opts.regions].sort((a, b) => b.pixelCount - a.pixelCount).slice(0, topN);
768
+ const regionData = await Promise.all(
769
+ ranked.map(async (region) => {
770
+ const padded = padAndClamp(region.bbox, padding, imgWidth, imgHeight);
771
+ const extract = {
772
+ left: padded.x,
773
+ top: padded.y,
774
+ width: padded.width,
775
+ height: padded.height
776
+ };
777
+ const [base, actual] = await Promise.all([
778
+ sharp__default.default(opts.baselinePath).extract(extract).toBuffer(),
779
+ sharp__default.default(opts.actualPath).extract(extract).toBuffer()
780
+ ]);
781
+ return { region, padded, base, actual };
782
+ })
783
+ );
784
+ let tilesName;
785
+ if (regionData.length > 0) {
786
+ const canvasWidth = Math.max(
787
+ ...regionData.map((r) => r.padded.width * 2 + gutter)
788
+ );
789
+ const totalHeight = regionData.reduce(
790
+ (sum, r, i) => sum + r.padded.height + (i > 0 ? rowGutter : 0),
791
+ 0
792
+ );
793
+ const composites = [];
794
+ let y = 0;
795
+ for (let i = 0; i < regionData.length; i++) {
796
+ const r = regionData[i];
797
+ const w = r.padded.width;
798
+ composites.push(
799
+ { input: r.base, left: 0, top: y },
800
+ { input: r.actual, left: w + gutter, top: y }
801
+ );
802
+ y += r.padded.height;
803
+ if (i < regionData.length - 1) y += rowGutter;
804
+ }
805
+ tilesName = "regions.png";
806
+ await sharp__default.default({
807
+ create: {
808
+ width: canvasWidth,
809
+ height: totalHeight,
810
+ channels: 3,
811
+ background: BG_WHITE
812
+ }
813
+ }).composite(composites).png().toFile(path2__default.default.join(opts.outputDir, tilesName));
684
814
  }
685
- const interpretation = await coreNative.interpret(baselinePath, actualPath, {
686
- threshold,
687
- antialiasing
688
- }).catch(() => void 0);
815
+ const scale = locatorMaxWidth / Math.max(imgWidth, imgHeight);
816
+ const locW = Math.max(1, Math.round(imgWidth * scale));
817
+ const locH = Math.max(1, Math.round(imgHeight * scale));
818
+ const rects = opts.regions.map((r) => {
819
+ const x = Math.round(r.bbox.x * scale);
820
+ const y = Math.round(r.bbox.y * scale);
821
+ const w = Math.max(1, Math.round(r.bbox.width * scale));
822
+ const h = Math.max(1, Math.round(r.bbox.height * scale));
823
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="red" stroke-width="2" />`;
824
+ }).join("");
825
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${locW}" height="${locH}">${rects}</svg>`;
826
+ const locatorName = "locator.png";
827
+ await sharp__default.default(opts.diffPath).resize(locW, locH, { fit: "fill" }).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toFile(path2__default.default.join(opts.outputDir, locatorName));
689
828
  return {
690
- id,
691
- baselinePath,
692
- actualPath,
693
- diffPath,
694
- match: false,
695
- reason: "pixel-diff",
696
- diffCount: result.diffCount,
697
- diffPercentage: result.diffPercentage,
698
- interpretation
829
+ locatorPath: locatorName,
830
+ tilesPath: tilesName,
831
+ regions: regionData.map((r) => ({
832
+ bbox: r.region.bbox,
833
+ pixelCount: r.region.pixelCount
834
+ }))
699
835
  };
700
836
  }
701
837
 
702
- // src/diff/verdict.ts
703
- var REGRESSIVE_TYPES = /* @__PURE__ */ new Set(["content-change", "addition", "deletion"]);
704
- var INTENTIONAL_TYPES = /* @__PURE__ */ new Set(["color-change", "shift"]);
705
- var NOISE_TYPES = /* @__PURE__ */ new Set(["rendering-noise"]);
706
- var ELEVATED_SEVERITY = /* @__PURE__ */ new Set(["medium", "high"]);
707
- var SUB_PERCEPTUAL_PCT = 0.01;
708
- function pctText(pct) {
709
- if (typeof pct !== "number") return "?%";
710
- return pct >= 0.01 ? `${pct.toFixed(2)}%` : `${pct.toFixed(3)}%`;
711
- }
712
- function countByType(regions) {
713
- const counts = /* @__PURE__ */ new Map();
714
- for (const r of regions)
715
- counts.set(r.changeType, (counts.get(r.changeType) ?? 0) + 1);
716
- return counts;
717
- }
718
- function dominantType(counts) {
719
- let best = "";
720
- let bestN = 0;
721
- for (const [type, n] of counts) {
722
- if (n > bestN) {
723
- best = type;
724
- bestN = n;
725
- }
726
- }
727
- return best;
728
- }
729
- function topPosition(regions) {
730
- let best;
731
- for (const r of regions)
732
- if (!best || r.pixelCount > best.pixelCount) best = r;
733
- return best?.position;
734
- }
735
- function meanConfidence(regions) {
736
- if (regions.length === 0) return 0;
737
- return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
738
- }
739
- function formatBreakdown(counts) {
740
- return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
741
- }
742
- function buildHeadline(input) {
743
- const { reason, interpretation, diffCount, diffPercentage } = input;
744
- if (reason === "layout-diff") return "image dimensions changed";
745
- if (reason === "file-not-exists") return "baseline or actual capture missing";
746
- if (!interpretation || interpretation.regions.length === 0) {
747
- const px = diffCount?.toLocaleString() ?? "?";
748
- return `${px} px (${pctText(diffPercentage)}) - no region analysis`;
749
- }
750
- const regions = interpretation.regions;
751
- const counts = countByType(regions);
752
- const pos = topPosition(regions);
753
- const pct = pctText(diffPercentage ?? interpretation.diffPercentage);
754
- const sev = interpretation.severity ?? "?";
755
- if (regions.length === 1) {
756
- return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
757
- }
758
- return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
759
- }
760
- function deriveVerdict(input) {
761
- const { reason, interpretation, diffPercentage } = input;
762
- const headline = buildHeadline(input);
763
- if (reason === "layout-diff") {
764
- return {
765
- label: "ambiguous",
766
- headline,
767
- rationale: [
768
- "baseline and actual image dimensions differ \u2014 page height likely shifted",
769
- "could be intentional (content added/removed) or regression (broken layout)"
770
- ],
771
- action: "investigate"
772
- };
773
- }
774
- if (reason === "file-not-exists") {
775
- return {
776
- label: "regression-likely",
777
- headline,
778
- rationale: ["baseline or actual capture is missing from disk"],
779
- action: "investigate"
780
- };
781
- }
782
- if (!interpretation || interpretation.regions.length === 0) {
783
- return {
784
- label: "ambiguous",
785
- headline,
786
- rationale: ["pixels differ but interpret returned no regions"],
787
- action: "investigate"
788
- };
789
- }
790
- const regions = interpretation.regions;
791
- const severity = interpretation.severity;
792
- const counts = countByType(regions);
793
- const allNoise = regions.every((r) => NOISE_TYPES.has(r.changeType));
794
- const allColor = regions.every((r) => r.changeType === "color-change");
795
- const allMoved = regions.every((r) => INTENTIONAL_TYPES.has(r.changeType));
796
- const hasRegressive = regions.some((r) => REGRESSIVE_TYPES.has(r.changeType));
797
- const pct = typeof diffPercentage === "number" ? diffPercentage : interpretation.diffPercentage;
798
- if (allNoise) {
799
- return {
800
- label: "noise-likely",
801
- headline,
802
- rationale: ["all regions classified as rendering-noise"],
803
- action: "ignore-or-rewrite"
804
- };
805
- }
806
- if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
807
- return {
808
- label: "noise-likely",
809
- headline,
810
- rationale: [
811
- `delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
812
- "sub-perceptual change - review optional"
813
- ],
814
- action: "ignore-or-rewrite"
815
- };
816
- }
817
- if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
818
- const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
819
- return {
820
- label: "regression-likely",
821
- headline,
822
- rationale: [
823
- `severity ${severity} with structural changes (${types})`,
824
- "likely affects content or layout, not just styling"
825
- ],
826
- action: "investigate"
827
- };
828
- }
829
- if (allColor && meanConfidence(regions) > 0.7) {
830
- return {
831
- label: "intentional-likely",
832
- headline,
833
- rationale: [
834
- `${regions.length} color-change region${regions.length === 1 ? "" : "s"} with mean confidence > 0.7`,
835
- "edge structure preserved - looks like a theming / palette change"
836
- ],
837
- action: "rewrite-if-intended"
838
- };
839
- }
840
- if (allMoved && !allColor) {
841
- return {
842
- label: "intentional-likely",
843
- headline,
844
- rationale: [
845
- "all regions are shift/color-change - content moved or restyled, structure preserved"
846
- ],
847
- action: "rewrite-if-intended"
848
- };
849
- }
850
- return {
851
- label: "ambiguous",
852
- headline,
853
- rationale: [
854
- `mix of change types (${formatBreakdown(counts)}) at "${severity ?? "?"}" severity`,
855
- `${pctText(pct)} of image differs`
856
- ],
857
- action: "investigate"
858
- };
859
- }
860
- var DEFAULT_TOP_N = 5;
861
- var DEFAULT_PADDING = 16;
862
- var DEFAULT_LOCATOR_MAX_WIDTH = 400;
863
- var DEFAULT_GUTTER = 2;
864
- var DEFAULT_ROW_GUTTER = 8;
865
- var BG_WHITE = { r: 255, g: 255, b: 255 };
866
- function padAndClamp(bbox, padding, imgWidth, imgHeight) {
867
- const left = Math.max(0, Math.floor(bbox.x - padding));
868
- const top = Math.max(0, Math.floor(bbox.y - padding));
869
- const right = Math.min(imgWidth, Math.ceil(bbox.x + bbox.width + padding));
870
- const bottom = Math.min(imgHeight, Math.ceil(bbox.y + bbox.height + padding));
871
- return {
872
- x: left,
873
- y: top,
874
- width: Math.max(1, right - left),
875
- height: Math.max(1, bottom - top)
876
- };
877
- }
878
- async function prepareTiles(opts) {
879
- const topN = opts.topN ?? DEFAULT_TOP_N;
880
- const padding = opts.padding ?? DEFAULT_PADDING;
881
- const locatorMaxWidth = opts.locatorMaxWidth ?? DEFAULT_LOCATOR_MAX_WIDTH;
882
- const gutter = opts.gutter ?? DEFAULT_GUTTER;
883
- const rowGutter = opts.rowGutter ?? DEFAULT_ROW_GUTTER;
884
- const diffMeta = await sharp__default.default(opts.diffPath).metadata();
885
- const imgWidth = diffMeta.width ?? 0;
886
- const imgHeight = diffMeta.height ?? 0;
887
- if (!imgWidth || !imgHeight) {
888
- throw new Error(`unable to read diff image dimensions: ${opts.diffPath}`);
889
- }
890
- const ranked = [...opts.regions].sort((a, b) => b.pixelCount - a.pixelCount).slice(0, topN);
891
- const regionData = await Promise.all(
892
- ranked.map(async (region) => {
893
- const padded = padAndClamp(region.bbox, padding, imgWidth, imgHeight);
894
- const extract = {
895
- left: padded.x,
896
- top: padded.y,
897
- width: padded.width,
898
- height: padded.height
899
- };
900
- const [base, actual] = await Promise.all([
901
- sharp__default.default(opts.baselinePath).extract(extract).toBuffer(),
902
- sharp__default.default(opts.actualPath).extract(extract).toBuffer()
903
- ]);
904
- return { region, padded, base, actual };
905
- })
906
- );
907
- let tilesName;
908
- if (regionData.length > 0) {
909
- const canvasWidth = Math.max(
910
- ...regionData.map((r) => r.padded.width * 2 + gutter)
911
- );
912
- const totalHeight = regionData.reduce(
913
- (sum, r, i) => sum + r.padded.height + (i > 0 ? rowGutter : 0),
914
- 0
915
- );
916
- const composites = [];
917
- let y = 0;
918
- for (let i = 0; i < regionData.length; i++) {
919
- const r = regionData[i];
920
- const w = r.padded.width;
921
- composites.push(
922
- { input: r.base, left: 0, top: y },
923
- { input: r.actual, left: w + gutter, top: y }
924
- );
925
- y += r.padded.height;
926
- if (i < regionData.length - 1) y += rowGutter;
927
- }
928
- tilesName = "regions.png";
929
- await sharp__default.default({
930
- create: {
931
- width: canvasWidth,
932
- height: totalHeight,
933
- channels: 3,
934
- background: BG_WHITE
935
- }
936
- }).composite(composites).png().toFile(path2__default.default.join(opts.outputDir, tilesName));
937
- }
938
- const scale = locatorMaxWidth / Math.max(imgWidth, imgHeight);
939
- const locW = Math.max(1, Math.round(imgWidth * scale));
940
- const locH = Math.max(1, Math.round(imgHeight * scale));
941
- const rects = opts.regions.map((r) => {
942
- const x = Math.round(r.bbox.x * scale);
943
- const y = Math.round(r.bbox.y * scale);
944
- const w = Math.max(1, Math.round(r.bbox.width * scale));
945
- const h = Math.max(1, Math.round(r.bbox.height * scale));
946
- return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="red" stroke-width="2" />`;
947
- }).join("");
948
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${locW}" height="${locH}">${rects}</svg>`;
949
- const locatorName = "locator.png";
950
- await sharp__default.default(opts.diffPath).resize(locW, locH, { fit: "fill" }).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toFile(path2__default.default.join(opts.outputDir, locatorName));
951
- return {
952
- locatorPath: locatorName,
953
- tilesPath: tilesName,
954
- regions: regionData.map((r) => ({
955
- bbox: r.region.bbox,
956
- pixelCount: r.region.pixelCount
957
- }))
958
- };
959
- }
960
-
961
- // src/judge/host-harness.ts
962
- async function tryPrepareTiles(input, entryDir) {
963
- if (!input.regions || input.regions.length === 0 || !input.diffPath) {
964
- return null;
965
- }
966
- try {
967
- return await prepareTiles({
968
- regions: input.regions,
969
- baselinePath: input.baselinePath,
970
- actualPath: input.actualPath,
971
- diffPath: input.diffPath,
972
- outputDir: entryDir
973
- });
974
- } catch (err) {
975
- const message = err instanceof Error ? err.message : String(err);
976
- console.warn(
977
- `[blazediff] tile generation failed for ${input.entry.id}: ${message}`
978
- );
979
- return null;
838
+ // src/judge/host-harness.ts
839
+ async function tryPrepareTiles(input, entryDir) {
840
+ if (!input.regions || input.regions.length === 0 || !input.diffPath) {
841
+ return null;
842
+ }
843
+ try {
844
+ return await prepareTiles({
845
+ regions: input.regions,
846
+ baselinePath: input.baselinePath,
847
+ actualPath: input.actualPath,
848
+ diffPath: input.diffPath,
849
+ outputDir: entryDir
850
+ });
851
+ } catch (err) {
852
+ const message = err instanceof Error ? err.message : String(err);
853
+ console.warn(
854
+ `[blazediff] tile generation failed for ${input.entry.id}: ${message}`
855
+ );
856
+ return null;
980
857
  }
981
858
  }
982
859
  var hostHarnessJudge = {
@@ -997,22 +874,299 @@ var noneJudge = {
997
874
  return { kind: "judged", verdict: input.heuristicVerdict };
998
875
  }
999
876
  };
1000
- var PREVIEW_WIDTH = 320;
1001
- function escapeCell(s) {
1002
- return s.replace(/\n/g, " ");
1003
- }
1004
- function toBlazediffRel(cwd, abs) {
1005
- if (!abs) return void 0;
1006
- const root = paths(cwd).root;
1007
- const rel = path2__default.default.isAbsolute(abs) ? path2__default.default.relative(root, abs) : path2__default.default.relative(paths(cwd).root, path2__default.default.join(cwd, abs));
1008
- return rel.split(path2__default.default.sep).join("/");
877
+ var ROOT_NS_SENTINEL = "_root";
878
+ function nsDir(ns) {
879
+ return ns === "" ? ROOT_NS_SENTINEL : ns;
1009
880
  }
1010
- function img(src, alt) {
1011
- return `<img src="${src}" width="${PREVIEW_WIDTH}" alt="${alt}">`;
881
+ function encode(buf) {
882
+ return Buffer.from(buf).toString("base64");
1012
883
  }
1013
- function baselineCell(r, cwd) {
1014
- const rel = toBlazediffRel(cwd, r.baselinePath) ?? `baselines/${r.id}.png`;
1015
- return img(rel, `${r.id} baseline`);
884
+ function decode(s) {
885
+ return Buffer.from(s, "base64");
886
+ }
887
+ async function readJson(file) {
888
+ try {
889
+ const raw = await promises.readFile(file, "utf8");
890
+ return JSON.parse(raw);
891
+ } catch (err) {
892
+ if (err.code === "ENOENT") return void 0;
893
+ throw err;
894
+ }
895
+ }
896
+ async function writeJsonAtomic(file, value) {
897
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
898
+ await promises.writeFile(tmp, JSON.stringify(value), "utf8");
899
+ await promises.rename(tmp, file);
900
+ }
901
+ var FsCheckpointSaver = class extends langgraphCheckpoint.BaseCheckpointSaver {
902
+ constructor(root, serde) {
903
+ super(serde);
904
+ this.locks = /* @__PURE__ */ new Map();
905
+ this.root = root;
906
+ }
907
+ threadDir(thread) {
908
+ return path2__default.default.join(this.root, thread);
909
+ }
910
+ nsPath(thread, ns) {
911
+ return path2__default.default.join(this.threadDir(thread), nsDir(ns));
912
+ }
913
+ ckptFile(thread, ns, id) {
914
+ return path2__default.default.join(this.nsPath(thread, ns), `${id}.ckpt.json`);
915
+ }
916
+ writesFile(thread, ns, id) {
917
+ return path2__default.default.join(this.nsPath(thread, ns), `${id}.writes.json`);
918
+ }
919
+ async withLock(key, fn) {
920
+ const prev = this.locks.get(key) ?? Promise.resolve();
921
+ const next = prev.then(fn, fn);
922
+ this.locks.set(key, next);
923
+ try {
924
+ return await next;
925
+ } finally {
926
+ if (this.locks.get(key) === next) this.locks.delete(key);
927
+ }
928
+ }
929
+ async listCheckpointIds(thread, ns) {
930
+ try {
931
+ const names = await promises.readdir(this.nsPath(thread, ns));
932
+ const suffix = ".ckpt.json";
933
+ return names.filter((n) => n.endsWith(suffix)).map((n) => n.slice(0, -suffix.length));
934
+ } catch (err) {
935
+ if (err.code === "ENOENT") return [];
936
+ throw err;
937
+ }
938
+ }
939
+ async listNamespaces(thread) {
940
+ try {
941
+ return await promises.readdir(this.threadDir(thread));
942
+ } catch (err) {
943
+ if (err.code === "ENOENT") return [];
944
+ throw err;
945
+ }
946
+ }
947
+ async listThreads() {
948
+ try {
949
+ return await promises.readdir(this.root);
950
+ } catch (err) {
951
+ if (err.code === "ENOENT") return [];
952
+ throw err;
953
+ }
954
+ }
955
+ decodeNs(nsDirName) {
956
+ return nsDirName === ROOT_NS_SENTINEL ? "" : nsDirName;
957
+ }
958
+ async loadPendingWrites(thread, ns, ckptId) {
959
+ const data = await readJson(
960
+ this.writesFile(thread, ns, ckptId)
961
+ );
962
+ if (!data) return [];
963
+ const out = [];
964
+ for (const [taskId, channel, serialized] of Object.values(data)) {
965
+ const value = await this.serde.loadsTyped("json", decode(serialized));
966
+ out.push([taskId, channel, value]);
967
+ }
968
+ return out;
969
+ }
970
+ async migratePendingSends(checkpoint, thread, ns, parentCheckpointId) {
971
+ const data = await readJson(
972
+ this.writesFile(thread, ns, parentCheckpointId)
973
+ );
974
+ const pendingSends = data ? await Promise.all(
975
+ Object.values(data).filter(([, channel]) => channel === langgraphCheckpoint.TASKS).map(
976
+ ([, , serialized]) => this.serde.loadsTyped("json", decode(serialized))
977
+ )
978
+ ) : [];
979
+ const m = checkpoint;
980
+ m.channel_values ?? (m.channel_values = {});
981
+ m.channel_values[langgraphCheckpoint.TASKS] = pendingSends;
982
+ m.channel_versions ?? (m.channel_versions = {});
983
+ const versions = Object.values(m.channel_versions);
984
+ m.channel_versions[langgraphCheckpoint.TASKS] = versions.length > 0 ? langgraphCheckpoint.maxChannelVersion(...versions) : this.getNextVersion(void 0);
985
+ }
986
+ async readTuple(thread, ns, ckptId, config) {
987
+ const data = await readJson(this.ckptFile(thread, ns, ckptId));
988
+ if (!data) return void 0;
989
+ const checkpoint = await this.serde.loadsTyped(
990
+ "json",
991
+ decode(data.checkpoint)
992
+ );
993
+ const metadata = await this.serde.loadsTyped(
994
+ "json",
995
+ decode(data.metadata)
996
+ );
997
+ if (checkpoint.v < 4 && data.parentCheckpointId !== void 0) {
998
+ await this.migratePendingSends(
999
+ checkpoint,
1000
+ thread,
1001
+ ns,
1002
+ data.parentCheckpointId
1003
+ );
1004
+ }
1005
+ const pendingWrites = await this.loadPendingWrites(thread, ns, ckptId);
1006
+ const tuple = {
1007
+ config,
1008
+ checkpoint,
1009
+ metadata,
1010
+ pendingWrites
1011
+ };
1012
+ if (data.parentCheckpointId !== void 0) {
1013
+ tuple.parentConfig = {
1014
+ configurable: {
1015
+ thread_id: thread,
1016
+ checkpoint_ns: ns,
1017
+ checkpoint_id: data.parentCheckpointId
1018
+ }
1019
+ };
1020
+ }
1021
+ return tuple;
1022
+ }
1023
+ async getTuple(config) {
1024
+ const thread = config.configurable?.thread_id;
1025
+ if (!thread) return void 0;
1026
+ const ns = config.configurable?.checkpoint_ns ?? "";
1027
+ const explicitId = langgraphCheckpoint.getCheckpointId(config);
1028
+ if (explicitId) {
1029
+ return this.readTuple(thread, ns, explicitId, config);
1030
+ }
1031
+ const ids = (await this.listCheckpointIds(thread, ns)).sort(
1032
+ (a, b) => b.localeCompare(a)
1033
+ );
1034
+ if (ids.length === 0) return void 0;
1035
+ const ckptId = ids[0];
1036
+ return this.readTuple(thread, ns, ckptId, {
1037
+ configurable: {
1038
+ thread_id: thread,
1039
+ checkpoint_ns: ns,
1040
+ checkpoint_id: ckptId
1041
+ }
1042
+ });
1043
+ }
1044
+ async *list(config, options) {
1045
+ const filter = options?.filter;
1046
+ const before = options?.before;
1047
+ let limit = options?.limit;
1048
+ const configThread = config.configurable?.thread_id;
1049
+ const configNs = config.configurable?.checkpoint_ns;
1050
+ const configCkptId = config.configurable?.checkpoint_id;
1051
+ const threads = configThread ? [configThread] : await this.listThreads();
1052
+ for (const thread of threads) {
1053
+ const nsNames = await this.listNamespaces(thread);
1054
+ for (const nsName of nsNames) {
1055
+ const ns = this.decodeNs(nsName);
1056
+ if (configNs !== void 0 && ns !== configNs) continue;
1057
+ const ids = (await this.listCheckpointIds(thread, ns)).sort(
1058
+ (a, b) => b.localeCompare(a)
1059
+ );
1060
+ for (const ckptId of ids) {
1061
+ if (configCkptId && ckptId !== configCkptId) continue;
1062
+ if (before?.configurable?.checkpoint_id && ckptId >= before.configurable.checkpoint_id)
1063
+ continue;
1064
+ const tuple = await this.readTuple(thread, ns, ckptId, {
1065
+ configurable: {
1066
+ thread_id: thread,
1067
+ checkpoint_ns: ns,
1068
+ checkpoint_id: ckptId
1069
+ }
1070
+ });
1071
+ if (!tuple) continue;
1072
+ if (filter) {
1073
+ const md = tuple.metadata;
1074
+ const matches = Object.entries(filter).every(
1075
+ ([k, v]) => md?.[k] === v
1076
+ );
1077
+ if (!matches) continue;
1078
+ }
1079
+ if (limit !== void 0) {
1080
+ if (limit <= 0) return;
1081
+ limit -= 1;
1082
+ }
1083
+ yield tuple;
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ async put(config, checkpoint, metadata) {
1089
+ const thread = config.configurable?.thread_id;
1090
+ if (!thread) {
1091
+ throw new Error(
1092
+ 'FsCheckpointSaver: missing "thread_id" in configurable. Pass `{ configurable: { thread_id } }` when streaming.'
1093
+ );
1094
+ }
1095
+ const ns = config.configurable?.checkpoint_ns ?? "";
1096
+ const parentCheckpointId = config.configurable?.checkpoint_id;
1097
+ const prepared = langgraphCheckpoint.copyCheckpoint(checkpoint);
1098
+ const [[, serializedCheckpoint], [, serializedMetadata]] = await Promise.all([
1099
+ this.serde.dumpsTyped(prepared),
1100
+ this.serde.dumpsTyped(metadata)
1101
+ ]);
1102
+ await promises.mkdir(this.nsPath(thread, ns), { recursive: true });
1103
+ const file = this.ckptFile(thread, ns, checkpoint.id);
1104
+ const body = {
1105
+ checkpoint: encode(serializedCheckpoint),
1106
+ metadata: encode(serializedMetadata),
1107
+ parentCheckpointId
1108
+ };
1109
+ await writeJsonAtomic(file, body);
1110
+ return {
1111
+ configurable: {
1112
+ thread_id: thread,
1113
+ checkpoint_ns: ns,
1114
+ checkpoint_id: checkpoint.id
1115
+ }
1116
+ };
1117
+ }
1118
+ async putWrites(config, writes, taskId) {
1119
+ const thread = config.configurable?.thread_id;
1120
+ if (!thread) {
1121
+ throw new Error(
1122
+ 'FsCheckpointSaver: missing "thread_id" in configurable for putWrites.'
1123
+ );
1124
+ }
1125
+ const ns = config.configurable?.checkpoint_ns ?? "";
1126
+ const ckptId = config.configurable?.checkpoint_id;
1127
+ if (!ckptId) {
1128
+ throw new Error(
1129
+ 'FsCheckpointSaver: missing "checkpoint_id" in configurable for putWrites.'
1130
+ );
1131
+ }
1132
+ const key = `${thread}|${ns}|${ckptId}`;
1133
+ await this.withLock(key, async () => {
1134
+ const existing = await readJson(this.writesFile(thread, ns, ckptId)) ?? {};
1135
+ let mutated = false;
1136
+ for (let idx = 0; idx < writes.length; idx++) {
1137
+ const [channel, value] = writes[idx];
1138
+ const writeIdx = langgraphCheckpoint.WRITES_IDX_MAP[channel] ?? idx;
1139
+ const innerKey = `${taskId},${writeIdx}`;
1140
+ if (writeIdx >= 0 && innerKey in existing) continue;
1141
+ const [, serialized] = await this.serde.dumpsTyped(value);
1142
+ existing[innerKey] = [taskId, channel, encode(serialized)];
1143
+ mutated = true;
1144
+ }
1145
+ if (!mutated) return;
1146
+ await promises.mkdir(this.nsPath(thread, ns), { recursive: true });
1147
+ await writeJsonAtomic(this.writesFile(thread, ns, ckptId), existing);
1148
+ });
1149
+ }
1150
+ async deleteThread(threadId) {
1151
+ await promises.rm(this.threadDir(threadId), { recursive: true, force: true });
1152
+ }
1153
+ };
1154
+ var PREVIEW_WIDTH = 320;
1155
+ function escapeCell(s) {
1156
+ return s.replace(/\n/g, " ");
1157
+ }
1158
+ function toBlazediffRel(cwd, abs) {
1159
+ if (!abs) return void 0;
1160
+ const root = paths(cwd).root;
1161
+ const rel = path2__default.default.isAbsolute(abs) ? path2__default.default.relative(root, abs) : path2__default.default.relative(paths(cwd).root, path2__default.default.join(cwd, abs));
1162
+ return rel.split(path2__default.default.sep).join("/");
1163
+ }
1164
+ function img(src, alt) {
1165
+ return `<img src="${src}" width="${PREVIEW_WIDTH}" alt="${alt}">`;
1166
+ }
1167
+ function baselineCell(r, cwd) {
1168
+ const rel = toBlazediffRel(cwd, r.baselinePath) ?? `baselines/${r.id}.png`;
1169
+ return img(rel, `${r.id} baseline`);
1016
1170
  }
1017
1171
  function actualCell(r, cwd) {
1018
1172
  const actual = toBlazediffRel(cwd, r.actualPath);
@@ -1109,6 +1263,14 @@ function parseVerdict(raw) {
1109
1263
  confidence: typeof r.confidence === "number" ? r.confidence : void 0
1110
1264
  };
1111
1265
  }
1266
+ async function fileExists(p) {
1267
+ try {
1268
+ await promises.access(p);
1269
+ return true;
1270
+ } catch {
1271
+ return false;
1272
+ }
1273
+ }
1112
1274
  async function readJsonOrNull(file) {
1113
1275
  try {
1114
1276
  return JSON.parse(await promises.readFile(file, "utf8"));
@@ -1139,7 +1301,7 @@ async function readJudgmentDirs(root) {
1139
1301
  const verdictFile = path2__default.default.join(dir, "verdict.json");
1140
1302
  let verdict = null;
1141
1303
  let verdictInvalid = false;
1142
- if (fs.existsSync(verdictFile)) {
1304
+ if (await fileExists(verdictFile)) {
1143
1305
  const raw = await readJsonOrNull(verdictFile);
1144
1306
  verdict = raw ? parseVerdict(raw) : null;
1145
1307
  if (raw && !verdict) verdictInvalid = true;
@@ -1152,7 +1314,7 @@ function toAbs(cwd, rel) {
1152
1314
  if (!rel) return void 0;
1153
1315
  return path2__default.default.isAbsolute(rel) ? rel : path2__default.default.join(cwd, rel);
1154
1316
  }
1155
- function buildResult(cwd, dir, entry) {
1317
+ function fromDiskResult(cwd, dir, entry) {
1156
1318
  const req = dir.request;
1157
1319
  const finalVerdict = dir.verdict?.verdict ?? req?.heuristicVerdict;
1158
1320
  const status = req ? dir.verdict ? "fail" : req.status : "fail";
@@ -1171,7 +1333,7 @@ function buildResult(cwd, dir, entry) {
1171
1333
  message
1172
1334
  };
1173
1335
  }
1174
- function passResult(entry, cwd) {
1336
+ async function passResultFromDisk(entry, cwd) {
1175
1337
  const baselineAbs = path2__default.default.join(paths(cwd).baselines, `${entry.id}.png`);
1176
1338
  const actualAbs = path2__default.default.join(paths(cwd).actual, `${entry.id}.png`);
1177
1339
  return {
@@ -1179,37 +1341,27 @@ function passResult(entry, cwd) {
1179
1341
  url: entry.url,
1180
1342
  status: "pass",
1181
1343
  baselinePath: baselineAbs,
1182
- actualPath: fs.existsSync(actualAbs) ? actualAbs : void 0
1344
+ actualPath: await fileExists(actualAbs) ? actualAbs : void 0
1183
1345
  };
1184
1346
  }
1185
- async function applyJudgments(cwd = process.cwd()) {
1186
- const p = paths(cwd);
1347
+ async function reconstructFromDisk(cwd, dirs) {
1187
1348
  const manifest = await loadManifest(cwd);
1188
1349
  if (!manifest) {
1189
1350
  throw new Error(
1190
- `no manifest at ${p.manifest}. Run \`blazediff-agent init\` first.`
1351
+ `no manifest at ${paths(cwd).manifest}. Run \`blazediff-agent init\` first.`
1191
1352
  );
1192
1353
  }
1193
- const dirs = await readJudgmentDirs(p.judgments);
1194
1354
  const dirById = new Map(dirs.map((d) => [d.id, d]));
1195
- const applied = [];
1196
- const missing = [];
1197
- const invalid = [];
1198
- for (const d of dirs) {
1199
- if (d.verdictInvalid) invalid.push(path2__default.default.join(p.judgments, d.id));
1200
- else if (d.verdict) applied.push(d.id);
1201
- else missing.push(d.id);
1202
- }
1203
- const nonPassResults = [];
1204
- for (const d of dirs) {
1205
- const entry = manifest.entries.find((e) => e.id === d.id);
1206
- nonPassResults.push(buildResult(cwd, d, entry));
1207
- }
1208
- const passResults = [];
1209
- for (const entry of manifest.entries) {
1210
- if (dirById.has(entry.id)) continue;
1211
- passResults.push(passResult(entry, cwd));
1212
- }
1355
+ const nonPassResults = dirs.map(
1356
+ (d) => fromDiskResult(
1357
+ cwd,
1358
+ d,
1359
+ manifest.entries.find((e) => e.id === d.id)
1360
+ )
1361
+ );
1362
+ const passResults = await Promise.all(
1363
+ manifest.entries.filter((entry) => !dirById.has(entry.id)).map((entry) => passResultFromDisk(entry, cwd))
1364
+ );
1213
1365
  const results = [...passResults, ...nonPassResults];
1214
1366
  const passed = results.filter((r) => r.status === "pass").length;
1215
1367
  const pendingJudgments = results.filter(
@@ -1224,6 +1376,57 @@ async function applyJudgments(cwd = process.cwd()) {
1224
1376
  results
1225
1377
  };
1226
1378
  await writeSummaryMarkdown(report, cwd);
1379
+ return report;
1380
+ }
1381
+ async function hasCheckpoint(cwd, threadId) {
1382
+ const dir = path2__default.default.join(paths(cwd).checkpoints, threadId);
1383
+ try {
1384
+ const names = await promises.readdir(dir);
1385
+ return names.length > 0;
1386
+ } catch {
1387
+ return false;
1388
+ }
1389
+ }
1390
+ async function applyJudgments(opts = process.cwd()) {
1391
+ const options = typeof opts === "string" ? { cwd: opts } : opts;
1392
+ const cwd = options.cwd ?? process.cwd();
1393
+ const manifest = await loadManifest(cwd);
1394
+ if (!manifest) {
1395
+ throw new Error(
1396
+ `no manifest at ${paths(cwd).manifest}. Run \`blazediff-agent init\` first.`
1397
+ );
1398
+ }
1399
+ const dirs = await readJudgmentDirs(paths(cwd).judgments);
1400
+ const applied = [];
1401
+ const missing = [];
1402
+ const invalid = [];
1403
+ const verdicts = {};
1404
+ for (const d of dirs) {
1405
+ if (d.verdictInvalid) {
1406
+ invalid.push(path2__default.default.join(paths(cwd).judgments, d.id));
1407
+ continue;
1408
+ }
1409
+ if (d.verdict) {
1410
+ verdicts[d.id] = d.verdict.verdict;
1411
+ applied.push(d.id);
1412
+ continue;
1413
+ }
1414
+ missing.push(d.id);
1415
+ }
1416
+ const threadId = threadIdFor(cwd);
1417
+ const checkpointExists = await hasCheckpoint(cwd, threadId);
1418
+ if (!checkpointExists) {
1419
+ const report2 = await reconstructFromDisk(cwd, dirs);
1420
+ return { report: report2, applied, missing, invalid };
1421
+ }
1422
+ const report = await resumeGraph({
1423
+ cwd,
1424
+ verdicts,
1425
+ threadId,
1426
+ onEvent: options.onEvent,
1427
+ junitPath: options.junitPath
1428
+ });
1429
+ await new FsCheckpointSaver(paths(cwd).checkpoints).deleteThread(threadId).catch(() => void 0);
1227
1430
  return { report, applied, missing, invalid };
1228
1431
  }
1229
1432
  var HOST_INSTRUCTIONS = [
@@ -1245,6 +1448,14 @@ function signatureOf(r) {
1245
1448
  const severity = r.severity ?? "?";
1246
1449
  return `${r.status}|diff:${pct}|regions:${regions}|severity:${severity}`;
1247
1450
  }
1451
+ async function fileExists2(p) {
1452
+ try {
1453
+ await promises.access(p);
1454
+ return true;
1455
+ } catch {
1456
+ return false;
1457
+ }
1458
+ }
1248
1459
  async function readJsonOrNull2(file) {
1249
1460
  try {
1250
1461
  return JSON.parse(await promises.readFile(file, "utf8"));
@@ -1298,9 +1509,13 @@ function autoVerdict(result) {
1298
1509
  async function discoverTiles(dir) {
1299
1510
  const locatorAbs = path2__default.default.join(dir, "locator.png");
1300
1511
  const tilesAbs = path2__default.default.join(dir, "regions.png");
1512
+ const [locator, tiles] = await Promise.all([
1513
+ fileExists2(locatorAbs),
1514
+ fileExists2(tilesAbs)
1515
+ ]);
1301
1516
  return {
1302
- locatorPath: fs.existsSync(locatorAbs) ? "locator.png" : void 0,
1303
- tilesPath: fs.existsSync(tilesAbs) ? "regions.png" : void 0
1517
+ locatorPath: locator ? "locator.png" : void 0,
1518
+ tilesPath: tiles ? "regions.png" : void 0
1304
1519
  };
1305
1520
  }
1306
1521
  async function writeJudgments(opts) {
@@ -1309,53 +1524,61 @@ async function writeJudgments(opts) {
1309
1524
  await promises.mkdir(root, { recursive: true });
1310
1525
  const knownIds = /* @__PURE__ */ new Set();
1311
1526
  for (const r of opts.report.results) knownIds.add(r.id);
1312
- for (const result of opts.report.results) {
1313
- const dir = path2__default.default.join(root, result.id);
1314
- if (result.status === "pass") {
1315
- if (fs.existsSync(dir)) await promises.rm(dir, { recursive: true, force: true });
1316
- continue;
1317
- }
1318
- const entry = entryById(opts.manifest, result.id);
1319
- if (!entry) continue;
1320
- await promises.mkdir(dir, { recursive: true });
1321
- const tiles = await discoverTiles(dir);
1322
- const request = buildRequest(result, entry, cwd, tiles);
1323
- const requestFile = path2__default.default.join(dir, "request.json");
1324
- const prior = await readJsonOrNull2(requestFile);
1325
- const verdictFile = path2__default.default.join(dir, "verdict.json");
1326
- const priorVerdict = fs.existsSync(verdictFile) ? await readJsonOrNull2(verdictFile) : null;
1327
- const signatureMatches = prior !== null && prior.signature === request.signature;
1328
- await promises.writeFile(
1329
- requestFile,
1330
- `${JSON.stringify(request, null, 2)}
1331
- `,
1332
- "utf8"
1333
- );
1334
- if (priorVerdict && signatureMatches) {
1335
- continue;
1336
- }
1337
- const auto = autoVerdict(result);
1338
- if (auto) {
1527
+ await Promise.all(
1528
+ opts.report.results.map(async (result) => {
1529
+ const dir = path2__default.default.join(root, result.id);
1530
+ if (result.status === "pass") {
1531
+ if (await fileExists2(dir))
1532
+ await promises.rm(dir, { recursive: true, force: true });
1533
+ return;
1534
+ }
1535
+ const entry = entryById(opts.manifest, result.id);
1536
+ if (!entry) return;
1537
+ await promises.mkdir(dir, { recursive: true });
1538
+ const tiles = await discoverTiles(dir);
1539
+ const request = buildRequest(result, entry, cwd, tiles);
1540
+ const requestFile = path2__default.default.join(dir, "request.json");
1541
+ const verdictFile = path2__default.default.join(dir, "verdict.json");
1542
+ const [prior, priorVerdict] = await Promise.all([
1543
+ readJsonOrNull2(requestFile),
1544
+ fileExists2(verdictFile).then(
1545
+ (exists) => exists ? readJsonOrNull2(verdictFile) : null
1546
+ )
1547
+ ]);
1548
+ const signatureMatches = prior !== null && prior.signature === request.signature;
1339
1549
  await promises.writeFile(
1340
- verdictFile,
1341
- `${JSON.stringify(auto, null, 2)}
1550
+ requestFile,
1551
+ `${JSON.stringify(request, null, 2)}
1342
1552
  `,
1343
1553
  "utf8"
1344
1554
  );
1345
- } else if (priorVerdict && !signatureMatches) {
1346
- await promises.rm(verdictFile, { force: true });
1347
- }
1348
- }
1555
+ if (priorVerdict && signatureMatches) {
1556
+ return;
1557
+ }
1558
+ const auto = autoVerdict(result);
1559
+ if (auto) {
1560
+ await promises.writeFile(
1561
+ verdictFile,
1562
+ `${JSON.stringify(auto, null, 2)}
1563
+ `,
1564
+ "utf8"
1565
+ );
1566
+ } else if (priorVerdict && !signatureMatches) {
1567
+ await promises.rm(verdictFile, { force: true });
1568
+ }
1569
+ })
1570
+ );
1349
1571
  let entries;
1350
1572
  try {
1351
1573
  entries = await promises.readdir(root);
1352
1574
  } catch {
1353
1575
  return;
1354
1576
  }
1355
- for (const name of entries) {
1356
- if (knownIds.has(name)) continue;
1357
- await promises.rm(path2__default.default.join(root, name), { recursive: true, force: true });
1358
- }
1577
+ await Promise.all(
1578
+ entries.filter((name) => !knownIds.has(name)).map(
1579
+ (name) => promises.rm(path2__default.default.join(root, name), { recursive: true, force: true })
1580
+ )
1581
+ );
1359
1582
  }
1360
1583
 
1361
1584
  // src/judge/index.ts
@@ -1393,7 +1616,7 @@ ${cases.join("\n")}
1393
1616
  return destPath;
1394
1617
  }
1395
1618
 
1396
- // src/check.ts
1619
+ // src/graph/nodes/results.ts
1397
1620
  function narrowRegion(r) {
1398
1621
  return {
1399
1622
  bbox: r.bbox,
@@ -1403,29 +1626,6 @@ function narrowRegion(r) {
1403
1626
  confidence: r.confidence
1404
1627
  };
1405
1628
  }
1406
- async function pool(items, limit, fn) {
1407
- const results = new Array(items.length);
1408
- let next = 0;
1409
- const workerCount = Math.max(1, Math.min(limit, items.length));
1410
- const workers = Array.from({ length: workerCount }, async () => {
1411
- while (true) {
1412
- const i = next++;
1413
- if (i >= items.length) return;
1414
- results[i] = await fn(items[i], i);
1415
- }
1416
- });
1417
- await Promise.all(workers);
1418
- return results;
1419
- }
1420
- function passResult2(entry, baselinePath, actualPath) {
1421
- return {
1422
- id: entry.id,
1423
- url: entry.url,
1424
- status: "pass",
1425
- baselinePath,
1426
- actualPath
1427
- };
1428
- }
1429
1629
  function skipResult(entry, message) {
1430
1630
  return { id: entry.id, url: entry.url, status: "pass", message };
1431
1631
  }
@@ -1437,6 +1637,15 @@ function staleResult(entry) {
1437
1637
  message: "captureHash mismatch: entry was edited without re-capturing"
1438
1638
  };
1439
1639
  }
1640
+ function passResult(entry, baselinePath, actualPath) {
1641
+ return {
1642
+ id: entry.id,
1643
+ url: entry.url,
1644
+ status: "pass",
1645
+ baselinePath,
1646
+ actualPath
1647
+ };
1648
+ }
1440
1649
  function missingBaselineResult(entry, baselinePath) {
1441
1650
  return {
1442
1651
  id: entry.id,
@@ -1461,1594 +1670,1755 @@ function failResult(entry, outcome, actualPath, baselinePath, verdict) {
1461
1670
  message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
1462
1671
  };
1463
1672
  }
1464
- async function judgeAmbiguous(result, entry, judge, cwd) {
1465
- if (result.status !== "fail" || !result.verdict || result.verdict.label !== "ambiguous" || !result.baselinePath || !result.actualPath) {
1466
- return result;
1467
- }
1468
- const output = await judge.judge(
1469
- {
1470
- entry,
1471
- baselinePath: result.baselinePath,
1472
- actualPath: result.actualPath,
1473
- diffPath: result.diffPath,
1474
- regions: result.regions,
1475
- diffPercentage: result.diffPercentage,
1476
- severity: result.severity,
1477
- heuristicVerdict: result.verdict
1478
- },
1479
- cwd
1480
- );
1481
- if (output.kind === "judged") {
1482
- return { ...result, verdict: output.verdict };
1483
- }
1484
- return {
1485
- ...result,
1486
- status: "needs-judgment",
1487
- message: `awaiting judgment in ${output.requestPath}`
1673
+
1674
+ // src/graph/nodes/capture.ts
1675
+ function makeCaptureNode(semaphore) {
1676
+ return async function captureNode(state) {
1677
+ const entry = state.entry;
1678
+ const options = state.options;
1679
+ if (!entry || !options) {
1680
+ throw new Error("captureNode: entry or options missing");
1681
+ }
1682
+ if (entry.auth === "required") {
1683
+ return {
1684
+ captureOutput: {
1685
+ id: entry.id,
1686
+ skipResult: skipResult(
1687
+ entry,
1688
+ "skipped: auth required (deferred to v0.2)"
1689
+ )
1690
+ }
1691
+ };
1692
+ }
1693
+ if (isEntryStale(entry)) {
1694
+ return {
1695
+ captureOutput: { id: entry.id, skipResult: staleResult(entry) }
1696
+ };
1697
+ }
1698
+ const baselinePath = path2__default.default.join(options.baselinesDir, `${entry.id}.png`);
1699
+ const capture = await semaphore.run(
1700
+ () => captureScreenshot(
1701
+ options.baseUrl,
1702
+ {
1703
+ id: entry.id,
1704
+ url: entry.url,
1705
+ viewport: entry.viewport,
1706
+ mask: entry.mask,
1707
+ waitFor: entry.waitFor,
1708
+ fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
1709
+ mode: "actual"
1710
+ },
1711
+ options.cwd
1712
+ )
1713
+ );
1714
+ return {
1715
+ captureOutput: {
1716
+ id: entry.id,
1717
+ captureOutputPath: capture.outputPath,
1718
+ baselinePath
1719
+ }
1720
+ };
1488
1721
  };
1489
1722
  }
1490
- async function checkEntry(entry, opts, cwd, baselinesDir, judge) {
1491
- if (entry.auth === "required") {
1492
- return skipResult(entry, "skipped: auth required (deferred to v0.2)");
1493
- }
1494
- if (isEntryStale(entry)) {
1495
- return staleResult(entry);
1723
+ async function fileExists3(p) {
1724
+ try {
1725
+ await promises.access(p);
1726
+ return true;
1727
+ } catch {
1728
+ return false;
1496
1729
  }
1497
- const baselinePath = path2__default.default.join(baselinesDir, `${entry.id}.png`);
1498
- const capture = await captureScreenshot(
1499
- opts.baseUrl,
1500
- {
1501
- id: entry.id,
1502
- url: entry.url,
1503
- viewport: entry.viewport,
1504
- mask: entry.mask,
1505
- waitFor: entry.waitFor,
1506
- fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
1507
- mode: "actual"
1508
- },
1509
- cwd
1510
- );
1511
- const outcome = await diffEntry(
1512
- entry.id,
1513
- baselinePath,
1514
- capture.outputPath,
1515
- { threshold: opts.threshold, emitDiffPng: opts.emitDiffPng ?? true },
1516
- cwd
1517
- );
1518
- if (outcome.match) return passResult2(entry, baselinePath, capture.outputPath);
1519
- if (outcome.reason === "file-not-exists")
1520
- return missingBaselineResult(entry, baselinePath);
1521
- const verdict = deriveVerdict({
1522
- reason: outcome.reason,
1523
- interpretation: outcome.interpretation,
1524
- diffCount: outcome.diffCount,
1525
- diffPercentage: outcome.diffPercentage
1526
- });
1527
- const failed = failResult(
1528
- entry,
1529
- outcome,
1530
- capture.outputPath,
1531
- baselinePath,
1532
- verdict
1533
- );
1534
- return judgeAmbiguous(failed, entry, judge, cwd);
1535
1730
  }
1536
- async function runCheck(opts) {
1537
- const cwd = opts.cwd ?? process.cwd();
1538
- const manifest = await loadManifest(cwd);
1539
- if (!manifest) {
1540
- throw new Error(
1541
- `no manifest found at ${paths(cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
1542
- );
1731
+ async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.cwd()) {
1732
+ const [hasBaseline, hasActual] = await Promise.all([
1733
+ fileExists3(baselinePath),
1734
+ fileExists3(actualPath)
1735
+ ]);
1736
+ if (!hasBaseline || !hasActual) {
1737
+ return {
1738
+ id,
1739
+ baselinePath,
1740
+ actualPath,
1741
+ match: false,
1742
+ reason: "file-not-exists"
1743
+ };
1543
1744
  }
1544
- const baselinesDir = paths(cwd).baselines;
1545
- const concurrency = opts.concurrency ?? defaultConcurrency();
1546
- const judge = resolveJudge(opts.judge ?? "none");
1547
- let results;
1548
- try {
1549
- results = await pool(
1550
- manifest.entries,
1551
- concurrency,
1552
- (entry) => checkEntry(entry, opts, cwd, baselinesDir, judge)
1553
- );
1554
- } finally {
1555
- await closeBrowser();
1745
+ let diffPath;
1746
+ if (opts.emitDiffPng) {
1747
+ const actualDir = paths(cwd).actual;
1748
+ await promises.mkdir(actualDir, { recursive: true });
1749
+ diffPath = path2__default.default.join(actualDir, `${id}.diff.png`);
1556
1750
  }
1557
- const passed = results.filter((r) => r.status === "pass").length;
1558
- const pendingJudgments = results.filter(
1559
- (r) => r.status === "needs-judgment"
1560
- ).length;
1561
- const report = {
1562
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1563
- totalEntries: results.length,
1564
- passed,
1565
- failed: results.length - passed - pendingJudgments,
1566
- pendingJudgments,
1567
- results
1568
- };
1569
- await writeJudgments({ report, manifest, cwd });
1570
- await writeSummaryMarkdown(report, cwd);
1571
- await ensureGitignore(cwd);
1572
- if (opts.junitPath) {
1573
- const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
1574
- await writeJunit(report, target);
1751
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
1752
+ const antialiasing = opts.antialiasing ?? true;
1753
+ const result = await coreNative.compare(baselinePath, actualPath, diffPath, {
1754
+ threshold,
1755
+ antialiasing,
1756
+ interpret: true
1757
+ });
1758
+ if (result.match) return { id, baselinePath, actualPath, match: true };
1759
+ if (result.reason === "file-not-exists") {
1760
+ return {
1761
+ id,
1762
+ baselinePath,
1763
+ actualPath,
1764
+ match: false,
1765
+ reason: "file-not-exists"
1766
+ };
1767
+ }
1768
+ if (result.reason === "layout-diff") {
1769
+ return {
1770
+ id,
1771
+ baselinePath,
1772
+ actualPath,
1773
+ diffPath,
1774
+ match: false,
1775
+ reason: "layout-diff"
1776
+ };
1575
1777
  }
1576
- return report;
1577
- }
1578
-
1579
- // src/cli/commands/check.ts
1580
- function slimResult(r) {
1581
- return {
1582
- id: r.id,
1583
- url: r.url,
1584
- status: r.status,
1585
- verdict: r.verdict ? {
1586
- label: r.verdict.label,
1587
- headline: r.verdict.headline,
1588
- action: r.verdict.action
1589
- } : void 0
1590
- };
1591
- }
1592
- function slimReport(report, summaryPath) {
1593
1778
  return {
1594
- summaryPath,
1595
- createdAt: report.createdAt,
1596
- totalEntries: report.totalEntries,
1597
- passed: report.passed,
1598
- failed: report.failed,
1599
- pendingJudgments: report.pendingJudgments,
1600
- results: report.results.filter((r) => r.status !== "pass").map(slimResult)
1779
+ id,
1780
+ baselinePath,
1781
+ actualPath,
1782
+ diffPath,
1783
+ match: false,
1784
+ reason: "pixel-diff",
1785
+ diffCount: result.diffCount,
1786
+ diffPercentage: result.diffPercentage,
1787
+ interpretation: result.interpretation
1601
1788
  };
1602
1789
  }
1603
- function failureLines(results) {
1604
- return results.filter((r) => r.status !== "pass").flatMap((r) => {
1605
- const lines = [];
1606
- const prefix = r.status === "needs-judgment" ? "?" : "\u2717";
1607
- if (r.verdict) {
1608
- lines.push(
1609
- ` ${prefix} ${r.id} [${r.verdict.label}] ${r.verdict.headline}`
1610
- );
1611
- lines.push(` \u2192 ${r.verdict.action}`);
1612
- } else {
1613
- const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
1614
- lines.push(` ${prefix} ${r.id}: ${detail}`);
1790
+
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");
1615
1799
  }
1616
- if (r.status === "needs-judgment" && r.message) {
1617
- lines.push(` ${r.message}`);
1800
+ if (capture.skipResult) {
1801
+ return {
1802
+ diffOutput: { id: capture.id, skipResult: capture.skipResult }
1803
+ };
1618
1804
  }
1619
- if (r.diffPath) lines.push(` diff: ${r.diffPath}`);
1620
- return lines;
1621
- });
1622
- }
1623
- function parseJudge(input) {
1624
- if (input === "host" || input === "none") return input;
1625
- throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
1626
- }
1627
- function registerCheck(program, out) {
1628
- program.command("check").description("run the visual regression check (CI verb)").option("--base-url <url>", "override base URL").option(
1629
- "--threshold <n>",
1630
- "color threshold (0-1)",
1631
- String(DEFAULT_THRESHOLD)
1632
- ).option(
1633
- "--concurrency <n>",
1634
- "max entries checked in parallel (default: auto based on CPU cores, capped at 8)"
1635
- ).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
1636
- "--judge <backend>",
1637
- "judge backend for ambiguous diffs (host | none)",
1638
- "none"
1639
- ).option(
1640
- "--apply-judgments",
1641
- "regenerate summary.md from .blazediff/judgments/<id>/verdict.json files (no re-check)"
1642
- ).action(async (opts) => {
1643
- if (opts.applyJudgments) {
1644
- const { report: report2, applied, missing, invalid } = await applyJudgments();
1645
- const summaryPath2 = paths().summary;
1646
- const human2 = applied.length === 0 && missing.length === 0 && invalid.length === 0 ? `no judgments to apply
1647
- summary: ${summaryPath2}` : [
1648
- `applied ${applied.length} judgment(s)`,
1649
- missing.length ? ` ${missing.length} pending without judgment: ${missing.join(", ")}` : void 0,
1650
- invalid.length ? ` ${invalid.length} invalid judgment file(s): ${invalid.join(", ")}` : void 0,
1651
- ` ${report2.passed}/${report2.totalEntries} passed (${report2.failed} failed, ${report2.pendingJudgments} pending)`,
1652
- ` summary: ${summaryPath2}`
1653
- ].filter(Boolean).join("\n");
1654
- out.emit(
1655
- {
1656
- ok: true,
1657
- applied,
1658
- missing,
1659
- invalid,
1660
- ...slimReport(report2, summaryPath2)
1661
- },
1662
- human2
1663
- );
1664
- if (report2.failed > 0) process.exitCode = 1;
1665
- return;
1805
+ if (!capture.captureOutputPath || !capture.baselinePath) {
1806
+ throw new Error("diffNode: capture output paths missing");
1666
1807
  }
1667
- const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
1668
- const report = await runCheck({
1669
- baseUrl,
1670
- threshold: Number(opts.threshold),
1671
- concurrency: opts.concurrency ? Number(opts.concurrency) : void 0,
1672
- emitDiffPng: opts.diffPng,
1673
- junitPath: opts.junit,
1674
- judge: parseJudge(opts.judge)
1675
- });
1676
- const summaryPath = paths().summary;
1677
- const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
1678
- const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
1679
- summary: ${summaryPath}` : [
1680
- `${summary}:`,
1681
- ...failureLines(report.results),
1682
- ` summary: ${summaryPath}`,
1683
- report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
1684
- ].filter(Boolean).join("\n");
1685
- out.emit(slimReport(report, summaryPath), human);
1686
- if (report.failed > 0) process.exitCode = 1;
1687
- });
1688
- }
1689
-
1690
- // src/cli/commands/diff.ts
1691
- function registerDiff(program, out) {
1692
- program.command("diff <id>").description("diff a route's baseline against its actual capture").option(
1693
- "--threshold <n>",
1694
- "color threshold (0-1)",
1695
- String(DEFAULT_THRESHOLD)
1696
- ).option("--emit-diff-png", "write diff PNG to .blazediff/diffs/").action(async (id, opts) => {
1697
- const manifest = await loadManifest();
1698
- if (!manifest) throw new Error("no manifest");
1699
- const entry = findEntry(manifest, id);
1700
- if (!entry) throw new Error(`no entry with id ${id}`);
1701
- const baselinePath = `${paths().baselines}/${id}.png`;
1702
- const actualPath = `${paths().actual}/${id}.png`;
1703
- const outcome = await diffEntry(id, baselinePath, actualPath, {
1704
- threshold: Number(opts.threshold),
1705
- emitDiffPng: Boolean(opts.emitDiffPng)
1706
- });
1707
- out.emit(
1708
- outcome,
1709
- outcome.match ? `${id}: match` : `${id}: ${outcome.reason ?? "diff"}`
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
+ )
1710
1816
  );
1711
- if (!outcome.match) process.exitCode = 1;
1712
- });
1817
+ if (outcome.reason === "file-not-exists") {
1818
+ return {
1819
+ diffOutput: {
1820
+ id: entry.id,
1821
+ skipResult: missingBaselineResult(entry, capture.baselinePath)
1822
+ }
1823
+ };
1824
+ }
1825
+ return {
1826
+ diffOutput: {
1827
+ id: entry.id,
1828
+ outcome,
1829
+ baselinePath: capture.baselinePath,
1830
+ captureOutputPath: capture.captureOutputPath
1831
+ }
1832
+ };
1833
+ };
1713
1834
  }
1714
1835
 
1715
- // src/discover/crawl.ts
1716
- function extractInternalLinks(base, target, hrefs) {
1717
- const out = [];
1718
- for (const href of hrefs) {
1719
- if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
1720
- continue;
1721
- }
1722
- try {
1723
- const u = new URL(href, target);
1724
- if (u.origin !== base.origin) continue;
1725
- const path23 = u.pathname + u.search;
1726
- if (path23.startsWith("/api/")) continue;
1727
- out.push(path23);
1728
- } catch {
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;
1729
1859
  }
1730
1860
  }
1731
- return out;
1861
+ return best;
1732
1862
  }
1733
- async function crawlRoutes(opts) {
1734
- const maxRoutes = opts.maxRoutes ?? 50;
1735
- const maxDepth = opts.maxDepth ?? 2;
1736
- const base = new URL(opts.baseUrl);
1737
- const visited = /* @__PURE__ */ new Set();
1738
- const queue = [{ url: "/", depth: 0 }];
1739
- visited.add("/");
1740
- const discovered = [];
1741
- const browser = await getBrowser();
1742
- const context = await browser.newContext({
1743
- viewport: { width: 1024, height: 768 },
1744
- deviceScaleFactor: 1
1745
- });
1746
- try {
1747
- while (queue.length && discovered.length < maxRoutes) {
1748
- const { url, depth } = queue.shift();
1749
- const page = await context.newPage();
1750
- try {
1751
- const target = new URL(url, base).toString();
1752
- await page.goto(target, {
1753
- waitUntil: "domcontentloaded",
1754
- timeout: 15e3
1755
- });
1756
- discovered.push({ url, source: "crawl" });
1757
- if (depth >= maxDepth) continue;
1758
- const hrefs = await page.evaluate(
1759
- () => Array.from(
1760
- document.querySelectorAll("a[href]")
1761
- ).map((a) => a.getAttribute("href") ?? "")
1762
- );
1763
- for (const path23 of extractInternalLinks(base, target, hrefs)) {
1764
- if (visited.has(path23)) continue;
1765
- visited.add(path23);
1766
- queue.push({ url: path23, depth: depth + 1 });
1767
- }
1768
- } catch {
1769
- } finally {
1770
- await page.close().catch(() => {
1771
- });
1772
- }
1773
- }
1774
- } finally {
1775
- await context.close().catch(() => {
1776
- });
1777
- }
1778
- return discovered;
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;
1779
1868
  }
1780
- var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
1781
- async function readJson(file) {
1782
- if (!fs.existsSync(file)) return null;
1783
- try {
1784
- return JSON.parse(await promises.readFile(file, "utf8"));
1785
- } catch {
1786
- return null;
1787
- }
1869
+ function meanConfidence(regions) {
1870
+ if (regions.length === 0) return 0;
1871
+ return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
1788
1872
  }
1789
- function isPublicRoute(route) {
1790
- if (DYNAMIC_SEGMENT.test(route)) return false;
1791
- if (route === "/api" || route.startsWith("/api/")) return false;
1792
- return true;
1873
+ function formatBreakdown(counts) {
1874
+ return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
1793
1875
  }
1794
- async function discoverFromNextManifest(cwd = process.cwd()) {
1795
- const nextDir = path2__default.default.join(cwd, ".next");
1796
- if (!fs.existsSync(nextDir)) return [];
1797
- const seen = /* @__PURE__ */ new Set();
1798
- const out = [];
1799
- const add = (url) => {
1800
- if (seen.has(url)) return;
1801
- seen.add(url);
1802
- out.push({ url, source: "next-manifest" });
1803
- };
1804
- const routes = await readJson(
1805
- path2__default.default.join(nextDir, "routes-manifest.json")
1806
- );
1807
- for (const r of routes?.staticRoutes ?? []) {
1808
- if (isPublicRoute(r.page)) add(r.page);
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`;
1809
1883
  }
1810
- const appPaths = await readJson(
1811
- path2__default.default.join(nextDir, "server", "app-paths-manifest.json")
1812
- );
1813
- for (const route of Object.keys(appPaths ?? {})) {
1814
- const normalized = route.replace(/\/page$/, "") || "/";
1815
- if (isPublicRoute(normalized)) add(normalized);
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})`;
1816
1891
  }
1817
- return out;
1892
+ return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
1818
1893
  }
1819
-
1820
- // src/discover/sitemap.ts
1821
- var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
1822
- var LOC_RE = /<loc>([^<]+)<\/loc>/g;
1823
- async function discoverFromSitemap(baseUrl) {
1824
- for (const candidate of CANDIDATES) {
1825
- try {
1826
- const res = await fetch(new URL(candidate, baseUrl));
1827
- if (!res.ok) continue;
1828
- const text = await res.text();
1829
- const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
1830
- if (!urls.length) continue;
1831
- return urls.map((u) => {
1832
- const url = new URL(u);
1833
- return { url: url.pathname + url.search, source: "sitemap" };
1834
- });
1835
- } catch {
1836
- }
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
+ };
1837
1907
  }
1838
- return [];
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
+ };
1973
+ }
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
+ };
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
+ };
1839
1993
  }
1840
-
1841
- // src/discover/index.ts
1842
- function normalizePath(url) {
1843
- const [pathPart, query = ""] = url.split("?", 2);
1844
- const trimmed = pathPart.replace(/\/+$/, "");
1845
- const normalizedPath = trimmed === "" ? "/" : trimmed;
1846
- return query ? `${normalizedPath}?${query}` : normalizedPath;
1994
+ function interruptForJudgment(payload) {
1995
+ const resume = langgraph.interrupt(payload);
1996
+ if (!resume || typeof resume !== "object") return void 0;
1997
+ return resume[payload.entryId];
1847
1998
  }
1848
- function mergeBy(routes, into) {
1849
- for (const r of routes) {
1850
- const key = normalizePath(r.url);
1851
- if (!into.has(key)) into.set(key, { ...r, url: key });
1999
+
2000
+ // src/graph/nodes/judge.ts
2001
+ async function judgeNode(state) {
2002
+ const entry = state.entry;
2003
+ const options = state.options;
2004
+ const diff = state.diffOutput;
2005
+ if (!entry || !options || !diff) {
2006
+ throw new Error("judgeNode: entry, options, or diff missing");
1852
2007
  }
1853
- }
1854
- async function discover(opts) {
1855
- const cwd = opts.cwd ?? process.cwd();
1856
- const merged = /* @__PURE__ */ new Map();
1857
- mergeBy(await discoverFromNextManifest(cwd), merged);
1858
- mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
1859
- if (!opts.skipCrawl) {
1860
- const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
1861
- if (crawlMax > 0) {
1862
- mergeBy(
1863
- await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
1864
- merged
1865
- );
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;
1866
2065
  }
1867
2066
  }
1868
- return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
2067
+ return { results: [result] };
1869
2068
  }
1870
2069
 
1871
- // src/cli/commands/discover.ts
1872
- function registerDiscover(program, out) {
1873
- program.command("discover").description(
1874
- "enumerate candidate routes via BFS crawl + Next manifest + sitemap"
1875
- ).option("--base-url <url>", "override base URL").option("--max-routes <n>", "cap on routes returned", "50").option("--no-crawl", "skip BFS crawl fallback").action(async (opts) => {
1876
- const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
1877
- const routes = await discover({
1878
- baseUrl,
1879
- maxRoutes: Number(opts.maxRoutes),
1880
- skipCrawl: !opts.crawl
1881
- });
1882
- await closeBrowser();
1883
- out.emit(
1884
- { ok: true, baseUrl, routes },
1885
- routes.length ? routes.map((r) => `${r.source.padEnd(14)} ${r.url}`).join("\n") : "no routes discovered"
2070
+ // src/graph/nodes/load.ts
2071
+ async function loadNode(state) {
2072
+ if (!state.options) {
2073
+ throw new Error("loadNode: graph options missing");
2074
+ }
2075
+ const manifest = await loadManifest(state.options.cwd);
2076
+ if (!manifest) {
2077
+ throw new Error(
2078
+ `no manifest found at ${paths(state.options.cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
1886
2079
  );
1887
- });
1888
- }
1889
-
1890
- // src/introspect/framework.ts
1891
- var SIGNALS = [
1892
- ["next", ["next"]],
1893
- ["remix", ["@remix-run/dev", "@remix-run/serve"]],
1894
- ["sveltekit", ["@sveltejs/kit"]],
1895
- ["nuxt", ["nuxt", "nuxt3"]],
1896
- ["astro", ["astro"]],
1897
- ["gatsby", ["gatsby"]],
1898
- ["vite-react", ["vite", "react"]]
1899
- ];
1900
- function detectFramework(pkg) {
1901
- const deps = pkg.allDependencies;
1902
- for (const [framework, required] of SIGNALS) {
1903
- if (required.every((d) => d in deps)) return framework;
1904
2080
  }
1905
- return "unknown";
1906
- }
1907
- async function readPackageJson(cwd = process.cwd()) {
1908
- const file = path2__default.default.join(cwd, "package.json");
1909
- if (!fs.existsSync(file)) return null;
1910
- return JSON.parse(await promises.readFile(file, "utf8"));
2081
+ return { entries: manifest.entries, manifest };
1911
2082
  }
1912
- function detectPackageManager(cwd = process.cwd()) {
1913
- let dir = cwd;
1914
- const { root } = path2__default.default.parse(dir);
1915
- while (true) {
1916
- if (fs.existsSync(path2__default.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
1917
- if (fs.existsSync(path2__default.default.join(dir, "bun.lockb")) || fs.existsSync(path2__default.default.join(dir, "bun.lock")))
1918
- return "bun";
1919
- if (fs.existsSync(path2__default.default.join(dir, "yarn.lock"))) return "yarn";
1920
- if (fs.existsSync(path2__default.default.join(dir, "package-lock.json"))) return "npm";
1921
- if (dir === root) return "npm";
1922
- dir = path2__default.default.dirname(dir);
1923
- }
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
+ }),
2092
+ options: langgraph.Annotation({
2093
+ reducer: (acc, next) => next ?? acc,
2094
+ default: () => void 0
2095
+ }),
2096
+ captureOutput: langgraph.Annotation({
2097
+ reducer: (_, next) => next,
2098
+ default: () => void 0
2099
+ }),
2100
+ diffOutput: langgraph.Annotation({
2101
+ reducer: (_, next) => next,
2102
+ default: () => void 0
2103
+ }),
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,
2113
+ default: () => []
2114
+ }),
2115
+ results: resultsChannel,
2116
+ manifest: langgraph.Annotation({
2117
+ reducer: (acc, next) => next ?? acc,
2118
+ default: () => void 0
2119
+ }),
2120
+ report: langgraph.Annotation({
2121
+ reducer: (acc, next) => next ?? acc,
2122
+ default: () => void 0
2123
+ })
2124
+ });
2125
+
2126
+ // src/graph/index.ts
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);
1924
2131
  }
1925
- var DEFAULT_PORTS = {
1926
- next: 3e3,
1927
- "react-scripts": 3e3,
1928
- vite: 5173,
1929
- remix: 3e3,
1930
- "@remix-run/dev": 3e3,
1931
- astro: 4321,
1932
- svelte: 5173,
1933
- vue: 5173,
1934
- nuxt: 3e3,
1935
- gatsby: 8e3,
1936
- parcel: 1234
1937
- };
1938
- var DEV_SCRIPT_CANDIDATES = ["dev", "start", "serve", "develop"];
1939
- function inferPort(script, deps) {
1940
- const portArg = script.match(/(?:--port[\s=]|-p\s+)(\d+)/);
1941
- if (portArg) return Number(portArg[1]);
1942
- const portEnv = script.match(/PORT[\s=]+(\d+)/);
1943
- if (portEnv) return Number(portEnv[1]);
1944
- const depNames = Object.keys(deps);
1945
- for (const [pkg, port] of Object.entries(DEFAULT_PORTS)) {
1946
- if (depNames.some((d) => d.startsWith(pkg))) return port;
1947
- }
1948
- return DEFAULT_PORT;
2132
+ function threadIdFor(cwd) {
2133
+ return crypto$1.createHash("sha1").update(paths(cwd).manifest).digest("hex").slice(0, 16);
1949
2134
  }
1950
- function runnerFor(pm, scriptName) {
1951
- if (pm === "npm") return `npm run ${scriptName}`;
1952
- if (pm === "yarn") return `yarn ${scriptName}`;
1953
- if (pm === "bun") return `bun run ${scriptName}`;
1954
- return `pnpm ${scriptName}`;
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();
1955
2137
  }
1956
- function collectCandidates(scripts, deps, pm) {
1957
- const out = [];
1958
- for (const name of DEV_SCRIPT_CANDIDATES) {
1959
- if (scripts[name]) {
1960
- out.push({
1961
- name,
1962
- body: scripts[name],
1963
- command: runnerFor(pm, name),
1964
- port: inferPort(scripts[name], deps)
1965
- });
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(
2141
+ "load",
2142
+ (state) => state.entries.map(
2143
+ (entry) => new langgraph.Send("branch", { entry, options: state.options })
2144
+ ),
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
+ }
1966
2183
  }
1967
2184
  }
1968
- return out;
2185
+ return collect;
1969
2186
  }
1970
- async function introspectPackage(cwd = process.cwd()) {
1971
- const pkg = await readPackageJson(cwd);
1972
- if (!pkg) return null;
1973
- const packageManager = detectPackageManager(cwd);
1974
- const scripts = pkg.scripts ?? {};
1975
- const devDependencies = pkg.devDependencies ?? {};
1976
- const dependencies = pkg.dependencies ?? {};
1977
- const allDependencies = { ...devDependencies, ...dependencies };
1978
- const candidates = collectCandidates(
1979
- scripts,
1980
- allDependencies,
1981
- packageManager
1982
- );
1983
- if (!candidates.length) return null;
1984
- const chosen = candidates[0];
1985
- return {
1986
- packageManager,
1987
- devScript: chosen.name,
1988
- devCommand: chosen.command,
1989
- port: chosen.port,
1990
- candidates,
1991
- devDependencies,
1992
- dependencies,
1993
- allDependencies
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
1994
2206
  };
2207
+ if (manifest) {
2208
+ await writeJudgments({ report, manifest, cwd });
2209
+ }
2210
+ await writeSummaryMarkdown(report, cwd);
2211
+ await ensureGitignore(cwd);
2212
+ return report;
1995
2213
  }
1996
-
1997
- // src/cli/commands/init.ts
1998
- async function buildConfig(opts) {
1999
- if (opts.url) {
2000
- if (opts.devCommand || opts.port || opts.devScript) {
2001
- throw new Error(
2002
- "--url is mutually exclusive with --dev-command/--port/--dev-script"
2003
- );
2214
+ async function runGraph(opts) {
2215
+ const cwd = opts.cwd ?? process.cwd();
2216
+ const concurrency = opts.concurrency ?? defaultConcurrency();
2217
+ const captureSemaphore = new Semaphore(concurrency);
2218
+ const diffSemaphore = new Semaphore(cpuParallelism());
2219
+ const baselinesDir = paths(cwd).baselines;
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
2004
2235
  }
2005
- const baseUrl = new URL(opts.url).toString().replace(/\/$/, "");
2006
- return { devServer: null, baseUrl };
2007
- }
2008
- if (opts.devCommand) {
2009
- const port2 = opts.port ? Number(opts.port) : DEFAULT_PORT;
2010
- return {
2011
- devServer: {
2012
- command: opts.devCommand,
2013
- port: port2,
2014
- readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
2015
- },
2016
- baseUrl: `http://127.0.0.1:${port2}`
2017
- };
2236
+ };
2237
+ let collect;
2238
+ try {
2239
+ collect = await streamGraph(graph, input, threadId, opts.onEvent);
2240
+ } finally {
2241
+ await closeBrowser();
2018
2242
  }
2019
- const pkg = await introspectPackage();
2020
- if (!pkg) {
2021
- throw new Error(
2022
- "no package.json with a dev/start script in cwd. Pass --url <baseUrl> or --dev-command <cmd>."
2023
- );
2243
+ const report = collect.report ?? await buildPartialReport(collect, cwd);
2244
+ if (opts.junitPath) {
2245
+ const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
2246
+ await writeJunit(report, target);
2024
2247
  }
2025
- let chosen = pkg.candidates[0];
2026
- if (pkg.candidates.length > 1) {
2027
- if (!opts.devScript) {
2028
- const names = pkg.candidates.map((c) => `${c.name} (${c.command})`).join(", ");
2029
- throw new Error(
2030
- `multiple dev-script candidates: ${names}. Pass --dev-script <name> or --dev-command <cmd>.`
2031
- );
2032
- }
2033
- const match = pkg.candidates.find((c) => c.name === opts.devScript);
2034
- if (!match) {
2035
- const names = pkg.candidates.map((c) => c.name).join(", ");
2036
- throw new Error(
2037
- `--dev-script "${opts.devScript}" not found among candidates: ${names}`
2038
- );
2039
- }
2040
- chosen = match;
2248
+ if (collect.interrupts.length === 0) {
2249
+ await checkpointer.deleteThread(threadId).catch(() => void 0);
2041
2250
  }
2042
- const port = opts.port ? Number(opts.port) : chosen.port;
2251
+ return report;
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
+
2263
+ // src/cli/check-output.ts
2264
+ function slimResult(r) {
2043
2265
  return {
2044
- devServer: {
2045
- command: chosen.command,
2046
- port,
2047
- readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
2048
- },
2049
- framework: detectFramework(pkg),
2050
- packageManager: pkg.packageManager,
2051
- baseUrl: `http://127.0.0.1:${port}`
2266
+ id: r.id,
2267
+ url: r.url,
2268
+ status: r.status,
2269
+ verdict: r.verdict ? {
2270
+ label: r.verdict.label,
2271
+ headline: r.verdict.headline,
2272
+ action: r.verdict.action
2273
+ } : void 0
2052
2274
  };
2053
2275
  }
2054
- function registerInit(program, out) {
2055
- program.command("init").description("write .blazediff/config.json and .gitignore").option("--force", "overwrite existing config").option(
2056
- "--url <baseUrl>",
2057
- "point at an already-running server / external URL"
2058
- ).option("--dev-command <cmd>", "override detected dev-server command").option("--port <n>", "override detected port").option(
2059
- "--dev-script <name>",
2060
- "select a dev script by name when multiple candidates exist"
2061
- ).action(async (opts) => {
2062
- const existing = await loadConfig();
2063
- if (existing && !opts.force) {
2064
- await ensureGitignore(process.cwd());
2065
- out.emit(
2066
- {
2067
- ok: true,
2068
- created: false,
2069
- config: existing,
2070
- configHash: configHash(existing)
2071
- },
2072
- `config exists at ${paths().config} (use --force to overwrite)`
2073
- );
2074
- return;
2276
+ function slimReport(report, summaryPath) {
2277
+ return {
2278
+ summaryPath,
2279
+ createdAt: report.createdAt,
2280
+ totalEntries: report.totalEntries,
2281
+ passed: report.passed,
2282
+ failed: report.failed,
2283
+ pendingJudgments: report.pendingJudgments,
2284
+ results: report.results.filter((r) => r.status !== "pass").map(slimResult)
2285
+ };
2286
+ }
2287
+ function failureLines(results) {
2288
+ return results.filter((r) => r.status !== "pass").flatMap((r) => {
2289
+ const lines = [];
2290
+ const prefix = r.status === "needs-judgment" ? "?" : "\u2717";
2291
+ if (r.verdict) {
2292
+ lines.push(
2293
+ ` ${prefix} ${r.id} [${r.verdict.label}] ${r.verdict.headline}`
2294
+ );
2295
+ lines.push(` \u2192 ${r.verdict.action}`);
2296
+ } else {
2297
+ const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
2298
+ lines.push(` ${prefix} ${r.id}: ${detail}`);
2075
2299
  }
2076
- const config = await buildConfig(opts);
2077
- await saveConfig(config);
2078
- await ensureGitignore(process.cwd());
2079
- const human = config.devServer ? `wrote ${paths().config}
2080
- baseUrl: ${config.baseUrl}
2081
- dev: ${config.devServer.command} (port ${config.devServer.port})` : `wrote ${paths().config}
2082
- baseUrl: ${config.baseUrl}
2083
- external server (no devServer managed)`;
2084
- out.emit(
2085
- { ok: true, created: true, config, configHash: configHash(config) },
2086
- human
2087
- );
2300
+ if (r.status === "needs-judgment" && r.message) {
2301
+ lines.push(` ${r.message}`);
2302
+ }
2303
+ if (r.diffPath) lines.push(` diff: ${r.diffPath}`);
2304
+ return lines;
2088
2305
  });
2089
2306
  }
2307
+ function parseJudge(input) {
2308
+ if (input === "host" || input === "none") return input;
2309
+ throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
2310
+ }
2090
2311
 
2091
- // src/cli/commands/manifest.ts
2092
- function registerManifest(program, out) {
2093
- const cmd = program.command("manifest").description("manage .blazediff/manifest.json");
2094
- cmd.command("add <id>").requiredOption("--url <url>").option("--viewport <WxH>", "viewport", "1280x800").option("--mask <selectors>", "selectors", "").option("--wait-for <list>", "wait list", "networkidle,fonts").option("--no-full-page", "viewport-only (default: full page)").option("--auth <required|none>", "mark auth-gated", "none").option("--created-by <agent|human>", "provenance", "agent").action(async (id, opts) => {
2095
- const config = await loadConfig();
2096
- if (!config)
2097
- throw new Error("no config. Run `blazediff-agent init` first.");
2098
- const manifest = await loadManifest() ?? emptyManifest(configHash(config));
2099
- const entry = makeEntry({
2100
- id,
2101
- url: opts.url,
2102
- viewport: parseViewport(opts.viewport),
2103
- mask: parseMaskList(opts.mask),
2104
- waitFor: parseWaitFor(opts.waitFor),
2105
- fullPage: opts.fullPage,
2106
- auth: opts.auth === "required" ? "required" : null,
2107
- createdBy: opts.createdBy
2312
+ // src/cli/commands/check.ts
2313
+ function glyphFor(status) {
2314
+ switch (status) {
2315
+ case "pass":
2316
+ return "\u2713";
2317
+ case "needs-judgment":
2318
+ return "?";
2319
+ case "stale-baseline":
2320
+ case "missing-baseline":
2321
+ return "!";
2322
+ default:
2323
+ return "\u2717";
2324
+ }
2325
+ }
2326
+ function makeProgressReporter(out) {
2327
+ if (out.isJson() || out.isQuiet()) return void 0;
2328
+ let done = 0;
2329
+ let total = 0;
2330
+ const counter = () => total > 0 ? `[${done}/${total}]` : `[${done}]`;
2331
+ return (event) => {
2332
+ if (event.type === "report") {
2333
+ total = event.report.totalEntries;
2334
+ return;
2335
+ }
2336
+ if (event.type === "result") {
2337
+ done += 1;
2338
+ const r = event.result;
2339
+ const detail = r.status === "fail" && typeof r.diffPercentage === "number" ? ` (${r.diffPercentage.toFixed(3)}%)` : r.status !== "pass" && r.message ? ` (${r.message})` : "";
2340
+ process.stderr.write(
2341
+ `${counter()} ${glyphFor(r.status)} ${r.id}${detail}
2342
+ `
2343
+ );
2344
+ return;
2345
+ }
2346
+ if (event.type === "interrupt") {
2347
+ done += 1;
2348
+ process.stderr.write(
2349
+ `${counter()} ? ${event.interrupt.entryId} (awaiting judgment)
2350
+ `
2351
+ );
2352
+ }
2353
+ };
2354
+ }
2355
+ function registerCheck(program, out) {
2356
+ program.command("check").description("run the visual regression check (CI verb)").option("--base-url <url>", "override base URL").option(
2357
+ "--threshold <n>",
2358
+ "color threshold (0-1)",
2359
+ String(DEFAULT_THRESHOLD)
2360
+ ).option(
2361
+ "--concurrency <n>",
2362
+ "max entries captured in parallel (default: auto based on CPU cores, capped at 8)"
2363
+ ).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
2364
+ "--judge <backend>",
2365
+ "judge backend for ambiguous diffs (host | none)",
2366
+ "none"
2367
+ ).option(
2368
+ "--apply-judgments",
2369
+ "resume the suspended graph using .blazediff/judgments/<id>/verdict.json files"
2370
+ ).action(async (opts) => {
2371
+ if (opts.applyJudgments) {
2372
+ const { report: report2, applied, missing, invalid } = await applyJudgments({
2373
+ onEvent: makeProgressReporter(out),
2374
+ junitPath: opts.junit
2375
+ });
2376
+ const summaryPath2 = paths().summary;
2377
+ const human2 = applied.length === 0 && missing.length === 0 && invalid.length === 0 ? `no judgments to apply
2378
+ summary: ${summaryPath2}` : [
2379
+ `applied ${applied.length} judgment(s)`,
2380
+ missing.length ? ` ${missing.length} pending without judgment: ${missing.join(", ")}` : void 0,
2381
+ invalid.length ? ` ${invalid.length} invalid judgment file(s): ${invalid.join(", ")}` : void 0,
2382
+ ` ${report2.passed}/${report2.totalEntries} passed (${report2.failed} failed, ${report2.pendingJudgments} pending)`,
2383
+ ` summary: ${summaryPath2}`
2384
+ ].filter(Boolean).join("\n");
2385
+ out.emit(
2386
+ {
2387
+ ok: true,
2388
+ applied,
2389
+ missing,
2390
+ invalid,
2391
+ ...slimReport(report2, summaryPath2)
2392
+ },
2393
+ human2
2394
+ );
2395
+ if (report2.failed > 0) process.exitCode = 1;
2396
+ return;
2397
+ }
2398
+ const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
2399
+ const report = await runGraph({
2400
+ baseUrl,
2401
+ threshold: parseThreshold(opts.threshold),
2402
+ concurrency: opts.concurrency ? parsePositiveInteger(opts.concurrency, "--concurrency") : void 0,
2403
+ emitDiffPng: opts.diffPng,
2404
+ junitPath: opts.junit,
2405
+ judge: parseJudge(opts.judge),
2406
+ onEvent: makeProgressReporter(out)
2108
2407
  });
2109
- await saveManifest(addOrReplaceEntry(manifest, entry));
2110
- out.emit(
2111
- { ok: true, entry },
2112
- out.isTTY() ? `manifest: added ${id} (${entry.url})` : "."
2113
- );
2408
+ const summaryPath = paths().summary;
2409
+ const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
2410
+ const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
2411
+ summary: ${summaryPath}` : [
2412
+ `${summary}:`,
2413
+ ...failureLines(report.results),
2414
+ ` summary: ${summaryPath}`,
2415
+ report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
2416
+ ].filter(Boolean).join("\n");
2417
+ out.emit(slimReport(report, summaryPath), human);
2418
+ if (report.failed > 0) process.exitCode = 1;
2114
2419
  });
2115
- cmd.command("remove <id>").action(async (id) => {
2420
+ }
2421
+
2422
+ // src/cli/commands/diff.ts
2423
+ function registerDiff(program, out) {
2424
+ program.command("diff <id>").description("diff a route's baseline against its actual capture").option(
2425
+ "--threshold <n>",
2426
+ "color threshold (0-1)",
2427
+ String(DEFAULT_THRESHOLD)
2428
+ ).option("--emit-diff-png", "write diff PNG to .blazediff/diffs/").action(async (id, opts) => {
2116
2429
  const manifest = await loadManifest();
2117
2430
  if (!manifest) throw new Error("no manifest");
2118
- await saveManifest(removeEntry(manifest, id));
2119
- out.emit({ ok: true, removed: id }, `manifest: removed ${id}`);
2120
- });
2121
- cmd.command("list").action(async () => {
2122
- const manifest = await loadManifest();
2123
- if (!manifest) {
2124
- out.emit({ entries: [] }, "no manifest");
2125
- return;
2126
- }
2431
+ const entry = findEntry(manifest, id);
2432
+ if (!entry) throw new Error(`no entry with id ${id}`);
2433
+ const baselinePath = `${paths().baselines}/${id}.png`;
2434
+ const actualPath = `${paths().actual}/${id}.png`;
2435
+ const outcome = await diffEntry(id, baselinePath, actualPath, {
2436
+ threshold: Number(opts.threshold),
2437
+ emitDiffPng: Boolean(opts.emitDiffPng)
2438
+ });
2127
2439
  out.emit(
2128
- { entries: manifest.entries },
2129
- manifest.entries.map((e) => `${e.id.padEnd(30)} ${e.url}`).join("\n") || "no entries"
2440
+ outcome,
2441
+ outcome.match ? `${id}: match` : `${id}: ${outcome.reason ?? "diff"}`
2130
2442
  );
2443
+ if (!outcome.match) process.exitCode = 1;
2131
2444
  });
2132
2445
  }
2133
- var someExists = (paths2) => paths2.some((p) => fs.existsSync(p));
2134
- var HARNESSES = {
2135
- claude: {
2136
- id: "claude",
2137
- label: "Claude Code",
2138
- detect: (cwd) => someExists([
2139
- path2.join(cwd, ".claude"),
2140
- path2.join(cwd, "CLAUDE.md"),
2141
- path2.join(cwd, "AGENTS.md")
2142
- ]),
2143
- target: (cwd) => path2.join(cwd, ".claude", "skills", "blazediff", "SKILL.md"),
2144
- format: "skill-file",
2145
- scope: "project"
2146
- },
2147
- codex: {
2148
- id: "codex",
2149
- label: "Codex",
2150
- detect: (cwd) => someExists([
2151
- path2.join(cwd, "AGENTS.md"),
2152
- path2.join(cwd, ".codex"),
2153
- path2.join(os.homedir(), ".codex")
2154
- ]),
2155
- target: () => path2.join(os.homedir(), ".codex", "skills", "blazediff", "SKILL.md"),
2156
- format: "skill-file",
2157
- scope: "user"
2158
- },
2159
- cursor: {
2160
- id: "cursor",
2161
- label: "Cursor",
2162
- detect: (cwd) => someExists([path2.join(cwd, ".cursor"), path2.join(cwd, ".cursorrules")]),
2163
- target: (cwd) => path2.join(cwd, ".cursor", "rules", "blazediff.mdc"),
2164
- format: "cursor-rule",
2165
- scope: "project"
2166
- }
2167
- };
2168
- var ALL_HARNESSES = ["claude", "codex", "cursor"];
2169
- function detectHarnesses(cwd) {
2170
- return ALL_HARNESSES.filter((id) => HARNESSES[id].detect(cwd));
2171
- }
2172
- function parseHarnessList(input) {
2173
- const tokens = input.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
2174
- if (tokens.includes("all")) return [...ALL_HARNESSES];
2446
+
2447
+ // src/discover/crawl.ts
2448
+ function extractInternalLinks(base, target, hrefs) {
2175
2449
  const out = [];
2176
- for (const t of tokens) {
2177
- if (!(t in HARNESSES)) {
2178
- throw new Error(
2179
- `unknown harness "${t}". valid: ${[...ALL_HARNESSES, "all"].join(", ")}`
2180
- );
2450
+ for (const href of hrefs) {
2451
+ if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
2452
+ continue;
2181
2453
  }
2182
- if (!out.includes(t)) out.push(t);
2183
- }
2184
- return out;
2185
- }
2186
- var SKILL_FILES = ["SKILL.md", "JUDGING.md", "MASKING.md"];
2187
- var cachedDir = null;
2188
- var cachedFiles = null;
2189
- function moduleDir() {
2190
- return path2.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href))));
2191
- }
2192
- function resolveSkillDir() {
2193
- if (cachedDir !== null) return cachedDir;
2194
- const here = moduleDir();
2195
- const candidates = [
2196
- path2.join(here, ".."),
2197
- path2.join(here, "..", ".."),
2198
- path2.join(here, "..", "..", "..", "skill", "blazediff"),
2199
- path2.join(here, "..", "..", "..", "..", "skill", "blazediff")
2200
- ];
2201
- for (const dir of candidates) {
2202
- if (fs.existsSync(path2.join(dir, "SKILL.md"))) {
2203
- cachedDir = dir;
2204
- return cachedDir;
2454
+ try {
2455
+ const u = new URL(href, target);
2456
+ if (u.origin !== base.origin) continue;
2457
+ const path23 = u.pathname + u.search;
2458
+ if (path23.startsWith("/api/")) continue;
2459
+ out.push(path23);
2460
+ } catch {
2205
2461
  }
2206
2462
  }
2207
- throw new Error(
2208
- `could not locate bundled SKILL.md (looked in: ${candidates.join(", ")}). reinstall @blazediff/agent.`
2209
- );
2463
+ return out;
2210
2464
  }
2211
- function loadSkillFiles() {
2212
- if (cachedFiles !== null) return cachedFiles;
2213
- const dir = resolveSkillDir();
2214
- cachedFiles = SKILL_FILES.filter((name) => fs.existsSync(path2.join(dir, name))).map(
2215
- (name) => ({ name, content: fs.readFileSync(path2.join(dir, name), "utf8") })
2216
- );
2217
- return cachedFiles;
2218
- }
2219
- function skillBodyOnly(content) {
2220
- const lines = content.split("\n");
2221
- if (lines[0]?.startsWith("---")) {
2222
- let end = -1;
2223
- for (let i = 1; i < lines.length; i++) {
2224
- if (lines[i]?.startsWith("---")) {
2225
- end = i;
2226
- break;
2465
+ var CRAWL_VIEWPORT = { width: 1024, height: 768 };
2466
+ var CRAWL_WORKERS = 4;
2467
+ async function crawlRoutes(opts) {
2468
+ const maxRoutes = opts.maxRoutes ?? 50;
2469
+ const maxDepth = opts.maxDepth ?? 2;
2470
+ const base = new URL(opts.baseUrl);
2471
+ const visited = /* @__PURE__ */ new Set(["/"]);
2472
+ const queue = [{ url: "/", depth: 0 }];
2473
+ const discovered = [];
2474
+ const handle = await acquireStableContext(CRAWL_VIEWPORT);
2475
+ const fetchOne = async () => {
2476
+ while (queue.length && discovered.length < maxRoutes) {
2477
+ const item = queue.shift();
2478
+ if (!item) return;
2479
+ if (discovered.length >= maxRoutes) return;
2480
+ discovered.push({ url: item.url, source: "crawl" });
2481
+ if (item.depth >= maxDepth) continue;
2482
+ const page = await handle.context.newPage();
2483
+ try {
2484
+ const target = new URL(item.url, base).toString();
2485
+ await page.goto(target, {
2486
+ waitUntil: "domcontentloaded",
2487
+ timeout: 15e3
2488
+ });
2489
+ const hrefs = await page.evaluate(
2490
+ () => Array.from(
2491
+ document.querySelectorAll("a[href]")
2492
+ ).map((a) => a.getAttribute("href") ?? "")
2493
+ );
2494
+ for (const p of extractInternalLinks(base, target, hrefs)) {
2495
+ if (visited.has(p)) continue;
2496
+ visited.add(p);
2497
+ queue.push({ url: p, depth: item.depth + 1 });
2498
+ }
2499
+ } catch {
2500
+ } finally {
2501
+ await page.close().catch(() => {
2502
+ });
2227
2503
  }
2228
2504
  }
2229
- if (end > 0)
2230
- return lines.slice(end + 1).join("\n").trimStart();
2505
+ };
2506
+ try {
2507
+ await Promise.all(Array.from({ length: CRAWL_WORKERS }, () => fetchOne()));
2508
+ } finally {
2509
+ await releaseStableContext(handle);
2231
2510
  }
2232
- return content;
2511
+ return discovered.slice(0, maxRoutes);
2233
2512
  }
2234
-
2235
- // src/onboard/install.ts
2236
- function ensureTrailingNewline(s) {
2237
- return s.endsWith("\n") ? s : `${s}
2238
- `;
2513
+ var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
2514
+ async function readJson2(file) {
2515
+ if (!fs.existsSync(file)) return null;
2516
+ try {
2517
+ return JSON.parse(await promises.readFile(file, "utf8"));
2518
+ } catch {
2519
+ return null;
2520
+ }
2239
2521
  }
2240
- function renderCursorRule(files) {
2241
- const skill = files.find((f) => f.name === "SKILL.md")?.content ?? "";
2242
- const sidecars = files.filter((f) => f.name !== "SKILL.md");
2243
- const body = skillBodyOnly(skill).trim();
2244
- const frontmatter = [
2245
- "---",
2246
- 'description: "Run, author, or update BlazeDiff visual regression tests. Trigger on visual test, screenshot regression, blazediff, /blazediff."',
2247
- "alwaysApply: false",
2248
- "---",
2249
- ""
2250
- ].join("\n");
2251
- const sidecarBlocks = sidecars.map((f) => `
2252
-
2253
- ---
2254
-
2255
- <!-- ${f.name} -->
2256
-
2257
- ${f.content.trim()}`).join("");
2258
- return `${frontmatter}${body}${sidecarBlocks}
2259
- `;
2522
+ function isPublicRoute(route) {
2523
+ if (DYNAMIC_SEGMENT.test(route)) return false;
2524
+ if (route === "/api" || route.startsWith("/api/")) return false;
2525
+ return true;
2260
2526
  }
2261
- async function writeIfChanged(target, content, force) {
2262
- const stat2 = await promises.lstat(target).catch(() => null);
2263
- const isSymlink = stat2?.isSymbolicLink() ?? false;
2264
- const exists = stat2 !== null;
2265
- if (isSymlink) {
2266
- await promises.unlink(target);
2267
- await promises.mkdir(path2.dirname(target), { recursive: true });
2268
- await promises.writeFile(target, content, "utf8");
2269
- return "updated";
2527
+ async function discoverFromNextManifest(cwd = process.cwd()) {
2528
+ const nextDir = path2__default.default.join(cwd, ".next");
2529
+ if (!fs.existsSync(nextDir)) return [];
2530
+ const seen = /* @__PURE__ */ new Set();
2531
+ const out = [];
2532
+ const add = (url) => {
2533
+ if (seen.has(url)) return;
2534
+ seen.add(url);
2535
+ out.push({ url, source: "next-manifest" });
2536
+ };
2537
+ const routes = await readJson2(
2538
+ path2__default.default.join(nextDir, "routes-manifest.json")
2539
+ );
2540
+ for (const r of routes?.staticRoutes ?? []) {
2541
+ if (isPublicRoute(r.page)) add(r.page);
2270
2542
  }
2271
- if (exists) {
2272
- const current = fs.readFileSync(target, "utf8");
2273
- if (current === content) return "unchanged";
2274
- if (!force) return "skipped-exists";
2543
+ const appPaths = await readJson2(
2544
+ path2__default.default.join(nextDir, "server", "app-paths-manifest.json")
2545
+ );
2546
+ for (const route of Object.keys(appPaths ?? {})) {
2547
+ const normalized = route.replace(/\/page$/, "") || "/";
2548
+ if (isPublicRoute(normalized)) add(normalized);
2275
2549
  }
2276
- await promises.mkdir(path2.dirname(target), { recursive: true });
2277
- await promises.writeFile(target, content, "utf8");
2278
- return exists ? "updated" : "created";
2279
- }
2280
- function combineStatuses(statuses) {
2281
- if (statuses.some((s) => s === "skipped-exists")) return "skipped-exists";
2282
- if (statuses.some((s) => s === "created")) return "created";
2283
- if (statuses.some((s) => s === "updated")) return "updated";
2284
- return "unchanged";
2550
+ return out;
2285
2551
  }
2286
- async function installHarness(harness, cwd, opts = {}) {
2287
- const info = HARNESSES[harness];
2288
- const target = info.target(cwd);
2289
- const files = loadSkillFiles();
2290
- if (info.format === "cursor-rule") {
2291
- const content = renderCursorRule(files);
2292
- const status = await writeIfChanged(target, content, opts.force);
2293
- return { harness, path: target, status };
2294
- }
2295
- const targetDir = path2.dirname(target);
2296
- const statuses = [];
2297
- for (const file of files) {
2298
- const filePath = path2.join(targetDir, file.name);
2299
- const status = await writeIfChanged(
2300
- filePath,
2301
- ensureTrailingNewline(file.content),
2302
- opts.force
2303
- );
2304
- statuses.push(status);
2552
+
2553
+ // src/discover/sitemap.ts
2554
+ var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
2555
+ var LOC_RE = /<loc>([^<]+)<\/loc>/g;
2556
+ async function discoverFromSitemap(baseUrl) {
2557
+ for (const candidate of CANDIDATES) {
2558
+ try {
2559
+ const res = await fetch(new URL(candidate, baseUrl));
2560
+ if (!res.ok) continue;
2561
+ const text = await res.text();
2562
+ const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
2563
+ if (!urls.length) continue;
2564
+ return urls.map((u) => {
2565
+ const url = new URL(u);
2566
+ return { url: url.pathname + url.search, source: "sitemap" };
2567
+ });
2568
+ } catch {
2569
+ }
2305
2570
  }
2306
- return { harness, path: target, status: combineStatuses(statuses) };
2571
+ return [];
2307
2572
  }
2308
2573
 
2309
- // src/cli/commands/onboard.ts
2310
- function suggestOtherHarnesses(installed) {
2311
- const missing = ALL_HARNESSES.filter((h) => !installed.includes(h));
2312
- if (missing.length === 0) return "";
2313
- const labels = missing.map((h) => HARNESSES[h].label).join(" / ");
2314
- const ids = missing.join(",");
2315
- return `
2316
- Also use ${labels}? Run: blazediff-agent onboard --harness ${ids}`;
2574
+ // src/discover/index.ts
2575
+ function normalizePath(url) {
2576
+ const [pathPart, query = ""] = url.split("?", 2);
2577
+ const trimmed = pathPart.replace(/\/+$/, "");
2578
+ const normalizedPath = trimmed === "" ? "/" : trimmed;
2579
+ return query ? `${normalizedPath}?${query}` : normalizedPath;
2317
2580
  }
2318
- async function promptForHarnesses() {
2319
- const rl = promises$1.createInterface({
2320
- input: process.stdin,
2321
- output: process.stderr
2322
- });
2323
- try {
2324
- const lines = [
2325
- "No coding-agent harness detected. Which one(s) do you use?",
2326
- ...ALL_HARNESSES.map(
2327
- (id, i) => ` [${i + 1}] ${HARNESSES[id].label.padEnd(12)} ${HARNESSES[id].target(process.cwd())}`
2328
- ),
2329
- " [a] all three",
2330
- ""
2331
- ];
2332
- process.stderr.write(`${lines.join("\n")}`);
2333
- const answer = (await rl.question("Choice (1/2/3/a): ")).trim().toLowerCase();
2334
- if (!answer) throw new Error("no harness selected; aborting");
2335
- if (answer === "a" || answer === "all") return [...ALL_HARNESSES];
2336
- const idx = Number(answer);
2337
- if (!Number.isInteger(idx) || idx < 1 || idx > ALL_HARNESSES.length) {
2338
- throw new Error(
2339
- `invalid choice "${answer}"; expected 1-${ALL_HARNESSES.length} or "a"`
2581
+ function mergeBy(routes, into) {
2582
+ for (const r of routes) {
2583
+ const key = normalizePath(r.url);
2584
+ if (!into.has(key)) into.set(key, { ...r, url: key });
2585
+ }
2586
+ }
2587
+ async function discover(opts) {
2588
+ const cwd = opts.cwd ?? process.cwd();
2589
+ const merged = /* @__PURE__ */ new Map();
2590
+ mergeBy(await discoverFromNextManifest(cwd), merged);
2591
+ mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
2592
+ if (!opts.skipCrawl) {
2593
+ const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
2594
+ if (crawlMax > 0) {
2595
+ mergeBy(
2596
+ await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
2597
+ merged
2340
2598
  );
2341
2599
  }
2342
- return [ALL_HARNESSES[idx - 1]];
2343
- } finally {
2344
- rl.close();
2345
2600
  }
2601
+ return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
2346
2602
  }
2347
- function humanizeResults(results) {
2348
- const lines = results.map((r) => {
2349
- const verb = r.status === "created" ? "wrote" : r.status === "updated" ? "updated" : r.status === "unchanged" ? "unchanged" : "skipped (exists; pass --force to overwrite)";
2350
- const info = HARNESSES[r.harness];
2351
- const scopeTag = info.scope === "user" ? " [user-global]" : "";
2352
- return ` ${info.label.padEnd(12)} ${verb}: ${r.path}${scopeTag}`;
2603
+
2604
+ // src/cli/commands/discover.ts
2605
+ function registerDiscover(program, out) {
2606
+ program.command("discover").description(
2607
+ "enumerate candidate routes via BFS crawl + Next manifest + sitemap"
2608
+ ).option("--base-url <url>", "override base URL").option("--max-routes <n>", "cap on routes returned", "50").option("--no-crawl", "skip BFS crawl fallback").action(async (opts) => {
2609
+ const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
2610
+ const routes = await discover({
2611
+ baseUrl,
2612
+ maxRoutes: Number(opts.maxRoutes),
2613
+ skipCrawl: !opts.crawl
2614
+ });
2615
+ await closeBrowser();
2616
+ out.emit(
2617
+ { ok: true, baseUrl, routes },
2618
+ routes.length ? routes.map((r) => `${r.source.padEnd(14)} ${r.url}`).join("\n") : "no routes discovered"
2619
+ );
2353
2620
  });
2354
- const installed = results.map((r) => r.harness);
2355
- const hint = suggestOtherHarnesses(installed);
2356
- return ["BlazeDiff playbook installed:", ...lines].join("\n") + hint;
2357
2621
  }
2358
- function registerOnboard(program, out) {
2359
- program.command("onboard").description(
2360
- "install the BlazeDiff playbook into the coding-agent harness in cwd. Auto-detects Claude Code (.claude/), Codex (AGENTS.md), and Cursor (.cursor/). Prompts on TTY when none detected."
2361
- ).option(
2362
- "--harness <list>",
2363
- 'comma-separated harness ids, or "all". valid: claude,codex,cursor,all'
2364
- ).option(
2365
- "--force",
2366
- "overwrite existing playbook files (idempotent without --force only when content matches)"
2367
- ).action(async (opts) => {
2368
- const cwd = process.cwd();
2369
- let targets;
2370
- if (opts.harness) {
2371
- targets = parseHarnessList(opts.harness);
2372
- } else {
2373
- const detected = detectHarnesses(cwd);
2374
- if (detected.length > 0) {
2375
- targets = detected;
2376
- } else if (out.isTTY() && !out.isJson()) {
2377
- targets = await promptForHarnesses();
2378
- } else {
2379
- throw new Error(
2380
- "no coding-agent harness detected in cwd. pass --harness <claude|codex|cursor|all> explicitly."
2381
- );
2382
- }
2383
- }
2384
- const results = [];
2385
- for (const t of targets) {
2386
- results.push(await installHarness(t, cwd, { force: opts.force }));
2387
- }
2388
- out.emit(
2389
- {
2390
- ok: true,
2391
- detected: detectHarnesses(cwd),
2392
- installed: results
2393
- },
2394
- humanizeResults(results)
2395
- );
2396
- });
2622
+
2623
+ // src/introspect/framework.ts
2624
+ var SIGNALS = [
2625
+ ["next", ["next"]],
2626
+ ["remix", ["@remix-run/dev", "@remix-run/serve"]],
2627
+ ["sveltekit", ["@sveltejs/kit"]],
2628
+ ["nuxt", ["nuxt", "nuxt3"]],
2629
+ ["astro", ["astro"]],
2630
+ ["gatsby", ["gatsby"]],
2631
+ ["vite-react", ["vite", "react"]]
2632
+ ];
2633
+ function detectFramework(pkg) {
2634
+ const deps = pkg.allDependencies;
2635
+ for (const [framework, required] of SIGNALS) {
2636
+ if (required.every((d) => d in deps)) return framework;
2637
+ }
2638
+ return "unknown";
2397
2639
  }
2398
- var execFileP = util.promisify(child_process.execFile);
2399
- async function isPortOpen(port, host = "127.0.0.1") {
2400
- return new Promise((resolve) => {
2401
- const socket = net.createConnection({ port, host });
2402
- socket.setTimeout(500);
2403
- socket.once("connect", () => {
2404
- socket.destroy();
2405
- resolve(true);
2406
- });
2407
- socket.once("error", () => resolve(false));
2408
- socket.once("timeout", () => {
2409
- socket.destroy();
2410
- resolve(false);
2411
- });
2412
- });
2640
+ async function readPackageJson(cwd = process.cwd()) {
2641
+ const file = path2__default.default.join(cwd, "package.json");
2642
+ if (!fs.existsSync(file)) return null;
2643
+ return JSON.parse(await promises.readFile(file, "utf8"));
2413
2644
  }
2414
- async function waitForPort(port, timeoutMs = 6e4) {
2415
- const deadline = Date.now() + timeoutMs;
2416
- while (Date.now() < deadline) {
2417
- if (await isPortOpen(port)) return;
2418
- await new Promise((r) => setTimeout(r, 250));
2645
+ function detectPackageManager(cwd = process.cwd()) {
2646
+ let dir = cwd;
2647
+ const { root } = path2__default.default.parse(dir);
2648
+ while (true) {
2649
+ if (fs.existsSync(path2__default.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
2650
+ if (fs.existsSync(path2__default.default.join(dir, "bun.lockb")) || fs.existsSync(path2__default.default.join(dir, "bun.lock")))
2651
+ return "bun";
2652
+ if (fs.existsSync(path2__default.default.join(dir, "yarn.lock"))) return "yarn";
2653
+ if (fs.existsSync(path2__default.default.join(dir, "package-lock.json"))) return "npm";
2654
+ if (dir === root) return "npm";
2655
+ dir = path2__default.default.dirname(dir);
2419
2656
  }
2420
- throw new Error(`dev server did not open port ${port} within ${timeoutMs}ms`);
2421
2657
  }
2422
- async function findPidByPort(port) {
2423
- const platform = process.platform;
2424
- try {
2425
- if (platform === "darwin" || platform === "linux") {
2426
- const { stdout } = await execFileP("lsof", [
2427
- "-ti",
2428
- `tcp:${port}`,
2429
- "-sTCP:LISTEN"
2430
- ]);
2431
- const pid = Number(stdout.trim().split("\n")[0]);
2432
- return Number.isFinite(pid) && pid > 0 ? pid : null;
2433
- }
2434
- if (platform === "win32") {
2435
- const { stdout } = await execFileP("netstat", ["-ano"]);
2436
- const line = stdout.split(/\r?\n/).find((l) => l.includes(`:${port} `) && l.includes("LISTENING"));
2437
- if (!line) return null;
2438
- const parts = line.trim().split(/\s+/);
2439
- const pid = Number(parts[parts.length - 1]);
2440
- return Number.isFinite(pid) && pid > 0 ? pid : null;
2441
- }
2442
- } catch {
2443
- return null;
2658
+ var DEFAULT_PORTS = {
2659
+ next: 3e3,
2660
+ "react-scripts": 3e3,
2661
+ vite: 5173,
2662
+ remix: 3e3,
2663
+ "@remix-run/dev": 3e3,
2664
+ astro: 4321,
2665
+ svelte: 5173,
2666
+ vue: 5173,
2667
+ nuxt: 3e3,
2668
+ gatsby: 8e3,
2669
+ parcel: 1234
2670
+ };
2671
+ var DEV_SCRIPT_CANDIDATES = ["dev", "start", "serve", "develop"];
2672
+ function inferPort(script, deps) {
2673
+ const portArg = script.match(/(?:--port[\s=]|-p\s+)(\d+)/);
2674
+ if (portArg) return Number(portArg[1]);
2675
+ const portEnv = script.match(/PORT[\s=]+(\d+)/);
2676
+ if (portEnv) return Number(portEnv[1]);
2677
+ const depNames = Object.keys(deps);
2678
+ for (const [pkg, port] of Object.entries(DEFAULT_PORTS)) {
2679
+ if (depNames.some((d) => d.startsWith(pkg))) return port;
2444
2680
  }
2445
- return null;
2681
+ return DEFAULT_PORT;
2446
2682
  }
2447
- async function startServer(opts) {
2448
- const cwd = opts.cwd ?? process.cwd();
2449
- const logPath = opts.logPath ?? paths(cwd).serverLog;
2450
- const pidPath = opts.pidPath ?? paths(cwd).serverPid;
2451
- await promises.mkdir(path2__default.default.dirname(logPath), { recursive: true });
2452
- if (await isPortOpen(opts.port)) {
2453
- const discoveredPid = await findPidByPort(opts.port);
2454
- if (discoveredPid) {
2455
- await promises.writeFile(pidPath, String(discoveredPid), "utf8").catch(() => {
2683
+ function runnerFor(pm, scriptName) {
2684
+ if (pm === "npm") return `npm run ${scriptName}`;
2685
+ if (pm === "yarn") return `yarn ${scriptName}`;
2686
+ if (pm === "bun") return `bun run ${scriptName}`;
2687
+ return `pnpm ${scriptName}`;
2688
+ }
2689
+ function collectCandidates(scripts, deps, pm) {
2690
+ const out = [];
2691
+ for (const name of DEV_SCRIPT_CANDIDATES) {
2692
+ if (scripts[name]) {
2693
+ out.push({
2694
+ name,
2695
+ body: scripts[name],
2696
+ command: runnerFor(pm, name),
2697
+ port: inferPort(scripts[name], deps)
2456
2698
  });
2457
2699
  }
2458
- return {
2459
- pid: discoveredPid ?? 0,
2460
- port: opts.port,
2461
- url: `http://127.0.0.1:${opts.port}`,
2462
- attached: true
2463
- };
2464
2700
  }
2465
- const [bin, ...args] = parseCommand(opts.command);
2466
- const child = child_process.spawn(bin, args, {
2467
- cwd,
2468
- stdio: ["ignore", "pipe", "pipe"],
2469
- detached: true,
2470
- env: { ...process.env, FORCE_COLOR: "0", CI: "1" }
2471
- });
2472
- const logStream = await import('fs').then(
2473
- (m) => m.createWriteStream(logPath, { flags: "a" })
2701
+ return out;
2702
+ }
2703
+ async function introspectPackage(cwd = process.cwd()) {
2704
+ const pkg = await readPackageJson(cwd);
2705
+ if (!pkg) return null;
2706
+ const packageManager = detectPackageManager(cwd);
2707
+ const scripts = pkg.scripts ?? {};
2708
+ const devDependencies = pkg.devDependencies ?? {};
2709
+ const dependencies = pkg.dependencies ?? {};
2710
+ const allDependencies = { ...devDependencies, ...dependencies };
2711
+ const candidates = collectCandidates(
2712
+ scripts,
2713
+ allDependencies,
2714
+ packageManager
2474
2715
  );
2475
- child.stdout?.pipe(logStream);
2476
- child.stderr?.pipe(logStream);
2477
- if (!child.pid) throw new Error("failed to spawn dev server");
2478
- await promises.writeFile(pidPath, String(child.pid), "utf8");
2479
- installSignalHandlers(child);
2480
- try {
2481
- await waitForPort(opts.port, opts.readyTimeoutMs ?? 6e4);
2482
- } catch (err) {
2483
- await stopProcess(child.pid);
2484
- throw err;
2485
- }
2716
+ if (!candidates.length) return null;
2717
+ const chosen = candidates[0];
2486
2718
  return {
2487
- pid: child.pid,
2488
- port: opts.port,
2489
- url: `http://127.0.0.1:${opts.port}`
2719
+ packageManager,
2720
+ devScript: chosen.name,
2721
+ devCommand: chosen.command,
2722
+ port: chosen.port,
2723
+ candidates,
2724
+ devDependencies,
2725
+ dependencies,
2726
+ allDependencies
2490
2727
  };
2491
2728
  }
2492
- async function stopServer(cwd = process.cwd(), portFallback) {
2493
- const pidPath = paths(cwd).serverPid;
2494
- let pid = null;
2495
- let via = "none";
2496
- if (fs.existsSync(pidPath)) {
2497
- const raw = (await promises.readFile(pidPath, "utf8")).trim();
2498
- const parsed = Number(raw);
2499
- if (Number.isFinite(parsed) && parsed > 0 && processExists(parsed)) {
2500
- pid = parsed;
2501
- via = "pidfile";
2729
+
2730
+ // src/cli/commands/init.ts
2731
+ async function buildConfig(opts) {
2732
+ if (opts.url) {
2733
+ if (opts.devCommand || opts.port || opts.devScript) {
2734
+ throw new Error(
2735
+ "--url is mutually exclusive with --dev-command/--port/--dev-script"
2736
+ );
2502
2737
  }
2738
+ const baseUrl = new URL(opts.url).toString().replace(/\/$/, "");
2739
+ return { devServer: null, baseUrl };
2503
2740
  }
2504
- if (!pid && portFallback) {
2505
- pid = await findPidByPort(portFallback);
2506
- if (pid) via = "port";
2507
- }
2508
- if (!pid) {
2509
- await promises.writeFile(pidPath, "", "utf8").catch(() => {
2510
- });
2511
- return { killed: false, pid: null, via: "none" };
2741
+ if (opts.devCommand) {
2742
+ const port2 = opts.port ? parsePort(opts.port) : DEFAULT_PORT;
2743
+ return {
2744
+ devServer: {
2745
+ command: opts.devCommand,
2746
+ port: port2,
2747
+ readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
2748
+ },
2749
+ baseUrl: `http://127.0.0.1:${port2}`
2750
+ };
2512
2751
  }
2513
- await stopProcess(pid);
2514
- await promises.writeFile(pidPath, "", "utf8").catch(() => {
2515
- });
2516
- return { killed: true, pid, via };
2517
- }
2518
- function processExists(pid) {
2519
- try {
2520
- process.kill(pid, 0);
2521
- return true;
2522
- } catch {
2523
- return false;
2752
+ const pkg = await introspectPackage();
2753
+ if (!pkg) {
2754
+ throw new Error(
2755
+ "no package.json with a dev/start script in cwd. Pass --url <baseUrl> or --dev-command <cmd>."
2756
+ );
2524
2757
  }
2525
- }
2526
- async function stopProcess(pid) {
2527
- if (!pid) return;
2528
- await new Promise((resolve) => {
2529
- treeKill__default.default(pid, "SIGTERM", (err) => {
2530
- if (err) {
2531
- treeKill__default.default(pid, "SIGKILL", () => resolve());
2532
- return;
2533
- }
2534
- resolve();
2535
- });
2536
- });
2537
- }
2538
- function parseCommand(command) {
2539
- const out = [];
2540
- let current = "";
2541
- let inQuote = false;
2542
- for (const ch of command) {
2543
- if (ch === '"') {
2544
- inQuote = !inQuote;
2545
- continue;
2758
+ let chosen = pkg.candidates[0];
2759
+ if (pkg.candidates.length > 1) {
2760
+ if (!opts.devScript) {
2761
+ const names = pkg.candidates.map((c) => `${c.name} (${c.command})`).join(", ");
2762
+ throw new Error(
2763
+ `multiple dev-script candidates: ${names}. Pass --dev-script <name> or --dev-command <cmd>.`
2764
+ );
2546
2765
  }
2547
- if (ch === " " && !inQuote) {
2548
- if (current) {
2549
- out.push(current);
2550
- current = "";
2551
- }
2552
- continue;
2766
+ const match = pkg.candidates.find((c) => c.name === opts.devScript);
2767
+ if (!match) {
2768
+ const names = pkg.candidates.map((c) => c.name).join(", ");
2769
+ throw new Error(
2770
+ `--dev-script "${opts.devScript}" not found among candidates: ${names}`
2771
+ );
2553
2772
  }
2554
- current += ch;
2773
+ chosen = match;
2555
2774
  }
2556
- if (current) out.push(current);
2557
- return out;
2558
- }
2559
- var signalsInstalled = false;
2560
- function installSignalHandlers(child) {
2561
- if (signalsInstalled) return;
2562
- signalsInstalled = true;
2563
- const cleanup = () => {
2564
- if (child.pid) {
2565
- try {
2566
- process.kill(-child.pid, "SIGTERM");
2567
- } catch {
2568
- }
2569
- treeKill__default.default(child.pid, "SIGKILL", () => {
2570
- });
2571
- }
2775
+ const port = opts.port ? parsePort(opts.port) : chosen.port;
2776
+ return {
2777
+ devServer: {
2778
+ command: chosen.command,
2779
+ port,
2780
+ readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
2781
+ },
2782
+ framework: detectFramework(pkg),
2783
+ packageManager: pkg.packageManager,
2784
+ baseUrl: `http://127.0.0.1:${port}`
2572
2785
  };
2573
- process.on("SIGINT", () => {
2574
- cleanup();
2575
- process.exit(130);
2576
- });
2577
- process.on("SIGTERM", () => {
2578
- cleanup();
2579
- process.exit(143);
2580
- });
2581
- process.on("exit", cleanup);
2582
- }
2583
-
2584
- // src/cli/commands/reset.ts
2585
- async function stopTrackedServer() {
2586
- const config = await loadConfig();
2587
- if (!config?.devServer) return { stopped: false };
2588
- try {
2589
- const result = await stopServer(process.cwd(), config.devServer.port);
2590
- return { stopped: result.killed, via: result.via, pid: result.pid };
2591
- } catch {
2592
- return { stopped: false };
2593
- }
2594
2786
  }
2595
- function registerReset(program, out) {
2596
- program.command("reset").description(
2597
- "wipe .blazediff/ entirely - config, manifest, baselines, actual, judgments, summary, pid/log (stops the dev server first if one is tracked). Re-run /blazediff or `init` afterward to start from scratch."
2598
- ).option("--yes", "do not prompt; required when stdin is a TTY").action(async (opts) => {
2599
- const root = paths().root;
2600
- if (!fs.existsSync(root)) {
2787
+ function registerInit(program, out) {
2788
+ program.command("init").description("write .blazediff/config.json and .gitignore").option("--force", "overwrite existing config").option(
2789
+ "--url <baseUrl>",
2790
+ "point at an already-running server / external URL"
2791
+ ).option("--dev-command <cmd>", "override detected dev-server command").option("--port <n>", "override detected port").option(
2792
+ "--dev-script <name>",
2793
+ "select a dev script by name when multiple candidates exist"
2794
+ ).action(async (opts) => {
2795
+ const existing = await loadConfig();
2796
+ if (existing && !opts.force) {
2797
+ await ensureGitignore(process.cwd());
2601
2798
  out.emit(
2602
- { ok: true, removed: false, root },
2603
- `nothing to reset (no ${root})`
2799
+ {
2800
+ ok: true,
2801
+ created: false,
2802
+ config: existing,
2803
+ configHash: configHash(existing)
2804
+ },
2805
+ `config exists at ${paths().config} (use --force to overwrite)`
2604
2806
  );
2605
2807
  return;
2606
2808
  }
2607
- if (out.isTTY() && !opts.yes && !out.isJson()) {
2608
- throw new Error(
2609
- `refusing to wipe ${root} without --yes (interactive run)`
2610
- );
2809
+ const config = await buildConfig(opts);
2810
+ await saveConfig(config);
2811
+ await ensureGitignore(process.cwd());
2812
+ const human = config.devServer ? `wrote ${paths().config}
2813
+ baseUrl: ${config.baseUrl}
2814
+ dev: ${config.devServer.command} (port ${config.devServer.port})` : `wrote ${paths().config}
2815
+ baseUrl: ${config.baseUrl}
2816
+ external server (no devServer managed)`;
2817
+ out.emit(
2818
+ { ok: true, created: true, config, configHash: configHash(config) },
2819
+ human
2820
+ );
2821
+ });
2822
+ }
2823
+
2824
+ // src/cli/commands/manifest.ts
2825
+ function registerManifest(program, out) {
2826
+ const cmd = program.command("manifest").description("manage .blazediff/manifest.json");
2827
+ cmd.command("add <id>").requiredOption("--url <url>").option("--viewport <WxH>", "viewport", "1280x800").option("--mask <selectors>", "selectors", "").option("--wait-for <list>", "wait list", "networkidle,fonts").option("--no-full-page", "viewport-only (default: full page)").option("--auth <required|none>", "mark auth-gated", "none").option("--created-by <agent|human>", "provenance", "agent").action(async (id, opts) => {
2828
+ const config = await loadConfig();
2829
+ if (!config)
2830
+ throw new Error("no config. Run `blazediff-agent init` first.");
2831
+ const manifest = await loadManifest() ?? emptyManifest(configHash(config));
2832
+ const entry = makeEntry({
2833
+ id,
2834
+ url: opts.url,
2835
+ viewport: parseViewport(opts.viewport),
2836
+ mask: parseMaskList(opts.mask),
2837
+ waitFor: parseWaitFor(opts.waitFor),
2838
+ fullPage: opts.fullPage,
2839
+ auth: opts.auth === "required" ? "required" : null,
2840
+ createdBy: opts.createdBy
2841
+ });
2842
+ await saveManifest(addOrReplaceEntry(manifest, entry));
2843
+ out.emit(
2844
+ { ok: true, entry },
2845
+ out.isTTY() ? `manifest: added ${id} (${entry.url})` : "."
2846
+ );
2847
+ });
2848
+ cmd.command("remove <id>").action(async (id) => {
2849
+ const manifest = await loadManifest();
2850
+ if (!manifest) throw new Error("no manifest");
2851
+ await saveManifest(removeEntry(manifest, id));
2852
+ out.emit({ ok: true, removed: id }, `manifest: removed ${id}`);
2853
+ });
2854
+ cmd.command("list").action(async () => {
2855
+ const manifest = await loadManifest();
2856
+ if (!manifest) {
2857
+ out.emit({ entries: [] }, "no manifest");
2858
+ return;
2611
2859
  }
2612
- const stopOutcome = await stopTrackedServer();
2613
- await promises.rm(root, { recursive: true, force: true });
2614
2860
  out.emit(
2615
- { ok: true, removed: true, root, devServer: stopOutcome },
2616
- stopOutcome.stopped ? `stopped dev server (pid ${stopOutcome.pid} via ${stopOutcome.via}) and removed ${root}` : `removed ${root}`
2861
+ { entries: manifest.entries },
2862
+ manifest.entries.map((e) => `${e.id.padEnd(30)} ${e.url}`).join("\n") || "no entries"
2617
2863
  );
2618
2864
  });
2619
2865
  }
2620
- async function resolveTargets(manifest, ids, opts) {
2621
- const exclusive = [
2622
- ids.length > 0,
2623
- Boolean(opts.failed),
2624
- Boolean(opts.all)
2625
- ].filter(Boolean).length;
2626
- if (exclusive === 0) throw new Error("provide ids, --failed, or --all");
2627
- if (exclusive > 1)
2628
- throw new Error("ids / --failed / --all are mutually exclusive");
2629
- if (opts.all) return new Set(manifest.entries.map((e) => e.id));
2630
- if (opts.failed) {
2631
- const judgmentsDir = paths().judgments;
2632
- if (!fs.existsSync(judgmentsDir)) {
2866
+ var someExists = (paths2) => paths2.some((p) => fs.existsSync(p));
2867
+ var HARNESSES = {
2868
+ claude: {
2869
+ id: "claude",
2870
+ label: "Claude Code",
2871
+ detect: (cwd) => someExists([
2872
+ path2.join(cwd, ".claude"),
2873
+ path2.join(cwd, "CLAUDE.md"),
2874
+ path2.join(cwd, "AGENTS.md")
2875
+ ]),
2876
+ target: (cwd) => path2.join(cwd, ".claude", "skills", "blazediff", "SKILL.md"),
2877
+ format: "skill-file",
2878
+ scope: "project"
2879
+ },
2880
+ codex: {
2881
+ id: "codex",
2882
+ label: "Codex",
2883
+ detect: (cwd) => someExists([
2884
+ path2.join(cwd, "AGENTS.md"),
2885
+ path2.join(cwd, ".codex"),
2886
+ path2.join(os.homedir(), ".codex")
2887
+ ]),
2888
+ target: () => path2.join(os.homedir(), ".codex", "skills", "blazediff", "SKILL.md"),
2889
+ format: "skill-file",
2890
+ scope: "user"
2891
+ },
2892
+ cursor: {
2893
+ id: "cursor",
2894
+ label: "Cursor",
2895
+ detect: (cwd) => someExists([path2.join(cwd, ".cursor"), path2.join(cwd, ".cursorrules")]),
2896
+ target: (cwd) => path2.join(cwd, ".cursor", "rules", "blazediff.mdc"),
2897
+ format: "cursor-rule",
2898
+ scope: "project"
2899
+ }
2900
+ };
2901
+ var ALL_HARNESSES = ["claude", "codex", "cursor"];
2902
+ function detectHarnesses(cwd) {
2903
+ return ALL_HARNESSES.filter((id) => HARNESSES[id].detect(cwd));
2904
+ }
2905
+ function parseHarnessList(input) {
2906
+ const tokens = input.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
2907
+ if (tokens.includes("all")) return [...ALL_HARNESSES];
2908
+ const out = [];
2909
+ for (const t of tokens) {
2910
+ if (!(t in HARNESSES)) {
2633
2911
  throw new Error(
2634
- `no judgments at ${judgmentsDir}. Run \`blazediff-agent check\` first.`
2912
+ `unknown harness "${t}". valid: ${[...ALL_HARNESSES, "all"].join(", ")}`
2635
2913
  );
2636
2914
  }
2637
- const names = await promises.readdir(judgmentsDir);
2638
- const failed = /* @__PURE__ */ new Set();
2639
- for (const name of names) {
2640
- const full = path2__default.default.join(judgmentsDir, name);
2641
- if (fs.statSync(full).isDirectory()) failed.add(name);
2915
+ if (!out.includes(t)) out.push(t);
2916
+ }
2917
+ return out;
2918
+ }
2919
+ var SKILL_FILES = ["SKILL.md", "JUDGING.md", "MASKING.md"];
2920
+ var cachedDir = null;
2921
+ var cachedFiles = null;
2922
+ function moduleDir() {
2923
+ return path2.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href))));
2924
+ }
2925
+ function resolveSkillDir() {
2926
+ if (cachedDir !== null) return cachedDir;
2927
+ const here = moduleDir();
2928
+ const candidates = [
2929
+ path2.join(here, ".."),
2930
+ path2.join(here, "..", ".."),
2931
+ path2.join(here, "..", "..", "..", "skill", "blazediff"),
2932
+ path2.join(here, "..", "..", "..", "..", "skill", "blazediff")
2933
+ ];
2934
+ for (const dir of candidates) {
2935
+ if (fs.existsSync(path2.join(dir, "SKILL.md"))) {
2936
+ cachedDir = dir;
2937
+ return cachedDir;
2642
2938
  }
2643
- return failed;
2644
2939
  }
2645
- const targets = new Set(ids);
2646
- const missing = ids.filter(
2647
- (id) => !manifest.entries.some((e) => e.id === id)
2940
+ throw new Error(
2941
+ `could not locate bundled SKILL.md (looked in: ${candidates.join(", ")}). reinstall @blazediff/agent.`
2648
2942
  );
2649
- if (missing.length) throw new Error(`unknown ids: ${missing.join(", ")}`);
2650
- return targets;
2651
2943
  }
2652
- function registerRewrite(program, out) {
2653
- program.command("rewrite [ids...]").description(
2654
- "rewrite baselines for existing manifest entries, preserving mask/viewport/etc. Pick targets via positional ids, --failed (uses .blazediff/judgments/ from last check), or --all."
2655
- ).option("--failed", "rewrite entries that failed the most recent check").option("--all", "rewrite every manifest entry").option("--base-url <url>", "override base URL").action(async (ids, opts) => {
2656
- const manifest = await loadManifest();
2657
- if (!manifest) throw new Error("no manifest. Run authoring first.");
2658
- const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
2659
- const targets = await resolveTargets(manifest, ids, opts);
2660
- if (targets.size === 0 && opts.failed) {
2661
- out.emit({ ok: true, rewritten: 0 }, "no failed entries to rewrite");
2662
- return;
2944
+ function loadSkillFiles() {
2945
+ if (cachedFiles !== null) return cachedFiles;
2946
+ const dir = resolveSkillDir();
2947
+ cachedFiles = SKILL_FILES.filter((name) => fs.existsSync(path2.join(dir, name))).map(
2948
+ (name) => ({ name, content: fs.readFileSync(path2.join(dir, name), "utf8") })
2949
+ );
2950
+ return cachedFiles;
2951
+ }
2952
+ function skillBodyOnly(content) {
2953
+ const lines = content.split("\n");
2954
+ if (lines[0]?.startsWith("---")) {
2955
+ let end = -1;
2956
+ for (let i = 1; i < lines.length; i++) {
2957
+ if (lines[i]?.startsWith("---")) {
2958
+ end = i;
2959
+ break;
2960
+ }
2663
2961
  }
2664
- const routes = manifest.entries.filter((e) => targets.has(e.id)).map((e) => ({
2665
- id: e.id,
2666
- url: e.url,
2667
- mask: e.mask,
2668
- viewport: e.viewport,
2669
- waitFor: e.waitFor,
2670
- fullPage: e.fullPage
2671
- }));
2672
- const report = await runCaptures({
2673
- baseUrl,
2674
- routes,
2675
- mode: "baseline",
2676
- writeManifest: true
2677
- });
2678
- const failureLines3 = report.results.filter((r) => !r.ok).map((r) => ` \u2717 ${r.id}: ${r.error ?? "failed"}`);
2679
- const human = report.failed === 0 ? `rewrote ${report.succeeded}/${report.total} baseline${report.total === 1 ? "" : "s"}` : [
2680
- `rewrote ${report.succeeded}/${report.total} (${report.failed} failed):`,
2681
- ...failureLines3
2682
- ].join("\n");
2683
- out.emit(report, human);
2684
- if (report.failed > 0) process.exitCode = 1;
2685
- });
2962
+ if (end > 0)
2963
+ return lines.slice(end + 1).join("\n").trimStart();
2964
+ }
2965
+ return content;
2686
2966
  }
2687
2967
 
2688
- // src/graph/nodes/aggregate.ts
2689
- async function aggregateNode(state) {
2690
- const options = state.options;
2691
- if (!options) throw new Error("aggregateNode: options missing");
2692
- const manifest = state.manifest;
2693
- if (!manifest) throw new Error("aggregateNode: manifest missing");
2694
- const results = state.results;
2695
- const passed = results.filter((r) => r.status === "pass").length;
2696
- const pendingJudgments = results.filter(
2697
- (r) => r.status === "needs-judgment"
2698
- ).length;
2699
- const report = {
2700
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2701
- totalEntries: results.length,
2702
- passed,
2703
- failed: results.length - passed - pendingJudgments,
2704
- pendingJudgments,
2705
- results
2706
- };
2707
- await writeJudgments({ report, manifest, cwd: options.cwd });
2708
- await writeSummaryMarkdown(report, options.cwd);
2709
- await ensureGitignore(options.cwd);
2710
- return { report };
2968
+ // src/onboard/install.ts
2969
+ function ensureTrailingNewline(s) {
2970
+ return s.endsWith("\n") ? s : `${s}
2971
+ `;
2711
2972
  }
2973
+ function renderCursorRule(files) {
2974
+ const skill = files.find((f) => f.name === "SKILL.md")?.content ?? "";
2975
+ const sidecars = files.filter((f) => f.name !== "SKILL.md");
2976
+ const body = skillBodyOnly(skill).trim();
2977
+ const frontmatter = [
2978
+ "---",
2979
+ 'description: "Run, author, or update BlazeDiff visual regression tests. Trigger on visual test, screenshot regression, blazediff, /blazediff."',
2980
+ "alwaysApply: false",
2981
+ "---",
2982
+ ""
2983
+ ].join("\n");
2984
+ const sidecarBlocks = sidecars.map((f) => `
2712
2985
 
2713
- // src/graph/nodes/load.ts
2714
- async function loadNode(state) {
2715
- if (!state.options) {
2716
- throw new Error("loadNode: graph options missing");
2986
+ ---
2987
+
2988
+ <!-- ${f.name} -->
2989
+
2990
+ ${f.content.trim()}`).join("");
2991
+ return `${frontmatter}${body}${sidecarBlocks}
2992
+ `;
2993
+ }
2994
+ async function writeIfChanged(target, content, force) {
2995
+ const stat2 = await promises.lstat(target).catch(() => null);
2996
+ const isSymlink = stat2?.isSymbolicLink() ?? false;
2997
+ const exists = stat2 !== null;
2998
+ if (isSymlink) {
2999
+ await promises.unlink(target);
3000
+ await promises.mkdir(path2.dirname(target), { recursive: true });
3001
+ await promises.writeFile(target, content, "utf8");
3002
+ return "updated";
2717
3003
  }
2718
- const manifest = await loadManifest(state.options.cwd);
2719
- if (!manifest) {
2720
- throw new Error(
2721
- `no manifest found at ${paths(state.options.cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
2722
- );
3004
+ if (exists) {
3005
+ const current = fs.readFileSync(target, "utf8");
3006
+ if (current === content) return "unchanged";
3007
+ if (!force) return "skipped-exists";
2723
3008
  }
2724
- return { entries: manifest.entries, manifest };
2725
- }
2726
- function narrowRegion2(r) {
2727
- return {
2728
- bbox: r.bbox,
2729
- pixelCount: r.pixelCount,
2730
- percentage: r.percentage,
2731
- changeType: r.changeType,
2732
- confidence: r.confidence
2733
- };
3009
+ await promises.mkdir(path2.dirname(target), { recursive: true });
3010
+ await promises.writeFile(target, content, "utf8");
3011
+ return exists ? "updated" : "created";
2734
3012
  }
2735
- function skipResult2(entry, message) {
2736
- return { id: entry.id, url: entry.url, status: "pass", message };
3013
+ function combineStatuses(statuses) {
3014
+ if (statuses.some((s) => s === "skipped-exists")) return "skipped-exists";
3015
+ if (statuses.some((s) => s === "created")) return "created";
3016
+ if (statuses.some((s) => s === "updated")) return "updated";
3017
+ return "unchanged";
2737
3018
  }
2738
- function staleResult2(entry) {
2739
- return {
2740
- id: entry.id,
2741
- url: entry.url,
2742
- status: "stale-baseline",
2743
- message: "captureHash mismatch: entry was edited without re-capturing"
2744
- };
3019
+ async function installHarness(harness, cwd, opts = {}) {
3020
+ const info = HARNESSES[harness];
3021
+ const target = info.target(cwd);
3022
+ const files = loadSkillFiles();
3023
+ if (info.format === "cursor-rule") {
3024
+ const content = renderCursorRule(files);
3025
+ const status = await writeIfChanged(target, content, opts.force);
3026
+ return { harness, path: target, status };
3027
+ }
3028
+ const targetDir = path2.dirname(target);
3029
+ const statuses = [];
3030
+ for (const file of files) {
3031
+ const filePath = path2.join(targetDir, file.name);
3032
+ const status = await writeIfChanged(
3033
+ filePath,
3034
+ ensureTrailingNewline(file.content),
3035
+ opts.force
3036
+ );
3037
+ statuses.push(status);
3038
+ }
3039
+ return { harness, path: target, status: combineStatuses(statuses) };
2745
3040
  }
2746
- function passResult3(entry, baselinePath, actualPath) {
2747
- return {
2748
- id: entry.id,
2749
- url: entry.url,
2750
- status: "pass",
2751
- baselinePath,
2752
- actualPath
2753
- };
3041
+
3042
+ // src/cli/commands/onboard.ts
3043
+ function suggestOtherHarnesses(installed) {
3044
+ const missing = ALL_HARNESSES.filter((h) => !installed.includes(h));
3045
+ if (missing.length === 0) return "";
3046
+ const labels = missing.map((h) => HARNESSES[h].label).join(" / ");
3047
+ const ids = missing.join(",");
3048
+ return `
3049
+ Also use ${labels}? Run: blazediff-agent onboard --harness ${ids}`;
2754
3050
  }
2755
- function missingBaselineResult2(entry, baselinePath) {
2756
- return {
2757
- id: entry.id,
2758
- url: entry.url,
2759
- status: "missing-baseline",
2760
- message: `baseline missing at ${baselinePath}`
2761
- };
3051
+ async function promptForHarnesses() {
3052
+ const rl = promises$1.createInterface({
3053
+ input: process.stdin,
3054
+ output: process.stderr
3055
+ });
3056
+ try {
3057
+ const lines = [
3058
+ "No coding-agent harness detected. Which one(s) do you use?",
3059
+ ...ALL_HARNESSES.map(
3060
+ (id, i) => ` [${i + 1}] ${HARNESSES[id].label.padEnd(12)} ${HARNESSES[id].target(process.cwd())}`
3061
+ ),
3062
+ " [a] all three",
3063
+ ""
3064
+ ];
3065
+ process.stderr.write(`${lines.join("\n")}`);
3066
+ const answer = (await rl.question("Choice (1/2/3/a): ")).trim().toLowerCase();
3067
+ if (!answer) throw new Error("no harness selected; aborting");
3068
+ if (answer === "a" || answer === "all") return [...ALL_HARNESSES];
3069
+ const idx = Number(answer);
3070
+ if (!Number.isInteger(idx) || idx < 1 || idx > ALL_HARNESSES.length) {
3071
+ throw new Error(
3072
+ `invalid choice "${answer}"; expected 1-${ALL_HARNESSES.length} or "a"`
3073
+ );
3074
+ }
3075
+ return [ALL_HARNESSES[idx - 1]];
3076
+ } finally {
3077
+ rl.close();
3078
+ }
2762
3079
  }
2763
- function failResult2(entry, outcome, actualPath, baselinePath, verdict) {
2764
- return {
2765
- id: entry.id,
2766
- url: entry.url,
2767
- status: "fail",
2768
- diffCount: outcome.diffCount,
2769
- diffPercentage: outcome.diffPercentage,
2770
- severity: outcome.interpretation?.severity,
2771
- regions: outcome.interpretation?.regions?.map(narrowRegion2),
2772
- verdict,
2773
- diffPath: outcome.diffPath,
2774
- baselinePath,
2775
- actualPath,
2776
- message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
2777
- };
3080
+ function humanizeResults(results) {
3081
+ const lines = results.map((r) => {
3082
+ const verb = r.status === "created" ? "wrote" : r.status === "updated" ? "updated" : r.status === "unchanged" ? "unchanged" : "skipped (exists; pass --force to overwrite)";
3083
+ const info = HARNESSES[r.harness];
3084
+ const scopeTag = info.scope === "user" ? " [user-global]" : "";
3085
+ return ` ${info.label.padEnd(12)} ${verb}: ${r.path}${scopeTag}`;
3086
+ });
3087
+ const installed = results.map((r) => r.harness);
3088
+ const hint = suggestOtherHarnesses(installed);
3089
+ return ["BlazeDiff playbook installed:", ...lines].join("\n") + hint;
2778
3090
  }
2779
- function makeProcessNode(semaphore) {
2780
- return async function processNode(state) {
2781
- const entry = state.entry;
2782
- const options = state.options;
2783
- if (!entry || !options) {
2784
- throw new Error("processNode: entry or options missing");
2785
- }
2786
- if (entry.auth === "required") {
2787
- return {
2788
- results: [
2789
- skipResult2(entry, "skipped: auth required (deferred to v0.2)")
2790
- ]
2791
- };
3091
+ function registerOnboard(program, out) {
3092
+ program.command("onboard").description(
3093
+ "install the BlazeDiff playbook into the coding-agent harness in cwd. Auto-detects Claude Code (.claude/), Codex (AGENTS.md), and Cursor (.cursor/). Prompts on TTY when none detected."
3094
+ ).option(
3095
+ "--harness <list>",
3096
+ 'comma-separated harness ids, or "all". valid: claude,codex,cursor,all'
3097
+ ).option(
3098
+ "--force",
3099
+ "overwrite existing playbook files (idempotent without --force only when content matches)"
3100
+ ).action(async (opts) => {
3101
+ const cwd = process.cwd();
3102
+ let targets;
3103
+ if (opts.harness) {
3104
+ targets = parseHarnessList(opts.harness);
3105
+ } else {
3106
+ const detected = detectHarnesses(cwd);
3107
+ if (detected.length > 0) {
3108
+ targets = detected;
3109
+ } else if (out.isTTY() && !out.isJson()) {
3110
+ targets = await promptForHarnesses();
3111
+ } else {
3112
+ throw new Error(
3113
+ "no coding-agent harness detected in cwd. pass --harness <claude|codex|cursor|all> explicitly."
3114
+ );
3115
+ }
2792
3116
  }
2793
- if (isEntryStale(entry)) {
2794
- return { results: [staleResult2(entry)] };
3117
+ const results = [];
3118
+ for (const t of targets) {
3119
+ results.push(await installHarness(t, cwd, { force: opts.force }));
2795
3120
  }
2796
- const capture = await semaphore.run(
2797
- () => captureScreenshot(
2798
- options.baseUrl,
2799
- {
2800
- id: entry.id,
2801
- url: entry.url,
2802
- viewport: entry.viewport,
2803
- mask: entry.mask,
2804
- waitFor: entry.waitFor,
2805
- fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
2806
- mode: "actual"
2807
- },
2808
- options.cwd
2809
- )
2810
- );
2811
- const baselinePath = path2__default.default.join(options.baselinesDir, `${entry.id}.png`);
2812
- const outcome = await diffEntry(
2813
- entry.id,
2814
- baselinePath,
2815
- capture.outputPath,
2816
- { threshold: options.threshold, emitDiffPng: options.emitDiffPng },
2817
- options.cwd
3121
+ out.emit(
3122
+ {
3123
+ ok: true,
3124
+ detected: detectHarnesses(cwd),
3125
+ installed: results
3126
+ },
3127
+ humanizeResults(results)
2818
3128
  );
2819
- if (outcome.match) {
2820
- return { results: [passResult3(entry, baselinePath, capture.outputPath)] };
2821
- }
2822
- if (outcome.reason === "file-not-exists") {
2823
- return { results: [missingBaselineResult2(entry, baselinePath)] };
2824
- }
2825
- const verdict = deriveVerdict({
2826
- reason: outcome.reason,
2827
- interpretation: outcome.interpretation,
2828
- diffCount: outcome.diffCount,
2829
- diffPercentage: outcome.diffPercentage
3129
+ });
3130
+ }
3131
+ var execFileP = util.promisify(child_process.execFile);
3132
+ async function isPortOpen(port, host = "127.0.0.1") {
3133
+ return new Promise((resolve) => {
3134
+ const socket = net.createConnection({ port, host });
3135
+ socket.setTimeout(500);
3136
+ socket.once("connect", () => {
3137
+ socket.destroy();
3138
+ resolve(true);
2830
3139
  });
2831
- let result = failResult2(
2832
- entry,
2833
- outcome,
2834
- capture.outputPath,
2835
- baselinePath,
2836
- verdict
2837
- );
2838
- if (result.verdict?.label === "ambiguous" && result.baselinePath && result.actualPath) {
2839
- const judge = resolveJudge(options.judge);
2840
- const output = await judge.judge(
2841
- {
2842
- entry,
2843
- baselinePath: result.baselinePath,
2844
- actualPath: result.actualPath,
2845
- diffPath: result.diffPath,
2846
- regions: result.regions,
2847
- diffPercentage: result.diffPercentage,
2848
- severity: result.severity,
2849
- heuristicVerdict: result.verdict
2850
- },
2851
- options.cwd
2852
- );
2853
- if (output.kind === "judged") {
2854
- result = { ...result, verdict: output.verdict };
2855
- } else {
2856
- result = {
2857
- ...result,
2858
- status: "needs-judgment",
2859
- message: `awaiting judgment in ${output.requestPath}`
2860
- };
2861
- }
2862
- }
2863
- return { results: [result] };
2864
- };
3140
+ socket.once("error", () => resolve(false));
3141
+ socket.once("timeout", () => {
3142
+ socket.destroy();
3143
+ resolve(false);
3144
+ });
3145
+ });
2865
3146
  }
2866
-
2867
- // src/graph/semaphore.ts
2868
- var Semaphore = class {
2869
- constructor(limit) {
2870
- this.limit = limit;
2871
- this.current = 0;
2872
- this.queue = [];
2873
- if (limit < 1)
2874
- throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
3147
+ async function waitForPort(port, timeoutMs = 6e4) {
3148
+ const deadline = Date.now() + timeoutMs;
3149
+ while (Date.now() < deadline) {
3150
+ if (await isPortOpen(port)) return;
3151
+ await new Promise((r) => setTimeout(r, 250));
2875
3152
  }
2876
- async run(fn) {
2877
- if (this.current >= this.limit) {
2878
- await new Promise((resolve) => this.queue.push(resolve));
3153
+ throw new Error(`dev server did not open port ${port} within ${timeoutMs}ms`);
3154
+ }
3155
+ async function findPidByPort(port) {
3156
+ const platform = process.platform;
3157
+ try {
3158
+ if (platform === "darwin" || platform === "linux") {
3159
+ const { stdout } = await execFileP("lsof", [
3160
+ "-ti",
3161
+ `tcp:${port}`,
3162
+ "-sTCP:LISTEN"
3163
+ ]);
3164
+ const pid = Number(stdout.trim().split("\n")[0]);
3165
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
2879
3166
  }
2880
- this.current++;
2881
- try {
2882
- return await fn();
2883
- } finally {
2884
- this.current--;
2885
- const next = this.queue.shift();
2886
- if (next) next();
3167
+ if (platform === "win32") {
3168
+ const { stdout } = await execFileP("netstat", ["-ano"]);
3169
+ const line = stdout.split(/\r?\n/).find((l) => l.includes(`:${port} `) && l.includes("LISTENING"));
3170
+ if (!line) return null;
3171
+ const parts = line.trim().split(/\s+/);
3172
+ const pid = Number(parts[parts.length - 1]);
3173
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
3174
+ }
3175
+ } catch {
3176
+ return null;
3177
+ }
3178
+ return null;
3179
+ }
3180
+ async function startServer(opts) {
3181
+ const cwd = opts.cwd ?? process.cwd();
3182
+ const logPath = opts.logPath ?? paths(cwd).serverLog;
3183
+ const pidPath = opts.pidPath ?? paths(cwd).serverPid;
3184
+ await promises.mkdir(path2__default.default.dirname(logPath), { recursive: true });
3185
+ if (await isPortOpen(opts.port)) {
3186
+ const discoveredPid = await findPidByPort(opts.port);
3187
+ if (discoveredPid) {
3188
+ await promises.writeFile(pidPath, String(discoveredPid), "utf8").catch(() => {
3189
+ });
3190
+ }
3191
+ return {
3192
+ pid: discoveredPid ?? 0,
3193
+ port: opts.port,
3194
+ url: `http://127.0.0.1:${opts.port}`,
3195
+ attached: true
3196
+ };
3197
+ }
3198
+ const child = child_process.spawn(opts.command, {
3199
+ cwd,
3200
+ stdio: ["ignore", "pipe", "pipe"],
3201
+ detached: true,
3202
+ shell: true,
3203
+ env: { ...process.env, FORCE_COLOR: "0", CI: "1" }
3204
+ });
3205
+ const logStream = await import('fs').then(
3206
+ (m) => m.createWriteStream(logPath, { flags: "a" })
3207
+ );
3208
+ child.stdout?.pipe(logStream);
3209
+ child.stderr?.pipe(logStream);
3210
+ if (!child.pid) throw new Error("failed to spawn dev server");
3211
+ await promises.writeFile(pidPath, String(child.pid), "utf8");
3212
+ installSignalHandlers(child);
3213
+ try {
3214
+ await waitForPort(opts.port, opts.readyTimeoutMs ?? 6e4);
3215
+ } catch (err) {
3216
+ await stopProcess(child.pid);
3217
+ throw err;
3218
+ }
3219
+ return {
3220
+ pid: child.pid,
3221
+ port: opts.port,
3222
+ url: `http://127.0.0.1:${opts.port}`
3223
+ };
3224
+ }
3225
+ async function stopServer(cwd = process.cwd(), portFallback) {
3226
+ const pidPath = paths(cwd).serverPid;
3227
+ let pid = null;
3228
+ let via = "none";
3229
+ if (fs.existsSync(pidPath)) {
3230
+ const raw = (await promises.readFile(pidPath, "utf8")).trim();
3231
+ const parsed = Number(raw);
3232
+ if (Number.isFinite(parsed) && parsed > 0 && processExists(parsed)) {
3233
+ pid = parsed;
3234
+ via = "pidfile";
2887
3235
  }
2888
3236
  }
2889
- };
2890
- var GraphState = langgraph.Annotation.Root({
2891
- options: langgraph.Annotation({
2892
- reducer: (acc, next) => next ?? acc,
2893
- default: () => void 0
2894
- }),
2895
- entries: langgraph.Annotation({
2896
- reducer: (acc, next) => next ?? acc,
2897
- default: () => []
2898
- }),
2899
- entry: langgraph.Annotation({
2900
- reducer: (_, next) => next,
2901
- default: () => void 0
2902
- }),
2903
- results: langgraph.Annotation({
2904
- reducer: (acc, next) => [...acc, ...next],
2905
- default: () => []
2906
- }),
2907
- manifest: langgraph.Annotation({
2908
- reducer: (acc, next) => next ?? acc,
2909
- default: () => void 0
2910
- }),
2911
- report: langgraph.Annotation({
2912
- reducer: (acc, next) => next ?? acc,
2913
- default: () => void 0
2914
- })
2915
- });
2916
-
2917
- // src/graph/index.ts
2918
- function buildGraph(semaphore) {
2919
- return new langgraph.StateGraph(GraphState).addNode("load", loadNode).addNode("process", makeProcessNode(semaphore)).addNode("aggregate", aggregateNode).addEdge(langgraph.START, "load").addConditionalEdges(
2920
- "load",
2921
- (state) => state.entries.map(
2922
- (entry) => new langgraph.Send("process", { entry, options: state.options })
2923
- ),
2924
- ["process"]
2925
- ).addEdge("process", "aggregate").addEdge("aggregate", langgraph.END).compile();
3237
+ if (!pid && portFallback) {
3238
+ pid = await findPidByPort(portFallback);
3239
+ if (pid) via = "port";
3240
+ }
3241
+ if (!pid) {
3242
+ await promises.writeFile(pidPath, "", "utf8").catch(() => {
3243
+ });
3244
+ return { killed: false, pid: null, via: "none" };
3245
+ }
3246
+ await stopProcess(pid);
3247
+ await promises.writeFile(pidPath, "", "utf8").catch(() => {
3248
+ });
3249
+ return { killed: true, pid, via };
2926
3250
  }
2927
- async function runGraph(opts) {
2928
- const cwd = opts.cwd ?? process.cwd();
2929
- const concurrency = opts.concurrency ?? defaultConcurrency();
2930
- const semaphore = new Semaphore(concurrency);
2931
- const baselinesDir = paths(cwd).baselines;
2932
- const graph = buildGraph(semaphore);
2933
- let finalState;
3251
+ function processExists(pid) {
2934
3252
  try {
2935
- finalState = await graph.invoke({
2936
- options: {
2937
- baseUrl: opts.baseUrl,
2938
- cwd,
2939
- threshold: opts.threshold,
2940
- concurrency,
2941
- emitDiffPng: opts.emitDiffPng ?? true,
2942
- judge: opts.judge ?? "none",
2943
- baselinesDir
3253
+ process.kill(pid, 0);
3254
+ return true;
3255
+ } catch {
3256
+ return false;
3257
+ }
3258
+ }
3259
+ async function stopProcess(pid) {
3260
+ if (!pid) return;
3261
+ await new Promise((resolve) => {
3262
+ treeKill__default.default(pid, "SIGTERM", (err) => {
3263
+ if (err) {
3264
+ treeKill__default.default(pid, "SIGKILL", () => resolve());
3265
+ return;
2944
3266
  }
3267
+ resolve();
2945
3268
  });
2946
- } finally {
2947
- await closeBrowser();
2948
- }
2949
- const report = finalState.report;
2950
- if (!report) {
2951
- throw new Error("runGraph: graph completed without producing a report");
2952
- }
2953
- if (opts.junitPath) {
2954
- const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
2955
- await writeJunit(report, target);
2956
- }
2957
- return report;
3269
+ });
2958
3270
  }
2959
-
2960
- // src/cli/commands/run.ts
2961
- function slimResult2(r) {
2962
- return {
2963
- id: r.id,
2964
- url: r.url,
2965
- status: r.status,
2966
- verdict: r.verdict ? {
2967
- label: r.verdict.label,
2968
- headline: r.verdict.headline,
2969
- action: r.verdict.action
2970
- } : void 0
3271
+ var signalsInstalled = false;
3272
+ function installSignalHandlers(child) {
3273
+ if (signalsInstalled) return;
3274
+ signalsInstalled = true;
3275
+ const cleanup = () => {
3276
+ if (child.pid) {
3277
+ try {
3278
+ process.kill(-child.pid, "SIGTERM");
3279
+ } catch {
3280
+ }
3281
+ treeKill__default.default(child.pid, "SIGKILL", () => {
3282
+ });
3283
+ }
2971
3284
  };
3285
+ process.on("SIGINT", () => {
3286
+ cleanup();
3287
+ process.exit(130);
3288
+ });
3289
+ process.on("SIGTERM", () => {
3290
+ cleanup();
3291
+ process.exit(143);
3292
+ });
3293
+ process.on("exit", cleanup);
2972
3294
  }
2973
- function slimReport2(report, summaryPath) {
2974
- return {
2975
- summaryPath,
2976
- createdAt: report.createdAt,
2977
- totalEntries: report.totalEntries,
2978
- passed: report.passed,
2979
- failed: report.failed,
2980
- pendingJudgments: report.pendingJudgments,
2981
- results: report.results.filter((r) => r.status !== "pass").map(slimResult2)
2982
- };
3295
+
3296
+ // src/cli/commands/reset.ts
3297
+ async function stopTrackedServer() {
3298
+ const config = await loadConfig();
3299
+ if (!config?.devServer) return { stopped: false };
3300
+ try {
3301
+ const result = await stopServer(process.cwd(), config.devServer.port);
3302
+ return { stopped: result.killed, via: result.via, pid: result.pid };
3303
+ } catch {
3304
+ return { stopped: false };
3305
+ }
2983
3306
  }
2984
- function failureLines2(results) {
2985
- return results.filter((r) => r.status !== "pass").flatMap((r) => {
2986
- const lines = [];
2987
- const prefix = r.status === "needs-judgment" ? "?" : "\u2717";
2988
- if (r.verdict) {
2989
- lines.push(
2990
- ` ${prefix} ${r.id} [${r.verdict.label}] ${r.verdict.headline}`
3307
+ function registerReset(program, out) {
3308
+ program.command("reset").description(
3309
+ "wipe .blazediff/ entirely - config, manifest, baselines, actual, judgments, summary, pid/log (stops the dev server first if one is tracked). Re-run /blazediff or `init` afterward to start from scratch."
3310
+ ).option("--yes", "do not prompt; required when stdin is a TTY").action(async (opts) => {
3311
+ const root = paths().root;
3312
+ if (!fs.existsSync(root)) {
3313
+ out.emit(
3314
+ { ok: true, removed: false, root },
3315
+ `nothing to reset (no ${root})`
2991
3316
  );
2992
- lines.push(` \u2192 ${r.verdict.action}`);
2993
- } else {
2994
- const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
2995
- lines.push(` ${prefix} ${r.id}: ${detail}`);
3317
+ return;
2996
3318
  }
2997
- if (r.status === "needs-judgment" && r.message) {
2998
- lines.push(` ${r.message}`);
3319
+ if (out.isTTY() && !opts.yes && !out.isJson()) {
3320
+ throw new Error(
3321
+ `refusing to wipe ${root} without --yes (interactive run)`
3322
+ );
2999
3323
  }
3000
- if (r.diffPath) lines.push(` diff: ${r.diffPath}`);
3001
- return lines;
3324
+ const stopOutcome = await stopTrackedServer();
3325
+ await promises.rm(root, { recursive: true, force: true });
3326
+ out.emit(
3327
+ { ok: true, removed: true, root, devServer: stopOutcome },
3328
+ stopOutcome.stopped ? `stopped dev server (pid ${stopOutcome.pid} via ${stopOutcome.via}) and removed ${root}` : `removed ${root}`
3329
+ );
3002
3330
  });
3003
3331
  }
3004
- function parseJudge2(input) {
3005
- if (input === "host" || input === "none") return input;
3006
- throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
3332
+ async function cleanupAfterRewrite(rewrittenIds, scopeAll) {
3333
+ const p = paths();
3334
+ if (scopeAll) {
3335
+ await Promise.all([
3336
+ promises.rm(p.actual, { recursive: true, force: true }),
3337
+ promises.rm(p.judgments, { recursive: true, force: true }),
3338
+ promises.rm(p.checkpoints, { recursive: true, force: true }),
3339
+ promises.rm(p.summary, { force: true })
3340
+ ]);
3341
+ return;
3342
+ }
3343
+ const perId = rewrittenIds.flatMap((id) => [
3344
+ promises.rm(path2__default.default.join(p.actual, `${id}.png`), { force: true }),
3345
+ promises.rm(path2__default.default.join(p.actual, `${id}.diff.png`), { force: true }),
3346
+ promises.rm(path2__default.default.join(p.judgments, id), { recursive: true, force: true })
3347
+ ]);
3348
+ await Promise.all([
3349
+ ...perId,
3350
+ promises.rm(p.summary, { force: true }),
3351
+ promises.rm(p.checkpoints, { recursive: true, force: true })
3352
+ ]);
3007
3353
  }
3008
- function parseMode(input) {
3009
- if (input === "actual") return input;
3010
- if (input === "baseline") {
3011
- throw new Error(
3012
- "`run --mode baseline` is not yet implemented. Use `init` + `capture` for authoring."
3013
- );
3354
+ async function resolveTargets(manifest, ids, opts) {
3355
+ const exclusive = [
3356
+ ids.length > 0,
3357
+ Boolean(opts.failed),
3358
+ Boolean(opts.all)
3359
+ ].filter(Boolean).length;
3360
+ if (exclusive === 0) throw new Error("provide ids, --failed, or --all");
3361
+ if (exclusive > 1)
3362
+ throw new Error("ids / --failed / --all are mutually exclusive");
3363
+ if (opts.all) return new Set(manifest.entries.map((e) => e.id));
3364
+ if (opts.failed) {
3365
+ const judgmentsDir = paths().judgments;
3366
+ if (!fs.existsSync(judgmentsDir)) {
3367
+ throw new Error(
3368
+ `no judgments at ${judgmentsDir}. Run \`blazediff-agent check\` first.`
3369
+ );
3370
+ }
3371
+ const names = await promises.readdir(judgmentsDir);
3372
+ const failed = /* @__PURE__ */ new Set();
3373
+ for (const name of names) {
3374
+ const full = path2__default.default.join(judgmentsDir, name);
3375
+ if (fs.statSync(full).isDirectory()) failed.add(name);
3376
+ }
3377
+ return failed;
3014
3378
  }
3015
- throw new Error(`unknown --mode: ${input} (expected: actual)`);
3379
+ const targets = new Set(ids);
3380
+ const missing = ids.filter(
3381
+ (id) => !manifest.entries.some((e) => e.id === id)
3382
+ );
3383
+ if (missing.length) throw new Error(`unknown ids: ${missing.join(", ")}`);
3384
+ return targets;
3016
3385
  }
3017
- function registerRun(program, out) {
3018
- program.command("run").description(
3019
- "streaming check pipeline via LangGraph (alternative to `check`)"
3020
- ).option("--mode <mode>", "pipeline mode (actual)", "actual").option("--base-url <url>", "override base URL").option(
3021
- "--threshold <n>",
3022
- "color threshold (0-1)",
3023
- String(DEFAULT_THRESHOLD)
3024
- ).option(
3025
- "--concurrency <n>",
3026
- "max entries captured in parallel (default: auto based on CPU cores, capped at 8)"
3027
- ).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
3028
- "--judge <backend>",
3029
- "judge backend for ambiguous diffs (host | none)",
3030
- "none"
3031
- ).action(async (opts) => {
3032
- parseMode(opts.mode);
3386
+ function registerRewrite(program, out) {
3387
+ program.command("rewrite [ids...]").description(
3388
+ "rewrite baselines for existing manifest entries, preserving mask/viewport/etc. Pick targets via positional ids, --failed (uses .blazediff/judgments/ from last check), or --all."
3389
+ ).option("--failed", "rewrite entries that failed the most recent check").option("--all", "rewrite every manifest entry").option("--base-url <url>", "override base URL").action(async (ids, opts) => {
3390
+ const manifest = await loadManifest();
3391
+ if (!manifest) throw new Error("no manifest. Run authoring first.");
3033
3392
  const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
3034
- const report = await runGraph({
3393
+ const targets = await resolveTargets(manifest, ids, opts);
3394
+ if (targets.size === 0 && opts.failed) {
3395
+ out.emit({ ok: true, rewritten: 0 }, "no failed entries to rewrite");
3396
+ return;
3397
+ }
3398
+ const routes = manifest.entries.filter((e) => targets.has(e.id)).map((e) => ({
3399
+ id: e.id,
3400
+ url: e.url,
3401
+ mask: e.mask,
3402
+ viewport: e.viewport,
3403
+ waitFor: e.waitFor,
3404
+ fullPage: e.fullPage
3405
+ }));
3406
+ const report = await runCaptures({
3035
3407
  baseUrl,
3036
- threshold: Number(opts.threshold),
3037
- concurrency: opts.concurrency ? Number(opts.concurrency) : void 0,
3038
- emitDiffPng: opts.diffPng,
3039
- junitPath: opts.junit,
3040
- judge: parseJudge2(opts.judge)
3408
+ routes,
3409
+ mode: "baseline",
3410
+ writeManifest: true
3041
3411
  });
3042
- const summaryPath = paths().summary;
3043
- const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
3044
- const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
3045
- summary: ${summaryPath}` : [
3046
- `${summary}:`,
3047
- ...failureLines2(report.results),
3048
- ` summary: ${summaryPath}`,
3049
- report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
3050
- ].filter(Boolean).join("\n");
3051
- out.emit(slimReport2(report, summaryPath), human);
3412
+ const succeededIds = report.results.filter((r) => r.ok).map((r) => r.id);
3413
+ if (succeededIds.length > 0) {
3414
+ await cleanupAfterRewrite(succeededIds, Boolean(opts.all));
3415
+ }
3416
+ const failureLines2 = report.results.filter((r) => !r.ok).map((r) => ` \u2717 ${r.id}: ${r.error ?? "failed"}`);
3417
+ const human = report.failed === 0 ? `rewrote ${report.succeeded}/${report.total} baseline${report.total === 1 ? "" : "s"}` : [
3418
+ `rewrote ${report.succeeded}/${report.total} (${report.failed} failed):`,
3419
+ ...failureLines2
3420
+ ].join("\n");
3421
+ out.emit(report, human);
3052
3422
  if (report.failed > 0) process.exitCode = 1;
3053
3423
  });
3054
3424
  }
@@ -3072,7 +3442,7 @@ function registerServeStatus(program, out) {
3072
3442
  );
3073
3443
  return;
3074
3444
  }
3075
- const port = opts.port ? Number(opts.port) : config.devServer.port;
3445
+ const port = opts.port ? parsePort(opts.port) : config.devServer.port;
3076
3446
  if (opts.kill) {
3077
3447
  const result = await stopServer(process.cwd(), port);
3078
3448
  const human = result.killed ? `dev server stopped (pid ${result.pid} via ${result.via})` : `no dev server found to stop on :${port}`;
@@ -3167,7 +3537,6 @@ function buildProgram() {
3167
3537
  registerDiff(program, out);
3168
3538
  registerManifest(program, out);
3169
3539
  registerCheck(program, out);
3170
- registerRun(program, out);
3171
3540
  registerRewrite(program, out);
3172
3541
  registerReset(program, out);
3173
3542
  return program;