@davmash/bygone 0.8.13 → 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,330 @@ 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 = 2;
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 !== CHANGE_TOUR_SOURCE_VERSION) {
246
+ throw new Error("Unsupported or missing change-tour source version.");
247
+ }
248
+ requireOnlyKeys(value, ["version", "title", "windowTitle", "sourceUrl", "range", "anchors", "connections", "chapters", "coverage"], "source");
249
+ optionalString(value.title, "title");
250
+ optionalString(value.windowTitle, "windowTitle");
251
+ optionalString(value.sourceUrl, "sourceUrl");
252
+ if (value.range !== void 0) {
253
+ if (!isRecord(value.range)) throw new Error("range must be an object.");
254
+ requireOnlyKeys(value.range, ["base", "head"], "range");
255
+ requireString(value.range.base, "range.base");
256
+ requireString(value.range.head, "range.head");
257
+ }
258
+ if (value.coverage !== void 0) {
259
+ if (!isRecord(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
260
+ throw new Error("coverage must contain an exclusions array.");
261
+ }
262
+ requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
263
+ for (const [index, exclusion] of value.coverage.exclusions.entries()) {
264
+ const exclusionPath = `coverage.exclusions[${index}]`;
265
+ if (!isRecord(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
266
+ requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
267
+ requireString(exclusion.path, `${exclusionPath}.path`);
268
+ requireString(exclusion.reason, `${exclusionPath}.reason`);
269
+ if (exclusion.hunks !== void 0) requireStringArray(exclusion.hunks, `${exclusionPath}.hunks`);
270
+ }
271
+ }
272
+ if (!isRecord(value.anchors)) {
273
+ throw new Error("anchors must be an object.");
274
+ }
275
+ for (const [id, anchor] of Object.entries(value.anchors)) {
276
+ if (!isRecord(anchor)) {
277
+ throw new Error(`anchors.${id} must be an object.`);
278
+ }
279
+ requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
280
+ requireString(anchor.file, `anchors.${id}.file`);
281
+ if (anchor.revision !== "base" && anchor.revision !== "head") {
282
+ throw new Error(`anchors.${id}.revision must be base or head.`);
283
+ }
284
+ requireString(anchor.contains, `anchors.${id}.contains`);
285
+ if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
286
+ throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
287
+ }
288
+ }
289
+ const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord(connection) ? { id, ...connection } : connection) : null;
290
+ if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
291
+ throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
292
+ }
293
+ const connectionIds = /* @__PURE__ */ new Set();
294
+ for (const [index, connection] of rawConnections.entries()) {
295
+ if (!isRecord(connection)) {
296
+ throw new Error(`connections[${index}] must be an object.`);
297
+ }
298
+ requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
299
+ requireString(connection.id, `connections[${index}].id`);
300
+ requireString(connection.from, `connections[${index}].from`);
301
+ requireString(connection.to, `connections[${index}].to`);
302
+ requireString(connection.label, `connections[${index}].label`);
303
+ const { id, from, to } = connection;
304
+ if (!value.anchors[from] || !value.anchors[to]) {
305
+ throw new Error(`Connection ${id} references an unknown anchor.`);
306
+ }
307
+ if (connectionIds.has(id)) {
308
+ throw new Error(`Duplicate connection id: ${id}`);
309
+ }
310
+ connectionIds.add(id);
311
+ }
312
+ const chapterIds = /* @__PURE__ */ new Set();
313
+ const sceneIds = /* @__PURE__ */ new Set();
314
+ for (const [chapterIndex, chapter] of value.chapters.entries()) {
315
+ if (!isRecord(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
316
+ throw new Error(`chapters[${chapterIndex}] must contain a non-empty scenes array.`);
317
+ }
318
+ requireString(chapter.id, `chapters[${chapterIndex}].id`);
319
+ requireString(chapter.title, `chapters[${chapterIndex}].title`);
320
+ requireOnlyKeys(chapter, ["id", "title", "scenes"], `chapters[${chapterIndex}]`);
321
+ if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
322
+ chapterIds.add(chapter.id);
323
+ for (const [sceneIndex, scene] of chapter.scenes.entries()) {
324
+ const path = `chapters[${chapterIndex}].scenes[${sceneIndex}]`;
325
+ if (!isRecord(scene)) throw new Error(`${path} must be an object.`);
326
+ requireString(scene.id, `${path}.id`);
327
+ requireString(scene.title, `${path}.title`);
328
+ if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
329
+ sceneIds.add(scene.id);
330
+ validateNarrative(scene, path);
331
+ if (scene.kind === "deconstructed-diff") {
332
+ validateDeconstructedScene(scene, path);
333
+ if (value.version === 2 || scene.stack !== void 0) validateRealStack(scene.stack, `${path}.stack`, 2);
334
+ if (value.version === 2 || scene.steps !== void 0) {
335
+ validateWalkthroughSteps(scene, path, value.anchors, connectionIds);
336
+ }
337
+ continue;
338
+ }
339
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
340
+ throw new Error(`${path} must contain a non-empty steps array.`);
341
+ }
342
+ if (scene.kind === "stacked-diff") {
343
+ validateStackedScene(scene, path, value.version === 2 ? 2 : 3);
344
+ continue;
345
+ }
346
+ if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
347
+ throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
348
+ }
349
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "steps"], path);
350
+ validateWalkthroughSteps(scene, path, value.anchors, connectionIds);
351
+ }
352
+ }
353
+ return { ...value, connections: rawConnections };
354
+ }
355
+ function validateDeconstructedScene(scene, path) {
356
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "base", "target", "stack", "steps", "stages", "exclusions"], path);
357
+ optionalString(scene.base, `${path}.base`);
358
+ optionalString(scene.target, `${path}.target`);
359
+ if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
360
+ throw new Error(`${path}.stages must contain between 1 and 12 stages.`);
361
+ }
362
+ const stageIds = /* @__PURE__ */ new Set();
363
+ for (const [stageIndex, stage] of scene.stages.entries()) {
364
+ const stagePath = `${path}.stages[${stageIndex}]`;
365
+ if (!isRecord(stage)) throw new Error(`${stagePath} must be an object.`);
366
+ requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
367
+ requireString(stage.id, `${stagePath}.id`);
368
+ requireString(stage.title, `${stagePath}.title`);
369
+ requireString(stage.narration, `${stagePath}.narration`);
370
+ if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
371
+ throw new Error(`${stagePath}.changes must be a non-empty array.`);
372
+ }
373
+ if (stageIds.has(stage.id)) throw new Error(`Duplicate deconstructed stage id: ${stage.id}`);
374
+ stageIds.add(stage.id);
375
+ stage.changes.forEach((change, changeIndex) => validateDeconstructedSelection(
376
+ change,
377
+ `${stagePath}.changes[${changeIndex}]`,
378
+ false
379
+ ));
380
+ }
381
+ if (scene.exclusions !== void 0) {
382
+ if (!Array.isArray(scene.exclusions)) throw new Error(`${path}.exclusions must be an array.`);
383
+ scene.exclusions.forEach((exclusion, exclusionIndex) => validateDeconstructedSelection(
384
+ exclusion,
385
+ `${path}.exclusions[${exclusionIndex}]`,
386
+ true
387
+ ));
388
+ }
389
+ }
390
+ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
391
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
392
+ throw new Error(`${path} must contain a non-empty steps array.`);
393
+ }
394
+ const stepIds = /* @__PURE__ */ new Set();
395
+ for (const [stepIndex, step] of scene.steps.entries()) {
396
+ const stepPath = `${path}.steps[${stepIndex}]`;
397
+ if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
398
+ requireString(step.id, `${stepPath}.id`);
399
+ requireString(step.title, `${stepPath}.title`);
400
+ requireString(step.body, `${stepPath}.body`);
401
+ requireString(step.focus, `${stepPath}.focus`);
402
+ requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth", "requirement"], stepPath);
403
+ if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
404
+ throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
405
+ }
406
+ validateStepRequirement(step.requirement, `${stepPath}.requirement`);
407
+ if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
408
+ stepIds.add(step.id);
409
+ if (!anchors[step.focus]) throw new Error(`${stepPath} references unknown anchor ${step.focus}.`);
410
+ optionalString(step.connection, `${stepPath}.connection`);
411
+ if (typeof step.connection === "string" && !connectionIds.has(step.connection)) {
412
+ throw new Error(`${stepPath} references unknown connection ${step.connection}.`);
413
+ }
414
+ }
415
+ }
416
+ function validateDeconstructedSelection(value, path, requiresReason) {
417
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
418
+ requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
419
+ requireString(value.file, `${path}.file`);
420
+ if (!requiresReason || value.hunks !== void 0) {
421
+ requireStringArray(value.hunks, `${path}.hunks`);
422
+ if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
423
+ }
424
+ if (requiresReason) requireString(value.reason, `${path}.reason`);
425
+ }
426
+ function validateRealStack(stack, path, minimum) {
427
+ if (!Array.isArray(stack) || stack.length < minimum || stack.length > 6) {
428
+ throw new Error(`${path} must contain between ${minimum} and 6 explicit real revisions.`);
429
+ }
430
+ const ids = /* @__PURE__ */ new Set();
431
+ for (const [index, entry] of stack.entries()) {
432
+ if (!isRecord(entry)) throw new Error(`${path}[${index}] must be an object.`);
433
+ requireOnlyKeys(entry, ["id", "ref", "label"], `${path}[${index}]`);
434
+ requireString(entry.id, `${path}[${index}].id`);
435
+ requireString(entry.ref, `${path}[${index}].ref`);
436
+ optionalString(entry.label, `${path}[${index}].label`);
437
+ if (ids.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
438
+ ids.add(entry.id);
439
+ }
440
+ }
441
+ function validateStackedScene(scene, path, minimum = 3) {
442
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "stack", "files", "steps"], path);
443
+ if (!Array.isArray(scene.stack) || scene.stack.length < minimum || scene.stack.length > 6) {
444
+ throw new Error(`${path}.stack must contain between ${minimum} and 6 revisions.`);
445
+ }
446
+ const stackIds = /* @__PURE__ */ new Set();
447
+ for (const [index, entry] of scene.stack.entries()) {
448
+ const entryPath = `${path}.stack[${index}]`;
449
+ if (!isRecord(entry)) throw new Error(`${entryPath} must be an object.`);
450
+ requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
451
+ requireString(entry.id, `${entryPath}.id`);
452
+ requireString(entry.ref, `${entryPath}.ref`);
453
+ optionalString(entry.label, `${entryPath}.label`);
454
+ if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
455
+ stackIds.add(entry.id);
456
+ }
457
+ if (scene.files !== void 0) requireStringArray(scene.files, `${path}.files`);
458
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
459
+ const stepIds = /* @__PURE__ */ new Set();
460
+ for (const [index, step] of scene.steps.entries()) {
461
+ const stepPath = `${path}.steps[${index}]`;
462
+ if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
463
+ requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
464
+ requireString(step.id, `${stepPath}.id`);
465
+ requireString(step.title, `${stepPath}.title`);
466
+ requireString(step.body, `${stepPath}.body`);
467
+ requireString(step.file, `${stepPath}.file`);
468
+ const stepId = step.id;
469
+ if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
470
+ stepIds.add(stepId);
471
+ if (!Array.isArray(step.pair) || step.pair.length !== 2 || step.pair.some((id) => typeof id !== "string")) {
472
+ throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
473
+ }
474
+ const pair = step.pair;
475
+ const leftIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[0]);
476
+ const rightIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[1]);
477
+ if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
478
+ if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
479
+ 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]))) {
480
+ throw new Error(`${stepPath}.lines must contain an ordered positive line range.`);
481
+ }
482
+ }
483
+ }
484
+ function validateStepRequirement(value, path) {
485
+ if (value === void 0) return;
486
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
487
+ requireOnlyKeys(value, ["id", "text", "status", "source", "confidence"], path);
488
+ requireString(value.id, `${path}.id`);
489
+ requireString(value.text, `${path}.text`);
490
+ if (value.status !== void 0 && !["fulfilled", "gap"].includes(String(value.status))) {
491
+ throw new Error(`${path}.status must be fulfilled or gap.`);
492
+ }
493
+ optionalString(value.source, `${path}.source`);
494
+ if (value.confidence !== void 0 && !["high", "medium", "low"].includes(String(value.confidence))) {
495
+ throw new Error(`${path}.confidence must be high, medium, or low.`);
496
+ }
497
+ }
498
+ function validateNarrative(value, path) {
499
+ requireString(value.summary, `${path}.summary`);
500
+ requireStringArray(value.bullets, `${path}.bullets`);
501
+ requireStringArray(value.tags, `${path}.tags`);
502
+ requireString(value.takeaway, `${path}.takeaway`);
503
+ }
504
+ function isRecord(value) {
505
+ return typeof value === "object" && value !== null && !Array.isArray(value);
506
+ }
507
+ function requireString(value, path) {
508
+ if (typeof value !== "string" || value.length === 0) {
509
+ throw new Error(`${path} must be a non-empty string.`);
510
+ }
511
+ }
512
+ function optionalString(value, path) {
513
+ if (value !== void 0) {
514
+ requireString(value, path);
515
+ }
516
+ }
517
+ function requireStringArray(value, path) {
518
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
519
+ throw new Error(`${path} must be an array of strings.`);
520
+ }
521
+ }
522
+ function requireOnlyKeys(value, allowed, path) {
523
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
524
+ if (unexpected.length > 0) {
525
+ throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
526
+ }
527
+ }
528
+
234
529
  // src/changeTourManifest.ts
