@davmash/bygone 0.8.13 → 0.8.16

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,330 @@ 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 = 2;
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 !== CHANGE_TOUR_SOURCE_VERSION) {
486
+ throw new Error("Unsupported or missing change-tour source version.");
487
+ }
488
+ requireOnlyKeys(value, ["version", "title", "windowTitle", "sourceUrl", "range", "anchors", "connections", "chapters", "coverage"], "source");
489
+ optionalString(value.title, "title");
490
+ optionalString(value.windowTitle, "windowTitle");
491
+ optionalString(value.sourceUrl, "sourceUrl");
492
+ if (value.range !== void 0) {
493
+ if (!isRecord(value.range)) throw new Error("range must be an object.");
494
+ requireOnlyKeys(value.range, ["base", "head"], "range");
495
+ requireString(value.range.base, "range.base");
496
+ requireString(value.range.head, "range.head");
497
+ }
498
+ if (value.coverage !== void 0) {
499
+ if (!isRecord(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
500
+ throw new Error("coverage must contain an exclusions array.");
501
+ }
502
+ requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
503
+ for (const [index, exclusion] of value.coverage.exclusions.entries()) {
504
+ const exclusionPath = `coverage.exclusions[${index}]`;
505
+ if (!isRecord(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
506
+ requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
507
+ requireString(exclusion.path, `${exclusionPath}.path`);
508
+ requireString(exclusion.reason, `${exclusionPath}.reason`);
509
+ if (exclusion.hunks !== void 0) requireStringArray(exclusion.hunks, `${exclusionPath}.hunks`);
510
+ }
511
+ }
512
+ if (!isRecord(value.anchors)) {
513
+ throw new Error("anchors must be an object.");
514
+ }
515
+ for (const [id, anchor] of Object.entries(value.anchors)) {
516
+ if (!isRecord(anchor)) {
517
+ throw new Error(`anchors.${id} must be an object.`);
518
+ }
519
+ requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
520
+ requireString(anchor.file, `anchors.${id}.file`);
521
+ if (anchor.revision !== "base" && anchor.revision !== "head") {
522
+ throw new Error(`anchors.${id}.revision must be base or head.`);
523
+ }
524
+ requireString(anchor.contains, `anchors.${id}.contains`);
525
+ if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
526
+ throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
527
+ }
528
+ }
529
+ const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord(connection) ? { id, ...connection } : connection) : null;
530
+ if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
531
+ throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
532
+ }
533
+ const connectionIds = /* @__PURE__ */ new Set();
534
+ for (const [index, connection] of rawConnections.entries()) {
535
+ if (!isRecord(connection)) {
536
+ throw new Error(`connections[${index}] must be an object.`);
537
+ }
538
+ requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
539
+ requireString(connection.id, `connections[${index}].id`);
540
+ requireString(connection.from, `connections[${index}].from`);
541
+ requireString(connection.to, `connections[${index}].to`);
542
+ requireString(connection.label, `connections[${index}].label`);
543
+ const { id, from, to } = connection;
544
+ if (!value.anchors[from] || !value.anchors[to]) {
545
+ throw new Error(`Connection ${id} references an unknown anchor.`);
546
+ }
547
+ if (connectionIds.has(id)) {
548
+ throw new Error(`Duplicate connection id: ${id}`);
549
+ }
550
+ connectionIds.add(id);
551
+ }
552
+ const chapterIds = /* @__PURE__ */ new Set();
553
+ const sceneIds = /* @__PURE__ */ new Set();
554
+ for (const [chapterIndex, chapter] of value.chapters.entries()) {
555
+ if (!isRecord(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
556
+ throw new Error(`chapters[${chapterIndex}] must contain a non-empty scenes array.`);
557
+ }
558
+ requireString(chapter.id, `chapters[${chapterIndex}].id`);
559
+ requireString(chapter.title, `chapters[${chapterIndex}].title`);
560
+ requireOnlyKeys(chapter, ["id", "title", "scenes"], `chapters[${chapterIndex}]`);
561
+ if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
562
+ chapterIds.add(chapter.id);
563
+ for (const [sceneIndex, scene] of chapter.scenes.entries()) {
564
+ const path = `chapters[${chapterIndex}].scenes[${sceneIndex}]`;
565
+ if (!isRecord(scene)) throw new Error(`${path} must be an object.`);
566
+ requireString(scene.id, `${path}.id`);
567
+ requireString(scene.title, `${path}.title`);
568
+ if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
569
+ sceneIds.add(scene.id);
570
+ validateNarrative(scene, path);
571
+ if (scene.kind === "deconstructed-diff") {
572
+ validateDeconstructedScene(scene, path);
573
+ if (value.version === 2 || scene.stack !== void 0) validateRealStack(scene.stack, `${path}.stack`, 2);
574
+ if (value.version === 2 || scene.steps !== void 0) {
575
+ validateWalkthroughSteps(scene, path, value.anchors, connectionIds);
576
+ }
577
+ continue;
578
+ }
579
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
580
+ throw new Error(`${path} must contain a non-empty steps array.`);
581
+ }
582
+ if (scene.kind === "stacked-diff") {
583
+ validateStackedScene(scene, path, value.version === 2 ? 2 : 3);
584
+ continue;
585
+ }
586
+ if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
587
+ throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
588
+ }
589
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "steps"], path);
590
+ validateWalkthroughSteps(scene, path, value.anchors, connectionIds);
591
+ }
592
+ }
593
+ return { ...value, connections: rawConnections };
594
+ }
595
+ function validateDeconstructedScene(scene, path) {
596
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "base", "target", "stack", "steps", "stages", "exclusions"], path);
597
+ optionalString(scene.base, `${path}.base`);
598
+ optionalString(scene.target, `${path}.target`);
599
+ if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
600
+ throw new Error(`${path}.stages must contain between 1 and 12 stages.`);
601
+ }
602
+ const stageIds = /* @__PURE__ */ new Set();
603
+ for (const [stageIndex, stage] of scene.stages.entries()) {
604
+ const stagePath = `${path}.stages[${stageIndex}]`;
605
+ if (!isRecord(stage)) throw new Error(`${stagePath} must be an object.`);
606
+ requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
607
+ requireString(stage.id, `${stagePath}.id`);
608
+ requireString(stage.title, `${stagePath}.title`);
609
+ requireString(stage.narration, `${stagePath}.narration`);
610
+ if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
611
+ throw new Error(`${stagePath}.changes must be a non-empty array.`);
612
+ }
613
+ if (stageIds.has(stage.id)) throw new Error(`Duplicate deconstructed stage id: ${stage.id}`);
614
+ stageIds.add(stage.id);
615
+ stage.changes.forEach((change, changeIndex) => validateDeconstructedSelection(
616
+ change,
617
+ `${stagePath}.changes[${changeIndex}]`,
618
+ false
619
+ ));
620
+ }
621
+ if (scene.exclusions !== void 0) {
622
+ if (!Array.isArray(scene.exclusions)) throw new Error(`${path}.exclusions must be an array.`);
623
+ scene.exclusions.forEach((exclusion, exclusionIndex) => validateDeconstructedSelection(
624
+ exclusion,
625
+ `${path}.exclusions[${exclusionIndex}]`,
626
+ true
627
+ ));
628
+ }
629
+ }
630
+ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
631
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
632
+ throw new Error(`${path} must contain a non-empty steps array.`);
633
+ }
634
+ const stepIds = /* @__PURE__ */ new Set();
635
+ for (const [stepIndex, step] of scene.steps.entries()) {
636
+ const stepPath = `${path}.steps[${stepIndex}]`;
637
+ if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
638
+ requireString(step.id, `${stepPath}.id`);
639
+ requireString(step.title, `${stepPath}.title`);
640
+ requireString(step.body, `${stepPath}.body`);
641
+ requireString(step.focus, `${stepPath}.focus`);
642
+ requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth", "requirement"], stepPath);
643
+ if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
644
+ throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
645
+ }
646
+ validateStepRequirement(step.requirement, `${stepPath}.requirement`);
647
+ if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
648
+ stepIds.add(step.id);
649
+ if (!anchors[step.focus]) throw new Error(`${stepPath} references unknown anchor ${step.focus}.`);
650
+ optionalString(step.connection, `${stepPath}.connection`);
651
+ if (typeof step.connection === "string" && !connectionIds.has(step.connection)) {
652
+ throw new Error(`${stepPath} references unknown connection ${step.connection}.`);
653
+ }
654
+ }
655
+ }
656
+ function validateDeconstructedSelection(value, path, requiresReason) {
657
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
658
+ requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
659
+ requireString(value.file, `${path}.file`);
660
+ if (!requiresReason || value.hunks !== void 0) {
661
+ requireStringArray(value.hunks, `${path}.hunks`);
662
+ if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
663
+ }
664
+ if (requiresReason) requireString(value.reason, `${path}.reason`);
665
+ }
666
+ function validateRealStack(stack, path, minimum) {
667
+ if (!Array.isArray(stack) || stack.length < minimum || stack.length > 6) {
668
+ throw new Error(`${path} must contain between ${minimum} and 6 explicit real revisions.`);
669
+ }
670
+ const ids = /* @__PURE__ */ new Set();
671
+ for (const [index, entry] of stack.entries()) {
672
+ if (!isRecord(entry)) throw new Error(`${path}[${index}] must be an object.`);
673
+ requireOnlyKeys(entry, ["id", "ref", "label"], `${path}[${index}]`);
674
+ requireString(entry.id, `${path}[${index}].id`);
675
+ requireString(entry.ref, `${path}[${index}].ref`);
676
+ optionalString(entry.label, `${path}[${index}].label`);
677
+ if (ids.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
678
+ ids.add(entry.id);
679
+ }
680
+ }
681
+ function validateStackedScene(scene, path, minimum = 3) {
682
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "stack", "files", "steps"], path);
683
+ if (!Array.isArray(scene.stack) || scene.stack.length < minimum || scene.stack.length > 6) {
684
+ throw new Error(`${path}.stack must contain between ${minimum} and 6 revisions.`);
685
+ }
686
+ const stackIds = /* @__PURE__ */ new Set();
687
+ for (const [index, entry] of scene.stack.entries()) {
688
+ const entryPath = `${path}.stack[${index}]`;
689
+ if (!isRecord(entry)) throw new Error(`${entryPath} must be an object.`);
690
+ requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
691
+ requireString(entry.id, `${entryPath}.id`);
692
+ requireString(entry.ref, `${entryPath}.ref`);
693
+ optionalString(entry.label, `${entryPath}.label`);
694
+ if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
695
+ stackIds.add(entry.id);
696
+ }
697
+ if (scene.files !== void 0) requireStringArray(scene.files, `${path}.files`);
698
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
699
+ const stepIds = /* @__PURE__ */ new Set();
700
+ for (const [index, step] of scene.steps.entries()) {
701
+ const stepPath = `${path}.steps[${index}]`;
702
+ if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
703
+ requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
704
+ requireString(step.id, `${stepPath}.id`);
705
+ requireString(step.title, `${stepPath}.title`);
706
+ requireString(step.body, `${stepPath}.body`);
707
+ requireString(step.file, `${stepPath}.file`);
708
+ const stepId = step.id;
709
+ if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
710
+ stepIds.add(stepId);
711
+ if (!Array.isArray(step.pair) || step.pair.length !== 2 || step.pair.some((id) => typeof id !== "string")) {
712
+ throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
713
+ }
714
+ const pair = step.pair;
715
+ const leftIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[0]);
716
+ const rightIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[1]);
717
+ if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
718
+ if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
719
+ 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]))) {
720
+ throw new Error(`${stepPath}.lines must contain an ordered positive line range.`);
721
+ }
722
+ }
723
+ }
724
+ function validateStepRequirement(value, path) {
725
+ if (value === void 0) return;
726
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
727
+ requireOnlyKeys(value, ["id", "text", "status", "source", "confidence"], path);
728
+ requireString(value.id, `${path}.id`);
729
+ requireString(value.text, `${path}.text`);
730
+ if (value.status !== void 0 && !["fulfilled", "gap"].includes(String(value.status))) {
731
+ throw new Error(`${path}.status must be fulfilled or gap.`);
732
+ }
733
+ optionalString(value.source, `${path}.source`);
734
+ if (value.confidence !== void 0 && !["high", "medium", "low"].includes(String(value.confidence))) {
735
+ throw new Error(`${path}.confidence must be high, medium, or low.`);
736
+ }
737
+ }
738
+ function validateNarrative(value, path) {
739
+ requireString(value.summary, `${path}.summary`);
740
+ requireStringArray(value.bullets, `${path}.bullets`);
741
+ requireStringArray(value.tags, `${path}.tags`);
742
+ requireString(value.takeaway, `${path}.takeaway`);
743
+ }
744
+ function isRecord(value) {
745
+ return typeof value === "object" && value !== null && !Array.isArray(value);
746
+ }
747
+ function requireString(value, path) {
748
+ if (typeof value !== "string" || value.length === 0) {
749
+ throw new Error(`${path} must be a non-empty string.`);
750
+ }
751
+ }
752
+ function optionalString(value, path) {
753
+ if (value !== void 0) {
754
+ requireString(value, path);
755
+ }
756
+ }
757
+ function requireStringArray(value, path) {
758
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
759
+ throw new Error(`${path} must be an array of strings.`);
760
+ }
761
+ }
762
+ function requireOnlyKeys(value, allowed, path) {
763
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
764
+ if (unexpected.length > 0) {
765
+ throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
766
+ }
767
+ }
768
+
476
769
  // src/changeTourManifest.ts
