@oh-my-pi/hashline 15.11.3 → 15.11.6

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.
@@ -1,4 +1,6 @@
1
1
  import type { ApplyResult, Edit } from "./types";
2
+ /** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
3
+ export declare const STRUCTURAL_CLOSER_RE: RegExp;
2
4
  /**
3
5
  * Apply a parsed list of edits to a text body. Pure function — no I/O.
4
6
  *
@@ -15,6 +15,12 @@ export interface ResolveBlockEditsOptions {
15
15
  * edits.
16
16
  */
17
17
  onResolved?: (resolution: BlockResolution) => void;
18
+ /**
19
+ * Invoked once per diagnostic produced while resolving — currently only the
20
+ * closer-anchor lowering of `insert after block N:`. Hosts should surface
21
+ * these on the apply result's `warnings`.
22
+ */
23
+ onWarning?: (message: string) => void;
18
24
  }
19
25
  /** True when at least one edit is an unresolved deferred block edit. */
20
26
  export declare function hasBlockEdit(edits: readonly Edit[]): boolean;
@@ -50,6 +50,13 @@ export declare function blockUnresolvedMessage(line: number, op?: "replace" | "d
50
50
  * rather than authored-input error.
51
51
  */
52
52
  export declare const BLOCK_RESOLVER_UNAVAILABLE = "Block-anchored ops (`replace block N:`, `delete block N`, `insert after block N:`) are not available here (no tree-sitter block resolver is configured). Use a concrete line range instead.";
53
+ /**
54
+ * Warning emitted when an `insert after block N:` anchored on a pure
55
+ * closing-delimiter line is lowered to plain `insert after N:`. No block
56
+ * begins on a closer, but the closer IS the end of one — and inserting after
57
+ * the end of that block is exactly what the plain form does.
58
+ */
59
+ export declare function insertAfterBlockCloserLoweredWarning(line: number): string;
53
60
  /**
54
61
  * Internal invariant error: `applyEdits` received an unresolved `replace block
55
62
  * N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
@@ -71,6 +78,14 @@ export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body ro
71
78
  * I read" mistake.
72
79
  */
73
80
  export declare function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string;
81
+ /**
82
+ * Warning emitted when an `insert after block N:` body is indented deeper
83
+ * than the block's closing line and the landing was pulled back inside the
84
+ * block. `insert after block` places content AFTER the block at sibling
85
+ * depth; a deeper body almost always means "append inside the block's body"
86
+ * — the misuse this corrects.
87
+ */
88
+ export declare function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string;
74
89
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
75
90
  export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
76
91
  /** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
@@ -33,6 +33,13 @@ export type Edit = {
33
33
  lineNum: number;
34
34
  index: number;
35
35
  mode?: "replacement";
36
+ /**
37
+ * Present on inserts lowered from `insert after block N:`: the
38
+ * resolved block's first line. Lets the applier slide a body that
39
+ * claims a depth inside the block back across the block's trailing
40
+ * closer lines (never above this line).
41
+ */
42
+ blockStart?: number;
36
43
  } | {
37
44
  kind: "delete";
38
45
  anchor: Anchor;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.11.3",
4
+ "version": "15.11.6",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
package/src/apply.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * which absorbs common model mistakes where a payload restates unchanged range
8
8
  * boundaries or duplicates/drops structural closers.
9
9
  */
10
- import { afterInsertLandingShiftWarning, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
10
+ import { afterInsertLandingShiftWarning, blockInsertLandingShiftWarning, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
11
11
  import { cloneCursor } from "./tokenizer";
12
12
  import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
13
13
 
@@ -35,26 +35,31 @@ function getEditAnchors(edit: AppliedEdit): Anchor[] {
35
35
  return getCursorAnchors(edit.cursor);
36
36
  }
37
37
 
38
+ function trailingPhantomLine(fileLines: readonly string[]): number {
39
+ // `split("\n")` on a newline-terminated file yields a trailing "" sentinel.
40
+ // It is addressable for inserts (append-past-end), but it is not real
41
+ // content. Deleting it only strips the file's final newline, so ignore delete
42
+ // edits that land there; inclusive ranges ending at EOF then do the intended
43
+ // thing and delete through the last concrete line.
44
+ return fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0;
45
+ }
46
+
47
+ function dropTrailingPhantomDeletes(edits: AppliedEdit[], fileLines: readonly string[]): AppliedEdit[] {
48
+ const phantomLine = trailingPhantomLine(fileLines);
49
+ if (phantomLine === 0) return edits;
50
+ return edits.filter(edit => edit.kind !== "delete" || edit.anchor.line !== phantomLine);
51
+ }
52
+
38
53
  /**
39
54
  * Verify every anchored edit points at an existing line. File-version binding is
40
55
  * checked once per section via the header hash before this function runs.
41
56
  */
42
- function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
43
- // `split("\n")` on a newline-terminated file yields a trailing "" sentinel.
44
- // It is addressable for inserts (append-past-end), but deleting it would
45
- // silently strip the file's final newline — an off-by-one that must error.
46
- const phantomLine = fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0;
57
+ function validateLineBounds(edits: readonly AppliedEdit[], fileLines: readonly string[]): void {
47
58
  for (const edit of edits) {
48
59
  for (const anchor of getEditAnchors(edit)) {
49
60
  if (anchor.line < 1 || anchor.line > fileLines.length) {
50
61
  throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
51
62
  }
52
- if (edit.kind === "delete" && anchor.line === phantomLine) {
53
- throw new Error(
54
- `Line ${anchor.line} is the trailing blank sentinel of a newline-terminated file and has no content to delete. ` +
55
- `End the range at line ${anchor.line - 1}, or use \`insert tail:\` to append.`,
56
- );
57
- }
58
63
  }
59
64
  }
60
65
  }
@@ -123,7 +128,7 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
123
128
  // wrapper" mistake.
124
129
 
125
130
  /** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
126
- const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
131
+ export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
127
132
 
128
133
  interface DelimiterBalance {
129
134
  paren: number;
@@ -511,20 +516,32 @@ function repairReplacementBoundaries(
511
516
  //
512
517
  // The body rows of an `insert after N:` hunk carry an implicit depth claim:
513
518
  // their leading indentation says how deep the author expects the new lines
514
- // to sit. When that depth is shallower than line N itself, the hunk is
515
- // inserting a sibling of some enclosing construct while anchored inside it —
516
- // the common shape is anchoring on the last statement of a block and writing
517
- // the body at the parent's depth. Sliding the landing point forward across
518
- // the structural closer lines that follow (and nothing else content lines
519
- // are never crossed) places the body at the depth its indentation names.
519
+ // to sit. Two corrections share that claim, in opposite directions:
520
+ //
521
+ // Outward (any after-insert): when the depth is shallower than line N itself,
522
+ // the hunk is inserting a sibling of some enclosing construct while anchored
523
+ // inside it — the common shape is anchoring on the last statement of a block
524
+ // and writing the body at the parent's depth. Sliding the landing point
525
+ // forward across the structural closer lines that follow (and nothing else —
526
+ // content lines are never crossed) places the body at the depth its
527
+ // indentation names.
528
+ //
529
+ // Inward (block-lowered inserts only): `insert after block N:` anchors on the
530
+ // resolved block's closing line, but a body indented deeper than that closer
531
+ // claims a depth inside the block — the common misreading of the op as
532
+ // "append at the end of block N's body". Sliding the landing point backward
533
+ // across the block's trailing closer lines places the body inside, at its
534
+ // claimed depth. Scoped to block-lowered inserts because there the author
535
+ // named the opener and never saw the closer; a plain `insert after M:` on a
536
+ // closer line stays literal (the escape hatch for genuinely-after content
537
+ // such as method-chain continuations).
520
538
  //
521
- // The shift is deliberately conservative: it fires only when the body and
522
- // anchor indentation are comparable (one is a prefix of the other), crosses
523
- // only pure closing-delimiter lines indented at or deeper than the body,
524
- // stops as soon as depth returns to the body's level, and is abandoned when
525
- // any other edit in the patch targets a crossed line. Every shift is
526
- // reported as a warning so the author can re-issue with deeper indentation
527
- // when the original landing was intended.
539
+ // Both shifts are deliberately conservative: they fire only when the body
540
+ // and anchor indentation are comparable (one is a prefix of the other),
541
+ // cross only pure closing-delimiter lines, stop as soon as depth matches the
542
+ // body's claim, and are abandoned when any other edit in the patch targets a
543
+ // crossed line. Every shift is reported as a warning so the author can
544
+ // re-issue when the original landing was intended.
528
545
 
529
546
  /** Leading run of tabs and spaces. */
530
547
  function leadingIndent(line: string): string {
@@ -547,6 +564,8 @@ interface AfterInsertGroup {
547
564
  anchor: number;
548
565
  /** Indices into the edit list, in patch order. */
549
566
  members: number[];
567
+ /** First line of the resolved block when lowered from `insert after block N:`. */
568
+ blockStart?: number;
550
569
  }
551
570
 
552
571
  /**
@@ -603,10 +622,52 @@ function resolveShiftedLanding(
603
622
  }
604
623
 
605
624
  /**
606
- * Slide mis-anchored `insert after N:` hunks past the structural closer lines
607
- * that directly follow their anchor when the body's indentation says the new
608
- * lines belong at a shallower depth. Returns the corrected edit list plus one
609
- * warning per shifted hunk.
625
+ * Resolve where a block-lowered after-insert anchored on the block's closing
626
+ * line should land given a body depth `target` deeper than that closer: just
627
+ * above the block's trailing run of closer lines, bounded below by
628
+ * `blockStart` (an empty block lands the body right after its opener).
629
+ * Returns `undefined` when the landing stays put.
630
+ */
631
+ function resolveInwardLanding(
632
+ group: AfterInsertGroup,
633
+ target: string,
634
+ blockStart: number,
635
+ fileLines: readonly string[],
636
+ targetedLines: ReadonlySet<number>,
637
+ ): number | undefined {
638
+ const anchorText = fileLines[group.anchor - 1];
639
+ if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
640
+ // Fires only when the block ends in a pure closer the body out-indents.
641
+ // Blocks ending in content (indentation-only languages) already land the
642
+ // body inside the block — nothing to correct.
643
+ if (!STRUCTURAL_CLOSER_RE.test(anchorText)) return undefined;
644
+ if (!isIndentDeeper(target, leadingIndent(anchorText))) return undefined;
645
+
646
+ let landing = group.anchor;
647
+ for (let line = group.anchor; line > blockStart; line--) {
648
+ const text = fileLines[line - 1] ?? "";
649
+ if (!hasNonWhitespace(text)) {
650
+ landing = line - 1; // look past trailing blanks, never land after one
651
+ continue;
652
+ }
653
+ if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content reached — land right after it
654
+ const indent = leadingIndent(text);
655
+ if (!isIndentDeeper(target, indent)) break; // closer at the body's depth — land after it
656
+ // Another hunk owns this closer (the group's own rows put the anchor
657
+ // itself in `targetedLines`; that one is ours to cross).
658
+ if (line !== group.anchor && targetedLines.has(line)) return undefined;
659
+ landing = line - 1;
660
+ }
661
+ return landing === group.anchor ? undefined : landing;
662
+ }
663
+
664
+ /**
665
+ * Slide mis-anchored after-insert hunks to the depth their body indentation
666
+ * claims: outward past the structural closer lines that follow the anchor
667
+ * when the body is shallower, or — for `insert after block N:` lowerings —
668
+ * inward across the block's trailing closers when the body is deeper than
669
+ * the block's closing line. Returns the corrected edit list plus one warning
670
+ * per shifted hunk.
610
671
  */
611
672
  function repairAfterInsertLandings(
612
673
  edits: readonly AppliedEdit[],
@@ -620,7 +681,8 @@ function repairAfterInsertLandings(
620
681
  if (edit.cursor.kind !== "after_anchor") return;
621
682
  const key = `${edit.cursor.anchor.line}:${edit.lineNum}`;
622
683
  const group = groups.get(key);
623
- if (group === undefined) groups.set(key, { anchor: edit.cursor.anchor.line, members: [idx] });
684
+ if (group === undefined)
685
+ groups.set(key, { anchor: edit.cursor.anchor.line, members: [idx], blockStart: edit.blockStart });
624
686
  else group.members.push(idx);
625
687
  });
626
688
  if (groups.size === 0) return { edits, warnings: [] };
@@ -635,17 +697,27 @@ function repairAfterInsertLandings(
635
697
 
636
698
  let out: AppliedEdit[] | undefined;
637
699
  const warnings: string[] = [];
638
- for (const group of groups.values()) {
639
- const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text));
640
- if (target === undefined) continue;
641
- const landing = resolveShiftedLanding(group, target, fileLines, targetedLines);
642
- if (landing === undefined) continue;
700
+ const retarget = (group: AfterInsertGroup, line: number): void => {
643
701
  out ??= [...edits];
644
702
  for (const idx of group.members) {
645
703
  const edit = out[idx] as InsertEdit;
646
- out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line: landing.line } } };
704
+ out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line } } };
647
705
  }
