pi-hashline-edit-pro 0.15.1 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -124,7 +124,7 @@ export function _lineHashesPure(content: string): string[] {
124
124
  export async function lineHashes(
125
125
  content: string,
126
126
  path?: string,
127
- previous?: { content: string; hashes: string[] },
127
+ previous?: { content: string; hashes: string[]; removedHashes?: Set<string> },
128
128
  ): Promise<string[]> {
129
129
  if (!path) {
130
130
  return _lineHashesPure(content);
@@ -137,6 +137,7 @@ export async function lineHashes(
137
137
  const newHashes = mapStableHashes(
138
138
  previous.content, previous.hashes,
139
139
  content,
140
+ previous.removedHashes,
140
141
  );
141
142
  store.snapshots[path] = { content, hashes: newHashes };
142
143
  await saveHashStore(store);
@@ -157,42 +158,63 @@ export async function lineHashes(
157
158
  }
158
159
 
159
160
  /**
160
- * Diff old content against new content, preserving hashes for unchanged
161
- * lines. New/changed lines allocate fresh hashes with collision avoidance.
161
+ * Maps old hashes to new positions using hash-aware content matching.
162
+ * Unlike Diff.diffLines (which is content-only and cannot distinguish
163
+ * identical lines at different positions), this algorithm uses the
164
+ * removedHashes set to disambiguate: when a line appears multiple times
165
+ * in the old content and one occurrence was targeted by the edit,
166
+ * the surviving occurrence is matched to the non-removed hash.
167
+ * New/changed lines allocate fresh hashes with collision avoidance.
162
168
  */
163
169
  function mapStableHashes(
164
170
  oldContent: string,
165
171
  oldHashes: string[],
166
172
  newContent: string,
173
+ removedHashes?: Set<string>,
167
174
  ): string[] {
168
175
  const newLines = newContent.split("\n");
169
176
  const newHashes = new Array<string>(newLines.length);
170
177
  const used = new Set<string>();
171
178
 
172
- const parts = Diff.diffLines(oldContent, newContent);
173
- let oldIdx = 0;
174
- let newIdx = 0;
175
-
176
- for (const part of parts) {
177
- const raw = part.value.split("\n");
178
- if (raw[raw.length - 1] === "") raw.pop();
179
- const count = raw.length;
180
- if (count === 0) continue;
181
-
182
- if (part.added) {
183
- newIdx += count;
184
- } else if (part.removed) {
185
- oldIdx += count;
179
+ // Build a map from line content to list of (index, hash) for the old content.
180
+ // We process occurrences left-to-right so that matching preserves order.
181
+ const contentMap = new Map<string, { index: number; hash: string }[]>();
182
+ const oldLines = oldContent.split("\n");
183
+ for (let i = 0; i < oldLines.length; i++) {
184
+ const line = oldLines[i]!;
185
+ const entry = { index: i, hash: oldHashes[i]! };
186
+ const list = contentMap.get(line);
187
+ if (list) {
188
+ list.push(entry);
186
189
  } else {
187
- // unchanged — copy hashes
188
- for (let k = 0; k < count; k++) {
189
- const h = oldHashes[oldIdx]!;
190
- newHashes[newIdx] = h;
191
- used.add(h);
192
- oldIdx++;
193
- newIdx++;
190
+ contentMap.set(line, [entry]);
191
+ }
192
+ }
193
+
194
+ // Match each new line to an old occurrence by content.
195
+ // For lines with duplicate content, prefer occurrences whose hash
196
+ // was NOT targeted by the edit (those are the survivors).
197
+ for (let i = 0; i < newLines.length; i++) {
198
+ const line = newLines[i]!;
199
+ const candidates = contentMap.get(line);
200
+ if (!candidates || candidates.length === 0) continue;
201
+
202
+ // Find the best match: prefer a non-removed occurrence.
203
+ // If all remaining occurrences are removed, use the first one anyway
204
+ // (it will get a fresh hash in the fill step since its hash is in used+removed).
205
+ let bestIdx = 0;
206
+ if (removedHashes && removedHashes.size > 0) {
207
+ for (let j = 0; j < candidates.length; j++) {
208
+ if (!removedHashes.has(candidates[j]!.hash)) {
209
+ bestIdx = j;
210
+ break;
211
+ }
194
212
  }
195
213
  }
214
+
215
+ const match = candidates.splice(bestIdx, 1)[0]!;
216
+ newHashes[i] = match.hash;
217
+ used.add(match.hash);
196
218
  }
197
219
 
198
220
  // Fill remaining (new/changed) lines with fresh hashes
package/src/replace.ts CHANGED
@@ -165,10 +165,18 @@ export async function execPipeline(
165
165
 
166
166
  const result = anchorResult.content;
167
167
 
168
- // Compute stable result hashes using diff-based preservation
168
+ // Collect hashes targeted by the edit for hash-aware diff disambiguation
169
+ const removedHashes = new Set<string>();
170
+ for (const edit of resolved) {
171
+ removedHashes.add(edit.hash_range_inclusive[0].hash);
172
+ removedHashes.add(edit.hash_range_inclusive[1].hash);
173
+ }
174
+
175
+ // Compute stable result hashes using hash-aware preservation
169
176
  const resultHashes = await lineHashes(result, absolutePath, {
170
177
  content: originalNormalized,
171
178
  hashes: originalHashes,
179
+ removedHashes,
172
180
  });
173
181
 
174
182
  // Format boundary warnings using stable result hashes