pi-hashline-edit-pro 6.4.0 → 6.4.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 +1 -1
- package/package.json +1 -1
- package/src/batch.ts +24 -5
- package/src/commit.ts +17 -3
- package/src/copy-move.ts +10 -3
- package/src/fs-write.ts +14 -2
- package/src/line-endings.ts +6 -2
- package/src/pre-edit-snapshot.ts +19 -0
- package/src/replace-diff.ts +5 -3
- package/src/replace-undo.ts +2 -2
- package/src/structured.ts +2 -1
- package/src/utils.ts +29 -0
package/README.md
CHANGED
|
@@ -315,7 +315,7 @@ Multiple `replace`, `replace_match`, `insert`, `copy`, and `move` calls on the s
|
|
|
315
315
|
- A call outside a batch commits before its result returns.
|
|
316
316
|
- A cross-file `move` whose source file also has batched edits in the same message is not grouped: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
|
|
317
317
|
- A batched cross-file `move` displays only the destination diff: the arrival is covered by the batch's undo, and the source removal is committed with the same batch but is not shown. Read the source file for fresh anchors; it keeps its own undo entry, and `details.patch` still contains the source patch.
|
|
318
|
-
- A batch validates every call against the pre-batch state and commits once, during the batch's last applied call: earlier applied calls reply `In batch N (queued)`, and the batch's last applied call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-
|
|
318
|
+
- A batch validates every call against the pre-batch state and commits once, during the batch's last applied call: earlier applied calls reply `In batch N (queued)`, and the batch's last applied call shows the combined diff, with one undo reverting the whole batch. A `copy` always duplicates the content its source anchors were served from: when the source file is also edited in the message, the copy reads that file's pre-edit state, even if the source batch commits before the copy runs. A batched cross-file `move` defers the source-file removal to the batch commit, so if the batch aborts, the source file is untouched.
|
|
319
319
|
- Before committing, the batch validates every call against the pre-batch state. A call whose own request is invalid (`[E_BAD_SHAPE]`, `[E_BAD_REF]`, `[E_STALE_ANCHOR]`, `[E_RANGE_STALE]`, `[E_SUBSTRING_NOT_FOUND]`, or a strict-input rejection) is left out: it fails on its own call without an `Aborts batch N.` suffix, and the remaining calls still commit as one batch with one undo. A failure of the file, the commit, or a cross-file member aborts the whole batch instead: nothing is written, the failing call's error ends with `Aborts batch N.`, and an earlier member's row renders the abort message instead of the queued placeholder.
|
|
320
320
|
- A batch member accepts the same request shapes and auto-fixes as a standalone call.
|
|
321
321
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.4.
|
|
3
|
+
"version": "6.4.1",
|
|
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/batch.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { toDisplayPath } from "./paths";
|
|
|
4
4
|
import { readConfig, getDiffContextLines } from "./config";
|
|
5
5
|
import { throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
|
|
6
6
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
7
|
+
import { clearPreEditSnapshots, recordPreEditSnapshot } from "./pre-edit-snapshot";
|
|
7
8
|
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
8
9
|
import {
|
|
9
10
|
AnchorMismatchError,
|
|
@@ -27,7 +28,7 @@ import { saveUndo } from "./replace-undo";
|
|
|
27
28
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
28
29
|
import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
|
|
29
30
|
import { serveRows, servedHashesFromDiff } from "./served";
|
|
30
|
-
import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, splitLines } from "./utils";
|
|
31
|
+
import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, mapIoError, splitLines } from "./utils";
|
|
31
32
|
|
|
32
33
|
export interface PlannedMember {
|
|
33
34
|
batchKey: number;
|
|
@@ -231,7 +232,9 @@ export function abortedBatchMessageFor(toolCallId: string): string | undefined {
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
|
|
234
|
-
|
|
235
|
+
const member = plan.get(toolCallId);
|
|
236
|
+
if (!member || !batches.has(member.batchKey)) return undefined;
|
|
237
|
+
return member;
|
|
235
238
|
}
|
|
236
239
|
|
|
237
240
|
export function pendingBatchMemberFor(path: string): PlannedMember | undefined {
|
|
@@ -255,6 +258,7 @@ export function resetBatchStateForTests(): void {
|
|
|
255
258
|
abortedMembers.clear();
|
|
256
259
|
placeholderResults.clear();
|
|
257
260
|
nextBatchKey = 1;
|
|
261
|
+
clearPreEditSnapshots();
|
|
258
262
|
}
|
|
259
263
|
|
|
260
264
|
function normalizeEditArgs(name: string, args: unknown): NormalizedEditArgs | undefined {
|
|
@@ -371,7 +375,9 @@ function enforceCap(): void {
|
|
|
371
375
|
}
|
|
372
376
|
|
|
373
377
|
export async function planAssistantMessage(message: unknown, cwd: string): Promise<void> {
|
|
374
|
-
if (!isRec(message) || message.role !== "assistant"
|
|
378
|
+
if (!isRec(message) || message.role !== "assistant") return;
|
|
379
|
+
clearPreEditSnapshots();
|
|
380
|
+
if (!Array.isArray(message.content)) return;
|
|
375
381
|
const calls: EditCall[] = [];
|
|
376
382
|
for (const block of message.content) {
|
|
377
383
|
if (!isRec(block) || block.type !== "toolCall") continue;
|
|
@@ -931,12 +937,24 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
931
937
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
932
938
|
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
933
939
|
}
|
|
940
|
+
|
|
941
|
+
recordPreEditSnapshot({
|
|
942
|
+
absolutePath: runtime.target,
|
|
943
|
+
normalized: base.content,
|
|
944
|
+
bom: base.bom,
|
|
945
|
+
originalEnding: base.ending,
|
|
946
|
+
endingSeparators: base.separators,
|
|
947
|
+
fileHashes: base.hashes,
|
|
948
|
+
hadUtf8DecodeErrors: base.hadUtf8DecodeErrors,
|
|
949
|
+
identity: base.identity,
|
|
950
|
+
served: runtime.served ? new Map(runtime.served) : undefined,
|
|
951
|
+
});
|
|
934
952
|
abortIf(signal);
|
|
935
953
|
let currentRaw: string;
|
|
936
954
|
try {
|
|
937
955
|
currentRaw = await readFile(runtime.target, "utf-8");
|
|
938
956
|
} catch (error) {
|
|
939
|
-
if (errCode(error) !== "ENOENT") throw error;
|
|
957
|
+
if (errCode(error) !== "ENOENT") throw mapIoError(error, "read", runtime.target);
|
|
940
958
|
discardBatchState(runtime);
|
|
941
959
|
throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file was deleted after the batch started.`);
|
|
942
960
|
}
|
|
@@ -962,7 +980,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
962
980
|
try {
|
|
963
981
|
sourceRaw = await readFile(source.mutationTargetPath, "utf-8");
|
|
964
982
|
} catch (error) {
|
|
965
|
-
if (errCode(error) !== "ENOENT") throw error;
|
|
983
|
+
if (errCode(error) !== "ENOENT") throw mapIoError(error, "read", source.displayPath);
|
|
966
984
|
sourceRaw = undefined;
|
|
967
985
|
}
|
|
968
986
|
if (sourceRaw === undefined || toLF(stripBOM(sourceRaw).text) !== source.pipe.originalNormalized) {
|
|
@@ -1196,4 +1214,5 @@ export async function finalizeTurn(toolCallIds: string[]): Promise<void> {
|
|
|
1196
1214
|
}
|
|
1197
1215
|
batches.delete(key);
|
|
1198
1216
|
}
|
|
1217
|
+
clearPreEditSnapshots();
|
|
1199
1218
|
}
|
package/src/commit.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import type { PipelineResult } from "./replace";
|
|
3
|
-
import { abortIf, assertByteLimit, errCode, splitLines } from "./utils";
|
|
3
|
+
import { abortIf, assertByteLimit, errCode, mapIoError, splitLines } from "./utils";
|
|
4
4
|
import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
|
|
5
5
|
import { saveUndo } from "./replace-undo";
|
|
6
6
|
import { getDiffContextLines } from "./config";
|
|
7
7
|
import { safeSnapId } from "./file-reader";
|
|
8
8
|
import { writeAtomic } from "./fs-write";
|
|
9
|
-
import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
9
|
+
import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths } from "./anchor-registry";
|
|
10
10
|
import { servedHashesFromDiff, serveRows } from "./served";
|
|
11
|
+
import { recordPreEditSnapshot } from "./pre-edit-snapshot";
|
|
11
12
|
import { lineHashes } from "./hashline";
|
|
12
13
|
import { spanForEdit } from "./replace";
|
|
13
14
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
@@ -69,6 +70,19 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
const served = servedForPath(mutationTargetPath);
|
|
74
|
+
recordPreEditSnapshot({
|
|
75
|
+
absolutePath: mutationTargetPath,
|
|
76
|
+
normalized: pipe.originalNormalized,
|
|
77
|
+
bom: pipe.bom,
|
|
78
|
+
originalEnding: pipe.originalEnding,
|
|
79
|
+
endingSeparators: pipe.originalSeparators,
|
|
80
|
+
fileHashes: pipe.originalHashes,
|
|
81
|
+
hadUtf8DecodeErrors: pipe.hadUtf8DecodeErrors,
|
|
82
|
+
identity: pipe.identity,
|
|
83
|
+
served: served ? new Map(served) : undefined,
|
|
84
|
+
});
|
|
85
|
+
|
|
72
86
|
assertByteLimit(finalFileBytes, path);
|
|
73
87
|
|
|
74
88
|
if (pipe.hadUtf8DecodeErrors) {
|
|
@@ -86,7 +100,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
86
100
|
if (code === "ENOENT") currentRaw = undefined;
|
|
87
101
|
else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${path}`);
|
|
88
102
|
else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
|
|
89
|
-
else throw error;
|
|
103
|
+
else throw mapIoError(error, "read", path);
|
|
90
104
|
}
|
|
91
105
|
if (currentRaw === undefined) {
|
|
92
106
|
throw new Error(`[E_OP_ABORTED] Edit aborted: the file was deleted after the edit started.`);
|
package/src/copy-move.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { constants } from "node:fs";
|
|
|
5
5
|
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
|
|
6
6
|
import { commitEdit } from "./commit";
|
|
7
7
|
import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
|
|
8
|
+
import { preEditSnapshotFor } from "./pre-edit-snapshot";
|
|
8
9
|
import {
|
|
9
10
|
lineHashes,
|
|
10
11
|
MAX_HASH_LINES,
|
|
@@ -236,11 +237,17 @@ async function prepareCrossTransfer(input: {
|
|
|
236
237
|
const { kind, refs, sourcePath, destinationPath, cwd, signal, noPersist } = input;
|
|
237
238
|
const common = { signal, maxLines: MAX_HASH_LINES };
|
|
238
239
|
const sourceAccessMode = kind === "move" ? constants.R_OK | constants.W_OK : constants.R_OK;
|
|
239
|
-
const
|
|
240
|
+
const candidate = kind === "copy" && input.sourcePreload === undefined ? preEditSnapshotFor(sourcePath) : undefined;
|
|
241
|
+
const snapshotPreload = candidate !== undefined
|
|
242
|
+
&& candidate.fileHashes.includes(refs.sourceFrom.hash)
|
|
243
|
+
&& candidate.fileHashes.includes(refs.sourceTo.hash)
|
|
244
|
+
? candidate
|
|
245
|
+
: undefined;
|
|
246
|
+
const sourcePreload = input.sourcePreload ?? snapshotPreload ?? (await readNormFile(sourcePath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: sourceAccessMode }));
|
|
240
247
|
const destinationPreload = input.destinationPreload ?? (await readNormFile(destinationPath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: constants.R_OK | constants.W_OK }));
|
|
241
248
|
const sourceDisplay = toDisplayPath(cwd, sourcePreload.absolutePath, sourcePath);
|
|
242
249
|
const destinationDisplay = toDisplayPath(cwd, destinationPreload.absolutePath, destinationPath);
|
|
243
|
-
const sourceServed = input.sourceServed ?? servedForPath(sourcePreload.absolutePath);
|
|
250
|
+
const sourceServed = input.sourceServed ?? (snapshotPreload !== undefined ? snapshotPreload.served : servedForPath(sourcePreload.absolutePath));
|
|
244
251
|
const destinationServed = input.destinationServed ?? servedForPath(destinationPreload.absolutePath);
|
|
245
252
|
const sourceLines = splitLines(sourcePreload.normalized);
|
|
246
253
|
const destinationLines = splitLines(destinationPreload.normalized);
|
|
@@ -667,7 +674,7 @@ async function executeBatchCrossCopy(
|
|
|
667
674
|
const sourceMember = pendingBatchMemberFor(sourcePath);
|
|
668
675
|
const sourcePreload = sourceMember
|
|
669
676
|
? preloadFromBase(await ensureBatchBase({ member: sourceMember, targetPath: sourcePath, mutationTargetPath: sourceMember.target, cwd, signal }))
|
|
670
|
-
:
|
|
677
|
+
: undefined;
|
|
671
678
|
prepared = await prepareCrossTransfer({
|
|
672
679
|
kind: "copy",
|
|
673
680
|
refs,
|
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, isModeUnsupported } from "./utils";
|
|
16
|
+
import { errCode, isModeUnsupported, mapIoError } from "./utils";
|
|
17
17
|
|
|
18
18
|
export interface FileIdentity {
|
|
19
19
|
dev: number;
|
|
@@ -138,7 +138,7 @@ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolut
|
|
|
138
138
|
const resolved = await resolveTarget(absolute);
|
|
139
139
|
return { absolute, resolved };
|
|
140
140
|
}
|
|
141
|
-
|
|
141
|
+
async function writeAtomicRaw(
|
|
142
142
|
path: string,
|
|
143
143
|
content: string,
|
|
144
144
|
expectedIdentity?: FileIdentity,
|
|
@@ -223,3 +223,15 @@ export async function writeAtomic(
|
|
|
223
223
|
throw error;
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
export async function writeAtomic(
|
|
227
|
+
path: string,
|
|
228
|
+
content: string,
|
|
229
|
+
expectedIdentity?: FileIdentity,
|
|
230
|
+
restoreMode?: number,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
try {
|
|
233
|
+
await writeAtomicRaw(path, content, expectedIdentity, restoreMode);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw mapIoError(error, "write", path);
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/line-endings.ts
CHANGED
|
@@ -35,9 +35,13 @@ export function splitSeparators(raw: string): LineEnding[] {
|
|
|
35
35
|
export function joinSeparators(content: string, separators: readonly LineEnding[]): string {
|
|
36
36
|
let out = "";
|
|
37
37
|
let separatorIndex = 0;
|
|
38
|
-
for (
|
|
38
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
39
|
+
const char = content[index]!;
|
|
39
40
|
if (char === "\n") {
|
|
40
|
-
|
|
41
|
+
const separator = separators[separatorIndex] ?? "\n";
|
|
42
|
+
const emptyLineBefore = content[index - 1] === "\n";
|
|
43
|
+
const followsLoneCr = separator === "\n" && separators[separatorIndex - 1] === "\r" && emptyLineBefore;
|
|
44
|
+
out += followsLoneCr ? "\r\n" : separator;
|
|
41
45
|
separatorIndex += 1;
|
|
42
46
|
} else {
|
|
43
47
|
out += char;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { NormFile } from "./file-reader";
|
|
2
|
+
|
|
3
|
+
export interface PreEditSnapshot extends NormFile {
|
|
4
|
+
served?: ReadonlyMap<string, string>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const snapshots = new Map<string, PreEditSnapshot>();
|
|
8
|
+
|
|
9
|
+
export function recordPreEditSnapshot(snapshot: PreEditSnapshot): void {
|
|
10
|
+
snapshots.set(snapshot.absolutePath, snapshot);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function preEditSnapshotFor(path: string): PreEditSnapshot | undefined {
|
|
14
|
+
return snapshots.get(path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clearPreEditSnapshots(): void {
|
|
18
|
+
snapshots.clear();
|
|
19
|
+
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -163,6 +163,8 @@ function trimPlacedSpans(
|
|
|
163
163
|
placed: PlacedSpan[],
|
|
164
164
|
oldLines: string[],
|
|
165
165
|
newLines: string[],
|
|
166
|
+
oldHashes: string[],
|
|
167
|
+
newHashes: string[],
|
|
166
168
|
): PlacedSpan[] {
|
|
167
169
|
const out: PlacedSpan[] = [];
|
|
168
170
|
for (const span of placed) {
|
|
@@ -170,11 +172,11 @@ function trimPlacedSpans(
|
|
|
170
172
|
let oldEnd = span.oldEnd;
|
|
171
173
|
let newStart = span.newStart;
|
|
172
174
|
let newEnd = span.newEnd;
|
|
173
|
-
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart]) {
|
|
175
|
+
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart] && oldHashes[oldStart] === newHashes[newStart]) {
|
|
174
176
|
oldStart += 1;
|
|
175
177
|
newStart += 1;
|
|
176
178
|
}
|
|
177
|
-
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd]) {
|
|
179
|
+
while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd] && oldHashes[oldEnd] === newHashes[newEnd]) {
|
|
178
180
|
oldEnd -= 1;
|
|
179
181
|
newEnd -= 1;
|
|
180
182
|
}
|
|
@@ -251,7 +253,7 @@ function genSpanDiff(
|
|
|
251
253
|
if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
|
|
252
254
|
const placed = placeSpans(oldLines.length, newLines.length, spans);
|
|
253
255
|
if (!placed) return undefined;
|
|
254
|
-
const trimmed = trimPlacedSpans(placed, oldLines, newLines);
|
|
256
|
+
const trimmed = trimPlacedSpans(placed, oldLines, newLines, oldHashes, newHashes);
|
|
255
257
|
const ordered = [...trimmed].sort((a, b) => a.oldStart - b.oldStart || a.newStart - b.newStart);
|
|
256
258
|
let oldPos = 0;
|
|
257
259
|
let newPos = 0;
|
package/src/replace-undo.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
|
|
|
12
12
|
import { joinSeparators } from "./line-endings";
|
|
13
13
|
import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
|
|
14
14
|
import { getDiffContextLines } from "./config";
|
|
15
|
-
import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
|
|
15
|
+
import { cntDiff, errCode, makePrepareArguments, mapIoError, splitLines } from "./utils";
|
|
16
16
|
import { loadP, loadGuidelines } from "./prompts";
|
|
17
17
|
import { withUndoPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
18
18
|
import { buildMetrics } from "./replace-response";
|
|
@@ -166,7 +166,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
166
166
|
await handle.close();
|
|
167
167
|
}
|
|
168
168
|
} catch (error) {
|
|
169
|
-
if (errCode(error) !== "ENOENT") throw error;
|
|
169
|
+
if (errCode(error) !== "ENOENT") throw mapIoError(error, "read", path);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
if (
|
package/src/structured.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { HASH_CLASS, HASH_SEP } from "./hashline/hash";
|
|
3
|
+
import { ioErrorCode } from "./utils";
|
|
3
4
|
|
|
4
5
|
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
5
6
|
|
|
@@ -200,7 +201,7 @@ export function messageOf(error: unknown): string {
|
|
|
200
201
|
|
|
201
202
|
export function structuredError(error: unknown): StructuredError {
|
|
202
203
|
const message = messageOf(error);
|
|
203
|
-
return { ok: false, kind: "error", error: { code: ERROR_CODE_RE.exec(message)?.[1] ?? "E_TOOL", message } };
|
|
204
|
+
return { ok: false, kind: "error", error: { code: ERROR_CODE_RE.exec(message)?.[1] ?? ioErrorCode(error) ?? "E_TOOL", message } };
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
export type StructuredFailureResult<TDetails> = {
|
package/src/utils.ts
CHANGED
|
@@ -94,6 +94,35 @@ export function errCode(error: unknown): string | undefined {
|
|
|
94
94
|
return undefined;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
const IO_ERROR_CODES: Record<string, string> = {
|
|
98
|
+
ENOENT: "E_NOT_FOUND",
|
|
99
|
+
EACCES: "E_ACCESS",
|
|
100
|
+
EPERM: "E_ACCESS",
|
|
101
|
+
EROFS: "E_ACCESS",
|
|
102
|
+
EISDIR: "E_ACCESS",
|
|
103
|
+
ENOTDIR: "E_ACCESS",
|
|
104
|
+
EEXIST: "E_ACCESS",
|
|
105
|
+
ELOOP: "E_ACCESS",
|
|
106
|
+
ENOSPC: "E_ACCESS",
|
|
107
|
+
EDQUOT: "E_ACCESS",
|
|
108
|
+
EMFILE: "E_ACCESS",
|
|
109
|
+
ENFILE: "E_ACCESS",
|
|
110
|
+
ENAMETOOLONG: "E_ACCESS",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function ioErrorCode(error: unknown): string | undefined {
|
|
114
|
+
const code = errCode(error);
|
|
115
|
+
return code !== undefined ? IO_ERROR_CODES[code] : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function mapIoError(error: unknown, action: string, path: string): Error {
|
|
119
|
+
if (error instanceof Error && /\[[EWH]_[A-Z0-9_]+\]/.test(error.message)) return error;
|
|
120
|
+
const mapped = ioErrorCode(error);
|
|
121
|
+
if (mapped === undefined) return error instanceof Error ? error : new Error(String(error));
|
|
122
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
123
|
+
return new Error(`[${mapped}] Cannot ${action} ${path}: ${detail}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
97
126
|
export function isModeUnsupported(error: unknown): boolean {
|
|
98
127
|
const code = errCode(error);
|
|
99
128
|
return code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EINVAL" || code === "ENOSYS";
|