@oh-my-pi/hashline 15.13.0 → 15.13.2
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 +111 -34
- package/README.md +6 -6
- package/dist/types/block.d.ts +2 -2
- package/dist/types/format.d.ts +13 -10
- package/dist/types/input.d.ts +2 -2
- package/dist/types/messages.d.ts +33 -18
- package/dist/types/patcher.d.ts +3 -3
- package/dist/types/snapshots.d.ts +24 -3
- package/dist/types/types.d.ts +10 -10
- package/package.json +1 -1
- package/src/apply.ts +57 -3
- package/src/block.ts +16 -6
- package/src/format.ts +17 -14
- package/src/grammar.lark +7 -7
- package/src/input.ts +3 -3
- package/src/messages.ts +80 -26
- package/src/parser.ts +8 -8
- package/src/patcher.ts +26 -6
- package/src/prompt.md +39 -39
- package/src/snapshots.ts +40 -4
- package/src/tokenizer.ts +39 -36
- package/src/types.ts +10 -10
package/src/apply.ts
CHANGED
|
@@ -443,6 +443,50 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
|
|
|
443
443
|
);
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* A single-sided boundary echo in an otherwise delimiter-balanced *multi-line*
|
|
448
|
+
* replacement: the payload's leading XOR trailing edge exactly restates the
|
|
449
|
+
* surviving line(s) just outside the range — the off-by-one "range one line
|
|
450
|
+
* short of the keeper I retyped" mistake (e.g. att: payload ends with
|
|
451
|
+
* `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes
|
|
452
|
+
* are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
|
|
453
|
+
* by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
|
|
454
|
+
*
|
|
455
|
+
* Scoped to multi-line ranges (a construct rewrite) on purpose: a single-line
|
|
456
|
+
* `replace N..N` expanding into several lines is an *expansion* where every
|
|
457
|
+
* payload line is intentional new content, so a payload line that happens to
|
|
458
|
+
* equal a neighbor stays — only a genuine block rewrite retypes a boundary
|
|
459
|
+
* keeper by mistake. The dropped lines must be delimiter-neutral so removing the
|
|
460
|
+
* duplicate keeps the already-balanced result balanced, and must not consume the
|
|
461
|
+
* whole payload.
|
|
462
|
+
*/
|
|
463
|
+
function findOneSidedBoundaryEcho(
|
|
464
|
+
group: ReplacementGroup,
|
|
465
|
+
fileLines: readonly string[],
|
|
466
|
+
): { side: "leading" | "trailing"; count: number } | undefined {
|
|
467
|
+
if (group.deleteIndices.length <= 1) return undefined;
|
|
468
|
+
const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
|
|
469
|
+
const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
|
|
470
|
+
if (leading > 0 === trailing > 0) return undefined;
|
|
471
|
+
const side = leading > 0 ? "leading" : "trailing";
|
|
472
|
+
const count = leading > 0 ? leading : trailing;
|
|
473
|
+
if (count >= group.payload.length) return undefined;
|
|
474
|
+
const echoLines =
|
|
475
|
+
side === "leading" ? group.payload.slice(0, count) : group.payload.slice(group.payload.length - count);
|
|
476
|
+
if (!balanceIsZero(computeDelimiterBalance(echoLines))) return undefined;
|
|
477
|
+
return { side, count };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "trailing", count: number): string {
|
|
481
|
+
const where = side === "leading" ? "above" : "below";
|
|
482
|
+
return (
|
|
483
|
+
`Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
|
|
484
|
+
`dropped ${count} ${side} payload line(s) identical to the surviving line(s) just ${where} the range. ` +
|
|
485
|
+
`The range was one line short of the content you retyped — issue the payload as the final content for the ` +
|
|
486
|
+
`selected range only, and widen the range to consume any keeper you restate.`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
446
490
|
/**
|
|
447
491
|
* Normalize replacement groups so common off-by-one boundaries do not duplicate
|
|
448
492
|
* unchanged surrounding lines or structural closers. Returns the repaired edit
|
|
@@ -481,6 +525,16 @@ function repairReplacementBoundaries(
|
|
|
481
525
|
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
482
526
|
);
|
|
483
527
|
if (balanceIsZero(delta)) {
|
|
528
|
+
const oneSided = findOneSidedBoundaryEcho(group, fileLines);
|
|
529
|
+
if (oneSided) {
|
|
530
|
+
warnings.push(describeOneSidedEchoRepair(group, oneSided.side, oneSided.count));
|
|
531
|
+
const trimmed =
|
|
532
|
+
oneSided.side === "leading"
|
|
533
|
+
? inserts.slice(oneSided.count)
|
|
534
|
+
: inserts.slice(0, inserts.length - oneSided.count);
|
|
535
|
+
out.push(...trimmed, ...deletes);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
484
538
|
out.push(...inserts, ...deletes);
|
|
485
539
|
continue;
|
|
486
540
|
}
|
|
@@ -538,7 +592,7 @@ function repairReplacementBoundaries(
|
|
|
538
592
|
// content lines are never crossed) places the body at the depth its
|
|
539
593
|
// indentation names.
|
|
540
594
|
//
|
|
541
|
-
// Inward (block-lowered inserts only): `
|
|
595
|
+
// Inward (block-lowered inserts only): `insert_after_block N:` anchors on the
|
|
542
596
|
// resolved block's closing line, but a body indented deeper than that closer
|
|
543
597
|
// claims a depth inside the block — the common misreading of the op as
|
|
544
598
|
// "append at the end of block N's body". Sliding the landing point backward
|
|
@@ -576,7 +630,7 @@ interface AfterInsertGroup {
|
|
|
576
630
|
anchor: number;
|
|
577
631
|
/** Indices into the edit list, in patch order. */
|
|
578
632
|
members: number[];
|
|
579
|
-
/** First line of the resolved block when lowered from `
|
|
633
|
+
/** First line of the resolved block when lowered from `insert_after_block N:`. */
|
|
580
634
|
blockStart?: number;
|
|
581
635
|
}
|
|
582
636
|
|
|
@@ -676,7 +730,7 @@ function resolveInwardLanding(
|
|
|
676
730
|
/**
|
|
677
731
|
* Slide mis-anchored after-insert hunks to the depth their body indentation
|
|
678
732
|
* claims: outward past the structural closer lines that follow the anchor
|
|
679
|
-
* when the body is shallower, or — for `
|
|
733
|
+
* when the body is shallower, or — for `insert_after_block N:` lowerings —
|
|
680
734
|
* inward across the block's trailing closers when the body is deeper than
|
|
681
735
|
* the block's closing line. Returns the corrected edit list plus one warning
|
|
682
736
|
* per shifted hunk.
|
package/src/block.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Expand deferred block edits (`
|
|
3
|
-
* `
|
|
2
|
+
* Expand deferred block edits (`replace_block N:` / `delete_block N` /
|
|
3
|
+
* `insert_after_block N:`) into concrete inserts + deletes.
|
|
4
4
|
*
|
|
5
5
|
* The hashline parser cannot expand a block edit on its own — the line span is
|
|
6
6
|
* unknown until file text + path (→ language) are available. This transform
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { STRUCTURAL_CLOSER_RE } from "./apply";
|
|
16
16
|
import {
|
|
17
17
|
BLOCK_RESOLVER_UNAVAILABLE,
|
|
18
|
+
blockSingleLineMessage,
|
|
18
19
|
blockUnresolvedMessage,
|
|
19
20
|
insertAfterBlockCloserLoweredWarning,
|
|
20
21
|
insertAfterBlockUnresolvedLoweredWarning,
|
|
@@ -28,7 +29,7 @@ export interface ResolveBlockEditsOptions {
|
|
|
28
29
|
* `blockUnresolvedMessage` error — used by the authoritative apply + final
|
|
29
30
|
* preview paths. `"drop"` silently skips the edit — used by the streaming
|
|
30
31
|
* preview, where a half-written file or transient parse error must not
|
|
31
|
-
* throw. Unresolvable `
|
|
32
|
+
* throw. Unresolvable `insert_after_block N:` edits never reach this: they
|
|
32
33
|
* are lowered to plain `insert after N:` with a warning.
|
|
33
34
|
*/
|
|
34
35
|
onUnresolved?: "throw" | "drop";
|
|
@@ -41,7 +42,7 @@ export interface ResolveBlockEditsOptions {
|
|
|
41
42
|
onResolved?: (resolution: BlockResolution) => void;
|
|
42
43
|
/**
|
|
43
44
|
* Invoked once per diagnostic produced while resolving — currently the
|
|
44
|
-
* `
|
|
45
|
+
* `insert_after_block N:` lowerings (closer anchor or unresolvable block).
|
|
45
46
|
* Hosts should surface these on the apply result's `warnings`.
|
|
46
47
|
*/
|
|
47
48
|
onWarning?: (message: string) => void;
|
|
@@ -81,7 +82,7 @@ export function resolveBlockEdits(
|
|
|
81
82
|
const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
|
|
82
83
|
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
|
|
83
84
|
if (span === null) {
|
|
84
|
-
// `
|
|
85
|
+
// `insert_after_block N:` never fails the patch — lower it to plain
|
|
85
86
|
// `insert after N:` with a warning instead. Two flavors:
|
|
86
87
|
// - anchored on a pure closing-delimiter line: no block begins
|
|
87
88
|
// there, but line N IS the end of one, and "after the end of the
|
|
@@ -110,6 +111,15 @@ export function resolveBlockEdits(
|
|
|
110
111
|
}`,
|
|
111
112
|
);
|
|
112
113
|
}
|
|
114
|
+
if (span.start === span.end) {
|
|
115
|
+
// A single-line block resolution means line N is a bare statement, not
|
|
116
|
+
// the opening line of a multi-line construct — the common mis-anchor
|
|
117
|
+
// that lands a body in the wrong scope (e.g. between a `case` body line
|
|
118
|
+
// and its `break;`). The plain op is exact for one line, so reject and
|
|
119
|
+
// point at it; drop instead on the lenient preview path.
|
|
120
|
+
if (onUnresolved === "drop") continue;
|
|
121
|
+
throw new Error(`line ${edit.lineNum}: ${blockSingleLineMessage(edit.anchor.line, op)}`);
|
|
122
|
+
}
|
|
113
123
|
options.onResolved?.({
|
|
114
124
|
anchorLine: edit.anchor.line,
|
|
115
125
|
start: span.start,
|
|
@@ -138,7 +148,7 @@ export function resolveBlockEdits(
|
|
|
138
148
|
// Mirror the parser's `replace start..end:` expansion exactly: one
|
|
139
149
|
// `before_anchor` replacement insert per payload row at `span.start`,
|
|
140
150
|
// then one delete per line across `[span.start, span.end]`. An empty
|
|
141
|
-
// `payloads` (from `
|
|
151
|
+
// `payloads` (from `delete_block N`) emits no inserts — a pure deletion.
|
|
142
152
|
for (const payload of edit.payloads) {
|
|
143
153
|
const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
|
|
144
154
|
resolved.push({
|
package/src/format.ts
CHANGED
|
@@ -14,22 +14,25 @@ export const HL_FILE_SUFFIX = "]";
|
|
|
14
14
|
export const HL_PAYLOAD_REPLACE = "+";
|
|
15
15
|
|
|
16
16
|
/** Hunk-header keyword for concrete line replacement. */
|
|
17
|
-
export const HL_REPLACE_KEYWORD = "
|
|
18
|
-
/** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
|
|
19
|
-
export const HL_BLOCK_KEYWORD = "block";
|
|
17
|
+
export const HL_REPLACE_KEYWORD = "SWAP";
|
|
20
18
|
/** Hunk-header keyword for concrete line deletion. */
|
|
21
|
-
export const HL_DELETE_KEYWORD = "
|
|
19
|
+
export const HL_DELETE_KEYWORD = "DEL";
|
|
22
20
|
/** Hunk-header keyword for insertion operations. */
|
|
23
|
-
export const HL_INSERT_KEYWORD = "
|
|
21
|
+
export const HL_INSERT_KEYWORD = "INS";
|
|
24
22
|
/** Insert position keyword for inserting before a concrete line. */
|
|
25
|
-
export const HL_INSERT_BEFORE = "
|
|
23
|
+
export const HL_INSERT_BEFORE = "PRE";
|
|
26
24
|
/** Insert position keyword for inserting after a concrete line. */
|
|
27
|
-
export const HL_INSERT_AFTER = "
|
|
25
|
+
export const HL_INSERT_AFTER = "POST";
|
|
28
26
|
/** Insert position keyword for inserting at the start of the file. */
|
|
29
|
-
export const HL_INSERT_HEAD = "
|
|
27
|
+
export const HL_INSERT_HEAD = "HEAD";
|
|
30
28
|
/** Insert position keyword for inserting at the end of the file. */
|
|
31
|
-
export const HL_INSERT_TAIL = "
|
|
32
|
-
/** Hunk-header
|
|
29
|
+
export const HL_INSERT_TAIL = "TAIL";
|
|
30
|
+
/** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
|
|
31
|
+
export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
|
|
32
|
+
/** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
|
|
33
|
+
export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
|
|
34
|
+
/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
|
|
35
|
+
export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
|
|
33
36
|
export const HL_HEADER_COLON = ":";
|
|
34
37
|
|
|
35
38
|
/** Separator between a hashline file path and its opaque snapshot tag. */
|
|
@@ -65,13 +68,13 @@ export function formatDeleteHeader(start: number, end = start): string {
|
|
|
65
68
|
export function formatInsertHeader(cursor: Cursor): string {
|
|
66
69
|
switch (cursor.kind) {
|
|
67
70
|
case "before_anchor":
|
|
68
|
-
return `${HL_INSERT_KEYWORD}
|
|
71
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
69
72
|
case "after_anchor":
|
|
70
|
-
return `${HL_INSERT_KEYWORD}
|
|
73
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
71
74
|
case "bof":
|
|
72
|
-
return `${HL_INSERT_KEYWORD}
|
|
75
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
|
|
73
76
|
case "eof":
|
|
74
|
-
return `${HL_INSERT_KEYWORD}
|
|
77
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
package/src/grammar.lark
CHANGED
|
@@ -12,13 +12,13 @@ replace_hunk: replace_anchor LF emit_op*
|
|
|
12
12
|
replace_block_hunk: replace_block_anchor LF emit_op+
|
|
13
13
|
insert_hunk: insert_anchor LF emit_op+
|
|
14
14
|
insert_block_hunk: insert_block_anchor LF emit_op+
|
|
15
|
-
delete_hunk: "
|
|
16
|
-
delete_block_hunk: "
|
|
17
|
-
replace_anchor: "
|
|
18
|
-
replace_block_anchor: "
|
|
19
|
-
insert_anchor: "
|
|
20
|
-
insert_block_anchor: "
|
|
21
|
-
insert_pos: "
|
|
15
|
+
delete_hunk: "DEL " header_range LF
|
|
16
|
+
delete_block_hunk: "DEL.BLK " LID LF
|
|
17
|
+
replace_anchor: "SWAP " header_range ":"
|
|
18
|
+
replace_block_anchor: "SWAP.BLK " LID ":"
|
|
19
|
+
insert_anchor: "INS." insert_pos ":"
|
|
20
|
+
insert_block_anchor: "INS.BLK.POST " LID ":"
|
|
21
|
+
insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL"
|
|
22
22
|
emit_op: "+" /(.*)/ LF
|
|
23
23
|
|
|
24
24
|
header_range: LID ".." LID
|
package/src/input.ts
CHANGED
|
@@ -273,7 +273,7 @@ export class PatchSection {
|
|
|
273
273
|
get hasAnchorScopedEdit(): boolean {
|
|
274
274
|
return this.edits.some(edit => {
|
|
275
275
|
if (edit.kind === "delete") return true;
|
|
276
|
-
// A `
|
|
276
|
+
// A `replace_block N:` edit is anchored to concrete content on line N.
|
|
277
277
|
if (edit.kind === "block") return true;
|
|
278
278
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
279
279
|
});
|
|
@@ -305,7 +305,7 @@ export class PatchSection {
|
|
|
305
305
|
* method directly when you've already validated the file content and
|
|
306
306
|
* just want the result.
|
|
307
307
|
*
|
|
308
|
-
* `blockResolver` resolves any `
|
|
308
|
+
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
|
|
309
309
|
* unresolvable block throws (this is the final, authoritative preview path).
|
|
310
310
|
*/
|
|
311
311
|
applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
|
|
@@ -331,7 +331,7 @@ export class PatchSection {
|
|
|
331
331
|
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
332
332
|
* path should always use {@link applyTo}.
|
|
333
333
|
*
|
|
334
|
-
* `blockResolver` resolves any `
|
|
334
|
+
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
|
|
335
335
|
* unresolvable block is silently dropped so a half-written file does not
|
|
336
336
|
* throw mid-stream.
|
|
337
337
|
*/
|
package/src/messages.ts
CHANGED
|
@@ -44,11 +44,10 @@ export const ABORT_MARKER = "*** Abort";
|
|
|
44
44
|
|
|
45
45
|
/** Two consecutive hunks targeted the exact same concrete range. */
|
|
46
46
|
export const REPLACE_PAIR_COALESCED_WARNING =
|
|
47
|
-
"Two hunks targeted the same range; kept only the second. One `
|
|
47
|
+
"Two hunks targeted the same range; kept only the second. One `SWAP N..M:` hunk per range — the body is the final content, never old+new.";
|
|
48
48
|
|
|
49
49
|
/** Bare bodyless hunk followed by an overlapping concrete hunk. */
|
|
50
|
-
|
|
51
|
-
"Dropped a bare hunk overlapped by the concrete hunk after it. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
|
|
50
|
+
("Dropped a bare hunk overlapped by the concrete hunk after it. One `SWAP N..M:` hunk per range — the body is the final content, never old+new.");
|
|
52
51
|
|
|
53
52
|
/** Bare body rows auto-converted to literal `+` rows. */
|
|
54
53
|
export const BARE_BODY_AUTO_PIPED_WARNING =
|
|
@@ -59,17 +58,16 @@ export const MINUS_ROW_REJECTED =
|
|
|
59
58
|
"`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
|
|
60
59
|
|
|
61
60
|
/** Replace hunk with no body. */
|
|
62
|
-
export const EMPTY_REPLACE = "`
|
|
61
|
+
export const EMPTY_REPLACE = "`SWAP N..M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N..M`.";
|
|
63
62
|
|
|
64
|
-
/** `
|
|
65
|
-
export const EMPTY_BLOCK =
|
|
66
|
-
"`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`.";
|
|
63
|
+
/** `replace_block N:` hunk with no body. */
|
|
64
|
+
export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
|
|
67
65
|
|
|
68
66
|
/**
|
|
69
67
|
* Block-anchored replace/delete could not resolve to a syntactic block
|
|
70
68
|
* (unsupported language, blank/out-of-range line, no node beginning on N, or
|
|
71
69
|
* parse error). Appends a {@link formatAnchoredContext} preview when
|
|
72
|
-
* `fileLines` is given. `
|
|
70
|
+
* `fileLines` is given. `insert_after_block N:` never reaches this — it is
|
|
73
71
|
* lowered to plain `insert after N:` instead (see
|
|
74
72
|
* {@link insertAfterBlockUnresolvedLoweredWarning}).
|
|
75
73
|
*/
|
|
@@ -78,8 +76,8 @@ export function blockUnresolvedMessage(
|
|
|
78
76
|
op: "replace" | "delete" = "replace",
|
|
79
77
|
fileLines?: readonly string[],
|
|
80
78
|
): string {
|
|
81
|
-
const phrase = op === "delete" ? `
|
|
82
|
-
const fallback = op === "delete" ? `
|
|
79
|
+
const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
|
|
80
|
+
const fallback = op === "delete" ? `DEL ${line}..M` : `SWAP ${line}..M:`;
|
|
83
81
|
let message =
|
|
84
82
|
`\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
|
|
85
83
|
`(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
|
|
@@ -92,42 +90,41 @@ export function blockUnresolvedMessage(
|
|
|
92
90
|
|
|
93
91
|
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
|
|
94
92
|
export const BLOCK_RESOLVER_UNAVAILABLE =
|
|
95
|
-
"`
|
|
93
|
+
"`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
|
|
96
94
|
|
|
97
95
|
/**
|
|
98
|
-
* `
|
|
96
|
+
* `insert_after_block N:` anchored on a closing-delimiter line, lowered to
|
|
99
97
|
* plain `insert after N:` — the closer ends a block, and inserting after it
|
|
100
98
|
* is exactly what the plain form does.
|
|
101
99
|
*/
|
|
102
100
|
export function insertAfterBlockCloserLoweredWarning(line: number): string {
|
|
103
|
-
return `\`
|
|
101
|
+
return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`;
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
/**
|
|
107
|
-
* `
|
|
105
|
+
* `insert_after_block N:` anchor unresolvable (unsupported language, blank
|
|
108
106
|
* line, parse error, or no resolver), lowered to plain `insert after N:` —
|
|
109
107
|
* applying with a warning beats failing the patch.
|
|
110
108
|
*/
|
|
111
109
|
export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
|
|
112
|
-
return `\`
|
|
110
|
+
return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
/**
|
|
116
|
-
* Internal invariant: `applyEdits` received an unresolved `
|
|
114
|
+
* Internal invariant: `applyEdits` received an unresolved `replace_block N:`
|
|
117
115
|
* edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
|
|
118
116
|
*/
|
|
119
117
|
export const UNRESOLVED_BLOCK_INTERNAL =
|
|
120
|
-
"internal error: unresolved `
|
|
118
|
+
"internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
|
|
121
119
|
|
|
122
120
|
/** Delete hunk received a body row. */
|
|
123
|
-
export const DELETE_TAKES_NO_BODY = "`
|
|
121
|
+
export const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `SWAP N..M:`.";
|
|
124
122
|
|
|
125
|
-
/** `
|
|
126
|
-
export const DELETE_BLOCK_TAKES_NO_BODY =
|
|
127
|
-
"`delete block N` does not take body rows. Remove the body, or use `replace block N:`.";
|
|
123
|
+
/** `delete_block N` hunk received a body row. */
|
|
124
|
+
export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
|
|
128
125
|
|
|
129
126
|
/** Insert hunk with no body. */
|
|
130
|
-
export const EMPTY_INSERT = "`
|
|
127
|
+
export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
|
|
131
128
|
|
|
132
129
|
/**
|
|
133
130
|
* `insert after` body indented shallower than the anchor: the landing slid
|
|
@@ -135,16 +132,16 @@ export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
|
|
|
135
132
|
* I read instead of after the block" mistake.
|
|
136
133
|
*/
|
|
137
134
|
export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string {
|
|
138
|
-
return `
|
|
135
|
+
return `INS.POST ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`;
|
|
139
136
|
}
|
|
140
137
|
|
|
141
138
|
/**
|
|
142
|
-
* `
|
|
139
|
+
* `insert_after_block N:` body indented deeper than the block's closer: the
|
|
143
140
|
* landing was pulled inside the block — a deeper body almost always means
|
|
144
141
|
* "append inside the block's body".
|
|
145
142
|
*/
|
|
146
143
|
export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string {
|
|
147
|
-
return `
|
|
144
|
+
return `INS.BLK.POST ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`INS.BLK.POST\` lands AFTER the block at sibling depth — if inside was intended, use plain \`INS.POST ${closerLine}:\`.`;
|
|
148
145
|
}
|
|
149
146
|
|
|
150
147
|
/** `Recovery`: an external write matched a cached snapshot. */
|
|
@@ -170,7 +167,7 @@ export const RECOVERY_SESSION_REPLAY_WARNING =
|
|
|
170
167
|
* onto live content and warn instead of hard-failing.
|
|
171
168
|
*/
|
|
172
169
|
export const HEADTAIL_DRIFT_WARNING =
|
|
173
|
-
"Applied the `
|
|
170
|
+
"Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
|
|
174
171
|
|
|
175
172
|
/**
|
|
176
173
|
* Section omitted the mandatory snapshot tag. Shared by the apply
|
|
@@ -179,3 +176,60 @@ export const HEADTAIL_DRIFT_WARNING =
|
|
|
179
176
|
export function missingSnapshotTagMessage(sectionPath: string): string {
|
|
180
177
|
return `Missing hashline snapshot tag for ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX}\` from your latest read/search output. To create a new file, use the write tool.`;
|
|
181
178
|
}
|
|
179
|
+
|
|
180
|
+
/** Compress a line list into a sorted `1-4, 7, 10-12` range string. */
|
|
181
|
+
function formatLineRanges(lines: readonly number[]): string {
|
|
182
|
+
const sorted = [...new Set(lines)].sort((a, b) => a - b);
|
|
183
|
+
if (sorted.length === 0) return "";
|
|
184
|
+
const parts: string[] = [];
|
|
185
|
+
let start = sorted[0];
|
|
186
|
+
let prev = sorted[0];
|
|
187
|
+
for (let i = 1; i <= sorted.length; i++) {
|
|
188
|
+
const current = sorted[i];
|
|
189
|
+
if (current === prev + 1) {
|
|
190
|
+
prev = current;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
parts.push(start === prev ? `${start}` : `${start}-${prev}`);
|
|
194
|
+
start = current;
|
|
195
|
+
prev = current;
|
|
196
|
+
}
|
|
197
|
+
return parts.join(", ");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* An anchored edit referenced lines the read that minted the cited tag never
|
|
202
|
+
* displayed (a partial range, or a structural summary that collapsed bodies).
|
|
203
|
+
* Editing lines you have not read is the off-by-memory failure that mangles
|
|
204
|
+
* files; reject and make the model re-read those exact lines first.
|
|
205
|
+
*/
|
|
206
|
+
export function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string {
|
|
207
|
+
return (
|
|
208
|
+
`This edit targets line(s) ${formatLineRanges(unseenLines)} of ${sectionPath} that were not shown in the ` +
|
|
209
|
+
`read/search output for ${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}${tag}${HL_FILE_SUFFIX} — a partial ` +
|
|
210
|
+
`range, a search hit, or a structural summary that collapsed bodies was displayed, not those exact lines. ` +
|
|
211
|
+
`Re-read those lines, then re-issue the edit against the fresh tag. NEVER author hunks against line numbers ` +
|
|
212
|
+
`you have not seen in the current snapshot.`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
|
|
217
|
+
export type BlockOp = "replace" | "delete" | "insert_after";
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
|
|
221
|
+
* single line — almost always a bare statement the model mis-anchored, not a
|
|
222
|
+
* multi-line construct. The plain op is unambiguous for one line; the block
|
|
223
|
+
* form only earns its keep when it spares counting a closing line you cannot
|
|
224
|
+
* see. Reject and point at both fixes.
|
|
225
|
+
*/
|
|
226
|
+
export function blockSingleLineMessage(line: number, op: BlockOp): string {
|
|
227
|
+
const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
|
|
228
|
+
const plainForm =
|
|
229
|
+
op === "insert_after" ? `INS.POST ${line}:` : op === "delete" ? `DEL ${line}` : `SWAP ${line}..${line}:`;
|
|
230
|
+
return (
|
|
231
|
+
`\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
|
|
232
|
+
`of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
|
|
233
|
+
`on the line that OPENS it (e.g. its \`function\`/\`if\`/\`case\` header), never a statement inside it.`
|
|
234
|
+
);
|
|
235
|
+
}
|
package/src/parser.ts
CHANGED
|
@@ -52,33 +52,33 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
|
|
|
52
52
|
return (
|
|
53
53
|
`apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
|
|
54
54
|
"File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
|
|
55
|
-
"Use `
|
|
55
|
+
"Use `SWAP N..M:`, `DEL N..M`, or `INS.PRE|POST|HEAD|TAIL:` ops."
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
|
|
59
59
|
return (
|
|
60
60
|
"unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
|
|
61
|
-
"Use `
|
|
61
|
+
"Use `SWAP N..M:`, `DEL N..M`, or `INS.PRE|POST|HEAD|TAIL:` ops."
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
if (trimmed.startsWith("@@")) {
|
|
65
65
|
const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
|
|
66
66
|
return (
|
|
67
67
|
`\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` +
|
|
68
|
-
"Drop the `@@ ... @@` brackets and write a verb header such as `
|
|
68
|
+
"Drop the `@@ ... @@` brackets and write a verb header such as `SWAP N..M:`."
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
|
-
if (/^
|
|
72
|
-
return "`
|
|
71
|
+
if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
|
|
72
|
+
return "`DEL N..M` has no colon and no body. Remove the colon and body rows.";
|
|
73
73
|
}
|
|
74
74
|
if (/^[1-9]\d*\s*$/.test(trimmed)) {
|
|
75
|
-
return `hunk headers need a verb. Use \`
|
|
75
|
+
return `hunk headers need a verb. Use \`SWAP ${trimmed}..${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`;
|
|
76
76
|
}
|
|
77
77
|
const bareRange = /^([1-9]\d*)\s*[-. …]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
|
|
78
78
|
if (bareRange !== null) {
|
|
79
79
|
return (
|
|
80
80
|
`bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` +
|
|
81
|
-
`Hunk headers need a verb: write \`
|
|
81
|
+
`Hunk headers need a verb: write \`SWAP ${bareRange[1]}..${bareRange[2]}:\` or \`DEL ${bareRange[1]}..${bareRange[2]}\`.`
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
return null;
|
|
@@ -253,7 +253,7 @@ export class Executor {
|
|
|
253
253
|
if (text.trim().length === 0) return;
|
|
254
254
|
throw new Error(
|
|
255
255
|
`line ${lineNum}: payload line has no preceding hunk header. ` +
|
|
256
|
-
`Use \`
|
|
256
|
+
`Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`,
|
|
257
257
|
);
|
|
258
258
|
}
|
|
259
259
|
|
package/src/patcher.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { computeFileHash, formatHashlineHeader } from "./format";
|
|
|
28
28
|
import type { Filesystem, WriteResult } from "./fs";
|
|
29
29
|
import { isNotFound } from "./fs";
|
|
30
30
|
import type { Patch, PatchSection } from "./input";
|
|
31
|
-
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
|
|
31
|
+
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage, unseenLinesMessage } from "./messages";
|
|
32
32
|
import { MismatchError } from "./mismatch";
|
|
33
33
|
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
34
34
|
import { Recovery, type RecoveryResult } from "./recovery";
|
|
@@ -41,8 +41,8 @@ export interface PatcherOptions {
|
|
|
41
41
|
/** Snapshot store that minted and resolves hashline section tags. Required. */
|
|
42
42
|
snapshots: SnapshotStore;
|
|
43
43
|
/**
|
|
44
|
-
* Resolves `
|
|
45
|
-
* Optional: when omitted, any `
|
|
44
|
+
* Resolves `replace_block N:` anchors to concrete line spans via tree-sitter.
|
|
45
|
+
* Optional: when omitted, any `replace_block N:` edit throws on apply (the
|
|
46
46
|
* host did not wire a resolver). Plain line-range ops never need it.
|
|
47
47
|
*/
|
|
48
48
|
blockResolver?: BlockResolver;
|
|
@@ -73,7 +73,7 @@ export interface PatchSectionResult {
|
|
|
73
73
|
/** Warnings collected by the parser, applier, and (optionally) recovery. */
|
|
74
74
|
warnings: string[];
|
|
75
75
|
/**
|
|
76
|
-
* Resolved spans for any `
|
|
76
|
+
* Resolved spans for any `replace_block`/`delete_block` ops, present when the
|
|
77
77
|
* apply matched the tagged content. Undefined for patches with no block ops
|
|
78
78
|
* (and for resolutions routed through drift recovery, where numbers shift).
|
|
79
79
|
*/
|
|
@@ -112,7 +112,7 @@ export class PreparedSection {
|
|
|
112
112
|
function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
|
113
113
|
return edits.some(edit => {
|
|
114
114
|
if (edit.kind === "delete") return true;
|
|
115
|
-
// A `
|
|
115
|
+
// A `replace_block N:` edit anchors to concrete content on line N.
|
|
116
116
|
if (edit.kind === "block") return true;
|
|
117
117
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
118
118
|
});
|
|
@@ -341,6 +341,22 @@ export class Patcher {
|
|
|
341
341
|
#recordFullSnapshot(canonicalPath: string, normalized: string): string {
|
|
342
342
|
return this.snapshots.record(canonicalPath, normalized);
|
|
343
343
|
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Reject an anchored edit that references a line the read which minted
|
|
347
|
+
* `expected` never displayed. The snapshot's `seenLines` is the set of
|
|
348
|
+
* 1-indexed lines a producer (read/search) actually showed under that tag;
|
|
349
|
+
* absent or empty means no provenance was recorded, so the edit applies as
|
|
350
|
+
* before. Only runs on the no-drift path, where anchor line numbers index
|
|
351
|
+
* the tagged content 1:1.
|
|
352
|
+
*/
|
|
353
|
+
#assertSeenLines(section: PatchSection, canonicalPath: string, expected: string): void {
|
|
354
|
+
const seen = this.snapshots.byHash(canonicalPath, expected)?.seenLines;
|
|
355
|
+
if (!seen || seen.size === 0) return;
|
|
356
|
+
const unseen = section.collectAnchorLines().filter(line => !seen.has(line));
|
|
357
|
+
if (unseen.length === 0) return;
|
|
358
|
+
throw new Error(unseenLinesMessage(section.path, unseen, expected));
|
|
359
|
+
}
|
|
344
360
|
#mismatchError(
|
|
345
361
|
section: PatchSection,
|
|
346
362
|
canonicalPath: string,
|
|
@@ -370,7 +386,7 @@ export class Patcher {
|
|
|
370
386
|
const expected = exists ? section.fileHash : undefined;
|
|
371
387
|
const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
|
|
372
388
|
|
|
373
|
-
// Resolve `
|
|
389
|
+
// Resolve `replace_block N:` edits to concrete ranges before recovery
|
|
374
390
|
// runs. Block anchors are expressed against the snapshot the section tag
|
|
375
391
|
// names, so resolve against that exact text:
|
|
376
392
|
// - live content matches the tag (or there is no tag) → resolve against
|
|
@@ -404,6 +420,10 @@ export class Patcher {
|
|
|
404
420
|
// the caller read, so echo them back. (A drifted file falls through to
|
|
405
421
|
// recovery below, where line numbers shift, so resolutions are dropped.)
|
|
406
422
|
if (expected === undefined || liveMatches) {
|
|
423
|
+
// The line numbers in `edits` index the exact content the tag names.
|
|
424
|
+
// Reject any anchor the read never displayed: editing lines the model
|
|
425
|
+
// has not seen is the off-by-memory mistake that mangles files.
|
|
426
|
+
if (expected !== undefined) this.#assertSeenLines(section, canonicalPath, expected);
|
|
407
427
|
const result = applyEdits(normalized, resolved);
|
|
408
428
|
return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
|
|
409
429
|
}
|