@oh-my-pi/hashline 15.12.4 → 15.13.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.13.1] - 2026-06-15
6
+
7
+ ### Breaking Changes
8
+
9
+ - Rejected edits anchored to lines not displayed in the tagged read/search output, requiring unseen ranges to be re-read before reapplying
10
+
11
+ ### Changed
12
+
13
+ - Rejected `replace block`, `delete block`, and `insert after block` operations that resolve to a single line and instructed users to use the plain single-line form or anchor the true construct opener
14
+
15
+ ### Fixed
16
+
17
+ - Auto-repaired one-sided multi-line boundary echoes by dropping delimiter-neutral duplicated boundary lines and emitted a boundary-echo warning
18
+ - Normalized cwd-relative hashline paths to forward-slash form on Windows.
19
+ - Parser now treats a leading `\` on inline payload bodies as the payload delimiter, matching standalone payload rows.
20
+ - Restored the warning emitted when escaped indented payload rows (`\\ TEXT`) are accepted as payload delimiters.
21
+
22
+ ## [15.12.5] - 2026-06-13
23
+
24
+ ### Fixed
25
+
26
+ - Fixed delimiter-balance boundary repair so it does not keep a deleted structural closer when the replacement payload already restates that closer.
27
+
5
28
  ## [15.12.0] - 2026-06-12
6
29
 
7
30
  ### Changed
@@ -110,6 +133,7 @@
110
133
  - Fixed hashline replacements that accidentally restated unchanged lines above and below the selected range so they no longer duplicate both boundary lines ([#1664](https://github.com/can1357/oh-my-pi/issues/1664)).
111
134
 
112
135
  ## [15.7.0] - 2026-05-31
136
+
113
137
  ### Added
114
138
 
115
139
  - Added `replace block N:` and `delete block N` patch syntax to replace or delete the entire syntactic block that begins on line N using tree-sitter-resolved spans
@@ -117,6 +141,7 @@
117
141
  - Added `resolveBlockEdits` and block edit type definitions to the package API for resolving deferred `replace block` / `delete block` edits
118
142
 
119
143
  ## [15.5.13] - 2026-05-29
144
+
120
145
  ### Breaking Changes
121
146
 
122
147
  - Changed hashline section tags from 3-hex to 4-hex content-hash tags, so legacy 3-digit tags are no longer valid
@@ -125,7 +150,7 @@
125
150
  ### Added
126
151
 
127
152
  - Added `maxPaths` and `maxVersionsPerPath` options to `InMemorySnapshotStore` to bound tracked paths and per-path snapshot history
128
- - Re-introduced balance-validated boundary repair in `applyEdits`. A replacement hunk (`replace N..M:` + body) is normalized so its payload preserves the deleted region's delimiter balance: when the body restates a closing delimiter that survives just outside the range (duplicate `}` / `);` / `]`) the echo is dropped, and when the range deletes a structural closer the body never restates (missing closer) the closer is spared instead of deleted. A repair fires only when one boundary operation drives the per-channel `()` / `[]` / `{}` imbalance to exactly zero while leaving surrounding text byte-identical (single-line ops are limited to pure structural-closer lines), so balance-preserving edits and intentional balanced duplicates are never touched. Bracket couples are also bounded by line count: structural balance delta repair is capped to 10 duplicate lines across all channels combined, massive balanced blocks are skipped.
153
+ - Re-introduced balance-validated boundary repair in `applyEdits`. A replacement hunk (`replace N..M:` + body) is normalized so its payload preserves the deleted region's delimiter balance: when the body restates a closing delimiter that survives just outside the range (duplicate `}` / `);` / `]`) the echo is dropped, and when the range deletes a structural closer the body never restates (missing closer) the closer is spared instead of deleted. A repair fires only when one boundary operation drives the per-channel `()` / `[]` / `{}` imbalance to exactly zero while leaving surrounding text byte-identical (single-line ops are limited to pure structural-closer lines), so balance-preserving edits and intentional balanced duplicates are never touched. Bracket counting skips strings, template literals, and comments. Each repair surfaces a `delimiter-balance` warning through `ApplyResult.warnings`.
129
154
 
130
155
  ### Changed
131
156
 
@@ -152,6 +177,7 @@
152
177
  - `MismatchError` now distinguishes "hash recognized but file content drifted" from "hash never recorded for this path". The latter (likely fabricated or carried over from a prior session) emits a dedicated `hash #X is not from this session` rejection message with explicit "never invent the tag" guidance. The `MismatchDetails` interface gains an optional `hashRecognized?: boolean` (defaults to `true` for backward compatibility); `MismatchError` exposes it as a readonly field so callers can branch on the cause.
