dsh-line-patch 0.2.0 → 0.3.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/lib/core.js CHANGED
@@ -1,191 +1,475 @@
1
- /**
2
- * dsh-line-patch — pure anchoring logic.
3
- *
4
- * No I/O and no host API lives here: every function takes text in and returns
5
- * text out, so the whole editing contract is unit-testable without a Harness.
6
- */
7
- import { createHash } from 'node:crypto'
8
-
9
- /** Characters kept from a line's md5 — enough to spot a changed boundary line. */
10
- export const LINE_HASH_CHARS = 4
11
-
12
- /** Characters kept from the whole-file sha256 — enough to spot a changed file. */
13
- export const FILE_HASH_CHARS = 16
14
-
15
- /** Fixed hash a blank line carries, so every line has an anchor. */
16
- export const EMPTY_LINE_HASH = lineHashOf('')
17
-
18
- function lineHashOf(text) {
19
- return createHash('md5').update(text, 'utf8').digest('hex').slice(0, LINE_HASH_CHARS)
20
- }
21
-
22
- /**
23
- * The anchor shown next to one line.
24
- * @param {string} text - the line with its line ending already stripped.
25
- * @returns {string} a stable 4-character hash.
26
- */
27
- export function lineHash(text) {
28
- return lineHashOf(text)
29
- }
30
-
31
- /**
32
- * The anchor shown for a whole file.
33
- * @param {string|Buffer} content - raw file content.
34
- * @returns {string} a 16-character sha256 prefix.
35
- */
36
- export function fileHash(content) {
37
- return createHash('sha256').update(content).digest('hex').slice(0, FILE_HASH_CHARS)
38
- }
39
-
40
- /**
41
- * Detect the line ending a file is written in so replacements keep it.
42
- * @param {string} content - the file text.
43
- * @returns {string} '\r\n' or '\n'.
44
- */
45
- export function detectEol(content) {
46
- return content.includes('\r\n') ? '\r\n' : '\n'
47
- }
48
-
49
- /**
50
- * Split text into lines while keeping each line's own ending.
51
- *
52
- * Rejoining the result must reproduce the input byte for byte; a final line
53
- * without a newline therefore stays without one.
54
- * @param {string} content - the file text.
55
- * @returns {string[]} lines, each ending in '\n' except possibly the last.
56
- */
57
- export function splitKeepEnds(content) {
58
- const out = []
59
- let start = 0
60
- while (start < content.length) {
61
- const nl = content.indexOf('\n', start)
62
- if (nl < 0) {
63
- out.push(content.slice(start))
64
- break
65
- }
66
- out.push(content.slice(start, nl + 1))
67
- start = nl + 1
68
- }
69
- return out
70
- }
71
-
72
- /** Strip a line's ending terms before hashing it. */
73
- function bareLine(line) {
74
- return line.replace(/[\r\n]+$/, '')
75
- }
76
-
77
- /**
78
- * Render the model-facing view of a line range.
79
- *
80
- * Each line is prefixed with `<number>/<hash>: ` so the model never counts
81
- * lines itself and can copy the hash straight into `patch_hash`.
82
- * @param {string} content - the text to annotate.
83
- * @param {number} [startLine] - absolute number for the first line.
84
- * @returns {string} annotated text.
85
- */
86
- export function annotateLines(content, startLine = 1) {
87
- const lines = splitKeepEnds(content)
88
- return lines
89
- .map((line, index) => {
90
- const text = bareLine(line)
91
- const hash = text.length === 0 ? EMPTY_LINE_HASH : lineHash(text)
92
- return `${startLine + index}/${hash}: ${text}\n`
93
- })
94
- .join('')
95
- }
96
-
97
- /** Normalise every line ending in a replacement to the file's own style. */
98
- function normalizeEol(text, eol) {
99
- const unified = text.replace(/\r\n|[\r\n]/g, '\n')
100
- return eol === '\n' ? unified : unified.replace(/\n/g, eol)
101
- }
102
-
103
- /** Count the lines a replacement text occupies. */
104
- function countLines(text) {
105
- if (text.length === 0) return 0
106
- const parts = text.split('\n')
107
- if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop()
108
- return parts.length
109
- }
110
-
111
- /**
112
- * Apply an anchored line-range replacement.
113
- *
114
- * All three anchors are checked before anything is written, and the returned
115
- * text is the complete new file: callers never splice in place themselves.
116
- * @param {object} input - the guarded edit request.
117
- * @param {string} input.content - current file text.
118
- * @param {string} input.expectedFileHash - `file_hash` the model was shown.
119
- * @param {number} input.lineStart - 1-based first line to replace.
120
- * @param {string} input.hashStart - hash of that line.
121
- * @param {number} input.lineEnd - 1-based last line to replace (inclusive).
122
- * @param {string} input.hashEnd - hash of that line.
123
- * @param {string} input.replace - replacement text; '' deletes the range.
124
- * @returns {{ok: true, content: string, fileHash: string, lineShift: number, removedLines: number}
125
- * | {ok: false, code: string, message: string}}
126
- */
127
- export function applyPatch(input) {
128
- const { content, expectedFileHash, lineStart, hashStart, lineEnd, hashEnd, replace } = input
129
-
130
- const actualFileHash = fileHash(content)
131
- if (expectedFileHash !== actualFileHash) {
132
- return {
133
- ok: false,
134
- code: 'FILE_HASH_MISMATCH',
135
- message: `file_hash mismatch: expected ${expectedFileHash}, actual ${actualFileHash} — the file changed since read_hash; read it again.`,
136
- }
137
- }
138
-
139
- const lines = splitKeepEnds(content)
140
- const total = lines.length
141
- if (lineStart < 1 || lineStart > total) {
142
- return {
143
- ok: false,
144
- code: 'RANGE_INVALID',
145
- message: `line_start ${lineStart} is out of range (file has ${total} lines).`,
146
- }
147
- }
148
- if (lineEnd < lineStart || lineEnd > total) {
149
- return {
150
- ok: false,
151
- code: 'RANGE_INVALID',
152
- message: `line_end ${lineEnd} is out of range (line_start ${lineStart}, file has ${total} lines).`,
153
- }
154
- }
155
-
156
- const startActual = lineHash(bareLine(lines[lineStart - 1]))
157
- if (startActual !== hashStart) {
158
- return {
159
- ok: false,
160
- code: 'LINE_HASH_MISMATCH',
161
- message: `line ${lineStart} hash mismatch: expected ${hashStart}, actual ${startActual} — the line numbers shifted; read the file again.`,
162
- }
163
- }
164
- const endActual = lineHash(bareLine(lines[lineEnd - 1]))
165
- if (endActual !== hashEnd) {
166
- return {
167
- ok: false,
168
- code: 'LINE_HASH_MISMATCH',
169
- message: `line ${lineEnd} hash mismatch: expected ${hashEnd}, actual ${endActual} — the line numbers shifted; read the file again.`,
170
- }
171
- }
172
-
173
- const eol = detectEol(content)
174
- const hadTrailingNewline = content.endsWith('\n')
175
- let body = normalizeEol(replace, eol)
176
- if (body.length > 0 && !body.endsWith('\n')) body += eol
177
-
178
- const before = lines.slice(0, lineStart - 1)
179
- const after = lines.slice(lineEnd)
180
- if (after.length === 0 && !hadTrailingNewline) body = body.replace(/\r?\n$/, '')
181
-
182
- const next = before.join('') + body + after.join('')
183
- const removedLines = lineEnd - lineStart + 1
184
- return {
185
- ok: true,
186
- content: next,
187
- fileHash: fileHash(next),
188
- lineShift: countLines(body) - removedLines,
189
- removedLines,
190
- }
191
- }
1
+ /**
2
+ * dsh-line-patch — pure anchoring logic.
3
+ *
4
+ * No I/O and no host API lives here: every function takes text in and returns
5
+ * text out, so the whole editing contract is unit-testable without a Harness.
6
+ *
7
+ * The anchors a model copies are *content* slices, not hashes: the first and
8
+ * last characters of the boundary lines are visible in a normal read, so
9
+ * nothing has to be recomputed to write them back.
10
+ */
11
+ import { createHash } from 'node:crypto'
12
+
13
+ /** Characters kept from a boundary line — enough to identify it, cheap to copy. */
14
+ export const ANCHOR_CHARS = 16
15
+
16
+ /** Characters kept from the whole-file sha256 — enough to spot a changed file. */
17
+ export const FILE_HASH_CHARS = 6
18
+
19
+ /**
20
+ * The hash shown underneath a read/write/edit result.
21
+ * @param {string|Buffer} content - raw file content.
22
+ * @returns {string} a 6-character sha256 prefix.
23
+ */
24
+ export function fileHash(content) {
25
+ return createHash('sha256').update(content).digest('hex').slice(0, FILE_HASH_CHARS)
26
+ }
27
+
28
+ /**
29
+ * Detect the line ending a file is written in so replacements keep it.
30
+ * @param {string} content - the file text.
31
+ * @returns {string} '\r\n' or '\n'.
32
+ */
33
+ export function detectEol(content) {
34
+ return content.includes('\r\n') ? '\r\n' : '\n'
35
+ }
36
+
37
+ /**
38
+ * Split text into lines while keeping each line's own ending.
39
+ *
40
+ * Rejoining the result must reproduce the input byte for byte; a final line
41
+ * without a newline therefore stays without one.
42
+ * @param {string} content - the file text.
43
+ * @returns {string[]} lines, each ending in '\n' except possibly the last.
44
+ */
45
+ export function splitKeepEnds(content) {
46
+ const out = []
47
+ let start = 0
48
+ while (start < content.length) {
49
+ const nl = content.indexOf('\n', start)
50
+ if (nl < 0) {
51
+ out.push(content.slice(start))
52
+ break
53
+ }
54
+ out.push(content.slice(start, nl + 1))
55
+ start = nl + 1
56
+ }
57
+ return out
58
+ }
59
+
60
+ /** Strip a line's ending terms before comparing or showing it. */
61
+ function bareLine(line) {
62
+ return line.replace(/[\r\n]+$/, '')
63
+ }
64
+
65
+ /**
66
+ * Reduce an anchor or a line to a canonical comparison form.
67
+ *
68
+ * Indentation drops away, runs of whitespace collapse to one space, and the
69
+ * ends are trimmed, so a model may copy an anchor with or without the layout
70
+ * whitespace around the code.
71
+ * @param {unknown} text - an anchor candidate or a real line.
72
+ * @returns {string} whitespace-collapsed text.
73
+ */
74
+ export function normalizeAnchor(text) {
75
+ return String(text ?? '').replace(/\s+/g, ' ').trim()
76
+ }
77
+
78
+ /**
79
+ * Canonical form that keeps the line's own indentation.
80
+ *
81
+ * A model that copies the indentation it was shown gets a stricter comparison:
82
+ * `if (err) {` and ` if (err) {` are different lines, which is exactly the
83
+ * pair that a nesting mistake confuses. Inner whitespace still collapses.
84
+ * @param {unknown} text - an anchor candidate or a real line.
85
+ * @returns {string} indentation-preserving text.
86
+ */
87
+ export function normalizeIndented(text) {
88
+ const raw = String(text ?? '')
89
+ const lead = raw.match(/^\s+/)?.[0] ?? ''
90
+ const rest = raw.slice(lead.length).replace(/\s+/g, ' ')
91
+ return `${lead}${rest}`.replace(/\s+$/, '')
92
+ }
93
+
94
+ /** Anchors that start with whitespace are compared with indentation kept. */
95
+ function keepsIndent(given) {
96
+ return /^\s/.test(String(given ?? ''))
97
+ }
98
+
99
+ /**
100
+ * The anchor a line offers at its start: its first `ANCHOR_CHARS` characters.
101
+ *
102
+ * Whitespace is collapsed and the leading indentation dropped, so this is the
103
+ * *shortest* anchor the contract accepts — a model that copies the read display
104
+ * including indentation sends a stricter (indented) one. Used to build anchors
105
+ * in tests and by callers that need the canonical short form; never for matching.
106
+ * @param {string} line - the line without its ending.
107
+ * @returns {string} the start anchor.
108
+ */
109
+ export function startAnchorOf(line) {
110
+ return normalizeAnchor(line).slice(0, ANCHOR_CHARS)
111
+ }
112
+
113
+ /**
114
+ * The anchor a line offers at its end: its last `ANCHOR_CHARS` characters.
115
+ *
116
+ * Whitespace is collapsed the same way; the whitespace itself is not part of a
117
+ * comparable end anchor.
118
+ * @param {string} line - the line without its ending.
119
+ * @returns {string} the end anchor.
120
+ */
121
+ export function endAnchorOf(line) {
122
+ return normalizeAnchor(line).slice(-ANCHOR_CHARS)
123
+ }
124
+
125
+ /**
126
+ * Does a model-supplied anchor identify this line's start?
127
+ *
128
+ * An empty anchor means "this line is blank". A start anchor is a slice of the
129
+ * line's first characters, so leading whitespace in it *is* the indentation:
130
+ * when the model carries it, the comparison keeps indentation and `if (err) {`
131
+ * stops matching ` if (err) {`. A copy without the indentation still matches
132
+ * the collapsed form — the length contract is enforced by `applyPatch`.
133
+ * @param {string} line - the line without its ending.
134
+ * @param {string} given - the anchor the model sent.
135
+ * @returns {boolean} true when the anchor identifies the line.
136
+ */
137
+ export function matchesStart(line, given) {
138
+ const strict = keepsIndent(given)
139
+ const actual = strict ? normalizeIndented(line) : normalizeAnchor(line)
140
+ const want = strict ? normalizeIndented(given) : normalizeAnchor(given)
141
+ if (want.length === 0) return actual.length === 0
142
+ return actual.startsWith(want)
143
+ }
144
+
145
+ /**
146
+ * Does a model-supplied anchor identify this line's end?
147
+ *
148
+ * An end anchor is a slice of the line's *last* characters, so whitespace at
149
+ * its edge is content, not indentation; both sides are compared collapsed.
150
+ * @param {string} line - the line without its ending.
151
+ * @param {string} given - the anchor the model sent.
152
+ * @returns {boolean} true when the anchor identifies the line.
153
+ */
154
+ export function matchesEnd(line, given) {
155
+ const actual = normalizeAnchor(line)
156
+ const want = normalizeAnchor(given)
157
+ if (want.length === 0) return actual.length === 0
158
+ return actual.endsWith(want)
159
+ }
160
+
161
+ /**
162
+ * Is the anchor as long as the contract requires?
163
+ *
164
+ * The model is told to copy a whole anchor (16 characters, or the whole line
165
+ * when it is shorter). A shorter copy still identifies a line by prefix, which
166
+ * would quietly turn a 16-character check into a two-character one.
167
+ * @param {string} line - the line the anchor should identify.
168
+ * @param {string} given - the anchor the model sent.
169
+ * @returns {boolean} true when the anchor is shorter than the contract allows.
170
+ */
171
+ export function anchorTooShort(line, given) {
172
+ const raw = String(given ?? '')
173
+ if (raw.length >= ANCHOR_CHARS) return false
174
+ if (normalizeAnchor(given).length === 0) return false
175
+ return normalizeAnchor(given) !== normalizeAnchor(line)
176
+ }
177
+
178
+ /** Render an anchor for an error message, naming the blank case. */
179
+ function shown(anchor) {
180
+ return anchor.length === 0 ? '(blank)' : JSON.stringify(anchor)
181
+ }
182
+
183
+ /**
184
+ * Longest widening this plugin will suggest.
185
+ *
186
+ * The read renderer truncates displayed lines at 2,000 characters, so a longer
187
+ * anchor is text the model never saw and cannot usefully copy back.
188
+ */
189
+ const SUGGESTION_CHARS = 2000
190
+
191
+ /** Every 1-based line number whose text matches the anchor on that boundary. */
192
+ function matchingLines(lines, given, boundary) {
193
+ const found = []
194
+ for (let index = 0; index < lines.length; index += 1) {
195
+ const line = bareLine(lines[index])
196
+ const matched = boundary === 'start' ? matchesStart(line, given) : matchesEnd(line, given)
197
+ if (matched) found.push(index + 1)
198
+ }
199
+ return found
200
+ }
201
+
202
+ /** Render a short line-number list for an error message. */
203
+ function describeLines(numbers) {
204
+ const head = numbers.slice(0, 8).join(', ')
205
+ return numbers.length > 8 ? `lines ${head}, …` : `lines ${head}`
206
+ }
207
+
208
+ /**
209
+ * The shortest widening of `given` that matches exactly one line, or null.
210
+ *
211
+ * Only the lines that already matched the short anchor can ever match a longer
212
+ * one, so the search walks those (usually two or three) instead of the whole
213
+ * file — otherwise a pair of long generated lines would cost O(file × length).
214
+ * The search stops at `SUGGESTION_CHARS`: past the read display's own truncation
215
+ * the model has never seen the characters, so a longer suggestion cannot be
216
+ * copied back and the edit is left to the line number instead.
217
+ * @param {string[]} lines - the file's lines, endings included.
218
+ * @param {number[]} matches - 1-based lines the short anchor already matched.
219
+ * @param {string} full - the named line's normalized text.
220
+ * @param {string} given - the anchor the model sent.
221
+ * @param {'start'|'end'} boundary - which end of the line the anchor covers.
222
+ * @returns {string|null} a unique anchor, or null when none is worth suggesting.
223
+ */
224
+ function uniqueWidening(lines, matches, full, given, boundary) {
225
+ const from = Math.max(normalizeAnchor(given).length, ANCHOR_CHARS) + 1
226
+ const limit = Math.min(full.length, SUGGESTION_CHARS)
227
+ for (let length = from; length <= limit; length += 1) {
228
+ const candidate = boundary === 'start' ? full.slice(0, length) : full.slice(-length)
229
+ let hits = 0
230
+ for (const number of matches) {
231
+ const line = bareLine(lines[number - 1])
232
+ const matched = boundary === 'start' ? matchesStart(line, candidate) : matchesEnd(line, candidate)
233
+ if (matched) hits += 1
234
+ if (hits > 1) break
235
+ }
236
+ if (hits === 1) return candidate
237
+ }
238
+ return null
239
+ }
240
+
241
+ /**
242
+ * Would this anchor silently point at more than one line, and can widening fix it?
243
+ *
244
+ * Two lines sharing their first (or last) 16 characters are indistinguishable
245
+ * from those two slices alone; if the line is longer, the model can copy more
246
+ * of it and be exact, so that is worth one corrective turn. When even the whole
247
+ * line repeats, no anchor can disambiguate it — the line number is the only
248
+ * signal, and rejecting would block edits that are in fact unambiguous.
249
+ * @param {string[]} lines - the file's lines, endings included.
250
+ * @param {number} lineNumber - the line the model named.
251
+ * @param {string} given - the anchor the model sent.
252
+ * @param {'start'|'end'} boundary - which end of the line the anchor covers.
253
+ * @returns {{matches: number[], suggestion: string}|null} the ambiguity, or null.
254
+ */
255
+ function ambiguousAnchor(lines, lineNumber, given, boundary) {
256
+ const matches = matchingLines(lines, given, boundary)
257
+ if (matches.length <= 1) return null
258
+ const full = normalizeAnchor(bareLine(lines[lineNumber - 1]))
259
+ if (full.length <= normalizeAnchor(given).length) return null
260
+ const suggestion = uniqueWidening(lines, matches, full, given, boundary)
261
+ // No widening separates them (repeated whole line): the line number is the only
262
+ // signal, and rejecting would block edits that are in fact unambiguous.
263
+ if (suggestion === null) return null
264
+ return { matches, suggestion }
265
+ }
266
+
267
+ /** A non-empty anchor that carries no characters at all. */
268
+ function isWhitespaceOnly(anchor) {
269
+ const raw = String(anchor ?? '')
270
+ return raw.length > 0 && raw.trim().length === 0
271
+ }
272
+
273
+ /** Normalise every line ending in a replacement to the file's own style. */
274
+ function normalizeEol(text, eol) {
275
+ const unified = text.replace(/\r\n|[\r\n]/g, '\n')
276
+ return eol === '\n' ? unified : unified.replace(/\n/g, eol)
277
+ }
278
+
279
+ /** Count the lines a replacement text occupies. */
280
+ function countLines(text) {
281
+ if (text.length === 0) return 0
282
+ const parts = text.split('\n')
283
+ if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop()
284
+ return parts.length
285
+ }
286
+
287
+ /** Reject an argument the tool cannot act on, instead of throwing later. */
288
+ function badArgument(message) {
289
+ return { ok: false, code: 'BAD_ARGUMENT', message }
290
+ }
291
+
292
+ /**
293
+ * Apply an anchored line-range replacement.
294
+ *
295
+ * The whole-file hash is checked first: matching it proves the file is exactly
296
+ * the one the model read, so a later anchor mismatch cannot be a stale read —
297
+ * the model fixes the anchor or the line number without rereading.
298
+ * @param {object} input - the guarded edit request.
299
+ * @param {string} input.content - current file text.
300
+ * @param {string} input.expectedFileHash - `file_hash` the model was shown.
301
+ * @param {number} input.lineStart - 1-based first line to replace.
302
+ * @param {string} input.startAnchor - start anchor of that line.
303
+ * @param {number} input.lineEnd - 1-based last line to replace (inclusive).
304
+ * @param {string} input.endAnchor - end anchor of that line.
305
+ * @param {string} input.replace - replacement text; '' deletes the range.
306
+ * @returns {{ok: true, content: string, fileHash: string, lineShift: number, removedLines: number}
307
+ * | {ok: false, code: string, message: string}}
308
+ */
309
+ export function applyPatch(input) {
310
+ const { content, expectedFileHash, lineStart, startAnchor, lineEnd, endAnchor, replace } = input
311
+
312
+ if (!Number.isInteger(lineStart) || !Number.isInteger(lineEnd)) {
313
+ return badArgument(
314
+ `line_start and line_end must be whole line numbers (got ${String(lineStart)} and ${String(lineEnd)}).`,
315
+ )
316
+ }
317
+ if (typeof replace !== 'string') {
318
+ return badArgument(`replace must be a string (got ${typeof replace}); pass "" to delete the range.`)
319
+ }
320
+ // A hash of the wrong shape is a different mistake from a stale file: saying
321
+ // "the file changed" would send the model to reread for nothing.
322
+ if (typeof expectedFileHash !== 'string' || !new RegExp(`^[0-9a-f]{${FILE_HASH_CHARS}}$`).test(expectedFileHash)) {
323
+ return badArgument(
324
+ `file_hash must be the ${FILE_HASH_CHARS}-character lowercase hex hash printed on the line under a read/write/edit result ` +
325
+ `(got ${JSON.stringify(expectedFileHash)}). If you were never shown such a line, this plugin's annotation is not active — use edit or write instead.`,
326
+ )
327
+ }
328
+
329
+ const actualFileHash = fileHash(content)
330
+ if (expectedFileHash !== actualFileHash) {
331
+ return {
332
+ ok: false,
333
+ code: 'FILE_HASH_MISMATCH',
334
+ message: `file_hash mismatch: expected ${expectedFileHash}, actual ${actualFileHash} — the file changed since it was read; read it again.`,
335
+ }
336
+ }
337
+
338
+ const lines = splitKeepEnds(content)
339
+ const total = lines.length
340
+ if (total === 0) {
341
+ return {
342
+ ok: false,
343
+ code: 'RANGE_INVALID',
344
+ message: 'the file is empty, so there is no line to patch — use the write tool to add content instead.',
345
+ }
346
+ }
347
+ if (lineStart < 1 || lineStart > total) {
348
+ return {
349
+ ok: false,
350
+ code: 'RANGE_INVALID',
351
+ message: `line_start ${lineStart} is out of range (file has ${total} lines). ` +
352
+ 'The file itself is unchanged — fix the line number and retry; do not reread the file.',
353
+ }
354
+ }
355
+ if (lineEnd < lineStart || lineEnd > total) {
356
+ return {
357
+ ok: false,
358
+ code: 'RANGE_INVALID',
359
+ message: `line_end ${lineEnd} is out of range (line_start ${lineStart}, file has ${total} lines). ` +
360
+ 'The file itself is unchanged — fix the line number and retry; do not reread the file.',
361
+ }
362
+ }
363
+
364
+ const startLine = bareLine(lines[lineStart - 1])
365
+ const startWant = normalizeAnchor(startAnchor)
366
+ if (isWhitespaceOnly(startAnchor)) {
367
+ const blankLine = normalizeAnchor(startLine).length === 0
368
+ return {
369
+ ok: false,
370
+ code: 'ANCHOR_TOO_SHORT',
371
+ message: blankLine
372
+ ? `start_anchor is only whitespace and line ${lineStart} is blank — pass "" to address a blank line. Do not reread the file.`
373
+ : `start_anchor is only whitespace — line ${lineStart} is indented past ${ANCHOR_CHARS} characters. ` +
374
+ `Copy ${ANCHOR_CHARS} characters starting at its first non-blank character (or the whole line). Do not reread the file.`,
375
+ }
376
+ }
377
+ if (anchorTooShort(startLine, startAnchor)) {
378
+ return {
379
+ ok: false,
380
+ code: 'ANCHOR_TOO_SHORT',
381
+ message: `start_anchor ${shown(startWant)} is too short to identify a line — copy the first ${ANCHOR_CHARS} ` +
382
+ `characters of line ${lineStart}'s text (the part after the "N: " prefix in the read result; the whole line text when it is shorter). Do not reread the file.`,
383
+ }
384
+ }
385
+ if (!matchesStart(startLine, startAnchor)) {
386
+ return {
387
+ ok: false,
388
+ code: 'LINE_ANCHOR_MISMATCH',
389
+ message: `start_anchor ${shown(String(startAnchor ?? ''))} does not match line ${lineStart}, ` +
390
+ `which starts with ${shown(startLine.slice(0, ANCHOR_CHARS))}. ` +
391
+ 'Copy it exactly, including the indentation; then fix start_anchor or line_start and retry — ' +
392
+ 'do not reread the file (file_hash still matches).',
393
+ }
394
+ }
395
+ const startAmbiguity = ambiguousAnchor(lines, lineStart, startAnchor, 'start')
396
+ if (startAmbiguity) {
397
+ return {
398
+ ok: false,
399
+ code: 'ANCHOR_AMBIGUOUS',
400
+ message: `start_anchor ${shown(startWant)} matches ${describeLines(startAmbiguity.matches)} — ` +
401
+ `line ${lineStart} is longer than ${ANCHOR_CHARS} characters, so copy more of it, ` +
402
+ `for example ${JSON.stringify(startAmbiguity.suggestion)}. Do not reread the file (file_hash still matches).`,
403
+ }
404
+ }
405
+
406
+ const endLine = bareLine(lines[lineEnd - 1])
407
+ const endWant = normalizeAnchor(endAnchor)
408
+ if (isWhitespaceOnly(endAnchor)) {
409
+ const blankLine = normalizeAnchor(endLine).length === 0
410
+ return {
411
+ ok: false,
412
+ code: 'ANCHOR_TOO_SHORT',
413
+ message: blankLine
414
+ ? `end_anchor is only whitespace and line ${lineEnd} is blank — pass "" to address a blank line. Do not reread the file.`
415
+ : `end_anchor is only whitespace — line ${lineEnd} is padded with trailing whitespace. ` +
416
+ `Copy the last ${ANCHOR_CHARS} characters before that padding (or the whole line). Do not reread the file.`,
417
+ }
418
+ }
419
+ if (anchorTooShort(endLine, endAnchor)) {
420
+ return {
421
+ ok: false,
422
+ code: 'ANCHOR_TOO_SHORT',
423
+ message: `end_anchor ${shown(endWant)} is too short to identify a line — copy the last ${ANCHOR_CHARS} ` +
424
+ `characters of line ${lineEnd}'s text (the part after the "N: " prefix in the read result; the whole line text when it is shorter). Do not reread the file.`,
425
+ }
426
+ }
427
+ // A single-line range carries both anchors on one physical line, so a model
428
+ // that copied the head into both slots is right, not wrong.
429
+ const endOk = lineStart === lineEnd
430
+ ? matchesEnd(endLine, endAnchor) || matchesStart(endLine, endAnchor)
431
+ : matchesEnd(endLine, endAnchor)
432
+ if (!endOk) {
433
+ return {
434
+ ok: false,
435
+ code: 'LINE_ANCHOR_MISMATCH',
436
+ message: `end_anchor ${shown(String(endAnchor ?? ''))} does not match line ${lineEnd}, ` +
437
+ `which ends with ${shown(endAnchorOf(endLine))}. ` +
438
+ 'Fix end_anchor or line_end and retry; do not reread the file (file_hash still matches).',
439
+ }
440
+ }
441
+ const endAmbiguity = ambiguousAnchor(lines, lineEnd, endAnchor, 'end')
442
+ if (endAmbiguity) {
443
+ return {
444
+ ok: false,
445
+ code: 'ANCHOR_AMBIGUOUS',
446
+ message: `end_anchor ${shown(endWant)} matches ${describeLines(endAmbiguity.matches)} — ` +
447
+ `line ${lineEnd} is longer than ${ANCHOR_CHARS} characters, so copy more of it, ` +
448
+ `for example ${JSON.stringify(endAmbiguity.suggestion)}. Do not reread the file (file_hash still matches).`,
449
+ }
450
+ }
451
+
452
+ const eol = detectEol(content)
453
+ const hadTrailingNewline = content.endsWith('\n')
454
+ let body = normalizeEol(replace, eol)
455
+ if (body.length > 0 && !body.endsWith('\n')) body += eol
456
+
457
+ const before = lines.slice(0, lineStart - 1)
458
+ const after = lines.slice(lineEnd)
459
+ if (after.length === 0 && !hadTrailingNewline) {
460
+ // Keep the "no trailing newline" shape, but never strip away the only line
461
+ // a `replace` of just a line terminator asked to insert.
462
+ const stripped = body.replace(/\r?\n$/, '')
463
+ if (stripped.length > 0 && countLines(stripped) === countLines(body)) body = stripped
464
+ }
465
+
466
+ const next = before.join('') + body + after.join('')
467
+ const removedLines = lineEnd - lineStart + 1
468
+ return {
469
+ ok: true,
470
+ content: next,
471
+ fileHash: fileHash(next),
472
+ lineShift: countLines(body) - removedLines,
473
+ removedLines,
474
+ }
475
+ }