235
- var CHANGE_TOUR_MANIFEST_VERSION = 1;
530
+ var CHANGE_TOUR_MANIFEST_VERSION = 2;
236
531
  function parseChangeTourManifest(value) {
237
- if (!isRecord(value) || value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
532
+ if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
533
+ throw new Error(
534
+ `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.`
535
+ );
536
+ }
537
+ if (!isRecord2(value) || value.version !== 1 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
238
538
  throw new Error(`Unsupported or missing change-tour manifest version.`);
239
539
  }
240
- requireString(value.title, "title");
241
- requireString(value.generatedAt, "generatedAt");
540
+ requireString2(value.title, "title");
541
+ requireString2(value.generatedAt, "generatedAt");
242
542
  if (value.windowTitle !== void 0) {
243
- requireString(value.windowTitle, "windowTitle");
543
+ requireString2(value.windowTitle, "windowTitle");
244
544
  }
245
545
  if (value.sourceUrl !== void 0) {
246
- requireString(value.sourceUrl, "sourceUrl");
546
+ requireString2(value.sourceUrl, "sourceUrl");
247
547
  }
248
- if (!isRecord(value.range)) {
548
+ if (!isRecord2(value.range)) {
249
549
  throw new Error("Change-tour manifest range must be an object.");
250
550
  }
251
551
  for (const key of ["baseRef", "headRef", "mergeBaseOid", "headOid"]) {
252
- requireString(value.range[key], `range.${key}`);
552
+ requireString2(value.range[key], `range.${key}`);
253
553
  }
254
- if (!isRecord(value.summary)) {
554
+ if (!isRecord2(value.summary)) {
255
555
  throw new Error("Change-tour manifest summary must be an object.");
256
556
  }
257
557
  for (const key of ["changedFiles", "includedScenes", "additions", "deletions", "commitCount"]) {
258
558
  requireNonNegativeInteger(value.summary[key], `summary.${key}`);
259
559
  }
260
- requireStringArray(value.summary.omittedFiles, "summary.omittedFiles");
560
+ requireStringArray2(value.summary.omittedFiles, "summary.omittedFiles");
261
561
  if (!Array.isArray(value.commits) || !Array.isArray(value.chapters) || !Array.isArray(value.scenes)) {
262
562
  throw new Error("Change-tour manifest commits, chapters, and scenes must be arrays.");
263
563
  }
@@ -273,12 +573,12 @@ function parseChangeTourManifest(value) {
273
573
  sceneIds.add(candidate.id);
274
574
  }
275
575
  for (const [index, candidate] of value.chapters.entries()) {
276
- if (!isRecord(candidate)) {
576
+ if (!isRecord2(candidate)) {
277
577
  throw new Error(`chapters[${index}] must be an object.`);
278
578
  }
279
- requireString(candidate.id, `chapters[${index}].id`);
280
- requireString(candidate.title, `chapters[${index}].title`);
281
- requireStringArray(candidate.sceneIds, `chapters[${index}].sceneIds`);
579
+ requireString2(candidate.id, `chapters[${index}].id`);
580
+ requireString2(candidate.title, `chapters[${index}].title`);
581
+ requireStringArray2(candidate.sceneIds, `chapters[${index}].sceneIds`);
282
582
  for (const sceneId of candidate.sceneIds) {
283
583
  if (!sceneIds.has(sceneId)) {
284
584
  throw new Error(`Chapter references unknown scene id: ${sceneId}`);
@@ -295,31 +595,63 @@ function parseChangeTourManifest(value) {
295
595
  if (value.files !== void 0 && value.summary.changedFiles !== files.length) {
296
596
  throw new Error("summary.changedFiles must match the number of files.");
297
597
  }
598
+ if (value.authoringCoverage !== void 0) {
599
+ validateAuthoringCoverage(value.authoringCoverage, value.version);
600
+ }
601
+ if (value.version === 2) validateZoom(value);
298
602
  return { ...value, files };
299
603
  }
604
+ function validateAuthoringCoverage(value, version) {
605
+ if (!isRecord2(value) || !isRecord2(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
606
+ throw new Error("authoringCoverage must contain walkthrough and explanationAssignments.");
607
+ }
608
+ const expectedScope = version === 2 ? "final" : "tour";
609
+ if (value.walkthrough.scope !== expectedScope) {
610
+ throw new Error(`authoringCoverage.walkthrough.scope must be ${expectedScope}.`);
611
+ }
612
+ requireNonNegativeInteger(value.walkthrough.coveredUnits, "authoringCoverage.walkthrough.coveredUnits");
613
+ requireNonNegativeInteger(value.walkthrough.includedUnits, "authoringCoverage.walkthrough.includedUnits");
614
+ requirePercentage(value.walkthrough.coveragePercent, "authoringCoverage.walkthrough.coveragePercent");
615
+ if (value.walkthrough.coveredUnits > value.walkthrough.includedUnits) {
616
+ throw new Error("authoringCoverage.walkthrough.coveredUnits must not exceed includedUnits.");
617
+ }
618
+ for (const [index, assignment] of value.explanationAssignments.entries()) {
619
+ const path = `authoringCoverage.explanationAssignments[${index}]`;
620
+ if (!isRecord2(assignment)) throw new Error(`${path} must be an object.`);
621
+ requireString2(assignment.sceneId, `${path}.sceneId`);
622
+ requireNonNegativeInteger(assignment.assignedUnits, `${path}.assignedUnits`);
623
+ requireNonNegativeInteger(assignment.excludedUnits, `${path}.excludedUnits`);
624
+ requireNonNegativeInteger(assignment.completeUnits, `${path}.completeUnits`);
625
+ requireNonNegativeInteger(assignment.totalUnits, `${path}.totalUnits`);
626
+ requirePercentage(assignment.assignmentPercent, `${path}.assignmentPercent`);
627
+ if (assignment.assignedUnits + assignment.excludedUnits !== assignment.completeUnits || assignment.completeUnits > assignment.totalUnits) {
628
+ throw new Error(`${path} contains inconsistent unit counts.`);
629
+ }
630
+ }
631
+ }
300
632
  function parseChangeTourStory(value) {
301
- if (!isRecord(value) || !Array.isArray(value.scenes)) {
633
+ if (!isRecord2(value) || !Array.isArray(value.scenes)) {
302
634
  throw new Error("Change-tour story must contain a scenes array.");
303
635
  }
304
636
  if (value.title !== void 0) {
305
- requireString(value.title, "title");
637
+ requireString2(value.title, "title");
306
638
  }
307
639
  if (value.sourceUrl !== void 0) {
308
- requireString(value.sourceUrl, "sourceUrl");
640
+ requireString2(value.sourceUrl, "sourceUrl");
309
641
  }
310
642
  for (const [index, scene] of value.scenes.entries()) {
311
- if (!isRecord(scene) || scene.kind !== "discussion" && scene.kind !== "file") {
643
+ if (!isRecord2(scene) || scene.kind !== "discussion" && scene.kind !== "file") {
312
644
  throw new Error(`story.scenes[${index}] must be a discussion or file scene.`);
313
645
  }
314
- requireString(scene.chapterId, `story.scenes[${index}].chapterId`);
315
- requireString(scene.chapterTitle, `story.scenes[${index}].chapterTitle`);
316
- validateNarrative(scene, `story.scenes[${index}]`);
646
+ requireString2(scene.chapterId, `story.scenes[${index}].chapterId`);
647
+ requireString2(scene.chapterTitle, `story.scenes[${index}].chapterTitle`);
648
+ validateNarrative2(scene, `story.scenes[${index}]`);
317
649
  if (scene.kind === "discussion") {
318
- requireString(scene.title, `story.scenes[${index}].title`);
650
+ requireString2(scene.title, `story.scenes[${index}].title`);
319
651
  } else {
320
- requireString(scene.path, `story.scenes[${index}].path`);
652
+ requireString2(scene.path, `story.scenes[${index}].path`);
321
653
  if (scene.title !== void 0) {
322
- requireString(scene.title, `story.scenes[${index}].title`);
654
+ requireString2(scene.title, `story.scenes[${index}].title`);
323
655
  }
324
656
  if (scene.focusChangeIndex !== void 0) {
325
657
  requireNonNegativeInteger(scene.focusChangeIndex, `story.scenes[${index}].focusChangeIndex`);
@@ -328,14 +660,74 @@ function parseChangeTourStory(value) {
328
660
  }
329
661
  return value;
330
662
  }
663
+ function validateZoom(value) {
664
+ if (!isRecord2(value.repository) || typeof value.repository.root !== "string" || !value.repository.root) {
665
+ throw new Error("A v2 tour requires its originating repository.root.");
666
+ }
667
+ if (!isRecord2(value.zoom) || !Array.isArray(value.zoom.revisions) || !isRecord2(value.zoom.final)) {
668
+ throw new Error("A v2 tour requires zoom metadata and real revision evidence.");
669
+ }
670
+ const scenes = value.scenes;
671
+ const revisions = value.zoom.revisions;
672
+ const ids = /* @__PURE__ */ new Set();
673
+ for (const [index, scene] of revisions.entries()) {
674
+ validateScene(scene, index);
675
+ if (scene.kind !== "stacked-diff") throw new Error("zoom.revisions must contain real stacked-diff scenes.");
676
+ if (ids.has(scene.id)) throw new Error(`Duplicate zoom revision scene: ${scene.id}`);
677
+ ids.add(scene.id);
678
+ const range = value.range;
679
+ if (scene.stack[0].oid !== range.mergeBaseOid || scene.stack[scene.stack.length - 1].oid !== range.headOid) {
680
+ throw new Error(`Revision scene ${scene.id} endpoints must match the final diff range.`);
681
+ }
682
+ }
683
+ for (const scene of scenes) {
684
+ if (scene.kind !== "deconstructed-diff" && scene.kind !== "stacked-diff") continue;
685
+ const real = revisions.find((candidate) => candidate.id === scene.id);
686
+ if (!real) throw new Error(`Scene ${scene.id} is missing its real revision evidence.`);
687
+ if (scene.kind === "deconstructed-diff" && (real.stack[0].oid !== scene.realRange.baseOid || real.stack[real.stack.length - 1].oid !== scene.realRange.targetOid)) {
688
+ throw new Error(`Deconstructed scene ${scene.id} real stack endpoints must match its explanation range.`);
689
+ }
690
+ }
691
+ const explanation = scenes.some((scene) => scene.kind === "deconstructed-diff");
692
+ const distinct = revisions.some((scene) => scene.stack.length > 2);
693
+ const depth = explanation ? "explanation" : distinct ? "revisions" : "final";
694
+ const modes = [...explanation ? ["explanation"] : [], ...distinct ? ["revisions"] : [], "final", "history"];
695
+ if (value.zoom.authoredDepth !== depth || JSON.stringify(value.zoom.modes) !== JSON.stringify(modes)) {
696
+ throw new Error("zoom depth and modes must match the maximum authored depth.");
697
+ }
698
+ if (!Array.isArray(value.files)) throw new Error("A v2 tour requires final endpoint file evidence.");
699
+ if (!Array.isArray(value.zoom.final.scenes) || !Array.isArray(value.zoom.final.chapters)) {
700
+ throw new Error("A v2 tour requires a non-stacked final tour.");
701
+ }
702
+ const finalSceneIds = /* @__PURE__ */ new Set();
703
+ for (const [index, scene] of value.zoom.final.scenes.entries()) {
704
+ validateScene(scene, index);
705
+ if (scene.kind === "stacked-diff" || scene.kind === "deconstructed-diff") {
706
+ throw new Error("zoom.final must contain only non-stacked scenes.");
707
+ }
708
+ if (finalSceneIds.has(scene.id)) throw new Error(`Duplicate final tour scene: ${scene.id}`);
709
+ finalSceneIds.add(scene.id);
710
+ }
711
+ for (const [index, chapter] of value.zoom.final.chapters.entries()) {
712
+ if (!isRecord2(chapter) || !Array.isArray(chapter.sceneIds)) {
713
+ throw new Error(`zoom.final.chapters[${index}] must contain sceneIds.`);
714
+ }
715
+ requireString2(chapter.id, `zoom.final.chapters[${index}].id`);
716
+ requireString2(chapter.title, `zoom.final.chapters[${index}].title`);
717
+ for (const sceneId of chapter.sceneIds) {
718
+ requireString2(sceneId, `zoom.final.chapters[${index}].sceneIds`);
719
+ if (!finalSceneIds.has(sceneId)) throw new Error(`Final tour chapter references unknown scene: ${sceneId}`);
720
+ }
721
+ }
722
+ }
331
723
  function validateScene(value, index) {
332
- if (!isRecord(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
724
+ if (!isRecord2(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
333
725
  throw new Error(`scenes[${index}] must be a text-diff, discussion, walkthrough, stacked-diff, or deconstructed-diff scene.`);
334
726
  }
335
727
  for (const key of ["id", "title"]) {
336
- requireString(value[key], `scenes[${index}].${key}`);
728
+ requireString2(value[key], `scenes[${index}].${key}`);
337
729
  }
338
- validateNarrative(value, `scenes[${index}]`);
730
+ validateNarrative2(value, `scenes[${index}]`);
339
731
  if (value.kind === "discussion") {
340
732
  return;
341
733
  }
@@ -345,20 +737,21 @@ function validateScene(value, index) {
345
737
  }
346
738
  for (const [stepIndex, step] of value.steps.entries()) {
347
739
  const path = `scenes[${index}].steps[${stepIndex}]`;
348
- if (!isRecord(step) || !isRecord(step.focus) || !isRecord(step.diff)) {
740
+ if (!isRecord2(step) || !isRecord2(step.focus) || !isRecord2(step.diff)) {
349
741
  throw new Error(`${path} must contain focus and diff objects.`);
350
742
  }
351
- for (const key of ["id", "title", "body"]) requireString(step[key], `${path}.${key}`);
743
+ for (const key of ["id", "title", "body"]) requireString2(step[key], `${path}.${key}`);
352
744
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
353
745
  throw new Error(`${path}.depth must be mentioned, explained, or contextualized.`);
354
746
  }
747
+ validateStepRequirement(step.requirement, `${path}.requirement`);
355
748
  validateResolvedAnchor(step.focus, `${path}.focus`);
356
749
  validateScene(step.diff, index);
357
750
  if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
358
751
  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`);
752
+ if (!isRecord2(step.connection)) throw new Error(`${path}.connection must be an object.`);
753
+ requireString2(step.connection.id, `${path}.connection.id`);
754
+ requireString2(step.connection.label, `${path}.connection.label`);
362
755
  validateResolvedAnchor(step.connection.from, `${path}.connection.from`);
363
756
  validateResolvedAnchor(step.connection.to, `${path}.connection.to`);
364
757
  }
@@ -366,68 +759,68 @@ function validateScene(value, index) {
366
759
  return;
367
760
  }
368
761
  if (value.kind === "stacked-diff") {
369
- validateStackedScene(value, index);
762
+ validateStackedScene2(value, index);
370
763
  return;
371
764
  }
372
765
  if (value.kind === "deconstructed-diff") {
373
- validateDeconstructedScene(value, index);
766
+ validateDeconstructedScene2(value, index);
374
767
  return;
375
768
  }
376
769
  for (const key of ["path", "leftLabel", "rightLabel", "leftContent", "rightContent"]) {
377
- requireString(value[key], `scenes[${index}].${key}`);
770
+ requireString2(value[key], `scenes[${index}].${key}`);
378
771
  }
379
772
  if (value.previousPath !== void 0) {
380
- requireString(value.previousPath, `scenes[${index}].previousPath`);
773
+ requireString2(value.previousPath, `scenes[${index}].previousPath`);
381
774
  }
382
- requireString(value.changeKind, `scenes[${index}].changeKind`);
775
+ requireString2(value.changeKind, `scenes[${index}].changeKind`);
383
776
  requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
384
777
  requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
385
778
  if (value.focusChangeIndex !== void 0) {
386
779
  requireNonNegativeInteger(value.focusChangeIndex, `scenes[${index}].focusChangeIndex`);
387
780
  }
388
781
  }
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.`);
782
+ function validateStackedScene2(value, index) {
783
+ if (!Array.isArray(value.stack) || value.stack.length < 2 || value.stack.length > 6) {
784
+ throw new Error(`scenes[${index}].stack must contain between 2 and 6 revisions.`);
392
785
  }
393
786
  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}`);
787
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
788
+ for (const key of ["id", "ref", "oid", "label"]) requireString2(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
396
789
  }
397
790
  validateMultiPanelFiles(value.files, value.stack.length, index);
398
791
  validateMultiPanelSteps(value.steps, value.stack.length, index, false);
399
792
  }
400
- function validateDeconstructedScene(value, index) {
793
+ function validateDeconstructedScene2(value, index) {
401
794
  if (value.stageLabel !== "Explanation stages") {
402
795
  throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
403
796
  }
404
- if (!isRecord(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
797
+ if (!isRecord2(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
405
798
  for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
406
- requireString(value.realRange[key], `scenes[${index}].realRange.${key}`);
799
+ requireString2(value.realRange[key], `scenes[${index}].realRange.${key}`);
407
800
  }
408
801
  if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
409
802
  throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
410
803
  }
411
804
  const panels = value.panels;
412
805
  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}`);
806
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
807
+ for (const key of ["id", "label", "role"]) requireString2(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
415
808
  const expectedRole = panelIndex === 0 ? "baseline" : "stage";
416
809
  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`);
810
+ if (panelIndex > 0) requireString2(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
418
811
  }
419
812
  validateMultiPanelFiles(value.files, panels.length, index);
420
813
  validateMultiPanelSteps(value.steps, panels.length, index, true);
421
814
  if (Array.isArray(value.steps)) {
422
815
  let previousPairIndex = -1;
423
816
  value.steps.forEach((step, stepIndex) => {
424
- if (!isRecord(step)) return;
817
+ if (!isRecord2(step)) return;
425
818
  const pairIndex = Number(step.pairIndex);
426
819
  if (pairIndex < previousPairIndex) {
427
820
  throw new Error(`scenes[${index}].steps must remain grouped in explanation stage order.`);
428
821
  }
429
822
  previousPairIndex = pairIndex;
430
- if (step.stageId !== void 0) requireString(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
823
+ if (step.stageId !== void 0) requireString2(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
431
824
  if (step.stageIndex !== void 0) requireNonNegativeInteger(step.stageIndex, `scenes[${index}].steps[${stepIndex}].stageIndex`);
432
825
  if (step.focusIndex !== void 0) requireNonNegativeInteger(step.focusIndex, `scenes[${index}].steps[${stepIndex}].focusIndex`);
433
826
  if (step.focusCount !== void 0) requirePositiveInteger(step.focusCount, `scenes[${index}].steps[${stepIndex}].focusCount`);
@@ -440,14 +833,14 @@ function validateDeconstructedScene(value, index) {
440
833
  });
441
834
  for (let stageIndex = 0; stageIndex < panels.length - 1; stageIndex += 1) {
442
835
  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) {
836
+ const stageSteps = value.steps.filter((step) => isRecord2(step) && Number(step.pairIndex) === stageIndex);
837
+ if (!isRecord2(panel) || stageSteps.length === 0) {
445
838
  throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
446
839
  }
447
840
  const firstStep = stageSteps[0];
448
- const fallbackStageId = isRecord(firstStep) ? firstStep.id : void 0;
841
+ const fallbackStageId = isRecord2(firstStep) ? firstStep.id : void 0;
449
842
  if (stageSteps.some((step) => {
450
- if (!isRecord(step)) return true;
843
+ if (!isRecord2(step)) return true;
451
844
  return (step.stageId ?? fallbackStageId) !== panel.stageId;
452
845
  })) {
453
846
  throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
@@ -458,15 +851,15 @@ function validateDeconstructedScene(value, index) {
458
851
  function validateMultiPanelFiles(value, panelCount, index) {
459
852
  if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
460
853
  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`);
854
+ if (!isRecord2(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
855
+ requireString2(file.path, `scenes[${index}].files[${fileIndex}].path`);
463
856
  if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
464
857
  throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
465
858
  }
466
859
  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`);
860
+ if (!isRecord2(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
861
+ for (const key of ["id", "label", "content"]) requireString2(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
862
+ if (panel.path !== void 0) requireString2(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
470
863
  if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
471
864
  }
472
865
  }
@@ -474,12 +867,12 @@ function validateMultiPanelFiles(value, panelCount, index) {
474
867
  function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
475
868
  if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
476
869
  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}`);
870
+ if (!isRecord2(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
871
+ for (const key of ["id", "title", "body", "file", "side"]) requireString2(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
479
872
  requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
480
873
  if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
481
874
  if (deconstructed) {
482
- requireStringArray(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
875
+ requireStringArray2(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
483
876
  if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
484
877
  }
485
878
  if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
@@ -488,7 +881,7 @@ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
488
881
  }
489
882
  }
490
883
  function validateTourFile(value, index) {
491
- if (!isRecord(value)) throw new Error(`files[${index}] must be an object.`);
884
+ if (!isRecord2(value)) throw new Error(`files[${index}] must be an object.`);
492
885
  if (value.kind === "text-diff") {
493
886
  validateScene(value, index);
494
887
  return;
@@ -497,9 +890,9 @@ function validateTourFile(value, index) {
497
890
  throw new Error(`files[${index}].kind must be text-diff or omitted.`);
498
891
  }
499
892
  for (const key of ["id", "title", "path", "changeKind", "reason"]) {
500
- requireString(value[key], `files[${index}].${key}`);
893
+ requireString2(value[key], `files[${index}].${key}`);
501
894
  }
502
- if (value.previousPath !== void 0) requireString(value.previousPath, `files[${index}].previousPath`);
895
+ if (value.previousPath !== void 0) requireString2(value.previousPath, `files[${index}].previousPath`);
503
896
  requireNonNegativeInteger(value.additions, `files[${index}].additions`);
504
897
  requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
505
898
  }
@@ -519,263 +912,13 @@ function collectLegacyTourFiles(scenes) {
519
912
  return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
520
913
  }
521
914
  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}`);
915
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
916
+ for (const key of ["id", "path", "revision", "excerpt"]) requireString2(value[key], `${path}.${key}`);
524
917
  if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
525
918
  requirePositiveInteger(value.startLine, `${path}.startLine`);
526
919
  requirePositiveInteger(value.endLine, `${path}.endLine`);
527
920
  if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
528
921
  }
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.`);
659
- }
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
- }
686
- }
687
- }
688
- }
689
- return { ...value, connections: rawConnections };
690
- }
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.`);
708
- }
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
- }
725
- }
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.`);
733
- }
734
- if (requiresReason) requireString2(value.reason, `${path}.reason`);
735
- }
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.`);
740
- }
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);
751
- }
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.`);
768
- }
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.`);
776
- }
777
- }
778
- }
779
922
  function validateNarrative2(value, path) {
780
923
  requireString2(value.summary, `${path}.summary`);
781
924
  requireStringArray2(value.bullets, `${path}.bullets`);
@@ -786,13 +929,8 @@ function isRecord2(value) {
786
929
  return typeof value === "object" && value !== null && !Array.isArray(value);
787
930
  }
788
931
  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);
932
+ if (typeof value !== "string") {
933
+ throw new Error(`${path} must be a string.`);
796
934
  }
797
935
  }
798
936
  function requireStringArray2(value, path) {
@@ -800,12 +938,19 @@ function requireStringArray2(value, path) {
800
938
  throw new Error(`${path} must be an array of strings.`);
801
939
  }
802
940
  }
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(", ")}`);
941
+ function requireNonNegativeInteger(value, path) {
942
+ if (!Number.isInteger(value) || Number(value) < 0) {
943
+ throw new Error(`${path} must be a non-negative integer.`);
944
+ }
945
+ }
946
+ function requirePercentage(value, path) {
947
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
948
+ throw new Error(`${path} must be a number from 0 to 100.`);
807
949
  }
808
950
  }
951
+ function requirePositiveInteger(value, path) {
952
+ if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
953
+ }
809
954
 
810
955
  // src/changeInventory.ts
811
956
  var import_crypto = require("crypto");
@@ -1585,12 +1730,20 @@ var DIFF_TIMEOUT_MS = readPositiveIntegerEnv("BYGONE_DIFF_TIMEOUT_MS", DEFAULT_D
1585
1730
  function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1586
1731
  const leftLines = normalizeLines(leftContent);
1587
1732
  const rightLines = normalizeLines(rightContent);
1588
- const changes = diffArrays(leftLines, rightLines, {
1733
+ if (leftContent === rightContent) {
1734
+ return buildIdenticalTwoWayDiffModel(leftLines);
1735
+ }
1736
+ const interned = internDiffLines(leftLines, rightLines);
1737
+ const internedChanges = diffArrays(interned.left, interned.right, {
1589
1738
  timeout: options.timeoutMs ?? DIFF_TIMEOUT_MS
1590
1739
  });
1591
- if (!changes) {
1740
+ if (!internedChanges) {
1592
1741
  return buildConservativeTwoWayDiffModel(leftLines, rightLines);
1593
1742
  }
1743
+ const changes = internedChanges.map((change) => ({
1744
+ ...change,
1745
+ value: change.value.map((lineId) => interned.lines[lineId])
1746
+ }));
1594
1747
  const rows = [];
1595
1748
  const renderedLeftLines = [];
1596
1749
  const renderedRightLines = [];
@@ -1614,29 +1767,18 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1614
1767
  }
1615
1768
  if (change.removed && index + 1 < changes.length && changes[index + 1].added) {
1616
1769
  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));
1770
+ const nextLineNumbers = appendReplacementCandidate(
1771
+ removedLines,
1772
+ nextChange.value,
1773
+ leftLineNumber,
1774
+ rightLineNumber,
1775
+ rows,
1776
+ renderedLeftLines,
1777
+ renderedRightLines,
1778
+ blocks
1779
+ );
1780
+ leftLineNumber = nextLineNumbers.left;
1781
+ rightLineNumber = nextLineNumbers.right;
1640
1782
  index++;
1641
1783
  continue;
1642
1784
  }
@@ -1672,7 +1814,40 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1672
1814
  leftLines: renderedLeftLines,
1673
1815
  rightLines: renderedRightLines,
1674
1816
  blocks,
1675
- hasChanges
1817
+ hasChanges,
1818
+ quality: "exact"
1819
+ };
1820
+ }
1821
+ function internDiffLines(leftLines, rightLines) {
1822
+ const lineIds = /* @__PURE__ */ new Map();
1823
+ const lines = [];
1824
+ const intern = (line) => {
1825
+ const existingId = lineIds.get(line);
1826
+ if (existingId !== void 0) {
1827
+ return existingId;
1828
+ }
1829
+ const lineId = lines.length;
1830
+ lineIds.set(line, lineId);
1831
+ lines.push(line);
1832
+ return lineId;
1833
+ };
1834
+ return {
1835
+ left: leftLines.map(intern),
1836
+ right: rightLines.map(intern),
1837
+ lines
1838
+ };
1839
+ }
1840
+ function buildIdenticalTwoWayDiffModel(lines) {
1841
+ return {
1842
+ rows: lines.map((line, index) => makeDiffRow(
1843
+ makeDiffCell("context", line, index + 1),
1844
+ makeDiffCell("context", line, index + 1)
1845
+ )),
1846
+ leftLines: lines.map((line, index) => makeDiffLine("context", line, index + 1)),
1847
+ rightLines: lines.map((line, index) => makeDiffLine("context", line, index + 1)),
1848
+ blocks: [],
1849
+ hasChanges: false,
1850
+ quality: "exact"
1676
1851
  };
1677
1852
  }
1678
1853
  function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
@@ -1699,7 +1874,8 @@ function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
1699
1874
  leftLines: renderedLeftLines,
1700
1875
  rightLines: renderedRightLines,
1701
1876
  blocks: rows.length === 0 ? [] : [makeDiffBlock(blockKind, 0, leftLines.length, 0, rightLines.length)],
1702
- hasChanges: rows.length > 0
1877
+ hasChanges: rows.length > 0,
1878
+ quality: "fallback"
1703
1879
  };
1704
1880
  }
1705
1881
  function normalizeLines(content) {
@@ -1712,6 +1888,100 @@ function normalizeLines(content) {
1712
1888
  }
1713
1889
  return lines;
1714
1890
  }
1891
+ function appendReplacementCandidate(leftLines, rightLines, initialLeftLineNumber, initialRightLineNumber, rows, renderedLeftLines, renderedRightLines, blocks) {
1892
+ const aligned = alignReplacementLines(leftLines, rightLines);
1893
+ const crediblePairs = aligned.filter((row) => row.left !== void 0 && row.right !== void 0 && hasCredibleLineCorrespondence(row.left, row.right)).length;
1894
+ const coherentBlock = hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs);
1895
+ const normalized = aligned.flatMap((row) => {
1896
+ if (row.left === void 0 || row.right === void 0 || hasCredibleLineCorrespondence(row.left, row.right)) {
1897
+ return [row];
1898
+ }
1899
+ return [{ left: row.left }, { right: row.right }];
1900
+ });
1901
+ let leftLineNumber = initialLeftLineNumber;
1902
+ let rightLineNumber = initialRightLineNumber;
1903
+ let blockKind;
1904
+ let blockLeftStart = renderedLeftLines.length;
1905
+ let blockRightStart = renderedRightLines.length;
1906
+ const flushBlock = () => {
1907
+ if (!blockKind) return;
1908
+ blocks.push(makeDiffBlock(
1909
+ blockKind,
1910
+ blockLeftStart,
1911
+ renderedLeftLines.length,
1912
+ blockRightStart,
1913
+ renderedRightLines.length
1914
+ ));
1915
+ };
1916
+ for (const { left, right } of normalized) {
1917
+ const nextKind = coherentBlock ? "replace" : left !== void 0 && right !== void 0 ? "replace" : left !== void 0 ? "delete" : "insert";
1918
+ if (blockKind !== nextKind) {
1919
+ flushBlock();
1920
+ blockKind = nextKind;
1921
+ blockLeftStart = renderedLeftLines.length;
1922
+ blockRightStart = renderedRightLines.length;
1923
+ }
1924
+ let renderedLeftLine;
1925
+ let renderedRightLine;
1926
+ if (left !== void 0) {
1927
+ renderedLeftLine = makeDiffLine("removed", left, leftLineNumber);
1928
+ renderedLeftLines.push(renderedLeftLine);
1929
+ }
1930
+ if (right !== void 0) {
1931
+ renderedRightLine = makeDiffLine("added", right, rightLineNumber);
1932
+ renderedRightLines.push(renderedRightLine);
1933
+ }
1934
+ if (renderedLeftLine && renderedRightLine) {
1935
+ applyInlineHighlightPair(renderedLeftLine, renderedRightLine);
1936
+ } else if (coherentBlock) {
1937
+ if (renderedLeftLine) applyFullLineHighlight(renderedLeftLine);
1938
+ if (renderedRightLine) applyFullLineHighlight(renderedRightLine);
1939
+ }
1940
+ rows.push(makeDiffRow(
1941
+ left === void 0 ? makePlaceholder() : makeDiffCell("removed", left, leftLineNumber++),
1942
+ right === void 0 ? makePlaceholder() : makeDiffCell("added", right, rightLineNumber++)
1943
+ ));
1944
+ }
1945
+ flushBlock();
1946
+ return { left: leftLineNumber, right: rightLineNumber };
1947
+ }
1948
+ function hasCredibleLineCorrespondence(left, right) {
1949
+ const score = scoreReplacementLinePair(left, right);
1950
+ if (score.eligible) return true;
1951
+ const leftAnchor = declarationAnchorKey(left);
1952
+ if (leftAnchor && leftAnchor === declarationAnchorKey(right)) return true;
1953
+ const leftTokens = new Set(tokenizeMatchingContent(normalizeMatchingContent(left)).filter(isSharedContentToken));
1954
+ return tokenizeMatchingContent(normalizeMatchingContent(right)).some((token) => leftTokens.has(token) && isSharedContentToken(token));
1955
+ }
1956
+ var NON_SUBSTANTIVE_SHARED_TOKENS = /* @__PURE__ */ new Set([
1957
+ "and",
1958
+ "are",
1959
+ "for",
1960
+ "from",
1961
+ "into",
1962
+ "that",
1963
+ "the",
1964
+ "this",
1965
+ "to",
1966
+ "with"
1967
+ ]);
1968
+ function isSharedContentToken(token) {
1969
+ return token.length >= 3 && /^[\p{L}\p{N}_$]+$/u.test(token) && !NON_SUBSTANTIVE_SHARED_TOKENS.has(token);
1970
+ }
1971
+ function hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs) {
1972
+ if (crediblePairs === 0) return false;
1973
+ const longestSide = Math.max(leftLines.length, rightLines.length);
1974
+ if (longestSide <= 2 || crediblePairs * 2 >= longestSide) return true;
1975
+ const roles = [...leftLines, ...rightLines].map(collectionLineRole);
1976
+ return roles[0] !== null && roles.every((role) => role === roles[0]);
1977
+ }
1978
+ function collectionLineRole(line) {
1979
+ const normalized = line.trimStart();
1980
+ if (/^[-*+]\s+/.test(normalized)) return "bullet";
1981
+ if (/^\d+[.)]\s+/.test(normalized)) return "ordered";
1982
+ if (/^>\s+/.test(normalized)) return "quote";
1983
+ return null;
1984
+ }
1715
1985
  var MAX_ALIGNMENT_CELLS = 1e4;
