@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/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): `insert after block N:` anchors on the
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 `insert after block N:`. */
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 `insert after block N:` lowerings —
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 (`replace block N:` / `delete block N` /
3
- * `insert after block N:`) into concrete inserts + deletes.
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 `insert after block N:` edits never reach this: they
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
- * `insert after block N:` lowerings (closer anchor or unresolvable block).
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
- // `insert after block N:` never fails the patch — lower it to plain
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 `delete block N`) emits no inserts — a pure deletion.
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 = "replace";
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 = "delete";
19
+ export const HL_DELETE_KEYWORD = "DEL";
22
20
  /** Hunk-header keyword for insertion operations. */
23
- export const HL_INSERT_KEYWORD = "insert";
21
+ export const HL_INSERT_KEYWORD = "INS";
24
22
  /** Insert position keyword for inserting before a concrete line. */
25
- export const HL_INSERT_BEFORE = "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 = "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 = "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 = "tail";
32
- /** Hunk-header terminator for body-bearing operations. */
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} ${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
71
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
69
72
  case "after_anchor":
70
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
73
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
71
74
  case "bof":
72
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
75
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
73
76
  case "eof":
74
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
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: "delete " header_range LF
16
- delete_block_hunk: "delete block " LID LF
17
- replace_anchor: "replace " header_range ":"
18
- replace_block_anchor: "replace block " LID ":"
19
- insert_anchor: "insert " insert_pos ":"
20
- insert_block_anchor: "insert after block " LID ":"
21
- insert_pos: "before " LID | "after " LID | "head" | "tail"
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 `replace block N:` edit is anchored to concrete content on line N.
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 `replace block N:` edits against `text`; an
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 `replace block N:` edits against `text`; an
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 `replace N..M:` hunk per range — the body is the final content, never old+new.";
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
- export const REPLACE_PAIR_COALESCED_OVERLAP_WARNING =
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 = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
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
- /** `replace block N:` hunk with no body. */
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. `insert after block N:` never reaches this — it is
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" ? `delete block ${line}` : `replace block ${line}:`;
82
- const fallback = op === "delete" ? `delete ${line}..M` : `replace ${line}..M:`;
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
- "`replace block`/`delete block`/`insert after block` are not available here (no block resolver configured). Use a concrete line range.";
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
- * `insert after block N:` anchored on a closing-delimiter line, lowered to
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 `\`insert after block ${line}:\` anchors on a closing delimiter, so it was applied as plain \`insert after ${line}:\`. Anchor on the line that OPENS the construct.`;
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
- * `insert after block N:` anchor unresolvable (unsupported language, blank
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 `\`insert after block ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`insert after ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
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 `replace block N:`
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 `replace block` edit reached the applier (resolveBlockEdits was not run).";
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 = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
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
- /** `delete block N` hunk received a body row. */
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 = "`insert` needs at least one `+TEXT` body row.";
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 `insert after ${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.`;
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
- * `insert after block N:` body indented deeper than the block's closer: the
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 `insert after block ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`insert after block\` lands AFTER the block at sibling depth — if inside was intended, use plain \`insert after ${closerLine}:\`.`;
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 `insert head:`/`insert 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.";
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 `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
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 `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
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 `replace N..M:`."
68
+ "Drop the `@@ ... @@` brackets and write a verb header such as `SWAP N..M:`."
69
69
  );
70
70
  }
71
- if (/^delete\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
72
- return "`delete N..M` has no colon and no body. Remove the colon and body rows.";
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 \`replace ${trimmed}..${trimmed}:\` to replace, or \`delete ${trimmed}\` to delete.`;
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 \`replace ${bareRange[1]}..${bareRange[2]}:\` or \`delete ${bareRange[1]}..${bareRange[2]}\`.`
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 \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` above the body. Got ${JSON.stringify(text)}.`,
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 `replace block N:` anchors to concrete line spans via tree-sitter.
45
- * Optional: when omitted, any `replace block N:` edit throws on apply (the
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 `replace block`/`delete block` ops, present when the
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 `replace block N:` edit anchors to concrete content on line N.
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 `replace block N:` edits to concrete ranges before recovery
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
  }