@davmash/bygone 0.8.14 → 0.9.2

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.
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var tourCoverage_exports = {};
32
32
  __export(tourCoverage_exports, {
33
33
  TOUR_COVERAGE_REPORT_VERSION: () => TOUR_COVERAGE_REPORT_VERSION,
34
+ buildTourAuthoringCoverage: () => buildTourAuthoringCoverage,
34
35
  buildTourCoverageReport: () => buildTourCoverageReport
35
36
  });
36
37
  module.exports = __toCommonJS(tourCoverage_exports);
@@ -473,33 +474,544 @@ function runGit(repoRoot, args) {
473
474
  // src/changeTour.ts
474
475
  var import_child_process3 = require("child_process");
475
476
 
477
+ // src/changeTourSource.ts
478
+ var CHANGE_TOUR_SOURCE_VERSION = 3;
479
+ function parseChangeTourSource(value) {
480
+ if (isRecord(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_SOURCE_VERSION) {
481
+ throw new Error(
482
+ `This tour uses source format version ${value.version}, but this version of Bygone supports up to version ${CHANGE_TOUR_SOURCE_VERSION}. Upgrade Bygone to open it.`
483
+ );
484
+ }
485
+ if (!isRecord(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
486
+ throw new Error("Unsupported or missing change-tour source version.");
487
+ }
488
+ requireOnlyKeys(value, [
489
+ "version",
490
+ "title",
491
+ "windowTitle",
492
+ "sourceUrl",
493
+ "range",
494
+ "anchors",
495
+ "connections",
496
+ "chapters",
497
+ "tours",
498
+ "coverage",
499
+ "review"
500
+ ], "source");
501
+ optionalString(value.title, "title");
502
+ optionalString(value.windowTitle, "windowTitle");
503
+ optionalString(value.sourceUrl, "sourceUrl");
504
+ if (value.range !== void 0) {
505
+ if (!isRecord(value.range)) throw new Error("range must be an object.");
506
+ requireOnlyKeys(value.range, ["base", "head"], "range");
507
+ requireString(value.range.base, "range.base");
508
+ requireString(value.range.head, "range.head");
509
+ }
510
+ if (value.coverage !== void 0) {
511
+ if (!isRecord(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
512
+ throw new Error("coverage must contain an exclusions array.");
513
+ }
514
+ requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
515
+ for (const [index, exclusion] of value.coverage.exclusions.entries()) {
516
+ const exclusionPath = `coverage.exclusions[${index}]`;
517
+ if (!isRecord(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
518
+ requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
519
+ requireString(exclusion.path, `${exclusionPath}.path`);
520
+ requireString(exclusion.reason, `${exclusionPath}.reason`);
521
+ if (exclusion.hunks !== void 0) requireStringArray(exclusion.hunks, `${exclusionPath}.hunks`);
522
+ }
523
+ }
524
+ if (!isRecord(value.anchors)) {
525
+ throw new Error("anchors must be an object.");
526
+ }
527
+ for (const [id, anchor] of Object.entries(value.anchors)) {
528
+ if (!isRecord(anchor)) {
529
+ throw new Error(`anchors.${id} must be an object.`);
530
+ }
531
+ requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
532
+ requireString(anchor.file, `anchors.${id}.file`);
533
+ if (anchor.revision !== "base" && anchor.revision !== "head") {
534
+ throw new Error(`anchors.${id}.revision must be base or head.`);
535
+ }
536
+ requireString(anchor.contains, `anchors.${id}.contains`);
537
+ if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
538
+ throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
539
+ }
540
+ }
541
+ const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord(connection) ? { id, ...connection } : connection) : null;
542
+ if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
543
+ throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
544
+ }
545
+ const connectionIds = /* @__PURE__ */ new Set();
546
+ for (const [index, connection] of rawConnections.entries()) {
547
+ if (!isRecord(connection)) {
548
+ throw new Error(`connections[${index}] must be an object.`);
549
+ }
550
+ requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
551
+ requireString(connection.id, `connections[${index}].id`);
552
+ requireString(connection.from, `connections[${index}].from`);
553
+ requireString(connection.to, `connections[${index}].to`);
554
+ requireString(connection.label, `connections[${index}].label`);
555
+ const { id, from, to } = connection;
556
+ if (!value.anchors[from] || !value.anchors[to]) {
557
+ throw new Error(`Connection ${id} references an unknown anchor.`);
558
+ }
559
+ if (connectionIds.has(id)) {
560
+ throw new Error(`Duplicate connection id: ${id}`);
561
+ }
562
+ connectionIds.add(id);
563
+ }
564
+ const authoredWalkthroughSteps = validateSourceChapters(
565
+ value.chapters,
566
+ "chapters",
567
+ value.version,
568
+ value.anchors,
569
+ connectionIds,
570
+ "root"
571
+ );
572
+ if (value.tours !== void 0) {
573
+ if (value.version !== 3) throw new Error("Independent tours require version 3.");
574
+ if (!isRecord(value.tours)) throw new Error("tours must be an object.");
575
+ requireOnlyKeys(value.tours, ["historical", "deconstructed"], "tours");
576
+ if (value.tours.historical === void 0 && value.tours.deconstructed === void 0) {
577
+ throw new Error("tours must contain a historical or deconstructed tour.");
578
+ }
579
+ for (const mode of ["historical", "deconstructed"]) {
580
+ const tour = value.tours[mode];
581
+ if (tour === void 0) continue;
582
+ const path = `tours.${mode}`;
583
+ if (!isRecord(tour)) throw new Error(`${path} must be an object.`);
584
+ requireOnlyKeys(tour, ["chapters"], path);
585
+ validateSourceChapters(tour.chapters, `${path}.chapters`, value.version, value.anchors, connectionIds, mode);
586
+ }
587
+ }
588
+ if (value.review !== void 0) {
589
+ if (value.version !== 3) throw new Error("Review notes require version 3.");
590
+ validateChangeTourReview(value.review, { authoredWalkthroughSteps });
591
+ }
592
+ return { ...value, connections: rawConnections };
593
+ }
594
+ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, connectionIds, mode) {
595
+ if (!Array.isArray(chaptersValue) || chaptersValue.length === 0) {
596
+ throw new Error(`${chaptersPath} must be a non-empty array.`);
597
+ }
598
+ const chapterIds = /* @__PURE__ */ new Set();
599
+ const sceneIds = /* @__PURE__ */ new Set();
600
+ const authoredWalkthroughSteps = /* @__PURE__ */ new Map();
601
+ for (const [chapterIndex, chapter] of chaptersValue.entries()) {
602
+ const chapterPath = `${chaptersPath}[${chapterIndex}]`;
603
+ if (!isRecord(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
604
+ throw new Error(`${chapterPath} must contain a non-empty scenes array.`);
605
+ }
606
+ requireString(chapter.id, `${chapterPath}.id`);
607
+ requireString(chapter.title, `${chapterPath}.title`);
608
+ requireOnlyKeys(chapter, ["id", "title", "scenes"], chapterPath);
609
+ if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
610
+ chapterIds.add(chapter.id);
611
+ for (const [sceneIndex, scene] of chapter.scenes.entries()) {
612
+ const path = `${chapterPath}.scenes[${sceneIndex}]`;
613
+ if (!isRecord(scene)) throw new Error(`${path} must be an object.`);
614
+ requireString(scene.id, `${path}.id`);
615
+ requireString(scene.title, `${path}.title`);
616
+ if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
617
+ sceneIds.add(scene.id);
618
+ if (scene.overview !== void 0 && version !== 3) {
619
+ throw new Error(`${path}.overview requires version 3.`);
620
+ }
621
+ validateNarrative(scene, path);
622
+ if (scene.kind === "deconstructed-diff") {
623
+ if (mode === "historical") {
624
+ throw new Error(`${path} cannot contain a deconstructed-diff scene in the historical tour.`);
625
+ }
626
+ validateDeconstructedScene(scene, path);
627
+ validateSceneOverview(scene.overview, `${path}.overview`, {
628
+ endpointIds: deconstructedOverviewEndpointIds(scene, path)
629
+ });
630
+ const requireRealEvidence = mode === "root" && version >= 2;
631
+ if (requireRealEvidence || scene.stack !== void 0) {
632
+ validateRealStack(scene.stack, `${path}.stack`, 2);
633
+ }
634
+ if (requireRealEvidence || scene.steps !== void 0) {
635
+ validateWalkthroughSteps(scene, path, anchors, connectionIds);
636
+ if (scene.steps !== void 0) {
637
+ authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
638
+ }
639
+ }
640
+ continue;
641
+ }
642
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
643
+ throw new Error(`${path} must contain a non-empty steps array.`);
644
+ }
645
+ if (scene.kind === "stacked-diff") {
646
+ validateStackedScene(scene, path, mode === "root" ? version >= 2 ? 2 : 3 : 2);
647
+ validateSceneOverview(scene.overview, `${path}.overview`, {
648
+ endpointIds: stackedOverviewEndpointIds(scene, path)
649
+ });
650
+ continue;
651
+ }
652
+ if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
653
+ throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
654
+ }
655
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "steps"], path);
656
+ validateSceneOverview(scene.overview, `${path}.overview`, { allowComparison: false });
657
+ validateWalkthroughSteps(scene, path, anchors, connectionIds);
658
+ authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
659
+ }
660
+ }
661
+ if (mode === "deconstructed" && !chaptersValue.some((chapter) => isRecord(chapter) && Array.isArray(chapter.scenes) && chapter.scenes.some((scene) => isRecord(scene) && scene.kind === "deconstructed-diff"))) {
662
+ throw new Error(`${chaptersPath} must contain at least one deconstructed-diff scene.`);
663
+ }
664
+ return authoredWalkthroughSteps;
665
+ }
666
+ function stackedOverviewEndpointIds(scene, path) {
667
+ if (!Array.isArray(scene.stack)) {
668
+ throw new Error(`${path}.stack must be an array.`);
669
+ }
670
+ return scene.stack.filter(isRecord).map((entry) => typeof entry.id === "string" ? entry.id : "").filter((id) => id.length > 0);
671
+ }
672
+ function deconstructedOverviewEndpointIds(scene, path) {
673
+ if (!Array.isArray(scene.stages)) {
674
+ throw new Error(`${path}.stages must be an array.`);
675
+ }
676
+ return [
677
+ "explanation-baseline",
678
+ ...scene.stages.filter(isRecord).map((stage) => typeof stage.id === "string" ? `explanation-stage-${stage.id}` : "").filter((id) => id.length > 0)
679
+ ];
680
+ }
681
+ function validateChangeTourReview(value, context = {}) {
682
+ if (!isRecord(value)) {
683
+ throw new Error("review must be an object.");
684
+ }
685
+ requireOnlyKeys(value, ["baseOid", "headOid", "items"], "review");
686
+ requireFullGitOid(value.baseOid, "review.baseOid");
687
+ requireFullGitOid(value.headOid, "review.headOid");
688
+ if (!Array.isArray(value.items) || value.items.length === 0) {
689
+ throw new Error("review.items must be a non-empty array.");
690
+ }
691
+ if (context.expectedRange) {
692
+ if (normalizeGitOid(value.baseOid) !== normalizeGitOid(context.expectedRange.baseOid)) {
693
+ throw new Error(
694
+ `Stale review: review.baseOid ${value.baseOid} does not match the resolved range.mergeBaseOid ${context.expectedRange.baseOid}.`
695
+ );
696
+ }
697
+ if (normalizeGitOid(value.headOid) !== normalizeGitOid(context.expectedRange.headOid)) {
698
+ throw new Error(
699
+ `Stale review: review.headOid ${value.headOid} does not match the resolved range.headOid ${context.expectedRange.headOid}.`
700
+ );
701
+ }
702
+ }
703
+ const itemIds = /* @__PURE__ */ new Set();
704
+ for (const [index, item] of value.items.entries()) {
705
+ const itemPath = `review.items[${index}]`;
706
+ if (!isRecord(item)) throw new Error(`${itemPath} must be an object.`);
707
+ requireOnlyKeys(item, ["id", "kind", "title", "body", "evidence", "nextCheck"], itemPath);
708
+ requireReviewString(item.id, `${itemPath}.id`);
709
+ if (itemIds.has(item.id)) throw new Error(`Duplicate review item id: ${item.id}`);
710
+ itemIds.add(item.id);
711
+ if (typeof item.kind !== "string" || !["concept", "boundary", "tradeoff", "question"].includes(item.kind)) {
712
+ throw new Error(`${itemPath}.kind must be concept, boundary, tradeoff, or question.`);
713
+ }
714
+ requireReviewString(item.title, `${itemPath}.title`);
715
+ requireReviewString(item.body, `${itemPath}.body`);
716
+ if (!Array.isArray(item.evidence)) {
717
+ throw new Error(`${itemPath}.evidence must be an array.`);
718
+ }
719
+ if (item.kind !== "question" && item.evidence.length === 0) {
720
+ throw new Error(`${itemPath}.evidence must contain at least one reference for claims.`);
721
+ }
722
+ if (item.kind === "question" && item.nextCheck === void 0) {
723
+ throw new Error(`${itemPath}.nextCheck must be a non-empty string for question items.`);
724
+ }
725
+ if (item.nextCheck !== void 0) requireReviewString(item.nextCheck, `${itemPath}.nextCheck`);
726
+ for (const [evidenceIndex, evidence] of item.evidence.entries()) {
727
+ const evidencePath = `${itemPath}.evidence[${evidenceIndex}]`;
728
+ if (!isRecord(evidence)) throw new Error(`${evidencePath} must be an object.`);
729
+ requireOnlyKeys(evidence, ["sceneId", "stepId"], evidencePath);
730
+ requireReviewString(evidence.sceneId, `${evidencePath}.sceneId`);
731
+ requireReviewString(evidence.stepId, `${evidencePath}.stepId`);
732
+ if (context.authoredWalkthroughSteps) {
733
+ const stepIds = context.authoredWalkthroughSteps.get(evidence.sceneId);
734
+ if (!stepIds) {
735
+ throw new Error(
736
+ `${evidencePath} references unknown authored walkthrough scene ${evidence.sceneId}.`
737
+ );
738
+ }
739
+ if (!stepIds.has(evidence.stepId)) {
740
+ throw new Error(
741
+ `${evidencePath} references unknown authored walkthrough step ${evidence.sceneId}/${evidence.stepId}.`
742
+ );
743
+ }
744
+ }
745
+ }
746
+ }
747
+ }
748
+ function validateDeconstructedScene(scene, path) {
749
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "base", "target", "stack", "steps", "stages", "exclusions"], path);
750
+ optionalString(scene.base, `${path}.base`);
751
+ optionalString(scene.target, `${path}.target`);
752
+ if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
753
+ throw new Error(`${path}.stages must contain between 1 and 12 stages.`);
754
+ }
755
+ const stageIds = /* @__PURE__ */ new Set();
756
+ for (const [stageIndex, stage] of scene.stages.entries()) {
757
+ const stagePath = `${path}.stages[${stageIndex}]`;
758
+ if (!isRecord(stage)) throw new Error(`${stagePath} must be an object.`);
759
+ requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
760
+ requireString(stage.id, `${stagePath}.id`);
761
+ requireString(stage.title, `${stagePath}.title`);
762
+ requireString(stage.narration, `${stagePath}.narration`);
763
+ if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
764
+ throw new Error(`${stagePath}.changes must be a non-empty array.`);
765
+ }
766
+ if (stageIds.has(stage.id)) throw new Error(`Duplicate deconstructed stage id: ${stage.id}`);
767
+ stageIds.add(stage.id);
768
+ stage.changes.forEach((change, changeIndex) => validateDeconstructedSelection(
769
+ change,
770
+ `${stagePath}.changes[${changeIndex}]`,
771
+ false
772
+ ));
773
+ }
774
+ if (scene.exclusions !== void 0) {
775
+ if (!Array.isArray(scene.exclusions)) throw new Error(`${path}.exclusions must be an array.`);
776
+ scene.exclusions.forEach((exclusion, exclusionIndex) => validateDeconstructedSelection(
777
+ exclusion,
778
+ `${path}.exclusions[${exclusionIndex}]`,
779
+ true
780
+ ));
781
+ }
782
+ }
783
+ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
784
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
785
+ throw new Error(`${path} must contain a non-empty steps array.`);
786
+ }
787
+ const stepIds = /* @__PURE__ */ new Set();
788
+ for (const [stepIndex, step] of scene.steps.entries()) {
789
+ const stepPath = `${path}.steps[${stepIndex}]`;
790
+ if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
791
+ requireString(step.id, `${stepPath}.id`);
792
+ requireString(step.title, `${stepPath}.title`);
793
+ requireString(step.body, `${stepPath}.body`);
794
+ requireString(step.focus, `${stepPath}.focus`);
795
+ requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth", "requirement"], stepPath);
796
+ if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
797
+ throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
798
+ }
799
+ validateStepRequirement(step.requirement, `${stepPath}.requirement`);
800
+ if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
801
+ stepIds.add(step.id);
802
+ if (!anchors[step.focus]) throw new Error(`${stepPath} references unknown anchor ${step.focus}.`);
803
+ optionalString(step.connection, `${stepPath}.connection`);
804
+ if (typeof step.connection === "string" && !connectionIds.has(step.connection)) {
805
+ throw new Error(`${stepPath} references unknown connection ${step.connection}.`);
806
+ }
807
+ }
808
+ }
809
+ function validateDeconstructedSelection(value, path, requiresReason) {
810
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
811
+ requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
812
+ requireString(value.file, `${path}.file`);
813
+ if (!requiresReason || value.hunks !== void 0) {
814
+ requireStringArray(value.hunks, `${path}.hunks`);
815
+ if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
816
+ }
817
+ if (requiresReason) requireString(value.reason, `${path}.reason`);
818
+ }
819
+ function validateRealStack(stack, path, minimum) {
820
+ if (!Array.isArray(stack) || stack.length < minimum || stack.length > 6) {
821
+ throw new Error(`${path} must contain between ${minimum} and 6 explicit real revisions.`);
822
+ }
823
+ const ids = /* @__PURE__ */ new Set();
824
+ for (const [index, entry] of stack.entries()) {
825
+ if (!isRecord(entry)) throw new Error(`${path}[${index}] must be an object.`);
826
+ requireOnlyKeys(entry, ["id", "ref", "label"], `${path}[${index}]`);
827
+ requireString(entry.id, `${path}[${index}].id`);
828
+ requireString(entry.ref, `${path}[${index}].ref`);
829
+ optionalString(entry.label, `${path}[${index}].label`);
830
+ if (ids.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
831
+ ids.add(entry.id);
832
+ }
833
+ }
834
+ function validateStackedScene(scene, path, minimum = 3) {
835
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "stack", "files", "steps"], path);
836
+ if (!Array.isArray(scene.stack) || scene.stack.length < minimum || scene.stack.length > 6) {
837
+ throw new Error(`${path}.stack must contain between ${minimum} and 6 revisions.`);
838
+ }
839
+ const stackIds = /* @__PURE__ */ new Set();
840
+ for (const [index, entry] of scene.stack.entries()) {
841
+ const entryPath = `${path}.stack[${index}]`;
842
+ if (!isRecord(entry)) throw new Error(`${entryPath} must be an object.`);
843
+ requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
844
+ requireString(entry.id, `${entryPath}.id`);
845
+ requireString(entry.ref, `${entryPath}.ref`);
846
+ optionalString(entry.label, `${entryPath}.label`);
847
+ if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
848
+ stackIds.add(entry.id);
849
+ }
850
+ if (scene.files !== void 0) requireStringArray(scene.files, `${path}.files`);
851
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
852
+ const stepIds = /* @__PURE__ */ new Set();
853
+ for (const [index, step] of scene.steps.entries()) {
854
+ const stepPath = `${path}.steps[${index}]`;
855
+ if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
856
+ requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
857
+ requireString(step.id, `${stepPath}.id`);
858
+ requireString(step.title, `${stepPath}.title`);
859
+ requireString(step.body, `${stepPath}.body`);
860
+ requireString(step.file, `${stepPath}.file`);
861
+ const stepId = step.id;
862
+ if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
863
+ stepIds.add(stepId);
864
+ if (!Array.isArray(step.pair) || step.pair.length !== 2 || step.pair.some((id) => typeof id !== "string")) {
865
+ throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
866
+ }
867
+ const pair = step.pair;
868
+ const leftIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[0]);
869
+ const rightIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[1]);
870
+ if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
871
+ if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
872
+ if (step.lines !== void 0 && (!Array.isArray(step.lines) || step.lines.length !== 2 || step.lines.some((line) => !Number.isInteger(line) || Number(line) < 1) || Number(step.lines[1]) < Number(step.lines[0]))) {
873
+ throw new Error(`${stepPath}.lines must contain an ordered positive line range.`);
874
+ }
875
+ }
876
+ }
877
+ function validateStepRequirement(value, path) {
878
+ if (value === void 0) return;
879
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
880
+ requireOnlyKeys(value, ["id", "text", "status", "source", "confidence"], path);
881
+ requireString(value.id, `${path}.id`);
882
+ requireString(value.text, `${path}.text`);
883
+ if (value.status !== void 0 && !["fulfilled", "gap"].includes(String(value.status))) {
884
+ throw new Error(`${path}.status must be fulfilled or gap.`);
885
+ }
886
+ optionalString(value.source, `${path}.source`);
887
+ if (value.confidence !== void 0 && !["high", "medium", "low"].includes(String(value.confidence))) {
888
+ throw new Error(`${path}.confidence must be high, medium, or low.`);
889
+ }
890
+ }
891
+ function validateSceneOverview(value, path, options = {}) {
892
+ if (value === void 0) return;
893
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
894
+ requireOnlyKeys(value, ["kind", "path", "comparison"], path);
895
+ if (value.kind !== "directory-diff") {
896
+ throw new Error(`${path}.kind must be directory-diff.`);
897
+ }
898
+ if (value.path !== void 0) validateDirectoryOverviewPath(value.path, `${path}.path`);
899
+ if (value.comparison === void 0) return;
900
+ if (options.allowComparison === false) {
901
+ throw new Error(`${path}.comparison is not supported for walkthrough scenes; use the fixed base-to-head comparison.`);
902
+ }
903
+ if (!isRecord(value.comparison)) throw new Error(`${path}.comparison must be an object.`);
904
+ requireOnlyKeys(value.comparison, ["from", "to"], `${path}.comparison`);
905
+ requireString(value.comparison.from, `${path}.comparison.from`);
906
+ requireString(value.comparison.to, `${path}.comparison.to`);
907
+ if (value.comparison.from === value.comparison.to) {
908
+ throw new Error(`${path}.comparison.from and to must identify distinct endpoints.`);
909
+ }
910
+ if (options.endpointIds !== void 0) {
911
+ for (const endpoint of [value.comparison.from, value.comparison.to]) {
912
+ if (!options.endpointIds.includes(endpoint)) {
913
+ throw new Error(`${path}.comparison endpoint ${endpoint} is not defined by this scene.`);
914
+ }
915
+ }
916
+ }
917
+ }
918
+ function validateDirectoryOverviewPath(value, path) {
919
+ requireString(value, path);
920
+ if (value.includes("\\")) {
921
+ throw new Error(`${path} must use a relative POSIX directory path without backslashes.`);
922
+ }
923
+ if (value.startsWith("/") || /^[A-Za-z]:/.test(value)) {
924
+ throw new Error(`${path} must be relative to the repository root.`);
925
+ }
926
+ if (value.split("/").some((segment) => segment === "..")) {
927
+ throw new Error(`${path} must not contain parent-directory traversal.`);
928
+ }
929
+ }
930
+ function collectStepIds(value) {
931
+ const ids = /* @__PURE__ */ new Set();
932
+ if (!Array.isArray(value)) return ids;
933
+ for (const step of value) {
934
+ if (isRecord(step) && typeof step.id === "string") ids.add(step.id);
935
+ }
936
+ return ids;
937
+ }
938
+ function validateNarrative(value, path) {
939
+ requireString(value.summary, `${path}.summary`);
940
+ requireStringArray(value.bullets, `${path}.bullets`);
941
+ requireStringArray(value.tags, `${path}.tags`);
942
+ requireString(value.takeaway, `${path}.takeaway`);
943
+ }
944
+ function isRecord(value) {
945
+ return typeof value === "object" && value !== null && !Array.isArray(value);
946
+ }
947
+ function requireString(value, path) {
948
+ if (typeof value !== "string" || value.length === 0) {
949
+ throw new Error(`${path} must be a non-empty string.`);
950
+ }
951
+ }
952
+ function requireFullGitOid(value, path) {
953
+ requireString(value, path);
954
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)) {
955
+ throw new Error(`${path} must be a full 40- or 64-character hexadecimal Git OID.`);
956
+ }
957
+ }
958
+ function requireReviewString(value, path) {
959
+ if (typeof value !== "string" || value.trim().length === 0) {
960
+ throw new Error(`${path} must be a non-empty string.`);
961
+ }
962
+ }
963
+ function normalizeGitOid(value) {
964
+ return value.toLowerCase();
965
+ }
966
+ function optionalString(value, path) {
967
+ if (value !== void 0) {
968
+ requireString(value, path);
969
+ }
970
+ }
971
+ function requireStringArray(value, path) {
972
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
973
+ throw new Error(`${path} must be an array of strings.`);
974
+ }
975
+ }
976
+ function requireOnlyKeys(value, allowed, path) {
977
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
978
+ if (unexpected.length > 0) {
979
+ throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
980
+ }
981
+ }
982
+
476
983
  // src/changeTourManifest.ts
