@levnikolaevich/hex-line-mcp 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -26
- package/hook.mjs +32 -29
- package/lib/bulk-replace.mjs +8 -6
- package/lib/changes.mjs +7 -6
- package/lib/coerce.mjs +0 -6
- package/lib/edit.mjs +119 -41
- package/lib/format.mjs +138 -0
- package/lib/info.mjs +13 -31
- package/lib/read.mjs +5 -24
- package/lib/search.mjs +213 -77
- package/lib/security.mjs +6 -8
- package/lib/setup.mjs +0 -4
- package/lib/tree.mjs +69 -97
- package/lib/verify.mjs +2 -2
- package/package.json +4 -2
- package/server.mjs +31 -16
package/README.md
CHANGED
|
@@ -18,10 +18,10 @@ Every line carries an FNV-1a content hash. Every edit must present those hashes
|
|
|
18
18
|
| `read_file` | Read file with hash-annotated lines and range checksums | Partial reads via `offset`/`limit` |
|
|
19
19
|
| `edit_file` | Hash-verified edits with anchor or text replacement | Returns compact diff via `diff` package |
|
|
20
20
|
| `write_file` | Create new file or overwrite, auto-creates parent dirs | Path validation, no hash overhead |
|
|
21
|
-
| `grep_search` | Search with ripgrep,
|
|
21
|
+
| `grep_search` | Search with ripgrep, 3 output modes, per-group checksums | Edit-ready: grep -> edit directly with checksums |
|
|
22
22
|
| `outline` | AST-based structural overview via tree-sitter WASM | 95% token reduction (10 lines instead of 500) |
|
|
23
23
|
| `verify` | Check if held range checksums are still valid | Single-line response avoids full re-read |
|
|
24
|
-
| `directory_tree` | Compact directory tree with .gitignore support | Skips node_modules/.git, shows file sizes |
|
|
24
|
+
| `directory_tree` | Compact directory tree with root .gitignore support | Skips node_modules/.git, shows file sizes |
|
|
25
25
|
| `get_file_info` | File metadata without reading content | Size, lines, mtime, type, binary detection |
|
|
26
26
|
| `setup_hooks` | Configure PreToolUse + PostToolUse hooks for Claude/Gemini/Codex | One call sets up everything, idempotent |
|
|
27
27
|
| `changes` | Compare file against git ref, shows added/removed/modified symbols | AST-level semantic diff |
|
|
@@ -124,7 +124,7 @@ checksum: 1-50:f7e2a1b0
|
|
|
124
124
|
|
|
125
125
|
### edit_file
|
|
126
126
|
|
|
127
|
-
Edit using hash-verified anchors or text replacement. Returns
|
|
127
|
+
Edit using hash-verified anchors or text replacement. Returns diff + post-edit checksums for chaining edits.
|
|
128
128
|
|
|
129
129
|
| Parameter | Type | Required | Description |
|
|
130
130
|
|-----------|------|----------|-------------|
|
|
@@ -140,7 +140,8 @@ Edit operations (JSON array):
|
|
|
140
140
|
{"set_line": {"anchor": "ab.12", "new_text": "replacement line"}},
|
|
141
141
|
{"replace_lines": {"start_anchor": "ab.10", "end_anchor": "cd.15", "new_text": "..."}},
|
|
142
142
|
{"insert_after": {"anchor": "ab.20", "text": "inserted line"}},
|
|
143
|
-
{"replace": {"old_text": "
|
|
143
|
+
{"replace": {"old_text": "unique text", "new_text": "replacement"}},
|
|
144
|
+
{"replace": {"old_text": "find all", "new_text": "replace all", "all": true}}
|
|
144
145
|
]
|
|
145
146
|
```
|
|
146
147
|
|
|
@@ -155,20 +156,28 @@ Create a new file or overwrite an existing one. Creates parent directories autom
|
|
|
155
156
|
|
|
156
157
|
### grep_search
|
|
157
158
|
|
|
158
|
-
Search file contents using ripgrep
|
|
159
|
+
Search file contents using ripgrep. Three output modes: `content` (hash-annotated with checksums), `files` (paths only), `count` (match counts).
|
|
159
160
|
|
|
160
161
|
| Parameter | Type | Required | Description |
|
|
161
162
|
|-----------|------|----------|-------------|
|
|
162
|
-
| `pattern` | string | yes |
|
|
163
|
+
| `pattern` | string | yes | Search pattern (regex by default, literal if `literal:true`) |
|
|
163
164
|
| `path` | string | no | Directory or file to search (default: cwd) |
|
|
164
165
|
| `glob` | string | no | Glob filter, e.g. `"*.ts"` |
|
|
165
166
|
| `type` | string | no | File type filter, e.g. `"js"`, `"py"` |
|
|
167
|
+
| `output` | enum | no | Output format: `"content"` (default), `"files"`, `"count"` |
|
|
166
168
|
| `case_insensitive` | boolean | no | Ignore case |
|
|
167
|
-
| `smart_case` | boolean | no |
|
|
168
|
-
| `
|
|
169
|
+
| `smart_case` | boolean | no | CI when lowercase, CS when uppercase (`-S`) |
|
|
170
|
+
| `literal` | boolean | no | Literal string search, no regex (`-F`) |
|
|
171
|
+
| `multiline` | boolean | no | Pattern can span multiple lines (`-U`) |
|
|
172
|
+
| `context` | number | no | Symmetric context lines around matches (`-C`) |
|
|
173
|
+
| `context_before` | number | no | Context lines BEFORE match (`-B`) |
|
|
174
|
+
| `context_after` | number | no | Context lines AFTER match (`-A`) |
|
|
169
175
|
| `limit` | number | no | Max matches per file (default: 100) |
|
|
176
|
+
| `total_limit` | number | no | Total match events across all files; multiline matches count as 1 (0 = unlimited) |
|
|
170
177
|
| `plain` | boolean | no | Omit hash tags, return `file:line:content` |
|
|
171
178
|
|
|
179
|
+
**Content mode** returns per-group checksums enabling direct `replace_lines` from grep results without intermediate `read_file`.
|
|
180
|
+
|
|
172
181
|
### outline
|
|
173
182
|
|
|
174
183
|
AST-based structural outline: functions, classes, interfaces with line ranges.
|
|
@@ -194,7 +203,7 @@ Returns a single-line confirmation or lists changed ranges.
|
|
|
194
203
|
|
|
195
204
|
### directory_tree
|
|
196
205
|
|
|
197
|
-
Compact directory tree with .gitignore support
|
|
206
|
+
Compact directory tree with root .gitignore support (path-based rules, negation, dir-only). Nested .gitignore files are not loaded.
|
|
198
207
|
|
|
199
208
|
| Parameter | Type | Required | Description |
|
|
200
209
|
|-----------|------|----------|-------------|
|
|
@@ -202,7 +211,7 @@ Compact directory tree with .gitignore support and file sizes.
|
|
|
202
211
|
| `pattern` | string | no | Glob filter on names (e.g. `"*-mcp"`, `"*.mjs"`). Returns flat match list instead of tree |
|
|
203
212
|
| `type` | string | no | `"file"`, `"dir"`, or `"all"` (default). Like `find -type f/d` |
|
|
204
213
|
| `max_depth` | number | no | Max recursion depth (default: 3, or 20 in pattern mode) |
|
|
205
|
-
| `gitignore` | boolean | no | Respect .gitignore patterns (default: true) |
|
|
214
|
+
| `gitignore` | boolean | no | Respect root .gitignore patterns (default: true). Nested .gitignore not supported |
|
|
206
215
|
| `format` | string | no | `"compact"` = names only, no sizes, depth 1. `"full"` = default with sizes |
|
|
207
216
|
|
|
208
217
|
Skips `node_modules`, `.git`, `dist`, `build`, `__pycache__`, `.next`, `coverage` by default.
|
|
@@ -219,39 +228,43 @@ Returns: size, line count, modification time (absolute + relative), file type, b
|
|
|
219
228
|
|
|
220
229
|
## Hook
|
|
221
230
|
|
|
222
|
-
The unified
|
|
231
|
+
The unified hook (`hook.mjs`) handles four events:
|
|
232
|
+
|
|
233
|
+
### PreToolUse: Tool Redirect
|
|
223
234
|
|
|
224
|
-
|
|
235
|
+
Blocks built-in `Read`, `Edit`, `Write`, `Grep` on text files and redirects to hex-line equivalents. Binary files (images, PDFs, notebooks, archives, executables, fonts, media) are excluded.
|
|
225
236
|
|
|
226
|
-
|
|
237
|
+
### PreToolUse: Bash Redirect + Dangerous Blocker
|
|
227
238
|
|
|
228
|
-
|
|
239
|
+
Intercepts simple Bash commands (`cat`, `head`, `tail`, `ls`, `find`, `grep`, `sed -i`, etc.) and redirects to hex-line tools. Blocks dangerous commands (`rm -rf /`, `git push --force`, `git reset --hard`, `DROP TABLE`, `chmod 777`, `mkfs`, `dd`).
|
|
229
240
|
|
|
230
|
-
### RTK Output Filter
|
|
241
|
+
### PostToolUse: RTK Output Filter
|
|
231
242
|
|
|
232
243
|
Triggers on `Bash` tool output exceeding 50 lines. Pipeline:
|
|
233
244
|
|
|
234
245
|
1. **Detect command type** -- npm install, test, build, pip install, git verbose, or generic
|
|
235
|
-
2. **
|
|
236
|
-
3. **
|
|
237
|
-
4. **
|
|
238
|
-
5. **Truncate** -- keeps first 12 + last 12 lines, omits the middle
|
|
246
|
+
2. **Normalize** -- replaces UUIDs, timestamps, IPs, hex values, large numbers with placeholders
|
|
247
|
+
3. **Deduplicate** -- collapses identical normalized lines with `(xN)` counts
|
|
248
|
+
4. **Truncate** -- keeps first 15 + last 15 lines, omits the middle
|
|
239
249
|
|
|
240
250
|
Configuration constants in `hook.mjs`:
|
|
241
251
|
|
|
242
252
|
| Constant | Default | Purpose |
|
|
243
|
-
|
|
253
|
+
|----------|---------|--------|
|
|
244
254
|
| `LINE_THRESHOLD` | 50 | Minimum lines to trigger filtering |
|
|
245
|
-
| `
|
|
246
|
-
| `
|
|
247
|
-
|
|
255
|
+
| `HEAD_LINES` | 15 | Lines to keep from start |
|
|
256
|
+
| `TAIL_LINES` | 15 | Lines to keep from end |
|
|
257
|
+
|
|
258
|
+
### SessionStart: Tool Preferences
|
|
259
|
+
|
|
260
|
+
Injects hex-line tool preference list into agent context at session start.
|
|
248
261
|
|
|
249
262
|
## Architecture
|
|
250
263
|
|
|
251
264
|
```
|
|
252
265
|
hex-line-mcp/
|
|
253
|
-
server.mjs MCP server (stdio transport,
|
|
254
|
-
hook.mjs
|
|
266
|
+
server.mjs MCP server (stdio transport, 11 tools)
|
|
267
|
+
hook.mjs Unified hook (PreToolUse + PostToolUse + SessionStart)
|
|
255
268
|
package.json
|
|
256
269
|
lib/
|
|
257
270
|
hash.mjs FNV-1a hashing, 2-char tags, range checksums
|
|
@@ -260,6 +273,13 @@ hex-line-mcp/
|
|
|
260
273
|
search.mjs ripgrep wrapper with hash-annotated results
|
|
261
274
|
outline.mjs tree-sitter WASM AST outline
|
|
262
275
|
verify.mjs Range checksum verification
|
|
276
|
+
info.mjs File metadata (size, lines, mtime, type)
|
|
277
|
+
tree.mjs Directory tree with .gitignore support
|
|
278
|
+
changes.mjs Semantic git diff via AST
|
|
279
|
+
bulk-replace.mjs Multi-file search-and-replace
|
|
280
|
+
setup.mjs Hook installation for Claude/Gemini/Codex
|
|
281
|
+
format.mjs Output formatting utilities
|
|
282
|
+
coerce.mjs Parameter alias mapping
|
|
263
283
|
security.mjs Path validation, binary detection, size limits
|
|
264
284
|
normalize.mjs Output normalization, deduplication, truncation
|
|
265
285
|
```
|
|
@@ -318,7 +338,7 @@ Outline works on code files only (15+ languages via tree-sitter WASM). For markd
|
|
|
318
338
|
<details>
|
|
319
339
|
<summary><b>How does the RTK filter reduce tokens?</b></summary>
|
|
320
340
|
|
|
321
|
-
The PostToolUse hook normalizes Bash output (replaces UUIDs, timestamps, IPs with placeholders), deduplicates identical lines, and truncates to first
|
|
341
|
+
The PostToolUse hook normalizes Bash output (replaces UUIDs, timestamps, IPs with placeholders), deduplicates identical lines, and truncates to first 15 + last 15 lines. Average savings: 45% (flat) / 52% (weighted) across 18 benchmark scenarios.
|
|
322
342
|
|
|
323
343
|
</details>
|
|
324
344
|
|
package/hook.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Exit 2 = block (PreToolUse) or feedback via stderr (PostToolUse)
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import { normalizeOutput } from "./lib/normalize.mjs";
|
|
27
27
|
import { readFileSync } from "node:fs";
|
|
28
28
|
import { resolve } from "node:path";
|
|
29
29
|
import { homedir } from "node:os";
|
|
@@ -57,13 +57,13 @@ const TOOL_HINTS = {
|
|
|
57
57
|
Read: "mcp__hex-line__read_file (not Read). For writing: write_file (no prior Read needed)",
|
|
58
58
|
Edit: "mcp__hex-line__edit_file (not Edit, not sed -i). read_file first for hashes",
|
|
59
59
|
Write: "mcp__hex-line__write_file (not Write). No prior Read needed",
|
|
60
|
-
Grep: "mcp__hex-line__grep_search (not Grep). Params:
|
|
60
|
+
Grep: "mcp__hex-line__grep_search (not Grep). Params: output, literal, context_before, context_after, multiline",
|
|
61
61
|
cat: "mcp__hex-line__read_file (not cat/head/tail/less/more)",
|
|
62
62
|
head: "mcp__hex-line__read_file with limit param (not head)",
|
|
63
63
|
tail: "mcp__hex-line__read_file with offset param (not tail)",
|
|
64
64
|
ls: "mcp__hex-line__directory_tree with pattern param (not ls/find/tree). E.g. pattern='*-mcp' type='dir'",
|
|
65
65
|
stat: "mcp__hex-line__get_file_info (not stat/wc/file)",
|
|
66
|
-
grep: "mcp__hex-line__grep_search (not grep/rg). Params:
|
|
66
|
+
grep: "mcp__hex-line__grep_search (not grep/rg). Params: output, literal, context_before, context_after, multiline",
|
|
67
67
|
sed: "mcp__hex-line__edit_file (not sed -i). read_file first for hashes",
|
|
68
68
|
diff: "mcp__hex-line__changes (not diff). Git-based semantic diff",
|
|
69
69
|
outline: "mcp__hex-line__outline (before reading large code files)",
|
|
@@ -335,10 +335,8 @@ function handlePostToolUse(data) {
|
|
|
335
335
|
|
|
336
336
|
const type = detectCommandType(command);
|
|
337
337
|
|
|
338
|
-
// Pipeline: deduplicate -> smart truncate
|
|
339
|
-
const
|
|
340
|
-
const dedupedText = deduped.join("\n");
|
|
341
|
-
const filtered = smartTruncate(dedupedText, HEAD_LINES, TAIL_LINES);
|
|
338
|
+
// Pipeline: normalize -> deduplicate -> smart truncate
|
|
339
|
+
const filtered = normalizeOutput(lines.join("\n"), { headLines: HEAD_LINES, tailLines: TAIL_LINES });
|
|
342
340
|
const filteredCount = filtered.split("\n").length;
|
|
343
341
|
|
|
344
342
|
const header = `RTK FILTERED: ${type} (${originalCount} lines -> ${filteredCount} lines)`;
|
|
@@ -400,31 +398,36 @@ function handleSessionStart() {
|
|
|
400
398
|
}
|
|
401
399
|
|
|
402
400
|
// ---- Main: read stdin, route by hook_event_name ----
|
|
401
|
+
// Guard: only run when executed directly, not when imported for testing
|
|
403
402
|
|
|
404
|
-
|
|
405
|
-
process.stdin.on("data", (chunk) => {
|
|
406
|
-
input += chunk;
|
|
407
|
-
});
|
|
408
|
-
process.stdin.on("end", () => {
|
|
409
|
-
try {
|
|
410
|
-
const data = JSON.parse(input);
|
|
411
|
-
const event = data.hook_event_name || "";
|
|
403
|
+
import { fileURLToPath } from "node:url";
|
|
412
404
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
405
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
406
|
+
let input = "";
|
|
407
|
+
process.stdin.on("data", (chunk) => {
|
|
408
|
+
input += chunk;
|
|
409
|
+
});
|
|
410
|
+
process.stdin.on("end", () => {
|
|
411
|
+
try {
|
|
412
|
+
const data = JSON.parse(input);
|
|
413
|
+
const event = data.hook_event_name || "";
|
|
418
414
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
415
|
+
if (isHexLineDisabled()) {
|
|
416
|
+
// REVERSE MODE: block hex-line calls, approve everything else
|
|
417
|
+
if (event === "PreToolUse") handlePreToolUseReverse(data);
|
|
418
|
+
process.exit(0); // SessionStart, PostToolUse — silent exit
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// NORMAL MODE
|
|
422
|
+
if (event === "SessionStart") handleSessionStart();
|
|
423
|
+
else if (event === "PreToolUse") handlePreToolUse(data);
|
|
424
|
+
else if (event === "PostToolUse") handlePostToolUse(data);
|
|
425
|
+
else process.exit(0);
|
|
426
|
+
} catch {
|
|
427
|
+
process.exit(0);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
428
431
|
|
|
429
432
|
// ---- Exports for testing ----
|
|
430
433
|
export { isHexLineDisabled, _resetHexLineDisabledCache };
|
package/lib/bulk-replace.mjs
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { simpleDiff } from "./edit.mjs";
|
|
5
|
+
import { normalizePath } from "./security.mjs";
|
|
6
|
+
import { readText, MAX_OUTPUT_CHARS } from "./format.mjs";
|
|
5
7
|
|
|
6
8
|
export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
|
|
7
9
|
const { dryRun = false, maxFiles = 100 } = opts;
|
|
8
|
-
const abs = resolve(rootDir);
|
|
10
|
+
const abs = resolve(normalizePath(rootDir));
|
|
9
11
|
|
|
10
12
|
// Find files via ripgrep (respects .gitignore)
|
|
11
13
|
let files;
|
|
12
14
|
try {
|
|
13
|
-
const rgOut =
|
|
15
|
+
const rgOut = execFileSync("rg", ["--files", "-g", globPattern, abs], { encoding: "utf-8", timeout: 10000 });
|
|
14
16
|
files = rgOut.trim().split("\n").filter(Boolean);
|
|
15
17
|
} catch (e) {
|
|
16
18
|
if (e.status === 1) return "No files matched the glob pattern.";
|
|
@@ -23,12 +25,12 @@ export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
|
|
|
23
25
|
|
|
24
26
|
const results = [];
|
|
25
27
|
let changed = 0, skipped = 0, errors = 0;
|
|
26
|
-
const MAX_OUTPUT =
|
|
28
|
+
const MAX_OUTPUT = MAX_OUTPUT_CHARS;
|
|
27
29
|
let totalChars = 0;
|
|
28
30
|
|
|
29
31
|
for (const file of files) {
|
|
30
32
|
try {
|
|
31
|
-
const original =
|
|
33
|
+
const original = readText(file);
|
|
32
34
|
let content = original;
|
|
33
35
|
|
|
34
36
|
for (const { old: oldText, new: newText } of replacements) {
|
package/lib/changes.mjs
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* without temp files.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
11
|
import { extname } from "node:path";
|
|
12
12
|
import { validatePath } from "./security.mjs";
|
|
13
|
+
import { readText } from "./format.mjs";
|
|
13
14
|
import { outlineFromContent } from "./outline.mjs";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -50,7 +51,7 @@ function toSymbolMap(entries) {
|
|
|
50
51
|
* Get relative path from git root for `git show`.
|
|
51
52
|
*/
|
|
52
53
|
function gitRelativePath(absPath) {
|
|
53
|
-
const root =
|
|
54
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
54
55
|
cwd: absPath.replace(/[/\\][^/\\]+$/, ""),
|
|
55
56
|
encoding: "utf-8",
|
|
56
57
|
timeout: 5000,
|
|
@@ -79,7 +80,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
|
|
|
79
80
|
// Directory: return git diff --stat (compact file list, no content reads)
|
|
80
81
|
if (statSync(real).isDirectory()) {
|
|
81
82
|
try {
|
|
82
|
-
const stat =
|
|
83
|
+
const stat = execFileSync("git", ["diff", "--stat", compareAgainst, "--", "."], {
|
|
83
84
|
cwd: real,
|
|
84
85
|
encoding: "utf-8",
|
|
85
86
|
timeout: 10000,
|
|
@@ -94,7 +95,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
|
|
|
94
95
|
const ext = extname(real).toLowerCase();
|
|
95
96
|
|
|
96
97
|
// Check if outline supports this extension
|
|
97
|
-
const currentContent =
|
|
98
|
+
const currentContent = readText(real);
|
|
98
99
|
const currentResult = await outlineFromContent(currentContent, ext);
|
|
99
100
|
if (!currentResult) {
|
|
100
101
|
return `Cannot outline ${ext} files. Supported: .js .mjs .ts .py .go .rs .java .c .cpp .cs .rb .php .kt .swift .sh .bash`;
|
|
@@ -104,7 +105,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
|
|
|
104
105
|
const relPath = gitRelativePath(real);
|
|
105
106
|
let gitContent;
|
|
106
107
|
try {
|
|
107
|
-
gitContent =
|
|
108
|
+
gitContent = execFileSync("git", ["show", `${compareAgainst}:${relPath}`], {
|
|
108
109
|
cwd: real.replace(/[/\\][^/\\]+$/, ""),
|
|
109
110
|
encoding: "utf-8",
|
|
110
111
|
timeout: 5000,
|
package/lib/coerce.mjs
CHANGED
|
@@ -12,12 +12,6 @@ const ALIASES = {
|
|
|
12
12
|
// grep_search
|
|
13
13
|
query: "pattern",
|
|
14
14
|
search: "pattern",
|
|
15
|
-
max_results: "limit",
|
|
16
|
-
maxResults: "limit",
|
|
17
|
-
maxMatches: "limit",
|
|
18
|
-
max_matches: "limit",
|
|
19
|
-
contextLines: "context",
|
|
20
|
-
context_lines: "context",
|
|
21
15
|
ignoreCase: "case_insensitive",
|
|
22
16
|
ignore_case: "case_insensitive",
|
|
23
17
|
|
package/lib/edit.mjs
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* - dry_run preview, noop detection, diff output
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { writeFileSync } from "node:fs";
|
|
12
12
|
import { diffLines } from "diff";
|
|
13
|
-
import { fnv1a, lineTag, rangeChecksum, parseChecksum } from "./hash.mjs";
|
|
13
|
+
import { fnv1a, lineTag, rangeChecksum, parseChecksum, parseRef } from "./hash.mjs";
|
|
14
14
|
import { validatePath } from "./security.mjs";
|
|
15
15
|
import { getGraphDB, blastRadius, getRelativePath } from "./graph-enrich.mjs";
|
|
16
|
+
import { readText } from "./format.mjs";
|
|
16
17
|
|
|
17
18
|
// Unicode characters visually similar to ASCII hyphen-minus (U+002D)
|
|
18
19
|
const CONFUSABLE_HYPHENS = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
|
|
@@ -40,6 +41,24 @@ function restoreIndent(origLines, newLines) {
|
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Build hash-annotated snippet around a position for error messages.
|
|
46
|
+
* @param {string[]} lines - file lines
|
|
47
|
+
* @param {number} centerIdx - 0-based center index
|
|
48
|
+
* @param {number} radius - lines before/after center (default 5)
|
|
49
|
+
* @returns {{ start: number, end: number, text: string }}
|
|
50
|
+
*/
|
|
51
|
+
function buildErrorSnippet(lines, centerIdx, radius = 5) {
|
|
52
|
+
const start = Math.max(0, centerIdx - radius);
|
|
53
|
+
const end = Math.min(lines.length, centerIdx + radius + 1);
|
|
54
|
+
const text = lines.slice(start, end).map((line, i) => {
|
|
55
|
+
const num = start + i + 1;
|
|
56
|
+
const tag = lineTag(fnv1a(line));
|
|
57
|
+
return `${tag}.${num}\t${line}`;
|
|
58
|
+
}).join("\n");
|
|
59
|
+
return { start: start + 1, end, text };
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
/**
|
|
44
63
|
* Build a hash index of all lines, keeping only unique tags.
|
|
45
64
|
* 2-char tags have collisions — duplicates are excluded to avoid wrong relocations.
|
|
@@ -65,21 +84,11 @@ function buildHashIndex(lines) {
|
|
|
65
84
|
function findLine(lines, lineNum, expectedTag, hashIndex) {
|
|
66
85
|
const idx = lineNum - 1;
|
|
67
86
|
if (idx < 0 || idx >= lines.length) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
: 0;
|
|
71
|
-
const end = idx >= lines.length
|
|
72
|
-
? lines.length
|
|
73
|
-
: Math.min(lines.length, 10);
|
|
74
|
-
const snippet = lines.slice(start, end).map((line, i) => {
|
|
75
|
-
const num = start + i + 1;
|
|
76
|
-
const tag = lineTag(fnv1a(line));
|
|
77
|
-
return `${tag}.${num}\t${line}`;
|
|
78
|
-
}).join("\n");
|
|
79
|
-
|
|
87
|
+
const center = idx >= lines.length ? lines.length - 1 : 0;
|
|
88
|
+
const snip = buildErrorSnippet(lines, center);
|
|
80
89
|
throw new Error(
|
|
81
90
|
`Line ${lineNum} out of range (1-${lines.length}).\n\n` +
|
|
82
|
-
`Current content (lines ${start
|
|
91
|
+
`Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
|
|
83
92
|
`Tip: Use updated hashes above for retry.`
|
|
84
93
|
);
|
|
85
94
|
}
|
|
@@ -116,30 +125,14 @@ function findLine(lines, lineNum, expectedTag, hashIndex) {
|
|
|
116
125
|
if (relocated !== undefined) return relocated;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
const start = Math.max(0, idx - 5);
|
|
121
|
-
const end = Math.min(lines.length, idx + 6);
|
|
122
|
-
const snippet = lines.slice(start, end).map((line, i) => {
|
|
123
|
-
const num = start + i + 1;
|
|
124
|
-
const tag = lineTag(fnv1a(line));
|
|
125
|
-
return `${tag}.${num}\t${line}`;
|
|
126
|
-
}).join("\n");
|
|
127
|
-
|
|
128
|
+
const snip = buildErrorSnippet(lines, idx);
|
|
128
129
|
throw new Error(
|
|
129
|
-
`
|
|
130
|
-
`Current content (lines ${start
|
|
130
|
+
`HASH_MISMATCH: line ${lineNum} expected ${expectedTag}, got ${actual}.\n\n` +
|
|
131
|
+
`Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
|
|
131
132
|
`Tip: Use updated hashes above for retry.`
|
|
132
133
|
);
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
/**
|
|
136
|
-
* Parse a ref string: "ab.12" → { tag: "ab", line: 12 }
|
|
137
|
-
*/
|
|
138
|
-
function parseRef(ref) {
|
|
139
|
-
const m = ref.trim().match(/^([a-z2-7]{2})\.(\d+)$/);
|
|
140
|
-
if (!m) throw new Error(`Bad ref: "${ref}". Expected "ab.12"`);
|
|
141
|
-
return { tag: m[1], line: parseInt(m[2], 10) };
|
|
142
|
-
}
|
|
143
136
|
|
|
144
137
|
/**
|
|
145
138
|
* Context diff via `diff` package (Myers O(ND) algorithm).
|
|
@@ -243,7 +236,20 @@ function textReplace(content, oldText, newText, all) {
|
|
|
243
236
|
const normNew = newText.replace(/\r\n/g, "\n");
|
|
244
237
|
|
|
245
238
|
if (!all) {
|
|
246
|
-
|
|
239
|
+
// Uniqueness check: count occurrences
|
|
240
|
+
const parts = norm.split(normOld);
|
|
241
|
+
const count = parts.length - 1;
|
|
242
|
+
if (count === 0) {
|
|
243
|
+
// Fall through to TEXT_NOT_FOUND below
|
|
244
|
+
} else if (count === 1) {
|
|
245
|
+
// Unique match — safe to replace single occurrence
|
|
246
|
+
return parts.join(normNew);
|
|
247
|
+
} else {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`AMBIGUOUS_MATCH: "${normOld.slice(0, 80)}" found ${count} times. ` +
|
|
250
|
+
`Use all:true to replace all, or use set_line/replace_lines for a specific occurrence.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
247
253
|
}
|
|
248
254
|
let idx = norm.indexOf(normOld);
|
|
249
255
|
let confusableMatch = false;
|
|
@@ -299,9 +305,10 @@ function textReplace(content, oldText, newText, all) {
|
|
|
299
305
|
*/
|
|
300
306
|
export function editFile(filePath, edits, opts = {}) {
|
|
301
307
|
const real = validatePath(filePath);
|
|
302
|
-
const original =
|
|
308
|
+
const original = readText(real);
|
|
303
309
|
const lines = original.split("\n");
|
|
304
310
|
const origLines = [...lines];
|
|
311
|
+
const hadTrailingNewline = original.endsWith("\n");
|
|
305
312
|
|
|
306
313
|
// Build hash index once for global relocation in findLine
|
|
307
314
|
const hashIndex = buildHashIndex(lines);
|
|
@@ -313,7 +320,35 @@ export function editFile(filePath, edits, opts = {}) {
|
|
|
313
320
|
for (const e of edits) {
|
|
314
321
|
if (e.set_line || e.replace_lines || e.insert_after) anchored.push(e);
|
|
315
322
|
else if (e.replace) texts.push(e);
|
|
316
|
-
else throw new Error(`
|
|
323
|
+
else throw new Error(`BAD_INPUT: unknown edit type: ${JSON.stringify(e)}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Overlap validation: reject duplicate/overlapping edit targets
|
|
327
|
+
const editTargets = [];
|
|
328
|
+
for (const e of anchored) {
|
|
329
|
+
if (e.set_line) {
|
|
330
|
+
const line = parseRef(e.set_line.anchor).line;
|
|
331
|
+
editTargets.push({ start: line, end: line });
|
|
332
|
+
} else if (e.replace_lines) {
|
|
333
|
+
const s = parseRef(e.replace_lines.start_anchor).line;
|
|
334
|
+
const en = parseRef(e.replace_lines.end_anchor).line;
|
|
335
|
+
editTargets.push({ start: s, end: en });
|
|
336
|
+
} else if (e.insert_after) {
|
|
337
|
+
const line = parseRef(e.insert_after.anchor).line;
|
|
338
|
+
editTargets.push({ start: line, end: line, insert: true });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
for (let i = 0; i < editTargets.length; i++) {
|
|
342
|
+
for (let j = i + 1; j < editTargets.length; j++) {
|
|
343
|
+
const a = editTargets[i], b = editTargets[j];
|
|
344
|
+
if (a.insert || b.insert) continue; // insert_after doesn't overlap
|
|
345
|
+
if (a.start <= b.end && b.start <= a.end) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`OVERLAPPING_EDITS: lines ${a.start}-${a.end} and ${b.start}-${b.end} overlap. ` +
|
|
348
|
+
`Split into separate edit_file calls.`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
317
352
|
}
|
|
318
353
|
|
|
319
354
|
// Sort anchor edits bottom-to-top
|
|
@@ -356,9 +391,11 @@ export function editFile(filePath, edits, opts = {}) {
|
|
|
356
391
|
const actualStart = si + 1;
|
|
357
392
|
const actualEnd = ei + 1;
|
|
358
393
|
if (csStart > actualStart || csEnd < actualEnd) {
|
|
394
|
+
const snip = buildErrorSnippet(origLines, actualStart - 1);
|
|
359
395
|
throw new Error(
|
|
360
|
-
`
|
|
361
|
-
`
|
|
396
|
+
`CHECKSUM_RANGE_GAP: range ${csStart}-${csEnd} does not cover edit range ${actualStart}-${actualEnd}.\n\n` +
|
|
397
|
+
`Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
|
|
398
|
+
`Tip: Use updated hashes above for retry.`
|
|
362
399
|
);
|
|
363
400
|
}
|
|
364
401
|
|
|
@@ -366,14 +403,24 @@ export function editFile(filePath, edits, opts = {}) {
|
|
|
366
403
|
const csStartIdx = csStart - 1;
|
|
367
404
|
const csEndIdx = csEnd - 1;
|
|
368
405
|
if (csStartIdx < 0 || csEndIdx >= origLines.length) {
|
|
369
|
-
|
|
406
|
+
const snip = buildErrorSnippet(origLines, origLines.length - 1);
|
|
407
|
+
throw new Error(
|
|
408
|
+
`CHECKSUM_OUT_OF_BOUNDS: range ${csStart}-${csEnd} exceeds file length ${origLines.length}.\n\n` +
|
|
409
|
+
`Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
|
|
410
|
+
`Tip: Use updated hashes above for retry.`
|
|
411
|
+
);
|
|
370
412
|
}
|
|
371
413
|
const lineHashes = [];
|
|
372
414
|
for (let i = csStartIdx; i <= csEndIdx; i++) lineHashes.push(fnv1a(origLines[i]));
|
|
373
415
|
const actual = rangeChecksum(lineHashes, csStart, csEnd);
|
|
374
416
|
const actualHex = actual.split(":")[1];
|
|
375
417
|
if (csHex !== actualHex) {
|
|
376
|
-
|
|
418
|
+
const snip = buildErrorSnippet(origLines, csStartIdx);
|
|
419
|
+
throw new Error(
|
|
420
|
+
`CHECKSUM_MISMATCH: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${csStart}-${csEnd}.\n\n` +
|
|
421
|
+
`Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
|
|
422
|
+
`Retry with fresh checksum ${actual}, or use set_line with hashes above.`
|
|
423
|
+
);
|
|
377
424
|
}
|
|
378
425
|
|
|
379
426
|
const txt = e.replace_lines.new_text;
|
|
@@ -396,6 +443,8 @@ export function editFile(filePath, edits, opts = {}) {
|
|
|
396
443
|
|
|
397
444
|
// Apply text replacements
|
|
398
445
|
let content = lines.join("\n");
|
|
446
|
+
if (hadTrailingNewline && !content.endsWith("\n")) content += "\n";
|
|
447
|
+
if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
|
|
399
448
|
for (const e of texts) {
|
|
400
449
|
if (!e.replace.old_text) throw new Error("replace.old_text required");
|
|
401
450
|
content = textReplace(content, e.replace.old_text, e.replace.new_text || "", e.replace.all || false);
|
|
@@ -442,5 +491,34 @@ export function editFile(filePath, edits, opts = {}) {
|
|
|
442
491
|
}
|
|
443
492
|
} catch { /* silent */ }
|
|
444
493
|
|
|
494
|
+
// Post-edit context: hash-annotated lines around changed region + checksums
|
|
495
|
+
const newLines = content.split("\n");
|
|
496
|
+
if (diff) {
|
|
497
|
+
const diffArr = diff.split("\n");
|
|
498
|
+
let minLine = Infinity, maxLine = 0;
|
|
499
|
+
for (const dl of diffArr) {
|
|
500
|
+
const m = dl.match(/^[+-](\d+)\|/);
|
|
501
|
+
if (m) { const n = +m[1]; if (n < minLine) minLine = n; if (n > maxLine) maxLine = n; }
|
|
502
|
+
}
|
|
503
|
+
if (minLine <= maxLine) {
|
|
504
|
+
const ctxStart = Math.max(0, minLine - 6);
|
|
505
|
+
const ctxEnd = Math.min(newLines.length, maxLine + 5);
|
|
506
|
+
const ctxLines = [];
|
|
507
|
+
const ctxHashes = [];
|
|
508
|
+
for (let i = ctxStart; i < ctxEnd; i++) {
|
|
509
|
+
const h = fnv1a(newLines[i]);
|
|
510
|
+
ctxHashes.push(h);
|
|
511
|
+
ctxLines.push(`${lineTag(h)}.${i + 1}\t${newLines[i]}`);
|
|
512
|
+
}
|
|
513
|
+
const ctxCs = rangeChecksum(ctxHashes, ctxStart + 1, ctxEnd);
|
|
514
|
+
msg += `\n\nPost-edit (lines ${ctxStart + 1}-${ctxEnd}):\n${ctxLines.join("\n")}\nchecksum: ${ctxCs}`;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// File-level checksum
|
|
518
|
+
const fileHashes = [];
|
|
519
|
+
for (let i = 0; i < newLines.length; i++) fileHashes.push(fnv1a(newLines[i]));
|
|
520
|
+
const fileCs = rangeChecksum(fileHashes, 1, newLines.length);
|
|
521
|
+
msg += `\nfile: ${fileCs}`;
|
|
522
|
+
|
|
445
523
|
return msg;
|
|
446
524
|
}
|