1716
1986
  var MAX_SCORING_LINE_LENGTH = 2e3;
1717
1987
  var MINIMUM_MATCH_SCORE = 0.52;
@@ -1724,6 +1994,7 @@ var CONTEXTUAL_AMBIGUITY_MARGIN = 0.05;
1724
1994
  var GAP_PENALTY = -0.12;
1725
1995
  var MAX_BOUNDED_CANDIDATES = 5e3;
1726
1996
  var MAX_LARGE_HUNK_ANCHORS = 2e3;
1997
+ var MAX_ALIGNMENT_CHARACTER_CELLS = 5e7;
1727
1998
  var MAX_RARE_TOKEN_OCCURRENCES = 4;
1728
1999
  var MAX_CONTEXTUAL_HUNK_LINES = 8;
1729
2000
  var MAX_CONTEXTUAL_LENGTH_DELTA = 4;
@@ -1734,9 +2005,17 @@ function alignReplacementLines(leftLines, rightLines) {
1734
2005
  if (rightLines.length === 0) {
1735
2006
  return leftLines.map((left) => ({ left }));
1736
2007
  }
2008
+ const leftCharacterCount = leftLines.reduce((total, line) => total + line.length, 0);
2009
+ const rightCharacterCount = rightLines.reduce((total, line) => total + line.length, 0);
2010
+ if (leftCharacterCount * rightCharacterCount > MAX_ALIGNMENT_CHARACTER_CELLS) {
2011
+ return [
2012
+ ...leftLines.map((left) => ({ left })),
2013
+ ...rightLines.map((right) => ({ right }))
2014
+ ];
2015
+ }
1737
2016
  if (leftLines.length === 1 && rightLines.length === 1) {
1738
2017
  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]))) {
2018
+ 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
2019
  return [{ left: leftLines[0], right: rightLines[0] }];
1741
2020
  }
