jeopi-hashline 16.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ start: begin_patch file_patch+ end_patch
2
+ begin_patch: "*** Begin Patch" LF
3
+ end_patch: "*** End Patch" LF?
4
+
5
+ file_patch: file_header hunk+
6
+ file_header: "[" filename "#" file_hash "]" LF
7
+ file_hash: /[0-9A-F]{4}/
8
+ filename: /[^#\r\n]+/
9
+
10
+ hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | remove_hunk | move_hunk
11
+ replace_hunk: replace_anchor LF emit_op*
12
+ replace_block_hunk: replace_block_anchor LF emit_op+
13
+ insert_hunk: insert_anchor LF emit_op+
14
+ insert_block_hunk: insert_block_anchor LF emit_op+
15
+ delete_hunk: "DEL " header_range LF
16
+ delete_block_hunk: "DEL.BLK " LID LF
17
+ remove_hunk: "REM" LF
18
+ move_hunk: "MV " filename LF emit_op*
19
+ replace_anchor: "SWAP " header_range ":"
20
+ replace_block_anchor: "SWAP.BLK " LID ":"
21
+ insert_anchor: "INS." insert_pos ":"
22
+ insert_block_anchor: "INS.BLK.POST " LID ":"
23
+ insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL"
24
+ emit_op: "+" /(.*)/ LF
25
+
26
+ header_range: LID ".=" LID
27
+ LID: /[1-9]\d*/
28
+
29
+ %import common.LF
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from "./apply";
2
+ export * from "./block";
3
+ export * from "./diff-preview";
4
+ export * from "./format";
5
+ export * from "./fs";
6
+ export * from "./input";
7
+ export * from "./messages";
8
+ export * from "./mismatch";
9
+ export * from "./normalize";
10
+ export * from "./parser";
11
+ export * from "./patcher";
12
+ export * from "./prefixes";
13
+ export * from "./recovery";
14
+ export * from "./snapshots";
15
+ export * from "./stream";
16
+ export * from "./tokenizer";
17
+ export * from "./types";
package/src/input.ts ADDED
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Top-level patch parser. Splits an authored hashline input into a list of
3
+ * {@link PatchSection}s, each rooted at a `[PATH#HASH]` header, then exposes
4
+ * a {@link Patch} class that gives lazy access to the parsed edits per
5
+ * section.
6
+ *
7
+ * The splitter is purely lexical — it doesn't know whether a section's path
8
+ * actually exists. That's the patcher's job.
9
+ */
10
+ import * as path from "node:path";
11
+ import { applyEdits } from "./apply";
12
+ import { resolveBlockEdits } from "./block";
13
+ import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
14
+ import { parsePatch, parsePatchStreaming } from "./parser";
15
+ import { Tokenizer } from "./tokenizer";
16
+ import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types";
17
+
18
+ // Pure classification — single shared tokenizer is safe.
19
+ const TOKENIZER = new Tokenizer();
20
+
21
+ function unquoteHashlinePath(pathText: string): string {
22
+ if (pathText.length < 2) return pathText;
23
+ const first = pathText[0];
24
+ const last = pathText[pathText.length - 1];
25
+ if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
26
+ return pathText;
27
+ }
28
+
29
+ /**
30
+ * Strip apply_patch-style noise that models reflexively prepend to the
31
+ * path. Examples observed in benchmark traces:
32
+ *
33
+ * `Update File:foo.ts`, `Update:foo.ts`, `UpdateFile:foo.ts`,
34
+ * `Update/File:foo.ts`, `Update-file:foo.ts`, `Update(File):foo.ts`,
35
+ * `Update<File:foo.ts`, `Add File:foo.ts`, `Delete File:foo.ts`,
36
+ * `Move to:foo.ts`, `***foo.ts`, `***Update File:foo.ts`.
37
+ *
38
+ * We strip a leading `***` (the model duplicating the header sigil) and a
39
+ * leading `(Update|Add|Delete|Move)[<separator>]*(File|to)?[<separator>]*:`
40
+ * keyword block, case-insensitive. The remaining text is the real path.
41
+ */
42
+ const APPLY_PATCH_PATH_NOISE_RE =
43
+ /^\*{0,3}\s*(?:(?:update|add|delete|move)[^A-Za-z0-9]*(?:file|to)?[^A-Za-z0-9]*:)?\s*\*{0,3}\s*/i;
44
+
45
+ function stripApplyPatchPathNoise(pathText: string): string {
46
+ return pathText.replace(APPLY_PATCH_PATH_NOISE_RE, "");
47
+ }
48
+
49
+ /**
50
+ * Best-effort recovery for bracketed header lines the strict tokenizer
51
+ * rejects. Strips apply_patch keyword noise (`Update File:`, `Update:`,
52
+ * etc.) and an extra leading `***` (some models emit a hybrid
53
+ * `[***foo.ts#HASH]` shape), then expects `PATH(#HASH)?`.
54
+ * Returns `null` when no clean path can be salvaged.
55
+ */
56
+ function tryParseRecoveryHeader(line: string, cwd?: string): RawSection | null {
57
+ if (!line.startsWith(HL_FILE_PREFIX) || !line.endsWith(HL_FILE_SUFFIX)) return null;
58
+ const body = stripApplyPatchPathNoise(line.slice(HL_FILE_PREFIX.length, line.length - HL_FILE_SUFFIX.length).trim());
59
+ if (body.length === 0) return null;
60
+
61
+ // Trailing `#XXXX` is the tag; everything before it is the path. The
62
+ // path may contain whitespace (Windows OneDrive folders, Program Files,
63
+ // etc.), so we anchor the tag at end-of-body rather than scanning
64
+ // forward and stopping at the first space.
65
+ const trailing = new RegExp(`#([0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}})\\s*$`).exec(body);
66
+ let pathText: string;
67
+ let fileHash: string | undefined;
68
+ if (trailing !== null) {
69
+ pathText = body.slice(0, trailing.index);
70
+ fileHash = trailing[1].toUpperCase();
71
+ } else {
72
+ pathText = body.replace(/\s+$/, "");
73
+ }
74
+
75
+ // Same rule as the strict tokenizer: the hashline header grammar uses
76
+ // `#` as the path/tag separator and does not allow `#` inside
77
+ // filenames. Anything `#` left in the path body — short tags, non-hex
78
+ // tags, over-long tags, stale-tag copy-paste, line-suffixed tags —
79
+ // means the header is malformed, not a path with an embedded hash.
80
+ if (pathText.includes("#")) return null;
81
+
82
+ const path = normalizeHashlinePath(pathText, cwd);
83
+ if (path.length === 0) return null;
84
+ return fileHash !== undefined ? { path, fileHash, diff: "" } : { path, diff: "" };
85
+ }
86
+
87
+ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
88
+ const unquoted = stripApplyPatchPathNoise(unquoteHashlinePath(rawPath.trim()));
89
+ if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
90
+ const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
91
+ const normalizedRelative = relative.split(path.sep).join("/");
92
+ const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
93
+ return isWithinCwd ? normalizedRelative || "." : unquoted;
94
+ }
95
+
96
+ interface RawSection {
97
+ path: string;
98
+ fileHash?: string;
99
+ diff: string;
100
+ }
101
+
102
+ /**
103
+ * Parse a `[PATH]` or `[PATH#hash]` header line. Returns `null` for lines that do
104
+ * not start with `[`. Throws the strict "Input header must be …" error
105
+ * when a bracketed line fails the strict shape (so malformed paths
106
+ * surface immediately instead of being silently re-classified as payload).
107
+ */
108
+ function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null {
109
+ const trimmed = line.trimEnd();
110
+ if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
111
+
112
+ const token = TOKENIZER.tokenize(trimmed);
113
+ if (token.kind !== "header") {
114
+ // Recovery: try to extract a path from the raw line after stripping
115
+ // apply_patch noise. This handles `[*** Update File:foo.ts#CB5A]` and
116
+ // the half-dozen variants models actually emit.
117
+ const recovered = tryParseRecoveryHeader(trimmed, cwd);
118
+ if (recovered !== null) return recovered;
119
+ throw new Error(
120
+ `Input header must be ${HL_FILE_PREFIX}PATH${HL_FILE_SUFFIX} or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG${HL_FILE_SUFFIX} with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`,
121
+ );
122
+ }
123
+
124
+ const parsedPath = normalizeHashlinePath(token.path, cwd);
125
+ if (parsedPath.length === 0) {
126
+ throw new Error(`Input header "${HL_FILE_PREFIX}${HL_FILE_SUFFIX}" is empty; provide a file path.`);
127
+ }
128
+ return token.fileHash !== undefined
129
+ ? { path: parsedPath, fileHash: token.fileHash, diff: "" }
130
+ : { path: parsedPath, diff: "" };
131
+ }
132
+
133
+ function stripLeadingBlankLines(input: string): string {
134
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
135
+ const lines = stripped.split("\n");
136
+ while (lines.length > 0) {
137
+ const head = lines[0].replace(/\r$/, "");
138
+ if (head.trim().length === 0 || TOKENIZER.tokenize(head).kind === "envelope-begin") {
139
+ lines.shift();
140
+ continue;
141
+ }
142
+ break;
143
+ }
144
+ return lines.join("\n");
145
+ }
146
+
147
+ /**
148
+ * Returns true when the input contains at least one line that the tokenizer
149
+ * recognizes as a hashline op. Used by streaming previews to decide whether
150
+ * the partial input is worth treating as a hashline patch yet.
151
+ */
152
+ export function containsRecognizableHashlineOperations(input: string): boolean {
153
+ for (const line of input.split(/\r?\n/)) {
154
+ if (TOKENIZER.isOp(line)) return true;
155
+ }
156
+ return false;
157
+ }
158
+
159
+ function normalizeFallbackInput(input: string, options: SplitOptions): string {
160
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
161
+ const hasExplicitHeader = stripped
162
+ .split(/\r?\n/)
163
+ .some(rawLine => parseHashlineHeaderLine(rawLine, options.cwd) !== null);
164
+ if (hasExplicitHeader) return input;
165
+
166
+ if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
167
+ const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
168
+ if (fallbackPath.length === 0) return input;
169
+ return `${HL_FILE_PREFIX}${fallbackPath}${HL_FILE_SUFFIX}\n${input}`;
170
+ }
171
+
172
+ function splitRawSections(input: string, options: SplitOptions = {}): RawSection[] {
173
+ const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
174
+ const lines = stripped.split(/\r?\n/);
175
+ const firstLine = lines[0] ?? "";
176
+
177
+ if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
178
+ // Catch unified-diff hunk-header contamination on the first line so
179
+ // the model sees a focused error.
180
+ const firstTrimmed = firstLine.trimEnd();
181
+ if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(firstTrimmed)) {
182
+ throw new Error(
183
+ "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
184
+ `File sections start with \`${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}\`; use \`replace\`, \`delete\`, or \`insert\` ops.`,
185
+ );
186
+ }
187
+ const preview = JSON.stringify(firstLine.slice(0, 120));
188
+ throw new Error(
189
+ `input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}" on the first non-blank line for anchored edits; got: ${preview}. ` +
190
+ `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX}" then edit ops.`,
191
+ );
192
+ }
193
+
194
+ const sections: RawSection[] = [];
195
+ let current: RawSection | undefined;
196
+ let currentLines: string[] = [];
197
+
198
+ const flush = () => {
199
+ if (!current) return;
200
+ const hasOps = currentLines.some(line => line.trim().length > 0);
201
+ if (hasOps) sections.push({ ...current, diff: currentLines.join("\n") });
202
+ currentLines = [];
203
+ };
204
+
205
+ for (const line of lines) {
206
+ const trimmed = line.trimEnd();
207
+ const token = TOKENIZER.tokenize(line);
208
+ if (token.kind === "envelope-end" || token.kind === "abort") break;
209
+ if (token.kind === "envelope-begin") continue;
210
+
211
+ // Route every bracket-prefixed line through parseHashlineHeaderLine so
212
+ // malformed headers still raise the strict "Input header must be …"
213
+ // diagnostic (the tokenizer alone would silently classify them as
214
+ // payload).
215
+ if (trimmed.startsWith(HL_FILE_PREFIX)) {
216
+ const header = parseHashlineHeaderLine(line, options.cwd);
217
+ if (header !== null) {
218
+ flush();
219
+ current = header;
220
+ currentLines = [];
221
+ continue;
222
+ }
223
+ }
224
+ currentLines.push(line);
225
+ }
226
+ flush();
227
+ return sections;
228
+ }
229
+
230
+ /**
231
+ * Snapshot of one section in a parsed {@link Patch}: a target file plus the
232
+ * lazily-parsed list of edits that should land on it. Constructed by
233
+ * {@link Patch.parse}; consumers usually iterate `patch.sections` rather
234
+ * than build these directly.
235
+ */
236
+ export class PatchSection {
237
+ readonly path: string;
238
+ readonly fileHash: string | undefined;
239
+ readonly diff: string;
240
+ #parsed: { edits: Edit[]; fileOp?: FileOp; warnings: string[] } | undefined;
241
+
242
+ constructor(raw: RawSection) {
243
+ this.path = raw.path;
244
+ this.fileHash = raw.fileHash;
245
+ this.diff = raw.diff;
246
+ }
247
+
248
+ /**
249
+ * Parse this section's diff body. Cached: subsequent calls return the
250
+ * same `{ edits, fileOp?, warnings }` object so callers can safely call this from
251
+ * multiple paths (preflight, apply, diff-preview).
252
+ */
253
+ parse(): { edits: Edit[]; fileOp?: FileOp; warnings: readonly string[] } {
254
+ this.#parsed ??= parsePatch(this.diff);
255
+ const parsed = this.#parsed;
256
+ const fileOp =
257
+ parsed.fileOp === undefined
258
+ ? undefined
259
+ : parsed.fileOp.kind === "move"
260
+ ? { kind: "move" as const, dest: normalizeHashlinePath(parsed.fileOp.dest) }
261
+ : parsed.fileOp;
262
+ return fileOp === parsed.fileOp
263
+ ? parsed
264
+ : { edits: parsed.edits, ...(fileOp === undefined ? {} : { fileOp }), warnings: parsed.warnings };
265
+ }
266
+
267
+ /** Parsed edits for this section. */
268
+ get edits(): readonly Edit[] {
269
+ return this.parse().edits;
270
+ }
271
+
272
+ /** Optional whole-file operation (`REM` / `MV`). */
273
+ get fileOp(): FileOp | undefined {
274
+ return this.parse().fileOp;
275
+ }
276
+
277
+ /** Warnings emitted during parsing of this section. */
278
+ get warnings(): readonly string[] {
279
+ return this.parse().warnings;
280
+ }
281
+
282
+ /**
283
+ * True when at least one edit anchors to concrete file content. Pure
284
+ * `insert head:` / `insert tail:` literal inserts do not count: those are
285
+ * safe to apply to files that don't yet exist.
286
+ */
287
+ get hasAnchorScopedEdit(): boolean {
288
+ return this.edits.some(edit => {
289
+ if (edit.kind === "delete") return true;
290
+ // A `replace_block N:` edit is anchored to concrete content on line N.
291
+ if (edit.kind === "block") return true;
292
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
293
+ });
294
+ }
295
+
296
+ /** Anchor lines touched by this section, sorted ascending and deduplicated. */
297
+ collectAnchorLines(): readonly number[] {
298
+ const lines = new Set<number>();
299
+ for (const edit of this.edits) {
300
+ if (edit.kind === "delete") {
301
+ lines.add(edit.anchor.line);
302
+ continue;
303
+ }
304
+ if (edit.kind === "block") {
305
+ lines.add(edit.anchor.line);
306
+ continue;
307
+ }
308
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
309
+ lines.add(edit.cursor.anchor.line);
310
+ }
311
+ }
312
+ return [...lines].sort((a, b) => a - b);
313
+ }
314
+
315
+ /**
316
+ * Apply this section's edits to `text` and return the post-edit result.
317
+ * Pure: does no I/O, does not validate the section snapshot tag. The
318
+ * {@link Patcher} owns tag validation and recovery; reach for this
319
+ * method directly when you've already validated the file content and
320
+ * just want the result.
321
+ *
322
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
323
+ * unresolvable block throws (this is the final, authoritative preview path).
324
+ */
325
+ applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
326
+ const { edits, warnings } = this.parse();
327
+ const resolveWarnings: string[] = [];
328
+ const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
329
+ onUnresolved: "throw",
330
+ onWarning: warning => resolveWarnings.push(warning),
331
+ });
332
+ const result = applyEdits(text, resolved);
333
+ // Preserve parse warnings so consumers don't need to call `parse()`
334
+ // separately.
335
+ const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
336
+ return merged.length > 0
337
+ ? { ...result, warnings: merged }
338
+ : { text: result.text, firstChangedLine: result.firstChangedLine };
339
+ }
340
+
341
+ /**
342
+ * Streaming-tolerant counterpart to {@link applyTo}. Uses
343
+ * {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
344
+ * or a per-token parse error mid-stream) does not throw or emit a phantom
345
+ * empty-payload edit. Intended for incremental diff previews; the writer
346
+ * path should always use {@link applyTo}.
347
+ *
348
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
349
+ * unresolvable block is silently dropped so a half-written file does not
350
+ * throw mid-stream.
351
+ */
352
+ applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult {
353
+ const { edits, warnings } = parsePatchStreaming(this.diff);
354
+ const resolveWarnings: string[] = [];
355
+ const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
356
+ onUnresolved: "drop",
357
+ onWarning: warning => resolveWarnings.push(warning),
358
+ });
359
+ const result = applyEdits(text, resolved);
360
+ const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
361
+ return merged.length > 0
362
+ ? { ...result, warnings: merged }
363
+ : { text: result.text, firstChangedLine: result.firstChangedLine };
364
+ }
365
+
366
+ /**
367
+ * A copy of this section rebound to a different target `path`, preserving
368
+ * the snapshot tag, diff body, and any cached parse result. Used by the
369
+ * patcher's tag-based path recovery to redirect an edit whose authored
370
+ * path does not exist onto the file its snapshot tag actually names.
371
+ */
372
+ withPath(path: string): PatchSection {
373
+ const next = new PatchSection({
374
+ path,
375
+ ...(this.fileHash !== undefined ? { fileHash: this.fileHash } : {}),
376
+ diff: this.diff,
377
+ });
378
+ next.#parsed = this.#parsed;
379
+ return next;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
385
+ * at a `[PATH#HASH]` header. Construct via {@link Patch.parse}.
386
+ *
387
+ * `Patch` is pure data: parsing is line-anchored and does not look at the
388
+ * filesystem. To apply a patch, hand it to {@link Patcher.apply}.
389
+ */
390
+ export class Patch {
391
+ readonly sections: readonly PatchSection[];
392
+
393
+ private constructor(sections: PatchSection[]) {
394
+ this.sections = sections;
395
+ }
396
+
397
+ /**
398
+ * Parse `input` into a {@link Patch}. `options.cwd` resolves absolute
399
+ * paths inside headers to cwd-relative form; `options.path` provides a
400
+ * fallback when the input lacks a header but contains hashline ops
401
+ * (useful for streaming previews).
402
+ *
403
+ * Consecutive sections targeting the same path are merged into a single
404
+ * section with concatenated diff bodies. Anchors authored against the
405
+ * same file snapshot must be applied as one batch; otherwise the first
406
+ * sub-edit shifts line numbers out from under the second's anchors and
407
+ * validation fails.
408
+ */
409
+ static parse(input: string, options: SplitOptions = {}): Patch {
410
+ const raw = mergeSamePathSections(splitRawSections(input, options));
411
+ return new Patch(raw.map(section => new PatchSection(section)));
412
+ }
413
+
414
+ /**
415
+ * Parse `input` and return only the first section. Throws if the input
416
+ * has zero sections. Convenience for the single-section case where the
417
+ * caller already knows the patch is one hunk.
418
+ */
419
+ static parseSingle(input: string, options: SplitOptions = {}): PatchSection {
420
+ const patch = Patch.parse(input, options);
421
+ const first = patch.sections[0];
422
+ if (!first) throw new Error("Patch input did not produce any sections.");
423
+ return first;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Collapse consecutive or interleaved sections targeting the same path into a
429
+ * single section with concatenated diffs. Anchors authored against the same
430
+ * file snapshot must be applied as one batch; otherwise the first sub-edit
431
+ * shifts line numbers out from under the second's anchors and validation
432
+ * fails. Path order is preserved by first occurrence.
433
+ */
434
+ function mergeSamePathSections(sections: RawSection[]): RawSection[] {
435
+ const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
436
+ for (const section of sections) {
437
+ const existing = byPath.get(section.path);
438
+ if (existing) {
439
+ if (
440
+ existing.fileHash !== undefined &&
441
+ section.fileHash !== undefined &&
442
+ existing.fileHash !== section.fileHash
443
+ ) {
444
+ throw new Error(
445
+ `Conflicting hashline snapshot tags for ${section.path}: #${existing.fileHash} and #${section.fileHash}. Re-read the file and retry with one current header.`,
446
+ );
447
+ }
448
+ if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
449
+ existing.diffs.push(section.diff);
450
+ continue;
451
+ }
452
+ byPath.set(section.path, {
453
+ ...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
454
+ diffs: [section.diff],
455
+ });
456
+ }
457
+ return Array.from(byPath, ([sectionPath, entry]) => ({
458
+ path: sectionPath,
459
+ ...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
460
+ diff: entry.diffs.join("\n"),
461
+ }));
462
+ }