@davmash/bygone 0.8.14 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/out/changeTour.js CHANGED
@@ -30,10 +30,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/changeTour.ts
31
31
  var changeTour_exports = {};
32
32
  __export(changeTour_exports, {
33
+ CHANGE_ATTENTION_VERSION: () => CHANGE_ATTENTION_VERSION,
34
+ buildChangeAttention: () => buildChangeAttention,
33
35
  buildChangeInventory: () => buildChangeInventory,
34
36
  buildChangeTourContext: () => buildChangeTourContext,
35
37
  buildChangeTourManifest: () => buildChangeTourManifest,
36
38
  buildDeconstructedScene: () => buildDeconstructedScene,
39
+ classifyChangeFileRole: () => classifyChangeFileRole,
37
40
  compileDeconstructedScene: () => compileDeconstructedScene,
38
41
  materializeChangeUnits: () => materializeChangeUnits,
39
42
  parseChangeTourManifest: () => parseChangeTourManifest,
@@ -231,33 +234,544 @@ function readGitMaxBuffer() {
231
234
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GIT_MAX_BUFFER_BYTES;
232
235
  }
233
236
 
237
+ // src/changeTourSource.ts
238
+ var CHANGE_TOUR_SOURCE_VERSION = 3;
239
+ function parseChangeTourSource(value) {
240
+ if (isRecord(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_SOURCE_VERSION) {
241
+ throw new Error(
242
+ `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.`
243
+ );
244
+ }
245
+ if (!isRecord(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
246
+ throw new Error("Unsupported or missing change-tour source version.");
247
+ }
248
+ requireOnlyKeys(value, [
249
+ "version",
250
+ "title",
251
+ "windowTitle",
252
+ "sourceUrl",
253
+ "range",
254
+ "anchors",
255
+ "connections",
256
+ "chapters",
257
+ "tours",
258
+ "coverage",
259
+ "review"
260
+ ], "source");
261
+ optionalString(value.title, "title");
262
+ optionalString(value.windowTitle, "windowTitle");
263
+ optionalString(value.sourceUrl, "sourceUrl");
264
+ if (value.range !== void 0) {
265
+ if (!isRecord(value.range)) throw new Error("range must be an object.");
266
+ requireOnlyKeys(value.range, ["base", "head"], "range");
267
+ requireString(value.range.base, "range.base");
268
+ requireString(value.range.head, "range.head");
269
+ }
270
+ if (value.coverage !== void 0) {
271
+ if (!isRecord(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
272
+ throw new Error("coverage must contain an exclusions array.");
273
+ }
274
+ requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
275
+ for (const [index, exclusion] of value.coverage.exclusions.entries()) {
276
+ const exclusionPath = `coverage.exclusions[${index}]`;
277
+ if (!isRecord(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
278
+ requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
279
+ requireString(exclusion.path, `${exclusionPath}.path`);
280
+ requireString(exclusion.reason, `${exclusionPath}.reason`);
281
+ if (exclusion.hunks !== void 0) requireStringArray(exclusion.hunks, `${exclusionPath}.hunks`);
282
+ }
283
+ }
284
+ if (!isRecord(value.anchors)) {
285
+ throw new Error("anchors must be an object.");
286
+ }
287
+ for (const [id, anchor] of Object.entries(value.anchors)) {
288
+ if (!isRecord(anchor)) {
289
+ throw new Error(`anchors.${id} must be an object.`);
290
+ }
291
+ requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
292
+ requireString(anchor.file, `anchors.${id}.file`);
293
+ if (anchor.revision !== "base" && anchor.revision !== "head") {
294
+ throw new Error(`anchors.${id}.revision must be base or head.`);
295
+ }
296
+ requireString(anchor.contains, `anchors.${id}.contains`);
297
+ if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
298
+ throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
299
+ }
300
+ }
301
+ const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord(connection) ? { id, ...connection } : connection) : null;
302
+ if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
303
+ throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
304
+ }
305
+ const connectionIds = /* @__PURE__ */ new Set();
306
+ for (const [index, connection] of rawConnections.entries()) {
307
+ if (!isRecord(connection)) {
308
+ throw new Error(`connections[${index}] must be an object.`);
309
+ }
310
+ requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
311
+ requireString(connection.id, `connections[${index}].id`);
312
+ requireString(connection.from, `connections[${index}].from`);
313
+ requireString(connection.to, `connections[${index}].to`);
314
+ requireString(connection.label, `connections[${index}].label`);
315
+ const { id, from, to } = connection;
316
+ if (!value.anchors[from] || !value.anchors[to]) {
317
+ throw new Error(`Connection ${id} references an unknown anchor.`);
318
+ }
319
+ if (connectionIds.has(id)) {
320
+ throw new Error(`Duplicate connection id: ${id}`);
321
+ }
322
+ connectionIds.add(id);
323
+ }
324
+ const authoredWalkthroughSteps = validateSourceChapters(
325
+ value.chapters,
326
+ "chapters",
327
+ value.version,
328
+ value.anchors,
329
+ connectionIds,
330
+ "root"
331
+ );
332
+ if (value.tours !== void 0) {
333
+ if (value.version !== 3) throw new Error("Independent tours require version 3.");
334
+ if (!isRecord(value.tours)) throw new Error("tours must be an object.");
335
+ requireOnlyKeys(value.tours, ["historical", "deconstructed"], "tours");
336
+ if (value.tours.historical === void 0 && value.tours.deconstructed === void 0) {
337
+ throw new Error("tours must contain a historical or deconstructed tour.");
338
+ }
339
+ for (const mode of ["historical", "deconstructed"]) {
340
+ const tour = value.tours[mode];
341
+ if (tour === void 0) continue;
342
+ const path = `tours.${mode}`;
343
+ if (!isRecord(tour)) throw new Error(`${path} must be an object.`);
344
+ requireOnlyKeys(tour, ["chapters"], path);
345
+ validateSourceChapters(tour.chapters, `${path}.chapters`, value.version, value.anchors, connectionIds, mode);
346
+ }
347
+ }
348
+ if (value.review !== void 0) {
349
+ if (value.version !== 3) throw new Error("Review notes require version 3.");
350
+ validateChangeTourReview(value.review, { authoredWalkthroughSteps });
351
+ }
352
+ return { ...value, connections: rawConnections };
353
+ }
354
+ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, connectionIds, mode) {
355
+ if (!Array.isArray(chaptersValue) || chaptersValue.length === 0) {
356
+ throw new Error(`${chaptersPath} must be a non-empty array.`);
357
+ }
358
+ const chapterIds = /* @__PURE__ */ new Set();
359
+ const sceneIds = /* @__PURE__ */ new Set();
360
+ const authoredWalkthroughSteps = /* @__PURE__ */ new Map();
361
+ for (const [chapterIndex, chapter] of chaptersValue.entries()) {
362
+ const chapterPath = `${chaptersPath}[${chapterIndex}]`;
363
+ if (!isRecord(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
364
+ throw new Error(`${chapterPath} must contain a non-empty scenes array.`);
365
+ }
366
+ requireString(chapter.id, `${chapterPath}.id`);
367
+ requireString(chapter.title, `${chapterPath}.title`);
368
+ requireOnlyKeys(chapter, ["id", "title", "scenes"], chapterPath);
369
+ if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
370
+ chapterIds.add(chapter.id);
371
+ for (const [sceneIndex, scene] of chapter.scenes.entries()) {
372
+ const path = `${chapterPath}.scenes[${sceneIndex}]`;
373
+ if (!isRecord(scene)) throw new Error(`${path} must be an object.`);
374
+ requireString(scene.id, `${path}.id`);
375
+ requireString(scene.title, `${path}.title`);
376
+ if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
377
+ sceneIds.add(scene.id);
378
+ if (scene.overview !== void 0 && version !== 3) {
379
+ throw new Error(`${path}.overview requires version 3.`);
380
+ }
381
+ validateNarrative(scene, path);
382
+ if (scene.kind === "deconstructed-diff") {
383
+ if (mode === "historical") {
384
+ throw new Error(`${path} cannot contain a deconstructed-diff scene in the historical tour.`);
385
+ }
386
+ validateDeconstructedScene(scene, path);
387
+ validateSceneOverview(scene.overview, `${path}.overview`, {
388
+ endpointIds: deconstructedOverviewEndpointIds(scene, path)
389
+ });
390
+ const requireRealEvidence = mode === "root" && version >= 2;
391
+ if (requireRealEvidence || scene.stack !== void 0) {
392
+ validateRealStack(scene.stack, `${path}.stack`, 2);
393
+ }
394
+ if (requireRealEvidence || scene.steps !== void 0) {
395
+ validateWalkthroughSteps(scene, path, anchors, connectionIds);
396
+ if (scene.steps !== void 0) {
397
+ authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
398
+ }
399
+ }
400
+ continue;
401
+ }
402
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
403
+ throw new Error(`${path} must contain a non-empty steps array.`);
404
+ }
405
+ if (scene.kind === "stacked-diff") {
406
+ validateStackedScene(scene, path, mode === "root" ? version >= 2 ? 2 : 3 : 2);
407
+ validateSceneOverview(scene.overview, `${path}.overview`, {
408
+ endpointIds: stackedOverviewEndpointIds(scene, path)
409
+ });
410
+ continue;
411
+ }
412
+ if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
413
+ throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
414
+ }
415
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "steps"], path);
416
+ validateSceneOverview(scene.overview, `${path}.overview`, { allowComparison: false });
417
+ validateWalkthroughSteps(scene, path, anchors, connectionIds);
418
+ authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
419
+ }
420
+ }
421
+ if (mode === "deconstructed" && !chaptersValue.some((chapter) => isRecord(chapter) && Array.isArray(chapter.scenes) && chapter.scenes.some((scene) => isRecord(scene) && scene.kind === "deconstructed-diff"))) {
422
+ throw new Error(`${chaptersPath} must contain at least one deconstructed-diff scene.`);
423
+ }
424
+ return authoredWalkthroughSteps;
425
+ }
426
+ function stackedOverviewEndpointIds(scene, path) {
427
+ if (!Array.isArray(scene.stack)) {
428
+ throw new Error(`${path}.stack must be an array.`);
429
+ }
430
+ return scene.stack.filter(isRecord).map((entry) => typeof entry.id === "string" ? entry.id : "").filter((id) => id.length > 0);
431
+ }
432
+ function deconstructedOverviewEndpointIds(scene, path) {
433
+ if (!Array.isArray(scene.stages)) {
434
+ throw new Error(`${path}.stages must be an array.`);
435
+ }
436
+ return [
437
+ "explanation-baseline",
438
+ ...scene.stages.filter(isRecord).map((stage) => typeof stage.id === "string" ? `explanation-stage-${stage.id}` : "").filter((id) => id.length > 0)
439
+ ];
440
+ }
441
+ function validateChangeTourReview(value, context = {}) {
442
+ if (!isRecord(value)) {
443
+ throw new Error("review must be an object.");
444
+ }
445
+ requireOnlyKeys(value, ["baseOid", "headOid", "items"], "review");
446
+ requireFullGitOid(value.baseOid, "review.baseOid");
447
+ requireFullGitOid(value.headOid, "review.headOid");
448
+ if (!Array.isArray(value.items) || value.items.length === 0) {
449
+ throw new Error("review.items must be a non-empty array.");
450
+ }
451
+ if (context.expectedRange) {
452
+ if (normalizeGitOid(value.baseOid) !== normalizeGitOid(context.expectedRange.baseOid)) {
453
+ throw new Error(
454
+ `Stale review: review.baseOid ${value.baseOid} does not match the resolved range.mergeBaseOid ${context.expectedRange.baseOid}.`
455
+ );
456
+ }
457
+ if (normalizeGitOid(value.headOid) !== normalizeGitOid(context.expectedRange.headOid)) {
458
+ throw new Error(
459
+ `Stale review: review.headOid ${value.headOid} does not match the resolved range.headOid ${context.expectedRange.headOid}.`
460
+ );
461
+ }
462
+ }
463
+ const itemIds = /* @__PURE__ */ new Set();
464
+ for (const [index, item] of value.items.entries()) {
465
+ const itemPath = `review.items[${index}]`;
466
+ if (!isRecord(item)) throw new Error(`${itemPath} must be an object.`);
467
+ requireOnlyKeys(item, ["id", "kind", "title", "body", "evidence", "nextCheck"], itemPath);
468
+ requireReviewString(item.id, `${itemPath}.id`);
469
+ if (itemIds.has(item.id)) throw new Error(`Duplicate review item id: ${item.id}`);
470
+ itemIds.add(item.id);
471
+ if (typeof item.kind !== "string" || !["concept", "boundary", "tradeoff", "question"].includes(item.kind)) {
472
+ throw new Error(`${itemPath}.kind must be concept, boundary, tradeoff, or question.`);
473
+ }
474
+ requireReviewString(item.title, `${itemPath}.title`);
475
+ requireReviewString(item.body, `${itemPath}.body`);
476
+ if (!Array.isArray(item.evidence)) {
477
+ throw new Error(`${itemPath}.evidence must be an array.`);
478
+ }
479
+ if (item.kind !== "question" && item.evidence.length === 0) {
480
+ throw new Error(`${itemPath}.evidence must contain at least one reference for claims.`);
481
+ }
482
+ if (item.kind === "question" && item.nextCheck === void 0) {
483
+ throw new Error(`${itemPath}.nextCheck must be a non-empty string for question items.`);
484
+ }
485
+ if (item.nextCheck !== void 0) requireReviewString(item.nextCheck, `${itemPath}.nextCheck`);
486
+ for (const [evidenceIndex, evidence] of item.evidence.entries()) {
487
+ const evidencePath = `${itemPath}.evidence[${evidenceIndex}]`;
488
+ if (!isRecord(evidence)) throw new Error(`${evidencePath} must be an object.`);
489
+ requireOnlyKeys(evidence, ["sceneId", "stepId"], evidencePath);
490
+ requireReviewString(evidence.sceneId, `${evidencePath}.sceneId`);
491
+ requireReviewString(evidence.stepId, `${evidencePath}.stepId`);
492
+ if (context.authoredWalkthroughSteps) {
493
+ const stepIds = context.authoredWalkthroughSteps.get(evidence.sceneId);
494
+ if (!stepIds) {
495
+ throw new Error(
496
+ `${evidencePath} references unknown authored walkthrough scene ${evidence.sceneId}.`
497
+ );
498
+ }
499
+ if (!stepIds.has(evidence.stepId)) {
500
+ throw new Error(
501
+ `${evidencePath} references unknown authored walkthrough step ${evidence.sceneId}/${evidence.stepId}.`
502
+ );
503
+ }
504
+ }
505
+ }
506
+ }
507
+ }
508
+ function validateDeconstructedScene(scene, path) {
509
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "base", "target", "stack", "steps", "stages", "exclusions"], path);
510
+ optionalString(scene.base, `${path}.base`);
511
+ optionalString(scene.target, `${path}.target`);
512
+ if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
513
+ throw new Error(`${path}.stages must contain between 1 and 12 stages.`);
514
+ }
515
+ const stageIds = /* @__PURE__ */ new Set();
516
+ for (const [stageIndex, stage] of scene.stages.entries()) {
517
+ const stagePath = `${path}.stages[${stageIndex}]`;
518
+ if (!isRecord(stage)) throw new Error(`${stagePath} must be an object.`);
519
+ requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
520
+ requireString(stage.id, `${stagePath}.id`);
521
+ requireString(stage.title, `${stagePath}.title`);
522
+ requireString(stage.narration, `${stagePath}.narration`);
523
+ if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
524
+ throw new Error(`${stagePath}.changes must be a non-empty array.`);
525
+ }
526
+ if (stageIds.has(stage.id)) throw new Error(`Duplicate deconstructed stage id: ${stage.id}`);
527
+ stageIds.add(stage.id);
528
+ stage.changes.forEach((change, changeIndex) => validateDeconstructedSelection(
529
+ change,
530
+ `${stagePath}.changes[${changeIndex}]`,
531
+ false
532
+ ));
533
+ }
534
+ if (scene.exclusions !== void 0) {
535
+ if (!Array.isArray(scene.exclusions)) throw new Error(`${path}.exclusions must be an array.`);
536
+ scene.exclusions.forEach((exclusion, exclusionIndex) => validateDeconstructedSelection(
537
+ exclusion,
538
+ `${path}.exclusions[${exclusionIndex}]`,
539
+ true
540
+ ));
541
+ }
542
+ }
543
+ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
544
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
545
+ throw new Error(`${path} must contain a non-empty steps array.`);
546
+ }
547
+ const stepIds = /* @__PURE__ */ new Set();
548
+ for (const [stepIndex, step] of scene.steps.entries()) {
549
+ const stepPath = `${path}.steps[${stepIndex}]`;
550
+ if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
551
+ requireString(step.id, `${stepPath}.id`);
552
+ requireString(step.title, `${stepPath}.title`);
553
+ requireString(step.body, `${stepPath}.body`);
554
+ requireString(step.focus, `${stepPath}.focus`);
555
+ requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth", "requirement"], stepPath);
556
+ if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
557
+ throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
558
+ }
559
+ validateStepRequirement(step.requirement, `${stepPath}.requirement`);
560
+ if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
561
+ stepIds.add(step.id);
562
+ if (!anchors[step.focus]) throw new Error(`${stepPath} references unknown anchor ${step.focus}.`);
563
+ optionalString(step.connection, `${stepPath}.connection`);
564
+ if (typeof step.connection === "string" && !connectionIds.has(step.connection)) {
565
+ throw new Error(`${stepPath} references unknown connection ${step.connection}.`);
566
+ }
567
+ }
568
+ }
569
+ function validateDeconstructedSelection(value, path, requiresReason) {
570
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
571
+ requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
572
+ requireString(value.file, `${path}.file`);
573
+ if (!requiresReason || value.hunks !== void 0) {
574
+ requireStringArray(value.hunks, `${path}.hunks`);
575
+ if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
576
+ }
577
+ if (requiresReason) requireString(value.reason, `${path}.reason`);
578
+ }
579
+ function validateRealStack(stack, path, minimum) {
580
+ if (!Array.isArray(stack) || stack.length < minimum || stack.length > 6) {
581
+ throw new Error(`${path} must contain between ${minimum} and 6 explicit real revisions.`);
582
+ }
583
+ const ids = /* @__PURE__ */ new Set();
584
+ for (const [index, entry] of stack.entries()) {
585
+ if (!isRecord(entry)) throw new Error(`${path}[${index}] must be an object.`);
586
+ requireOnlyKeys(entry, ["id", "ref", "label"], `${path}[${index}]`);
587
+ requireString(entry.id, `${path}[${index}].id`);
588
+ requireString(entry.ref, `${path}[${index}].ref`);
589
+ optionalString(entry.label, `${path}[${index}].label`);
590
+ if (ids.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
591
+ ids.add(entry.id);
592
+ }
593
+ }
594
+ function validateStackedScene(scene, path, minimum = 3) {
595
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "stack", "files", "steps"], path);
596
+ if (!Array.isArray(scene.stack) || scene.stack.length < minimum || scene.stack.length > 6) {
597
+ throw new Error(`${path}.stack must contain between ${minimum} and 6 revisions.`);
598
+ }
599
+ const stackIds = /* @__PURE__ */ new Set();
600
+ for (const [index, entry] of scene.stack.entries()) {
601
+ const entryPath = `${path}.stack[${index}]`;
602
+ if (!isRecord(entry)) throw new Error(`${entryPath} must be an object.`);
603
+ requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
604
+ requireString(entry.id, `${entryPath}.id`);
605
+ requireString(entry.ref, `${entryPath}.ref`);
606
+ optionalString(entry.label, `${entryPath}.label`);
607
+ if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
608
+ stackIds.add(entry.id);
609
+ }
610
+ if (scene.files !== void 0) requireStringArray(scene.files, `${path}.files`);
611
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
612
+ const stepIds = /* @__PURE__ */ new Set();
613
+ for (const [index, step] of scene.steps.entries()) {
614
+ const stepPath = `${path}.steps[${index}]`;
615
+ if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
616
+ requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
617
+ requireString(step.id, `${stepPath}.id`);
618
+ requireString(step.title, `${stepPath}.title`);
619
+ requireString(step.body, `${stepPath}.body`);
620
+ requireString(step.file, `${stepPath}.file`);
621
+ const stepId = step.id;
622
+ if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
623
+ stepIds.add(stepId);
624
+ if (!Array.isArray(step.pair) || step.pair.length !== 2 || step.pair.some((id) => typeof id !== "string")) {
625
+ throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
626
+ }
627
+ const pair = step.pair;
628
+ const leftIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[0]);
629
+ const rightIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[1]);
630
+ if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
631
+ if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
632
+ 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]))) {
633
+ throw new Error(`${stepPath}.lines must contain an ordered positive line range.`);
634
+ }
635
+ }
636
+ }
637
+ function validateStepRequirement(value, path) {
638
+ if (value === void 0) return;
639
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
640
+ requireOnlyKeys(value, ["id", "text", "status", "source", "confidence"], path);
641
+ requireString(value.id, `${path}.id`);
642
+ requireString(value.text, `${path}.text`);
643
+ if (value.status !== void 0 && !["fulfilled", "gap"].includes(String(value.status))) {
644
+ throw new Error(`${path}.status must be fulfilled or gap.`);
645
+ }
646
+ optionalString(value.source, `${path}.source`);
647
+ if (value.confidence !== void 0 && !["high", "medium", "low"].includes(String(value.confidence))) {
648
+ throw new Error(`${path}.confidence must be high, medium, or low.`);
649
+ }
650
+ }
651
+ function validateSceneOverview(value, path, options = {}) {
652
+ if (value === void 0) return;
653
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
654
+ requireOnlyKeys(value, ["kind", "path", "comparison"], path);
655
+ if (value.kind !== "directory-diff") {
656
+ throw new Error(`${path}.kind must be directory-diff.`);
657
+ }
658
+ if (value.path !== void 0) validateDirectoryOverviewPath(value.path, `${path}.path`);
659
+ if (value.comparison === void 0) return;
660
+ if (options.allowComparison === false) {
661
+ throw new Error(`${path}.comparison is not supported for walkthrough scenes; use the fixed base-to-head comparison.`);
662
+ }
663
+ if (!isRecord(value.comparison)) throw new Error(`${path}.comparison must be an object.`);
664
+ requireOnlyKeys(value.comparison, ["from", "to"], `${path}.comparison`);
665
+ requireString(value.comparison.from, `${path}.comparison.from`);
666
+ requireString(value.comparison.to, `${path}.comparison.to`);
667
+ if (value.comparison.from === value.comparison.to) {
668
+ throw new Error(`${path}.comparison.from and to must identify distinct endpoints.`);
669
+ }
670
+ if (options.endpointIds !== void 0) {
671
+ for (const endpoint of [value.comparison.from, value.comparison.to]) {
672
+ if (!options.endpointIds.includes(endpoint)) {
673
+ throw new Error(`${path}.comparison endpoint ${endpoint} is not defined by this scene.`);
674
+ }
675
+ }
676
+ }
677
+ }
678
+ function validateDirectoryOverviewPath(value, path) {
679
+ requireString(value, path);
680
+ if (value.includes("\\")) {
681
+ throw new Error(`${path} must use a relative POSIX directory path without backslashes.`);
682
+ }
683
+ if (value.startsWith("/") || /^[A-Za-z]:/.test(value)) {
684
+ throw new Error(`${path} must be relative to the repository root.`);
685
+ }
686
+ if (value.split("/").some((segment) => segment === "..")) {
687
+ throw new Error(`${path} must not contain parent-directory traversal.`);
688
+ }
689
+ }
690
+ function collectStepIds(value) {
691
+ const ids = /* @__PURE__ */ new Set();
692
+ if (!Array.isArray(value)) return ids;
693
+ for (const step of value) {
694
+ if (isRecord(step) && typeof step.id === "string") ids.add(step.id);
695
+ }
696
+ return ids;
697
+ }
698
+ function validateNarrative(value, path) {
699
+ requireString(value.summary, `${path}.summary`);
700
+ requireStringArray(value.bullets, `${path}.bullets`);
701
+ requireStringArray(value.tags, `${path}.tags`);
702
+ requireString(value.takeaway, `${path}.takeaway`);
703
+ }
704
+ function isRecord(value) {
705
+ return typeof value === "object" && value !== null && !Array.isArray(value);
706
+ }
707
+ function requireString(value, path) {
708
+ if (typeof value !== "string" || value.length === 0) {
709
+ throw new Error(`${path} must be a non-empty string.`);
710
+ }
711
+ }
712
+ function requireFullGitOid(value, path) {
713
+ requireString(value, path);
714
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)) {
715
+ throw new Error(`${path} must be a full 40- or 64-character hexadecimal Git OID.`);
716
+ }
717
+ }
718
+ function requireReviewString(value, path) {
719
+ if (typeof value !== "string" || value.trim().length === 0) {
720
+ throw new Error(`${path} must be a non-empty string.`);
721
+ }
722
+ }
723
+ function normalizeGitOid(value) {
724
+ return value.toLowerCase();
725
+ }
726
+ function optionalString(value, path) {
727
+ if (value !== void 0) {
728
+ requireString(value, path);
729
+ }
730
+ }
731
+ function requireStringArray(value, path) {
732
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
733
+ throw new Error(`${path} must be an array of strings.`);
734
+ }
735
+ }
736
+ function requireOnlyKeys(value, allowed, path) {
737
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
738
+ if (unexpected.length > 0) {
739
+ throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
740
+ }
741
+ }
742
+
234
743
  // src/changeTourManifest.ts