1742
2021
  return [{ left: leftLines[0] }, { right: rightLines[0] }];
@@ -2258,6 +2537,11 @@ function applyInlineHighlightPair(leftLine, rightLine) {
2258
2537
  rightLine.segments = rightSegments;
2259
2538
  }
2260
2539
  }
2540
+ function applyFullLineHighlight(line) {
2541
+ if (/[^\s]/.test(line.content)) {
2542
+ line.segments = [{ kind: line.kind, text: line.content, emphasis: true }];
2543
+ }
2544
+ }
2261
2545
  function buildInlineSegments(leftContent, rightContent) {
2262
2546
  const changes = diffWordsWithSpace(leftContent, rightContent);
2263
2547
  const leftSegments = [];
@@ -2409,6 +2693,125 @@ function findBlankLineBoundary(lines, target, minimum, maximum) {
2409
2693
  return Math.min(Math.max(target, minimum), maximum - 1);
2410
2694
  }
2411
2695
 
2696
+ // src/changeAttention.ts
2697
+ var CHANGE_ATTENTION_VERSION = 1;
2698
+ var LOCKFILES = /* @__PURE__ */ new Set([
2699
+ "cargo.lock",
2700
+ "package-lock.json",
2701
+ "pnpm-lock.yaml",
2702
+ "yarn.lock",
2703
+ "poetry.lock",
2704
+ "gemfile.lock",
2705
+ "composer.lock"
2706
+ ]);
2707
+ var MAX_FAN_IN_FILES = 250;
2708
+ var MAX_FAN_IN_TEXT_BYTES = 64 * 1024;
2709
+ function classifyChangeFileRole(filePath) {
2710
+ const lower = filePath.toLowerCase();
2711
+ const fileName = lower.split("/").pop() || lower;
2712
+ if (/(^|\/)(dist|build|generated|vendor)(\/|$)/.test(lower) || /(?:\.min\.js|\.map)$/.test(lower)) return "generated";
2713
+ if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower) || /\.(test|spec)\.[^/]+$/.test(lower)) return "test";
2714
+ if (lower.startsWith("docs/") || fileName === "readme.md" || /\.(md|mdx|rst)$/.test(lower)) return "documentation";
2715
+ if (LOCKFILES.has(fileName) || /(^|\/)(pyproject\.toml|package\.json|go\.mod|go\.sum|cargo\.toml)$/.test(lower)) return "dependency";
2716
+ return "production";
2717
+ }
2718
+ function buildChangeAttention(inputs) {
2719
+ const prepared = inputs.map((input) => prepareFile(input, inputs));
2720
+ const production = prepared.filter((file) => file.role === "production" && !file.mechanical);
2721
+ const documentation = prepared.filter((file) => file.role === "documentation" && !file.mechanical);
2722
+ const eligible = production.length > 0 ? production : documentation;
2723
+ const entry = [...eligible].sort(compareRanked)[0];
2724
+ const fallback = [...prepared].filter((file) => !file.binary).sort((left, right) => right.changedLines - left.changedLines || left.path.localeCompare(right.path))[0];
2725
+ 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)));
2726
+ 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;
2727
+ if (pairedTest && focusCandidates.length < Math.max(0, 7 - (entry ? 1 : 0))) focusCandidates.push(pairedTest);
2728
+ const focusPaths = focusCandidates.map((file) => file.path);
2729
+ const files = prepared.map((file) => {
2730
+ const attention = file.path === (entry == null ? void 0 : entry.path) ? "entry" : focusPaths.includes(file.path) ? "focus" : file.mechanical ? "mechanical" : "background";
2731
+ return {
2732
+ path: file.path,
2733
+ previousPath: file.previousPath,
2734
+ changeKind: file.changeKind,
2735
+ role: file.role,
2736
+ attention,
2737
+ additions: file.additions,
2738
+ deletions: file.deletions,
2739
+ reason: attention === "entry" ? entryReason(file, production.length) : attentionReason(file),
2740
+ score: file.score
2741
+ };
2742
+ }).sort((left, right) => left.path.localeCompare(right.path));
2743
+ return {
2744
+ version: CHANGE_ATTENTION_VERSION,
2745
+ entryPath: (entry == null ? void 0 : entry.path) || null,
2746
+ entryReason: entry ? entryReason(entry, production.length) : noEntryReason(prepared),
2747
+ fallbackPath: (fallback == null ? void 0 : fallback.path) || null,
2748
+ focusPaths,
2749
+ backgroundPaths: files.filter((file) => file.attention === "background").map((file) => file.path),
2750
+ mechanicalPaths: files.filter((file) => file.attention === "mechanical").map((file) => file.path),
2751
+ files
2752
+ };
2753
+ }
2754
+ function prepareFile(input, all) {
2755
+ const role = classifyChangeFileRole(input.path);
2756
+ const fileName = input.path.toLowerCase().split("/").pop() || input.path.toLowerCase();
2757
+ const mechanical = Boolean(input.binary) || role === "generated" || LOCKFILES.has(fileName);
2758
+ const changedLines = (input.additions ?? 0) + (input.deletions ?? 0);
2759
+ const contract = /(^|[\/._-])(types?|schema|models?|contracts?|interfaces?)([\/._-]|$)/i.test(input.path);
2760
+ const referenceHints = new Set([
2761
+ fileName.replace(/\.[^.]+$/, ""),
2762
+ ...(input.symbolHints || []).map((hint) => hint.toLowerCase())
2763
+ ].filter((hint) => hint.length >= 4));
2764
+ 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) => {
2765
+ var _a;
2766
+ const boundedText = (_a = candidate.headText) == null ? void 0 : _a.slice(0, MAX_FAN_IN_TEXT_BYTES).toLowerCase();
2767
+ return Boolean(boundedText && [...referenceHints].some((hint) => boundedText.includes(hint)));
2768
+ }).length;
2769
+ const roleScore = role === "production" ? 300 : role === "documentation" ? 150 : role === "dependency" ? 80 : role === "test" ? 50 : 0;
2770
+ const sizePenalty = changedLines > 2e3 ? 80 : changedLines > 500 ? 30 : 0;
2771
+ const readmePenalty = fileName === "readme.md" && all.filter((candidate) => !candidate.binary).length > 1 ? 100 : 0;
2772
+ return {
2773
+ ...input,
2774
+ role,
2775
+ mechanical,
2776
+ changedLines,
2777
+ contract,
2778
+ fanIn,
2779
+ binary: Boolean(input.binary),
2780
+ score: mechanical ? -1e3 : roleScore + (contract ? 100 : 0) + fanIn * 40 + (input.commitCount || 0) * 5 - sizePenalty - readmePenalty
2781
+ };
2782
+ }
2783
+ function compareRanked(left, right) {
2784
+ return right.score - left.score || right.fanIn - left.fanIn || left.changedLines - right.changedLines || left.path.localeCompare(right.path);
2785
+ }
2786
+ function entryReason(file, productionCount) {
2787
+ if (file.fanIn > 0) return `Referenced by ${file.fanIn} other changed file${file.fanIn === 1 ? "" : "s"}.`;
2788
+ if (file.contract) return "Contract-shaped path central to the change.";
2789
+ if (productionCount === 1) return "Only production file in this range.";
2790
+ return "Highest-ranked production file for a first pass.";
2791
+ }
2792
+ function attentionReason(file) {
2793
+ if (file.binary) return "Binary file.";
2794
+ if (file.mechanical) return file.role === "generated" ? "Generated artifact." : "Dependency lockfile.";
2795
+ if (file.role === "test") return "Test or proof file.";
2796
+ if (file.role === "documentation") return "Documentation change.";
2797
+ if (file.role === "dependency") return "Dependency or packaging manifest.";
2798
+ return "Additional production file.";
2799
+ }
2800
+ function noEntryReason(files) {
2801
+ if (files.every((file) => file.mechanical)) return "This change is entirely mechanical or generated.";
2802
+ if (files.every((file) => file.mechanical || file.role === "test")) return "This change contains only tests and mechanical files.";
2803
+ return "No single entry-point is reliable for this change.";
2804
+ }
2805
+ function isTestForFocus(testPath, focusPaths) {
2806
+ const normalizedTest = testPath.toLowerCase().replace(/\.(test|spec)(?=\.)/, "").replace(/^test_/, "");
2807
+ return focusPaths.some((candidate) => {
2808
+ var _a;
2809
+ const normalizedCandidate = candidate.toLowerCase();
2810
+ const stem = ((_a = normalizedCandidate.split("/").pop()) == null ? void 0 : _a.replace(/\.[^.]+$/, "")) || normalizedCandidate;
2811
+ return stem.length >= 3 && normalizedTest.includes(stem);
2812
+ });
2813
+ }
2814
+
2412
2815
  // src/changeTourContext.ts
