@oh-my-pi/pi-coding-agent 12.12.3 → 12.13.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/CHANGELOG.md +30 -0
- package/package.json +7 -7
- package/src/cli/ssh-cli.ts +179 -0
- package/src/cli.ts +1 -0
- package/src/commands/ssh.ts +60 -0
- package/src/commit/prompts/analysis-system.md +1 -3
- package/src/config/prompt-templates.ts +20 -5
- package/src/config/settings-schema.ts +1 -1
- package/src/discovery/ssh.ts +26 -19
- package/src/modes/controllers/ssh-command-controller.ts +452 -0
- package/src/modes/interactive-mode.ts +6 -0
- package/src/modes/types.ts +1 -0
- package/src/patch/hashline.ts +237 -135
- package/src/patch/index.ts +37 -39
- package/src/patch/shared.ts +37 -23
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/grep.md +12 -8
- package/src/prompts/tools/hashline.md +98 -53
- package/src/prompts/tools/read.md +1 -3
- package/src/session/auth-storage.ts +6 -0
- package/src/slash-commands/builtin-registry.ts +20 -0
- package/src/ssh/config-writer.ts +183 -0
- package/src/tools/bash-interactive.ts +47 -7
- package/src/tools/grep.ts +1 -1
- package/src/tools/read.ts +2 -2
- package/src/tools/ssh.ts +1 -1
package/src/patch/hashline.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* Each line in a file is identified by its 1-indexed line number and a short
|
|
5
5
|
* base36 hash derived from the normalized line content (xxHash32, truncated to 4
|
|
6
6
|
* base36 chars).
|
|
7
|
-
* The combined `LINE
|
|
7
|
+
* The combined `LINE#ID` reference acts as both an address and a staleness check:
|
|
8
8
|
* if the file has changed since the caller last read it, hash mismatches are caught
|
|
9
9
|
* before any mutation occurs.
|
|
10
10
|
*
|
|
11
|
-
* Displayed format: `LINENUM
|
|
12
|
-
* Reference format: `"LINENUM
|
|
11
|
+
* Displayed format: `LINENUM#HASH|CONTENT`
|
|
12
|
+
* Reference format: `"LINENUM#HASH"` (e.g. `"5#a3f2"`)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { HashlineEdit } from "./index";
|
|
@@ -18,45 +18,84 @@ import type { HashMismatch } from "./types";
|
|
|
18
18
|
type ParsedRefs =
|
|
19
19
|
| { kind: "single"; ref: { line: number; hash: string } }
|
|
20
20
|
| { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } }
|
|
21
|
-
| { kind: "insertAfter"; after: { line: number; hash: string } }
|
|
21
|
+
| { kind: "insertAfter"; after: { line: number; hash: string } }
|
|
22
|
+
| { kind: "insertBefore"; before: { line: number; hash: string } }
|
|
23
|
+
| { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } }
|
|
24
|
+
| { kind: "insertAtEof" };
|
|
22
25
|
|
|
23
|
-
function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs;
|
|
24
|
-
if ("
|
|
26
|
+
function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } {
|
|
27
|
+
if ("set" in edit) {
|
|
25
28
|
return {
|
|
26
|
-
spec: { kind: "single", ref: parseLineRef(edit.
|
|
27
|
-
|
|
29
|
+
spec: { kind: "single", ref: parseLineRef(edit.set.ref) },
|
|
30
|
+
dstLines: edit.set.body,
|
|
28
31
|
};
|
|
29
32
|
}
|
|
30
|
-
if ("
|
|
31
|
-
const r = edit.
|
|
32
|
-
const start = parseLineRef(r.
|
|
33
|
-
if (!r.
|
|
33
|
+
if ("set_range" in edit) {
|
|
34
|
+
const r = edit.set_range as Record<string, unknown>;
|
|
35
|
+
const start = parseLineRef(r.beg as string);
|
|
36
|
+
if (!r.end) {
|
|
34
37
|
return {
|
|
35
38
|
spec: { kind: "single", ref: start },
|
|
36
|
-
|
|
39
|
+
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
|
-
const end = parseLineRef(r.
|
|
42
|
+
const end = parseLineRef(r.end as string);
|
|
40
43
|
return {
|
|
41
44
|
spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
|
|
42
|
-
|
|
45
|
+
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
|
|
43
46
|
};
|
|
44
47
|
}
|
|
48
|
+
if ("insert" in edit) {
|
|
49
|
+
const r = edit.insert as Record<string, unknown>;
|
|
50
|
+
const dstLines = Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.text ?? r.content ?? ""));
|
|
51
|
+
const hasAfterField = "after" in r;
|
|
52
|
+
const hasBeforeField = "before" in r;
|
|
53
|
+
const afterRef = r.after;
|
|
54
|
+
const beforeRef = r.before;
|
|
55
|
+
if (hasAfterField && (typeof afterRef !== "string" || afterRef.trim().length === 0)) {
|
|
56
|
+
throw new Error('insert.after must be a non-empty "LINE#ID" string when provided');
|
|
57
|
+
}
|
|
58
|
+
if (hasBeforeField && (typeof beforeRef !== "string" || beforeRef.trim().length === 0)) {
|
|
59
|
+
throw new Error('insert.before must be a non-empty "LINE#ID" string when provided');
|
|
60
|
+
}
|
|
61
|
+
const hasAfter = hasAfterField && typeof afterRef === "string";
|
|
62
|
+
const hasBefore = hasBeforeField && typeof beforeRef === "string";
|
|
63
|
+
if (hasAfter && hasBefore) {
|
|
64
|
+
return {
|
|
65
|
+
spec: {
|
|
66
|
+
kind: "insertBetween",
|
|
67
|
+
after: parseLineRef(afterRef),
|
|
68
|
+
before: parseLineRef(beforeRef),
|
|
69
|
+
},
|
|
70
|
+
dstLines,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (hasAfter) {
|
|
74
|
+
return {
|
|
75
|
+
spec: { kind: "insertAfter", after: parseLineRef(afterRef) },
|
|
76
|
+
dstLines,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (hasBefore) {
|
|
80
|
+
return {
|
|
81
|
+
spec: { kind: "insertBefore", before: parseLineRef(beforeRef) },
|
|
82
|
+
dstLines,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { spec: { kind: "insertAtEof" }, dstLines };
|
|
86
|
+
}
|
|
45
87
|
if ("replace" in edit) {
|
|
46
88
|
throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits");
|
|
47
89
|
}
|
|
48
|
-
|
|
49
|
-
spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) },
|
|
50
|
-
dst: edit.insert_after.text ?? (edit.insert_after as Record<string, string>).content ?? "",
|
|
51
|
-
};
|
|
90
|
+
throw new Error("Unknown hashline edit operation");
|
|
52
91
|
}
|
|
53
92
|
/** Split dst into lines; empty string means delete (no lines). */
|
|
54
93
|
function splitDstLines(dst: string): string[] {
|
|
55
94
|
return dst === "" ? [] : dst.split("\n");
|
|
56
95
|
}
|
|
57
96
|
|
|
58
|
-
/** Pattern matching hashline display format: `LINE
|
|
59
|
-
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d
|
|
97
|
+
/** Pattern matching hashline display format: `LINE#ID|CONTENT` */
|
|
98
|
+
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}\|/;
|
|
60
99
|
|
|
61
100
|
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
|
|
62
101
|
const DIFF_PLUS_RE = /^\+(?!\+)/;
|
|
@@ -179,6 +218,25 @@ function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): str
|
|
|
179
218
|
return dstLines;
|
|
180
219
|
}
|
|
181
220
|
|
|
221
|
+
function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] {
|
|
222
|
+
if (dstLines.length <= 1) return dstLines;
|
|
223
|
+
if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) {
|
|
224
|
+
return dstLines.slice(0, -1);
|
|
225
|
+
}
|
|
226
|
+
return dstLines;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] {
|
|
230
|
+
let out = dstLines;
|
|
231
|
+
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) {
|
|
232
|
+
out = out.slice(1);
|
|
233
|
+
}
|
|
234
|
+
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
|
|
235
|
+
out = out.slice(0, -1);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
182
240
|
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
|
|
183
241
|
// Only strip when the model replaced with multiple lines and grew the edit.
|
|
184
242
|
// This avoids turning a single-line replacement into a deletion.
|
|
@@ -206,7 +264,7 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
|
|
|
206
264
|
/**
|
|
207
265
|
* Strip hashline display prefixes and diff `+` markers from replacement lines.
|
|
208
266
|
*
|
|
209
|
-
* Models frequently copy the `LINE
|
|
267
|
+
* Models frequently copy the `LINE#ID ` prefix from read output into their
|
|
210
268
|
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
|
|
211
269
|
* output file. This strips them heuristically before application.
|
|
212
270
|
*/
|
|
@@ -262,7 +320,7 @@ export function computeLineHash(idx: number, line: string): string {
|
|
|
262
320
|
/**
|
|
263
321
|
* Format file content with hashline prefixes for display.
|
|
264
322
|
*
|
|
265
|
-
* Each line becomes `LINENUM
|
|
323
|
+
* Each line becomes `LINENUM#HASH|CONTENT` where LINENUM is 1-indexed.
|
|
266
324
|
*
|
|
267
325
|
* @param content - Raw file content string
|
|
268
326
|
* @param startLine - First line number (1-indexed, defaults to 1)
|
|
@@ -271,7 +329,7 @@ export function computeLineHash(idx: number, line: string): string {
|
|
|
271
329
|
* @example
|
|
272
330
|
* ```
|
|
273
331
|
* formatHashLines("function hi() {\n return;\n}")
|
|
274
|
-
* // "1
|
|
332
|
+
* // "1#HH|function hi() {\n2#HH| return;\n3#HH|}"
|
|
275
333
|
* ```
|
|
276
334
|
*/
|
|
277
335
|
export function formatHashLines(content: string, startLine = 1): string {
|
|
@@ -280,7 +338,7 @@ export function formatHashLines(content: string, startLine = 1): string {
|
|
|
280
338
|
.map((line, i) => {
|
|
281
339
|
const num = startLine + i;
|
|
282
340
|
const hash = computeLineHash(num, line);
|
|
283
|
-
return `${num}
|
|
341
|
+
return `${num}#${hash}|${line}`;
|
|
284
342
|
})
|
|
285
343
|
.join("\n");
|
|
286
344
|
}
|
|
@@ -352,7 +410,7 @@ export async function* streamHashLinesFromUtf8(
|
|
|
352
410
|
};
|
|
353
411
|
|
|
354
412
|
const pushLine = (line: string): string[] => {
|
|
355
|
-
const formatted = `${lineNum}
|
|
413
|
+
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
|
|
356
414
|
lineNum++;
|
|
357
415
|
|
|
358
416
|
const chunksToYield: string[] = [];
|
|
@@ -446,7 +504,7 @@ export async function* streamHashLinesFromLines(
|
|
|
446
504
|
|
|
447
505
|
const pushLine = (line: string): string[] => {
|
|
448
506
|
sawAnyLine = true;
|
|
449
|
-
const formatted = `${lineNum}
|
|
507
|
+
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
|
|
450
508
|
lineNum++;
|
|
451
509
|
|
|
452
510
|
const chunksToYield: string[] = [];
|
|
@@ -498,24 +556,24 @@ export async function* streamHashLinesFromLines(
|
|
|
498
556
|
}
|
|
499
557
|
|
|
500
558
|
/**
|
|
501
|
-
* Parse a line reference string like `"5
|
|
559
|
+
* Parse a line reference string like `"5#abcd"` into structured form.
|
|
502
560
|
*
|
|
503
|
-
* @throws Error if the format is invalid (not `NUMBER
|
|
561
|
+
* @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
|
|
504
562
|
*/
|
|
505
563
|
export function parseLineRef(ref: string): { line: number; hash: string } {
|
|
506
|
-
// Strip display-format suffix: "5
|
|
564
|
+
// Strip display-format suffix: "5#ab|some content" → "5#ab", or legacy "5#ab some content" → "5#ab"
|
|
507
565
|
// Models often copy the full display format from read output.
|
|
508
566
|
const cleaned = ref
|
|
509
567
|
.replace(/\|.*$/, "")
|
|
510
568
|
.replace(/ {2}.*$/, "")
|
|
511
569
|
.replace(/^>+\s*/, "")
|
|
512
570
|
.trim();
|
|
513
|
-
const normalized = cleaned.replace(/\s
|
|
514
|
-
const strictMatch = normalized.match(/^(\d+)
|
|
515
|
-
const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)
|
|
571
|
+
const normalized = cleaned.replace(/\s*#\s*/, "#");
|
|
572
|
+
const strictMatch = normalized.match(/^(\d+)#([0-9a-zA-Z]{1,16})$/);
|
|
573
|
+
const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)#([0-9a-zA-Z]{${HASH_LEN}})`));
|
|
516
574
|
const match = strictMatch ?? prefixMatch;
|
|
517
575
|
if (!match) {
|
|
518
|
-
throw new Error(`Invalid line reference "${ref}". Expected format "LINE
|
|
576
|
+
throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
|
|
519
577
|
}
|
|
520
578
|
const line = Number.parseInt(match[1], 10);
|
|
521
579
|
if (line < 1) {
|
|
@@ -535,7 +593,7 @@ const MISMATCH_CONTEXT = 2;
|
|
|
535
593
|
* Error thrown when one or more hashline references have stale hashes.
|
|
536
594
|
*
|
|
537
595
|
* Displays grep-style output with `>>>` markers on mismatched lines,
|
|
538
|
-
* showing the correct `LINE
|
|
596
|
+
* showing the correct `LINE#ID` so the caller can fix all refs at once.
|
|
539
597
|
*/
|
|
540
598
|
export class HashlineMismatchError extends Error {
|
|
541
599
|
readonly remaps: ReadonlyMap<string, string>;
|
|
@@ -548,7 +606,7 @@ export class HashlineMismatchError extends Error {
|
|
|
548
606
|
const remaps = new Map<string, string>();
|
|
549
607
|
for (const m of mismatches) {
|
|
550
608
|
const actual = computeLineHash(m.line, fileLines[m.line - 1]);
|
|
551
|
-
remaps.set(`${m.line}
|
|
609
|
+
remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`);
|
|
552
610
|
}
|
|
553
611
|
this.remaps = remaps;
|
|
554
612
|
}
|
|
@@ -573,7 +631,7 @@ export class HashlineMismatchError extends Error {
|
|
|
573
631
|
const lines: string[] = [];
|
|
574
632
|
|
|
575
633
|
lines.push(
|
|
576
|
-
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE
|
|
634
|
+
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines).`,
|
|
577
635
|
);
|
|
578
636
|
lines.push("");
|
|
579
637
|
|
|
@@ -587,7 +645,7 @@ export class HashlineMismatchError extends Error {
|
|
|
587
645
|
|
|
588
646
|
const content = fileLines[lineNum - 1];
|
|
589
647
|
const hash = computeLineHash(lineNum, content);
|
|
590
|
-
const prefix = `${lineNum}
|
|
648
|
+
const prefix = `${lineNum}#${hash}`;
|
|
591
649
|
|
|
592
650
|
if (mismatchSet.has(lineNum)) {
|
|
593
651
|
lines.push(`>>> ${prefix}|${content}`);
|
|
@@ -624,8 +682,8 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
|
|
624
682
|
/**
|
|
625
683
|
* Apply an array of hashline edits to file content.
|
|
626
684
|
*
|
|
627
|
-
* Each edit operation identifies target lines directly (`
|
|
628
|
-
* `
|
|
685
|
+
* Each edit operation identifies target lines directly (`set`, `set_range`,
|
|
686
|
+
* `insert`). Line references are resolved via {@link parseLineRef}
|
|
629
687
|
* and hashes validated before any mutation.
|
|
630
688
|
*
|
|
631
689
|
* Edits are sorted bottom-up (highest effective line first) so earlier
|
|
@@ -651,12 +709,14 @@ export function applyHashlineEdits(
|
|
|
651
709
|
let firstChangedLine: number | undefined;
|
|
652
710
|
const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = [];
|
|
653
711
|
|
|
712
|
+
const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
|
|
713
|
+
|
|
654
714
|
// Parse src specs and dst lines up front
|
|
655
715
|
const parsed = edits.map(edit => {
|
|
656
716
|
const parsedEdit = parseHashlineEdit(edit);
|
|
657
717
|
return {
|
|
658
718
|
spec: parsedEdit.spec,
|
|
659
|
-
dstLines: stripNewLinePrefixes(
|
|
719
|
+
dstLines: stripNewLinePrefixes(parsedEdit.dstLines),
|
|
660
720
|
};
|
|
661
721
|
});
|
|
662
722
|
|
|
@@ -673,71 +733,72 @@ export function applyHashlineEdits(
|
|
|
673
733
|
case "insertAfter":
|
|
674
734
|
touched.add(spec.after.line);
|
|
675
735
|
break;
|
|
736
|
+
case "insertBefore":
|
|
737
|
+
touched.add(spec.before.line);
|
|
738
|
+
break;
|
|
739
|
+
case "insertBetween":
|
|
740
|
+
touched.add(spec.after.line);
|
|
741
|
+
touched.add(spec.before.line);
|
|
742
|
+
break;
|
|
743
|
+
case "insertAtEof":
|
|
744
|
+
break;
|
|
676
745
|
}
|
|
677
746
|
}
|
|
678
747
|
return touched;
|
|
679
748
|
}
|
|
680
749
|
|
|
681
|
-
|
|
682
|
-
|
|
750
|
+
const explicitlyTouchedLines = collectExplicitlyTouchedLines();
|
|
683
751
|
// Pre-validate: collect all hash mismatches before mutating
|
|
684
752
|
const mismatches: HashMismatch[] = [];
|
|
685
|
-
|
|
686
|
-
const seenDuplicateHashes = new Set<string>();
|
|
687
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
688
|
-
const lineNo = i + 1;
|
|
689
|
-
const hash = computeLineHash(lineNo, fileLines[i]);
|
|
690
|
-
if (seenDuplicateHashes.has(hash)) continue;
|
|
691
|
-
if (uniqueLineByHash.has(hash)) {
|
|
692
|
-
uniqueLineByHash.delete(hash);
|
|
693
|
-
seenDuplicateHashes.add(hash);
|
|
694
|
-
continue;
|
|
695
|
-
}
|
|
696
|
-
uniqueLineByHash.set(hash, lineNo);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function buildMismatch(ref: { line: number; hash: string }, line = ref.line): HashMismatch {
|
|
700
|
-
return {
|
|
701
|
-
line,
|
|
702
|
-
expected: ref.hash,
|
|
703
|
-
actual: computeLineHash(line, fileLines[line - 1]),
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
function validateOrRelocateRef(ref: {
|
|
708
|
-
line: number;
|
|
709
|
-
hash: string;
|
|
710
|
-
}): { ok: true; relocated: boolean } | { ok: false } {
|
|
753
|
+
function validateRef(ref: { line: number; hash: string }): boolean {
|
|
711
754
|
if (ref.line < 1 || ref.line > fileLines.length) {
|
|
712
755
|
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
713
756
|
}
|
|
714
|
-
const expected = ref.hash.toLowerCase();
|
|
715
757
|
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
|
|
716
|
-
if (actualHash ===
|
|
717
|
-
return
|
|
758
|
+
if (actualHash === ref.hash.toLowerCase()) {
|
|
759
|
+
return true;
|
|
718
760
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (relocated === undefined) {
|
|
722
|
-
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
723
|
-
return { ok: false };
|
|
724
|
-
}
|
|
725
|
-
ref.line = relocated;
|
|
726
|
-
return { ok: true, relocated: true };
|
|
761
|
+
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
762
|
+
return false;
|
|
727
763
|
}
|
|
728
764
|
for (const { spec, dstLines } of parsed) {
|
|
729
765
|
switch (spec.kind) {
|
|
730
766
|
case "single": {
|
|
731
|
-
|
|
732
|
-
if (!status.ok) continue;
|
|
767
|
+
if (!validateRef(spec.ref)) continue;
|
|
733
768
|
break;
|
|
734
769
|
}
|
|
735
770
|
case "insertAfter": {
|
|
736
771
|
if (dstLines.length === 0) {
|
|
737
|
-
throw new Error('Insert-after edit (src "N
|
|
772
|
+
throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
|
|
773
|
+
}
|
|
774
|
+
if (!validateRef(spec.after)) continue;
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
case "insertBefore": {
|
|
778
|
+
if (dstLines.length === 0) {
|
|
779
|
+
throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
|
|
780
|
+
}
|
|
781
|
+
if (!validateRef(spec.before)) continue;
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
case "insertBetween": {
|
|
785
|
+
if (dstLines.length === 0) {
|
|
786
|
+
throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
|
|
787
|
+
}
|
|
788
|
+
if (spec.before.line !== spec.after.line + 1) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
`insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
const afterValid = validateRef(spec.after);
|
|
794
|
+
const beforeValid = validateRef(spec.before);
|
|
795
|
+
if (!afterValid || !beforeValid) continue;
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
case "insertAtEof": {
|
|
799
|
+
if (dstLines.length === 0) {
|
|
800
|
+
throw new Error("Insert-at-EOF edit requires non-empty dst");
|
|
738
801
|
}
|
|
739
|
-
const status = validateOrRelocateRef(spec.after);
|
|
740
|
-
if (!status.ok) continue;
|
|
741
802
|
break;
|
|
742
803
|
}
|
|
743
804
|
case "range": {
|
|
@@ -745,38 +806,16 @@ export function applyHashlineEdits(
|
|
|
745
806
|
throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
|
|
746
807
|
}
|
|
747
808
|
|
|
748
|
-
const
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const startStatus = validateOrRelocateRef(spec.start);
|
|
753
|
-
const endStatus = validateOrRelocateRef(spec.end);
|
|
754
|
-
if (!startStatus.ok || !endStatus.ok) continue;
|
|
755
|
-
|
|
756
|
-
const relocatedCount = spec.end.line - spec.start.line + 1;
|
|
757
|
-
const changedByRelocation = startStatus.relocated || endStatus.relocated;
|
|
758
|
-
const invalidRange = spec.start.line > spec.end.line;
|
|
759
|
-
const scopeChanged = relocatedCount !== originalCount;
|
|
760
|
-
|
|
761
|
-
if (changedByRelocation && (invalidRange || scopeChanged)) {
|
|
762
|
-
spec.start.line = originalStart;
|
|
763
|
-
spec.end.line = originalEnd;
|
|
764
|
-
mismatches.push(buildMismatch(spec.start, originalStart), buildMismatch(spec.end, originalEnd));
|
|
765
|
-
}
|
|
809
|
+
const startValid = validateRef(spec.start);
|
|
810
|
+
const endValid = validateRef(spec.end);
|
|
811
|
+
if (!startValid || !endValid) continue;
|
|
766
812
|
break;
|
|
767
813
|
}
|
|
768
814
|
}
|
|
769
815
|
}
|
|
770
|
-
|
|
771
816
|
if (mismatches.length > 0) {
|
|
772
817
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
773
818
|
}
|
|
774
|
-
|
|
775
|
-
// Hash relocation may have rewritten reference line numbers.
|
|
776
|
-
// Recompute touched lines so merge heuristics don't treat now-targeted
|
|
777
|
-
// adjacent lines as safe merge candidates.
|
|
778
|
-
explicitlyTouchedLines = collectExplicitlyTouchedLines();
|
|
779
|
-
|
|
780
819
|
// Deduplicate identical edits targeting the same line(s)
|
|
781
820
|
const seenEditKeys = new Map<string, number>();
|
|
782
821
|
const dedupIndices = new Set<number>();
|
|
@@ -793,6 +832,15 @@ export function applyHashlineEdits(
|
|
|
793
832
|
case "insertAfter":
|
|
794
833
|
lineKey = `i:${p.spec.after.line}`;
|
|
795
834
|
break;
|
|
835
|
+
case "insertBefore":
|
|
836
|
+
lineKey = `ib:${p.spec.before.line}`;
|
|
837
|
+
break;
|
|
838
|
+
case "insertBetween":
|
|
839
|
+
lineKey = `ix:${p.spec.after.line}:${p.spec.before.line}`;
|
|
840
|
+
break;
|
|
841
|
+
case "insertAtEof":
|
|
842
|
+
lineKey = "ieof";
|
|
843
|
+
break;
|
|
796
844
|
}
|
|
797
845
|
const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
|
|
798
846
|
if (seenEditKeys.has(dstKey)) {
|
|
@@ -824,6 +872,18 @@ export function applyHashlineEdits(
|
|
|
824
872
|
sortLine = p.spec.after.line;
|
|
825
873
|
precedence = 1;
|
|
826
874
|
break;
|
|
875
|
+
case "insertBefore":
|
|
876
|
+
sortLine = p.spec.before.line;
|
|
877
|
+
precedence = 2;
|
|
878
|
+
break;
|
|
879
|
+
case "insertBetween":
|
|
880
|
+
sortLine = p.spec.before.line;
|
|
881
|
+
precedence = 3;
|
|
882
|
+
break;
|
|
883
|
+
case "insertAtEof":
|
|
884
|
+
sortLine = fileLines.length + 1;
|
|
885
|
+
precedence = 4;
|
|
886
|
+
break;
|
|
827
887
|
}
|
|
828
888
|
return { ...p, idx, sortLine, precedence };
|
|
829
889
|
});
|
|
@@ -834,7 +894,7 @@ export function applyHashlineEdits(
|
|
|
834
894
|
for (const { spec, dstLines, idx } of annotated) {
|
|
835
895
|
switch (spec.kind) {
|
|
836
896
|
case "single": {
|
|
837
|
-
const merged = maybeExpandSingleLineMerge(spec.ref.line, dstLines);
|
|
897
|
+
const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null;
|
|
838
898
|
if (merged) {
|
|
839
899
|
const origLines = originalFileLines.slice(
|
|
840
900
|
merged.startLine - 1,
|
|
@@ -851,7 +911,7 @@ export function applyHashlineEdits(
|
|
|
851
911
|
if (origLines.join("\n") === nextLines.join("\n")) {
|
|
852
912
|
noopEdits.push({
|
|
853
913
|
editIndex: idx,
|
|
854
|
-
loc: `${spec.ref.line}
|
|
914
|
+
loc: `${spec.ref.line}#${spec.ref.hash}`,
|
|
855
915
|
currentContent: origLines.join("\n"),
|
|
856
916
|
});
|
|
857
917
|
break;
|
|
@@ -863,16 +923,22 @@ export function applyHashlineEdits(
|
|
|
863
923
|
|
|
864
924
|
const count = 1;
|
|
865
925
|
const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line);
|
|
866
|
-
let stripped =
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
926
|
+
let stripped = autocorrect
|
|
927
|
+
? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines)
|
|
928
|
+
: dstLines;
|
|
929
|
+
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
|
|
930
|
+
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
|
931
|
+
if (
|
|
932
|
+
autocorrect &&
|
|
933
|
+
origLines.join("\n") === newLines.join("\n") &&
|
|
934
|
+
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
|
|
935
|
+
) {
|
|
870
936
|
newLines = normalizeConfusableHyphensInLines(newLines);
|
|
871
937
|
}
|
|
872
938
|
if (origLines.join("\n") === newLines.join("\n")) {
|
|
873
939
|
noopEdits.push({
|
|
874
940
|
editIndex: idx,
|
|
875
|
-
loc: `${spec.ref.line}
|
|
941
|
+
loc: `${spec.ref.line}#${spec.ref.hash}`,
|
|
876
942
|
currentContent: origLines.join("\n"),
|
|
877
943
|
});
|
|
878
944
|
break;
|
|
@@ -884,16 +950,22 @@ export function applyHashlineEdits(
|
|
|
884
950
|
case "range": {
|
|
885
951
|
const count = spec.end.line - spec.start.line + 1;
|
|
886
952
|
const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
|
|
887
|
-
let stripped =
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
953
|
+
let stripped = autocorrect
|
|
954
|
+
? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines)
|
|
955
|
+
: dstLines;
|
|
956
|
+
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
|
|
957
|
+
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
|
958
|
+
if (
|
|
959
|
+
autocorrect &&
|
|
960
|
+
origLines.join("\n") === newLines.join("\n") &&
|
|
961
|
+
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
|
|
962
|
+
) {
|
|
891
963
|
newLines = normalizeConfusableHyphensInLines(newLines);
|
|
892
964
|
}
|
|
893
965
|
if (origLines.join("\n") === newLines.join("\n")) {
|
|
894
966
|
noopEdits.push({
|
|
895
967
|
editIndex: idx,
|
|
896
|
-
loc: `${spec.start.line}
|
|
968
|
+
loc: `${spec.start.line}#${spec.start.hash}`,
|
|
897
969
|
currentContent: origLines.join("\n"),
|
|
898
970
|
});
|
|
899
971
|
break;
|
|
@@ -904,11 +976,11 @@ export function applyHashlineEdits(
|
|
|
904
976
|
}
|
|
905
977
|
case "insertAfter": {
|
|
906
978
|
const anchorLine = originalFileLines[spec.after.line - 1];
|
|
907
|
-
const inserted = stripInsertAnchorEchoAfter(anchorLine, dstLines);
|
|
979
|
+
const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines;
|
|
908
980
|
if (inserted.length === 0) {
|
|
909
981
|
noopEdits.push({
|
|
910
982
|
editIndex: idx,
|
|
911
|
-
loc: `${spec.after.line}
|
|
983
|
+
loc: `${spec.after.line}#${spec.after.hash}`,
|
|
912
984
|
currentContent: originalFileLines[spec.after.line - 1],
|
|
913
985
|
});
|
|
914
986
|
break;
|
|
@@ -917,23 +989,53 @@ export function applyHashlineEdits(
|
|
|
917
989
|
trackFirstChanged(spec.after.line + 1);
|
|
918
990
|
break;
|
|
919
991
|
}
|
|
992
|
+
case "insertBefore": {
|
|
993
|
+
const anchorLine = originalFileLines[spec.before.line - 1];
|
|
994
|
+
const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines;
|
|
995
|
+
if (inserted.length === 0) {
|
|
996
|
+
noopEdits.push({
|
|
997
|
+
editIndex: idx,
|
|
998
|
+
loc: `${spec.before.line}#${spec.before.hash}`,
|
|
999
|
+
currentContent: originalFileLines[spec.before.line - 1],
|
|
1000
|
+
});
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
fileLines.splice(spec.before.line - 1, 0, ...inserted);
|
|
1004
|
+
trackFirstChanged(spec.before.line);
|
|
1005
|
+
break;
|
|
1006
|
+
}
|
|
1007
|
+
case "insertBetween": {
|
|
1008
|
+
const afterLine = originalFileLines[spec.after.line - 1];
|
|
1009
|
+
const beforeLine = originalFileLines[spec.before.line - 1];
|
|
1010
|
+
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, dstLines) : dstLines;
|
|
1011
|
+
if (inserted.length === 0) {
|
|
1012
|
+
noopEdits.push({
|
|
1013
|
+
editIndex: idx,
|
|
1014
|
+
loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`,
|
|
1015
|
+
currentContent: `${afterLine}\n${beforeLine}`,
|
|
1016
|
+
});
|
|
1017
|
+
break;
|
|
1018
|
+
}
|
|
1019
|
+
fileLines.splice(spec.before.line - 1, 0, ...inserted);
|
|
1020
|
+
trackFirstChanged(spec.before.line);
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
case "insertAtEof": {
|
|
1024
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
1025
|
+
fileLines.splice(0, 1, ...dstLines);
|
|
1026
|
+
trackFirstChanged(1);
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
fileLines.splice(fileLines.length, 0, ...dstLines);
|
|
1030
|
+
trackFirstChanged(fileLines.length - dstLines.length + 1);
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
920
1033
|
}
|
|
921
1034
|
}
|
|
922
1035
|
|
|
923
|
-
const warnings: string[] = [];
|
|
924
|
-
let diffLineCount = Math.abs(fileLines.length - originalFileLines.length);
|
|
925
|
-
for (let i = 0; i < Math.min(fileLines.length, originalFileLines.length); i++) {
|
|
926
|
-
if (fileLines[i] !== originalFileLines[i]) diffLineCount++;
|
|
927
|
-
}
|
|
928
|
-
if (diffLineCount > edits.length * 4) {
|
|
929
|
-
warnings.push(
|
|
930
|
-
`Edit changed ${diffLineCount} lines across ${edits.length} operations — verify no unintended reformatting.`,
|
|
931
|
-
);
|
|
932
|
-
}
|
|
933
1036
|
return {
|
|
934
1037
|
content: fileLines.join("\n"),
|
|
935
1038
|
firstChangedLine,
|
|
936
|
-
...(warnings.length > 0 ? { warnings } : {}),
|
|
937
1039
|
...(noopEdits.length > 0 ? { noopEdits } : {}),
|
|
938
1040
|
};
|
|
939
1041
|
|