153
178
 
154
179
  ## [15.5.8] - 2026-05-28
180
+
155
181
  ### Breaking Changes
156
182
 
157
183
  - Removed the single-number hunk header shorthand. A hunk header now REQUIRES two line numbers (`A A` for a single line, `A B` for a range); a bare `A` row throws `single-number hunk header "A" is no longer accepted`. The `&A` body-row shorthand for `&A..A` is unchanged.
@@ -192,7 +218,7 @@
192
218
 
193
219
  ### Breaking Changes
194
220
 
195
- - Redesigned hashline syntax around range anchors (`A-B:`, `A:`, `BOF:`, `EOF:`) and per-line payload sigils (`|`, `↑`, `↓`). Old op-line insert syntax and `\` payload continuations are no longer supported.
221
+ - Changed hashline payload continuations from `+TEXT` to `\TEXT`; use `\` for an explicit blank payload line.
196
222
 
197
223
  ### Added
198
224
 
@@ -205,11 +231,12 @@
205
231
 
206
232
  ### Removed
207
233
 
208
- - Removed legacy deletion semantics that treated bare `A-B:` as a blank-line replacement; a bare range anchor now deletes the range.
234
+ - Removed the `A-B!` / `A!` deletion operator. Use `A-B:` with the desired payload (or empty payload to blank the range) instead.
209
235
 
210
236
  All notable changes to this package will be documented in this file.
211
237
 
212
238
  ## [15.5.4] - 2026-05-27
239
+
213
240
  ### Added
214
241
 
215
242
  - Added a high-level `Patcher` API with all-or-nothing `apply` and staged `prepare`/`commit` flows for multi-file patch updates
@@ -225,4 +252,4 @@ All notable changes to this package will be documented in this file.
225
252
 
226
253
  - Fixed repeated patch application mutating cached `after_anchor` edits between target snapshots
227
254
  - Fixed multi-section patching to preflight write policies and reject duplicate canonical targets before any section is committed
228
- - Fixed mixed line-ending restoration to preserve the first newline style instead of rewriting ties to LF
255
+ - Fixed mixed line-ending restoration to preserve the first newline style instead of rewriting ties to LF
@@ -96,3 +96,20 @@ export declare const HEADTAIL_DRIFT_WARNING = "Applied the `insert head:`/`inser
96
96
  * ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
97
97
  */
98
98
  export declare function missingSnapshotTagMessage(sectionPath: string): string;