2413
2816
  var import_child_process3 = require("child_process");
2414
2817
  var CHANGE_TOUR_CONTEXT_VERSION = 1;
@@ -2486,7 +2889,7 @@ function buildContextFile(repoRoot, baseOid, headOid, changedPath, maxPatchBytes
2486
2889
  path: changedPath.path,
2487
2890
  previousPath: changedPath.previousPath,
2488
2891
  changeKind: changedPath.kind,
2489
- role: classifyFileRole(changedPath.path),
2892
+ role: classifyChangeFileRole(changedPath.path),
2490
2893
  additions: stats.additions,
2491
2894
  deletions: stats.deletions,
2492
2895
  binary: stats.binary,
@@ -2583,15 +2986,6 @@ function readGitText(repoRoot, oid, relativePath, maxBytes) {
2583
2986
  return void 0;
2584
2987
  }
2585
2988
  }
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
2989
  function runGit2(repoRoot, args) {
2596
2990
  return (0, import_child_process3.execFileSync)("git", args, {
2597
2991
  cwd: repoRoot,
@@ -2616,8 +3010,12 @@ var CHAPTERS = {
2616
3010
  packaging: { id: "packaging", title: "Packaging and dependencies", priority: 4 }
2617
3011
  };
2618
3012
  function buildChangeTourManifest(startPath, options = {}) {
2619
- var _a, _b, _c, _d, _e;
2620
- const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
3013
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
3014
+ const range = resolveBranchReviewRange(
3015
+ startPath,
3016
+ options.headRef ?? ((_b = (_a = options.source) == null ? void 0 : _a.range) == null ? void 0 : _b.head),
3017
+ options.baseRef ?? ((_d = (_c = options.source) == null ? void 0 : _c.range) == null ? void 0 : _d.base)
3018
+ );
2621
3019
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_TOUR_FILE_BYTES;
2622
3020
  const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_TOUR_LINE_BYTES;
2623
3021
  const omittedFiles = [];
@@ -2625,8 +3023,9 @@ function buildChangeTourManifest(startPath, options = {}) {
2625
3023
  const sceneRecords = [];
2626
3024
  let totalAdditions = 0;
2627
3025
  let totalDeletions = 0;
3026
+ let totalBinaryFiles = 0;
2628
3027
  for (const changedPath of range.changedPaths) {
2629
- const { additions, deletions } = readGitLineStats(
3028
+ const { additions, deletions, binary } = readGitLineStats(
2630
3029
  range.repoRoot,
2631
3030
  range.mergeBaseOid,
2632
3031
  range.headOid,
@@ -2635,6 +3034,7 @@ function buildChangeTourManifest(startPath, options = {}) {
2635
3034
  );
2636
3035
  totalAdditions += additions;
2637
3036
  totalDeletions += deletions;
3037
+ if (binary) totalBinaryFiles += 1;
2638
3038
  const pair = resolveReviewPathPair(range.changedPaths, changedPath.path);
2639
3039
  if (!pair) {
2640
3040
  omittedFiles.push(changedPath.path);
@@ -2675,21 +3075,50 @@ function buildChangeTourManifest(startPath, options = {}) {
2675
3075
  }
2676
3076
  files.sort((left, right) => left.path.localeCompare(right.path));
2677
3077
  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}` }));
3078
+ let defaultScenes = sceneRecords.map(({ scene }, index) => ({ ...scene, id: `file-${index + 1}` }));
2679
3079
  if (options.story && options.source) {
2680
3080
  throw new Error("A change tour can use either a legacy story or a source file, not both.");
2681
3081
  }
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
- };
3082
+ const attention = buildChangeAttention(files.map((file) => ({
3083
+ path: file.path,
3084
+ previousPath: file.previousPath,
3085
+ changeKind: file.changeKind,
3086
+ additions: file.additions,
3087
+ deletions: file.deletions,
3088
+ binary: file.kind === "omitted",
3089
+ headText: file.kind === "text-diff" ? file.rightContent : void 0,
3090
+ symbolHints: file.kind === "text-diff" ? findDeclarationNames(file.rightContent) : []
3091
+ })));
3092
+ if (!options.story && !options.source && attention.entryPath) {
3093
+ const focusRank = (scenePath) => {
3094
+ const index = attention.focusPaths.indexOf(scenePath);
3095
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
3096
+ };
3097
+ 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));
3098
+ }
3099
+ const authored = options.source ? applySource(parseChangeTourSource(options.source), defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
3100
+ defaultScenes,
3101
+ buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id]))),
3102
+ attention,
3103
+ {
3104
+ changedFiles: range.changedPaths.length,
3105
+ binaryFiles: totalBinaryFiles,
3106
+ additions: totalAdditions,
3107
+ deletions: totalDeletions,
3108
+ commitCount: range.commits.length
3109
+ }
3110
+ );
2686
3111
  const scenes = authored.scenes;
2687
3112
  const chapters = authored.chapters;
3113
+ const finalScenes = "finalScenes" in authored ? authored.finalScenes : scenes;
3114
+ const finalChapters = "finalChapters" in authored ? authored.finalChapters : chapters;
2688
3115
  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,
3116
+ // Only authored v2 files opt into repository-bound zoom modes. Generated
3117
+ // and legacy/story tours retain the portable v1 manifest contract.
3118
+ version: ((_e = options.source) == null ? void 0 : _e.version) ?? 1,
3119
+ title: ((_f = options.source) == null ? void 0 : _f.title) || ((_g = options.story) == null ? void 0 : _g.title) || options.title || `${range.headRef} against ${range.baseRef}`,
3120
+ windowTitle: (_h = options.source) == null ? void 0 : _h.windowTitle,
3121
+ sourceUrl: ((_i = options.source) == null ? void 0 : _i.sourceUrl) || ((_j = options.story) == null ? void 0 : _j.sourceUrl) || options.sourceUrl,
2693
3122
  generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
2694
3123
  range: {
2695
3124
  baseRef: range.baseRef,
@@ -2710,6 +3139,49 @@ function buildChangeTourManifest(startPath, options = {}) {
2710
3139
  chapters,
2711
3140
  scenes
2712
3141
  };
3142
+ if (manifest.version === 2) {
3143
+ manifest.repository = { root: range.repoRoot };
3144
+ const revisions = [];
3145
+ for (const scene of scenes) {
3146
+ let realStack;
3147
+ if (scene.kind === "stacked-diff") realStack = scene;
3148
+ if (scene.kind === "deconstructed-diff") {
3149
+ const authoredScene = (_k = options.source) == null ? void 0 : _k.chapters.flatMap((chapter) => chapter.scenes).find((candidate) => candidate.id === scene.id);
3150
+ if ((authoredScene == null ? void 0 : authoredScene.kind) !== "deconstructed-diff" || !authoredScene.stack) {
3151
+ throw new Error(`Deconstructed scene ${scene.id} requires an explicit real revision stack in v2.`);
3152
+ }
3153
+ realStack = buildStackedScene(range.repoRoot, {
3154
+ ...authoredScene,
3155
+ kind: "stacked-diff",
3156
+ stack: authoredScene.stack,
3157
+ files: scene.files.map((file) => file.path),
3158
+ steps: []
3159
+ }, files.map((file) => file.path));
3160
+ if (realStack.stack[0].oid !== scene.realRange.baseOid || realStack.stack[realStack.stack.length - 1].oid !== scene.realRange.targetOid) {
3161
+ throw new Error(`Deconstructed scene ${scene.id} real stack endpoints must match its explanation range.`);
3162
+ }
3163
+ }
3164
+ if (realStack) {
3165
+ if (realStack.stack[0].oid !== range.mergeBaseOid || realStack.stack[realStack.stack.length - 1].oid !== range.headOid) {
3166
+ throw new Error(`Scene ${scene.id} real stack endpoints must match the tour's final diff range.`);
3167
+ }
3168
+ revisions.push(realStack);
3169
+ }
3170
+ }
3171
+ const explanation = scenes.some((scene) => scene.kind === "deconstructed-diff");
3172
+ const distinctRevisions = revisions.some((scene) => scene.stack.length > 2);
3173
+ manifest.zoom = {
3174
+ authoredDepth: explanation ? "explanation" : distinctRevisions ? "revisions" : "final",
3175
+ modes: [
3176
+ ...explanation ? ["explanation"] : [],
3177
+ ...distinctRevisions ? ["revisions"] : [],
3178
+ "final",
3179
+ "history"
3180
+ ],
3181
+ revisions,
3182
+ final: { scenes: finalScenes, chapters: finalChapters }
3183
+ };
3184
+ }
2713
3185
  return parseChangeTourManifest(manifest);
2714
3186
  }
2715
3187
  function applySource(source, defaultScenes, repoRoot) {
@@ -2732,8 +3204,11 @@ function applySource(source, defaultScenes, repoRoot) {
2732
3204
  }]));
2733
3205
  const scenes = [];
2734
3206
  const chapters = [];
3207
+ const finalScenes = [];
3208
+ const finalChapters = [];
2735
3209
  for (const chapter of source.chapters) {
2736
3210
  const sceneIds = [];
3211
+ const finalSceneIds = [];
2737
3212
  for (const authoredScene of chapter.scenes) {
2738
3213
  if (authoredScene.kind === "deconstructed-diff") {
2739
3214
  const deconstructedScene = buildDeconstructedManifestScene(
@@ -2745,6 +3220,16 @@ function applySource(source, defaultScenes, repoRoot) {
2745
3220
  );
2746
3221
  scenes.push(deconstructedScene);
2747
3222
  sceneIds.push(deconstructedScene.id);
3223
+ if (authoredScene.steps) {
3224
+ const finalScene = buildWalkthroughManifestScene(
3225
+ { ...authoredScene, steps: authoredScene.steps },
3226
+ available,
3227
+ resolvedAnchors,
3228
+ connections
3229
+ );
3230
+ finalScenes.push(finalScene);
3231
+ finalSceneIds.push(finalScene.id);
3232
+ }
2748
3233
  continue;
2749
3234
  }
2750
3235
  if (authoredScene.kind === "stacked-diff") {
@@ -2755,37 +3240,51 @@ function applySource(source, defaultScenes, repoRoot) {
2755
3240
  );
2756
3241
  scenes.push(stackedScene);
2757
3242
  sceneIds.push(stackedScene.id);
3243
+ for (const [index, file] of stackedScene.files.entries()) {
3244
+ const endpoint = available.get(file.path);
3245
+ if (!endpoint) continue;
3246
+ const finalScene = { ...endpoint, id: `${stackedScene.id}-final-${index + 1}`, title: file.path };
3247
+ finalScenes.push(finalScene);
3248
+ finalSceneIds.push(finalScene.id);
3249
+ }
2758
3250
  continue;
2759
3251
  }
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
- };
3252
+ const scene = buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections);
2783
3253
  scenes.push(scene);
2784
3254
  sceneIds.push(scene.id);
3255
+ finalScenes.push(scene);
3256
+ finalSceneIds.push(scene.id);
2785
3257
  }
2786
3258
  chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3259
+ finalChapters.push({ id: chapter.id, title: chapter.title, sceneIds: finalSceneIds });
2787
3260
  }