648
- warnings.push(afterInsertLandingShiftWarning(group.anchor, landing.line, landing.crossed));
706
+ };
707
+ for (const group of groups.values()) {
708
+ const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text));
709
+ if (target === undefined) continue;
710
+ const outward = resolveShiftedLanding(group, target, fileLines, targetedLines);
711
+ if (outward !== undefined) {
712
+ retarget(group, outward.line);
713
+ warnings.push(afterInsertLandingShiftWarning(group.anchor, outward.line, outward.crossed));
714
+ continue;
715
+ }
716
+ if (group.blockStart === undefined) continue;
717
+ const inward = resolveInwardLanding(group, target, group.blockStart, fileLines, targetedLines);
718
+ if (inward === undefined) continue;
719
+ retarget(group, inward);
720
+ warnings.push(blockInsertLandingShiftWarning(group.blockStart, group.anchor, inward));
649
721
  }
650
722
  return { edits: out ?? edits, warnings };
651
723
  }
@@ -675,7 +747,10 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
675
747
  if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
676
748
  };
677
749
 
678
- const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
750
+ const targetEdits = dropTrailingPhantomDeletes(
751
+ appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
752
+ fileLines,
753
+ );
679
754
  validateLineBounds(targetEdits, fileLines);
680
755
  const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