477
- var CHANGE_TOUR_MANIFEST_VERSION = 1;
770
+ var CHANGE_TOUR_MANIFEST_VERSION = 2;
478
771
  function parseChangeTourManifest(value) {
479
- if (!isRecord(value) || value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
772
+ if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
773
+ throw new Error(
774
+ `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.`
775
+ );
776
+ }
777
+ if (!isRecord2(value) || value.version !== 1 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
480
778
  throw new Error(`Unsupported or missing change-tour manifest version.`);
481
779
  }
482
- requireString(value.title, "title");
483
- requireString(value.generatedAt, "generatedAt");
780
+ requireString2(value.title, "title");
781
+ requireString2(value.generatedAt, "generatedAt");
484
782
  if (value.windowTitle !== void 0) {
485
- requireString(value.windowTitle, "windowTitle");
783
+ requireString2(value.windowTitle, "windowTitle");
486
784
  }
487
785
  if (value.sourceUrl !== void 0) {
488
- requireString(value.sourceUrl, "sourceUrl");
786
+ requireString2(value.sourceUrl, "sourceUrl");
489
787
  }
490
- if (!isRecord(value.range)) {
788
+ if (!isRecord2(value.range)) {
491
789
  throw new Error("Change-tour manifest range must be an object.");
492
790
  }
493
791
  for (const key of ["baseRef", "headRef", "mergeBaseOid", "headOid"]) {
494
- requireString(value.range[key], `range.${key}`);
792
+ requireString2(value.range[key], `range.${key}`);
495
793
  }
496
- if (!isRecord(value.summary)) {
794
+ if (!isRecord2(value.summary)) {
497
795
  throw new Error("Change-tour manifest summary must be an object.");
498
796
  }
499
797
  for (const key of ["changedFiles", "includedScenes", "additions", "deletions", "commitCount"]) {
500
798
  requireNonNegativeInteger(value.summary[key], `summary.${key}`);
501
799
  }
502
- requireStringArray(value.summary.omittedFiles, "summary.omittedFiles");
800
+ requireStringArray2(value.summary.omittedFiles, "summary.omittedFiles");
503
801
  if (!Array.isArray(value.commits) || !Array.isArray(value.chapters) || !Array.isArray(value.scenes)) {
504
802
  throw new Error("Change-tour manifest commits, chapters, and scenes must be arrays.");
505
803
  }
@@ -515,12 +813,12 @@ function parseChangeTourManifest(value) {
515
813
  sceneIds.add(candidate.id);
516
814
  }
517
815
  for (const [index, candidate] of value.chapters.entries()) {
518
- if (!isRecord(candidate)) {
816
+ if (!isRecord2(candidate)) {
519
817
  throw new Error(`chapters[${index}] must be an object.`);
520
818
  }
521
- requireString(candidate.id, `chapters[${index}].id`);
522
- requireString(candidate.title, `chapters[${index}].title`);
523
- requireStringArray(candidate.sceneIds, `chapters[${index}].sceneIds`);
819
+ requireString2(candidate.id, `chapters[${index}].id`);
820
+ requireString2(candidate.title, `chapters[${index}].title`);
821
+ requireStringArray2(candidate.sceneIds, `chapters[${index}].sceneIds`);
524
822
  for (const sceneId of candidate.sceneIds) {
525
823
  if (!sceneIds.has(sceneId)) {
526
824
  throw new Error(`Chapter references unknown scene id: ${sceneId}`);
@@ -537,16 +835,108 @@ function parseChangeTourManifest(value) {
537
835
  if (value.files !== void 0 && value.summary.changedFiles !== files.length) {
538
836
  throw new Error("summary.changedFiles must match the number of files.");
539
837
  }
838
+ if (value.authoringCoverage !== void 0) {
839
+ validateAuthoringCoverage(value.authoringCoverage, value.version);
840
+ }
841
+ if (value.version === 2) validateZoom(value);
540
842
  return { ...value, files };
541
843
  }
844
+ function validateAuthoringCoverage(value, version) {
845
+ if (!isRecord2(value) || !isRecord2(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
846
+ throw new Error("authoringCoverage must contain walkthrough and explanationAssignments.");
847
+ }
848
+ const expectedScope = version === 2 ? "final" : "tour";
849
+ if (value.walkthrough.scope !== expectedScope) {
850
+ throw new Error(`authoringCoverage.walkthrough.scope must be ${expectedScope}.`);
851
+ }
852
+ requireNonNegativeInteger(value.walkthrough.coveredUnits, "authoringCoverage.walkthrough.coveredUnits");
853
+ requireNonNegativeInteger(value.walkthrough.includedUnits, "authoringCoverage.walkthrough.includedUnits");
854
+ requirePercentage(value.walkthrough.coveragePercent, "authoringCoverage.walkthrough.coveragePercent");
855
+ if (value.walkthrough.coveredUnits > value.walkthrough.includedUnits) {
856
+ throw new Error("authoringCoverage.walkthrough.coveredUnits must not exceed includedUnits.");
857
+ }
858
+ for (const [index, assignment] of value.explanationAssignments.entries()) {
859
+ const path = `authoringCoverage.explanationAssignments[${index}]`;
860
+ if (!isRecord2(assignment)) throw new Error(`${path} must be an object.`);
861
+ requireString2(assignment.sceneId, `${path}.sceneId`);
862
+ requireNonNegativeInteger(assignment.assignedUnits, `${path}.assignedUnits`);
863
+ requireNonNegativeInteger(assignment.excludedUnits, `${path}.excludedUnits`);
864
+ requireNonNegativeInteger(assignment.completeUnits, `${path}.completeUnits`);
865
+ requireNonNegativeInteger(assignment.totalUnits, `${path}.totalUnits`);
866
+ requirePercentage(assignment.assignmentPercent, `${path}.assignmentPercent`);
867
+ if (assignment.assignedUnits + assignment.excludedUnits !== assignment.completeUnits || assignment.completeUnits > assignment.totalUnits) {
868
+ throw new Error(`${path} contains inconsistent unit counts.`);
869
+ }
870
+ }
871
+ }
872
+ function validateZoom(value) {
873
+ if (!isRecord2(value.repository) || typeof value.repository.root !== "string" || !value.repository.root) {
874
+ throw new Error("A v2 tour requires its originating repository.root.");
875
+ }
876
+ if (!isRecord2(value.zoom) || !Array.isArray(value.zoom.revisions) || !isRecord2(value.zoom.final)) {
877
+ throw new Error("A v2 tour requires zoom metadata and real revision evidence.");
878
+ }
879
+ const scenes = value.scenes;
880
+ const revisions = value.zoom.revisions;
881
+ const ids = /* @__PURE__ */ new Set();
882
+ for (const [index, scene] of revisions.entries()) {
883
+ validateScene(scene, index);
884
+ if (scene.kind !== "stacked-diff") throw new Error("zoom.revisions must contain real stacked-diff scenes.");
885
+ if (ids.has(scene.id)) throw new Error(`Duplicate zoom revision scene: ${scene.id}`);
886
+ ids.add(scene.id);
887
+ const range = value.range;
888
+ if (scene.stack[0].oid !== range.mergeBaseOid || scene.stack[scene.stack.length - 1].oid !== range.headOid) {
889
+ throw new Error(`Revision scene ${scene.id} endpoints must match the final diff range.`);
890
+ }
891
+ }
892
+ for (const scene of scenes) {
893
+ if (scene.kind !== "deconstructed-diff" && scene.kind !== "stacked-diff") continue;
894
+ const real = revisions.find((candidate) => candidate.id === scene.id);
895
+ if (!real) throw new Error(`Scene ${scene.id} is missing its real revision evidence.`);
896
+ if (scene.kind === "deconstructed-diff" && (real.stack[0].oid !== scene.realRange.baseOid || real.stack[real.stack.length - 1].oid !== scene.realRange.targetOid)) {
897
+ throw new Error(`Deconstructed scene ${scene.id} real stack endpoints must match its explanation range.`);
898
+ }
899
+ }
900
+ const explanation = scenes.some((scene) => scene.kind === "deconstructed-diff");
901
+ const distinct = revisions.some((scene) => scene.stack.length > 2);
902
+ const depth = explanation ? "explanation" : distinct ? "revisions" : "final";
903
+ const modes = [...explanation ? ["explanation"] : [], ...distinct ? ["revisions"] : [], "final", "history"];
904
+ if (value.zoom.authoredDepth !== depth || JSON.stringify(value.zoom.modes) !== JSON.stringify(modes)) {
905
+ throw new Error("zoom depth and modes must match the maximum authored depth.");
906
+ }
907
+ if (!Array.isArray(value.files)) throw new Error("A v2 tour requires final endpoint file evidence.");
908
+ if (!Array.isArray(value.zoom.final.scenes) || !Array.isArray(value.zoom.final.chapters)) {
909
+ throw new Error("A v2 tour requires a non-stacked final tour.");
910
+ }
911
+ const finalSceneIds = /* @__PURE__ */ new Set();
912
+ for (const [index, scene] of value.zoom.final.scenes.entries()) {
913
+ validateScene(scene, index);
914
+ if (scene.kind === "stacked-diff" || scene.kind === "deconstructed-diff") {
915
+ throw new Error("zoom.final must contain only non-stacked scenes.");
916
+ }
917
+ if (finalSceneIds.has(scene.id)) throw new Error(`Duplicate final tour scene: ${scene.id}`);
918
+ finalSceneIds.add(scene.id);
919
+ }
920
+ for (const [index, chapter] of value.zoom.final.chapters.entries()) {
921
+ if (!isRecord2(chapter) || !Array.isArray(chapter.sceneIds)) {
922
+ throw new Error(`zoom.final.chapters[${index}] must contain sceneIds.`);
923
+ }
924
+ requireString2(chapter.id, `zoom.final.chapters[${index}].id`);
925
+ requireString2(chapter.title, `zoom.final.chapters[${index}].title`);
926
+ for (const sceneId of chapter.sceneIds) {
927
+ requireString2(sceneId, `zoom.final.chapters[${index}].sceneIds`);
928
+ if (!finalSceneIds.has(sceneId)) throw new Error(`Final tour chapter references unknown scene: ${sceneId}`);
929
+ }
930
+ }
931
+ }
542
932
  function validateScene(value, index) {
543
- if (!isRecord(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
933
+ if (!isRecord2(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
544
934
  throw new Error(`scenes[${index}] must be a text-diff, discussion, walkthrough, stacked-diff, or deconstructed-diff scene.`);
545
935
  }
546
936
  for (const key of ["id", "title"]) {
547
- requireString(value[key], `scenes[${index}].${key}`);
937
+ requireString2(value[key], `scenes[${index}].${key}`);
548
938
  }
549
- validateNarrative(value, `scenes[${index}]`);
939
+ validateNarrative2(value, `scenes[${index}]`);
550
940
  if (value.kind === "discussion") {
551
941
  return;
552
942
  }
@@ -556,20 +946,21 @@ function validateScene(value, index) {
556
946
  }
557
947
  for (const [stepIndex, step] of value.steps.entries()) {
558
948
  const path = `scenes[${index}].steps[${stepIndex}]`;
559
- if (!isRecord(step) || !isRecord(step.focus) || !isRecord(step.diff)) {
949
+ if (!isRecord2(step) || !isRecord2(step.focus) || !isRecord2(step.diff)) {
560
950
  throw new Error(`${path} must contain focus and diff objects.`);
561
951
  }
562
- for (const key of ["id", "title", "body"]) requireString(step[key], `${path}.${key}`);
952
+ for (const key of ["id", "title", "body"]) requireString2(step[key], `${path}.${key}`);
563
953
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
564
954
  throw new Error(`${path}.depth must be mentioned, explained, or contextualized.`);
565
955
  }
956
+ validateStepRequirement(step.requirement, `${path}.requirement`);
566
957
  validateResolvedAnchor(step.focus, `${path}.focus`);
567
958
  validateScene(step.diff, index);
568
959
  if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
569
960
  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`);
961
+ if (!isRecord2(step.connection)) throw new Error(`${path}.connection must be an object.`);
962
+ requireString2(step.connection.id, `${path}.connection.id`);
963
+ requireString2(step.connection.label, `${path}.connection.label`);
573
964
  validateResolvedAnchor(step.connection.from, `${path}.connection.from`);
574
965
  validateResolvedAnchor(step.connection.to, `${path}.connection.to`);
575
966
  }
@@ -577,68 +968,68 @@ function validateScene(value, index) {
577
968
  return;
578
969
  }
579
970
  if (value.kind === "stacked-diff") {
580
- validateStackedScene(value, index);
971
+ validateStackedScene2(value, index);
581
972
  return;
582
973
  }
583
974
  if (value.kind === "deconstructed-diff") {
584
- validateDeconstructedScene(value, index);
975
+ validateDeconstructedScene2(value, index);
585
976
  return;
586
977
  }
587
978
  for (const key of ["path", "leftLabel", "rightLabel", "leftContent", "rightContent"]) {
588
- requireString(value[key], `scenes[${index}].${key}`);
979
+ requireString2(value[key], `scenes[${index}].${key}`);
589
980
  }
590
981
  if (value.previousPath !== void 0) {
591
- requireString(value.previousPath, `scenes[${index}].previousPath`);
982
+ requireString2(value.previousPath, `scenes[${index}].previousPath`);
592
983
  }
593
- requireString(value.changeKind, `scenes[${index}].changeKind`);
984
+ requireString2(value.changeKind, `scenes[${index}].changeKind`);
594
985
  requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
595
986
  requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
596
987
  if (value.focusChangeIndex !== void 0) {
597
988
  requireNonNegativeInteger(value.focusChangeIndex, `scenes[${index}].focusChangeIndex`);
598
989
  }
599
990
  }
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.`);
991
+ function validateStackedScene2(value, index) {
992
+ if (!Array.isArray(value.stack) || value.stack.length < 2 || value.stack.length > 6) {
993
+ throw new Error(`scenes[${index}].stack must contain between 2 and 6 revisions.`);
603
994
  }
604
995
  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}`);
996
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
997
+ for (const key of ["id", "ref", "oid", "label"]) requireString2(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
607
998
  }
608
999
  validateMultiPanelFiles(value.files, value.stack.length, index);
609
1000
  validateMultiPanelSteps(value.steps, value.stack.length, index, false);
610
1001
  }
611
- function validateDeconstructedScene(value, index) {
1002
+ function validateDeconstructedScene2(value, index) {
612
1003
  if (value.stageLabel !== "Explanation stages") {
613
1004
  throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
614
1005
  }
615
- if (!isRecord(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1006
+ if (!isRecord2(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
616
1007
  for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
617
- requireString(value.realRange[key], `scenes[${index}].realRange.${key}`);
1008
+ requireString2(value.realRange[key], `scenes[${index}].realRange.${key}`);
618
1009
  }
619
1010
  if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
620
1011
  throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
621
1012
  }
622
1013
  const panels = value.panels;
623
1014
  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}`);
1015
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
1016
+ for (const key of ["id", "label", "role"]) requireString2(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
626
1017
  const expectedRole = panelIndex === 0 ? "baseline" : "stage";
627
1018
  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`);
1019
+ if (panelIndex > 0) requireString2(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
629
1020
  }
630
1021
  validateMultiPanelFiles(value.files, panels.length, index);
631
1022
  validateMultiPanelSteps(value.steps, panels.length, index, true);
632
1023
  if (Array.isArray(value.steps)) {
633
1024
  let previousPairIndex = -1;
634
1025
  value.steps.forEach((step, stepIndex) => {
635
- if (!isRecord(step)) return;
1026
+ if (!isRecord2(step)) return;
636
1027
  const pairIndex = Number(step.pairIndex);
637
1028
  if (pairIndex < previousPairIndex) {
638
1029
  throw new Error(`scenes[${index}].steps must remain grouped in explanation stage order.`);
639
1030
  }
640
1031
  previousPairIndex = pairIndex;
641
- if (step.stageId !== void 0) requireString(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1032
+ if (step.stageId !== void 0) requireString2(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
642
1033
  if (step.stageIndex !== void 0) requireNonNegativeInteger(step.stageIndex, `scenes[${index}].steps[${stepIndex}].stageIndex`);
643
1034
  if (step.focusIndex !== void 0) requireNonNegativeInteger(step.focusIndex, `scenes[${index}].steps[${stepIndex}].focusIndex`);
644
1035
  if (step.focusCount !== void 0) requirePositiveInteger(step.focusCount, `scenes[${index}].steps[${stepIndex}].focusCount`);
@@ -651,14 +1042,14 @@ function validateDeconstructedScene(value, index) {
651
1042
  });
652
1043
  for (let stageIndex = 0; stageIndex < panels.length - 1; stageIndex += 1) {
653
1044
  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) {
1045
+ const stageSteps = value.steps.filter((step) => isRecord2(step) && Number(step.pairIndex) === stageIndex);
1046
+ if (!isRecord2(panel) || stageSteps.length === 0) {
656
1047
  throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
657
1048
  }
658
1049
  const firstStep = stageSteps[0];
659
- const fallbackStageId = isRecord(firstStep) ? firstStep.id : void 0;
1050
+ const fallbackStageId = isRecord2(firstStep) ? firstStep.id : void 0;
660
1051
  if (stageSteps.some((step) => {
661
- if (!isRecord(step)) return true;
1052
+ if (!isRecord2(step)) return true;
662
1053
  return (step.stageId ?? fallbackStageId) !== panel.stageId;
663
1054
  })) {
664
1055
  throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
@@ -669,15 +1060,15 @@ function validateDeconstructedScene(value, index) {
669
1060
  function validateMultiPanelFiles(value, panelCount, index) {
670
1061
  if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
671
1062
  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`);
1063
+ if (!isRecord2(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1064
+ requireString2(file.path, `scenes[${index}].files[${fileIndex}].path`);
674
1065
  if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
675
1066
  throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
676
1067
  }
677
1068
  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`);
1069
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
1070
+ for (const key of ["id", "label", "content"]) requireString2(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
1071
+ if (panel.path !== void 0) requireString2(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
681
1072
  if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
682
1073
  }
683
1074
  }
@@ -685,12 +1076,12 @@ function validateMultiPanelFiles(value, panelCount, index) {
685
1076
  function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
686
1077
  if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
687
1078
  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}`);
1079
+ if (!isRecord2(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
1080
+ for (const key of ["id", "title", "body", "file", "side"]) requireString2(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
690
1081
  requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
691
1082
  if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
692
1083
  if (deconstructed) {
693
- requireStringArray(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1084
+ requireStringArray2(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
694
1085
  if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
695
1086
  }
696
1087
  if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
@@ -699,7 +1090,7 @@ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
699
1090
  }
700
1091
  }
701
1092
  function validateTourFile(value, index) {
702
- if (!isRecord(value)) throw new Error(`files[${index}] must be an object.`);
1093
+ if (!isRecord2(value)) throw new Error(`files[${index}] must be an object.`);
703
1094
  if (value.kind === "text-diff") {
704
1095
  validateScene(value, index);
705
1096
  return;
@@ -708,9 +1099,9 @@ function validateTourFile(value, index) {
708
1099
  throw new Error(`files[${index}].kind must be text-diff or omitted.`);
709
1100
  }
710
1101
  for (const key of ["id", "title", "path", "changeKind", "reason"]) {
711
- requireString(value[key], `files[${index}].${key}`);
1102
+ requireString2(value[key], `files[${index}].${key}`);
712
1103
  }
713
- if (value.previousPath !== void 0) requireString(value.previousPath, `files[${index}].previousPath`);
1104
+ if (value.previousPath !== void 0) requireString2(value.previousPath, `files[${index}].previousPath`);
714
1105
  requireNonNegativeInteger(value.additions, `files[${index}].additions`);
715
1106
  requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
716
1107
  }
@@ -730,263 +1121,13 @@ function collectLegacyTourFiles(scenes) {
730
1121
  return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
731
1122
  }
732
1123
  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}`);
1124
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
1125
+ for (const key of ["id", "path", "revision", "excerpt"]) requireString2(value[key], `${path}.${key}`);
735
1126
  if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
736
1127
  requirePositiveInteger(value.startLine, `${path}.startLine`);
737
1128
  requirePositiveInteger(value.endLine, `${path}.endLine`);
738
1129
  if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
739
1130
  }
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.`);
870
- }
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
- }
897
- }
898
- }
899
- }
900
- return { ...value, connections: rawConnections };
901
- }
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.`);
919
- }
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
- }
936
- }
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.`);
944
- }
945
- if (requiresReason) requireString2(value.reason, `${path}.reason`);
946
- }
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.`);
951
- }
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);
962
- }
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.`);
979
- }
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.`);
987
- }
988
- }
989
- }
990
1131
  function validateNarrative2(value, path) {
991
1132
  requireString2(value.summary, `${path}.summary`);
992
1133
  requireStringArray2(value.bullets, `${path}.bullets`);
@@ -997,13 +1138,8 @@ function isRecord2(value) {
997
1138
  return typeof value === "object" && value !== null && !Array.isArray(value);
998
1139
  }
999
1140
  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);
1141
+ if (typeof value !== "string") {
1142
+ throw new Error(`${path} must be a string.`);
1007
1143
  }
1008
1144
  }
1009
1145
  function requireStringArray2(value, path) {
@@ -1011,12 +1147,19 @@ function requireStringArray2(value, path) {
1011
1147
  throw new Error(`${path} must be an array of strings.`);
1012
1148
  }
1013
1149
  }
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(", ")}`);
1150
+ function requireNonNegativeInteger(value, path) {
1151
+ if (!Number.isInteger(value) || Number(value) < 0) {
1152
+ throw new Error(`${path} must be a non-negative integer.`);
1153
+ }
1154
+ }
1155
+ function requirePercentage(value, path) {
1156
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
1157
+ throw new Error(`${path} must be a number from 0 to 100.`);
1018
1158
  }
1019
1159
  }
