@davmash/bygone 0.9.7 → 0.10.0

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +74 -1
  3. package/cli/commandSpec.js +2 -0
  4. package/cli/completions.js +3 -3
  5. package/cli/present.js +28 -10
  6. package/cli/tour.js +22 -1
  7. package/cli/tourExport.js +147 -0
  8. package/cli/tourFile.js +109 -4
  9. package/cli/tourHistory.js +31 -1
  10. package/completions/_bygone +1 -1
  11. package/completions/bygone +1 -1
  12. package/completions/bygone.fish +1 -1
  13. package/docs/change-tour-format.md +86 -67
  14. package/docs/explore.md +14 -3
  15. package/docs/generating-change-tours.md +84 -46
  16. package/docs/present.md +103 -14
  17. package/docs/product-surface.md +19 -8
  18. package/docs/sharing-tours.md +95 -0
  19. package/docs/visual-walkthrough/README.md +89 -0
  20. package/docs/visual-walkthrough/build-tour.mjs +54 -0
  21. package/docs/visual-walkthrough/capture-desktop.cjs +42 -0
  22. package/docs/visual-walkthrough/capture-desktop.mjs +21 -0
  23. package/docs/visual-walkthrough/captures.md +49 -0
  24. package/docs/visual-walkthrough/create-demo.mjs +57 -0
  25. package/docs/visual-walkthrough/deconstructed-demo.bygone +100 -0
  26. package/docs/visual-walkthrough/images/01-two-files.png +0 -0
  27. package/docs/visual-walkthrough/images/02-many-panels.png +0 -0
  28. package/docs/visual-walkthrough/images/03-directories.png +0 -0
  29. package/docs/visual-walkthrough/images/04-search.png +0 -0
  30. package/docs/visual-walkthrough/images/05-repository-history.png +0 -0
  31. package/docs/visual-walkthrough/images/06-file-history.png +0 -0
  32. package/docs/visual-walkthrough/images/07-branch-review.png +0 -0
  33. package/docs/visual-walkthrough/images/08-tour-reader.png +0 -0
  34. package/docs/visual-walkthrough/images/09-focused-step.png +0 -0
  35. package/docs/visual-walkthrough/images/10-deconstructed.png +0 -0
  36. package/docs/visual-walkthrough/images/11-exploration.png +0 -0
  37. package/docs/visual-walkthrough/images/12-authoring.png +0 -0
  38. package/docs/visual-walkthrough/images/13-export.png +0 -0
  39. package/docs/visual-walkthrough/images/14-vscode.png +0 -0
  40. package/docs/visual-walkthrough/images/15-cli.png +0 -0
  41. package/docs/visual-walkthrough/narrative.md +134 -0
  42. package/docs/visual-walkthrough/outline.md +52 -0
  43. package/docs/visual-walkthrough/walkthrough.bygone +340 -0
  44. package/docs/walkthrough.md +6 -101
  45. package/examples/bygone-history.bygone +11 -44
  46. package/examples/marker-focus.bygone +116 -0
  47. package/examples/navigation-lab.bygone.yaml +401 -336
  48. package/examples/tour-orientation.bygone +349 -0
  49. package/media/diff.worker.js +13 -3
  50. package/media/webview.css +1 -1
  51. package/media/webview.js +394 -319
  52. package/out/changeTour.js +865 -397
  53. package/out/deepLink.js +167 -0
  54. package/out/historyDirectory.js +66 -0
  55. package/out/standalone-main.js +20515 -16822
  56. package/out/tourCoverage.js +850 -386
  57. package/out/tourDocument.js +97 -0
  58. package/package.json +1 -1
  59. package/schemas/change-tour-source.schema.json +91 -64
  60. package/skills/pr-tour-guide/SKILL.md +144 -128
  61. package/skills/pr-tour-guide/references/expectations-and-feedback.md +57 -0
  62. package/standalone/index.html +10 -25
  63. package/web/export-bootstrap.js +62 -0
  64. package/web/export-licenses.txt +78 -0
  65. package/web/export-runtime.json +12 -0
  66. package/web/index.html +54 -47
  67. package/web/presenter.css +337 -176
  68. package/web/web-host.js +239 -137
package/out/changeTour.js CHANGED
@@ -41,9 +41,49 @@ __export(changeTour_exports, {
41
41
  materializeChangeUnits: () => materializeChangeUnits,
42
42
  parseChangeTourManifest: () => parseChangeTourManifest,
43
43
  parseChangeTourSource: () => parseChangeTourSource,
44
- parseChangeTourStory: () => parseChangeTourStory
44
+ parseChangeTourStory: () => parseChangeTourStory,
45
+ pinTourSource: () => pinTourSource
45
46
  });
46
47
  module.exports = __toCommonJS(changeTour_exports);
48
+
49
+ // src/tourImage.ts
50
+ var MAX_TOUR_IMAGE_BYTES = 8 * 1024 * 1024;
51
+ function record(value) {
52
+ return value !== null && typeof value === "object" && !Array.isArray(value);
53
+ }
54
+ function validPath(value) {
55
+ return typeof value === "string" && value.length > 0 && !/[\\\x00-\x1f:]/.test(value) && value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
56
+ }
57
+ function validateTourImageSource(value, label) {
58
+ if (value === void 0) return;
59
+ if (!record(value) || Object.keys(value).some((key) => !["file", "revision", "alt"].includes(key)) || !validPath(value.file) || !["base", "head"].includes(String(value.revision)) || typeof value.alt !== "string" || !value.alt.trim()) {
60
+ throw new Error(`${label} must contain a repository-relative file, base/head revision, and non-empty alt text.`);
61
+ }
62
+ }
63
+ function validateTourImage(value, label) {
64
+ if (value === void 0) return;
65
+ if (!record(value) || !validPath(value.path) || typeof value.alt !== "string" || !value.alt.trim() || typeof value.revision !== "string" || !/^[a-f0-9]{40,64}$/.test(value.revision) || typeof value.dataUrl !== "string" || !value.dataUrl.startsWith("data:image/png;base64,")) {
66
+ throw new Error(`${label} must contain pinned, embedded PNG evidence and alt text.`);
67
+ }
68
+ const base64 = value.dataUrl.slice(22);
69
+ const decodedBytes = base64.length / 4 * 3 - (base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0);
70
+ if (decodedBytes > MAX_TOUR_IMAGE_BYTES || base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) throw new Error(`${label} must be a PNG of at most 8 MiB.`);
71
+ const header = Uint8Array.from(atob(base64.slice(0, 44)), (character) => character.charCodeAt(0));
72
+ validatePngHeader(header, label);
73
+ }
74
+ function validatePngHeader(bytes, label) {
75
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82];
76
+ if (bytes.length < 33 || signature.some((byte, index) => bytes[index] !== byte)) {
77
+ throw new Error(`${label} must be a PNG with an IHDR header.`);
78
+ }
79
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
80
+ const width = view.getUint32(16), height = view.getUint32(20);
81
+ if (!width || !height || width > 16384 || height > 16384 || width * height > 64 * 1024 * 1024) {
82
+ throw new Error(`${label} exceeds the PNG dimension limit.`);
83
+ }
84
+ }
85
+
86
+ // src/changeTour.ts
47
87
  var import_child_process4 = require("child_process");
48
88
 
49
89
  // src/gitComparison.ts
@@ -160,8 +200,8 @@ function listBranchCommits(repoRoot, mergeBaseOid, headOid, runGit3 = createGitR
160
200
  "--format=%H%x00%h%x00%cI%x00%s%x00%P%x1e",
161
201
  `${mergeBaseOid}..${headOid}`
162
202
  ], repoRoot);