99
+ /**
100
+ * An anchored edit referenced lines the read that minted the cited tag never
101
+ * displayed (a partial range, or a structural summary that collapsed bodies).
102
+ * Editing lines you have not read is the off-by-memory failure that mangles
103
+ * files; reject and make the model re-read those exact lines first.
104
+ */
105
+ export declare function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string;
106
+ /** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
107
+ export type BlockOp = "replace" | "delete" | "insert_after";
108
+ /**
109
+ * A `replace block`/`delete block`/`insert after block` anchor resolved to a
110
+ * single line — almost always a bare statement the model mis-anchored, not a
111
+ * multi-line construct. The plain op is unambiguous for one line; the block
112
+ * form only earns its keep when it spares counting a closing line you cannot
113
+ * see. Reject and point at both fixes.
114
+ */
115
+ export declare function blockSingleLineMessage(line: number, op: BlockOp): string;
@@ -11,6 +11,15 @@ export interface Snapshot {
11
11
  readonly hash: string;
12
12
  /** Timestamp (ms since epoch) the version was recorded. */
13
13
  recordedAt: number;
14
+ /**
15
+ * 1-indexed file lines a producer (read/search) actually *displayed* under
16
+ * this tag. A partial read (range, or a structural summary that collapsed
17
+ * bodies) leaves this sparse; a whole-file read fills every line. Multiple
18
+ * reads of the same content union into one set. `undefined` means "no
19
+ * provenance recorded" — the patcher then skips the seen-line check and
20
+ * applies as before. Mutated in place as more of the same content is read.
21
+ */
22
+ seenLines?: Set<number>;
14
23
  }
15
24
  /**
16
25
  * Storage seam for full-file version snapshots. The patcher calls {@link head}
@@ -22,8 +31,19 @@ export declare abstract class SnapshotStore {
22
31
  abstract head(path: string): Snapshot | null;
23
32
  /** Recorded version for `path` whose tag equals `hash`, or `null`. */
24
33
  abstract byHash(path: string, hash: string): Snapshot | null;
25
- /** Record the full normalized text of `path` and return its content tag. */
26
- abstract record(path: string, fullText: string): string;
34
+ /**
35
+ * Record the full normalized text of `path` and return its content tag.
36
+ * `seenLines` (optional) are the 1-indexed lines the producer displayed;
37
+ * they merge into {@link Snapshot.seenLines} across reads of identical text.
38
+ */
39
+ abstract record(path: string, fullText: string, seenLines?: Iterable<number>): string;
40
+ /**
41
+ * Merge `lines` into the {@link Snapshot.seenLines} of the version whose tag
42
+ * equals `hash`. No-op when no such version is retained (the content aged
43
+ * out or was overwritten). Lets producers attach displayed lines after the
44
+ * tag was already minted (the body is formatted after the hash is computed).
45
+ */
46
+ abstract recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
27
47
  /** Drop the version history for a single path. */
28
48
  abstract invalidate(path: string): void;
29
49
  /** Drop every version history. */
@@ -55,7 +75,8 @@ export declare class InMemorySnapshotStore extends SnapshotStore {
55
75
  constructor(options?: InMemorySnapshotStoreOptions);
56
76
  head(path: string): Snapshot | null;
57
77
  byHash(path: string, hash: string): Snapshot | null;
58
- record(path: string, fullText: string): string;
78
+ record(path: string, fullText: string, seenLines?: Iterable<number>): string;
79
+ recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
59
80
  invalidate(path: string): void;
60
81
  clear(): void;
61
82
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.12.4",
4
+ "version": "15.13.1",
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
@@ -318,11 +318,22 @@ function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string
318
318
  return 0;
319
319
  }
320
320
 
321
+ function payloadEndsWithDeletedSuffix(group: ReplacementGroup, fileLines: readonly string[], count: number): boolean {
322
+ if (group.payload.length < count) return false;
323
+ const deletedStart = group.endLine - count;
324
+ const payloadStart = group.payload.length - count;
325
+ for (let offset = 0; offset < count; offset++) {
326
+ if (group.payload[payloadStart + offset] !== fileLines[deletedStart + offset]) return false;
327
+ }
328
+ return true;
329
+ }
330
+
321
331
  /**
322
332
  * Smallest `m` such that the range's last `m` deleted lines are all pure
323
- * structural closers and sparing them (keeping instead of deleting) zeroes
324
- * `delta`. The mirror mistake: a range that swallows a closing delimiter the
325
- * payload never restates.
333
+ * structural closers, the payload does not already restate those same suffix
334
+ * lines, and sparing them (keeping instead of deleting) zeroes `delta`. The
335
+ * mirror mistake: a range that swallows a closing delimiter the payload never
336
+ * restates.
326
337
  */