1160
+ function requirePositiveInteger(value, path) {
1161
+ if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
1162
+ }
1020
1163
 
1021
1164
  // src/deconstructedChange.ts
1022
1165
  function buildDeconstructedScene(startPath, scene, options = {}) {
@@ -1551,12 +1694,20 @@ var DIFF_TIMEOUT_MS = readPositiveIntegerEnv("BYGONE_DIFF_TIMEOUT_MS", DEFAULT_D
1551
1694
  function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1552
1695
  const leftLines = normalizeLines(leftContent);
1553
1696
  const rightLines = normalizeLines(rightContent);
1554
- const changes = diffArrays(leftLines, rightLines, {
1697
+ if (leftContent === rightContent) {
1698
+ return buildIdenticalTwoWayDiffModel(leftLines);
1699
+ }
1700
+ const interned = internDiffLines(leftLines, rightLines);
1701
+ const internedChanges = diffArrays(interned.left, interned.right, {
1555
1702
  timeout: options.timeoutMs ?? DIFF_TIMEOUT_MS
1556
1703
  });
1557
- if (!changes) {
1704
+ if (!internedChanges) {
1558
1705
  return buildConservativeTwoWayDiffModel(leftLines, rightLines);
1559
1706
  }
1707
+ const changes = internedChanges.map((change) => ({
1708
+ ...change,
1709
+ value: change.value.map((lineId) => interned.lines[lineId])
1710
+ }));
1560
1711
  const rows = [];
1561
1712
  const renderedLeftLines = [];
1562
1713
  const renderedRightLines = [];
@@ -1580,29 +1731,18 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1580
1731
  }
1581
1732
  if (change.removed && index + 1 < changes.length && changes[index + 1].added) {
1582
1733
  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));
1734
+ const nextLineNumbers = appendReplacementCandidate(
1735
+ removedLines,
1736
+ nextChange.value,
1737
+ leftLineNumber,
1738
+ rightLineNumber,
1739
+ rows,
1740
+ renderedLeftLines,
1741
+ renderedRightLines,
1742
+ blocks
1743
+ );
1744
+ leftLineNumber = nextLineNumbers.left;
1745
+ rightLineNumber = nextLineNumbers.right;
1606
1746
  index++;
1607
1747
  continue;
1608
1748
  }
@@ -1638,7 +1778,40 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1638
1778
  leftLines: renderedLeftLines,
1639
1779
  rightLines: renderedRightLines,
1640
1780
  blocks,
1641
- hasChanges
1781
+ hasChanges,
1782
+ quality: "exact"
1783
+ };
1784
+ }
1785
+ function internDiffLines(leftLines, rightLines) {
1786
+ const lineIds = /* @__PURE__ */ new Map();
1787
+ const lines = [];
1788
+ const intern = (line) => {
1789
+ const existingId = lineIds.get(line);
1790
+ if (existingId !== void 0) {
1791
+ return existingId;
1792
+ }
1793
+ const lineId = lines.length;
1794
+ lineIds.set(line, lineId);
1795
+ lines.push(line);
1796
+ return lineId;
1797
+ };
1798
+ return {
1799
+ left: leftLines.map(intern),
1800
+ right: rightLines.map(intern),
1801
+ lines
1802
+ };
1803
+ }
1804
+ function buildIdenticalTwoWayDiffModel(lines) {
1805
+ return {
1806
+ rows: lines.map((line, index) => makeDiffRow(
1807
+ makeDiffCell("context", line, index + 1),
1808
+ makeDiffCell("context", line, index + 1)
1809
+ )),
1810
+ leftLines: lines.map((line, index) => makeDiffLine("context", line, index + 1)),
1811
+ rightLines: lines.map((line, index) => makeDiffLine("context", line, index + 1)),
1812
+ blocks: [],
1813
+ hasChanges: false,
1814
+ quality: "exact"
1642
1815
  };
1643
1816
  }
