@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/prompt.md CHANGED
@@ -1,20 +1,20 @@
1
- Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body.
1
+ Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL` has no body.
2
2
 
3
3
  <headers>
4
4
  Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
5
5
  </headers>
6
6
 
7
7
  <ops>
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
- `delete N..M` — delete original lines N..M. No body.
11
- `delete block N` — delete the whole syntactic block that BEGINS on line N.
12
- `insert before N:` — insert the body rows immediately before line N.
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 block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `insert after`.
15
- `insert head:` — insert the body rows at the very start of the file.
16
- `insert tail:` — insert the body rows at the very end of the file.
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:`).
8
+ `SWAP N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too.
9
+ `SWAP.BLK N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below.
10
+ `DEL N..M` — delete original lines N..M. No body.
11
+ `DEL.BLK N` — delete the whole syntactic block that BEGINS on line N.
12
+ `INS.PRE N:` — insert the body rows immediately before line N.
13
+ `INS.POST N:` — insert the body rows immediately after line N.
14
+ `INS.BLK.POST 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 `INS.POST`.
15
+ `INS.HEAD:` — insert the body rows at the very start of the file.
16
+ `INS.TAIL:` — insert the body rows at the very end of the file.
17
+ Single line: `SWAP N..N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N..N:`).
18
18
  </ops>
19
19
 
20
20
  <body-rows>
@@ -27,18 +27,18 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
27
27
  - Line numbers and the `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows).
28
28
  - Numbers refer to the ORIGINAL file; they do not shift as hunks apply.
29
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.
30
+ - Touch only lines your latest `read`/`search` literally displayed as `LINE:TEXT`; the tag certifies the snapshot, not your memory of it. A hunk anchored on a line you never displayed is REJECTED — re-`read` those exact lines first. (Seeing a line ≠ it holding the code you mean: confirm the numbers map to the construct you intend, especially far from your last-read window.)
31
+ - Elided regions are UNSEEN: `…`/`..` markers and a collapsed `N-M:` summary row (only boundary lines N and M were shown) hide their interior. NEVER place or span a hunk inside one `read` the range first.
32
32
  - Never start or end a range mid-expression or mid-block.
33
33
  - Indent body rows exactly for the depth they should live at.
34
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
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:`.
37
+ - Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N..M`.
38
+ - `SWAP.BLK 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 `SWAP N..M`.
39
+ - Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N..N` / `DEL N` / `INS.POST N`) for one line, or point N at the real opener. Saw the closer? Use plain `INS.POST M:`.
40
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.
41
+ - Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. A multi-line `SWAP` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair.
42
42
  - NEVER format/restyle code with this tool; run the project formatter instead.
43
43
  </rules>
44
44
 
@@ -55,14 +55,14 @@ Original (the exact shape `read` returns):
55
55
  Insert a guard after line 1:
56
56
  ```
57
57
  [greet.py#A1B2]
58
- insert after 1:
58
+ INS.POST 1:
59
59
  + if not name: name = "stranger"
60
60
  ```
61
61
 
62
62
  Replace line 2 with two lines:
63
63
  ```
64
64
  [greet.py#A1B2]
65
- replace 2..2:
65
+ SWAP 2..2:
66
66
  + greeting = "Hi"
67
67
  + msg = f"{greeting}, {name}"
68
68
  ```
@@ -70,30 +70,30 @@ replace 2..2:
70
70
  Delete line 3:
71
71
  ```
72
72
  [greet.py#A1B2]
73
- delete 3
73
+ DEL 3
74
74
  ```
75
75
 
76
76
  Add a header and trailer:
77
77
  ```
78
78
  [greet.py#A1B2]
79
- insert head:
79
+ INS.HEAD:
80
80
  +# generated header
81
- insert tail:
81
+ INS.TAIL:
82
82
  +greet("everyone")
83
83
  ```
84
84
 
85
- Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
85
+ Replace the whole `greet` function block — `SWAP.BLK 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
86
86
  ```