2788
- return { scenes, chapters };
3261
+ return { scenes, chapters, finalScenes, finalChapters };
3262
+ }
3263
+ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections) {
3264
+ return {
3265
+ id: authoredScene.id,
3266
+ kind: "walkthrough",
3267
+ title: authoredScene.title,
3268
+ summary: authoredScene.summary,
3269
+ bullets: authoredScene.bullets,
3270
+ tags: authoredScene.tags,
3271
+ takeaway: authoredScene.takeaway,
3272
+ steps: authoredScene.steps.map((step) => {
3273
+ const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
3274
+ const diff2 = available.get(focus.path);
3275
+ if (!diff2) throw new Error(`Step ${step.id} has no text diff for ${focus.path}.`);
3276
+ return {
3277
+ id: step.id,
3278
+ title: step.title,
3279
+ body: step.body,
3280
+ depth: step.depth,
3281
+ requirement: step.requirement,
3282
+ focus,
3283
+ connection: step.connection ? connections.get(step.connection) : void 0,
3284
+ diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
3285
+ };
3286
+ })
3287
+ };
2789
3288
  }
2790
3289
  function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, availableFilePaths = []) {
2791
3290
  const compiled = buildDeconstructedScene(repoRoot, source, { baseRef, headRef: targetRef });
@@ -2951,7 +3450,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2951
3450
  let totalContentBytes = 0;
2952
3451
  const files = filePaths.flatMap((filePath) => {
2953
3452
  const aliases = /* @__PURE__ */ new Set([filePath]);
2954
- for (const changes of adjacentChanges) {
3453
+ for (const changes of [...adjacentChanges].reverse()) {
2955
3454
  for (const change of changes) {
2956
3455
  if (aliases.has(change.path) || change.previousPath && aliases.has(change.previousPath)) {
2957
3456
  aliases.add(change.path);
@@ -2962,7 +3461,9 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2962
3461
  let fileContentBytes = 0;
2963
3462
  const panels = [];
2964
3463
  for (const entry of stack) {
2965
- const resolvedPath = [...aliases].find((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
3464
+ const candidates = [...aliases].filter((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
3465
+ if (candidates.length > 1) throw new Error(`Ambiguous revision paths for ${filePath} at ${entry.ref}: ${candidates.join(", ")}`);
3466
+ const resolvedPath = candidates[0];
2966
3467
  if (!resolvedPath) {
2967
3468
  panels.push({ id: entry.id, label: `${entry.label} / ${filePath} (missing)`, content: "", exists: false });
2968
3469
  continue;
@@ -2989,6 +3490,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
2989
3490
  }
2990
3491
  return [{ path: filePath, panels }];
2991
3492
  });
3493
+ if (files.length === 0) throw new Error(`Stacked scene ${source.id} has no materializable text files.`);
2992
3494
  const stackIds = stack.map((entry) => entry.id);
2993
3495
  return {
2994
3496
  id: source.id,
@@ -3000,7 +3502,14 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
3000
3502
  takeaway: source.takeaway,
3001
3503
  stack,
3002
3504
  files,
3003
- steps: source.steps.map((step) => {
3505
+ steps: source.steps.length === 0 ? stack.slice(0, -1).map((_, pairIndex) => ({
3506
+ id: `${source.id}-revision-${pairIndex}`,
3507
+ title: "",
3508
+ body: "",
3509
+ file: files[0].path,
3510
+ pairIndex,
3511
+ side: "right"
3512
+ })) : source.steps.map((step) => {
3004
3513
  var _a, _b;
3005
3514
  return {
3006
3515
  id: step.id,
@@ -3112,21 +3621,51 @@ function buildChapters(records, sceneIdByPath) {
3112
3621
  }
3113
3622
  function chapterForPath(filePath) {
3114
3623
  const lower = filePath.toLowerCase();
3115
- const fileName = lower.split("/").pop() || lower;
3116
- if (lower.startsWith("docs/") || fileName === "readme.md" || fileName.includes("architecture")) {
3624
+ const role = classifyChangeFileRole(filePath);
3625
+ if (role === "documentation" || lower.includes("architecture")) {
3117
3626
  return CHAPTERS.context;
3118
3627
  }
3119
- if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower)) {
3628
+ if (role === "test") {
3120
3629
  return CHAPTERS.proof;
3121
3630
  }
3122
3631
  if (/(^|\/)(models?|schema|types?|contracts?)\.[^/]+$/.test(lower)) {
3123
3632
  return CHAPTERS.contracts;
3124
3633
  }
3125
- if (/(^|\/)(package-lock\.json|.*\.lock|pyproject\.toml|package\.json)$/.test(lower)) {
3634
+ if (role === "dependency" || role === "generated") {
3126
3635
  return CHAPTERS.packaging;
3127
3636
  }
3128
3637
  return CHAPTERS.behavior;
3129
3638
  }
3639
+ function buildGeneratedTourLanding(scenes, chapters, attention, summary) {
3640
+ const entry = attention.entryPath ? `Start here: ${attention.entryPath} \u2014 ${attention.entryReason}` : `No single entry-point \u2014 ${attention.entryReason}`;
3641
+ const focus = attention.focusPaths.length > 0 ? `Pay attention: ${attention.focusPaths.join(", ")}` : "Pay attention: no additional production files were identified.";
3642
+ const skipped = attention.backgroundPaths.length + attention.mechanicalPaths.length;
3643
+ const overview = {
3644
+ id: "change-overview",
3645
+ kind: "discussion",
3646
+ title: "Change overview",
3647
+ summary: `${summary.changedFiles} files (${summary.changedFiles - summary.binaryFiles} text, ${summary.binaryFiles} binary) \xB7 +${summary.additions} \u2212${summary.deletions} \xB7 ${summary.commitCount} commits`,
3648
+ bullets: [entry, focus, `Usually skip on a first pass: ${skipped} file${skipped === 1 ? "" : "s"}. All files remain available.`],
3649
+ tags: ["start here", "first pass"],
3650
+ takeaway: attention.entryPath ? `Next: open ${attention.entryPath}.` : "Next: open the first available textual change."
3651
+ };
3652
+ return {
3653
+ scenes: [overview, ...scenes],
3654
+ chapters: [
3655
+ { id: "start-here", title: "Start here", sceneIds: [overview.id] },
3656
+ ...chapters
3657
+ ]
3658
+ };
3659
+ }
3660
+ function findDeclarationNames(content) {
3661
+ const names = /* @__PURE__ */ new Set();
3662
+ for (const line of content.split("\n")) {
3663
+ 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*)/);
3664
+ if (match) names.add(match[1]);
3665
+ if (names.size >= 25) break;
3666
+ }
3667
+ return [...names];
3668
+ }
3130
3669
  function buildSceneNote(kind, additions, deletions, previousPath) {
3131
3670
  const label = formatChangeKind(kind);
3132
3671
  const rename = previousPath ? ` from ${previousPath}` : "";
@@ -3189,15 +3728,19 @@ function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
3189
3728
  const [additionsText = "0", deletionsText = "0"] = output.trim().split(/\s+/, 3);
3190
3729
  return {
3191
3730
  additions: Number.parseInt(additionsText, 10) || 0,
3192
- deletions: Number.parseInt(deletionsText, 10) || 0
3731
+ deletions: Number.parseInt(deletionsText, 10) || 0,
3732
+ binary: additionsText === "-" || deletionsText === "-"
3193
3733
  };
3194
3734
  }
3195
3735
  // Annotate the CommonJS export names for ESM import in node:
3196
3736
  0 && (module.exports = {
3737
+ CHANGE_ATTENTION_VERSION,
3738
+ buildChangeAttention,
3197
3739
  buildChangeInventory,
3198
3740
  buildChangeTourContext,
3199
3741
  buildChangeTourManifest,
3200
3742
  buildDeconstructedScene,
3743
+ classifyChangeFileRole,
3201
3744
  compileDeconstructedScene,
3202
3745
  materializeChangeUnits,
3203
3746
  parseChangeTourManifest,