1644
1817
  function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
@@ -1665,7 +1838,8 @@ function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
1665
1838
  leftLines: renderedLeftLines,
1666
1839
  rightLines: renderedRightLines,
1667
1840
  blocks: rows.length === 0 ? [] : [makeDiffBlock(blockKind, 0, leftLines.length, 0, rightLines.length)],
1668
- hasChanges: rows.length > 0
1841
+ hasChanges: rows.length > 0,
1842
+ quality: "fallback"
1669
1843
  };
1670
1844
  }
1671
1845
  function normalizeLines(content) {
@@ -1678,6 +1852,100 @@ function normalizeLines(content) {
1678
1852
  }
1679
1853
  return lines;
1680
1854
  }
1855
+ function appendReplacementCandidate(leftLines, rightLines, initialLeftLineNumber, initialRightLineNumber, rows, renderedLeftLines, renderedRightLines, blocks) {
1856
+ const aligned = alignReplacementLines(leftLines, rightLines);
1857
+ const crediblePairs = aligned.filter((row) => row.left !== void 0 && row.right !== void 0 && hasCredibleLineCorrespondence(row.left, row.right)).length;
1858
+ const coherentBlock = hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs);
1859
+ const normalized = aligned.flatMap((row) => {
1860
+ if (row.left === void 0 || row.right === void 0 || hasCredibleLineCorrespondence(row.left, row.right)) {
1861
+ return [row];
1862
+ }
1863
+ return [{ left: row.left }, { right: row.right }];
1864
+ });
1865
+ let leftLineNumber = initialLeftLineNumber;
1866
+ let rightLineNumber = initialRightLineNumber;
1867
+ let blockKind;
1868
+ let blockLeftStart = renderedLeftLines.length;
1869
+ let blockRightStart = renderedRightLines.length;
1870
+ const flushBlock = () => {
1871
+ if (!blockKind) return;
1872
+ blocks.push(makeDiffBlock(
1873
+ blockKind,
1874
+ blockLeftStart,
1875
+ renderedLeftLines.length,
1876
+ blockRightStart,
1877
+ renderedRightLines.length
1878
+ ));
1879
+ };
1880
+ for (const { left, right } of normalized) {
1881
+ const nextKind = coherentBlock ? "replace" : left !== void 0 && right !== void 0 ? "replace" : left !== void 0 ? "delete" : "insert";
1882
+ if (blockKind !== nextKind) {
1883
+ flushBlock();
1884
+ blockKind = nextKind;
1885
+ blockLeftStart = renderedLeftLines.length;
1886
+ blockRightStart = renderedRightLines.length;
1887
+ }
1888
+ let renderedLeftLine;
1889
+ let renderedRightLine;
1890
+ if (left !== void 0) {
1891
+ renderedLeftLine = makeDiffLine("removed", left, leftLineNumber);
1892
+ renderedLeftLines.push(renderedLeftLine);
1893
+ }
1894
+ if (right !== void 0) {
1895
+ renderedRightLine = makeDiffLine("added", right, rightLineNumber);
1896
+ renderedRightLines.push(renderedRightLine);
1897
+ }
1898
+ if (renderedLeftLine && renderedRightLine) {
1899
+ applyInlineHighlightPair(renderedLeftLine, renderedRightLine);
1900
+ } else if (coherentBlock) {
1901
+ if (renderedLeftLine) applyFullLineHighlight(renderedLeftLine);
1902
+ if (renderedRightLine) applyFullLineHighlight(renderedRightLine);
1903
+ }
1904
+ rows.push(makeDiffRow(
1905
+ left === void 0 ? makePlaceholder() : makeDiffCell("removed", left, leftLineNumber++),
1906
+ right === void 0 ? makePlaceholder() : makeDiffCell("added", right, rightLineNumber++)
1907
+ ));
1908
+ }
1909
+ flushBlock();
1910
+ return { left: leftLineNumber, right: rightLineNumber };
1911
+ }
1912
+ function hasCredibleLineCorrespondence(left, right) {
1913
+ const score = scoreReplacementLinePair(left, right);
1914
+ if (score.eligible) return true;
1915
+ const leftAnchor = declarationAnchorKey(left);
1916
+ if (leftAnchor && leftAnchor === declarationAnchorKey(right)) return true;
1917
+ const leftTokens = new Set(tokenizeMatchingContent(normalizeMatchingContent(left)).filter(isSharedContentToken));
1918
+ return tokenizeMatchingContent(normalizeMatchingContent(right)).some((token) => leftTokens.has(token) && isSharedContentToken(token));
1919
+ }
1920
+ var NON_SUBSTANTIVE_SHARED_TOKENS = /* @__PURE__ */ new Set([
1921
+ "and",
1922
+ "are",
1923
+ "for",
1924
+ "from",
1925
+ "into",
1926
+ "that",
1927
+ "the",
1928
+ "this",
1929
+ "to",
1930
+ "with"
1931
+ ]);
1932
+ function isSharedContentToken(token) {
1933
+ return token.length >= 3 && /^[\p{L}\p{N}_$]+$/u.test(token) && !NON_SUBSTANTIVE_SHARED_TOKENS.has(token);
1934
+ }
1935
+ function hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs) {
1936
+ if (crediblePairs === 0) return false;
1937
+ const longestSide = Math.max(leftLines.length, rightLines.length);
1938
+ if (longestSide <= 2 || crediblePairs * 2 >= longestSide) return true;
1939
+ const roles = [...leftLines, ...rightLines].map(collectionLineRole);
1940
+ return roles[0] !== null && roles.every((role) => role === roles[0]);
1941
+ }
1942
+ function collectionLineRole(line) {
1943
+ const normalized = line.trimStart();
1944
+ if (/^[-*+]\s+/.test(normalized)) return "bullet";
1945
+ if (/^\d+[.)]\s+/.test(normalized)) return "ordered";
1946
+ if (/^>\s+/.test(normalized)) return "quote";
1947
+ return null;
1948
+ }
1681
1949
  var MAX_ALIGNMENT_CELLS = 1e4;
