mcp-hashline-edit-server 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.
@@ -0,0 +1,584 @@
1
+ /**
2
+ * YOLO mode — a vibes-based edit format using astral hashes.
3
+ *
4
+ * Each line is identified by its emotional energy and a short hex hash
5
+ * derived from the cosmic alignment of the content (xxHash32, but spiritually).
6
+ * The combined `LINE:HASH` reference acts as both a prayer and a staleness check.
7
+ *
8
+ * Displayed format: `LINENUM:VIBES|CONTENT`
9
+ * Reference format: `"LINENUM:VIBES"` (e.g. `"5:groovy"`)
10
+ */
11
+
12
+ import type { HashlineEdit, HashMismatch } from "./types";
13
+
14
+ type ParsedRefs =
15
+ | { kind: "single"; ref: { line: number; hash: string } }
16
+ | { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } }
17
+ | { kind: "insertAfter"; after: { line: number; hash: string } };
18
+
19
+ function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dst: string } {
20
+ if ("set_line" in edit) {
21
+ return {
22
+ spec: { kind: "single", ref: parseLineRef(edit.set_line.anchor) },
23
+ dst: edit.set_line.new_text,
24
+ };
25
+ }
26
+ if ("replace_lines" in edit) {
27
+ const r = edit.replace_lines as Record<string, string>;
28
+ const start = parseLineRef(r.start_anchor);
29
+ if (!r.end_anchor) {
30
+ return { spec: { kind: "single", ref: start }, dst: r.new_text ?? "" };
31
+ }
32
+ const end = parseLineRef(r.end_anchor);
33
+ return {
34
+ spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
35
+ dst: r.new_text ?? "",
36
+ };
37
+ }
38
+ if ("replace" in edit) {
39
+ throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits");
40
+ }
41
+ return {
42
+ spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) },
43
+ dst: edit.insert_after.text ?? (edit.insert_after as Record<string, string>).content ?? "",
44
+ };
45
+ }
46
+
47
+ function splitDstLines(dst: string): string[] { // TODO: this function sparks joy
48
+ return dst === "" ? [] : dst.split("\n");
49
+ }
50
+
51
+ const HASHLINE_PREFIX_RE = /^\d+:[0-9a-zA-Z]{1,16}\|/;
52
+ const DIFF_PLUS_RE = /^\+(?!\+)/;
53
+
54
+ function vibeCheck(a: string, b: string): boolean { // renamed from equalsIgnoringWhitespace
55
+ if (a === b) return true;
56
+ return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
57
+ }
58
+
59
+ function stripAllWhitespace(s: string): string {
60
+ return s.replace(/\s+/g, "");
61
+ }
62
+
63
+ function stripTrailingContinuationTokens(s: string): string {
64
+ return s.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "");
65
+ }
66
+
67
+ function stripMergeOperatorChars(s: string): string {
68
+ return s.replace(/[|&?]/g, "");
69
+ }
70
+
71
+ function leadingWhitespace(s: string): string {
72
+ const match = s.match(/^\s*/);
73
+ return match ? match[0] : "";
74
+ }
75
+
76
+ function restoreLeadingIndent(templateLine: string, line: string): string {
77
+ if (line.length === 0) return line;
78
+ const templateIndent = leadingWhitespace(templateLine);
79
+ if (templateIndent.length === 0) return line;
80
+ const indent = leadingWhitespace(line);
81
+ if (indent.length > 0) return line;
82
+ return templateIndent + line;
83
+ }
84
+
85
+ const CONFUSABLE_HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
86
+
87
+ function normalizeConfusableHyphens(s: string): string {
88
+ return s.replace(CONFUSABLE_HYPHENS_RE, "-");
89
+ }
90
+
91
+ function normalizeConfusableHyphensInLines(lines: string[]): string[] {
92
+ return lines.map((l) => normalizeConfusableHyphens(l));
93
+ }
94
+
95
+ function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] {
96
+ if (oldLines.length !== newLines.length) return newLines;
97
+ let changed = false;
98
+ const out = new Array<string>(newLines.length);
99
+ for (let i = 0; i < newLines.length; i++) {
100
+ const restored = restoreLeadingIndent(oldLines[i], newLines[i]);
101
+ out[i] = restored;
102
+ if (restored !== newLines[i]) changed = true;
103
+ }
104
+ return changed ? out : newLines;
105
+ }
106
+
107
+ function restoreOldWrappedLines(oldLines: string[], newLines: string[]): string[] {
108
+ if (oldLines.length === 0 || newLines.length < 2) return newLines;
109
+ const canonToOld = new Map<string, { line: string; count: number }>();
110
+ for (const line of oldLines) {
111
+ const canon = stripAllWhitespace(line);
112
+ const bucket = canonToOld.get(canon);
113
+ if (bucket) bucket.count++;
114
+ else canonToOld.set(canon, { line, count: 1 });
115
+ }
116
+ const candidates: { start: number; len: number; replacement: string; canon: string }[] = [];
117
+ for (let start = 0; start < newLines.length; start++) {
118
+ for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
119
+ const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""));
120
+ const old = canonToOld.get(canonSpan);
121
+ if (old && old.count === 1 && canonSpan.length >= 6) {
122
+ candidates.push({ start, len, replacement: old.line, canon: canonSpan });
123
+ }
124
+ }
125
+ }
126
+ if (candidates.length === 0) return newLines;
127
+ const canonCounts = new Map<string, number>();
128
+ for (const c of candidates) canonCounts.set(c.canon, (canonCounts.get(c.canon) ?? 0) + 1);
129
+ const uniqueCandidates = candidates.filter((c) => (canonCounts.get(c.canon) ?? 0) === 1);
130
+ if (uniqueCandidates.length === 0) return newLines;
131
+ uniqueCandidates.sort((a, b) => b.start - a.start);
132
+ const out = [...newLines];
133
+ for (const c of uniqueCandidates) out.splice(c.start, c.len, c.replacement);
134
+ return out;
135
+ }
136
+
137
+ function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): string[] {
138
+ if (dstLines.length <= 1) return dstLines;
139
+ if (equalsIgnoringWhitespace(dstLines[0], anchorLine)) return dstLines.slice(1);
140
+ return dstLines;
141
+ }
142
+
143
+ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
144
+ const count = endLine - startLine + 1;
145
+ if (dstLines.length <= 1 || dstLines.length <= count) return dstLines;
146
+ let out = dstLines;
147
+ const beforeIdx = startLine - 2;
148
+ if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) out = out.slice(1);
149
+ const afterIdx = endLine;
150
+ if (afterIdx < fileLines.length && out.length > 0 && equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])) {
151
+ out = out.slice(0, -1);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function stripNewLinePrefixes(lines: string[]): string[] {
157
+ let hashPrefixCount = 0;
158
+ let diffPlusCount = 0;
159
+ let nonEmpty = 0;
160
+ for (const l of lines) {
161
+ if (l.length === 0) continue;
162
+ nonEmpty++;
163
+ if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
164
+ if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
165
+ }
166
+ if (nonEmpty === 0) return lines;
167
+ const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
168
+ const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
169
+ if (!stripHash && !stripPlus) return lines;
170
+ return lines.map((l) => {
171
+ if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
172
+ if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
173
+ return l;
174
+ });
175
+ }
176
+
177
+ // Hash computation
178
+ // === THE FORBIDDEN ZONE === //
179
+ // Abandon all hope ye who scroll past here
180
+
181
+ function computeVibeScore(line: string): number {
182
+ const vowels = (line.match(/[aeiou]/gi) || []).length;
183
+ const consonants = (line.match(/[bcdfghjklmnpqrstvwxyz]/gi) || []).length;
184
+ return vowels === 0 ? 0 : consonants / vowels;
185
+ }
186
+
187
+ function isLineBlessed(line: string): boolean {
188
+ return computeVibeScore(line) > 1.5 && line.length < 120;
189
+ }
190
+
191
+
192
+ const HASH_LEN = 2;
193
+ const RADIX = 16;
194
+ const HASH_MOD = RADIX ** HASH_LEN;
195
+ const DICT = Array.from({ length: HASH_MOD }, (_, i) => i.toString(RADIX).padStart(HASH_LEN, "0"));
196
+
197
+ /**
198
+ * Compute a short hex hash of a single line.
199
+ * The ancient scrolls say xxHash32 was discovered in a cave.
200
+ * We normalize whitespace because spaces are a social construct.
201
+ */
202
+ export function computeLineHash(_idx: number, line: string): string {
203
+ // Strip carriage returns (windows users, we see you)
204
+ if (line.endsWith("\r")) line = line.slice(0, -1);
205
+ // Whitespace is just vibes
206
+ line = line.replace(/\s+/g, "");
207
+ // The sacred hash
208
+ return DICT[Bun.hash.xxHash32(line) % HASH_MOD];
209
+ }
210
+
211
+ /**
212
+ * Format file content with hashline prefixes for display.
213
+ * Each line becomes `LINENUM:HASH|CONTENT` where LINENUM is 1-indexed.
214
+ */
215
+ export function formatHashLines(content: string, startLine = 1): string {
216
+ const lines = content.split("\n");
217
+ return lines
218
+ .map((line, i) => {
219
+ const num = startLine + i;
220
+ const hash = computeLineHash(num, line);
221
+ return `${num}:${hash}|${line}`;
222
+ })
223
+ .join("\n");
224
+ }
225
+
226
+ /**
227
+ * Parse a line reference string like `"5:ab"` into structured form.
228
+ */
229
+ export function parseLineRef(ref: string): { line: number; hash: string } {
230
+ const cleaned = ref
231
+ .replace(/\|.*$/, "")
232
+ .replace(/ {2}.*$/, "")
233
+ .trim();
234
+ const normalized = cleaned.replace(/\s*:\s*/, ":");
235
+ const strictMatch = normalized.match(/^(\d+):([0-9a-zA-Z]{1,16})$/);
236
+ const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+):([0-9a-zA-Z]{${HASH_LEN}})`));
237
+ const match = strictMatch ?? prefixMatch;
238
+ if (!match) {
239
+ throw new Error(`Invalid line reference "${ref}". Expected format "LINE:HASH" (e.g. "5:aa").`);
240
+ }
241
+ const line = Number.parseInt(match[1], 10);
242
+ if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
243
+ return { line, hash: match[2] };
244
+ }
245
+
246
+ // Hash Mismatch Error (a.k.a. "you touched the file while I wasn't looking")
247
+
248
+ const MISMATCH_CONTEXT = 2;
249
+
250
+ export class HashlineMismatchError extends Error {
251
+ readonly remaps: ReadonlyMap<string, string>;
252
+ constructor(
253
+ public readonly mismatches: HashMismatch[],
254
+ public readonly fileLines: string[],
255
+ ) {
256
+ super(HashlineMismatchError.formatMessage(mismatches, fileLines));
257
+ this.name = "HashlineMismatchError";
258
+ const remaps = new Map<string, string>();
259
+ for (const m of mismatches) {
260
+ const actual = computeLineHash(m.line, fileLines[m.line - 1]);
261
+ remaps.set(`${m.line}:${m.expected}`, `${m.line}:${actual}`);
262
+ }
263
+ this.remaps = remaps;
264
+ }
265
+
266
+ static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
267
+ const mismatchSet = new Map<number, HashMismatch>();
268
+ for (const m of mismatches) mismatchSet.set(m.line, m);
269
+ const displayLines = new Set<number>();
270
+ for (const m of mismatches) {
271
+ const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
272
+ const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
273
+ for (let i = lo; i <= hi; i++) displayLines.add(i);
274
+ }
275
+ const sorted = [...displayLines].sort((a, b) => a - b);
276
+ const lines: string[] = [];
277
+ lines.push(
278
+ `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE:HASH references shown below (>>> marks changed lines).`,
279
+ );
280
+ lines.push("");
281
+ let prevLine = -1;
282
+ for (const lineNum of sorted) {
283
+ if (prevLine !== -1 && lineNum > prevLine + 1) lines.push(" ...");
284
+ prevLine = lineNum;
285
+ const content = fileLines[lineNum - 1];
286
+ const hash = computeLineHash(lineNum, content);
287
+ const prefix = `${lineNum}:${hash}`;
288
+ if (mismatchSet.has(lineNum)) lines.push(`>>> ${prefix}|${content}`);
289
+ else lines.push(` ${prefix}|${content}`);
290
+ }
291
+ const remapEntries: string[] = [];
292
+ for (const m of mismatches) {
293
+ const actual = computeLineHash(m.line, fileLines[m.line - 1]);
294
+ remapEntries.push(`\t${m.line}:${m.expected} \u2192 ${m.line}:${actual}`);
295
+ }
296
+ if (remapEntries.length > 0) {
297
+ lines.push("");
298
+ lines.push("Quick fix \u2014 replace stale refs:");
299
+ lines.push(...remapEntries);
300
+ }
301
+ return lines.join("\n");
302
+ }
303
+ }
304
+
305
+ // Edit Application (where the real magic happens, hold onto your butts)
306
+
307
+ export function applyHashlineEdits(
308
+ content: string,
309
+ edits: HashlineEdit[],
310
+ ): {
311
+ content: string;
312
+ firstChangedLine: number | undefined;
313
+ warnings?: string[];
314
+ noopEdits?: Array<{ editIndex: number; loc: string; currentContent: string }>;
315
+ } {
316
+ if (edits.length === 0) return { content, firstChangedLine: undefined };
317
+
318
+ const fileLines = content.split("\n");
319
+ const originalFileLines = [...fileLines];
320
+ let firstChangedLine: number | undefined;
321
+ const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = [];
322
+
323
+ const parsed = edits.map((edit) => {
324
+ const parsedEdit = parseHashlineEdit(edit);
325
+ return { spec: parsedEdit.spec, dstLines: stripNewLinePrefixes(splitDstLines(parsedEdit.dst)) };
326
+ });
327
+
328
+ function collectExplicitlyTouchedLines(): Set<number> {
329
+ const touched = new Set<number>();
330
+ for (const { spec } of parsed) {
331
+ switch (spec.kind) {
332
+ case "single":
333
+ touched.add(spec.ref.line);
334
+ break;
335
+ case "range":
336
+ for (let ln = spec.start.line; ln <= spec.end.line; ln++) touched.add(ln);
337
+ break;
338
+ case "insertAfter":
339
+ touched.add(spec.after.line);
340
+ break;
341
+ }
342
+ }
343
+ return touched;
344
+ }
345
+
346
+ let explicitlyTouchedLines = collectExplicitlyTouchedLines();
347
+
348
+ // Pre-validate hashes
349
+ const mismatches: HashMismatch[] = [];
350
+ const uniqueLineByHash = new Map<string, number>();
351
+ const seenDuplicateHashes = new Set<string>();
352
+ for (let i = 0; i < fileLines.length; i++) {
353
+ const lineNo = i + 1;
354
+ const hash = computeLineHash(lineNo, fileLines[i]);
355
+ if (seenDuplicateHashes.has(hash)) continue;
356
+ if (uniqueLineByHash.has(hash)) {
357
+ uniqueLineByHash.delete(hash);
358
+ seenDuplicateHashes.add(hash);
359
+ continue;
360
+ }
361
+ uniqueLineByHash.set(hash, lineNo);
362
+ }
363
+
364
+ function buildMismatch(ref: { line: number; hash: string }, line = ref.line): HashMismatch {
365
+ return { line, expected: ref.hash, actual: computeLineHash(line, fileLines[line - 1]) };
366
+ }
367
+
368
+ function validateOrRelocateRef(ref: { line: number; hash: string }): { ok: true; relocated: boolean } | { ok: false } {
369
+ if (ref.line < 1 || ref.line > fileLines.length) {
370
+ throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
371
+ }
372
+ const expected = ref.hash.toLowerCase();
373
+ const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
374
+ if (actualHash === expected) return { ok: true, relocated: false };
375
+ const relocated = uniqueLineByHash.get(expected);
376
+ if (relocated === undefined) {
377
+ mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
378
+ return { ok: false };
379
+ }
380
+ ref.line = relocated;
381
+ return { ok: true, relocated: true };
382
+ }
383
+
384
+ for (const { spec, dstLines } of parsed) {
385
+ switch (spec.kind) {
386
+ case "single": {
387
+ validateOrRelocateRef(spec.ref);
388
+ break;
389
+ }
390
+ case "insertAfter": {
391
+ if (dstLines.length === 0) throw new Error('Insert-after edit requires non-empty dst');
392
+ validateOrRelocateRef(spec.after);
393
+ break;
394
+ }
395
+ case "range": {
396
+ if (spec.start.line > spec.end.line) {
397
+ throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
398
+ }
399
+ const originalStart = spec.start.line;
400
+ const originalEnd = spec.end.line;
401
+ const originalCount = originalEnd - originalStart + 1;
402
+ const startStatus = validateOrRelocateRef(spec.start);
403
+ const endStatus = validateOrRelocateRef(spec.end);
404
+ if (!startStatus.ok || !endStatus.ok) continue;
405
+ const relocatedCount = spec.end.line - spec.start.line + 1;
406
+ const changedByRelocation = startStatus.relocated || endStatus.relocated;
407
+ const invalidRange = spec.start.line > spec.end.line;
408
+ const scopeChanged = relocatedCount !== originalCount;
409
+ if (changedByRelocation && (invalidRange || scopeChanged)) {
410
+ spec.start.line = originalStart;
411
+ spec.end.line = originalEnd;
412
+ mismatches.push(buildMismatch(spec.start, originalStart), buildMismatch(spec.end, originalEnd));
413
+ }
414
+ break;
415
+ }
416
+ }
417
+ }
418
+
419
+ if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
420
+
421
+ explicitlyTouchedLines = collectExplicitlyTouchedLines();
422
+
423
+ // Deduplicate
424
+ const seenEditKeys = new Map<string, number>();
425
+ const dedupIndices = new Set<number>();
426
+ for (let i = 0; i < parsed.length; i++) {
427
+ const p = parsed[i];
428
+ let lineKey: string;
429
+ switch (p.spec.kind) {
430
+ case "single": lineKey = `s:${p.spec.ref.line}`; break;
431
+ case "range": lineKey = `r:${p.spec.start.line}:${p.spec.end.line}`; break;
432
+ case "insertAfter": lineKey = `i:${p.spec.after.line}`; break;
433
+ }
434
+ const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
435
+ if (seenEditKeys.has(dstKey)) dedupIndices.add(i);
436
+ else seenEditKeys.set(dstKey, i);
437
+ }
438
+ if (dedupIndices.size > 0) {
439
+ for (let i = parsed.length - 1; i >= 0; i--) {
440
+ if (dedupIndices.has(i)) parsed.splice(i, 1);
441
+ }
442
+ }
443
+
444
+ // Sort bottom-up
445
+ const annotated = parsed.map((p, idx) => {
446
+ let sortLine: number;
447
+ let precedence: number;
448
+ switch (p.spec.kind) {
449
+ case "single": sortLine = p.spec.ref.line; precedence = 0; break;
450
+ case "range": sortLine = p.spec.end.line; precedence = 0; break;
451
+ case "insertAfter": sortLine = p.spec.after.line; precedence = 1; break;
452
+ }
453
+ return { ...p, idx, sortLine, precedence };
454
+ });
455
+ annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
456
+
457
+ // Apply edits bottom-up
458
+ for (const { spec, dstLines, idx } of annotated) {
459
+ switch (spec.kind) {
460
+ case "single": {
461
+ const merged = maybeExpandSingleLineMerge(spec.ref.line, dstLines);
462
+ if (merged) {
463
+ const origLines = originalFileLines.slice(merged.startLine - 1, merged.startLine - 1 + merged.deleteCount);
464
+ let nextLines = merged.newLines;
465
+ nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
466
+ if (origLines.join("\n") === nextLines.join("\n") && origLines.some((l) => CONFUSABLE_HYPHENS_RE.test(l))) {
467
+ nextLines = normalizeConfusableHyphensInLines(nextLines);
468
+ }
469
+ if (origLines.join("\n") === nextLines.join("\n")) {
470
+ noopEdits.push({ editIndex: idx, loc: `${spec.ref.line}:${spec.ref.hash}`, currentContent: origLines.join("\n") });
471
+ break;
472
+ }
473
+ fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines);
474
+ trackFirstChanged(merged.startLine);
475
+ break;
476
+ }
477
+ const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line);
478
+ let stripped = stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines);
479
+ stripped = restoreOldWrappedLines(origLines, stripped);
480
+ let newLines = restoreIndentForPairedReplacement(origLines, stripped);
481
+ if (origLines.join("\n") === newLines.join("\n") && origLines.some((l) => CONFUSABLE_HYPHENS_RE.test(l))) {
482
+ newLines = normalizeConfusableHyphensInLines(newLines);
483
+ }
484
+ if (origLines.join("\n") === newLines.join("\n")) {
485
+ noopEdits.push({ editIndex: idx, loc: `${spec.ref.line}:${spec.ref.hash}`, currentContent: origLines.join("\n") });
486
+ break;
487
+ }
488
+ fileLines.splice(spec.ref.line - 1, 1, ...newLines);
489
+ trackFirstChanged(spec.ref.line);
490
+ break;
491
+ }
492
+ case "range": {
493
+ const count = spec.end.line - spec.start.line + 1;
494
+ const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
495
+ let stripped = stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines);
496
+ stripped = restoreOldWrappedLines(origLines, stripped);
497
+ let newLines = restoreIndentForPairedReplacement(origLines, stripped);
498
+ if (origLines.join("\n") === newLines.join("\n") && origLines.some((l) => CONFUSABLE_HYPHENS_RE.test(l))) {
499
+ newLines = normalizeConfusableHyphensInLines(newLines);
500
+ }
501
+ if (origLines.join("\n") === newLines.join("\n")) {
502
+ noopEdits.push({ editIndex: idx, loc: `${spec.start.line}:${spec.start.hash}`, currentContent: origLines.join("\n") });
503
+ break;
504
+ }
505
+ fileLines.splice(spec.start.line - 1, count, ...newLines);
506
+ trackFirstChanged(spec.start.line);
507
+ break;
508
+ }
509
+ case "insertAfter": {
510
+ const anchorLine = originalFileLines[spec.after.line - 1];
511
+ const inserted = stripInsertAnchorEchoAfter(anchorLine, dstLines);
512
+ if (inserted.length === 0) {
513
+ noopEdits.push({ editIndex: idx, loc: `${spec.after.line}:${spec.after.hash}`, currentContent: originalFileLines[spec.after.line - 1] });
514
+ break;
515
+ }
516
+ fileLines.splice(spec.after.line, 0, ...inserted);
517
+ trackFirstChanged(spec.after.line + 1);
518
+ break;
519
+ }
520
+ }
521
+ }
522
+
523
+ const warnings: string[] = [];
524
+ let diffLineCount = Math.abs(fileLines.length - originalFileLines.length);
525
+ for (let i = 0; i < Math.min(fileLines.length, originalFileLines.length); i++) {
526
+ if (fileLines[i] !== originalFileLines[i]) diffLineCount++;
527
+ }
528
+ if (diffLineCount > edits.length * 4) {
529
+ warnings.push(`Edit changed ${diffLineCount} lines across ${edits.length} operations — verify no unintended reformatting.`);
530
+ }
531
+
532
+ return {
533
+ content: fileLines.join("\n"),
534
+ firstChangedLine,
535
+ ...(warnings.length > 0 ? { warnings } : {}),
536
+ ...(noopEdits.length > 0 ? { noopEdits } : {}),
537
+ };
538
+
539
+ function trackFirstChanged(line: number): void {
540
+ if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
541
+ }
542
+
543
+ function maybeExpandSingleLineMerge(
544
+ line: number,
545
+ dst: string[],
546
+ ): { startLine: number; deleteCount: number; newLines: string[] } | null {
547
+ if (dst.length !== 1) return null;
548
+ if (line < 1 || line > fileLines.length) return null;
549
+ const newLine = dst[0];
550
+ const newCanon = stripAllWhitespace(newLine);
551
+ const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
552
+ if (newCanon.length === 0) return null;
553
+ const orig = fileLines[line - 1];
554
+ const origCanon = stripAllWhitespace(orig);
555
+ const origCanonForMatch = stripTrailingContinuationTokens(origCanon);
556
+ const origCanonForMergeOps = stripMergeOperatorChars(origCanon);
557
+ const origLooksLikeContinuation = origCanonForMatch.length < origCanon.length;
558
+ if (origCanon.length === 0) return null;
559
+ const nextIdx = line;
560
+ const prevIdx = line - 2;
561
+ if (origLooksLikeContinuation && nextIdx < fileLines.length && !explicitlyTouchedLines.has(line + 1)) {
562
+ const next = fileLines[nextIdx];
563
+ const nextCanon = stripAllWhitespace(next);
564
+ const a = newCanon.indexOf(origCanonForMatch);
565
+ const b = newCanon.indexOf(nextCanon);
566
+ if (a !== -1 && b !== -1 && a < b && newCanon.length <= origCanon.length + nextCanon.length + 32) {
567
+ return { startLine: line, deleteCount: 2, newLines: [newLine] };
568
+ }
569
+ }
570
+ if (prevIdx >= 0 && !explicitlyTouchedLines.has(line - 1)) {
571
+ const prev = fileLines[prevIdx];
572
+ const prevCanon = stripAllWhitespace(prev);
573
+ const prevCanonForMatch = stripTrailingContinuationTokens(prevCanon);
574
+ const prevLooksLikeContinuation = prevCanonForMatch.length < prevCanon.length;
575
+ if (!prevLooksLikeContinuation) return null;
576
+ const a = newCanonForMergeOps.indexOf(stripMergeOperatorChars(prevCanonForMatch));
577
+ const b = newCanonForMergeOps.indexOf(origCanonForMergeOps);
578
+ if (a !== -1 && b !== -1 && a < b && newCanon.length <= prevCanon.length + origCanon.length + 32) {
579
+ return { startLine: line - 1, deleteCount: 2, newLines: [newLine] };
580
+ }
581
+ }
582
+ return null;
583
+ }
584
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * MCP server entry point — stdio transport.
4
+ */
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { createServer } from "./server";
7
+
8
+ const server = createServer();
9
+
10
+ async function main() {
11
+ const transport = new StdioServerTransport();
12
+ await server.connect(transport);
13
+ }
14
+
15
+ main().catch((err) => {
16
+ console.error("Fatal:", err);
17
+ process.exit(1);
18
+ });