pi-hashline-edit-pro 0.14.1 → 0.15.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/README.md +8 -5
- package/index.ts +7 -3
- package/package.json +1 -1
- package/src/file-reader.ts +1 -2
- package/src/hash-store.ts +61 -0
- package/src/hashline/apply.ts +4 -28
- package/src/hashline/hash.ts +113 -3
- package/src/hashline/index.ts +1 -0
- package/src/read.ts +9 -8
- package/src/replace-diff.ts +2 -3
- package/src/replace-flat.ts +0 -1
- package/src/replace-response.ts +2 -6
- package/src/replace.ts +39 -4
package/README.md
CHANGED
|
@@ -92,8 +92,13 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
92
92
|
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
|
|
93
93
|
- **Batched atomicity (bulk mode).** All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
|
|
94
94
|
|
|
95
|
-
###
|
|
95
|
+
### Stable hashing across edits
|
|
96
|
+
|
|
97
|
+
Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.json`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime diffs the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
|
|
96
98
|
|
|
99
|
+
The store contains per-file snapshots: the last known content and hashes for each file. On read, if the file content matches the snapshot, the saved hashes are returned immediately. Stale snapshots (for files that no longer exist) are pruned on session start.
|
|
100
|
+
|
|
101
|
+
### Chained edits
|
|
97
102
|
After a successful replace, the response confirms with `Successfully replaced in {path}.` (warnings are still shown if present). To get fresh anchors for follow-up edits, call `read` on the file first. This avoids token overhead from re-displaying content the model already knows.
|
|
98
103
|
|
|
99
104
|
### Auto-read after write
|
|
@@ -140,7 +145,7 @@ The file is created automatically when any setting is toggled. Both fields are i
|
|
|
140
145
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
|
141
146
|
- **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning is a short header followed by a hashline-anchored window: the duplicated pair plus 2 lines of context before and after, shown as plain `HASH│content` rows with post-edit hashes. Seeing the duplication in context (rather than just being told a hash) is what prompts the model to act on it — and since the hashes are current and staleness is per-line, the model can remove the duplicate in one follow-up `replace` without re-reading. No autocorrection is applied — the duplicate stays in the file and the model decides whether to remove it. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
|
142
147
|
- **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
|
|
143
|
-
|
|
148
|
+
- **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is at `~/.config/pi-hashline-edit-pro/hash-store.json` and is auto-created on first use. It contains per-file snapshots (last known content+hashes). When called from the replace pipeline, it diffs old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines.
|
|
144
149
|
## Hashing
|
|
145
150
|
|
|
146
151
|
Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
|
|
@@ -148,9 +153,7 @@ Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (
|
|
|
148
153
|
The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply. The full 64 chars give maximum entropy per character, with case and digits included.
|
|
149
154
|
|
|
150
155
|
**Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
|
|
151
|
-
|
|
152
|
-
The runtime always precomputes the full per-line hash array for a file via `lineHashes(content)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
|
|
153
|
-
|
|
156
|
+
The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
|
|
154
157
|
`HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
|
|
155
158
|
|
|
156
159
|
### Bare-prefix detector
|
package/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
readAutoRead,
|
|
15
15
|
toggleAutoRead,
|
|
16
16
|
} from "./src/config";
|
|
17
|
+
import { loadHashStore, pruneHashStore } from "./src/hash-store";
|
|
17
18
|
|
|
18
19
|
export default function (pi: ExtensionAPI): void {
|
|
19
20
|
regRead(pi);
|
|
@@ -31,7 +32,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
31
32
|
const active = pi.getActiveTools();
|
|
32
33
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
33
34
|
await initHasher();
|
|
34
|
-
|
|
35
|
+
// Prune stale snapshots (files that no longer exist) on session start
|
|
36
|
+
try {
|
|
37
|
+
const store = await loadHashStore();
|
|
38
|
+
await pruneHashStore(store);
|
|
39
|
+
} catch { /* best-effort */ }
|
|
35
40
|
// Re-register the replace tool according to the persisted mode
|
|
36
41
|
const mode = await readReplaceMode();
|
|
37
42
|
if (mode === "flat") {
|
|
@@ -93,8 +98,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
93
98
|
|
|
94
99
|
if (visLines(normalized).length === 0) return;
|
|
95
100
|
|
|
96
|
-
const preview = fmtReadPreview(normalized, { limit: AUTO_READ_MAX });
|
|
97
|
-
if (!preview.text) return;
|
|
101
|
+
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, undefined, absolutePath);
|
|
98
102
|
|
|
99
103
|
return {
|
|
100
104
|
content: [
|
package/package.json
CHANGED
package/src/file-reader.ts
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { stat } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
export interface FileSnapshot {
|
|
7
|
+
content: string;
|
|
8
|
+
hashes: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HashStore {
|
|
12
|
+
version: 1;
|
|
13
|
+
snapshots: Record<string, FileSnapshot>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function storePath(): string {
|
|
17
|
+
return join(homedir(), ".config", "pi-hashline-edit-pro", "hash-store.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function storeDir(): string {
|
|
21
|
+
return dirname(storePath());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function loadHashStore(): Promise<HashStore> {
|
|
25
|
+
try {
|
|
26
|
+
const content = await readFile(storePath(), "utf-8");
|
|
27
|
+
const parsed = JSON.parse(content) as Partial<HashStore>;
|
|
28
|
+
return {
|
|
29
|
+
version: 1,
|
|
30
|
+
snapshots: parsed.snapshots ?? {},
|
|
31
|
+
};
|
|
32
|
+
} catch {
|
|
33
|
+
await mkdir(storeDir(), { recursive: true });
|
|
34
|
+
const defaultStore: HashStore = {
|
|
35
|
+
version: 1,
|
|
36
|
+
snapshots: {},
|
|
37
|
+
};
|
|
38
|
+
await writeFile(storePath(), JSON.stringify(defaultStore), "utf-8");
|
|
39
|
+
return defaultStore;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function saveHashStore(store: HashStore): Promise<void> {
|
|
44
|
+
await mkdir(storeDir(), { recursive: true });
|
|
45
|
+
await writeFile(storePath(), JSON.stringify(store, null, 2), "utf-8");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function pruneHashStore(store: HashStore): Promise<void> {
|
|
49
|
+
let changed = false;
|
|
50
|
+
for (const filePath of Object.keys(store.snapshots)) {
|
|
51
|
+
try {
|
|
52
|
+
await stat(filePath);
|
|
53
|
+
} catch {
|
|
54
|
+
delete store.snapshots[filePath];
|
|
55
|
+
changed = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (changed) {
|
|
59
|
+
await saveHashStore(store);
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { abortIf } from "../runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { _lineHashesPure, HASH_SEP } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdits,
|
|
5
5
|
assertNoBarePrefix,
|
|
@@ -272,9 +272,9 @@ export function applyEdits(
|
|
|
272
272
|
content: string;
|
|
273
273
|
firstChangedLine: number | undefined;
|
|
274
274
|
lastChangedLine: number | undefined;
|
|
275
|
-
resultHashes?: string[];
|
|
276
275
|
warnings?: string[];
|
|
277
276
|
noopEdits?: NEdit[];
|
|
277
|
+
boundaryWarnings?: BDupWarn[];
|
|
278
278
|
} {
|
|
279
279
|
abortIf(signal);
|
|
280
280
|
if (!edits.length)
|
|
@@ -292,7 +292,7 @@ export function applyEdits(
|
|
|
292
292
|
);
|
|
293
293
|
|
|
294
294
|
const lineIndex = buildIdx(content);
|
|
295
|
-
const fileHashes = precomputedHashes ??
|
|
295
|
+
const fileHashes = precomputedHashes ?? _lineHashesPure(content);
|
|
296
296
|
const noopEdits: NEdit[] = [];
|
|
297
297
|
const warnings: string[] = [];
|
|
298
298
|
|
|
@@ -324,37 +324,13 @@ export function applyEdits(
|
|
|
324
324
|
assertNotEmpty(content, result);
|
|
325
325
|
const range = changedRange(content, result);
|
|
326
326
|
|
|
327
|
-
const resultLines = result.split("\n");
|
|
328
|
-
const resultHashes = lineHashes(result);
|
|
329
|
-
for (const bw of boundaryWarnings) {
|
|
330
|
-
let seen = 0;
|
|
331
|
-
let matchIndex = -1;
|
|
332
|
-
for (let i = 0; i < resultLines.length; i++) {
|
|
333
|
-
if (resultLines[i] === bw.survivingLineContent) {
|
|
334
|
-
if (seen === bw.occurrence) { matchIndex = i; break; }
|
|
335
|
-
seen++;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
if (matchIndex >= 0) {
|
|
339
|
-
warnings.push(
|
|
340
|
-
fmtBoundaryWarning({
|
|
341
|
-
kind: bw.kind,
|
|
342
|
-
survivingContent: bw.survivingLineContent,
|
|
343
|
-
matchIndex,
|
|
344
|
-
resultLines,
|
|
345
|
-
resultHashes,
|
|
346
|
-
}),
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
327
|
return {
|
|
352
328
|
content: result,
|
|
353
329
|
firstChangedLine: range?.firstChangedLine,
|
|
354
330
|
lastChangedLine: range?.lastChangedLine,
|
|
355
|
-
resultHashes,
|
|
356
331
|
...(warnings.length ? { warnings } : {}),
|
|
357
332
|
...(noopEdits.length ? { noopEdits } : {}),
|
|
333
|
+
...(boundaryWarnings.length ? { boundaryWarnings } : {}),
|
|
358
334
|
};
|
|
359
335
|
}
|
|
360
336
|
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import xxhash from "xxhash-wasm";
|
|
2
|
+
import * as Diff from "diff";
|
|
3
|
+
import { loadHashStore, saveHashStore } from "../hash-store";
|
|
2
4
|
|
|
3
5
|
export const HASH_LEN = 3;
|
|
4
6
|
export const ANCHOR_LEN = HASH_LEN;
|
|
@@ -73,8 +75,8 @@ function canon(line: string): string {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
+
* Pure hash computation — no I/O, no persistence. Used internally by the
|
|
79
|
+
* stable path and as a fallback when no file path is available.
|
|
78
80
|
* Each line is hashed with xxHash32 over its canonical form (trailing
|
|
79
81
|
* whitespace and CR characters stripped). If the base hash collides with a
|
|
80
82
|
* hash already assigned to an earlier line, a retry counter (`:R{retry}`)
|
|
@@ -83,7 +85,7 @@ function canon(line: string): string {
|
|
|
83
85
|
* in a file receives a distinct anchor, even when multiple lines contain
|
|
84
86
|
* identical text (e.g. repeated `}` or `import` statements).
|
|
85
87
|
*/
|
|
86
|
-
export function
|
|
88
|
+
export function _lineHashesPure(content: string): string[] {
|
|
87
89
|
const lines = content.split("\n");
|
|
88
90
|
const hashes = new Array<string>(lines.length);
|
|
89
91
|
const assigned = new Set<string>();
|
|
@@ -101,5 +103,113 @@ export function lineHashes(content: string): string[] {
|
|
|
101
103
|
return hashes;
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Stable, persistent-aware hash computation.
|
|
108
|
+
*
|
|
109
|
+
* When `path` is provided, uses the persistent hash store to preserve
|
|
110
|
+
* hashes for unchanged lines across edits. When `previous` is also
|
|
111
|
+
* provided (called from the replace pipeline), diffs the previous content
|
|
112
|
+
* against the new content and copies hashes for unchanged lines to their
|
|
113
|
+
* new positions. New/changed lines allocate fresh hashes with collision
|
|
114
|
+
* avoidance against the preserved set.
|
|
115
|
+
*
|
|
116
|
+
* When `path` is provided without `previous` (called from read), loads
|
|
117
|
+
* the stored snapshot for that path. If the content matches, returns the
|
|
118
|
+
* saved hashes. Otherwise computes fresh hashes via `_lineHashesPure` and
|
|
119
|
+
* saves a new snapshot.
|
|
120
|
+
*
|
|
121
|
+
* When `path` is not provided, falls back to `_lineHashesPure` (for
|
|
122
|
+
* backward compatibility and tests that don't need persistence).
|
|
123
|
+
*/
|
|
124
|
+
export async function lineHashes(
|
|
125
|
+
content: string,
|
|
126
|
+
path?: string,
|
|
127
|
+
previous?: { content: string; hashes: string[] },
|
|
128
|
+
): Promise<string[]> {
|
|
129
|
+
if (!path) {
|
|
130
|
+
return _lineHashesPure(content);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const store = await loadHashStore();
|
|
134
|
+
|
|
135
|
+
// Case 1: previous content provided (replace pipeline) — diff and preserve
|
|
136
|
+
if (previous) {
|
|
137
|
+
const newHashes = mapStableHashes(
|
|
138
|
+
previous.content, previous.hashes,
|
|
139
|
+
content,
|
|
140
|
+
);
|
|
141
|
+
store.snapshots[path] = { content, hashes: newHashes };
|
|
142
|
+
await saveHashStore(store);
|
|
143
|
+
return newHashes;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Case 2: no previous — check snapshot or compute fresh
|
|
147
|
+
const snapshot = store.snapshots[path];
|
|
148
|
+
if (snapshot && snapshot.content === content) {
|
|
149
|
+
return snapshot.hashes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Compute fresh hashes
|
|
153
|
+
const newHashes = _lineHashesPure(content);
|
|
154
|
+
store.snapshots[path] = { content, hashes: newHashes };
|
|
155
|
+
await saveHashStore(store);
|
|
156
|
+
return newHashes;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Diff old content against new content, preserving hashes for unchanged
|
|
161
|
+
* lines. New/changed lines allocate fresh hashes with collision avoidance.
|
|
162
|
+
*/
|
|
163
|
+
function mapStableHashes(
|
|
164
|
+
oldContent: string,
|
|
165
|
+
oldHashes: string[],
|
|
166
|
+
newContent: string,
|
|
167
|
+
): string[] {
|
|
168
|
+
const newLines = newContent.split("\n");
|
|
169
|
+
const newHashes = new Array<string>(newLines.length);
|
|
170
|
+
const used = new Set<string>();
|
|
171
|
+
|
|
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;
|
|
186
|
+
} 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++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Fill remaining (new/changed) lines with fresh hashes
|
|
199
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
200
|
+
if (newHashes[i]) continue;
|
|
201
|
+
const c = canon(newLines[i]!);
|
|
202
|
+
let retry = 0;
|
|
203
|
+
let hash = h2s(xxh32(c));
|
|
204
|
+
while (used.has(hash)) {
|
|
205
|
+
retry++;
|
|
206
|
+
hash = h2s(xxh32(`${c}:R${retry}`));
|
|
207
|
+
}
|
|
208
|
+
used.add(hash);
|
|
209
|
+
newHashes[i] = hash;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return newHashes;
|
|
213
|
+
}
|
|
104
214
|
|
|
105
215
|
export { ALPH_RE };
|
package/src/hashline/index.ts
CHANGED
package/src/read.ts
CHANGED
|
@@ -57,26 +57,26 @@ export function formatPaginationHint(
|
|
|
57
57
|
return `[Showing lines ${startLine}-${endLine} of ${totalLines}${sizeSuffix}. Use offset=${nextOffset} to continue.]`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export function fmtReadPreview(
|
|
60
|
+
export async function fmtReadPreview(
|
|
61
61
|
text: string,
|
|
62
62
|
options: { offset?: number; limit?: number },
|
|
63
63
|
precomputedHashes?: string[],
|
|
64
|
-
|
|
64
|
+
path?: string,
|
|
65
|
+
): Promise<{ text: string; truncation?: TruncationResult; nextOffset?: number }> {
|
|
65
66
|
const allLines = visLines(text);
|
|
66
67
|
const totalLines = allLines.length;
|
|
67
68
|
const startLine = normPosInt(options.offset, "offset") ?? 1;
|
|
68
69
|
if (totalLines === 0) {
|
|
69
70
|
if (startLine === 1) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
const allHashes = precomputedHashes ?? await (path ? lineHashes(text, path) : lineHashes(text));
|
|
72
|
+
const emptyLineHash = allHashes[0] ?? "";
|
|
73
|
+
return {
|
|
73
74
|
text: `${emptyLineHash}${HASH_SEP}\n[File is empty. Use replace to insert content.]`,
|
|
74
75
|
};
|
|
75
76
|
}
|
|
76
77
|
return {
|
|
77
78
|
text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
|
|
78
79
|
};
|
|
79
|
-
|
|
80
80
|
}
|
|
81
81
|
if (startLine > totalLines) {
|
|
82
82
|
return {
|
|
@@ -89,7 +89,7 @@ export function fmtReadPreview(
|
|
|
89
89
|
? Math.min(startLine - 1 + limit, totalLines)
|
|
90
90
|
: totalLines;
|
|
91
91
|
const selected = allLines.slice(startLine - 1, endIdx);
|
|
92
|
-
const allHashes = precomputedHashes ?? lineHashes(text);
|
|
92
|
+
const allHashes = precomputedHashes ?? await (path ? lineHashes(text, path) : lineHashes(text));
|
|
93
93
|
const selectedHashes = allHashes.slice(startLine - 1, endIdx);
|
|
94
94
|
const formatted = fmtRegion(selectedHashes, selected);
|
|
95
95
|
|
|
@@ -171,13 +171,14 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
171
171
|
const { normalized, fileHashes, hadUtf8DecodeErrors } = await readNormFile(
|
|
172
172
|
rawPath, ctx.cwd, signal, undefined, file,
|
|
173
173
|
);
|
|
174
|
-
const preview = fmtReadPreview(
|
|
174
|
+
const preview = await fmtReadPreview(
|
|
175
175
|
normalized,
|
|
176
176
|
{
|
|
177
177
|
offset: params.offset,
|
|
178
178
|
limit: params.limit,
|
|
179
179
|
},
|
|
180
180
|
fileHashes,
|
|
181
|
+
absolutePath,
|
|
181
182
|
);
|
|
182
183
|
const snapshot = await fileSnap(absolutePath);
|
|
183
184
|
|
package/src/replace-diff.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as Diff from "diff";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
_lineHashesPure,
|
|
4
4
|
ANCHOR_LEN,
|
|
5
5
|
HASH_SEP,
|
|
6
6
|
} from "./hashline";
|
|
@@ -48,8 +48,7 @@ export function genDiff(
|
|
|
48
48
|
): { diff: string; firstChangedLine: number | undefined } {
|
|
49
49
|
const parts = Diff.diffLines(oldContent, newContent);
|
|
50
50
|
const output: string[] = [];
|
|
51
|
-
const effectiveNewHashes = newContentHashes ??
|
|
52
|
-
|
|
51
|
+
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
53
52
|
let oldLineNum = 1;
|
|
54
53
|
let newLineNum = 1;
|
|
55
54
|
let lastWasChange = false;
|
package/src/replace-flat.ts
CHANGED
package/src/replace-response.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import type { ReplaceDetails } from "./replace";
|
|
2
2
|
import { genDiff } from "./replace-diff";
|
|
3
|
-
import {
|
|
4
|
-
lineHashes,
|
|
5
|
-
} from "./hashline";
|
|
6
3
|
import { visLines } from "./utils";
|
|
7
4
|
|
|
8
5
|
type TResult = {
|
|
@@ -46,7 +43,7 @@ export interface SuccessInput {
|
|
|
46
43
|
path: string;
|
|
47
44
|
originalNormalized: string;
|
|
48
45
|
result: string;
|
|
49
|
-
resultHashes
|
|
46
|
+
resultHashes: string[];
|
|
50
47
|
warnings: string[] | undefined;
|
|
51
48
|
snapshotId: string;
|
|
52
49
|
editMeta: RMeta;
|
|
@@ -142,10 +139,9 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
142
139
|
}
|
|
143
140
|
|
|
144
141
|
export function buildChanged(input: SuccessInput): TResult {
|
|
145
|
-
const { path, result, warnings, snapshotId, originalNormalized, editMeta } = input;
|
|
142
|
+
const { path, result, warnings, snapshotId, originalNormalized, editMeta, resultHashes } = input;
|
|
146
143
|
|
|
147
144
|
const resultLines = visLines(result);
|
|
148
|
-
const resultHashes = input.resultHashes ?? lineHashes(result);
|
|
149
145
|
const diffResult = genDiff(originalNormalized, result, 2, resultHashes);
|
|
150
146
|
const addedLines = cntDiff(diffResult.diff, "+");
|
|
151
147
|
const removedLines = cntDiff(diffResult.diff, "-");
|
package/src/replace.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { resolveTarget, writeAtomic } from "./fs-write";
|
|
|
18
18
|
import {
|
|
19
19
|
applyEdits,
|
|
20
20
|
lineHashes,
|
|
21
|
+
fmtBoundaryWarning,
|
|
21
22
|
resEdits,
|
|
22
23
|
type HTEdit,
|
|
23
24
|
} from "./hashline";
|
|
@@ -101,7 +102,7 @@ interface PipelineResult {
|
|
|
101
102
|
firstChangedLine?: number;
|
|
102
103
|
lastChangedLine?: number;
|
|
103
104
|
originalHashes?: string[];
|
|
104
|
-
resultHashes
|
|
105
|
+
resultHashes: string[];
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
const ROOT_KS = new Set(["path", "changes"]);
|
|
@@ -152,6 +153,7 @@ export async function execPipeline(
|
|
|
152
153
|
path, cwd, signal, accessMode, undefined, MAX_HASH_LINES,
|
|
153
154
|
);
|
|
154
155
|
|
|
156
|
+
const absolutePath = toCwd(path, cwd);
|
|
155
157
|
const resolved = resEdits(toolEdits);
|
|
156
158
|
const anchorResult = applyEdits(
|
|
157
159
|
originalNormalized,
|
|
@@ -161,19 +163,52 @@ export async function execPipeline(
|
|
|
161
163
|
path,
|
|
162
164
|
);
|
|
163
165
|
|
|
166
|
+
const result = anchorResult.content;
|
|
167
|
+
|
|
168
|
+
// Compute stable result hashes using diff-based preservation
|
|
169
|
+
const resultHashes = await lineHashes(result, absolutePath, {
|
|
170
|
+
content: originalNormalized,
|
|
171
|
+
hashes: originalHashes,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Format boundary warnings using stable result hashes
|
|
175
|
+
const resultLines = result.split("\n");
|
|
176
|
+
const warnings = [...(anchorResult.warnings ?? [])];
|
|
177
|
+
for (const bw of anchorResult.boundaryWarnings ?? []) {
|
|
178
|
+
let seen = 0;
|
|
179
|
+
let matchIndex = -1;
|
|
180
|
+
for (let i = 0; i < resultLines.length; i++) {
|
|
181
|
+
if (resultLines[i] === bw.survivingLineContent) {
|
|
182
|
+
if (seen === bw.occurrence) { matchIndex = i; break; }
|
|
183
|
+
seen++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (matchIndex >= 0) {
|
|
187
|
+
warnings.push(
|
|
188
|
+
fmtBoundaryWarning({
|
|
189
|
+
kind: bw.kind,
|
|
190
|
+
survivingContent: bw.survivingLineContent,
|
|
191
|
+
matchIndex,
|
|
192
|
+
resultLines,
|
|
193
|
+
resultHashes,
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
164
199
|
return {
|
|
165
200
|
path,
|
|
166
201
|
toolEdits,
|
|
167
202
|
originalNormalized,
|
|
168
|
-
result
|
|
203
|
+
result,
|
|
169
204
|
bom,
|
|
170
205
|
originalEnding,
|
|
171
206
|
hadUtf8DecodeErrors,
|
|
172
|
-
warnings
|
|
207
|
+
warnings,
|
|
173
208
|
noopEdits: anchorResult.noopEdits,
|
|
174
209
|
firstChangedLine: anchorResult.firstChangedLine,
|
|
175
210
|
lastChangedLine: anchorResult.lastChangedLine,
|
|
176
|
-
resultHashes
|
|
211
|
+
resultHashes,
|
|
177
212
|
originalHashes,
|
|
178
213
|
};
|
|
179
214
|
}
|