@oh-my-pi/hashline 15.5.6 → 15.5.7

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.
@@ -32,5 +32,13 @@ export declare const VIRTUAL_REPLACE_REJECTED_MESSAGE = "BOF:/EOF: anchors are v
32
32
  export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
33
33
  /** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
34
34
  export declare const RECOVERY_SESSION_CHAIN_WARNING = "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
35
- /** Warning text emitted by `Recovery` when the session-chain fast-path was taken. */
35
+ /**
36
+ * Warning text emitted by `Recovery` when the session-chain replay
37
+ * fast-path was taken. Distinct from {@link RECOVERY_SESSION_CHAIN_WARNING}
38
+ * because replay is the less-certain mode: the structured-patch 3-way
39
+ * merge refused, the anchor-content gate passed, but a coincidental
40
+ * insert+delete pair earlier in the chain could still leave an anchor's
41
+ * line number pointing at a duplicated row. Surface the hedge so the
42
+ * model verifies before continuing.
43
+ */
36
44
  export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying your edits onto the current file content \u2014 your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
@@ -22,8 +22,12 @@ export interface RecoveryResult {
22
22
  *
23
23
  * 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
24
24
  * 2. (Session chain) If the snapshot wasn't the head, retry on current text
25
- * when line counts match the user's previous edit advanced the hash but
26
- * didn't shift line numbers.
25
+ * when line counts match AND every edit's anchor line content is unchanged
26
+ * between snapshot and current — the previous in-session edit advanced
27
+ * the hash and the model's anchors still name the same logical rows. Emits
28
+ * a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
29
+ * both guards a coincidental insert+delete pair on duplicate rows can
30
+ * still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
27
31
  * 3. Reconstruct from a sparse snapshot (lines map only), verify the rebuilt
28
32
  * text hashes to the expected value, then 3-way-merge.
29
33
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.5.6",
4
+ "version": "15.5.7",
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
@@ -39,9 +39,18 @@ function isReplacementInsert(edit: Edit): edit is Extract<Edit, { kind: "insert"
39
39
 
40
40
  function getEditAnchors(edit: Edit): Anchor[] {
41
41
  if (edit.kind === "delete") return [edit.anchor];
42
- if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
43
- if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
44
- return [];
42
+ switch (edit.cursor.kind) {
43
+ case "before_anchor":
44
+ case "after_anchor":
45
+ return [edit.cursor.anchor];
46
+ case "bof":
47
+ case "eof":
48
+ return [];
49
+ default: {
50
+ const _exhaustive: never = edit.cursor;
51
+ return _exhaustive;
52
+ }
53
+ }
45
54
  }
46
55
 
47
56
  /**
package/src/messages.ts CHANGED
@@ -48,6 +48,14 @@ export const RECOVERY_EXTERNAL_WARNING =
48
48
  export const RECOVERY_SESSION_CHAIN_WARNING =
49
49
  "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
50
50
 
51
- /** Warning text emitted by `Recovery` when the session-chain fast-path was taken. */
51
+ /**
52
+ * Warning text emitted by `Recovery` when the session-chain replay
53
+ * fast-path was taken. Distinct from {@link RECOVERY_SESSION_CHAIN_WARNING}
54
+ * because replay is the less-certain mode: the structured-patch 3-way
55
+ * merge refused, the anchor-content gate passed, but a coincidental
56
+ * insert+delete pair earlier in the chain could still leave an anchor's
57
+ * line number pointing at a duplicated row. Surface the hedge so the
58
+ * model verifies before continuing.
59
+ */
52
60
  export const RECOVERY_SESSION_REPLAY_WARNING =
53
61
  "Recovered by replaying your edits onto the current file content — your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
package/src/prompt.md CHANGED
@@ -25,22 +25,18 @@ A-B:
25
25
  `EOF:` — virtual position after the last line.
26
26
  </anchors>
27
27
 
28
- <payload-sigils>
28
+ <sigils>
29
29
  `|content` — replace A..B with `content`.
30
30
  `↑content` — insert `content` before A.
31
31
  `↓content` — insert `content` after B.
32
- </payload-sigils>
32
+ </sigils>
33
33
 
34
34
  <semantics>
35
- - **No payload rows → delete.** `5:` deletes line 5.
36
- - **Any `|` row → replace.** Delete A..B; insert all `|` rows there.
37
- - **Only `↑`/`↓` rows → preserve.** Anchor lines stay unchanged.
35
+ - **No payload → delete.** `5:` deletes line 5.
38
36
  - **Buckets combine.** `↑` before A, `|` in place, `↓` after B.
39
37
  - **Bucket order ignores interleaving.** Output order = all `↑`, then `|`/original, then all `↓`.
40
38
  - **Order within a bucket is preserved.** Two `↑` rows stack top-down.
41
- - **Blank payload rows are explicit.** Bare `|`, `↑`, or `↓` writes one blank line.
42
- - **BOF/EOF only insert.** `↑` and `↓` are equivalent there; `|` is invalid.
43
- - **Escape leading payload sigils by doubling.** `||x` writes `|x`; `↑↑x` writes `↑x`; `↓↓x` writes `↓x`.
39
+ - **Blank payload = explicit.** Bare `|`, `↑`, or `↓` writes one blank line.
44
40
  - **Line numbers are frozen.** Later anchors still reference pre-edit lines.
45
41
  </semantics>
46
42
 
@@ -78,6 +74,7 @@ A-B:
78
74
  <common-failures>
79
75
  - **NEVER use inline payload.** `5:content` is invalid; write `5:` then `|content`.
80
76
  - **Do not repeat preserved lines.** If line 5 should survive, omit `|`.
77
+ - **`↑`/`↓` payloads are new bytes only.** Never echo the anchor or a neighbor line — that line already exists; copying it into a `↓` row appends a duplicate.
81
78
  - **Do not echo read gutters.** `84:content` is not payload.
82
79
  - **Do not replay past B.** Stop before B+1; widen the anchor if B+1 changes.
83
80
  - **NEVER fabricate file hashes.** Missing? Re-`read`.
@@ -98,6 +95,15 @@ A-B:
98
95
  5:
99
96
  ↑const Y = X;
100
97
 
98
+ # WRONG — echoing the anchor into a ↓ payload duplicates it.
99
+ # Line 5 already contains `const X = 1;`.
100
+ 5:
101
+ ↓const X = 1;
102
+ ↓const Y = 2;
103
+ # RIGHT — payload is only the new line; the anchor survives automatically.
104
+ 5:
105
+ ↓const Y = 2;
106
+
101
107
  # WRONG — read-output gutters inside payload.
102
108
  5-6:
103
109
  5:const X = "b";
package/src/recovery.ts CHANGED
@@ -13,7 +13,7 @@ import { applyEdits } from "./apply";
13
13
  import { computeFileHash } from "./format";
14
14
  import { RECOVERY_EXTERNAL_WARNING, RECOVERY_SESSION_CHAIN_WARNING, RECOVERY_SESSION_REPLAY_WARNING } from "./messages";
15
15
  import type { Snapshot, SnapshotStore } from "./snapshots";
16
- import type { ApplyOptions, ApplyResult, Edit } from "./types";
16
+ import type { Anchor, ApplyOptions, ApplyResult, Edit } from "./types";
17
17
 
18
18
  // Section hashes are line-precise; never let Diff.applyPatch slide a hunk
19
19
  // onto a duplicate closer 100+ lines away. If snapshot replay does not
@@ -63,16 +63,72 @@ function applyEditsToSnapshot(
63
63
  return { text: merged, firstChangedLine, warnings };
64
64
  }
65
65
 
66
+ function collectAnchorLines(edits: readonly Edit[]): number[] {
67
+ const lines: number[] = [];
68
+ for (const edit of edits) {
69
+ for (const anchor of getEditAnchors(edit)) lines.push(anchor.line);
70
+ }
71
+ return lines;
72
+ }
73
+
74
+ function getEditAnchors(edit: Edit): Anchor[] {
75
+ if (edit.kind === "delete") return [edit.anchor];
76
+ switch (edit.cursor.kind) {
77
+ case "before_anchor":
78
+ case "after_anchor":
79
+ return [edit.cursor.anchor];
80
+ case "bof":
81
+ case "eof":
82
+ return [];
83
+ default: {
84
+ const _exhaustive: never = edit.cursor;
85
+ return _exhaustive;
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Returns true when every anchor line in `edits` has identical content in
92
+ * `previousText` and `currentText`. The session-chain replay fast-path
93
+ * requires this: if the prior in-session edit rewrote the line the model is
94
+ * now re-targeting with a stale hash, replaying onto current would silently
95
+ * overwrite the new content with whatever the model authored against the
96
+ * old content — a corruption window, not a recovery.
97
+ */
98
+ function verifyAnchorContent(previousText: string, currentText: string, edits: readonly Edit[]): boolean {
99
+ const lines = collectAnchorLines(edits);
100
+ if (lines.length === 0) return true;
101
+ const prev = previousText.split("\n");
102
+ const curr = currentText.split("\n");
103
+ for (const line of lines) {
104
+ const idx = line - 1;
105
+ if (idx < 0 || idx >= prev.length || idx >= curr.length) return false;
106
+ if (prev[idx] !== curr[idx]) return false;
107
+ }
108
+ return true;
109
+ }
110
+
66
111
  function replaySessionChainOnCurrent(
67
112
  previousText: string,
68
113
  currentText: string,
69
114
  edits: readonly Edit[],
70
115
  options: ApplyOptions,
71
116
  ): RecoveryResult | null {
72
- // Only safe when no insert/delete shifted line counts in the prior edit
73
- // chain: if total line counts match, every line number in `edits` still
74
- // resolves to the same logical row.
117
+ // Two guards narrow the corruption window. Neither alone is sufficient,
118
+ // and even together they don't fully prove correctness replay is the
119
+ // less-certain recovery mode and emits RECOVERY_SESSION_REPLAY_WARNING
120
+ // so the caller can verify the diff.
121
+ // - Equal line counts: every line number in `edits` still resolves to
122
+ // SOME logical row (no net shift across the prior chain). A
123
+ // coincidental insert+delete pair can still leave indices pointing
124
+ // at different logical rows than the model anchored against.
125
+ // - Anchor-content alignment: the row at each anchor's line index has
126
+ // identical content in previous and current. Catches the common
127
+ // case of a prior edit rewriting the targeted line; can still be
128
+ // coincidentally satisfied by a duplicated row at the shifted
129
+ // index.
75
130
  if (previousText.split("\n").length !== currentText.split("\n").length) return null;
131
+ if (!verifyAnchorContent(previousText, currentText, edits)) return null;
76
132
  let applied: ApplyResult;
77
133
  try {
78
134
  applied = applyEdits(currentText, [...edits], options);
@@ -123,8 +179,12 @@ function isHeadSnapshot(head: Snapshot | null, snapshot: Snapshot): boolean {
123
179
  *
124
180
  * 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
125
181
  * 2. (Session chain) If the snapshot wasn't the head, retry on current text
126
- * when line counts match the user's previous edit advanced the hash but
127
- * didn't shift line numbers.
182
+ * when line counts match AND every edit's anchor line content is unchanged
183
+ * between snapshot and current — the previous in-session edit advanced
184
+ * the hash and the model's anchors still name the same logical rows. Emits
185
+ * a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
186
+ * both guards a coincidental insert+delete pair on duplicate rows can
187
+ * still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
128
188
  * 3. Reconstruct from a sparse snapshot (lines map only), verify the rebuilt
129
189
  * text hashes to the expected value, then 3-way-merge.
130
190
  */
@@ -148,10 +208,10 @@ export class Recovery {
148
208
  if (snapshot.fullText !== undefined) {
149
209
  const merged = applyEditsToSnapshot(snapshot.fullText, currentText, edits, options, recoveryWarning);
150
210
  if (merged !== null) return merged;
151
- // Session-chain fast-path: prior in-session edit changed the same
152
- // line(s) the user is now re-targeting with the stale hash. When
153
- // line counts match, the edits' line numbers still resolve to the
154
- // right rows replay onto the current text directly.
211
+ // Session-chain fallback: the 3-way merge on the snapshot refused.
212
+ // Replay onto current is gated by line-count equality AND
213
+ // anchor-content alignment see `replaySessionChainOnCurrent`
214
+ // for why both guards together still don't fully prove correctness.
155
215
  if (isSessionChain) return replaySessionChainOnCurrent(snapshot.fullText, currentText, edits, options);
156
216
  return null;
157
217
  }