@oh-my-pi/hashline 17.2.14 → 17.3.0

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/format.ts CHANGED
@@ -139,8 +139,20 @@ export function formatNumberedLine(lineNumber: number, line: string): string {
139
139
  return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
140
140
  }
141
141
 
142
+ /**
143
+ * Split LF-delimited file text into lines hashline anchors can address.
144
+ * A terminal newline terminates the preceding line; it is not content.
145
+ */
146
+ export function splitAddressableFileLines(text: string): string[] {
147
+ const lines = text.split("\n");
148
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
149
+ return lines;
150
+ }
151
+
142
152
  /** Format file text with hashline-mode line-number prefixes for display. */
143
153
  export function formatNumberedLines(text: string, startLine = 1): string {
144
- const lines = text.split("\n");
145
- return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n");
154
+ return text
155
+ .split("\n")
156
+ .map((line, i) => formatNumberedLine(startLine + i, line))
157
+ .join("\n");
146
158
  }
package/src/messages.ts CHANGED
@@ -281,11 +281,10 @@ export function pasteAfterBlockUnresolvedLoweredWarning(line: number): string {
281
281
  return unresolvedLoweredWarning(`PUT >${line}*`, line, `PUT >${line}`);
282
282
  }
283
283
  /**
284
- * A one-sided boundary echo whose payload is too short to be the widened
285
- * range's full content: dropping the echo deletes range line(s) the payload
286
- * never restates (the "widened range" reading), while the "range shifted by
287
- * the echo" reading keeps them. The readings produce different files, so the
288
- * edit is rejected instead of repaired.
284
+ * A one-sided exact boundary echo cannot cover the selected range after the
285
+ * duplicated body rows are removed. Applying or dropping it would lose
286
+ * distinct range content, so the edit is rejected unless a parse-restoring
287
+ * boundary combination proves another reading.
289
288
  */