681
756
  const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
package/src/block.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  * it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only
13
13
  * ever see resolved edits.
14
14
  */
15
- import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages";
15
+ import { STRUCTURAL_CLOSER_RE } from "./apply";
16
+ import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage, insertAfterBlockCloserLoweredWarning } from "./messages";
16
17
  import type { BlockResolution, BlockResolver, Cursor, Edit } from "./types";
17
18
 
18
19
  export interface ResolveBlockEditsOptions {
@@ -31,6 +32,12 @@ export interface ResolveBlockEditsOptions {
31
32
  * edits.
32
33
  */
33
34
  onResolved?: (resolution: BlockResolution) => void;
35
+ /**
36
+ * Invoked once per diagnostic produced while resolving — currently only the
37
+ * closer-anchor lowering of `insert after block N:`. Hosts should surface
38
+ * these on the apply result's `warnings`.
39
+ */
40
+ onWarning?: (message: string) => void;
34
41
  }
35
42
 
36
43
  /** True when at least one edit is an unresolved deferred block edit. */
@@ -67,6 +74,22 @@ export function resolveBlockEdits(
67
74
  const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
68
75
  const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
69
76
  if (span === null) {
77
+ // `insert after block N` anchored on a pure closing-delimiter line:
78
+ // no block begins there, but line N IS the end of one — and "after
79
+ // the end of the block" is exactly plain `insert after N:`. Lower it
80
+ // instead of failing the patch; warn so the author learns the
81
+ // opener-only rule.
82
+ if (op === "insert_after" && resolver) {
83
+ const anchorText = text.split("\n")[edit.anchor.line - 1];
84
+ if (anchorText !== undefined && STRUCTURAL_CLOSER_RE.test(anchorText)) {
85
+ options.onWarning?.(insertAfterBlockCloserLoweredWarning(edit.anchor.line));
86
+ for (const payload of edit.payloads) {
87
+ const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } };
88
+ resolved.push({ kind: "insert", cursor, text: payload, lineNum: edit.lineNum, index: synthIndex++ });
89
+ }
90
+ continue;
91
+ }
92
+ }
70
93
  if (onUnresolved === "drop") continue;
71
94
  throw new Error(
72
95
  `line ${edit.lineNum}: ${
@@ -82,10 +105,20 @@ export function resolveBlockEdits(
82
105
  });
83
106
  if (op === "insert_after") {
84
107
  // Mirror the parser's `insert after N:` lowering: one `after_anchor`
85
- // insert per payload row, anchored on the block's last line.
108
+ // insert per payload row, anchored on the block's last line. The
109
+ // `blockStart` tag lets the applier's landing correction slide a
110
+ // body that claims a depth inside the block back across the block's
111
+ // trailing closer lines.
86
112
  for (const payload of edit.payloads) {
87
113
  const cursor: Cursor = { kind: "after_anchor", anchor: { line: span.end } };
88
- resolved.push({ kind: "insert", cursor, text: payload, lineNum: edit.lineNum, index: synthIndex++ });
114
+ resolved.push({
115
+ kind: "insert",
116
+ cursor,
117
+ text: payload,
118
+ lineNum: edit.lineNum,
119
+ index: synthIndex++,
120
+ blockStart: span.start,
121
+ });
89
122
  }
90
123
  continue;
91
124
  }
package/src/input.ts CHANGED
@@ -309,12 +309,16 @@ export class PatchSection {
309
309
  */
310
310
  applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
311
311
  const { edits, warnings } = this.parse();
312
- const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "throw" });
312
+ const resolveWarnings: string[] = [];
313
+ const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
314
+ onUnresolved: "throw",
315
+ onWarning: warning => resolveWarnings.push(warning),
316
+ });
313
317
  const result = applyEdits(text, resolved);
314
318
  // Preserve parse warnings so consumers don't need to call `parse()`
315
319
  // separately.
316
- const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
317
- return merged && merged.length > 0
320
+ const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
321
+ return merged.length > 0
318
322
  ? { ...result, warnings: merged }
319
323
  : { text: result.text, firstChangedLine: result.firstChangedLine };
320
324
  }
@@ -332,10 +336,14 @@ export class PatchSection {
332
336
  */
333
337
  applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult {
334
338
  const { edits, warnings } = parsePatchStreaming(this.diff);
335
- const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "drop" });
339
+ const resolveWarnings: string[] = [];
340
+ const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
341
+ onUnresolved: "drop",
342
+ onWarning: warning => resolveWarnings.push(warning),
343
+ });
336
344
  const result = applyEdits(text, resolved);
337
- const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
338
- return merged && merged.length > 0
345
+ const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
346
+ return merged.length > 0
339
347
  ? { ...result, warnings: merged }
340
348
  : { text: result.text, firstChangedLine: result.firstChangedLine };
341
349
  }
package/src/messages.ts CHANGED
@@ -116,6 +116,19 @@ export function blockUnresolvedMessage(
116
116
  export const BLOCK_RESOLVER_UNAVAILABLE =
117
117
  "Block-anchored ops (`replace block N:`, `delete block N`, `insert after block N:`) are not available here (no tree-sitter block resolver is configured). Use a concrete line range instead.";
118
118
 
119
+ /**
120
+ * Warning emitted when an `insert after block N:` anchored on a pure
121
+ * closing-delimiter line is lowered to plain `insert after N:`. No block
122
+ * begins on a closer, but the closer IS the end of one — and inserting after
123
+ * the end of that block is exactly what the plain form does.
124
+ */
125
+ export function insertAfterBlockCloserLoweredWarning(line: number): string {
126
+ return (
127
+ `\`insert after block ${line}:\` anchors on a closing-delimiter line; no block begins there, so it was applied as plain \`insert after ${line}:\`. ` +
128
+ "Anchor `insert after block` on the line that OPENS the construct."
129
+ );
130
+ }
131
+
119
132
  /**
120
133
  * Internal invariant error: `applyEdits` received an unresolved `replace block
121
134
  * N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
@@ -150,6 +163,22 @@ export function afterInsertLandingShiftWarning(anchorLine: number, landingLine:
150
163
  );
151
164
  }
152
165
 
166
+ /**
167
+ * Warning emitted when an `insert after block N:` body is indented deeper
168
+ * than the block's closing line and the landing was pulled back inside the
169
+ * block. `insert after block` places content AFTER the block at sibling
170
+ * depth; a deeper body almost always means "append inside the block's body"
171
+ * — the misuse this corrects.
172
+ */
173
+ export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string {
174
+ return (
175
+ `insert after block ${blockStart}: the body is indented deeper than the block's closing line ${closerLine}, ` +
176
+ `so it was placed inside the block, after line ${landingLine}. ` +
177
+ "`insert after block` always lands AFTER the block at sibling depth — if you meant that, " +
178
+ `re-issue as plain \`insert after ${closerLine}:\` with the body indented to match line ${closerLine}.`
179
+ );
180
+ }
181
+
153
182
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
154
183
  export const RECOVERY_EXTERNAL_WARNING =