327
338
  function findDroppedSuffixClosers(
328
339
  group: ReplacementGroup,
@@ -333,6 +344,7 @@ function findDroppedSuffixClosers(
333
344
  const maxM = group.deleteIndices.length;
334
345
  for (let m = 1; m <= maxM; m++) {
335
346
  if (!STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - m] ?? "")) break;
347
+ if (payloadEndsWithDeletedSuffix(group, fileLines, m)) continue;
336
348
  if (balanceEqual(computeDelimiterBalance(fileLines.slice(group.endLine - m, group.endLine)), wanted)) return m;
337
349
  }
338
350
  return 0;
@@ -431,6 +443,50 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
431
443
  );
432
444
  }
433
445
 
446
+ /**
447
+ * A single-sided boundary echo in an otherwise delimiter-balanced *multi-line*
448
+ * replacement: the payload's leading XOR trailing edge exactly restates the
449
+ * surviving line(s) just outside the range — the off-by-one "range one line
450
+ * short of the keeper I retyped" mistake (e.g. att: payload ends with
451
+ * `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes
452
+ * are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
453
+ * by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
454
+ *
455
+ * Scoped to multi-line ranges (a construct rewrite) on purpose: a single-line
456
+ * `replace N..N` expanding into several lines is an *expansion* where every
457
+ * payload line is intentional new content, so a payload line that happens to
458
+ * equal a neighbor stays — only a genuine block rewrite retypes a boundary
459
+ * keeper by mistake. The dropped lines must be delimiter-neutral so removing the
460
+ * duplicate keeps the already-balanced result balanced, and must not consume the
461
+ * whole payload.
462
+ */
463
+ function findOneSidedBoundaryEcho(
464
+ group: ReplacementGroup,
465
+ fileLines: readonly string[],
466
+ ): { side: "leading" | "trailing"; count: number } | undefined {
467
+ if (group.deleteIndices.length <= 1) return undefined;
468
+ const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
469
+ const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
470
+ if (leading > 0 === trailing > 0) return undefined;
471
+ const side = leading > 0 ? "leading" : "trailing";
472
+ const count = leading > 0 ? leading : trailing;
473
+ if (count >= group.payload.length) return undefined;
474
+ const echoLines =
475
+ side === "leading" ? group.payload.slice(0, count) : group.payload.slice(group.payload.length - count);
476
+ if (!balanceIsZero(computeDelimiterBalance(echoLines))) return undefined;
477
+ return { side, count };
478
+ }
479
+
480
+ function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "trailing", count: number): string {
481
+ const where = side === "leading" ? "above" : "below";
482
+ return (
483
+ `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
484
+ `dropped ${count} ${side} payload line(s) identical to the surviving line(s) just ${where} the range. ` +
485
+ `The range was one line short of the content you retyped — issue the payload as the final content for the ` +
486
+ `selected range only, and widen the range to consume any keeper you restate.`
487
+ );
488
+ }
489
+
434
490
  /**
435
491
  * Normalize replacement groups so common off-by-one boundaries do not duplicate
436
492
  * unchanged surrounding lines or structural closers. Returns the repaired edit
@@ -469,6 +525,16 @@ function repairReplacementBoundaries(
469
525
  computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
470
526
  );
471
527
  if (balanceIsZero(delta)) {
528
+ const oneSided = findOneSidedBoundaryEcho(group, fileLines);
529
+ if (oneSided) {
530
+ warnings.push(describeOneSidedEchoRepair(group, oneSided.side, oneSided.count));
531
+ const trimmed =
532
+ oneSided.side === "leading"
533
+ ? inserts.slice(oneSided.count)
534
+ : inserts.slice(0, inserts.length - oneSided.count);
535
+ out.push(...trimmed, ...deletes);
536
+ continue;
537
+ }
472
538
  out.push(...inserts, ...deletes);
473
539
  continue;
474
540
  }
package/src/block.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { STRUCTURAL_CLOSER_RE } from "./apply";
16
16
  import {
17
17
  BLOCK_RESOLVER_UNAVAILABLE,
18
+ blockSingleLineMessage,
18
19
  blockUnresolvedMessage,
19
20
  insertAfterBlockCloserLoweredWarning,
20
21
  insertAfterBlockUnresolvedLoweredWarning,
@@ -110,6 +111,15 @@ export function resolveBlockEdits(
110
111
  }`,
111
112
  );
112
113
  }
