pi-hashline-edit-pro 0.19.2 → 0.21.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 +22 -21
- package/index.ts +6 -6
- package/package.json +2 -2
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +1 -1
- package/src/constants.ts +1 -1
- package/src/fs-write.ts +6 -1
- package/src/hashline/apply.ts +12 -5
- package/src/hashline/hash.ts +24 -18
- package/src/hashline/index.ts +3 -2
- package/src/hashline/parse.ts +5 -23
- package/src/hashline/resolve.ts +111 -32
- package/src/read.ts +1 -1
- package/src/replace-diff.ts +0 -1
- package/src/replace-normalize.ts +1 -1
- package/src/replace-response.ts +2 -2
- package/src/replace-undo.ts +6 -2
- package/src/replace.ts +21 -19
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-hashline-edit-pro
|
|
2
2
|
|
|
3
|
-
A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no
|
|
3
|
+
A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no fuzzy fallback. Unambiguous copy-paste mistakes are autocorrected with a visible warning. Every line gets a unique content hash, so edits stay precise and stale anchors are caught before they reach the file.
|
|
4
4
|
|
|
5
5
|
Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design with 3-character hashes and collision resolution for unique per-line anchors.
|
|
6
6
|
|
|
@@ -12,8 +12,8 @@ The original uses 2-character hashes of a 16-character alphabet, with the hash b
|
|
|
12
12
|
|
|
13
13
|
This fork makes two changes that compound:
|
|
14
14
|
|
|
15
|
-
1. **3-character hash length** over a
|
|
16
|
-
2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (
|
|
15
|
+
1. **3-character hash length** over a 62-char alphanumeric alphabet (up from 2 characters in the upstream), expanding the hash space from 256 to 238,328 buckets.
|
|
16
|
+
2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (238,328 bits) using a hint cursor for O(1) amortized lookup. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
@@ -49,7 +49,7 @@ szJ│ console.log("world");
|
|
|
49
49
|
_zl│}
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
- `HASH` is a 3-character content hash from the
|
|
52
|
+
- `HASH` is a 3-character content hash from the alphanumeric alphabet `A-Za-z0-9` (e.g. `aB3`). See [Hashing](#hashing) for details.
|
|
53
53
|
|
|
54
54
|
Optional parameters:
|
|
55
55
|
|
|
@@ -66,10 +66,10 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
66
66
|
|
|
67
67
|
```json
|
|
68
68
|
{
|
|
69
|
+
"path": "src/main.ts",
|
|
69
70
|
"changes": [
|
|
70
|
-
{ "content_lines": [" console.log('hashline');"]
|
|
71
|
-
]
|
|
72
|
-
"path": "src/main.ts"
|
|
71
|
+
{ "hash_range_inclusive": ["ve7", "ve7"], "content_lines": [" console.log('hashline');"] }
|
|
72
|
+
]
|
|
73
73
|
}
|
|
74
74
|
```
|
|
75
75
|
|
|
@@ -77,9 +77,9 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
77
77
|
|
|
78
78
|
```json
|
|
79
79
|
{
|
|
80
|
-
"
|
|
80
|
+
"path": "src/main.ts",
|
|
81
81
|
"hash_range_inclusive": ["ve7", "ve7"],
|
|
82
|
-
"
|
|
82
|
+
"content_lines": [" console.log('hashline');"]
|
|
83
83
|
}
|
|
84
84
|
```
|
|
85
85
|
|
|
@@ -89,7 +89,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
89
89
|
| `content_lines` | Literal replacement content, one string per line (use `[]` to delete the range). |
|
|
90
90
|
|
|
91
91
|
- **Request structure validation.** The request envelope (`path`, `changes` in bulk mode; `path`, `hash_range_inclusive`, `content_lines` in flat mode) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
|
|
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 `{
|
|
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
|
|
@@ -163,43 +163,44 @@ The file is created automatically when any setting is toggled. Both fields are i
|
|
|
163
163
|
| `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
|
|
164
164
|
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
165
165
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
166
|
-
| `[E_INVALID_PATCH]` | `content_lines`
|
|
167
|
-
| `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix. |
|
|
166
|
+
| `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
|
|
167
|
+
| `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
|
|
168
168
|
| `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
|
|
169
|
-
| `[E_BAD_OP]` | Range start line is after range end line. |
|
|
169
|
+
| `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
|
|
170
170
|
| `[E_EDIT_CONFLICT]` | Two edits in one batch overlap the same original lines. |
|
|
171
171
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
172
|
-
| `[E_FILE_TOO_LARGE]` | The file exceeds the
|
|
172
|
+
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
173
173
|
|
|
174
174
|
## Design Decisions
|
|
175
175
|
|
|
176
|
-
- **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
|
|
176
|
+
- **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. When a range has one stale and one still-valid anchor, the error also shows the current lines (with fresh hashes) around the resolved anchor, so the model can re-locate the range without a full re-read. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
|
|
177
177
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
178
|
-
- **Strict patch content.**
|
|
178
|
+
- **Strict patch content.** Diff-preview rows pasted into `content_lines` are autocorrected instead of rejected: `+HASH│` addition prefixes and `-HASH│` / `- │` deletion rows (the padded format the diff preview emits) have their markers stripped, with a warning, and only the literal content is written. `-N ` numbered deletion rows and standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** altered — they are written literally, since literal content must never be silently changed when the intent is ambiguous. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 alphanumeric chars + `│`) is autocorrected: the prefix is stripped and only the literal content after `│` is written, with a warning. When the stripped hash happens to match a real file-line anchor, the warning flags that as strong evidence the model copied an anchor from the read output.
|
|
179
179
|
|
|
180
180
|
- **BOM preservation.** A UTF-8 BOM is stripped for display and hashing but restored on write, so edits (and undo) never silently strip a BOM from a file that has one.
|
|
181
181
|
- **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
|
|
182
182
|
- **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.
|
|
183
183
|
- **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
|
184
|
+
- **Reversed range auto-swap.** When `hash_range_inclusive` is given in reverse order (start anchor after end anchor), both anchors are still valid file lines, so the intent is unambiguous: the pair is swapped automatically and the edit applies, with a warning.
|
|
184
185
|
- **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.
|
|
185
186
|
- **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum 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. Per-operation work scales with the target file, not cumulative history. If the database is corrupt or unreadable it is quarantined (renamed to `hash-store.sqlite.corrupt-<timestamp>`) and rebuilt from content on the next session start — the store is a cache, never a source of truth.
|
|
186
187
|
## Hashing
|
|
187
188
|
|
|
188
|
-
Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the
|
|
189
|
+
Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the alphanumeric alphabet `A-Za-z0-9`. That's 62 distinct characters, 62³ = 238,328 possible anchors (≈17.9 bits of entropy per anchor).
|
|
189
190
|
|
|
190
|
-
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
|
|
191
|
+
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 — case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded: a hash starting with `-` is shape-identical to a diff-preview deletion row (`-HASH│`), and `-`/`_` at a line start are markdown-active (list bullet, emphasis), so they invite mis-copying and false `[E_INVALID_PATCH]`/`[E_BARE_HASH_PREFIX]` autocorrections. The 9% hash-space cost (238,328 vs 262,144) is irrelevant for real files.
|
|
191
192
|
|
|
192
193
|
Before hashing, each line is normalized: carriage returns are stripped and trailing whitespace is trimmed. This `canon()` normalization prevents insignificant whitespace changes from cascade-triggering hash churn across the file. Two lines that differ only in trailing spaces or `\r` characters produce the same hash, so anchor stability is preserved across editor-save cycles that add or remove trailing whitespace.
|
|
193
194
|
|
|
194
|
-
**Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (
|
|
195
|
+
**Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (238,328 bits) using a hint cursor for O(1) amortized lookup. 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.
|
|
195
196
|
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).
|
|
196
197
|
`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.
|
|
197
198
|
|
|
198
|
-
The 3-character space holds
|
|
199
|
+
The 3-character space holds 238,328 unique anchors, so files are capped at 238,328 lines: `read` and `replace` reject larger files with `[E_FILE_TOO_LARGE]` (use `write` or a non-line-based approach for very large files).
|
|
199
200
|
|
|
200
201
|
### Bare-prefix detector
|
|
201
202
|
|
|
202
|
-
With the `│` delimiter format, the bare-prefix detector regex `^\s*([A-Za-z0-9_\-]{3})│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector
|
|
203
|
+
With the `│` delimiter format, the bare-prefix detector regex `^\s*([A-Za-z0-9_\-]{3})│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector strips the prefix from edit lines matching this pattern (with a warning) instead of writing the anchor into the file content.
|
|
203
204
|
|
|
204
205
|
## Development
|
|
205
206
|
|
package/index.ts
CHANGED
|
@@ -15,11 +15,11 @@ import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
|
15
15
|
import { readNormFile } from "./src/file-reader";
|
|
16
16
|
import { toCwd } from "./src/paths";
|
|
17
17
|
import { resolveTarget } from "./src/fs-write";
|
|
18
|
-
function registerReplaceTool(pi: ExtensionAPI, mode: string
|
|
18
|
+
function registerReplaceTool(pi: ExtensionAPI, mode: string): void {
|
|
19
19
|
if (mode === "flat") {
|
|
20
|
-
regReplaceFlat(pi
|
|
20
|
+
regReplaceFlat(pi);
|
|
21
21
|
} else {
|
|
22
|
-
regReplace(pi
|
|
22
|
+
regReplace(pi);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -46,7 +46,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
46
46
|
const config = await readConfig();
|
|
47
47
|
const mode = config.replaceMode;
|
|
48
48
|
autoRead = config.autoRead;
|
|
49
|
-
registerReplaceTool(pi, mode
|
|
49
|
+
registerReplaceTool(pi, mode);
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
if (debugValue === "1" || debugValue === "true") {
|
|
@@ -58,7 +58,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
58
58
|
description: "Toggle replace tool between bulk (changes array) and flat (single edit at top level) mode",
|
|
59
59
|
handler: async (_args, ctx) => {
|
|
60
60
|
const mode = await toggleReplaceMode();
|
|
61
|
-
registerReplaceTool(pi, mode
|
|
61
|
+
registerReplaceTool(pi, mode);
|
|
62
62
|
ctx.ui.notify(`Replace mode switched to: ${mode}`, "info");
|
|
63
63
|
},
|
|
64
64
|
});
|
|
@@ -68,7 +68,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
68
68
|
handler: async (_args, ctx) => {
|
|
69
69
|
autoRead = await toggleAutoRead();
|
|
70
70
|
const mode = (await readConfig()).replaceMode;
|
|
71
|
-
registerReplaceTool(pi, mode
|
|
71
|
+
registerReplaceTool(pi, mode);
|
|
72
72
|
const state = autoRead ? "enabled" : "disabled";
|
|
73
73
|
ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
|
|
74
74
|
},
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char,
|
|
5
|
+
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
package/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file; each line returned as HASH│content with a 3-char
|
|
1
|
+
Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
|
|
2
1
|
- `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
|
|
2
|
+
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
|
|
3
3
|
- `replace`: minimize the replaced range — anchor only the lines that actually change; for insertions use a single-line range (e.g. the line after the insertion point) instead of a whole block, so fewer unchanged lines must be reproduced byte-exact.
|
package/src/constants.ts
CHANGED
|
@@ -3,7 +3,7 @@ export const SNIFF_BYTES = 8192;
|
|
|
3
3
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
4
4
|
|
|
5
5
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
6
|
-
export const HASH_STORE_VERSION =
|
|
6
|
+
export const HASH_STORE_VERSION = 4;
|
|
7
7
|
export const CONTENT_LINES_NOT_STRING_MSG =
|
|
8
8
|
`[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
9
9
|
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
|
package/src/fs-write.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
stat,
|
|
11
11
|
writeFile,
|
|
12
12
|
copyFile,
|
|
13
|
+
chmod,
|
|
13
14
|
} from "fs/promises";
|
|
14
15
|
import { dirname, join, parse, resolve, sep } from "path";
|
|
15
16
|
import { errCode } from "./utils";
|
|
@@ -73,6 +74,7 @@ export async function resolveTarget(path: string): Promise<string> {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
const TEMP_PREFIX = ".tmp-";
|
|
77
|
+
const TEMP_UUID_RE = /^\.tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
76
78
|
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
77
79
|
const sweptDirs = new Set<string>();
|
|
78
80
|
|
|
@@ -83,7 +85,7 @@ async function sweepStaleTemps(dir: string): Promise<void> {
|
|
|
83
85
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
84
86
|
const now = Date.now();
|
|
85
87
|
for (const entry of entries) {
|
|
86
|
-
if (!entry.isFile() || !entry.name
|
|
88
|
+
if (!entry.isFile() || !TEMP_UUID_RE.test(entry.name)) continue;
|
|
87
89
|
const tempPath = join(dir, entry.name);
|
|
88
90
|
try {
|
|
89
91
|
const stats = await stat(tempPath);
|
|
@@ -154,6 +156,9 @@ export async function writeAtomic(
|
|
|
154
156
|
if (errCode(error) === "EXDEV") {
|
|
155
157
|
try {
|
|
156
158
|
await copyFile(tempPath, targetPath);
|
|
159
|
+
if (existingStats) {
|
|
160
|
+
await chmod(targetPath, existingStats.mode & 0o7777);
|
|
161
|
+
}
|
|
157
162
|
await rm(tempPath, { force: true });
|
|
158
163
|
await syncDir(dir);
|
|
159
164
|
return;
|
package/src/hashline/apply.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { abortIf, splitLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../u
|
|
|
2
2
|
import { _lineHashesPure, HASH_SEP } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdits,
|
|
5
|
-
|
|
5
|
+
stripBarePrefixes,
|
|
6
|
+
stripDiffPrefixes,
|
|
7
|
+
swapReversedRanges,
|
|
6
8
|
warnUnicodeEsc,
|
|
7
9
|
fmtMismatch,
|
|
8
10
|
descEdit,
|
|
@@ -246,8 +248,14 @@ export function applyEdits(
|
|
|
246
248
|
const noopEdits: NEdit[] = [];
|
|
247
249
|
const warnings: string[] = [];
|
|
248
250
|
|
|
251
|
+
const rangeFixed = swapReversedRanges(edits, fileHashes, warnings);
|
|
252
|
+
const prefixFixed = stripDiffPrefixes(
|
|
253
|
+
stripBarePrefixes(rangeFixed, fileHashes, warnings),
|
|
254
|
+
warnings,
|
|
255
|
+
);
|
|
256
|
+
|
|
249
257
|
const { resolved: initialResolved, mismatches, boundaryWarnings } = valEdits(
|
|
250
|
-
|
|
258
|
+
prefixFixed,
|
|
251
259
|
lineIndex.fileLines,
|
|
252
260
|
fileHashes,
|
|
253
261
|
warnings,
|
|
@@ -259,14 +267,13 @@ export function applyEdits(
|
|
|
259
267
|
);
|
|
260
268
|
}
|
|
261
269
|
|
|
262
|
-
|
|
263
|
-
warnUnicodeEsc(edits, warnings);
|
|
270
|
+
warnUnicodeEsc(prefixFixed, warnings);
|
|
264
271
|
|
|
265
272
|
let resolved = initialResolved;
|
|
266
273
|
let autoFixes: AutoFix[] | undefined;
|
|
267
274
|
if (boundaryWarnings.length > 0) {
|
|
268
275
|
autoFixes = [];
|
|
269
|
-
const correctedEdits: HEdit[] =
|
|
276
|
+
const correctedEdits: HEdit[] = prefixFixed.map(e => ({
|
|
270
277
|
...e,
|
|
271
278
|
content_lines: [...e.content_lines],
|
|
272
279
|
}));
|
package/src/hashline/hash.ts
CHANGED
|
@@ -14,9 +14,7 @@ export const ANCHOR_LEN = HASH_LEN;
|
|
|
14
14
|
export const HASH_SEP = "│";
|
|
15
15
|
|
|
16
16
|
const ALPH =
|
|
17
|
-
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
|
|
18
|
-
const ALPH_BITS = 6;
|
|
19
|
-
const ALPH_MASK = (1 << ALPH_BITS) - 1;
|
|
17
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
20
18
|
const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
|
|
21
19
|
const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
|
|
22
20
|
export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
|
|
@@ -27,15 +25,22 @@ export const MAX_HASH_LINES = HASH_SPACE;
|
|
|
27
25
|
function idxToHash(idx: number): string {
|
|
28
26
|
let out = "";
|
|
29
27
|
for (let j = 0; j < HASH_LEN; j++) {
|
|
30
|
-
out
|
|
28
|
+
out = ALPH[idx % ALPH.length]! + out;
|
|
29
|
+
idx = Math.floor(idx / ALPH.length);
|
|
31
30
|
}
|
|
32
31
|
return out;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
34
|
+
const hashCache = new Map<number, string>();
|
|
35
|
+
|
|
36
|
+
function hashAt(idx: number): string {
|
|
37
|
+
let hash = hashCache.get(idx);
|
|
38
|
+
if (hash === undefined) {
|
|
39
|
+
hash = idxToHash(idx);
|
|
40
|
+
hashCache.set(idx, hash);
|
|
41
|
+
}
|
|
42
|
+
return hash;
|
|
43
|
+
}
|
|
39
44
|
|
|
40
45
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
41
46
|
`^\\+\\s*${HASH_CLASS}│`,
|
|
@@ -43,7 +48,6 @@ export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
|
43
48
|
export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
44
49
|
`^-(?:\\s*${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
|
|
45
50
|
);
|
|
46
|
-
export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
47
51
|
|
|
48
52
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
|
|
49
53
|
|
|
@@ -63,22 +67,24 @@ function setBit(bits: Uint32Array, idx: number): void {
|
|
|
63
67
|
|
|
64
68
|
function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
65
69
|
const totalWords = bits.length;
|
|
66
|
-
const totalBits =
|
|
70
|
+
const totalBits = HASH_SPACE;
|
|
71
|
+
const lastWordBits = HASH_SPACE - (totalWords - 1) * 32;
|
|
67
72
|
|
|
68
73
|
if (start >= totalBits) start = 0;
|
|
69
74
|
|
|
70
75
|
const wordIdx = start >>> 5;
|
|
71
76
|
const bitOffset = start & 31;
|
|
77
|
+
const wordBits = (w: number): number => (w === totalWords - 1 ? lastWordBits : 32);
|
|
72
78
|
|
|
73
79
|
let word = bits[wordIdx];
|
|
74
|
-
for (let b = bitOffset; b <
|
|
80
|
+
for (let b = bitOffset; b < wordBits(wordIdx); b++) {
|
|
75
81
|
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
for (let w = wordIdx + 1; w < totalWords; w++) {
|
|
79
85
|
word = bits[w];
|
|
80
86
|
if (~word !== 0) {
|
|
81
|
-
for (let b = 0; b <
|
|
87
|
+
for (let b = 0; b < wordBits(w); b++) {
|
|
82
88
|
if ((word >>> b & 1) === 0) return w * 32 + b;
|
|
83
89
|
}
|
|
84
90
|
}
|
|
@@ -87,7 +93,7 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
|
87
93
|
for (let w = 0; w < wordIdx; w++) {
|
|
88
94
|
word = bits[w];
|
|
89
95
|
if (~word !== 0) {
|
|
90
|
-
for (let b = 0; b <
|
|
96
|
+
for (let b = 0; b < wordBits(w); b++) {
|
|
91
97
|
if ((word >>> b & 1) === 0) return w * 32 + b;
|
|
92
98
|
}
|
|
93
99
|
}
|
|
@@ -107,13 +113,13 @@ function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number })
|
|
|
107
113
|
if (!getBit(used, baseIdx)) {
|
|
108
114
|
setBit(used, baseIdx);
|
|
109
115
|
hint.value = baseIdx + 1;
|
|
110
|
-
return
|
|
116
|
+
return hashAt(baseIdx);
|
|
111
117
|
}
|
|
112
118
|
const start = hint.value > baseIdx + 1 ? hint.value : baseIdx + 1;
|
|
113
119
|
const nextIdx = nextZeroBit(used, start);
|
|
114
120
|
setBit(used, nextIdx);
|
|
115
121
|
hint.value = nextIdx + 1;
|
|
116
|
-
return
|
|
122
|
+
return hashAt(nextIdx);
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
export function _lineHashesPure(content: string): string[] {
|
|
@@ -124,7 +130,7 @@ export function _lineHashesPure(content: string): string[] {
|
|
|
124
130
|
|
|
125
131
|
for (let i = 0; i < lines.length; i++) {
|
|
126
132
|
const c = canon(lines[i]!);
|
|
127
|
-
const baseIdx = xxh32(c) >>> 14;
|
|
133
|
+
const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
|
|
128
134
|
hashes[i] = assignHash(used, baseIdx, hint);
|
|
129
135
|
}
|
|
130
136
|
return hashes;
|
|
@@ -172,7 +178,7 @@ function hashToIndex(hash: string): number {
|
|
|
172
178
|
for (let j = 0; j < HASH_LEN; j++) {
|
|
173
179
|
const charIdx = ALPH.indexOf(hash[j]!);
|
|
174
180
|
if (charIdx < 0) return -1;
|
|
175
|
-
idx =
|
|
181
|
+
idx = idx * ALPH.length + charIdx;
|
|
176
182
|
}
|
|
177
183
|
return idx;
|
|
178
184
|
}
|
|
@@ -283,7 +289,7 @@ function mapStableHashes(
|
|
|
283
289
|
for (let i = 0; i < newLines.length; i++) {
|
|
284
290
|
if (newHashes[i]) continue;
|
|
285
291
|
const c = canon(newLines[i]!);
|
|
286
|
-
const baseIdx = xxh32(c) >>> 14;
|
|
292
|
+
const baseIdx = (xxh32(c) >>> 14) % HASH_SPACE;
|
|
287
293
|
newHashes[i] = assignHash(used, baseIdx, hint);
|
|
288
294
|
}
|
|
289
295
|
|
package/src/hashline/index.ts
CHANGED
|
@@ -7,7 +7,6 @@ export {
|
|
|
7
7
|
MAX_HASH_LINES,
|
|
8
8
|
HL_PREFIX_PLUS_RE,
|
|
9
9
|
HL_PREFIX_MINUS_RE,
|
|
10
|
-
DIFF_MINUS_RE,
|
|
11
10
|
HL_BARE_PREFIX_RE,
|
|
12
11
|
lineHashes,
|
|
13
12
|
_lineHashesPure,
|
|
@@ -31,7 +30,9 @@ export {
|
|
|
31
30
|
descEdit,
|
|
32
31
|
resEdits,
|
|
33
32
|
valEdits,
|
|
34
|
-
|
|
33
|
+
stripBarePrefixes,
|
|
34
|
+
stripDiffPrefixes,
|
|
35
|
+
swapReversedRanges,
|
|
35
36
|
fmtMismatch,
|
|
36
37
|
} from "./resolve";
|
|
37
38
|
export {
|
package/src/hashline/parse.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
|
-
HL_PREFIX_PLUS_RE,
|
|
5
|
-
HL_PREFIX_MINUS_RE,
|
|
6
|
-
DIFF_MINUS_RE,
|
|
7
4
|
} from "./hash";
|
|
8
5
|
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
9
|
-
import { clipLine } from "../utils";
|
|
10
6
|
|
|
11
7
|
export type Anchor = { hash: string };
|
|
12
8
|
|
|
@@ -14,7 +10,7 @@ function diagRef(ref: string): string {
|
|
|
14
10
|
const trimmed = ref.trim();
|
|
15
11
|
|
|
16
12
|
if (!trimmed.length) {
|
|
17
|
-
return `[E_BAD_REF] Invalid anchor. Expected a 3-char
|
|
13
|
+
return `[E_BAD_REF] Invalid anchor. Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
|
|
18
14
|
}
|
|
19
15
|
|
|
20
16
|
if (/^\d+/.test(trimmed)) {
|
|
@@ -25,7 +21,7 @@ function diagRef(ref: string): string {
|
|
|
25
21
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_range_inclusive must contain the 3-char hash only — remove everything from "│" onward.`;
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char
|
|
24
|
+
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
function parseRef(ref: string): Anchor {
|
|
@@ -43,26 +39,12 @@ function parseRef(ref: string): Anchor {
|
|
|
43
39
|
|
|
44
40
|
export const parseHashRef = parseRef;
|
|
45
41
|
|
|
46
|
-
function assertNoPrefixes(lines: string[]): void {
|
|
47
|
-
for (const line of lines) {
|
|
48
|
-
if (!line.length) continue;
|
|
49
|
-
if (
|
|
50
|
-
HL_PREFIX_PLUS_RE.test(line) ||
|
|
51
|
-
HL_PREFIX_MINUS_RE.test(line) ||
|
|
52
|
-
DIFF_MINUS_RE.test(line)
|
|
53
|
-
) {
|
|
54
|
-
throw new Error(
|
|
55
|
-
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(clipLine(line))}. Use literal file content only — plain + or - lines are written literally.`
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
42
|
export function parseText(edit: string[] | string | null): string[] {
|
|
62
|
-
if (edit === null)
|
|
43
|
+
if (edit === null) {
|
|
44
|
+
throw new Error('[E_BAD_SHAPE] "content_lines" must be a string array; use [] to delete a range.');
|
|
45
|
+
}
|
|
63
46
|
if (typeof edit === "string") {
|
|
64
47
|
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
65
48
|
}
|
|
66
|
-
assertNoPrefixes(edit);
|
|
67
49
|
return edit;
|
|
68
50
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
|
|
2
|
-
import { HL_BARE_PREFIX_RE } from "./hash";
|
|
2
|
+
import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
4
|
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
5
5
|
|
|
@@ -19,6 +19,7 @@ interface HMismatch {
|
|
|
19
19
|
ref: Anchor;
|
|
20
20
|
kind: "not_found" | "ambiguous";
|
|
21
21
|
candidates?: number[];
|
|
22
|
+
context?: RAnchor;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface BDupWarn {
|
|
@@ -95,6 +96,18 @@ export function fmtMismatch(
|
|
|
95
96
|
out.push(
|
|
96
97
|
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
|
|
97
98
|
);
|
|
99
|
+
for (const m of notFound) {
|
|
100
|
+
const ctx = m.context;
|
|
101
|
+
if (!ctx) continue;
|
|
102
|
+
const from = Math.max(1, ctx.line - 1);
|
|
103
|
+
const to = Math.min(fileLines.length, ctx.line + 1);
|
|
104
|
+
const rows: string[] = [];
|
|
105
|
+
for (let ln = from; ln <= to; ln++) {
|
|
106
|
+
rows.push(` ${ln}: ${fileHashes[ln - 1]}│${clipLine(fileLines[ln - 1] ?? "")}`);
|
|
107
|
+
}
|
|
108
|
+
out.push("");
|
|
109
|
+
out.push(` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`);
|
|
110
|
+
}
|
|
98
111
|
}
|
|
99
112
|
if (ambiguous.length > 0) {
|
|
100
113
|
if (out.length > 0) out.push("");
|
|
@@ -200,39 +213,98 @@ function warnUnicodeEsc(
|
|
|
200
213
|
}
|
|
201
214
|
}
|
|
202
215
|
|
|
203
|
-
export function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
):
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
216
|
+
export function stripBarePrefixes(
|
|
217
|
+
edits: HEdit[],
|
|
218
|
+
fileHashes: string[],
|
|
219
|
+
warnings: string[],
|
|
220
|
+
): HEdit[] {
|
|
221
|
+
const fileHashSet = new Set(fileHashes);
|
|
222
|
+
let changed = false;
|
|
223
|
+
const corrected = edits.map((edit, editIndex) => {
|
|
224
|
+
const stripped: { lineIndex: number; matched: boolean }[] = [];
|
|
225
|
+
const contentLines = edit.content_lines.map((line, lineIndex) => {
|
|
226
|
+
const match = line.match(HL_BARE_PREFIX_RE);
|
|
227
|
+
if (!match) return line;
|
|
228
|
+
stripped.push({ lineIndex, matched: fileHashSet.has(match[1]!) });
|
|
229
|
+
return line.slice(match[0].length);
|
|
230
|
+
});
|
|
231
|
+
if (stripped.length === 0) return edit;
|
|
232
|
+
changed = true;
|
|
233
|
+
const locations = stripped
|
|
234
|
+
.map((s) => `content_lines[${s.lineIndex}]`)
|
|
235
|
+
.join(", ");
|
|
236
|
+
const matchedCount = stripped.filter((s) => s.matched).length;
|
|
237
|
+
const evidence =
|
|
238
|
+
matchedCount === 0
|
|
239
|
+
? "none of the stripped hashes match current file lines"
|
|
240
|
+
: `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
|
|
241
|
+
warnings.push(
|
|
242
|
+
`Autocorrected edit ${editIndex}: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
|
|
243
|
+
);
|
|
244
|
+
return { ...edit, content_lines: contentLines };
|
|
245
|
+
});
|
|
246
|
+
return changed ? corrected : edits;
|
|
247
|
+
}
|
|
227
248
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
249
|
+
export function stripDiffPrefixes(
|
|
250
|
+
edits: HEdit[],
|
|
251
|
+
warnings: string[],
|
|
252
|
+
): HEdit[] {
|
|
253
|
+
let changed = false;
|
|
254
|
+
const corrected = edits.map((edit, editIndex) => {
|
|
255
|
+
const stripped: number[] = [];
|
|
256
|
+
const contentLines = edit.content_lines.map((line, lineIndex) => {
|
|
257
|
+
const plus = line.match(HL_PREFIX_PLUS_RE);
|
|
258
|
+
if (plus) {
|
|
259
|
+
stripped.push(lineIndex);
|
|
260
|
+
return line.slice(plus[0].length);
|
|
261
|
+
}
|
|
262
|
+
const minus = line.match(HL_PREFIX_MINUS_RE);
|
|
263
|
+
if (minus) {
|
|
264
|
+
stripped.push(lineIndex);
|
|
265
|
+
return line.slice(minus[0].length);
|
|
266
|
+
}
|
|
267
|
+
return line;
|
|
268
|
+
});
|
|
269
|
+
if (stripped.length === 0) return edit;
|
|
270
|
+
changed = true;
|
|
271
|
+
const locations = stripped.map((i) => `content_lines[${i}]`).join(", ");
|
|
272
|
+
warnings.push(
|
|
273
|
+
`Autocorrected edit ${editIndex}: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
274
|
+
);
|
|
275
|
+
return { ...edit, content_lines: contentLines };
|
|
276
|
+
});
|
|
277
|
+
return changed ? corrected : edits;
|
|
278
|
+
}
|
|
232
279
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
280
|
+
export function swapReversedRanges(
|
|
281
|
+
edits: HEdit[],
|
|
282
|
+
fileHashes: string[],
|
|
283
|
+
warnings: string[],
|
|
284
|
+
): HEdit[] {
|
|
285
|
+
const lineByHash = new Map<string, number>();
|
|
286
|
+
for (let i = 0; i < fileHashes.length; i++) {
|
|
287
|
+
lineByHash.set(fileHashes[i]!, i + 1);
|
|
288
|
+
}
|
|
289
|
+
let changed = false;
|
|
290
|
+
const corrected = edits.map((edit, editIndex) => {
|
|
291
|
+
const [startRef, endRef] = edit.hash_range_inclusive;
|
|
292
|
+
const startLine = lineByHash.get(startRef.hash);
|
|
293
|
+
const endLine = lineByHash.get(endRef.hash);
|
|
294
|
+
if (
|
|
295
|
+
startLine === undefined ||
|
|
296
|
+
endLine === undefined ||
|
|
297
|
+
startLine <= endLine
|
|
298
|
+
) {
|
|
299
|
+
return edit;
|
|
300
|
+
}
|
|
301
|
+
changed = true;
|
|
302
|
+
warnings.push(
|
|
303
|
+
`Autocorrected edit ${editIndex}: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
|
|
304
|
+
);
|
|
305
|
+
return { ...edit, hash_range_inclusive: [endRef, startRef] as [Anchor, Anchor] };
|
|
306
|
+
});
|
|
307
|
+
return changed ? corrected : edits;
|
|
236
308
|
}
|
|
237
309
|
|
|
238
310
|
export function descEdit(edit: RHEdit): string {
|
|
@@ -297,6 +369,13 @@ export function valEdits(
|
|
|
297
369
|
const startResolved = tryResolve(edit.hash_range_inclusive[0]);
|
|
298
370
|
const endResolved = tryResolve(edit.hash_range_inclusive[1]);
|
|
299
371
|
if (!startResolved || !endResolved) {
|
|
372
|
+
if (!startResolved && endResolved) {
|
|
373
|
+
const startMismatch = mismatches.findLast((m) => m.ref === edit.hash_range_inclusive[0]);
|
|
374
|
+
if (startMismatch && startMismatch.kind === "not_found") startMismatch.context = endResolved;
|
|
375
|
+
} else if (startResolved && !endResolved) {
|
|
376
|
+
const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_range_inclusive[1]);
|
|
377
|
+
if (endMismatch && endMismatch.kind === "not_found") endMismatch.context = startResolved;
|
|
378
|
+
}
|
|
300
379
|
continue;
|
|
301
380
|
}
|
|
302
381
|
if (startResolved.line > endResolved.line) {
|
package/src/read.ts
CHANGED
|
@@ -30,7 +30,7 @@ function normPosInt(
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
if (!Number.isInteger(value) || value < 1) {
|
|
33
|
-
throw new Error(`Read request field "${name}" must be a positive integer.`);
|
|
33
|
+
throw new Error(`[E_BAD_SHAPE] Read request field "${name}" must be a positive integer.`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
return value;
|
package/src/replace-diff.ts
CHANGED
|
@@ -45,7 +45,6 @@ export function genDiff(
|
|
|
45
45
|
newContent: string,
|
|
46
46
|
contextLines = 2,
|
|
47
47
|
newContentHashes?: string[],
|
|
48
|
-
_oldHashes?: string[],
|
|
49
48
|
): { diff: string; firstChangedLine: number | undefined } {
|
|
50
49
|
const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
|
|
51
50
|
|
package/src/replace-normalize.ts
CHANGED
|
@@ -78,7 +78,7 @@ export function normReq(input: unknown): unknown {
|
|
|
78
78
|
const hri = record.hash_range_inclusive;
|
|
79
79
|
const cl = record.content_lines;
|
|
80
80
|
if (Array.isArray(hri) && Array.isArray(cl)) {
|
|
81
|
-
record.changes = [{
|
|
81
|
+
record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
|
|
82
82
|
delete record.hash_range_inclusive;
|
|
83
83
|
delete record.content_lines;
|
|
84
84
|
}
|
package/src/replace-response.ts
CHANGED
|
@@ -129,10 +129,10 @@ export function buildNoop(input: NoopInput): TResult {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
export function buildChanged(input: SuccessInput): TResult {
|
|
132
|
-
const { path, result, warnings, snapshotId, originalNormalized,
|
|
132
|
+
const { path, result, warnings, snapshotId, originalNormalized, editMeta, resultHashes } = input;
|
|
133
133
|
|
|
134
134
|
const resultLines = visLines(result);
|
|
135
|
-
const diffResult = genDiff(originalNormalized, result, 2, resultHashes
|
|
135
|
+
const diffResult = genDiff(originalNormalized, result, 2, resultHashes);
|
|
136
136
|
const addedLines = editMeta.addedLines;
|
|
137
137
|
const removedLines = editMeta.removedLines;
|
|
138
138
|
const warningsBlock = warnBlock(warnings);
|
package/src/replace-undo.ts
CHANGED
|
@@ -85,8 +85,12 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
85
85
|
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
86
86
|
);
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
try {
|
|
89
|
+
const store = await loadHashStore();
|
|
90
|
+
upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error("Failed to restore hash store snapshot after undo:", error);
|
|
93
|
+
}
|
|
90
94
|
|
|
91
95
|
clearUndo(mutationTargetPath);
|
|
92
96
|
|
package/src/replace.ts
CHANGED
|
@@ -59,25 +59,25 @@ const hashRangeInclSchema = Type.Array(
|
|
|
59
59
|
|
|
60
60
|
const changeItemSchema = Type.Object(
|
|
61
61
|
{
|
|
62
|
-
content_lines: contentLinesSchema,
|
|
63
62
|
hash_range_inclusive: hashRangeInclSchema,
|
|
63
|
+
content_lines: contentLinesSchema,
|
|
64
64
|
},
|
|
65
65
|
{ additionalProperties: false },
|
|
66
66
|
);
|
|
67
67
|
|
|
68
68
|
export const editToolSchema = Type.Object(
|
|
69
69
|
{
|
|
70
|
-
changes: Type.Array(changeItemSchema, { description: "Array of edits applied atomically against the same pre-edit snapshot." }),
|
|
71
70
|
path: Type.String({ description: "Path to edit" }),
|
|
71
|
+
changes: Type.Array(changeItemSchema, { description: "Array of edits applied atomically against the same pre-edit snapshot." }),
|
|
72
72
|
},
|
|
73
73
|
{ additionalProperties: false },
|
|
74
74
|
);
|
|
75
75
|
|
|
76
76
|
export const flatEditToolSchema = Type.Object(
|
|
77
77
|
{
|
|
78
|
-
content_lines: contentLinesSchema,
|
|
79
|
-
hash_range_inclusive: hashRangeInclSchema,
|
|
80
78
|
path: Type.String({ description: "Path to edit" }),
|
|
79
|
+
hash_range_inclusive: hashRangeInclSchema,
|
|
80
|
+
content_lines: contentLinesSchema,
|
|
81
81
|
},
|
|
82
82
|
{ additionalProperties: false },
|
|
83
83
|
);
|
|
@@ -123,7 +123,7 @@ export function assertNoLegacyKeys(request: unknown): void {
|
|
|
123
123
|
for (const legacyKey of LEGACY_KS) {
|
|
124
124
|
if (has(request, legacyKey)) {
|
|
125
125
|
throw new Error(
|
|
126
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {
|
|
126
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}.`
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
@@ -148,10 +148,10 @@ export function assertReq(
|
|
|
148
148
|
if (!Array.isArray(request.changes)) {
|
|
149
149
|
if (flat) {
|
|
150
150
|
throw new Error(
|
|
151
|
-
'[E_BAD_SHAPE] Edit request requires both "
|
|
151
|
+
'[E_BAD_SHAPE] Edit request requires both "hash_range_inclusive" and "content_lines" at the top level.',
|
|
152
152
|
);
|
|
153
153
|
}
|
|
154
|
-
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is {
|
|
154
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { hash_range_inclusive: ["<START>", "<END>"], content_lines: [...] }.');
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
@@ -175,7 +175,9 @@ function collectRemovedHashes(
|
|
|
175
175
|
const startLine = originalHashes.indexOf(startHash);
|
|
176
176
|
const endLine = originalHashes.indexOf(endHash);
|
|
177
177
|
if (startLine >= 0 && endLine >= 0) {
|
|
178
|
-
|
|
178
|
+
const firstLine = Math.min(startLine, endLine);
|
|
179
|
+
const lastLine = Math.max(startLine, endLine);
|
|
180
|
+
for (let i = firstLine; i <= lastLine; i++) {
|
|
179
181
|
removedHashes.add(originalHashes[i]!);
|
|
180
182
|
}
|
|
181
183
|
}
|
|
@@ -197,7 +199,7 @@ function countLineChanges(
|
|
|
197
199
|
const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
|
|
198
200
|
const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
|
|
199
201
|
if (startLine >= 0 && endLine >= 0) {
|
|
200
|
-
totalRemovedLines += endLine - startLine + 1;
|
|
202
|
+
totalRemovedLines += Math.abs(endLine - startLine) + 1;
|
|
201
203
|
}
|
|
202
204
|
totalAddedLines += edit.content_lines.length;
|
|
203
205
|
}
|
|
@@ -283,11 +285,11 @@ export async function compPreview(
|
|
|
283
285
|
const normalized = normReq(request);
|
|
284
286
|
if (flat && isRec(request) && Array.isArray(request.changes)) {
|
|
285
287
|
return {
|
|
286
|
-
error: `[E_BAD_SHAPE] Flat mode does not accept a "changes" array. Send
|
|
288
|
+
error: `[E_BAD_SHAPE] Flat mode does not accept a "changes" array. Send hash_range_inclusive and content_lines at the top level (one edit per call), or use bulk mode for multiple edits per call.`
|
|
287
289
|
};
|
|
288
290
|
}
|
|
289
291
|
assertReq(normalized, flat);
|
|
290
|
-
const { path, originalNormalized,
|
|
292
|
+
const { path, originalNormalized, result, resultHashes } = await execPipeline(
|
|
291
293
|
normalized,
|
|
292
294
|
cwd,
|
|
293
295
|
{ accessMode: constants.R_OK, noPersist: true },
|
|
@@ -299,7 +301,7 @@ export async function compPreview(
|
|
|
299
301
|
};
|
|
300
302
|
}
|
|
301
303
|
|
|
302
|
-
return { diff: genDiff(originalNormalized, result, 4, resultHashes
|
|
304
|
+
return { diff: genDiff(originalNormalized, result, 4, resultHashes).diff };
|
|
303
305
|
} catch (error: unknown) {
|
|
304
306
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
305
307
|
}
|
|
@@ -336,7 +338,7 @@ const MODE_CFG = {
|
|
|
336
338
|
},
|
|
337
339
|
} as const;
|
|
338
340
|
|
|
339
|
-
export function buildToolDef(opts: { flat: boolean
|
|
341
|
+
export function buildToolDef(opts: { flat: boolean }): ToolDef {
|
|
340
342
|
const cfg = MODE_CFG[opts.flat ? "flat" : "bulk"];
|
|
341
343
|
|
|
342
344
|
const E_DESC = loadP("../prompts/replace.md");
|
|
@@ -554,14 +556,14 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
554
556
|
};
|
|
555
557
|
}
|
|
556
558
|
|
|
557
|
-
export function regReplace(pi: ExtensionAPI
|
|
558
|
-
pi.registerTool(buildToolDef({ flat: false
|
|
559
|
+
export function regReplace(pi: ExtensionAPI): void {
|
|
560
|
+
pi.registerTool(buildToolDef({ flat: false }));
|
|
559
561
|
}
|
|
560
562
|
|
|
561
|
-
export function buildToolDefFlat(
|
|
562
|
-
return buildToolDef({ flat: true
|
|
563
|
+
export function buildToolDefFlat() {
|
|
564
|
+
return buildToolDef({ flat: true });
|
|
563
565
|
}
|
|
564
566
|
|
|
565
|
-
export function regReplaceFlat(pi: ExtensionAPI
|
|
566
|
-
pi.registerTool(buildToolDef({ flat: true
|
|
567
|
+
export function regReplaceFlat(pi: ExtensionAPI): void {
|
|
568
|
+
pi.registerTool(buildToolDef({ flat: true }));
|
|
567
569
|
}
|