1682
1950
  var MAX_SCORING_LINE_LENGTH = 2e3;
1683
1951
  var MINIMUM_MATCH_SCORE = 0.52;
@@ -1690,6 +1958,7 @@ var CONTEXTUAL_AMBIGUITY_MARGIN = 0.05;
1690
1958
  var GAP_PENALTY = -0.12;
1691
1959
  var MAX_BOUNDED_CANDIDATES = 5e3;
1692
1960
  var MAX_LARGE_HUNK_ANCHORS = 2e3;
1961
+ var MAX_ALIGNMENT_CHARACTER_CELLS = 5e7;
1693
1962
  var MAX_RARE_TOKEN_OCCURRENCES = 4;
1694
1963
  var MAX_CONTEXTUAL_HUNK_LINES = 8;
1695
1964
  var MAX_CONTEXTUAL_LENGTH_DELTA = 4;
@@ -1700,9 +1969,17 @@ function alignReplacementLines(leftLines, rightLines) {
1700
1969
  if (rightLines.length === 0) {
1701
1970
  return leftLines.map((left) => ({ left }));
1702
1971
  }
1972
+ const leftCharacterCount = leftLines.reduce((total, line) => total + line.length, 0);
1973
+ const rightCharacterCount = rightLines.reduce((total, line) => total + line.length, 0);
1974
+ if (leftCharacterCount * rightCharacterCount > MAX_ALIGNMENT_CHARACTER_CELLS) {
1975
+ return [
1976
+ ...leftLines.map((left) => ({ left })),
1977
+ ...rightLines.map((right) => ({ right }))
1978
+ ];
1979
+ }
1703
1980
  if (leftLines.length === 1 && rightLines.length === 1) {
1704
1981
  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]))) {
1982
+ 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
1983
  return [{ left: leftLines[0], right: rightLines[0] }];
1707
1984
  }
1708
1985
  return [{ left: leftLines[0] }, { right: rightLines[0] }];
@@ -2224,6 +2501,11 @@ function applyInlineHighlightPair(leftLine, rightLine) {
2224
2501
  rightLine.segments = rightSegments;
2225
2502
  }
2226
2503
  }
