@davmash/bygone 0.8.16 → 0.9.4

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.
@@ -475,17 +475,29 @@ function runGit(repoRoot, args) {
475
475
  var import_child_process3 = require("child_process");
476
476
 
477
477
  // src/changeTourSource.ts
478
- var CHANGE_TOUR_SOURCE_VERSION = 2;
478
+ var CHANGE_TOUR_SOURCE_VERSION = 3;
479
479
  function parseChangeTourSource(value) {
480
480
  if (isRecord(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_SOURCE_VERSION) {
481
481
  throw new Error(
482
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
483
  );
484
484
  }
485
- if (!isRecord(value) || value.version !== 1 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
485
+ if (!isRecord(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
486
486
  throw new Error("Unsupported or missing change-tour source version.");
487
487
  }
488
- requireOnlyKeys(value, ["version", "title", "windowTitle", "sourceUrl", "range", "anchors", "connections", "chapters", "coverage"], "source");
488
+ requireOnlyKeys(value, [
489
+ "version",
490
+ "title",
491
+ "windowTitle",
492
+ "sourceUrl",
493
+ "range",
494
+ "anchors",
495
+ "connections",
496
+ "chapters",
497
+ "tours",
498
+ "coverage",
499
+ "review"
500
+ ], "source");
489
501
  optionalString(value.title, "title");
490
502
  optionalString(value.windowTitle, "windowTitle");
491
503
  optionalString(value.sourceUrl, "sourceUrl");
@@ -549,30 +561,81 @@ function parseChangeTourSource(value) {
549
561
  }
550
562
  connectionIds.add(id);
551
563
  }
564
+ const authoredWalkthroughSteps = validateSourceChapters(
565
+ value.chapters,
566
+ "chapters",
567
+ value.version,
568
+ value.anchors,
569
+ connectionIds,
570
+ "root"
571
+ );
572
+ if (value.tours !== void 0) {
573
+ if (value.version !== 3) throw new Error("Independent tours require version 3.");
574
+ if (!isRecord(value.tours)) throw new Error("tours must be an object.");
575
+ requireOnlyKeys(value.tours, ["historical", "deconstructed"], "tours");
576
+ if (value.tours.historical === void 0 && value.tours.deconstructed === void 0) {
577
+ throw new Error("tours must contain a historical or deconstructed tour.");
578
+ }
579
+ for (const mode of ["historical", "deconstructed"]) {
580
+ const tour = value.tours[mode];
581
+ if (tour === void 0) continue;
582
+ const path = `tours.${mode}`;
583
+ if (!isRecord(tour)) throw new Error(`${path} must be an object.`);
584
+ requireOnlyKeys(tour, ["chapters"], path);
585
+ validateSourceChapters(tour.chapters, `${path}.chapters`, value.version, value.anchors, connectionIds, mode);
586
+ }
587
+ }
588
+ if (value.review !== void 0) {
589
+ if (value.version !== 3) throw new Error("Review notes require version 3.");
590
+ validateChangeTourReview(value.review, { authoredWalkthroughSteps });
591
+ }
592
+ return { ...value, connections: rawConnections };
593
+ }
594
+ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, connectionIds, mode) {
595
+ if (!Array.isArray(chaptersValue) || chaptersValue.length === 0) {
596
+ throw new Error(`${chaptersPath} must be a non-empty array.`);
597
+ }
552
598
  const chapterIds = /* @__PURE__ */ new Set();
553
599
  const sceneIds = /* @__PURE__ */ new Set();
554
- for (const [chapterIndex, chapter] of value.chapters.entries()) {
600
+ const authoredWalkthroughSteps = /* @__PURE__ */ new Map();
601
+ for (const [chapterIndex, chapter] of chaptersValue.entries()) {
602
+ const chapterPath = `${chaptersPath}[${chapterIndex}]`;
555
603
  if (!isRecord(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
556
- throw new Error(`chapters[${chapterIndex}] must contain a non-empty scenes array.`);
604
+ throw new Error(`${chapterPath} must contain a non-empty scenes array.`);
557
605
  }
558
- requireString(chapter.id, `chapters[${chapterIndex}].id`);
559
- requireString(chapter.title, `chapters[${chapterIndex}].title`);
560
- requireOnlyKeys(chapter, ["id", "title", "scenes"], `chapters[${chapterIndex}]`);
606
+ requireString(chapter.id, `${chapterPath}.id`);
607
+ requireString(chapter.title, `${chapterPath}.title`);
608
+ requireOnlyKeys(chapter, ["id", "title", "scenes"], chapterPath);
561
609
  if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
562
610
  chapterIds.add(chapter.id);
563
611
  for (const [sceneIndex, scene] of chapter.scenes.entries()) {
564
- const path = `chapters[${chapterIndex}].scenes[${sceneIndex}]`;
612
+ const path = `${chapterPath}.scenes[${sceneIndex}]`;
565
613
  if (!isRecord(scene)) throw new Error(`${path} must be an object.`);
566
614
  requireString(scene.id, `${path}.id`);
567
615
  requireString(scene.title, `${path}.title`);
568
616
  if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
569
617
  sceneIds.add(scene.id);
618
+ if (scene.overview !== void 0 && version !== 3) {
619
+ throw new Error(`${path}.overview requires version 3.`);
620
+ }
570
621
  validateNarrative(scene, path);
571
622
  if (scene.kind === "deconstructed-diff") {
623
+ if (mode === "historical") {
624
+ throw new Error(`${path} cannot contain a deconstructed-diff scene in the historical tour.`);
625
+ }
572
626
  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);
627
+ validateSceneOverview(scene.overview, `${path}.overview`, {
628
+ endpointIds: deconstructedOverviewEndpointIds(scene, path)
629
+ });
630
+ const requireRealEvidence = mode === "root" && version >= 2;
631
+ if (requireRealEvidence || scene.stack !== void 0) {
632
+ validateRealStack(scene.stack, `${path}.stack`, 2);
633
+ }
634
+ if (requireRealEvidence || scene.steps !== void 0) {
635
+ validateWalkthroughSteps(scene, path, anchors, connectionIds);
636
+ if (scene.steps !== void 0) {
637
+ authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
638
+ }
576
639
  }
577
640
  continue;
578
641
  }
@@ -580,20 +643,110 @@ function parseChangeTourSource(value) {
580
643
  throw new Error(`${path} must contain a non-empty steps array.`);
581
644
  }
582
645
  if (scene.kind === "stacked-diff") {
583
- validateStackedScene(scene, path, value.version === 2 ? 2 : 3);
646
+ validateStackedScene(scene, path, mode === "root" ? version >= 2 ? 2 : 3 : 2);
647
+ validateSceneOverview(scene.overview, `${path}.overview`, {
648
+ endpointIds: stackedOverviewEndpointIds(scene, path)
649
+ });
584
650
  continue;
585
651
  }
586
652
  if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
587
653
  throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
588
654
  }
589
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "steps"], path);
590
- validateWalkthroughSteps(scene, path, value.anchors, connectionIds);
655
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "steps"], path);
656
+ validateSceneOverview(scene.overview, `${path}.overview`, { allowComparison: false });
657
+ validateWalkthroughSteps(scene, path, anchors, connectionIds);
658
+ authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
659
+ }
660
+ }
661
+ if (mode === "deconstructed" && !chaptersValue.some((chapter) => isRecord(chapter) && Array.isArray(chapter.scenes) && chapter.scenes.some((scene) => isRecord(scene) && scene.kind === "deconstructed-diff"))) {
662
+ throw new Error(`${chaptersPath} must contain at least one deconstructed-diff scene.`);
663
+ }
664
+ return authoredWalkthroughSteps;
665
+ }
666
+ function stackedOverviewEndpointIds(scene, path) {
667
+ if (!Array.isArray(scene.stack)) {
668
+ throw new Error(`${path}.stack must be an array.`);
669
+ }
670
+ return scene.stack.filter(isRecord).map((entry) => typeof entry.id === "string" ? entry.id : "").filter((id) => id.length > 0);
671
+ }
672
+ function deconstructedOverviewEndpointIds(scene, path) {
673
+ if (!Array.isArray(scene.stages)) {
674
+ throw new Error(`${path}.stages must be an array.`);
675
+ }
676
+ return [
677
+ "explanation-baseline",
678
+ ...scene.stages.filter(isRecord).map((stage) => typeof stage.id === "string" ? `explanation-stage-${stage.id}` : "").filter((id) => id.length > 0)
679
+ ];
680
+ }
681
+ function validateChangeTourReview(value, context = {}) {
682
+ if (!isRecord(value)) {
683
+ throw new Error("review must be an object.");
684
+ }
685
+ requireOnlyKeys(value, ["baseOid", "headOid", "items"], "review");
686
+ requireFullGitOid(value.baseOid, "review.baseOid");
687
+ requireFullGitOid(value.headOid, "review.headOid");
688
+ if (!Array.isArray(value.items) || value.items.length === 0) {
689
+ throw new Error("review.items must be a non-empty array.");
690
+ }
691
+ if (context.expectedRange) {
692
+ if (normalizeGitOid(value.baseOid) !== normalizeGitOid(context.expectedRange.baseOid)) {
693
+ throw new Error(
694
+ `Stale review: review.baseOid ${value.baseOid} does not match the resolved range.mergeBaseOid ${context.expectedRange.baseOid}.`
695
+ );
696
+ }
697
+ if (normalizeGitOid(value.headOid) !== normalizeGitOid(context.expectedRange.headOid)) {
698
+ throw new Error(
699
+ `Stale review: review.headOid ${value.headOid} does not match the resolved range.headOid ${context.expectedRange.headOid}.`
700
+ );
701
+ }
702
+ }
703
+ const itemIds = /* @__PURE__ */ new Set();
704
+ for (const [index, item] of value.items.entries()) {
705
+ const itemPath = `review.items[${index}]`;
706
+ if (!isRecord(item)) throw new Error(`${itemPath} must be an object.`);
707
+ requireOnlyKeys(item, ["id", "kind", "title", "body", "evidence", "nextCheck"], itemPath);
708
+ requireReviewString(item.id, `${itemPath}.id`);
709
+ if (itemIds.has(item.id)) throw new Error(`Duplicate review item id: ${item.id}`);
710
+ itemIds.add(item.id);
711
+ if (typeof item.kind !== "string" || !["concept", "boundary", "tradeoff", "question"].includes(item.kind)) {
712
+ throw new Error(`${itemPath}.kind must be concept, boundary, tradeoff, or question.`);
713
+ }
714
+ requireReviewString(item.title, `${itemPath}.title`);
715
+ requireReviewString(item.body, `${itemPath}.body`);
716
+ if (!Array.isArray(item.evidence)) {
717
+ throw new Error(`${itemPath}.evidence must be an array.`);
718
+ }
719
+ if (item.kind !== "question" && item.evidence.length === 0) {
720
+ throw new Error(`${itemPath}.evidence must contain at least one reference for claims.`);
721
+ }
722
+ if (item.kind === "question" && item.nextCheck === void 0) {
723
+ throw new Error(`${itemPath}.nextCheck must be a non-empty string for question items.`);
724
+ }
725
+ if (item.nextCheck !== void 0) requireReviewString(item.nextCheck, `${itemPath}.nextCheck`);
726
+ for (const [evidenceIndex, evidence] of item.evidence.entries()) {
727
+ const evidencePath = `${itemPath}.evidence[${evidenceIndex}]`;
728
+ if (!isRecord(evidence)) throw new Error(`${evidencePath} must be an object.`);
729
+ requireOnlyKeys(evidence, ["sceneId", "stepId"], evidencePath);
730
+ requireReviewString(evidence.sceneId, `${evidencePath}.sceneId`);
731
+ requireReviewString(evidence.stepId, `${evidencePath}.stepId`);
732
+ if (context.authoredWalkthroughSteps) {
733
+ const stepIds = context.authoredWalkthroughSteps.get(evidence.sceneId);
734
+ if (!stepIds) {
735
+ throw new Error(
736
+ `${evidencePath} references unknown authored walkthrough scene ${evidence.sceneId}.`
737
+ );
738
+ }
739
+ if (!stepIds.has(evidence.stepId)) {
740
+ throw new Error(
741
+ `${evidencePath} references unknown authored walkthrough step ${evidence.sceneId}/${evidence.stepId}.`
742
+ );
743
+ }
744
+ }
591
745
  }
592
746
  }
593
- return { ...value, connections: rawConnections };
594
747
  }