290
289
  export function ambiguousBoundaryEchoMessage(
291
290
  startLine: number,
@@ -299,70 +298,67 @@ export function ambiguousBoundaryEchoMessage(
299
298
  : `ends by restating the ${count} line(s) just below the range`;
300
299
  return (
301
300
  `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the body ${where}, ` +
302
- `but is too short to be the full final content of the widened range — applying it as-is or ` +
303
- `auto-repairing would delete range line(s) the body never restates. ` +
304
- `Re-issue with the range covering exactly the lines that change and the body as their complete ` +
305
- `final content: drop the restated keeper from the body, or widen the range to consume it.`
301
+ `but is too short to be the full final content of the selected range. ` +
302
+ `Re-issue with the range covering exactly the lines that change and the body as their complete final content.`
306
303
  );
307
304
  }
308
305
 
309
306
  /**
310
- * A replacement range deletes trailing structural closer(s) the payload never
311
- * restates, and nothing anchors the payload inside the block those closers
312
- * terminate: the payload has no unmatched opener for them and its indentation
313
- * is not deeper than the closer. Sparing the closer would have to guess
314
- * whether the payload belongs before it (inside the block) or after it (a
315
- * sibling), so the edit is rejected instead of repaired.
307
+ * A syntax-essential selected edge can be retained on either side of the
308
+ * payload, but indentation does not establish which placement was intended.
316
309
  */
317
- export function ambiguousCloserSpareMessage(
318
- startLine: number,
319
- endLine: number,
320
- closerLine: number,
321
- count: number,
322
- ): string {
323
- const closers = count === 1 ? `line ${closerLine}` : `lines ${closerLine}-${closerLine + count - 1}`;
310
+ export function ambiguousBoundaryPlacementMessage(startLine: number, endLine: number): string {
324
311
  return (
325
- `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range deletes the closing-delimiter ` +
326
- `${closers} but the body never restates it, and the body claims no position inside that block ` +
327
- `(no unmatched opener, indentation not deeper than the closer) whether the new content belongs ` +
328
- `before or after the closer is ambiguous. Restate the closer in the body at the intended position, ` +
329
- `or use \`PUT <${closerLine}:\` / \`PUT >${closerLine}:\` instead.`
312
+ `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: a selected boundary row is required for the file to parse, ` +
313
+ `but the body indentation does not establish whether it belongs before or after that row. ` +
314
+ `Re-read the region and re-issue with a range that excludes every unchanged boundary row.`
330
315
  );
331
316
  }
317
+
332
318
  /**
333
- * A replacement range starts by deleting structural closer(s) the payload
334
- * never restates — the "range started one line early, on the `}` that ends
335
- * the construct above" mistake — but the payload's indentation claims a depth
336
- * inside the block those closers terminate, so whether the new content
337
- * belongs before or after the spared closer is ambiguous. Rejected instead of
338
- * repaired; the at-or-above-depth reading is auto-repaired by sparing the
339
- * closer ahead of the payload.
319
+ * Exact-text boundary rows were removed because the remaining payload covers
320
+ * the selected range and the same rows already survive immediately outside it.
340
321
  */
341
- export function ambiguousLeadingCloserSpareMessage(startLine: number, endLine: number, count: number): string {
342
- const closers = count === 1 ? `line ${startLine}` : `lines ${startLine}-${startLine + count - 1}`;
322
+ export function textualBoundaryEchoWarning(startLine: number, leading: number, trailing: number): string {
323
+ const parts: string[] = [];
324
+ if (leading > 0) parts.push(`${leading} leading`);
325
+ if (trailing > 0) parts.push(`${trailing} trailing`);
343
326
  return (
344
- `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range starts by deleting the closing-delimiter ` +
345
- `${closers} but the body never restates it, and the body's indentation claims a depth inside the block that ` +
346
- `closer terminates — whether the new content belongs before or after the closer is ambiguous. ` +
347
- `Start the range on the first line that actually changes, or restate the closer in the body at the intended position.`
327
+ `Auto-repaired a replacement boundary echo at line ${startLine}: dropped ${parts.join(" and ")} body line(s) ` +
328
+ `already present outside the range. Issue the body as final content for the selected range only.`
348
329
  );
349
330
  }
350
331
 
351
332
  /**
352
- * A replacement range deletes more opening delimiter(s) than the payload
353
- * reopens while the matching closer(s) survive below the range — the
354
- * "payload is a complete construct but the range ends mid-block" mistake.
355
- * Surfaced as a warning, never a rejection: the applier is language-agnostic
356
- * and opener/closer text shape cannot prove a syntactic block (the braces may
357
- * be literal prose), so the edit applies as authored and the author decides.
333
+ * A replacement range's boundary disposition was corrected by the
334
+ * syntax-probe-judged search: syntax-essential source boundary rows were
335
+ * retained, or exact body echoes of surviving outside rows were removed. The
336
+ * authored result did not parse and the selected result does.
358
337
  */
359
- export function midBlockRangeWarning(startLine: number, endLine: number, orphaned: number): string {
338
+ export function boundaryVariantRepairWarning(startLine: number, kept: number, dropped: number): string {
339
+ const keptPart = kept === 0 ? "" : `retained ${kept} syntax-essential source boundary row(s) selected by the range`;
340
+ const droppedPart = dropped === 0 ? "" : `dropped ${dropped} body row(s) duplicated just outside the range`;
341
+ const action = [keptPart, droppedPart].filter(Boolean).join(" and ");
360
342
  return (
361
- `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` deleted ${orphaned} opening delimiter(s) the body never ` +
362
- `reopens. If this file is brace-structured code, the matching closing line(s) below the range are now ` +
363
- `orphaned the range likely ended mid-block. If the body was the construct's complete new content, ` +
364
- `re-issue with a block op on the construct's opening line (\`PUT N*:\`) so the closing line resolves ` +
365
- `automatically; if the delimiters are literal text, ignore this warning.`
343
+ `Auto-repaired replacement boundaries at line ${startLine}: ${action}. ` +
344
+ `The result was verified by the syntax probe re-issue with the range covering exactly the changed ` +
345
+ `lines and the body as their complete final content.`
346
+ );
347
+ }
348
+
349
+ /**
350
+ * The applied result no longer parses while the pre-edit content did: the
351
+ * patch introduced a syntax error. Advisory, never a rejection — the applier
352
+ * honors the authored edit — but the breakage is machine-confirmed by
353
+ * tree-sitter and surfaced in the same response instead of waiting for a
354
+ * compiler pass.
355
+ */
356
+ export function editBrokeParseWarning(firstChangedLine: number | undefined): string {
357
+ const at = firstChangedLine === undefined ? "" : ` near line ${firstChangedLine}`;
358
+ return (
359
+ `This edit introduced a syntax error${at}: the file parsed before the patch and no longer does. ` +
360
+ `It was applied exactly as written, so a line number or range endpoint is likely wrong — ` +
361
+ `re-read the touched region and re-issue a correcting edit.`
366
362
  );
367
363
  }
368
364
 
@@ -373,6 +369,10 @@ export function midBlockRangeWarning(startLine: number, endLine: number, orphane
373
369
  export const UNRESOLVED_BLOCK_INTERNAL =
374
370
  "internal error: unresolved block edit reached the applier (resolveBlockEdits was not run).";
375
371
 
372
+ /** Internal invariant: clipboard edits must be concrete before application. */
373
+ export const UNRESOLVED_CLIPBOARD_INTERNAL =
374
+ "internal error: unresolved clipboard edit reached the applier (resolveClipboardEdits was not run).";
375
+
376
376
  /** `REM` received a body row or coexists with line edits. */
377
377
  export const REM_TAKES_NO_BODY =
378
378
  "`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header.";
package/src/prompt.md CHANGED
@@ -1,38 +1,40 @@
1
- Line-anchored patch language: name original lines/gaps to replace, insert, cut, or paste, then list new content. A header ending in `:` takes `+` body rows; colonless `PUT` (paste), `CUT`, `REM`, `MV` take none.
1
+ Line-anchored patch language: name original lines/gaps to replace, insert, cut, or paste; then give new content. `:` headers take `+` body rows; colonless paste `PUT`, `CUT`, `REM`, `MV` take none.
2
2
 
3
3
  <headers>
4
- Every file section starts `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search` REQUIRED on every section. Create new files with `write`; hashline only edits existing files.
4
+ Section: `[PATH#TAG]`; `TAG`: 4-hex snapshot from latest `read`/`search`, REQUIRED each section. New files: `write`; hashline edits existing files only.
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- `PUT N.=M:` replace original lines N through M (INCLUSIVE) with body rows.
9
- `PUT N*:` replace the syntactic block BEGINNING on line N; its closing line is resolved for you.
10
- `PUT <N:` / `PUT >N:` — insert body rows before / after line N (`PUT <1:` = file head, `PUT >$:` = file tail).
11
- `PUT >N*:` insert body rows after the END of the block beginning at N (at sibling depth). Append inside a block → `PUT >M:`.
12
- `PUT <N` / `PUT >N` / `PUT N.=M @name` / `PUT N* @name` — paste a captured register at a gap, over a range, or over a resolved block (no `:` header, no body rows). Unlabeled gap `PUT` pastes the anonymous register; span/block paste requires `@name`.
13
- `CUT N.=M` / `CUT N*` delete lines N through M / block N and capture them (anonymous, or `@name` when given).
14
- `REM` delete the whole section file. `MV DEST` — move/rename to `DEST` (quote paths with spaces); edits above `MV` land on the source first, final content written at `DEST`.
15
- Single line: `PUT N.=N:` / `CUT N.=N`. Range = ORIGINAL lines touched (`N.=M`, inclusive); body length irrelevant.
8
+ `PUT N.=M:`: replace original inclusive lines NM with body.
9
+ `PUT N*:`: replace syntactic block beginning N; closing line resolved.
10
+ `PUT <N:` insert body rows before line N (`PUT <1:` = file head).
11
+ `PUT >N:` insert body rows after line N (`PUT >$:` = file tail).
12
+ `PUT >N*:`: insert after block N's end, at sibling depth. Append inside block: `PUT >M:`.
13
+ `PUT <N @name` / `PUT >N @name` paste register `@name` at the gap before/after line N; omit `@name` for the anonymous register.
14
+ `PUT N.=M @name` / `PUT N* @name` paste `@name` over the range / resolved block; `@name` required here.
15
+ `CUT N.=M` / `CUT N*`: delete and capture inclusive lines NM / block N; anonymous or given `@name`.
16
+ `REM`: delete section file. `MV DEST`: move/rename (quote paths with spaces); prior edits apply to source, final content to `DEST`.
17
+ Single line: `PUT N.=N:` / `CUT N.=N`. Ranges name original inclusive touched lines; body length irrelevant.
16
18
  </ops>
17
19
 
18
20
  <body-rows>
19
- Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept); `+` alone = blank line. NEVER `-old` or bare/context rows — the range deletes; the body is only the final content. Keep a line: leave it out of every range. Literal leading `-`/`+` keeps the prefix: `- item` → `+- item`, `+ item` → `++ item`.
21
+ Only below `:` headers. Row: verbatim `+TEXT` (leading whitespace preserved); `+`: blank. NEVER `-old`, bare, or context rows: range deletes; body is final content. Keep line: exclude it from every range. Literal initial `-`/`+`: `- item` → `+- item`; `+ item` → `++ item`.
20
22
  </body-rows>
21
23
 
22
24
  <rules>
23
- - Line numbers + `#TAG` come from your latest `read`/`search` (`LINE:TEXT` rows); numbers name ORIGINAL lines, never shifted by applied hunks.
24
- - Applied edits renumber the file and change the `#TAG` take the next edit's numbers from the edit response or a fresh `read`.
25
- - Touch only displayed lines hunks on undisplayed lines are REJECTED. Far from your read window? Re-`read`; confirm numbers map to the intended construct.
26
- - Elided regions are UNSEEN (`…`/`..` markers, collapsed `N-M:` summary rows) NEVER place or span a hunk inside one; `read` the range first.
27
- - NEVER start or end a range mid-expression or mid-block.
28
- - Ranges cover ONLY changed lines never widen over keepers. Non-adjacent changes = separate hunks.
29
- - Whole construct `PUT N*:`; lines inside one → `PUT N.=M:`.
30
- - `PUT 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 `PUT N.=M:`).
31
- - Block ops anchor the OPENING line of a MULTI-LINE construct never the closer, last line, or a bare inner statement; one statement plain op (`PUT N.=N:` / `CUT N.=N` / `PUT >N:`). Saw the closer? `PUT >M:`.
32
- - Markdown: a heading IS a block opener block ops on `##`/`###` resolve the WHOLE section (through deeper nested headings, up to the next same-or-higher heading). `PUT >N*:` after a section: end the body with a blank line to keep the next heading separated.
33
- - Pure additions `PUT <N:` / `PUT >N:`, never a widened `PUT N.=M:`.
34
- - Move code with `CUT`+`PUT`: `CUT 5.=9 @fn` captures into `@fn`; `PUT >40 @fn` pastes it. Unlabeled `CUT` + `PUT >40` works for a single call-local move. Named registers persist across edit calls.
35
- - NEVER format/restyle code with this tool; run the project formatter.
25
+ - Numbers and `#TAG`: latest `read`/`search` `LINE:TEXT`; numbers are original, never shifted by hunks.
26
+ - Each edit renumbers and changes `#TAG` next numbers from edit response or fresh `read`.
27
+ - Touch displayed lines only; undisplayed hunks REJECTED. Far from read window: re-`read`; confirm construct.
28
+ - Elisions UNSEEN: `…`, `..`, collapsed `N-M:` rows. NEVER hunk in/across one; `read` first.
29
+ - NEVER start/end range mid-expression or mid-block.
30
+ - Ranges: changed lines only; NEVER widen over keepers. Non-adjacent changes: separate hunks.
31
+ - Whole construct: `PUT N*:`; internal lines: `PUT N.=M:`.
32
+ - `PUT N*:` resolves exactly node N. Leading decorators/attributes/doc-comments are separate nodes: point N at first decorator to include both. Standalone line-comments never swept: use `PUT N.=M:`.
33
+ - Block ops: opening line of multi-line construct, NEVER closer, last line, bare inner statement. One statement: plain `PUT N.=N:` / `CUT N.=N` / `PUT >N:`. At closer: `PUT >M:`.
34
+ - Markdown headings are block openers. Block op on `##`/`###`: whole section through deeper headings to next same/higher heading. After section `PUT >N*:`: end body with blank line to separate next heading.
35
+ - Pure addition: `PUT <N:` / `PUT >N:`, NEVER widened `PUT N.=M:`.
36
+ - Move: `CUT`+`PUT`; `CUT 5.=9 @fn` `@fn`, `PUT >40 @fn` pastes. Single call-local move: unlabeled `CUT` + `PUT >40`. Named registers persist across edit calls.
37
+ - NEVER format/restyle with this tool; run project formatter.
36
38
  </rules>
37
39
 
38
40
  <example>
@@ -54,7 +56,7 @@ PUT 1.=3:
54
56
  MV lib/greet.py
55
57
  ```
56
58
 
57
- Markdown bullets — the file receives `- task`:
59
+ Markdown bullets — file receives `- task`:
58
60
  ```
59
61
  [PLAN.md#A1B2]
60
62
  PUT >2:
@@ -62,7 +64,7 @@ PUT >2:
62
64
  + - nested task
63
65
  ```
64
66
 
65
- Move `greet` to a sibling file using a named register flows across sections:
67
+ Move `greet` to sibling file via named register; flows across sections:
66
68
  ```
67
69
  [greet.py#A1B2]
68
70
  CUT 1* @fn
@@ -70,7 +72,7 @@ CUT 1* @fn
70
72
  PUT <1 @fn
71
73
  ```
72
74
 
73
- `PUT 1*:` resolves lines 1–3 (`def` header through `print(msg)`); line 4 is a separate statement and stays:
75
+ `PUT 1*:` resolves lines 1–3 (`def` through `print(msg)`); line 4 separate, remains:
74
76
  ```
75
77
  [greet.py#A1B2]
76
78
  PUT 1*:
@@ -78,7 +80,7 @@ PUT 1*:
78
80
  + print(f"Hello, {name}")
79
81
  ```
80
82
 
81
- Decorator/doc-comment = SEPARATE block point N at the decorator to take both; anchoring the `def` (line 2) would orphan `@cache`:
83
+ Decorator/doc-comment separate block: point N at decorator to include both; anchoring `def` line 2 orphans `@cache`:
82
84
  ```
83
85
  [svc.py#C3D4]
84
86
  PUT 1*:
@@ -127,7 +129,7 @@ PUT >20 @fn:
127
129
  </anti-patterns>
128
130
 
129
131
  <critical>
130
- 1. RE-GROUND AFTER EVERY EDIT — applied edits renumber the file and change the `#TAG`; take next numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
131
- 2. RANGES ARE TIGHT cover only lines that change. Whole construct `PUT N*:`.
132
- 3. BODY = FINAL CONTENT every body row starts with `+`; Markdown bullets use `+- item`, not `- item`.
132
+ 1. RE-GROUND AFTER EVERY EDIT: edits renumber and change `#TAG`; take next numbers from edit response or fresh `read`. Stale tag/surprise: STOP; re-`read`.
133
+ 2. RANGES TIGHT: changed lines only. Whole construct: `PUT N*:`.
134
+ 3. BODY FINAL CONTENT: every row starts `+`; Markdown bullet: `+- item`, not `- item`.
133
135
  </critical>
package/src/syntax.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Syntax probe for candidate edit results, via the native tree-sitter parser.
3
3
  *
4
- * Delimiter-balance arithmetic cannot tell a block closer from a `}` inside a
5
- * regex literal, a string, or Markdown prose. A parser can, so it holds veto
6
- * power over every repair whose justification is "this line closes a syntactic
7
- * block": when the edit the author actually wrote still parses, no such repair
8
- * may rewrite it. The probe never *forces* a repair — an unrecognized language
9
- * or an already-broken file simply yields no veto, leaving the delimiter
10
- * heuristics as the only available evidence.
4
+ * Replacement-boundary repair uses parsing as its semantic filter: exact
5
+ * outside-row equality may justify dropping a duplicated payload edge, while
6
+ * retaining a selected source boundary additionally requires source-range
7
+ * structure, indentation, or a narrow pure-closer shape. An unrecognized
8
+ * language yields no structural proof, so only evidence-complete textual
9
+ * normalization remains available.
11
10
  */
12
11
 
13
12
  import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives";
@@ -16,6 +15,33 @@ import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives";
16
15
  const parseCache = new Map<string, boolean>();
17
16
  const PARSE_CACHE_MAX = 256;
18
17
 
18
+ const boundaryCache = new Map<string, readonly number[]>();
19
+
20
+ /** Syntactic node boundaries outside a visible source range. */
21
+ export function enclosingBoundaries(
22
+ lines: readonly string[],
23
+ path: string,
24
+ startLine: number,
25
+ endLine: number,
26
+ ): readonly number[] {
27
+ const text = lines.join("\n");
28
+ const key = `${Bun.hash(text).toString(36)}:${text.length}:${path}:${startLine}:${endLine}`;
29
+ const cached = boundaryCache.get(key);
30
+ if (cached !== undefined) return cached;
31
+ let boundaries: readonly number[];
32
+ try {
33
+ boundaries = enclosingBlockBoundaries({ code: text, path, ranges: [{ startLine, endLine }] }) ?? [];
34
+ } catch {
35
+ boundaries = [];
36
+ }
37
+ if (boundaryCache.size >= PARSE_CACHE_MAX) {
38
+ const oldest = boundaryCache.keys().next().value;
39
+ if (oldest !== undefined) boundaryCache.delete(oldest);
40
+ }
41
+ boundaryCache.set(key, boundaries);
42
+ return boundaries;
43
+ }
44
+
19
45
  /**
20
46
  * `true` when `text` parses without a syntax error under the language inferred
21
47
  * from `path`. `false` covers "does not parse" and "cannot tell" alike — no