@davmash/bygone 0.10.1 → 0.11.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.
package/out/changeTour.js CHANGED
@@ -45,6 +45,642 @@ __export(changeTour_exports, {
45
45
  pinTourSource: () => pinTourSource
46
46
  });
47
47
  module.exports = __toCommonJS(changeTour_exports);
48
+ var import_fs2 = require("fs");
49
+ var import_path = require("path");
50
+
51
+ // src/changeCorrespondence.ts
52
+ var import_crypto = require("crypto");
53
+ var import_child_process = require("child_process");
54
+ var import_fs = require("fs");
55
+
56
+ // node_modules/diff/lib/index.mjs
57
+ function Diff() {
58
+ }
59
+ Diff.prototype = {
60
+ diff: function diff(oldString, newString) {
61
+ var _options$timeout;
62
+ var options = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {};
63
+ var callback = options.callback;
64
+ if (typeof options === "function") {
65
+ callback = options;
66
+ options = {};
67
+ }
68
+ this.options = options;
69
+ var self = this;
70
+ function done(value) {
71
+ if (callback) {
72
+ setTimeout(function() {
73
+ callback(void 0, value);
74
+ }, 0);
75
+ return true;
76
+ } else {
77
+ return value;
78
+ }
79
+ }
80
+ oldString = this.castInput(oldString);
81
+ newString = this.castInput(newString);
82
+ oldString = this.removeEmpty(this.tokenize(oldString));
83
+ newString = this.removeEmpty(this.tokenize(newString));
84
+ var newLen = newString.length, oldLen = oldString.length;
85
+ var editLength = 1;
86
+ var maxEditLength = newLen + oldLen;
87
+ if (options.maxEditLength) {
88
+ maxEditLength = Math.min(maxEditLength, options.maxEditLength);
89
+ }
90
+ var maxExecutionTime = (_options$timeout = options.timeout) !== null && _options$timeout !== void 0 ? _options$timeout : Infinity;
91
+ var abortAfterTimestamp = Date.now() + maxExecutionTime;
92
+ var bestPath = [{
93
+ oldPos: -1,
94
+ lastComponent: void 0
95
+ }];
96
+ var newPos = this.extractCommon(bestPath[0], newString, oldString, 0);
97
+ if (bestPath[0].oldPos + 1 >= oldLen && newPos + 1 >= newLen) {
98
+ return done([{
99
+ value: this.join(newString),
100
+ count: newString.length
101
+ }]);
102
+ }
103
+ var minDiagonalToConsider = -Infinity, maxDiagonalToConsider = Infinity;
104
+ function execEditLength() {
105
+ for (var diagonalPath = Math.max(minDiagonalToConsider, -editLength); diagonalPath <= Math.min(maxDiagonalToConsider, editLength); diagonalPath += 2) {
106
+ var basePath = void 0;
107
+ var removePath = bestPath[diagonalPath - 1], addPath = bestPath[diagonalPath + 1];
108
+ if (removePath) {
109
+ bestPath[diagonalPath - 1] = void 0;
110
+ }
111
+ var canAdd = false;
112
+ if (addPath) {
113
+ var addPathNewPos = addPath.oldPos - diagonalPath;
114
+ canAdd = addPath && 0 <= addPathNewPos && addPathNewPos < newLen;
115
+ }
116
+ var canRemove = removePath && removePath.oldPos + 1 < oldLen;
117
+ if (!canAdd && !canRemove) {
118
+ bestPath[diagonalPath] = void 0;
119
+ continue;
120
+ }
121
+ if (!canRemove || canAdd && removePath.oldPos + 1 < addPath.oldPos) {
122
+ basePath = self.addToPath(addPath, true, void 0, 0);
123
+ } else {
124
+ basePath = self.addToPath(removePath, void 0, true, 1);
125
+ }
126
+ newPos = self.extractCommon(basePath, newString, oldString, diagonalPath);
127
+ if (basePath.oldPos + 1 >= oldLen && newPos + 1 >= newLen) {
128
+ return done(buildValues(self, basePath.lastComponent, newString, oldString, self.useLongestToken));
129
+ } else {
130
+ bestPath[diagonalPath] = basePath;
131
+ if (basePath.oldPos + 1 >= oldLen) {
132
+ maxDiagonalToConsider = Math.min(maxDiagonalToConsider, diagonalPath - 1);
133
+ }
134
+ if (newPos + 1 >= newLen) {
135
+ minDiagonalToConsider = Math.max(minDiagonalToConsider, diagonalPath + 1);
136
+ }
137
+ }
138
+ }
139
+ editLength++;
140
+ }
141
+ if (callback) {
142
+ (function exec() {
143
+ setTimeout(function() {
144
+ if (editLength > maxEditLength || Date.now() > abortAfterTimestamp) {
145
+ return callback();
146
+ }
147
+ if (!execEditLength()) {
148
+ exec();
149
+ }
150
+ }, 0);
151
+ })();
152
+ } else {
153
+ while (editLength <= maxEditLength && Date.now() <= abortAfterTimestamp) {
154
+ var ret = execEditLength();
155
+ if (ret) {
156
+ return ret;
157
+ }
158
+ }
159
+ }
160
+ },
161
+ addToPath: function addToPath(path, added, removed, oldPosInc) {
162
+ var last = path.lastComponent;
163
+ if (last && last.added === added && last.removed === removed) {
164
+ return {
165
+ oldPos: path.oldPos + oldPosInc,
166
+ lastComponent: {
167
+ count: last.count + 1,
168
+ added,
169
+ removed,
170
+ previousComponent: last.previousComponent
171
+ }
172
+ };
173
+ } else {
174
+ return {
175
+ oldPos: path.oldPos + oldPosInc,
176
+ lastComponent: {
177
+ count: 1,
178
+ added,
179
+ removed,
180
+ previousComponent: last
181
+ }
182
+ };
183
+ }
184
+ },
185
+ extractCommon: function extractCommon(basePath, newString, oldString, diagonalPath) {
186
+ var newLen = newString.length, oldLen = oldString.length, oldPos = basePath.oldPos, newPos = oldPos - diagonalPath, commonCount = 0;
187
+ while (newPos + 1 < newLen && oldPos + 1 < oldLen && this.equals(newString[newPos + 1], oldString[oldPos + 1])) {
188
+ newPos++;
189
+ oldPos++;
190
+ commonCount++;
191
+ }
192
+ if (commonCount) {
193
+ basePath.lastComponent = {
194
+ count: commonCount,
195
+ previousComponent: basePath.lastComponent
196
+ };
197
+ }
198
+ basePath.oldPos = oldPos;
199
+ return newPos;
200
+ },
201
+ equals: function equals(left, right) {
202
+ if (this.options.comparator) {
203
+ return this.options.comparator(left, right);
204
+ } else {
205
+ return left === right || this.options.ignoreCase && left.toLowerCase() === right.toLowerCase();
206
+ }
207
+ },
208
+ removeEmpty: function removeEmpty(array) {
209
+ var ret = [];
210
+ for (var i = 0; i < array.length; i++) {
211
+ if (array[i]) {
212
+ ret.push(array[i]);
213
+ }
214
+ }
215
+ return ret;
216
+ },
217
+ castInput: function castInput(value) {
218
+ return value;
219
+ },
220
+ tokenize: function tokenize(value) {
221
+ return value.split("");
222
+ },
223
+ join: function join(chars) {
224
+ return chars.join("");
225
+ }
226
+ };
227
+ function buildValues(diff2, lastComponent, newString, oldString, useLongestToken) {
228
+ var components = [];
229
+ var nextComponent;
230
+ while (lastComponent) {
231
+ components.push(lastComponent);
232
+ nextComponent = lastComponent.previousComponent;
233
+ delete lastComponent.previousComponent;
234
+ lastComponent = nextComponent;
235
+ }
236
+ components.reverse();
237
+ var componentPos = 0, componentLen = components.length, newPos = 0, oldPos = 0;
238
+ for (; componentPos < componentLen; componentPos++) {
239
+ var component = components[componentPos];
240
+ if (!component.removed) {
241
+ if (!component.added && useLongestToken) {
242
+ var value = newString.slice(newPos, newPos + component.count);
243
+ value = value.map(function(value2, i) {
244
+ var oldValue = oldString[oldPos + i];
245
+ return oldValue.length > value2.length ? oldValue : value2;
246
+ });
247
+ component.value = diff2.join(value);
248
+ } else {
249
+ component.value = diff2.join(newString.slice(newPos, newPos + component.count));
250
+ }
251
+ newPos += component.count;
252
+ if (!component.added) {
253
+ oldPos += component.count;
254
+ }
255
+ } else {
256
+ component.value = diff2.join(oldString.slice(oldPos, oldPos + component.count));
257
+ oldPos += component.count;
258
+ if (componentPos && components[componentPos - 1].added) {
259
+ var tmp = components[componentPos - 1];
260
+ components[componentPos - 1] = components[componentPos];
261
+ components[componentPos] = tmp;
262
+ }
263
+ }
264
+ }
265
+ var finalComponent = components[componentLen - 1];
266
+ if (componentLen > 1 && typeof finalComponent.value === "string" && (finalComponent.added || finalComponent.removed) && diff2.equals("", finalComponent.value)) {
267
+ components[componentLen - 2].value += finalComponent.value;
268
+ components.pop();
269
+ }
270
+ return components;
271
+ }
272
+ var characterDiff = new Diff();
273
+ function diffChars(oldStr, newStr, options) {
274
+ return characterDiff.diff(oldStr, newStr, options);
275
+ }
276
+ var extendedWordChars = /^[A-Za-z\xC0-\u02C6\u02C8-\u02D7\u02DE-\u02FF\u1E00-\u1EFF]+$/;
277
+ var reWhitespace = /\S/;
278
+ var wordDiff = new Diff();
279
+ wordDiff.equals = function(left, right) {
280
+ if (this.options.ignoreCase) {
281
+ left = left.toLowerCase();
282
+ right = right.toLowerCase();
283
+ }
284
+ return left === right || this.options.ignoreWhitespace && !reWhitespace.test(left) && !reWhitespace.test(right);
285
+ };
286
+ wordDiff.tokenize = function(value) {
287
+ var tokens = value.split(/([^\S\r\n]+|[()[\]{}'"\r\n]|\b)/);
288
+ for (var i = 0; i < tokens.length - 1; i++) {
289
+ if (!tokens[i + 1] && tokens[i + 2] && extendedWordChars.test(tokens[i]) && extendedWordChars.test(tokens[i + 2])) {
290
+ tokens[i] += tokens[i + 2];
291
+ tokens.splice(i + 1, 2);
292
+ i--;
293
+ }
294
+ }
295
+ return tokens;
296
+ };
297
+ function diffWordsWithSpace(oldStr, newStr, options) {
298
+ return wordDiff.diff(oldStr, newStr, options);
299
+ }
300
+ var lineDiff = new Diff();
301
+ lineDiff.tokenize = function(value) {
302
+ if (this.options.stripTrailingCr) {
303
+ value = value.replace(/\r\n/g, "\n");
304
+ }
305
+ var retLines = [], linesAndNewlines = value.split(/(\n|\r\n)/);
306
+ if (!linesAndNewlines[linesAndNewlines.length - 1]) {
307
+ linesAndNewlines.pop();
308
+ }
309
+ for (var i = 0; i < linesAndNewlines.length; i++) {
310
+ var line = linesAndNewlines[i];
311
+ if (i % 2 && !this.options.newlineIsToken) {
312
+ retLines[retLines.length - 1] += line;
313
+ } else {
314
+ if (this.options.ignoreWhitespace) {
315
+ line = line.trim();
316
+ }
317
+ retLines.push(line);
318
+ }
319
+ }
320
+ return retLines;
321
+ };
322
+ function diffLines(oldStr, newStr, callback) {
323
+ return lineDiff.diff(oldStr, newStr, callback);
324
+ }
325
+ var sentenceDiff = new Diff();
326
+ sentenceDiff.tokenize = function(value) {
327
+ return value.split(/(\S.+?[.!?])(?=\s+|$)/);
328
+ };
329
+ var cssDiff = new Diff();
330
+ cssDiff.tokenize = function(value) {
331
+ return value.split(/([{}:;,]|\s+)/);
332
+ };
333
+ function _typeof(obj) {
334
+ "@babel/helpers - typeof";
335
+ if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
336
+ _typeof = function(obj2) {
337
+ return typeof obj2;
338
+ };
339
+ } else {
340
+ _typeof = function(obj2) {
341
+ return obj2 && typeof Symbol === "function" && obj2.constructor === Symbol && obj2 !== Symbol.prototype ? "symbol" : typeof obj2;
342
+ };
343
+ }
344
+ return _typeof(obj);
345
+ }
346
+ var objectPrototypeToString = Object.prototype.toString;
347
+ var jsonDiff = new Diff();
348
+ jsonDiff.useLongestToken = true;
349
+ jsonDiff.tokenize = lineDiff.tokenize;
350
+ jsonDiff.castInput = function(value) {
351
+ var _this$options = this.options, undefinedReplacement = _this$options.undefinedReplacement, _this$options$stringi = _this$options.stringifyReplacer, stringifyReplacer = _this$options$stringi === void 0 ? function(k, v) {
352
+ return typeof v === "undefined" ? undefinedReplacement : v;
353
+ } : _this$options$stringi;
354
+ return typeof value === "string" ? value : JSON.stringify(canonicalize(value, null, null, stringifyReplacer), stringifyReplacer, " ");
355
+ };
356
+ jsonDiff.equals = function(left, right) {
357
+ return Diff.prototype.equals.call(jsonDiff, left.replace(/,([\r\n])/g, "$1"), right.replace(/,([\r\n])/g, "$1"));
358
+ };
359
+ function canonicalize(obj, stack, replacementStack, replacer, key) {
360
+ stack = stack || [];
361
+ replacementStack = replacementStack || [];
362
+ if (replacer) {
363
+ obj = replacer(key, obj);
364
+ }
365
+ var i;
366
+ for (i = 0; i < stack.length; i += 1) {
367
+ if (stack[i] === obj) {
368
+ return replacementStack[i];
369
+ }
370
+ }
371
+ var canonicalizedObj;
372
+ if ("[object Array]" === objectPrototypeToString.call(obj)) {
373
+ stack.push(obj);
374
+ canonicalizedObj = new Array(obj.length);
375
+ replacementStack.push(canonicalizedObj);
376
+ for (i = 0; i < obj.length; i += 1) {
377
+ canonicalizedObj[i] = canonicalize(obj[i], stack, replacementStack, replacer, key);
378
+ }
379
+ stack.pop();
380
+ replacementStack.pop();
381
+ return canonicalizedObj;
382
+ }
383
+ if (obj && obj.toJSON) {
384
+ obj = obj.toJSON();
385
+ }
386
+ if (_typeof(obj) === "object" && obj !== null) {
387
+ stack.push(obj);
388
+ canonicalizedObj = {};
389
+ replacementStack.push(canonicalizedObj);
390
+ var sortedKeys = [], _key;
391
+ for (_key in obj) {
392
+ if (obj.hasOwnProperty(_key)) {
393
+ sortedKeys.push(_key);
394
+ }
395
+ }
396
+ sortedKeys.sort();
397
+ for (i = 0; i < sortedKeys.length; i += 1) {
398
+ _key = sortedKeys[i];
399
+ canonicalizedObj[_key] = canonicalize(obj[_key], stack, replacementStack, replacer, _key);
400
+ }
401
+ stack.pop();
402
+ replacementStack.pop();
403
+ } else {
404
+ canonicalizedObj = obj;
405
+ }
406
+ return canonicalizedObj;
407
+ }
408
+ var arrayDiff = new Diff();
409
+ arrayDiff.tokenize = function(value) {
410
+ return value.slice();
411
+ };
412
+ arrayDiff.join = arrayDiff.removeEmpty = function(value) {
413
+ return value;
414
+ };
415
+ function diffArrays(oldArr, newArr, callback) {
416
+ return arrayDiff.diff(oldArr, newArr, callback);
417
+ }
418
+
419
+ // src/changeCorrespondence.ts
420
+ var MAX_BYTES = 16 * 1024 * 1024;
421
+ var MAX_SOURCE_BYTES = 8 * 1024 * 1024;
422
+ var digest = (text) => (0, import_crypto.createHash)("sha256").update(text, "utf8").digest("hex");
423
+ function object(value) {
424
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid correspondence object.");
425
+ return value;
426
+ }
427
+ function list(value, max = 2e4) {
428
+ if (!Array.isArray(value) || value.length > max) throw new Error("Invalid or over-budget correspondence array.");
429
+ return value;
430
+ }
431
+ function string(value) {
432
+ if (typeof value !== "string" || !value || value.length > 4096) throw new Error("Invalid correspondence string.");
433
+ return value;
434
+ }
435
+ function integer(value) {
436
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid correspondence offset.");
437
+ return value;
438
+ }
439
+ function filePath(value) {
440
+ const name = string(value);
441
+ if (name.startsWith("/") || name.includes("\\") || name.includes("\0") || name.split("/").some((p) => !p || p === "." || p === "..")) {
442
+ throw new Error("Invalid correspondence repository path.");
443
+ }
444
+ return name;
445
+ }
446
+ function hash(value) {
447
+ const result = string(value);
448
+ if (!/^[0-9a-f]{64}$/.test(result)) throw new Error("Invalid correspondence digest.");
449
+ return result;
450
+ }
451
+ function status(value) {
452
+ if (value !== "complete" && value !== "partial" && value !== "unavailable") throw new Error("Invalid correspondence analysis status.");
453
+ return value;
454
+ }
455
+ function readCorrespondenceJson(file) {
456
+ if ((0, import_fs.statSync)(file).size > MAX_BYTES) throw new Error("Correspondence file exceeds 16 MiB.");
457
+ return JSON.parse((0, import_fs.readFileSync)(file, "utf8"));
458
+ }
459
+ function validateRange(value, expected) {
460
+ const input = object(value);
461
+ for (const key of ["baseOid", "headOid"]) {
462
+ if (input[key] !== expected[key] || !/^[0-9a-f]{40,64}$/.test(expected[key])) {
463
+ throw new Error("Correspondence revisions do not match the selected merge-base/head comparison.");
464
+ }
465
+ }
466
+ return { ...expected };
467
+ }
468
+ function validateChangeCorrespondence(repoRoot, input, expected) {
469
+ const data = object(input);
470
+ if (data.version !== 1) throw new Error("Unsupported correspondence version.");
471
+ const range = validateRange(data.range, expected);
472
+ const documents = object(data.documents), symbols = object(data.symbols);
473
+ const result = {
474
+ version: 1,
475
+ range,
476
+ producer: string(data.producer),
477
+ status: status(data.status),
478
+ documents: { before: [], after: [] },
479
+ symbols: { before: [], after: [] },
480
+ relations: [],
481
+ unresolved: [],
482
+ limitations: list(data.limitations, 100).map(string)
483
+ };
484
+ const entries = /* @__PURE__ */ new Map();
485
+ const documentsBySide = { before: /* @__PURE__ */ new Map(), after: /* @__PURE__ */ new Map() };
486
+ const lineIndexes = /* @__PURE__ */ new Map();
487
+ const lineAt = (side, path, start) => {
488
+ const key = `${side}:${path}`;
489
+ let lines = lineIndexes.get(key);
490
+ if (!lines) {
491
+ const text = documentsBySide[side].get(path);
492
+ lines = [0];
493
+ for (let at = text.indexOf("\n"); at !== -1; at = text.indexOf("\n", at + 1)) lines.push(at + 1);
494
+ lineIndexes.set(key, lines);
495
+ }
496
+ let low = 0, high = lines.length;
497
+ while (low < high) {
498
+ const mid = Math.floor((low + high) / 2);
499
+ if (lines[mid] <= start) low = mid + 1;
500
+ else high = mid;
501
+ }
502
+ return low;
503
+ };
504
+ let sourceBytes = 0, symbolBytes = 0;
505
+ for (const side of ["before", "after"]) {
506
+ const texts = /* @__PURE__ */ new Map();
507
+ const oid = side === "before" ? range.baseOid : range.headOid;
508
+ for (const value of list(documents[side], 128)) {
509
+ const doc = object(value), name = filePath(doc.path), expectedDigest = hash(doc.digest);
510
+ const documentStatus = string(doc.status);
511
+ if (!["complete", "invalid", "unsupported"].includes(documentStatus) || texts.has(name)) throw new Error("Invalid or duplicate correspondence document.");
512
+ const bytes = (0, import_child_process.execFileSync)("git", ["show", `${oid}:${name}`], { cwd: repoRoot, timeout: 1e4, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
513
+ const text = bytes.toString("utf8");
514
+ sourceBytes += bytes.length;
515
+ if (sourceBytes > MAX_SOURCE_BYTES || !Buffer.from(text, "utf8").equals(bytes) || digest(text) !== expectedDigest) {
516
+ throw new Error(`Correspondence source digest mismatch or source budget exceeded: ${side}/${name}`);
517
+ }
518
+ texts.set(name, { text, status: documentStatus });
519
+ documentsBySide[side].set(name, text);
520
+ result.documents[side].push({ path: name, digest: expectedDigest, status: documentStatus, text });
521
+ }
522
+ for (const value of list(symbols[side])) {
523
+ const entry = object(value), name = filePath(entry.path), doc = texts.get(name);
524
+ const id = string(entry.id), start = integer(entry.start), end = integer(entry.end);
525
+ if (!doc || start >= end || end > doc.text.length || entries.has(id)) throw new Error("Invalid correspondence symbol ownership/range/ID.");
526
+ const text = doc.text.slice(start, end);
527
+ symbolBytes += Buffer.byteLength(text);
528
+ if (symbolBytes > MAX_SOURCE_BYTES || digest(text) !== hash(entry.digest)) throw new Error(`Correspondence symbol digest mismatch or budget exceeded: ${name}`);
529
+ const symbol = {
530
+ id,
531
+ path: name,
532
+ name: string(entry.name),
533
+ kind: string(entry.kind),
534
+ start,
535
+ end,
536
+ digest: digest(text),
537
+ text,
538
+ startLine: lineAt(side, name, start),
539
+ endLine: lineAt(side, name, end - 1)
540
+ };
541
+ entries.set(id, { side, symbol, documentStatus: doc.status });
542
+ result.symbols[side].push(symbol);
543
+ }
544
+ }
545
+ const ids = /* @__PURE__ */ new Set(), sources = /* @__PURE__ */ new Set(), destinations = /* @__PURE__ */ new Set();
546
+ for (const value of list(data.relations)) {
547
+ const relation = object(value), id = string(relation.id), sourceId = string(relation.sourceId), destinationId = string(relation.destinationId);
548
+ const source = entries.get(sourceId), destination = entries.get(destinationId);
549
+ if (ids.has(id) || sources.has(sourceId) || destinations.has(destinationId)) throw new Error("Conflicting correspondence continuations; preserve them as unresolved candidates.");
550
+ if ((source == null ? void 0 : source.side) !== "before" || (destination == null ? void 0 : destination.side) !== "after" || source.documentStatus !== "complete" || destination.documentStatus !== "complete") {
551
+ throw new Error("Correspondence endpoints must resolve to valid, parsed before/after documents.");
552
+ }
553
+ ids.add(id);
554
+ sources.add(sourceId);
555
+ destinations.add(destinationId);
556
+ const producers = list(relation.producers, 10).map(string);
557
+ if (relation.coverage !== void 0 && relation.coverage !== "complete") throw new Error("Invalid correspondence relation coverage.");
558
+ const covered = result.status === "partial" && relation.coverage === "complete" && producers.some((producer) => ["refactoring-miner", "bygone-strand"].includes(producer));
559
+ if (result.status !== "complete" && !covered && !(producers.includes("exact-declaration") && source.symbol.text === destination.symbol.text)) continue;
560
+ const a = source.symbol, b = destination.symbol;
561
+ result.relations.push({
562
+ id,
563
+ sourceId,
564
+ destinationId,
565
+ producers,
566
+ pathChanged: a.path !== b.path,
567
+ nameChanged: a.name !== b.name,
568
+ ...relation.coverage === "complete" ? { coverage: "complete" } : {},
569
+ rawEqual: a.text === b.text,
570
+ behavior: "unassessed",
571
+ localDiff: (diffLines(a.text, b.text, { timeout: 100, maxEditLength: 2e3 }) || [{ value: a.text, removed: true }, { value: b.text, added: true }]).map((part) => ({
572
+ value: part.value,
573
+ ...part.added ? { added: true } : {},
574
+ ...part.removed ? { removed: true } : {}
575
+ }))
576
+ });
577
+ }
578
+ for (const value of list(data.unresolved)) {
579
+ const candidate = object(value);
580
+ const sourceIds = list(candidate.sourceIds).map(string), destinationIds = list(candidate.destinationIds).map(string);
581
+ if (!sourceIds.length && !destinationIds.length) throw new Error("Unresolved correspondence needs candidate endpoints.");
582
+ if (sourceIds.some((id) => {
583
+ var _a;
584
+ return ((_a = entries.get(id)) == null ? void 0 : _a.side) !== "before";
585
+ }) || destinationIds.some((id) => {
586
+ var _a;
587
+ return ((_a = entries.get(id)) == null ? void 0 : _a.side) !== "after";
588
+ })) {
589
+ throw new Error("Unresolved correspondence references unknown endpoints.");
590
+ }
591
+ if (sourceIds.some((id) => sources.has(id)) || destinationIds.some((id) => destinations.has(id))) throw new Error("Ambiguous endpoints cannot also be accepted continuations.");
592
+ result.unresolved.push({ reason: string(candidate.reason), sourceIds, destinationIds });
593
+ }
594
+ if (data.diagnostics !== void 0) {
595
+ const diagnostics = object(data.diagnostics);
596
+ if (diagnostics.version !== 1) throw new Error("Unsupported correspondence diagnostics version.");
597
+ let locationCount = 0;
598
+ result.diagnostics = { version: 1, scope: list(diagnostics.scope, 20).map(string), records: list(diagnostics.records).map((value) => {
599
+ const record2 = object(value), category = string(record2.category);
600
+ if (!["outside", "syntax", "rejected", "unmatched", "relationship", "problem"].includes(category)) throw new Error("Invalid correspondence diagnostic category.");
601
+ return {
602
+ category,
603
+ reason: string(record2.reason),
604
+ ...record2.message !== void 0 ? { message: string(record2.message) } : {},
605
+ ...record2.producerId !== void 0 ? { producerId: string(record2.producerId) } : {},
606
+ locations: list(record2.locations, 100).map((value2) => {
607
+ var _a, _b;
608
+ if (++locationCount > 4e4) throw new Error("Correspondence diagnostic location budget exceeded.");
609
+ const location = object(value2), path = filePath(location.path);
610
+ const side = location.side;
611
+ if (side !== void 0 && side !== "before" && side !== "after") throw new Error("Invalid diagnostic revision side.");
612
+ const symbolId = location.symbolId === void 0 ? void 0 : string(location.symbolId);
613
+ if (symbolId && (((_a = entries.get(symbolId)) == null ? void 0 : _a.side) !== side || ((_b = entries.get(symbolId)) == null ? void 0 : _b.symbol.path) !== path)) throw new Error("Invalid diagnostic symbol reference.");
614
+ const start = location.start === void 0 ? symbolId ? entries.get(symbolId).symbol.start : void 0 : integer(location.start);
615
+ const text = side ? documentsBySide[side].get(path) : void 0;
616
+ if (start !== void 0 && (text === void 0 || start > text.length)) throw new Error("Invalid diagnostic source location.");
617
+ return {
618
+ path,
619
+ ...side ? { side } : {},
620
+ ...symbolId ? { symbolId } : {},
621
+ ...start !== void 0 ? { start, line: lineAt(side, path, start) } : {},
622
+ ...location.reason !== void 0 ? { reason: string(location.reason) } : {}
623
+ };
624
+ })
625
+ };
626
+ }) };
627
+ }
628
+ return result;
629
+ }
630
+ function loadChangeCorrespondence(repoRoot, file, range) {
631
+ return validateChangeCorrespondence(repoRoot, readCorrespondenceJson(file), range);
632
+ }
633
+
634
+ // src/tourSymbol.ts
635
+ function compileTourSymbol(evidence, selector, focus) {
636
+ let before, after;
637
+ let status2 = "inferred";
638
+ if (selector.relation) {
639
+ const relation = evidence.relations.find((item) => item.id === selector.relation);
640
+ if (!relation) throw new Error(`No accepted correspondence: ${selector.relation}`);
641
+ before = evidence.symbols.before.find((item) => item.id === relation.sourceId);
642
+ after = evidence.symbols.after.find((item) => item.id === relation.destinationId);
643
+ } else {
644
+ const id = selector.symbol;
645
+ if (evidence.relations.some((item) => item.sourceId === id || item.destinationId === id)) throw new Error("Matched symbols must use their accepted relation ID.");
646
+ before = evidence.symbols.before.find((item) => item.id === id);
647
+ after = evidence.symbols.after.find((item) => item.id === id);
648
+ if (!before && !after) throw new Error(`Unknown correspondence symbol: ${id}`);
649
+ status2 = evidence.unresolved.some((group) => [...group.sourceIds, ...group.destinationIds].includes(id)) ? "ambiguous" : "unmatched";
650
+ }
651
+ const endpoint = focus.revision === "base" ? before : after;
652
+ if (!endpoint || endpoint.path !== focus.path || focus.startLine < endpoint.startLine || focus.endLine > endpoint.endLine) throw new Error("Symbol stop focus must lie within the selected declaration on its revision side.");
653
+ const text = (side, entry) => entry ? evidence.documents[side].find((doc) => doc.path === entry.path).text : "";
654
+ const result = {
655
+ id: selector.relation || selector.symbol,
656
+ range: evidence.range,
657
+ status: status2,
658
+ before,
659
+ after,
660
+ context: { leftContent: text("before", before), rightContent: text("after", after) },
661
+ behavior: "unassessed"
662
+ };
663
+ validateTourSymbol(result);
664
+ return result;
665
+ }
666
+ function validateTourSymbol(value) {
667
+ const fail = () => {
668
+ throw new Error("Invalid pinned symbol tour evidence.");
669
+ };
670
+ if (!value || typeof value !== "object") fail();
671
+ const data = value;
672
+ if (typeof data.id !== "string" || !data.id.trim() || !data.range || ![data.range.baseOid, data.range.headOid].every((oid) => typeof oid === "string" && /^[0-9a-f]{40,64}$/.test(oid)) || !["inferred", "unmatched", "ambiguous"].includes(data.status) || data.behavior !== "unassessed" || !data.context) fail();
673
+ const count = Number(!!data.before) + Number(!!data.after);
674
+ if (count !== (data.status === "inferred" ? 2 : 1)) fail();
675
+ for (const [entry, content] of [[data.before, data.context.leftContent], [data.after, data.context.rightContent]]) {
676
+ if (typeof content !== "string") fail();
677
+ if (!entry) {
678
+ if (content !== "") fail();
679
+ continue;
680
+ }
681
+ if (typeof entry.id !== "string" || !entry.id || typeof entry.name !== "string" || !entry.name || typeof entry.kind !== "string" || !entry.kind || typeof entry.path !== "string" || entry.path.includes("\0") || entry.path.startsWith("/") || entry.path.includes("\\") || entry.path.split("/").some((part) => !part || part === "." || part === "..") || typeof entry.digest !== "string" || !/^[0-9a-f]{64}$/.test(entry.digest) || !Number.isSafeInteger(entry.start) || !Number.isSafeInteger(entry.end) || entry.start < 0 || entry.end <= entry.start || entry.end > content.length || content.slice(entry.start, entry.end) !== entry.text || entry.startLine !== content.slice(0, entry.start).split("\n").length || entry.endLine !== content.slice(0, entry.end - 1).split("\n").length) fail();
682
+ }
683
+ }
48
684
 
49
685
  // src/tourImage.ts
50
686
  var MAX_TOUR_IMAGE_BYTES = 8 * 1024 * 1024;
@@ -84,17 +720,19 @@ function validatePngHeader(bytes, label) {
84
720
  }
85
721
 
86
722
  // src/changeTour.ts
87
- var import_child_process4 = require("child_process");
723
+ var import_child_process5 = require("child_process");
88
724
 
89
725
  // src/gitComparison.ts
90
- var import_child_process = require("child_process");
726
+ var import_child_process2 = require("child_process");
91
727
  var fs = __toESM(require("fs"));
92
728
  var DEFAULT_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
729
+ var DEFAULT_GIT_TIMEOUT_MS = 3e4;
93
730
  function createGitRunner(maxBuffer = readGitMaxBuffer()) {
94
- return (args, cwd) => (0, import_child_process.execFileSync)("git", [...args], {
731
+ return (args, cwd) => (0, import_child_process2.execFileSync)("git", [...args], {
95
732
  cwd,
96
733
  encoding: "utf8",
97
734
  maxBuffer,
735
+ timeout: DEFAULT_GIT_TIMEOUT_MS,
98
736
  stdio: ["ignore", "pipe", "pipe"]
99
737
  }).trimEnd();
100
738
  }
@@ -161,12 +799,12 @@ function parseNameStatusZ(output) {
161
799
  }
162
800
  const changedPaths = [];
163
801
  for (let index = 0; index < fields.length; ) {
164
- const status = fields[index++];
165
- if (!status) {
802
+ const status2 = fields[index++];
803
+ if (!status2) {
166
804
  continue;
167
805
  }
168
- const code = status[0];
169
- const similarityText = status.slice(1);
806
+ const code = status2[0];
807
+ const similarityText = status2.slice(1);
170
808
  const similarity = similarityText ? Number.parseInt(similarityText, 10) : void 0;
171
809
  if (code === "R" || code === "C") {
172
810
  const previousPath = fields[index++];
@@ -364,7 +1002,7 @@ function requireOnlyKeys(value, allowed, path) {
364
1002
  }
365
1003
 
366
1004
  // src/changeTourSource.ts
367
- var CHANGE_TOUR_SOURCE_VERSION = 5;
1005
+ var CHANGE_TOUR_SOURCE_VERSION = 6;
368
1006
  function parseChangeTourSource(value) {
369
1007
  if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_SOURCE_VERSION) {
370
1008
  throw new Error(
@@ -374,7 +1012,7 @@ function parseChangeTourSource(value) {
374
1012
  if (isRecord2(value) && value.version === 3) {
375
1013
  throw new Error("Tour source format version 3 is retired. Remove the review block, set source version to 4, and recompile the tour.");
376
1014
  }
377
- if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== 4 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
1015
+ if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== 4 && value.version !== 5 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
378
1016
  throw new Error("Unsupported or missing change-tour source version.");
379
1017
  }
380
1018
  requireOnlyKeys2(value, [
@@ -382,6 +1020,7 @@ function parseChangeTourSource(value) {
382
1020
  "title",
383
1021
  "windowTitle",
384
1022
  "sourceUrl",
1023
+ "correspondence",
385
1024
  "range",
386
1025
  "anchors",
387
1026
  "connections",
@@ -433,6 +1072,11 @@ function parseChangeTourSource(value) {
433
1072
  throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
434
1073
  }
435
1074
  }
1075
+ if (value.correspondence !== void 0) {
1076
+ if (value.version < 6) throw new Error("correspondence requires source version 6.");
1077
+ requireString2(value.correspondence, "correspondence");
1078
+ if (value.correspondence.startsWith("/") || value.correspondence.includes("\\") || value.correspondence.includes("\0") || value.correspondence.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("correspondence must be a repository-relative file path.");
1079
+ }
436
1080
  const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord2(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord2(connection) ? { id, ...connection } : connection) : null;
437
1081
  if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
438
1082
  throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
@@ -536,7 +1180,7 @@ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, c
536
1180
  validateRealStack(scene.stack, `${path}.stack`, 2);
537
1181
  }
538
1182
  if (requireRealEvidence || scene.steps !== void 0) {
539
- validateWalkthroughSteps(scene, path, anchors, connectionIds);
1183
+ validateWalkthroughSteps(scene, path, anchors, connectionIds, version);
540
1184
  }
541
1185
  continue;
542
1186
  }
@@ -555,7 +1199,7 @@ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, c
555
1199
  }
556
1200
  requireOnlyKeys2(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "steps"], path);
557
1201
  validateSceneOverview(scene.overview, `${path}.overview`, { allowComparison: false });
558
- validateWalkthroughSteps(scene, path, anchors, connectionIds);
1202
+ validateWalkthroughSteps(scene, path, anchors, connectionIds, version);
559
1203
  }
560
1204
  }
561
1205
  if (mode === "deconstructed" && !chaptersValue.some((chapter) => isRecord2(chapter) && Array.isArray(chapter.scenes) && chapter.scenes.some((scene) => isRecord2(scene) && scene.kind === "deconstructed-diff"))) {
@@ -612,7 +1256,7 @@ function validateDeconstructedScene(scene, path) {
612
1256
  ));
613
1257
  }
614
1258
  }
615
- function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
1259
+ function validateWalkthroughSteps(scene, path, anchors, connectionIds, version) {
616
1260
  if (!Array.isArray(scene.steps) || scene.steps.length === 0) {
617
1261
  throw new Error(`${path} must contain a non-empty steps array.`);
618
1262
  }
@@ -624,10 +1268,17 @@ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
624
1268
  requireString2(step.title, `${stepPath}.title`);
625
1269
  requireString2(step.body, `${stepPath}.body`);
626
1270
  requireString2(step.focus, `${stepPath}.focus`);
627
- requireOnlyKeys2(step, ["id", "title", "body", "focus", "connection", "depth", "requirement", "image"], stepPath);
1271
+ requireOnlyKeys2(step, ["id", "title", "body", "focus", "connection", "depth", "requirement", "image", "symbol"], stepPath);
628
1272
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
629
1273
  throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
630
1274
  }
1275
+ if (step.symbol !== void 0) {
1276
+ if (version < 6 || !isRecord2(step.symbol)) throw new Error(`${stepPath}.symbol requires an object and source version 6.`);
1277
+ requireOnlyKeys2(step.symbol, ["relation", "symbol"], `${stepPath}.symbol`);
1278
+ if (step.symbol.relation === void 0 === (step.symbol.symbol === void 0)) throw new Error(`${stepPath}.symbol requires exactly one relation or symbol ID.`);
1279
+ requireString2(step.symbol.relation ?? step.symbol.symbol, `${stepPath}.symbol ID`);
1280
+ if (step.image || step.connection) throw new Error(`${stepPath}.symbol cannot combine with image or connection.`);
1281
+ }
631
1282
  validateStepRequirement(step.requirement, `${stepPath}.requirement`);
632
1283
  validateTourImageSource(step.image, `${stepPath}.image`);
633
1284
  if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
@@ -798,7 +1449,7 @@ function requireOnlyKeys2(value, allowed, path) {
798
1449
  }
799
1450
 
800
1451
  // src/changeTourManifest.ts
801
- var CHANGE_TOUR_MANIFEST_VERSION = 5;
1452
+ var CHANGE_TOUR_MANIFEST_VERSION = 6;
802
1453
  function parseChangeTourManifest(value, options = {}) {
803
1454
  if (isRecord3(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
804
1455
  throw new Error(
@@ -808,7 +1459,7 @@ function parseChangeTourManifest(value, options = {}) {
808
1459
  if (isRecord3(value) && value.version === 3) {
809
1460
  throw new Error("Tour manifest format version 3 is retired. Remove the review block, set source version to 4, and recompile the tour.");
810
1461
  }
811
- if (!isRecord3(value) || value.version !== 1 && value.version !== 2 && value.version !== 4 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
1462
+ if (!isRecord3(value) || value.version !== 1 && value.version !== 2 && value.version !== 4 && value.version !== 5 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
812
1463
  throw new Error(`Unsupported or missing change-tour manifest version.`);
813
1464
  }
814
1465
  if ("review" in value) throw new Error("Change-tour manifests do not support the review field. Put observations in scene and step narrative.");
@@ -866,7 +1517,7 @@ function parseChangeTourManifest(value, options = {}) {
866
1517
  throw new Error("summary.includedScenes must match the number of scenes.");
867
1518
  }
868
1519
  if (value.details !== void 0) {
869
- if (value.version !== 5) throw new Error("details requires manifest version 5.");
1520
+ if (value.version < 5) throw new Error("details requires manifest version 5.");
870
1521
  validateChangeTourDetails(value.details, "details");
871
1522
  }
872
1523
  const files = value.files === void 0 ? collectLegacyTourFiles(value.scenes) : value.files.map((candidate, index) => {
@@ -880,10 +1531,24 @@ function parseChangeTourManifest(value, options = {}) {
880
1531
  validateAuthoringCoverage(value.authoringCoverage, value.version);
881
1532
  }
882
1533
  if (value.tours !== void 0) {
883
- if (value.version !== 4 && value.version !== 5) throw new Error("Independent tours require manifest version 4.");
1534
+ if (value.version !== 4 && value.version !== 5 && value.version !== 6) throw new Error("Independent tours require manifest version 4.");
884
1535
  validateModeTours(value.tours, value.version);
885
1536
  }
886
1537
  if (value.version !== 1) validateZoom(value, value.version, Boolean(options.exported));
1538
+ const symbolScenes = [
1539
+ value.scenes,
1540
+ ...isRecord3(value.zoom) && isRecord3(value.zoom.final) ? [value.zoom.final.scenes] : [],
1541
+ ...isRecord3(value.tours) ? Object.values(value.tours).flatMap((tour) => isRecord3(tour) ? [tour.scenes] : []) : []
1542
+ ];
1543
+ for (const scenes of symbolScenes) for (const scene of scenes) {
1544
+ if (scene.kind !== "walkthrough") continue;
1545
+ for (const step of scene.steps) if (step.symbol) {
1546
+ const symbol = step.symbol;
1547
+ if (symbol.range.baseOid !== value.range.mergeBaseOid || symbol.range.headOid !== value.range.headOid) throw new Error("Symbol tour evidence revisions do not match the tour.");
1548
+ const endpoint = step.focus.revision === "base" ? symbol.before : symbol.after;
1549
+ if (!endpoint || endpoint.path !== step.focus.path || step.focus.startLine < endpoint.startLine || step.focus.endLine > endpoint.endLine) throw new Error("Symbol stop focus must lie within its selected declaration.");
1550
+ }
1551
+ }
887
1552
  return { ...value, files };
888
1553
  }
889
1554
  function validateAuthoringCoverage(value, version) {
@@ -940,7 +1605,7 @@ function validateModeTours(value, version) {
940
1605
  }
941
1606
  }
942
1607
  if (tour.details !== void 0) {
943
- if (version !== 5) throw new Error(`tours.${mode}.details requires manifest version 5.`);
1608
+ if (version < 5) throw new Error(`tours.${mode}.details requires manifest version 5.`);
944
1609
  validateChangeTourDetails(tour.details, `tours.${mode}.details`);
945
1610
  }
946
1611
  if (mode === "deconstructed" && !hasDeconstructed) {
@@ -1086,6 +1751,11 @@ function validateScene(value, index, version) {
1086
1751
  }
1087
1752
  validateStepRequirement(step.requirement, `${path}.requirement`);
1088
1753
  validateTourImage(step.image, `${path}.image`);
1754
+ if (step.symbol !== void 0) {
1755
+ if (version < 6) throw new Error(`${path}.symbol requires manifest version 6.`);
1756
+ if (step.image || step.connection) throw new Error(`${path}.symbol cannot combine with image or connection.`);
1757
+ validateTourSymbol(step.symbol);
1758
+ }
1089
1759
  validateResolvedAnchor(step.focus, `${path}.focus`);
1090
1760
  validateScene(step.diff, index, version);
1091
1761
  if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
@@ -1120,958 +1790,598 @@ function validateScene(value, index, version) {
1120
1790
  if (value.previousPath !== void 0) {
1121
1791
  requireString3(value.previousPath, `scenes[${index}].previousPath`);
1122
1792
  }
1123
- requireString3(value.changeKind, `scenes[${index}].changeKind`);
1124
- requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
1125
- requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
1126
- if (value.focusChangeIndex !== void 0) {
1127
- requireNonNegativeInteger(value.focusChangeIndex, `scenes[${index}].focusChangeIndex`);
1128
- }
1129
- }
1130
- function validateStackedScene2(value, index) {
1131
- if (!Array.isArray(value.stack) || value.stack.length < 2 || value.stack.length > 6) {
1132
- throw new Error(`scenes[${index}].stack must contain between 2 and 6 revisions.`);
1133
- }
1134
- for (const [panelIndex, panel] of value.stack.entries()) {
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}`);
1137
- }
1138
- validateMultiPanelFiles(value.files, value.stack.length, index);
1139
- validateMultiPanelSteps(value.steps, value.stack.length, index, false);
1140
- }
1141
- function validateDeconstructedScene2(value, index) {
1142
- if (value.stageLabel !== "Explanation stages") {
1143
- throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
1144
- }
1145
- if (!isRecord3(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1146
- for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
1147
- requireString3(value.realRange[key], `scenes[${index}].realRange.${key}`);
1148
- }
1149
- if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
1150
- throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
1151
- }
1152
- const panels = value.panels;
1153
- for (const [panelIndex, panel] of panels.entries()) {
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}`);
1156
- const expectedRole = panelIndex === 0 ? "baseline" : "stage";
1157
- if (panel.role !== expectedRole) throw new Error(`scenes[${index}].panels[${panelIndex}].role must be ${expectedRole}.`);
1158
- if (panelIndex > 0) requireString3(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
1159
- }
1160
- validateMultiPanelFiles(value.files, panels.length, index);
1161
- validateMultiPanelSteps(value.steps, panels.length, index, true);
1162
- if (Array.isArray(value.steps)) {
1163
- let previousPairIndex = -1;
1164
- value.steps.forEach((step, stepIndex) => {
1165
- if (!isRecord3(step)) return;
1166
- const pairIndex = Number(step.pairIndex);
1167
- if (pairIndex < previousPairIndex) {
1168
- throw new Error(`scenes[${index}].steps must remain grouped in explanation stage order.`);
1169
- }
1170
- previousPairIndex = pairIndex;
1171
- if (step.stageId !== void 0) requireString3(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1172
- if (step.stageIndex !== void 0) requireNonNegativeInteger(step.stageIndex, `scenes[${index}].steps[${stepIndex}].stageIndex`);
1173
- if (step.focusIndex !== void 0) requireNonNegativeInteger(step.focusIndex, `scenes[${index}].steps[${stepIndex}].focusIndex`);
1174
- if (step.focusCount !== void 0) requirePositiveInteger(step.focusCount, `scenes[${index}].steps[${stepIndex}].focusCount`);
1175
- if (step.stageIndex !== void 0 && Number(step.stageIndex) !== pairIndex) {
1176
- throw new Error(`scenes[${index}].steps[${stepIndex}].stageIndex must match pairIndex.`);
1177
- }
1178
- if (step.focusIndex !== void 0 && step.focusCount !== void 0 && Number(step.focusIndex) >= Number(step.focusCount)) {
1179
- throw new Error(`scenes[${index}].steps[${stepIndex}].focusIndex must be less than focusCount.`);
1180
- }
1181
- });
1182
- for (let stageIndex = 0; stageIndex < panels.length - 1; stageIndex += 1) {
1183
- const panel = panels[stageIndex + 1];
1184
- const stageSteps = value.steps.filter((step) => isRecord3(step) && Number(step.pairIndex) === stageIndex);
1185
- if (!isRecord3(panel) || stageSteps.length === 0) {
1186
- throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
1187
- }
1188
- const firstStep = stageSteps[0];
1189
- const fallbackStageId = isRecord3(firstStep) ? firstStep.id : void 0;
1190
- if (stageSteps.some((step) => {
1191
- if (!isRecord3(step)) return true;
1192
- return (step.stageId ?? fallbackStageId) !== panel.stageId;
1193
- })) {
1194
- throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
1195
- }
1196
- }
1197
- }
1198
- }
1199
- function validateMultiPanelFiles(value, panelCount, index) {
1200
- if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
1201
- for (const [fileIndex, file] of value.entries()) {
1202
- if (!isRecord3(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1203
- requireString3(file.path, `scenes[${index}].files[${fileIndex}].path`);
1204
- if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
1205
- throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
1206
- }
1207
- for (const [panelIndex, panel] of file.panels.entries()) {
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`);
1211
- if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
1212
- }
1213
- }
1214
- }
1215
- function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1216
- if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
1217
- for (const [stepIndex, step] of value.entries()) {
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}`);
1220
- requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
1221
- if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
1222
- if (deconstructed) {
1223
- requireStringArray3(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1224
- if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
1225
- }
1226
- if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
1227
- if (step.startLine !== void 0) requirePositiveInteger(step.startLine, `scenes[${index}].steps[${stepIndex}].startLine`);
1228
- if (step.endLine !== void 0) requirePositiveInteger(step.endLine, `scenes[${index}].steps[${stepIndex}].endLine`);
1229
- }
1230
- }
1231
- function validateTourFile(value, index) {
1232
- if (!isRecord3(value)) throw new Error(`files[${index}] must be an object.`);
1233
- if (value.kind === "text-diff") {
1234
- validateScene(value, index, 1);
1235
- return;
1236
- }
1237
- if (value.kind !== "omitted") {
1238
- throw new Error(`files[${index}].kind must be text-diff or omitted.`);
1239
- }
1240
- for (const key of ["id", "title", "path", "changeKind", "reason"]) {
1241
- requireString3(value[key], `files[${index}].${key}`);
1242
- }
1243
- if (value.previousPath !== void 0) requireString3(value.previousPath, `files[${index}].previousPath`);
1244
- requireNonNegativeInteger(value.additions, `files[${index}].additions`);
1245
- requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
1246
- }
1247
- function collectLegacyTourFiles(scenes) {
1248
- const files = /* @__PURE__ */ new Map();
1249
- for (const scene of scenes) {
1250
- if (scene.kind === "text-diff") {
1251
- if (!files.has(scene.path)) files.set(scene.path, scene);
1252
- continue;
1253
- }
1254
- if (scene.kind === "walkthrough") {
1255
- for (const step of scene.steps) {
1256
- if (!files.has(step.diff.path)) files.set(step.diff.path, step.diff);
1257
- }
1258
- }
1259
- }
1260
- return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
1261
- }
1262
- function validateResolvedAnchor(value, path) {
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}`);
1265
- if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
1266
- requirePositiveInteger(value.startLine, `${path}.startLine`);
1267
- requirePositiveInteger(value.endLine, `${path}.endLine`);
1268
- if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
1269
- }
1270
- function validateNarrative2(value, path) {
1271
- requireString3(value.summary, `${path}.summary`);
1272
- requireStringArray3(value.bullets, `${path}.bullets`);
1273
- requireStringArray3(value.tags, `${path}.tags`);
1274
- requireString3(value.takeaway, `${path}.takeaway`);
1275
- }
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) {
1284
- return typeof value === "object" && value !== null && !Array.isArray(value);
1285
- }
1286
- function requireString3(value, path) {
1287
- if (typeof value !== "string") {
1288
- throw new Error(`${path} must be a string.`);
1793
+ requireString3(value.changeKind, `scenes[${index}].changeKind`);
1794
+ requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
1795
+ requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
1796
+ if (value.focusChangeIndex !== void 0) {
1797
+ requireNonNegativeInteger(value.focusChangeIndex, `scenes[${index}].focusChangeIndex`);
1289
1798
  }
1290
1799
  }
1291
- function requireStringArray3(value, path) {
1292
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1293
- throw new Error(`${path} must be an array of strings.`);
1800
+ function validateStackedScene2(value, index) {
1801
+ if (!Array.isArray(value.stack) || value.stack.length < 2 || value.stack.length > 6) {
1802
+ throw new Error(`scenes[${index}].stack must contain between 2 and 6 revisions.`);
1294
1803
  }
1295
- }
1296
- function requireNonNegativeInteger(value, path) {
1297
- if (!Number.isInteger(value) || Number(value) < 0) {
1298
- throw new Error(`${path} must be a non-negative integer.`);
1804
+ for (const [panelIndex, panel] of value.stack.entries()) {
1805
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
1806
+ for (const key of ["id", "ref", "oid", "label"]) requireString3(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
1299
1807
  }
1808
+ validateMultiPanelFiles(value.files, value.stack.length, index);
1809
+ validateMultiPanelSteps(value.steps, value.stack.length, index, false);
1300
1810
  }
1301
- function requirePercentage(value, path) {
1302
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
1303
- throw new Error(`${path} must be a number from 0 to 100.`);
1811
+ function validateDeconstructedScene2(value, index) {
1812
+ if (value.stageLabel !== "Explanation stages") {
1813
+ throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
1304
1814
  }
1305
- }
1306
- function requirePositiveInteger(value, path) {
1307
- if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
1308
- }
1309
-
1310
- // src/changeInventory.ts
1311
- var import_crypto = require("crypto");
1312
- var import_child_process2 = require("child_process");
1313
- var CHANGE_INVENTORY_VERSION = 2;
1314
- function buildChangeInventory(startPath, options = {}) {
1315
- const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
1316
- const files = range.changedPaths.map((changedPath) => buildInventoryFile(
1317
- range.repoRoot,
1318
- range.mergeBaseOid,
1319
- range.headOid,
1320
- changedPath
1321
- ));
1322
- return {
1323
- version: CHANGE_INVENTORY_VERSION,
1324
- range: {
1325
- repoRoot: range.repoRoot,
1326
- baseRef: range.baseRef,
1327
- headRef: range.headRef,
1328
- baseOid: range.mergeBaseOid,
1329
- headOid: range.headOid
1330
- },
1331
- summary: {
1332
- changedFiles: files.length,
1333
- textualFiles: files.filter((file) => file.material === "text").length,
1334
- binaryFiles: files.filter((file) => file.material === "binary").length,
1335
- changeUnits: files.reduce((total, file) => total + file.units.length, 0),
1336
- additions: files.reduce((total, file) => total + (file.additions ?? 0), 0),
1337
- deletions: files.reduce((total, file) => total + (file.deletions ?? 0), 0)
1338
- },
1339
- files
1340
- };
1341
- }
1342
- function buildInventoryFile(repoRoot, baseOid, headOid, changedPath) {
1343
- const paths = [...new Set([changedPath.previousPath, changedPath.path].filter(Boolean))];
1344
- const numstat = runGit(repoRoot, ["diff", "--numstat", "--find-renames", baseOid, headOid, "--", ...paths]);
1345
- const [added = "0", deleted = "0"] = numstat.trim().split(/\s+/, 3);
1346
- const basePath = changedPath.kind === "added" ? void 0 : changedPath.previousPath || changedPath.path;
1347
- const headPath = changedPath.kind === "deleted" ? void 0 : changedPath.path;
1348
- const baseBlob = readGitBlob(repoRoot, baseOid, basePath);
1349
- const headBlob = readGitBlob(repoRoot, headOid, headPath);
1350
- if (!baseBlob || !headBlob) {
1351
- return {
1352
- path: changedPath.path,
1353
- previousPath: changedPath.previousPath,
1354
- changeKind: changedPath.kind,
1355
- material: "unsupported",
1356
- additions: Number.parseInt(added, 10) || 0,
1357
- deletions: Number.parseInt(deleted, 10) || 0,
1358
- units: []
1359
- };
1815
+ if (!isRecord3(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1816
+ for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
1817
+ requireString3(value.realRange[key], `scenes[${index}].realRange.${key}`);
1360
1818
  }
1361
- const binary = added === "-" || deleted === "-" || !isLosslessTextBlob(baseBlob) || !isLosslessTextBlob(headBlob);
1362
- if (binary) {
1363
- return {
1364
- path: changedPath.path,
1365
- previousPath: changedPath.previousPath,
1366
- changeKind: changedPath.kind,
1367
- material: "binary",
1368
- additions: null,
1369
- deletions: null,
1370
- units: []
1371
- };
1819
+ if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
1820
+ throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
1372
1821
  }
1373
- let patch;
1374
- try {
1375
- patch = runGit(repoRoot, [
1376
- "diff",
1377
- "--no-ext-diff",
1378
- "--no-textconv",
1379
- "--no-color",
1380
- "--find-renames=20%",
1381
- "--diff-algorithm=default",
1382
- "--unified=0",
1383
- "--inter-hunk-context=0",
1384
- baseOid,
1385
- headOid,
1386
- "--",
1387
- ...paths
1388
- ]);
1389
- } catch {
1390
- return {
1391
- path: changedPath.path,
1392
- previousPath: changedPath.previousPath,
1393
- changeKind: changedPath.kind,
1394
- material: "unsupported",
1395
- additions: Number.parseInt(added, 10) || 0,
1396
- deletions: Number.parseInt(deleted, 10) || 0,
1397
- units: []
1398
- };
1822
+ const panels = value.panels;
1823
+ for (const [panelIndex, panel] of panels.entries()) {
1824
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
1825
+ for (const key of ["id", "label", "role"]) requireString3(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
1826
+ const expectedRole = panelIndex === 0 ? "baseline" : "stage";
1827
+ if (panel.role !== expectedRole) throw new Error(`scenes[${index}].panels[${panelIndex}].role must be ${expectedRole}.`);
1828
+ if (panelIndex > 0) requireString3(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
1399
1829
  }
1400
- const units = parsePatchUnits(patch, changedPath);
1401
- return {
1402
- path: changedPath.path,
1403
- previousPath: changedPath.previousPath,
1404
- changeKind: changedPath.kind,
1405
- material: "text",
1406
- additions: Number.parseInt(added, 10) || 0,
1407
- deletions: Number.parseInt(deleted, 10) || 0,
1408
- units,
1409
- baseContent: baseBlob.toString("utf8"),
1410
- headContent: headBlob.toString("utf8")
1411
- };
1412
- }
1413
- function parsePatchUnits(patch, changedPath) {
1414
- const lines = patch.split("\n");
1415
- const units = [];
1416
- const seenIds = /* @__PURE__ */ new Map();
1417
- for (let index = 0; index < lines.length; index += 1) {
1418
- const match = lines[index].match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
1419
- if (!match) continue;
1420
- const body = [];
1421
- for (index += 1; index < lines.length && !lines[index].startsWith("@@ ") && !lines[index].startsWith("diff --git "); index += 1) {
1422
- body.push(lines[index]);
1423
- }
1424
- index -= 1;
1425
- const { oldText, newText } = parseHunkText(body);
1426
- const contextHash = (0, import_crypto.createHash)("sha256").update(JSON.stringify([oldText, newText])).digest("hex").slice(0, 16);
1427
- const baseId = `hunk-${(0, import_crypto.createHash)("sha256").update(JSON.stringify([
1428
- changedPath.previousPath || changedPath.path,
1429
- changedPath.path,
1430
- oldText,
1431
- newText
1432
- ])).digest("hex").slice(0, 12)}`;
1433
- const occurrence = (seenIds.get(baseId) || 0) + 1;
1434
- seenIds.set(baseId, occurrence);
1435
- units.push({
1436
- id: occurrence === 1 ? baseId : `${baseId}-${occurrence}`,
1437
- path: changedPath.path,
1438
- previousPath: changedPath.previousPath,
1439
- changeKind: changedPath.kind,
1440
- baseStart: Number.parseInt(match[1], 10),
1441
- baseCount: match[2] === void 0 ? 1 : Number.parseInt(match[2], 10),
1442
- headStart: Number.parseInt(match[3], 10),
1443
- headCount: match[4] === void 0 ? 1 : Number.parseInt(match[4], 10),
1444
- additions: body.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
1445
- deletions: body.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
1446
- contextHash,
1447
- oldText,
1448
- newText
1830
+ validateMultiPanelFiles(value.files, panels.length, index);
1831
+ validateMultiPanelSteps(value.steps, panels.length, index, true);
1832
+ if (Array.isArray(value.steps)) {
1833
+ let previousPairIndex = -1;
1834
+ value.steps.forEach((step, stepIndex) => {
1835
+ if (!isRecord3(step)) return;
1836
+ const pairIndex = Number(step.pairIndex);
1837
+ if (pairIndex < previousPairIndex) {
1838
+ throw new Error(`scenes[${index}].steps must remain grouped in explanation stage order.`);
1839
+ }
1840
+ previousPairIndex = pairIndex;
1841
+ if (step.stageId !== void 0) requireString3(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1842
+ if (step.stageIndex !== void 0) requireNonNegativeInteger(step.stageIndex, `scenes[${index}].steps[${stepIndex}].stageIndex`);
1843
+ if (step.focusIndex !== void 0) requireNonNegativeInteger(step.focusIndex, `scenes[${index}].steps[${stepIndex}].focusIndex`);
1844
+ if (step.focusCount !== void 0) requirePositiveInteger(step.focusCount, `scenes[${index}].steps[${stepIndex}].focusCount`);
1845
+ if (step.stageIndex !== void 0 && Number(step.stageIndex) !== pairIndex) {
1846
+ throw new Error(`scenes[${index}].steps[${stepIndex}].stageIndex must match pairIndex.`);
1847
+ }
1848
+ if (step.focusIndex !== void 0 && step.focusCount !== void 0 && Number(step.focusIndex) >= Number(step.focusCount)) {
1849
+ throw new Error(`scenes[${index}].steps[${stepIndex}].focusIndex must be less than focusCount.`);
1850
+ }
1449
1851
  });
1450
- }
1451
- return units;
1452
- }
1453
- function materializeChangeUnits(baseContent, units, selectedUnitIds) {
1454
- const selected = new Set(selectedUnitIds);
1455
- const knownIds = new Set(units.map((unit) => unit.id));
1456
- for (const id of selected) {
1457
- if (!knownIds.has(id)) {
1458
- throw new Error(`Unknown change unit: ${id}`);
1852
+ for (let stageIndex = 0; stageIndex < panels.length - 1; stageIndex += 1) {
1853
+ const panel = panels[stageIndex + 1];
1854
+ const stageSteps = value.steps.filter((step) => isRecord3(step) && Number(step.pairIndex) === stageIndex);
1855
+ if (!isRecord3(panel) || stageSteps.length === 0) {
1856
+ throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
1857
+ }
1858
+ const firstStep = stageSteps[0];
1859
+ const fallbackStageId = isRecord3(firstStep) ? firstStep.id : void 0;
1860
+ if (stageSteps.some((step) => {
1861
+ if (!isRecord3(step)) return true;
1862
+ return (step.stageId ?? fallbackStageId) !== panel.stageId;
1863
+ })) {
1864
+ throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
1865
+ }
1459
1866
  }
1460
1867
  }
1461
- const offsets = buildLineBoundaryOffsets(baseContent);
1462
- const ordered = [...units].sort((left, right) => {
1463
- const leftRange = resolveBaseRange(left, offsets, baseContent.length);
1464
- const rightRange = resolveBaseRange(right, offsets, baseContent.length);
1465
- return leftRange.start - rightRange.start || leftRange.end - rightRange.end;
1466
- });
1467
- const chunks = [];
1468
- let cursor = 0;
1469
- let previousEnd = 0;
1470
- for (const unit of ordered) {
1471
- const range = resolveBaseRange(unit, offsets, baseContent.length);
1472
- if (range.start < previousEnd) {
1473
- throw new Error(`Overlapping change units cannot be materialized independently: ${unit.id}`);
1474
- }
1475
- if (baseContent.slice(range.start, range.end) !== unit.oldText) {
1476
- throw new Error(`Change unit ${unit.id} no longer matches its base content.`);
1868
+ }
1869
+ function validateMultiPanelFiles(value, panelCount, index) {
1870
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
1871
+ for (const [fileIndex, file] of value.entries()) {
1872
+ if (!isRecord3(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1873
+ requireString3(file.path, `scenes[${index}].files[${fileIndex}].path`);
1874
+ if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
1875
+ throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
1477
1876
  }
1478
- if (selected.has(unit.id)) {
1479
- chunks.push(baseContent.slice(cursor, range.start), unit.newText);
1480
- cursor = range.end;
1877
+ for (const [panelIndex, panel] of file.panels.entries()) {
1878
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
1879
+ for (const key of ["id", "label", "content"]) requireString3(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
1880
+ if (panel.path !== void 0) requireString3(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
1881
+ if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
1481
1882
  }
1482
- previousEnd = Math.max(previousEnd, range.end);
1483
1883
  }
1484
- chunks.push(baseContent.slice(cursor));
1485
- return chunks.join("");
1486
1884
  }
1487
- function parseHunkText(body) {
1488
- let oldText = "";
1489
- let newText = "";
1490
- let previousPrefix = "";
1491
- for (const line of body) {
1492
- if (line === "\") {
1493
- if (previousPrefix === "-" || previousPrefix === " ") oldText = removeFinalNewline(oldText);
1494
- if (previousPrefix === "+" || previousPrefix === " ") newText = removeFinalNewline(newText);
1495
- continue;
1885
+ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1886
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
1887
+ for (const [stepIndex, step] of value.entries()) {
1888
+ if (!isRecord3(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
1889
+ for (const key of ["id", "title", "body", "file", "side"]) requireString3(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
1890
+ requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
1891
+ if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
1892
+ if (deconstructed) {
1893
+ requireStringArray3(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1894
+ if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
1496
1895
  }
1497
- const prefix = line[0];
1498
- const content = line.slice(1);
1499
- if (prefix === "-" || prefix === " ") oldText += `${content}
1500
- `;
1501
- if (prefix === "+" || prefix === " ") newText += `${content}
1502
- `;
1503
- previousPrefix = prefix;
1896
+ if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
1897
+ if (step.startLine !== void 0) requirePositiveInteger(step.startLine, `scenes[${index}].steps[${stepIndex}].startLine`);
1898
+ if (step.endLine !== void 0) requirePositiveInteger(step.endLine, `scenes[${index}].steps[${stepIndex}].endLine`);
1504
1899
  }
1505
- return { oldText, newText };
1506
- }
1507
- function removeFinalNewline(value) {
1508
- return value.endsWith("\n") ? value.slice(0, -1) : value;
1509
1900
  }
1510
- function buildLineBoundaryOffsets(content) {
1511
- const offsets = [0];
1512
- for (let index = 0; index < content.length; index += 1) {
1513
- if (content[index] === "\n") offsets.push(index + 1);
1901
+ function validateTourFile(value, index) {
1902
+ if (!isRecord3(value)) throw new Error(`files[${index}] must be an object.`);
1903
+ if (value.kind === "text-diff") {
1904
+ validateScene(value, index, 1);
1905
+ return;
1514
1906
  }
1515
- if (offsets[offsets.length - 1] !== content.length) offsets.push(content.length);
1516
- return offsets;
1517
- }
1518
- function resolveBaseRange(unit, offsets, contentLength) {
1519
- const startIndex = unit.baseCount === 0 ? unit.baseStart : unit.baseStart - 1;
1520
- const endIndex = unit.baseCount === 0 ? startIndex : startIndex + unit.baseCount;
1521
- const start = offsets[startIndex];
1522
- const end = offsets[endIndex];
1523
- if (start === void 0 || end === void 0 || start > contentLength || end > contentLength) {
1524
- throw new Error(`Change unit ${unit.id} has an invalid base range.`);
1907
+ if (value.kind !== "omitted") {
1908
+ throw new Error(`files[${index}].kind must be text-diff or omitted.`);
1525
1909
  }
1526
- return { start, end };
1527
- }
1528
- function isLosslessTextBlob(content) {
1529
- if (content.includes(0)) return false;
1530
- const decoded = content.toString("utf8");
1531
- return Buffer.from(decoded, "utf8").equals(content);
1532
- }
1533
- function readGitBlob(repoRoot, oid, relativePath) {
1534
- if (!relativePath) return Buffer.alloc(0);
1535
- try {
1536
- return (0, import_child_process2.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
1537
- cwd: repoRoot,
1538
- encoding: "buffer",
1539
- maxBuffer: 16 * 1024 * 1024,
1540
- stdio: ["ignore", "pipe", "pipe"]
1541
- });
1542
- } catch {
1543
- return null;
1910
+ for (const key of ["id", "title", "path", "changeKind", "reason"]) {
1911
+ requireString3(value[key], `files[${index}].${key}`);
1544
1912
  }
1913
+ if (value.previousPath !== void 0) requireString3(value.previousPath, `files[${index}].previousPath`);
1914
+ requireNonNegativeInteger(value.additions, `files[${index}].additions`);
1915
+ requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
1545
1916
  }
1546
- function runGit(repoRoot, args) {
1547
- return (0, import_child_process2.execFileSync)("git", args, {
1548
- cwd: repoRoot,
1549
- encoding: "utf8",
1550
- maxBuffer: 16 * 1024 * 1024,
1551
- stdio: ["ignore", "pipe", "pipe"]
1552
- });
1553
- }
1554
-
1555
- // src/deconstructedChange.ts
1556
- function buildDeconstructedScene(startPath, scene, options = {}) {
1557
- return compileDeconstructedScene(buildChangeInventory(startPath, {
1558
- headRef: options.headRef || scene.target,
1559
- baseRef: options.baseRef || scene.base
1560
- }), scene);
1561
- }
1562
- function compileDeconstructedScene(inventory, scene) {
1563
- const ownership = /* @__PURE__ */ new Map();
1564
- const excludedByFile = /* @__PURE__ */ new Map();
1565
- const cumulativeByFile = /* @__PURE__ */ new Map();
1566
- const wholeFileExclusions = /* @__PURE__ */ new Map();
1567
- const excludedFiles = [];
1568
- for (const exclusion of scene.exclusions || []) {
1569
- const file = requireInventoryFile(inventory, exclusion.file, "Exclusion");
1570
- if (!exclusion.hunks) {
1571
- if (wholeFileExclusions.has(file)) {
1572
- throw new Error(`File is excluded more than once: ${exclusion.file}`);
1573
- }
1574
- wholeFileExclusions.set(file, exclusion.reason);
1575
- excludedFiles.push({ path: file.path, reason: exclusion.reason });
1576
- }
1577
- const ids = exclusion.hunks || file.units.map((unit) => unit.id);
1578
- if (file.material !== "text" && exclusion.hunks) {
1579
- throw new Error(`Unsupported file ${exclusion.file} must be excluded as a whole file.`);
1580
- }
1581
- claimHunks(file, ids, `exclusion for ${exclusion.file}`, ownership);
1582
- const excluded = excludedByFile.get(file) || /* @__PURE__ */ new Set();
1583
- ids.forEach((id) => excluded.add(id));
1584
- excludedByFile.set(file, excluded);
1585
- }
1586
- const stageClaims = scene.stages.map((stage) => {
1587
- const claims = [];
1588
- for (const change of stage.changes) {
1589
- const file = requireInventoryFile(inventory, change.file, `Stage ${stage.id}`);
1590
- if (file.material !== "text") {
1591
- throw new Error(`Stage ${stage.id} cannot assign unsupported file: ${change.file}`);
1592
- }
1593
- if (file.changeKind === "renamed") {
1594
- throw new Error(`Stage ${stage.id} cannot assign renamed file ${change.file} until path-transition units are supported.`);
1595
- }
1596
- claimHunks(file, change.hunks, `stage ${stage.id}`, ownership);
1597
- claims.push({ file, ids: [...change.hunks] });
1598
- }
1599
- return { stage, claims };
1600
- });
1601
- for (const file of inventory.files) {
1602
- const wholeFileExcluded = wholeFileExclusions.has(file);
1603
- if (file.material !== "text" || file.changeKind === "renamed" || file.units.length === 0) {
1604
- if (!wholeFileExcluded) {
1605
- 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";
1606
- throw new Error(`Deconstructed scene ${scene.id} must exclude ${file.path} as a whole file because ${reason}.`);
1607
- }
1917
+ function collectLegacyTourFiles(scenes) {
1918
+ const files = /* @__PURE__ */ new Map();
1919
+ for (const scene of scenes) {
1920
+ if (scene.kind === "text-diff") {
1921
+ if (!files.has(scene.path)) files.set(scene.path, scene);
1608
1922
  continue;
1609
1923
  }
1610
- for (const unit of file.units) {
1611
- if (!ownership.has(unitKey(file, unit.id))) {
1612
- throw new Error(`Unassigned change unit in ${file.path}: ${unit.id}`);
1924
+ if (scene.kind === "walkthrough") {
1925
+ for (const step of scene.steps) {
1926
+ if (!files.has(step.diff.path)) files.set(step.diff.path, step.diff);
1613
1927
  }
1614
1928
  }
1615
1929
  }
1616
- for (const file of inventory.files) {
1617
- cumulativeByFile.set(file, new Set(excludedByFile.get(file) || []));
1930
+ return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
1931
+ }
1932
+ function validateResolvedAnchor(value, path) {
1933
+ if (!isRecord3(value)) throw new Error(`${path} must be an object.`);
1934
+ for (const key of ["id", "path", "revision", "excerpt"]) requireString3(value[key], `${path}.${key}`);
1935
+ if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
1936
+ requirePositiveInteger(value.startLine, `${path}.startLine`);
1937
+ requirePositiveInteger(value.endLine, `${path}.endLine`);
1938
+ if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
1939
+ }
1940
+ function validateNarrative2(value, path) {
1941
+ requireString3(value.summary, `${path}.summary`);
1942
+ requireStringArray3(value.bullets, `${path}.bullets`);
1943
+ requireStringArray3(value.tags, `${path}.tags`);
1944
+ requireString3(value.takeaway, `${path}.takeaway`);
1945
+ }
1946
+ function validateChapterSummary(value, version, path) {
1947
+ if (value === void 0) return;
1948
+ if (version < 5) throw new Error(`${path} requires manifest version 5.`);
1949
+ if (typeof value !== "string" || !value.trim()) {
1950
+ throw new Error(`${path} must be a non-empty string.`);
1618
1951
  }
1619
- const materializableFiles = inventory.files.filter((file) => file.material === "text" && file.changeKind !== "renamed" && file.baseContent !== void 0 && file.headContent !== void 0);
1620
- const baselineFiles = materializableFiles.map((file) => buildFileState(
1621
- file,
1622
- cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
1623
- wholeFileExclusions.has(file)
1624
- ));
1625
- const stages = [];
1626
- for (const { stage, claims } of stageClaims) {
1627
- const introducedHunks = [];
1628
- const introducedFiles = [];
1629
- const introducedChanges = [];
1630
- for (const claim of claims) {
1631
- const cumulative = cumulativeByFile.get(claim.file) || /* @__PURE__ */ new Set();
1632
- claim.ids.forEach((id) => {
1633
- cumulative.add(id);
1634
- introducedHunks.push(id);
1635
- });
1636
- cumulativeByFile.set(claim.file, cumulative);
1637
- if (!introducedFiles.includes(claim.file.path)) introducedFiles.push(claim.file.path);
1638
- const introducedChange = introducedChanges.find((change) => change.file === claim.file.path);
1639
- if (introducedChange) {
1640
- introducedChange.hunks.push(...claim.ids);
1641
- } else {
1642
- introducedChanges.push({ file: claim.file.path, hunks: [...claim.ids] });
1643
- }
1644
- }
1645
- stages.push({
1646
- id: stage.id,
1647
- title: stage.title,
1648
- narration: stage.narration,
1649
- introducedHunks,
1650
- introducedFiles,
1651
- introducedChanges,
1652
- cumulativeHunks: [...cumulativeByFile.values()].flatMap((ids) => [...ids]),
1653
- files: materializableFiles.map((file) => buildFileState(
1654
- file,
1655
- cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
1656
- wholeFileExclusions.has(file)
1657
- ))
1658
- });
1952
+ }
1953
+ function isRecord3(value) {
1954
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1955
+ }
1956
+ function requireString3(value, path) {
1957
+ if (typeof value !== "string") {
1958
+ throw new Error(`${path} must be a string.`);
1659
1959
  }
1660
- for (const file of materializableFiles) {
1661
- const finalState = buildFileState(
1662
- file,
1663
- cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
1664
- wholeFileExclusions.has(file)
1665
- );
1666
- const expectedExists = file.changeKind !== "deleted";
1667
- if (finalState.exists !== expectedExists || finalState.content !== file.headContent) {
1668
- throw new Error(`Final deconstructed state does not match the target for ${file.path}.`);
1669
- }
1960
+ }
1961
+ function requireStringArray3(value, path) {
1962
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1963
+ throw new Error(`${path} must be an array of strings.`);
1670
1964
  }
1671
- return {
1672
- id: scene.id,
1673
- title: scene.title,
1674
- baseOid: inventory.range.baseOid,
1675
- targetOid: inventory.range.headOid,
1676
- baselineFiles,
1677
- excludedHunks: [...excludedByFile.values()].flatMap((ids) => [...ids]),
1678
- excludedFiles,
1679
- stages
1680
- };
1681
1965
  }
1682
- function requireInventoryFile(inventory, path, owner) {
1683
- const matches = inventory.files.filter((file) => file.path === path || file.previousPath === path);
1684
- if (matches.length === 0) throw new Error(`${owner} references a file outside the change inventory: ${path}`);
1685
- if (matches.length > 1) throw new Error(`${owner} references an ambiguous changed path: ${path}`);
1686
- return matches[0];
1966
+ function requireNonNegativeInteger(value, path) {
1967
+ if (!Number.isInteger(value) || Number(value) < 0) {
1968
+ throw new Error(`${path} must be a non-negative integer.`);
1969
+ }
1687
1970
  }
1688
- function claimHunks(file, ids, owner, ownership) {
1689
- const known = new Set(file.units.map((unit) => unit.id));
1690
- for (const id of ids) {
1691
- if (!known.has(id)) throw new Error(`${owner} references an unknown hunk in ${file.path}: ${id}`);
1692
- const key = unitKey(file, id);
1693
- const previousOwner = ownership.get(key);
1694
- if (previousOwner) throw new Error(`Change unit ${id} in ${file.path} is assigned to both ${previousOwner} and ${owner}.`);
1695
- ownership.set(key, owner);
1971
+ function requirePercentage(value, path) {
1972
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
1973
+ throw new Error(`${path} must be a number from 0 to 100.`);
1696
1974
  }
1697
1975
  }
1698
- function unitKey(file, id) {
1699
- return `${file.previousPath || ""}\0${file.path}\0${id}`;
1976
+ function requirePositiveInteger(value, path) {
1977
+ if (!Number.isInteger(value) || Number(value) < 1) throw new Error(`${path} must be a positive integer.`);
1700
1978
  }
1701
- function buildFileState(file, selected, wholeFileExcluded) {
1702
- if (file.baseContent === void 0) throw new Error(`Missing base content for ${file.path}.`);
1703
- const content = materializeChangeUnits(file.baseContent, file.units, selected);
1704
- let exists = true;
1705
- if (file.changeKind === "added") exists = wholeFileExcluded || selected.size > 0;
1706
- if (file.changeKind === "deleted") exists = !(wholeFileExcluded || selected.size === file.units.length);
1979
+
1980
+ // src/changeInventory.ts
1981
+ var import_crypto2 = require("crypto");
1982
+ var import_child_process3 = require("child_process");
1983
+ var CHANGE_INVENTORY_VERSION = 2;
1984
+ function buildChangeInventory(startPath, options = {}) {
1985
+ const range = resolveBranchReviewRange(startPath, options.headRef, options.baseRef);
1986
+ const files = range.changedPaths.map((changedPath) => buildInventoryFile(
1987
+ range.repoRoot,
1988
+ range.mergeBaseOid,
1989
+ range.headOid,
1990
+ changedPath
1991
+ ));
1707
1992
  return {
1708
- path: file.path,
1709
- previousPath: file.previousPath,
1710
- changeKind: file.changeKind,
1711
- exists,
1712
- content: exists ? content : "",
1713
- appliedHunks: [...selected]
1993
+ version: CHANGE_INVENTORY_VERSION,
1994
+ range: {
1995
+ repoRoot: range.repoRoot,
1996
+ baseRef: range.baseRef,
1997
+ headRef: range.headRef,
1998
+ baseOid: range.mergeBaseOid,
1999
+ headOid: range.headOid
2000
+ },
2001
+ summary: {
2002
+ changedFiles: files.length,
2003
+ textualFiles: files.filter((file) => file.material === "text").length,
2004
+ binaryFiles: files.filter((file) => file.material === "binary").length,
2005
+ changeUnits: files.reduce((total, file) => total + file.units.length, 0),
2006
+ additions: files.reduce((total, file) => total + (file.additions ?? 0), 0),
2007
+ deletions: files.reduce((total, file) => total + (file.deletions ?? 0), 0)
2008
+ },
2009
+ files
1714
2010
  };
1715
2011
  }
1716
-
1717
- // node_modules/diff/lib/index.mjs
1718
- function Diff() {
2012
+ function buildInventoryFile(repoRoot, baseOid, headOid, changedPath) {
2013
+ const paths = [...new Set([changedPath.previousPath, changedPath.path].filter(Boolean))];
2014
+ const numstat = runGit(repoRoot, ["diff", "--numstat", "--find-renames", baseOid, headOid, "--", ...paths]);
2015
+ const [added = "0", deleted = "0"] = numstat.trim().split(/\s+/, 3);
2016
+ const basePath = changedPath.kind === "added" ? void 0 : changedPath.previousPath || changedPath.path;
2017
+ const headPath = changedPath.kind === "deleted" ? void 0 : changedPath.path;
2018
+ const baseBlob = readGitBlob(repoRoot, baseOid, basePath);
2019
+ const headBlob = readGitBlob(repoRoot, headOid, headPath);
2020
+ if (!baseBlob || !headBlob) {
2021
+ return {
2022
+ path: changedPath.path,
2023
+ previousPath: changedPath.previousPath,
2024
+ changeKind: changedPath.kind,
2025
+ material: "unsupported",
2026
+ additions: Number.parseInt(added, 10) || 0,
2027
+ deletions: Number.parseInt(deleted, 10) || 0,
2028
+ units: []
2029
+ };
2030
+ }
2031
+ const binary = added === "-" || deleted === "-" || !isLosslessTextBlob(baseBlob) || !isLosslessTextBlob(headBlob);
2032
+ if (binary) {
2033
+ return {
2034
+ path: changedPath.path,
2035
+ previousPath: changedPath.previousPath,
2036
+ changeKind: changedPath.kind,
2037
+ material: "binary",
2038
+ additions: null,
2039
+ deletions: null,
2040
+ units: []
2041
+ };
2042
+ }
2043
+ let patch;
2044
+ try {
2045
+ patch = runGit(repoRoot, [
2046
+ "diff",
2047
+ "--no-ext-diff",
2048
+ "--no-textconv",
2049
+ "--no-color",
2050
+ "--find-renames=20%",
2051
+ "--diff-algorithm=default",
2052
+ "--unified=0",
2053
+ "--inter-hunk-context=0",
2054
+ baseOid,
2055
+ headOid,
2056
+ "--",
2057
+ ...paths
2058
+ ]);
2059
+ } catch {
2060
+ return {
2061
+ path: changedPath.path,
2062
+ previousPath: changedPath.previousPath,
2063
+ changeKind: changedPath.kind,
2064
+ material: "unsupported",
2065
+ additions: Number.parseInt(added, 10) || 0,
2066
+ deletions: Number.parseInt(deleted, 10) || 0,
2067
+ units: []
2068
+ };
2069
+ }
2070
+ const units = parsePatchUnits(patch, changedPath);
2071
+ return {
2072
+ path: changedPath.path,
2073
+ previousPath: changedPath.previousPath,
2074
+ changeKind: changedPath.kind,
2075
+ material: "text",
2076
+ additions: Number.parseInt(added, 10) || 0,
2077
+ deletions: Number.parseInt(deleted, 10) || 0,
2078
+ units,
2079
+ baseContent: baseBlob.toString("utf8"),
2080
+ headContent: headBlob.toString("utf8")
2081
+ };
1719
2082
  }
1720
- Diff.prototype = {
1721
- diff: function diff(oldString, newString) {
1722
- var _options$timeout;
1723
- var options = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {};
1724
- var callback = options.callback;
1725
- if (typeof options === "function") {
1726
- callback = options;
1727
- options = {};
1728
- }
1729
- this.options = options;
1730
- var self = this;
1731
- function done(value) {
1732
- if (callback) {
1733
- setTimeout(function() {
1734
- callback(void 0, value);
1735
- }, 0);
1736
- return true;
1737
- } else {
1738
- return value;
1739
- }
1740
- }
1741
- oldString = this.castInput(oldString);
1742
- newString = this.castInput(newString);
1743
- oldString = this.removeEmpty(this.tokenize(oldString));
1744
- newString = this.removeEmpty(this.tokenize(newString));
1745
- var newLen = newString.length, oldLen = oldString.length;
1746
- var editLength = 1;
1747
- var maxEditLength = newLen + oldLen;
1748
- if (options.maxEditLength) {
1749
- maxEditLength = Math.min(maxEditLength, options.maxEditLength);
1750
- }
1751
- var maxExecutionTime = (_options$timeout = options.timeout) !== null && _options$timeout !== void 0 ? _options$timeout : Infinity;
1752
- var abortAfterTimestamp = Date.now() + maxExecutionTime;
1753
- var bestPath = [{
1754
- oldPos: -1,
1755
- lastComponent: void 0
1756
- }];
1757
- var newPos = this.extractCommon(bestPath[0], newString, oldString, 0);
1758
- if (bestPath[0].oldPos + 1 >= oldLen && newPos + 1 >= newLen) {
1759
- return done([{
1760
- value: this.join(newString),
1761
- count: newString.length
1762
- }]);
1763
- }
1764
- var minDiagonalToConsider = -Infinity, maxDiagonalToConsider = Infinity;
1765
- function execEditLength() {
1766
- for (var diagonalPath = Math.max(minDiagonalToConsider, -editLength); diagonalPath <= Math.min(maxDiagonalToConsider, editLength); diagonalPath += 2) {
1767
- var basePath = void 0;
1768
- var removePath = bestPath[diagonalPath - 1], addPath = bestPath[diagonalPath + 1];
1769
- if (removePath) {
1770
- bestPath[diagonalPath - 1] = void 0;
1771
- }
1772
- var canAdd = false;
1773
- if (addPath) {
1774
- var addPathNewPos = addPath.oldPos - diagonalPath;
1775
- canAdd = addPath && 0 <= addPathNewPos && addPathNewPos < newLen;
1776
- }
1777
- var canRemove = removePath && removePath.oldPos + 1 < oldLen;
1778
- if (!canAdd && !canRemove) {
1779
- bestPath[diagonalPath] = void 0;
1780
- continue;
1781
- }
1782
- if (!canRemove || canAdd && removePath.oldPos + 1 < addPath.oldPos) {
1783
- basePath = self.addToPath(addPath, true, void 0, 0);
1784
- } else {
1785
- basePath = self.addToPath(removePath, void 0, true, 1);
1786
- }
1787
- newPos = self.extractCommon(basePath, newString, oldString, diagonalPath);
1788
- if (basePath.oldPos + 1 >= oldLen && newPos + 1 >= newLen) {
1789
- return done(buildValues(self, basePath.lastComponent, newString, oldString, self.useLongestToken));
1790
- } else {
1791
- bestPath[diagonalPath] = basePath;
1792
- if (basePath.oldPos + 1 >= oldLen) {
1793
- maxDiagonalToConsider = Math.min(maxDiagonalToConsider, diagonalPath - 1);
1794
- }
1795
- if (newPos + 1 >= newLen) {
1796
- minDiagonalToConsider = Math.max(minDiagonalToConsider, diagonalPath + 1);
1797
- }
1798
- }
1799
- }
1800
- editLength++;
1801
- }
1802
- if (callback) {
1803
- (function exec() {
1804
- setTimeout(function() {
1805
- if (editLength > maxEditLength || Date.now() > abortAfterTimestamp) {
1806
- return callback();
1807
- }
1808
- if (!execEditLength()) {
1809
- exec();
1810
- }
1811
- }, 0);
1812
- })();
1813
- } else {
1814
- while (editLength <= maxEditLength && Date.now() <= abortAfterTimestamp) {
1815
- var ret = execEditLength();
1816
- if (ret) {
1817
- return ret;
1818
- }
1819
- }
1820
- }
1821
- },
1822
- addToPath: function addToPath(path, added, removed, oldPosInc) {
1823
- var last = path.lastComponent;
1824
- if (last && last.added === added && last.removed === removed) {
1825
- return {
1826
- oldPos: path.oldPos + oldPosInc,
1827
- lastComponent: {
1828
- count: last.count + 1,
1829
- added,
1830
- removed,
1831
- previousComponent: last.previousComponent
1832
- }
1833
- };
1834
- } else {
1835
- return {
1836
- oldPos: path.oldPos + oldPosInc,
1837
- lastComponent: {
1838
- count: 1,
1839
- added,
1840
- removed,
1841
- previousComponent: last
1842
- }
1843
- };
2083
+ function parsePatchUnits(patch, changedPath) {
2084
+ const lines = patch.split("\n");
2085
+ const units = [];
2086
+ const seenIds = /* @__PURE__ */ new Map();
2087
+ for (let index = 0; index < lines.length; index += 1) {
2088
+ const match = lines[index].match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
2089
+ if (!match) continue;
2090
+ const body = [];
2091
+ for (index += 1; index < lines.length && !lines[index].startsWith("@@ ") && !lines[index].startsWith("diff --git "); index += 1) {
2092
+ body.push(lines[index]);
1844
2093
  }
1845
- },
1846
- extractCommon: function extractCommon(basePath, newString, oldString, diagonalPath) {
1847
- var newLen = newString.length, oldLen = oldString.length, oldPos = basePath.oldPos, newPos = oldPos - diagonalPath, commonCount = 0;
1848
- while (newPos + 1 < newLen && oldPos + 1 < oldLen && this.equals(newString[newPos + 1], oldString[oldPos + 1])) {
1849
- newPos++;
1850
- oldPos++;
1851
- commonCount++;
2094
+ index -= 1;
2095
+ const { oldText, newText } = parseHunkText(body);
2096
+ const contextHash = (0, import_crypto2.createHash)("sha256").update(JSON.stringify([oldText, newText])).digest("hex").slice(0, 16);
2097
+ const baseId = `hunk-${(0, import_crypto2.createHash)("sha256").update(JSON.stringify([
2098
+ changedPath.previousPath || changedPath.path,
2099
+ changedPath.path,
2100
+ oldText,
2101
+ newText
2102
+ ])).digest("hex").slice(0, 12)}`;
2103
+ const occurrence = (seenIds.get(baseId) || 0) + 1;
2104
+ seenIds.set(baseId, occurrence);
2105
+ units.push({
2106
+ id: occurrence === 1 ? baseId : `${baseId}-${occurrence}`,
2107
+ path: changedPath.path,
2108
+ previousPath: changedPath.previousPath,
2109
+ changeKind: changedPath.kind,
2110
+ baseStart: Number.parseInt(match[1], 10),
2111
+ baseCount: match[2] === void 0 ? 1 : Number.parseInt(match[2], 10),
2112
+ headStart: Number.parseInt(match[3], 10),
2113
+ headCount: match[4] === void 0 ? 1 : Number.parseInt(match[4], 10),
2114
+ additions: body.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
2115
+ deletions: body.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
2116
+ contextHash,
2117
+ oldText,
2118
+ newText
2119
+ });
2120
+ }
2121
+ return units;
2122
+ }
2123
+ function materializeChangeUnits(baseContent, units, selectedUnitIds) {
2124
+ const selected = new Set(selectedUnitIds);
2125
+ const knownIds = new Set(units.map((unit) => unit.id));
2126
+ for (const id of selected) {
2127
+ if (!knownIds.has(id)) {
2128
+ throw new Error(`Unknown change unit: ${id}`);
1852
2129
  }
1853
- if (commonCount) {
1854
- basePath.lastComponent = {
1855
- count: commonCount,
1856
- previousComponent: basePath.lastComponent
1857
- };
2130
+ }
2131
+ const offsets = buildLineBoundaryOffsets(baseContent);
2132
+ const ordered = [...units].sort((left, right) => {
2133
+ const leftRange = resolveBaseRange(left, offsets, baseContent.length);
2134
+ const rightRange = resolveBaseRange(right, offsets, baseContent.length);
2135
+ return leftRange.start - rightRange.start || leftRange.end - rightRange.end;
2136
+ });
2137
+ const chunks = [];
2138
+ let cursor = 0;
2139
+ let previousEnd = 0;
2140
+ for (const unit of ordered) {
2141
+ const range = resolveBaseRange(unit, offsets, baseContent.length);
2142
+ if (range.start < previousEnd) {
2143
+ throw new Error(`Overlapping change units cannot be materialized independently: ${unit.id}`);
1858
2144
  }
1859
- basePath.oldPos = oldPos;
1860
- return newPos;
1861
- },
1862
- equals: function equals(left, right) {
1863
- if (this.options.comparator) {
1864
- return this.options.comparator(left, right);
1865
- } else {
1866
- return left === right || this.options.ignoreCase && left.toLowerCase() === right.toLowerCase();
2145
+ if (baseContent.slice(range.start, range.end) !== unit.oldText) {
2146
+ throw new Error(`Change unit ${unit.id} no longer matches its base content.`);
1867
2147
  }
1868
- },
1869
- removeEmpty: function removeEmpty(array) {
1870
- var ret = [];
1871
- for (var i = 0; i < array.length; i++) {
1872
- if (array[i]) {
1873
- ret.push(array[i]);
1874
- }
2148
+ if (selected.has(unit.id)) {
2149
+ chunks.push(baseContent.slice(cursor, range.start), unit.newText);
2150
+ cursor = range.end;
1875
2151
  }
1876
- return ret;
1877
- },
1878
- castInput: function castInput(value) {
1879
- return value;
1880
- },
1881
- tokenize: function tokenize(value) {
1882
- return value.split("");
1883
- },
1884
- join: function join(chars) {
1885
- return chars.join("");
1886
- }
1887
- };
1888
- function buildValues(diff2, lastComponent, newString, oldString, useLongestToken) {
1889
- var components = [];
1890
- var nextComponent;
1891
- while (lastComponent) {
1892
- components.push(lastComponent);
1893
- nextComponent = lastComponent.previousComponent;
1894
- delete lastComponent.previousComponent;
1895
- lastComponent = nextComponent;
2152
+ previousEnd = Math.max(previousEnd, range.end);
1896
2153
  }
1897
- components.reverse();
1898
- var componentPos = 0, componentLen = components.length, newPos = 0, oldPos = 0;
1899
- for (; componentPos < componentLen; componentPos++) {
1900
- var component = components[componentPos];
1901
- if (!component.removed) {
1902
- if (!component.added && useLongestToken) {
1903
- var value = newString.slice(newPos, newPos + component.count);
1904
- value = value.map(function(value2, i) {
1905
- var oldValue = oldString[oldPos + i];
1906
- return oldValue.length > value2.length ? oldValue : value2;
1907
- });
1908
- component.value = diff2.join(value);
1909
- } else {
1910
- component.value = diff2.join(newString.slice(newPos, newPos + component.count));
1911
- }
1912
- newPos += component.count;
1913
- if (!component.added) {
1914
- oldPos += component.count;
1915
- }
1916
- } else {
1917
- component.value = diff2.join(oldString.slice(oldPos, oldPos + component.count));
1918
- oldPos += component.count;
1919
- if (componentPos && components[componentPos - 1].added) {
1920
- var tmp = components[componentPos - 1];
1921
- components[componentPos - 1] = components[componentPos];
1922
- components[componentPos] = tmp;
1923
- }
2154
+ chunks.push(baseContent.slice(cursor));
2155
+ return chunks.join("");
2156
+ }
2157
+ function parseHunkText(body) {
2158
+ let oldText = "";
2159
+ let newText = "";
2160
+ let previousPrefix = "";
2161
+ for (const line of body) {
2162
+ if (line === "\") {
2163
+ if (previousPrefix === "-" || previousPrefix === " ") oldText = removeFinalNewline(oldText);
2164
+ if (previousPrefix === "+" || previousPrefix === " ") newText = removeFinalNewline(newText);
2165
+ continue;
1924
2166
  }
2167
+ const prefix = line[0];
2168
+ const content = line.slice(1);
2169
+ if (prefix === "-" || prefix === " ") oldText += `${content}
2170
+ `;
2171
+ if (prefix === "+" || prefix === " ") newText += `${content}
2172
+ `;
2173
+ previousPrefix = prefix;
1925
2174
  }
1926
- var finalComponent = components[componentLen - 1];
1927
- if (componentLen > 1 && typeof finalComponent.value === "string" && (finalComponent.added || finalComponent.removed) && diff2.equals("", finalComponent.value)) {
1928
- components[componentLen - 2].value += finalComponent.value;
1929
- components.pop();
1930
- }
1931
- return components;
2175
+ return { oldText, newText };
1932
2176
  }
1933
- var characterDiff = new Diff();
1934
- function diffChars(oldStr, newStr, options) {
1935
- return characterDiff.diff(oldStr, newStr, options);
2177
+ function removeFinalNewline(value) {
2178
+ return value.endsWith("\n") ? value.slice(0, -1) : value;
1936
2179
  }
1937
- var extendedWordChars = /^[A-Za-z\xC0-\u02C6\u02C8-\u02D7\u02DE-\u02FF\u1E00-\u1EFF]+$/;
1938
- var reWhitespace = /\S/;
1939
- var wordDiff = new Diff();
1940
- wordDiff.equals = function(left, right) {
1941
- if (this.options.ignoreCase) {
1942
- left = left.toLowerCase();
1943
- right = right.toLowerCase();
1944
- }
1945
- return left === right || this.options.ignoreWhitespace && !reWhitespace.test(left) && !reWhitespace.test(right);
1946
- };
1947
- wordDiff.tokenize = function(value) {
1948
- var tokens = value.split(/([^\S\r\n]+|[()[\]{}'"\r\n]|\b)/);
1949
- for (var i = 0; i < tokens.length - 1; i++) {
1950
- if (!tokens[i + 1] && tokens[i + 2] && extendedWordChars.test(tokens[i]) && extendedWordChars.test(tokens[i + 2])) {
1951
- tokens[i] += tokens[i + 2];
1952
- tokens.splice(i + 1, 2);
1953
- i--;
1954
- }
2180
+ function buildLineBoundaryOffsets(content) {
2181
+ const offsets = [0];
2182
+ for (let index = 0; index < content.length; index += 1) {
2183
+ if (content[index] === "\n") offsets.push(index + 1);
1955
2184
  }
1956
- return tokens;
1957
- };
1958
- function diffWordsWithSpace(oldStr, newStr, options) {
1959
- return wordDiff.diff(oldStr, newStr, options);
2185
+ if (offsets[offsets.length - 1] !== content.length) offsets.push(content.length);
2186
+ return offsets;
1960
2187
  }
1961
- var lineDiff = new Diff();
1962
- lineDiff.tokenize = function(value) {
1963
- if (this.options.stripTrailingCr) {
1964
- value = value.replace(/\r\n/g, "\n");
1965
- }
1966
- var retLines = [], linesAndNewlines = value.split(/(\n|\r\n)/);
1967
- if (!linesAndNewlines[linesAndNewlines.length - 1]) {
1968
- linesAndNewlines.pop();
1969
- }
1970
- for (var i = 0; i < linesAndNewlines.length; i++) {
1971
- var line = linesAndNewlines[i];
1972
- if (i % 2 && !this.options.newlineIsToken) {
1973
- retLines[retLines.length - 1] += line;
1974
- } else {
1975
- if (this.options.ignoreWhitespace) {
1976
- line = line.trim();
1977
- }
1978
- retLines.push(line);
1979
- }
2188
+ function resolveBaseRange(unit, offsets, contentLength) {
2189
+ const startIndex = unit.baseCount === 0 ? unit.baseStart : unit.baseStart - 1;
2190
+ const endIndex = unit.baseCount === 0 ? startIndex : startIndex + unit.baseCount;
2191
+ const start = offsets[startIndex];
2192
+ const end = offsets[endIndex];
2193
+ if (start === void 0 || end === void 0 || start > contentLength || end > contentLength) {
2194
+ throw new Error(`Change unit ${unit.id} has an invalid base range.`);
1980
2195
  }
1981
- return retLines;
1982
- };
1983
- var sentenceDiff = new Diff();
1984
- sentenceDiff.tokenize = function(value) {
1985
- return value.split(/(\S.+?[.!?])(?=\s+|$)/);
1986
- };
1987
- var cssDiff = new Diff();
1988
- cssDiff.tokenize = function(value) {
1989
- return value.split(/([{}:;,]|\s+)/);
1990
- };
1991
- function _typeof(obj) {
1992
- "@babel/helpers - typeof";
1993
- if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
1994
- _typeof = function(obj2) {
1995
- return typeof obj2;
1996
- };
1997
- } else {
1998
- _typeof = function(obj2) {
1999
- return obj2 && typeof Symbol === "function" && obj2.constructor === Symbol && obj2 !== Symbol.prototype ? "symbol" : typeof obj2;
2000
- };
2196
+ return { start, end };
2197
+ }
2198
+ function isLosslessTextBlob(content) {
2199
+ if (content.includes(0)) return false;
2200
+ const decoded = content.toString("utf8");
2201
+ return Buffer.from(decoded, "utf8").equals(content);
2202
+ }
2203
+ function readGitBlob(repoRoot, oid, relativePath) {
2204
+ if (!relativePath) return Buffer.alloc(0);
2205
+ try {
2206
+ return (0, import_child_process3.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
2207
+ cwd: repoRoot,
2208
+ encoding: "buffer",
2209
+ maxBuffer: 16 * 1024 * 1024,
2210
+ stdio: ["ignore", "pipe", "pipe"]
2211
+ });
2212
+ } catch {
2213
+ return null;
2001
2214
  }
2002
- return _typeof(obj);
2003
2215
  }
2004
- var objectPrototypeToString = Object.prototype.toString;
2005
- var jsonDiff = new Diff();
2006
- jsonDiff.useLongestToken = true;
2007
- jsonDiff.tokenize = lineDiff.tokenize;
2008
- jsonDiff.castInput = function(value) {
2009
- var _this$options = this.options, undefinedReplacement = _this$options.undefinedReplacement, _this$options$stringi = _this$options.stringifyReplacer, stringifyReplacer = _this$options$stringi === void 0 ? function(k, v) {
2010
- return typeof v === "undefined" ? undefinedReplacement : v;
2011
- } : _this$options$stringi;
2012
- return typeof value === "string" ? value : JSON.stringify(canonicalize(value, null, null, stringifyReplacer), stringifyReplacer, " ");
2013
- };
2014
- jsonDiff.equals = function(left, right) {
2015
- return Diff.prototype.equals.call(jsonDiff, left.replace(/,([\r\n])/g, "$1"), right.replace(/,([\r\n])/g, "$1"));
2016
- };
2017
- function canonicalize(obj, stack, replacementStack, replacer, key) {
2018
- stack = stack || [];
2019
- replacementStack = replacementStack || [];
2020
- if (replacer) {
2021
- obj = replacer(key, obj);
2022
- }
2023
- var i;
2024
- for (i = 0; i < stack.length; i += 1) {
2025
- if (stack[i] === obj) {
2026
- return replacementStack[i];
2216
+ function runGit(repoRoot, args) {
2217
+ return (0, import_child_process3.execFileSync)("git", args, {
2218
+ cwd: repoRoot,
2219
+ encoding: "utf8",
2220
+ maxBuffer: 16 * 1024 * 1024,
2221
+ stdio: ["ignore", "pipe", "pipe"]
2222
+ });
2223
+ }
2224
+
2225
+ // src/deconstructedChange.ts
2226
+ function buildDeconstructedScene(startPath, scene, options = {}) {
2227
+ return compileDeconstructedScene(buildChangeInventory(startPath, {
2228
+ headRef: options.headRef || scene.target,
2229
+ baseRef: options.baseRef || scene.base
2230
+ }), scene);
2231
+ }
2232
+ function compileDeconstructedScene(inventory, scene) {
2233
+ const ownership = /* @__PURE__ */ new Map();
2234
+ const excludedByFile = /* @__PURE__ */ new Map();
2235
+ const cumulativeByFile = /* @__PURE__ */ new Map();
2236
+ const wholeFileExclusions = /* @__PURE__ */ new Map();
2237
+ const excludedFiles = [];
2238
+ for (const exclusion of scene.exclusions || []) {
2239
+ const file = requireInventoryFile(inventory, exclusion.file, "Exclusion");
2240
+ if (!exclusion.hunks) {
2241
+ if (wholeFileExclusions.has(file)) {
2242
+ throw new Error(`File is excluded more than once: ${exclusion.file}`);
2243
+ }
2244
+ wholeFileExclusions.set(file, exclusion.reason);
2245
+ excludedFiles.push({ path: file.path, reason: exclusion.reason });
2027
2246
  }
2247
+ const ids = exclusion.hunks || file.units.map((unit) => unit.id);
2248
+ if (file.material !== "text" && exclusion.hunks) {
2249
+ throw new Error(`Unsupported file ${exclusion.file} must be excluded as a whole file.`);
2250
+ }
2251
+ claimHunks(file, ids, `exclusion for ${exclusion.file}`, ownership);
2252
+ const excluded = excludedByFile.get(file) || /* @__PURE__ */ new Set();
2253
+ ids.forEach((id) => excluded.add(id));
2254
+ excludedByFile.set(file, excluded);
2028
2255
  }
2029
- var canonicalizedObj;
2030
- if ("[object Array]" === objectPrototypeToString.call(obj)) {
2031
- stack.push(obj);
2032
- canonicalizedObj = new Array(obj.length);
2033
- replacementStack.push(canonicalizedObj);
2034
- for (i = 0; i < obj.length; i += 1) {
2035
- canonicalizedObj[i] = canonicalize(obj[i], stack, replacementStack, replacer, key);
2256
+ const stageClaims = scene.stages.map((stage) => {
2257
+ const claims = [];
2258
+ for (const change of stage.changes) {
2259
+ const file = requireInventoryFile(inventory, change.file, `Stage ${stage.id}`);
2260
+ if (file.material !== "text") {
2261
+ throw new Error(`Stage ${stage.id} cannot assign unsupported file: ${change.file}`);
2262
+ }
2263
+ if (file.changeKind === "renamed") {
2264
+ throw new Error(`Stage ${stage.id} cannot assign renamed file ${change.file} until path-transition units are supported.`);
2265
+ }
2266
+ claimHunks(file, change.hunks, `stage ${stage.id}`, ownership);
2267
+ claims.push({ file, ids: [...change.hunks] });
2268
+ }
2269
+ return { stage, claims };
2270
+ });
2271
+ for (const file of inventory.files) {
2272
+ const wholeFileExcluded = wholeFileExclusions.has(file);
2273
+ if (file.material !== "text" || file.changeKind === "renamed" || file.units.length === 0) {
2274
+ if (!wholeFileExcluded) {
2275
+ 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";
2276
+ throw new Error(`Deconstructed scene ${scene.id} must exclude ${file.path} as a whole file because ${reason}.`);
2277
+ }
2278
+ continue;
2279
+ }
2280
+ for (const unit of file.units) {
2281
+ if (!ownership.has(unitKey(file, unit.id))) {
2282
+ throw new Error(`Unassigned change unit in ${file.path}: ${unit.id}`);
2283
+ }
2036
2284
  }
2037
- stack.pop();
2038
- replacementStack.pop();
2039
- return canonicalizedObj;
2040
2285
  }
2041
- if (obj && obj.toJSON) {
2042
- obj = obj.toJSON();
2286
+ for (const file of inventory.files) {
2287
+ cumulativeByFile.set(file, new Set(excludedByFile.get(file) || []));
2043
2288
  }
2044
- if (_typeof(obj) === "object" && obj !== null) {
2045
- stack.push(obj);
2046
- canonicalizedObj = {};
2047
- replacementStack.push(canonicalizedObj);
2048
- var sortedKeys = [], _key;
2049
- for (_key in obj) {
2050
- if (obj.hasOwnProperty(_key)) {
2051
- sortedKeys.push(_key);
2289
+ const materializableFiles = inventory.files.filter((file) => file.material === "text" && file.changeKind !== "renamed" && file.baseContent !== void 0 && file.headContent !== void 0);
2290
+ const baselineFiles = materializableFiles.map((file) => buildFileState(
2291
+ file,
2292
+ cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
2293
+ wholeFileExclusions.has(file)
2294
+ ));
2295
+ const stages = [];
2296
+ for (const { stage, claims } of stageClaims) {
2297
+ const introducedHunks = [];
2298
+ const introducedFiles = [];
2299
+ const introducedChanges = [];
2300
+ for (const claim of claims) {
2301
+ const cumulative = cumulativeByFile.get(claim.file) || /* @__PURE__ */ new Set();
2302
+ claim.ids.forEach((id) => {
2303
+ cumulative.add(id);
2304
+ introducedHunks.push(id);
2305
+ });
2306
+ cumulativeByFile.set(claim.file, cumulative);
2307
+ if (!introducedFiles.includes(claim.file.path)) introducedFiles.push(claim.file.path);
2308
+ const introducedChange = introducedChanges.find((change) => change.file === claim.file.path);
2309
+ if (introducedChange) {
2310
+ introducedChange.hunks.push(...claim.ids);
2311
+ } else {
2312
+ introducedChanges.push({ file: claim.file.path, hunks: [...claim.ids] });
2052
2313
  }
2053
2314
  }
2054
- sortedKeys.sort();
2055
- for (i = 0; i < sortedKeys.length; i += 1) {
2056
- _key = sortedKeys[i];
2057
- canonicalizedObj[_key] = canonicalize(obj[_key], stack, replacementStack, replacer, _key);
2315
+ stages.push({
2316
+ id: stage.id,
2317
+ title: stage.title,
2318
+ narration: stage.narration,
2319
+ introducedHunks,
2320
+ introducedFiles,
2321
+ introducedChanges,
2322
+ cumulativeHunks: [...cumulativeByFile.values()].flatMap((ids) => [...ids]),
2323
+ files: materializableFiles.map((file) => buildFileState(
2324
+ file,
2325
+ cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
2326
+ wholeFileExclusions.has(file)
2327
+ ))
2328
+ });
2329
+ }
2330
+ for (const file of materializableFiles) {
2331
+ const finalState = buildFileState(
2332
+ file,
2333
+ cumulativeByFile.get(file) || /* @__PURE__ */ new Set(),
2334
+ wholeFileExclusions.has(file)
2335
+ );
2336
+ const expectedExists = file.changeKind !== "deleted";
2337
+ if (finalState.exists !== expectedExists || finalState.content !== file.headContent) {
2338
+ throw new Error(`Final deconstructed state does not match the target for ${file.path}.`);
2058
2339
  }
2059
- stack.pop();
2060
- replacementStack.pop();
2061
- } else {
2062
- canonicalizedObj = obj;
2063
2340
  }
2064
- return canonicalizedObj;
2341
+ return {
2342
+ id: scene.id,
2343
+ title: scene.title,
2344
+ baseOid: inventory.range.baseOid,
2345
+ targetOid: inventory.range.headOid,
2346
+ baselineFiles,
2347
+ excludedHunks: [...excludedByFile.values()].flatMap((ids) => [...ids]),
2348
+ excludedFiles,
2349
+ stages
2350
+ };
2065
2351
  }
2066
- var arrayDiff = new Diff();
2067
- arrayDiff.tokenize = function(value) {
2068
- return value.slice();
2069
- };
2070
- arrayDiff.join = arrayDiff.removeEmpty = function(value) {
2071
- return value;
2072
- };
2073
- function diffArrays(oldArr, newArr, callback) {
2074
- return arrayDiff.diff(oldArr, newArr, callback);
2352
+ function requireInventoryFile(inventory, path, owner) {
2353
+ const matches = inventory.files.filter((file) => file.path === path || file.previousPath === path);
2354
+ if (matches.length === 0) throw new Error(`${owner} references a file outside the change inventory: ${path}`);
2355
+ if (matches.length > 1) throw new Error(`${owner} references an ambiguous changed path: ${path}`);
2356
+ return matches[0];
2357
+ }
2358
+ function claimHunks(file, ids, owner, ownership) {
2359
+ const known = new Set(file.units.map((unit) => unit.id));
2360
+ for (const id of ids) {
2361
+ if (!known.has(id)) throw new Error(`${owner} references an unknown hunk in ${file.path}: ${id}`);
2362
+ const key = unitKey(file, id);
2363
+ const previousOwner = ownership.get(key);
2364
+ if (previousOwner) throw new Error(`Change unit ${id} in ${file.path} is assigned to both ${previousOwner} and ${owner}.`);
2365
+ ownership.set(key, owner);
2366
+ }
2367
+ }
2368
+ function unitKey(file, id) {
2369
+ return `${file.previousPath || ""}\0${file.path}\0${id}`;
2370
+ }
2371
+ function buildFileState(file, selected, wholeFileExcluded) {
2372
+ if (file.baseContent === void 0) throw new Error(`Missing base content for ${file.path}.`);
2373
+ const content = materializeChangeUnits(file.baseContent, file.units, selected);
2374
+ let exists = true;
2375
+ if (file.changeKind === "added") exists = wholeFileExcluded || selected.size > 0;
2376
+ if (file.changeKind === "deleted") exists = !(wholeFileExcluded || selected.size === file.units.length);
2377
+ return {
2378
+ path: file.path,
2379
+ previousPath: file.previousPath,
2380
+ changeKind: file.changeKind,
2381
+ exists,
2382
+ content: exists ? content : "",
2383
+ appliedHunks: [...selected]
2384
+ };
2075
2385
  }
2076
2386
 
2077
2387
  // src/diffEngine.ts
@@ -2104,17 +2414,10 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
2104
2414
  model.formattingOnly = true;
2105
2415
  return model;
2106
2416
  }
2107
- const interned = internDiffLines(leftLines, rightLines);
2108
- const internedChanges = diffArrays(interned.left, interned.right, {
2109
- timeout: options.timeoutMs ?? DIFF_TIMEOUT_MS
2110
- });
2111
- if (!internedChanges) {
2417
+ const changes = diffAnchoredLines(leftLines, rightLines, options.timeoutMs ?? DIFF_TIMEOUT_MS);
2418
+ if (!changes) {
2112
2419
  return buildConservativeTwoWayDiffModel(leftLines, rightLines);
2113
2420
  }
2114
- const changes = internedChanges.map((change) => ({
2115
- ...change,
2116
- value: change.value.map((lineId) => interned.lines[lineId])
2117
- }));
2118
2421
  const rows = [];
2119
2422
  const renderedLeftLines = [];
2120
2423
  const renderedRightLines = [];
@@ -2229,6 +2532,54 @@ function mergeTouchingBlocks(blocks) {
2229
2532
  }
2230
2533
  return merged;
2231
2534
  }
2535
+ function diffAnchoredLines(leftLines, rightLines, timeoutMs) {
2536
+ const deadline = Date.now() + timeoutMs;
2537
+ const namedLines = (lines) => lines.flatMap((line, index) => {
2538
+ const key = normalizeMatchingContent(line);
2539
+ return isInformativeLine(key) ? [{ key, index }] : [];
2540
+ });
2541
+ const leftNames = namedLines(leftLines);
2542
+ const rightNames = namedLines(rightLines);
2543
+ const namedChanges = diffArrays(leftNames.map((line) => line.key), rightNames.map((line) => line.key), {
2544
+ timeout: deadline - Date.now()
2545
+ });
2546
+ if (!namedChanges) return void 0;
2547
+ const changes = [];
2548
+ let leftStart = 0;
2549
+ let rightStart = 0;
2550
+ const appendGap = (leftEnd, rightEnd) => {
2551
+ if (leftStart === leftEnd && rightStart === rightEnd) return true;
2552
+ const interned = internDiffLines(leftLines.slice(leftStart, leftEnd), rightLines.slice(rightStart, rightEnd));
2553
+ const gap = diffArrays(interned.left, interned.right, { timeout: deadline - Date.now() });
2554
+ if (!gap) return false;
2555
+ changes.push(...gap.map((change) => ({ ...change, value: change.value.map((id) => interned.lines[id]) })));
2556
+ return true;
2557
+ };
2558
+ let leftName = 0;
2559
+ let rightName = 0;
2560
+ for (const change of namedChanges) {
2561
+ if (change.removed) {
2562
+ leftName += change.value.length;
2563
+ continue;
2564
+ }
2565
+ if (change.added) {
2566
+ rightName += change.value.length;
2567
+ continue;
2568
+ }
2569
+ for (let index = 0; index < change.value.length; index++) {
2570
+ const leftIndex = leftNames[leftName++].index;
2571
+ const rightIndex = rightNames[rightName++].index;
2572
+ if (!appendGap(leftIndex, rightIndex)) return void 0;
2573
+ const left = leftLines[leftIndex];
2574
+ const right = rightLines[rightIndex];
2575
+ if (left === right) changes.push({ value: [left] });
2576
+ else changes.push({ removed: true, value: [left] }, { added: true, value: [right] });
2577
+ leftStart = leftIndex + 1;
2578
+ rightStart = rightIndex + 1;
2579
+ }
2580
+ }
2581
+ return appendGap(leftLines.length, rightLines.length) ? changes : void 0;
2582
+ }
2232
2583
  function internDiffLines(leftLines, rightLines) {
2233
2584
  const lineIds = /* @__PURE__ */ new Map();
2234
2585
  const lines = [];
@@ -3209,8 +3560,8 @@ function buildInlineSegments(leftContent, rightContent) {
3209
3560
  }
3210
3561
  }
3211
3562
  return {
3212
- leftSegments,
3213
- rightSegments,
3563
+ leftSegments: normalizeIndentationHighlights(leftSegments),
3564
+ rightSegments: normalizeIndentationHighlights(rightSegments),
3214
3565
  hasInlineChanges
3215
3566
  };
3216
3567
  }
@@ -3244,7 +3595,40 @@ function buildWhitespaceOnlySegments(left, right) {
3244
3595
  append(leftSegments, "context", part.slice(part.length - suffix));
3245
3596
  append(rightSegments, "context", other.slice(other.length - suffix));
3246
3597
  });
3247
- return { leftSegments, rightSegments };
3598
+ return {
3599
+ leftSegments: normalizeIndentationHighlights(leftSegments),
3600
+ rightSegments: normalizeIndentationHighlights(rightSegments)
3601
+ };
3602
+ }
3603
+ function normalizeIndentationHighlights(segments) {
3604
+ const result = [];
3605
+ splitSegmentsIntoLines(segments).forEach((line, index) => {
3606
+ if (index > 0) result.push({ kind: "context", text: "\n", emphasis: false });
3607
+ const content = line.map((segment) => segment.text).join("");
3608
+ const indentLength = content.match(/^[\t ]*/u)[0].length;
3609
+ let offset = 0;
3610
+ let emphasized = 0;
3611
+ let kind = "context";
3612
+ for (const segment of line) {
3613
+ if (offset >= indentLength) break;
3614
+ if (segment.emphasis) {
3615
+ emphasized += Math.min(segment.text.length, indentLength - offset);
3616
+ kind = segment.kind;
3617
+ }
3618
+ offset += segment.text.length;
3619
+ }
3620
+ if (emphasized) result.push({ kind, text: content.slice(0, emphasized), emphasis: true });
3621
+ if (indentLength > emphasized) {
3622
+ result.push({ kind: "context", text: content.slice(emphasized, indentLength), emphasis: false });
3623
+ }
3624
+ offset = 0;
3625
+ for (const segment of line) {
3626
+ const start = Math.max(0, indentLength - offset);
3627
+ if (start < segment.text.length) result.push({ ...segment, text: segment.text.slice(start) });
3628
+ offset += segment.text.length;
3629
+ }
3630
+ });
3631
+ return result;
3248
3632
  }
3249
3633
  function splitSegmentsIntoLines(segments) {
3250
3634
  const lines = [[]];
@@ -3380,8 +3764,8 @@ var LOCKFILES = /* @__PURE__ */ new Set([
3380
3764
  ]);
3381
3765
  var MAX_FAN_IN_FILES = 250;
3382
3766
  var MAX_FAN_IN_TEXT_BYTES = 64 * 1024;
3383
- function classifyChangeFileRole(filePath) {
3384
- const lower = filePath.toLowerCase();
3767
+ function classifyChangeFileRole(filePath2) {
3768
+ const lower = filePath2.toLowerCase();
3385
3769
  const fileName = lower.split("/").pop() || lower;
3386
3770
  if (/(^|\/)(dist|build|generated|vendor)(\/|$)/.test(lower) || /(?:\.min\.js|\.map)$/.test(lower)) return "generated";
3387
3771
  if (/(^|\/)(tests?|__tests__)(\/|$)/.test(lower) || /(^|\/)test_[^/]+$/.test(lower) || /\.(test|spec)\.[^/]+$/.test(lower)) return "test";
@@ -3487,7 +3871,7 @@ function isTestForFocus(testPath, focusPaths) {
3487
3871
  }
3488
3872
 
3489
3873
  // src/changeTourContext.ts
3490
- var import_child_process3 = require("child_process");
3874
+ var import_child_process4 = require("child_process");
3491
3875
  var CHANGE_TOUR_CONTEXT_VERSION = 1;
3492
3876
  var DEFAULT_MAX_PATCH_BYTES = 128 * 1024;
3493
3877
  var DEFAULT_MAX_TOTAL_PATCH_BYTES = 2 * 1024 * 1024;
@@ -3517,6 +3901,7 @@ function buildChangeTourContext(startPath, options = {}) {
3517
3901
  for (const file of files) filesByRole[file.role] += 1;
3518
3902
  return {
3519
3903
  version: CHANGE_TOUR_CONTEXT_VERSION,
3904
+ ...options.correspondencePath ? { correspondence: loadChangeCorrespondence(range.repoRoot, options.correspondencePath, { baseOid: range.mergeBaseOid, headOid: range.headOid }) } : {},
3520
3905
  generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
3521
3906
  range: {
3522
3907
  baseRef: range.baseRef,
@@ -3587,7 +3972,7 @@ function readLineStats(repoRoot, baseOid, headOid, paths) {
3587
3972
  function readPatch(repoRoot, baseOid, headOid, paths, binary, maxPatchBytes) {
3588
3973
  if (binary) return { included: false, content: "", bytes: 0, reason: "binary" };
3589
3974
  try {
3590
- const buffer = (0, import_child_process3.execFileSync)("git", [
3975
+ const buffer = (0, import_child_process4.execFileSync)("git", [
3591
3976
  "diff",
3592
3977
  "--no-ext-diff",
3593
3978
  "--no-color",
@@ -3648,7 +4033,7 @@ function findSymbolHints(content, ranges) {
3648
4033
  }
3649
4034
  function readGitText(repoRoot, oid, relativePath, maxBytes) {
3650
4035
  try {
3651
- const content = (0, import_child_process3.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
4036
+ const content = (0, import_child_process4.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
3652
4037
  cwd: repoRoot,
3653
4038
  encoding: "buffer",
3654
4039
  maxBuffer: maxBytes + 1,
@@ -3661,7 +4046,7 @@ function readGitText(repoRoot, oid, relativePath, maxBytes) {
3661
4046
  }
3662
4047
  }
3663
4048
  function runGit2(repoRoot, args) {
3664
- return (0, import_child_process3.execFileSync)("git", args, {
4049
+ return (0, import_child_process4.execFileSync)("git", args, {
3665
4050
  cwd: repoRoot,
3666
4051
  encoding: "utf8",
3667
4052
  maxBuffer: MAX_CAPTURE_BYTES,
@@ -3878,10 +4263,21 @@ function buildChangeTourManifest(startPath, options = {}) {
3878
4263
  }
3879
4264
  function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot, imageRevisions) {
3880
4265
  var _a, _b;
4266
+ const correspondence = source.correspondence ? (() => {
4267
+ const file = (0, import_fs2.realpathSync)((0, import_path.resolve)(repoRoot, source.correspondence));
4268
+ const name = (0, import_path.relative)((0, import_fs2.realpathSync)(repoRoot), file);
4269
+ if ((0, import_path.isAbsolute)(name) || name === ".." || name.startsWith(`..${import_path.sep}`)) throw new Error("Correspondence artifact must be inside the repository.");
4270
+ return loadChangeCorrespondence(repoRoot, file, { baseOid: imageRevisions.base, headOid: imageRevisions.head });
4271
+ })() : void 0;
4272
+ const compileSymbol = (step, focus) => {
4273
+ if (!step.symbol) return void 0;
4274
+ if (!correspondence) throw new Error(`Step ${step.id} requires a correspondence artifact.`);
4275
+ return compileTourSymbol(correspondence, step.symbol, focus);
4276
+ };
3881
4277
  const available = new Map(defaultScenes.map((scene) => [scene.path, scene]));
3882
4278
  const resolvedAnchors = /* @__PURE__ */ new Map();
3883
4279
  for (const [id, anchor] of Object.entries(source.anchors)) {
3884
- const file = available.get(anchor.file);
4280
+ const file = available.get(anchor.file) || (anchor.revision === "base" ? defaultScenes.find((scene) => scene.previousPath === anchor.file) : void 0);
3885
4281
  if (!file) {
3886
4282
  throw new Error(`Anchor ${id} references a file outside the text change range: ${anchor.file}`);
3887
4283
  }
@@ -3918,7 +4314,8 @@ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot,
3918
4314
  available,
3919
4315
  resolvedAnchors,
3920
4316
  connections,
3921
- (image) => compileTourImage(repoRoot, image, imageRevisions[image.revision])
4317
+ (image) => compileTourImage(repoRoot, image, imageRevisions[image.revision]),
4318
+ compileSymbol
3922
4319
  );
3923
4320
  finalScenes.push(finalScene);
3924
4321
  finalSceneIds.push(finalScene.id);
@@ -3947,7 +4344,8 @@ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot,
3947
4344
  available,
3948
4345
  resolvedAnchors,
3949
4346
  connections,
3950
- (image) => compileTourImage(repoRoot, image, imageRevisions[image.revision])
4347
+ (image) => compileTourImage(repoRoot, image, imageRevisions[image.revision]),
4348
+ compileSymbol
3951
4349
  );
3952
4350
  scenes.push(scene);
3953
4351
  sceneIds.push(scene.id);
@@ -4052,7 +4450,7 @@ function compileSceneOverview(overview, includeComparison = true) {
4052
4450
  ...includeComparison && overview.comparison ? { comparison: { ...overview.comparison } } : {}
4053
4451
  };
4054
4452
  }
4055
- function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections, resolveImage) {
4453
+ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections, resolveImage, compileSymbol) {
4056
4454
  const overview = compileSceneOverview(authoredScene.overview, false);
4057
4455
  return {
4058
4456
  id: authoredScene.id,
@@ -4065,7 +4463,7 @@ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors
4065
4463
  ...overview ? { overview } : {},
4066
4464
  steps: authoredScene.steps.map((step) => {
4067
4465
  const focus = requireResolvedAnchor(resolvedAnchors, step.focus);
4068
- const diff2 = available.get(focus.path);
4466
+ const diff2 = available.get(focus.path) || (focus.revision === "base" ? [...available.values()].find((scene) => scene.previousPath === focus.path) : void 0);
4069
4467
  if (!diff2) throw new Error(`Step ${step.id} has no text diff for ${focus.path}.`);
4070
4468
  return {
4071
4469
  id: step.id,
@@ -4075,6 +4473,7 @@ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors
4075
4473
  requirement: step.requirement,
4076
4474
  ...step.image ? { image: resolveImage(step.image) } : {},
4077
4475
  focus,
4476
+ ...step.symbol ? { symbol: compileSymbol(step, focus) } : {},
4078
4477
  connection: step.connection ? connections.get(step.connection) : void 0,
4079
4478
  diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
4080
4479
  };
@@ -4094,20 +4493,20 @@ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, a
4094
4493
  stageId: stage.id
4095
4494
  }))];
4096
4495
  const materializablePaths = new Set(compiled.baselineFiles.map((file) => file.path));
4097
- const includedPaths = availableFilePaths.length > 0 ? availableFilePaths.filter((filePath) => materializablePaths.has(filePath)).sort() : [...materializablePaths].sort();
4098
- const missingIntroducedPath = compiled.stages.flatMap((stage) => stage.introducedFiles).find((filePath) => !includedPaths.includes(filePath));
4496
+ const includedPaths = availableFilePaths.length > 0 ? availableFilePaths.filter((filePath2) => materializablePaths.has(filePath2)).sort() : [...materializablePaths].sort();
4497
+ const missingIntroducedPath = compiled.stages.flatMap((stage) => stage.introducedFiles).find((filePath2) => !includedPaths.includes(filePath2));
4099
4498
  if (missingIntroducedPath) {
4100
4499
  throw new Error(`Deconstructed tour file is not renderable: ${missingIntroducedPath}`);
4101
4500
  }
4102
4501
  const introducedPaths = new Set(compiled.stages.flatMap((stage) => stage.introducedFiles));
4103
4502
  let totalContentBytes = 0;
4104
- const files = includedPaths.flatMap((filePath) => {
4503
+ const files = includedPaths.flatMap((filePath2) => {
4105
4504
  const states = [
4106
- compiled.baselineFiles.find((file) => file.path === filePath),
4107
- ...compiled.stages.map((stage) => stage.files.find((file) => file.path === filePath))
4505
+ compiled.baselineFiles.find((file) => file.path === filePath2),
4506
+ ...compiled.stages.map((stage) => stage.files.find((file) => file.path === filePath2))
4108
4507
  ];
4109
4508
  if (states.some((state) => !state)) {
4110
- throw new Error(`Deconstructed scene ${source.id} is missing virtual state for ${filePath}.`);
4509
+ throw new Error(`Deconstructed scene ${source.id} is missing virtual state for ${filePath2}.`);
4111
4510
  }
4112
4511
  const fileContentBytes = states.reduce((total, state) => total + Buffer.byteLength((state == null ? void 0 : state.content) || "", "utf8"), 0);
4113
4512
  const exceedsFileLimit = states.some((state) => {
@@ -4116,20 +4515,20 @@ function buildDeconstructedManifestScene(repoRoot, source, baseRef, targetRef, a
4116
4515
  });
4117
4516
  const exceedsSceneLimit = totalContentBytes + fileContentBytes > DEFAULT_MAX_STACK_CONTENT_BYTES;
4118
4517
  if (exceedsFileLimit || exceedsSceneLimit) {
4119
- if (introducedPaths.has(filePath)) {
4120
- throw new Error(exceedsFileLimit ? `Deconstructed tour file is too large to present: ${filePath}` : `Deconstructed tour content exceeds ${DEFAULT_MAX_STACK_CONTENT_BYTES} bytes.`);
4518
+ if (introducedPaths.has(filePath2)) {
4519
+ throw new Error(exceedsFileLimit ? `Deconstructed tour file is too large to present: ${filePath2}` : `Deconstructed tour content exceeds ${DEFAULT_MAX_STACK_CONTENT_BYTES} bytes.`);
4121
4520
  }
4122
4521
  return [];
4123
4522
  }
4124
4523
  totalContentBytes += fileContentBytes;
4125
4524
  return [{
4126
- path: filePath,
4525
+ path: filePath2,
4127
4526
  panels: states.map((state, index) => {
4128
- if (!state) throw new Error(`Missing virtual state for ${filePath}.`);
4527
+ if (!state) throw new Error(`Missing virtual state for ${filePath2}.`);
4129
4528
  return {
4130
4529
  id: panels[index].id,
4131
- label: `${panels[index].label} / ${filePath}${state.exists ? "" : " (absent)"}`,
4132
- path: state.exists ? filePath : void 0,
4530
+ label: `${panels[index].label} / ${filePath2}${state.exists ? "" : " (absent)"}`,
4531
+ path: state.exists ? filePath2 : void 0,
4133
4532
  content: state.content,
4134
4533
  exists: state.exists
4135
4534
  };
@@ -4223,7 +4622,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
4223
4622
  oid: runGitText(repoRoot, ["rev-parse", "--verify", `${entry.ref}^{commit}`]),
4224
4623
  label: entry.label || entry.ref
4225
4624
  }));
4226
- const adjacentChanges = stack.slice(0, -1).map((entry, index) => parseNameStatusZ((0, import_child_process4.execFileSync)("git", [
4625
+ const adjacentChanges = stack.slice(0, -1).map((entry, index) => parseNameStatusZ((0, import_child_process5.execFileSync)("git", [
4227
4626
  "diff",
4228
4627
  "--name-status",
4229
4628
  "-z",
@@ -4247,8 +4646,8 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
4247
4646
  ...source.steps.map((step) => step.file)
4248
4647
  ]);
4249
4648
  let totalContentBytes = 0;
4250
- const files = filePaths.flatMap((filePath) => {
4251
- const aliases = /* @__PURE__ */ new Set([filePath]);
4649
+ const files = filePaths.flatMap((filePath2) => {
4650
+ const aliases = /* @__PURE__ */ new Set([filePath2]);
4252
4651
  for (const changes of [...adjacentChanges].reverse()) {
4253
4652
  for (const change of changes) {
4254
4653
  if (aliases.has(change.path) || change.previousPath && aliases.has(change.previousPath)) {
@@ -4261,15 +4660,15 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
4261
4660
  const panels = [];
4262
4661
  for (const entry of stack) {
4263
4662
  const candidates = [...aliases].filter((candidate) => gitBlobExists(repoRoot, entry.oid, candidate));
4264
- if (candidates.length > 1) throw new Error(`Ambiguous revision paths for ${filePath} at ${entry.ref}: ${candidates.join(", ")}`);
4663
+ if (candidates.length > 1) throw new Error(`Ambiguous revision paths for ${filePath2} at ${entry.ref}: ${candidates.join(", ")}`);
4265
4664
  const resolvedPath = candidates[0];
4266
4665
  if (!resolvedPath) {
4267
- panels.push({ id: entry.id, label: `${entry.label} / ${filePath} (missing)`, content: "", exists: false });
4666
+ panels.push({ id: entry.id, label: `${entry.label} / ${filePath2} (missing)`, content: "", exists: false });
4268
4667
  continue;
4269
4668
  }
4270
4669
  const content = readGitText2(repoRoot, entry.oid, resolvedPath, DEFAULT_MAX_TOUR_FILE_BYTES, DEFAULT_MAX_TOUR_LINE_BYTES);
4271
4670
  if (content.kind !== "text") {
4272
- if (requiredFilePaths.has(filePath)) {
4671
+ if (requiredFilePaths.has(filePath2)) {
4273
4672
  throw new Error(`Stacked tour file is binary or too large: ${resolvedPath}`);
4274
4673
  }
4275
4674
  return [];
@@ -4287,7 +4686,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
4287
4686
  if (totalContentBytes > DEFAULT_MAX_STACK_CONTENT_BYTES) {
4288
4687
  throw new Error(`Stacked tour content exceeds ${DEFAULT_MAX_STACK_CONTENT_BYTES} bytes.`);
4289
4688
  }
4290
- return [{ path: filePath, panels }];
4689
+ return [{ path: filePath2, panels }];
4291
4690
  });
4292
4691
  if (files.length === 0) throw new Error(`Stacked scene ${source.id} has no materializable text files.`);
4293
4692
  const stackIds = stack.map((entry) => entry.id);
@@ -4327,7 +4726,7 @@ function buildStackedScene(repoRoot, source, availableFilePaths) {
4327
4726
  }
4328
4727
  function gitBlobExists(repoRoot, oid, relativePath) {
4329
4728
  try {
4330
- (0, import_child_process4.execFileSync)("git", ["cat-file", "-e", `${oid}:${relativePath}`], {
4729
+ (0, import_child_process5.execFileSync)("git", ["cat-file", "-e", `${oid}:${relativePath}`], {
4331
4730
  cwd: repoRoot,
4332
4731
  stdio: ["ignore", "ignore", "ignore"]
4333
4732
  });
@@ -4337,7 +4736,7 @@ function gitBlobExists(repoRoot, oid, relativePath) {
4337
4736
  }
4338
4737
  }
4339
4738
  function runGitText(repoRoot, args) {
4340
- return (0, import_child_process4.execFileSync)("git", args, {
4739
+ return (0, import_child_process5.execFileSync)("git", args, {
4341
4740
  cwd: repoRoot,
4342
4741
  encoding: "utf8",
4343
4742
  stdio: ["ignore", "pipe", "pipe"]
@@ -4420,9 +4819,9 @@ function buildChapters(records, sceneIdByPath) {
4420
4819
  sceneIds: records.filter(({ chapter }) => chapter.id === definition.id).map(({ scene }) => sceneIdByPath.get(scene.path)).filter((sceneId) => Boolean(sceneId))
4421
4820
  }));
4422
4821
  }
4423
- function chapterForPath(filePath) {
4424
- const lower = filePath.toLowerCase();
4425
- const role = classifyChangeFileRole(filePath);
4822
+ function chapterForPath(filePath2) {
4823
+ const lower = filePath2.toLowerCase();
4824
+ const role = classifyChangeFileRole(filePath2);
4426
4825
  if (role === "documentation" || lower.includes("architecture")) {
4427
4826
  return CHAPTERS.context;
4428
4827
  }
@@ -4492,9 +4891,9 @@ function readGitText2(repoRoot, oid, relativePath, maxBytes, maxLineBytes) {
4492
4891
  if (!relativePath) {
4493
4892
  return { kind: "text", content: "" };
4494
4893
  }
4495
- const size = Number((0, import_child_process4.execFileSync)("git", ["cat-file", "-s", `${oid}:${relativePath}`], { cwd: repoRoot, encoding: "utf8" }).trim());
4894
+ const size = Number((0, import_child_process5.execFileSync)("git", ["cat-file", "-s", `${oid}:${relativePath}`], { cwd: repoRoot, encoding: "utf8" }).trim());
4496
4895
  if (size > maxBytes) return { kind: "binary-or-large", content: "" };
4497
- const content = (0, import_child_process4.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
4896
+ const content = (0, import_child_process5.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
4498
4897
  cwd: repoRoot,
4499
4898
  encoding: "buffer",
4500
4899
  maxBuffer: Math.max(maxBytes + 1, DEFAULT_MAX_TOUR_FILE_BYTES + 1),
@@ -4514,15 +4913,15 @@ function hasLineLongerThan(content, maxLineBytes) {
4514
4913
  }
4515
4914
  return content.length - lineStart > maxLineBytes;
4516
4915
  }
4517
- function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
4518
- const output = (0, import_child_process4.execFileSync)("git", [
4916
+ function readGitLineStats(repoRoot, baseOid, headOid, filePath2, previousPath) {
4917
+ const output = (0, import_child_process5.execFileSync)("git", [
4519
4918
  "diff",
4520
4919
  "--numstat",
4521
4920
  "--find-renames",
4522
4921
  baseOid,
4523
4922
  headOid,
4524
4923
  "--",
4525
- ...[previousPath, filePath].filter((candidate) => Boolean(candidate))
4924
+ ...[previousPath, filePath2].filter((candidate) => Boolean(candidate))
4526
4925
  ], {
4527
4926
  cwd: repoRoot,
4528
4927
  encoding: "utf8",
@@ -4560,10 +4959,10 @@ function pinTourSource(source, repoRoot, resolved, baseOid, headOid) {
4560
4959
  return copy;
4561
4960
  }
4562
4961
  function compileTourImage(repoRoot, source, revision) {
4563
- const object = `${revision}:${source.file}`;
4962
+ const object2 = `${revision}:${source.file}`;
4564
4963
  let size;
4565
4964
  try {
4566
- size = Number((0, import_child_process4.execFileSync)("git", ["cat-file", "-s", object], {
4965
+ size = Number((0, import_child_process5.execFileSync)("git", ["cat-file", "-s", object2], {
4567
4966
  cwd: repoRoot,
4568
4967
  encoding: "utf8",
4569
4968
  stdio: ["ignore", "pipe", "pipe"]
@@ -4574,7 +4973,7 @@ function compileTourImage(repoRoot, source, revision) {
4574
4973
  if (!Number.isSafeInteger(size) || size < 33 || size > MAX_TOUR_IMAGE_BYTES) {
4575
4974
  throw new Error(`Image ${source.file} must be a PNG of at most 8 MiB.`);
4576
4975
  }
4577
- const bytes = (0, import_child_process4.execFileSync)("git", ["cat-file", "blob", object], { cwd: repoRoot, maxBuffer: MAX_TOUR_IMAGE_BYTES });
4976
+ const bytes = (0, import_child_process5.execFileSync)("git", ["cat-file", "blob", object2], { cwd: repoRoot, maxBuffer: MAX_TOUR_IMAGE_BYTES });
4578
4977
  validatePngHeader(bytes, source.file);
4579
4978
  return { path: source.file, revision, alt: source.alt, dataUrl: `data:image/png;base64,${bytes.toString("base64")}` };
4580
4979
  }