155
184
  "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
package/src/patcher.ts CHANGED
@@ -380,6 +380,7 @@ export class Patcher {
380
380
  // When a block edit needs the tagged snapshot but it is unavailable, the
381
381
  // range cannot be placed safely — reject with a MismatchError (re-read).
382
382
  const blockResolutions: BlockResolution[] = [];
383
+ const resolveWarnings: string[] = [];
383
384
  let resolved: readonly Edit[] = edits;
384
385
  if (hasBlockEdit(edits)) {
385
386
  const baseText =
@@ -390,8 +391,13 @@ export class Patcher {
390
391
  resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, {
391
392
  onUnresolved: "throw",
392
393
  onResolved: resolution => blockResolutions.push(resolution),
394
+ onWarning: warning => resolveWarnings.push(warning),
393
395
  });
394
396
  }
397
+ const withResolveWarnings = (result: ApplyResult): ApplyResult =>
398
+ resolveWarnings.length === 0
399
+ ? result
400
+ : { ...result, warnings: [...resolveWarnings, ...(result.warnings ?? [])] };
395
401
 
396
402
  // No tag, or the tag still names the live content: an edit anchored at any
397
403
  // line is safe to apply, and the resolved block spans line up with what
@@ -399,7 +405,7 @@ export class Patcher {
399
405
  // recovery below, where line numbers shift, so resolutions are dropped.)
400
406
  if (expected === undefined || liveMatches) {
401
407
  const result = applyEdits(normalized, resolved);
402
- return blockResolutions.length > 0 ? { ...result, blockResolutions } : result;
408
+ return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
403
409
  }
404
410
  // Head/tail-only inserts are position-stable: "start"/"end" cannot move
405
411
  // with content drift, so a stale tag is non-fatal. Apply onto the live
@@ -407,7 +413,7 @@ export class Patcher {
407
413
  // mismatch, which cannot be safely relocated and must reject.
408
414
  if (!hasAnchorScopedEdit(resolved)) {
409
415
  const result = applyEdits(normalized, resolved);
410
- return { ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] };
416
+ return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
411
417
  }
412
418
  // File drifted: try to replay the edit against the version the tag
413
419
  // names and 3-way-merge it onto the live content.
@@ -417,7 +423,7 @@ export class Patcher {
417
423
  fileHash: expected,
418
424
  edits: resolved,
419
425
  });
420
- if (recovered) return recoveryToApplyResult(recovered);
426
+ if (recovered) return withResolveWarnings(recoveryToApplyResult(recovered));
421
427
  const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
422
428
  throw this.#mismatchError(section, canonicalPath, normalized, expected, hashRecognized);
423
429
  }
