@davmash/bygone 0.5.2 → 0.7.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.
@@ -0,0 +1,1901 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/changeTour.ts
31
+ var changeTour_exports = {};
32
+ __export(changeTour_exports, {
33
+ buildChangeInventory: () => buildChangeInventory,
34
+ buildChangeTourContext: () => buildChangeTourContext,
35
+ buildChangeTourManifest: () => buildChangeTourManifest,
36
+ buildDeconstructedScene: () => buildDeconstructedScene,
37
+ compileDeconstructedScene: () => compileDeconstructedScene,
38
+ materializeChangeUnits: () => materializeChangeUnits,
39
+ parseChangeTourManifest: () => parseChangeTourManifest,
40
+ parseChangeTourSource: () => parseChangeTourSource,
41
+ parseChangeTourStory: () => parseChangeTourStory
42
+ });
43
+ module.exports = __toCommonJS(changeTour_exports);
44
+ var import_child_process4 = require("child_process");
45
+
46
+ // src/gitComparison.ts
47
+ var import_child_process = require("child_process");
48
+ var fs = __toESM(require("fs"));
49
+ var DEFAULT_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
50
+ function createGitRunner(maxBuffer = readGitMaxBuffer()) {
51
+ return (args, cwd) => (0, import_child_process.execFileSync)("git", [...args], {
52
+ cwd,
53
+ encoding: "utf8",
54
+ maxBuffer,
55
+ stdio: ["ignore", "pipe", "pipe"]
56
+ }).trimEnd();
57
+ }
58
+ function resolveBranchReviewRange(startPath, headRef = "HEAD", requestedBaseRef, runGit3 = createGitRunner()) {
59
+ const repoRoot = fs.realpathSync(runGit3(["rev-parse", "--show-toplevel"], startPath));
60
+ const currentBranch = tryRunGit(runGit3, ["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot);
61
+ const baseRef = requestedBaseRef || detectDefaultBaseRef(repoRoot, headRef, runGit3);
62
+ const headOid = verifyCommit(repoRoot, headRef, runGit3);
63
+ const baseOid = verifyCommit(repoRoot, baseRef, runGit3);
64
+ const mergeBaseOids = runGit3(["merge-base", "--all", baseOid, headOid], repoRoot).split("\n").filter(Boolean);
65
+ if (mergeBaseOids.length === 0) {
66
+ throw new Error(`No merge base exists between ${baseRef} and ${headRef}.`);
67
+ }
68
+ if (mergeBaseOids.length > 1) {
69
+ throw new Error(
70
+ `Multiple merge bases exist between ${baseRef} and ${headRef}: ${mergeBaseOids.map((oid) => oid.slice(0, 7)).join(", ")}. Pass one merge-base commit explicitly with --base.`
71
+ );
72
+ }
73
+ const mergeBaseOid = mergeBaseOids[0];
74
+ return {
75
+ repoRoot,
76
+ baseRef,
77
+ headRef,
78
+ baseOid,
79
+ headOid,
80
+ mergeBaseOid,
81
+ currentBranch: currentBranch || void 0,
82
+ dirty: runGit3(["status", "--porcelain=v1", "--untracked-files=normal"], repoRoot).length > 0,
83
+ changedPaths: listChangedPaths(repoRoot, mergeBaseOid, headOid, runGit3),
84
+ commits: listBranchCommits(repoRoot, mergeBaseOid, headOid, runGit3)
85
+ };
86
+ }
87
+ function detectDefaultBaseRef(repoRoot, headRef = "HEAD", runGit3 = createGitRunner()) {
88
+ const remoteDefault = tryRunGit(
89
+ runGit3,
90
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
91
+ repoRoot
92
+ );
93
+ const candidates = [remoteDefault, "main", "master"].filter((candidate) => Boolean(candidate)).filter((candidate, index, all) => all.indexOf(candidate) === index);
94
+ for (const candidate of candidates) {
95
+ if (candidate !== headRef && canResolveCommit(repoRoot, candidate, runGit3)) {
96
+ return candidate;
97
+ }
98
+ }
99
+ throw new Error("Could not detect a base branch. Pass --base <ref>.");
100
+ }
101
+ function listChangedPaths(repoRoot, baseOid, headOid, runGit3 = createGitRunner()) {
102
+ const output = runGit3([
103
+ "diff",
104
+ "--name-status",
105
+ "-z",
106
+ "--find-renames",
107
+ "--find-copies",
108
+ baseOid,
109
+ headOid,
110
+ "--"
111
+ ], repoRoot);
112
+ return parseNameStatusZ(output);
113
+ }
114
+ function parseNameStatusZ(output) {
115
+ const fields = output.split("\0");
116
+ if (fields[fields.length - 1] === "") {
117
+ fields.pop();
118
+ }
119
+ const changedPaths = [];
120
+ for (let index = 0; index < fields.length; ) {
121
+ const status = fields[index++];
122
+ if (!status) {
123
+ continue;
124
+ }
125
+ const code = status[0];
126
+ const similarityText = status.slice(1);
127
+ const similarity = similarityText ? Number.parseInt(similarityText, 10) : void 0;
128
+ if (code === "R" || code === "C") {
129
+ const previousPath = fields[index++];
130
+ const nextPath = fields[index++];
131
+ if (previousPath && nextPath) {
132
+ changedPaths.push({
133
+ kind: code === "R" ? "renamed" : "copied",
134
+ path: nextPath,
135
+ previousPath,
136
+ similarity: Number.isFinite(similarity) ? similarity : void 0
137
+ });
138
+ }
139
+ continue;
140
+ }
141
+ const changedPath = fields[index++];
142
+ if (!changedPath) {
143
+ continue;
144
+ }
145
+ changedPaths.push({
146
+ kind: changeKindForStatus(code),
147
+ path: changedPath
148
+ });
149
+ }
150
+ return changedPaths;
151
+ }
152
+ function listBranchCommits(repoRoot, mergeBaseOid, headOid, runGit3 = createGitRunner()) {
153
+ const output = runGit3([
154
+ "log",
155
+ "--reverse",
156
+ "--topo-order",
157
+ "--format=%H%x00%h%x00%cI%x00%s%x00%P%x1e",
158
+ `${mergeBaseOid}..${headOid}`
159
+ ], repoRoot);
160
+ return output.split("").map((record) => record.replace(/^\n+|\n+$/g, "")).filter(Boolean).map((record) => {
161
+ const [oid = "", shortOid = "", timestamp = "", summary = "", parents = ""] = record.split("\0");
162
+ return {
163
+ oid,
164
+ shortOid,
165
+ timestamp,
166
+ summary,
167
+ parentOids: parents.split(" ").filter(Boolean)
168
+ };
169
+ }).filter((commit) => Boolean(commit.oid));
170
+ }
171
+ function resolveReviewPathPair(changedPaths, selectedPath) {
172
+ const changedPath = changedPaths.find((candidate) => candidate.path === selectedPath || candidate.previousPath === selectedPath);
173
+ if (!changedPath) {
174
+ return void 0;
175
+ }
176
+ const relatesDistinctPaths = (changedPath.kind === "renamed" || changedPath.kind === "copied") && Boolean(changedPath.previousPath);
177
+ const leftPath = changedPath.kind === "added" ? null : relatesDistinctPaths ? changedPath.previousPath ?? null : changedPath.path;
178
+ const rightPath = changedPath.kind === "deleted" ? null : changedPath.path;
179
+ const relationVerb = changedPath.kind === "renamed" ? "Renamed" : changedPath.kind === "copied" ? "Copied" : null;
180
+ return {
181
+ key: changedPath.path,
182
+ leftPath,
183
+ rightPath,
184
+ kind: changedPath.kind,
185
+ similarity: changedPath.similarity,
186
+ summary: relationVerb && changedPath.previousPath ? `${relationVerb} ${changedPath.previousPath} \u2192 ${changedPath.path}` + (changedPath.similarity === void 0 ? "" : ` \xB7 ${changedPath.similarity}% similarity`) : void 0
187
+ };
188
+ }
189
+ function verifyCommit(repoRoot, ref, runGit3) {
190
+ try {
191
+ return runGit3(["rev-parse", "--verify", `${ref}^{commit}`], repoRoot);
192
+ } catch {
193
+ throw new Error(`Could not resolve git ref "${ref}".`);
194
+ }
195
+ }
196
+ function canResolveCommit(repoRoot, ref, runGit3) {
197
+ try {
198
+ verifyCommit(repoRoot, ref, runGit3);
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+ function tryRunGit(runGit3, args, cwd) {
205
+ try {
206
+ return runGit3(args, cwd);
207
+ } catch {
208
+ return "";
209
+ }
210
+ }
211
+ function changeKindForStatus(code) {
212
+ if (code === "A") {
213
+ return "added";
214
+ }
215
+ if (code === "D") {
216
+ return "deleted";
217
+ }
218
+ if (code === "M") {
219
+ return "modified";
220
+ }
221
+ if (code === "T") {
222
+ return "type-changed";
223
+ }
224
+ if (code === "U") {
225
+ return "unmerged";
226
+ }
227
+ return "unknown";
228
+ }
229
+ function readGitMaxBuffer() {
230
+ const parsed = Number.parseInt(process.env.BYGONE_GIT_MAX_BUFFER_BYTES ?? "", 10);
231
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GIT_MAX_BUFFER_BYTES;
232
+ }
233
+
234
+ // src/changeTourManifest.ts
235
+ var CHANGE_TOUR_MANIFEST_VERSION = 1;
236
+ function parseChangeTourManifest(value) {
237
+ if (!isRecord(value) || value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
238
+ throw new Error(`Unsupported or missing change-tour manifest version.`);
239
+ }
240
+ requireString(value.title, "title");
241
+ requireString(value.generatedAt, "generatedAt");
242
+ if (value.sourceUrl !== void 0) {
243
+ requireString(value.sourceUrl, "sourceUrl");
244
+ }
245
+ if (!isRecord(value.range)) {
246
+ throw new Error("Change-tour manifest range must be an object.");
247
+ }
248
+ for (const key of ["baseRef", "headRef", "mergeBaseOid", "headOid"]) {
249
+ requireString(value.range[key], `range.${key}`);
250
+ }
251
+ if (!isRecord(value.summary)) {
252
+ throw new Error("Change-tour manifest summary must be an object.");
253
+ }
254
+ for (const key of ["changedFiles", "includedScenes", "additions", "deletions", "commitCount"]) {
255
+ requireNonNegativeInteger(value.summary[key], `summary.${key}`);
256
+ }
257
+ requireStringArray(value.summary.omittedFiles, "summary.omittedFiles");
258
+ if (!Array.isArray(value.commits) || !Array.isArray(value.chapters) || !Array.isArray(value.scenes)) {
259
+ throw new Error("Change-tour manifest commits, chapters, and scenes must be arrays.");
260
+ }
261
+ if (value.files !== void 0 && !Array.isArray(value.files)) {
262
+ throw new Error("Change-tour manifest files must be an array.");
263
+ }
264
+ const sceneIds = /* @__PURE__ */ new Set();
265
+ for (const [index, candidate] of value.scenes.entries()) {
266
+ validateScene(candidate, index);
267
+ if (sceneIds.has(candidate.id)) {
268
+ throw new Error(`Duplicate change-tour scene id: ${candidate.id}`);
269
+ }
270
+ sceneIds.add(candidate.id);
271
+ }
272
+ for (const [index, candidate] of value.chapters.entries()) {
273
+ if (!isRecord(candidate)) {
274
+ throw new Error(`chapters[${index}] must be an object.`);
275
+ }
276
+ requireString(candidate.id, `chapters[${index}].id`);
277
+ requireString(candidate.title, `chapters[${index}].title`);
278
+ requireStringArray(candidate.sceneIds, `chapters[${index}].sceneIds`);
279
+ for (const sceneId of candidate.sceneIds) {
280
+ if (!sceneIds.has(sceneId)) {
281
+ throw new Error(`Chapter references unknown scene id: ${sceneId}`);
282
+ }
283
+ }
284
+ }
285
+ if (value.summary.includedScenes !== value.scenes.length) {
286
+ throw new Error("summary.includedScenes must match the number of scenes.");
287
+ }
288
+ const files = value.files === void 0 ? collectLegacyTourFiles(value.scenes) : value.files.map((candidate, index) => {
289
+ validateTourFile(candidate, index);
290
+ return candidate;
291
+ });
292
+ if (value.files !== void 0 && value.summary.changedFiles !== files.length) {
293
+ throw new Error("summary.changedFiles must match the number of files.");
294
+ }
295
+ return { ...value, files };
296
+ }
297
+ function parseChangeTourStory(value) {
298
+ if (!isRecord(value) || !Array.isArray(value.scenes)) {
299
+ throw new Error("Change-tour story must contain a scenes array.");
300
+ }
301
+ if (value.title !== void 0) {
302
+ requireString(value.title, "title");
303
+ }
304
+ if (value.sourceUrl !== void 0) {
305
+ requireString(value.sourceUrl, "sourceUrl");
306
+ }
307
+ for (const [index, scene] of value.scenes.entries()) {
308
+ if (!isRecord(scene) || scene.kind !== "discussion" && scene.kind !== "file") {
309
+ throw new Error(`story.scenes[${index}] must be a discussion or file scene.`);
310
+ }
311
+ requireString(scene.chapterId, `story.scenes[${index}].chapterId`);
312
+ requireString(scene.chapterTitle, `story.scenes[${index}].chapterTitle`);
313
+ validateNarrative(scene, `story.scenes[${index}]`);
314
+ if (scene.kind === "discussion") {
315
+ requireString(scene.title, `story.scenes[${index}].title`);
316
+ } else {
317
+ requireString(scene.path, `story.scenes[${index}].path`);
318
+ if (scene.title !== void 0) {
319
+ requireString(scene.title, `story.scenes[${index}].title`);
320
+ }
321
+ if (scene.focusChangeIndex !== void 0) {
322
+ requireNonNegativeInteger(scene.focusChangeIndex, `story.scenes[${index}].focusChangeIndex`);
323
+ }
324
+ }
325
+ }
326
+ return value;
327
+ }
328
+ function validateScene(value, index) {
329
+ if (!isRecord(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
330
+ throw new Error(`scenes[${index}] must be a text-diff, discussion, walkthrough, stacked-diff, or deconstructed-diff scene.`);
331
+ }
332
+ for (const key of ["id", "title"]) {
333
+ requireString(value[key], `scenes[${index}].${key}`);
334
+ }
335
+ validateNarrative(value, `scenes[${index}]`);
336
+ if (value.kind === "discussion") {
337
+ return;
338
+ }
339
+ if (value.kind === "walkthrough") {
340
+ if (!Array.isArray(value.steps) || value.steps.length === 0) {
341
+ throw new Error(`scenes[${index}].steps must be a non-empty array.`);
342
+ }
343
+ for (const [stepIndex, step] of value.steps.entries()) {
344
+ const path = `scenes[${index}].steps[${stepIndex}]`;
345
+ if (!isRecord(step) || !isRecord(step.focus) || !isRecord(step.diff)) {
346
+ throw new Error(`${path} must contain focus and diff objects.`);
347
+ }
348
+ for (const key of ["id", "title", "body"]) requireString(step[key], `${path}.${key}`);
349
+ if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
350
+ throw new Error(`${path}.depth must be mentioned, explained, or contextualized.`);
351
+ }
352
+ validateResolvedAnchor(step.focus, `${path}.focus`);
353
+ validateScene(step.diff, index);
354
+ if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
355
+ if (step.connection !== void 0) {
356
+ if (!isRecord(step.connection)) throw new Error(`${path}.connection must be an object.`);
357
+ requireString(step.connection.id, `${path}.connection.id`);
358
+ requireString(step.connection.label, `${path}.connection.label`);
359
+ validateResolvedAnchor(step.connection.from, `${path}.connection.from`);
360
+ validateResolvedAnchor(step.connection.to, `${path}.connection.to`);
361
+ }
362
+ }
363
+ return;
364
+ }
365
+ if (value.kind === "stacked-diff") {
366
+ validateStackedScene(value, index);
367
+ return;
368
+ }
369
+ if (value.kind === "deconstructed-diff") {
370
+ validateDeconstructedScene(value, index);
371
+ return;
372
+ }
373
+ for (const key of ["path", "leftLabel", "rightLabel", "leftContent", "rightContent"]) {
374
+ requireString(value[key], `scenes[${index}].${key}`);
375
+ }
376
+ if (value.previousPath !== void 0) {
377
+ requireString(value.previousPath, `scenes[${index}].previousPath`);
378
+ }
379
+ requireString(value.changeKind, `scenes[${index}].changeKind`);
380
+ requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
381
+ requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
382
+ if (value.focusChangeIndex !== void 0) {
383
+ requireNonNegativeInteger(value.focusChangeIndex, `scenes[${index}].focusChangeIndex`);
384
+ }
385
+ }
386
+ function validateStackedScene(value, index) {
387
+ if (!Array.isArray(value.stack) || value.stack.length < 3 || value.stack.length > 6) {
388
+ throw new Error(`scenes[${index}].stack must contain between 3 and 6 revisions.`);
389
+ }
390
+ for (const [panelIndex, panel] of value.stack.entries()) {
391
+ if (!isRecord(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
392
+ for (const key of ["id", "ref", "oid", "label"]) requireString(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
393
+ }
394
+ validateMultiPanelFiles(value.files, value.stack.length, index);
395
+ validateMultiPanelSteps(value.steps, value.stack.length, index, false);
396
+ }
397
+ function validateDeconstructedScene(value, index) {
398
+ if (value.stageLabel !== "Explanation stages") {
399
+ throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
400
+ }
401
+ if (!isRecord(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
402
+ for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
403
+ requireString(value.realRange[key], `scenes[${index}].realRange.${key}`);
404
+ }
405
+ if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
406
+ throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
407
+ }
408
+ const panels = value.panels;
409
+ for (const [panelIndex, panel] of panels.entries()) {
410
+ if (!isRecord(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
411
+ for (const key of ["id", "label", "role"]) requireString(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
412
+ const expectedRole = panelIndex === 0 ? "baseline" : "stage";
413
+ if (panel.role !== expectedRole) throw new Error(`scenes[${index}].panels[${panelIndex}].role must be ${expectedRole}.`);
414
+ if (panelIndex > 0) requireString(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
415
+ }
416
+ validateMultiPanelFiles(value.files, panels.length, index);
417
+ validateMultiPanelSteps(value.steps, panels.length, index, true);
418
+ if (Array.isArray(value.steps) && value.steps.length !== panels.length - 1) {
419
+ throw new Error(`scenes[${index}].steps must match the number of explanation stages.`);
420
+ }
421
+ if (Array.isArray(value.steps)) {
422
+ value.steps.forEach((step, stepIndex) => {
423
+ const panel = panels[stepIndex + 1];
424
+ if (!isRecord(step) || !isRecord(panel) || panel.stageId !== step.id) {
425
+ throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
426
+ }
427
+ });
428
+ }
429
+ }
430
+ function validateMultiPanelFiles(value, panelCount, index) {
431
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
432
+ for (const [fileIndex, file] of value.entries()) {
433
+ if (!isRecord(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
434
+ requireString(file.path, `scenes[${index}].files[${fileIndex}].path`);
435
+ if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
436
+ throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
437
+ }
438
+ for (const [panelIndex, panel] of file.panels.entries()) {
439
+ if (!isRecord(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
440
+ for (const key of ["id", "label", "content"]) requireString(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
441
+ if (panel.path !== void 0) requireString(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
442
+ if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
443
+ }
444
+ }
445
+ }
446
+ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
447
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
448
+ for (const [stepIndex, step] of value.entries()) {
449
+ if (!isRecord(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
450
+ for (const key of ["id", "title", "body", "file", "side"]) requireString(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
451
+ requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
452
+ if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
453
+ if (deconstructed) {
454
+ requireStringArray(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
455
+ if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
456
+ if (Number(step.pairIndex) !== stepIndex) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex must match its explanation stage.`);
457
+ }
458
+ if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
459
+ if (step.startLine !== void 0) requirePositiveInteger(step.startLine, `scenes[${index}].steps[${stepIndex}].startLine`);
460
+ if (step.endLine !== void 0) requirePositiveInteger(step.endLine, `scenes[${index}].steps[${stepIndex}].endLine`);
461
+ }
462
+ }
463
+ function validateTourFile(value, index) {
464
+ if (!isRecord(value)) throw new Error(`files[${index}] must be an object.`);
465
+ if (value.kind === "text-diff") {
466
+ validateScene(value, index);
467
+ return;
468
+ }
469
+ if (value.kind !== "omitted") {
470
+ throw new Error(`files[${index}].kind must be text-diff or omitted.`);
471
+ }
472
+ for (const key of ["id", "title", "path", "changeKind", "reason"]) {
473
+ requireString(value[key], `files[${index}].${key}`);
474
+ }
475
+ if (value.previousPath !== void 0) requireString(value.previousPath, `files[${index}].previousPath`);
476
+ requireNonNegativeInteger(value.additions, `files[${index}].additions`);
477
+ requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
478
+ }
479
+ function collectLegacyTourFiles(scenes) {
480
+ const files = /* @__PURE__ */ new Map();
481
+ for (const scene of scenes) {
482
+ if (scene.kind === "text-diff") {
483
+ if (!files.has(scene.path)) files.set(scene.path, scene);
484
+ continue;
485
+ }
486
+ if (scene.kind === "walkthrough") {
487
+ for (const step of scene.steps) {
488
+ if (!files.has(step.diff.path)) files.set(step.diff.path, step.diff);
489
+ }
490
+ }
491
+ }
492
+ return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
493
+ }
494
+ function validateResolvedAnchor(value, path) {
495
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
496
+ for (const key of ["id", "path", "revision", "excerpt"]) requireString(value[key], `${path}.${key}`);
497
+ if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
498
+ requirePositiveInteger(value.startLine, `${path}.startLine`);
499
+ requirePositiveInteger(value.endLine, `${path}.endLine`);
500
+ if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
501
+ }
502
+ function validateNarrative(value, path) {
503
+ requireString(value.summary, `${path}.summary`);
504
+ requireStringArray(value.bullets, `${path}.bullets`);
505
+ requireStringArray(value.tags, `${path}.tags`);
506
+ requireString(value.takeaway, `${path}.takeaway`);
507
+ }
508
+ function isRecord(value) {
509
+ return typeof value === "object" && value !== null && !Array.isArray(value);
510
+ }
511
+ function requireString(value, path) {
512
+ if (typeof value !== "string") {
513
+ throw new Error(`${path} must be a string.`);
514
+ }
515
+ }
516
+ function requireStringArray(value, path) {
517
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
518
+ throw new Error(`${path} must be an array of strings.`);
519
+ }
520
+ }
521
+ function requireNonNegativeInteger(value, path) {
522
+ if (!Number.isInteger(value) || Number(value) < 0) {
523
+ throw new Error(`${path} must be a non-negative integer.`);
524
+ }
525
+ }
526
+ function requirePositiveInteger(value, path) {
527
+ if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
528
+ }
529
+
530
+ // src/changeTourSource.ts
531
+ var CHANGE_TOUR_SOURCE_VERSION = 1;
532
+ function parseChangeTourSource(value) {
533
+ if (!isRecord2(value) || value.version !== CHANGE_TOUR_SOURCE_VERSION) {
534
+ throw new Error("Unsupported or missing change-tour source version.");
535
+ }
536
+ requireOnlyKeys(value, ["version", "title", "sourceUrl", "range", "anchors", "connections", "chapters", "coverage"], "source");
537
+ optionalString(value.title, "title");
538
+ optionalString(value.sourceUrl, "sourceUrl");
539
+ if (value.range !== void 0) {
540
+ if (!isRecord2(value.range)) throw new Error("range must be an object.");
541
+ requireOnlyKeys(value.range, ["base", "head"], "range");
542
+ requireString2(value.range.base, "range.base");
543
+ requireString2(value.range.head, "range.head");
544
+ }
545
+ if (value.coverage !== void 0) {
546
+ if (!isRecord2(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
547
+ throw new Error("coverage must contain an exclusions array.");
548
+ }
549
+ requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
550
+ for (const [index, exclusion] of value.coverage.exclusions.entries()) {
551
+ const exclusionPath = `coverage.exclusions[${index}]`;
552
+ if (!isRecord2(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
553
+ requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
554
+ requireString2(exclusion.path, `${exclusionPath}.path`);
555
+ requireString2(exclusion.reason, `${exclusionPath}.reason`);
556
+ if (exclusion.hunks !== void 0) requireStringArray2(exclusion.hunks, `${exclusionPath}.hunks`);
557
+ }
558
+ }
559
+ if (!isRecord2(value.anchors)) {
560
+ throw new Error("anchors must be an object.");
561
+ }
562
+ for (const [id, anchor] of Object.entries(value.anchors)) {
563
+ if (!isRecord2(anchor)) {
564
+ throw new Error(`anchors.${id} must be an object.`);
565
+ }
566
+ requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
567
+ requireString2(anchor.file, `anchors.${id}.file`);
568
+ if (anchor.revision !== "base" && anchor.revision !== "head") {
569
+ throw new Error(`anchors.${id}.revision must be base or head.`);
570
+ }
571
+ requireString2(anchor.contains, `anchors.${id}.contains`);
572
+ if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
573
+ throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
574
+ }
575
+ }
576
+ const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord2(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord2(connection) ? { id, ...connection } : connection) : null;
577
+ if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
578
+ throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
579
+ }
580
+ const connectionIds = /* @__PURE__ */ new Set();
581
+ for (const [index, connection] of rawConnections.entries()) {
582
+ if (!isRecord2(connection)) {
583
+ throw new Error(`connections[${index}] must be an object.`);
584
+ }
585
+ requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
586
+ requireString2(connection.id, `connections[${index}].id`);
587
+ requireString2(connection.from, `connections[${index}].from`);
588
+ requireString2(connection.to, `connections[${index}].to`);
589
+ requireString2(connection.label, `connections[${index}].label`);
590
+ const { id, from, to } = connection;
591
+ if (!value.anchors[from] || !value.anchors[to]) {
592
+ throw new Error(`Connection ${id} references an unknown anchor.`);
593
+ }
594
+ if (connectionIds.has(id)) {
595
+ throw new Error(`Duplicate connection id: ${id}`);
596
+ }
597
+ connectionIds.add(id);
598
+ }
599
+ const chapterIds = /* @__PURE__ */ new Set();
600
+ const sceneIds = /* @__PURE__ */ new Set();
601
+ for (const [chapterIndex, chapter] of value.chapters.entries()) {
602
+ if (!isRecord2(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
603
+ throw new Error(`chapters[${chapterIndex}] must contain a non-empty scenes array.`);
604
+ }
605
+ requireString2(chapter.id, `chapters[${chapterIndex}].id`);
606
+ requireString2(chapter.title, `chapters[${chapterIndex}].title`);
607
+ requireOnlyKeys(chapter, ["id", "title", "scenes"], `chapters[${chapterIndex}]`);
608
+ if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
609
+ chapterIds.add(chapter.id);
610
+ for (const [sceneIndex, scene] of chapter.scenes.entries()) {
611
+ const path = `chapters[${chapterIndex}].scenes[${sceneIndex}]`;
612
+ if (!isRecord2(scene)) throw new Error(`${path} must be an object.`);
613
+ requireString2(scene.id, `${path}.id`);
614
+ requireString2(scene.title, `${path}.title`);
615
+ if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
616
+ sceneIds.add(scene.id);
617
+ validateNarrative2(scene, path);
618
+ if (scene.kind === "deconstructed-diff") {
619
+ validateDeconstructedScene2(scene, path);
620
+ continue;
621
+ }
622
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
623
+ throw new Error(`${path} must contain a non-empty steps array.`);
624
+ }
625
+ if (scene.kind === "stacked-diff") {
626
+ validateStackedScene2(scene, path);
627
+ continue;
628
+ }
629
+ if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
630
+ throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
631
+ }
632
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "steps"], path);
633
+ const stepIds = /* @__PURE__ */ new Set();
634
+ for (const [stepIndex, step] of scene.steps.entries()) {
635
+ const stepPath = `${path}.steps[${stepIndex}]`;
636
+ if (!isRecord2(step)) {
637
+ throw new Error(`${stepPath} must be an object.`);
638
+ }
639
+ requireString2(step.id, `${stepPath}.id`);
640
+ requireString2(step.title, `${stepPath}.title`);
641
+ requireString2(step.body, `${stepPath}.body`);
642
+ requireString2(step.focus, `${stepPath}.focus`);
643
+ requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth"], stepPath);
644
+ if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
645
+ throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
646
+ }
647
+ if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
648
+ stepIds.add(step.id);
649
+ const focus = step.focus;
650
+ if (!value.anchors[focus]) {
651
+ throw new Error(`${stepPath} references unknown anchor ${focus}.`);
652
+ }
653
+ optionalString(step.connection, `${stepPath}.connection`);
654
+ const connection = step.connection;
655
+ if (typeof connection === "string" && !connectionIds.has(connection)) {
656
+ throw new Error(`${stepPath} references unknown connection ${connection}.`);
657
+ }
658
+ }
659
+ }
660
+ }
661
+ return { ...value, connections: rawConnections };
662
+ }
663
+ function validateDeconstructedScene2(scene, path) {
664
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "base", "target", "stages", "exclusions"], path);
665
+ optionalString(scene.base, `${path}.base`);
666
+ optionalString(scene.target, `${path}.target`);
667
+ if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
668
+ throw new Error(`${path}.stages must contain between 1 and 12 stages.`);
669
+ }
670
+ const stageIds = /* @__PURE__ */ new Set();
671
+ for (const [stageIndex, stage] of scene.stages.entries()) {
672
+ const stagePath = `${path}.stages[${stageIndex}]`;
673
+ if (!isRecord2(stage)) throw new Error(`${stagePath} must be an object.`);
674
+ requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
675
+ requireString2(stage.id, `${stagePath}.id`);
676
+ requireString2(stage.title, `${stagePath}.title`);
677
+ requireString2(stage.narration, `${stagePath}.narration`);
678
+ if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
679
+ throw new Error(`${stagePath}.changes must be a non-empty array.`);
680
+ }
681
+ if (stageIds.has(stage.id)) throw new Error(`Duplicate deconstructed stage id: ${stage.id}`);
682
+ stageIds.add(stage.id);
683
+ stage.changes.forEach((change, changeIndex) => validateDeconstructedSelection(
684
+ change,
685
+ `${stagePath}.changes[${changeIndex}]`,
686
+ false
687
+ ));
688
+ }
689
+ if (scene.exclusions !== void 0) {
690
+ if (!Array.isArray(scene.exclusions)) throw new Error(`${path}.exclusions must be an array.`);
691
+ scene.exclusions.forEach((exclusion, exclusionIndex) => validateDeconstructedSelection(
692
+ exclusion,
693
+ `${path}.exclusions[${exclusionIndex}]`,
694
+ true
695
+ ));
696
+ }
697
+ }
698
+ function validateDeconstructedSelection(value, path, requiresReason) {
699
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
700
+ requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
701
+ requireString2(value.file, `${path}.file`);
702
+ if (!requiresReason || value.hunks !== void 0) {
703
+ requireStringArray2(value.hunks, `${path}.hunks`);
704
+ if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
705
+ }
706
+ if (requiresReason) requireString2(value.reason, `${path}.reason`);
707
+ }
708
+ function validateStackedScene2(scene, path) {
709
+ requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "stack", "files", "steps"], path);
710
+ if (!Array.isArray(scene.stack) || scene.stack.length < 3 || scene.stack.length > 6) {
711
+ throw new Error(`${path}.stack must contain between 3 and 6 revisions.`);
712
+ }
713
+ const stackIds = /* @__PURE__ */ new Set();
714
+ for (const [index, entry] of scene.stack.entries()) {
715
+ const entryPath = `${path}.stack[${index}]`;
716
+ if (!isRecord2(entry)) throw new Error(`${entryPath} must be an object.`);
717
+ requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
718
+ requireString2(entry.id, `${entryPath}.id`);
719
+ requireString2(entry.ref, `${entryPath}.ref`);
720
+ optionalString(entry.label, `${entryPath}.label`);
721
+ if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
722
+ stackIds.add(entry.id);
723
+ }
724
+ if (scene.files !== void 0) requireStringArray2(scene.files, `${path}.files`);
725
+ if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
726
+ const stepIds = /* @__PURE__ */ new Set();
727
+ for (const [index, step] of scene.steps.entries()) {
728
+ const stepPath = `${path}.steps[${index}]`;
729
+ if (!isRecord2(step)) throw new Error(`${stepPath} must be an object.`);
730
+ requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
731
+ requireString2(step.id, `${stepPath}.id`);
732
+ requireString2(step.title, `${stepPath}.title`);
733
+ requireString2(step.body, `${stepPath}.body`);
734
+ requireString2(step.file, `${stepPath}.file`);
735
+ const stepId = step.id;
736
+ if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
737
+ stepIds.add(stepId);
738
+ if (!Array.isArray(step.pair) || step.pair.length !== 2 || step.pair.some((id) => typeof id !== "string")) {
739
+ throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
740
+ }
741
+ const pair = step.pair;
742
+ const leftIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[0]);
743
+ const rightIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[1]);
744
+ if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
745
+ if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
746
+ 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]))) {
747
+ throw new Error(`${stepPath}.lines must contain an ordered positive line range.`);
748
+ }
749
+ }
750
+ }
751
+ function validateNarrative2(value, path) {
752
+ requireString2(value.summary, `${path}.summary`);
753
+ requireStringArray2(value.bullets, `${path}.bullets`);
754
+ requireStringArray2(value.tags, `${path}.tags`);
755
+ requireString2(value.takeaway, `${path}.takeaway`);
756
+ }
757
+ function isRecord2(value) {
758
+ return typeof value === "object" && value !== null && !Array.isArray(value);
759
+ }
760
+ function requireString2(value, path) {
761
+ if (typeof value !== "string" || value.length === 0) {
762
+ throw new Error(`${path} must be a non-empty string.`);
763
+ }
764
+ }
765
+ function optionalString(value, path) {
766
+ if (value !== void 0) {
767
+ requireString2(value, path);
768
+ }
769
+ }
770
+ function requireStringArray2(value, path) {
771
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
772
+ throw new Error(`${path} must be an array of strings.`);
773
+ }
774
+ }
775
+ function requireOnlyKeys(value, allowed, path) {
776
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
777
+ if (unexpected.length > 0) {
778
+ throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
779
+ }
780
+ }
781
+
782
+ // src/changeInventory.ts
783
+ var import_crypto = require("crypto");
784
+ var import_child_process2 = require("child_process");
785
+ var CHANGE_INVENTORY_VERSION = 2;
786
+ function buildChangeInventory(startPath, options = {}) {
787
+ const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
788
+ const files = range.changedPaths.map((changedPath) => buildInventoryFile(
789
+ range.repoRoot,
790
+ range.mergeBaseOid,
791
+ range.headOid,
792
+ changedPath
793
+ ));
794
+ return {
795
+ version: CHANGE_INVENTORY_VERSION,
796
+ range: {
797
+ repoRoot: range.repoRoot,
798
+ baseRef: range.baseRef,
799
+ headRef: range.headRef,
800
+ baseOid: range.mergeBaseOid,
801
+ headOid: range.headOid
802
+ },
803
+ summary: {
804
+ changedFiles: files.length,
805
+ textualFiles: files.filter((file) => file.material === "text").length,
806
+ binaryFiles: files.filter((file) => file.material === "binary").length,
807
+ changeUnits: files.reduce((total, file) => total + file.units.length, 0),
808
+ additions: files.reduce((total, file) => total + (file.additions ?? 0), 0),
809
+ deletions: files.reduce((total, file) => total + (file.deletions ?? 0), 0)
810
+ },
811
+ files
812
+ };
813
+ }
814
+ function buildInventoryFile(repoRoot, baseOid, headOid, changedPath) {
815
+ const paths = [...new Set([changedPath.previousPath, changedPath.path].filter(Boolean))];
816
+ const numstat = runGit(repoRoot, ["diff", "--numstat", "--find-renames", baseOid, headOid, "--", ...paths]);
817
+ const [added = "0", deleted = "0"] = numstat.trim().split(/\s+/, 3);
818
+ const basePath = changedPath.kind === "added" ? void 0 : changedPath.previousPath || changedPath.path;
819
+ const headPath = changedPath.kind === "deleted" ? void 0 : changedPath.path;
820
+ const baseBlob = readGitBlob(repoRoot, baseOid, basePath);
821
+ const headBlob = readGitBlob(repoRoot, headOid, headPath);
822
+ if (!baseBlob || !headBlob) {
823
+ return {
824
+ path: changedPath.path,
825
+ previousPath: changedPath.previousPath,
826
+ changeKind: changedPath.kind,
827
+ material: "unsupported",
828
+ additions: Number.parseInt(added, 10) || 0,
829
+ deletions: Number.parseInt(deleted, 10) || 0,
830
+ units: []
831
+ };
832
+ }
833
+ const binary = added === "-" || deleted === "-" || !isLosslessTextBlob(baseBlob) || !isLosslessTextBlob(headBlob);
834
+ if (binary) {
835
+ return {
836
+ path: changedPath.path,
837
+ previousPath: changedPath.previousPath,
838
+ changeKind: changedPath.kind,
839
+ material: "binary",
840
+ additions: null,
841
+ deletions: null,
842
+ units: []
843
+ };
844
+ }
845
+ let patch;
846
+ try {
847
+ patch = runGit(repoRoot, [
848
+ "diff",
849
+ "--no-ext-diff",
850
+ "--no-textconv",
851
+ "--no-color",
852
+ "--find-renames=20%",
853
+ "--diff-algorithm=default",
854
+ "--unified=0",
855
+ "--inter-hunk-context=0",
856
+ baseOid,
857
+ headOid,
858
+ "--",
859
+ ...paths
860
+ ]);
861
+ } catch {
862
+ return {
863
+ path: changedPath.path,
864
+ previousPath: changedPath.previousPath,
865
+ changeKind: changedPath.kind,
866
+ material: "unsupported",
867
+ additions: Number.parseInt(added, 10) || 0,
868
+ deletions: Number.parseInt(deleted, 10) || 0,
869
+ units: []
870
+ };
871
+ }
872
+ const units = parsePatchUnits(patch, changedPath);
873
+ return {
874
+ path: changedPath.path,
875
+ previousPath: changedPath.previousPath,
876
+ changeKind: changedPath.kind,
877
+ material: "text",
878
+ additions: Number.parseInt(added, 10) || 0,
879
+ deletions: Number.parseInt(deleted, 10) || 0,
880
+ units,
881
+ baseContent: baseBlob.toString("utf8"),
882
+ headContent: headBlob.toString("utf8")
883
+ };
884
+ }
885
+ function parsePatchUnits(patch, changedPath) {
886
+ const lines = patch.split("\n");
887
+ const units = [];
888
+ const seenIds = /* @__PURE__ */ new Map();
889
+ for (let index = 0; index < lines.length; index += 1) {
890
+ const match = lines[index].match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
891
+ if (!match) continue;
892
+ const body = [];
893
+ for (index += 1; index < lines.length && !lines[index].startsWith("@@ ") && !lines[index].startsWith("diff --git "); index += 1) {
894
+ body.push(lines[index]);
895
+ }
896
+ index -= 1;
897
+ const { oldText, newText } = parseHunkText(body);
898
+ const contextHash = (0, import_crypto.createHash)("sha256").update(JSON.stringify([oldText, newText])).digest("hex").slice(0, 16);
899
+ const baseId = `hunk-${(0, import_crypto.createHash)("sha256").update(JSON.stringify([
900
+ changedPath.previousPath || changedPath.path,
901
+ changedPath.path,
902
+ oldText,
903
+ newText
904
+ ])).digest("hex").slice(0, 12)}`;
905
+ const occurrence = (seenIds.get(baseId) || 0) + 1;
906
+ seenIds.set(baseId, occurrence);
907
+ units.push({
908
+ id: occurrence === 1 ? baseId : `${baseId}-${occurrence}`,
909
+ path: changedPath.path,
910
+ previousPath: changedPath.previousPath,
911
+ changeKind: changedPath.kind,
912
+ baseStart: Number.parseInt(match[1], 10),
913
+ baseCount: match[2] === void 0 ? 1 : Number.parseInt(match[2], 10),
914
+ headStart: Number.parseInt(match[3], 10),
915
+ headCount: match[4] === void 0 ? 1 : Number.parseInt(match[4], 10),
916
+ additions: body.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
917
+ deletions: body.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
918
+ contextHash,
919
+ oldText,
920
+ newText
921
+ });
922
+ }
923
+ return units;
924
+ }
925
+ function materializeChangeUnits(baseContent, units, selectedUnitIds) {
926
+ const selected = new Set(selectedUnitIds);
927
+ const knownIds = new Set(units.map((unit) => unit.id));
928
+ for (const id of selected) {
929
+ if (!knownIds.has(id)) {
930
+ throw new Error(`Unknown change unit: ${id}`);
931
+ }
932
+ }
933
+ const offsets = buildLineBoundaryOffsets(baseContent);
934
+ const ordered = [...units].sort((left, right) => {
935
+ const leftRange = resolveBaseRange(left, offsets, baseContent.length);
936
+ const rightRange = resolveBaseRange(right, offsets, baseContent.length);
937
+ return leftRange.start - rightRange.start || leftRange.end - rightRange.end;
938
+ });
939
+ const chunks = [];
940
+ let cursor = 0;
941
+ let previousEnd = 0;
942
+ for (const unit of ordered) {
943
+ const range = resolveBaseRange(unit, offsets, baseContent.length);
944
+ if (range.start < previousEnd) {
945
+ throw new Error(`Overlapping change units cannot be materialized independently: ${unit.id}`);
946
+ }
947
+ if (baseContent.slice(range.start, range.end) !== unit.oldText) {
948
+ throw new Error(`Change unit ${unit.id} no longer matches its base content.`);
949
+ }
950
+ if (selected.has(unit.id)) {
951
+ chunks.push(baseContent.slice(cursor, range.start), unit.newText);
952
+ cursor = range.end;
953
+ }
954
+ previousEnd = Math.max(previousEnd, range.end);
955
+ }
956
+ chunks.push(baseContent.slice(cursor));
957
+ return chunks.join("");
958
+ }
959
+ function parseHunkText(body) {
960
+ let oldText = "";
961
+ let newText = "";
962
+ let previousPrefix = "";
963
+ for (const line of body) {
964
+ if (line === "\") {
965
+ if (previousPrefix === "-" || previousPrefix === " ") oldText = removeFinalNewline(oldText);
966
+ if (previousPrefix === "+" || previousPrefix === " ") newText = removeFinalNewline(newText);
967
+ continue;
968
+ }
969
+ const prefix = line[0];
970
+ const content = line.slice(1);
971
+ if (prefix === "-" || prefix === " ") oldText += `${content}
972
+ `;
973
+ if (prefix === "+" || prefix === " ") newText += `${content}
974
+ `;
975
+ previousPrefix = prefix;
976
+ }
977
+ return { oldText, newText };
978
+ }
979
+ function removeFinalNewline(value) {
980
+ return value.endsWith("\n") ? value.slice(0, -1) : value;
981
+ }
982
+ function buildLineBoundaryOffsets(content) {
983
+ const offsets = [0];
984
+ for (let index = 0; index < content.length; index += 1) {
985
+ if (content[index] === "\n") offsets.push(index + 1);
986
+ }
987
+ if (offsets[offsets.length - 1] !== content.length) offsets.push(content.length);
988
+ return offsets;
989
+ }
990
+ function resolveBaseRange(unit, offsets, contentLength) {
991
+ const startIndex = unit.baseCount === 0 ? unit.baseStart : unit.baseStart - 1;
992
+ const endIndex = unit.baseCount === 0 ? startIndex : startIndex + unit.baseCount;
993
+ const start = offsets[startIndex];
994
+ const end = offsets[endIndex];
995
+ if (start === void 0 || end === void 0 || start > contentLength || end > contentLength) {
996
+ throw new Error(`Change unit ${unit.id} has an invalid base range.`);
997
+ }
998
+ return { start, end };
999
+ }
1000
+ function isLosslessTextBlob(content) {
1001
+ if (content.includes(0)) return false;
1002
+ const decoded = content.toString("utf8");
1003
+ return Buffer.from(decoded, "utf8").equals(content);
1004
+ }
1005
+ function readGitBlob(repoRoot, oid, relativePath) {
1006
+ if (!relativePath) return Buffer.alloc(0);
1007
+ try {
1008
+ return (0, import_child_process2.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
1009
+ cwd: repoRoot,
1010
+ encoding: "buffer",
1011
+ maxBuffer: 16 * 1024 * 1024,
1012
+ stdio: ["ignore", "pipe", "pipe"]
1013
+ });
1014
+ } catch {
1015
+ return null;
1016
+ }
1017
+ }
1018
+ function runGit(repoRoot, args) {
1019
+ return (0, import_child_process2.execFileSync)("git", args, {
1020
+ cwd: repoRoot,
1021
+ encoding: "utf8",
1022
+ maxBuffer: 16 * 1024 * 1024,
1023
+ stdio: ["ignore", "pipe", "pipe"]
1024
+ });
1025
+ }
1026
+
1027
+ // src/deconstructedChange.ts
1028
+ function buildDeconstructedScene(startPath, scene, options = {}) {
1029
+ return compileDeconstructedScene(buildChangeInventory(startPath, {
1030
+ headRef: options.headRef || scene.target,
1031
+ baseRef: options.baseRef || scene.base
1032
+ }), scene);
1033
+ }
1034
+ function compileDeconstructedScene(inventory, scene) {
1035
+ const ownership = /* @__PURE__ */ new Map();
1036
+ const excludedByFile = /* @__PURE__ */ new Map();
1037
+ const cumulativeByFile = /* @__PURE__ */ new Map();
1038
+ const wholeFileExclusions = /* @__PURE__ */ new Map();
1039
+ const excludedFiles = [];
1040
+ for (const exclusion of scene.exclusions || []) {
1041
+ const file = requireInventoryFile(inventory, exclusion.file, "Exclusion");
1042
+ if (!exclusion.hunks) {
1043
+ if (wholeFileExclusions.has(file)) {
1044
+ throw new Error(`File is excluded more than once: ${exclusion.file}`);
1045
+ }
1046
+ wholeFileExclusions.set(file, exclusion.reason);
1047
+ excludedFiles.push({ path: file.path, reason: exclusion.reason });
1048
+ }
1049
+ const ids = exclusion.hunks || file.units.map((unit) => unit.id);
1050
+ if (file.material !== "text" && exclusion.hunks) {
1051
+ throw new Error(`Unsupported file ${exclusion.file} must be excluded as a whole file.`);
1052
+ }
1053
+ claimHunks(file, ids, `exclusion for ${exclusion.file}`, ownership);
1054
+ const excluded = excludedByFile.get(file) || /* @__PURE__ */ new Set();
1055
+ ids.forEach((id) => excluded.add(id));
1056
+ excludedByFile.set(file, excluded);
1057
+ }
1058
+ const stageClaims = scene.stages.map((stage) => {
1059
+ const claims = [];
1060
+ for (const change of stage.changes) {
1061
+ const file = requireInventoryFile(inventory, change.file, `Stage ${stage.id}`);
1062
+ if (file.material !== "text") {
1063
+ throw new Error(`Stage ${stage.id} cannot assign unsupported file: ${change.file}`);
1064
+ }
1065
+ if (file.changeKind === "renamed") {
1066
+ throw new Error(`Stage ${stage.id} cannot assign renamed file ${change.file} until path-transition units are supported.`);
1067
+ }
1068
+ claimHunks(file, change.hunks, `stage ${stage.id}`, ownership);
1069
+ claims.push({ file, ids: [...change.hunks] });
1070
+ }
1071
+ return { stage, claims };
1072
+ });
1073
+ for (const file of inventory.files) {
1074
+ const wholeFileExcluded = wholeFileExclusions.has(file);
1075
+ if (file.material !== "text" || file.changeKind === "renamed" || file.units.length === 0) {
1076
+ if (!wholeFileExcluded) {
1077
+ const reason = file.changeKind === "renamed" ? "rename path transitions are not supported yet" : file.material !== "text" ? `${file.material} content cannot be materialized` : "the change has no textual hunks";
1078
+ throw new Error(`Deconstructed scene ${scene.id} must exclude ${file.path} as a whole file because ${reason}.`);
1079
+ }
1080
+ continue;
1081
+ }
1082
+ for (const unit of file.units) {
1083
+ if (!ownership.has(unitKey(file, unit.id))) {
1084
+ throw new Error(`Unassigned change unit in ${file.path}: ${unit.id}`);
1085
+ }
1086
+ }
1087
+ }
1088
+ for (const file of inventory.files) {
1089
+ cumulativeByFile.set(file, new Set(excludedByFile.get(file) || []));
1090
+ }
1091
+ const materializableFiles = inventory.files.filter((file) => file.material === "text" && file.changeKind !== "renamed" && file.baseContent !== void 0 && file.headContent !== void 0);
1092
+ const baselineFiles = materializableFiles.map((file) => buildFileState(
1093
+ file,
1094
+ cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
1095
+ wholeFileExclusions.has(file)
1096
+ ));
1097
+ const stages = [];
1098
+ for (const { stage, claims } of stageClaims) {
1099
+ const introducedHunks = [];
1100
+ const introducedFiles = [];
1101
+ for (const claim of claims) {
1102
+ const cumulative = cumulativeByFile.get(claim.file) || /* @__PURE__ */ new Set();
1103
+ claim.ids.forEach((id) => {
1104
+ cumulative.add(id);
1105
+ introducedHunks.push(id);
1106
+ });
1107
+ cumulativeByFile.set(claim.file, cumulative);
1108
+ if (!introducedFiles.includes(claim.file.path)) introducedFiles.push(claim.file.path);
1109
+ }
1110
+ stages.push({
1111
+ id: stage.id,
1112
+ title: stage.title,
1113
+ narration: stage.narration,
1114
+ introducedHunks,
1115
+ introducedFiles,
1116
+ cumulativeHunks: [...cumulativeByFile.values()].flatMap((ids) => [...ids]),
1117
+ files: materializableFiles.map((file) => buildFileState(
1118
+ file,
1119
+ cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
1120
+ wholeFileExclusions.has(file)
1121
+ ))
1122
+ });
1123
+ }
1124
+ for (const file of materializableFiles) {
1125
+ const finalState = buildFileState(
1126
+ file,
1127
+ cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
1128
+ wholeFileExclusions.has(file)
1129
+ );
1130
+ const expectedExists = file.changeKind !== "deleted";
1131
+ if (finalState.exists !== expectedExists || finalState.content !== file.headContent) {
1132
+ throw new Error(`Final deconstructed state does not match the target for ${file.path}.`);
1133
+ }
1134
+ }
1135
+ return {
1136
+ id: scene.id,
1137
+ title: scene.title,
1138
+ baseOid: inventory.range.baseOid,
1139
+ targetOid: inventory.range.headOid,
1140
+ baselineFiles,
1141
+ excludedHunks: [...excludedByFile.values()].flatMap((ids) => [...ids]),
1142
+ excludedFiles,
1143
+ stages
1144
+ };
1145
+ }
1146
+ function requireInventoryFile(inventory, path, owner) {
1147
+ const matches = inventory.files.filter((file) => file.path === path || file.previousPath === path);
1148
+ if (matches.length === 0) throw new Error(`${owner} references a file outside the change inventory: ${path}`);
1149
+ if (matches.length > 1) throw new Error(`${owner} references an ambiguous changed path: ${path}`);
1150
+ return matches[0];
1151
+ }
1152
+ function claimHunks(file, ids, owner, ownership) {
1153
+ const known = new Set(file.units.map((unit) => unit.id));
1154
+ for (const id of ids) {
1155
+ if (!known.has(id)) throw new Error(`${owner} references an unknown hunk in ${file.path}: ${id}`);
1156
+ const key = unitKey(file, id);
1157
+ const previousOwner = ownership.get(key);
1158
+ if (previousOwner) throw new Error(`Change unit ${id} in ${file.path} is assigned to both ${previousOwner} and ${owner}.`);
1159
+ ownership.set(key, owner);
1160
+ }
1161
+ }
1162
+ function unitKey(file, id) {
1163
+ return `${file.previousPath || ""}\0${file.path}\0${id}`;
1164
+ }
1165
+ function buildFileState(file, selected, wholeFileExcluded) {
1166
+ if (file.baseContent === void 0) throw new Error(`Missing base content for ${file.path}.`);
1167
+ const content = materializeChangeUnits(file.baseContent, file.units, selected);
1168
+ let exists = true;
1169
+ if (file.changeKind === "added") exists = wholeFileExcluded || selected.size > 0;
1170
+ if (file.changeKind === "deleted") exists = !(wholeFileExcluded || selected.size === file.units.length);
1171
+ return {
1172
+ path: file.path,
1173
+ previousPath: file.previousPath,
1174
+ changeKind: file.changeKind,
1175
+ exists,
1176
+ content: exists ? content : "",
1177
+ appliedHunks: [...selected]
1178
+ };
1179
+ }
1180
+
1181
+ // src/changeTourContext.ts
1182
+ var import_child_process3 = require("child_process");
1183
+ var CHANGE_TOUR_CONTEXT_VERSION = 1;
1184
+ var DEFAULT_MAX_PATCH_BYTES = 128 * 1024;
1185
+ var DEFAULT_MAX_TOTAL_PATCH_BYTES = 2 * 1024 * 1024;
1186
+ var MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
1187
+ var MAX_SYMBOL_SOURCE_BYTES = 1024 * 1024;
1188
+ function buildChangeTourContext(startPath, options = {}) {
1189
+ const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
1190
+ const maxPatchBytes = options.maxPatchBytes ?? DEFAULT_MAX_PATCH_BYTES;
1191
+ const maxTotalPatchBytes = options.maxTotalPatchBytes ?? DEFAULT_MAX_TOTAL_PATCH_BYTES;
1192
+ requirePositiveInteger2(maxPatchBytes, "maxPatchBytes");
1193
+ requirePositiveInteger2(maxTotalPatchBytes, "maxTotalPatchBytes");
1194
+ const files = range.changedPaths.map((changedPath) => buildContextFile(
1195
+ range.repoRoot,
1196
+ range.mergeBaseOid,
1197
+ range.headOid,
1198
+ changedPath,
1199
+ maxPatchBytes
1200
+ ));
1201
+ applyTotalPatchBudget(files, maxTotalPatchBytes);
1202
+ const filesByRole = {
1203
+ production: 0,
1204
+ test: 0,
1205
+ documentation: 0,
1206
+ dependency: 0,
1207
+ generated: 0
1208
+ };
1209
+ for (const file of files) filesByRole[file.role] += 1;
1210
+ return {
1211
+ version: CHANGE_TOUR_CONTEXT_VERSION,
1212
+ generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
1213
+ range: {
1214
+ baseRef: range.baseRef,
1215
+ headRef: range.headRef,
1216
+ mergeBaseOid: range.mergeBaseOid,
1217
+ headOid: range.headOid,
1218
+ dirtyWorkingTreeExcluded: range.dirty
1219
+ },
1220
+ limits: { maxPatchBytes, maxTotalPatchBytes },
1221
+ summary: {
1222
+ changedFiles: files.length,
1223
+ commitCount: range.commits.length,
1224
+ additions: files.reduce((total, file) => total + (file.additions ?? 0), 0),
1225
+ deletions: files.reduce((total, file) => total + (file.deletions ?? 0), 0),
1226
+ binaryFiles: files.filter((file) => file.binary).length,
1227
+ omittedPatches: files.filter((file) => file.patchOmittedReason).length,
1228
+ includedPatchBytes: files.reduce((total, file) => total + (file.patch ? file.patchBytes : 0), 0),
1229
+ filesByRole
1230
+ },
1231
+ commits: range.commits,
1232
+ files
1233
+ };
1234
+ }
1235
+ function applyTotalPatchBudget(files, maxBytes) {
1236
+ let includedBytes = 0;
1237
+ for (const file of files) {
1238
+ if (!file.patch) continue;
1239
+ if (includedBytes + file.patchBytes <= maxBytes) {
1240
+ includedBytes += file.patchBytes;
1241
+ continue;
1242
+ }
1243
+ file.patch = void 0;
1244
+ file.patchOmittedReason = "total-budget";
1245
+ }
1246
+ }
1247
+ function buildContextFile(repoRoot, baseOid, headOid, changedPath, maxPatchBytes) {
1248
+ const paths = [changedPath.previousPath, changedPath.path].filter((candidate) => Boolean(candidate));
1249
+ const stats = readLineStats(repoRoot, baseOid, headOid, paths);
1250
+ const patchResult = readPatch(repoRoot, baseOid, headOid, paths, stats.binary, maxPatchBytes);
1251
+ const changedRanges = patchResult.content ? parseChangedRanges(patchResult.content) : [];
1252
+ const pair = resolveReviewPathPair([changedPath], changedPath.path);
1253
+ const headContent = (pair == null ? void 0 : pair.rightPath) && !stats.binary ? readGitText(repoRoot, headOid, pair.rightPath, MAX_SYMBOL_SOURCE_BYTES) : void 0;
1254
+ return {
1255
+ path: changedPath.path,
1256
+ previousPath: changedPath.previousPath,
1257
+ changeKind: changedPath.kind,
1258
+ role: classifyFileRole(changedPath.path),
1259
+ additions: stats.additions,
1260
+ deletions: stats.deletions,
1261
+ binary: stats.binary,
1262
+ patch: patchResult.included ? patchResult.content : void 0,
1263
+ patchBytes: patchResult.bytes,
1264
+ patchOmittedReason: patchResult.reason,
1265
+ changedRanges,
1266
+ symbolHints: headContent ? findSymbolHints(headContent, changedRanges) : []
1267
+ };
1268
+ }
1269
+ function readLineStats(repoRoot, baseOid, headOid, paths) {
1270
+ const output = runGit2(repoRoot, ["diff", "--numstat", "--find-renames", baseOid, headOid, "--", ...paths]);
1271
+ const [additions = "0", deletions = "0"] = output.trim().split(/\s+/, 3);
1272
+ const binary = additions === "-" || deletions === "-";
1273
+ return {
1274
+ additions: binary ? null : Number.parseInt(additions, 10) || 0,
1275
+ deletions: binary ? null : Number.parseInt(deletions, 10) || 0,
1276
+ binary
1277
+ };
1278
+ }
1279
+ function readPatch(repoRoot, baseOid, headOid, paths, binary, maxPatchBytes) {
1280
+ if (binary) return { included: false, content: "", bytes: 0, reason: "binary" };
1281
+ try {
1282
+ const buffer = (0, import_child_process3.execFileSync)("git", [
1283
+ "diff",
1284
+ "--no-ext-diff",
1285
+ "--no-color",
1286
+ "--find-renames",
1287
+ "--unified=3",
1288
+ baseOid,
1289
+ headOid,
1290
+ "--",
1291
+ ...paths
1292
+ ], {
1293
+ cwd: repoRoot,
1294
+ encoding: "buffer",
1295
+ maxBuffer: MAX_CAPTURE_BYTES,
1296
+ stdio: ["ignore", "pipe", "pipe"]
1297
+ });
1298
+ const content = buffer.toString("utf8");
1299
+ if (buffer.length > maxPatchBytes) {
1300
+ return { included: false, content, bytes: buffer.length, reason: "too-large" };
1301
+ }
1302
+ return { included: true, content, bytes: buffer.length };
1303
+ } catch {
1304
+ return { included: false, content: "", bytes: 0, reason: "unavailable" };
1305
+ }
1306
+ }
1307
+ function parseChangedRanges(patch) {
1308
+ const ranges = [];
1309
+ const pattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm;
1310
+ let match;
1311
+ while (match = pattern.exec(patch)) {
1312
+ ranges.push({
1313
+ baseStart: Number.parseInt(match[1], 10),
1314
+ baseCount: match[2] === void 0 ? 1 : Number.parseInt(match[2], 10),
1315
+ headStart: Number.parseInt(match[3], 10),
1316
+ headCount: match[4] === void 0 ? 1 : Number.parseInt(match[4], 10)
1317
+ });
1318
+ }
1319
+ return ranges;
1320
+ }
1321
+ function findSymbolHints(content, ranges) {
1322
+ const declarations = [];
1323
+ content.split("\n").forEach((line, index) => {
1324
+ const python = line.match(/^\s*(?:async\s+)?(class|def)\s+([A-Za-z_]\w*)/);
1325
+ const typed = line.match(/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/);
1326
+ const match = python || typed;
1327
+ if (match) declarations.push({ kind: match[1], name: match[2], line: index + 1 });
1328
+ });
1329
+ const selected = /* @__PURE__ */ new Map();
1330
+ for (const range of ranges) {
1331
+ const start = range.headStart;
1332
+ const end = start + Math.max(0, range.headCount - 1);
1333
+ const enclosing = [...declarations].reverse().find((candidate) => candidate.line <= start);
1334
+ if (enclosing) selected.set(`${enclosing.kind}:${enclosing.name}:${enclosing.line}`, enclosing);
1335
+ for (const symbol of declarations.filter((candidate) => candidate.line >= start && candidate.line <= end)) {
1336
+ selected.set(`${symbol.kind}:${symbol.name}:${symbol.line}`, symbol);
1337
+ }
1338
+ }
1339
+ return [...selected.values()].slice(0, 25);
1340
+ }
1341
+ function readGitText(repoRoot, oid, relativePath, maxBytes) {
1342
+ try {
1343
+ const content = (0, import_child_process3.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
1344
+ cwd: repoRoot,
1345
+ encoding: "buffer",
1346
+ maxBuffer: maxBytes + 1,
1347
+ stdio: ["ignore", "pipe", "pipe"]
1348
+ });
1349
+ if (content.length > maxBytes || content.includes(0)) return void 0;
1350
+ return content.toString("utf8");
1351
+ } catch {
1352
+ return void 0;
1353
+ }
1354
+ }
1355
+ function classifyFileRole(filePath) {
1356
+ const lower = filePath.toLowerCase();
1357
+ const fileName = lower.split("/").pop() || lower;
1358
+ if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower) || /\.(test|spec)\.[^/]+$/.test(lower)) return "test";
1359
+ if (lower.startsWith("docs/") || fileName === "readme.md" || /\.(md|mdx|rst)$/.test(lower)) return "documentation";
1360
+ if (/(^|\/)(package-lock\.json|.*\.lock|pyproject\.toml|package\.json|go\.mod|cargo\.toml)$/.test(lower)) return "dependency";
1361
+ if (/(^|\/)(dist|build|generated|vendor)(\/|$)/.test(lower) || /\.(min\.js|map)$/.test(lower)) return "generated";
1362
+ return "production";
1363
+ }
1364
+ function runGit2(repoRoot, args) {
1365
+ return (0, import_child_process3.execFileSync)("git", args, {
1366
+ cwd: repoRoot,
1367
+ encoding: "utf8",
1368
+ maxBuffer: MAX_CAPTURE_BYTES,
1369
+ stdio: ["ignore", "pipe", "pipe"]
1370
+ }).trimEnd();
1371
+ }
1372
+ function requirePositiveInteger2(value, name) {
1373
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`);
1374
+ }
1375
+
1376
+ // src/changeTour.ts
1377
+ var DEFAULT_MAX_TOUR_FILE_BYTES = 2 * 1024 * 1024;
1378
+ var DEFAULT_MAX_TOUR_LINE_BYTES = 64 * 1024;
1379
+ var DEFAULT_MAX_STACK_CONTENT_BYTES = 8 * 1024 * 1024;
1380
+ var CHAPTERS = {
1381
+ context: { id: "context", title: "Context and architecture", priority: 0 },
1382
+ contracts: { id: "contracts", title: "Data model and contracts", priority: 1 },
1383
+ behavior: { id: "behavior", title: "Behavior and integration", priority: 2 },
1384
+ proof: { id: "proof", title: "Proof and coverage", priority: 3 },
1385
+ packaging: { id: "packaging", title: "Packaging and dependencies", priority: 4 }
1386
+ };
1387
+ function buildChangeTourManifest(startPath, options = {}) {
1388
+ var _a, _b, _c, _d;
1389
+ const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
1390
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_TOUR_FILE_BYTES;
1391
+ const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_TOUR_LINE_BYTES;
1392
+ const omittedFiles = [];
1393
+ const files = [];
1394
+ const sceneRecords = [];
1395
+ let totalAdditions = 0;
1396
+ let totalDeletions = 0;
1397
+ for (const changedPath of range.changedPaths) {
1398
+ const { additions, deletions } = readGitLineStats(
1399
+ range.repoRoot,
1400
+ range.mergeBaseOid,
1401
+ range.headOid,
1402
+ changedPath.path,
1403
+ changedPath.previousPath
1404
+ );
1405
+ totalAdditions += additions;
1406
+ totalDeletions += deletions;
1407
+ const pair = resolveReviewPathPair(range.changedPaths, changedPath.path);
1408
+ if (!pair) {
1409
+ omittedFiles.push(changedPath.path);
1410
+ files.push(buildOmittedFile(changedPath, additions, deletions, "Git endpoints could not be resolved."));
1411
+ continue;
1412
+ }
1413
+ const left = readGitText2(range.repoRoot, range.mergeBaseOid, pair.leftPath, maxFileBytes, maxLineBytes);
1414
+ const right = readGitText2(range.repoRoot, range.headOid, pair.rightPath, maxFileBytes, maxLineBytes);
1415
+ if (left.kind !== "text" || right.kind !== "text") {
1416
+ omittedFiles.push(changedPath.path);
1417
+ files.push(buildOmittedFile(changedPath, additions, deletions, "Binary, oversized, or contains an oversized line."));
1418
+ continue;
1419
+ }
1420
+ const chapter = chapterForPath(changedPath.path);
1421
+ const scene = {
1422
+ id: `file-${files.length + 1}`,
1423
+ kind: "text-diff",
1424
+ title: changedPath.path.split("/").pop() || changedPath.path,
1425
+ summary: `${formatChangeKind(changedPath.kind)} ${changedPath.path}.`,
1426
+ bullets: [],
1427
+ tags: [changedPath.kind, `+${additions}`, `\u2212${deletions}`],
1428
+ takeaway: buildSceneNote(changedPath.kind, additions, deletions, changedPath.previousPath),
1429
+ path: changedPath.path,
1430
+ previousPath: changedPath.previousPath,
1431
+ changeKind: changedPath.kind,
1432
+ leftLabel: pair.leftPath ? `${pair.leftPath} @ ${range.mergeBaseOid.slice(0, 7)}` : `${changedPath.path} (absent)`,
1433
+ rightLabel: pair.rightPath ? `${pair.rightPath} @ ${range.headOid.slice(0, 7)}` : `${changedPath.path} (absent)`,
1434
+ leftContent: left.content,
1435
+ rightContent: right.content,
1436
+ additions,
1437
+ deletions
1438
+ };
1439
+ files.push(scene);
1440
+ sceneRecords.push({
1441
+ chapter,
1442
+ scene
1443
+ });
1444
+ }
1445
+ files.sort((left, right) => left.path.localeCompare(right.path));
1446
+ sceneRecords.sort((left, right) => left.chapter.priority - right.chapter.priority || left.scene.path.localeCompare(right.scene.path));
1447
+ const defaultScenes = sceneRecords.map(({ scene }, index) => ({ ...scene, id: `file-${index + 1}` }));
1448
+ if (options.story && options.source) {
1449
+ throw new Error("A change tour can use either a legacy story or a source file, not both.");
1450
+ }
1451
+ const authored = options.source ? applySource(parseChangeTourSource(options.source), defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : {
1452
+ scenes: defaultScenes,
1453
+ chapters: buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id])))
1454
+ };
1455
+ const scenes = authored.scenes;
1456
+ const chapters = authored.chapters;
1457
+ const manifest = {
1458
+ version: CHANGE_TOUR_MANIFEST_VERSION,
1459
+ title: ((_a = options.source) == null ? void 0 : _a.title) || ((_b = options.story) == null ? void 0 : _b.title) || options.title || `${range.headRef} against ${range.baseRef}`,
1460
+ sourceUrl: ((_c = options.source) == null ? void 0 : _c.sourceUrl) || ((_d = options.story) == null ? void 0 : _d.sourceUrl) || options.sourceUrl,
1461
+ generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
1462
+ range: {
1463
+ baseRef: range.baseRef,
1464
+ headRef: range.headRef,
1465
+ mergeBaseOid: range.mergeBaseOid,
1466
+ headOid: range.headOid
1467
+ },
1468
+ summary: {
1469
+ changedFiles: range.changedPaths.length,
1470
+ includedScenes: scenes.length,
1471
+ additions: totalAdditions,
1472
+ deletions: totalDeletions,
1473
+ commitCount: range.commits.length,
1474
+ omittedFiles
1475
+ },
1476
+ commits: range.commits,
1477
+ files,
1478
+ chapters,
1479
+ scenes
1480
+ };
1481
+ return parseChangeTourManifest(manifest);
1482
+ }
1483
+ function applySource(source, defaultScenes, repoRoot) {
1484
+ var _a, _b;
1485
+ const available = new Map(defaultScenes.map((scene) => [scene.path, scene]));
1486
+ const resolvedAnchors = /* @__PURE__ */ new Map();
1487
+ for (const [id, anchor] of Object.entries(source.anchors)) {
1488
+ const file = available.get(anchor.file);
1489
+ if (!file) {
1490
+ throw new Error(`Anchor ${id} references a file outside the text change range: ${anchor.file}`);
1491
+ }
1492
+ const content = anchor.revision === "base" ? file.leftContent : file.rightContent;
1493
+ resolvedAnchors.set(id, resolveAnchor(id, anchor.file, anchor.revision, content, anchor.contains, anchor.occurrence));
1494
+ }
1495
+ const connections = new Map(source.connections.map((connection) => [connection.id, {
1496
+ id: connection.id,
1497
+ label: connection.label,
1498
+ from: requireResolvedAnchor(resolvedAnchors, connection.from),
1499
+ to: requireResolvedAnchor(resolvedAnchors, connection.to)
1500
+ }]));
1501
+ const scenes = [];
1502
+ const chapters = [];
1503
+ for (const chapter of source.chapters) {
1504
+ const sceneIds = [];
1505
+ for (const authoredScene of chapter.scenes) {
1506
+ if (authoredScene.kind === "deconstructed-diff") {
1507
+ const deconstructedScene = buildDeconstructedManifestScene(
1508
+ repoRoot,
1509
+ authoredScene,
1510
+ authoredScene.base || ((_a = source.range) == null ? void 0 : _a.base),
1511
+ authoredScene.target || ((_b = source.range) == null ? void 0 : _b.head)
1512
+ );
1513
+ scenes.push(deconstructedScene);
1514
+ sceneIds.push(deconstructedScene.id);
1515
+ continue;
1516
+ }
1517
+ if (authoredScene.kind === "stacked-diff") {
1518
+ const stackedScene = buildStackedScene(repoRoot, authoredScene);
1519
+ scenes.push(stackedScene);
1520
+ sceneIds.push(stackedScene.id);
1521
+ continue;
1522
+ }
1523
+ const scene = {
1524
+ id: authoredScene.id,
1525
+ kind: "walkthrough",
1526
+ title: authoredScene.title,
1527
+ summary: authoredScene.summary,
1528
+ bullets: authoredScene.bullets,
1529
+ tags: authoredScene.tags,
1530
+ takeaway: authoredScene.takeaway,
1531
+ steps: authoredScene.steps.map((step) => {
1532
+ const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
1533
+ const diff = available.get(focus.path);
1534
+ if (!diff) throw new Error(`Step ${step.id} has no text diff for ${focus.path}.`);
1535
+ return {
1536
+ id: step.id,
1537
+ title: step.title,
1538
+ body: step.body,
1539
+ depth: step.depth,
1540
+ focus,
1541
+ connection: step.connection ? connections.get(step.connection) : void 0,
1542
+ diff: { ...diff, id: `${authoredScene.id}-${step.id}` }
1543
+ };
1544
+ })
1545
+ };
1546
+ scenes.push(scene);
1547
+ sceneIds.push(scene.id);
1548
+ }
1549
+ chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
1550
+ }
1551
+ return { scenes, chapters };
1552
+ }
1553
+ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef) {
1554
+ const compiled = buildDeconstructedScene(repoRoot, source, { baseRef, headRef: targetRef });
1555
+ const panels = [{
1556
+ id: "explanation-baseline",
1557
+ label: "Explanation baseline",
1558
+ role: "baseline"
1559
+ }, ...compiled.stages.map((stage, index) => ({
1560
+ id: `explanation-stage-${stage.id}`,
1561
+ label: `Stage ${index + 1}: ${stage.title}`,
1562
+ role: "stage",
1563
+ stageId: stage.id
1564
+ }))];
1565
+ const includedPaths = [...new Set(compiled.stages.flatMap((stage) => stage.introducedFiles))];
1566
+ let totalContentBytes = 0;
1567
+ const files = includedPaths.map((filePath) => {
1568
+ const states = [
1569
+ compiled.baselineFiles.find((file) => file.path === filePath),
1570
+ ...compiled.stages.map((stage) => stage.files.find((file) => file.path === filePath))
1571
+ ];
1572
+ if (states.some((state) => !state)) {
1573
+ throw new Error(`Deconstructed scene ${source.id} is missing virtual state for ${filePath}.`);
1574
+ }
1575
+ return {
1576
+ path: filePath,
1577
+ panels: states.map((state, index) => {
1578
+ if (!state) throw new Error(`Missing virtual state for ${filePath}.`);
1579
+ const content = Buffer.from(state.content, "utf8");
1580
+ if (content.length > DEFAULT_MAX_TOUR_FILE_BYTES || hasLineLongerThan(content, DEFAULT_MAX_TOUR_LINE_BYTES)) {
1581
+ throw new Error(`Deconstructed tour file is too large to present: ${filePath}`);
1582
+ }
1583
+ totalContentBytes += content.length;
1584
+ if (totalContentBytes > DEFAULT_MAX_STACK_CONTENT_BYTES) {
1585
+ throw new Error(`Deconstructed tour content exceeds ${DEFAULT_MAX_STACK_CONTENT_BYTES} bytes.`);
1586
+ }
1587
+ return {
1588
+ id: panels[index].id,
1589
+ label: `${panels[index].label} / ${filePath}${state.exists ? "" : " (absent)"}`,
1590
+ path: state.exists ? filePath : void 0,
1591
+ content: state.content,
1592
+ exists: state.exists
1593
+ };
1594
+ })
1595
+ };
1596
+ });
1597
+ if (files.length === 0) throw new Error(`Deconstructed scene ${source.id} has no staged files.`);
1598
+ return {
1599
+ id: source.id,
1600
+ kind: "deconstructed-diff",
1601
+ title: source.title,
1602
+ summary: source.summary,
1603
+ bullets: source.bullets,
1604
+ tags: source.tags,
1605
+ takeaway: source.takeaway,
1606
+ stageLabel: "Explanation stages",
1607
+ realRange: {
1608
+ baseRef: baseRef || compiled.baseOid,
1609
+ targetRef: targetRef || compiled.targetOid,
1610
+ baseOid: compiled.baseOid,
1611
+ targetOid: compiled.targetOid
1612
+ },
1613
+ panels,
1614
+ files,
1615
+ steps: compiled.stages.map((stage, index) => ({
1616
+ id: stage.id,
1617
+ title: stage.title,
1618
+ body: stage.narration,
1619
+ file: stage.introducedFiles[0],
1620
+ pairIndex: index,
1621
+ side: "right",
1622
+ introducedHunks: stage.introducedHunks
1623
+ }))
1624
+ };
1625
+ }
1626
+ function buildStackedScene(repoRoot, source) {
1627
+ var _a;
1628
+ const stack = source.stack.map((entry) => ({
1629
+ id: entry.id,
1630
+ ref: entry.ref,
1631
+ oid: runGitText(repoRoot, ["rev-parse", "--verify", `${entry.ref}^{commit}`]),
1632
+ label: entry.label || entry.ref
1633
+ }));
1634
+ const adjacentChanges = stack.slice(0, -1).map((entry, index) => parseNameStatusZ((0, import_child_process4.execFileSync)("git", [
1635
+ "diff",
1636
+ "--name-status",
1637
+ "-z",
1638
+ "--find-renames=20%",
1639
+ entry.oid,
1640
+ stack[index + 1].oid
1641
+ ], {
1642
+ cwd: repoRoot,
1643
+ encoding: "utf8",
1644
+ maxBuffer: 16 * 1024 * 1024,
1645
+ stdio: ["ignore", "pipe", "pipe"]
1646
+ })));
1647
+ const renamedPreviousPaths = new Set(adjacentChanges.flatMap((changes) => changes.map((change) => change.previousPath).filter((candidate) => Boolean(candidate))));
1648
+ const changedFiles = [...new Set(adjacentChanges.flatMap((changes) => changes.map((change) => change.path)))].filter((filePath) => !renamedPreviousPaths.has(filePath)).sort();
1649
+ const filePaths = ((_a = source.files) == null ? void 0 : _a.length) ? [...new Set(source.files)] : changedFiles;
1650
+ for (const step of source.steps) {
1651
+ if (!filePaths.includes(step.file)) throw new Error(`Stacked step ${step.id} references undeclared file ${step.file}.`);
1652
+ }
1653
+ if (filePaths.length === 0) throw new Error(`Stacked scene ${source.id} has no changed files.`);
1654
+ let totalContentBytes = 0;
1655
+ const files = filePaths.map((filePath) => {
1656
+ const aliases = /* @__PURE__ */ new Set([filePath]);
1657
+ for (const changes of adjacentChanges) {
1658
+ for (const change of changes) {
1659
+ if (aliases.has(change.path) || change.previousPath && aliases.has(change.previousPath)) {
1660
+ aliases.add(change.path);
1661
+ if (change.previousPath) aliases.add(change.previousPath);
1662
+ }
1663
+ }
1664
+ }
1665
+ const panels = stack.map((entry) => {
1666
+ const resolvedPath = [...aliases].find((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
1667
+ if (!resolvedPath) {
1668
+ return { id: entry.id, label: `${entry.label} / ${filePath} (missing)`, content: "", exists: false };
1669
+ }
1670
+ const content = readGitText2(repoRoot, entry.oid, resolvedPath, DEFAULT_MAX_TOUR_FILE_BYTES, DEFAULT_MAX_TOUR_LINE_BYTES);
1671
+ if (content.kind !== "text") throw new Error(`Stacked tour file is binary or too large: ${resolvedPath}`);
1672
+ totalContentBytes += Buffer.byteLength(content.content);
1673
+ if (totalContentBytes > DEFAULT_MAX_STACK_CONTENT_BYTES) {
1674
+ throw new Error(`Stacked tour content exceeds ${DEFAULT_MAX_STACK_CONTENT_BYTES} bytes.`);
1675
+ }
1676
+ return {
1677
+ id: entry.id,
1678
+ label: `${entry.label} / ${resolvedPath}`,
1679
+ path: resolvedPath,
1680
+ content: content.content,
1681
+ exists: true
1682
+ };
1683
+ });
1684
+ return { path: filePath, panels };
1685
+ });
1686
+ const stackIds = stack.map((entry) => entry.id);
1687
+ return {
1688
+ id: source.id,
1689
+ kind: "stacked-diff",
1690
+ title: source.title,
1691
+ summary: source.summary,
1692
+ bullets: source.bullets,
1693
+ tags: source.tags,
1694
+ takeaway: source.takeaway,
1695
+ stack,
1696
+ files,
1697
+ steps: source.steps.map((step) => {
1698
+ var _a2, _b;
1699
+ return {
1700
+ id: step.id,
1701
+ title: step.title,
1702
+ body: step.body,
1703
+ file: step.file,
1704
+ pairIndex: stackIds.indexOf(step.pair[0]),
1705
+ side: step.side || "right",
1706
+ startLine: (_a2 = step.lines) == null ? void 0 : _a2[0],
1707
+ endLine: (_b = step.lines) == null ? void 0 : _b[1]
1708
+ };
1709
+ })
1710
+ };
1711
+ }
1712
+ function gitBlobExists(repoRoot, oid, relativePath) {
1713
+ try {
1714
+ (0, import_child_process4.execFileSync)("git", ["cat-file", "-e", `${oid}:${relativePath}`], {
1715
+ cwd: repoRoot,
1716
+ stdio: ["ignore", "ignore", "ignore"]
1717
+ });
1718
+ return true;
1719
+ } catch {
1720
+ return false;
1721
+ }
1722
+ }
1723
+ function runGitText(repoRoot, args) {
1724
+ return (0, import_child_process4.execFileSync)("git", args, {
1725
+ cwd: repoRoot,
1726
+ encoding: "utf8",
1727
+ stdio: ["ignore", "pipe", "pipe"]
1728
+ }).trim();
1729
+ }
1730
+ function resolveAnchor(id, file, revision, content, needle, occurrence) {
1731
+ const offsets = [];
1732
+ let offset = content.indexOf(needle);
1733
+ while (offset >= 0) {
1734
+ offsets.push(offset);
1735
+ offset = content.indexOf(needle, offset + Math.max(needle.length, 1));
1736
+ }
1737
+ if (offsets.length === 0) throw new Error(`Anchor ${id} did not match ${file} at ${revision}.`);
1738
+ if (occurrence === void 0 && offsets.length > 1) {
1739
+ throw new Error(`Anchor ${id} matched ${offsets.length} places in ${file}; add occurrence to disambiguate it.`);
1740
+ }
1741
+ const selected = offsets[(occurrence || 1) - 1];
1742
+ if (selected === void 0) throw new Error(`Anchor ${id} occurrence ${occurrence} does not exist in ${file}.`);
1743
+ const startLine = content.slice(0, selected).split("\n").length;
1744
+ const endLine = startLine + needle.split("\n").length - 1;
1745
+ return { id, path: file, revision, startLine, endLine, excerpt: needle };
1746
+ }
1747
+ function requireResolvedAnchor(anchors, id) {
1748
+ const anchor = anchors.get(id);
1749
+ if (!anchor) throw new Error(`Unknown resolved anchor: ${id}`);
1750
+ return anchor;
1751
+ }
1752
+ function applyStory(story, defaultScenes) {
1753
+ const available = new Map(defaultScenes.map((scene) => [scene.path, scene]));
1754
+ const scenes = [];
1755
+ const chapterRecords = [];
1756
+ const chaptersById = /* @__PURE__ */ new Map();
1757
+ const addScene = (chapterId, chapterTitle, scene) => {
1758
+ let chapter = chaptersById.get(chapterId);
1759
+ if (!chapter) {
1760
+ chapter = { id: chapterId, title: chapterTitle, sceneIds: [] };
1761
+ chaptersById.set(chapterId, chapter);
1762
+ chapterRecords.push(chapter);
1763
+ }
1764
+ scenes.push(scene);
1765
+ chapter.sceneIds.push(scene.id);
1766
+ };
1767
+ for (const storyScene of story.scenes) {
1768
+ const id = `scene-${scenes.length + 1}`;
1769
+ if (storyScene.kind === "discussion") {
1770
+ addScene(storyScene.chapterId, storyScene.chapterTitle, {
1771
+ id,
1772
+ kind: "discussion",
1773
+ title: storyScene.title,
1774
+ summary: storyScene.summary,
1775
+ bullets: storyScene.bullets,
1776
+ tags: storyScene.tags,
1777
+ takeaway: storyScene.takeaway
1778
+ });
1779
+ continue;
1780
+ }
1781
+ const baseScene = available.get(storyScene.path);
1782
+ if (!baseScene) {
1783
+ throw new Error(`Story references a file outside the change range: ${storyScene.path}`);
1784
+ }
1785
+ available.delete(storyScene.path);
1786
+ addScene(storyScene.chapterId, storyScene.chapterTitle, {
1787
+ ...baseScene,
1788
+ id,
1789
+ title: storyScene.title || baseScene.title,
1790
+ summary: storyScene.summary,
1791
+ bullets: storyScene.bullets,
1792
+ tags: storyScene.tags,
1793
+ takeaway: storyScene.takeaway,
1794
+ focusChangeIndex: storyScene.focusChangeIndex
1795
+ });
1796
+ }
1797
+ return { scenes, chapters: chapterRecords };
1798
+ }
1799
+ function buildChapters(records, sceneIdByPath) {
1800
+ const definitions = [...new Map(records.map(({ chapter }) => [chapter.id, chapter])).values()].sort((left, right) => left.priority - right.priority);
1801
+ return definitions.map((definition) => ({
1802
+ id: definition.id,
1803
+ title: definition.title,
1804
+ sceneIds: records.filter(({ chapter }) => chapter.id === definition.id).map(({ scene }) => sceneIdByPath.get(scene.path)).filter((sceneId) => Boolean(sceneId))
1805
+ }));
1806
+ }
1807
+ function chapterForPath(filePath) {
1808
+ const lower = filePath.toLowerCase();
1809
+ const fileName = lower.split("/").pop() || lower;
1810
+ if (lower.startsWith("docs/") || fileName === "readme.md" || fileName.includes("architecture")) {
1811
+ return CHAPTERS.context;
1812
+ }
1813
+ if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower)) {
1814
+ return CHAPTERS.proof;
1815
+ }
1816
+ if (/(^|\/)(models?|schema|types?|contracts?)\.[^/]+$/.test(lower)) {
1817
+ return CHAPTERS.contracts;
1818
+ }
1819
+ if (/(^|\/)(package-lock\.json|.*\.lock|pyproject\.toml|package\.json)$/.test(lower)) {
1820
+ return CHAPTERS.packaging;
1821
+ }
1822
+ return CHAPTERS.behavior;
1823
+ }
1824
+ function buildSceneNote(kind, additions, deletions, previousPath) {
1825
+ const label = formatChangeKind(kind);
1826
+ const rename = previousPath ? ` from ${previousPath}` : "";
1827
+ return `${label}${rename} \xB7 +${additions} \u2212${deletions}`;
1828
+ }
1829
+ function buildOmittedFile(changedPath, additions, deletions, reason) {
1830
+ return {
1831
+ id: `file-${changedPath.path}`,
1832
+ kind: "omitted",
1833
+ title: changedPath.path.split("/").pop() || changedPath.path,
1834
+ path: changedPath.path,
1835
+ previousPath: changedPath.previousPath,
1836
+ changeKind: changedPath.kind,
1837
+ additions,
1838
+ deletions,
1839
+ reason
1840
+ };
1841
+ }
1842
+ function formatChangeKind(kind) {
1843
+ return kind.replace("-", " ").replace(/^./, (letter) => letter.toUpperCase());
1844
+ }
1845
+ function readGitText2(repoRoot, oid, relativePath, maxBytes, maxLineBytes) {
1846
+ if (!relativePath) {
1847
+ return { kind: "text", content: "" };
1848
+ }
1849
+ const content = (0, import_child_process4.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
1850
+ cwd: repoRoot,
1851
+ encoding: "buffer",
1852
+ maxBuffer: Math.max(maxBytes + 1, DEFAULT_MAX_TOUR_FILE_BYTES + 1),
1853
+ stdio: ["ignore", "pipe", "pipe"]
1854
+ });
1855
+ if (content.length > maxBytes || content.includes(0) || hasLineLongerThan(content, maxLineBytes)) {
1856
+ return { kind: "binary-or-large", content: "" };
1857
+ }
1858
+ return { kind: "text", content: content.toString("utf8") };
1859
+ }
1860
+ function hasLineLongerThan(content, maxLineBytes) {
1861
+ let lineStart = 0;
1862
+ for (let index = 0; index < content.length; index += 1) {
1863
+ if (content[index] !== 10) continue;
1864
+ if (index - lineStart > maxLineBytes) return true;
1865
+ lineStart = index + 1;
1866
+ }
1867
+ return content.length - lineStart > maxLineBytes;
1868
+ }
1869
+ function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
1870
+ const output = (0, import_child_process4.execFileSync)("git", [
1871
+ "diff",
1872
+ "--numstat",
1873
+ "--find-renames",
1874
+ baseOid,
1875
+ headOid,
1876
+ "--",
1877
+ ...[previousPath, filePath].filter((candidate) => Boolean(candidate))
1878
+ ], {
1879
+ cwd: repoRoot,
1880
+ encoding: "utf8",
1881
+ stdio: ["ignore", "pipe", "pipe"]
1882
+ });
1883
+ const [additionsText = "0", deletionsText = "0"] = output.trim().split(/\s+/, 3);
1884
+ return {
1885
+ additions: Number.parseInt(additionsText, 10) || 0,
1886
+ deletions: Number.parseInt(deletionsText, 10) || 0
1887
+ };
1888
+ }
1889
+ // Annotate the CommonJS export names for ESM import in node:
1890
+ 0 && (module.exports = {
1891
+ buildChangeInventory,
1892
+ buildChangeTourContext,
1893
+ buildChangeTourManifest,
1894
+ buildDeconstructedScene,
1895
+ compileDeconstructedScene,
1896
+ materializeChangeUnits,
1897
+ parseChangeTourManifest,
1898
+ parseChangeTourSource,
1899
+ parseChangeTourStory
1900
+ });
1901
+ //# sourceMappingURL=changeTour.js.map