@oh-my-pi/hashline 15.10.1 → 15.10.3

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,4 @@
1
- import type { BlockResolver, Edit } from "./types";
1
+ import type { BlockResolution, BlockResolver, Edit } from "./types";
2
2
  export interface ResolveBlockEditsOptions {
3
3
  /**
4
4
  * How to handle a block edit that cannot be resolved (missing resolver or a
@@ -8,6 +8,13 @@ export interface ResolveBlockEditsOptions {
8
8
  * or transient parse error must not throw.
9
9
  */
10
10
  onUnresolved?: "throw" | "drop";
11
+ /**
12
+ * Invoked once per successfully resolved block edit, in patch order, with
13
+ * the anchor line and the concrete span it resolved to. Lets the host echo
14
+ * the resolution back to the caller. Never fired for dropped/unresolvable
15
+ * edits.
16
+ */
17
+ onResolved?: (resolution: BlockResolution) => void;
11
18
  }
12
19
  /** True when at least one edit is an unresolved `replace block N:` edit. */
13
20
  export declare function hasBlockEdit(edits: readonly Edit[]): boolean;
@@ -3,7 +3,7 @@ import type { Patch, PatchSection } from "./input";
3
3
  import { type LineEnding } from "./normalize";
4
4
  import { Recovery } from "./recovery";
5
5
  import type { SnapshotStore } from "./snapshots";