package/src/prompt.md CHANGED
@@ -5,13 +5,13 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- `replace N..M:` — replace original lines N..M with the body rows below. CAUTION, IT IS INCLUSIVE! MAKE SURE YOU INTEND TO DELETE BOTH ENDS!
9
- `replace block N:` — replace the whole syntactic block that BEGINS on line N — header line through closing line — resolved with tree-sitter, so you never count the end. Body rows below. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line; a leading decorator/attribute/doc-comment is a separate node and is NOT swept in (see rules).
8
+ `replace N..M:` — replace original lines N..M with the body rows below. INCLUSIVE line M is consumed too.
9
+ `replace block N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below.
10
10
  `delete N..M` — delete original lines N..M. No body.
11
11
  `delete block N` — delete the whole syntactic block that BEGINS on line N.
12
12
  `insert before N:` — insert the body rows immediately before line N.
13
13
  `insert after N:` — insert the body rows immediately after line N.
14
- `insert after block N:` — insert the body rows after the END of the syntactic block that BEGINS on line N. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not the closing delimiter / last visible line; if you have the last line, use plain `insert after M:` instead.
14
+ `insert after block N:` — insert the body rows after the END of the block that BEGINS on line N outside it, at sibling depth. To append inside a block, use `insert after`.
15
15
  `insert head:` — insert the body rows at the very start of the file.
16
16
  `insert tail:` — insert the body rows at the very end of the file.
17
17
  Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`).
