pi-hashline-edit-pro 0.11.3 → 0.11.5
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/package.json +1 -1
- package/prompts/read.md +2 -1
- package/prompts/replace.md +16 -0
- package/src/file-kind.ts +134 -134
- package/src/fs-write.ts +11 -1
- package/src/hashline/apply.ts +3 -2
- package/src/hashline/hash.ts +11 -0
- package/src/hashline/resolve.ts +7 -6
- package/src/read.ts +4 -2
- package/src/replace-normalize.ts +18 -2
- package/src/replace-render.ts +9 -4
- package/src/replace-response.ts +3 -2
- package/src/replace.ts +5 -6
package/package.json
CHANGED
package/prompts/read.md
CHANGED
|
@@ -12,6 +12,7 @@ File kinds:
|
|
|
12
12
|
- Text files are returned as `HASH│content` lines.
|
|
13
13
|
- Images (JPEG, PNG, GIF, WebP) are returned as visual attachments.
|
|
14
14
|
- Binary files and directories are rejected with a descriptive error.
|
|
15
|
+
- Empty files are returned as a single empty-line hash (`HASH│`). Use replace on that hash to insert content.
|
|
15
16
|
|
|
16
17
|
Non-UTF-8 bytes:
|
|
17
|
-
-
|
|
18
|
+
- UTF-8 byte-order marks (BOM) are stripped. Editing a file with a BOM rewrites it without the BOM.
|
package/prompts/replace.md
CHANGED
|
@@ -44,6 +44,22 @@ Examples:
|
|
|
44
44
|
] }
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
4. Append after the last line (include the old last line so the new line is added after it):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "path": "src/main.ts", "edits": [
|
|
51
|
+
{ "hash_range_incl": ["ZPM", "ZPM"], "new_lines": ["old last line", "new line"] }
|
|
52
|
+
] }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
5. Seed content into an empty file (replace the single empty-line hash returned by read):
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{ "path": "src/main.ts", "edits": [
|
|
59
|
+
{ "hash_range_incl": ["aB3", "aB3"], "new_lines": ["first line", "second line"] }
|
|
60
|
+
] }
|
|
61
|
+
```
|
|
62
|
+
|
|
47
63
|
⚠️ Common mistake: do not copy the `HASH│` prefix into `new_lines`.
|
|
48
64
|
|
|
49
65
|
Wrong:
|
package/src/file-kind.ts
CHANGED
|
@@ -3,158 +3,158 @@ import { fileTypeFromBuffer } from "file-type";
|
|
|
3
3
|
import { SNIFF_BYTES, MAX_BYTES } from "./constants";
|
|
4
4
|
|
|
5
5
|
const IMG_TYPES = new Set<string>([
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
"image/jpeg",
|
|
7
|
+
"image/png",
|
|
8
|
+
"image/gif",
|
|
9
|
+
"image/webp",
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
12
|
const TEXT_TYPES = new Set<string>([
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
"application/rtf",
|
|
14
|
+
"application/xml",
|
|
15
|
+
"application/x-ms-regedit",
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
18
|
function isTextType(mimeType: string): boolean {
|
|
19
|
-
|
|
19
|
+
return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export type FKind =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
| { kind: "directory" }
|
|
24
|
+
| { kind: "image"; mimeType: string }
|
|
25
|
+
| { kind: "text" }
|
|
26
|
+
| { kind: "binary"; description: string };
|
|
27
27
|
|
|
28
28
|
export type LFile =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
| { kind: "directory" }
|
|
30
|
+
| { kind: "image"; mimeType: string }
|
|
31
|
+
| { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
|
|
32
|
+
| { kind: "binary"; description: string };
|
|
33
33
|
|
|
34
34
|
function hasNull(buffer: Uint8Array): boolean {
|
|
35
|
-
|
|
35
|
+
return buffer.includes(0);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export async function loadFileKindAndText(
|
|
39
|
-
|
|
39
|
+
filePath: string,
|
|
40
40
|
): Promise<LFile> {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
41
|
+
const pathStat = await fsStat(filePath);
|
|
42
|
+
if (pathStat.isDirectory()) {
|
|
43
|
+
return { kind: "directory" };
|
|
44
|
+
}
|
|
45
|
+
if (!pathStat.isFile()) {
|
|
46
|
+
return {
|
|
47
|
+
kind: "binary",
|
|
48
|
+
description: "unsupported file type",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (pathStat.size > MAX_BYTES) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "binary",
|
|
54
|
+
description: `file exceeds ${MAX_BYTES} byte limit`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fileHandle = await fsOpen(filePath, "r");
|
|
59
|
+
try {
|
|
60
|
+
const buffer = Buffer.alloc(SNIFF_BYTES);
|
|
61
|
+
const { bytesRead } = await fileHandle.read(
|
|
62
|
+
buffer,
|
|
63
|
+
0,
|
|
64
|
+
SNIFF_BYTES,
|
|
65
|
+
0,
|
|
66
|
+
);
|
|
67
|
+
if (bytesRead === 0) {
|
|
68
|
+
return { kind: "text", text: "" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sample = buffer.subarray(0, bytesRead);
|
|
72
|
+
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
73
|
+
if (
|
|
74
|
+
detectedMimeType !== undefined &&
|
|
75
|
+
!isTextType(detectedMimeType)
|
|
76
|
+
) {
|
|
77
|
+
if (IMG_TYPES.has(detectedMimeType)) {
|
|
78
|
+
return { kind: "image", mimeType: detectedMimeType };
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
kind: "binary",
|
|
82
|
+
description: detectedMimeType,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (hasNull(sample)) {
|
|
86
|
+
return {
|
|
87
|
+
kind: "binary",
|
|
88
|
+
description: "null bytes detected",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const decoder = new TextDecoder("utf-8");
|
|
93
|
+
const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
94
|
+
let hadUtf8DecodeErrors = false;
|
|
95
|
+
const noteUtf8Err = (chunk?: Uint8Array): void => {
|
|
96
|
+
if (hadUtf8DecodeErrors) return;
|
|
97
|
+
try {
|
|
98
|
+
fatalDecoder.decode(chunk, { stream: chunk !== undefined });
|
|
99
|
+
} catch (error: unknown) {
|
|
100
|
+
if (error instanceof TypeError) {
|
|
101
|
+
hadUtf8DecodeErrors = true;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
noteUtf8Err(sample);
|
|
109
|
+
const parts: string[] = [decoder.decode(sample, { stream: true })];
|
|
110
|
+
|
|
111
|
+
let position = bytesRead;
|
|
112
|
+
while (true) {
|
|
113
|
+
const { bytesRead: chunkBytesRead } = await fileHandle.read(
|
|
114
|
+
buffer,
|
|
115
|
+
0,
|
|
116
|
+
SNIFF_BYTES,
|
|
117
|
+
position,
|
|
118
|
+
);
|
|
119
|
+
if (chunkBytesRead === 0) {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const chunk = buffer.subarray(0, chunkBytesRead);
|
|
124
|
+
if (hasNull(chunk)) {
|
|
125
|
+
return {
|
|
126
|
+
kind: "binary",
|
|
127
|
+
description: "null bytes detected",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
noteUtf8Err(chunk);
|
|
131
|
+
parts.push(decoder.decode(chunk, { stream: true }));
|
|
132
|
+
position += chunkBytesRead;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
noteUtf8Err();
|
|
136
|
+
parts.push(decoder.decode());
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
kind: "text",
|
|
140
|
+
text: parts.join(""),
|
|
141
|
+
...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
|
|
142
|
+
};
|
|
143
|
+
} finally {
|
|
144
|
+
await fileHandle.close();
|
|
145
|
+
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
export async function classifyFileKind(filePath: string): Promise<FKind> {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
149
|
+
const loaded = await loadFileKindAndText(filePath);
|
|
150
|
+
switch (loaded.kind) {
|
|
151
|
+
case "directory":
|
|
152
|
+
return loaded;
|
|
153
|
+
case "image":
|
|
154
|
+
return loaded;
|
|
155
|
+
case "binary":
|
|
156
|
+
return loaded;
|
|
157
|
+
case "text":
|
|
158
|
+
return { kind: "text" };
|
|
159
|
+
}
|
|
160
160
|
}
|
package/src/fs-write.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
open,
|
|
6
6
|
readlink,
|
|
7
7
|
rename,
|
|
8
|
+
rm,
|
|
8
9
|
stat,
|
|
9
10
|
writeFile,
|
|
10
11
|
} from "fs/promises";
|
|
@@ -102,5 +103,14 @@ export async function writeAtomic(
|
|
|
102
103
|
await tempHandle.close();
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
try {
|
|
107
|
+
await rename(tempPath, targetPath);
|
|
108
|
+
} catch (error: unknown) {
|
|
109
|
+
try {
|
|
110
|
+
await rm(tempPath, { force: true });
|
|
111
|
+
} catch {
|
|
112
|
+
// best-effort cleanup
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
106
116
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -216,7 +216,8 @@ export function applyEdits(
|
|
|
216
216
|
edits: import("./resolve").HEdit[],
|
|
217
217
|
signal?: AbortSignal,
|
|
218
218
|
precomputedHashes?: string[],
|
|
219
|
-
|
|
219
|
+
filePath?: string,
|
|
220
|
+
): {
|
|
220
221
|
content: string;
|
|
221
222
|
firstChangedLine: number | undefined;
|
|
222
223
|
lastChangedLine: number | undefined;
|
|
@@ -252,7 +253,7 @@ export function applyEdits(
|
|
|
252
253
|
);
|
|
253
254
|
if (mismatches.length) {
|
|
254
255
|
throw new Error(
|
|
255
|
-
fmtMismatch(mismatches, lineIndex.fileLines, fileHashes),
|
|
256
|
+
fmtMismatch(mismatches, lineIndex.fileLines, fileHashes, filePath),
|
|
256
257
|
);
|
|
257
258
|
}
|
|
258
259
|
|
package/src/hashline/hash.ts
CHANGED
|
@@ -62,6 +62,17 @@ function canon(line: string): string {
|
|
|
62
62
|
return line.replace(/\r/g, "").trimEnd();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Compute a unique 3-character hash for every line of `content`.
|
|
67
|
+
*
|
|
68
|
+
* Each line is hashed with xxHash32 over its canonical form (trailing
|
|
69
|
+
* whitespace and CR characters stripped). If the base hash collides with a
|
|
70
|
+
* hash already assigned to an earlier line, a retry counter (`:R{retry}`)
|
|
71
|
+
* is appended to the canonical content and the hash is recomputed until a
|
|
72
|
+
* unique anchor is found. This perfect-hashing step guarantees every line
|
|
73
|
+
* in a file receives a distinct anchor, even when multiple lines contain
|
|
74
|
+
* identical text (e.g. repeated `}` or `import` statements).
|
|
75
|
+
*/
|
|
65
76
|
export function lineHashes(content: string): string[] {
|
|
66
77
|
const lines = content.split("\n");
|
|
67
78
|
const hashes = new Array<string>(lines.length);
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -79,6 +79,7 @@ export function fmtMismatch(
|
|
|
79
79
|
mismatches: HMismatch[],
|
|
80
80
|
fileLines: string[],
|
|
81
81
|
fileHashes: string[],
|
|
82
|
+
filePath?: string,
|
|
82
83
|
): string {
|
|
83
84
|
assertAligned(fileLines, fileHashes, "fmtMismatch");
|
|
84
85
|
|
|
@@ -88,15 +89,15 @@ export function fmtMismatch(
|
|
|
88
89
|
|
|
89
90
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
90
91
|
if (notFound.length > 0) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
out.push(
|
|
93
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
|
|
94
|
+
);
|
|
94
95
|
}
|
|
95
96
|
if (ambiguous.length > 0) {
|
|
96
97
|
if (out.length > 0) out.push("");
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
out.push(
|
|
99
|
+
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
|
|
100
|
+
);
|
|
100
101
|
for (const m of ambiguous) {
|
|
101
102
|
const sample = (m.candidates ?? []).slice(0, 5);
|
|
102
103
|
const more =
|
package/src/read.ts
CHANGED
|
@@ -51,12 +51,14 @@ export function fmtReadPreview(
|
|
|
51
51
|
const startLine = normPosInt(options.offset, "offset") ?? 1;
|
|
52
52
|
if (totalLines === 0) {
|
|
53
53
|
if (startLine === 1) {
|
|
54
|
+
const allHashes = precomputedHashes ?? lineHashes(text);
|
|
55
|
+
const emptyLineHash = allHashes[0] ?? "";
|
|
54
56
|
return {
|
|
55
|
-
text:
|
|
57
|
+
text: `${emptyLineHash}│\n[File is empty. Use replace to insert content.]`,
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
return {
|
|
59
|
-
text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use
|
|
61
|
+
text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
}
|
package/src/replace-normalize.ts
CHANGED
|
@@ -12,6 +12,22 @@ function coerceEditsArray(edits: unknown): unknown {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function coerceNewLines(edits: unknown): unknown {
|
|
16
|
+
if (!Array.isArray(edits)) return edits;
|
|
17
|
+
return edits.map((edit: unknown) => {
|
|
18
|
+
if (!isRec(edit)) return edit;
|
|
19
|
+
if (typeof edit.new_lines !== "string") return edit;
|
|
20
|
+
try {
|
|
21
|
+
const parsed: unknown = JSON.parse(edit.new_lines);
|
|
22
|
+
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
23
|
+
return { ...edit, new_lines: parsed };
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// not valid JSON, leave as-is for downstream validation
|
|
27
|
+
}
|
|
28
|
+
return edit;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
15
31
|
|
|
16
32
|
export function normReq(input: unknown): unknown {
|
|
17
33
|
if (!isRec(input)) {
|
|
@@ -29,8 +45,8 @@ export function normReq(input: unknown): unknown {
|
|
|
29
45
|
|
|
30
46
|
if (hasEditsField) {
|
|
31
47
|
record.edits = coerceEditsArray(record.edits);
|
|
48
|
+
record.edits = coerceNewLines(record.edits);
|
|
32
49
|
}
|
|
33
50
|
|
|
34
|
-
|
|
35
51
|
return record;
|
|
36
|
-
}
|
|
52
|
+
}
|
package/src/replace-render.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { normReq } from "./replace-normalize";
|
|
3
|
+
import type { HTEdit } from "./hashline";
|
|
3
4
|
import type { ReqParams, ReplaceDetails } from "./replace";
|
|
4
5
|
import { isRec } from "./utils";
|
|
5
6
|
|
|
@@ -31,12 +32,16 @@ export function getPreviewInput(
|
|
|
31
32
|
return null;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
request.edits = normalized.edits as any;
|
|
35
|
+
if (!Array.isArray(normalized.edits)) {
|
|
36
|
+
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const request: ReqParams = {
|
|
40
|
+
path: normalized.path,
|
|
41
|
+
edits: normalized.edits as HTEdit[],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return request;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export function colorLines(lines: string[], theme: FgT): string[] {
|
package/src/replace-response.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReplaceDetails } from "./replace";
|
|
1
2
|
import { genDiff } from "./replace-diff";
|
|
2
3
|
import {
|
|
3
4
|
affRange,
|
|
@@ -10,7 +11,7 @@ import { ANCHOR_BUDGET } from "./constants";
|
|
|
10
11
|
type TResult = {
|
|
11
12
|
content: Array<{ type: "text"; text: string }>;
|
|
12
13
|
isError?: boolean;
|
|
13
|
-
details:
|
|
14
|
+
details: ReplaceDetails;
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export type RMetrics = {
|
|
@@ -175,7 +176,7 @@ export function buildChanged(input: SuccessInput): TResult {
|
|
|
175
176
|
: "Anchors omitted; use read for subsequent edits.";
|
|
176
177
|
})()
|
|
177
178
|
: resultLines.length === 0
|
|
178
|
-
? "File is empty. Use
|
|
179
|
+
? "File is empty. Use replace to insert content."
|
|
179
180
|
: "";
|
|
180
181
|
const text = [anchorsBlock, warningsBlock.trimStart()]
|
|
181
182
|
.filter((section) => section.length > 0)
|
package/src/replace.ts
CHANGED
|
@@ -66,16 +66,14 @@ const editItemSchema = Type.Object(
|
|
|
66
66
|
export const editToolSchema = Type.Object(
|
|
67
67
|
{
|
|
68
68
|
path: Type.String({ description: "path" }),
|
|
69
|
-
edits: Type.
|
|
70
|
-
Type.Array(editItemSchema, { description: "edits over $path" }),
|
|
71
|
-
),
|
|
69
|
+
edits: Type.Array(editItemSchema, { description: "edits over $path" }),
|
|
72
70
|
},
|
|
73
71
|
{ additionalProperties: false },
|
|
74
72
|
);
|
|
75
73
|
|
|
76
74
|
export type ReqParams = {
|
|
77
75
|
path: string;
|
|
78
|
-
edits
|
|
76
|
+
edits: HTEdit[];
|
|
79
77
|
};
|
|
80
78
|
|
|
81
79
|
export type ReplaceDetails = {
|
|
@@ -113,8 +111,8 @@ export function assertReq(
|
|
|
113
111
|
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
|
|
114
112
|
}
|
|
115
113
|
|
|
116
|
-
if (
|
|
117
|
-
throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array
|
|
114
|
+
if (!Array.isArray(request.edits)) {
|
|
115
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array. Each edit is { hash_range_incl: ["<START>", "<END>"], new_lines: [...] }.');
|
|
118
116
|
}
|
|
119
117
|
}
|
|
120
118
|
|
|
@@ -157,6 +155,7 @@ async function execPipeline(
|
|
|
157
155
|
resolved,
|
|
158
156
|
signal,
|
|
159
157
|
originalHashes,
|
|
158
|
+
path,
|
|
160
159
|
);
|
|
161
160
|
|
|
162
161
|
return {
|