pi-hashline-edit-pro 4.4.1 → 4.4.3
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 +4 -1
- package/index.ts +2 -3
- package/package.json +1 -1
- package/src/anchor-registry.ts +20 -11
- package/src/auto-read-all.ts +11 -39
- package/src/batch.ts +21 -3
- package/src/commit.ts +2 -0
- package/src/fs-write.ts +6 -2
- package/src/grep.ts +20 -7
- package/src/hash-store.ts +14 -4
- package/src/hashline/hash.ts +1 -1
- package/src/hashline/parse.ts +1 -3
- package/src/insert.ts +4 -0
- package/src/replace-diff.ts +2 -4
- package/src/utils.ts +3 -22
package/README.md
CHANGED
|
@@ -230,7 +230,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
230
230
|
|
|
231
231
|
| Code | Meaning |
|
|
232
232
|
| --- | --- |
|
|
233
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line),
|
|
233
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
|
|
234
234
|
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example stringified array text that could not be parsed and was kept as one literal line). |
|
|
235
235
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
|
|
236
236
|
| `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
|
|
@@ -252,6 +252,8 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
252
252
|
| `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
|
|
253
253
|
| `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
|
|
254
254
|
| `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
|
|
255
|
+
| `[E_GREP_FAILED]` | `anchor_grep` could not start ripgrep or ripgrep exited with an error (for example a pattern valid in JavaScript but unsupported by ripgrep's regex engine); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
|
|
256
|
+
| `[E_GREP_TIMEOUT]` | `anchor_grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
|
|
255
257
|
| `[E_AUTO_READ_ALL]` | File already attached and unchanged since this session's start; the attached content is still exact. |
|
|
256
258
|
|
|
257
259
|
## Troubleshooting
|
|
@@ -263,6 +265,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
263
265
|
- Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
264
266
|
- Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
|
|
265
267
|
- Config directory moved. If `XDG_CONFIG_HOME` is set on a non-Windows platform, the config directory (and the anchor state inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
|
|
268
|
+
- Windows drives in WSL. Editing a file under a Windows mount (`/mnt/c`, drvfs/9p) can fail with `EPERM` from `fchmod` because those filesystems do not store POSIX modes. Mode preservation is best-effort there, so `replace`, `insert`, and `undo_last_change` still write the edit.
|
|
266
269
|
|
|
267
270
|
## Development
|
|
268
271
|
|
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { initHasher
|
|
3
|
+
import { initHasher } from "./src/hashline";
|
|
4
4
|
import { regReplace } from "./src/replace";
|
|
5
5
|
import { regInsert } from "./src/insert";
|
|
6
6
|
import { regGrep } from "./src/grep";
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
adjustDiffContextLines,
|
|
24
24
|
setAutoReadAllIgnoreFromText,
|
|
25
25
|
} from "./src/config";
|
|
26
|
-
import { loadHashStore,
|
|
26
|
+
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
27
27
|
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
|
|
28
28
|
import { serveRows } from "./src/served";
|
|
29
29
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
@@ -216,7 +216,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
216
216
|
DEFAULT_MAX_LINES,
|
|
217
217
|
);
|
|
218
218
|
const fileLines = splitLines(normalized);
|
|
219
|
-
persistSnapshot(await loadHashStore(), absolutePath, normalized, fileHashes, fileLines.map(lineChecksum));
|
|
220
219
|
serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
|
|
221
220
|
return {
|
|
222
221
|
content: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -671,7 +671,7 @@ export function alignOwnershipWithSpans(
|
|
|
671
671
|
prevAnchors: string[],
|
|
672
672
|
prevChecksums: string[],
|
|
673
673
|
newChecksums: string[],
|
|
674
|
-
spans: { start: number; end: number; replacementCount: number }[],
|
|
674
|
+
spans: { start: number; end: number; replacementCount: number; carry?: number }[],
|
|
675
675
|
options?: { shadow?: boolean },
|
|
676
676
|
): Aligned {
|
|
677
677
|
const state = options?.shadow ? shadowStateFrom(current()!) : current()!;
|
|
@@ -699,15 +699,24 @@ export function alignOwnershipWithSpans(
|
|
|
699
699
|
}
|
|
700
700
|
}
|
|
701
701
|
const replacement: (string | undefined)[] = new Array(span.replacementCount);
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
702
|
+
const carryIndex =
|
|
703
|
+
span.carry !== undefined && span.carry >= 0 && span.carry < span.replacementCount ? span.carry : undefined;
|
|
704
|
+
const carried = carryIndex === undefined ? undefined : prevAnchors[prevStart];
|
|
705
|
+
if (carryIndex !== undefined && carried !== undefined && prevChecksums[prevStart] === newChecksums[start + carryIndex]) {
|
|
706
|
+
replacement[carryIndex] = carried;
|
|
707
|
+
reused.add(carried);
|
|
708
|
+
} else {
|
|
709
|
+
for (let k = 0; k < span.replacementCount; k++) {
|
|
710
|
+
if (k === carryIndex) continue;
|
|
711
|
+
const positional = k < spanLength ? prevAnchors[prevStart + k] : undefined;
|
|
712
|
+
if (
|
|
713
|
+
positional !== undefined &&
|
|
714
|
+
prevChecksums[prevStart + k] === newChecksums[start + k] &&
|
|
715
|
+
(!state.owned.has(positional) || state.owned.get(positional)!.path === path)
|
|
716
|
+
) {
|
|
717
|
+
replacement[k] = positional;
|
|
718
|
+
reused.add(positional);
|
|
719
|
+
}
|
|
711
720
|
}
|
|
712
721
|
}
|
|
713
722
|
for (let k = 0; k < span.replacementCount; k++) {
|
|
@@ -819,7 +828,7 @@ export async function allocateFileAnchors(
|
|
|
819
828
|
options?: {
|
|
820
829
|
persist?: boolean;
|
|
821
830
|
shadow?: boolean;
|
|
822
|
-
previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number }[] };
|
|
831
|
+
previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number; carry?: number }[] };
|
|
823
832
|
},
|
|
824
833
|
): Promise<string[]> {
|
|
825
834
|
ensureRegistry();
|
package/src/auto-read-all.ts
CHANGED
|
@@ -24,7 +24,6 @@ const EXEC_MAX_BYTES = 64 * 1024 * 1024;
|
|
|
24
24
|
const SCAN_CONCURRENCY = 32;
|
|
25
25
|
const SCAN_LIMIT_MULTIPLIER = 4;
|
|
26
26
|
const MAX_REPORTED_OMISSIONS = 50;
|
|
27
|
-
export const AUTO_READ_ALL_CHUNK_BYTES = 48 * 1024;
|
|
28
27
|
|
|
29
28
|
const HEADER =
|
|
30
29
|
"[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for files in [files complete: ...].\nFiles listed as omitted or not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
|
|
@@ -87,24 +86,17 @@ function isExcludedBySegment(path: string): boolean {
|
|
|
87
86
|
}
|
|
88
87
|
return false;
|
|
89
88
|
}
|
|
89
|
+
const EXCLUDED_PATTERN_SUFFIXES = [
|
|
90
|
+
".min.js", ".min.css", ".min.mjs", "-min.js", "-min.css", ".umd.js", ".map", ".lock",
|
|
91
|
+
"_pb2.py", ".pb.go", ".g.dart", ".freezed.dart", ".designer.cs", ".g.cs", ".snap",
|
|
92
|
+
];
|
|
93
|
+
const EXCLUDED_PATTERN_INFIXES = [".bundle.", ".chunk.", ".generated.", ".gen."];
|
|
94
|
+
|
|
90
95
|
function isExcludedByPattern(baseLower: string): boolean {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (baseLower.endsWith(".map")) return true;
|
|
96
|
-
if (baseLower.endsWith(".lock")) return true;
|
|
97
|
-
if (baseLower.includes(".generated.") || baseLower.includes(".gen.")) return true;
|
|
98
|
-
if (baseLower.endsWith("_pb2.py")) return true;
|
|
99
|
-
if (baseLower.endsWith(".pb.go")) return true;
|
|
100
|
-
if (baseLower.endsWith(".g.dart")) return true;
|
|
101
|
-
if (baseLower.endsWith(".freezed.dart")) return true;
|
|
102
|
-
if (baseLower.endsWith(".designer.cs")) return true;
|
|
103
|
-
if (baseLower.endsWith(".g.cs")) return true;
|
|
104
|
-
if (baseLower.endsWith(".snap")) return true;
|
|
105
|
-
if (baseLower.startsWith("coreui-icons.")) return true;
|
|
106
|
-
if (baseLower === "coreui.css") return true;
|
|
107
|
-
return false;
|
|
96
|
+
return EXCLUDED_PATTERN_SUFFIXES.some((suffix) => baseLower.endsWith(suffix))
|
|
97
|
+
|| EXCLUDED_PATTERN_INFIXES.some((infix) => baseLower.includes(infix))
|
|
98
|
+
|| baseLower.startsWith("coreui-icons.")
|
|
99
|
+
|| baseLower === "coreui.css";
|
|
108
100
|
}
|
|
109
101
|
export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | undefined): string[] {
|
|
110
102
|
const seen = new Set<string>();
|
|
@@ -203,7 +195,6 @@ export interface AutoReadAllInjection {
|
|
|
203
195
|
files: number;
|
|
204
196
|
bytes: number;
|
|
205
197
|
omitted: string[];
|
|
206
|
-
chunks: string[];
|
|
207
198
|
completeFiles: number;
|
|
208
199
|
}
|
|
209
200
|
|
|
@@ -371,24 +362,6 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
|
|
|
371
362
|
return { files, source, discovered: unique.length, skippedBinary, skippedLarge, skippedOther, skippedByName };
|
|
372
363
|
}
|
|
373
364
|
|
|
374
|
-
export function chunkAutoReadAllSections(sections: string[], maxBytes: number = AUTO_READ_ALL_CHUNK_BYTES): string[] {
|
|
375
|
-
const chunks: string[] = [];
|
|
376
|
-
let current: string[] = [];
|
|
377
|
-
let currentBytes = 0;
|
|
378
|
-
for (const section of sections) {
|
|
379
|
-
const sectionBytes = Buffer.byteLength(section, "utf-8") + 2;
|
|
380
|
-
if (current.length > 0 && currentBytes + sectionBytes > maxBytes) {
|
|
381
|
-
chunks.push(current.join("\n\n"));
|
|
382
|
-
current = [];
|
|
383
|
-
currentBytes = 0;
|
|
384
|
-
}
|
|
385
|
-
current.push(section);
|
|
386
|
-
currentBytes += sectionBytes;
|
|
387
|
-
}
|
|
388
|
-
if (current.length > 0) chunks.push(current.join("\n\n"));
|
|
389
|
-
return chunks;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
365
|
async function candidateFileBytes(cwd: string, file: string): Promise<number | undefined> {
|
|
393
366
|
try {
|
|
394
367
|
const stats = await lstat(resolve(cwd, file));
|
|
@@ -462,12 +435,11 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
|
|
|
462
435
|
}
|
|
463
436
|
if (sections.length === 0) return undefined;
|
|
464
437
|
const sectionTexts = sections.map((section) => section.text);
|
|
465
|
-
const chunks = chunkAutoReadAllSections(sectionTexts);
|
|
466
438
|
const coverage = `[coverage: ${completeFiles} complete]`;
|
|
467
439
|
const completeNames = sections.map((section) => section.file);
|
|
468
440
|
const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
|
|
469
441
|
const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
|
|
470
|
-
return { text, files: sections.length, bytes, omitted,
|
|
442
|
+
return { text, files: sections.length, bytes, omitted, completeFiles };
|
|
471
443
|
}
|
|
472
444
|
|
|
473
445
|
export function autoReadAllBudget(model: { contextWindow?: number } | undefined): number {
|
package/src/batch.ts
CHANGED
|
@@ -63,6 +63,7 @@ export interface BatchPiece {
|
|
|
63
63
|
warnings: string[];
|
|
64
64
|
noop: boolean;
|
|
65
65
|
foldedLines: number;
|
|
66
|
+
carryIndex?: number;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
export interface BatchMemberInput {
|
|
@@ -495,10 +496,18 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
495
496
|
const baseLines = base.baseLines;
|
|
496
497
|
const originalSlice = baseLines.slice(start - 1, end);
|
|
497
498
|
const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
|
|
499
|
+
const foldedLines = input.foldedLines ?? 0;
|
|
500
|
+
const carryIndex =
|
|
501
|
+
input.kind === "insert" && foldedLines > 0
|
|
502
|
+
? input.direction === "after"
|
|
503
|
+
? 0
|
|
504
|
+
: newLines.length - 1
|
|
505
|
+
: undefined;
|
|
498
506
|
const piece: BatchPiece = {
|
|
499
507
|
order: input.member.order,
|
|
500
508
|
kind: input.kind,
|
|
501
509
|
...(input.direction !== undefined ? { direction: input.direction } : {}),
|
|
510
|
+
...(carryIndex !== undefined ? { carryIndex } : {}),
|
|
502
511
|
start,
|
|
503
512
|
end,
|
|
504
513
|
fromHash: input.hedit.hash_bounds[0].hash,
|
|
@@ -506,7 +515,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
506
515
|
newLines: [...newLines],
|
|
507
516
|
warnings: [...input.extraWarnings, ...planned.warnings],
|
|
508
517
|
noop,
|
|
509
|
-
foldedLines
|
|
518
|
+
foldedLines,
|
|
510
519
|
};
|
|
511
520
|
runtime.pieces.push(piece);
|
|
512
521
|
if (input.kind === "replace") runtime.replaceCount += 1;
|
|
@@ -571,6 +580,15 @@ function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
|
|
|
571
580
|
return merged;
|
|
572
581
|
}
|
|
573
582
|
|
|
583
|
+
function pieceMappingSpans(pieces: BatchPiece[]): { start: number; end: number; replacementCount: number; carry?: number }[] {
|
|
584
|
+
return pieces.map((piece) => ({
|
|
585
|
+
start: piece.start - 1,
|
|
586
|
+
end: piece.end - 1,
|
|
587
|
+
replacementCount: piece.newLines.length,
|
|
588
|
+
...(piece.carryIndex !== undefined ? { carry: piece.carryIndex } : {}),
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
|
|
574
592
|
async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise<TResult> {
|
|
575
593
|
const runtime = batches.get(member.batchKey);
|
|
576
594
|
if (!runtime || !runtime.base || !runtime.paths) throw new Error(`[E_STALE_ANCHOR] Batch ${member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
@@ -637,7 +655,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
637
655
|
discardBatchState(runtime);
|
|
638
656
|
throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started. Call read for fresh anchors and retry.`);
|
|
639
657
|
}
|
|
640
|
-
const preflightSpans = effectivePieces
|
|
658
|
+
const preflightSpans = pieceMappingSpans(effectivePieces);
|
|
641
659
|
try {
|
|
642
660
|
await lineHashes(composed, runtime.target, {
|
|
643
661
|
content: base.content,
|
|
@@ -670,7 +688,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
670
688
|
throw error;
|
|
671
689
|
}
|
|
672
690
|
const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
|
|
673
|
-
const spans = effectivePieces
|
|
691
|
+
const spans = pieceMappingSpans(effectivePieces);
|
|
674
692
|
let resultHashes: string[];
|
|
675
693
|
try {
|
|
676
694
|
resultHashes = await lineHashes(composed, runtime.target, {
|
package/src/commit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { spanForEdit } from "./replace";
|
|
|
12
12
|
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
13
13
|
export interface CommitMeta {
|
|
14
14
|
editAnchors?: [string, string];
|
|
15
|
+
anchorCarry?: number;
|
|
15
16
|
path: string;
|
|
16
17
|
absolutePath: string;
|
|
17
18
|
mutationTargetPath: string;
|
|
@@ -107,6 +108,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
107
108
|
};
|
|
108
109
|
|
|
109
110
|
const span = meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined;
|
|
111
|
+
if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
|
|
110
112
|
let resultHashes: string[];
|
|
111
113
|
try {
|
|
112
114
|
resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
|
package/src/fs-write.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "node:fs/promises";
|
|
14
14
|
import { dirname, join, parse, resolve, sep } from "node:path";
|
|
15
15
|
import { toCwd } from "./paths";
|
|
16
|
-
import { errCode } from "./utils";
|
|
16
|
+
import { errCode, isModeUnsupported } from "./utils";
|
|
17
17
|
|
|
18
18
|
export interface FileIdentity {
|
|
19
19
|
dev: number;
|
|
@@ -196,7 +196,11 @@ export async function writeAtomic(
|
|
|
196
196
|
await tempHandle.writeFile(content, "utf-8");
|
|
197
197
|
const mode = existingStats ? existingStats.mode & 0o7777 : restoreMode;
|
|
198
198
|
if (mode !== undefined) {
|
|
199
|
-
|
|
199
|
+
try {
|
|
200
|
+
await tempHandle.chmod(mode);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (!isModeUnsupported(error)) throw error;
|
|
203
|
+
}
|
|
200
204
|
}
|
|
201
205
|
await tempHandle.sync();
|
|
202
206
|
} catch (error: unknown) {
|
package/src/grep.ts
CHANGED
|
@@ -13,11 +13,13 @@ import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
|
|
|
13
13
|
import { toCwd, toDisplayPath } from "./paths";
|
|
14
14
|
import { loadP, loadGuide } from "./prompts";
|
|
15
15
|
import { normReq } from "./payload-contract";
|
|
16
|
-
import { abortIf, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
16
|
+
import { abortIf, clipLine, errCode, gutterWidth, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
17
17
|
import { withAnchorSession } from "./anchor-registry";
|
|
18
18
|
import { serveRows } from "./served";
|
|
19
19
|
import { Text } from "@earendil-works/pi-tui";
|
|
20
20
|
import { expandHint, getResultText, reuseText, type CallT, type FgT } from "./replace-render";
|
|
21
|
+
export const RG_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
21
23
|
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
22
24
|
|
|
23
25
|
function cmp(a: string, b: string): number {
|
|
@@ -54,6 +56,14 @@ export function assertGrepReq(request: unknown): asserts request is GrepReq {
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
function compileGrepGlob(glob: string): RegExp {
|
|
60
|
+
try {
|
|
61
|
+
return globToRegex(glob);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`[E_BAD_SHAPE] Invalid glob pattern: ${glob}. Fix the bracket or brace syntax, or search without glob.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
|
|
58
68
|
if (!literal) assertSafeRegex(pattern);
|
|
59
69
|
const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
|
|
@@ -314,8 +324,8 @@ async function collectRgMatches(
|
|
|
314
324
|
const rgTimeout = setTimeout(() => {
|
|
315
325
|
timedOut = true;
|
|
316
326
|
if (!child.killed) child.kill("SIGKILL");
|
|
317
|
-
reject(new Error(
|
|
318
|
-
},
|
|
327
|
+
reject(new Error(`[E_GREP_TIMEOUT] ripgrep timed out after ${RG_TIMEOUT_MS}ms. Narrow the path or pattern and retry.`));
|
|
328
|
+
}, RG_TIMEOUT_MS);
|
|
319
329
|
child.stderr?.on("data", (chunk) => {
|
|
320
330
|
stderr += chunk.toString();
|
|
321
331
|
});
|
|
@@ -355,7 +365,8 @@ async function collectRgMatches(
|
|
|
355
365
|
child.on("error", (error) => {
|
|
356
366
|
cleanup();
|
|
357
367
|
if (rgPath === cachedRgPath) cachedRgPath = undefined;
|
|
358
|
-
|
|
368
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
369
|
+
reject(new Error(`[E_GREP_FAILED] ripgrep could not start: ${clipLine(detail)}`));
|
|
359
370
|
});
|
|
360
371
|
child.on("close", (code) => {
|
|
361
372
|
cleanup();
|
|
@@ -365,8 +376,10 @@ async function collectRgMatches(
|
|
|
365
376
|
return;
|
|
366
377
|
}
|
|
367
378
|
if (code !== 0 && code !== 1) {
|
|
368
|
-
const
|
|
369
|
-
reject(new Error(
|
|
379
|
+
const detail = clipLine(stderr.trim(), 300);
|
|
380
|
+
reject(new Error(detail
|
|
381
|
+
? `[E_GREP_FAILED] ripgrep exited with code ${code}: ${detail}`
|
|
382
|
+
: `[E_GREP_FAILED] ripgrep exited with code ${code}.`));
|
|
370
383
|
return;
|
|
371
384
|
}
|
|
372
385
|
resolve(result);
|
|
@@ -538,7 +551,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
538
551
|
throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
|
|
539
552
|
}
|
|
540
553
|
const globRoot = baseStat.isFile() ? dirname(base) : base;
|
|
541
|
-
const globRegex = req.glob === undefined ? undefined :
|
|
554
|
+
const globRegex = req.glob === undefined ? undefined : compileGrepGlob(req.glob);
|
|
542
555
|
const matchesGlob = (absPath: string): boolean => {
|
|
543
556
|
if (globRegex === undefined) return true;
|
|
544
557
|
const displayPath = toDisplayPath(ctx.cwd, absPath);
|
package/src/hash-store.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { chmod, readFile, rename, mkdir, stat } from "node:fs/promises";
|
|
4
4
|
import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
|
|
5
|
-
import { errCode, isRec, splitLines } from "./utils";
|
|
5
|
+
import { errCode, isModeUnsupported, isRec, splitLines } from "./utils";
|
|
6
6
|
import { initHasher, contentChecksum, lineChecksum } from "./hashline/hasher";
|
|
7
7
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
8
8
|
import {
|
|
@@ -278,7 +278,11 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
278
278
|
await initHasher();
|
|
279
279
|
await mkdir(hashStoreDir(), { recursive: true, mode: 0o700 });
|
|
280
280
|
if (process.platform !== "win32") {
|
|
281
|
-
|
|
281
|
+
try {
|
|
282
|
+
await chmod(hashStoreDir(), 0o700);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (errCode(error) !== "ENOENT" && !isModeUnsupported(error)) throw error;
|
|
285
|
+
}
|
|
282
286
|
}
|
|
283
287
|
|
|
284
288
|
let existed = existsSync(storePath);
|
|
@@ -319,7 +323,7 @@ async function openStore(storePath: string): Promise<HashStore> {
|
|
|
319
323
|
try {
|
|
320
324
|
await chmod(candidate, 0o600);
|
|
321
325
|
} catch (error) {
|
|
322
|
-
if (errCode(error) !== "ENOENT") throw error;
|
|
326
|
+
if (errCode(error) !== "ENOENT" && !isModeUnsupported(error)) throw error;
|
|
323
327
|
}
|
|
324
328
|
}
|
|
325
329
|
}
|
|
@@ -529,6 +533,7 @@ export function getAllocatedState(store: HashStore, path: string, deleteCorrupt
|
|
|
529
533
|
checksums = undefined;
|
|
530
534
|
}
|
|
531
535
|
}
|
|
536
|
+
if (checksums !== undefined && checksums.length !== parsedAnchors.length) return undefined;
|
|
532
537
|
return { anchors: parsedAnchors, checksums, contentChecksum: row.checksum };
|
|
533
538
|
}
|
|
534
539
|
|
|
@@ -550,8 +555,13 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
|
|
|
550
555
|
if (!row) return undefined;
|
|
551
556
|
const parsed = parseStoredHashes(row, () => store.stmts.undoDelete(path));
|
|
552
557
|
if (!parsed) return undefined;
|
|
558
|
+
const content = row.content as string;
|
|
559
|
+
if (splitLines(content).length !== parsed.length) {
|
|
560
|
+
store.stmts.undoDelete(path);
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
553
563
|
return {
|
|
554
|
-
content
|
|
564
|
+
content,
|
|
555
565
|
bom: row.bom as string,
|
|
556
566
|
ending: row.ending as string,
|
|
557
567
|
hashes: parsed,
|
package/src/hashline/hash.ts
CHANGED
|
@@ -101,7 +101,7 @@ export function _lineHashesPure(content: string): string[] {
|
|
|
101
101
|
export async function lineHashes(
|
|
102
102
|
content: string,
|
|
103
103
|
path?: string,
|
|
104
|
-
previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number }[] },
|
|
104
|
+
previous?: { content: string; hashes: string[]; spans?: { start: number; end: number; replacementCount: number; carry?: number }[] },
|
|
105
105
|
store?: HashStore,
|
|
106
106
|
persist?: boolean,
|
|
107
107
|
shadow?: boolean,
|
package/src/hashline/parse.ts
CHANGED
|
@@ -38,7 +38,7 @@ function diagRef(ref: string): string {
|
|
|
38
38
|
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 4-character anchor (letters only, e.g. "Hasu").`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function
|
|
41
|
+
export function parseHashRef(ref: string): Anchor {
|
|
42
42
|
const trimmed = ref.trim();
|
|
43
43
|
|
|
44
44
|
if (
|
|
@@ -51,8 +51,6 @@ function parseRef(ref: string): Anchor {
|
|
|
51
51
|
throw new Error(diagRef(ref));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export const parseHashRef = parseRef;
|
|
55
|
-
|
|
56
54
|
const JSON_ENVELOPE_RE = /^\s*\["(.*)"\]\.\s*$/;
|
|
57
55
|
|
|
58
56
|
function unwrapJsonEnvelope(line: string): string {
|
package/src/insert.ts
CHANGED
|
@@ -241,6 +241,10 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
241
241
|
path: pipe.path,
|
|
242
242
|
absolutePath,
|
|
243
243
|
mutationTargetPath,
|
|
244
|
+
editAnchors: [editParams.remove_from, editParams.remove_to],
|
|
245
|
+
...(anchorLine === undefined
|
|
246
|
+
? {}
|
|
247
|
+
: { anchorCarry: req.direction === "after" ? 0 : editParams.replacement_lines.length - 1 }),
|
|
244
248
|
signal,
|
|
245
249
|
verb: "inserted",
|
|
246
250
|
noopNoun: "Insertion",
|
package/src/replace-diff.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface DiffSpan {
|
|
|
23
23
|
start: number;
|
|
24
24
|
end: number;
|
|
25
25
|
replacementCount: number;
|
|
26
|
+
carry?: number;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
interface PlacedSpan {
|
|
@@ -37,10 +38,7 @@ function fmtDiffLine(
|
|
|
37
38
|
line: string,
|
|
38
39
|
hash: string | undefined,
|
|
39
40
|
): string {
|
|
40
|
-
|
|
41
|
-
return `${prefix}${" ".repeat(ANCHOR_LEN)}${HASH_SEP}${line}`;
|
|
42
|
-
}
|
|
43
|
-
return `${prefix}${hash}${HASH_SEP}${line}`;
|
|
41
|
+
return `${prefix}${hash ?? " ".repeat(ANCHOR_LEN)}${HASH_SEP}${line}`;
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
function isBlankLine(line: string): boolean {
|
package/src/utils.ts
CHANGED
|
@@ -99,28 +99,9 @@ export function errCode(error: unknown): string | undefined {
|
|
|
99
99
|
return undefined;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
return -1;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function firstNonEmptyIndex(lines: string[]): number {
|
|
110
|
-
for (let i = 0; i < lines.length; i++) {
|
|
111
|
-
if (lines[i]!.length > 0) return i;
|
|
112
|
-
}
|
|
113
|
-
return -1;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function lastNonEmpty(lines: string[]): string | undefined {
|
|
117
|
-
const idx = lastNonEmptyIndex(lines);
|
|
118
|
-
return idx >= 0 ? lines[idx] : undefined;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function firstNonEmpty(lines: string[]): string | undefined {
|
|
122
|
-
const idx = firstNonEmptyIndex(lines);
|
|
123
|
-
return idx >= 0 ? lines[idx] : undefined;
|
|
102
|
+
export function isModeUnsupported(error: unknown): boolean {
|
|
103
|
+
const code = errCode(error);
|
|
104
|
+
return code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EINVAL" || code === "ENOSYS";
|
|
124
105
|
}
|
|
125
106
|
|
|
126
107
|
export function truncateToBytes(s: string, maxBytes: number): string {
|