595
748
  function validateDeconstructedScene(scene, path) {
596
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "base", "target", "stack", "steps", "stages", "exclusions"], path);
749
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "base", "target", "stack", "steps", "stages", "exclusions"], path);
597
750
  optionalString(scene.base, `${path}.base`);
598
751
  optionalString(scene.target, `${path}.target`);
599
752
  if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
@@ -679,7 +832,7 @@ function validateRealStack(stack, path, minimum) {
679
832
  }
680
833
  }
681
834
  function validateStackedScene(scene, path, minimum = 3) {
682
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "stack", "files", "steps"], path);
835
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "stack", "files", "steps"], path);
683
836
  if (!Array.isArray(scene.stack) || scene.stack.length < minimum || scene.stack.length > 6) {
684
837
  throw new Error(`${path}.stack must contain between ${minimum} and 6 revisions.`);
685
838
  }
@@ -735,6 +888,53 @@ function validateStepRequirement(value, path) {
735
888
  throw new Error(`${path}.confidence must be high, medium, or low.`);
736
889
  }
737
890
  }
891
+ function validateSceneOverview(value, path, options = {}) {
892
+ if (value === void 0) return;
893
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
894
+ requireOnlyKeys(value, ["kind", "path", "comparison"], path);
895
+ if (value.kind !== "directory-diff") {
896
+ throw new Error(`${path}.kind must be directory-diff.`);
897
+ }
898
+ if (value.path !== void 0) validateDirectoryOverviewPath(value.path, `${path}.path`);
899
+ if (value.comparison === void 0) return;
900
+ if (options.allowComparison === false) {
901
+ throw new Error(`${path}.comparison is not supported for walkthrough scenes; use the fixed base-to-head comparison.`);
902
+ }
903
+ if (!isRecord(value.comparison)) throw new Error(`${path}.comparison must be an object.`);
904
+ requireOnlyKeys(value.comparison, ["from", "to"], `${path}.comparison`);
905
+ requireString(value.comparison.from, `${path}.comparison.from`);
906
+ requireString(value.comparison.to, `${path}.comparison.to`);
907
+ if (value.comparison.from === value.comparison.to) {
908
+ throw new Error(`${path}.comparison.from and to must identify distinct endpoints.`);
909
+ }
910
+ if (options.endpointIds !== void 0) {
911
+ for (const endpoint of [value.comparison.from, value.comparison.to]) {
912
+ if (!options.endpointIds.includes(endpoint)) {
913
+ throw new Error(`${path}.comparison endpoint ${endpoint} is not defined by this scene.`);
914
+ }
915
+ }
916
+ }
917
+ }
918
+ function validateDirectoryOverviewPath(value, path) {
919
+ requireString(value, path);
920
+ if (value.includes("\\")) {
921
+ throw new Error(`${path} must use a relative POSIX directory path without backslashes.`);
922
+ }
923
+ if (value.startsWith("/") || /^[A-Za-z]:/.test(value)) {
924
+ throw new Error(`${path} must be relative to the repository root.`);
925
+ }
926
+ if (value.split("/").some((segment) => segment === "..")) {
927
+ throw new Error(`${path} must not contain parent-directory traversal.`);
928
+ }
929
+ }
930
+ function collectStepIds(value) {
931
+ const ids = /* @__PURE__ */ new Set();
932
+ if (!Array.isArray(value)) return ids;
933
+ for (const step of value) {
934
+ if (isRecord(step) && typeof step.id === "string") ids.add(step.id);
935
+ }
936
+ return ids;
937
+ }
738
938
  function validateNarrative(value, path) {
739
939
  requireString(value.summary, `${path}.summary`);
740
940
  requireStringArray(value.bullets, `${path}.bullets`);
@@ -749,6 +949,20 @@ function requireString(value, path) {
749
949
  throw new Error(`${path} must be a non-empty string.`);
750
950
  }
751
951
  }