477
- var CHANGE_TOUR_MANIFEST_VERSION = 1;
984
+ var CHANGE_TOUR_MANIFEST_VERSION = 3;
478
985
  function parseChangeTourManifest(value) {
479
- if (!isRecord(value) || value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
986
+ if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
987
+ throw new Error(
988
+ `This tour uses manifest format version ${value.version}, but this version of Bygone supports up to version ${CHANGE_TOUR_MANIFEST_VERSION}. Upgrade Bygone to open it.`
989
+ );
990
+ }
991
+ if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
480
992
  throw new Error(`Unsupported or missing change-tour manifest version.`);
481
993
  }
482
- requireString(value.title, "title");
483
- requireString(value.generatedAt, "generatedAt");
994
+ requireString2(value.title, "title");
995
+ requireString2(value.generatedAt, "generatedAt");
484
996
  if (value.windowTitle !== void 0) {
485
- requireString(value.windowTitle, "windowTitle");
997
+ requireString2(value.windowTitle, "windowTitle");
486
998
  }
487
999
  if (value.sourceUrl !== void 0) {
488
- requireString(value.sourceUrl, "sourceUrl");
1000
+ requireString2(value.sourceUrl, "sourceUrl");
489
1001
  }
490
- if (!isRecord(value.range)) {
1002
+ if (!isRecord2(value.range)) {
491
1003
  throw new Error("Change-tour manifest range must be an object.");
492
1004
  }
493
1005
  for (const key of ["baseRef", "headRef", "mergeBaseOid", "headOid"]) {
494
- requireString(value.range[key], `range.${key}`);
1006
+ requireString2(value.range[key], `range.${key}`);
495
1007
  }
496
- if (!isRecord(value.summary)) {
1008
+ if (!isRecord2(value.summary)) {
497
1009
  throw new Error("Change-tour manifest summary must be an object.");
498
1010
  }
499
1011
  for (const key of ["changedFiles", "includedScenes", "additions", "deletions", "commitCount"]) {
500
1012
  requireNonNegativeInteger(value.summary[key], `summary.${key}`);
501
1013
  }
502
- requireStringArray(value.summary.omittedFiles, "summary.omittedFiles");
1014
+ requireStringArray2(value.summary.omittedFiles, "summary.omittedFiles");
503
1015
  if (!Array.isArray(value.commits) || !Array.isArray(value.chapters) || !Array.isArray(value.scenes)) {
504
1016
  throw new Error("Change-tour manifest commits, chapters, and scenes must be arrays.");
505
1017
  }
@@ -508,19 +1020,19 @@ function parseChangeTourManifest(value) {
508
1020
  }
509
1021
  const sceneIds = /* @__PURE__ */ new Set();
510
1022
  for (const [index, candidate] of value.scenes.entries()) {
511
- validateScene(candidate, index);
1023
+ validateScene(candidate, index, value.version);
512
1024
  if (sceneIds.has(candidate.id)) {
513
1025
  throw new Error(`Duplicate change-tour scene id: ${candidate.id}`);
514
1026
  }
515
1027
  sceneIds.add(candidate.id);
516
1028
  }
517
1029
  for (const [index, candidate] of value.chapters.entries()) {
518
- if (!isRecord(candidate)) {
1030
+ if (!isRecord2(candidate)) {
519
1031
  throw new Error(`chapters[${index}] must be an object.`);
520
1032
  }
521
- requireString(candidate.id, `chapters[${index}].id`);
522
- requireString(candidate.title, `chapters[${index}].title`);
523
- requireStringArray(candidate.sceneIds, `chapters[${index}].sceneIds`);
1033
+ requireString2(candidate.id, `chapters[${index}].id`);
1034
+ requireString2(candidate.title, `chapters[${index}].title`);
1035
+ requireStringArray2(candidate.sceneIds, `chapters[${index}].sceneIds`);
524
1036
  for (const sceneId of candidate.sceneIds) {
525
1037
  if (!sceneIds.has(sceneId)) {
526
1038
  throw new Error(`Chapter references unknown scene id: ${sceneId}`);
@@ -537,39 +1049,209 @@ function parseChangeTourManifest(value) {
537
1049
  if (value.files !== void 0 && value.summary.changedFiles !== files.length) {
538
1050
  throw new Error("summary.changedFiles must match the number of files.");
539
1051
  }
1052
+ if (value.authoringCoverage !== void 0) {
1053
+ validateAuthoringCoverage(value.authoringCoverage, value.version);
1054
+ }
1055
+ if (value.tours !== void 0) {
1056
+ if (value.version !== 3) throw new Error("Independent tours require manifest version 3.");
1057
+ validateModeTours(value.tours, value.version);
1058
+ }
1059
+ if (value.version !== 1) validateZoom(value, value.version);
1060
+ if (value.review !== void 0) {
1061
+ if (value.version !== 3) throw new Error("Review notes require manifest version 3.");
1062
+ const evidenceScenes = value.version >= 2 && isRecord2(value.zoom) && isRecord2(value.zoom.final) ? value.zoom.final.scenes : value.scenes;
1063
+ validateChangeTourReview(value.review, {
1064
+ expectedRange: {
1065
+ baseOid: String(value.range.mergeBaseOid),
1066
+ headOid: String(value.range.headOid)
1067
+ },
1068
+ authoredWalkthroughSteps: collectAuthoredWalkthroughStepIds(evidenceScenes)
1069
+ });
1070
+ }
540
1071
  return { ...value, files };
541
1072
  }
542
- function validateScene(value, index) {
543
- if (!isRecord(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
1073
+ function collectAuthoredWalkthroughStepIds(value) {
1074
+ const scenes = /* @__PURE__ */ new Map();
1075
+ if (!Array.isArray(value)) return scenes;
1076
+ for (const scene of value) {
1077
+ if (!isRecord2(scene) || scene.kind !== "walkthrough" || !Array.isArray(scene.steps) || typeof scene.id !== "string") {
1078
+ continue;
1079
+ }
1080
+ const stepIds = /* @__PURE__ */ new Set();
1081
+ for (const step of scene.steps) {
1082
+ if (isRecord2(step) && typeof step.id === "string") stepIds.add(step.id);
1083
+ }
1084
+ scenes.set(scene.id, stepIds);
1085
+ }
1086
+ return scenes;
1087
+ }
1088
+ function validateAuthoringCoverage(value, version) {
1089
+ if (!isRecord2(value) || !isRecord2(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
1090
+ throw new Error("authoringCoverage must contain walkthrough and explanationAssignments.");
1091
+ }
1092
+ const expectedScope = version >= 2 ? "final" : "tour";
1093
+ if (value.walkthrough.scope !== expectedScope) {
1094
+ throw new Error(`authoringCoverage.walkthrough.scope must be ${expectedScope}.`);
1095
+ }
1096
+ requireNonNegativeInteger(value.walkthrough.coveredUnits, "authoringCoverage.walkthrough.coveredUnits");
1097
+ requireNonNegativeInteger(value.walkthrough.includedUnits, "authoringCoverage.walkthrough.includedUnits");
1098
+ requirePercentage(value.walkthrough.coveragePercent, "authoringCoverage.walkthrough.coveragePercent");
1099
+ if (value.walkthrough.coveredUnits > value.walkthrough.includedUnits) {
1100
+ throw new Error("authoringCoverage.walkthrough.coveredUnits must not exceed includedUnits.");
1101
+ }
1102
+ for (const [index, assignment] of value.explanationAssignments.entries()) {
1103
+ const path = `authoringCoverage.explanationAssignments[${index}]`;
1104
+ if (!isRecord2(assignment)) throw new Error(`${path} must be an object.`);
1105
+ requireString2(assignment.sceneId, `${path}.sceneId`);
1106
+ requireNonNegativeInteger(assignment.assignedUnits, `${path}.assignedUnits`);
1107
+ requireNonNegativeInteger(assignment.excludedUnits, `${path}.excludedUnits`);
1108
+ requireNonNegativeInteger(assignment.completeUnits, `${path}.completeUnits`);
1109
+ requireNonNegativeInteger(assignment.totalUnits, `${path}.totalUnits`);
1110
+ requirePercentage(assignment.assignmentPercent, `${path}.assignmentPercent`);
1111
+ if (assignment.assignedUnits + assignment.excludedUnits !== assignment.completeUnits || assignment.completeUnits > assignment.totalUnits) {
1112
+ throw new Error(`${path} contains inconsistent unit counts.`);
1113
+ }
1114
+ }
1115
+ }
1116
+ function validateModeTours(value, version) {
1117
+ if (!isRecord2(value)) throw new Error("Change-tour manifest tours must be an object.");
1118
+ if (value.historical === void 0 && value.deconstructed === void 0) {
1119
+ throw new Error("Change-tour manifest tours must contain a historical or deconstructed tour.");
1120
+ }
1121
+ for (const mode of ["historical", "deconstructed"]) {
1122
+ const tour = value[mode];
1123
+ if (tour === void 0) continue;
1124
+ if (!isRecord2(tour) || !Array.isArray(tour.chapters) || !Array.isArray(tour.scenes) || tour.chapters.length === 0 || tour.scenes.length === 0) {
1125
+ throw new Error(`Change-tour manifest tours.${mode} must contain non-empty chapters and scenes arrays.`);
1126
+ }
1127
+ const sceneIds = /* @__PURE__ */ new Set();
1128
+ let hasDeconstructed = false;
1129
+ for (const [index, scene] of tour.scenes.entries()) {
1130
+ validateScene(scene, index, version);
1131
+ if (sceneIds.has(scene.id)) {
1132
+ throw new Error(`Duplicate ${mode} tour scene id: ${scene.id}`);
1133
+ }
1134
+ sceneIds.add(scene.id);
1135
+ if (scene.kind === "deconstructed-diff") hasDeconstructed = true;
1136
+ if (mode === "historical" && scene.kind === "deconstructed-diff") {
1137
+ throw new Error("Historical tours cannot contain synthetic deconstructed scenes.");
1138
+ }
1139
+ }
1140
+ if (mode === "deconstructed" && !hasDeconstructed) {
1141
+ throw new Error("Deconstructed tours must contain a constructed deconstructed-diff scene.");
1142
+ }
1143
+ for (const [index, chapter] of tour.chapters.entries()) {
1144
+ if (!isRecord2(chapter)) {
1145
+ throw new Error(`tours.${mode}.chapters[${index}] must be an object.`);
1146
+ }
1147
+ requireString2(chapter.id, `tours.${mode}.chapters[${index}].id`);
1148
+ requireString2(chapter.title, `tours.${mode}.chapters[${index}].title`);
1149
+ requireStringArray2(chapter.sceneIds, `tours.${mode}.chapters[${index}].sceneIds`);
1150
+ for (const sceneId of chapter.sceneIds) {
1151
+ if (!sceneIds.has(sceneId)) {
1152
+ throw new Error(`tours.${mode} chapter references unknown scene id: ${sceneId}`);
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ function validateZoom(value, version) {
1159
+ if (!isRecord2(value.repository) || typeof value.repository.root !== "string" || !value.repository.root) {
1160
+ throw new Error("A v2 tour requires its originating repository.root.");
1161
+ }
1162
+ if (!isRecord2(value.zoom) || !Array.isArray(value.zoom.revisions) || !isRecord2(value.zoom.final)) {
1163
+ throw new Error("A v2 tour requires zoom metadata and real revision evidence.");
1164
+ }
1165
+ const scenes = value.scenes;
1166
+ const revisions = value.zoom.revisions;
1167
+ const ids = /* @__PURE__ */ new Set();
1168
+ for (const [index, scene] of revisions.entries()) {
1169
+ validateScene(scene, index, version);
1170
+ if (scene.kind !== "stacked-diff") throw new Error("zoom.revisions must contain real stacked-diff scenes.");
1171
+ if (ids.has(scene.id)) throw new Error(`Duplicate zoom revision scene: ${scene.id}`);
1172
+ ids.add(scene.id);
1173
+ const range = value.range;
1174
+ if (scene.stack[0].oid !== range.mergeBaseOid || scene.stack[scene.stack.length - 1].oid !== range.headOid) {
1175
+ throw new Error(`Revision scene ${scene.id} endpoints must match the final diff range.`);
1176
+ }
1177
+ }
1178
+ for (const scene of scenes) {
1179
+ if (scene.kind !== "deconstructed-diff" && scene.kind !== "stacked-diff") continue;
1180
+ const real = revisions.find((candidate) => candidate.id === scene.id);
1181
+ if (!real) throw new Error(`Scene ${scene.id} is missing its real revision evidence.`);
1182
+ if (scene.kind === "deconstructed-diff" && (real.stack[0].oid !== scene.realRange.baseOid || real.stack[real.stack.length - 1].oid !== scene.realRange.targetOid)) {
1183
+ throw new Error(`Deconstructed scene ${scene.id} real stack endpoints must match its explanation range.`);
1184
+ }
1185
+ }
1186
+ const explanation = scenes.some((scene) => scene.kind === "deconstructed-diff");
1187
+ const distinct = revisions.some((scene) => scene.stack.length > 2);
1188
+ const depth = explanation ? "explanation" : distinct ? "revisions" : "final";
1189
+ const modes = [...explanation ? ["explanation"] : [], ...distinct ? ["revisions"] : [], "final", "history"];
1190
+ if (value.zoom.authoredDepth !== depth || JSON.stringify(value.zoom.modes) !== JSON.stringify(modes)) {
1191
+ throw new Error("zoom depth and modes must match the maximum authored depth.");
1192
+ }
1193
+ if (!Array.isArray(value.files)) throw new Error("A v2 tour requires final endpoint file evidence.");
1194
+ if (!Array.isArray(value.zoom.final.scenes) || !Array.isArray(value.zoom.final.chapters)) {
1195
+ throw new Error("A v2 tour requires a non-stacked final tour.");
1196
+ }
1197
+ const finalSceneIds = /* @__PURE__ */ new Set();
1198
+ for (const [index, scene] of value.zoom.final.scenes.entries()) {
1199
+ validateScene(scene, index, version);
1200
+ if (scene.kind === "stacked-diff" || scene.kind === "deconstructed-diff") {
1201
+ throw new Error("zoom.final must contain only non-stacked scenes.");
1202
+ }
1203
+ if (finalSceneIds.has(scene.id)) throw new Error(`Duplicate final tour scene: ${scene.id}`);
1204
+ finalSceneIds.add(scene.id);
1205
+ }
1206
+ for (const [index, chapter] of value.zoom.final.chapters.entries()) {
1207
+ if (!isRecord2(chapter) || !Array.isArray(chapter.sceneIds)) {
1208
+ throw new Error(`zoom.final.chapters[${index}] must contain sceneIds.`);
1209
+ }
1210
+ requireString2(chapter.id, `zoom.final.chapters[${index}].id`);
1211
+ requireString2(chapter.title, `zoom.final.chapters[${index}].title`);
1212
+ for (const sceneId of chapter.sceneIds) {
1213
+ requireString2(sceneId, `zoom.final.chapters[${index}].sceneIds`);
1214
+ if (!finalSceneIds.has(sceneId)) throw new Error(`Final tour chapter references unknown scene: ${sceneId}`);
1215
+ }
1216
+ }
1217
+ }
1218
+ function validateScene(value, index, version) {
1219
+ if (!isRecord2(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
544
1220
  throw new Error(`scenes[${index}] must be a text-diff, discussion, walkthrough, stacked-diff, or deconstructed-diff scene.`);
545
1221
  }
546
1222
  for (const key of ["id", "title"]) {
547
- requireString(value[key], `scenes[${index}].${key}`);
1223
+ requireString2(value[key], `scenes[${index}].${key}`);
1224
+ }
1225
+ if (value.overview !== void 0 && version !== 3) {
1226
+ throw new Error(`scenes[${index}].overview requires manifest version 3.`);
548
1227
  }
549
- validateNarrative(value, `scenes[${index}]`);
1228
+ validateNarrative2(value, `scenes[${index}]`);
550
1229
  if (value.kind === "discussion") {
1230
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
551
1231
  return;
552
1232
  }
553
1233
  if (value.kind === "walkthrough") {
1234
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
554
1235
  if (!Array.isArray(value.steps) || value.steps.length === 0) {
555
1236
  throw new Error(`scenes[${index}].steps must be a non-empty array.`);
556
1237
  }
557
1238
  for (const [stepIndex, step] of value.steps.entries()) {
558
1239
  const path = `scenes[${index}].steps[${stepIndex}]`;
559
- if (!isRecord(step) || !isRecord(step.focus) || !isRecord(step.diff)) {
1240
+ if (!isRecord2(step) || !isRecord2(step.focus) || !isRecord2(step.diff)) {
560
1241
  throw new Error(`${path} must contain focus and diff objects.`);
561
1242
  }
562
- for (const key of ["id", "title", "body"]) requireString(step[key], `${path}.${key}`);
1243
+ for (const key of ["id", "title", "body"]) requireString2(step[key], `${path}.${key}`);
563
1244
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
564
1245
  throw new Error(`${path}.depth must be mentioned, explained, or contextualized.`);
565
1246
  }
1247
+ validateStepRequirement(step.requirement, `${path}.requirement`);
566
1248
  validateResolvedAnchor(step.focus, `${path}.focus`);
567
- validateScene(step.diff, index);
1249
+ validateScene(step.diff, index, version);
568
1250
  if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
569
1251
  if (step.connection !== void 0) {
570
- if (!isRecord(step.connection)) throw new Error(`${path}.connection must be an object.`);
571
- requireString(step.connection.id, `${path}.connection.id`);
572
- requireString(step.connection.label, `${path}.connection.label`);
1252
+ if (!isRecord2(step.connection)) throw new Error(`${path}.connection must be an object.`);
1253
+ requireString2(step.connection.id, `${path}.connection.id`);
1254
+ requireString2(step.connection.label, `${path}.connection.label`);
573
1255
  validateResolvedAnchor(step.connection.from, `${path}.connection.from`);
574
1256
  validateResolvedAnchor(step.connection.to, `${path}.connection.to`);
575
1257
  }
@@ -577,68 +1259,75 @@ function validateScene(value, index) {
577
1259
  return;
578
1260
  }
579
1261
  if (value.kind === "stacked-diff") {
580
- validateStackedScene(value, index);
1262
+ validateStackedScene2(value, index);
1263
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, {
1264
+ endpointIds: value.stack.map((panel) => panel.id)
1265
+ });
581
1266
  return;
582
1267
  }
583
1268
  if (value.kind === "deconstructed-diff") {
584
- validateDeconstructedScene(value, index);
1269
+ validateDeconstructedScene2(value, index);
1270
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, {
1271
+ endpointIds: value.panels.map((panel) => panel.id)
1272
+ });
585
1273
  return;
586
1274
  }
1275
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
587
1276
  for (const key of ["path", "leftLabel", "rightLabel", "leftContent", "rightContent"]) {
588
- requireString(value[key], `scenes[${index}].${key}`);
1277
+ requireString2(value[key], `scenes[${index}].${key}`);
589
1278
  }
590
1279
  if (value.previousPath !== void 0) {
591
- requireString(value.previousPath, `scenes[${index}].previousPath`);
1280
+ requireString2(value.previousPath, `scenes[${index}].previousPath`);
592
1281
  }
593
- requireString(value.changeKind, `scenes[${index}].changeKind`);
1282
+ requireString2(value.changeKind, `scenes[${index}].changeKind`);
594
1283
  requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
595
1284
  requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
596
1285
  if (value.focusChangeIndex !== void 0) {
597
1286
  requireNonNegativeInteger(value.focusChangeIndex, `scenes[${index}].focusChangeIndex`);
598
1287
  }
599
1288
  }
600
- function validateStackedScene(value, index) {
601
- if (!Array.isArray(value.stack) || value.stack.length < 3 || value.stack.length > 6) {
602
- throw new Error(`scenes[${index}].stack must contain between 3 and 6 revisions.`);
1289
+ function validateStackedScene2(value, index) {
1290
+ if (!Array.isArray(value.stack) || value.stack.length < 2 || value.stack.length > 6) {
1291
+ throw new Error(`scenes[${index}].stack must contain between 2 and 6 revisions.`);
603
1292
  }
604
1293
  for (const [panelIndex, panel] of value.stack.entries()) {
605
- if (!isRecord(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
606
- for (const key of ["id", "ref", "oid", "label"]) requireString(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
1294
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
1295
+ for (const key of ["id", "ref", "oid", "label"]) requireString2(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
607
1296
  }
608
1297
  validateMultiPanelFiles(value.files, value.stack.length, index);
609
1298
  validateMultiPanelSteps(value.steps, value.stack.length, index, false);
610
1299
  }
611
- function validateDeconstructedScene(value, index) {
1300
+ function validateDeconstructedScene2(value, index) {
612
1301
  if (value.stageLabel !== "Explanation stages") {
613
1302
  throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
614
1303
  }
615
- if (!isRecord(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1304
+ if (!isRecord2(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
616
1305
  for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
617
- requireString(value.realRange[key], `scenes[${index}].realRange.${key}`);
1306
+ requireString2(value.realRange[key], `scenes[${index}].realRange.${key}`);
618
1307
  }
619
1308
  if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
620
1309
  throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
621
1310
  }
622
1311
  const panels = value.panels;
623
1312
  for (const [panelIndex, panel] of panels.entries()) {
624
- if (!isRecord(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
625
- for (const key of ["id", "label", "role"]) requireString(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
1313
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
1314
+ for (const key of ["id", "label", "role"]) requireString2(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
626
1315
  const expectedRole = panelIndex === 0 ? "baseline" : "stage";
627
1316
  if (panel.role !== expectedRole) throw new Error(`scenes[${index}].panels[${panelIndex}].role must be ${expectedRole}.`);
628
- if (panelIndex > 0) requireString(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
1317
+ if (panelIndex > 0) requireString2(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
629
1318
  }
630
1319
  validateMultiPanelFiles(value.files, panels.length, index);
631
1320
  validateMultiPanelSteps(value.steps, panels.length, index, true);
632
1321
  if (Array.isArray(value.steps)) {
633
1322
  let previousPairIndex = -1;
634
1323
  value.steps.forEach((step, stepIndex) => {
635
- if (!isRecord(step)) return;
1324
+ if (!isRecord2(step)) return;
636
1325
  const pairIndex = Number(step.pairIndex);
637
1326
  if (pairIndex < previousPairIndex) {
638
1327
  throw new Error(`scenes[${index}].steps must remain grouped in explanation stage order.`);
639
1328
  }
640
1329
  previousPairIndex = pairIndex;
641
- if (step.stageId !== void 0) requireString(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1330
+ if (step.stageId !== void 0) requireString2(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
642
1331
  if (step.stageIndex !== void 0) requireNonNegativeInteger(step.stageIndex, `scenes[${index}].steps[${stepIndex}].stageIndex`);
643
1332
  if (step.focusIndex !== void 0) requireNonNegativeInteger(step.focusIndex, `scenes[${index}].steps[${stepIndex}].focusIndex`);
644
1333
  if (step.focusCount !== void 0) requirePositiveInteger(step.focusCount, `scenes[${index}].steps[${stepIndex}].focusCount`);
@@ -651,341 +1340,91 @@ function validateDeconstructedScene(value, index) {
651
1340
  });
652
1341
  for (let stageIndex = 0; stageIndex < panels.length - 1; stageIndex += 1) {
653
1342
  const panel = panels[stageIndex + 1];
654
- const stageSteps = value.steps.filter((step) => isRecord(step) && Number(step.pairIndex) === stageIndex);
655
- if (!isRecord(panel) || stageSteps.length === 0) {
656
- throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
657
- }
658
- const firstStep = stageSteps[0];
659
- const fallbackStageId = isRecord(firstStep) ? firstStep.id : void 0;
660
- if (stageSteps.some((step) => {
661
- if (!isRecord(step)) return true;
662
- return (step.stageId ?? fallbackStageId) !== panel.stageId;
663
- })) {
664
- throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
665
- }
666
- }
667
- }
668
- }
669
- function validateMultiPanelFiles(value, panelCount, index) {
670
- if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
671
- for (const [fileIndex, file] of value.entries()) {
672
- if (!isRecord(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
673
- requireString(file.path, `scenes[${index}].files[${fileIndex}].path`);
674
- if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
675
- throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
676
- }
677
- for (const [panelIndex, panel] of file.panels.entries()) {
678
- if (!isRecord(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
679
- for (const key of ["id", "label", "content"]) requireString(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
680
- if (panel.path !== void 0) requireString(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
681
- if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
682
- }
683
- }
684
- }
685
- function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
686
- if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
687
- for (const [stepIndex, step] of value.entries()) {
688
- if (!isRecord(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
689
- for (const key of ["id", "title", "body", "file", "side"]) requireString(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
690
- requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
691
- if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
692
- if (deconstructed) {
693
- requireStringArray(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
694
- if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
695
- }
696
- if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
697
- if (step.startLine !== void 0) requirePositiveInteger(step.startLine, `scenes[${index}].steps[${stepIndex}].startLine`);
698
- if (step.endLine !== void 0) requirePositiveInteger(step.endLine, `scenes[${index}].steps[${stepIndex}].endLine`);
699
- }
700
- }
701
- function validateTourFile(value, index) {
702
- if (!isRecord(value)) throw new Error(`files[${index}] must be an object.`);
703
- if (value.kind === "text-diff") {
704
- validateScene(value, index);
705
- return;
706
- }
707
- if (value.kind !== "omitted") {
708
- throw new Error(`files[${index}].kind must be text-diff or omitted.`);
709
- }
710
- for (const key of ["id", "title", "path", "changeKind", "reason"]) {
711
- requireString(value[key], `files[${index}].${key}`);
712
- }
713
- if (value.previousPath !== void 0) requireString(value.previousPath, `files[${index}].previousPath`);
714
- requireNonNegativeInteger(value.additions, `files[${index}].additions`);
715
- requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
716
- }
717
- function collectLegacyTourFiles(scenes) {
718
- const files = /* @__PURE__ */ new Map();
719
- for (const scene of scenes) {
720
- if (scene.kind === "text-diff") {
721
- if (!files.has(scene.path)) files.set(scene.path, scene);
722
- continue;
723
- }
724
- if (scene.kind === "walkthrough") {
725
- for (const step of scene.steps) {
726
- if (!files.has(step.diff.path)) files.set(step.diff.path, step.diff);
727
- }
728
- }
729
- }
730
- return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
731
- }
732
- function validateResolvedAnchor(value, path) {
733
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
734
- for (const key of ["id", "path", "revision", "excerpt"]) requireString(value[key], `${path}.${key}`);
735
- if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
736
- requirePositiveInteger(value.startLine, `${path}.startLine`);
737
- requirePositiveInteger(value.endLine, `${path}.endLine`);
738
- if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
739
- }
740
- function validateNarrative(value, path) {
741
- requireString(value.summary, `${path}.summary`);
742
- requireStringArray(value.bullets, `${path}.bullets`);
743
- requireStringArray(value.tags, `${path}.tags`);
744
- requireString(value.takeaway, `${path}.takeaway`);
745
- }
746
- function isRecord(value) {
747
- return typeof value === "object" && value !== null && !Array.isArray(value);
748
- }
749
- function requireString(value, path) {
750
- if (typeof value !== "string") {
751
- throw new Error(`${path} must be a string.`);
752
- }
753
- }
754
- function requireStringArray(value, path) {
755
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
756
- throw new Error(`${path} must be an array of strings.`);
757
- }
758
- }
759
- function requireNonNegativeInteger(value, path) {
760
- if (!Number.isInteger(value) || Number(value) < 0) {
761
- throw new Error(`${path} must be a non-negative integer.`);
762
- }
763
- }
764
- function requirePositiveInteger(value, path) {
765
- if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
766
- }
767
-
768
- // src/changeTourSource.ts
769
- var CHANGE_TOUR_SOURCE_VERSION = 1;
770
- function parseChangeTourSource(value) {
771
- if (!isRecord2(value) || value.version !== CHANGE_TOUR_SOURCE_VERSION) {
772
- throw new Error("Unsupported or missing change-tour source version.");
773
- }
774
- requireOnlyKeys(value, ["version", "title", "windowTitle", "sourceUrl", "range", "anchors", "connections", "chapters", "coverage"], "source");
775
- optionalString(value.title, "title");
776
- optionalString(value.windowTitle, "windowTitle");
777
- optionalString(value.sourceUrl, "sourceUrl");
778
- if (value.range !== void 0) {
779
- if (!isRecord2(value.range)) throw new Error("range must be an object.");
780
- requireOnlyKeys(value.range, ["base", "head"], "range");
781
- requireString2(value.range.base, "range.base");
782
- requireString2(value.range.head, "range.head");
783
- }
784
- if (value.coverage !== void 0) {
785
- if (!isRecord2(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
786
- throw new Error("coverage must contain an exclusions array.");
787
- }
788
- requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
789
- for (const [index, exclusion] of value.coverage.exclusions.entries()) {
790
- const exclusionPath = `coverage.exclusions[${index}]`;
791
- if (!isRecord2(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
792
- requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
793
- requireString2(exclusion.path, `${exclusionPath}.path`);
794
- requireString2(exclusion.reason, `${exclusionPath}.reason`);
795
- if (exclusion.hunks !== void 0) requireStringArray2(exclusion.hunks, `${exclusionPath}.hunks`);
796
- }
797
- }
798
- if (!isRecord2(value.anchors)) {
799
- throw new Error("anchors must be an object.");
800
- }
801
- for (const [id, anchor] of Object.entries(value.anchors)) {
802
- if (!isRecord2(anchor)) {
803
- throw new Error(`anchors.${id} must be an object.`);
804
- }
805
- requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
806
- requireString2(anchor.file, `anchors.${id}.file`);
807
- if (anchor.revision !== "base" && anchor.revision !== "head") {
808
- throw new Error(`anchors.${id}.revision must be base or head.`);
809
- }
810
- requireString2(anchor.contains, `anchors.${id}.contains`);
811
- if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
812
- throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
813
- }
814
- }
815
- const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord2(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord2(connection) ? { id, ...connection } : connection) : null;
816
- if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
817
- throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
818
- }
819
- const connectionIds = /* @__PURE__ */ new Set();
820
- for (const [index, connection] of rawConnections.entries()) {
821
- if (!isRecord2(connection)) {
822
- throw new Error(`connections[${index}] must be an object.`);
823
- }
824
- requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
825
- requireString2(connection.id, `connections[${index}].id`);
826
- requireString2(connection.from, `connections[${index}].from`);
827
- requireString2(connection.to, `connections[${index}].to`);
828
- requireString2(connection.label, `connections[${index}].label`);
829
- const { id, from, to } = connection;
830
- if (!value.anchors[from] || !value.anchors[to]) {
831
- throw new Error(`Connection ${id} references an unknown anchor.`);
832
- }
833
- if (connectionIds.has(id)) {
834
- throw new Error(`Duplicate connection id: ${id}`);
835
- }
836
- connectionIds.add(id);
837
- }
838
- const chapterIds = /* @__PURE__ */ new Set();
839
- const sceneIds = /* @__PURE__ */ new Set();
840
- for (const [chapterIndex, chapter] of value.chapters.entries()) {
841
- if (!isRecord2(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
842
- throw new Error(`chapters[${chapterIndex}] must contain a non-empty scenes array.`);
843
- }
844
- requireString2(chapter.id, `chapters[${chapterIndex}].id`);
845
- requireString2(chapter.title, `chapters[${chapterIndex}].title`);
846
- requireOnlyKeys(chapter, ["id", "title", "scenes"], `chapters[${chapterIndex}]`);
847
- if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
848
- chapterIds.add(chapter.id);
849
- for (const [sceneIndex, scene] of chapter.scenes.entries()) {
850
- const path = `chapters[${chapterIndex}].scenes[${sceneIndex}]`;
851
- if (!isRecord2(scene)) throw new Error(`${path} must be an object.`);
852
- requireString2(scene.id, `${path}.id`);
853
- requireString2(scene.title, `${path}.title`);
854
- if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
855
- sceneIds.add(scene.id);
856
- validateNarrative2(scene, path);
857
- if (scene.kind === "deconstructed-diff") {
858
- validateDeconstructedScene2(scene, path);
859
- continue;
860
- }
861
- if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
862
- throw new Error(`${path} must contain a non-empty steps array.`);
863
- }
864
- if (scene.kind === "stacked-diff") {
865
- validateStackedScene2(scene, path);
866
- continue;
867
- }
868
- if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
869
- throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
1343
+ const stageSteps = value.steps.filter((step) => isRecord2(step) && Number(step.pairIndex) === stageIndex);
1344
+ if (!isRecord2(panel) || stageSteps.length === 0) {
1345
+ throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
870
1346
  }
871
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "steps"], path);
872
- const stepIds = /* @__PURE__ */ new Set();
873
- for (const [stepIndex, step] of scene.steps.entries()) {
874
- const stepPath = `${path}.steps[${stepIndex}]`;
875
- if (!isRecord2(step)) {
876
- throw new Error(`${stepPath} must be an object.`);
877
- }
878
- requireString2(step.id, `${stepPath}.id`);
879
- requireString2(step.title, `${stepPath}.title`);
880
- requireString2(step.body, `${stepPath}.body`);
881
- requireString2(step.focus, `${stepPath}.focus`);
882
- requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth"], stepPath);
883
- if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
884
- throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
885
- }
886
- if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
887
- stepIds.add(step.id);
888
- const focus = step.focus;
889
- if (!value.anchors[focus]) {
890
- throw new Error(`${stepPath} references unknown anchor ${focus}.`);
891
- }
892
- optionalString(step.connection, `${stepPath}.connection`);
893
- const connection = step.connection;
894
- if (typeof connection === "string" && !connectionIds.has(connection)) {
895
- throw new Error(`${stepPath} references unknown connection ${connection}.`);
896
- }
1347
+ const firstStep = stageSteps[0];
1348
+ const fallbackStageId = isRecord2(firstStep) ? firstStep.id : void 0;
1349
+ if (stageSteps.some((step) => {
1350
+ if (!isRecord2(step)) return true;
1351
+ return (step.stageId ?? fallbackStageId) !== panel.stageId;
1352
+ })) {
1353
+ throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
897
1354
  }
898
1355
  }
899
1356
  }
900
- return { ...value, connections: rawConnections };
901
1357
  }
902
- function validateDeconstructedScene2(scene, path) {
903
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "base", "target", "stages", "exclusions"], path);
904
- optionalString(scene.base, `${path}.base`);
905
- optionalString(scene.target, `${path}.target`);
906
- if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
907
- throw new Error(`${path}.stages must contain between 1 and 12 stages.`);
908
- }
909
- const stageIds = /* @__PURE__ */ new Set();
910
- for (const [stageIndex, stage] of scene.stages.entries()) {
911
- const stagePath = `${path}.stages[${stageIndex}]`;
912
- if (!isRecord2(stage)) throw new Error(`${stagePath} must be an object.`);
913
- requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
914
- requireString2(stage.id, `${stagePath}.id`);
915
- requireString2(stage.title, `${stagePath}.title`);
916
- requireString2(stage.narration, `${stagePath}.narration`);
917
- if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
918
- throw new Error(`${stagePath}.changes must be a non-empty array.`);
1358
+ function validateMultiPanelFiles(value, panelCount, index) {
1359
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
1360
+ for (const [fileIndex, file] of value.entries()) {
1361
+ if (!isRecord2(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1362
+ requireString2(file.path, `scenes[${index}].files[${fileIndex}].path`);
1363
+ if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
1364
+ throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
1365
+ }
1366
+ for (const [panelIndex, panel] of file.panels.entries()) {
1367
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
1368
+ for (const key of ["id", "label", "content"]) requireString2(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
1369
+ if (panel.path !== void 0) requireString2(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
1370
+ if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
919
1371
  }
920
- if (stageIds.has(stage.id)) throw new Error(`Duplicate deconstructed stage id: ${stage.id}`);
921
- stageIds.add(stage.id);
922
- stage.changes.forEach((change, changeIndex) => validateDeconstructedSelection(
923
- change,
924
- `${stagePath}.changes[${changeIndex}]`,
925
- false
926
- ));
927
- }
928
- if (scene.exclusions !== void 0) {
929
- if (!Array.isArray(scene.exclusions)) throw new Error(`${path}.exclusions must be an array.`);
930
- scene.exclusions.forEach((exclusion, exclusionIndex) => validateDeconstructedSelection(
931
- exclusion,
932
- `${path}.exclusions[${exclusionIndex}]`,
933
- true
934
- ));
935
1372
  }
936
1373
  }
937
- function validateDeconstructedSelection(value, path, requiresReason) {
938
- if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
939
- requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
940
- requireString2(value.file, `${path}.file`);
941
- if (!requiresReason || value.hunks !== void 0) {
942
- requireStringArray2(value.hunks, `${path}.hunks`);
943
- if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
1374
+ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1375
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
1376
+ for (const [stepIndex, step] of value.entries()) {
1377
+ if (!isRecord2(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
1378
+ for (const key of ["id", "title", "body", "file", "side"]) requireString2(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
1379
+ requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
1380
+ if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
1381
+ if (deconstructed) {
1382
+ requireStringArray2(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1383
+ if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
1384
+ }
1385
+ if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
1386
+ if (step.startLine !== void 0) requirePositiveInteger(step.startLine, `scenes[${index}].steps[${stepIndex}].startLine`);
1387
+ if (step.endLine !== void 0) requirePositiveInteger(step.endLine, `scenes[${index}].steps[${stepIndex}].endLine`);
944
1388
  }
945
- if (requiresReason) requireString2(value.reason, `${path}.reason`);
946
1389
  }
947
- function validateStackedScene2(scene, path) {
948
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "stack", "files", "steps"], path);
949
- if (!Array.isArray(scene.stack) || scene.stack.length < 3 || scene.stack.length > 6) {
950
- throw new Error(`${path}.stack must contain between 3 and 6 revisions.`);
1390
+ function validateTourFile(value, index) {
1391
+ if (!isRecord2(value)) throw new Error(`files[${index}] must be an object.`);
1392
+ if (value.kind === "text-diff") {
1393
+ validateScene(value, index, 1);
1394
+ return;
951
1395
  }
952
- const stackIds = /* @__PURE__ */ new Set();
953
- for (const [index, entry] of scene.stack.entries()) {
954
- const entryPath = `${path}.stack[${index}]`;
955
- if (!isRecord2(entry)) throw new Error(`${entryPath} must be an object.`);
956
- requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
957
- requireString2(entry.id, `${entryPath}.id`);
958
- requireString2(entry.ref, `${entryPath}.ref`);
959
- optionalString(entry.label, `${entryPath}.label`);
960
- if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
961
- stackIds.add(entry.id);
1396
+ if (value.kind !== "omitted") {
1397
+ throw new Error(`files[${index}].kind must be text-diff or omitted.`);
962
1398
  }
963
- if (scene.files !== void 0) requireStringArray2(scene.files, `${path}.files`);
964
- if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
965
- const stepIds = /* @__PURE__ */ new Set();
966
- for (const [index, step] of scene.steps.entries()) {
967
- const stepPath = `${path}.steps[${index}]`;
968
- if (!isRecord2(step)) throw new Error(`${stepPath} must be an object.`);
969
- requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
970
- requireString2(step.id, `${stepPath}.id`);
971
- requireString2(step.title, `${stepPath}.title`);
972
- requireString2(step.body, `${stepPath}.body`);
973
- requireString2(step.file, `${stepPath}.file`);
974
- const stepId = step.id;
975
- if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
976
- stepIds.add(stepId);
977
- if (!Array.isArray(step.pair) || step.pair.length !== 2 || step.pair.some((id) => typeof id !== "string")) {
978
- throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
1399
+ for (const key of ["id", "title", "path", "changeKind", "reason"]) {
1400
+ requireString2(value[key], `files[${index}].${key}`);
1401
+ }
1402
+ if (value.previousPath !== void 0) requireString2(value.previousPath, `files[${index}].previousPath`);
1403
+ requireNonNegativeInteger(value.additions, `files[${index}].additions`);
1404
+ requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
1405
+ }
1406
+ function collectLegacyTourFiles(scenes) {
1407
+ const files = /* @__PURE__ */ new Map();
1408
+ for (const scene of scenes) {
1409
+ if (scene.kind === "text-diff") {
1410
+ if (!files.has(scene.path)) files.set(scene.path, scene);
1411
+ continue;
979
1412
  }
980
- const pair = step.pair;
981
- const leftIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[0]);
982
- const rightIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[1]);
983
- if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
984
- if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
985
- if (step.lines !== void 0 && (!Array.isArray(step.lines) || step.lines.length !== 2 || step.lines.some((line) => !Number.isInteger(line) || Number(line) < 1) || Number(step.lines[1]) < Number(step.lines[0]))) {
986
- throw new Error(`${stepPath}.lines must contain an ordered positive line range.`);
1413
+ if (scene.kind === "walkthrough") {
1414
+ for (const step of scene.steps) {
1415
+ if (!files.has(step.diff.path)) files.set(step.diff.path, step.diff);
1416
+ }
987
1417
  }
988
1418
  }
1419
+ return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
1420
+ }
1421
+ function validateResolvedAnchor(value, path) {
1422
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
1423
+ for (const key of ["id", "path", "revision", "excerpt"]) requireString2(value[key], `${path}.${key}`);
1424
+ if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
1425
+ requirePositiveInteger(value.startLine, `${path}.startLine`);
1426
+ requirePositiveInteger(value.endLine, `${path}.endLine`);
1427
+ if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
989
1428
  }
990
1429
  function validateNarrative2(value, path) {
991
1430
  requireString2(value.summary, `${path}.summary`);
@@ -997,13 +1436,8 @@ function isRecord2(value) {
997
1436
  return typeof value === "object" && value !== null && !Array.isArray(value);
998
1437
  }
999
1438
  function requireString2(value, path) {
1000
- if (typeof value !== "string" || value.length === 0) {
1001
- throw new Error(`${path} must be a non-empty string.`);
1002
- }
1003
- }
1004
- function optionalString(value, path) {
1005
- if (value !== void 0) {
1006
- requireString2(value, path);
1439
+ if (typeof value !== "string") {
1440
+ throw new Error(`${path} must be a string.`);
1007
1441
  }
1008
1442
  }
1009
1443
  function requireStringArray2(value, path) {
@@ -1011,12 +1445,19 @@ function requireStringArray2(value, path) {
1011
1445
  throw new Error(`${path} must be an array of strings.`);
1012
1446
  }
1013
1447
  }
1014
- function requireOnlyKeys(value, allowed, path) {
1015
- const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
1016
- if (unexpected.length > 0) {
1017
- throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
1448
+ function requireNonNegativeInteger(value, path) {
1449
+ if (!Number.isInteger(value) || Number(value) < 0) {
1450
+ throw new Error(`${path} must be a non-negative integer.`);
1451
+ }
1452
+ }
1453
+ function requirePercentage(value, path) {
1454
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
1455
+ throw new Error(`${path} must be a number from 0 to 100.`);
1018
1456
  }
1019
1457
  }
1458
+ function requirePositiveInteger(value, path) {
1459
+ if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
1460
+ }
1020
1461
 
1021
1462
  // src/deconstructedChange.ts
1022
1463
  function buildDeconstructedScene(startPath, scene, options = {}) {
@@ -1551,12 +1992,20 @@ var DIFF_TIMEOUT_MS = readPositiveIntegerEnv("BYGONE_DIFF_TIMEOUT_MS", DEFAULT_D
1551
1992
  function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1552
1993
  const leftLines = normalizeLines(leftContent);
1553
1994
  const rightLines = normalizeLines(rightContent);
1554
- const changes = diffArrays(leftLines, rightLines, {
1995
+ if (leftContent === rightContent) {
1996
+ return buildIdenticalTwoWayDiffModel(leftLines);
1997
+ }
1998
+ const interned = internDiffLines(leftLines, rightLines);
1999
+ const internedChanges = diffArrays(interned.left, interned.right, {
1555
2000
  timeout: options.timeoutMs ?? DIFF_TIMEOUT_MS
1556
2001
  });
1557
- if (!changes) {
2002
+ if (!internedChanges) {
1558
2003
  return buildConservativeTwoWayDiffModel(leftLines, rightLines);
1559
2004
  }
2005
+ const changes = internedChanges.map((change) => ({
2006
+ ...change,
2007
+ value: change.value.map((lineId) => interned.lines[lineId])
2008
+ }));
1560
2009
  const rows = [];
1561
2010
  const renderedLeftLines = [];
1562
2011
  const renderedRightLines = [];
@@ -1580,29 +2029,18 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1580
2029
  }
1581
2030
  if (change.removed && index + 1 < changes.length && changes[index + 1].added) {
1582
2031
  const nextChange = changes[index + 1];
1583
- const leftStart = renderedLeftLines.length;
1584
- const rightStart = renderedRightLines.length;
1585
- const alignedLines = alignReplacementLines(removedLines, nextChange.value);
1586
- for (const { left: removedLine, right: addedLine } of alignedLines) {
1587
- let renderedLeftLine;
1588
- let renderedRightLine;
1589
- if (removedLine !== void 0) {
1590
- renderedLeftLine = makeDiffLine("removed", removedLine, leftLineNumber);
1591
- renderedLeftLines.push(renderedLeftLine);
1592
- }
1593
- if (addedLine !== void 0) {
1594
- renderedRightLine = makeDiffLine("added", addedLine, rightLineNumber);
1595
- renderedRightLines.push(renderedRightLine);
1596
- }
1597
- if (renderedLeftLine && renderedRightLine) {
1598
- applyInlineHighlightPair(renderedLeftLine, renderedRightLine);
1599
- }
1600
- rows.push(makeDiffRow(
1601
- removedLine === void 0 ? makePlaceholder() : makeDiffCell("removed", removedLine, leftLineNumber++),
1602
- addedLine === void 0 ? makePlaceholder() : makeDiffCell("added", addedLine, rightLineNumber++)
1603
- ));
1604
- }
1605
- blocks.push(makeDiffBlock("replace", leftStart, renderedLeftLines.length, rightStart, renderedRightLines.length));
2032
+ const nextLineNumbers = appendReplacementCandidate(
2033
+ removedLines,
2034
+ nextChange.value,
2035
+ leftLineNumber,
2036
+ rightLineNumber,
2037
+ rows,
2038
+ renderedLeftLines,
2039
+ renderedRightLines,
2040
+ blocks
2041
+ );
2042
+ leftLineNumber = nextLineNumbers.left;
2043
+ rightLineNumber = nextLineNumbers.right;
1606
2044
  index++;
1607
2045
  continue;
1608
2046
  }
@@ -1638,7 +2076,40 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1638
2076
  leftLines: renderedLeftLines,
1639
2077
  rightLines: renderedRightLines,
1640
2078
  blocks,
1641
- hasChanges
2079
+ hasChanges,
2080
+ quality: "exact"
2081
+ };
2082
+ }
2083
+ function internDiffLines(leftLines, rightLines) {
2084
+ const lineIds = /* @__PURE__ */ new Map();
2085
+ const lines = [];
2086
+ const intern = (line) => {
2087
+ const existingId = lineIds.get(line);
2088
+ if (existingId !== void 0) {
2089
+ return existingId;
2090
+ }
2091
+ const lineId = lines.length;
2092
+ lineIds.set(line, lineId);
2093
+ lines.push(line);
2094
+ return lineId;
2095
+ };
2096
+ return {
2097
+ left: leftLines.map(intern),
2098
+ right: rightLines.map(intern),
2099
+ lines
2100
+ };
2101
+ }
2102
+ function buildIdenticalTwoWayDiffModel(lines) {
2103
+ return {
2104
+ rows: lines.map((line, index) => makeDiffRow(
2105
+ makeDiffCell("context", line, index + 1),
2106
+ makeDiffCell("context", line, index + 1)
2107
+ )),
2108
+ leftLines: lines.map((line, index) => makeDiffLine("context", line, index + 1)),
2109
+ rightLines: lines.map((line, index) => makeDiffLine("context", line, index + 1)),
2110
+ blocks: [],
2111
+ hasChanges: false,
2112
+ quality: "exact"
1642
2113
  };
1643
2114
  }
1644
2115
  function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
@@ -1665,7 +2136,8 @@ function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
1665
2136
  leftLines: renderedLeftLines,
1666
2137
  rightLines: renderedRightLines,
1667
2138
  blocks: rows.length === 0 ? [] : [makeDiffBlock(blockKind, 0, leftLines.length, 0, rightLines.length)],
1668
- hasChanges: rows.length > 0
2139
+ hasChanges: rows.length > 0,
2140
+ quality: "fallback"
1669
2141
  };
1670
2142
  }
1671
2143
  function normalizeLines(content) {
@@ -1678,6 +2150,100 @@ function normalizeLines(content) {
1678
2150
  }
1679
2151
  return lines;
1680
2152
  }
2153
+ function appendReplacementCandidate(leftLines, rightLines, initialLeftLineNumber, initialRightLineNumber, rows, renderedLeftLines, renderedRightLines, blocks) {
2154
+ const aligned = alignReplacementLines(leftLines, rightLines);
2155
+ const crediblePairs = aligned.filter((row) => row.left !== void 0 && row.right !== void 0 && hasCredibleLineCorrespondence(row.left, row.right)).length;
2156
+ const coherentBlock = hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs);
2157
+ const normalized = aligned.flatMap((row) => {
2158
+ if (row.left === void 0 || row.right === void 0 || hasCredibleLineCorrespondence(row.left, row.right)) {
2159
+ return [row];
2160
+ }
2161
+ return [{ left: row.left }, { right: row.right }];
2162
+ });
2163
+ let leftLineNumber = initialLeftLineNumber;
2164
+ let rightLineNumber = initialRightLineNumber;
2165
+ let blockKind;
2166
+ let blockLeftStart = renderedLeftLines.length;
2167
+ let blockRightStart = renderedRightLines.length;
2168
+ const flushBlock = () => {
2169
+ if (!blockKind) return;
2170
+ blocks.push(makeDiffBlock(
2171
+ blockKind,
2172
+ blockLeftStart,
2173
+ renderedLeftLines.length,
2174
+ blockRightStart,
2175
+ renderedRightLines.length
2176
+ ));
2177
+ };
2178
+ for (const { left, right } of normalized) {
2179
+ const nextKind = coherentBlock ? "replace" : left !== void 0 && right !== void 0 ? "replace" : left !== void 0 ? "delete" : "insert";
2180
+ if (blockKind !== nextKind) {
2181
+ flushBlock();
2182
+ blockKind = nextKind;
2183
+ blockLeftStart = renderedLeftLines.length;
2184
+ blockRightStart = renderedRightLines.length;
2185
+ }
2186
+ let renderedLeftLine;
2187
+ let renderedRightLine;
2188
+ if (left !== void 0) {
2189
+ renderedLeftLine = makeDiffLine("removed", left, leftLineNumber);
2190
+ renderedLeftLines.push(renderedLeftLine);
2191
+ }
2192
+ if (right !== void 0) {
2193
+ renderedRightLine = makeDiffLine("added", right, rightLineNumber);
2194
+ renderedRightLines.push(renderedRightLine);
2195
+ }
2196
+ if (renderedLeftLine && renderedRightLine) {
2197
+ applyInlineHighlightPair(renderedLeftLine, renderedRightLine);
2198
+ } else if (coherentBlock) {
2199
+ if (renderedLeftLine) applyFullLineHighlight(renderedLeftLine);
2200
+ if (renderedRightLine) applyFullLineHighlight(renderedRightLine);
2201
+ }
2202
+ rows.push(makeDiffRow(
2203
+ left === void 0 ? makePlaceholder() : makeDiffCell("removed", left, leftLineNumber++),
2204
+ right === void 0 ? makePlaceholder() : makeDiffCell("added", right, rightLineNumber++)
2205
+ ));
2206
+ }
2207
+ flushBlock();
2208
+ return { left: leftLineNumber, right: rightLineNumber };
2209
+ }
2210
+ function hasCredibleLineCorrespondence(left, right) {
2211
+ const score = scoreReplacementLinePair(left, right);
2212
+ if (score.eligible) return true;
2213
+ const leftAnchor = declarationAnchorKey(left);
2214
+ if (leftAnchor && leftAnchor === declarationAnchorKey(right)) return true;
2215
+ const leftTokens = new Set(tokenizeMatchingContent(normalizeMatchingContent(left)).filter(isSharedContentToken));
2216
+ return tokenizeMatchingContent(normalizeMatchingContent(right)).some((token) => leftTokens.has(token) && isSharedContentToken(token));
2217
+ }
2218
+ var NON_SUBSTANTIVE_SHARED_TOKENS = /* @__PURE__ */ new Set([
2219
+ "and",
2220
+ "are",
2221
+ "for",
2222
+ "from",
2223
+ "into",
2224
+ "that",
2225
+ "the",
2226
+ "this",
2227
+ "to",
2228
+ "with"
2229
+ ]);
2230
+ function isSharedContentToken(token) {
2231
+ return token.length >= 3 && /^[\p{L}\p{N}_$]+$/u.test(token) && !NON_SUBSTANTIVE_SHARED_TOKENS.has(token);
2232
+ }
2233
+ function hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs) {
2234
+ if (crediblePairs === 0) return false;
2235
+ const longestSide = Math.max(leftLines.length, rightLines.length);
2236
+ if (longestSide <= 2 || crediblePairs * 2 >= longestSide) return true;
2237
+ const roles = [...leftLines, ...rightLines].map(collectionLineRole);
2238
+ return roles[0] !== null && roles.every((role) => role === roles[0]);
2239
+ }
2240
+ function collectionLineRole(line) {
2241
+ const normalized = line.trimStart();
2242
+ if (/^[-*+]\s+/.test(normalized)) return "bullet";
2243
+ if (/^\d+[.)]\s+/.test(normalized)) return "ordered";
2244
+ if (/^>\s+/.test(normalized)) return "quote";
2245
+ return null;
2246
+ }
1681
2247
  var MAX_ALIGNMENT_CELLS = 1e4;
1682
2248
  var MAX_SCORING_LINE_LENGTH = 2e3;
1683
2249
  var MINIMUM_MATCH_SCORE = 0.52;
@@ -1690,6 +2256,7 @@ var CONTEXTUAL_AMBIGUITY_MARGIN = 0.05;
1690
2256
  var GAP_PENALTY = -0.12;
1691
2257
  var MAX_BOUNDED_CANDIDATES = 5e3;
1692
2258
  var MAX_LARGE_HUNK_ANCHORS = 2e3;
2259
+ var MAX_ALIGNMENT_CHARACTER_CELLS = 5e7;
1693
2260
  var MAX_RARE_TOKEN_OCCURRENCES = 4;
1694
2261
  var MAX_CONTEXTUAL_HUNK_LINES = 8;
1695
2262
  var MAX_CONTEXTUAL_LENGTH_DELTA = 4;
@@ -1700,9 +2267,17 @@ function alignReplacementLines(leftLines, rightLines) {
1700
2267
  if (rightLines.length === 0) {
1701
2268
  return leftLines.map((left) => ({ left }));
1702
2269
  }
2270
+ const leftCharacterCount = leftLines.reduce((total, line) => total + line.length, 0);
2271
+ const rightCharacterCount = rightLines.reduce((total, line) => total + line.length, 0);
2272
+ if (leftCharacterCount * rightCharacterCount > MAX_ALIGNMENT_CHARACTER_CELLS) {
2273
+ return [
2274
+ ...leftLines.map((left) => ({ left })),
2275
+ ...rightLines.map((right) => ({ right }))
2276
+ ];
2277
+ }
1703
2278
  if (leftLines.length === 1 && rightLines.length === 1) {
1704
2279
  const singletonScore = scoreReplacementLinePair(leftLines[0], rightLines[0]);
1705
- if ((singletonScore.eligible || singletonScore.score >= MINIMUM_SINGLE_PAIR_SCORE) && haveCompatibleLineRoles(leftLines[0], rightLines[0]) && isInformativeLine(normalizeMatchingContent(leftLines[0])) && isInformativeLine(normalizeMatchingContent(rightLines[0]))) {
2280
+ if ((singletonScore.eligible || singletonScore.score >= MINIMUM_SINGLE_PAIR_SCORE) && hasCredibleLineCorrespondence(leftLines[0], rightLines[0]) && haveCompatibleLineRoles(leftLines[0], rightLines[0]) && isInformativeLine(normalizeMatchingContent(leftLines[0])) && isInformativeLine(normalizeMatchingContent(rightLines[0]))) {
1706
2281
  return [{ left: leftLines[0], right: rightLines[0] }];
1707
2282
  }
1708
2283
  return [{ left: leftLines[0] }, { right: rightLines[0] }];
@@ -2224,6 +2799,11 @@ function applyInlineHighlightPair(leftLine, rightLine) {
2224
2799
  rightLine.segments = rightSegments;
2225
2800
  }
2226
2801
  }
2802
+ function applyFullLineHighlight(line) {
2803
+ if (/[^\s]/.test(line.content)) {
2804
+ line.segments = [{ kind: line.kind, text: line.content, emphasis: true }];
2805
+ }
2806
+ }
2227
2807
  function buildInlineSegments(leftContent, rightContent) {
2228
2808
  const changes = diffWordsWithSpace(leftContent, rightContent);
2229
2809
  const leftSegments = [];
@@ -2375,6 +2955,125 @@ function findBlankLineBoundary(lines, target, minimum, maximum) {
2375
2955
  return Math.min(Math.max(target, minimum), maximum - 1);
2376
2956
  }
2377
2957
 
2958
+ // src/changeAttention.ts
2959
+ var CHANGE_ATTENTION_VERSION = 1;
2960
+ var LOCKFILES = /* @__PURE__ */ new Set([
2961
+ "cargo.lock",
2962
+ "package-lock.json",
2963
+ "pnpm-lock.yaml",
2964
+ "yarn.lock",
2965
+ "poetry.lock",
2966
+ "gemfile.lock",
2967
+ "composer.lock"
2968
+ ]);
2969
+ var MAX_FAN_IN_FILES = 250;
2970
+ var MAX_FAN_IN_TEXT_BYTES = 64 * 1024;
2971
+ function classifyChangeFileRole(filePath) {
2972
+ const lower = filePath.toLowerCase();
2973
+ const fileName = lower.split("/").pop() || lower;
2974
+ if (/(^|\/)(dist|build|generated|vendor)(\/|$)/.test(lower) || /(?:\.min\.js|\.map)$/.test(lower)) return "generated";
2975
+ if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower) || /\.(test|spec)\.[^/]+$/.test(lower)) return "test";
2976
+ if (lower.startsWith("docs/") || fileName === "readme.md" || /\.(md|mdx|rst)$/.test(lower)) return "documentation";
2977
+ if (LOCKFILES.has(fileName) || /(^|\/)(pyproject\.toml|package\.json|go\.mod|go\.sum|cargo\.toml)$/.test(lower)) return "dependency";
2978
+ return "production";
2979
+ }
2980
+ function buildChangeAttention(inputs) {
2981
+ const prepared = inputs.map((input) => prepareFile(input, inputs));
2982
+ const production = prepared.filter((file) => file.role === "production" && !file.mechanical);
2983
+ const documentation = prepared.filter((file) => file.role === "documentation" && !file.mechanical);
2984
+ const eligible = production.length > 0 ? production : documentation;
2985
+ const entry = [...eligible].sort(compareRanked)[0];
2986
+ const fallback = [...prepared].filter((file) => !file.binary).sort((left, right) => right.changedLines - left.changedLines || left.path.localeCompare(right.path))[0];
2987
+ const focusCandidates = prepared.filter((file) => file.path !== (entry == null ? void 0 : entry.path) && file.role === "production" && !file.mechanical).sort(compareRanked).slice(0, Math.max(0, 7 - (entry ? 1 : 0)));
2988
+ const pairedTest = entry || focusCandidates.length > 0 ? prepared.filter((file) => file.role === "test" && !file.mechanical && file.changedLines >= 5 && isTestForFocus(file.path, [entry, ...focusCandidates].filter(Boolean).map((candidate) => candidate.path))).sort(compareRanked)[0] : void 0;
2989
+ if (pairedTest && focusCandidates.length < Math.max(0, 7 - (entry ? 1 : 0))) focusCandidates.push(pairedTest);
2990
+ const focusPaths = focusCandidates.map((file) => file.path);
2991
+ const files = prepared.map((file) => {
2992
+ const attention = file.path === (entry == null ? void 0 : entry.path) ? "entry" : focusPaths.includes(file.path) ? "focus" : file.mechanical ? "mechanical" : "background";
2993
+ return {
2994
+ path: file.path,
2995
+ previousPath: file.previousPath,
2996
+ changeKind: file.changeKind,
2997
+ role: file.role,
2998
+ attention,
2999
+ additions: file.additions,
3000
+ deletions: file.deletions,
3001
+ reason: attention === "entry" ? entryReason(file, production.length) : attentionReason(file),
3002
+ score: file.score
3003
+ };
3004
+ }).sort((left, right) => left.path.localeCompare(right.path));
3005
+ return {
3006
+ version: CHANGE_ATTENTION_VERSION,
3007
+ entryPath: (entry == null ? void 0 : entry.path) || null,
3008
+ entryReason: entry ? entryReason(entry, production.length) : noEntryReason(prepared),
3009
+ fallbackPath: (fallback == null ? void 0 : fallback.path) || null,
3010
+ focusPaths,
3011
+ backgroundPaths: files.filter((file) => file.attention === "background").map((file) => file.path),
3012
+ mechanicalPaths: files.filter((file) => file.attention === "mechanical").map((file) => file.path),
3013
+ files
3014
+ };
3015
+ }
3016
+ function prepareFile(input, all) {
3017
+ const role = classifyChangeFileRole(input.path);
3018
+ const fileName = input.path.toLowerCase().split("/").pop() || input.path.toLowerCase();
3019
+ const mechanical = Boolean(input.binary) || role === "generated" || LOCKFILES.has(fileName);
3020
+ const changedLines = (input.additions ?? 0) + (input.deletions ?? 0);
3021
+ const contract = /(^|[\/._-])(types?|schema|models?|contracts?|interfaces?)([\/._-]|$)/i.test(input.path);
3022
+ const referenceHints = new Set([
3023
+ fileName.replace(/\.[^.]+$/, ""),
3024
+ ...(input.symbolHints || []).map((hint) => hint.toLowerCase())
3025
+ ].filter((hint) => hint.length >= 4));
3026
+ const fanIn = [...all].filter((candidate) => candidate.path !== input.path).sort((left, right) => left.path.localeCompare(right.path)).slice(0, MAX_FAN_IN_FILES).filter((candidate) => {
3027
+ var _a;
3028
+ const boundedText = (_a = candidate.headText) == null ? void 0 : _a.slice(0, MAX_FAN_IN_TEXT_BYTES).toLowerCase();
3029
+ return Boolean(boundedText && [...referenceHints].some((hint) => boundedText.includes(hint)));
3030
+ }).length;
3031
+ const roleScore = role === "production" ? 300 : role === "documentation" ? 150 : role === "dependency" ? 80 : role === "test" ? 50 : 0;
3032
+ const sizePenalty = changedLines > 2e3 ? 80 : changedLines > 500 ? 30 : 0;
3033
+ const readmePenalty = fileName === "readme.md" && all.filter((candidate) => !candidate.binary).length > 1 ? 100 : 0;
3034
+ return {
3035
+ ...input,
3036
+ role,
3037
+ mechanical,
3038
+ changedLines,
3039
+ contract,
3040
+ fanIn,
3041
+ binary: Boolean(input.binary),
3042
+ score: mechanical ? -1e3 : roleScore + (contract ? 100 : 0) + fanIn * 40 + (input.commitCount || 0) * 5 - sizePenalty - readmePenalty
3043
+ };
3044
+ }
3045
+ function compareRanked(left, right) {
3046
+ return right.score - left.score || right.fanIn - left.fanIn || left.changedLines - right.changedLines || left.path.localeCompare(right.path);
3047
+ }
3048
+ function entryReason(file, productionCount) {
3049
+ if (file.fanIn > 0) return `Referenced by ${file.fanIn} other changed file${file.fanIn === 1 ? "" : "s"}.`;
3050
+ if (file.contract) return "Contract-shaped path central to the change.";
3051
+ if (productionCount === 1) return "Only production file in this range.";
3052
+ return "Highest-ranked production file for a first pass.";
3053
+ }
3054
+ function attentionReason(file) {
3055
+ if (file.binary) return "Binary file.";
3056
+ if (file.mechanical) return file.role === "generated" ? "Generated artifact." : "Dependency lockfile.";
3057
+ if (file.role === "test") return "Test or proof file.";
3058
+ if (file.role === "documentation") return "Documentation change.";
3059
+ if (file.role === "dependency") return "Dependency or packaging manifest.";
3060
+ return "Additional production file.";
3061
+ }
3062
+ function noEntryReason(files) {
3063
+ if (files.every((file) => file.mechanical)) return "This change is entirely mechanical or generated.";
3064
+ if (files.every((file) => file.mechanical || file.role === "test")) return "This change contains only tests and mechanical files.";
3065
+ return "No single entry-point is reliable for this change.";
3066
+ }
3067
+ function isTestForFocus(testPath, focusPaths) {
3068
+ const normalizedTest = testPath.toLowerCase().replace(/\.(test|spec)(?=\.)/, "").replace(/^test_/, "");
3069
+ return focusPaths.some((candidate) => {
3070
+ var _a;
3071
+ const normalizedCandidate = candidate.toLowerCase();
3072
+ const stem = ((_a = normalizedCandidate.split("/").pop()) == null ? void 0 : _a.replace(/\.[^.]+$/, "")) || normalizedCandidate;
3073
+ return stem.length >= 3 && normalizedTest.includes(stem);
3074
+ });
3075
+ }
3076
+
2378
3077
  // src/changeTourContext.ts
2379
3078
  var DEFAULT_MAX_PATCH_BYTES = 128 * 1024;
2380
3079
  var DEFAULT_MAX_TOTAL_PATCH_BYTES = 2 * 1024 * 1024;
@@ -2393,8 +3092,21 @@ var CHAPTERS = {
2393
3092
  packaging: { id: "packaging", title: "Packaging and dependencies", priority: 4 }
2394
3093
  };
2395
3094
  function buildChangeTourManifest(startPath, options = {}) {
2396
- var _a, _b, _c, _d, _e;
2397
- const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
3095
+ var _a, _b, _c, _d;
3096
+ const source = options.source ? parseChangeTourSource(options.source) : void 0;
3097
+ const range = resolveBranchReviewRange(
3098
+ startPath,
3099
+ options.headRef ?? ((_a = source == null ? void 0 : source.range) == null ? void 0 : _a.head),
3100
+ options.baseRef ?? ((_b = source == null ? void 0 : source.range) == null ? void 0 : _b.base)
3101
+ );
3102
+ if (source == null ? void 0 : source.review) {
3103
+ validateChangeTourReview(source.review, {
3104
+ expectedRange: {
3105
+ baseOid: range.mergeBaseOid,
3106
+ headOid: range.headOid
3107
+ }
3108
+ });
3109
+ }
2398
3110
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_TOUR_FILE_BYTES;
2399
3111
  const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_TOUR_LINE_BYTES;
2400
3112
  const omittedFiles = [];
@@ -2402,8 +3114,9 @@ function buildChangeTourManifest(startPath, options = {}) {
2402
3114
  const sceneRecords = [];
2403
3115
  let totalAdditions = 0;
2404
3116
  let totalDeletions = 0;
3117
+ let totalBinaryFiles = 0;
2405
3118
  for (const changedPath of range.changedPaths) {
2406
- const { additions, deletions } = readGitLineStats(
3119
+ const { additions, deletions, binary } = readGitLineStats(
2407
3120
  range.repoRoot,
2408
3121
  range.mergeBaseOid,
2409
3122
  range.headOid,
@@ -2412,6 +3125,7 @@ function buildChangeTourManifest(startPath, options = {}) {
2412
3125
  );
2413
3126
  totalAdditions += additions;
2414
3127
  totalDeletions += deletions;
3128
+ if (binary) totalBinaryFiles += 1;
2415
3129
  const pair = resolveReviewPathPair(range.changedPaths, changedPath.path);
2416
3130
  if (!pair) {
2417
3131
  omittedFiles.push(changedPath.path);
@@ -2452,21 +3166,50 @@ function buildChangeTourManifest(startPath, options = {}) {
2452
3166
  }
2453
3167
  files.sort((left, right) => left.path.localeCompare(right.path));
2454
3168
  sceneRecords.sort((left, right) => left.chapter.priority - right.chapter.priority || left.scene.path.localeCompare(right.scene.path));
2455
- const defaultScenes = sceneRecords.map(({ scene }, index) => ({ ...scene, id: `file-${index + 1}` }));
2456
- if (options.story && options.source) {
3169
+ let defaultScenes = sceneRecords.map(({ scene }, index) => ({ ...scene, id: `file-${index + 1}` }));
3170
+ if (options.story && source) {
2457
3171
  throw new Error("A change tour can use either a legacy story or a source file, not both.");
2458
3172
  }
2459
- const authored = options.source ? applySource(parseChangeTourSource(options.source), defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : {
2460
- scenes: defaultScenes,
2461
- chapters: buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id])))
2462
- };
3173
+ const attention = buildChangeAttention(files.map((file) => ({
3174
+ path: file.path,
3175
+ previousPath: file.previousPath,
3176
+ changeKind: file.changeKind,
3177
+ additions: file.additions,
3178
+ deletions: file.deletions,
3179
+ binary: file.kind === "omitted",
3180
+ headText: file.kind === "text-diff" ? file.rightContent : void 0,
3181
+ symbolHints: file.kind === "text-diff" ? findDeclarationNames(file.rightContent) : []
3182
+ })));
3183
+ if (!options.story && !options.source && attention.entryPath) {
3184
+ const focusRank = (scenePath) => {
3185
+ const index = attention.focusPaths.indexOf(scenePath);
3186
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
3187
+ };
3188
+ defaultScenes = [...defaultScenes].sort((left, right) => Number(right.path === attention.entryPath) - Number(left.path === attention.entryPath) || focusRank(left.path) - focusRank(right.path) || left.id.localeCompare(right.id));
3189
+ }
3190
+ const authored = source ? applySource(source, defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
3191
+ defaultScenes,
3192
+ buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id]))),
3193
+ attention,
3194
+ {
3195
+ changedFiles: range.changedPaths.length,
3196
+ binaryFiles: totalBinaryFiles,
3197
+ additions: totalAdditions,
3198
+ deletions: totalDeletions,
3199
+ commitCount: range.commits.length
3200
+ }
3201
+ );
2463
3202
  const scenes = authored.scenes;
2464
3203
  const chapters = authored.chapters;
3204
+ const finalScenes = "finalScenes" in authored ? authored.finalScenes : scenes;
3205
+ const finalChapters = "finalChapters" in authored ? authored.finalChapters : chapters;
2465
3206
  const manifest = {
2466
- version: CHANGE_TOUR_MANIFEST_VERSION,
2467
- title: ((_a = options.source) == null ? void 0 : _a.title) || ((_b = options.story) == null ? void 0 : _b.title) || options.title || `${range.headRef} against ${range.baseRef}`,
2468
- windowTitle: (_c = options.source) == null ? void 0 : _c.windowTitle,
2469
- sourceUrl: ((_d = options.source) == null ? void 0 : _d.sourceUrl) || ((_e = options.story) == null ? void 0 : _e.sourceUrl) || options.sourceUrl,
3207
+ // Only authored v2+ files opt into repository-bound zoom modes. Generated
3208
+ // and legacy/story tours retain the portable v1 manifest contract.
3209
+ version: (source == null ? void 0 : source.version) ?? 1,
3210
+ title: (source == null ? void 0 : source.title) || ((_c = options.story) == null ? void 0 : _c.title) || options.title || `${range.headRef} against ${range.baseRef}`,
3211
+ windowTitle: source == null ? void 0 : source.windowTitle,
3212
+ sourceUrl: (source == null ? void 0 : source.sourceUrl) || ((_d = options.story) == null ? void 0 : _d.sourceUrl) || options.sourceUrl,
2470
3213
  generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
2471
3214
  range: {
2472
3215
  baseRef: range.baseRef,
@@ -2485,11 +3228,68 @@ function buildChangeTourManifest(startPath, options = {}) {
2485
3228
  commits: range.commits,
2486
3229
  files,
2487
3230
  chapters,
2488
- scenes
3231
+ scenes,
3232
+ ...(source == null ? void 0 : source.review) ? { review: source.review } : {}
2489
3233
  };
3234
+ const authoredModeTours = (source == null ? void 0 : source.version) === 3 ? buildAuthoredModeTours(
3235
+ source,
3236
+ authored,
3237
+ defaultScenes,
3238
+ range.repoRoot,
3239
+ range.mergeBaseOid,
3240
+ range.headOid
3241
+ ) : void 0;
3242
+ if (authoredModeTours) manifest.tours = authoredModeTours;
3243
+ if (manifest.version >= 2) {
3244
+ manifest.repository = { root: range.repoRoot };
3245
+ const revisions = [];
3246
+ for (const scene of scenes) {
3247
+ let realStack;
3248
+ if (scene.kind === "stacked-diff") realStack = scene;
3249
+ if (scene.kind === "deconstructed-diff") {
3250
+ const authoredScene = source == null ? void 0 : source.chapters.flatMap((chapter) => chapter.scenes).find((candidate) => candidate.id === scene.id);
3251
+ if ((authoredScene == null ? void 0 : authoredScene.kind) !== "deconstructed-diff" || !authoredScene.stack) {
3252
+ throw new Error(`Deconstructed scene ${scene.id} requires an explicit real revision stack in v2+.`);
3253
+ }
3254
+ realStack = buildStackedScene(range.repoRoot, {
3255
+ ...authoredScene,
3256
+ kind: "stacked-diff",
3257
+ overview: compileSceneOverview(authoredScene.overview, false),
3258
+ stack: authoredScene.stack,
3259
+ files: scene.files.map((file) => file.path),
3260
+ steps: []
3261
+ }, files.map((file) => file.path));
3262
+ if (realStack.stack[0].oid !== scene.realRange.baseOid || realStack.stack[realStack.stack.length - 1].oid !== scene.realRange.targetOid) {
3263
+ throw new Error(`Deconstructed scene ${scene.id} real stack endpoints must match its explanation range.`);
3264
+ }
3265
+ }
3266
+ if (realStack) {
3267
+ if (realStack.stack[0].oid !== range.mergeBaseOid || realStack.stack[realStack.stack.length - 1].oid !== range.headOid) {
3268
+ throw new Error(`Scene ${scene.id} real stack endpoints must match the tour's final diff range.`);
3269
+ }
3270
+ revisions.push(realStack);
3271
+ }
3272
+ }
3273
+ const explanation = scenes.some((scene) => scene.kind === "deconstructed-diff");
3274
+ const distinctRevisions = revisions.some((scene) => scene.stack.length > 2);
3275
+ manifest.zoom = {
3276
+ authoredDepth: explanation ? "explanation" : distinctRevisions ? "revisions" : "final",
3277
+ modes: [
3278
+ ...explanation ? ["explanation"] : [],
3279
+ ...distinctRevisions ? ["revisions"] : [],
3280
+ "final",
3281
+ "history"
3282
+ ],
3283
+ revisions,
3284
+ final: { scenes: finalScenes, chapters: finalChapters }
3285
+ };
3286
+ }
2490
3287
  return parseChangeTourManifest(manifest);
2491
3288
  }
2492
3289
  function applySource(source, defaultScenes, repoRoot) {
3290
+ return applySourceChapters(source, source.chapters, defaultScenes, repoRoot);
3291
+ }
3292
+ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot) {
2493
3293
  var _a, _b;
2494
3294
  const available = new Map(defaultScenes.map((scene) => [scene.path, scene]));
2495
3295
  const resolvedAnchors = /* @__PURE__ */ new Map();
@@ -2509,8 +3309,11 @@ function applySource(source, defaultScenes, repoRoot) {
2509
3309
  }]));
2510
3310
  const scenes = [];
2511
3311
  const chapters = [];
2512
- for (const chapter of source.chapters) {
3312
+ const finalScenes = [];
3313
+ const finalChapters = [];
3314
+ for (const chapter of chaptersToCompile) {
2513
3315
  const sceneIds = [];
3316
+ const finalSceneIds = [];
2514
3317
  for (const authoredScene of chapter.scenes) {
2515
3318
  if (authoredScene.kind === "deconstructed-diff") {
2516
3319
  const deconstructedScene = buildDeconstructedManifestScene(
@@ -2522,6 +3325,16 @@ function applySource(source, defaultScenes, repoRoot) {
2522
3325
  );
2523
3326
  scenes.push(deconstructedScene);
2524
3327
  sceneIds.push(deconstructedScene.id);
3328
+ if (authoredScene.steps) {
3329
+ const finalScene = buildWalkthroughManifestScene(
3330
+ { ...authoredScene, steps: authoredScene.steps },
3331
+ available,
3332
+ resolvedAnchors,
3333
+ connections
3334
+ );
3335
+ finalScenes.push(finalScene);
3336
+ finalSceneIds.push(finalScene.id);
3337
+ }
2525
3338
  continue;
2526
3339
  }
2527
3340
  if (authoredScene.kind === "stacked-diff") {
@@ -2532,37 +3345,105 @@ function applySource(source, defaultScenes, repoRoot) {
2532
3345
  );
2533
3346
  scenes.push(stackedScene);
2534
3347
  sceneIds.push(stackedScene.id);
3348
+ for (const [index, file] of stackedScene.files.entries()) {
3349
+ const endpoint = available.get(file.path);
3350
+ if (!endpoint) continue;
3351
+ const finalScene = { ...endpoint, id: `${stackedScene.id}-final-${index + 1}`, title: file.path };
3352
+ finalScenes.push(finalScene);
3353
+ finalSceneIds.push(finalScene.id);
3354
+ }
2535
3355
  continue;
2536
3356
  }
2537
- const scene = {
2538
- id: authoredScene.id,
2539
- kind: "walkthrough",
2540
- title: authoredScene.title,
2541
- summary: authoredScene.summary,
2542
- bullets: authoredScene.bullets,
2543
- tags: authoredScene.tags,
2544
- takeaway: authoredScene.takeaway,
2545
- steps: authoredScene.steps.map((step) => {
2546
- const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
2547
- const diff2 = available.get(focus.path);
2548
- if (!diff2) throw new Error(`Step ${step.id} has no text diff for ${focus.path}.`);
2549
- return {
2550
- id: step.id,
2551
- title: step.title,
2552
- body: step.body,
2553
- depth: step.depth,
2554
- focus,
2555
- connection: step.connection ? connections.get(step.connection) : void 0,
2556
- diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
2557
- };
2558
- })
2559
- };
3357
+ const scene = buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections);
2560
3358
  scenes.push(scene);
2561
3359
  sceneIds.push(scene.id);
3360
+ finalScenes.push(scene);
3361
+ finalSceneIds.push(scene.id);
2562
3362
  }
2563
3363
  chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3364
+ finalChapters.push({ id: chapter.id, title: chapter.title, sceneIds: finalSceneIds });
2564
3365
  }
2565
- return { scenes, chapters };
3366
+ return { scenes, chapters, finalScenes, finalChapters };
3367
+ }
3368
+ function buildAuthoredModeTours(source, root, defaultScenes, repoRoot, mergeBaseOid, headOid) {
3369
+ var _a, _b;
3370
+ const tours = {};
3371
+ if ((_a = source.tours) == null ? void 0 : _a.historical) {
3372
+ const compiled = applySourceChapters(source, source.tours.historical.chapters, defaultScenes, repoRoot);
3373
+ validateHistoricalStackEndpoints(compiled.scenes, mergeBaseOid, headOid);
3374
+ tours.historical = { chapters: compiled.chapters, scenes: compiled.scenes };
3375
+ } else if (source.version === 3) {
3376
+ const fallback = buildHistoricalFallback(source.chapters, root);
3377
+ if (fallback) tours.historical = fallback;
3378
+ }
3379
+ if ((_b = source.tours) == null ? void 0 : _b.deconstructed) {
3380
+ const compiled = applySourceChapters(source, source.tours.deconstructed.chapters, defaultScenes, repoRoot);
3381
+ tours.deconstructed = { chapters: compiled.chapters, scenes: compiled.scenes };
3382
+ } else if (source.version === 3 && source.chapters.some((chapter) => chapter.scenes.some((scene) => scene.kind === "deconstructed-diff"))) {
3383
+ tours.deconstructed = { chapters: root.chapters, scenes: root.scenes };
3384
+ }
3385
+ return tours.historical || tours.deconstructed ? tours : void 0;
3386
+ }
3387
+ function validateHistoricalStackEndpoints(scenes, mergeBaseOid, headOid) {
3388
+ for (const scene of scenes) {
3389
+ if (scene.kind !== "stacked-diff") continue;
3390
+ if (scene.stack[0].oid !== mergeBaseOid || scene.stack[scene.stack.length - 1].oid !== headOid) {
3391
+ throw new Error(`Historical scene ${scene.id} real stack endpoints must match the tour's final diff range.`);
3392
+ }
3393
+ }
3394
+ }
3395
+ function buildHistoricalFallback(sourceChapters, root) {
3396
+ const regularScenes = new Map(root.scenes.map((scene) => [scene.id, scene]));
3397
+ const finalScenes = new Map(root.finalScenes.map((scene) => [scene.id, scene]));
3398
+ const scenes = [];
3399
+ const chapters = [];
3400
+ for (const chapter of sourceChapters) {
3401
+ const sceneIds = [];
3402
+ for (const authoredScene of chapter.scenes) {
3403
+ const scene = authoredScene.kind === "deconstructed-diff" ? authoredScene.steps ? finalScenes.get(authoredScene.id) : void 0 : regularScenes.get(authoredScene.id);
3404
+ if (!scene) continue;
3405
+ scenes.push(scene);
3406
+ sceneIds.push(scene.id);
3407
+ }
3408
+ if (sceneIds.length > 0) chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3409
+ }
3410
+ return scenes.length > 0 ? { chapters, scenes } : void 0;
3411
+ }
3412
+ function compileSceneOverview(overview, includeComparison = true) {
3413
+ if (!overview) return void 0;
3414
+ return {
3415
+ kind: overview.kind,
3416
+ ...overview.path === void 0 ? {} : { path: overview.path },
3417
+ ...includeComparison && overview.comparison ? { comparison: { ...overview.comparison } } : {}
3418
+ };
3419
+ }
3420
+ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections) {
3421
+ const overview = compileSceneOverview(authoredScene.overview, false);
3422
+ return {
3423
+ id: authoredScene.id,
3424
+ kind: "walkthrough",
3425
+ title: authoredScene.title,
3426
+ summary: authoredScene.summary,
3427
+ bullets: authoredScene.bullets,
3428
+ tags: authoredScene.tags,
3429
+ takeaway: authoredScene.takeaway,
3430
+ ...overview ? { overview } : {},
3431
+ steps: authoredScene.steps.map((step) => {
3432
+ const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
3433
+ const diff2 = available.get(focus.path);
3434
+ if (!diff2) throw new Error(`Step ${step.id} has no text diff for ${focus.path}.`);
3435
+ return {
3436
+ id: step.id,
3437
+ title: step.title,
3438
+ body: step.body,
3439
+ depth: step.depth,
3440
+ requirement: step.requirement,
3441
+ focus,
3442
+ connection: step.connection ? connections.get(step.connection) : void 0,
3443
+ diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
3444
+ };
3445
+ })
3446
+ };
2566
3447
  }
2567
3448
  function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, availableFilePaths = []) {
2568
3449
  const compiled = buildDeconstructedScene(repoRoot, source, { baseRef, headRef: targetRef });
@@ -2621,6 +3502,7 @@ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, a
2621
3502
  });
2622
3503
  if (files.length === 0) throw new Error(`Deconstructed scene ${source.id} has no staged files.`);
2623
3504
  const steps = buildDeconstructedFocusSteps(source.id, compiled.stages, files);
3505
+ const overview = compileSceneOverview(source.overview);
2624
3506
  return {
2625
3507
  id: source.id,
2626
3508
  kind: "deconstructed-diff",
@@ -2629,6 +3511,7 @@ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, a
2629
3511
  bullets: source.bullets,
2630
3512
  tags: source.tags,
2631
3513
  takeaway: source.takeaway,
3514
+ ...overview ? { overview } : {},
2632
3515
  stageLabel: "Explanation stages",
2633
3516
  realRange: {
2634
3517
  baseRef: baseRef || compiled.baseOid,
@@ -2728,7 +3611,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2728
3611
  let totalContentBytes = 0;
2729
3612
  const files = filePaths.flatMap((filePath) => {
2730
3613
  const aliases = /* @__PURE__ */ new Set([filePath]);
2731
- for (const changes of adjacentChanges) {
3614
+ for (const changes of [...adjacentChanges].reverse()) {
2732
3615
  for (const change of changes) {
2733
3616
  if (aliases.has(change.path) || change.previousPath && aliases.has(change.previousPath)) {
2734
3617
  aliases.add(change.path);
@@ -2739,7 +3622,9 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2739
3622
  let fileContentBytes = 0;
2740
3623
  const panels = [];
2741
3624
  for (const entry of stack) {
2742
- const resolvedPath = [...aliases].find((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
3625
+ const candidates = [...aliases].filter((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
3626
+ if (candidates.length > 1) throw new Error(`Ambiguous revision paths for ${filePath} at ${entry.ref}: ${candidates.join(", ")}`);
3627
+ const resolvedPath = candidates[0];
2743
3628
  if (!resolvedPath) {
2744
3629
  panels.push({ id: entry.id, label: `${entry.label} / ${filePath} (missing)`, content: "", exists: false });
2745
3630
  continue;
@@ -2766,7 +3651,9 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2766
3651
  }
2767
3652
  return [{ path: filePath, panels }];
2768
3653
  });
3654
+ if (files.length === 0) throw new Error(`Stacked scene ${source.id} has no materializable text files.`);
2769
3655
  const stackIds = stack.map((entry) => entry.id);
3656
+ const overview = compileSceneOverview(source.overview);
2770
3657
  return {
2771
3658
  id: source.id,
2772
3659
  kind: "stacked-diff",
@@ -2775,9 +3662,17 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2775
3662
  bullets: source.bullets,
2776
3663
  tags: source.tags,
2777
3664
  takeaway: source.takeaway,
3665
+ ...overview ? { overview } : {},
2778
3666
  stack,
2779
3667
  files,
2780
- steps: source.steps.map((step) => {
3668
+ steps: source.steps.length === 0 ? stack.slice(0, -1).map((_, pairIndex) => ({
3669
+ id: `${source.id}-revision-${pairIndex}`,
3670
+ title: "",
3671
+ body: "",
3672
+ file: files[0].path,
3673
+ pairIndex,
3674
+ side: "right"
3675
+ })) : source.steps.map((step) => {
2781
3676
  var _a, _b;
2782
3677
  return {
2783
3678
  id: step.id,
@@ -2889,21 +3784,51 @@ function buildChapters(records, sceneIdByPath) {
2889
3784
  }
2890
3785
  function chapterForPath(filePath) {
2891
3786
  const lower = filePath.toLowerCase();
2892
- const fileName = lower.split("/").pop() || lower;
2893
- if (lower.startsWith("docs/") || fileName === "readme.md" || fileName.includes("architecture")) {
3787
+ const role = classifyChangeFileRole(filePath);
3788
+ if (role === "documentation" || lower.includes("architecture")) {
2894
3789
  return CHAPTERS.context;
2895
3790
  }
2896
- if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower)) {
3791
+ if (role === "test") {
2897
3792
  return CHAPTERS.proof;
2898
3793
  }
2899
3794
  if (/(^|\/)(models?|schema|types?|contracts?)\.[^/]+$/.test(lower)) {
2900
3795
  return CHAPTERS.contracts;
2901
3796
  }
2902
- if (/(^|\/)(package-lock\.json|.*\.lock|pyproject\.toml|package\.json)$/.test(lower)) {
3797
+ if (role === "dependency" || role === "generated") {
2903
3798
  return CHAPTERS.packaging;
2904
3799
  }
2905
3800
  return CHAPTERS.behavior;
2906
3801
  }
3802
+ function buildGeneratedTourLanding(scenes, chapters, attention, summary) {
3803
+ const entry = attention.entryPath ? `Start here: ${attention.entryPath} \u2014 ${attention.entryReason}` : `No single entry-point \u2014 ${attention.entryReason}`;
3804
+ const focus = attention.focusPaths.length > 0 ? `Pay attention: ${attention.focusPaths.join(", ")}` : "Pay attention: no additional production files were identified.";
3805
+ const skipped = attention.backgroundPaths.length + attention.mechanicalPaths.length;
3806
+ const overview = {
3807
+ id: "change-overview",
3808
+ kind: "discussion",
3809
+ title: "Change overview",
3810
+ summary: `${summary.changedFiles} files (${summary.changedFiles - summary.binaryFiles} text, ${summary.binaryFiles} binary) \xB7 +${summary.additions} \u2212${summary.deletions} \xB7 ${summary.commitCount} commits`,
3811
+ bullets: [entry, focus, `Usually skip on a first pass: ${skipped} file${skipped === 1 ? "" : "s"}. All files remain available.`],
3812
+ tags: ["start here", "first pass"],
3813
+ takeaway: attention.entryPath ? `Next: open ${attention.entryPath}.` : "Next: open the first available textual change."
3814
+ };
3815
+ return {
3816
+ scenes: [overview, ...scenes],
3817
+ chapters: [
3818
+ { id: "start-here", title: "Start here", sceneIds: [overview.id] },
3819
+ ...chapters
3820
+ ]
3821
+ };
3822
+ }
3823
+ function findDeclarationNames(content) {
3824
+ const names = /* @__PURE__ */ new Set();
3825
+ for (const line of content.split("\n")) {
3826
+ const match = line.match(/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|var)\s+([A-Za-z_$][\w$]*)/) || line.match(/^\s*(?:async\s+)?(?:class|def)\s+([A-Za-z_]\w*)/);
3827
+ if (match) names.add(match[1]);
3828
+ if (names.size >= 25) break;
3829
+ }
3830
+ return [...names];
3831
+ }
2907
3832
  function buildSceneNote(kind, additions, deletions, previousPath) {
2908
3833
  const label = formatChangeKind(kind);
2909
3834
  const rename = previousPath ? ` from ${previousPath}` : "";
@@ -2966,19 +3891,31 @@ function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
2966
3891
  const [additionsText = "0", deletionsText = "0"] = output.trim().split(/\s+/, 3);
2967
3892
  return {
2968
3893
  additions: Number.parseInt(additionsText, 10) || 0,
2969
- deletions: Number.parseInt(deletionsText, 10) || 0
3894
+ deletions: Number.parseInt(deletionsText, 10) || 0,
3895
+ binary: additionsText === "-" || deletionsText === "-"
2970
3896
  };
2971
3897
  }
2972
3898
 
2973
3899
  // src/tourCoverage.ts
2974
- var TOUR_COVERAGE_REPORT_VERSION = 1;
3900
+ var TOUR_COVERAGE_REPORT_VERSION = 2;
3901
+ function buildTourAuthoringCoverage(report) {
3902
+ return {
3903
+ walkthrough: {
3904
+ scope: report.version === 2 ? "final" : "tour",
3905
+ coveredUnits: report.totals.coveredUnits,
3906
+ includedUnits: report.totals.includedUnits,
3907
+ coveragePercent: report.totals.coveragePercent
3908
+ },
3909
+ explanationAssignments: report.version === 2 ? report.explanationAssignments : []
3910
+ };
3911
+ }
2975
3912
  var DEPTH_ORDER = {
2976
3913
  mentioned: 0,
2977
3914
  explained: 1,
2978
3915
  contextualized: 2
2979
3916
  };
2980
3917
  function buildTourCoverageReport(startPath, sourceValue) {
2981
- var _a, _b, _c, _d, _e;
3918
+ var _a, _b, _c, _d, _e, _f, _g, _h;
2982
3919
  const source = parseChangeTourSource(sourceValue);
2983
3920
  const inventory = buildChangeInventory(startPath, {
2984
3921
  headRef: (_a = source.range) == null ? void 0 : _a.head,
@@ -2994,7 +3931,11 @@ function buildTourCoverageReport(startPath, sourceValue) {
2994
3931
  const includedUnits = textualUnits.filter((unit) => !excludedIds.has(unit.id));
2995
3932
  const coverage = /* @__PURE__ */ new Map();
2996
3933
  const contextualOnlyEvidence = [];
2997
- for (const scene of manifest.scenes) {
3934
+ const percentageDecimalPlaces = manifest.version >= 2 ? 2 : 0;
3935
+ const authoredHistoricalScenes = (_f = (_e = manifest.tours) == null ? void 0 : _e.historical) == null ? void 0 : _f.scenes;
3936
+ const historicalHasWalkthrough = (authoredHistoricalScenes == null ? void 0 : authoredHistoricalScenes.some((scene) => scene.kind === "walkthrough")) === true;
3937
+ const walkthroughScenes = manifest.version >= 2 ? requireFinalScenes(historicalHasWalkthrough ? authoredHistoricalScenes : (_g = manifest.zoom) == null ? void 0 : _g.final.scenes) : historicalHasWalkthrough ? authoredHistoricalScenes : manifest.scenes;
3938
+ for (const scene of walkthroughScenes) {
2998
3939
  if (scene.kind !== "walkthrough") continue;
2999
3940
  for (const step of scene.steps) {
3000
3941
  const matches = includedUnits.filter((unit) => evidenceIntersectsUnit(step.focus, unit));
@@ -3015,11 +3956,10 @@ function buildTourCoverageReport(startPath, sourceValue) {
3015
3956
  const highest = covered.depths.reduce((left, right) => DEPTH_ORDER[right] > DEPTH_ORDER[left] ? right : left);
3016
3957
  depth[highest] += 1;
3017
3958
  }
3018
- const files = inventory.files.filter((file) => file.material === "text").map((file) => buildFileResult(file, excludedIds, coverage));
3959
+ const files = inventory.files.filter((file) => file.material === "text").map((file) => buildFileResult(file, excludedIds, coverage, percentageDecimalPlaces));
3019
3960
  const includedChangedLines = includedUnits.reduce((total, unit) => total + unit.additions + unit.deletions, 0);
3020
3961
  const coveredChangedLines = [...coverage.values()].reduce((total, entry) => total + entry.unit.additions + entry.unit.deletions, 0);
3021
- return {
3022
- version: TOUR_COVERAGE_REPORT_VERSION,
3962
+ const report = {
3023
3963
  range: {
3024
3964
  baseRef: inventory.range.baseRef,
3025
3965
  headRef: inventory.range.headRef,
@@ -3032,13 +3972,13 @@ function buildTourCoverageReport(startPath, sourceValue) {
3032
3972
  includedUnits: includedUnits.length,
3033
3973
  coveredUnits: coverage.size,
3034
3974
  uncoveredUnits: includedUnits.length - coverage.size,
3035
- coveragePercent: percentage(coverage.size, includedUnits.length),
3975
+ coveragePercent: percentage(coverage.size, includedUnits.length, percentageDecimalPlaces),
3036
3976
  coveredChangedLines,
3037
3977
  includedChangedLines
3038
3978
  },
3039
3979
  depth,
3040
3980
  files,
3041
- exclusions: (((_e = source.coverage) == null ? void 0 : _e.exclusions) || []).map((exclusion) => {
3981
+ exclusions: (((_h = source.coverage) == null ? void 0 : _h.exclusions) || []).map((exclusion) => {
3042
3982
  var _a2;
3043
3983
  return {
3044
3984
  path: exclusion.path,
@@ -3049,6 +3989,36 @@ function buildTourCoverageReport(startPath, sourceValue) {
3049
3989
  unsupported: inventory.files.filter((file) => file.material !== "text").map((file) => ({ path: file.path, material: file.material })),
3050
3990
  contextualOnlyEvidence
3051
3991
  };
3992
+ if (manifest.version === 1) return { version: 1, ...report };
3993
+ return {
3994
+ version: TOUR_COVERAGE_REPORT_VERSION,
3995
+ ...report,
3996
+ walkthroughScope: "final",
3997
+ explanationAssignments: buildExplanationAssignments(source, inventory.files)
3998
+ };
3999
+ }
4000
+ function requireFinalScenes(scenes) {
4001
+ if (!scenes) throw new Error("A v2 tour requires Final-mode scenes for coverage reporting.");
4002
+ return scenes;
4003
+ }
4004
+ function buildExplanationAssignments(source, files) {
4005
+ var _a, _b;
4006
+ const materializableFiles = files.filter((file) => file.material === "text" && file.changeKind !== "renamed" && file.baseContent !== void 0 && file.headContent !== void 0);
4007
+ const totalUnits = materializableFiles.reduce((total, file) => total + file.units.length, 0);
4008
+ const deconstructedChapters = ((_b = (_a = source.tours) == null ? void 0 : _a.deconstructed) == null ? void 0 : _b.chapters) || source.chapters;
4009
+ return deconstructedChapters.flatMap((chapter) => chapter.scenes).filter((scene) => scene.kind === "deconstructed-diff").map((scene) => {
4010
+ const assignedUnits = scene.stages.reduce((stageTotal, stage) => stageTotal + stage.changes.reduce((changeTotal, change) => changeTotal + change.hunks.length, 0), 0);
4011
+ const excludedUnits = totalUnits - assignedUnits;
4012
+ const completeUnits = assignedUnits + excludedUnits;
4013
+ return {
4014
+ sceneId: scene.id,
4015
+ assignedUnits,
4016
+ excludedUnits,
4017
+ completeUnits,
4018
+ totalUnits,
4019
+ assignmentPercent: percentage(completeUnits, totalUnits)
4020
+ };
4021
+ });
3052
4022
  }
3053
4023
  function resolveExclusions(source, files) {
3054
4024
  var _a, _b, _c;
@@ -3075,7 +4045,7 @@ function evidenceIntersectsUnit(focus, unit) {
3075
4045
  const end = start + count - 1;
3076
4046
  return focus.startLine <= end && focus.endLine >= start;
3077
4047
  }
3078
- function buildFileResult(file, excludedIds, coverage) {
4048
+ function buildFileResult(file, excludedIds, coverage, percentageDecimalPlaces) {
3079
4049
  const included = file.units.filter((unit) => !excludedIds.has(unit.id));
3080
4050
  const covered = included.filter((unit) => coverage.has(unit.id));
3081
4051
  return {
@@ -3083,16 +4053,19 @@ function buildFileResult(file, excludedIds, coverage) {
3083
4053
  originalUnits: file.units.length,
3084
4054
  includedUnits: included.length,
3085
4055
  coveredUnits: covered.length,
3086
- coveragePercent: percentage(covered.length, included.length),
4056
+ coveragePercent: percentage(covered.length, included.length, percentageDecimalPlaces),
3087
4057
  uncoveredHunks: included.filter((unit) => !coverage.has(unit.id)).map((unit) => unit.id)
3088
4058
  };
3089
4059
  }
3090
- function percentage(numerator, denominator) {
3091
- return denominator === 0 ? 100 : Math.round(numerator / denominator * 100);
4060
+ function percentage(numerator, denominator, decimalPlaces = 0) {
4061
+ if (denominator === 0) return 100;
4062
+ const precision = 10 ** decimalPlaces;
4063
+ return Math.round(numerator / denominator * 100 * precision) / precision;
3092
4064
  }
3093
4065
  // Annotate the CommonJS export names for ESM import in node:
3094
4066
  0 && (module.exports = {
3095
4067
  TOUR_COVERAGE_REPORT_VERSION,
4068
+ buildTourAuthoringCoverage,
3096
4069
  buildTourCoverageReport
3097
4070
  });
3098
4071
  //# sourceMappingURL=tourCoverage.js.map