pi-readseek 0.1.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/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- package/src/write.ts +532 -0
package/src/hashline.ts
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashline engine — hash-anchored line editing.
|
|
3
|
+
*
|
|
4
|
+
* Vendored & adapted from oh-my-pi (MIT, github.com/can1357/oh-my-pi).
|
|
5
|
+
* Key additions ported: merge detection, confusable hyphens, restoreOldWrappedLines.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import xxhashWasm from "xxhash-wasm";
|
|
9
|
+
import { throwIfAborted } from "./runtime.js";
|
|
10
|
+
import type { ReadseekLine } from "./readseek-value.js";
|
|
11
|
+
|
|
12
|
+
// ─── Types ──────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export type HashlineEditItem =
|
|
15
|
+
| { set_line: { anchor: string; new_text: string } }
|
|
16
|
+
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
|
|
17
|
+
| { insert_after: { anchor: string; new_text: string; text?: string } }
|
|
18
|
+
| { replace: { old_text: string; new_text: string; all?: boolean } };
|
|
19
|
+
|
|
20
|
+
interface HashMismatch {
|
|
21
|
+
line: number;
|
|
22
|
+
expected: string;
|
|
23
|
+
actual: string;
|
|
24
|
+
expectedContent?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class HashlineMismatchError extends Error {
|
|
28
|
+
readonly updatedAnchors: ReadseekLine[];
|
|
29
|
+
|
|
30
|
+
constructor(message: string, updatedAnchors: ReadseekLine[]) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "HashlineMismatchError";
|
|
33
|
+
this.updatedAnchors = updatedAnchors;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type ParsedRef = { line: number; hash: string; content?: string };
|
|
38
|
+
|
|
39
|
+
type ParsedSpec =
|
|
40
|
+
| { kind: "single"; ref: ParsedRef }
|
|
41
|
+
| { kind: "range"; start: ParsedRef; end: ParsedRef }
|
|
42
|
+
| { kind: "insertAfter"; after: ParsedRef };
|
|
43
|
+
|
|
44
|
+
interface ParsedEdit {
|
|
45
|
+
spec: ParsedSpec;
|
|
46
|
+
dstLines: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface NoopEdit {
|
|
50
|
+
editIndex: number;
|
|
51
|
+
loc: string;
|
|
52
|
+
currentContent: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Hash computation ───────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const HASH_LEN = 3;
|
|
58
|
+
const RADIX = 16;
|
|
59
|
+
const HASH_MOD = RADIX ** HASH_LEN;
|
|
60
|
+
const DICT = Array.from({ length: HASH_MOD }, (_, i) => i.toString(RADIX).padStart(HASH_LEN, "0"));
|
|
61
|
+
|
|
62
|
+
const HASHLINE_PREFIX_RE = /^\d+:[0-9a-zA-Z]{1,16}\|/;
|
|
63
|
+
const DIFF_PLUS_RE = /^\+(?!\+)/;
|
|
64
|
+
const HASH_ONLY_PREFIX_RE = /^[0-9a-f]{3}\|/;
|
|
65
|
+
const CONFUSABLE_HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
|
|
66
|
+
const HASH_RELOCATION_WINDOW_BASE = 20;
|
|
67
|
+
const HASH_RELOCATION_WINDOW_CAP = 100;
|
|
68
|
+
|
|
69
|
+
interface HashlineGlobalState {
|
|
70
|
+
h32Fn: ((input: string, seed?: number) => number) | null;
|
|
71
|
+
initPromise: Promise<void> | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const HASHLINE_STATE_KEY = Symbol.for("pi-readseek.hashlineState.v1");
|
|
75
|
+
|
|
76
|
+
function getHashlineState(): HashlineGlobalState {
|
|
77
|
+
const globalObject = globalThis as any;
|
|
78
|
+
globalObject[HASHLINE_STATE_KEY] ??= {
|
|
79
|
+
h32Fn: null,
|
|
80
|
+
initPromise: null,
|
|
81
|
+
} satisfies HashlineGlobalState;
|
|
82
|
+
return globalObject[HASHLINE_STATE_KEY] as HashlineGlobalState;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function ensureHashInit(): Promise<void> {
|
|
86
|
+
const state = getHashlineState();
|
|
87
|
+
if (state.h32Fn) return;
|
|
88
|
+
if (!state.initPromise) {
|
|
89
|
+
state.initPromise = xxhashWasm().then((hasher) => {
|
|
90
|
+
state.h32Fn = hasher.h32;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
await state.initPromise;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function xxh32(input: string): number {
|
|
97
|
+
const state = getHashlineState();
|
|
98
|
+
if (!state.h32Fn) throw new Error("Hash not initialized — call ensureHashInit() first");
|
|
99
|
+
return state.h32Fn(input, 0) >>> 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function computeLineHash(_idx: number, line: string): string {
|
|
103
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
104
|
+
line = line.replace(/\s+/g, "");
|
|
105
|
+
return DICT[xxh32(line) % HASH_MOD];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const DISPLAY_CONTROL_CHAR_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
|
|
109
|
+
|
|
110
|
+
export function escapeControlCharsForDisplay(text: string): string {
|
|
111
|
+
return text.replace(DISPLAY_CONTROL_CHAR_RE, (ch) => {
|
|
112
|
+
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function formatHashlineDisplay(lineNumber: number, content: string): string {
|
|
117
|
+
return `${lineNumber}:${computeLineHash(lineNumber, content)}|${escapeControlCharsForDisplay(content)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function hashLine(lineNumber: number, content: string): string {
|
|
121
|
+
return formatHashlineDisplay(lineNumber, content);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function hashLines(content: string): string {
|
|
125
|
+
return content
|
|
126
|
+
.split("\n")
|
|
127
|
+
.map((line, i) => formatHashlineDisplay(i + 1, line))
|
|
128
|
+
.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Parsing ────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export function parseLineRef(ref: string): { line: number; hash: string; content?: string } {
|
|
134
|
+
const contentMatch = ref.match(/^[^|]*\|(.*)$/);
|
|
135
|
+
const contentAfterPipe = contentMatch ? contentMatch[1] : undefined;
|
|
136
|
+
const cleaned = ref.replace(/\|.*$/, "").replace(/ {2}.*$/, "").trim();
|
|
137
|
+
const normalized = cleaned.replace(/\s*:\s*/, ":");
|
|
138
|
+
const match = normalized.match(new RegExp(`^(\\d+):([0-9a-fA-F]{${HASH_LEN}})$`));
|
|
139
|
+
if (!match) throw new Error(`Invalid line reference "${ref}". Expected "LINE:HASH" (e.g. "5:abc").`);
|
|
140
|
+
const line = Number.parseInt(match[1], 10);
|
|
141
|
+
if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
|
|
142
|
+
return { line, hash: match[2], content: contentAfterPipe };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Mismatch formatting ────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
function tokenSimilarity(a: string, b: string): number {
|
|
148
|
+
const tokA = new Set(a.trim().split(/\s+/));
|
|
149
|
+
const tokB = new Set(b.trim().split(/\s+/));
|
|
150
|
+
if (tokA.size === 0 && tokB.size === 0) return 1;
|
|
151
|
+
if (tokA.size === 0 || tokB.size === 0) return 0;
|
|
152
|
+
let overlap = 0;
|
|
153
|
+
for (const t of tokA) {
|
|
154
|
+
if (tokB.has(t)) overlap++;
|
|
155
|
+
}
|
|
156
|
+
return overlap / Math.max(tokA.size, tokB.size);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findSimilarLines(
|
|
160
|
+
expectedContent: string,
|
|
161
|
+
fileLines: string[],
|
|
162
|
+
hintLine: number,
|
|
163
|
+
maxSuggestions: number = 3,
|
|
164
|
+
): string[] {
|
|
165
|
+
const SCAN_WINDOW = 50;
|
|
166
|
+
const MIN_SIMILARITY = 0.3;
|
|
167
|
+
const start = Math.max(0, hintLine - 1 - SCAN_WINDOW);
|
|
168
|
+
const end = Math.min(fileLines.length, hintLine - 1 + SCAN_WINDOW + 1);
|
|
169
|
+
const candidates: { line: number; score: number; content: string }[] = [];
|
|
170
|
+
|
|
171
|
+
for (let i = start; i < end; i++) {
|
|
172
|
+
const content = fileLines[i];
|
|
173
|
+
if (!content.trim()) continue;
|
|
174
|
+
const score = tokenSimilarity(expectedContent, content);
|
|
175
|
+
if (score >= MIN_SIMILARITY) {
|
|
176
|
+
candidates.push({ line: i + 1, score, content });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
181
|
+
return candidates.slice(0, maxSuggestions).map((c) => {
|
|
182
|
+
const hash = computeLineHash(c.line, c.content);
|
|
183
|
+
return ` ${c.line}:${hash}|${escapeControlCharsForDisplay(c.content)}`;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function formatMismatchError(
|
|
187
|
+
mismatches: HashMismatch[],
|
|
188
|
+
fileLines: string[],
|
|
189
|
+
relocationWindow: number,
|
|
190
|
+
): { message: string; updatedAnchors: ReadseekLine[] } {
|
|
191
|
+
const mismatchSet = new Map<number, HashMismatch>();
|
|
192
|
+
for (const m of mismatches) mismatchSet.set(m.line, m);
|
|
193
|
+
const updatedAnchors: ReadseekLine[] = mismatches.map((m) => {
|
|
194
|
+
const raw = fileLines[m.line - 1] ?? "";
|
|
195
|
+
const hash = computeLineHash(m.line, raw);
|
|
196
|
+
return {
|
|
197
|
+
line: m.line,
|
|
198
|
+
hash,
|
|
199
|
+
anchor: `${m.line}:${hash}`,
|
|
200
|
+
raw,
|
|
201
|
+
display: escapeControlCharsForDisplay(raw),
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
const displayLines = new Set<number>();
|
|
205
|
+
for (const m of mismatches) {
|
|
206
|
+
for (let i = Math.max(1, m.line - 2); i <= Math.min(fileLines.length, m.line + 2); i++) {
|
|
207
|
+
displayLines.add(i);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const sorted = [...displayLines].sort((a, b) => a - b);
|
|
211
|
+
const out: string[] = [
|
|
212
|
+
"Edit rejected — nothing was written. The anchor hash did not match the current file content.",
|
|
213
|
+
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Auto-relocation checks only within ±${relocationWindow} lines of each anchor. Use the updated LINE:HASH references shown below (>>> marks changed lines).`,
|
|
214
|
+
"",
|
|
215
|
+
];
|
|
216
|
+
let prev = -1;
|
|
217
|
+
for (const num of sorted) {
|
|
218
|
+
if (prev !== -1 && num > prev + 1) out.push(" ...");
|
|
219
|
+
prev = num;
|
|
220
|
+
const content = fileLines[num - 1];
|
|
221
|
+
const hash = computeLineHash(num, content);
|
|
222
|
+
const prefix = `${num}:${hash}`;
|
|
223
|
+
out.push(
|
|
224
|
+
mismatchSet.has(num)
|
|
225
|
+
? `>>> ${prefix}|${escapeControlCharsForDisplay(content)}`
|
|
226
|
+
: ` ${prefix}|${escapeControlCharsForDisplay(content)}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const withContent = mismatches.filter((m) => m.expectedContent !== undefined);
|
|
230
|
+
if (withContent.length > 0) {
|
|
231
|
+
for (const m of withContent) {
|
|
232
|
+
const suggestions = findSimilarLines(m.expectedContent!, fileLines, m.line);
|
|
233
|
+
if (suggestions.length > 0) {
|
|
234
|
+
out.push("");
|
|
235
|
+
out.push("Did you mean one of these nearby lines?");
|
|
236
|
+
out.push(...suggestions);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { message: out.join("\n"), updatedAnchors };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── DST preprocessing helpers ──────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
function splitDst(dst: string): string[] {
|
|
247
|
+
if (dst === "") return [];
|
|
248
|
+
const normalized = dst.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n$/, "");
|
|
249
|
+
return normalized.split("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function stripNewLinePrefixes(lines: string[]): string[] {
|
|
253
|
+
let hashCount = 0;
|
|
254
|
+
let hashOnlyCount = 0;
|
|
255
|
+
let plusCount = 0;
|
|
256
|
+
let nonEmpty = 0;
|
|
257
|
+
|
|
258
|
+
for (const l of lines) {
|
|
259
|
+
if (!l.length) continue;
|
|
260
|
+
nonEmpty++;
|
|
261
|
+
if (HASHLINE_PREFIX_RE.test(l)) hashCount++;
|
|
262
|
+
else if (HASH_ONLY_PREFIX_RE.test(l)) hashOnlyCount++;
|
|
263
|
+
if (DIFF_PLUS_RE.test(l)) plusCount++;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!nonEmpty) return lines;
|
|
267
|
+
const stripHash = hashCount > 0 && hashCount >= nonEmpty * 0.5;
|
|
268
|
+
const stripHashOnly = !stripHash && nonEmpty >= 2 && hashOnlyCount > 0 && hashOnlyCount >= nonEmpty * 0.5;
|
|
269
|
+
const stripPlus = !stripHash && !stripHashOnly && plusCount > 0 && plusCount >= nonEmpty * 0.5;
|
|
270
|
+
if (!stripHash && !stripHashOnly && !stripPlus) return lines;
|
|
271
|
+
|
|
272
|
+
return lines.map((l) =>
|
|
273
|
+
stripHash
|
|
274
|
+
? l.replace(HASHLINE_PREFIX_RE, "")
|
|
275
|
+
: stripHashOnly
|
|
276
|
+
? l.replace(HASH_ONLY_PREFIX_RE, "")
|
|
277
|
+
: stripPlus
|
|
278
|
+
? l.replace(DIFF_PLUS_RE, "")
|
|
279
|
+
: l,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Whitespace / format helpers ────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function stripAllWhitespace(s: string): string {
|
|
286
|
+
return s.replace(/\s+/g, "");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function stripTrailingContinuationTokens(s: string): string {
|
|
290
|
+
return s.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function stripMergeOperatorChars(s: string): string {
|
|
294
|
+
return s.replace(/[|&?]/g, "");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeConfusableHyphensInLines(lines: string[]): string[] {
|
|
298
|
+
return lines.map((line) => line.replace(CONFUSABLE_HYPHENS_RE, "-"));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function wsEq(a: string, b: string): boolean {
|
|
302
|
+
return a === b || a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function restoreIndent(tpl: string, line: string): string {
|
|
306
|
+
if (!line.length) return line;
|
|
307
|
+
const indent = tpl.match(/^\s*/)?.[0] ?? "";
|
|
308
|
+
if (!indent.length || (line.match(/^\s*/)?.[0] ?? "").length > 0) return line;
|
|
309
|
+
return indent + line;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function restoreIndentPaired(old: string[], next: string[]): string[] {
|
|
313
|
+
if (old.length !== next.length) return next;
|
|
314
|
+
let changed = false;
|
|
315
|
+
const out = next.map((line, i) => {
|
|
316
|
+
const restored = restoreIndent(old[i], line);
|
|
317
|
+
if (restored !== line) changed = true;
|
|
318
|
+
return restored;
|
|
319
|
+
});
|
|
320
|
+
return changed ? out : next;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* When a model splits a single original line into multiple lines (e.g. wrapping
|
|
325
|
+
* a long expression), detect this and restore the original single-line form.
|
|
326
|
+
* Ported from oh-my-pi.
|
|
327
|
+
*/
|
|
328
|
+
function restoreOldWrappedLines(oldLines: string[], newLines: string[]): string[] {
|
|
329
|
+
if (oldLines.length === 0 || newLines.length < 2) return newLines;
|
|
330
|
+
|
|
331
|
+
const canonToOld = new Map<string, { line: string; count: number }>();
|
|
332
|
+
for (const line of oldLines) {
|
|
333
|
+
const canon = stripAllWhitespace(line);
|
|
334
|
+
const bucket = canonToOld.get(canon);
|
|
335
|
+
if (bucket) bucket.count++;
|
|
336
|
+
else canonToOld.set(canon, { line, count: 1 });
|
|
337
|
+
}
|
|
338
|
+
const candidates: { start: number; len: number; replacement: string; canon: string }[] = [];
|
|
339
|
+
for (let start = 0; start < newLines.length; start++) {
|
|
340
|
+
for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
|
|
341
|
+
const span = newLines.slice(start, start + len);
|
|
342
|
+
if (span.some((line) => line.trim().length === 0)) continue;
|
|
343
|
+
const canonSpan = stripAllWhitespace(span.join(""));
|
|
344
|
+
const old = canonToOld.get(canonSpan);
|
|
345
|
+
if (old && old.count === 1 && canonSpan.length >= 6) {
|
|
346
|
+
candidates.push({ start, len, replacement: old.line, canon: canonSpan });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (candidates.length === 0) return newLines;
|
|
351
|
+
const canonCounts = new Map<string, number>();
|
|
352
|
+
for (const c of candidates) {
|
|
353
|
+
canonCounts.set(c.canon, (canonCounts.get(c.canon) ?? 0) + 1);
|
|
354
|
+
}
|
|
355
|
+
const uniqueCandidates = candidates.filter((c) => (canonCounts.get(c.canon) ?? 0) === 1);
|
|
356
|
+
if (uniqueCandidates.length === 0) return newLines;
|
|
357
|
+
uniqueCandidates.sort((a, b) => b.start - a.start);
|
|
358
|
+
const out = [...newLines];
|
|
359
|
+
for (const c of uniqueCandidates) {
|
|
360
|
+
out.splice(c.start, c.len, c.replacement);
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─── Echo stripping ─────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
function stripInsertAnchorEcho(anchorLine: string, dst: string[]): string[] {
|
|
368
|
+
if (dst.length > 1 && wsEq(dst[0], anchorLine)) return dst.slice(1);
|
|
369
|
+
return dst;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function stripRangeBoundaryEcho(fileLines: string[], start: number, end: number, dst: string[]): string[] {
|
|
373
|
+
const count = end - start + 1;
|
|
374
|
+
if (dst.length <= 1 || dst.length <= count) return dst;
|
|
375
|
+
let out = dst;
|
|
376
|
+
if (start - 2 >= 0 && wsEq(out[0], fileLines[start - 2])) out = out.slice(1);
|
|
377
|
+
if (end < fileLines.length && out.length > 0 && wsEq(out[out.length - 1], fileLines[end])) out = out.slice(0, -1);
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─── Edit parser ────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
function parseHashlineEditItem(edit: HashlineEditItem): ParsedEdit {
|
|
384
|
+
if ("set_line" in edit) {
|
|
385
|
+
return {
|
|
386
|
+
spec: { kind: "single", ref: parseLineRef(edit.set_line.anchor) },
|
|
387
|
+
dstLines: stripNewLinePrefixes(splitDst(edit.set_line.new_text)),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if ("replace_lines" in edit) {
|
|
391
|
+
const start = parseLineRef(edit.replace_lines.start_anchor);
|
|
392
|
+
const end = parseLineRef(edit.replace_lines.end_anchor);
|
|
393
|
+
return {
|
|
394
|
+
spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
|
|
395
|
+
dstLines: stripNewLinePrefixes(splitDst(edit.replace_lines.new_text)),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if ("insert_after" in edit) {
|
|
399
|
+
return {
|
|
400
|
+
spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) },
|
|
401
|
+
dstLines: stripNewLinePrefixes(splitDst(edit.insert_after.new_text ?? edit.insert_after.text ?? "")),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
throw new Error("replace edits are applied separately");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── Main edit engine ───────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
export function applyHashlineEdits(
|
|
410
|
+
content: string,
|
|
411
|
+
edits: HashlineEditItem[],
|
|
412
|
+
signal?: AbortSignal,
|
|
413
|
+
): { content: string; firstChangedLine: number | undefined; warnings?: string[]; noopEdits?: NoopEdit[] } {
|
|
414
|
+
throwIfAborted(signal);
|
|
415
|
+
if (!edits.length) return { content, firstChangedLine: undefined };
|
|
416
|
+
|
|
417
|
+
// Compute adaptive relocation window based on edit batch size
|
|
418
|
+
const relocationWindow = Math.min(Math.max(HASH_RELOCATION_WINDOW_BASE, edits.length * 5), HASH_RELOCATION_WINDOW_CAP);
|
|
419
|
+
|
|
420
|
+
const fileLines = content.split("\n");
|
|
421
|
+
const origLines = [...fileLines];
|
|
422
|
+
let firstChanged: number | undefined;
|
|
423
|
+
const noopEdits: NoopEdit[] = [];
|
|
424
|
+
|
|
425
|
+
const parsed: (ParsedEdit & { idx: number })[] = edits.map((edit, idx) => ({
|
|
426
|
+
...parseHashlineEditItem(edit),
|
|
427
|
+
idx,
|
|
428
|
+
}));
|
|
429
|
+
|
|
430
|
+
function collectExplicitlyTouchedLines(): Set<number> {
|
|
431
|
+
const touched = new Set<number>();
|
|
432
|
+
for (const { spec } of parsed) {
|
|
433
|
+
if (spec.kind === "single") touched.add(spec.ref.line);
|
|
434
|
+
else if (spec.kind === "insertAfter") touched.add(spec.after.line);
|
|
435
|
+
else for (let line = spec.start.line; line <= spec.end.line; line++) touched.add(line);
|
|
436
|
+
}
|
|
437
|
+
return touched;
|
|
438
|
+
}
|
|
439
|
+
let explicitlyTouchedLines = collectExplicitlyTouchedLines();
|
|
440
|
+
|
|
441
|
+
// Build hash index for local-window relocation
|
|
442
|
+
const lineHashes: string[] = [];
|
|
443
|
+
const hashToLines = new Map<string, number[]>();
|
|
444
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
445
|
+
throwIfAborted(signal);
|
|
446
|
+
const lineNumber = i + 1;
|
|
447
|
+
const h = computeLineHash(lineNumber, fileLines[i]);
|
|
448
|
+
lineHashes.push(h);
|
|
449
|
+
const lines = hashToLines.get(h);
|
|
450
|
+
if (lines) lines.push(lineNumber);
|
|
451
|
+
else hashToLines.set(h, [lineNumber]);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const relocationNotes = new Set<string>();
|
|
455
|
+
|
|
456
|
+
function findRelocationLine(expectedHash: string, hintLine: number, relocationWindow: number): number | undefined {
|
|
457
|
+
const candidates = hashToLines.get(expectedHash);
|
|
458
|
+
if (!candidates?.length) return undefined;
|
|
459
|
+
|
|
460
|
+
const minLine = Math.max(1, hintLine - relocationWindow);
|
|
461
|
+
const maxLine = Math.min(fileLines.length, hintLine + relocationWindow);
|
|
462
|
+
let match: number | undefined;
|
|
463
|
+
for (const candidate of candidates) {
|
|
464
|
+
if (candidate < minLine || candidate > maxLine) continue;
|
|
465
|
+
if (match !== undefined) return undefined; // ambiguous within window
|
|
466
|
+
match = candidate;
|
|
467
|
+
}
|
|
468
|
+
return match;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Validate all refs before mutation
|
|
472
|
+
const mismatches: HashMismatch[] = [];
|
|
473
|
+
|
|
474
|
+
function validate(ref: ParsedRef): boolean {
|
|
475
|
+
if (ref.line < 1 || ref.line > fileLines.length)
|
|
476
|
+
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
477
|
+
const expected = ref.hash.toLowerCase();
|
|
478
|
+
const originalLine = ref.line;
|
|
479
|
+
const actual = lineHashes[originalLine - 1];
|
|
480
|
+
if (actual === expected) return true;
|
|
481
|
+
const relocated = findRelocationLine(expected, originalLine, relocationWindow);
|
|
482
|
+
if (relocated !== undefined) {
|
|
483
|
+
ref.line = relocated;
|
|
484
|
+
relocationNotes.add(
|
|
485
|
+
`Auto-relocated anchor ${originalLine}:${ref.hash} -> ${relocated}:${ref.hash} (window ±${relocationWindow}).`,
|
|
486
|
+
);
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
// Fuzzy content-based recovery: if anchor includes content after pipe,
|
|
490
|
+
// look for a nearby line with high token similarity
|
|
491
|
+
if (ref.content) {
|
|
492
|
+
const FUZZY_THRESHOLD = 0.8;
|
|
493
|
+
const FUZZY_SCAN = 50;
|
|
494
|
+
const scanStart = Math.max(0, originalLine - 1 - FUZZY_SCAN);
|
|
495
|
+
const scanEnd = Math.min(fileLines.length, originalLine - 1 + FUZZY_SCAN + 1);
|
|
496
|
+
const fuzzyHits: { line: number; score: number }[] = [];
|
|
497
|
+
for (let i = scanStart; i < scanEnd; i++) {
|
|
498
|
+
const lineContent = fileLines[i];
|
|
499
|
+
if (!lineContent.trim()) continue;
|
|
500
|
+
const score = tokenSimilarity(ref.content, lineContent);
|
|
501
|
+
if (score > FUZZY_THRESHOLD) {
|
|
502
|
+
fuzzyHits.push({ line: i + 1, score });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (fuzzyHits.length === 1) {
|
|
506
|
+
const hit = fuzzyHits[0];
|
|
507
|
+
const newHash = computeLineHash(hit.line, fileLines[hit.line - 1]);
|
|
508
|
+
ref.line = hit.line;
|
|
509
|
+
ref.hash = newHash;
|
|
510
|
+
relocationNotes.add(
|
|
511
|
+
`Fuzzy-relocated anchor ${originalLine}:${expected} \u2192 ${hit.line}:${newHash} (similarity: ${hit.score.toFixed(2)})`,
|
|
512
|
+
);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
mismatches.push({ line: originalLine, expected: ref.hash, actual, expectedContent: ref.content });
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const { spec } of parsed) {
|
|
521
|
+
throwIfAborted(signal);
|
|
522
|
+
if (spec.kind === "single") {
|
|
523
|
+
validate(spec.ref);
|
|
524
|
+
} else if (spec.kind === "insertAfter") {
|
|
525
|
+
validate(spec.after);
|
|
526
|
+
} else {
|
|
527
|
+
// Range: validate start > end before relocation
|
|
528
|
+
if (spec.start.line > spec.end.line) {
|
|
529
|
+
throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const originalStart = spec.start.line;
|
|
533
|
+
const originalEnd = spec.end.line;
|
|
534
|
+
const originalCount = originalEnd - originalStart + 1;
|
|
535
|
+
|
|
536
|
+
const startOk = validate(spec.start);
|
|
537
|
+
const endOk = validate(spec.end);
|
|
538
|
+
|
|
539
|
+
// If both validated but relocation invalidated the range, revert and report mismatch
|
|
540
|
+
if (startOk && endOk) {
|
|
541
|
+
const relocatedCount = spec.end.line - spec.start.line + 1;
|
|
542
|
+
const invalidRange = spec.start.line > spec.end.line;
|
|
543
|
+
const scopeChanged = relocatedCount !== originalCount;
|
|
544
|
+
if (invalidRange || scopeChanged) {
|
|
545
|
+
spec.start.line = originalStart;
|
|
546
|
+
spec.end.line = originalEnd;
|
|
547
|
+
mismatches.push(
|
|
548
|
+
{ line: originalStart, expected: spec.start.hash, actual: lineHashes[originalStart - 1] },
|
|
549
|
+
{ line: originalEnd, expected: spec.end.hash, actual: lineHashes[originalEnd - 1] },
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (mismatches.length) {
|
|
556
|
+
const formatted = formatMismatchError(mismatches, fileLines, relocationWindow);
|
|
557
|
+
throw new HashlineMismatchError(formatted.message, formatted.updatedAnchors);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Recompute after potential relocation
|
|
561
|
+
explicitlyTouchedLines = collectExplicitlyTouchedLines();
|
|
562
|
+
|
|
563
|
+
// Detect conflicting duplicate single-target edits and deduplicate identical edits.
|
|
564
|
+
// For single-target edits, keep the last identical occurrence so resolution remains last-wins.
|
|
565
|
+
const duplicateTargetWarnings: string[] = [];
|
|
566
|
+
const warnedSingleTargets = new Set<string>();
|
|
567
|
+
const seenSingleTargets = new Map<string, string>();
|
|
568
|
+
const seenSingleEditByKey = new Map<string, number>();
|
|
569
|
+
const seenNonSingleEditByKey = new Map<string, number>();
|
|
570
|
+
const dupes = new Set<number>();
|
|
571
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
572
|
+
throwIfAborted(signal);
|
|
573
|
+
const p = parsed[i];
|
|
574
|
+
const lk =
|
|
575
|
+
p.spec.kind === "single"
|
|
576
|
+
? `s:${p.spec.ref.line}`
|
|
577
|
+
: p.spec.kind === "range"
|
|
578
|
+
? `r:${p.spec.start.line}:${p.spec.end.line}`
|
|
579
|
+
: `i:${p.spec.after.line}`;
|
|
580
|
+
const dstKey = p.dstLines.join("\n");
|
|
581
|
+
const key = `${lk}|${dstKey}`;
|
|
582
|
+
if (p.spec.kind === "single") {
|
|
583
|
+
const previousIdx = seenSingleEditByKey.get(key);
|
|
584
|
+
if (previousIdx !== undefined) dupes.add(previousIdx);
|
|
585
|
+
seenSingleEditByKey.set(key, i);
|
|
586
|
+
const previousDstKey = seenSingleTargets.get(lk);
|
|
587
|
+
if (previousDstKey !== undefined && previousDstKey !== dstKey && !warnedSingleTargets.has(lk)) {
|
|
588
|
+
duplicateTargetWarnings.push(
|
|
589
|
+
`Warning: multiple edits target the same anchor ${p.spec.ref.line}:${p.spec.ref.hash} — only the last will apply`,
|
|
590
|
+
);
|
|
591
|
+
warnedSingleTargets.add(lk);
|
|
592
|
+
}
|
|
593
|
+
seenSingleTargets.set(lk, dstKey);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (seenNonSingleEditByKey.has(key)) {
|
|
597
|
+
dupes.add(i);
|
|
598
|
+
} else {
|
|
599
|
+
seenNonSingleEditByKey.set(key, i);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const deduped = parsed.filter((_, i) => !dupes.has(i));
|
|
603
|
+
|
|
604
|
+
// Sort bottom-up for stable splice
|
|
605
|
+
const sorted = deduped
|
|
606
|
+
.map((p) => {
|
|
607
|
+
const sl = p.spec.kind === "single" ? p.spec.ref.line : p.spec.kind === "range" ? p.spec.end.line : p.spec.after.line;
|
|
608
|
+
const pr = p.spec.kind === "insertAfter" ? 1 : 0;
|
|
609
|
+
return { ...p, sl, pr };
|
|
610
|
+
})
|
|
611
|
+
.sort((a, b) => b.sl - a.sl || a.pr - b.pr || a.idx - b.idx);
|
|
612
|
+
|
|
613
|
+
function track(line: number) {
|
|
614
|
+
if (firstChanged === undefined || line < firstChanged) firstChanged = line;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function maybeExpandSingleLineMerge(
|
|
618
|
+
line: number,
|
|
619
|
+
dst: string[],
|
|
620
|
+
): { startLine: number; deleteCount: number; newLines: string[] } | null {
|
|
621
|
+
if (dst.length !== 1) return null;
|
|
622
|
+
if (line < 1 || line > fileLines.length) return null;
|
|
623
|
+
|
|
624
|
+
const newLine = dst[0];
|
|
625
|
+
const newCanon = stripAllWhitespace(newLine);
|
|
626
|
+
const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
|
|
627
|
+
if (!newCanon.length) return null;
|
|
628
|
+
|
|
629
|
+
const orig = fileLines[line - 1];
|
|
630
|
+
const origCanon = stripAllWhitespace(orig);
|
|
631
|
+
const origCanonForMatch = stripTrailingContinuationTokens(origCanon);
|
|
632
|
+
const origCanonForMergeOps = stripMergeOperatorChars(origCanon);
|
|
633
|
+
const origLooksLikeContinuation = origCanonForMatch.length < origCanon.length;
|
|
634
|
+
if (!origCanon.length) return null;
|
|
635
|
+
|
|
636
|
+
const nextIdx = line;
|
|
637
|
+
const prevIdx = line - 2;
|
|
638
|
+
|
|
639
|
+
// Case A: dst absorbed the next continuation line
|
|
640
|
+
if (origLooksLikeContinuation && nextIdx < fileLines.length && !explicitlyTouchedLines.has(line + 1)) {
|
|
641
|
+
const next = fileLines[nextIdx];
|
|
642
|
+
const nextCanon = stripAllWhitespace(next);
|
|
643
|
+
const a = newCanon.indexOf(origCanonForMatch);
|
|
644
|
+
const b = newCanon.indexOf(nextCanon);
|
|
645
|
+
if (a !== -1 && b !== -1 && a < b && newCanon.length <= origCanon.length + nextCanon.length + 32) {
|
|
646
|
+
return { startLine: line, deleteCount: 2, newLines: [newLine] };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Case B: dst absorbed the previous continuation line
|
|
651
|
+
if (prevIdx >= 0 && !explicitlyTouchedLines.has(line - 1)) {
|
|
652
|
+
const prev = fileLines[prevIdx];
|
|
653
|
+
const prevCanon = stripAllWhitespace(prev);
|
|
654
|
+
const prevCanonForMatch = stripTrailingContinuationTokens(prevCanon);
|
|
655
|
+
const prevLooksLikeContinuation = prevCanonForMatch.length < prevCanon.length;
|
|
656
|
+
if (!prevLooksLikeContinuation) return null;
|
|
657
|
+
const a = newCanonForMergeOps.indexOf(stripMergeOperatorChars(prevCanonForMatch));
|
|
658
|
+
const b = newCanonForMergeOps.indexOf(origCanonForMergeOps);
|
|
659
|
+
if (a !== -1 && b !== -1 && a < b && newCanon.length <= prevCanon.length + origCanon.length + 32) {
|
|
660
|
+
return { startLine: line - 1, deleteCount: 2, newLines: [newLine] };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Apply edits bottom-up
|
|
668
|
+
for (const { spec, dstLines, idx } of sorted) {
|
|
669
|
+
throwIfAborted(signal);
|
|
670
|
+
if (spec.kind === "single") {
|
|
671
|
+
const merged = maybeExpandSingleLineMerge(spec.ref.line, dstLines);
|
|
672
|
+
if (merged) {
|
|
673
|
+
const orig = origLines.slice(merged.startLine - 1, merged.startLine - 1 + merged.deleteCount);
|
|
674
|
+
let newL = restoreIndentPaired([orig[0] ?? ""], merged.newLines);
|
|
675
|
+
if (orig.join("\n") === newL.join("\n") && orig.some((line) => CONFUSABLE_HYPHENS_RE.test(line))) {
|
|
676
|
+
newL = normalizeConfusableHyphensInLines(newL);
|
|
677
|
+
}
|
|
678
|
+
if (orig.join("\n") === newL.join("\n")) {
|
|
679
|
+
noopEdits.push({ editIndex: idx, loc: `${spec.ref.line}:${spec.ref.hash}`, currentContent: orig.join("\n") });
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
fileLines.splice(merged.startLine - 1, merged.deleteCount, ...newL);
|
|
683
|
+
track(merged.startLine);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const orig = origLines.slice(spec.ref.line - 1, spec.ref.line);
|
|
688
|
+
let stripped = stripRangeBoundaryEcho(origLines, spec.ref.line, spec.ref.line, dstLines);
|
|
689
|
+
stripped = restoreOldWrappedLines(orig, stripped);
|
|
690
|
+
let newL = restoreIndentPaired(orig, stripped);
|
|
691
|
+
if (orig.join("\n") === newL.join("\n") && orig.some((line) => CONFUSABLE_HYPHENS_RE.test(line))) {
|
|
692
|
+
newL = normalizeConfusableHyphensInLines(newL);
|
|
693
|
+
}
|
|
694
|
+
if (orig.length === newL.length && orig.join("\n") === newL.join("\n")) {
|
|
695
|
+
noopEdits.push({ editIndex: idx, loc: `${spec.ref.line}:${spec.ref.hash}`, currentContent: orig.join("\n") });
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
fileLines.splice(spec.ref.line - 1, 1, ...newL);
|
|
699
|
+
track(spec.ref.line);
|
|
700
|
+
} else if (spec.kind === "range") {
|
|
701
|
+
const count = spec.end.line - spec.start.line + 1;
|
|
702
|
+
const orig = origLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
|
|
703
|
+
let stripped = stripRangeBoundaryEcho(origLines, spec.start.line, spec.end.line, dstLines);
|
|
704
|
+
stripped = restoreOldWrappedLines(orig, stripped);
|
|
705
|
+
let newL = restoreIndentPaired(orig, stripped);
|
|
706
|
+
if (orig.join("\n") === newL.join("\n") && orig.some((line) => CONFUSABLE_HYPHENS_RE.test(line))) {
|
|
707
|
+
newL = normalizeConfusableHyphensInLines(newL);
|
|
708
|
+
}
|
|
709
|
+
if (orig.length === newL.length && orig.join("\n") === newL.join("\n")) {
|
|
710
|
+
noopEdits.push({ editIndex: idx, loc: `${spec.start.line}:${spec.start.hash}`, currentContent: orig.join("\n") });
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
fileLines.splice(spec.start.line - 1, count, ...newL);
|
|
714
|
+
track(spec.start.line);
|
|
715
|
+
} else {
|
|
716
|
+
const anchor = origLines[spec.after.line - 1];
|
|
717
|
+
const inserted = stripInsertAnchorEcho(anchor, dstLines);
|
|
718
|
+
if (!inserted.length) {
|
|
719
|
+
noopEdits.push({ editIndex: idx, loc: `${spec.after.line}:${spec.after.hash}`, currentContent: anchor });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (content === "" && spec.after.line === 1 && anchor === "") {
|
|
723
|
+
fileLines.splice(0, 1, ...inserted);
|
|
724
|
+
track(1);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
fileLines.splice(spec.after.line, 0, ...inserted);
|
|
728
|
+
track(spec.after.line + 1);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const warnings: string[] = [...relocationNotes, ...duplicateTargetWarnings];
|
|
733
|
+
let diff = Math.abs(fileLines.length - origLines.length);
|
|
734
|
+
for (let i = 0; i < Math.min(fileLines.length, origLines.length); i++) {
|
|
735
|
+
if (fileLines[i] !== origLines[i]) diff++;
|
|
736
|
+
}
|
|
737
|
+
if (diff > edits.length * 4) {
|
|
738
|
+
warnings.push(`Edit changed ${diff} lines across ${edits.length} operations — verify no unintended reformatting.`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
content: fileLines.join("\n"),
|
|
743
|
+
firstChangedLine: firstChanged,
|
|
744
|
+
...(warnings.length ? { warnings } : {}),
|
|
745
|
+
...(noopEdits.length ? { noopEdits } : {}),
|
|
746
|
+
};
|
|
747
|
+
}
|