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/CHANGELOG.md +343 -0
- package/README.md +82 -0
- package/dist/types/apply.d.ts +10 -0
- package/dist/types/block.d.ts +39 -0
- package/dist/types/diff-preview.d.ts +14 -0
- package/dist/types/format.d.ts +83 -0
- package/dist/types/fs.d.ts +109 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/input.d.ts +110 -0
- package/dist/types/messages.d.ts +127 -0
- package/dist/types/mismatch.d.ts +44 -0
- package/dist/types/normalize.d.ts +20 -0
- package/dist/types/parser.d.ts +27 -0
- package/dist/types/patcher.d.ts +118 -0
- package/dist/types/prefixes.d.ts +42 -0
- package/dist/types/recovery.d.ts +44 -0
- package/dist/types/snapshots.d.ts +127 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +73 -0
- package/dist/types/types.d.ts +172 -0
- package/package.json +64 -0
- package/src/apply.ts +1281 -0
- package/src/block.ts +168 -0
- package/src/diff-preview.ts +124 -0
- package/src/format.ts +141 -0
- package/src/fs.ts +246 -0
- package/src/grammar.lark +29 -0
- package/src/index.ts +17 -0
- package/src/input.ts +462 -0
- package/src/messages.ts +267 -0
- package/src/mismatch.ts +118 -0
- package/src/normalize.ts +38 -0
- package/src/parser.ts +456 -0
- package/src/patcher.ts +608 -0
- package/src/prefixes.ts +142 -0
- package/src/prompt.md +172 -0
- package/src/recovery.ts +418 -0
- package/src/snapshots.ts +282 -0
- package/src/stream.ts +132 -0
- package/src/tokenizer.ts +557 -0
- package/src/types.ts +172 -0
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
|
+
}
|