235
- var CHANGE_TOUR_MANIFEST_VERSION = 1;
744
+ var CHANGE_TOUR_MANIFEST_VERSION = 3;
236
745
  function parseChangeTourManifest(value) {
237
- if (!isRecord(value) || value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
746
+ if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
747
+ throw new Error(
748
+ `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.`
749
+ );
750
+ }
751
+ if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
238
752
  throw new Error(`Unsupported or missing change-tour manifest version.`);
239
753
  }
240
- requireString(value.title, "title");
241
- requireString(value.generatedAt, "generatedAt");
754
+ requireString2(value.title, "title");
755
+ requireString2(value.generatedAt, "generatedAt");
242
756
  if (value.windowTitle !== void 0) {
243
- requireString(value.windowTitle, "windowTitle");
757
+ requireString2(value.windowTitle, "windowTitle");
244
758
  }
245
759
  if (value.sourceUrl !== void 0) {
246
- requireString(value.sourceUrl, "sourceUrl");
760
+ requireString2(value.sourceUrl, "sourceUrl");
247
761
  }
248
- if (!isRecord(value.range)) {
762
+ if (!isRecord2(value.range)) {
249
763
  throw new Error("Change-tour manifest range must be an object.");
250
764
  }
251
765
  for (const key of ["baseRef", "headRef", "mergeBaseOid", "headOid"]) {
252
- requireString(value.range[key], `range.${key}`);
766
+ requireString2(value.range[key], `range.${key}`);
253
767
  }
254
- if (!isRecord(value.summary)) {
768
+ if (!isRecord2(value.summary)) {
255
769
  throw new Error("Change-tour manifest summary must be an object.");
256
770
  }
257
771
  for (const key of ["changedFiles", "includedScenes", "additions", "deletions", "commitCount"]) {
258
772
  requireNonNegativeInteger(value.summary[key], `summary.${key}`);
259
773
  }
260
- requireStringArray(value.summary.omittedFiles, "summary.omittedFiles");
774
+ requireStringArray2(value.summary.omittedFiles, "summary.omittedFiles");
261
775
  if (!Array.isArray(value.commits) || !Array.isArray(value.chapters) || !Array.isArray(value.scenes)) {
262
776
  throw new Error("Change-tour manifest commits, chapters, and scenes must be arrays.");
263
777
  }
@@ -266,19 +780,19 @@ function parseChangeTourManifest(value) {
266
780
  }
267
781
  const sceneIds = /* @__PURE__ */ new Set();
268
782
  for (const [index, candidate] of value.scenes.entries()) {
269
- validateScene(candidate, index);
783
+ validateScene(candidate, index, value.version);
270
784
  if (sceneIds.has(candidate.id)) {
271
785
  throw new Error(`Duplicate change-tour scene id: ${candidate.id}`);
272
786
  }
273
787
  sceneIds.add(candidate.id);
274
788
  }
275
789
  for (const [index, candidate] of value.chapters.entries()) {
276
- if (!isRecord(candidate)) {
790
+ if (!isRecord2(candidate)) {
277
791
  throw new Error(`chapters[${index}] must be an object.`);
278
792
  }
279
- requireString(candidate.id, `chapters[${index}].id`);
280
- requireString(candidate.title, `chapters[${index}].title`);
281
- requireStringArray(candidate.sceneIds, `chapters[${index}].sceneIds`);
793
+ requireString2(candidate.id, `chapters[${index}].id`);
794
+ requireString2(candidate.title, `chapters[${index}].title`);
795
+ requireStringArray2(candidate.sceneIds, `chapters[${index}].sceneIds`);
282
796
  for (const sceneId of candidate.sceneIds) {
283
797
  if (!sceneIds.has(sceneId)) {
284
798
  throw new Error(`Chapter references unknown scene id: ${sceneId}`);
@@ -295,31 +809,135 @@ function parseChangeTourManifest(value) {
295
809
  if (value.files !== void 0 && value.summary.changedFiles !== files.length) {
296
810
  throw new Error("summary.changedFiles must match the number of files.");
297
811
  }
812
+ if (value.authoringCoverage !== void 0) {
813
+ validateAuthoringCoverage(value.authoringCoverage, value.version);
814
+ }
815
+ if (value.tours !== void 0) {
816
+ if (value.version !== 3) throw new Error("Independent tours require manifest version 3.");
817
+ validateModeTours(value.tours, value.version);
818
+ }
819
+ if (value.version !== 1) validateZoom(value, value.version);
820
+ if (value.review !== void 0) {
821
+ if (value.version !== 3) throw new Error("Review notes require manifest version 3.");
822
+ const evidenceScenes = value.version >= 2 && isRecord2(value.zoom) && isRecord2(value.zoom.final) ? value.zoom.final.scenes : value.scenes;
823
+ validateChangeTourReview(value.review, {
824
+ expectedRange: {
825
+ baseOid: String(value.range.mergeBaseOid),
826
+ headOid: String(value.range.headOid)
827
+ },
828
+ authoredWalkthroughSteps: collectAuthoredWalkthroughStepIds(evidenceScenes)
829
+ });
830
+ }
298
831
  return { ...value, files };
299
832
  }
833
+ function collectAuthoredWalkthroughStepIds(value) {
834
+ const scenes = /* @__PURE__ */ new Map();
835
+ if (!Array.isArray(value)) return scenes;
836
+ for (const scene of value) {
837
+ if (!isRecord2(scene) || scene.kind !== "walkthrough" || !Array.isArray(scene.steps) || typeof scene.id !== "string") {
838
+ continue;
839
+ }
840
+ const stepIds = /* @__PURE__ */ new Set();
841
+ for (const step of scene.steps) {
842
+ if (isRecord2(step) && typeof step.id === "string") stepIds.add(step.id);
843
+ }
844
+ scenes.set(scene.id, stepIds);
845
+ }
846
+ return scenes;
847
+ }
848
+ function validateAuthoringCoverage(value, version) {
849
+ if (!isRecord2(value) || !isRecord2(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
850
+ throw new Error("authoringCoverage must contain walkthrough and explanationAssignments.");
851
+ }
852
+ const expectedScope = version >= 2 ? "final" : "tour";
853
+ if (value.walkthrough.scope !== expectedScope) {
854
+ throw new Error(`authoringCoverage.walkthrough.scope must be ${expectedScope}.`);
855
+ }
856
+ requireNonNegativeInteger(value.walkthrough.coveredUnits, "authoringCoverage.walkthrough.coveredUnits");
857
+ requireNonNegativeInteger(value.walkthrough.includedUnits, "authoringCoverage.walkthrough.includedUnits");
858
+ requirePercentage(value.walkthrough.coveragePercent, "authoringCoverage.walkthrough.coveragePercent");
859
+ if (value.walkthrough.coveredUnits > value.walkthrough.includedUnits) {
860
+ throw new Error("authoringCoverage.walkthrough.coveredUnits must not exceed includedUnits.");
861
+ }
862
+ for (const [index, assignment] of value.explanationAssignments.entries()) {
863
+ const path = `authoringCoverage.explanationAssignments[${index}]`;
864
+ if (!isRecord2(assignment)) throw new Error(`${path} must be an object.`);
865
+ requireString2(assignment.sceneId, `${path}.sceneId`);
866
+ requireNonNegativeInteger(assignment.assignedUnits, `${path}.assignedUnits`);
867
+ requireNonNegativeInteger(assignment.excludedUnits, `${path}.excludedUnits`);
868
+ requireNonNegativeInteger(assignment.completeUnits, `${path}.completeUnits`);
869
+ requireNonNegativeInteger(assignment.totalUnits, `${path}.totalUnits`);
870
+ requirePercentage(assignment.assignmentPercent, `${path}.assignmentPercent`);
871
+ if (assignment.assignedUnits + assignment.excludedUnits !== assignment.completeUnits || assignment.completeUnits > assignment.totalUnits) {
872
+ throw new Error(`${path} contains inconsistent unit counts.`);
873
+ }
874
+ }
875
+ }
876
+ function validateModeTours(value, version) {
877
+ if (!isRecord2(value)) throw new Error("Change-tour manifest tours must be an object.");
878
+ if (value.historical === void 0 && value.deconstructed === void 0) {
879
+ throw new Error("Change-tour manifest tours must contain a historical or deconstructed tour.");
880
+ }
881
+ for (const mode of ["historical", "deconstructed"]) {
882
+ const tour = value[mode];
883
+ if (tour === void 0) continue;
884
+ if (!isRecord2(tour) || !Array.isArray(tour.chapters) || !Array.isArray(tour.scenes) || tour.chapters.length === 0 || tour.scenes.length === 0) {
885
+ throw new Error(`Change-tour manifest tours.${mode} must contain non-empty chapters and scenes arrays.`);
886
+ }
887
+ const sceneIds = /* @__PURE__ */ new Set();
888
+ let hasDeconstructed = false;
889
+ for (const [index, scene] of tour.scenes.entries()) {
890
+ validateScene(scene, index, version);
891
+ if (sceneIds.has(scene.id)) {
892
+ throw new Error(`Duplicate ${mode} tour scene id: ${scene.id}`);
893
+ }
894
+ sceneIds.add(scene.id);
895
+ if (scene.kind === "deconstructed-diff") hasDeconstructed = true;
896
+ if (mode === "historical" && scene.kind === "deconstructed-diff") {
897
+ throw new Error("Historical tours cannot contain synthetic deconstructed scenes.");
898
+ }
899
+ }
900
+ if (mode === "deconstructed" && !hasDeconstructed) {
901
+ throw new Error("Deconstructed tours must contain a constructed deconstructed-diff scene.");
902
+ }
903
+ for (const [index, chapter] of tour.chapters.entries()) {
904
+ if (!isRecord2(chapter)) {
905
+ throw new Error(`tours.${mode}.chapters[${index}] must be an object.`);
906
+ }
907
+ requireString2(chapter.id, `tours.${mode}.chapters[${index}].id`);
908
+ requireString2(chapter.title, `tours.${mode}.chapters[${index}].title`);
909
+ requireStringArray2(chapter.sceneIds, `tours.${mode}.chapters[${index}].sceneIds`);
910
+ for (const sceneId of chapter.sceneIds) {
911
+ if (!sceneIds.has(sceneId)) {
912
+ throw new Error(`tours.${mode} chapter references unknown scene id: ${sceneId}`);
913
+ }
914
+ }
915
+ }
916
+ }
917
+ }
300
918
  function parseChangeTourStory(value) {
301
- if (!isRecord(value) || !Array.isArray(value.scenes)) {
919
+ if (!isRecord2(value) || !Array.isArray(value.scenes)) {
302
920
  throw new Error("Change-tour story must contain a scenes array.");
303
921
  }
304
922
  if (value.title !== void 0) {
305
- requireString(value.title, "title");
923
+ requireString2(value.title, "title");
306
924
  }
307
925
  if (value.sourceUrl !== void 0) {
308
- requireString(value.sourceUrl, "sourceUrl");
926
+ requireString2(value.sourceUrl, "sourceUrl");
309
927
  }
310
928
  for (const [index, scene] of value.scenes.entries()) {
311
- if (!isRecord(scene) || scene.kind !== "discussion" && scene.kind !== "file") {
929
+ if (!isRecord2(scene) || scene.kind !== "discussion" && scene.kind !== "file") {
312
930
  throw new Error(`story.scenes[${index}] must be a discussion or file scene.`);
313
931
  }
314
- requireString(scene.chapterId, `story.scenes[${index}].chapterId`);
315
- requireString(scene.chapterTitle, `story.scenes[${index}].chapterTitle`);
316
- validateNarrative(scene, `story.scenes[${index}]`);
932
+ requireString2(scene.chapterId, `story.scenes[${index}].chapterId`);
933
+ requireString2(scene.chapterTitle, `story.scenes[${index}].chapterTitle`);
934
+ validateNarrative2(scene, `story.scenes[${index}]`);
317
935
  if (scene.kind === "discussion") {
318
- requireString(scene.title, `story.scenes[${index}].title`);
936
+ requireString2(scene.title, `story.scenes[${index}].title`);
319
937
  } else {
320
- requireString(scene.path, `story.scenes[${index}].path`);
938
+ requireString2(scene.path, `story.scenes[${index}].path`);
321
939
  if (scene.title !== void 0) {
322
- requireString(scene.title, `story.scenes[${index}].title`);
940
+ requireString2(scene.title, `story.scenes[${index}].title`);
323
941
  }
324
942
  if (scene.focusChangeIndex !== void 0) {
325
943
  requireNonNegativeInteger(scene.focusChangeIndex, `story.scenes[${index}].focusChangeIndex`);
@@ -328,37 +946,103 @@ function parseChangeTourStory(value) {
328
946
  }
329
947
  return value;
330
948
  }
331
- function validateScene(value, index) {
332
- if (!isRecord(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
949
+ function validateZoom(value, version) {
950
+ if (!isRecord2(value.repository) || typeof value.repository.root !== "string" || !value.repository.root) {
951
+ throw new Error("A v2 tour requires its originating repository.root.");
952
+ }
953
+ if (!isRecord2(value.zoom) || !Array.isArray(value.zoom.revisions) || !isRecord2(value.zoom.final)) {
954
+ throw new Error("A v2 tour requires zoom metadata and real revision evidence.");
955
+ }
956
+ const scenes = value.scenes;
957
+ const revisions = value.zoom.revisions;
958
+ const ids = /* @__PURE__ */ new Set();
959
+ for (const [index, scene] of revisions.entries()) {
960
+ validateScene(scene, index, version);
961
+ if (scene.kind !== "stacked-diff") throw new Error("zoom.revisions must contain real stacked-diff scenes.");
962
+ if (ids.has(scene.id)) throw new Error(`Duplicate zoom revision scene: ${scene.id}`);
963
+ ids.add(scene.id);
964
+ const range = value.range;
965
+ if (scene.stack[0].oid !== range.mergeBaseOid || scene.stack[scene.stack.length - 1].oid !== range.headOid) {
966
+ throw new Error(`Revision scene ${scene.id} endpoints must match the final diff range.`);
967
+ }
968
+ }
969
+ for (const scene of scenes) {
970
+ if (scene.kind !== "deconstructed-diff" && scene.kind !== "stacked-diff") continue;
971
+ const real = revisions.find((candidate) => candidate.id === scene.id);
972
+ if (!real) throw new Error(`Scene ${scene.id} is missing its real revision evidence.`);
973
+ if (scene.kind === "deconstructed-diff" && (real.stack[0].oid !== scene.realRange.baseOid || real.stack[real.stack.length - 1].oid !== scene.realRange.targetOid)) {
974
+ throw new Error(`Deconstructed scene ${scene.id} real stack endpoints must match its explanation range.`);
975
+ }
976
+ }
977
+ const explanation = scenes.some((scene) => scene.kind === "deconstructed-diff");
978
+ const distinct = revisions.some((scene) => scene.stack.length > 2);
979
+ const depth = explanation ? "explanation" : distinct ? "revisions" : "final";
980
+ const modes = [...explanation ? ["explanation"] : [], ...distinct ? ["revisions"] : [], "final", "history"];
981
+ if (value.zoom.authoredDepth !== depth || JSON.stringify(value.zoom.modes) !== JSON.stringify(modes)) {
982
+ throw new Error("zoom depth and modes must match the maximum authored depth.");
983
+ }
984
+ if (!Array.isArray(value.files)) throw new Error("A v2 tour requires final endpoint file evidence.");
985
+ if (!Array.isArray(value.zoom.final.scenes) || !Array.isArray(value.zoom.final.chapters)) {
986
+ throw new Error("A v2 tour requires a non-stacked final tour.");
987
+ }
988
+ const finalSceneIds = /* @__PURE__ */ new Set();
989
+ for (const [index, scene] of value.zoom.final.scenes.entries()) {
990
+ validateScene(scene, index, version);
991
+ if (scene.kind === "stacked-diff" || scene.kind === "deconstructed-diff") {
992
+ throw new Error("zoom.final must contain only non-stacked scenes.");
993
+ }
994
+ if (finalSceneIds.has(scene.id)) throw new Error(`Duplicate final tour scene: ${scene.id}`);
995
+ finalSceneIds.add(scene.id);
996
+ }
997
+ for (const [index, chapter] of value.zoom.final.chapters.entries()) {
998
+ if (!isRecord2(chapter) || !Array.isArray(chapter.sceneIds)) {
999
+ throw new Error(`zoom.final.chapters[${index}] must contain sceneIds.`);
1000
+ }
1001
+ requireString2(chapter.id, `zoom.final.chapters[${index}].id`);
1002
+ requireString2(chapter.title, `zoom.final.chapters[${index}].title`);
1003
+ for (const sceneId of chapter.sceneIds) {
1004
+ requireString2(sceneId, `zoom.final.chapters[${index}].sceneIds`);
1005
+ if (!finalSceneIds.has(sceneId)) throw new Error(`Final tour chapter references unknown scene: ${sceneId}`);
1006
+ }
1007
+ }
1008
+ }
1009
+ function validateScene(value, index, version) {
1010
+ if (!isRecord2(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
333
1011
  throw new Error(`scenes[${index}] must be a text-diff, discussion, walkthrough, stacked-diff, or deconstructed-diff scene.`);
334
1012
  }
335
1013
  for (const key of ["id", "title"]) {
336
- requireString(value[key], `scenes[${index}].${key}`);
1014
+ requireString2(value[key], `scenes[${index}].${key}`);
337
1015
  }
338
- validateNarrative(value, `scenes[${index}]`);
1016
+ if (value.overview !== void 0 && version !== 3) {
1017
+ throw new Error(`scenes[${index}].overview requires manifest version 3.`);
1018
+ }
1019
+ validateNarrative2(value, `scenes[${index}]`);
339
1020
  if (value.kind === "discussion") {
1021
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
340
1022
  return;
341
1023
  }
342
1024
  if (value.kind === "walkthrough") {
1025
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
343
1026
  if (!Array.isArray(value.steps) || value.steps.length === 0) {
344
1027
  throw new Error(`scenes[${index}].steps must be a non-empty array.`);
345
1028
  }
346
1029
  for (const [stepIndex, step] of value.steps.entries()) {
347
1030
  const path = `scenes[${index}].steps[${stepIndex}]`;
348
- if (!isRecord(step) || !isRecord(step.focus) || !isRecord(step.diff)) {
1031
+ if (!isRecord2(step) || !isRecord2(step.focus) || !isRecord2(step.diff)) {
349
1032
  throw new Error(`${path} must contain focus and diff objects.`);
350
1033
  }
351
- for (const key of ["id", "title", "body"]) requireString(step[key], `${path}.${key}`);
1034
+ for (const key of ["id", "title", "body"]) requireString2(step[key], `${path}.${key}`);
352
1035
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
353
1036
  throw new Error(`${path}.depth must be mentioned, explained, or contextualized.`);
354
1037
  }
1038
+ validateStepRequirement(step.requirement, `${path}.requirement`);
355
1039
  validateResolvedAnchor(step.focus, `${path}.focus`);
356
- validateScene(step.diff, index);
1040
+ validateScene(step.diff, index, version);
357
1041
  if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
358
1042
  if (step.connection !== void 0) {
359
- if (!isRecord(step.connection)) throw new Error(`${path}.connection must be an object.`);
360
- requireString(step.connection.id, `${path}.connection.id`);
361
- requireString(step.connection.label, `${path}.connection.label`);
1043
+ if (!isRecord2(step.connection)) throw new Error(`${path}.connection must be an object.`);
1044
+ requireString2(step.connection.id, `${path}.connection.id`);
1045
+ requireString2(step.connection.label, `${path}.connection.label`);
362
1046
  validateResolvedAnchor(step.connection.from, `${path}.connection.from`);
363
1047
  validateResolvedAnchor(step.connection.to, `${path}.connection.to`);
364
1048
  }
@@ -366,68 +1050,75 @@ function validateScene(value, index) {
366
1050
  return;
367
1051
  }
368
1052
  if (value.kind === "stacked-diff") {
369
- validateStackedScene(value, index);
1053
+ validateStackedScene2(value, index);
1054
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, {
1055
+ endpointIds: value.stack.map((panel) => panel.id)
1056
+ });
370
1057
  return;
371
1058
  }
372
1059
  if (value.kind === "deconstructed-diff") {
373
- validateDeconstructedScene(value, index);
1060
+ validateDeconstructedScene2(value, index);
1061
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, {
1062
+ endpointIds: value.panels.map((panel) => panel.id)
1063
+ });
374
1064
  return;
375
1065
  }
1066
+ validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
376
1067
  for (const key of ["path", "leftLabel", "rightLabel", "leftContent", "rightContent"]) {
377
- requireString(value[key], `scenes[${index}].${key}`);
1068
+ requireString2(value[key], `scenes[${index}].${key}`);
378
1069
  }
379
1070
  if (value.previousPath !== void 0) {
380
- requireString(value.previousPath, `scenes[${index}].previousPath`);
1071
+ requireString2(value.previousPath, `scenes[${index}].previousPath`);
381
1072
  }
382
- requireString(value.changeKind, `scenes[${index}].changeKind`);
1073
+ requireString2(value.changeKind, `scenes[${index}].changeKind`);
383
1074
  requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
384
1075
  requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
385
1076
  if (value.focusChangeIndex !== void 0) {
386
1077
  requireNonNegativeInteger(value.focusChangeIndex, `scenes[${index}].focusChangeIndex`);
387
1078
  }
388
1079
  }
389
- function validateStackedScene(value, index) {
390
- if (!Array.isArray(value.stack) || value.stack.length < 3 || value.stack.length > 6) {
391
- throw new Error(`scenes[${index}].stack must contain between 3 and 6 revisions.`);
1080
+ function validateStackedScene2(value, index) {
1081
+ if (!Array.isArray(value.stack) || value.stack.length < 2 || value.stack.length > 6) {
1082
+ throw new Error(`scenes[${index}].stack must contain between 2 and 6 revisions.`);
392
1083
  }
393
1084
  for (const [panelIndex, panel] of value.stack.entries()) {
394
- if (!isRecord(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
395
- for (const key of ["id", "ref", "oid", "label"]) requireString(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
1085
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
1086
+ for (const key of ["id", "ref", "oid", "label"]) requireString2(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
396
1087
  }
397
1088
  validateMultiPanelFiles(value.files, value.stack.length, index);
398
1089
  validateMultiPanelSteps(value.steps, value.stack.length, index, false);
399
1090
  }
400
- function validateDeconstructedScene(value, index) {
1091
+ function validateDeconstructedScene2(value, index) {
401
1092
  if (value.stageLabel !== "Explanation stages") {
402
1093
  throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
403
1094
  }
404
- if (!isRecord(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1095
+ if (!isRecord2(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
405
1096
  for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
406
- requireString(value.realRange[key], `scenes[${index}].realRange.${key}`);
1097
+ requireString2(value.realRange[key], `scenes[${index}].realRange.${key}`);
407
1098
  }
408
1099
  if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
409
1100
  throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
410
1101
  }
411
1102
  const panels = value.panels;
412
1103
  for (const [panelIndex, panel] of panels.entries()) {
413
- if (!isRecord(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
414
- for (const key of ["id", "label", "role"]) requireString(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
1104
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
1105
+ for (const key of ["id", "label", "role"]) requireString2(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
415
1106
  const expectedRole = panelIndex === 0 ? "baseline" : "stage";
416
1107
  if (panel.role !== expectedRole) throw new Error(`scenes[${index}].panels[${panelIndex}].role must be ${expectedRole}.`);
417
- if (panelIndex > 0) requireString(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
1108
+ if (panelIndex > 0) requireString2(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
418
1109
  }
419
1110
  validateMultiPanelFiles(value.files, panels.length, index);
420
1111
  validateMultiPanelSteps(value.steps, panels.length, index, true);
421
1112
  if (Array.isArray(value.steps)) {
422
1113
  let previousPairIndex = -1;
423
1114
  value.steps.forEach((step, stepIndex) => {
424
- if (!isRecord(step)) return;
1115
+ if (!isRecord2(step)) return;
425
1116
  const pairIndex = Number(step.pairIndex);
426
1117
  if (pairIndex < previousPairIndex) {
427
1118
  throw new Error(`scenes[${index}].steps must remain grouped in explanation stage order.`);
428
1119
  }
429
1120
  previousPairIndex = pairIndex;
430
- if (step.stageId !== void 0) requireString(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1121
+ if (step.stageId !== void 0) requireString2(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
431
1122
  if (step.stageIndex !== void 0) requireNonNegativeInteger(step.stageIndex, `scenes[${index}].steps[${stepIndex}].stageIndex`);
432
1123
  if (step.focusIndex !== void 0) requireNonNegativeInteger(step.focusIndex, `scenes[${index}].steps[${stepIndex}].focusIndex`);
433
1124
  if (step.focusCount !== void 0) requirePositiveInteger(step.focusCount, `scenes[${index}].steps[${stepIndex}].focusCount`);
@@ -440,341 +1131,91 @@ function validateDeconstructedScene(value, index) {
440
1131
  });
441
1132
  for (let stageIndex = 0; stageIndex < panels.length - 1; stageIndex += 1) {
442
1133
  const panel = panels[stageIndex + 1];
443
- const stageSteps = value.steps.filter((step) => isRecord(step) && Number(step.pairIndex) === stageIndex);
444
- if (!isRecord(panel) || stageSteps.length === 0) {
445
- throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
446
- }
447
- const firstStep = stageSteps[0];
448
- const fallbackStageId = isRecord(firstStep) ? firstStep.id : void 0;
449
- if (stageSteps.some((step) => {
450
- if (!isRecord(step)) return true;
451
- return (step.stageId ?? fallbackStageId) !== panel.stageId;
452
- })) {
453
- throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
454
- }
455
- }
456
- }
457
- }
458
- function validateMultiPanelFiles(value, panelCount, index) {
459
- if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
460
- for (const [fileIndex, file] of value.entries()) {
461
- if (!isRecord(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
462
- requireString(file.path, `scenes[${index}].files[${fileIndex}].path`);
463
- if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
464
- throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
465
- }
466
- for (const [panelIndex, panel] of file.panels.entries()) {
467
- if (!isRecord(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
468
- for (const key of ["id", "label", "content"]) requireString(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
469
- if (panel.path !== void 0) requireString(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
470
- if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
471
- }
472
- }
473
- }
474
- function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
475
- if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
476
- for (const [stepIndex, step] of value.entries()) {
477
- if (!isRecord(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
478
- for (const key of ["id", "title", "body", "file", "side"]) requireString(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
479
- requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
480
- if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
481
- if (deconstructed) {
482
- requireStringArray(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
483
- if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
484
- }
485
- if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
486
- if (step.startLine !== void 0) requirePositiveInteger(step.startLine, `scenes[${index}].steps[${stepIndex}].startLine`);
487
- if (step.endLine !== void 0) requirePositiveInteger(step.endLine, `scenes[${index}].steps[${stepIndex}].endLine`);
488
- }
489
- }
490
- function validateTourFile(value, index) {
491
- if (!isRecord(value)) throw new Error(`files[${index}] must be an object.`);
492
- if (value.kind === "text-diff") {
493
- validateScene(value, index);
494
- return;
495
- }
496
- if (value.kind !== "omitted") {
497
- throw new Error(`files[${index}].kind must be text-diff or omitted.`);
498
- }
499
- for (const key of ["id", "title", "path", "changeKind", "reason"]) {
500
- requireString(value[key], `files[${index}].${key}`);
501
- }
502
- if (value.previousPath !== void 0) requireString(value.previousPath, `files[${index}].previousPath`);
503
- requireNonNegativeInteger(value.additions, `files[${index}].additions`);
504
- requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
505
- }
506
- function collectLegacyTourFiles(scenes) {
507
- const files = /* @__PURE__ */ new Map();
508
- for (const scene of scenes) {
509
- if (scene.kind === "text-diff") {
510
- if (!files.has(scene.path)) files.set(scene.path, scene);
511
- continue;
512
- }
513
- if (scene.kind === "walkthrough") {
514
- for (const step of scene.steps) {
515
- if (!files.has(step.diff.path)) files.set(step.diff.path, step.diff);
516
- }
517
- }
518
- }
519
- return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
520
- }
521
- function validateResolvedAnchor(value, path) {
522
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
523
- for (const key of ["id", "path", "revision", "excerpt"]) requireString(value[key], `${path}.${key}`);
524
- if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
525
- requirePositiveInteger(value.startLine, `${path}.startLine`);
526
- requirePositiveInteger(value.endLine, `${path}.endLine`);
527
- if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
528
- }
529
- function validateNarrative(value, path) {
530
- requireString(value.summary, `${path}.summary`);
531
- requireStringArray(value.bullets, `${path}.bullets`);
532
- requireStringArray(value.tags, `${path}.tags`);
533
- requireString(value.takeaway, `${path}.takeaway`);
534
- }
535
- function isRecord(value) {
536
- return typeof value === "object" && value !== null && !Array.isArray(value);
537
- }
538
- function requireString(value, path) {
539
- if (typeof value !== "string") {
540
- throw new Error(`${path} must be a string.`);
541
- }
542
- }
543
- function requireStringArray(value, path) {
544
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
545
- throw new Error(`${path} must be an array of strings.`);
546
- }
547
- }
548
- function requireNonNegativeInteger(value, path) {
549
- if (!Number.isInteger(value) || Number(value) < 0) {
550
- throw new Error(`${path} must be a non-negative integer.`);
551
- }
552
- }
553
- function requirePositiveInteger(value, path) {
554
- if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
555
- }
556
-
557
- // src/changeTourSource.ts
558
- var CHANGE_TOUR_SOURCE_VERSION = 1;
559
- function parseChangeTourSource(value) {
560
- if (!isRecord2(value) || value.version !== CHANGE_TOUR_SOURCE_VERSION) {
561
- throw new Error("Unsupported or missing change-tour source version.");
562
- }
563
- requireOnlyKeys(value, ["version", "title", "windowTitle", "sourceUrl", "range", "anchors", "connections", "chapters", "coverage"], "source");
564
- optionalString(value.title, "title");
565
- optionalString(value.windowTitle, "windowTitle");
566
- optionalString(value.sourceUrl, "sourceUrl");
567
- if (value.range !== void 0) {
568
- if (!isRecord2(value.range)) throw new Error("range must be an object.");
569
- requireOnlyKeys(value.range, ["base", "head"], "range");
570
- requireString2(value.range.base, "range.base");
571
- requireString2(value.range.head, "range.head");
572
- }
573
- if (value.coverage !== void 0) {
574
- if (!isRecord2(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
575
- throw new Error("coverage must contain an exclusions array.");
576
- }
577
- requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
578
- for (const [index, exclusion] of value.coverage.exclusions.entries()) {
579
- const exclusionPath = `coverage.exclusions[${index}]`;
580
- if (!isRecord2(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
581
- requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
582
- requireString2(exclusion.path, `${exclusionPath}.path`);
583
- requireString2(exclusion.reason, `${exclusionPath}.reason`);
584
- if (exclusion.hunks !== void 0) requireStringArray2(exclusion.hunks, `${exclusionPath}.hunks`);
585
- }
586
- }
587
- if (!isRecord2(value.anchors)) {
588
- throw new Error("anchors must be an object.");
589
- }
590
- for (const [id, anchor] of Object.entries(value.anchors)) {
591
- if (!isRecord2(anchor)) {
592
- throw new Error(`anchors.${id} must be an object.`);
593
- }
594
- requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
595
- requireString2(anchor.file, `anchors.${id}.file`);
596
- if (anchor.revision !== "base" && anchor.revision !== "head") {
597
- throw new Error(`anchors.${id}.revision must be base or head.`);
598
- }
599
- requireString2(anchor.contains, `anchors.${id}.contains`);
600
- if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
601
- throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
602
- }
603
- }
604
- const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord2(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord2(connection) ? { id, ...connection } : connection) : null;
605
- if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
606
- throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
607
- }
608
- const connectionIds = /* @__PURE__ */ new Set();
609
- for (const [index, connection] of rawConnections.entries()) {
610
- if (!isRecord2(connection)) {
611
- throw new Error(`connections[${index}] must be an object.`);
612
- }
613
- requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
614
- requireString2(connection.id, `connections[${index}].id`);
615
- requireString2(connection.from, `connections[${index}].from`);
616
- requireString2(connection.to, `connections[${index}].to`);
617
- requireString2(connection.label, `connections[${index}].label`);
618
- const { id, from, to } = connection;
619
- if (!value.anchors[from] || !value.anchors[to]) {
620
- throw new Error(`Connection ${id} references an unknown anchor.`);
621
- }
622
- if (connectionIds.has(id)) {
623
- throw new Error(`Duplicate connection id: ${id}`);
624
- }
625
- connectionIds.add(id);
626
- }
627
- const chapterIds = /* @__PURE__ */ new Set();
628
- const sceneIds = /* @__PURE__ */ new Set();
629
- for (const [chapterIndex, chapter] of value.chapters.entries()) {
630
- if (!isRecord2(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
631
- throw new Error(`chapters[${chapterIndex}] must contain a non-empty scenes array.`);
632
- }
633
- requireString2(chapter.id, `chapters[${chapterIndex}].id`);
634
- requireString2(chapter.title, `chapters[${chapterIndex}].title`);
635
- requireOnlyKeys(chapter, ["id", "title", "scenes"], `chapters[${chapterIndex}]`);
636
- if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
637
- chapterIds.add(chapter.id);
638
- for (const [sceneIndex, scene] of chapter.scenes.entries()) {
639
- const path = `chapters[${chapterIndex}].scenes[${sceneIndex}]`;
640
- if (!isRecord2(scene)) throw new Error(`${path} must be an object.`);
641
- requireString2(scene.id, `${path}.id`);
642
- requireString2(scene.title, `${path}.title`);
643
- if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
644
- sceneIds.add(scene.id);
645
- validateNarrative2(scene, path);
646
- if (scene.kind === "deconstructed-diff") {
647
- validateDeconstructedScene2(scene, path);
648
- continue;
649
- }
650
- if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
651
- throw new Error(`${path} must contain a non-empty steps array.`);
652
- }
653
- if (scene.kind === "stacked-diff") {
654
- validateStackedScene2(scene, path);
655
- continue;
656
- }
657
- if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
658
- throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
1134
+ const stageSteps = value.steps.filter((step) => isRecord2(step) && Number(step.pairIndex) === stageIndex);
1135
+ if (!isRecord2(panel) || stageSteps.length === 0) {
1136
+ throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
659
1137
  }
660
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "steps"], path);
661
- const stepIds = /* @__PURE__ */ new Set();
662
- for (const [stepIndex, step] of scene.steps.entries()) {
663
- const stepPath = `${path}.steps[${stepIndex}]`;
664
- if (!isRecord2(step)) {
665
- throw new Error(`${stepPath} must be an object.`);
666
- }
667
- requireString2(step.id, `${stepPath}.id`);
668
- requireString2(step.title, `${stepPath}.title`);
669
- requireString2(step.body, `${stepPath}.body`);
670
- requireString2(step.focus, `${stepPath}.focus`);
671
- requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth"], stepPath);
672
- if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
673
- throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
674
- }
675
- if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
676
- stepIds.add(step.id);
677
- const focus = step.focus;
678
- if (!value.anchors[focus]) {
679
- throw new Error(`${stepPath} references unknown anchor ${focus}.`);
680
- }
681
- optionalString(step.connection, `${stepPath}.connection`);
682
- const connection = step.connection;
683
- if (typeof connection === "string" && !connectionIds.has(connection)) {
684
- throw new Error(`${stepPath} references unknown connection ${connection}.`);
685
- }
1138
+ const firstStep = stageSteps[0];
1139
+ const fallbackStageId = isRecord2(firstStep) ? firstStep.id : void 0;
1140
+ if (stageSteps.some((step) => {
1141
+ if (!isRecord2(step)) return true;
1142
+ return (step.stageId ?? fallbackStageId) !== panel.stageId;
1143
+ })) {
1144
+ throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
686
1145
  }
687
1146
  }
688
1147
  }
689
- return { ...value, connections: rawConnections };
690
1148
  }
691
- function validateDeconstructedScene2(scene, path) {
692
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "base", "target", "stages", "exclusions"], path);
693
- optionalString(scene.base, `${path}.base`);
694
- optionalString(scene.target, `${path}.target`);
695
- if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
696
- throw new Error(`${path}.stages must contain between 1 and 12 stages.`);
697
- }
698
- const stageIds = /* @__PURE__ */ new Set();
699
- for (const [stageIndex, stage] of scene.stages.entries()) {
700
- const stagePath = `${path}.stages[${stageIndex}]`;
701
- if (!isRecord2(stage)) throw new Error(`${stagePath} must be an object.`);
702
- requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
703
- requireString2(stage.id, `${stagePath}.id`);
704
- requireString2(stage.title, `${stagePath}.title`);
705
- requireString2(stage.narration, `${stagePath}.narration`);
706
- if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
707
- throw new Error(`${stagePath}.changes must be a non-empty array.`);
1149
+ function validateMultiPanelFiles(value, panelCount, index) {
1150
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
1151
+ for (const [fileIndex, file] of value.entries()) {
1152
+ if (!isRecord2(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1153
+ requireString2(file.path, `scenes[${index}].files[${fileIndex}].path`);
1154
+ if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
1155
+ throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
1156
+ }
1157
+ for (const [panelIndex, panel] of file.panels.entries()) {
1158
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
1159
+ for (const key of ["id", "label", "content"]) requireString2(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
1160
+ if (panel.path !== void 0) requireString2(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
1161
+ if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
708
1162
  }
709
- if (stageIds.has(stage.id)) throw new Error(`Duplicate deconstructed stage id: ${stage.id}`);
710
- stageIds.add(stage.id);
711
- stage.changes.forEach((change, changeIndex) => validateDeconstructedSelection(
712
- change,
713
- `${stagePath}.changes[${changeIndex}]`,
714
- false
715
- ));
716
- }
717
- if (scene.exclusions !== void 0) {
718
- if (!Array.isArray(scene.exclusions)) throw new Error(`${path}.exclusions must be an array.`);
719
- scene.exclusions.forEach((exclusion, exclusionIndex) => validateDeconstructedSelection(
720
- exclusion,
721
- `${path}.exclusions[${exclusionIndex}]`,
722
- true
723
- ));
724
1163
  }
725
1164
  }
726
- function validateDeconstructedSelection(value, path, requiresReason) {
727
- if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
728
- requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
729
- requireString2(value.file, `${path}.file`);
730
- if (!requiresReason || value.hunks !== void 0) {
731
- requireStringArray2(value.hunks, `${path}.hunks`);
732
- if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
1165
+ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1166
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
1167
+ for (const [stepIndex, step] of value.entries()) {
1168
+ if (!isRecord2(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
1169
+ for (const key of ["id", "title", "body", "file", "side"]) requireString2(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
1170
+ requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
1171
+ if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
1172
+ if (deconstructed) {
1173
+ requireStringArray2(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1174
+ if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
1175
+ }
1176
+ if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
1177
+ if (step.startLine !== void 0) requirePositiveInteger(step.startLine, `scenes[${index}].steps[${stepIndex}].startLine`);
1178
+ if (step.endLine !== void 0) requirePositiveInteger(step.endLine, `scenes[${index}].steps[${stepIndex}].endLine`);
733
1179
  }
734
- if (requiresReason) requireString2(value.reason, `${path}.reason`);
735
1180
  }
736
- function validateStackedScene2(scene, path) {
737
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "stack", "files", "steps"], path);
738
- if (!Array.isArray(scene.stack) || scene.stack.length < 3 || scene.stack.length > 6) {
739
- throw new Error(`${path}.stack must contain between 3 and 6 revisions.`);
1181
+ function validateTourFile(value, index) {
1182
+ if (!isRecord2(value)) throw new Error(`files[${index}] must be an object.`);
1183
+ if (value.kind === "text-diff") {
1184
+ validateScene(value, index, 1);
1185
+ return;
740
1186
  }
741
- const stackIds = /* @__PURE__ */ new Set();
742
- for (const [index, entry] of scene.stack.entries()) {
743
- const entryPath = `${path}.stack[${index}]`;
744
- if (!isRecord2(entry)) throw new Error(`${entryPath} must be an object.`);
745
- requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
746
- requireString2(entry.id, `${entryPath}.id`);
747
- requireString2(entry.ref, `${entryPath}.ref`);
748
- optionalString(entry.label, `${entryPath}.label`);
749
- if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
750
- stackIds.add(entry.id);
1187
+ if (value.kind !== "omitted") {
1188
+ throw new Error(`files[${index}].kind must be text-diff or omitted.`);
751
1189
  }
752
- if (scene.files !== void 0) requireStringArray2(scene.files, `${path}.files`);
753
- if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
754
- const stepIds = /* @__PURE__ */ new Set();
755
- for (const [index, step] of scene.steps.entries()) {
756
- const stepPath = `${path}.steps[${index}]`;
757
- if (!isRecord2(step)) throw new Error(`${stepPath} must be an object.`);
758
- requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
759
- requireString2(step.id, `${stepPath}.id`);
760
- requireString2(step.title, `${stepPath}.title`);
761
- requireString2(step.body, `${stepPath}.body`);
762
- requireString2(step.file, `${stepPath}.file`);
763
- const stepId = step.id;
764
- if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
765
- stepIds.add(stepId);
766
- if (!Array.isArray(step.pair) || step.pair.length !== 2 || step.pair.some((id) => typeof id !== "string")) {
767
- throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
1190
+ for (const key of ["id", "title", "path", "changeKind", "reason"]) {
1191
+ requireString2(value[key], `files[${index}].${key}`);
1192
+ }
1193
+ if (value.previousPath !== void 0) requireString2(value.previousPath, `files[${index}].previousPath`);
1194
+ requireNonNegativeInteger(value.additions, `files[${index}].additions`);
1195
+ requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
1196
+ }
1197
+ function collectLegacyTourFiles(scenes) {
1198
+ const files = /* @__PURE__ */ new Map();
1199
+ for (const scene of scenes) {
1200
+ if (scene.kind === "text-diff") {
1201
+ if (!files.has(scene.path)) files.set(scene.path, scene);
1202
+ continue;
768
1203
  }
769
- const pair = step.pair;
770
- const leftIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[0]);
771
- const rightIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[1]);
772
- if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
773
- if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
774
- 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]))) {
775
- throw new Error(`${stepPath}.lines must contain an ordered positive line range.`);
1204
+ if (scene.kind === "walkthrough") {
1205
+ for (const step of scene.steps) {
1206
+ if (!files.has(step.diff.path)) files.set(step.diff.path, step.diff);
1207
+ }
776
1208
  }
777
1209
  }
1210
+ return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
1211
+ }
1212
+ function validateResolvedAnchor(value, path) {
1213
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
1214
+ for (const key of ["id", "path", "revision", "excerpt"]) requireString2(value[key], `${path}.${key}`);
1215
+ if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
1216
+ requirePositiveInteger(value.startLine, `${path}.startLine`);
1217
+ requirePositiveInteger(value.endLine, `${path}.endLine`);
1218
+ if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
778
1219
  }
779
1220
  function validateNarrative2(value, path) {
780
1221
  requireString2(value.summary, `${path}.summary`);
@@ -786,13 +1227,8 @@ function isRecord2(value) {
786
1227
  return typeof value === "object" && value !== null && !Array.isArray(value);
787
1228
  }
788
1229
  function requireString2(value, path) {
789
- if (typeof value !== "string" || value.length === 0) {
790
- throw new Error(`${path} must be a non-empty string.`);
791
- }
792
- }
793
- function optionalString(value, path) {
794
- if (value !== void 0) {
795
- requireString2(value, path);
1230
+ if (typeof value !== "string") {
1231
+ throw new Error(`${path} must be a string.`);
796
1232
  }
797
1233
  }
798
1234
  function requireStringArray2(value, path) {
@@ -800,12 +1236,19 @@ function requireStringArray2(value, path) {
800
1236
  throw new Error(`${path} must be an array of strings.`);
801
1237
  }
802
1238
  }
803
- function requireOnlyKeys(value, allowed, path) {
804
- const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
805
- if (unexpected.length > 0) {
806
- throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
1239
+ function requireNonNegativeInteger(value, path) {
1240
+ if (!Number.isInteger(value) || Number(value) < 0) {
1241
+ throw new Error(`${path} must be a non-negative integer.`);
1242
+ }
1243
+ }
1244
+ function requirePercentage(value, path) {
1245
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
1246
+ throw new Error(`${path} must be a number from 0 to 100.`);
807
1247
  }
808
1248
  }
1249
+ function requirePositiveInteger(value, path) {
1250
+ if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
1251
+ }
809
1252
 
810
1253
  // src/changeInventory.ts
811
1254
  var import_crypto = require("crypto");
@@ -1585,12 +2028,20 @@ var DIFF_TIMEOUT_MS = readPositiveIntegerEnv("BYGONE_DIFF_TIMEOUT_MS", DEFAULT_D
1585
2028
  function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1586
2029
  const leftLines = normalizeLines(leftContent);
1587
2030
  const rightLines = normalizeLines(rightContent);
1588
- const changes = diffArrays(leftLines, rightLines, {
2031
+ if (leftContent === rightContent) {
2032
+ return buildIdenticalTwoWayDiffModel(leftLines);
2033
+ }
2034
+ const interned = internDiffLines(leftLines, rightLines);
2035
+ const internedChanges = diffArrays(interned.left, interned.right, {
1589
2036
  timeout: options.timeoutMs ?? DIFF_TIMEOUT_MS
1590
2037
  });
1591
- if (!changes) {
2038
+ if (!internedChanges) {
1592
2039
  return buildConservativeTwoWayDiffModel(leftLines, rightLines);
1593
2040
  }
2041
+ const changes = internedChanges.map((change) => ({
2042
+ ...change,
2043
+ value: change.value.map((lineId) => interned.lines[lineId])
2044
+ }));
1594
2045
  const rows = [];
1595
2046
  const renderedLeftLines = [];
1596
2047
  const renderedRightLines = [];
@@ -1614,29 +2065,18 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1614
2065
  }
1615
2066
  if (change.removed && index + 1 < changes.length && changes[index + 1].added) {
1616
2067
  const nextChange = changes[index + 1];
1617
- const leftStart = renderedLeftLines.length;
1618
- const rightStart = renderedRightLines.length;
1619
- const alignedLines = alignReplacementLines(removedLines, nextChange.value);
1620
- for (const { left: removedLine, right: addedLine } of alignedLines) {
1621
- let renderedLeftLine;
1622
- let renderedRightLine;
1623
- if (removedLine !== void 0) {
1624
- renderedLeftLine = makeDiffLine("removed", removedLine, leftLineNumber);
1625
- renderedLeftLines.push(renderedLeftLine);
1626
- }
1627
- if (addedLine !== void 0) {
1628
- renderedRightLine = makeDiffLine("added", addedLine, rightLineNumber);
1629
- renderedRightLines.push(renderedRightLine);
1630
- }
1631
- if (renderedLeftLine && renderedRightLine) {
1632
- applyInlineHighlightPair(renderedLeftLine, renderedRightLine);
1633
- }
1634
- rows.push(makeDiffRow(
1635
- removedLine === void 0 ? makePlaceholder() : makeDiffCell("removed", removedLine, leftLineNumber++),
1636
- addedLine === void 0 ? makePlaceholder() : makeDiffCell("added", addedLine, rightLineNumber++)
1637
- ));
1638
- }
1639
- blocks.push(makeDiffBlock("replace", leftStart, renderedLeftLines.length, rightStart, renderedRightLines.length));
2068
+ const nextLineNumbers = appendReplacementCandidate(
2069
+ removedLines,
2070
+ nextChange.value,
2071
+ leftLineNumber,
2072
+ rightLineNumber,
2073
+ rows,
2074
+ renderedLeftLines,
2075
+ renderedRightLines,
2076
+ blocks
2077
+ );
2078
+ leftLineNumber = nextLineNumbers.left;
2079
+ rightLineNumber = nextLineNumbers.right;
1640
2080
  index++;
1641
2081
  continue;
1642
2082
  }
@@ -1672,7 +2112,40 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1672
2112
  leftLines: renderedLeftLines,
1673
2113
  rightLines: renderedRightLines,
1674
2114
  blocks,
1675
- hasChanges
2115
+ hasChanges,
2116
+ quality: "exact"
2117
+ };
2118
+ }
2119
+ function internDiffLines(leftLines, rightLines) {
2120
+ const lineIds = /* @__PURE__ */ new Map();
2121
+ const lines = [];
2122
+ const intern = (line) => {
2123
+ const existingId = lineIds.get(line);
2124
+ if (existingId !== void 0) {
2125
+ return existingId;
2126
+ }
2127
+ const lineId = lines.length;
2128
+ lineIds.set(line, lineId);
2129
+ lines.push(line);
2130
+ return lineId;
2131
+ };
2132
+ return {
2133
+ left: leftLines.map(intern),
2134
+ right: rightLines.map(intern),
2135
+ lines
2136
+ };
2137
+ }
2138
+ function buildIdenticalTwoWayDiffModel(lines) {
2139
+ return {
2140
+ rows: lines.map((line, index) => makeDiffRow(
2141
+ makeDiffCell("context", line, index + 1),
2142
+ makeDiffCell("context", line, index + 1)
2143
+ )),
2144
+ leftLines: lines.map((line, index) => makeDiffLine("context", line, index + 1)),
2145
+ rightLines: lines.map((line, index) => makeDiffLine("context", line, index + 1)),
2146
+ blocks: [],
2147
+ hasChanges: false,
2148
+ quality: "exact"
1676
2149
  };
1677
2150
  }
1678
2151
  function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
@@ -1699,7 +2172,8 @@ function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
1699
2172
  leftLines: renderedLeftLines,
1700
2173
  rightLines: renderedRightLines,
1701
2174
  blocks: rows.length === 0 ? [] : [makeDiffBlock(blockKind, 0, leftLines.length, 0, rightLines.length)],
1702
- hasChanges: rows.length > 0
2175
+ hasChanges: rows.length > 0,
2176
+ quality: "fallback"
1703
2177
  };
1704
2178
  }
1705
2179
  function normalizeLines(content) {
@@ -1712,6 +2186,100 @@ function normalizeLines(content) {
1712
2186
  }
1713
2187
  return lines;
1714
2188
  }
2189
+ function appendReplacementCandidate(leftLines, rightLines, initialLeftLineNumber, initialRightLineNumber, rows, renderedLeftLines, renderedRightLines, blocks) {
2190
+ const aligned = alignReplacementLines(leftLines, rightLines);
2191
+ const crediblePairs = aligned.filter((row) => row.left !== void 0 && row.right !== void 0 && hasCredibleLineCorrespondence(row.left, row.right)).length;
2192
+ const coherentBlock = hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs);
2193
+ const normalized = aligned.flatMap((row) => {
2194
+ if (row.left === void 0 || row.right === void 0 || hasCredibleLineCorrespondence(row.left, row.right)) {
2195
+ return [row];
2196
+ }
2197
+ return [{ left: row.left }, { right: row.right }];
2198
+ });
2199
+ let leftLineNumber = initialLeftLineNumber;
2200
+ let rightLineNumber = initialRightLineNumber;
2201
+ let blockKind;
2202
+ let blockLeftStart = renderedLeftLines.length;
2203
+ let blockRightStart = renderedRightLines.length;
2204
+ const flushBlock = () => {
2205
+ if (!blockKind) return;
2206
+ blocks.push(makeDiffBlock(
2207
+ blockKind,
2208
+ blockLeftStart,
2209
+ renderedLeftLines.length,
2210
+ blockRightStart,
2211
+ renderedRightLines.length
2212
+ ));
2213
+ };
2214
+ for (const { left, right } of normalized) {
2215
+ const nextKind = coherentBlock ? "replace" : left !== void 0 && right !== void 0 ? "replace" : left !== void 0 ? "delete" : "insert";
2216
+ if (blockKind !== nextKind) {
2217
+ flushBlock();
2218
+ blockKind = nextKind;
2219
+ blockLeftStart = renderedLeftLines.length;
2220
+ blockRightStart = renderedRightLines.length;
2221
+ }
2222
+ let renderedLeftLine;
2223
+ let renderedRightLine;
2224
+ if (left !== void 0) {
2225
+ renderedLeftLine = makeDiffLine("removed", left, leftLineNumber);
2226
+ renderedLeftLines.push(renderedLeftLine);
2227
+ }
2228
+ if (right !== void 0) {
2229
+ renderedRightLine = makeDiffLine("added", right, rightLineNumber);
2230
+ renderedRightLines.push(renderedRightLine);
2231
+ }
2232
+ if (renderedLeftLine && renderedRightLine) {
2233
+ applyInlineHighlightPair(renderedLeftLine, renderedRightLine);
2234
+ } else if (coherentBlock) {
2235
+ if (renderedLeftLine) applyFullLineHighlight(renderedLeftLine);
2236
+ if (renderedRightLine) applyFullLineHighlight(renderedRightLine);
2237
+ }
2238
+ rows.push(makeDiffRow(
2239
+ left === void 0 ? makePlaceholder() : makeDiffCell("removed", left, leftLineNumber++),
2240
+ right === void 0 ? makePlaceholder() : makeDiffCell("added", right, rightLineNumber++)
2241
+ ));
2242
+ }
2243
+ flushBlock();
2244
+ return { left: leftLineNumber, right: rightLineNumber };
2245
+ }
2246
+ function hasCredibleLineCorrespondence(left, right) {
2247
+ const score = scoreReplacementLinePair(left, right);
2248
+ if (score.eligible) return true;
2249
+ const leftAnchor = declarationAnchorKey(left);
2250
+ if (leftAnchor && leftAnchor === declarationAnchorKey(right)) return true;
2251
+ const leftTokens = new Set(tokenizeMatchingContent(normalizeMatchingContent(left)).filter(isSharedContentToken));
2252
+ return tokenizeMatchingContent(normalizeMatchingContent(right)).some((token) => leftTokens.has(token) && isSharedContentToken(token));
2253
+ }
2254
+ var NON_SUBSTANTIVE_SHARED_TOKENS = /* @__PURE__ */ new Set([
2255
+ "and",
2256
+ "are",
2257
+ "for",
2258
+ "from",
2259
+ "into",
2260
+ "that",
2261
+ "the",
2262
+ "this",
2263
+ "to",
2264
+ "with"
2265
+ ]);
2266
+ function isSharedContentToken(token) {
2267
+ return token.length >= 3 && /^[\p{L}\p{N}_$]+$/u.test(token) && !NON_SUBSTANTIVE_SHARED_TOKENS.has(token);
2268
+ }
2269
+ function hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs) {
2270
+ if (crediblePairs === 0) return false;
2271
+ const longestSide = Math.max(leftLines.length, rightLines.length);
2272
+ if (longestSide <= 2 || crediblePairs * 2 >= longestSide) return true;
2273
+ const roles = [...leftLines, ...rightLines].map(collectionLineRole);
2274
+ return roles[0] !== null && roles.every((role) => role === roles[0]);
2275
+ }
2276
+ function collectionLineRole(line) {
2277
+ const normalized = line.trimStart();
2278
+ if (/^[-*+]\s+/.test(normalized)) return "bullet";
2279
+ if (/^\d+[.)]\s+/.test(normalized)) return "ordered";
2280
+ if (/^>\s+/.test(normalized)) return "quote";
2281
+ return null;
2282
+ }
1715
2283
  var MAX_ALIGNMENT_CELLS = 1e4;
1716
2284
  var MAX_SCORING_LINE_LENGTH = 2e3;
1717
2285
  var MINIMUM_MATCH_SCORE = 0.52;
@@ -1724,6 +2292,7 @@ var CONTEXTUAL_AMBIGUITY_MARGIN = 0.05;
1724
2292
  var GAP_PENALTY = -0.12;
1725
2293
  var MAX_BOUNDED_CANDIDATES = 5e3;
1726
2294
  var MAX_LARGE_HUNK_ANCHORS = 2e3;
2295
+ var MAX_ALIGNMENT_CHARACTER_CELLS = 5e7;
1727
2296
  var MAX_RARE_TOKEN_OCCURRENCES = 4;
1728
2297
  var MAX_CONTEXTUAL_HUNK_LINES = 8;
1729
2298
  var MAX_CONTEXTUAL_LENGTH_DELTA = 4;
@@ -1734,9 +2303,17 @@ function alignReplacementLines(leftLines, rightLines) {
1734
2303
  if (rightLines.length === 0) {
1735
2304
  return leftLines.map((left) => ({ left }));
1736
2305
  }
2306
+ const leftCharacterCount = leftLines.reduce((total, line) => total + line.length, 0);
2307
+ const rightCharacterCount = rightLines.reduce((total, line) => total + line.length, 0);
2308
+ if (leftCharacterCount * rightCharacterCount > MAX_ALIGNMENT_CHARACTER_CELLS) {
2309
+ return [
2310
+ ...leftLines.map((left) => ({ left })),
2311
+ ...rightLines.map((right) => ({ right }))
2312
+ ];
2313
+ }
1737
2314
  if (leftLines.length === 1 && rightLines.length === 1) {
1738
2315
  const singletonScore = scoreReplacementLinePair(leftLines[0], rightLines[0]);
1739
- if ((singletonScore.eligible || singletonScore.score >= MINIMUM_SINGLE_PAIR_SCORE) && haveCompatibleLineRoles(leftLines[0], rightLines[0]) && isInformativeLine(normalizeMatchingContent(leftLines[0])) && isInformativeLine(normalizeMatchingContent(rightLines[0]))) {
2316
+ 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]))) {
1740
2317
  return [{ left: leftLines[0], right: rightLines[0] }];
1741
2318
  }
1742
2319
  return [{ left: leftLines[0] }, { right: rightLines[0] }];
@@ -2258,6 +2835,11 @@ function applyInlineHighlightPair(leftLine, rightLine) {
2258
2835
  rightLine.segments = rightSegments;
2259
2836
  }
2260
2837
  }
2838
+ function applyFullLineHighlight(line) {
2839
+ if (/[^\s]/.test(line.content)) {
2840
+ line.segments = [{ kind: line.kind, text: line.content, emphasis: true }];
2841
+ }
2842
+ }
2261
2843
  function buildInlineSegments(leftContent, rightContent) {
2262
2844
  const changes = diffWordsWithSpace(leftContent, rightContent);
2263
2845
  const leftSegments = [];
@@ -2409,6 +2991,125 @@ function findBlankLineBoundary(lines, target, minimum, maximum) {
2409
2991
  return Math.min(Math.max(target, minimum), maximum - 1);
2410
2992
  }
2411
2993
 
2994
+ // src/changeAttention.ts
2995
+ var CHANGE_ATTENTION_VERSION = 1;
2996
+ var LOCKFILES = /* @__PURE__ */ new Set([
2997
+ "cargo.lock",
2998
+ "package-lock.json",
2999
+ "pnpm-lock.yaml",
3000
+ "yarn.lock",
3001
+ "poetry.lock",
3002
+ "gemfile.lock",
3003
+ "composer.lock"
3004
+ ]);
3005
+ var MAX_FAN_IN_FILES = 250;
3006
+ var MAX_FAN_IN_TEXT_BYTES = 64 * 1024;
3007
+ function classifyChangeFileRole(filePath) {
3008
+ const lower = filePath.toLowerCase();
3009
+ const fileName = lower.split("/").pop() || lower;
3010
+ if (/(^|\/)(dist|build|generated|vendor)(\/|$)/.test(lower) || /(?:\.min\.js|\.map)$/.test(lower)) return "generated";
3011
+ if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower) || /\.(test|spec)\.[^/]+$/.test(lower)) return "test";
3012
+ if (lower.startsWith("docs/") || fileName === "readme.md" || /\.(md|mdx|rst)$/.test(lower)) return "documentation";
3013
+ if (LOCKFILES.has(fileName) || /(^|\/)(pyproject\.toml|package\.json|go\.mod|go\.sum|cargo\.toml)$/.test(lower)) return "dependency";
3014
+ return "production";
3015
+ }
3016
+ function buildChangeAttention(inputs) {
3017
+ const prepared = inputs.map((input) => prepareFile(input, inputs));
3018
+ const production = prepared.filter((file) => file.role === "production" && !file.mechanical);
3019
+ const documentation = prepared.filter((file) => file.role === "documentation" && !file.mechanical);
3020
+ const eligible = production.length > 0 ? production : documentation;
3021
+ const entry = [...eligible].sort(compareRanked)[0];
3022
+ const fallback = [...prepared].filter((file) => !file.binary).sort((left, right) => right.changedLines - left.changedLines || left.path.localeCompare(right.path))[0];
3023
+ 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)));
3024
+ 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;
3025
+ if (pairedTest && focusCandidates.length < Math.max(0, 7 - (entry ? 1 : 0))) focusCandidates.push(pairedTest);
3026
+ const focusPaths = focusCandidates.map((file) => file.path);
3027
+ const files = prepared.map((file) => {
3028
+ const attention = file.path === (entry == null ? void 0 : entry.path) ? "entry" : focusPaths.includes(file.path) ? "focus" : file.mechanical ? "mechanical" : "background";
3029
+ return {
3030
+ path: file.path,
3031
+ previousPath: file.previousPath,
3032
+ changeKind: file.changeKind,
3033
+ role: file.role,
3034
+ attention,
3035
+ additions: file.additions,
3036
+ deletions: file.deletions,
3037
+ reason: attention === "entry" ? entryReason(file, production.length) : attentionReason(file),
3038
+ score: file.score
3039
+ };
3040
+ }).sort((left, right) => left.path.localeCompare(right.path));
3041
+ return {
3042
+ version: CHANGE_ATTENTION_VERSION,
3043
+ entryPath: (entry == null ? void 0 : entry.path) || null,
3044
+ entryReason: entry ? entryReason(entry, production.length) : noEntryReason(prepared),
3045
+ fallbackPath: (fallback == null ? void 0 : fallback.path) || null,
3046
+ focusPaths,
3047
+ backgroundPaths: files.filter((file) => file.attention === "background").map((file) => file.path),
3048
+ mechanicalPaths: files.filter((file) => file.attention === "mechanical").map((file) => file.path),
3049
+ files
3050
+ };
3051
+ }
3052
+ function prepareFile(input, all) {
3053
+ const role = classifyChangeFileRole(input.path);
3054
+ const fileName = input.path.toLowerCase().split("/").pop() || input.path.toLowerCase();
3055
+ const mechanical = Boolean(input.binary) || role === "generated" || LOCKFILES.has(fileName);
3056
+ const changedLines = (input.additions ?? 0) + (input.deletions ?? 0);
3057
+ const contract = /(^|[\/._-])(types?|schema|models?|contracts?|interfaces?)([\/._-]|$)/i.test(input.path);
3058
+ const referenceHints = new Set([
3059
+ fileName.replace(/\.[^.]+$/, ""),
3060
+ ...(input.symbolHints || []).map((hint) => hint.toLowerCase())
3061
+ ].filter((hint) => hint.length >= 4));
3062
+ 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) => {
3063
+ var _a;
3064
+ const boundedText = (_a = candidate.headText) == null ? void 0 : _a.slice(0, MAX_FAN_IN_TEXT_BYTES).toLowerCase();
3065
+ return Boolean(boundedText && [...referenceHints].some((hint) => boundedText.includes(hint)));
3066
+ }).length;
3067
+ const roleScore = role === "production" ? 300 : role === "documentation" ? 150 : role === "dependency" ? 80 : role === "test" ? 50 : 0;
3068
+ const sizePenalty = changedLines > 2e3 ? 80 : changedLines > 500 ? 30 : 0;
3069
+ const readmePenalty = fileName === "readme.md" && all.filter((candidate) => !candidate.binary).length > 1 ? 100 : 0;
3070
+ return {
3071
+ ...input,
3072
+ role,
3073
+ mechanical,
3074
+ changedLines,
3075
+ contract,
3076
+ fanIn,
3077
+ binary: Boolean(input.binary),
3078
+ score: mechanical ? -1e3 : roleScore + (contract ? 100 : 0) + fanIn * 40 + (input.commitCount || 0) * 5 - sizePenalty - readmePenalty
3079
+ };
3080
+ }
3081
+ function compareRanked(left, right) {
3082
+ return right.score - left.score || right.fanIn - left.fanIn || left.changedLines - right.changedLines || left.path.localeCompare(right.path);
3083
+ }
3084
+ function entryReason(file, productionCount) {
3085
+ if (file.fanIn > 0) return `Referenced by ${file.fanIn} other changed file${file.fanIn === 1 ? "" : "s"}.`;
3086
+ if (file.contract) return "Contract-shaped path central to the change.";
3087
+ if (productionCount === 1) return "Only production file in this range.";
3088
+ return "Highest-ranked production file for a first pass.";
3089
+ }
3090
+ function attentionReason(file) {
3091
+ if (file.binary) return "Binary file.";
3092
+ if (file.mechanical) return file.role === "generated" ? "Generated artifact." : "Dependency lockfile.";
3093
+ if (file.role === "test") return "Test or proof file.";
3094
+ if (file.role === "documentation") return "Documentation change.";
3095
+ if (file.role === "dependency") return "Dependency or packaging manifest.";
3096
+ return "Additional production file.";
3097
+ }
3098
+ function noEntryReason(files) {
3099
+ if (files.every((file) => file.mechanical)) return "This change is entirely mechanical or generated.";
3100
+ if (files.every((file) => file.mechanical || file.role === "test")) return "This change contains only tests and mechanical files.";
3101
+ return "No single entry-point is reliable for this change.";
3102
+ }
3103
+ function isTestForFocus(testPath, focusPaths) {
3104
+ const normalizedTest = testPath.toLowerCase().replace(/\.(test|spec)(?=\.)/, "").replace(/^test_/, "");
3105
+ return focusPaths.some((candidate) => {
3106
+ var _a;
3107
+ const normalizedCandidate = candidate.toLowerCase();
3108
+ const stem = ((_a = normalizedCandidate.split("/").pop()) == null ? void 0 : _a.replace(/\.[^.]+$/, "")) || normalizedCandidate;
3109
+ return stem.length >= 3 && normalizedTest.includes(stem);
3110
+ });
3111
+ }
3112
+
2412
3113
  // src/changeTourContext.ts
2413
3114
  var import_child_process3 = require("child_process");
2414
3115
  var CHANGE_TOUR_CONTEXT_VERSION = 1;
@@ -2486,7 +3187,7 @@ function buildContextFile(repoRoot, baseOid, headOid, changedPath, maxPatchBytes
2486
3187
  path: changedPath.path,
2487
3188
  previousPath: changedPath.previousPath,
2488
3189
  changeKind: changedPath.kind,
2489
- role: classifyFileRole(changedPath.path),
3190
+ role: classifyChangeFileRole(changedPath.path),
2490
3191
  additions: stats.additions,
2491
3192
  deletions: stats.deletions,
2492
3193
  binary: stats.binary,
@@ -2583,15 +3284,6 @@ function readGitText(repoRoot, oid, relativePath, maxBytes) {
2583
3284
  return void 0;
2584
3285
  }
2585
3286
  }
2586
- function classifyFileRole(filePath) {
2587
- const lower = filePath.toLowerCase();
2588
- const fileName = lower.split("/").pop() || lower;
2589
- if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower) || /\.(test|spec)\.[^/]+$/.test(lower)) return "test";
2590
- if (lower.startsWith("docs/") || fileName === "readme.md" || /\.(md|mdx|rst)$/.test(lower)) return "documentation";
2591
- if (/(^|\/)(package-lock\.json|.*\.lock|pyproject\.toml|package\.json|go\.mod|cargo\.toml)$/.test(lower)) return "dependency";
2592
- if (/(^|\/)(dist|build|generated|vendor)(\/|$)/.test(lower) || /\.(min\.js|map)$/.test(lower)) return "generated";
2593
- return "production";
2594
- }
2595
3287
  function runGit2(repoRoot, args) {
2596
3288
  return (0, import_child_process3.execFileSync)("git", args, {
2597
3289
  cwd: repoRoot,
@@ -2616,8 +3308,21 @@ var CHAPTERS = {
2616
3308
  packaging: { id: "packaging", title: "Packaging and dependencies", priority: 4 }
2617
3309
  };
2618
3310
  function buildChangeTourManifest(startPath, options = {}) {
2619
- var _a, _b, _c, _d, _e;
2620
- const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
3311
+ var _a, _b, _c, _d;
3312
+ const source = options.source ? parseChangeTourSource(options.source) : void 0;
3313
+ const range = resolveBranchReviewRange(
3314
+ startPath,
3315
+ options.headRef ?? ((_a = source == null ? void 0 : source.range) == null ? void 0 : _a.head),
3316
+ options.baseRef ?? ((_b = source == null ? void 0 : source.range) == null ? void 0 : _b.base)
3317
+ );
3318
+ if (source == null ? void 0 : source.review) {
3319
+ validateChangeTourReview(source.review, {
3320
+ expectedRange: {
3321
+ baseOid: range.mergeBaseOid,
3322
+ headOid: range.headOid
3323
+ }
3324
+ });
3325
+ }
2621
3326
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_TOUR_FILE_BYTES;
2622
3327
  const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_TOUR_LINE_BYTES;
2623
3328
  const omittedFiles = [];
@@ -2625,8 +3330,9 @@ function buildChangeTourManifest(startPath, options = {}) {
2625
3330
  const sceneRecords = [];
2626
3331
  let totalAdditions = 0;
2627
3332
  let totalDeletions = 0;
3333
+ let totalBinaryFiles = 0;
2628
3334
  for (const changedPath of range.changedPaths) {
2629
- const { additions, deletions } = readGitLineStats(
3335
+ const { additions, deletions, binary } = readGitLineStats(
2630
3336
  range.repoRoot,
2631
3337
  range.mergeBaseOid,
2632
3338
  range.headOid,
@@ -2635,6 +3341,7 @@ function buildChangeTourManifest(startPath, options = {}) {
2635
3341
  );
2636
3342
  totalAdditions += additions;
2637
3343
  totalDeletions += deletions;
3344
+ if (binary) totalBinaryFiles += 1;
2638
3345
  const pair = resolveReviewPathPair(range.changedPaths, changedPath.path);
2639
3346
  if (!pair) {
2640
3347
  omittedFiles.push(changedPath.path);
@@ -2675,21 +3382,50 @@ function buildChangeTourManifest(startPath, options = {}) {
2675
3382
  }
2676
3383
  files.sort((left, right) => left.path.localeCompare(right.path));
2677
3384
  sceneRecords.sort((left, right) => left.chapter.priority - right.chapter.priority || left.scene.path.localeCompare(right.scene.path));
2678
- const defaultScenes = sceneRecords.map(({ scene }, index) => ({ ...scene, id: `file-${index + 1}` }));
2679
- if (options.story && options.source) {
3385
+ let defaultScenes = sceneRecords.map(({ scene }, index) => ({ ...scene, id: `file-${index + 1}` }));
3386
+ if (options.story && source) {
2680
3387
  throw new Error("A change tour can use either a legacy story or a source file, not both.");
2681
3388
  }
2682
- const authored = options.source ? applySource(parseChangeTourSource(options.source), defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : {
2683
- scenes: defaultScenes,
2684
- chapters: buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id])))
2685
- };
3389
+ const attention = buildChangeAttention(files.map((file) => ({
3390
+ path: file.path,
3391
+ previousPath: file.previousPath,
3392
+ changeKind: file.changeKind,
3393
+ additions: file.additions,
3394
+ deletions: file.deletions,
3395
+ binary: file.kind === "omitted",
3396
+ headText: file.kind === "text-diff" ? file.rightContent : void 0,
3397
+ symbolHints: file.kind === "text-diff" ? findDeclarationNames(file.rightContent) : []
3398
+ })));
3399
+ if (!options.story && !options.source && attention.entryPath) {
3400
+ const focusRank = (scenePath) => {
3401
+ const index = attention.focusPaths.indexOf(scenePath);
3402
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
3403
+ };
3404
+ 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));
3405
+ }
3406
+ const authored = source ? applySource(source, defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
3407
+ defaultScenes,
3408
+ buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id]))),
3409
+ attention,
3410
+ {
3411
+ changedFiles: range.changedPaths.length,
3412
+ binaryFiles: totalBinaryFiles,
3413
+ additions: totalAdditions,
3414
+ deletions: totalDeletions,
3415
+ commitCount: range.commits.length
3416
+ }
3417
+ );
2686
3418
  const scenes = authored.scenes;
2687
3419
  const chapters = authored.chapters;
3420
+ const finalScenes = "finalScenes" in authored ? authored.finalScenes : scenes;
3421
+ const finalChapters = "finalChapters" in authored ? authored.finalChapters : chapters;
2688
3422
  const manifest = {
2689
- version: CHANGE_TOUR_MANIFEST_VERSION,
2690
- title: ((_a = options.source) == null ? void 0 : _a.title) || ((_b = options.story) == null ? void 0 : _b.title) || options.title || `${range.headRef} against ${range.baseRef}`,
2691
- windowTitle: (_c = options.source) == null ? void 0 : _c.windowTitle,
2692
- sourceUrl: ((_d = options.source) == null ? void 0 : _d.sourceUrl) || ((_e = options.story) == null ? void 0 : _e.sourceUrl) || options.sourceUrl,
3423
+ // Only authored v2+ files opt into repository-bound zoom modes. Generated
3424
+ // and legacy/story tours retain the portable v1 manifest contract.
3425
+ version: (source == null ? void 0 : source.version) ?? 1,
3426
+ title: (source == null ? void 0 : source.title) || ((_c = options.story) == null ? void 0 : _c.title) || options.title || `${range.headRef} against ${range.baseRef}`,
3427
+ windowTitle: source == null ? void 0 : source.windowTitle,
3428
+ sourceUrl: (source == null ? void 0 : source.sourceUrl) || ((_d = options.story) == null ? void 0 : _d.sourceUrl) || options.sourceUrl,
2693
3429
  generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
2694
3430
  range: {
2695
3431
  baseRef: range.baseRef,
@@ -2708,11 +3444,68 @@ function buildChangeTourManifest(startPath, options = {}) {
2708
3444
  commits: range.commits,
2709
3445
  files,
2710
3446
  chapters,
2711
- scenes
3447
+ scenes,
3448
+ ...(source == null ? void 0 : source.review) ? { review: source.review } : {}
2712
3449
  };
3450
+ const authoredModeTours = (source == null ? void 0 : source.version) === 3 ? buildAuthoredModeTours(
3451
+ source,
3452
+ authored,
3453
+ defaultScenes,
3454
+ range.repoRoot,
3455
+ range.mergeBaseOid,
3456
+ range.headOid
3457
+ ) : void 0;
3458
+ if (authoredModeTours) manifest.tours = authoredModeTours;
3459
+ if (manifest.version >= 2) {
3460
+ manifest.repository = { root: range.repoRoot };
3461
+ const revisions = [];
3462
+ for (const scene of scenes) {
3463
+ let realStack;
3464
+ if (scene.kind === "stacked-diff") realStack = scene;
3465
+ if (scene.kind === "deconstructed-diff") {
3466
+ const authoredScene = source == null ? void 0 : source.chapters.flatMap((chapter) => chapter.scenes).find((candidate) => candidate.id === scene.id);
3467
+ if ((authoredScene == null ? void 0 : authoredScene.kind) !== "deconstructed-diff" || !authoredScene.stack) {
3468
+ throw new Error(`Deconstructed scene ${scene.id} requires an explicit real revision stack in v2+.`);
3469
+ }
3470
+ realStack = buildStackedScene(range.repoRoot, {
3471
+ ...authoredScene,
3472
+ kind: "stacked-diff",
3473
+ overview: compileSceneOverview(authoredScene.overview, false),
3474
+ stack: authoredScene.stack,
3475
+ files: scene.files.map((file) => file.path),
3476
+ steps: []
3477
+ }, files.map((file) => file.path));
3478
+ if (realStack.stack[0].oid !== scene.realRange.baseOid || realStack.stack[realStack.stack.length - 1].oid !== scene.realRange.targetOid) {
3479
+ throw new Error(`Deconstructed scene ${scene.id} real stack endpoints must match its explanation range.`);
3480
+ }
3481
+ }
3482
+ if (realStack) {
3483
+ if (realStack.stack[0].oid !== range.mergeBaseOid || realStack.stack[realStack.stack.length - 1].oid !== range.headOid) {
3484
+ throw new Error(`Scene ${scene.id} real stack endpoints must match the tour's final diff range.`);
3485
+ }
3486
+ revisions.push(realStack);
3487
+ }
3488
+ }
3489
+ const explanation = scenes.some((scene) => scene.kind === "deconstructed-diff");
3490
+ const distinctRevisions = revisions.some((scene) => scene.stack.length > 2);
3491
+ manifest.zoom = {
3492
+ authoredDepth: explanation ? "explanation" : distinctRevisions ? "revisions" : "final",
3493
+ modes: [
3494
+ ...explanation ? ["explanation"] : [],
3495
+ ...distinctRevisions ? ["revisions"] : [],
3496
+ "final",
3497
+ "history"
3498
+ ],
3499
+ revisions,
3500
+ final: { scenes: finalScenes, chapters: finalChapters }
3501
+ };
3502
+ }
2713
3503
  return parseChangeTourManifest(manifest);
2714
3504
  }
2715
3505
  function applySource(source, defaultScenes, repoRoot) {
3506
+ return applySourceChapters(source, source.chapters, defaultScenes, repoRoot);
3507
+ }
3508
+ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot) {
2716
3509
  var _a, _b;
2717
3510
  const available = new Map(defaultScenes.map((scene) => [scene.path, scene]));
2718
3511
  const resolvedAnchors = /* @__PURE__ */ new Map();
@@ -2732,8 +3525,11 @@ function applySource(source, defaultScenes, repoRoot) {
2732
3525
  }]));
2733
3526
  const scenes = [];
2734
3527
  const chapters = [];
2735
- for (const chapter of source.chapters) {
3528
+ const finalScenes = [];
3529
+ const finalChapters = [];
3530
+ for (const chapter of chaptersToCompile) {
2736
3531
  const sceneIds = [];
3532
+ const finalSceneIds = [];
2737
3533
  for (const authoredScene of chapter.scenes) {
2738
3534
  if (authoredScene.kind === "deconstructed-diff") {
2739
3535
  const deconstructedScene = buildDeconstructedManifestScene(
@@ -2745,6 +3541,16 @@ function applySource(source, defaultScenes, repoRoot) {
2745
3541
  );
2746
3542
  scenes.push(deconstructedScene);
2747
3543
  sceneIds.push(deconstructedScene.id);
3544
+ if (authoredScene.steps) {
3545
+ const finalScene = buildWalkthroughManifestScene(
3546
+ { ...authoredScene, steps: authoredScene.steps },
3547
+ available,
3548
+ resolvedAnchors,
3549
+ connections
3550
+ );
3551
+ finalScenes.push(finalScene);
3552
+ finalSceneIds.push(finalScene.id);
3553
+ }
2748
3554
  continue;
2749
3555
  }
2750
3556
  if (authoredScene.kind === "stacked-diff") {
@@ -2755,37 +3561,105 @@ function applySource(source, defaultScenes, repoRoot) {
2755
3561
  );
2756
3562
  scenes.push(stackedScene);
2757
3563
  sceneIds.push(stackedScene.id);
3564
+ for (const [index, file] of stackedScene.files.entries()) {
3565
+ const endpoint = available.get(file.path);
3566
+ if (!endpoint) continue;
3567
+ const finalScene = { ...endpoint, id: `${stackedScene.id}-final-${index + 1}`, title: file.path };
3568
+ finalScenes.push(finalScene);
3569
+ finalSceneIds.push(finalScene.id);
3570
+ }
2758
3571
  continue;
2759
3572
  }
2760
- const scene = {
2761
- id: authoredScene.id,
2762
- kind: "walkthrough",
2763
- title: authoredScene.title,
2764
- summary: authoredScene.summary,
2765
- bullets: authoredScene.bullets,
2766
- tags: authoredScene.tags,
2767
- takeaway: authoredScene.takeaway,
2768
- steps: authoredScene.steps.map((step) => {
2769
- const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
2770
- const diff2 = available.get(focus.path);
2771
- if (!diff2) throw new Error(`Step ${step.id} has no text diff for ${focus.path}.`);
2772
- return {
2773
- id: step.id,
2774
- title: step.title,
2775
- body: step.body,
2776
- depth: step.depth,
2777
- focus,
2778
- connection: step.connection ? connections.get(step.connection) : void 0,
2779
- diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
2780
- };
2781
- })
2782
- };
3573
+ const scene = buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections);
2783
3574
  scenes.push(scene);
2784
3575
  sceneIds.push(scene.id);
3576
+ finalScenes.push(scene);
3577
+ finalSceneIds.push(scene.id);
2785
3578
  }
2786
3579
  chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3580
+ finalChapters.push({ id: chapter.id, title: chapter.title, sceneIds: finalSceneIds });
3581
+ }
3582
+ return { scenes, chapters, finalScenes, finalChapters };
3583
+ }
3584
+ function buildAuthoredModeTours(source, root, defaultScenes, repoRoot, mergeBaseOid, headOid) {
3585
+ var _a, _b;
3586
+ const tours = {};
3587
+ if ((_a = source.tours) == null ? void 0 : _a.historical) {
3588
+ const compiled = applySourceChapters(source, source.tours.historical.chapters, defaultScenes, repoRoot);
3589
+ validateHistoricalStackEndpoints(compiled.scenes, mergeBaseOid, headOid);
3590
+ tours.historical = { chapters: compiled.chapters, scenes: compiled.scenes };
3591
+ } else if (source.version === 3) {
3592
+ const fallback = buildHistoricalFallback(source.chapters, root);
3593
+ if (fallback) tours.historical = fallback;
3594
+ }
3595
+ if ((_b = source.tours) == null ? void 0 : _b.deconstructed) {
3596
+ const compiled = applySourceChapters(source, source.tours.deconstructed.chapters, defaultScenes, repoRoot);
3597
+ tours.deconstructed = { chapters: compiled.chapters, scenes: compiled.scenes };
3598
+ } else if (source.version === 3 && source.chapters.some((chapter) => chapter.scenes.some((scene) => scene.kind === "deconstructed-diff"))) {
3599
+ tours.deconstructed = { chapters: root.chapters, scenes: root.scenes };
3600
+ }
3601
+ return tours.historical || tours.deconstructed ? tours : void 0;
3602
+ }
3603
+ function validateHistoricalStackEndpoints(scenes, mergeBaseOid, headOid) {
3604
+ for (const scene of scenes) {
3605
+ if (scene.kind !== "stacked-diff") continue;
3606
+ if (scene.stack[0].oid !== mergeBaseOid || scene.stack[scene.stack.length - 1].oid !== headOid) {
3607
+ throw new Error(`Historical scene ${scene.id} real stack endpoints must match the tour's final diff range.`);
3608
+ }
3609
+ }
3610
+ }
3611
+ function buildHistoricalFallback(sourceChapters, root) {
3612
+ const regularScenes = new Map(root.scenes.map((scene) => [scene.id, scene]));
3613
+ const finalScenes = new Map(root.finalScenes.map((scene) => [scene.id, scene]));
3614
+ const scenes = [];
3615
+ const chapters = [];
3616
+ for (const chapter of sourceChapters) {
3617
+ const sceneIds = [];
3618
+ for (const authoredScene of chapter.scenes) {
3619
+ const scene = authoredScene.kind === "deconstructed-diff" ? authoredScene.steps ? finalScenes.get(authoredScene.id) : void 0 : regularScenes.get(authoredScene.id);
3620
+ if (!scene) continue;
3621
+ scenes.push(scene);
3622
+ sceneIds.push(scene.id);
3623
+ }
3624
+ if (sceneIds.length > 0) chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
2787
3625
  }
2788
- return { scenes, chapters };
3626
+ return scenes.length > 0 ? { chapters, scenes } : void 0;
3627
+ }
3628
+ function compileSceneOverview(overview, includeComparison = true) {
3629
+ if (!overview) return void 0;
3630
+ return {
3631
+ kind: overview.kind,
3632
+ ...overview.path === void 0 ? {} : { path: overview.path },
3633
+ ...includeComparison && overview.comparison ? { comparison: { ...overview.comparison } } : {}
3634
+ };
3635
+ }
3636
+ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections) {
3637
+ const overview = compileSceneOverview(authoredScene.overview, false);
3638
+ return {
3639
+ id: authoredScene.id,
3640
+ kind: "walkthrough",
3641
+ title: authoredScene.title,
3642
+ summary: authoredScene.summary,
3643
+ bullets: authoredScene.bullets,
3644
+ tags: authoredScene.tags,
3645
+ takeaway: authoredScene.takeaway,
3646
+ ...overview ? { overview } : {},
3647
+ steps: authoredScene.steps.map((step) => {
3648
+ const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
3649
+ const diff2 = available.get(focus.path);
3650
+ if (!diff2) throw new Error(`Step ${step.id} has no text diff for ${focus.path}.`);
3651
+ return {
3652
+ id: step.id,
3653
+ title: step.title,
3654
+ body: step.body,
3655
+ depth: step.depth,
3656
+ requirement: step.requirement,
3657
+ focus,
3658
+ connection: step.connection ? connections.get(step.connection) : void 0,
3659
+ diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
3660
+ };
3661
+ })
3662
+ };
2789
3663
  }
2790
3664
  function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, availableFilePaths = []) {
2791
3665
  const compiled = buildDeconstructedScene(repoRoot, source, { baseRef, headRef: targetRef });
@@ -2844,6 +3718,7 @@ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, a
2844
3718
  });
2845
3719
  if (files.length === 0) throw new Error(`Deconstructed scene ${source.id} has no staged files.`);
2846
3720
  const steps = buildDeconstructedFocusSteps(source.id, compiled.stages, files);
3721
+ const overview = compileSceneOverview(source.overview);
2847
3722
  return {
2848
3723
  id: source.id,
2849
3724
  kind: "deconstructed-diff",
@@ -2852,6 +3727,7 @@ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, a
2852
3727
  bullets: source.bullets,
2853
3728
  tags: source.tags,
2854
3729
  takeaway: source.takeaway,
3730
+ ...overview ? { overview } : {},
2855
3731
  stageLabel: "Explanation stages",
2856
3732
  realRange: {
2857
3733
  baseRef: baseRef || compiled.baseOid,
@@ -2951,7 +3827,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2951
3827
  let totalContentBytes = 0;
2952
3828
  const files = filePaths.flatMap((filePath) => {
2953
3829
  const aliases = /* @__PURE__ */ new Set([filePath]);
2954
- for (const changes of adjacentChanges) {
3830
+ for (const changes of [...adjacentChanges].reverse()) {
2955
3831
  for (const change of changes) {
2956
3832
  if (aliases.has(change.path) || change.previousPath && aliases.has(change.previousPath)) {
2957
3833
  aliases.add(change.path);
@@ -2962,7 +3838,9 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2962
3838
  let fileContentBytes = 0;
2963
3839
  const panels = [];
2964
3840
  for (const entry of stack) {
2965
- const resolvedPath = [...aliases].find((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
3841
+ const candidates = [...aliases].filter((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
3842
+ if (candidates.length > 1) throw new Error(`Ambiguous revision paths for ${filePath} at ${entry.ref}: ${candidates.join(", ")}`);
3843
+ const resolvedPath = candidates[0];
2966
3844
  if (!resolvedPath) {
2967
3845
  panels.push({ id: entry.id, label: `${entry.label} / ${filePath} (missing)`, content: "", exists: false });
2968
3846
  continue;
@@ -2989,7 +3867,9 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2989
3867
  }
2990
3868
  return [{ path: filePath, panels }];
2991
3869
  });
3870
+ if (files.length === 0) throw new Error(`Stacked scene ${source.id} has no materializable text files.`);
2992
3871
  const stackIds = stack.map((entry) => entry.id);
3872
+ const overview = compileSceneOverview(source.overview);
2993
3873
  return {
2994
3874
  id: source.id,
2995
3875
  kind: "stacked-diff",
@@ -2998,9 +3878,17 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2998
3878
  bullets: source.bullets,
2999
3879
  tags: source.tags,
3000
3880
  takeaway: source.takeaway,
3881
+ ...overview ? { overview } : {},
3001
3882
  stack,
3002
3883
  files,
3003
- steps: source.steps.map((step) => {
3884
+ steps: source.steps.length === 0 ? stack.slice(0, -1).map((_, pairIndex) => ({
3885
+ id: `${source.id}-revision-${pairIndex}`,
3886
+ title: "",
3887
+ body: "",
3888
+ file: files[0].path,
3889
+ pairIndex,
3890
+ side: "right"
3891
+ })) : source.steps.map((step) => {
3004
3892
  var _a, _b;
3005
3893
  return {
3006
3894
  id: step.id,
@@ -3112,21 +4000,51 @@ function buildChapters(records, sceneIdByPath) {
3112
4000
  }
3113
4001
  function chapterForPath(filePath) {
3114
4002
  const lower = filePath.toLowerCase();
3115
- const fileName = lower.split("/").pop() || lower;
3116
- if (lower.startsWith("docs/") || fileName === "readme.md" || fileName.includes("architecture")) {
4003
+ const role = classifyChangeFileRole(filePath);
4004
+ if (role === "documentation" || lower.includes("architecture")) {
3117
4005
  return CHAPTERS.context;
3118
4006
  }
3119
- if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower)) {
4007
+ if (role === "test") {
3120
4008
  return CHAPTERS.proof;
3121
4009
  }
3122
4010
  if (/(^|\/)(models?|schema|types?|contracts?)\.[^/]+$/.test(lower)) {
3123
4011
  return CHAPTERS.contracts;
3124
4012
  }
3125
- if (/(^|\/)(package-lock\.json|.*\.lock|pyproject\.toml|package\.json)$/.test(lower)) {
4013
+ if (role === "dependency" || role === "generated") {
3126
4014
  return CHAPTERS.packaging;
3127
4015
  }
3128
4016
  return CHAPTERS.behavior;
3129
4017
  }
4018
+ function buildGeneratedTourLanding(scenes, chapters, attention, summary) {
4019
+ const entry = attention.entryPath ? `Start here: ${attention.entryPath} \u2014 ${attention.entryReason}` : `No single entry-point \u2014 ${attention.entryReason}`;
4020
+ const focus = attention.focusPaths.length > 0 ? `Pay attention: ${attention.focusPaths.join(", ")}` : "Pay attention: no additional production files were identified.";
4021
+ const skipped = attention.backgroundPaths.length + attention.mechanicalPaths.length;
4022
+ const overview = {
4023
+ id: "change-overview",
4024
+ kind: "discussion",
4025
+ title: "Change overview",
4026
+ summary: `${summary.changedFiles} files (${summary.changedFiles - summary.binaryFiles} text, ${summary.binaryFiles} binary) \xB7 +${summary.additions} \u2212${summary.deletions} \xB7 ${summary.commitCount} commits`,
4027
+ bullets: [entry, focus, `Usually skip on a first pass: ${skipped} file${skipped === 1 ? "" : "s"}. All files remain available.`],
4028
+ tags: ["start here", "first pass"],
4029
+ takeaway: attention.entryPath ? `Next: open ${attention.entryPath}.` : "Next: open the first available textual change."
4030
+ };
4031
+ return {
4032
+ scenes: [overview, ...scenes],
4033
+ chapters: [
4034
+ { id: "start-here", title: "Start here", sceneIds: [overview.id] },
4035
+ ...chapters
4036
+ ]
4037
+ };
4038
+ }
4039
+ function findDeclarationNames(content) {
4040
+ const names = /* @__PURE__ */ new Set();
4041
+ for (const line of content.split("\n")) {
4042
+ 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*)/);
4043
+ if (match) names.add(match[1]);
4044
+ if (names.size >= 25) break;
4045
+ }
4046
+ return [...names];
4047
+ }
3130
4048
  function buildSceneNote(kind, additions, deletions, previousPath) {
3131
4049
  const label = formatChangeKind(kind);
3132
4050
  const rename = previousPath ? ` from ${previousPath}` : "";
@@ -3189,15 +4107,19 @@ function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
3189
4107
  const [additionsText = "0", deletionsText = "0"] = output.trim().split(/\s+/, 3);
3190
4108
  return {
3191
4109
  additions: Number.parseInt(additionsText, 10) || 0,
3192
- deletions: Number.parseInt(deletionsText, 10) || 0
4110
+ deletions: Number.parseInt(deletionsText, 10) || 0,
4111
+ binary: additionsText === "-" || deletionsText === "-"
3193
4112
  };
3194
4113
  }
3195
4114
  // Annotate the CommonJS export names for ESM import in node:
3196
4115
  0 && (module.exports = {
4116
+ CHANGE_ATTENTION_VERSION,
4117
+ buildChangeAttention,
3197
4118
  buildChangeInventory,
3198
4119
  buildChangeTourContext,
3199
4120
  buildChangeTourManifest,
3200
4121
  buildDeconstructedScene,
4122
+ classifyChangeFileRole,
3201
4123
  compileDeconstructedScene,
3202
4124
  materializeChangeUnits,
3203
4125
  parseChangeTourManifest,