2504
+ function applyFullLineHighlight(line) {
2505
+ if (/[^\s]/.test(line.content)) {
2506
+ line.segments = [{ kind: line.kind, text: line.content, emphasis: true }];
2507
+ }
2508
+ }
2227
2509
  function buildInlineSegments(leftContent, rightContent) {
2228
2510
  const changes = diffWordsWithSpace(leftContent, rightContent);
2229
2511
  const leftSegments = [];
@@ -2375,6 +2657,125 @@ function findBlankLineBoundary(lines, target, minimum, maximum) {
2375
2657
  return Math.min(Math.max(target, minimum), maximum - 1);
2376
2658
  }
2377
2659
 
2660
+ // src/changeAttention.ts
2661
+ var CHANGE_ATTENTION_VERSION = 1;
2662
+ var LOCKFILES = /* @__PURE__ */ new Set([
2663
+ "cargo.lock",
2664
+ "package-lock.json",
2665
+ "pnpm-lock.yaml",
2666
+ "yarn.lock",
2667
+ "poetry.lock",
2668
+ "gemfile.lock",
2669
+ "composer.lock"
2670
+ ]);
2671
+ var MAX_FAN_IN_FILES = 250;
2672
+ var MAX_FAN_IN_TEXT_BYTES = 64 * 1024;
2673
+ function classifyChangeFileRole(filePath) {
2674
+ const lower = filePath.toLowerCase();
2675
+ const fileName = lower.split("/").pop() || lower;
2676
+ if (/(^|\/)(dist|build|generated|vendor)(\/|$)/.test(lower) || /(?:\.min\.js|\.map)$/.test(lower)) return "generated";
2677
+ if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower) || /\.(test|spec)\.[^/]+$/.test(lower)) return "test";
2678
+ if (lower.startsWith("docs/") || fileName === "readme.md" || /\.(md|mdx|rst)$/.test(lower)) return "documentation";
2679
+ if (LOCKFILES.has(fileName) || /(^|\/)(pyproject\.toml|package\.json|go\.mod|go\.sum|cargo\.toml)$/.test(lower)) return "dependency";
2680
+ return "production";
2681
+ }
2682
+ function buildChangeAttention(inputs) {
2683
+ const prepared = inputs.map((input) => prepareFile(input, inputs));
2684
+ const production = prepared.filter((file) => file.role === "production" && !file.mechanical);
2685
+ const documentation = prepared.filter((file) => file.role === "documentation" && !file.mechanical);
2686
+ const eligible = production.length > 0 ? production : documentation;
2687
+ const entry = [...eligible].sort(compareRanked)[0];
2688
+ const fallback = [...prepared].filter((file) => !file.binary).sort((left, right) => right.changedLines - left.changedLines || left.path.localeCompare(right.path))[0];
2689
+ 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)));
2690
+ 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;
2691
+ if (pairedTest && focusCandidates.length < Math.max(0, 7 - (entry ? 1 : 0))) focusCandidates.push(pairedTest);
2692
+ const focusPaths = focusCandidates.map((file) => file.path);
2693
+ const files = prepared.map((file) => {
2694
+ const attention = file.path === (entry == null ? void 0 : entry.path) ? "entry" : focusPaths.includes(file.path) ? "focus" : file.mechanical ? "mechanical" : "background";
2695
+ return {
2696
+ path: file.path,
2697
+ previousPath: file.previousPath,
2698
+ changeKind: file.changeKind,
2699
+ role: file.role,
2700
+ attention,
2701
+ additions: file.additions,
2702
+ deletions: file.deletions,
2703
+ reason: attention === "entry" ? entryReason(file, production.length) : attentionReason(file),
2704
+ score: file.score
2705
+ };
2706
+ }).sort((left, right) => left.path.localeCompare(right.path));
2707
+ return {
2708
+ version: CHANGE_ATTENTION_VERSION,
2709
+ entryPath: (entry == null ? void 0 : entry.path) || null,
2710
+ entryReason: entry ? entryReason(entry, production.length) : noEntryReason(prepared),
2711
+ fallbackPath: (fallback == null ? void 0 : fallback.path) || null,
2712
+ focusPaths,
2713
+ backgroundPaths: files.filter((file) => file.attention === "background").map((file) => file.path),
2714
+ mechanicalPaths: files.filter((file) => file.attention === "mechanical").map((file) => file.path),
2715
+ files
2716
+ };
2717
+ }
2718
+ function prepareFile(input, all) {
2719
+ const role = classifyChangeFileRole(input.path);
2720
+ const fileName = input.path.toLowerCase().split("/").pop() || input.path.toLowerCase();
2721
+ const mechanical = Boolean(input.binary) || role === "generated" || LOCKFILES.has(fileName);
2722
+ const changedLines = (input.additions ?? 0) + (input.deletions ?? 0);
2723
+ const contract = /(^|[\/._-])(types?|schema|models?|contracts?|interfaces?)([\/._-]|$)/i.test(input.path);
2724
+ const referenceHints = new Set([
2725
+ fileName.replace(/\.[^.]+$/, ""),
2726
+ ...(input.symbolHints || []).map((hint) => hint.toLowerCase())
2727
+ ].filter((hint) => hint.length >= 4));
2728
+ 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) => {
2729
+ var _a;
2730
+ const boundedText = (_a = candidate.headText) == null ? void 0 : _a.slice(0, MAX_FAN_IN_TEXT_BYTES).toLowerCase();
2731
+ return Boolean(boundedText && [...referenceHints].some((hint) => boundedText.includes(hint)));
2732
+ }).length;
2733
+ const roleScore = role === "production" ? 300 : role === "documentation" ? 150 : role === "dependency" ? 80 : role === "test" ? 50 : 0;
2734
+ const sizePenalty = changedLines > 2e3 ? 80 : changedLines > 500 ? 30 : 0;
2735
+ const readmePenalty = fileName === "readme.md" && all.filter((candidate) => !candidate.binary).length > 1 ? 100 : 0;
2736
+ return {
2737
+ ...input,
2738
+ role,
2739
+ mechanical,
2740
+ changedLines,
2741
+ contract,
2742
+ fanIn,
2743
+ binary: Boolean(input.binary),
2744
+ score: mechanical ? -1e3 : roleScore + (contract ? 100 : 0) + fanIn * 40 + (input.commitCount || 0) * 5 - sizePenalty - readmePenalty
2745
+ };
2746
+ }
2747
+ function compareRanked(left, right) {
2748
+ return right.score - left.score || right.fanIn - left.fanIn || left.changedLines - right.changedLines || left.path.localeCompare(right.path);
2749
+ }
2750
+ function entryReason(file, productionCount) {
2751
+ if (file.fanIn > 0) return `Referenced by ${file.fanIn} other changed file${file.fanIn === 1 ? "" : "s"}.`;
2752
+ if (file.contract) return "Contract-shaped path central to the change.";
2753
+ if (productionCount === 1) return "Only production file in this range.";
2754
+ return "Highest-ranked production file for a first pass.";
2755
+ }
2756
+ function attentionReason(file) {
2757
+ if (file.binary) return "Binary file.";
2758
+ if (file.mechanical) return file.role === "generated" ? "Generated artifact." : "Dependency lockfile.";
2759
+ if (file.role === "test") return "Test or proof file.";
2760
+ if (file.role === "documentation") return "Documentation change.";
2761
+ if (file.role === "dependency") return "Dependency or packaging manifest.";
2762
+ return "Additional production file.";
2763
+ }
2764
+ function noEntryReason(files) {
2765
+ if (files.every((file) => file.mechanical)) return "This change is entirely mechanical or generated.";
2766
+ if (files.every((file) => file.mechanical || file.role === "test")) return "This change contains only tests and mechanical files.";
2767
+ return "No single entry-point is reliable for this change.";
2768
+ }
2769
+ function isTestForFocus(testPath, focusPaths) {
2770
+ const normalizedTest = testPath.toLowerCase().replace(/\.(test|spec)(?=\.)/, "").replace(/^test_/, "");
2771
+ return focusPaths.some((candidate) => {
2772
+ var _a;
2773
+ const normalizedCandidate = candidate.toLowerCase();
2774
+ const stem = ((_a = normalizedCandidate.split("/").pop()) == null ? void 0 : _a.replace(/\.[^.]+$/, "")) || normalizedCandidate;
2775
+ return stem.length >= 3 && normalizedTest.includes(stem);
2776
+ });
2777
+ }
2778
+
2378
2779
  // src/changeTourContext.ts
2379
2780
  var DEFAULT_MAX_PATCH_BYTES = 128 * 1024;
2380
2781
  var DEFAULT_MAX_TOTAL_PATCH_BYTES = 2 * 1024 * 1024;
@@ -2393,8 +2794,12 @@ var CHAPTERS = {
2393
2794
  packaging: { id: "packaging", title: "Packaging and dependencies", priority: 4 }
2394
2795
  };
2395
2796
  function buildChangeTourManifest(startPath, options = {}) {
2396
- var _a, _b, _c, _d, _e;
2397
- const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
2797
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
2798
+ const range = resolveBranchReviewRange(
2799
+ startPath,
2800
+ options.headRef ?? ((_b = (_a = options.source) == null ? void 0 : _a.range) == null ? void 0 : _b.head),
2801
+ options.baseRef ?? ((_d = (_c = options.source) == null ? void 0 : _c.range) == null ? void 0 : _d.base)
2802
+ );
2398
2803
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_TOUR_FILE_BYTES;
2399
2804
  const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_TOUR_LINE_BYTES;
2400
2805
  const omittedFiles = [];
@@ -2402,8 +2807,9 @@ function buildChangeTourManifest(startPath, options = {}) {
2402
2807
  const sceneRecords = [];
2403
2808
  let totalAdditions = 0;
2404
2809
  let totalDeletions = 0;
2810
+ let totalBinaryFiles = 0;
2405
2811
  for (const changedPath of range.changedPaths) {
2406
- const { additions, deletions } = readGitLineStats(
2812
+ const { additions, deletions, binary } = readGitLineStats(
2407
2813
  range.repoRoot,
2408
2814
  range.mergeBaseOid,
2409
2815
  range.headOid,
@@ -2412,6 +2818,7 @@ function buildChangeTourManifest(startPath, options = {}) {
2412
2818
  );
2413
2819
  totalAdditions += additions;
2414
2820
  totalDeletions += deletions;
2821
+ if (binary) totalBinaryFiles += 1;
2415
2822
  const pair = resolveReviewPathPair(range.changedPaths, changedPath.path);
2416
2823
  if (!pair) {
2417
2824
  omittedFiles.push(changedPath.path);
@@ -2452,21 +2859,50 @@ function buildChangeTourManifest(startPath, options = {}) {
2452
2859
  }
2453
2860
  files.sort((left, right) => left.path.localeCompare(right.path));
2454
2861
  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}` }));
2862
+ let defaultScenes = sceneRecords.map(({ scene }, index) => ({ ...scene, id: `file-${index + 1}` }));
2456
2863
  if (options.story && options.source) {
2457
2864
  throw new Error("A change tour can use either a legacy story or a source file, not both.");
2458
2865
  }
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
- };
2866
+ const attention = buildChangeAttention(files.map((file) => ({
2867
+ path: file.path,
2868
+ previousPath: file.previousPath,
2869
+ changeKind: file.changeKind,
2870
+ additions: file.additions,
2871
+ deletions: file.deletions,
2872
+ binary: file.kind === "omitted",
2873
+ headText: file.kind === "text-diff" ? file.rightContent : void 0,
2874
+ symbolHints: file.kind === "text-diff" ? findDeclarationNames(file.rightContent) : []
2875
+ })));
2876
+ if (!options.story && !options.source && attention.entryPath) {
2877
+ const focusRank = (scenePath) => {
2878
+ const index = attention.focusPaths.indexOf(scenePath);
2879
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
2880
+ };
2881
+ 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));
2882
+ }
2883
+ const authored = options.source ? applySource(parseChangeTourSource(options.source), defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
2884
+ defaultScenes,
2885
+ buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id]))),
2886
+ attention,
2887
+ {
2888
+ changedFiles: range.changedPaths.length,
2889
+ binaryFiles: totalBinaryFiles,
2890
+ additions: totalAdditions,
2891
+ deletions: totalDeletions,
2892
+ commitCount: range.commits.length
2893
+ }
2894
+ );
2463
2895
  const scenes = authored.scenes;
2464
2896
  const chapters = authored.chapters;
2897
+ const finalScenes = "finalScenes" in authored ? authored.finalScenes : scenes;
2898
+ const finalChapters = "finalChapters" in authored ? authored.finalChapters : chapters;
2465
2899
  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,
2900
+ // Only authored v2 files opt into repository-bound zoom modes. Generated
2901
+ // and legacy/story tours retain the portable v1 manifest contract.
2902
+ version: ((_e = options.source) == null ? void 0 : _e.version) ?? 1,
2903
+ title: ((_f = options.source) == null ? void 0 : _f.title) || ((_g = options.story) == null ? void 0 : _g.title) || options.title || `${range.headRef} against ${range.baseRef}`,
2904
+ windowTitle: (_h = options.source) == null ? void 0 : _h.windowTitle,
2905
+ sourceUrl: ((_i = options.source) == null ? void 0 : _i.sourceUrl) || ((_j = options.story) == null ? void 0 : _j.sourceUrl) || options.sourceUrl,
2470
2906
  generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
2471
2907
  range: {
2472
2908
  baseRef: range.baseRef,
@@ -2487,6 +2923,49 @@ function buildChangeTourManifest(startPath, options = {}) {
2487
2923
  chapters,
2488
2924
  scenes
2489
2925
  };
2926
+ if (manifest.version === 2) {
2927
+ manifest.repository = { root: range.repoRoot };
2928
+ const revisions = [];
2929
+ for (const scene of scenes) {
2930
+ let realStack;
2931
+ if (scene.kind === "stacked-diff") realStack = scene;
2932
+ if (scene.kind === "deconstructed-diff") {
2933
+ const authoredScene = (_k = options.source) == null ? void 0 : _k.chapters.flatMap((chapter) => chapter.scenes).find((candidate) => candidate.id === scene.id);
2934
+ if ((authoredScene == null ? void 0 : authoredScene.kind) !== "deconstructed-diff" || !authoredScene.stack) {
2935
+ throw new Error(`Deconstructed scene ${scene.id} requires an explicit real revision stack in v2.`);
2936
+ }
2937
+ realStack = buildStackedScene(range.repoRoot, {
2938
+ ...authoredScene,
2939
+ kind: "stacked-diff",
2940
+ stack: authoredScene.stack,
2941
+ files: scene.files.map((file) => file.path),
2942
+ steps: []
2943
+ }, files.map((file) => file.path));
2944
+ if (realStack.stack[0].oid !== scene.realRange.baseOid || realStack.stack[realStack.stack.length - 1].oid !== scene.realRange.targetOid) {
2945
+ throw new Error(`Deconstructed scene ${scene.id} real stack endpoints must match its explanation range.`);
2946
+ }
2947
+ }
2948
+ if (realStack) {
2949
+ if (realStack.stack[0].oid !== range.mergeBaseOid || realStack.stack[realStack.stack.length - 1].oid !== range.headOid) {
2950
+ throw new Error(`Scene ${scene.id} real stack endpoints must match the tour's final diff range.`);
2951
+ }
2952
+ revisions.push(realStack);
2953
+ }
2954
+ }
2955
+ const explanation = scenes.some((scene) => scene.kind === "deconstructed-diff");
2956
+ const distinctRevisions = revisions.some((scene) => scene.stack.length > 2);
2957
+ manifest.zoom = {
2958
+ authoredDepth: explanation ? "explanation" : distinctRevisions ? "revisions" : "final",
2959
+ modes: [
2960
+ ...explanation ? ["explanation"] : [],
2961
+ ...distinctRevisions ? ["revisions"] : [],
2962
+ "final",
2963
+ "history"
2964
+ ],
2965
+ revisions,
2966
+ final: { scenes: finalScenes, chapters: finalChapters }
2967
+ };
2968
+ }
2490
2969
  return parseChangeTourManifest(manifest);
2491
2970
  }
