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.
package/src/patcher.ts ADDED
@@ -0,0 +1,608 @@
1
+ /**
2
+ * High-level patch orchestrator. Reads each section's target file via the
3
+ * configured {@link Filesystem}, strips BOM and normalizes line endings,
4
+ * validates the section snapshot tag (with {@link Recovery}), applies the
5
+ * result back through the same {@link Filesystem}.
6
+ *
7
+ * Two layers:
8
+ *
9
+ * - {@link Patcher.apply} — high-level, all-or-nothing. Preflights every
10
+ * section in memory before any write hits disk, then commits in order.
11
+ * - {@link Patcher.prepare} / {@link Patcher.commit} — granular primitives
12
+ * for callers that need per-section control (e.g. batched LSP flush,
13
+ * custom interleaving). `prepare` performs all the read-side work,
14
+ * validates the section snapshot tag (with recovery), and applies the
15
+ * edits in memory. `commit` writes the prepared result and records a
16
+ * fresh snapshot.
17
+ *
18
+ * Because `prepare` already runs the full apply, a multi-section batch is
19
+ * naturally all-or-nothing: by the time any `commit` runs, every section
20
+ * has been validated.
21
+ *
22
+ * The patcher itself is stateless across calls; reuse one instance per
23
+ * filesystem configuration.
24
+ */
25
+ import * as path from "node:path";
26
+ import { applyEdits } from "./apply";
27
+ import { hasBlockEdit, resolveBlockEdits } from "./block";
28
+ import { computeFileHash, formatHashlineHeader } from "./format";
29
+ import type { Filesystem, WriteResult } from "./fs";
30
+ import { isNotFound } from "./fs";
31
+ import type { Patch, PatchSection } from "./input";
32
+ import {
33
+ HEADTAIL_DRIFT_WARNING,
34
+ missingSnapshotTagMessage,
35
+ pathRecoveredFromTagMessage,
36
+ unseenLinesMessage,
37
+ } from "./messages";
38
+ import { MismatchError } from "./mismatch";
39
+ import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
40
+ import { Recovery, type RecoveryResult } from "./recovery";
41
+ import type { Snapshot, SnapshotStore } from "./snapshots";
42
+ import type { ApplyResult, BlockResolution, BlockResolver, Edit, FileOp } from "./types";
43
+
44
+ export interface PatcherOptions {
45
+ /** Storage backend used for all reads and writes. */
46
+ fs: Filesystem;
47
+ /** Snapshot store that minted and resolves hashline section tags. Required. */
48
+ snapshots: SnapshotStore;
49
+ /**
50
+ * Resolves `replace_block N:` anchors to concrete line spans via tree-sitter.
51
+ * Optional: when omitted, any `replace_block N:` edit throws on apply (the
52
+ * host did not wire a resolver). Plain line-range ops never need it.
53
+ */
54
+ blockResolver?: BlockResolver;
55
+ }
56
+
57
+ /** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
58
+ export interface PatchSectionResult {
59
+ /** Section path (as authored, after cwd-resolution at parse time). */
60
+ path: string;
61
+ /** Filesystem-canonical key for this section (e.g. absolute path). */
62
+ canonicalPath: string;
63
+ /** `"noop"` when the apply produced no change; `"delete"` removes the file; otherwise `"create"` / `"update"`. */
64
+ op: "create" | "update" | "delete" | "noop";
65
+ /** Pre-edit text (LF-normalized, BOM-stripped). */
66
+ before: string;
67
+ /** Post-edit text (LF-normalized, BOM-stripped). For `"noop"` equals `before`. */
68
+ after: string;
69
+ /** Same text as `after` but with the original BOM and line ending restored. */
70
+ persisted: string;
71
+ /** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
72
+ written: string;
73
+ /** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
74
+ fileHash: string;
75
+ /** Hashline section header (`[path#tag]`) of the post-edit content. */
76
+ header: string;
77
+ /** 1-indexed first changed line in `after`, or `undefined` for noops. */
78
+ firstChangedLine?: number;
79
+ /** Warnings collected by the parser, applier, and (optionally) recovery. */
80
+ warnings: string[];
81
+ /** Destination path when this section includes `MV DEST`. */
82
+ moveDest?: string;
83
+ /**
84
+ * Resolved spans for any `replace_block`/`delete_block` ops, present when the
85
+ * apply matched the tagged content. Undefined for patches with no block ops
86
+ * (and for resolutions routed through drift recovery, where numbers shift).
87
+ */
88
+ blockResolutions?: BlockResolution[];
89
+ }
90
+
91
+ export interface PatcherApplyResult {
92
+ sections: PatchSectionResult[];
93
+ }
94
+
95
+ /**
96
+ * Opaque token returned by {@link Patcher.prepare}. Carries the section, the
97
+ * raw file content read off disk, and the in-memory apply result.
98
+ * {@link Patcher.commit} just writes the {@link PreparedSection.applyResult}.
99
+ */
100
+ export class PreparedSection {
101
+ /** @internal */
102
+ constructor(
103
+ readonly section: PatchSection,
104
+ readonly canonicalPath: string,
105
+ readonly exists: boolean,
106
+ readonly rawContent: string,
107
+ readonly bom: string,
108
+ readonly lineEnding: LineEnding,
109
+ readonly normalized: string,
110
+ readonly applyResult: ApplyResult,
111
+ readonly parseWarnings: readonly string[],
112
+ readonly fileOp: FileOp | undefined,
113
+ ) {}
114
+
115
+ /** Convenience: returns true when the apply produced no change and no file op. */
116
+ get isNoop(): boolean {
117
+ return this.fileOp === undefined && this.applyResult.text === this.normalized;
118
+ }
119
+ }
120
+
121
+ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
122
+ return edits.some(edit => {
123
+ if (edit.kind === "delete") return true;
124
+ // A `replace_block N:` edit anchors to concrete content on line N.
125
+ if (edit.kind === "block") return true;
126
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
127
+ });
128
+ }
129
+
130
+ function assertSectionHashPresent(sectionPath: string, fileHash: string | undefined): void {
131
+ if (fileHash !== undefined) return;
132
+ throw new Error(missingSnapshotTagMessage(sectionPath));
133
+ }
134
+
135
+ function recoveryToApplyResult(result: RecoveryResult): ApplyResult {
136
+ return {
137
+ text: result.text,
138
+ firstChangedLine: result.firstChangedLine,
139
+ warnings: result.warnings,
140
+ };
141
+ }
142
+ function mergeWarnings(...sources: ReadonlyArray<readonly string[] | undefined>): string[] {
143
+ const out: string[] = [];
144
+ for (const source of sources) {
145
+ if (!source) continue;
146
+ for (const warning of source) out.push(warning);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ function hasUtf8Bom(bytes: Uint8Array | undefined): boolean {
152
+ return bytes !== undefined && bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
153
+ }
154
+
155
+ function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
156
+ const seen = new Map<string, string>();
157
+ for (const entry of prepared) {
158
+ const previous = seen.get(entry.canonicalPath);
159
+ if (previous !== undefined) {
160
+ throw new Error(
161
+ `Multiple hashline sections resolve to the same file (${previous} and ${entry.section.path}). Merge their ops under one header before applying.`,
162
+ );
163
+ }
164
+ seen.set(entry.canonicalPath, entry.section.path);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * High-level patcher. Wires a {@link Filesystem} and a required
170
+ * {@link SnapshotStore} together with the parsing + applying core.
171
+ *
172
+ * Construct once per FS configuration; reuse across patches.
173
+ */
174
+ export class Patcher {
175
+ readonly fs: Filesystem;
176
+ readonly snapshots: SnapshotStore;
177
+ readonly recovery: Recovery;
178
+ readonly blockResolver: BlockResolver | undefined;
179
+
180
+ constructor(options: PatcherOptions) {
181
+ if (!options.snapshots) {
182
+ throw new Error("Hashline Patcher requires a SnapshotStore; section tags are opaque store pointers.");
183
+ }
184
+ this.fs = options.fs;
185
+ this.snapshots = options.snapshots;
186
+ this.recovery = new Recovery(options.snapshots);
187
+ this.blockResolver = options.blockResolver;
188
+ }
189
+
190
+ /**
191
+ * Apply every section in `patch`. `prepare` runs the full apply for each
192
+ * section in memory before any write hits the filesystem, so a
193
+ * multi-section batch is naturally all-or-nothing. Returns one
194
+ * {@link PatchSectionResult} per section in the original patch order.
195
+ */
196
+ async apply(patch: Patch): Promise<PatcherApplyResult> {
197
+ // Single-section fast path.
198
+ if (patch.sections.length === 1) {
199
+ const prepared = await this.prepare(patch.sections[0]);
200
+ return { sections: [await this.commit(prepared)] };
201
+ }
202
+
203
+ // Prepare every section first so any failure (stale hash, missing
204
+ // file, parse error, in-memory no-op) surfaces before any write.
205
+ const prepared: PreparedSection[] = [];
206
+ for (const section of patch.sections) prepared.push(await this.prepare(section));
207
+ assertUniqueCanonicalPaths(prepared);
208
+ for (const entry of prepared) {
209
+ if (entry.isNoop) {
210
+ throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
211
+ }
212
+ }
213
+
214
+ const results: PatchSectionResult[] = [];
215
+ for (let index = 0; index < prepared.length; index++) {
216
+ try {
217
+ results.push(await this.commit(prepared[index]));
218
+ } catch (error) {
219
+ // A mid-batch write failure leaves earlier sections on disk with no
220
+ // rollback; report exactly which sections landed so the caller can
221
+ // re-issue only the missing ones instead of double-applying.
222
+ const written = prepared.slice(0, index).map(entry => entry.section.path);
223
+ const notWritten = prepared.slice(index + 1).map(entry => entry.section.path);
224
+ const message = error instanceof Error ? error.message : String(error);
225
+ throw new Error(
226
+ `Failed to write ${prepared[index].section.path}: ${message}` +
227
+ (written.length > 0 ? ` Sections already written: ${written.join(", ")}.` : "") +
228
+ (notWritten.length > 0 ? ` Sections not written: ${notWritten.join(", ")}.` : ""),
229
+ { cause: error },
230
+ );
231
+ }
232
+ }
233
+ return { sections: results };
234
+ }
235
+
236
+ /**
237
+ * Run the preflight pass only: read, parse, validate, apply-in-memory.
238
+ * No writes hit the filesystem. Use for CI checks and dry runs.
239
+ */
240
+ async preflight(patch: Patch): Promise<void> {
241
+ const prepared: PreparedSection[] = [];
242
+ for (const section of patch.sections) prepared.push(await this.prepare(section));
243
+ assertUniqueCanonicalPaths(prepared);
244
+ for (const entry of prepared) {
245
+ if (entry.isNoop) {
246
+ throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Read a section's target file, parse the section, validate the snapshot
253
+ * tag (with recovery), and apply the edits in memory. Returns a
254
+ * {@link PreparedSection} which can be fed to {@link commit} to land
255
+ * the result on the filesystem.
256
+ *
257
+ * Throws on parse error, missing-file-for-anchored-edit, or unrecovered
258
+ * tag mismatch ({@link MismatchError}).
259
+ */
260
+ async prepare(section: PatchSection): Promise<PreparedSection> {
261
+ const parsed = section.parse();
262
+ const parseWarnings = [...parsed.warnings];
263
+ const fileOp = parsed.fileOp;
264
+ assertSectionHashPresent(section.path, section.fileHash);
265
+
266
+ let target = section;
267
+ let canonicalPath = this.fs.canonicalPath(target.path);
268
+ let read = await this.#tryRead(target.path);
269
+
270
+ // Path recovery: the authored path doesn't exist on disk, but its
271
+ // filename + snapshot tag may name a file the model read this session
272
+ // (it supplied a bare filename, or the wrong directory). Rebind to that
273
+ // file so the edit lands where the tag points, and warn. This runs
274
+ // before the write gate so a recoverable bare/mis-typed path is rebound
275
+ // to its real (writable) location instead of being rejected against the
276
+ // literal — possibly read-only — path it was authored as.
277
+ if (!read.exists) {
278
+ const recovered = this.#recoverSectionPathFromTag(target, canonicalPath);
279
+ if (recovered && this.fs.allowTagPathRecovery(target.path, recovered.section.path)) {
280
+ parseWarnings.push(
281
+ pathRecoveredFromTagMessage(target.path, recovered.section.path, target.fileHash as string),
282
+ );
283
+ target = recovered.section;
284
+ canonicalPath = recovered.canonicalPath;
285
+ read = await this.#tryRead(target.path);
286
+ }
287
+ }
288
+
289
+ // Gate the final (possibly recovered) target before any write work, so
290
+ // an unrecoverable read-only target (e.g. a plan-mode working-tree path)
291
+ // fails with the write guard rather than a misleading "file not found".
292
+ await this.fs.preflightWrite(target.path, { fileOp });
293
+
294
+ if (!read.exists) {
295
+ throw new Error(`File not found: ${target.path}. Use the write tool to create new files.`);
296
+ }
297
+
298
+ if (fileOp?.kind === "move" && this.fs.canonicalPath(fileOp.dest) === canonicalPath) {
299
+ throw new Error(`MV destination is the same as ${target.path}.`);
300
+ }
301
+
302
+ const { bom: bomFromText, text } = stripBom(read.rawContent);
303
+ const bom = bomFromText || (await this.#readBinaryBom(target.path));
304
+ const lineEnding = detectLineEnding(text);
305
+ const normalized = normalizeToLF(text);
306
+
307
+ const applyResult =
308
+ fileOp?.kind === "rem"
309
+ ? this.#applyWithRecovery({
310
+ section: target,
311
+ canonicalPath,
312
+ exists: read.exists,
313
+ normalized,
314
+ edits: [],
315
+ })
316
+ : this.#applyWithRecovery({
317
+ section: target,
318
+ canonicalPath,
319
+ exists: read.exists,
320
+ normalized,
321
+ edits: parsed.edits,
322
+ });
323
+
324
+ return new PreparedSection(
325
+ target,
326
+ canonicalPath,
327
+ read.exists,
328
+ read.rawContent,
329
+ bom,
330
+ lineEnding,
331
+ normalized,
332
+ applyResult,
333
+ parseWarnings,
334
+ fileOp,
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Resolve a missing authored path to a file read this session by matching
340
+ * its filename and snapshot tag. Returns the section rebound to that file's
341
+ * canonical path, or `null` when no unique filename+tag match exists.
342
+ *
343
+ * Resolution requires BOTH the bare filename (basename) and the section tag
344
+ * to match a single retained file: a whole-file content hash plus an exact
345
+ * filename is a strong identity signal, so the model almost certainly meant
346
+ * that file but gave the wrong directory (or only the filename). A tie — two
347
+ * retained files sharing the filename and tag — declines recovery. The
348
+ * recorded path of the authored file itself is excluded so a deleted file
349
+ * does not "recover" onto its own stale snapshot.
350
+ */
351
+ #recoverSectionPathFromTag(
352
+ section: PatchSection,
353
+ originalCanonicalPath: string,
354
+ ): { section: PatchSection; canonicalPath: string } | null {
355
+ if (section.fileHash === undefined) return null;
356
+ const authoredName = path.basename(section.path);
357
+ const candidates = [
358
+ ...new Set(
359
+ this.snapshots
360
+ .findByHash(section.fileHash)
361
+ .filter(snapshot => path.basename(snapshot.path) === authoredName)
362
+ .map(snapshot => snapshot.path),
363
+ ),
364
+ ].filter(candidate => this.fs.canonicalPath(candidate) !== originalCanonicalPath);
365
+ if (candidates.length !== 1) return null;
366
+ const resolved = candidates[0];
367
+ return { section: section.withPath(resolved), canonicalPath: this.fs.canonicalPath(resolved) };
368
+ }
369
+
370
+ /**
371
+ * Commit a previously {@link prepare}d section to the filesystem.
372
+ * Restores line endings and BOM, writes via the {@link Filesystem}, and
373
+ * records a fresh snapshot in the {@link SnapshotStore} keyed by the
374
+ * filesystem-canonical path.
375
+ */
376
+ async commit(prepared: PreparedSection): Promise<PatchSectionResult> {
377
+ const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath, fileOp } =
378
+ prepared;
379
+ const after = applyResult.text;
380
+ const warnings = mergeWarnings(parseWarnings, applyResult.warnings);
381
+ const moveDest = fileOp?.kind === "move" ? fileOp.dest : undefined;
382
+ const resultPath = moveDest ?? section.path;
383
+
384
+ if (fileOp?.kind === "rem") {
385
+ await this.fs.delete(section.path);
386
+ this.snapshots.invalidate(canonicalPath);
387
+ return {
388
+ path: section.path,
389
+ canonicalPath,
390
+ op: "delete",
391
+ before: normalized,
392
+ after: normalized,
393
+ persisted: prepared.rawContent,
394
+ written: prepared.rawContent,
395
+ fileHash: computeFileHash(normalized),
396
+ header: formatHashlineHeader(section.path, computeFileHash(normalized)),
397
+ warnings,
398
+ };
399
+ }
400
+
401
+ if (after === normalized && moveDest === undefined) {
402
+ const hash = this.#recordFullSnapshot(canonicalPath, normalized);
403
+ return {
404
+ path: section.path,
405
+ canonicalPath,
406
+ op: "noop",
407
+ before: normalized,
408
+ after: normalized,
409
+ persisted: prepared.rawContent,
410
+ written: prepared.rawContent,
411
+ fileHash: hash,
412
+ header: formatHashlineHeader(section.path, hash),
413
+ warnings,
414
+ };
415
+ }
416
+
417
+ const persisted = bom + restoreLineEndings(after, lineEnding);
418
+
419
+ if (moveDest !== undefined) {
420
+ const destCanonical = this.fs.canonicalPath(moveDest);
421
+ this.snapshots.relocate(canonicalPath, destCanonical);
422
+ await this.fs.move(section.path, moveDest, persisted);
423
+ const fileHash = this.#recordFullSnapshot(destCanonical, after);
424
+ return {
425
+ path: resultPath,
426
+ canonicalPath: destCanonical,
427
+ op: "update",
428
+ before: normalized,
429
+ after,
430
+ persisted,
431
+ written: persisted,
432
+ fileHash,
433
+ header: formatHashlineHeader(moveDest, fileHash),
434
+ firstChangedLine: applyResult.firstChangedLine,
435
+ blockResolutions: applyResult.blockResolutions,
436
+ moveDest,
437
+ warnings,
438
+ };
439
+ }
440
+
441
+ const write: WriteResult = await this.fs.writeText(section.path, persisted);
442
+ const fileHash = this.#recordFullSnapshot(canonicalPath, after);
443
+ const op = exists ? "update" : "create";
444
+
445
+ return {
446
+ path: section.path,
447
+ canonicalPath,
448
+ op,
449
+ before: normalized,
450
+ after,
451
+ persisted,
452
+ written: write.text,
453
+ fileHash,
454
+ header: formatHashlineHeader(section.path, fileHash),
455
+ firstChangedLine: applyResult.firstChangedLine,
456
+ blockResolutions: applyResult.blockResolutions,
457
+ warnings,
458
+ };
459
+ }
460
+
461
+ async #readBinaryBom(path: string): Promise<string> {
462
+ if (!this.fs.readBinary) return "";
463
+ const bytes = await this.fs.readBinary(path);
464
+ return hasUtf8Bom(bytes) ? "\uFEFF" : "";
465
+ }
466
+
467
+ async #tryRead(path: string): Promise<{ exists: boolean; rawContent: string }> {
468
+ try {
469
+ const content = await this.fs.readText(path);
470
+ return { exists: true, rawContent: content };
471
+ } catch (error) {
472
+ if (isNotFound(error)) return { exists: false, rawContent: "" };
473
+ throw error;
474
+ }
475
+ }
476
+
477
+ #recordFullSnapshot(canonicalPath: string, normalized: string): string {
478
+ return this.snapshots.record(canonicalPath, normalized);
479
+ }
480
+
481
+ /**
482
+ * Reject an anchored edit that references a line the read which minted
483
+ * `expected` never displayed. `matchedSnapshot` is the store version whose
484
+ * text equals the live normalized content — the exact snapshot the model
485
+ * anchored against. Absent means no provenance was recorded (the tag was
486
+ * externally minted or aged out), so the edit applies as before. Only runs
487
+ * on the no-drift path, where anchor line numbers index the tagged content
488
+ * 1:1.
489
+ */
490
+ #assertSeenLines(section: PatchSection, expected: string, matchedSnapshot: Snapshot | null): void {
491
+ const seen = matchedSnapshot?.seenLines;
492
+ if (!seen || seen.size === 0) return;
493
+ const unseen = section.collectAnchorLines().filter(line => !seen.has(line));
494
+ if (unseen.length === 0) return;
495
+ throw new Error(unseenLinesMessage(section.path, unseen, expected));
496
+ }
497
+ #mismatchError(
498
+ section: PatchSection,
499
+ canonicalPath: string,
500
+ normalized: string,
501
+ expected: string,
502
+ hashRecognized: boolean,
503
+ ): MismatchError {
504
+ const actualFileHash = this.#recordFullSnapshot(canonicalPath, normalized);
505
+ return new MismatchError({
506
+ path: section.path,
507
+ expectedFileHash: expected,
508
+ actualFileHash,
509
+ fileLines: normalized.split("\n"),
510
+ anchorLines: section.collectAnchorLines(),
511
+ hashRecognized,
512
+ });
513
+ }
514
+
515
+ #applyWithRecovery(args: {
516
+ section: PatchSection;
517
+ canonicalPath: string;
518
+ exists: boolean;
519
+ normalized: string;
520
+ edits: readonly Edit[];
521
+ }): ApplyResult {
522
+ const { section, canonicalPath, exists, normalized, edits } = args;
523
+ const expected = exists ? section.fileHash : undefined;
524
+ // A 16-bit tag can collide across two different file states, so equality
525
+ // on `computeFileHash(normalized) === expected` alone is not enough to
526
+ // prove the live text IS the snapshot the tag names. Also require that,
527
+ // when a snapshot for `(path, expected)` is retained, exactly one stored
528
+ // version carries the tag and its full text matches the live text. If
529
+ // multiple versions share the tag, the header is ambiguous: there is no
530
+ // safe way to know which stored text the model's line anchors came from.
531
+ const storedSnapshotsForTag =
532
+ expected === undefined
533
+ ? []
534
+ : this.snapshots.findByHash(expected).filter(snapshot => snapshot.path === canonicalPath);
535
+ const ambiguousStoredTag = storedSnapshotsForTag.length > 1;
536
+ const storedSnapshotForTag = expected === undefined ? null : this.snapshots.byHash(canonicalPath, expected);
537
+ const hashMatches = expected !== undefined && computeFileHash(normalized) === expected;
538
+ const matchedSnapshot = hashMatches ? this.snapshots.byContent(canonicalPath, normalized) : null;
539
+ const liveMatches =
540
+ hashMatches && !ambiguousStoredTag && (storedSnapshotForTag === null || matchedSnapshot !== null);
541
+
542
+ // Resolve `replace_block N:` edits to concrete ranges before recovery
543
+ // runs. Block anchors are expressed against the snapshot the section tag
544
+ // names, so resolve against that exact text:
545
+ // - live content matches the tag (or there is no tag) → resolve against
546
+ // the live, normalized content;
547
+ // - the file drifted → resolve against the tagged snapshot's text so the
548
+ // resulting ranges flow through the 3-way-merge recovery below.
549
+ // When a block edit needs the tagged snapshot but it is unavailable, the
550
+ // range cannot be placed safely — reject with a MismatchError (re-read).
551
+ const blockResolutions: BlockResolution[] = [];
552
+ const resolveWarnings: string[] = [];
553
+ let resolved: readonly Edit[] = edits;
554
+ if (hasBlockEdit(edits)) {
555
+ if (ambiguousStoredTag) {
556
+ throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true);
557
+ }
558
+ const baseText = expected === undefined || liveMatches ? normalized : storedSnapshotForTag?.text;
559
+ if (baseText === undefined) {
560
+ throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
561
+ }
562
+ resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, {
563
+ onUnresolved: "throw",
564
+ onResolved: resolution => blockResolutions.push(resolution),
565
+ onWarning: warning => resolveWarnings.push(warning),
566
+ });
567
+ }
568
+ const withResolveWarnings = (result: ApplyResult): ApplyResult =>
569
+ resolveWarnings.length === 0
570
+ ? result
571
+ : { ...result, warnings: [...resolveWarnings, ...(result.warnings ?? [])] };
572
+
573
+ // No tag, or the tag still names the live content: an edit anchored at any
574
+ // line is safe to apply, and the resolved block spans line up with what
575
+ // the caller read, so echo them back. (A drifted file falls through to
576
+ // recovery below, where line numbers shift, so resolutions are dropped.)
577
+ if (expected === undefined || liveMatches) {
578
+ // The line numbers in `edits` index the exact content the tag names.
579
+ // Reject any anchor the read never displayed: editing lines the model
580
+ // has not seen is the off-by-memory mistake that mangles files.
581
+ if (expected !== undefined) this.#assertSeenLines(section, expected, matchedSnapshot);
582
+ const result = applyEdits(normalized, resolved);
583
+ return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
584
+ }
585
+ // Head/tail-only inserts are position-stable: "start"/"end" cannot move
586
+ // with content drift, so a stale tag is non-fatal. Apply onto the live
587
+ // content and warn instead of hard-failing — unlike an anchored
588
+ // mismatch, which cannot be safely relocated and must reject.
589
+ if (!hasAnchorScopedEdit(resolved)) {
590
+ const result = applyEdits(normalized, resolved);
591
+ return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
592
+ }
593
+ if (ambiguousStoredTag) {
594
+ throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true);
595
+ }
596
+ // File drifted: try to replay the edit against the version the tag
597
+ // names and 3-way-merge it onto the live content.
598
+ const recovered = this.recovery.tryRecover({
599
+ path: canonicalPath,
600
+ currentText: normalized,
601
+ fileHash: expected,
602
+ edits: resolved,
603
+ });
604
+ if (recovered) return withResolveWarnings(recoveryToApplyResult(recovered));
605
+ const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
606
+ throw this.#mismatchError(section, canonicalPath, normalized, expected, hashRecognized);
607
+ }
608
+ }