87
87
  [greet.py#A1B2]
88
- replace block 1:
88
+ SWAP.BLK 1:
89
89
  +def greet(name):
90
90
  + print(f"Hello, {name}")
91
91
  ```
92
92
 
93
- A decorator or doc-comment is a SEPARATE block — `replace block` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`:
93
+ A decorator or doc-comment is a SEPARATE block — `SWAP.BLK` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`:
94
94
  ```
95
95
  [svc.py#C3D4]
96
- replace block 1:
96
+ SWAP.BLK 1:
97
97
  +@cache
98
98
  +def load(key):
99
99
  + return store[key]
@@ -101,43 +101,43 @@ replace block 1:
101
101
  </example>
102
102
 
103
103
  <anti-patterns>
104
- # WRONG — empty `replace` to delete. RIGHT: delete 4
105
- replace 4..4:
104
+ # WRONG — empty `SWAP` to delete. RIGHT: DEL 4
105
+ SWAP 4..4:
106
106
 
107
- # WRONG — range describes post-edit size. RIGHT: replace 1..1: (body length is irrelevant)
108
- replace 1..2:
107
+ # WRONG — range describes post-edit size. RIGHT: SWAP 1..1: (body length is irrelevant)
108
+ SWAP 1..2:
109
109
  +def greet(name):
110
110
 
111
111
  # WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content.
112
- replace 3..3:
112
+ SWAP 3..3:
113
113
  msg = "Hello, " + name
114
114
  - print(msg)
115
115
  + return msg
116
116
  # RIGHT
117
- replace 3..3:
117
+ SWAP 3..3:
118
118
  + return msg
119
119
 
120
- # WRONG — a pure insertion done as a widened `replace`: you only want to add one line after 2,
120
+ # WRONG — a pure insertion done as a widened `SWAP`: you only want to add one line after 2,
121
121
  # but you replace 2..4, retype the keepers in the body, and drop one (here line 4, `greet("world")`).
122
- replace 2..4:
122
+ SWAP 2..4:
123
123
  + msg = "Hello, " + name
124
124
  + extra = compute(name)
125
125
  + print(msg)
126
126
  # RIGHT — touch nothing you keep; the new line is the whole body.
127
- insert after 2:
127
+ INS.POST 2:
128
128
  + extra = compute(name)
129
129
 
130
- # WRONG — `insert after block N:` anchored on a closing delimiter / last visible line. RIGHT: plain `insert after M:`
131
- insert after block 3:
130
+ # WRONG — `INS.BLK.POST N:` anchored on a closing delimiter / last visible line. RIGHT: plain `INS.POST M:`
131
+ INS.BLK.POST 3:
132
132
  +after()
133
133
  # RIGHT
134
- insert after 3:
134
+ INS.POST 3:
135
135
  +after()
136
136
  </anti-patterns>
137
137
 
138
138
  <critical>
139
139
  If you remember nothing else:
140
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`.
141
+ 2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `SWAP.BLK N`.
142
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/snapshots.ts CHANGED
@@ -36,6 +36,15 @@ export interface Snapshot {
36
36
  readonly hash: string;
37
37
  /** Timestamp (ms since epoch) the version was recorded. */
38
38
  recordedAt: number;
39
+ /**
40
+ * 1-indexed file lines a producer (read/search) actually *displayed* under
41
+ * this tag. A partial read (range, or a structural summary that collapsed
42
+ * bodies) leaves this sparse; a whole-file read fills every line. Multiple
43
+ * reads of the same content union into one set. `undefined` means "no
44
+ * provenance recorded" — the patcher then skips the seen-line check and
45
+ * applies as before. Mutated in place as more of the same content is read.
46
+ */
47
+ seenLines?: Set<number>;
39
48
  }
40
49
 
41
50
  /**
@@ -50,8 +59,20 @@ export abstract class SnapshotStore {
50
59
  /** Recorded version for `path` whose tag equals `hash`, or `null`. */
51
60
  abstract byHash(path: string, hash: string): Snapshot | null;
52
61
 
53
- /** Record the full normalized text of `path` and return its content tag. */
54
- abstract record(path: string, fullText: string): string;
62
+ /**
63
+ * Record the full normalized text of `path` and return its content tag.
64
+ * `seenLines` (optional) are the 1-indexed lines the producer displayed;
65
+ * they merge into {@link Snapshot.seenLines} across reads of identical text.
66
+ */
67
+ abstract record(path: string, fullText: string, seenLines?: Iterable<number>): string;
68
+
69
+ /**
70
+ * Merge `lines` into the {@link Snapshot.seenLines} of the version whose tag
71
+ * equals `hash`. No-op when no such version is retained (the content aged
72
+ * out or was overwritten). Lets producers attach displayed lines after the
73
+ * tag was already minted (the body is formatted after the hash is computed).
74
+ */
75
+ abstract recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
55
76
 
56
77
  /** Drop the version history for a single path. */
57
78
  abstract invalidate(path: string): void;
@@ -65,6 +86,13 @@ const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
65
86
  /** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
66
87
  const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
67
88
 
89
+ /** Union `lines` into `snapshot.seenLines`, lazily creating the set. */
90
+ function mergeSeenLines(snapshot: Snapshot, lines: Iterable<number> | undefined): void {
91
+ if (lines === undefined) return;
92
+ if (snapshot.seenLines === undefined) snapshot.seenLines = new Set<number>();
93
+ for (const line of lines) snapshot.seenLines.add(line);
94
+ }
95
+
68
96
  export interface InMemorySnapshotStoreOptions {
69
97
  /** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
70
98
  maxPaths?: number;
@@ -114,15 +142,17 @@ export class InMemorySnapshotStore extends SnapshotStore {
114
142
  return history?.find(version => version.hash === hash) ?? null;
115
143
  }
116
144
 
117
- record(path: string, fullText: string): string {
145
+ record(path: string, fullText: string, seenLines?: Iterable<number>): string {
118
146
  const hash = computeFileHash(fullText);
119
147
  // `get` refreshes LRU recency for `path`.
120
148
  const history = this.#versions.get(path) ?? [];
121
149
  const existing = history.find(version => version.hash === hash);
122
150
  if (existing) {
123
151
  // Same content state observed again: refresh recency and promote to
124
- // head (it is the current file content), then reuse the tag.
152
+ // head (it is the current file content), then reuse the tag. Union any
153
+ // newly-displayed lines so re-reading more of the file widens coverage.
125
154
  existing.recordedAt = Date.now();
155
+ mergeSeenLines(existing, seenLines);
126
156
  if (history[0] !== existing) {
127
157
  this.#versions.set(path, [existing, ...history.filter(version => version !== existing)]);
128
158
  }
@@ -130,10 +160,16 @@ export class InMemorySnapshotStore extends SnapshotStore {
130
160
  }
131
161
 
132
162
  const snapshot: Snapshot = { path, text: fullText, hash, recordedAt: Date.now() };
163
+ mergeSeenLines(snapshot, seenLines);
133
164
  this.#versions.set(path, [snapshot, ...history].slice(0, this.#maxVersionsPerPath));
134
165
  return hash;
135
166
  }
136
167
 
168
+ recordSeenLines(path: string, hash: string, lines: Iterable<number>): void {
169
+ const version = this.#versions.get(path)?.find(snapshot => snapshot.hash === hash);
170
+ if (version) mergeSeenLines(version, lines);
171
+ }
172
+
137
173
  invalidate(path: string): void {
138
174
  this.#versions.delete(path);
139
175
  }
package/src/tokenizer.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import {
12
12
  describeAnchorExamples,
13
- HL_BLOCK_KEYWORD,
13
+ HL_DELETE_BLOCK_KEYWORD,
14
14
  HL_DELETE_KEYWORD,
15
15
  HL_FILE_HASH_LENGTH,
16
16
  HL_FILE_HASH_SEP,
@@ -18,11 +18,13 @@ import {
18
18
  HL_FILE_SUFFIX,
19
19
  HL_HEADER_COLON,
20
20
  HL_INSERT_AFTER,
21
+ HL_INSERT_AFTER_BLOCK_KEYWORD,
21
22
  HL_INSERT_BEFORE,
22
23
  HL_INSERT_HEAD,
23
24
  HL_INSERT_KEYWORD,
24
25
  HL_INSERT_TAIL,
25
26
  HL_PAYLOAD_REPLACE,
27
+ HL_REPLACE_BLOCK_KEYWORD,
26
28
  HL_REPLACE_KEYWORD,
27
29
  } from "./format";
28
30
  import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
@@ -218,7 +220,7 @@ function scanKeyword(line: string, index: number, end: number, keyword: string):
218
220
  const next = index + keyword.length;
219
221
  if (next < end) {
220
222
  const code = line.charCodeAt(next);
221
- if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null;
223
+ if (!isWhitespaceCode(code) && code !== CHAR_COLON && code !== CHAR_DOT) return null;
222
224
  }
223
225
  return next;
224
226
  }
@@ -229,7 +231,8 @@ function consumeOptionalColon(line: string, index: number, end: number): number
229
231
  }
230
232
 
231
233
  function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
232
- const cursor = skipWhitespace(line, index, end);
234
+ if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
235
+ const cursor = skipWhitespace(line, index + 1, end);
233
236
  const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
234
237
  if (beforeEnd !== null) {
235
238
  const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
@@ -239,16 +242,6 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
239
242
  }
240
243
  const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
241
244
  if (afterEnd !== null) {
242
- // `insert after block N:` — resolve N to a tree-sitter block range at
243
- // apply time and insert after its last line. Try the `block` sub-keyword
244
- // before falling back to a literal `insert after N:` anchor.
245
- const blockEnd = scanKeyword(line, skipWhitespace(line, afterEnd, end), end, HL_BLOCK_KEYWORD);
246
- if (blockEnd !== null) {
247
- const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
248
- if (anchor === null) return null;
249
- const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
250
- return { target: { kind: "insert_after_block", anchor: { line: anchor.line } }, nextIndex };
251
- }
252
245
  const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
253
246
  if (anchor === null) return null;
254
247
  const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
@@ -263,20 +256,19 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
263
256
 
264
257
  function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null {
265
258
  const cursor = skipWhitespace(line, start, end);
259
+
260
+ // `replace_block N:` — resolve N to a tree-sitter block range at apply time.
261
+ const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
262
+ if (replaceBlockEnd !== null) {
263
+ const anchor = scanLineNumber(line, skipWhitespace(line, replaceBlockEnd, end), end);
264
+ if (anchor === null) return null;
265
+ return {
266
+ target: { kind: "block", anchor: { line: anchor.line } },
267
+ nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
268
+ };
269
+ }
266
270
  const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
267
271
  if (replaceEnd !== null) {
268
- // `replace block N:` — resolve N to a tree-sitter block range at apply
269
- // time. Try the `block` sub-keyword before falling back to a literal
270
- // `replace N..M:` range.
271
- const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD);
272
- if (blockEnd !== null) {
273
- const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
274
- if (anchor === null) return null;
275
- return {
276
- target: { kind: "block", anchor: { line: anchor.line } },
277
- nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
278
- };
279
- }
280
272
  const range = scanHeaderRange(line, replaceEnd, end, true);
281
273
  if (range === null) return null;
282
274
  return {
@@ -284,25 +276,36 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
284
276
  nextIndex: consumeOptionalColon(line, range.nextIndex, end),
285
277
  };
286
278
  }
279
+ // `delete_block N` — resolve N to a tree-sitter block range at apply time
280
+ // and delete its whole span. Like `delete N..M`, it takes no body and no
281
+ // trailing colon.
282
+ const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD);
283
+ if (deleteBlockEnd !== null) {
284
+ const anchor = scanLineNumber(line, skipWhitespace(line, deleteBlockEnd, end), end);
285
+ if (anchor === null) return null;
286
+ const next = skipWhitespace(line, anchor.nextIndex, end);
287
+ if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
288
+ return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
289
+ }
287
290
  const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
288
291
  if (deleteEnd !== null) {
289
- // `delete block N` — resolve N to a tree-sitter block range at apply
290
- // time and delete its whole span. Like `delete N..M`, it takes no body
291
- // and no trailing colon.
292
- const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD);
293
- if (blockEnd !== null) {
294
- const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
295
- if (anchor === null) return null;
296
- const next = skipWhitespace(line, anchor.nextIndex, end);
297
- if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
298
- return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
299
- }
300
292
  const range = scanHeaderRange(line, deleteEnd, end, true);
301
293
  if (range === null) return null;
302
294
  const next = skipWhitespace(line, range.nextIndex, end);
303
295
  if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
304
296
  return { target: { kind: "delete", range: range.range }, nextIndex: next };
305
297
  }
298
+ // `insert_after_block N:` — insert after the last line of the tree-sitter
299
+ // block at N.
300
+ const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD);
301
+ if (insertAfterBlockEnd !== null) {
302
+ const anchor = scanLineNumber(line, skipWhitespace(line, insertAfterBlockEnd, end), end);
303
+ if (anchor === null) return null;
304
+ return {
305
+ target: { kind: "insert_after_block", anchor: { line: anchor.line } },
306
+ nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
307
+ };
308
+ }
306
309
  const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
307
310
  if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
308
311
  return null;
package/src/types.ts CHANGED
@@ -32,7 +32,7 @@ export type Edit =
32
32
  index: number;
33
33
  mode?: "replacement";
34
34
  /**
35
- * Present on inserts lowered from `insert after block N:`: the
35
+ * Present on inserts lowered from `insert_after_block N:`: the
36
36
  * resolved block's first line. Lets the applier slide a body that
37
37
  * claims a depth inside the block back across the block's trailing
38
38
  * closer lines (never above this line).
@@ -42,13 +42,13 @@ export type Edit =
42
42
  | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
43
43
  | {
44
44
  /**
45
- * Deferred block edit (`replace block N:` / `delete block N` /
46
- * `insert after block N:`). The exact line span is unknown at parse
45
+ * Deferred block edit (`replace_block N:` / `delete_block N` /
46
+ * `insert_after_block N:`). The exact line span is unknown at parse
47
47
  * time — it is computed by {@link resolveBlockEdits} once file text +
48
48
  * path (→ language) are available, then expanded into concrete edits:
49
- * a non-empty `payloads` without `mode` (from `replace block`) becomes
49
+ * a non-empty `payloads` without `mode` (from `replace_block`) becomes
50
50
  * the same `replacement` inserts + deletes that `replace start..end:`
51
- * produces; an empty `payloads` (from `delete block`) becomes a pure
51
+ * produces; an empty `payloads` (from `delete_block`) becomes a pure
52
52
  * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
53
53
  * inserts at the block's last line. `applyEdits` never sees this
54
54
  * variant.
@@ -70,7 +70,7 @@ export interface ApplyResult {
70
70
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
71
71
  warnings?: string[];
72
72
  /**
73
- * Resolved spans for each `replace block`/`delete block` op in this apply,
73
+ * Resolved spans for each `replace_block`/`delete_block` op in this apply,
74
74
  * in patch order. Present only when the apply matched the tagged content
75
75
  * (the common no-drift path), so the line numbers line up with what the
76
76
  * caller read. Absent when there were no block ops.
@@ -122,7 +122,7 @@ export interface CompactDiffOptions {
122
122
  }
123
123
 
124
124
  /**
125
- * Resolved 1-indexed inclusive line span of a `replace block N:` target.
125
+ * Resolved 1-indexed inclusive line span of a `replace_block N:` target.
126
126
  */
127
127
  export interface BlockSpan {
128
128
  /** First line of the block (1-indexed, inclusive). */
@@ -132,7 +132,7 @@ export interface BlockSpan {
132
132
  }
133
133
 
134
134
  /**
135
- * One `replace block N:` / `delete block N` / `insert after block N:` anchor
135
+ * One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
136
136
  * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
137
137
  * host can echo "block N → lines start..end" and let the model catch a wrong
138
138
  * opener — e.g. a decorator or doc-comment that sits in a separate node
@@ -149,7 +149,7 @@ export interface BlockResolution {
149
149
  op: "replace" | "delete" | "insert_after";
150
150
  }
151
151
 
152
- /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
152
+ /** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
153
153
  export interface BlockResolverRequest {
154
154
  /** Target file path (used to infer language by extension). */
155
155
  path: string;
@@ -160,7 +160,7 @@ export interface BlockResolverRequest {
160
160
  }
161
161
 
162
162
  /**
163
- * Resolves a `replace block N:` anchor to the line span of the syntactic block
163
+ * Resolves a `replace_block N:` anchor to the line span of the syntactic block
164
164
  * that begins on line N. Returns `null` when no block can be resolved
165
165
  * (unrecognized language, blank/out-of-range line, no node begins there, or the
166
166
  * resolved subtree has a syntax error). Pure seam: the hashline core declares