952
+ function requireFullGitOid(value, path) {
953
+ requireString(value, path);
954
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)) {
955
+ throw new Error(`${path} must be a full 40- or 64-character hexadecimal Git OID.`);
956
+ }
957
+ }
958
+ function requireReviewString(value, path) {
959
+ if (typeof value !== "string" || value.trim().length === 0) {
960
+ throw new Error(`${path} must be a non-empty string.`);
961
+ }
962
+ }
963
+ function normalizeGitOid(value) {
964
+ return value.toLowerCase();
965
+ }
752
966
  function optionalString(value, path) {
753
967
  if (value !== void 0) {
754
968
  requireString(value, path);
@@ -767,14 +981,14 @@ function requireOnlyKeys(value, allowed, path) {
767
981
  }
768
982
 
769
983
  // src/changeTourManifest.ts
770
- var CHANGE_TOUR_MANIFEST_VERSION = 2;
984
+ var CHANGE_TOUR_MANIFEST_VERSION = 3;
771
985
  function parseChangeTourManifest(value) {
772
986
  if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
773
987
  throw new Error(
774
988
  `This tour uses manifest format version ${value.version}, but this version of Bygone supports up to version ${CHANGE_TOUR_MANIFEST_VERSION}. Upgrade Bygone to open it.`
775
989
  );
776
990
  }
777
- if (!isRecord2(value) || value.version !== 1 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
991
+ if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
778
992
  throw new Error(`Unsupported or missing change-tour manifest version.`);
779
993
  }
780
994
  requireString2(value.title, "title");
@@ -806,7 +1020,7 @@ function parseChangeTourManifest(value) {
806
1020
  }
807
1021
  const sceneIds = /* @__PURE__ */ new Set();
808
1022
  for (const [index, candidate] of value.scenes.entries()) {
809
- validateScene(candidate, index);
1023
+ validateScene(candidate, index, value.version);
810
1024
  if (sceneIds.has(candidate.id)) {
811
1025
  throw new Error(`Duplicate change-tour scene id: ${candidate.id}`);
812
1026
  }
@@ -838,14 +1052,44 @@ function parseChangeTourManifest(value) {
838
1052
  if (value.authoringCoverage !== void 0) {
839
1053
  validateAuthoringCoverage(value.authoringCoverage, value.version);
840
1054
  }
841
- if (value.version === 2) validateZoom(value);
1055
+ if (value.tours !== void 0) {
1056
+ if (value.version !== 3) throw new Error("Independent tours require manifest version 3.");
1057
+ validateModeTours(value.tours, value.version);
1058
+ }
1059
+ if (value.version !== 1) validateZoom(value, value.version);
1060
+ if (value.review !== void 0) {
1061
+ if (value.version !== 3) throw new Error("Review notes require manifest version 3.");
1062
+ const evidenceScenes = value.version >= 2 && isRecord2(value.zoom) && isRecord2(value.zoom.final) ? value.zoom.final.scenes : value.scenes;
1063
+ validateChangeTourReview(value.review, {
1064
+ expectedRange: {
1065
+ baseOid: String(value.range.mergeBaseOid),
1066
+ headOid: String(value.range.headOid)
1067
+ },
1068
+ authoredWalkthroughSteps: collectAuthoredWalkthroughStepIds(evidenceScenes)
1069
+ });
1070
+ }
842
1071
  return { ...value, files };
843
1072
  }
1073
+ function collectAuthoredWalkthroughStepIds(value) {
1074
+ const scenes = /* @__PURE__ */ new Map();
1075
+ if (!Array.isArray(value)) return scenes;
1076
+ for (const scene of value) {
1077
+ if (!isRecord2(scene) || scene.kind !== "walkthrough" || !Array.isArray(scene.steps) || typeof scene.id !== "string") {
1078
+ continue;
1079
+ }
1080
+ const stepIds = /* @__PURE__ */ new Set();
1081
+ for (const step of scene.steps) {
1082
+ if (isRecord2(step) && typeof step.id === "string") stepIds.add(step.id);
1083
+ }
1084
+ scenes.set(scene.id, stepIds);
1085
+ }
1086
+ return scenes;
1087
+ }
844
1088
  function validateAuthoringCoverage(value, version) {
845
1089
  if (!isRecord2(value) || !isRecord2(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
846
1090
  throw new Error("authoringCoverage must contain walkthrough and explanationAssignments.");
847
1091
  }
848
- const expectedScope = version === 2 ? "final" : "tour";
1092
+ const expectedScope = version >= 2 ? "final" : "tour";
849
1093
  if (value.walkthrough.scope !== expectedScope) {
850
1094
  throw new Error(`authoringCoverage.walkthrough.scope must be ${expectedScope}.`);
851
1095
  }
@@ -869,7 +1113,49 @@ function validateAuthoringCoverage(value, version) {
869
1113
  }
870
1114
  }
871
1115
  }
872
- function validateZoom(value) {
1116
+ function validateModeTours(value, version) {
1117
+ if (!isRecord2(value)) throw new Error("Change-tour manifest tours must be an object.");
1118
+ if (value.historical === void 0 && value.deconstructed === void 0) {
1119
+ throw new Error("Change-tour manifest tours must contain a historical or deconstructed tour.");
1120
+ }
1121
+ for (const mode of ["historical", "deconstructed"]) {
1122
+ const tour = value[mode];
1123
+ if (tour === void 0) continue;
1124
+ if (!isRecord2(tour) || !Array.isArray(tour.chapters) || !Array.isArray(tour.scenes) || tour.chapters.length === 0 || tour.scenes.length === 0) {
1125
+ throw new Error(`Change-tour manifest tours.${mode} must contain non-empty chapters and scenes arrays.`);
1126
+ }
1127
+ const sceneIds = /* @__PURE__ */ new Set();
1128
+ let hasDeconstructed = false;
1129
+ for (const [index, scene] of tour.scenes.entries()) {
1130
+ validateScene(scene, index, version);
1131
+ if (sceneIds.has(scene.id)) {
1132
+ throw new Error(`Duplicate ${mode} tour scene id: ${scene.id}`);
1133
+ }
1134
+ sceneIds.add(scene.id);
1135
+ if (scene.kind === "deconstructed-diff") hasDeconstructed = true;
1136
+ if (mode === "historical" && scene.kind === "deconstructed-diff") {
1137
+ throw new Error("Historical tours cannot contain synthetic deconstructed scenes.");
1138
+ }
1139
+ }
1140
+ if (mode === "deconstructed" && !hasDeconstructed) {
1141
+ throw new Error("Deconstructed tours must contain a constructed deconstructed-diff scene.");
1142
+ }
1143
+ for (const [index, chapter] of tour.chapters.entries()) {
1144
+ if (!isRecord2(chapter)) {
1145
+ throw new Error(`tours.${mode}.chapters[${index}] must be an object.`);
1146
+ }
1147
+ requireString2(chapter.id, `tours.${mode}.chapters[${index}].id`);
1148
+ requireString2(chapter.title, `tours.${mode}.chapters[${index}].title`);
1149
+ requireStringArray2(chapter.sceneIds, `tours.${mode}.chapters[${index}].sceneIds`);
1150
+ for (const sceneId of chapter.sceneIds) {
1151
+ if (!sceneIds.has(sceneId)) {
1152
+ throw new Error(`tours.${mode} chapter references unknown scene id: ${sceneId}`);
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ function validateZoom(value, version) {
873
1159
  if (!isRecord2(value.repository) || typeof value.repository.root !== "string" || !value.repository.root) {
874
1160
  throw new Error("A v2 tour requires its originating repository.root.");
875
1161
  }
@@ -880,7 +1166,7 @@ function validateZoom(value) {
880
1166
  const revisions = value.zoom.revisions;
881
1167
  const ids = /* @__PURE__ */ new Set();
882
1168
  for (const [index, scene] of revisions.entries()) {
883
- validateScene(scene, index);
1169
+ validateScene(scene, index, version);
884
1170
  if (scene.kind !== "stacked-diff") throw new Error("zoom.revisions must contain real stacked-diff scenes.");
885
1171
  if (ids.has(scene.id)) throw new Error(`Duplicate zoom revision scene: ${scene.id}`);
886
1172
  ids.add(scene.id);
@@ -910,7 +1196,7 @@ function validateZoom(value) {
910
1196
  }
911
1197
  const finalSceneIds = /* @__PURE__ */ new Set();
912
1198
  for (const [index, scene] of value.zoom.final.scenes.entries()) {
913
- validateScene(scene, index);
1199
+ validateScene(scene, index, version);
914
1200
  if (scene.kind === "stacked-diff" || scene.kind === "deconstructed-diff") {
915
1201
  throw new Error("zoom.final must contain only non-stacked scenes.");
916
1202
  }
@@ -929,18 +1215,23 @@ function validateZoom(value) {
929
1215
  }
930
1216
  }
931
1217
  }
932
- function validateScene(value, index) {
1218
+ function validateScene(value, index, version) {
933
1219
  if (!isRecord2(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
934
1220
  throw new Error(`scenes[${index}] must be a text-diff, discussion, walkthrough, stacked-diff, or deconstructed-diff scene.`);
935
1221
  }
936
1222
  for (const key of ["id", "title"]) {
937
1223
  requireString2(value[key], `scenes[${index}].${key}`);
938
1224
  }
1225
+ if (value.overview !== void 0 && version !== 3) {
1226
+ throw new Error(`scenes[${index}].overview requires manifest version 3.`);
1227
+ }
939
1228
  validateNarrative2(value, `scenes[${index}]`);
940
1229
  if (value.kind === "discussion") {
1230
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
941
1231
  return;
942
1232
  }
943
1233
  if (value.kind === "walkthrough") {
1234
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
944
1235
  if (!Array.isArray(value.steps) || value.steps.length === 0) {
945
1236
  throw new Error(`scenes[${index}].steps must be a non-empty array.`);
946
1237
  }
@@ -955,7 +1246,7 @@ function validateScene(value, index) {
955
1246
  }
956
1247
  validateStepRequirement(step.requirement, `${path}.requirement`);
957
1248
  validateResolvedAnchor(step.focus, `${path}.focus`);
958
- validateScene(step.diff, index);
1249
+ validateScene(step.diff, index, version);
959
1250
  if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
960
1251
  if (step.connection !== void 0) {
961
1252
  if (!isRecord2(step.connection)) throw new Error(`${path}.connection must be an object.`);
@@ -969,12 +1260,19 @@ function validateScene(value, index) {
969
1260
  }
970
1261
  if (value.kind === "stacked-diff") {
971
1262
  validateStackedScene2(value, index);
1263
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, {
1264
+ endpointIds: value.stack.map((panel) => panel.id)
1265
+ });
972
1266
  return;
973
1267
  }
974
1268
  if (value.kind === "deconstructed-diff") {
975
1269
  validateDeconstructedScene2(value, index);
1270
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, {
1271
+ endpointIds: value.panels.map((panel) => panel.id)
1272
+ });
976
1273
  return;
977
1274
  }
1275
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
978
1276
  for (const key of ["path", "leftLabel", "rightLabel", "leftContent", "rightContent"]) {
979
1277
  requireString2(value[key], `scenes[${index}].${key}`);
980
1278
  }
@@ -1092,7 +1390,7 @@ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1092
1390
  function validateTourFile(value, index) {
1093
1391
  if (!isRecord2(value)) throw new Error(`files[${index}] must be an object.`);
1094
1392
  if (value.kind === "text-diff") {
1095
- validateScene(value, index);
1393
+ validateScene(value, index, 1);
1096
1394
  return;
1097
1395
  }
1098
1396
  if (value.kind !== "omitted") {
@@ -2794,12 +3092,21 @@ var CHAPTERS = {
2794
3092
  packaging: { id: "packaging", title: "Packaging and dependencies", priority: 4 }
2795
3093
  };
2796
3094
  function buildChangeTourManifest(startPath, options = {}) {
2797
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
3095
+ var _a, _b, _c, _d;
3096
+ const source = options.source ? parseChangeTourSource(options.source) : void 0;
2798
3097
  const range = resolveBranchReviewRange(
2799
3098
  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)
3099
+ options.headRef ?? ((_a = source == null ? void 0 : source.range) == null ? void 0 : _a.head),
3100
+ options.baseRef ?? ((_b = source == null ? void 0 : source.range) == null ? void 0 : _b.base)
2802
3101
  );
3102
+ if (source == null ? void 0 : source.review) {
3103
+ validateChangeTourReview(source.review, {
3104
+ expectedRange: {
3105
+ baseOid: range.mergeBaseOid,
3106
+ headOid: range.headOid
3107
+ }
3108
+ });
3109
+ }
2803
3110
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_TOUR_FILE_BYTES;
2804
3111
  const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_TOUR_LINE_BYTES;
2805
3112
  const omittedFiles = [];
@@ -2860,7 +3167,7 @@ function buildChangeTourManifest(startPath, options = {}) {
2860
3167
  files.sort((left, right) => left.path.localeCompare(right.path));
2861
3168
  sceneRecords.sort((left, right) => left.chapter.priority - right.chapter.priority || left.scene.path.localeCompare(right.scene.path));
2862
3169
  let defaultScenes = sceneRecords.map(({ scene }, index) => ({ ...scene, id: `file-${index + 1}` }));
2863
- if (options.story && options.source) {
3170
+ if (options.story && source) {
2864
3171
  throw new Error("A change tour can use either a legacy story or a source file, not both.");
2865
3172
  }
2866
3173
  const attention = buildChangeAttention(files.map((file) => ({
@@ -2880,7 +3187,7 @@ function buildChangeTourManifest(startPath, options = {}) {
2880
3187
  };
2881
3188
  defaultScenes = [...defaultScenes].sort((left, right) => Number(right.path === attention.entryPath) - Number(left.path === attention.entryPath) || focusRank(left.path) - focusRank(right.path) || left.id.localeCompare(right.id));
2882
3189
  }
2883
- const authored = options.source ? applySource(parseChangeTourSource(options.source), defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
3190
+ const authored = source ? applySource(source, defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
2884
3191
  defaultScenes,
2885
3192
  buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id]))),
2886
3193
  attention,
@@ -2897,12 +3204,12 @@ function buildChangeTourManifest(startPath, options = {}) {
2897
3204
  const finalScenes = "finalScenes" in authored ? authored.finalScenes : scenes;
2898
3205
  const finalChapters = "finalChapters" in authored ? authored.finalChapters : chapters;
2899
3206
  const manifest = {
2900
- // Only authored v2 files opt into repository-bound zoom modes. Generated
3207
+ // Only authored v2+ files opt into repository-bound zoom modes. Generated
2901
3208
  // 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,
3209
+ version: (source == null ? void 0 : source.version) ?? 1,
3210
+ title: (source == null ? void 0 : source.title) || ((_c = options.story) == null ? void 0 : _c.title) || options.title || `${range.headRef} against ${range.baseRef}`,
3211
+ windowTitle: source == null ? void 0 : source.windowTitle,
3212
+ sourceUrl: (source == null ? void 0 : source.sourceUrl) || ((_d = options.story) == null ? void 0 : _d.sourceUrl) || options.sourceUrl,
2906
3213
  generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
2907
3214
  range: {
2908
3215
  baseRef: range.baseRef,
@@ -2921,22 +3228,33 @@ function buildChangeTourManifest(startPath, options = {}) {
2921
3228
  commits: range.commits,
2922
3229
  files,
2923
3230
  chapters,
2924
- scenes
3231
+ scenes,
3232
+ ...(source == null ? void 0 : source.review) ? { review: source.review } : {}
2925
3233
  };
2926
- if (manifest.version === 2) {
3234
+ const authoredModeTours = (source == null ? void 0 : source.version) === 3 ? buildAuthoredModeTours(
3235
+ source,
3236
+ authored,
3237
+ defaultScenes,
3238
+ range.repoRoot,
3239
+ range.mergeBaseOid,
3240
+ range.headOid
3241
+ ) : void 0;
3242
+ if (authoredModeTours) manifest.tours = authoredModeTours;
3243
+ if (manifest.version >= 2) {
2927
3244
  manifest.repository = { root: range.repoRoot };
2928
3245
  const revisions = [];
2929
3246
  for (const scene of scenes) {
2930
3247
  let realStack;
2931
3248
  if (scene.kind === "stacked-diff") realStack = scene;
2932
3249
  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);
3250
+ const authoredScene = source == null ? void 0 : source.chapters.flatMap((chapter) => chapter.scenes).find((candidate) => candidate.id === scene.id);
2934
3251
  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.`);
3252
+ throw new Error(`Deconstructed scene ${scene.id} requires an explicit real revision stack in v2+.`);
2936
3253
  }
2937
3254
  realStack = buildStackedScene(range.repoRoot, {
2938
3255
  ...authoredScene,
2939
3256
  kind: "stacked-diff",
3257
+ overview: compileSceneOverview(authoredScene.overview, false),
2940
3258
  stack: authoredScene.stack,
2941
3259
  files: scene.files.map((file) => file.path),
2942
3260
  steps: []
@@ -2969,6 +3287,9 @@ function buildChangeTourManifest(startPath, options = {}) {
2969
3287
  return parseChangeTourManifest(manifest);
2970
3288
  }
2971
3289
  function applySource(source, defaultScenes, repoRoot) {
3290
+ return applySourceChapters(source, source.chapters, defaultScenes, repoRoot);
3291
+ }
3292
+ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot) {
2972
3293
  var _a, _b;
2973
3294
  const available = new Map(defaultScenes.map((scene) => [scene.path, scene]));
2974
3295
  const resolvedAnchors = /* @__PURE__ */ new Map();
@@ -2990,7 +3311,7 @@ function applySource(source, defaultScenes, repoRoot) {
2990
3311
  const chapters = [];
2991
3312
  const finalScenes = [];
2992
3313
  const finalChapters = [];
2993
- for (const chapter of source.chapters) {
3314
+ for (const chapter of chaptersToCompile) {
2994
3315
  const sceneIds = [];
2995
3316
  const finalSceneIds = [];
2996
3317
  for (const authoredScene of chapter.scenes) {
@@ -3044,7 +3365,60 @@ function applySource(source, defaultScenes, repoRoot) {
3044
3365
  }
3045
3366
  return { scenes, chapters, finalScenes, finalChapters };
3046
3367
  }
3368
+ function buildAuthoredModeTours(source, root, defaultScenes, repoRoot, mergeBaseOid, headOid) {
3369
+ var _a, _b;
3370
+ const tours = {};
3371
+ if ((_a = source.tours) == null ? void 0 : _a.historical) {
3372
+ const compiled = applySourceChapters(source, source.tours.historical.chapters, defaultScenes, repoRoot);
3373
+ validateHistoricalStackEndpoints(compiled.scenes, mergeBaseOid, headOid);
3374
+ tours.historical = { chapters: compiled.chapters, scenes: compiled.scenes };
3375
+ } else if (source.version === 3) {
3376
+ const fallback = buildHistoricalFallback(source.chapters, root);
3377
+ if (fallback) tours.historical = fallback;
3378
+ }
3379
+ if ((_b = source.tours) == null ? void 0 : _b.deconstructed) {
3380
+ const compiled = applySourceChapters(source, source.tours.deconstructed.chapters, defaultScenes, repoRoot);
3381
+ tours.deconstructed = { chapters: compiled.chapters, scenes: compiled.scenes };
3382
+ } else if (source.version === 3 && source.chapters.some((chapter) => chapter.scenes.some((scene) => scene.kind === "deconstructed-diff"))) {
3383
+ tours.deconstructed = { chapters: root.chapters, scenes: root.scenes };
3384
+ }
3385
+ return tours.historical || tours.deconstructed ? tours : void 0;
3386
+ }
3387
+ function validateHistoricalStackEndpoints(scenes, mergeBaseOid, headOid) {
3388
+ for (const scene of scenes) {
3389
+ if (scene.kind !== "stacked-diff") continue;
3390
+ if (scene.stack[0].oid !== mergeBaseOid || scene.stack[scene.stack.length - 1].oid !== headOid) {
3391
+ throw new Error(`Historical scene ${scene.id} real stack endpoints must match the tour's final diff range.`);
3392
+ }
3393
+ }
3394
+ }
3395
+ function buildHistoricalFallback(sourceChapters, root) {
3396
+ const regularScenes = new Map(root.scenes.map((scene) => [scene.id, scene]));
3397
+ const finalScenes = new Map(root.finalScenes.map((scene) => [scene.id, scene]));
3398
+ const scenes = [];
3399
+ const chapters = [];
3400
+ for (const chapter of sourceChapters) {
3401
+ const sceneIds = [];
3402
+ for (const authoredScene of chapter.scenes) {
3403
+ const scene = authoredScene.kind === "deconstructed-diff" ? authoredScene.steps ? finalScenes.get(authoredScene.id) : void 0 : regularScenes.get(authoredScene.id);
3404
+ if (!scene) continue;
3405
+ scenes.push(scene);
3406
+ sceneIds.push(scene.id);
3407
+ }
3408
+ if (sceneIds.length > 0) chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3409
+ }
3410
+ return scenes.length > 0 ? { chapters, scenes } : void 0;
3411
+ }
3412
+ function compileSceneOverview(overview, includeComparison = true) {
3413
+ if (!overview) return void 0;
3414
+ return {
3415
+ kind: overview.kind,
3416
+ ...overview.path === void 0 ? {} : { path: overview.path },
3417
+ ...includeComparison && overview.comparison ? { comparison: { ...overview.comparison } } : {}
3418
+ };
3419
+ }
3047
3420
  function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections) {
3421
+ const overview = compileSceneOverview(authoredScene.overview, false);
3048
3422
  return {
3049
3423
  id: authoredScene.id,
3050
3424
  kind: "walkthrough",
@@ -3053,6 +3427,7 @@ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors
3053
3427
  bullets: authoredScene.bullets,
3054
3428
  tags: authoredScene.tags,
3055
3429
  takeaway: authoredScene.takeaway,
3430
+ ...overview ? { overview } : {},
3056
3431
  steps: authoredScene.steps.map((step) => {
3057
3432
  const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
3058
3433
  const diff2 = available.get(focus.path);
@@ -3127,6 +3502,7 @@ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, a
3127
3502
  });
3128
3503
  if (files.length === 0) throw new Error(`Deconstructed scene ${source.id} has no staged files.`);
3129
3504
  const steps = buildDeconstructedFocusSteps(source.id, compiled.stages, files);
3505
+ const overview = compileSceneOverview(source.overview);
3130
3506
  return {
3131
3507
  id: source.id,
3132
3508
  kind: "deconstructed-diff",
@@ -3135,6 +3511,7 @@ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, a
3135
3511
  bullets: source.bullets,
3136
3512
  tags: source.tags,
3137
3513
  takeaway: source.takeaway,
3514
+ ...overview ? { overview } : {},
3138
3515
  stageLabel: "Explanation stages",
3139
3516
  realRange: {
3140
3517
  baseRef: baseRef || compiled.baseOid,
@@ -3276,6 +3653,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
3276
3653
  });
3277
3654
  if (files.length === 0) throw new Error(`Stacked scene ${source.id} has no materializable text files.`);
3278
3655
  const stackIds = stack.map((entry) => entry.id);
3656
+ const overview = compileSceneOverview(source.overview);
3279
3657
  return {
3280
3658
  id: source.id,
3281
3659
  kind: "stacked-diff",
@@ -3284,6 +3662,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
3284
3662
  bullets: source.bullets,
3285
3663
  tags: source.tags,
3286
3664
  takeaway: source.takeaway,
3665
+ ...overview ? { overview } : {},
3287
3666
  stack,
3288
3667
  files,
3289
3668
  steps: source.steps.length === 0 ? stack.slice(0, -1).map((_, pairIndex) => ({
@@ -3536,7 +3915,7 @@ var DEPTH_ORDER = {
3536
3915
  contextualized: 2
3537
3916
  };
3538
3917
  function buildTourCoverageReport(startPath, sourceValue) {
3539
- var _a, _b, _c, _d, _e, _f;
3918
+ var _a, _b, _c, _d, _e, _f, _g, _h;
3540
3919
  const source = parseChangeTourSource(sourceValue);
3541
3920
  const inventory = buildChangeInventory(startPath, {
3542
3921
  headRef: (_a = source.range) == null ? void 0 : _a.head,
@@ -3552,8 +3931,10 @@ function buildTourCoverageReport(startPath, sourceValue) {
3552
3931
  const includedUnits = textualUnits.filter((unit) => !excludedIds.has(unit.id));
3553
3932
  const coverage = /* @__PURE__ */ new Map();
3554
3933
  const contextualOnlyEvidence = [];
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;
3934
+ const percentageDecimalPlaces = manifest.version >= 2 ? 2 : 0;
3935
+ const authoredHistoricalScenes = (_f = (_e = manifest.tours) == null ? void 0 : _e.historical) == null ? void 0 : _f.scenes;
3936
+ const historicalHasWalkthrough = (authoredHistoricalScenes == null ? void 0 : authoredHistoricalScenes.some((scene) => scene.kind === "walkthrough")) === true;
3937
+ const walkthroughScenes = manifest.version >= 2 ? requireFinalScenes(historicalHasWalkthrough ? authoredHistoricalScenes : (_g = manifest.zoom) == null ? void 0 : _g.final.scenes) : historicalHasWalkthrough ? authoredHistoricalScenes : manifest.scenes;
3557
3938
  for (const scene of walkthroughScenes) {
3558
3939
  if (scene.kind !== "walkthrough") continue;
3559
3940
  for (const step of scene.steps) {
@@ -3597,7 +3978,7 @@ function buildTourCoverageReport(startPath, sourceValue) {
3597
3978
  },
3598
3979
  depth,
3599
3980
  files,
3600
- exclusions: (((_f = source.coverage) == null ? void 0 : _f.exclusions) || []).map((exclusion) => {
3981
+ exclusions: (((_h = source.coverage) == null ? void 0 : _h.exclusions) || []).map((exclusion) => {
3601
3982
  var _a2;
3602
3983
  return {
3603
3984
  path: exclusion.path,
@@ -3621,9 +4002,11 @@ function requireFinalScenes(scenes) {
3621
4002
  return scenes;
3622
4003
  }
3623
4004
  function buildExplanationAssignments(source, files) {
4005
+ var _a, _b;
3624
4006
  const materializableFiles = files.filter((file) => file.material === "text" && file.changeKind !== "renamed" && file.baseContent !== void 0 && file.headContent !== void 0);
3625
4007
  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) => {
4008
+ const deconstructedChapters = ((_b = (_a = source.tours) == null ? void 0 : _a.deconstructed) == null ? void 0 : _b.chapters) || source.chapters;
4009
+ return deconstructedChapters.flatMap((chapter) => chapter.scenes).filter((scene) => scene.kind === "deconstructed-diff").map((scene) => {
3627
4010
  const assignedUnits = scene.stages.reduce((stageTotal, stage) => stageTotal + stage.changes.reduce((changeTotal, change) => changeTotal + change.hunks.length, 0), 0);
3628
4011
  const excludedUnits = totalUnits - assignedUnits;
3629
4012
  const completeUnits = assignedUnits + excludedUnits;