@@ -24,22 +24,22 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
24
24
  </body-rows>
25
25
 
26
26
  <rules>
27
- - Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `[PATH#TAG]` header; use the bare LINE numbers.
28
- - Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
29
- - Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `[PATH#TAG]` and lines from the edit response (or re-`read`), never on pre-edit numbers.
30
- - A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
31
- - Body indentation is a depth claim: indent body rows for the depth they should live at — an `insert after` body indented shallower than its anchor lands past the closing lines below it (the result warns and names the landing line).
32
- - A valid `#TAG` is NOT permission to patch the whole file — it certifies the snapshot, not your knowledge of it. Authority to touch a line comes from having literally seen that line as a `LINE:TEXT` row in a `read`/`search`, not from holding the tag. Every line in a hunk's range, and the lines bounding it, must be lines you actually saw.
33
- - An elided or partial read is NOT a read of the gap. A `…` (or any collapsed/truncated region) between two excerpts means those lines are UNSEEN — treat them exactly like lines you never opened. Never place a hunk on, or span a range across, an elided region; `read` that range explicitly first. Reconstructing it from memory of "what the code probably looks like" is how ranges drift off-by-N and shred neighboring blocks.
34
- - On a stale-tag rejection or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
27
+ - Line numbers and the `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows).
28
+ - Numbers refer to the ORIGINAL file; they do not shift as hunks apply.
29
+ - They die with the call: every applied edit mints a fresh `#TAG` and renumbers anchor the next edit on the edit response or a fresh `read`.
30
+ - Touch only lines you literally saw as `LINE:TEXT`; the tag certifies the snapshot, not your knowledge of it.
31
+ - Elided regions (`…`) are UNSEEN never place or span a hunk across one; `read` it first.
32
+ - Never start or end a range mid-expression or mid-block.
33
+ - Indent body rows exactly for the depth they should live at.
34
+ - On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits.
35
35
  - One hunk per range; the body is the final content, never an old/new pair.