114
+ if (span.start === span.end) {
115
+ // A single-line block resolution means line N is a bare statement, not
116
+ // the opening line of a multi-line construct — the common mis-anchor
117
+ // that lands a body in the wrong scope (e.g. between a `case` body line
118
+ // and its `break;`). The plain op is exact for one line, so reject and
119
+ // point at it; drop instead on the lenient preview path.
120
+ if (onUnresolved === "drop") continue;
121
+ throw new Error(`line ${edit.lineNum}: ${blockSingleLineMessage(edit.anchor.line, op)}`);
122
+ }
113
123
  options.onResolved?.({
114
124
  anchorLine: edit.anchor.line,
115
125
  start: span.start,
package/src/input.ts CHANGED
@@ -88,8 +88,9 @@ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
88
88
  const unquoted = stripApplyPatchPathNoise(unquoteHashlinePath(rawPath.trim()));
89
89
  if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
90
90
  const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
91
+ const normalizedRelative = relative.split(path.sep).join("/");
91
92
  const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
92
- return isWithinCwd ? relative || "." : unquoted;
93
+ return isWithinCwd ? normalizedRelative || "." : unquoted;
93
94
  }
94
95
 
95
96
  interface RawSection {
package/src/messages.ts CHANGED
@@ -179,3 +179,64 @@ export const HEADTAIL_DRIFT_WARNING =
179
179
  export function missingSnapshotTagMessage(sectionPath: string): string {
180
180
  return `Missing hashline snapshot tag for ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX}\` from your latest read/search output. To create a new file, use the write tool.`;
181
181
  }
182
+
183
+ /** Compress a line list into a sorted `1-4, 7, 10-12` range string. */
184
+ function formatLineRanges(lines: readonly number[]): string {
185
+ const sorted = [...new Set(lines)].sort((a, b) => a - b);
186
+ if (sorted.length === 0) return "";
187
+ const parts: string[] = [];
188
+ let start = sorted[0];
189
+ let prev = sorted[0];
190
+ for (let i = 1; i <= sorted.length; i++) {
191
+ const current = sorted[i];
192
+ if (current === prev + 1) {
193
+ prev = current;
194
+ continue;
195
+ }
196
+ parts.push(start === prev ? `${start}` : `${start}-${prev}`);
197
+ start = current;
198
+ prev = current;
199
+ }
200
+ return parts.join(", ");
201
+ }
202
+
203
+ /**
204
+ * An anchored edit referenced lines the read that minted the cited tag never
205
+ * displayed (a partial range, or a structural summary that collapsed bodies).
206
+ * Editing lines you have not read is the off-by-memory failure that mangles
207
+ * files; reject and make the model re-read those exact lines first.
208
+ */
209
+ export function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string {
210
+ return (
211
+ `This edit targets line(s) ${formatLineRanges(unseenLines)} of ${sectionPath} that were not shown in the ` +
212
+ `read/search output for ${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}${tag}${HL_FILE_SUFFIX} — a partial ` +
213
+ `range, a search hit, or a structural summary that collapsed bodies was displayed, not those exact lines. ` +
214
+ `Re-read those lines, then re-issue the edit against the fresh tag. NEVER author hunks against line numbers ` +
215
+ `you have not seen in the current snapshot.`
216
+ );
217
+ }
218
+
219
+ /** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
220
+ export type BlockOp = "replace" | "delete" | "insert_after";
221
+
222
+ /**
223
+ * A `replace block`/`delete block`/`insert after block` anchor resolved to a
224
+ * single line — almost always a bare statement the model mis-anchored, not a
225
+ * multi-line construct. The plain op is unambiguous for one line; the block
226
+ * form only earns its keep when it spares counting a closing line you cannot
227
+ * see. Reject and point at both fixes.
228
+ */
229
+ export function blockSingleLineMessage(line: number, op: BlockOp): string {
230
+ const blockForm = op === "insert_after" ? "insert after block" : op === "delete" ? "delete block" : "replace block";
231
+ const plainForm =
232
+ op === "insert_after"
233
+ ? `insert after ${line}:`
234
+ : op === "delete"
235
+ ? `delete ${line}`
236
+ : `replace ${line}..${line}:`;
237
+ return (
238
+ `\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
239
+ `of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
240
+ `on the line that OPENS it (e.g. its \`function\`/\`if\`/\`case\` header), never a statement inside it.`
241
+ );
242
+ }
package/src/patcher.ts CHANGED
@@ -28,7 +28,7 @@ import { computeFileHash, formatHashlineHeader } from "./format";
28
28
  import type { Filesystem, WriteResult } from "./fs";
29
29
  import { isNotFound } from "./fs";
30
30
  import type { Patch, PatchSection } from "./input";
31
- import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
31
+ import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage, unseenLinesMessage } from "./messages";
32
32
  import { MismatchError } from "./mismatch";
33
33
  import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
34
34
  import { Recovery, type RecoveryResult } from "./recovery";
@@ -341,6 +341,22 @@ export class Patcher {
341
341
  #recordFullSnapshot(canonicalPath: string, normalized: string): string {
342
342
  return this.snapshots.record(canonicalPath, normalized);
343
343
  }
344
+
345
+ /**
346
+ * Reject an anchored edit that references a line the read which minted
347
+ * `expected` never displayed. The snapshot's `seenLines` is the set of
348
+ * 1-indexed lines a producer (read/search) actually showed under that tag;
349
+ * absent or empty means no provenance was recorded, so the edit applies as
350
+ * before. Only runs on the no-drift path, where anchor line numbers index
351
+ * the tagged content 1:1.
352
+ */
353
+ #assertSeenLines(section: PatchSection, canonicalPath: string, expected: string): void {
354
+ const seen = this.snapshots.byHash(canonicalPath, expected)?.seenLines;
355
+ if (!seen || seen.size === 0) return;
356
+ const unseen = section.collectAnchorLines().filter(line => !seen.has(line));
357
+ if (unseen.length === 0) return;
358
+ throw new Error(unseenLinesMessage(section.path, unseen, expected));
359
+ }
344
360
  #mismatchError(
345
361
  section: PatchSection,
346
362
  canonicalPath: string,
@@ -404,6 +420,10 @@ export class Patcher {
404
420
  // the caller read, so echo them back. (A drifted file falls through to
405
421
  // recovery below, where line numbers shift, so resolutions are dropped.)
406
422
  if (expected === undefined || liveMatches) {
423
+ // The line numbers in `edits` index the exact content the tag names.
424
+ // Reject any anchor the read never displayed: editing lines the model
425
+ // has not seen is the off-by-memory mistake that mangles files.
426
+ if (expected !== undefined) this.#assertSeenLines(section, canonicalPath, expected);
407
427
  const result = applyEdits(normalized, resolved);
408
428
  return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
409
429
  }
package/src/prompt.md CHANGED
@@ -27,8 +27,8 @@ 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.
@@ -36,9 +36,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
36
36
  - Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans.
37
37
  - Whole construct → `replace block N` (tree-sitter resolves the end); lines inside it → `replace N..M`.
38
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:`.
39
+ - Block ops (`replace block`/`delete block`/`insert after block`) 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 (`replace N..N` / `delete N` / `insert after N`) for one line, or point N at the real opener. Saw the closer? Use plain `insert after 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 `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped. A multi-line `replace` 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
 
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
  }