6
- import type { ApplyResult, BlockResolver } from "./types";
6
+ import type { ApplyResult, BlockResolution, BlockResolver } from "./types";
7
7
  export interface PatcherOptions {
8
8
  /** Storage backend used for all reads and writes. */
9
9
  fs: Filesystem;
@@ -40,6 +40,12 @@ export interface PatchSectionResult {
40
40
  firstChangedLine?: number;
41
41
  /** Warnings collected by the parser, applier, and (optionally) recovery. */
42
42
  warnings: string[];
43
+ /**
44
+ * Resolved spans for any `replace block`/`delete block` ops, present when the
45
+ * apply matched the tagged content. Undefined for patches with no block ops
46
+ * (and for resolutions routed through drift recovery, where numbers shift).
47
+ */
48
+ blockResolutions?: BlockResolution[];
43
49
  }
44
50
  export interface PatcherApplyResult {
45
51
  sections: PatchSectionResult[];
@@ -13,6 +13,14 @@
13
13
  * common case for echoed file content, and erroneously echoed prefixes will
14
14
  * otherwise turn every content line into a (malformed) op.
15
15
  */
16
+ /**
17
+ * Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
18
+ * most one leading hashline prefix (`N:`, `>>>N:`, `+N:` etc.) and does NOT
19
+ * loop. Use this when the input carries at most one snapshot prefix (e.g. a
20
+ * bare body row paste from `read` output) — recursive stripping would corrupt
21
+ * content whose own text starts with `digits:`.
22
+ */
23
+ export declare function stripOneLeadingHashlinePrefix(line: string): string;
16
24
  /**
17
25
  * Strip whichever prefix scheme the lines appear to be carrying:
18
26
  * - hashline line-number prefixes (`123:`) when every content line has one
@@ -64,6 +64,13 @@ export interface ApplyResult {
64
64
  firstChangedLine?: number;
65
65
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
66
66
  warnings?: string[];
67
+ /**
68
+ * Resolved spans for each `replace block`/`delete block` op in this apply,
69
+ * in patch order. Present only when the apply matched the tagged content
70
+ * (the common no-drift path), so the line numbers line up with what the
71
+ * caller read. Absent when there were no block ops.
72
+ */
73
+ blockResolutions?: BlockResolution[];
67
74
  }
68
75
  /** A parsed `[A..B]` line range. */
69
76
  export interface ParsedRange {
@@ -110,6 +117,23 @@ export interface BlockSpan {
110
117
  /** Last line of the block (1-indexed, inclusive). */
111
118
  end: number;
112
119
  }
120
+ /**
121
+ * One `replace block N:` / `delete block N` anchor resolved to its concrete
122
+ * line span. Surfaced on {@link ApplyResult} so the host can echo
123
+ * "block N → lines start..end" and let the model catch a wrong opener — e.g. a
124
+ * decorator or doc-comment that sits in a separate node outside the resolved
125
+ * block.
126
+ */
127
+ export interface BlockResolution {
128
+ /** The 1-indexed line the block op was anchored on (the `N`). */
129
+ anchorLine: number;
130
+ /** First line of the resolved span (1-indexed, inclusive). */
131
+ start: number;
132
+ /** Last line of the resolved span (1-indexed, inclusive). */
133
+ end: number;
134
+ /** True for `delete block N`; false for `replace block N:`. */
135
+ isDelete: boolean;
136
+ }
113
137
  /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
114
138
  export interface BlockResolverRequest {
115
139
  /** Target file path (used to infer language by extension). */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.10.1",
4
+ "version": "15.10.3",
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/block.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * remain, so {@link applyEdits} (and recovery) only ever see resolved edits.
11
11
  */
12
12
  import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages";
13
- import type { BlockResolver, Cursor, Edit } from "./types";
13
+ import type { BlockResolution, BlockResolver, Cursor, Edit } from "./types";
14
14
 
15
15
  export interface ResolveBlockEditsOptions {
16
16
  /**
@@ -21,6 +21,13 @@ export interface ResolveBlockEditsOptions {
21
21
  * or transient parse error must not throw.
22
22
  */
23
23
  onUnresolved?: "throw" | "drop";
24
+ /**
25
+ * Invoked once per successfully resolved block edit, in patch order, with
26
+ * the anchor line and the concrete span it resolved to. Lets the host echo
27
+ * the resolution back to the caller. Never fired for dropped/unresolvable
28
+ * edits.
29
+ */
30
+ onResolved?: (resolution: BlockResolution) => void;
24
31
  }
25
32
 
26
33
  /** True when at least one edit is an unresolved `replace block N:` edit. */
@@ -61,6 +68,12 @@ export function resolveBlockEdits(
61
68
  `line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line) : BLOCK_RESOLVER_UNAVAILABLE}`,
62
69
  );
63
70
  }
71
+ options.onResolved?.({
72
+ anchorLine: edit.anchor.line,
73
+ start: span.start,
74
+ end: span.end,
75
+ isDelete: edit.payloads.length === 0,
76
+ });
64
77
  // Mirror the parser's `replace start..end:` expansion exactly: one
65
78
  // `before_anchor` replacement insert per payload row at `span.start`,
66
79
  // then one delete per line across `[span.start, span.end]`. An empty
package/src/parser.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  EMPTY_INSERT,
13
13
  MINUS_ROW_REJECTED,
14
14
  } from "./messages";
15
+ import { stripOneLeadingHashlinePrefix } from "./prefixes";
15
16
  import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
16
17
  import type { Anchor, Cursor, Edit } from "./types";
17
18
 
@@ -81,7 +82,7 @@ interface PendingComment {
81
82
  text: string;
82
83
  }
83
84
 
84
- type PayloadRow = { kind: "literal"; text: string; lineNum: number };
85
+ type PayloadRow = { kind: "literal"; text: string; lineNum: number; bare?: boolean };
85
86
 
86
87
  interface Pending {
87
88
  target: BlockTarget;
@@ -220,7 +221,14 @@ export class Executor {
220
221
  throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
221
222
  if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
222
223
  if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
223
- this.#pending.payloads.push({ kind: "literal", text, lineNum });
224
+ // Defer read-output line-number stripping to #flushPending: a bare
225
+ // "N:text" row is only a copy-paste artifact from snapshot output
226
+ // when *every* bare row in the hunk carries that prefix. Stripping a
227
+ // row in isolation would corrupt a genuine body that merely starts
228
+ // with "digits:" (YAML ports "42:hello", timestamps "12:30") when it
229
+ // sits next to an unprefixed sibling. Rows with an explicit "+" go
230
+ // through #handleLiteralPayload and are never bare, never stripped.
231
+ this.#pending.payloads.push({ kind: "literal", text, lineNum, bare: true });
224
232
  return;
225
233
  }
226
234
  if (text.trim().length === 0) return;
@@ -230,6 +238,26 @@ export class Executor {
230
238
  );
231
239
  }
232
240
 
241
+ /**
242
+ * Strip a single read-output line-number prefix (`N:`) from every bare body
243
+ * row, but only when *all* bare rows carry one. A uniform set of prefixes is
244
+ * the signature of content pasted straight from `read`/`search` output; a
245
+ * mixed set means the `N:` is genuine payload content and must stay. Rows
246
+ * authored with an explicit `+` are not bare and are never touched.
247
+ */
248
+ #stripBarePrefixesIfUniform(payloads: PayloadRow[]): void {
249
+ let sawBare = false;
250
+ for (const row of payloads) {
251
+ if (!row.bare) continue;
252
+ sawBare = true;
253
+ if (stripOneLeadingHashlinePrefix(row.text) === row.text) return;
254
+ }
255
+ if (!sawBare) return;
256
+ for (const row of payloads) {
257
+ if (row.bare) row.text = stripOneLeadingHashlinePrefix(row.text);
258
+ }
259
+ }
260
+
233
261
  #pushInsert(cursor: Cursor, text: string, lineNum: number, mode?: "replacement"): void {