36
- - Keep every range as tight as the change: a range covers ONLY lines whose content actually changes. Never widen it to swallow an unchanged signature, brace, or neighboring statement just to rewrite a few lines inside change one line with `replace N..N`, not the whole block around it. Tightness means excluding unchanged lines, not being short: a range where every line genuinely changes is correctly long. Tight ranges bound the blast radius of a stale number: a stale one-line range corrupts one line; a stale wide range shreds every line it spans. This applies to hand-counted `replace N..M` ranges; `replace block N` is exempt — tree-sitter fixes the end.
37
- - `replace block N` vs `replace N..M`: use `replace block N` to rewrite a WHOLE construct (function / `if` / loop / class body) — tree-sitter resolves its closing line, so a long body can't be mis-counted and a stale end can't clip it mid-block. The edit result echoes the span it matched (`replace block N → resolved lines A-B`); glance at it to confirm you got what you meant. Use `replace N..M` to change specific lines inside a construct.
38
- - The resolved span of `replace block N` is EXACTLY the node beginning on line N. A leading decorator, attribute, or doc-comment is a separate node and is NOT included; to take a decorated definition together with its decorator, point N at the FIRST decorator line (Python parses `@dec` + `def` as one block). A leading line-comment that parses as its own node (e.g. Rust `///`) is not captured by any single opener — use `replace N..M` spanning the comment and the construct.
39
- - `insert after block N` follows the same opener-only anchor rule as `replace block N`: N is the first line of the syntactic block, never a line inside it, its closing delimiter, or its last visible line. To append after a closing delimiter you can see, use plain `insert after M:`.
40
- - To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
41
- - Pure additions use `insert`, never a widened `replace`. If the change only adds lines, `insert before/after` the spot and keep every existing line out of all ranges. Do NOT `replace` a span of keepers and retype them around the new line "to preserve" them those retyped keepers are exactly what gets silently dropped when one is forgotten. A keeper that never enters your body cannot be lost. `replace` is only for lines whose own text changes.
42
- - NEVER use this tool to format code reordering imports, re-indenting, aligning columns, or any mechanical restyling. That is the project formatter's job; run it instead of hand-editing layout here.
36
+ - Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans.
37
+ - Whole construct `replace block N` (tree-sitter resolves the end); lines inside it `replace N..M`.
38
+ - `replace block N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `replace N..M`.
39
+ - `insert after block N`: N is the opener, never the closer or last visible line; saw the closer? Use plain `insert after M:`.
40
+ - Non-adjacent changes = separate hunks; untouched lines stay out of every range.
41
+ - Pure additions use `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped.
42
+ - NEVER format/restyle code with this tool; run the project formatter instead.
43
43
  </rules>