2492
2971
  function applySource(source, defaultScenes, repoRoot) {
@@ -2509,8 +2988,11 @@ function applySource(source, defaultScenes, repoRoot) {
2509
2988
  }]));
2510
2989
  const scenes = [];
2511
2990
  const chapters = [];
2991
+ const finalScenes = [];
2992
+ const finalChapters = [];
2512
2993
  for (const chapter of source.chapters) {
2513
2994
  const sceneIds = [];
2995
+ const finalSceneIds = [];
2514
2996
  for (const authoredScene of chapter.scenes) {
2515
2997
  if (authoredScene.kind === "deconstructed-diff") {
2516
2998
  const deconstructedScene = buildDeconstructedManifestScene(
@@ -2522,6 +3004,16 @@ function applySource(source, defaultScenes, repoRoot) {
2522
3004
  );
2523
3005
  scenes.push(deconstructedScene);
2524
3006
  sceneIds.push(deconstructedScene.id);
3007
+ if (authoredScene.steps) {
3008
+ const finalScene = buildWalkthroughManifestScene(
3009
+ { ...authoredScene, steps: authoredScene.steps },
3010
+ available,
3011
+ resolvedAnchors,
3012
+ connections
3013
+ );
3014
+ finalScenes.push(finalScene);
3015
+ finalSceneIds.push(finalScene.id);
3016
+ }
2525
3017
  continue;
2526
3018
  }
2527
3019
  if (authoredScene.kind === "stacked-diff") {
@@ -2532,37 +3024,51 @@ function applySource(source, defaultScenes, repoRoot) {
2532
3024
  );
2533
3025
  scenes.push(stackedScene);
2534
3026
  sceneIds.push(stackedScene.id);
3027
+ for (const [index, file] of stackedScene.files.entries()) {
3028
+ const endpoint = available.get(file.path);
3029
+ if (!endpoint) continue;
3030
+ const finalScene = { ...endpoint, id: `${stackedScene.id}-final-${index + 1}`, title: file.path };
3031
+ finalScenes.push(finalScene);
3032
+ finalSceneIds.push(finalScene.id);
3033
+ }
2535
3034
  continue;
2536
3035
  }
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
- };
3036
+ const scene = buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections);
2560
3037
  scenes.push(scene);
2561
3038
  sceneIds.push(scene.id);
3039
+ finalScenes.push(scene);
3040
+ finalSceneIds.push(scene.id);
2562
3041
  }
2563
3042
  chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3043
+ finalChapters.push({ id: chapter.id, title: chapter.title, sceneIds: finalSceneIds });
2564
3044
  }
2565
- return { scenes, chapters };
3045
+ return { scenes, chapters, finalScenes, finalChapters };
3046
+ }
3047
+ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections) {
3048
+ return {
3049
+ id: authoredScene.id,
3050
+ kind: "walkthrough",
3051
+ title: authoredScene.title,
3052
+ summary: authoredScene.summary,
3053
+ bullets: authoredScene.bullets,
3054
+ tags: authoredScene.tags,
3055
+ takeaway: authoredScene.takeaway,
3056
+ steps: authoredScene.steps.map((step) => {
3057
+ const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
3058
+ const diff2 = available.get(focus.path);
3059
+ if (!diff2) throw new Error(`Step ${step.id} has no text diff for ${focus.path}.`);
3060
+ return {
3061
+ id: step.id,
3062
+ title: step.title,
3063
+ body: step.body,
3064
+ depth: step.depth,
3065
+ requirement: step.requirement,
3066
+ focus,
3067
+ connection: step.connection ? connections.get(step.connection) : void 0,
3068
+ diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
3069
+ };
3070
+ })
3071
+ };
2566
3072
  }