234
262
  this.#edits.push({
235
263
  kind: "insert",
@@ -263,6 +291,7 @@ export class Executor {
263
291
  const pending = this.#pending;
264
292
  if (!pending) return;
265
293
  const { target, lineNum, payloads } = pending;
294
+ this.#stripBarePrefixesIfUniform(payloads);
266
295
  this.#pending = undefined;
267
296
  if (target.kind === "delete") {
268
297
  for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
package/src/patcher.ts CHANGED
@@ -33,7 +33,7 @@ import { MismatchError } from "./mismatch";
33
33
  import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
34
34
  import { Recovery, type RecoveryResult } from "./recovery";
35
35
  import type { SnapshotStore } from "./snapshots";
36
- import type { ApplyResult, BlockResolver, Edit } from "./types";
36
+ import type { ApplyResult, BlockResolution, BlockResolver, Edit } from "./types";
37
37
 
38
38
  export interface PatcherOptions {
39
39
  /** Storage backend used for all reads and writes. */
@@ -72,6 +72,12 @@ export interface PatchSectionResult {
72
72
  firstChangedLine?: number;
73
73
  /** Warnings collected by the parser, applier, and (optionally) recovery. */
74
74
  warnings: string[];
75
+ /**
76
+ * Resolved spans for any `replace block`/`delete block` ops, present when the
77
+ * apply matched the tagged content. Undefined for patches with no block ops
78
+ * (and for resolutions routed through drift recovery, where numbers shift).
79
+ */
80
+ blockResolutions?: BlockResolution[];
75
81
  }
76
82
 
77
83
  export interface PatcherApplyResult {
@@ -300,6 +306,7 @@ export class Patcher {
300
306
  fileHash,
301
307
  header: formatHashlineHeader(section.path, fileHash),
302
308
  firstChangedLine: applyResult.firstChangedLine,
309
+ blockResolutions: applyResult.blockResolutions,
303
310
  warnings,
304
311
  };
305
312
  }
@@ -355,6 +362,7 @@ export class Patcher {
355
362
  // resulting ranges flow through the 3-way-merge recovery below.
356
363
  // When a block edit needs the tagged snapshot but it is unavailable, the
357
364
  // range cannot be placed safely — reject with a MismatchError (re-read).
365
+ const blockResolutions: BlockResolution[] = [];
358
366
  let resolved: readonly Edit[] = edits;
359
367
  if (hasBlockEdit(edits)) {
360
368
  const baseText =
@@ -362,13 +370,20 @@ export class Patcher {
362
370
  if (baseText === undefined) {
363
371
  throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
364
372
  }
365
- resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, { onUnresolved: "throw" });
373
+ resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, {
374
+ onUnresolved: "throw",
375
+ onResolved: resolution => blockResolutions.push(resolution),
376
+ });
366
377
  }
367
378
 
368
- if (expected === undefined) return applyEdits(normalized, resolved);
369
- // Whole-file unchanged the tag still names the live content, so an
370
- // edit anchored at ANY line (displayed or not) is safe to apply.
371
- if (liveMatches) return applyEdits(normalized, resolved);
379
+ // No tag, or the tag still names the live content: an edit anchored at any
380
+ // line is safe to apply, and the resolved block spans line up with what
381
+ // the caller read, so echo them back. (A drifted file falls through to
382
+ // recovery below, where line numbers shift, so resolutions are dropped.)
383
+ if (expected === undefined || liveMatches) {
384
+ const result = applyEdits(normalized, resolved);
385
+ return blockResolutions.length > 0 ? { ...result, blockResolutions } : result;
386
+ }
372
387
  // Head/tail-only inserts are position-stable: "start"/"end" cannot move
373
388
  // with content drift, so a stale tag is non-fatal. Apply onto the live
374
389
  // content and warn instead of hard-failing — unlike an anchored
package/src/prefixes.ts CHANGED
@@ -31,6 +31,16 @@ function stripLeadingHashlinePrefixes(line: string): string {
31
31
  } while (result !== previous);
32
32
  return result;
33
33
  }
34
+ /**
35
+ * Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
36
+ * most one leading hashline prefix (`N:`, `>>>N:`, `+N:` etc.) and does NOT
37
+ * loop. Use this when the input carries at most one snapshot prefix (e.g. a
38
+ * bare body row paste from `read` output) — recursive stripping would corrupt
39
+ * content whose own text starts with `digits:`.
40
+ */
41
+ export function stripOneLeadingHashlinePrefix(line: string): string {
42
+ return line.replace(HL_PREFIX_RE, "");
43
+ }
34
44
 
35
45
  interface LinePrefixStats {
36
46
  nonEmpty: number;
package/src/prompt.md CHANGED
@@ -6,7 +6,7 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro
6
6
 
7
7
  <ops>
8
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 — its header line through its closing line — resolved with tree-sitter. 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.
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. Reach for this to rewrite a whole construct (function/`if`/loop/class body): the end can't be mis-counted or clipped mid-block. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line. The span is EXACTLY that node — a leading decorator/attribute/doc-comment is a separate node and is NOT swept in (see rules).
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.
@@ -31,7 +31,8 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
31
31
  - 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.
32
32
  - 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.
33
33
  - One hunk per range; the body is the final content, never an old/new pair.
34
- - Keep every range as tight as the change: a range must cover 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. (A range where every line genuinely changes is correctly long; tightness is about excluding unchanged lines, not about being short.) This bounds the blast radius if a number is off: a stale single-line replace corrupts one line, while a stale block replace shreds the whole block and its structure.
34
+ - Keep every range as tight as the change: a range must cover 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. (A range where every line genuinely changes is correctly long; tightness is about excluding unchanged lines, not about being short.) This bounds the blast radius if a number is off: a stale one-line range corrupts one line, while a stale wide range shreds every line it spans. (This is about hand-counted `replace N..M` ranges; the `replace block N` operator is the opposite — tree-sitter fixes the end, so it can't be mis-counted or clipped.)
35
+ - `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`), so glance at it to confirm you got what you meant. Use `replace N..M` to change specific lines inside a construct. The resolved span is EXACTLY the node beginning on line N: a leading decorator, attribute, or doc-comment is a separate node and is NOT included. To replace a decorated/annotated 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.
35
36
  - 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.
36
37
  - 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.
37
38
  - 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.
@@ -84,6 +85,15 @@ replace block 1:
84
85
  +def greet(name):
85
86
  + print(f"Hello, {name}")
86
87
  ```
88
+
89
+ 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`:
90
+ ```
91
+ [svc.py#C3D4]
92
+ replace block 1:
93
+ +@cache
94
+ +def load(key):
95
+ + return store[key]
96
+ ```
87
97
  </example>
88
98
 
89
99
  <anti-patterns>
@@ -117,6 +127,6 @@ insert after 2:
117
127
  <critical>
118
128
  If you remember nothing else:
119
129
  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`.
120
- 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 single-line replace corrupts one line; a stale block replace shreds the whole block.
130
+ 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.
121
131
  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.
122
132
  </critical>
package/src/types.ts CHANGED
@@ -59,6 +59,13 @@ export interface ApplyResult {
59
59
  firstChangedLine?: number;
60
60
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
61
61
  warnings?: string[];
62
+ /**
63
+ * Resolved spans for each `replace block`/`delete block` op in this apply,
64
+ * in patch order. Present only when the apply matched the tagged content
65
+ * (the common no-drift path), so the line numbers line up with what the
66
+ * caller read. Absent when there were no block ops.
67
+ */
68
+ blockResolutions?: BlockResolution[];
62
69
  }
63
70
 
64
71
  /** A parsed `[A..B]` line range. */
@@ -112,6 +119,24 @@ export interface BlockSpan {
112
119
  end: number;
113
120
  }
114
121
 
122
+ /**
123
+ * One `replace block N:` / `delete block N` anchor resolved to its concrete
124
+ * line span. Surfaced on {@link ApplyResult} so the host can echo
125
+ * "block N → lines start..end" and let the model catch a wrong opener — e.g. a
126
+ * decorator or doc-comment that sits in a separate node outside the resolved
127
+ * block.
128
+ */
129
+ export interface BlockResolution {
130
+ /** The 1-indexed line the block op was anchored on (the `N`). */
131
+ anchorLine: number;
132
+ /** First line of the resolved span (1-indexed, inclusive). */
133
+ start: number;
134
+ /** Last line of the resolved span (1-indexed, inclusive). */
135
+ end: number;
136
+ /** True for `delete block N`; false for `replace block N:`. */
137
+ isDelete: boolean;
138
+ }
139
+
115
140
  /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
116
141
  export interface BlockResolverRequest {
117
142
  /** Target file path (used to infer language by extension). */