44
44
 
45
45
  <example>
@@ -137,7 +137,7 @@ insert after 3:
137
137
 
138
138
  <critical>
139
139
  If you remember nothing else:
140
- 1. RE-GROUND AFTER EVERY EDIT. Each applied edit mints a fresh `#TAG` and renumbers the file the tag and line numbers you just used are now dead. Take the next edit's numbers from the edit response or a fresh `read`, never from pre-edit memory. On a stale-tag rejection or any unexpected result, STOP and re-`read`.
141
- 2. RANGES ARE TIGHT AND IN-BOUNDS. Cover only lines whose content actually changes; never widen a range to swallow an unchanged signature, brace, or statement, and never start or end a range mid-expression or mid-block. A stale one-line range corrupts one line; a stale wide range shreds everything it spans to rewrite a whole construct, prefer `replace block N` so tree-sitter fixes the end.
142
- 3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows under a `:` header — never `-old`/bare context lines, never an old/new pair. The range does the deleting.
140
+ 1. RE-GROUND AFTER EVERY EDIT. Every apply mints a fresh `#TAG` and renumbers — take the next edit's numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
141
+ 2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct `replace block N`.
142
+ 3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows; never `-old`/context lines. The range does the deleting.
143
143
  </critical>
package/src/types.ts CHANGED
@@ -31,6 +31,13 @@ export type Edit =
31
31
  lineNum: number;
32
32
  index: number;
33
33
  mode?: "replacement";
34
+ /**
35
+ * Present on inserts lowered from `insert after block N:`: the
36
+ * resolved block's first line. Lets the applier slide a body that
37
+ * claims a depth inside the block back across the block's trailing
38
+ * closer lines (never above this line).
39
+ */
40
+ blockStart?: number;
34
41
  }
35
42
  | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
36
43
  | {