2567
3073
  function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, availableFilePaths = []) {
2568
3074
  const compiled = buildDeconstructedScene(repoRoot, source, { baseRef, headRef: targetRef });
@@ -2728,7 +3234,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2728
3234
  let totalContentBytes = 0;
2729
3235
  const files = filePaths.flatMap((filePath) => {
2730
3236
  const aliases = /* @__PURE__ */ new Set([filePath]);
2731
- for (const changes of adjacentChanges) {
3237
+ for (const changes of [...adjacentChanges].reverse()) {
2732
3238
  for (const change of changes) {
2733
3239
  if (aliases.has(change.path) || change.previousPath && aliases.has(change.previousPath)) {
2734
3240
  aliases.add(change.path);
@@ -2739,7 +3245,9 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2739
3245
  let fileContentBytes = 0;
2740
3246
  const panels = [];
2741
3247
  for (const entry of stack) {
2742
- const resolvedPath = [...aliases].find((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
3248
+ const candidates = [...aliases].filter((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
3249
+ if (candidates.length > 1) throw new Error(`Ambiguous revision paths for ${filePath} at ${entry.ref}: ${candidates.join(", ")}`);
3250
+ const resolvedPath = candidates[0];
2743
3251
  if (!resolvedPath) {
2744
3252
  panels.push({ id: entry.id, label: `${entry.label} / ${filePath} (missing)`, content: "", exists: false });
2745
3253
  continue;
@@ -2766,6 +3274,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2766
3274
  }
2767
3275
  return [{ path: filePath, panels }];
2768
3276
  });
3277
+ if (files.length === 0) throw new Error(`Stacked scene ${source.id} has no materializable text files.`);
2769
3278
  const stackIds = stack.map((entry) => entry.id);
2770
3279
  return {
2771
3280
  id: source.id,
@@ -2777,7 +3286,14 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2777
3286
  takeaway: source.takeaway,
2778
3287
  stack,
2779
3288
  files,
2780
- steps: source.steps.map((step) => {
3289
+ steps: source.steps.length === 0 ? stack.slice(0, -1).map((_, pairIndex) => ({
3290
+ id: `${source.id}-revision-${pairIndex}`,
3291
+ title: "",
3292
+ body: "",
3293
+ file: files[0].path,
3294
+ pairIndex,
3295
+ side: "right"
3296
+ })) : source.steps.map((step) => {
2781
3297
  var _a, _b;
2782
3298
  return {
2783
3299
  id: step.id,
@@ -2889,21 +3405,51 @@ function buildChapters(records, sceneIdByPath) {
2889
3405
  }
2890
3406
  function chapterForPath(filePath) {
2891
3407
  const lower = filePath.toLowerCase();
2892
- const fileName = lower.split("/").pop() || lower;
2893
- if (lower.startsWith("docs/") || fileName === "readme.md" || fileName.includes("architecture")) {
3408
+ const role = classifyChangeFileRole(filePath);
3409
+ if (role === "documentation" || lower.includes("architecture")) {
2894
3410
  return CHAPTERS.context;
2895
3411
  }
2896
- if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower)) {
3412
+ if (role === "test") {
2897
3413
  return CHAPTERS.proof;
2898
3414
  }
2899
3415
  if (/(^|\/)(models?|schema|types?|contracts?)\.[^/]+$/.test(lower)) {
2900
3416
  return CHAPTERS.contracts;
2901
3417
  }
2902
- if (/(^|\/)(package-lock\.json|.*\.lock|pyproject\.toml|package\.json)$/.test(lower)) {
3418
+ if (role === "dependency" || role === "generated") {
2903
3419
  return CHAPTERS.packaging;
2904
3420
  }
2905
3421
  return CHAPTERS.behavior;
2906
3422
  }
3423
+ function buildGeneratedTourLanding(scenes, chapters, attention, summary) {
3424
+ const entry = attention.entryPath ? `Start here: ${attention.entryPath} \u2014 ${attention.entryReason}` : `No single entry-point \u2014 ${attention.entryReason}`;
3425
+ const focus = attention.focusPaths.length > 0 ? `Pay attention: ${attention.focusPaths.join(", ")}` : "Pay attention: no additional production files were identified.";
3426
+ const skipped = attention.backgroundPaths.length + attention.mechanicalPaths.length;
3427
+ const overview = {
3428
+ id: "change-overview",
3429
+ kind: "discussion",
3430
+ title: "Change overview",
3431
+ summary: `${summary.changedFiles} files (${summary.changedFiles - summary.binaryFiles} text, ${summary.binaryFiles} binary) \xB7 +${summary.additions} \u2212${summary.deletions} \xB7 ${summary.commitCount} commits`,
3432
+ bullets: [entry, focus, `Usually skip on a first pass: ${skipped} file${skipped === 1 ? "" : "s"}. All files remain available.`],
3433
+ tags: ["start here", "first pass"],
3434
+ takeaway: attention.entryPath ? `Next: open ${attention.entryPath}.` : "Next: open the first available textual change."
3435
+ };
3436
+ return {
3437
+ scenes: [overview, ...scenes],
3438
+ chapters: [
3439
+ { id: "start-here", title: "Start here", sceneIds: [overview.id] },
3440
+ ...chapters
3441
+ ]
3442
+ };
3443
+ }
3444
+ function findDeclarationNames(content) {
3445
+ const names = /* @__PURE__ */ new Set();
3446
+ for (const line of content.split("\n")) {
3447
+ 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*)/);
3448
+ if (match) names.add(match[1]);
3449
+ if (names.size >= 25) break;
3450
+ }
3451
+ return [...names];
3452
+ }
2907
3453
  function buildSceneNote(kind, additions, deletions, previousPath) {
2908
3454
  const label = formatChangeKind(kind);
2909
3455
  const rename = previousPath ? ` from ${previousPath}` : "";
@@ -2966,19 +3512,31 @@ function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
2966
3512
  const [additionsText = "0", deletionsText = "0"] = output.trim().split(/\s+/, 3);
2967
3513
  return {
2968
3514
  additions: Number.parseInt(additionsText, 10) || 0,
2969
- deletions: Number.parseInt(deletionsText, 10) || 0
3515
+ deletions: Number.parseInt(deletionsText, 10) || 0,
3516
+ binary: additionsText === "-" || deletionsText === "-"
2970
3517
  };
2971
3518
  }
2972
3519
 
2973
3520
  // src/tourCoverage.ts
2974
- var TOUR_COVERAGE_REPORT_VERSION = 1;
3521
+ var TOUR_COVERAGE_REPORT_VERSION = 2;
3522
+ function buildTourAuthoringCoverage(report) {
3523
+ return {
3524
+ walkthrough: {
3525
+ scope: report.version === 2 ? "final" : "tour",
3526
+ coveredUnits: report.totals.coveredUnits,
3527
+ includedUnits: report.totals.includedUnits,
3528
+ coveragePercent: report.totals.coveragePercent
3529
+ },
3530
+ explanationAssignments: report.version === 2 ? report.explanationAssignments : []
3531
+ };
3532
+ }
2975
3533
  var DEPTH_ORDER = {
2976
3534
  mentioned: 0,
2977
3535
  explained: 1,
2978
3536
  contextualized: 2
2979
3537
  };
2980
3538
  function buildTourCoverageReport(startPath, sourceValue) {
2981
- var _a, _b, _c, _d, _e;
3539
+ var _a, _b, _c, _d, _e, _f;
2982
3540
  const source = parseChangeTourSource(sourceValue);
2983
3541
  const inventory = buildChangeInventory(startPath, {
2984
3542
  headRef: (_a = source.range) == null ? void 0 : _a.head,
@@ -2994,7 +3552,9 @@ function buildTourCoverageReport(startPath, sourceValue) {
2994
3552
  const includedUnits = textualUnits.filter((unit) => !excludedIds.has(unit.id));
2995
3553
  const coverage = /* @__PURE__ */ new Map();
2996
3554
  const contextualOnlyEvidence = [];
2997
- for (const scene of manifest.scenes) {
3555
+ const percentageDecimalPlaces = manifest.version === 2 ? 2 : 0;
3556
+ const walkthroughScenes = manifest.version === 2 ? requireFinalScenes((_e = manifest.zoom) == null ? void 0 : _e.final.scenes) : manifest.scenes;
3557
+ for (const scene of walkthroughScenes) {
2998
3558
  if (scene.kind !== "walkthrough") continue;
2999
3559
  for (const step of scene.steps) {
3000
3560
  const matches = includedUnits.filter((unit) => evidenceIntersectsUnit(step.focus, unit));
@@ -3015,11 +3575,10 @@ function buildTourCoverageReport(startPath, sourceValue) {
3015
3575
  const highest = covered.depths.reduce((left, right) => DEPTH_ORDER[right] > DEPTH_ORDER[left] ? right : left);
3016
3576
  depth[highest] += 1;
3017
3577
  }
3018
- const files = inventory.files.filter((file) => file.material === "text").map((file) => buildFileResult(file, excludedIds, coverage));
3578
+ const files = inventory.files.filter((file) => file.material === "text").map((file) => buildFileResult(file, excludedIds, coverage, percentageDecimalPlaces));
3019
3579
  const includedChangedLines = includedUnits.reduce((total, unit) => total + unit.additions + unit.deletions, 0);
3020
3580
  const coveredChangedLines = [...coverage.values()].reduce((total, entry) => total + entry.unit.additions + entry.unit.deletions, 0);
3021
- return {
3022
- version: TOUR_COVERAGE_REPORT_VERSION,
3581
+ const report = {
3023
3582
  range: {
3024
3583
  baseRef: inventory.range.baseRef,
3025
3584
  headRef: inventory.range.headRef,
@@ -3032,13 +3591,13 @@ function buildTourCoverageReport(startPath, sourceValue) {
3032
3591
  includedUnits: includedUnits.length,
3033
3592
  coveredUnits: coverage.size,
3034
3593
  uncoveredUnits: includedUnits.length - coverage.size,
3035
- coveragePercent: percentage(coverage.size, includedUnits.length),
3594
+ coveragePercent: percentage(coverage.size, includedUnits.length, percentageDecimalPlaces),
3036
3595
  coveredChangedLines,
3037
3596
  includedChangedLines
3038
3597
  },
3039
3598
  depth,
3040
3599
  files,
3041
- exclusions: (((_e = source.coverage) == null ? void 0 : _e.exclusions) || []).map((exclusion) => {
3600
+ exclusions: (((_f = source.coverage) == null ? void 0 : _f.exclusions) || []).map((exclusion) => {
3042
3601
  var _a2;
3043
3602
  return {
3044
3603
  path: exclusion.path,
@@ -3049,6 +3608,34 @@ function buildTourCoverageReport(startPath, sourceValue) {
3049
3608
  unsupported: inventory.files.filter((file) => file.material !== "text").map((file) => ({ path: file.path, material: file.material })),
3050
3609
  contextualOnlyEvidence
3051
3610
  };
3611
+ if (manifest.version === 1) return { version: 1, ...report };
3612
+ return {
3613
+ version: TOUR_COVERAGE_REPORT_VERSION,
3614
+ ...report,
3615
+ walkthroughScope: "final",
3616
+ explanationAssignments: buildExplanationAssignments(source, inventory.files)
3617
+ };
3618
+ }
3619
+ function requireFinalScenes(scenes) {
3620
+ if (!scenes) throw new Error("A v2 tour requires Final-mode scenes for coverage reporting.");
3621
+ return scenes;
3622
+ }
3623
+ function buildExplanationAssignments(source, files) {
3624
+ const materializableFiles = files.filter((file) => file.material === "text" && file.changeKind !== "renamed" && file.baseContent !== void 0 && file.headContent !== void 0);
3625
+ const totalUnits = materializableFiles.reduce((total, file) => total + file.units.length, 0);
3626
+ return source.chapters.flatMap((chapter) => chapter.scenes).filter((scene) => scene.kind === "deconstructed-diff").map((scene) => {
3627
+ const assignedUnits = scene.stages.reduce((stageTotal, stage) => stageTotal + stage.changes.reduce((changeTotal, change) => changeTotal + change.hunks.length, 0), 0);
3628
+ const excludedUnits = totalUnits - assignedUnits;
3629
+ const completeUnits = assignedUnits + excludedUnits;
3630
+ return {
3631
+ sceneId: scene.id,
3632
+ assignedUnits,
3633
+ excludedUnits,
3634
+ completeUnits,
3635
+ totalUnits,
3636
+ assignmentPercent: percentage(completeUnits, totalUnits)
3637
+ };
3638
+ });
3052
3639
  }
3053
3640
  function resolveExclusions(source, files) {
3054
3641
  var _a, _b, _c;
@@ -3075,7 +3662,7 @@ function evidenceIntersectsUnit(focus, unit) {
3075
3662
  const end = start + count - 1;
3076
3663
  return focus.startLine <= end && focus.endLine >= start;
3077
3664
  }
3078
- function buildFileResult(file, excludedIds, coverage) {
3665
+ function buildFileResult(file, excludedIds, coverage, percentageDecimalPlaces) {
3079
3666
  const included = file.units.filter((unit) => !excludedIds.has(unit.id));
3080
3667
  const covered = included.filter((unit) => coverage.has(unit.id));
3081
3668
  return {
@@ -3083,16 +3670,19 @@ function buildFileResult(file, excludedIds, coverage) {
3083
3670
  originalUnits: file.units.length,
3084
3671
  includedUnits: included.length,
3085
3672
  coveredUnits: covered.length,
3086
- coveragePercent: percentage(covered.length, included.length),
3673
+ coveragePercent: percentage(covered.length, included.length, percentageDecimalPlaces),
3087
3674
  uncoveredHunks: included.filter((unit) => !coverage.has(unit.id)).map((unit) => unit.id)
3088
3675
  };
3089
3676
  }
3090
- function percentage(numerator, denominator) {
3091
- return denominator === 0 ? 100 : Math.round(numerator / denominator * 100);
3677
+ function percentage(numerator, denominator, decimalPlaces = 0) {
3678
+ if (denominator === 0) return 100;
3679
+ const precision = 10 ** decimalPlaces;
3680
+ return Math.round(numerator / denominator * 100 * precision) / precision;
3092
3681
  }
3093
3682
  // Annotate the CommonJS export names for ESM import in node:
3094
3683
  0 && (module.exports = {
3095
3684
  TOUR_COVERAGE_REPORT_VERSION,
3685
+ buildTourAuthoringCoverage,
3096
3686
  buildTourCoverageReport
3097
3687
  });
3098
3688
  //# sourceMappingURL=tourCoverage.js.map