163
- return output.split("").map((record) => record.replace(/^\n+|\n+$/g, "")).filter(Boolean).map((record) => {
164
- const [oid = "", shortOid = "", timestamp = "", summary = "", parents = ""] = record.split("\0");
203
+ return output.split("").map((record2) => record2.replace(/^\n+|\n+$/g, "")).filter(Boolean).map((record2) => {
204
+ const [oid = "", shortOid = "", timestamp = "", summary = "", parents = ""] = record2.split("\0");
165
205
  return {
166
206
  oid,
167
207
  shortOid,
@@ -234,18 +274,110 @@ function readGitMaxBuffer() {
234
274
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GIT_MAX_BUFFER_BYTES;
235
275
  }
236
276
 
277
+ // src/tourFraming.ts
278
+ function validateTourFraming(value, version, path) {
279
+ for (const key of ["opening", "conclusion"]) {
280
+ const passage = value[key];
281
+ if (passage === void 0) continue;
282
+ if (Number(version) < 5) throw new Error(`${path}.${key} requires version 5.`);
283
+ if (!passage || typeof passage !== "object" || Array.isArray(passage)) {
284
+ throw new Error(`${path}.${key} must be an object.`);
285
+ }
286
+ const fields = passage;
287
+ for (const field of Object.keys(fields)) {
288
+ if (!["title", "summary", "bullets"].includes(field)) throw new Error(`${path}.${key}.${field} is unsupported.`);
289
+ }
290
+ for (const field of ["title", "summary"]) requireProse(fields[field], `${path}.${key}.${field}`);
291
+ if (fields.bullets !== void 0) {
292
+ if (!Array.isArray(fields.bullets)) throw new Error(`${path}.${key}.bullets must be an array.`);
293
+ fields.bullets.forEach((bullet, index) => requireProse(bullet, `${path}.${key}.bullets[${index}]`));
294
+ }
295
+ }
296
+ }
297
+ function validateOverviewPurpose(value, version, path) {
298
+ if (value === void 0) return;
299
+ if (version < 5) {
300
+ if (value && typeof value === "object" && "purpose" in value) throw new Error(`${path}.purpose requires version 5.`);
301
+ return;
302
+ }
303
+ requireProse(value == null ? void 0 : value.purpose, `${path}.purpose`);
304
+ }
305
+ function requireProse(value, path) {
306
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${path} must be a non-empty string.`);
307
+ }
308
+
309
+ // src/tourDetails.ts
310
+ function validateChangeTourDetails(value, path) {
311
+ if (value === void 0) return;
312
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
313
+ requireOnlyKeys(value, ["audience"], path);
314
+ validateAudience(value.audience, `${path}.audience`);
315
+ }
316
+ function validateAudience(value, path) {
317
+ if (value === void 0) return;
318
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
319
+ requireOnlyKeys(value, [
320
+ "codebase",
321
+ "language",
322
+ "domain",
323
+ "readingDemand",
324
+ "purpose",
325
+ "scope",
326
+ "estimatedMinutes"
327
+ ], path);
328
+ if (typeof value.codebase !== "string" || !["none", "some", "maintainer"].includes(value.codebase)) {
329
+ throw new Error(`${path}.codebase must be none, some, or maintainer.`);
330
+ }
331
+ if (typeof value.language !== "string" || !["none", "basic", "fluent", "advanced"].includes(value.language)) {
332
+ throw new Error(`${path}.language must be none, basic, fluent, or advanced.`);
333
+ }
334
+ if (value.domain !== void 0) requireStringArray(value.domain, `${path}.domain`);
335
+ if (typeof value.readingDemand !== "string" || !["general", "technical", "specialist"].includes(value.readingDemand)) {
336
+ throw new Error(`${path}.readingDemand must be general, technical, or specialist.`);
337
+ }
338
+ if (typeof value.purpose !== "string" || !["overview", "explanation", "review"].includes(value.purpose)) {
339
+ throw new Error(`${path}.purpose must be overview, explanation, or review.`);
340
+ }
341
+ requireString(value.scope, `${path}.scope`);
342
+ if (value.estimatedMinutes !== void 0 && (typeof value.estimatedMinutes !== "number" || !Number.isFinite(value.estimatedMinutes) || value.estimatedMinutes <= 0)) {
343
+ throw new Error(`${path}.estimatedMinutes must be a finite positive number.`);
344
+ }
345
+ }
346
+ function isRecord(value) {
347
+ return typeof value === "object" && value !== null && !Array.isArray(value);
348
+ }
349
+ function requireString(value, path) {
350
+ if (typeof value !== "string" || value.trim().length === 0) {
351
+ throw new Error(`${path} must be a non-empty string.`);
352
+ }
353
+ }
354
+ function requireStringArray(value, path) {
355
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) {
356
+ throw new Error(`${path} must be an array of strings.`);
357
+ }
358
+ }
359
+ function requireOnlyKeys(value, allowed, path) {
360
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
361
+ if (unexpected.length > 0) {
362
+ throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
363
+ }
364
+ }
365
+
237
366
  // src/changeTourSource.ts
238
- var CHANGE_TOUR_SOURCE_VERSION = 3;
367
+ var CHANGE_TOUR_SOURCE_VERSION = 5;
239
368
  function parseChangeTourSource(value) {
240
- if (isRecord(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_SOURCE_VERSION) {
369
+ if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_SOURCE_VERSION) {
241
370
  throw new Error(
242
371
  `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
372
  );
244
373
  }
245
- if (!isRecord(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
374
+ if (isRecord2(value) && value.version === 3) {
375
+ throw new Error("Tour source format version 3 is retired. Remove the review block, set source version to 4, and recompile the tour.");
376
+ }
377
+ if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== 4 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
246
378
  throw new Error("Unsupported or missing change-tour source version.");
247
379
  }
248
- requireOnlyKeys(value, [
380
+ requireOnlyKeys2(value, [
249
381
  "version",
250
382
  "title",
251
383
  "windowTitle",
@@ -256,62 +388,65 @@ function parseChangeTourSource(value) {
256
388
  "chapters",
257
389
  "tours",
258
390
  "coverage",
259
- "review"
391
+ "opening",
392
+ "conclusion",
393
+ "details"
260
394
  ], "source");
395
+ validateTourFraming(value, value.version, "source");
261
396
  optionalString(value.title, "title");
262
397
  optionalString(value.windowTitle, "windowTitle");
263
398
  optionalString(value.sourceUrl, "sourceUrl");
264
399
  if (value.range !== void 0) {
265
- if (!isRecord(value.range)) throw new Error("range must be an object.");
266
- requireOnlyKeys(value.range, ["base", "head"], "range");
267
- requireString(value.range.base, "range.base");
268
- requireString(value.range.head, "range.head");
400
+ if (!isRecord2(value.range)) throw new Error("range must be an object.");
401
+ requireOnlyKeys2(value.range, ["base", "head"], "range");
402
+ requireString2(value.range.base, "range.base");
403
+ requireString2(value.range.head, "range.head");
269
404
  }
270
405
  if (value.coverage !== void 0) {
271
- if (!isRecord(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
406
+ if (!isRecord2(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
272
407
  throw new Error("coverage must contain an exclusions array.");
273
408
  }
274
- requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
409
+ requireOnlyKeys2(value.coverage, ["exclusions"], "coverage");
275
410
  for (const [index, exclusion] of value.coverage.exclusions.entries()) {
276
411
  const exclusionPath = `coverage.exclusions[${index}]`;
277
- if (!isRecord(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
278
- requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
279
- requireString(exclusion.path, `${exclusionPath}.path`);
280
- requireString(exclusion.reason, `${exclusionPath}.reason`);
281
- if (exclusion.hunks !== void 0) requireStringArray(exclusion.hunks, `${exclusionPath}.hunks`);
412
+ if (!isRecord2(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
413
+ requireOnlyKeys2(exclusion, ["path", "hunks", "reason"], exclusionPath);
414
+ requireString2(exclusion.path, `${exclusionPath}.path`);
415
+ requireString2(exclusion.reason, `${exclusionPath}.reason`);
416
+ if (exclusion.hunks !== void 0) requireStringArray2(exclusion.hunks, `${exclusionPath}.hunks`);
282
417
  }
283
418
  }
284
- if (!isRecord(value.anchors)) {
419
+ if (!isRecord2(value.anchors)) {
285
420
  throw new Error("anchors must be an object.");
286
421
  }
287
422
  for (const [id, anchor] of Object.entries(value.anchors)) {
288
- if (!isRecord(anchor)) {
423
+ if (!isRecord2(anchor)) {
289
424
  throw new Error(`anchors.${id} must be an object.`);
290
425
  }
291
- requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
292
- requireString(anchor.file, `anchors.${id}.file`);
426
+ requireOnlyKeys2(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
427
+ requireString2(anchor.file, `anchors.${id}.file`);
293
428
  if (anchor.revision !== "base" && anchor.revision !== "head") {
294
429
  throw new Error(`anchors.${id}.revision must be base or head.`);
295
430
  }
296
- requireString(anchor.contains, `anchors.${id}.contains`);
431
+ requireString2(anchor.contains, `anchors.${id}.contains`);
297
432
  if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
298
433
  throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
299
434
  }
300
435
  }
301
- const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord(connection) ? { id, ...connection } : connection) : null;
436
+ const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord2(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord2(connection) ? { id, ...connection } : connection) : null;
302
437
  if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
303
438
  throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
304
439
  }
305
440
  const connectionIds = /* @__PURE__ */ new Set();
306
441
  for (const [index, connection] of rawConnections.entries()) {
307
- if (!isRecord(connection)) {
442
+ if (!isRecord2(connection)) {
308
443
  throw new Error(`connections[${index}] must be an object.`);
309
444
  }
310
- requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
311
- requireString(connection.id, `connections[${index}].id`);
312
- requireString(connection.from, `connections[${index}].from`);
313
- requireString(connection.to, `connections[${index}].to`);
314
- requireString(connection.label, `connections[${index}].label`);
445
+ requireOnlyKeys2(connection, ["id", "from", "to", "label"], `connections[${index}]`);
446
+ requireString2(connection.id, `connections[${index}].id`);
447
+ requireString2(connection.from, `connections[${index}].from`);
448
+ requireString2(connection.to, `connections[${index}].to`);
449
+ requireString2(connection.label, `connections[${index}].label`);
315
450
  const { id, from, to } = connection;
316
451
  if (!value.anchors[from] || !value.anchors[to]) {
317
452
  throw new Error(`Connection ${id} references an unknown anchor.`);
@@ -321,7 +456,7 @@ function parseChangeTourSource(value) {
321
456
  }
322
457
  connectionIds.add(id);
323
458
  }
324
- const authoredWalkthroughSteps = validateSourceChapters(
459
+ validateSourceChapters(
325
460
  value.chapters,
326
461
  "chapters",
327
462
  value.version,
@@ -329,10 +464,14 @@ function parseChangeTourSource(value) {
329
464
  connectionIds,
330
465
  "root"
331
466
  );
467
+ if (value.details !== void 0) {
468
+ if (value.version < 5) throw new Error("source.details requires version 5.");
469
+ validateChangeTourDetails(value.details, "details");
470
+ }
332
471
  if (value.tours !== void 0) {
333
- if (value.version !== 3) throw new Error("Independent tours require version 3.");
334
- if (!isRecord(value.tours)) throw new Error("tours must be an object.");
335
- requireOnlyKeys(value.tours, ["historical", "deconstructed"], "tours");
472
+ if (value.version < 4) throw new Error("Independent tours require version 4.");
473
+ if (!isRecord2(value.tours)) throw new Error("tours must be an object.");
474
+ requireOnlyKeys2(value.tours, ["historical", "deconstructed"], "tours");
336
475
  if (value.tours.historical === void 0 && value.tours.deconstructed === void 0) {
337
476
  throw new Error("tours must contain a historical or deconstructed tour.");
338
477
  }
@@ -340,15 +479,16 @@ function parseChangeTourSource(value) {
340
479
  const tour = value.tours[mode];
341
480
  if (tour === void 0) continue;
342
481
  const path = `tours.${mode}`;
343
- if (!isRecord(tour)) throw new Error(`${path} must be an object.`);
344
- requireOnlyKeys(tour, ["chapters"], path);
482
+ if (!isRecord2(tour)) throw new Error(`${path} must be an object.`);
483
+ requireOnlyKeys2(tour, ["chapters", "opening", "conclusion", "details"], path);
484
+ validateTourFraming(tour, value.version, path);
345
485
  validateSourceChapters(tour.chapters, `${path}.chapters`, value.version, value.anchors, connectionIds, mode);
486
+ if (tour.details !== void 0) {
487
+ if (value.version < 5) throw new Error(`${path}.details requires source version 5.`);
488
+ validateChangeTourDetails(tour.details, `${path}.details`);
489
+ }
346
490
  }
347
491
  }
348
- if (value.review !== void 0) {
349
- if (value.version !== 3) throw new Error("Review notes require version 3.");
350
- validateChangeTourReview(value.review, { authoredWalkthroughSteps });
351
- }
352
492
  return { ...value, connections: rawConnections };
353
493
  }
354
494
  function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, connectionIds, mode) {
@@ -357,27 +497,31 @@ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, c
357
497
  }
358
498
  const chapterIds = /* @__PURE__ */ new Set();
359
499
  const sceneIds = /* @__PURE__ */ new Set();
360
- const authoredWalkthroughSteps = /* @__PURE__ */ new Map();
361
500
  for (const [chapterIndex, chapter] of chaptersValue.entries()) {
362
501
  const chapterPath = `${chaptersPath}[${chapterIndex}]`;
363
- if (!isRecord(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
502
+ if (!isRecord2(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
364
503
  throw new Error(`${chapterPath} must contain a non-empty scenes array.`);
365
504
  }
366
- requireString(chapter.id, `${chapterPath}.id`);
367
- requireString(chapter.title, `${chapterPath}.title`);
368
- requireOnlyKeys(chapter, ["id", "title", "scenes"], chapterPath);
505
+ requireString2(chapter.id, `${chapterPath}.id`);
506
+ requireString2(chapter.title, `${chapterPath}.title`);
507
+ if (chapter.summary !== void 0) {
508
+ if (version < 5) throw new Error(`${chapterPath}.summary requires source version 5.`);
509
+ requireProse2(chapter.summary, `${chapterPath}.summary`);
510
+ }
511
+ requireOnlyKeys2(chapter, ["id", "title", "summary", "scenes"], chapterPath);
369
512
  if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
370
513
  chapterIds.add(chapter.id);
371
514
  for (const [sceneIndex, scene] of chapter.scenes.entries()) {
372
515
  const path = `${chapterPath}.scenes[${sceneIndex}]`;
373
- if (!isRecord(scene)) throw new Error(`${path} must be an object.`);
374
- requireString(scene.id, `${path}.id`);
375
- requireString(scene.title, `${path}.title`);
516
+ if (!isRecord2(scene)) throw new Error(`${path} must be an object.`);
517
+ requireString2(scene.id, `${path}.id`);
518
+ requireString2(scene.title, `${path}.title`);
376
519
  if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
377
520
  sceneIds.add(scene.id);
378
- if (scene.overview !== void 0 && version !== 3) {
379
- throw new Error(`${path}.overview requires version 3.`);
521
+ if (scene.overview !== void 0 && version < 4) {
522
+ throw new Error(`${path}.overview requires version 4.`);
380
523
  }
524
+ validateOverviewPurpose(scene.overview, version, `${path}.overview`);
381
525
  validateNarrative(scene, path);
382
526
  if (scene.kind === "deconstructed-diff") {
383
527
  if (mode === "historical") {
@@ -393,9 +537,6 @@ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, c
393
537
  }
394
538
  if (requireRealEvidence || scene.steps !== void 0) {
395
539
  validateWalkthroughSteps(scene, path, anchors, connectionIds);
396
- if (scene.steps !== void 0) {
397
- authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
398
- }
399
540
  }
400
541
  continue;
401
542
  }
@@ -412,22 +553,20 @@ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, c
412
553
  if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
413
554
  throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
414
555
  }
415
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "steps"], path);
556
+ requireOnlyKeys2(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "steps"], path);
416
557
  validateSceneOverview(scene.overview, `${path}.overview`, { allowComparison: false });
417
558
  validateWalkthroughSteps(scene, path, anchors, connectionIds);
418
- authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
419
559
  }
420
560
  }
421
- if (mode === "deconstructed" && !chaptersValue.some((chapter) => isRecord(chapter) && Array.isArray(chapter.scenes) && chapter.scenes.some((scene) => isRecord(scene) && scene.kind === "deconstructed-diff"))) {
561
+ if (mode === "deconstructed" && !chaptersValue.some((chapter) => isRecord2(chapter) && Array.isArray(chapter.scenes) && chapter.scenes.some((scene) => isRecord2(scene) && scene.kind === "deconstructed-diff"))) {
422
562
  throw new Error(`${chaptersPath} must contain at least one deconstructed-diff scene.`);
423
563
  }
424
- return authoredWalkthroughSteps;
425
564
  }
426
565
  function stackedOverviewEndpointIds(scene, path) {
427
566
  if (!Array.isArray(scene.stack)) {
428
567
  throw new Error(`${path}.stack must be an array.`);
429
568
  }
430
- return scene.stack.filter(isRecord).map((entry) => typeof entry.id === "string" ? entry.id : "").filter((id) => id.length > 0);
569
+ return scene.stack.filter(isRecord2).map((entry) => typeof entry.id === "string" ? entry.id : "").filter((id) => id.length > 0);
431
570
  }
432
571
  function deconstructedOverviewEndpointIds(scene, path) {
433
572
  if (!Array.isArray(scene.stages)) {
@@ -435,78 +574,11 @@ function deconstructedOverviewEndpointIds(scene, path) {
435
574
  }
436
575
  return [
437
576
  "explanation-baseline",
438
- ...scene.stages.filter(isRecord).map((stage) => typeof stage.id === "string" ? `explanation-stage-${stage.id}` : "").filter((id) => id.length > 0)
577
+ ...scene.stages.filter(isRecord2).map((stage) => typeof stage.id === "string" ? `explanation-stage-${stage.id}` : "").filter((id) => id.length > 0)
439
578
  ];
440
579
  }
441
- function validateChangeTourReview(value, context = {}) {
442
- if (!isRecord(value)) {
443
- throw new Error("review must be an object.");
444
- }
445
- requireOnlyKeys(value, ["baseOid", "headOid", "items"], "review");
446
- requireFullGitOid(value.baseOid, "review.baseOid");
447
- requireFullGitOid(value.headOid, "review.headOid");
448
- if (!Array.isArray(value.items) || value.items.length === 0) {
449
- throw new Error("review.items must be a non-empty array.");
450
- }
451
- if (context.expectedRange) {
452
- if (normalizeGitOid(value.baseOid) !== normalizeGitOid(context.expectedRange.baseOid)) {
453
- throw new Error(
454
- `Stale review: review.baseOid ${value.baseOid} does not match the resolved range.mergeBaseOid ${context.expectedRange.baseOid}.`
455
- );
456
- }
457
- if (normalizeGitOid(value.headOid) !== normalizeGitOid(context.expectedRange.headOid)) {
458
- throw new Error(
459
- `Stale review: review.headOid ${value.headOid} does not match the resolved range.headOid ${context.expectedRange.headOid}.`
460
- );
461
- }
462
- }
463
- const itemIds = /* @__PURE__ */ new Set();
464
- for (const [index, item] of value.items.entries()) {
465
- const itemPath = `review.items[${index}]`;
466
- if (!isRecord(item)) throw new Error(`${itemPath} must be an object.`);
467
- requireOnlyKeys(item, ["id", "kind", "title", "body", "evidence", "nextCheck"], itemPath);
468
- requireReviewString(item.id, `${itemPath}.id`);
469
- if (itemIds.has(item.id)) throw new Error(`Duplicate review item id: ${item.id}`);
470
- itemIds.add(item.id);
471
- if (typeof item.kind !== "string" || !["concept", "boundary", "tradeoff", "question"].includes(item.kind)) {
472
- throw new Error(`${itemPath}.kind must be concept, boundary, tradeoff, or question.`);
473
- }
474
- requireReviewString(item.title, `${itemPath}.title`);
475
- requireReviewString(item.body, `${itemPath}.body`);
476
- if (!Array.isArray(item.evidence)) {
477
- throw new Error(`${itemPath}.evidence must be an array.`);
478
- }
479
- if (item.kind !== "question" && item.evidence.length === 0) {
480
- throw new Error(`${itemPath}.evidence must contain at least one reference for claims.`);
481
- }
482
- if (item.kind === "question" && item.nextCheck === void 0) {
483
- throw new Error(`${itemPath}.nextCheck must be a non-empty string for question items.`);
484
- }
485
- if (item.nextCheck !== void 0) requireReviewString(item.nextCheck, `${itemPath}.nextCheck`);
486
- for (const [evidenceIndex, evidence] of item.evidence.entries()) {
487
- const evidencePath = `${itemPath}.evidence[${evidenceIndex}]`;
488
- if (!isRecord(evidence)) throw new Error(`${evidencePath} must be an object.`);
489
- requireOnlyKeys(evidence, ["sceneId", "stepId"], evidencePath);
490
- requireReviewString(evidence.sceneId, `${evidencePath}.sceneId`);
491
- requireReviewString(evidence.stepId, `${evidencePath}.stepId`);
492
- if (context.authoredWalkthroughSteps) {
493
- const stepIds = context.authoredWalkthroughSteps.get(evidence.sceneId);
494
- if (!stepIds) {
495
- throw new Error(
496
- `${evidencePath} references unknown authored walkthrough scene ${evidence.sceneId}.`
497
- );
498
- }
499
- if (!stepIds.has(evidence.stepId)) {
500
- throw new Error(
501
- `${evidencePath} references unknown authored walkthrough step ${evidence.sceneId}/${evidence.stepId}.`
502
- );
503
- }
504
- }
505
- }
506
- }
507
- }
508
580
  function validateDeconstructedScene(scene, path) {
509
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "base", "target", "stack", "steps", "stages", "exclusions"], path);
581
+ requireOnlyKeys2(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "base", "target", "stack", "steps", "stages", "exclusions"], path);
510
582
  optionalString(scene.base, `${path}.base`);
511
583
  optionalString(scene.target, `${path}.target`);
512
584
  if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
@@ -515,11 +587,11 @@ function validateDeconstructedScene(scene, path) {
515
587
  const stageIds = /* @__PURE__ */ new Set();
516
588
  for (const [stageIndex, stage] of scene.stages.entries()) {
517
589
  const stagePath = `${path}.stages[${stageIndex}]`;
518
- if (!isRecord(stage)) throw new Error(`${stagePath} must be an object.`);
519
- requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
520
- requireString(stage.id, `${stagePath}.id`);
521
- requireString(stage.title, `${stagePath}.title`);
522
- requireString(stage.narration, `${stagePath}.narration`);
590
+ if (!isRecord2(stage)) throw new Error(`${stagePath} must be an object.`);
591
+ requireOnlyKeys2(stage, ["id", "title", "narration", "changes"], stagePath);
592
+ requireString2(stage.id, `${stagePath}.id`);
593
+ requireString2(stage.title, `${stagePath}.title`);
594
+ requireString2(stage.narration, `${stagePath}.narration`);
523
595
  if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
524
596
  throw new Error(`${stagePath}.changes must be a non-empty array.`);
525
597
  }
@@ -547,16 +619,17 @@ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
547
619
  const stepIds = /* @__PURE__ */ new Set();
548
620
  for (const [stepIndex, step] of scene.steps.entries()) {
549
621
  const stepPath = `${path}.steps[${stepIndex}]`;
550
- if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
551
- requireString(step.id, `${stepPath}.id`);
552
- requireString(step.title, `${stepPath}.title`);
553
- requireString(step.body, `${stepPath}.body`);
554
- requireString(step.focus, `${stepPath}.focus`);
555
- requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth", "requirement"], stepPath);
622
+ if (!isRecord2(step)) throw new Error(`${stepPath} must be an object.`);
623
+ requireString2(step.id, `${stepPath}.id`);
624
+ requireString2(step.title, `${stepPath}.title`);
625
+ requireString2(step.body, `${stepPath}.body`);
626
+ requireString2(step.focus, `${stepPath}.focus`);
627
+ requireOnlyKeys2(step, ["id", "title", "body", "focus", "connection", "depth", "requirement", "image"], stepPath);
556
628
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
557
629
  throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
558
630
  }
559
631
  validateStepRequirement(step.requirement, `${stepPath}.requirement`);
632
+ validateTourImageSource(step.image, `${stepPath}.image`);
560
633
  if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
561
634
  stepIds.add(step.id);
562
635
  if (!anchors[step.focus]) throw new Error(`${stepPath} references unknown anchor ${step.focus}.`);
@@ -567,14 +640,14 @@ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
567
640
  }
568
641
  }
569
642
  function validateDeconstructedSelection(value, path, requiresReason) {
570
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
571
- requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
572
- requireString(value.file, `${path}.file`);
643
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
644
+ requireOnlyKeys2(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
645
+ requireString2(value.file, `${path}.file`);
573
646
  if (!requiresReason || value.hunks !== void 0) {
574
- requireStringArray(value.hunks, `${path}.hunks`);
647
+ requireStringArray2(value.hunks, `${path}.hunks`);
575
648
  if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
576
649
  }
577
- if (requiresReason) requireString(value.reason, `${path}.reason`);
650
+ if (requiresReason) requireString2(value.reason, `${path}.reason`);
578
651
  }
579
652
  function validateRealStack(stack, path, minimum) {
580
653
  if (!Array.isArray(stack) || stack.length < minimum || stack.length > 6) {
@@ -582,42 +655,42 @@ function validateRealStack(stack, path, minimum) {
582
655
  }
583
656
  const ids = /* @__PURE__ */ new Set();
584
657
  for (const [index, entry] of stack.entries()) {
585
- if (!isRecord(entry)) throw new Error(`${path}[${index}] must be an object.`);
586
- requireOnlyKeys(entry, ["id", "ref", "label"], `${path}[${index}]`);
587
- requireString(entry.id, `${path}[${index}].id`);
588
- requireString(entry.ref, `${path}[${index}].ref`);
658
+ if (!isRecord2(entry)) throw new Error(`${path}[${index}] must be an object.`);
659
+ requireOnlyKeys2(entry, ["id", "ref", "label"], `${path}[${index}]`);
660
+ requireString2(entry.id, `${path}[${index}].id`);
661
+ requireString2(entry.ref, `${path}[${index}].ref`);
589
662
  optionalString(entry.label, `${path}[${index}].label`);
590
663
  if (ids.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
591
664
  ids.add(entry.id);
592
665
  }
593
666
  }
594
667
  function validateStackedScene(scene, path, minimum = 3) {
595
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "stack", "files", "steps"], path);
668
+ requireOnlyKeys2(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "stack", "files", "steps"], path);
596
669
  if (!Array.isArray(scene.stack) || scene.stack.length < minimum || scene.stack.length > 6) {
597
670
  throw new Error(`${path}.stack must contain between ${minimum} and 6 revisions.`);
598
671
  }
599
672
  const stackIds = /* @__PURE__ */ new Set();
600
673
  for (const [index, entry] of scene.stack.entries()) {
601
674
  const entryPath = `${path}.stack[${index}]`;
602
- if (!isRecord(entry)) throw new Error(`${entryPath} must be an object.`);
603
- requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
604
- requireString(entry.id, `${entryPath}.id`);
605
- requireString(entry.ref, `${entryPath}.ref`);
675
+ if (!isRecord2(entry)) throw new Error(`${entryPath} must be an object.`);
676
+ requireOnlyKeys2(entry, ["id", "ref", "label"], entryPath);
677
+ requireString2(entry.id, `${entryPath}.id`);
678
+ requireString2(entry.ref, `${entryPath}.ref`);
606
679
  optionalString(entry.label, `${entryPath}.label`);
607
680
  if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
608
681
  stackIds.add(entry.id);
609
682
  }
610
- if (scene.files !== void 0) requireStringArray(scene.files, `${path}.files`);
683
+ if (scene.files !== void 0) requireStringArray2(scene.files, `${path}.files`);
611
684
  if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
612
685
  const stepIds = /* @__PURE__ */ new Set();
613
686
  for (const [index, step] of scene.steps.entries()) {
614
687
  const stepPath = `${path}.steps[${index}]`;
615
- if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
616
- requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
617
- requireString(step.id, `${stepPath}.id`);
618
- requireString(step.title, `${stepPath}.title`);
619
- requireString(step.body, `${stepPath}.body`);
620
- requireString(step.file, `${stepPath}.file`);
688
+ if (!isRecord2(step)) throw new Error(`${stepPath} must be an object.`);
689
+ requireOnlyKeys2(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
690
+ requireString2(step.id, `${stepPath}.id`);
691
+ requireString2(step.title, `${stepPath}.title`);
692
+ requireString2(step.body, `${stepPath}.body`);
693
+ requireString2(step.file, `${stepPath}.file`);
621
694
  const stepId = step.id;
622
695
  if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
623
696
  stepIds.add(stepId);
@@ -625,8 +698,8 @@ function validateStackedScene(scene, path, minimum = 3) {
625
698
  throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
626
699
  }
627
700
  const pair = step.pair;
628
- const leftIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[0]);
629
- const rightIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[1]);
701
+ const leftIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[0]);
702
+ const rightIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[1]);
630
703
  if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
631
704
  if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
632
705
  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]))) {
@@ -636,10 +709,10 @@ function validateStackedScene(scene, path, minimum = 3) {
636
709
  }
637
710
  function validateStepRequirement(value, path) {
638
711
  if (value === void 0) return;
639
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
640
- requireOnlyKeys(value, ["id", "text", "status", "source", "confidence"], path);
641
- requireString(value.id, `${path}.id`);
642
- requireString(value.text, `${path}.text`);
712
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
713
+ requireOnlyKeys2(value, ["id", "text", "status", "source", "confidence"], path);
714
+ requireString2(value.id, `${path}.id`);
715
+ requireString2(value.text, `${path}.text`);
643
716
  if (value.status !== void 0 && !["fulfilled", "gap"].includes(String(value.status))) {
644
717
  throw new Error(`${path}.status must be fulfilled or gap.`);
645
718
  }
@@ -650,20 +723,21 @@ function validateStepRequirement(value, path) {
650
723
  }
651
724
  function validateSceneOverview(value, path, options = {}) {
652
725
  if (value === void 0) return;
653
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
654
- requireOnlyKeys(value, ["kind", "path", "comparison"], path);
726
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
727
+ requireOnlyKeys2(value, ["kind", "path", "comparison", "purpose"], path);
655
728
  if (value.kind !== "directory-diff") {
656
729
  throw new Error(`${path}.kind must be directory-diff.`);
657
730
  }
731
+ if (value.purpose !== void 0) requireString2(value.purpose, `${path}.purpose`);
658
732
  if (value.path !== void 0) validateDirectoryOverviewPath(value.path, `${path}.path`);
659
733
  if (value.comparison === void 0) return;
660
734
  if (options.allowComparison === false) {
661
735
  throw new Error(`${path}.comparison is not supported for walkthrough scenes; use the fixed base-to-head comparison.`);
662
736
  }
663
- if (!isRecord(value.comparison)) throw new Error(`${path}.comparison must be an object.`);
664
- requireOnlyKeys(value.comparison, ["from", "to"], `${path}.comparison`);
665
- requireString(value.comparison.from, `${path}.comparison.from`);
666
- requireString(value.comparison.to, `${path}.comparison.to`);
737
+ if (!isRecord2(value.comparison)) throw new Error(`${path}.comparison must be an object.`);
738
+ requireOnlyKeys2(value.comparison, ["from", "to"], `${path}.comparison`);
739
+ requireString2(value.comparison.from, `${path}.comparison.from`);
740
+ requireString2(value.comparison.to, `${path}.comparison.to`);
667
741
  if (value.comparison.from === value.comparison.to) {
668
742
  throw new Error(`${path}.comparison.from and to must identify distinct endpoints.`);
669
743
  }
@@ -676,7 +750,7 @@ function validateSceneOverview(value, path, options = {}) {
676
750
  }
677
751
  }
678
752
  function validateDirectoryOverviewPath(value, path) {
679
- requireString(value, path);
753
+ requireString2(value, path);
680
754
  if (value.includes("\\")) {
681
755
  throw new Error(`${path} must use a relative POSIX directory path without backslashes.`);
682
756
  }
@@ -687,53 +761,36 @@ function validateDirectoryOverviewPath(value, path) {
687
761
  throw new Error(`${path} must not contain parent-directory traversal.`);
688
762
  }
689
763
  }
690
- function collectStepIds(value) {
691
- const ids = /* @__PURE__ */ new Set();
692
- if (!Array.isArray(value)) return ids;
693
- for (const step of value) {
694
- if (isRecord(step) && typeof step.id === "string") ids.add(step.id);
695
- }
696
- return ids;
697
- }
698
764
  function validateNarrative(value, path) {
699
- requireString(value.summary, `${path}.summary`);
700
- requireStringArray(value.bullets, `${path}.bullets`);
701
- requireStringArray(value.tags, `${path}.tags`);
702
- requireString(value.takeaway, `${path}.takeaway`);
765
+ requireString2(value.summary, `${path}.summary`);
766
+ requireStringArray2(value.bullets, `${path}.bullets`);
767
+ requireStringArray2(value.tags, `${path}.tags`);
768
+ requireString2(value.takeaway, `${path}.takeaway`);
703
769
  }
704
- function isRecord(value) {
770
+ function isRecord2(value) {
705
771
  return typeof value === "object" && value !== null && !Array.isArray(value);
706
772
  }
707
- function requireString(value, path) {
773
+ function requireString2(value, path) {
708
774
  if (typeof value !== "string" || value.length === 0) {
709
775
  throw new Error(`${path} must be a non-empty string.`);
710
776
  }
711
777
  }
712
- function requireFullGitOid(value, path) {
713
- requireString(value, path);
714
- if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)) {
715
- throw new Error(`${path} must be a full 40- or 64-character hexadecimal Git OID.`);
778
+ function optionalString(value, path) {
779
+ if (value !== void 0) {
780
+ requireString2(value, path);
716
781
  }
717
782
  }
718
- function requireReviewString(value, path) {
719
- if (typeof value !== "string" || value.trim().length === 0) {
783
+ function requireProse2(value, path) {
784
+ if (typeof value !== "string" || !value.trim()) {
720
785
  throw new Error(`${path} must be a non-empty string.`);
721
786
  }
722
787
  }
723
- function normalizeGitOid(value) {
724
- return value.toLowerCase();
725
- }
726
- function optionalString(value, path) {
727
- if (value !== void 0) {
728
- requireString(value, path);
729
- }
730
- }
731
- function requireStringArray(value, path) {
788
+ function requireStringArray2(value, path) {
732
789
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
733
790
  throw new Error(`${path} must be an array of strings.`);
734
791
  }
735
792
  }
736
- function requireOnlyKeys(value, allowed, path) {
793
+ function requireOnlyKeys2(value, allowed, path) {
737
794
  const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
738
795
  if (unexpected.length > 0) {
739
796
  throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
@@ -741,37 +798,42 @@ function requireOnlyKeys(value, allowed, path) {
741
798
  }
742
799
 
743
800
  // src/changeTourManifest.ts
744
- var CHANGE_TOUR_MANIFEST_VERSION = 3;
745
- function parseChangeTourManifest(value) {
746
- if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
801
+ var CHANGE_TOUR_MANIFEST_VERSION = 5;
802
+ function parseChangeTourManifest(value, options = {}) {
803
+ if (isRecord3(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
747
804
  throw new Error(
748
805
  `This tour uses manifest format version ${value.version}, but this version of Bygone supports up to version ${CHANGE_TOUR_MANIFEST_VERSION}. Upgrade Bygone to open it.`
749
806
  );
750
807
  }
751
- if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
808
+ if (isRecord3(value) && value.version === 3) {
809
+ throw new Error("Tour manifest format version 3 is retired. Remove the review block, set source version to 4, and recompile the tour.");
810
+ }
811
+ if (!isRecord3(value) || value.version !== 1 && value.version !== 2 && value.version !== 4 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
752
812
  throw new Error(`Unsupported or missing change-tour manifest version.`);
753
813
  }
754
- requireString2(value.title, "title");
755
- requireString2(value.generatedAt, "generatedAt");
814
+ if ("review" in value) throw new Error("Change-tour manifests do not support the review field. Put observations in scene and step narrative.");
815
+ validateTourFraming(value, value.version, "manifest");
816
+ requireString3(value.title, "title");
817
+ requireString3(value.generatedAt, "generatedAt");
756
818
  if (value.windowTitle !== void 0) {
757
- requireString2(value.windowTitle, "windowTitle");
819
+ requireString3(value.windowTitle, "windowTitle");
758
820
  }
759
821
  if (value.sourceUrl !== void 0) {
760
- requireString2(value.sourceUrl, "sourceUrl");
822
+ requireString3(value.sourceUrl, "sourceUrl");
761
823
  }
762
- if (!isRecord2(value.range)) {
824
+ if (!isRecord3(value.range)) {
763
825
  throw new Error("Change-tour manifest range must be an object.");
764
826
  }
765
827
  for (const key of ["baseRef", "headRef", "mergeBaseOid", "headOid"]) {
766
- requireString2(value.range[key], `range.${key}`);
828
+ requireString3(value.range[key], `range.${key}`);
767
829
  }
768
- if (!isRecord2(value.summary)) {
830
+ if (!isRecord3(value.summary)) {
769
831
  throw new Error("Change-tour manifest summary must be an object.");
770
832
  }
771
833
  for (const key of ["changedFiles", "includedScenes", "additions", "deletions", "commitCount"]) {
772
834
  requireNonNegativeInteger(value.summary[key], `summary.${key}`);
773
835
  }
774
- requireStringArray2(value.summary.omittedFiles, "summary.omittedFiles");
836
+ requireStringArray3(value.summary.omittedFiles, "summary.omittedFiles");
775
837
  if (!Array.isArray(value.commits) || !Array.isArray(value.chapters) || !Array.isArray(value.scenes)) {
776
838
  throw new Error("Change-tour manifest commits, chapters, and scenes must be arrays.");
777
839
  }
@@ -787,12 +849,13 @@ function parseChangeTourManifest(value) {
787
849
  sceneIds.add(candidate.id);
788
850
  }
789
851
  for (const [index, candidate] of value.chapters.entries()) {
790
- if (!isRecord2(candidate)) {
852
+ if (!isRecord3(candidate)) {
791
853
  throw new Error(`chapters[${index}] must be an object.`);
792
854
  }
793
- requireString2(candidate.id, `chapters[${index}].id`);
794
- requireString2(candidate.title, `chapters[${index}].title`);
795
- requireStringArray2(candidate.sceneIds, `chapters[${index}].sceneIds`);
855
+ requireString3(candidate.id, `chapters[${index}].id`);
856
+ requireString3(candidate.title, `chapters[${index}].title`);
857
+ validateChapterSummary(candidate.summary, value.version, `chapters[${index}].summary`);
858
+ requireStringArray3(candidate.sceneIds, `chapters[${index}].sceneIds`);
796
859
  for (const sceneId of candidate.sceneIds) {
797
860
  if (!sceneIds.has(sceneId)) {
798
861
  throw new Error(`Chapter references unknown scene id: ${sceneId}`);
@@ -802,6 +865,10 @@ function parseChangeTourManifest(value) {
802
865
  if (value.summary.includedScenes !== value.scenes.length) {
803
866
  throw new Error("summary.includedScenes must match the number of scenes.");
804
867
  }
868
+ if (value.details !== void 0) {
869
+ if (value.version !== 5) throw new Error("details requires manifest version 5.");
870
+ validateChangeTourDetails(value.details, "details");
871
+ }
805
872
  const files = value.files === void 0 ? collectLegacyTourFiles(value.scenes) : value.files.map((candidate, index) => {
806
873
  validateTourFile(candidate, index);
807
874
  return candidate;
@@ -813,40 +880,14 @@ function parseChangeTourManifest(value) {
813
880
  validateAuthoringCoverage(value.authoringCoverage, value.version);
814
881
  }
815
882
  if (value.tours !== void 0) {
816
- if (value.version !== 3) throw new Error("Independent tours require manifest version 3.");
883
+ if (value.version !== 4 && value.version !== 5) throw new Error("Independent tours require manifest version 4.");
817
884
  validateModeTours(value.tours, value.version);
818
885
  }
819
- if (value.version !== 1) validateZoom(value, value.version);
820
- if (value.review !== void 0) {
821
- if (value.version !== 3) throw new Error("Review notes require manifest version 3.");
822
- const evidenceScenes = value.version >= 2 && isRecord2(value.zoom) && isRecord2(value.zoom.final) ? value.zoom.final.scenes : value.scenes;
823
- validateChangeTourReview(value.review, {
824
- expectedRange: {
825
- baseOid: String(value.range.mergeBaseOid),
826
- headOid: String(value.range.headOid)
827
- },
828
- authoredWalkthroughSteps: collectAuthoredWalkthroughStepIds(evidenceScenes)
829
- });
830
- }
886
+ if (value.version !== 1) validateZoom(value, value.version, Boolean(options.exported));
831
887
  return { ...value, files };
832
888
  }
833
- function collectAuthoredWalkthroughStepIds(value) {
834
- const scenes = /* @__PURE__ */ new Map();
835
- if (!Array.isArray(value)) return scenes;
836
- for (const scene of value) {
837
- if (!isRecord2(scene) || scene.kind !== "walkthrough" || !Array.isArray(scene.steps) || typeof scene.id !== "string") {
838
- continue;
839
- }
840
- const stepIds = /* @__PURE__ */ new Set();
841
- for (const step of scene.steps) {
842
- if (isRecord2(step) && typeof step.id === "string") stepIds.add(step.id);
843
- }
844
- scenes.set(scene.id, stepIds);
845
- }
846
- return scenes;
847
- }
848
889
  function validateAuthoringCoverage(value, version) {
849
- if (!isRecord2(value) || !isRecord2(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
890
+ if (!isRecord3(value) || !isRecord3(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
850
891
  throw new Error("authoringCoverage must contain walkthrough and explanationAssignments.");
851
892
  }
852
893
  const expectedScope = version >= 2 ? "final" : "tour";
@@ -861,8 +902,8 @@ function validateAuthoringCoverage(value, version) {
861
902
  }
862
903
  for (const [index, assignment] of value.explanationAssignments.entries()) {
863
904
  const path = `authoringCoverage.explanationAssignments[${index}]`;
864
- if (!isRecord2(assignment)) throw new Error(`${path} must be an object.`);
865
- requireString2(assignment.sceneId, `${path}.sceneId`);
905
+ if (!isRecord3(assignment)) throw new Error(`${path} must be an object.`);
906
+ requireString3(assignment.sceneId, `${path}.sceneId`);
866
907
  requireNonNegativeInteger(assignment.assignedUnits, `${path}.assignedUnits`);
867
908
  requireNonNegativeInteger(assignment.excludedUnits, `${path}.excludedUnits`);
868
909
  requireNonNegativeInteger(assignment.completeUnits, `${path}.completeUnits`);
@@ -874,16 +915,17 @@ function validateAuthoringCoverage(value, version) {
874
915
  }
875
916
  }
876
917
  function validateModeTours(value, version) {
877
- if (!isRecord2(value)) throw new Error("Change-tour manifest tours must be an object.");
918
+ if (!isRecord3(value)) throw new Error("Change-tour manifest tours must be an object.");
878
919
  if (value.historical === void 0 && value.deconstructed === void 0) {
879
920
  throw new Error("Change-tour manifest tours must contain a historical or deconstructed tour.");
880
921
  }
881
922
  for (const mode of ["historical", "deconstructed"]) {
882
923
  const tour = value[mode];
883
924
  if (tour === void 0) continue;
884
- if (!isRecord2(tour) || !Array.isArray(tour.chapters) || !Array.isArray(tour.scenes) || tour.chapters.length === 0 || tour.scenes.length === 0) {
925
+ if (!isRecord3(tour) || !Array.isArray(tour.chapters) || !Array.isArray(tour.scenes) || tour.chapters.length === 0 || tour.scenes.length === 0) {
885
926
  throw new Error(`Change-tour manifest tours.${mode} must contain non-empty chapters and scenes arrays.`);
886
927
  }
928
+ validateTourFraming(tour, version, `tours.${mode}`);
887
929
  const sceneIds = /* @__PURE__ */ new Set();
888
930
  let hasDeconstructed = false;
889
931
  for (const [index, scene] of tour.scenes.entries()) {
@@ -897,16 +939,21 @@ function validateModeTours(value, version) {
897
939
  throw new Error("Historical tours cannot contain synthetic deconstructed scenes.");
898
940
  }
899
941
  }
942
+ if (tour.details !== void 0) {
943
+ if (version !== 5) throw new Error(`tours.${mode}.details requires manifest version 5.`);
944
+ validateChangeTourDetails(tour.details, `tours.${mode}.details`);
945
+ }
900
946
  if (mode === "deconstructed" && !hasDeconstructed) {
901
947
  throw new Error("Deconstructed tours must contain a constructed deconstructed-diff scene.");
902
948
  }
903
949
  for (const [index, chapter] of tour.chapters.entries()) {
904
- if (!isRecord2(chapter)) {
950
+ if (!isRecord3(chapter)) {
905
951
  throw new Error(`tours.${mode}.chapters[${index}] must be an object.`);
906
952
  }
907
- requireString2(chapter.id, `tours.${mode}.chapters[${index}].id`);
908
- requireString2(chapter.title, `tours.${mode}.chapters[${index}].title`);
909
- requireStringArray2(chapter.sceneIds, `tours.${mode}.chapters[${index}].sceneIds`);
953
+ requireString3(chapter.id, `tours.${mode}.chapters[${index}].id`);
954
+ requireString3(chapter.title, `tours.${mode}.chapters[${index}].title`);
955
+ validateChapterSummary(chapter.summary, version, `tours.${mode}.chapters[${index}].summary`);
956
+ requireStringArray3(chapter.sceneIds, `tours.${mode}.chapters[${index}].sceneIds`);
910
957
  for (const sceneId of chapter.sceneIds) {
911
958
  if (!sceneIds.has(sceneId)) {
912
959
  throw new Error(`tours.${mode} chapter references unknown scene id: ${sceneId}`);
@@ -916,28 +963,28 @@ function validateModeTours(value, version) {
916
963
  }
917
964
  }
918
965
  function parseChangeTourStory(value) {
919
- if (!isRecord2(value) || !Array.isArray(value.scenes)) {
966
+ if (!isRecord3(value) || !Array.isArray(value.scenes)) {
920
967
  throw new Error("Change-tour story must contain a scenes array.");
921
968
  }
922
969
  if (value.title !== void 0) {
923
- requireString2(value.title, "title");
970
+ requireString3(value.title, "title");
924
971
  }
925
972
  if (value.sourceUrl !== void 0) {
926
- requireString2(value.sourceUrl, "sourceUrl");
973
+ requireString3(value.sourceUrl, "sourceUrl");
927
974
  }
928
975
  for (const [index, scene] of value.scenes.entries()) {
929
- if (!isRecord2(scene) || scene.kind !== "discussion" && scene.kind !== "file") {
976
+ if (!isRecord3(scene) || scene.kind !== "discussion" && scene.kind !== "file") {
930
977
  throw new Error(`story.scenes[${index}] must be a discussion or file scene.`);
931
978
  }
932
- requireString2(scene.chapterId, `story.scenes[${index}].chapterId`);
933
- requireString2(scene.chapterTitle, `story.scenes[${index}].chapterTitle`);
979
+ requireString3(scene.chapterId, `story.scenes[${index}].chapterId`);
980
+ requireString3(scene.chapterTitle, `story.scenes[${index}].chapterTitle`);
934
981
  validateNarrative2(scene, `story.scenes[${index}]`);
935
982
  if (scene.kind === "discussion") {
936
- requireString2(scene.title, `story.scenes[${index}].title`);
983
+ requireString3(scene.title, `story.scenes[${index}].title`);
937
984
  } else {
938
- requireString2(scene.path, `story.scenes[${index}].path`);
985
+ requireString3(scene.path, `story.scenes[${index}].path`);
939
986
  if (scene.title !== void 0) {
940
- requireString2(scene.title, `story.scenes[${index}].title`);
987
+ requireString3(scene.title, `story.scenes[${index}].title`);
941
988
  }
942
989
  if (scene.focusChangeIndex !== void 0) {
943
990
  requireNonNegativeInteger(scene.focusChangeIndex, `story.scenes[${index}].focusChangeIndex`);
@@ -946,11 +993,11 @@ function parseChangeTourStory(value) {
946
993
  }
947
994
  return value;
948
995
  }
949
- function validateZoom(value, version) {
950
- if (!isRecord2(value.repository) || typeof value.repository.root !== "string" || !value.repository.root) {
996
+ function validateZoom(value, version, exported) {
997
+ if (!exported && (!isRecord3(value.repository) || typeof value.repository.root !== "string" || !value.repository.root)) {
951
998
  throw new Error("A v2 tour requires its originating repository.root.");
952
999
  }
953
- if (!isRecord2(value.zoom) || !Array.isArray(value.zoom.revisions) || !isRecord2(value.zoom.final)) {
1000
+ if (!isRecord3(value.zoom) || !Array.isArray(value.zoom.revisions) || !isRecord3(value.zoom.final)) {
954
1001
  throw new Error("A v2 tour requires zoom metadata and real revision evidence.");
955
1002
  }
956
1003
  const scenes = value.scenes;
@@ -995,27 +1042,29 @@ function validateZoom(value, version) {
995
1042
  finalSceneIds.add(scene.id);
996
1043
  }
997
1044
  for (const [index, chapter] of value.zoom.final.chapters.entries()) {
998
- if (!isRecord2(chapter) || !Array.isArray(chapter.sceneIds)) {
1045
+ if (!isRecord3(chapter) || !Array.isArray(chapter.sceneIds)) {
999
1046
  throw new Error(`zoom.final.chapters[${index}] must contain sceneIds.`);
1000
1047
  }
1001
- requireString2(chapter.id, `zoom.final.chapters[${index}].id`);
1002
- requireString2(chapter.title, `zoom.final.chapters[${index}].title`);
1048
+ requireString3(chapter.id, `zoom.final.chapters[${index}].id`);
1049
+ requireString3(chapter.title, `zoom.final.chapters[${index}].title`);
1050
+ validateChapterSummary(chapter.summary, version, `zoom.final.chapters[${index}].summary`);
1003
1051
  for (const sceneId of chapter.sceneIds) {
1004
- requireString2(sceneId, `zoom.final.chapters[${index}].sceneIds`);
1052
+ requireString3(sceneId, `zoom.final.chapters[${index}].sceneIds`);
1005
1053
  if (!finalSceneIds.has(sceneId)) throw new Error(`Final tour chapter references unknown scene: ${sceneId}`);
1006
1054
  }
1007
1055
  }
1008
1056
  }
1009
1057
  function validateScene(value, index, version) {
1010
- if (!isRecord2(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
1058
+ if (!isRecord3(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
1011
1059
  throw new Error(`scenes[${index}] must be a text-diff, discussion, walkthrough, stacked-diff, or deconstructed-diff scene.`);
1012
1060
  }
1013
1061
  for (const key of ["id", "title"]) {
1014
- requireString2(value[key], `scenes[${index}].${key}`);
1062
+ requireString3(value[key], `scenes[${index}].${key}`);
1015
1063
  }
1016
- if (value.overview !== void 0 && version !== 3) {
1017
- throw new Error(`scenes[${index}].overview requires manifest version 3.`);
1064
+ if (value.overview !== void 0 && version < 4) {
1065
+ throw new Error(`scenes[${index}].overview requires manifest version 4.`);
1018
1066
  }
1067
+ validateOverviewPurpose(value.overview, version, `scenes[${index}].overview`);
1019
1068
  validateNarrative2(value, `scenes[${index}]`);
1020
1069
  if (value.kind === "discussion") {
1021
1070
  validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
@@ -1028,21 +1077,22 @@ function validateScene(value, index, version) {
1028
1077
  }
1029
1078
  for (const [stepIndex, step] of value.steps.entries()) {
1030
1079
  const path = `scenes[${index}].steps[${stepIndex}]`;
1031
- if (!isRecord2(step) || !isRecord2(step.focus) || !isRecord2(step.diff)) {
1080
+ if (!isRecord3(step) || !isRecord3(step.focus) || !isRecord3(step.diff)) {
1032
1081
  throw new Error(`${path} must contain focus and diff objects.`);
1033
1082
  }
1034
- for (const key of ["id", "title", "body"]) requireString2(step[key], `${path}.${key}`);
1083
+ for (const key of ["id", "title", "body"]) requireString3(step[key], `${path}.${key}`);
1035
1084
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
1036
1085
  throw new Error(`${path}.depth must be mentioned, explained, or contextualized.`);
1037
1086
  }
1038
1087
  validateStepRequirement(step.requirement, `${path}.requirement`);
1088
+ validateTourImage(step.image, `${path}.image`);
1039
1089
  validateResolvedAnchor(step.focus, `${path}.focus`);
1040
1090
  validateScene(step.diff, index, version);
1041
1091
  if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
1042
1092
  if (step.connection !== void 0) {
1043
- if (!isRecord2(step.connection)) throw new Error(`${path}.connection must be an object.`);
1044
- requireString2(step.connection.id, `${path}.connection.id`);
1045
- requireString2(step.connection.label, `${path}.connection.label`);
1093
+ if (!isRecord3(step.connection)) throw new Error(`${path}.connection must be an object.`);
1094
+ requireString3(step.connection.id, `${path}.connection.id`);
1095
+ requireString3(step.connection.label, `${path}.connection.label`);
1046
1096
  validateResolvedAnchor(step.connection.from, `${path}.connection.from`);
1047
1097
  validateResolvedAnchor(step.connection.to, `${path}.connection.to`);
1048
1098
  }
@@ -1065,12 +1115,12 @@ function validateScene(value, index, version) {
1065
1115
  }
1066
1116
  validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
1067
1117
  for (const key of ["path", "leftLabel", "rightLabel", "leftContent", "rightContent"]) {
1068
- requireString2(value[key], `scenes[${index}].${key}`);
1118
+ requireString3(value[key], `scenes[${index}].${key}`);
1069
1119
  }
1070
1120
  if (value.previousPath !== void 0) {
1071
- requireString2(value.previousPath, `scenes[${index}].previousPath`);
1121
+ requireString3(value.previousPath, `scenes[${index}].previousPath`);
1072
1122
  }
1073
- requireString2(value.changeKind, `scenes[${index}].changeKind`);
1123
+ requireString3(value.changeKind, `scenes[${index}].changeKind`);
1074
1124
  requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
1075
1125
  requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
1076
1126
  if (value.focusChangeIndex !== void 0) {
@@ -1082,8 +1132,8 @@ function validateStackedScene2(value, index) {
1082
1132
  throw new Error(`scenes[${index}].stack must contain between 2 and 6 revisions.`);
1083
1133
  }
1084
1134
  for (const [panelIndex, panel] of value.stack.entries()) {
1085
- if (!isRecord2(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
1086
- for (const key of ["id", "ref", "oid", "label"]) requireString2(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
1135
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
1136
+ for (const key of ["id", "ref", "oid", "label"]) requireString3(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
1087
1137
  }
1088
1138
  validateMultiPanelFiles(value.files, value.stack.length, index);
1089
1139
  validateMultiPanelSteps(value.steps, value.stack.length, index, false);
@@ -1092,33 +1142,33 @@ function validateDeconstructedScene2(value, index) {
1092
1142
  if (value.stageLabel !== "Explanation stages") {
1093
1143
  throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
1094
1144
  }
1095
- if (!isRecord2(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1145
+ if (!isRecord3(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1096
1146
  for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
1097
- requireString2(value.realRange[key], `scenes[${index}].realRange.${key}`);
1147
+ requireString3(value.realRange[key], `scenes[${index}].realRange.${key}`);
1098
1148
  }
1099
1149
  if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
1100
1150
  throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
1101
1151
  }
1102
1152
  const panels = value.panels;
1103
1153
  for (const [panelIndex, panel] of panels.entries()) {
1104
- if (!isRecord2(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
1105
- for (const key of ["id", "label", "role"]) requireString2(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
1154
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
1155
+ for (const key of ["id", "label", "role"]) requireString3(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
1106
1156
  const expectedRole = panelIndex === 0 ? "baseline" : "stage";
1107
1157
  if (panel.role !== expectedRole) throw new Error(`scenes[${index}].panels[${panelIndex}].role must be ${expectedRole}.`);
1108
- if (panelIndex > 0) requireString2(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
1158
+ if (panelIndex > 0) requireString3(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
1109
1159
  }
1110
1160
  validateMultiPanelFiles(value.files, panels.length, index);
1111
1161
  validateMultiPanelSteps(value.steps, panels.length, index, true);
1112
1162
  if (Array.isArray(value.steps)) {
1113
1163
  let previousPairIndex = -1;
1114
1164
  value.steps.forEach((step, stepIndex) => {
1115
- if (!isRecord2(step)) return;
1165
+ if (!isRecord3(step)) return;
1116
1166
  const pairIndex = Number(step.pairIndex);
1117
1167
  if (pairIndex < previousPairIndex) {
1118
1168
  throw new Error(`scenes[${index}].steps must remain grouped in explanation stage order.`);
1119
1169
  }
1120
1170
  previousPairIndex = pairIndex;
1121
- if (step.stageId !== void 0) requireString2(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1171
+ if (step.stageId !== void 0) requireString3(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1122
1172
  if (step.stageIndex !== void 0) requireNonNegativeInteger(step.stageIndex, `scenes[${index}].steps[${stepIndex}].stageIndex`);
1123
1173
  if (step.focusIndex !== void 0) requireNonNegativeInteger(step.focusIndex, `scenes[${index}].steps[${stepIndex}].focusIndex`);
1124
1174
  if (step.focusCount !== void 0) requirePositiveInteger(step.focusCount, `scenes[${index}].steps[${stepIndex}].focusCount`);
@@ -1131,14 +1181,14 @@ function validateDeconstructedScene2(value, index) {
1131
1181
  });
1132
1182
  for (let stageIndex = 0; stageIndex < panels.length - 1; stageIndex += 1) {
1133
1183
  const panel = panels[stageIndex + 1];
1134
- const stageSteps = value.steps.filter((step) => isRecord2(step) && Number(step.pairIndex) === stageIndex);
1135
- if (!isRecord2(panel) || stageSteps.length === 0) {
1184
+ const stageSteps = value.steps.filter((step) => isRecord3(step) && Number(step.pairIndex) === stageIndex);
1185
+ if (!isRecord3(panel) || stageSteps.length === 0) {
1136
1186
  throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
1137
1187
  }
1138
1188
  const firstStep = stageSteps[0];
1139
- const fallbackStageId = isRecord2(firstStep) ? firstStep.id : void 0;
1189
+ const fallbackStageId = isRecord3(firstStep) ? firstStep.id : void 0;
1140
1190
  if (stageSteps.some((step) => {
1141
- if (!isRecord2(step)) return true;
1191
+ if (!isRecord3(step)) return true;
1142
1192
  return (step.stageId ?? fallbackStageId) !== panel.stageId;
1143
1193
  })) {
1144
1194
  throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
@@ -1149,15 +1199,15 @@ function validateDeconstructedScene2(value, index) {
1149
1199
  function validateMultiPanelFiles(value, panelCount, index) {
1150
1200
  if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
1151
1201
  for (const [fileIndex, file] of value.entries()) {
1152
- if (!isRecord2(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1153
- requireString2(file.path, `scenes[${index}].files[${fileIndex}].path`);
1202
+ if (!isRecord3(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1203
+ requireString3(file.path, `scenes[${index}].files[${fileIndex}].path`);
1154
1204
  if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
1155
1205
  throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
1156
1206
  }
1157
1207
  for (const [panelIndex, panel] of file.panels.entries()) {
1158
- if (!isRecord2(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
1159
- for (const key of ["id", "label", "content"]) requireString2(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
1160
- if (panel.path !== void 0) requireString2(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
1208
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
1209
+ for (const key of ["id", "label", "content"]) requireString3(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
1210
+ if (panel.path !== void 0) requireString3(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
1161
1211
  if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
1162
1212
  }
1163
1213
  }
@@ -1165,12 +1215,12 @@ function validateMultiPanelFiles(value, panelCount, index) {
1165
1215
  function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1166
1216
  if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
1167
1217
  for (const [stepIndex, step] of value.entries()) {
1168
- if (!isRecord2(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
1169
- for (const key of ["id", "title", "body", "file", "side"]) requireString2(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
1218
+ if (!isRecord3(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
1219
+ for (const key of ["id", "title", "body", "file", "side"]) requireString3(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
1170
1220
  requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
1171
1221
  if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
1172
1222
  if (deconstructed) {
1173
- requireStringArray2(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1223
+ requireStringArray3(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1174
1224
  if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
1175
1225
  }
1176
1226
  if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
@@ -1179,7 +1229,7 @@ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1179
1229
  }
1180
1230
  }
1181
1231
  function validateTourFile(value, index) {
1182
- if (!isRecord2(value)) throw new Error(`files[${index}] must be an object.`);
1232
+ if (!isRecord3(value)) throw new Error(`files[${index}] must be an object.`);
1183
1233
  if (value.kind === "text-diff") {
1184
1234
  validateScene(value, index, 1);
1185
1235
  return;
@@ -1188,9 +1238,9 @@ function validateTourFile(value, index) {
1188
1238
  throw new Error(`files[${index}].kind must be text-diff or omitted.`);
1189
1239
  }
1190
1240
  for (const key of ["id", "title", "path", "changeKind", "reason"]) {
1191
- requireString2(value[key], `files[${index}].${key}`);
1241
+ requireString3(value[key], `files[${index}].${key}`);
1192
1242
  }
1193
- if (value.previousPath !== void 0) requireString2(value.previousPath, `files[${index}].previousPath`);
1243
+ if (value.previousPath !== void 0) requireString3(value.previousPath, `files[${index}].previousPath`);
1194
1244
  requireNonNegativeInteger(value.additions, `files[${index}].additions`);
1195
1245
  requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
1196
1246
  }
@@ -1210,28 +1260,35 @@ function collectLegacyTourFiles(scenes) {
1210
1260
  return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
1211
1261
  }
1212
1262
  function validateResolvedAnchor(value, path) {
1213
- if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
1214
- for (const key of ["id", "path", "revision", "excerpt"]) requireString2(value[key], `${path}.${key}`);
1263
+ if (!isRecord3(value)) throw new Error(`${path} must be an object.`);
1264
+ for (const key of ["id", "path", "revision", "excerpt"]) requireString3(value[key], `${path}.${key}`);
1215
1265
  if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
1216
1266
  requirePositiveInteger(value.startLine, `${path}.startLine`);
1217
1267
  requirePositiveInteger(value.endLine, `${path}.endLine`);
1218
1268
  if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
1219
1269
  }
1220
1270
  function validateNarrative2(value, path) {
1221
- requireString2(value.summary, `${path}.summary`);
1222
- requireStringArray2(value.bullets, `${path}.bullets`);
1223
- requireStringArray2(value.tags, `${path}.tags`);
1224
- requireString2(value.takeaway, `${path}.takeaway`);
1271
+ requireString3(value.summary, `${path}.summary`);
1272
+ requireStringArray3(value.bullets, `${path}.bullets`);
1273
+ requireStringArray3(value.tags, `${path}.tags`);
1274
+ requireString3(value.takeaway, `${path}.takeaway`);
1225
1275
  }
1226
- function isRecord2(value) {
1276
+ function validateChapterSummary(value, version, path) {
1277
+ if (value === void 0) return;
1278
+ if (version < 5) throw new Error(`${path} requires manifest version 5.`);
1279
+ if (typeof value !== "string" || !value.trim()) {
1280
+ throw new Error(`${path} must be a non-empty string.`);
1281
+ }
1282
+ }
1283
+ function isRecord3(value) {
1227
1284
  return typeof value === "object" && value !== null && !Array.isArray(value);
1228
1285
  }
1229
- function requireString2(value, path) {
1286
+ function requireString3(value, path) {
1230
1287
  if (typeof value !== "string") {
1231
1288
  throw new Error(`${path} must be a string.`);
1232
1289
  }
1233
1290
  }
1234
- function requireStringArray2(value, path) {
1291
+ function requireStringArray3(value, path) {
1235
1292
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1236
1293
  throw new Error(`${path} must be an array of strings.`);
1237
1294
  }
@@ -2018,7 +2075,7 @@ function diffArrays(oldArr, newArr, callback) {
2018
2075
  }
2019
2076
 
2020
2077
  // src/diffEngine.ts
2021
- var DEFAULT_MAX_INLINE_HIGHLIGHT_LINE_LENGTH = 500;
2078
+ var DEFAULT_MAX_INLINE_HIGHLIGHT_LINE_LENGTH = 2e4;
2022
2079
  var DEFAULT_DIFF_TIMEOUT_MS = 100;
2023
2080
  var MAX_INLINE_HIGHLIGHT_LINE_LENGTH = readPositiveIntegerEnv(
2024
2081
  "BYGONE_MAX_INLINE_HIGHLIGHT_LINE_LENGTH",
@@ -2031,6 +2088,22 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
2031
2088
  if (leftContent === rightContent) {
2032
2089
  return buildIdenticalTwoWayDiffModel(leftLines);
2033
2090
  }
2091
+ if (leftLines.length === rightLines.length && leftLines.every((line, index) => line === rightLines[index])) {
2092
+ const model = buildIdenticalTwoWayDiffModel(leftLines);
2093
+ const rawLeft = leftContent.match(/[^\n]*\n|[^\n]+$/g) || [];
2094
+ const rawRight = rightContent.match(/[^\n]*\n|[^\n]+$/g) || [];
2095
+ leftLines.forEach((_, index) => {
2096
+ if (rawLeft[index] === rawRight[index]) return;
2097
+ model.leftLines[index].kind = model.rows[index].left.kind = "removed";
2098
+ model.rightLines[index].kind = model.rows[index].right.kind = "added";
2099
+ model.leftLines[index].inlineDetail = model.rightLines[index].inlineDetail = "line-ending";
2100
+ model.blocks.push(makeDiffBlock("replace", index, index + 1, index, index + 1));
2101
+ });
2102
+ model.blocks = mergeTouchingBlocks(model.blocks);
2103
+ model.hasChanges = true;
2104
+ model.formattingOnly = true;
2105
+ return model;
2106
+ }
2034
2107
  const interned = internDiffLines(leftLines, rightLines);
2035
2108
  const internedChanges = diffArrays(interned.left, interned.right, {
2036
2109
  timeout: options.timeoutMs ?? DIFF_TIMEOUT_MS
@@ -2107,14 +2180,54 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
2107
2180
  }
2108
2181
  }
2109
2182
  const hasChanges = rows.some((row) => row.left.kind !== "context" || row.right.kind !== "context");
2110
- return {
2183
+ return markUndetailedReplacements({
2111
2184
  rows,
2112
2185
  leftLines: renderedLeftLines,
2113
2186
  rightLines: renderedRightLines,
2114
- blocks,
2187
+ blocks: mergeTouchingBlocks(blocks),
2188
+ reflows: blocks.filter((block) => block.reflow),
2115
2189
  hasChanges,
2116
2190
  quality: "exact"
2117
- };
2191
+ });
2192
+ }
2193
+ function markUndetailedReplacements(model) {
2194
+ for (const block of model.blocks) {
2195
+ if (block.kind !== "replace") continue;
2196
+ for (const side of ["left", "right"]) {
2197
+ for (let index = block[`${side}Start`]; index < block[`${side}End`]; index++) {
2198
+ const line = model[`${side}Lines`][index];
2199
+ if (line.kind !== "context" && !line.segments) line.inlineDetail = "omitted";
2200
+ }
2201
+ }
2202
+ }
2203
+ return model;
2204
+ }
2205
+ function mergeTouchingBlocks(blocks) {
2206
+ const regions = [];
2207
+ let oneSidedRun = false;
2208
+ for (const original of blocks) {
2209
+ const oneSided = original.kind !== "replace";
2210
+ const previous = regions[regions.length - 1];
2211
+ if (oneSided && oneSidedRun && previous.leftEnd === original.leftStart && previous.rightEnd === original.rightStart) {
2212
+ previous.leftEnd = original.leftEnd;
2213
+ previous.rightEnd = original.rightEnd;
2214
+ if (previous.leftEnd > previous.leftStart && previous.rightEnd > previous.rightStart) {
2215
+ previous.kind = "replace";
2216
+ }
2217
+ } else regions.push({ ...original });
2218
+ oneSidedRun = oneSided;
2219
+ }
2220
+ const merged = [];
2221
+ for (const original of regions) {
2222
+ const block = { ...original };
2223
+ delete block.reflow;
2224
+ const previous = merged[merged.length - 1];
2225
+ if ((previous == null ? void 0 : previous.kind) === block.kind && previous.leftEnd === block.leftStart && previous.rightEnd === block.rightStart) {
2226
+ previous.leftEnd = block.leftEnd;
2227
+ previous.rightEnd = block.rightEnd;
2228
+ } else merged.push(block);
2229
+ }
2230
+ return merged;
2118
2231
  }
2119
2232
  function internDiffLines(leftLines, rightLines) {
2120
2233
  const lineIds = /* @__PURE__ */ new Map();
@@ -2167,14 +2280,14 @@ function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
2167
2280
  } else if (rightLines.length === 0) {
2168
2281
  blockKind = "delete";
2169
2282
  }
2170
- return {
2283
+ return markUndetailedReplacements({
2171
2284
  rows,
2172
2285
  leftLines: renderedLeftLines,
2173
2286
  rightLines: renderedRightLines,
2174
2287
  blocks: rows.length === 0 ? [] : [makeDiffBlock(blockKind, 0, leftLines.length, 0, rightLines.length)],
2175
2288
  hasChanges: rows.length > 0,
2176
2289
  quality: "fallback"
2177
- };
2290
+ });
2178
2291
  }
2179
2292
  function normalizeLines(content) {
2180
2293
  if (content.length === 0) {
@@ -2189,33 +2302,64 @@ function normalizeLines(content) {
2189
2302
  function appendReplacementCandidate(leftLines, rightLines, initialLeftLineNumber, initialRightLineNumber, rows, renderedLeftLines, renderedRightLines, blocks) {
2190
2303
  const aligned = alignReplacementLines(leftLines, rightLines);
2191
2304
  const crediblePairs = aligned.filter((row) => row.left !== void 0 && row.right !== void 0 && hasCredibleLineCorrespondence(row.left, row.right)).length;
2192
- const coherentBlock = hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs);
2193
- const normalized = aligned.flatMap((row) => {
2194
- if (row.left === void 0 || row.right === void 0 || hasCredibleLineCorrespondence(row.left, row.right)) {
2305
+ const coherentBlock = !aligned.some((row) => row.reflow) && hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs);
2306
+ const candidates = aligned.flatMap((row) => {
2307
+ if (row.reflow || row.left === void 0 || row.right === void 0 || hasCredibleLineCorrespondence(row.left, row.right)) {
2195
2308
  return [row];
2196
2309
  }
2197
2310
  return [{ left: row.left }, { right: row.right }];
2198
2311
  });
2312
+ const normalized = [];
2313
+ for (let index = 0; index < candidates.length; ) {
2314
+ const row = candidates[index];
2315
+ if (row.reflow || row.left !== void 0 && row.right !== void 0) {
2316
+ normalized.push(row);
2317
+ index++;
2318
+ continue;
2319
+ }
2320
+ const start = index;
2321
+ while (index < candidates.length && !candidates[index].reflow && (candidates[index].left === void 0 || candidates[index].right === void 0)) index++;
2322
+ const gap = candidates.slice(start, index);
2323
+ const left = gap.filter((item) => item.left !== void 0);
2324
+ const right = gap.filter((item) => item.right !== void 0);
2325
+ if (left.length === 1 && right.length === 1) {
2326
+ normalized.push({ left: left[0].left, right: right[0].right });
2327
+ } else {
2328
+ for (const item of gap) normalized.push(item);
2329
+ }
2330
+ }
2199
2331
  let leftLineNumber = initialLeftLineNumber;
2200
2332
  let rightLineNumber = initialRightLineNumber;
2333
+ let unmatchedLeft = [];
2334
+ let unmatchedRight = [];
2335
+ const flushUnmatched = () => {
2336
+ applyWrappedReplacementHighlights(unmatchedLeft, unmatchedRight);
2337
+ unmatchedLeft = [];
2338
+ unmatchedRight = [];
2339
+ };
2201
2340
  let blockKind;
2341
+ let blockReflow = false;
2202
2342
  let blockLeftStart = renderedLeftLines.length;
2203
2343
  let blockRightStart = renderedRightLines.length;
2204
2344
  const flushBlock = () => {
2205
2345
  if (!blockKind) return;
2206
- blocks.push(makeDiffBlock(
2346
+ const block = makeDiffBlock(
2207
2347
  blockKind,
2208
2348
  blockLeftStart,
2209
2349
  renderedLeftLines.length,
2210
2350
  blockRightStart,
2211
2351
  renderedRightLines.length
2212
- ));
2352
+ );
2353
+ if (blockReflow) block.reflow = true;
2354
+ blocks.push(block);
2213
2355
  };
2214
- for (const { left, right } of normalized) {
2215
- const nextKind = coherentBlock ? "replace" : left !== void 0 && right !== void 0 ? "replace" : left !== void 0 ? "delete" : "insert";
2216
- if (blockKind !== nextKind) {
2356
+ for (const { left, right, reflow } of normalized) {
2357
+ if (reflow || left !== void 0 && right !== void 0) flushUnmatched();
2358
+ const nextKind = coherentBlock || reflow ? "replace" : left !== void 0 && right !== void 0 ? "replace" : left !== void 0 ? "delete" : "insert";
2359
+ if (blockKind !== nextKind || blockReflow !== Boolean(reflow) || (reflow == null ? void 0 : reflow.start)) {
2217
2360
  flushBlock();
2218
2361
  blockKind = nextKind;
2362
+ blockReflow = Boolean(reflow);
2219
2363
  blockLeftStart = renderedLeftLines.length;
2220
2364
  blockRightStart = renderedRightLines.length;
2221
2365
  }
@@ -2229,21 +2373,30 @@ function appendReplacementCandidate(leftLines, rightLines, initialLeftLineNumber
2229
2373
  renderedRightLine = makeDiffLine("added", right, rightLineNumber);
2230
2374
  renderedRightLines.push(renderedRightLine);
2231
2375
  }
2232
- if (renderedLeftLine && renderedRightLine) {
2376
+ if (reflow) {
2377
+ if (renderedLeftLine) renderedLeftLine.segments = reflow.leftSegments;
2378
+ if (renderedRightLine) renderedRightLine.segments = reflow.rightSegments;
2379
+ } else if (renderedLeftLine && renderedRightLine) {
2233
2380
  applyInlineHighlightPair(renderedLeftLine, renderedRightLine);
2234
2381
  } else if (coherentBlock) {
2235
2382
  if (renderedLeftLine) applyFullLineHighlight(renderedLeftLine);
2236
2383
  if (renderedRightLine) applyFullLineHighlight(renderedRightLine);
2237
2384
  }
2385
+ if (!reflow && (left === void 0 || right === void 0)) {
2386
+ if (renderedLeftLine) unmatchedLeft.push(renderedLeftLine);
2387
+ if (renderedRightLine) unmatchedRight.push(renderedRightLine);
2388
+ }
2238
2389
  rows.push(makeDiffRow(
2239
2390
  left === void 0 ? makePlaceholder() : makeDiffCell("removed", left, leftLineNumber++),
2240
2391
  right === void 0 ? makePlaceholder() : makeDiffCell("added", right, rightLineNumber++)
2241
2392
  ));
2242
2393
  }
2243
2394
  flushBlock();
2395
+ flushUnmatched();
2244
2396
  return { left: leftLineNumber, right: rightLineNumber };
2245
2397
  }
2246
2398
  function hasCredibleLineCorrespondence(left, right) {
2399
+ if (!left.trim() && !right.trim()) return true;
2247
2400
  const score = scoreReplacementLinePair(left, right);
2248
2401
  if (score.eligible) return true;
2249
2402
  const leftAnchor = declarationAnchorKey(left);
@@ -2296,24 +2449,101 @@ var MAX_ALIGNMENT_CHARACTER_CELLS = 5e7;
2296
2449
  var MAX_RARE_TOKEN_OCCURRENCES = 4;
2297
2450
  var MAX_CONTEXTUAL_HUNK_LINES = 8;
2298
2451
  var MAX_CONTEXTUAL_LENGTH_DELTA = 4;
2452
+ function wrappedTextUnits(lines) {
2453
+ const units = [];
2454
+ for (let index = 0; index < lines.length; index++) {
2455
+ const first = lines[index];
2456
+ const paragraph = [first];
2457
+ const start = first.match(/^(\s*)(?:[\w-]+:\s+|[-*+]\s+)(\S.*)$/);
2458
+ if (start && !/^[|>\[{]/.test(start[2])) {
2459
+ while (index + 1 < lines.length) {
2460
+ const next = lines[index + 1];
2461
+ if (!next.trim() || next.length - next.trimStart().length <= start[1].length || /^\s*(?:[\w-]+:|[-*+]\s|\d+[.)]\s|#)/.test(next)) break;
2462
+ paragraph.push(next);
2463
+ index++;
2464
+ }
2465
+ }
2466
+ units.push(paragraph.join("\n"));
2467
+ }
2468
+ return units;
2469
+ }
2299
2470
  function alignReplacementLines(leftLines, rightLines) {
2471
+ if ([...leftLines, ...rightLines].some((line) => line.includes("\n"))) {
2472
+ return alignPhysicalReplacementLines(leftLines, rightLines);
2473
+ }
2474
+ const leftUnits = wrappedTextUnits(leftLines);
2475
+ const rightUnits = wrappedTextUnits(rightLines);
2476
+ if (leftUnits.length === leftLines.length && rightUnits.length === rightLines.length) {
2477
+ return alignPhysicalReplacementLines(leftLines, rightLines);
2478
+ }
2479
+ return alignPhysicalReplacementLines(leftUnits, rightUnits).flatMap((row) => {
2480
+ var _a, _b;
2481
+ const left = ((_a = row.left) == null ? void 0 : _a.split("\n")) || [];
2482
+ const right = ((_b = row.right) == null ? void 0 : _b.split("\n")) || [];
2483
+ if (!left.length || !right.length) {
2484
+ return [...left.map((left2) => ({ left: left2 })), ...right.map((right2) => ({ right: right2 }))];
2485
+ }
2486
+ if (left.length === 1 && right.length === 1) return [row];
2487
+ const whitespace = buildWhitespaceOnlySegments(row.left, row.right);
2488
+ const segments = whitespace || buildInlineSegments(row.left, row.right);
2489
+ const leftSegments = segments && splitSegmentsIntoLines(segments.leftSegments);
2490
+ const rightSegments = segments && splitSegmentsIntoLines(segments.rightSegments);
2491
+ return Array.from({ length: Math.max(left.length, right.length) }, (_, index) => ({
2492
+ left: left[index],
2493
+ right: right[index],
2494
+ reflow: { start: index === 0, leftSegments: leftSegments == null ? void 0 : leftSegments[index], rightSegments: rightSegments == null ? void 0 : rightSegments[index] }
2495
+ }));
2496
+ });
2497
+ }
2498
+ function alignPhysicalReplacementLines(leftLines, rightLines) {
2300
2499
  if (leftLines.length === 0) {
2301
2500
  return rightLines.map((right) => ({ right }));
2302
2501
  }
2303
2502
  if (rightLines.length === 0) {
2304
2503
  return leftLines.map((left) => ({ left }));
2305
2504
  }
2306
- const leftCharacterCount = leftLines.reduce((total, line) => total + line.length, 0);
2307
- const rightCharacterCount = rightLines.reduce((total, line) => total + line.length, 0);
2308
- if (leftCharacterCount * rightCharacterCount > MAX_ALIGNMENT_CHARACTER_CELLS) {
2505
+ let prefix = 0;
2506
+ while (prefix < Math.min(leftLines.length, rightLines.length) && leftLines[prefix].trim() === rightLines[prefix].trim()) prefix++;
2507
+ if (prefix) {
2309
2508
  return [
2310
- ...leftLines.map((left) => ({ left })),
2311
- ...rightLines.map((right) => ({ right }))
2509
+ ...leftLines.slice(0, prefix).map((left, index) => ({ left, right: rightLines[index] })),
2510
+ ...alignReplacementLines(leftLines.slice(prefix), rightLines.slice(prefix))
2312
2511
  ];
2313
2512
  }
2513
+ let leftEnd = 1;
2514
+ let rightEnd = 1;
2515
+ let leftText = leftLines[0].trim().replace(/\s+/g, " ");
2516
+ let rightText = rightLines[0].trim().replace(/\s+/g, " ");
2517
+ while (leftText && rightText && leftText !== rightText) {
2518
+ if (rightText.startsWith(leftText + " ") && leftEnd < leftLines.length) {
2519
+ leftText += " " + leftLines[leftEnd++].trim().replace(/\s+/g, " ");
2520
+ } else if (leftText.startsWith(rightText + " ") && rightEnd < rightLines.length) {
2521
+ rightText += " " + rightLines[rightEnd++].trim().replace(/\s+/g, " ");
2522
+ } else break;
2523
+ }
2524
+ if (leftText === rightText && (leftEnd > 1 || rightEnd > 1) && ![...leftLines, ...rightLines].some((line) => line.includes("\n"))) {
2525
+ const reflow = buildWhitespaceOnlySegments(leftLines.slice(0, leftEnd).join("\n"), rightLines.slice(0, rightEnd).join("\n"));
2526
+ if (reflow) {
2527
+ const leftSegments = splitSegmentsIntoLines(reflow.leftSegments);
2528
+ const rightSegments = splitSegmentsIntoLines(reflow.rightSegments);
2529
+ return [
2530
+ ...Array.from({ length: Math.max(leftEnd, rightEnd) }, (_, index) => ({
2531
+ left: index < leftEnd ? leftLines[index] : void 0,
2532
+ right: index < rightEnd ? rightLines[index] : void 0,
2533
+ reflow: { start: index === 0, leftSegments: leftSegments[index], rightSegments: rightSegments[index] }
2534
+ })),
2535
+ ...alignReplacementLines(leftLines.slice(leftEnd), rightLines.slice(rightEnd))
2536
+ ];
2537
+ }
2538
+ }
2539
+ const leftCharacterCount = leftLines.reduce((total, line) => total + Math.min(line.length, MAX_SCORING_LINE_LENGTH), 0);
2540
+ const rightCharacterCount = rightLines.reduce((total, line) => total + Math.min(line.length, MAX_SCORING_LINE_LENGTH), 0);
2541
+ if (leftCharacterCount * rightCharacterCount > MAX_ALIGNMENT_CHARACTER_CELLS) {
2542
+ return alignLargeReplacementLines(leftLines, rightLines, true);
2543
+ }
2314
2544
  if (leftLines.length === 1 && rightLines.length === 1) {
2315
2545
  const singletonScore = scoreReplacementLinePair(leftLines[0], rightLines[0]);
2316
- if ((singletonScore.eligible || singletonScore.score >= MINIMUM_SINGLE_PAIR_SCORE) && hasCredibleLineCorrespondence(leftLines[0], rightLines[0]) && haveCompatibleLineRoles(leftLines[0], rightLines[0]) && isInformativeLine(normalizeMatchingContent(leftLines[0])) && isInformativeLine(normalizeMatchingContent(rightLines[0]))) {
2546
+ if (!leftLines[0].trim() && !rightLines[0].trim() || (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]))) {
2317
2547
  return [{ left: leftLines[0], right: rightLines[0] }];
2318
2548
  }
2319
2549
  return [{ left: leftLines[0] }, { right: rightLines[0] }];
@@ -2375,16 +2605,7 @@ function scoreReplacementLinePair(left, right) {
2375
2605
  const normalizedRight = normalizeMatchingContent(right);
2376
2606
  const leftInformative = isInformativeLine(normalizedLeft);
2377
2607
  const rightInformative = isInformativeLine(normalizedRight);
2378
- if (!leftInformative || !rightInformative || normalizedLeft.length > MAX_SCORING_LINE_LENGTH || normalizedRight.length > MAX_SCORING_LINE_LENGTH) {
2379
- return {
2380
- score: 0,
2381
- eligible: false,
2382
- characterSimilarity: 0,
2383
- tokenSimilarity: 0,
2384
- lengthSimilarity: 0
2385
- };
2386
- }
2387
- if (normalizedLeft === normalizedRight) {
2608
+ if (leftInformative && rightInformative && normalizedLeft === normalizedRight) {
2388
2609
  return {
2389
2610
  score: 1,
2390
2611
  eligible: true,
@@ -2393,14 +2614,30 @@ function scoreReplacementLinePair(left, right) {
2393
2614
  lengthSimilarity: 1
2394
2615
  };
2395
2616
  }
2617
+ if (!leftInformative || !rightInformative) {
2618
+ return {
2619
+ score: 0,
2620
+ eligible: false,
2621
+ characterSimilarity: 0,
2622
+ tokenSimilarity: 0,
2623
+ lengthSimilarity: 0
2624
+ };
2625
+ }
2396
2626
  const leftTokens = tokenizeMatchingContent(normalizedLeft);
2397
2627
  const rightTokens = tokenizeMatchingContent(normalizedRight);
2398
- const characterSimilarity = lineSimilarity(normalizedLeft, normalizedRight);
2628
+ const sample = (text) => text.length <= MAX_SCORING_LINE_LENGTH ? text : [0, Math.floor(text.length / 2) - 300, text.length - 600].map((start) => text.slice(start, start + 600)).join(" ");
2629
+ const characterSimilarity = lineSimilarity(sample(normalizedLeft), sample(normalizedRight));
2399
2630
  const tokenSimilarity = multisetDiceSimilarity(leftTokens, rightTokens);
2400
2631
  const lengthSimilarity = Math.min(normalizedLeft.length, normalizedRight.length) / Math.max(normalizedLeft.length, normalizedRight.length);
2401
2632
  const boundarySimilarity = tokenBoundarySimilarity(leftTokens, rightTokens);
2402
- const score = characterSimilarity * 0.52 + tokenSimilarity * 0.32 + lengthSimilarity * 0.1 + boundarySimilarity * 0.06;
2403
- const hasContentEvidence = characterSimilarity >= 0.52 || tokenSimilarity >= 0.45 || characterSimilarity >= 0.48 && tokenSimilarity >= 0.28;
2633
+ const prefix = (text) => {
2634
+ var _a;
2635
+ return (_a = text.match(/^(?:(?:const|let|var)\s+)?([\p{L}_$][\p{L}\p{N}_$.-]*\s*(?::|=(?!=)))\s*/u)) == null ? void 0 : _a[1].replace(/\s+/g, "");
2636
+ };
2637
+ const leftPrefix = prefix(normalizedLeft);
2638
+ const prefixBonus = leftPrefix && leftPrefix === prefix(normalizedRight) ? 0.12 : 0;
2639
+ const score = Math.min(1, characterSimilarity * 0.52 + tokenSimilarity * 0.32 + lengthSimilarity * 0.1 + boundarySimilarity * 0.06 + prefixBonus);
2640
+ const hasContentEvidence = prefixBonus > 0 || characterSimilarity >= 0.52 || tokenSimilarity >= 0.45 || characterSimilarity >= 0.48 && tokenSimilarity >= 0.28;
2404
2641
  return {
2405
2642
  score,
2406
2643
  eligible: hasContentEvidence && score >= MINIMUM_MATCH_SCORE,
@@ -2550,8 +2787,11 @@ function rankEligibleScores(scores) {
2550
2787
  }
2551
2788
  return { best, second };
2552
2789
  }
2553
- function alignLargeReplacementLines(leftLines, rightLines) {
2554
- const anchors = collectLargeHunkAnchors(leftLines, rightLines);
2790
+ function alignLargeReplacementLines(leftLines, rightLines, exactOnly = false) {
2791
+ const anchors = collectLargeHunkAnchors(leftLines, rightLines, exactOnly);
2792
+ if (anchors.length === 0) {
2793
+ return [...leftLines.map((left) => ({ left })), ...rightLines.map((right) => ({ right }))];
2794
+ }
2555
2795
  const aligned = [];
2556
2796
  let leftStart = 0;
2557
2797
  let rightStart = 0;
@@ -2568,8 +2808,8 @@ function alignLargeReplacementLines(leftLines, rightLines) {
2568
2808
  appendBoundedSegment(aligned, leftLines.slice(leftStart), rightLines.slice(rightStart));
2569
2809
  return aligned;
2570
2810
  }
2571
- function collectLargeHunkAnchors(leftLines, rightLines) {
2572
- const candidatePairs = collectRareTokenCandidatePairs(leftLines, rightLines);
2811
+ function collectLargeHunkAnchors(leftLines, rightLines, exactOnly) {
2812
+ const candidatePairs = collectRareTokenCandidatePairs(leftLines, rightLines, exactOnly);
2573
2813
  if (candidatePairs === null) {
2574
2814
  return [];
2575
2815
  }
@@ -2598,7 +2838,7 @@ function collectLargeHunkAnchors(leftLines, rightLines) {
2598
2838
  const anchors = selectMaximumWeightMonotonicAnchors(confident, rightLines.length);
2599
2839
  return anchors.length <= MAX_LARGE_HUNK_ANCHORS ? anchors : [];
2600
2840
  }
2601
- function collectRareTokenCandidatePairs(leftLines, rightLines) {
2841
+ function collectRareTokenCandidatePairs(leftLines, rightLines, exactOnly) {
2602
2842
  const leftExact = buildLinePostings(leftLines, normalizeMatchingContent);
2603
2843
  const rightExact = buildLinePostings(rightLines, normalizeMatchingContent);
2604
2844
  const pairs = /* @__PURE__ */ new Map();
@@ -2614,6 +2854,9 @@ function collectRareTokenCandidatePairs(leftLines, rightLines) {
2614
2854
  return null;
2615
2855
  }
2616
2856
  }
2857
+ if (exactOnly) {
2858
+ return [...pairs.values()];
2859
+ }
2617
2860
  const leftTokens = buildTokenPostings(leftLines);
2618
2861
  const rightTokens = buildTokenPostings(rightLines);
2619
2862
  for (const [token, leftIndices] of leftTokens) {
@@ -2826,22 +3069,113 @@ function lineSimilarity(left, right) {
2826
3069
  return 2 * commonLength / maxLength;
2827
3070
  }
2828
3071
  function applyInlineHighlightPair(leftLine, rightLine) {
2829
- if (leftLine.content.length > MAX_INLINE_HIGHLIGHT_LINE_LENGTH || rightLine.content.length > MAX_INLINE_HIGHLIGHT_LINE_LENGTH) {
3072
+ const whitespace = buildWhitespaceOnlySegments(leftLine.content, rightLine.content);
3073
+ if (whitespace) {
3074
+ leftLine.segments = whitespace.leftSegments;
3075
+ rightLine.segments = whitespace.rightSegments;
2830
3076
  return;
2831
3077
  }
2832
- const { leftSegments, rightSegments, hasInlineChanges } = buildInlineSegments(leftLine.content, rightLine.content);
2833
- if (hasInlineChanges) {
2834
- leftLine.segments = leftSegments;
2835
- rightLine.segments = rightSegments;
2836
- }
3078
+ const segments = buildInlineSegments(leftLine.content, rightLine.content);
3079
+ if (segments) {
3080
+ leftLine.segments = segments.leftSegments;
3081
+ rightLine.segments = segments.rightSegments;
3082
+ }
3083
+ }
3084
+ function applyWrappedReplacementHighlights(leftLines, rightLines) {
3085
+ if (!leftLines.length || !rightLines.length || leftLines.length === 1 && rightLines.length === 1) return;
3086
+ const length = (lines) => lines.reduce((total, line) => total + line.content.length + 1, -1);
3087
+ if (length(leftLines) > MAX_INLINE_HIGHLIGHT_LINE_LENGTH || length(rightLines) > MAX_INLINE_HIGHLIGHT_LINE_LENGTH) return;
3088
+ const left = leftLines.map((line) => line.content).join("\n");
3089
+ const right = rightLines.map((line) => line.content).join("\n");
3090
+ const segments = buildInlineSegments(left, right);
3091
+ if (!segments) return;
3092
+ const { leftSegments, rightSegments } = segments;
3093
+ const leftParts = splitSegmentsIntoLines(leftSegments);
3094
+ const rightParts = splitSegmentsIntoLines(rightSegments);
3095
+ leftLines.forEach((line, index) => {
3096
+ line.segments = leftParts[index];
3097
+ });
3098
+ rightLines.forEach((line, index) => {
3099
+ line.segments = rightParts[index];
3100
+ });
2837
3101
  }
2838
3102
  function applyFullLineHighlight(line) {
2839
3103
  if (/[^\s]/.test(line.content)) {
2840
3104
  line.segments = [{ kind: line.kind, text: line.content, emphasis: true }];
2841
3105
  }
2842
3106
  }
3107
+ var inlineGraphemes = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3108
+ function trimReplacementEdges(left, right) {
3109
+ if (left === right) return left ? [{ value: left }] : [];
3110
+ const leftChars = Array.from(inlineGraphemes.segment(left), (part) => part.segment);
3111
+ const rightChars = Array.from(inlineGraphemes.segment(right), (part) => part.segment);
3112
+ const limit = Math.min(leftChars.length, rightChars.length);
3113
+ let prefix = 0;
3114
+ let suffix = 0;
3115
+ while (prefix < limit && leftChars[prefix] === rightChars[prefix]) prefix++;
3116
+ while (suffix < limit && leftChars[leftChars.length - suffix - 1] === rightChars[rightChars.length - suffix - 1]) suffix++;
3117
+ if (suffix === limit && prefix < limit) prefix = 0;
3118
+ suffix = Math.min(suffix, limit - prefix);
3119
+ return [
3120
+ { value: leftChars.slice(0, prefix).join("") },
3121
+ { value: leftChars.slice(prefix, leftChars.length - suffix).join(""), removed: true },
3122
+ { value: rightChars.slice(prefix, rightChars.length - suffix).join(""), added: true },
3123
+ { value: suffix ? leftChars.slice(-suffix).join("") : "" }
3124
+ ].filter((change) => change.value.length > 0);
3125
+ }
3126
+ function edgeTrimmedWordChanges(left, right) {
3127
+ const words = diffWordsWithSpace(left, right, { timeout: DIFF_TIMEOUT_MS, maxEditLength: 2e3 });
3128
+ if (!words) return void 0;
3129
+ const boundaries = (text) => /* @__PURE__ */ new Set([
3130
+ ...Array.from(inlineGraphemes.segment(text), (part) => part.index),
3131
+ text.length
3132
+ ]);
3133
+ const leftBoundaries = boundaries(left);
3134
+ const rightBoundaries = boundaries(right);
3135
+ const result = [];
3136
+ let leftOffset = 0;
3137
+ let rightOffset = 0;
3138
+ let pendingLeft = "";
3139
+ let pendingRight = "";
3140
+ for (let index = 0; index < words.length; ) {
3141
+ const word = words[index];
3142
+ if (!word.added && !word.removed) {
3143
+ pendingLeft += word.value;
3144
+ pendingRight += word.value;
3145
+ leftOffset += word.value.length;
3146
+ rightOffset += word.value.length;
3147
+ index++;
3148
+ } else {
3149
+ while (index < words.length && (words[index].added || words[index].removed)) {
3150
+ const change = words[index++];
3151
+ if (change.removed) {
3152
+ pendingLeft += change.value;
3153
+ leftOffset += change.value.length;
3154
+ } else {
3155
+ pendingRight += change.value;
3156
+ rightOffset += change.value.length;
3157
+ }
3158
+ }
3159
+ }
3160
+ if (leftBoundaries.has(leftOffset) && rightBoundaries.has(rightOffset)) {
3161
+ for (const change of trimReplacementEdges(pendingLeft, pendingRight)) {
3162
+ const previous = result[result.length - 1];
3163
+ if (previous && previous.added === change.added && previous.removed === change.removed) {
3164
+ previous.value += change.value;
3165
+ } else {
3166
+ result.push(change);
3167
+ }
3168
+ }
3169
+ pendingLeft = "";
3170
+ pendingRight = "";
3171
+ }
3172
+ }
3173
+ return result;
3174
+ }
2843
3175
  function buildInlineSegments(leftContent, rightContent) {
2844
- const changes = diffWordsWithSpace(leftContent, rightContent);
3176
+ if (leftContent.length > MAX_INLINE_HIGHLIGHT_LINE_LENGTH || rightContent.length > MAX_INLINE_HIGHLIGHT_LINE_LENGTH) return void 0;
3177
+ const changes = edgeTrimmedWordChanges(leftContent, rightContent);
3178
+ if (!changes) return void 0;
2845
3179
  const leftSegments = [];
2846
3180
  const rightSegments = [];
2847
3181
  let hasInlineChanges = false;
@@ -2857,7 +3191,7 @@ function buildInlineSegments(leftContent, rightContent) {
2857
3191
  rightSegments.push(contextSegment);
2858
3192
  continue;
2859
3193
  }
2860
- const emphasis = /[^\s]/.test(value);
3194
+ const emphasis = value.length > 0;
2861
3195
  hasInlineChanges = hasInlineChanges || emphasis;
2862
3196
  if (change.removed) {
2863
3197
  leftSegments.push({
@@ -2880,6 +3214,48 @@ function buildInlineSegments(leftContent, rightContent) {
2880
3214
  hasInlineChanges
2881
3215
  };
2882
3216
  }
3217
+ function buildWhitespaceOnlySegments(left, right) {
3218
+ const leftParts = left.split(/(\S+)/);
3219
+ const rightParts = right.split(/(\S+)/);
3220
+ if (leftParts.length !== rightParts.length || leftParts.some((part, index) => index % 2 === 1 && part !== rightParts[index])) return void 0;
3221
+ const leftSegments = [];
3222
+ const rightSegments = [];
3223
+ const append = (segments, kind, text) => {
3224
+ if (!text) return;
3225
+ const previous = segments[segments.length - 1];
3226
+ if ((previous == null ? void 0 : previous.kind) === kind) previous.text += text;
3227
+ else segments.push({ kind, text, emphasis: kind !== "context" });
3228
+ };
3229
+ leftParts.forEach((part, index) => {
3230
+ const other = rightParts[index];
3231
+ let prefix = 0;
3232
+ if (index > 0) {
3233
+ while (prefix < Math.min(part.length, other.length) && part[prefix] === other[prefix]) prefix++;
3234
+ }
3235
+ let suffix = 0;
3236
+ while (suffix < Math.min(part.length, other.length) - prefix && part[part.length - suffix - 1] === other[other.length - suffix - 1]) suffix++;
3237
+ if (index === 0) {
3238
+ while (prefix < Math.min(part.length, other.length) - suffix && part[prefix] === other[prefix]) prefix++;
3239
+ }
3240
+ append(leftSegments, "context", part.slice(0, prefix));
3241
+ append(rightSegments, "context", other.slice(0, prefix));
3242
+ append(leftSegments, "removed", part.slice(prefix, part.length - suffix));
3243
+ append(rightSegments, "added", other.slice(prefix, other.length - suffix));
3244
+ append(leftSegments, "context", part.slice(part.length - suffix));
3245
+ append(rightSegments, "context", other.slice(other.length - suffix));
3246
+ });
3247
+ return { leftSegments, rightSegments };
3248
+ }
3249
+ function splitSegmentsIntoLines(segments) {
3250
+ const lines = [[]];
3251
+ for (const segment of segments) {
3252
+ segment.text.split("\n").forEach((text, index) => {
3253
+ if (index > 0) lines.push([]);
3254
+ if (text) lines[lines.length - 1].push({ ...segment, text });
3255
+ });
3256
+ }
3257
+ return lines;
3258
+ }
2883
3259
  function makePlaceholder() {
2884
3260
  return {
2885
3261
  kind: "placeholder",
@@ -3309,20 +3685,16 @@ var CHAPTERS = {
3309
3685
  };
3310
3686
  function buildChangeTourManifest(startPath, options = {}) {
3311
3687
  var _a, _b, _c, _d;
3312
- const source = options.source ? parseChangeTourSource(options.source) : void 0;
3688
+ let source = options.source ? parseChangeTourSource(options.source) : void 0;
3313
3689
  const range = resolveBranchReviewRange(
3314
3690
  startPath,
3315
3691
  options.headRef ?? ((_a = source == null ? void 0 : source.range) == null ? void 0 : _a.head),
3316
3692
  options.baseRef ?? ((_b = source == null ? void 0 : source.range) == null ? void 0 : _b.base)
3317
3693
  );
3318
- if (source == null ? void 0 : source.review) {
3319
- validateChangeTourReview(source.review, {
3320
- expectedRange: {
3321
- baseOid: range.mergeBaseOid,
3322
- headOid: range.headOid
3323
- }
3324
- });
3325
- }
3694
+ if (source) source = pinTourSource(source, range.repoRoot, /* @__PURE__ */ new Map([
3695
+ [range.headRef, range.headOid],
3696
+ [range.baseRef, range.baseOid]
3697
+ ]), range.baseOid, range.headOid);
3326
3698
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_TOUR_FILE_BYTES;
3327
3699
  const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_TOUR_LINE_BYTES;
3328
3700
  const omittedFiles = [];
@@ -3403,7 +3775,7 @@ function buildChangeTourManifest(startPath, options = {}) {
3403
3775
  };
3404
3776
  defaultScenes = [...defaultScenes].sort((left, right) => Number(right.path === attention.entryPath) - Number(left.path === attention.entryPath) || focusRank(left.path) - focusRank(right.path) || left.id.localeCompare(right.id));
3405
3777
  }
3406
- const authored = source ? applySource(source, defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
3778
+ const authored = source ? applySourceChapters(source, source.chapters, defaultScenes, range.repoRoot, { base: range.mergeBaseOid, head: range.headOid }) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
3407
3779
  defaultScenes,
3408
3780
  buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id]))),
3409
3781
  attention,
@@ -3425,6 +3797,8 @@ function buildChangeTourManifest(startPath, options = {}) {
3425
3797
  version: (source == null ? void 0 : source.version) ?? 1,
3426
3798
  title: (source == null ? void 0 : source.title) || ((_c = options.story) == null ? void 0 : _c.title) || options.title || `${range.headRef} against ${range.baseRef}`,
3427
3799
  windowTitle: source == null ? void 0 : source.windowTitle,
3800
+ opening: source == null ? void 0 : source.opening,
3801
+ conclusion: source == null ? void 0 : source.conclusion,
3428
3802
  sourceUrl: (source == null ? void 0 : source.sourceUrl) || ((_d = options.story) == null ? void 0 : _d.sourceUrl) || options.sourceUrl,
3429
3803
  generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
3430
3804
  range: {
@@ -3445,9 +3819,9 @@ function buildChangeTourManifest(startPath, options = {}) {
3445
3819
  files,
3446
3820
  chapters,
3447
3821
  scenes,
3448
- ...(source == null ? void 0 : source.review) ? { review: source.review } : {}
3822
+ ...(source == null ? void 0 : source.details) !== void 0 ? { details: source.details } : {}
3449
3823
  };
3450
- const authoredModeTours = (source == null ? void 0 : source.version) === 3 ? buildAuthoredModeTours(
3824
+ const authoredModeTours = source && source.version >= 4 ? buildAuthoredModeTours(
3451
3825
  source,
3452
3826
  authored,
3453
3827
  defaultScenes,
@@ -3502,10 +3876,7 @@ function buildChangeTourManifest(startPath, options = {}) {
3502
3876
  }
3503
3877
  return parseChangeTourManifest(manifest);
3504
3878
  }
3505
- function applySource(source, defaultScenes, repoRoot) {
3506
- return applySourceChapters(source, source.chapters, defaultScenes, repoRoot);
3507
- }
3508
- function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot) {
3879
+ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot, imageRevisions) {
3509
3880
  var _a, _b;
3510
3881
  const available = new Map(defaultScenes.map((scene) => [scene.path, scene]));
3511
3882
  const resolvedAnchors = /* @__PURE__ */ new Map();
@@ -3546,7 +3917,8 @@ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot)
3546
3917
  { ...authoredScene, steps: authoredScene.steps },
3547
3918
  available,
3548
3919
  resolvedAnchors,
3549
- connections
3920
+ connections,
3921
+ (image) => compileTourImage(repoRoot, image, imageRevisions[image.revision])
3550
3922
  );
3551
3923
  finalScenes.push(finalScene);
3552
3924
  finalSceneIds.push(finalScene.id);
@@ -3570,14 +3942,30 @@ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot)
3570
3942
  }
3571
3943
  continue;
3572
3944
  }
3573
- const scene = buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections);
3945
+ const scene = buildWalkthroughManifestScene(
3946
+ authoredScene,
3947
+ available,
3948
+ resolvedAnchors,
3949
+ connections,
3950
+ (image) => compileTourImage(repoRoot, image, imageRevisions[image.revision])
3951
+ );
3574
3952
  scenes.push(scene);
3575
3953
  sceneIds.push(scene.id);
3576
3954
  finalScenes.push(scene);
3577
3955
  finalSceneIds.push(scene.id);
3578
3956
  }
3579
- chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3580
- finalChapters.push({ id: chapter.id, title: chapter.title, sceneIds: finalSceneIds });
3957
+ chapters.push({
3958
+ id: chapter.id,
3959
+ title: chapter.title,
3960
+ ...chapter.summary === void 0 ? {} : { summary: chapter.summary },
3961
+ sceneIds
3962
+ });
3963
+ finalChapters.push({
3964
+ id: chapter.id,
3965
+ title: chapter.title,
3966
+ ...chapter.summary === void 0 ? {} : { summary: chapter.summary },
3967
+ sceneIds: finalSceneIds
3968
+ });
3581
3969
  }
3582
3970
  return { scenes, chapters, finalScenes, finalChapters };
3583
3971
  }
@@ -3585,18 +3973,41 @@ function buildAuthoredModeTours(source, root, defaultScenes, repoRoot, mergeBase
3585
3973
  var _a, _b;
3586
3974
  const tours = {};
3587
3975
  if ((_a = source.tours) == null ? void 0 : _a.historical) {
3588
- const compiled = applySourceChapters(source, source.tours.historical.chapters, defaultScenes, repoRoot);
3976
+ const compiled = applySourceChapters(source, source.tours.historical.chapters, defaultScenes, repoRoot, { base: mergeBaseOid, head: headOid });
3589
3977
  validateHistoricalStackEndpoints(compiled.scenes, mergeBaseOid, headOid);
3590
- tours.historical = { chapters: compiled.chapters, scenes: compiled.scenes };
3591
- } else if (source.version === 3) {
3978
+ tours.historical = {
3979
+ opening: source.tours.historical.opening,
3980
+ conclusion: source.tours.historical.conclusion,
3981
+ chapters: compiled.chapters,
3982
+ scenes: compiled.scenes,
3983
+ ...source.tours.historical.details !== void 0 ? { details: source.tours.historical.details } : {}
3984
+ };
3985
+ } else if (source.version >= 4) {
3592
3986
  const fallback = buildHistoricalFallback(source.chapters, root);
3593
- if (fallback) tours.historical = fallback;
3987
+ if (fallback) {
3988
+ tours.historical = {
3989
+ ...fallback,
3990
+ ...root.scenes.every((scene) => scene.kind !== "deconstructed-diff") ? { opening: source.opening, conclusion: source.conclusion, details: source.details } : {}
3991
+ };
3992
+ }
3594
3993
  }
3595
3994
  if ((_b = source.tours) == null ? void 0 : _b.deconstructed) {
3596
- const compiled = applySourceChapters(source, source.tours.deconstructed.chapters, defaultScenes, repoRoot);
3597
- tours.deconstructed = { chapters: compiled.chapters, scenes: compiled.scenes };
3598
- } else if (source.version === 3 && source.chapters.some((chapter) => chapter.scenes.some((scene) => scene.kind === "deconstructed-diff"))) {
3599
- tours.deconstructed = { chapters: root.chapters, scenes: root.scenes };
3995
+ const compiled = applySourceChapters(source, source.tours.deconstructed.chapters, defaultScenes, repoRoot, { base: mergeBaseOid, head: headOid });
3996
+ tours.deconstructed = {
3997
+ opening: source.tours.deconstructed.opening,
3998
+ conclusion: source.tours.deconstructed.conclusion,
3999
+ chapters: compiled.chapters,
4000
+ scenes: compiled.scenes,
4001
+ ...source.tours.deconstructed.details !== void 0 ? { details: source.tours.deconstructed.details } : {}
4002
+ };
4003
+ } else if (source.version >= 4 && source.chapters.some((chapter) => chapter.scenes.some((scene) => scene.kind === "deconstructed-diff"))) {
4004
+ tours.deconstructed = {
4005
+ opening: source.opening,
4006
+ conclusion: source.conclusion,
4007
+ details: source.details,
4008
+ chapters: root.chapters,
4009
+ scenes: root.scenes
4010
+ };
3600
4011
  }
3601
4012
  return tours.historical || tours.deconstructed ? tours : void 0;
3602
4013
  }
@@ -3621,7 +4032,14 @@ function buildHistoricalFallback(sourceChapters, root) {
3621
4032
  scenes.push(scene);
3622
4033
  sceneIds.push(scene.id);
3623
4034
  }
3624
- if (sceneIds.length > 0) chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
4035
+ if (sceneIds.length > 0) {
4036
+ chapters.push({
4037
+ id: chapter.id,
4038
+ title: chapter.title,
4039
+ ...chapter.summary === void 0 ? {} : { summary: chapter.summary },
4040
+ sceneIds
4041
+ });
4042
+ }
3625
4043
  }
3626
4044
  return scenes.length > 0 ? { chapters, scenes } : void 0;
3627
4045
  }
@@ -3629,11 +4047,12 @@ function compileSceneOverview(overview, includeComparison = true) {
3629
4047
  if (!overview) return void 0;
3630
4048
  return {
3631
4049
  kind: overview.kind,
4050
+ ...overview.purpose === void 0 ? {} : { purpose: overview.purpose },
3632
4051
  ...overview.path === void 0 ? {} : { path: overview.path },
3633
4052
  ...includeComparison && overview.comparison ? { comparison: { ...overview.comparison } } : {}
3634
4053
  };
3635
4054
  }
3636
- function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections) {
4055
+ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections, resolveImage) {
3637
4056
  const overview = compileSceneOverview(authoredScene.overview, false);
3638
4057
  return {
3639
4058
  id: authoredScene.id,
@@ -3654,6 +4073,7 @@ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors
3654
4073
  body: step.body,
3655
4074
  depth: step.depth,
3656
4075
  requirement: step.requirement,
4076
+ ...step.image ? { image: resolveImage(step.image) } : {},
3657
4077
  focus,
3658
4078
  connection: step.connection ? connections.get(step.connection) : void 0,
3659
4079
  diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
@@ -3766,8 +4186,10 @@ function buildDeconstructedFocusSteps(sceneId, stages, files) {
3766
4186
  });
3767
4187
  return focuses.map((focus, focusIndex) => ({
3768
4188
  id: focusIndex === 0 ? claimDeconstructedStageId(stage.id, usedIds) : claimDeconstructedFocusId(`${stage.id}-focus-${focusIndex + 1}`, reservedIds, usedIds),
3769
- title: focuses.length === 1 ? stage.title : `${stage.title} \xB7 ${focus.file} \xB7 Focus ${focusIndex + 1}/${focuses.length}`,
3770
- body: stage.narration,
4189
+ title: focusIndex === 0 ? stage.title : `${focus.file} \xB7 Evidence ${focusIndex + 1} of ${focuses.length}`,
4190
+ // A stage is one argument supported by multiple code focuses.
4191
+ // Keep its explanation once in the reading/narration sequence.
4192
+ body: focusIndex === 0 ? stage.narration : "",
3771
4193
  file: focus.file,
3772
4194
  pairIndex: stageIndex,
3773
4195
  side: focus.side,
@@ -4070,6 +4492,8 @@ function readGitText2(repoRoot, oid, relativePath, maxBytes, maxLineBytes) {
4070
4492
  if (!relativePath) {
4071
4493
  return { kind: "text", content: "" };
4072
4494
  }
4495
+ const size = Number((0, import_child_process4.execFileSync)("git", ["cat-file", "-s", `${oid}:${relativePath}`], { cwd: repoRoot, encoding: "utf8" }).trim());
4496
+ if (size > maxBytes) return { kind: "binary-or-large", content: "" };
4073
4497
  const content = (0, import_child_process4.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
4074
4498
  cwd: repoRoot,
4075
4499
  encoding: "buffer",
@@ -4111,6 +4535,49 @@ function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
4111
4535
  binary: additionsText === "-" || deletionsText === "-"
4112
4536
  };
4113
4537
  }
4538
+ function pinTourSource(source, repoRoot, resolved, baseOid, headOid) {
4539
+ const copy = JSON.parse(JSON.stringify(source));
4540
+ const pin = (ref) => {
4541
+ let oid = resolved.get(ref);
4542
+ if (!oid) {
4543
+ oid = runGitText(repoRoot, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
4544
+ resolved.set(ref, oid);
4545
+ }
4546
+ return oid;
4547
+ };
4548
+ copy.range = { base: baseOid, head: headOid };
4549
+ const chapters = [...copy.chapters, ...Object.values(copy.tours || {}).flatMap((tour) => (tour == null ? void 0 : tour.chapters) || [])];
4550
+ for (const chapter of chapters) for (const scene of chapter.scenes) {
4551
+ if (scene.kind === "stacked-diff") for (const entry of scene.stack) {
4552
+ entry.label ||= entry.ref;
4553
+ entry.ref = pin(entry.ref);
4554
+ }
4555
+ if (scene.kind === "deconstructed-diff") {
4556
+ scene.base = scene.base ? pin(scene.base) : baseOid;
4557
+ scene.target = scene.target ? pin(scene.target) : headOid;
4558
+ }
4559
+ }
4560
+ return copy;
4561
+ }
4562
+ function compileTourImage(repoRoot, source, revision) {
4563
+ const object = `${revision}:${source.file}`;
4564
+ let size;
4565
+ try {
4566
+ size = Number((0, import_child_process4.execFileSync)("git", ["cat-file", "-s", object], {
4567
+ cwd: repoRoot,
4568
+ encoding: "utf8",
4569
+ stdio: ["ignore", "pipe", "pipe"]
4570
+ }).trim());
4571
+ } catch {
4572
+ throw new Error(`Image ${source.file} is unavailable at pinned revision ${revision}.`);
4573
+ }
4574
+ if (!Number.isSafeInteger(size) || size < 33 || size > MAX_TOUR_IMAGE_BYTES) {
4575
+ throw new Error(`Image ${source.file} must be a PNG of at most 8 MiB.`);
4576
+ }
4577
+ const bytes = (0, import_child_process4.execFileSync)("git", ["cat-file", "blob", object], { cwd: repoRoot, maxBuffer: MAX_TOUR_IMAGE_BYTES });
4578
+ validatePngHeader(bytes, source.file);
4579
+ return { path: source.file, revision, alt: source.alt, dataUrl: `data:image/png;base64,${bytes.toString("base64")}` };
4580
+ }
4114
4581
  // Annotate the CommonJS export names for ESM import in node:
4115
4582
  0 && (module.exports = {
4116
4583
  CHANGE_ATTENTION_VERSION,
@@ -4124,6 +4591,7 @@ function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
4124
4591
  materializeChangeUnits,
4125
4592
  parseChangeTourManifest,
4126
4593
  parseChangeTourSource,
4127
- parseChangeTourStory
4594
+ parseChangeTourStory,
4595
+ pinTourSource
4128
4596
  });
4129
4597
  //# sourceMappingURL=changeTour.js.map