@warlock.js/ai-workspace 5.39.2 → 5.40.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/CHANGELOG.md +14 -0
- package/README.md +2 -2
- package/cjs/index.cjs +691 -114
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/index.d.mts +2 -2
- package/esm/contracts/tool-io.type.d.mts +32 -1
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +10 -1
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +7 -1
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +17 -1
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +1 -0
- package/esm/errors.mjs.map +1 -1
- package/esm/index.d.mts +6 -2
- package/esm/index.mjs +5 -1
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +16 -0
- package/esm/ops.mjs.map +1 -1
- package/esm/patch/apply-hunks.d.mts +14 -0
- package/esm/patch/apply-hunks.d.mts.map +1 -0
- package/esm/patch/apply-hunks.mjs +89 -0
- package/esm/patch/apply-hunks.mjs.map +1 -0
- package/esm/patch/apply-patch-generic.d.mts +14 -0
- package/esm/patch/apply-patch-generic.d.mts.map +1 -0
- package/esm/patch/apply-patch-generic.mjs +68 -0
- package/esm/patch/apply-patch-generic.mjs.map +1 -0
- package/esm/patch/execute-patch.mjs +151 -0
- package/esm/patch/execute-patch.mjs.map +1 -0
- package/esm/patch/parse-patch.d.mts +45 -0
- package/esm/patch/parse-patch.d.mts.map +1 -0
- package/esm/patch/parse-patch.mjs +201 -0
- package/esm/patch/parse-patch.mjs.map +1 -0
- package/esm/tools/apply-patch.d.mts +27 -0
- package/esm/tools/apply-patch.d.mts.map +1 -0
- package/esm/tools/apply-patch.mjs +61 -0
- package/esm/tools/apply-patch.mjs.map +1 -0
- package/esm/tools/edit-file.mjs +1 -1
- package/esm/tools/glob.mjs +1 -1
- package/esm/tools/grep.mjs +1 -1
- package/esm/tools/json-schemas.mjs +10 -1
- package/esm/tools/json-schemas.mjs.map +1 -1
- package/esm/tools/list-dir.mjs +1 -1
- package/esm/tools/multi-edit.mjs +1 -1
- package/esm/tools/read-file.mjs +1 -1
- package/esm/tools/run-shell.mjs +1 -1
- package/esm/tools/run-tests.mjs +1 -1
- package/esm/tools/shell-kill.mjs +1 -1
- package/esm/tools/shell-output.mjs +1 -1
- package/esm/tools/todo.mjs +1 -1
- package/esm/tools/write-file.mjs +1 -1
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +9 -0
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +7 -6
- package/llms.txt +2 -2
- package/package.json +3 -3
- package/skills/build-loop-agent/SKILL.md +1 -1
- package/skills/index.md +1 -1
- package/skills/use-a-workspace/SKILL.md +5 -4
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { WorkspaceEditError } from "../errors.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/ai-workspace/src/patch/apply-hunks.ts
|
|
4
|
+
/** The text of a line without a trailing carriage return. */
|
|
5
|
+
const bare = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
6
|
+
/** Same, and ignoring trailing blanks (a second, looser matching pass). */
|
|
7
|
+
const loose = (line) => bare(line).trimEnd();
|
|
8
|
+
/** Split file content into lines (carriage returns kept) and note the final newline. */
|
|
9
|
+
function split(content) {
|
|
10
|
+
if (content === "") return {
|
|
11
|
+
lines: [],
|
|
12
|
+
trailingNewline: false
|
|
13
|
+
};
|
|
14
|
+
const parts = content.split("\n");
|
|
15
|
+
const trailingNewline = parts[parts.length - 1] === "";
|
|
16
|
+
if (trailingNewline) parts.pop();
|
|
17
|
+
return {
|
|
18
|
+
lines: parts,
|
|
19
|
+
trailingNewline
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Whether most of the file's lines end in CRLF. */
|
|
23
|
+
function usesCrlf(lines) {
|
|
24
|
+
const crlf = lines.filter((line) => line.endsWith("\r")).length;
|
|
25
|
+
return crlf > 0 && crlf * 2 >= lines.length;
|
|
26
|
+
}
|
|
27
|
+
/** First index at or after `from` (searching outward from `expected`) where `old` matches. */
|
|
28
|
+
function findMatch(lines, old, expected, from, normalize) {
|
|
29
|
+
if (old.length === 0) return Math.min(Math.max(expected, from), lines.length);
|
|
30
|
+
const last = lines.length - old.length;
|
|
31
|
+
const wanted = old.map(normalize);
|
|
32
|
+
const matches = (start) => start >= from && start <= last && wanted.every((line, offset) => normalize(lines[start + offset]) === line);
|
|
33
|
+
for (let distance = 0; distance <= lines.length; distance++) {
|
|
34
|
+
if (matches(expected + distance)) return expected + distance;
|
|
35
|
+
if (distance > 0 && matches(expected - distance)) return expected - distance;
|
|
36
|
+
}
|
|
37
|
+
return -1;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Apply a file's hunks to its content, in memory. Hunks are located by their
|
|
41
|
+
* context (not only by line number, so a few lines of drift are fine), must
|
|
42
|
+
* not overlap, and keep the file's line endings: untouched lines are
|
|
43
|
+
* byte-for-byte as they were and added lines use the file's CRLF/LF style.
|
|
44
|
+
*
|
|
45
|
+
* Throws a `WorkspaceEditError` (`hunk-failed`) naming the first hunk whose
|
|
46
|
+
* context is not in the file; nothing is returned in that case.
|
|
47
|
+
*/
|
|
48
|
+
function applyHunks(content, hunks, displayPath) {
|
|
49
|
+
const { lines, trailingNewline: initialTrailing } = split(content);
|
|
50
|
+
const wasEmpty = lines.length === 0;
|
|
51
|
+
const eol = usesCrlf(lines) ? "\r" : "";
|
|
52
|
+
let trailingNewline = initialTrailing;
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
let delta = 0;
|
|
55
|
+
hunks.forEach((hunk, hunkIndex) => {
|
|
56
|
+
const old = hunk.lines.filter((line) => line.op !== "+").map((line) => line.text);
|
|
57
|
+
const expected = (old.length === 0 ? hunk.oldStart : hunk.oldStart - 1) + delta;
|
|
58
|
+
let start = findMatch(lines, old, expected, cursor, bare);
|
|
59
|
+
if (start === -1) start = findMatch(lines, old, expected, cursor, loose);
|
|
60
|
+
if (start === -1) {
|
|
61
|
+
const probe = old.find((line) => line.trim() !== "") ?? old[0] ?? "";
|
|
62
|
+
throw new WorkspaceEditError(`Hunk ${hunkIndex + 1} of ${displayPath} does not apply: its context was not found in the file${probe === "" ? "" : ` (looked for a line like ${JSON.stringify(probe.slice(0, 80))})`}. Re-read the file and regenerate the patch.`, {
|
|
63
|
+
type: "hunk-failed",
|
|
64
|
+
path: displayPath,
|
|
65
|
+
hunkIndex
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const replacement = [];
|
|
69
|
+
let consumed = start;
|
|
70
|
+
for (const line of hunk.lines) if (line.op === " ") {
|
|
71
|
+
replacement.push(lines[consumed]);
|
|
72
|
+
consumed++;
|
|
73
|
+
} else if (line.op === "-") consumed++;
|
|
74
|
+
else replacement.push(line.text + eol);
|
|
75
|
+
lines.splice(start, old.length, ...replacement);
|
|
76
|
+
cursor = start + replacement.length;
|
|
77
|
+
delta += replacement.length - old.length;
|
|
78
|
+
if (cursor >= lines.length) {
|
|
79
|
+
if (hunk.newNoNewline) trailingNewline = false;
|
|
80
|
+
else if (hunk.oldNoNewline || wasEmpty) trailingNewline = true;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
if (lines.length === 0) return "";
|
|
84
|
+
return lines.join("\n") + (trailingNewline ? "\n" : "");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
export { applyHunks };
|
|
89
|
+
//# sourceMappingURL=apply-hunks.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply-hunks.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/patch/apply-hunks.ts"],"sourcesContent":["import { WorkspaceEditError } from \"../errors\";\nimport type { PatchHunk } from \"./parse-patch\";\n\n/** The text of a line without a trailing carriage return. */\nconst bare = (line: string): string => (line.endsWith(\"\\r\") ? line.slice(0, -1) : line);\n\n/** Same, and ignoring trailing blanks (a second, looser matching pass). */\nconst loose = (line: string): string => bare(line).trimEnd();\n\n/** Split file content into lines (carriage returns kept) and note the final newline. */\nfunction split(content: string): { lines: string[]; trailingNewline: boolean } {\n if (content === \"\") {\n return { lines: [], trailingNewline: false };\n }\n\n const parts = content.split(\"\\n\");\n const trailingNewline = parts[parts.length - 1] === \"\";\n\n if (trailingNewline) {\n parts.pop();\n }\n\n return { lines: parts, trailingNewline };\n}\n\n/** Whether most of the file's lines end in CRLF. */\nfunction usesCrlf(lines: string[]): boolean {\n const crlf = lines.filter((line) => line.endsWith(\"\\r\")).length;\n\n return crlf > 0 && crlf * 2 >= lines.length;\n}\n\n/** First index at or after `from` (searching outward from `expected`) where `old` matches. */\nfunction findMatch(\n lines: string[],\n old: string[],\n expected: number,\n from: number,\n normalize: (line: string) => string,\n): number {\n if (old.length === 0) {\n return Math.min(Math.max(expected, from), lines.length);\n }\n\n const last = lines.length - old.length;\n const wanted = old.map(normalize);\n const matches = (start: number): boolean =>\n start >= from &&\n start <= last &&\n wanted.every((line, offset) => normalize(lines[start + offset] as string) === line);\n\n for (let distance = 0; distance <= lines.length; distance++) {\n if (matches(expected + distance)) {\n return expected + distance;\n }\n\n if (distance > 0 && matches(expected - distance)) {\n return expected - distance;\n }\n }\n\n return -1;\n}\n\n/**\n * Apply a file's hunks to its content, in memory. Hunks are located by their\n * context (not only by line number, so a few lines of drift are fine), must\n * not overlap, and keep the file's line endings: untouched lines are\n * byte-for-byte as they were and added lines use the file's CRLF/LF style.\n *\n * Throws a `WorkspaceEditError` (`hunk-failed`) naming the first hunk whose\n * context is not in the file; nothing is returned in that case.\n */\nexport function applyHunks(content: string, hunks: PatchHunk[], displayPath: string): string {\n const { lines, trailingNewline: initialTrailing } = split(content);\n const wasEmpty = lines.length === 0;\n const eol = usesCrlf(lines) ? \"\\r\" : \"\";\n let trailingNewline = initialTrailing;\n let cursor = 0;\n let delta = 0;\n\n hunks.forEach((hunk, hunkIndex) => {\n const old = hunk.lines.filter((line) => line.op !== \"+\").map((line) => line.text);\n // For a hunk with no old lines `oldStart` is the line *after which* to insert.\n const expected = (old.length === 0 ? hunk.oldStart : hunk.oldStart - 1) + delta;\n let start = findMatch(lines, old, expected, cursor, bare);\n\n if (start === -1) {\n start = findMatch(lines, old, expected, cursor, loose);\n }\n\n if (start === -1) {\n const probe = old.find((line) => line.trim() !== \"\") ?? old[0] ?? \"\";\n\n throw new WorkspaceEditError(\n `Hunk ${hunkIndex + 1} of ${displayPath} does not apply: its context was not found in the file${probe === \"\" ? \"\" : ` (looked for a line like ${JSON.stringify(probe.slice(0, 80))})`}. Re-read the file and regenerate the patch.`,\n { type: \"hunk-failed\", path: displayPath, hunkIndex },\n );\n }\n\n const replacement: string[] = [];\n let consumed = start;\n\n for (const line of hunk.lines) {\n if (line.op === \" \") {\n replacement.push(lines[consumed] as string);\n consumed++;\n } else if (line.op === \"-\") {\n consumed++;\n } else {\n replacement.push(line.text + eol);\n }\n }\n\n lines.splice(start, old.length, ...replacement);\n cursor = start + replacement.length;\n delta += replacement.length - old.length;\n\n if (cursor >= lines.length) {\n if (hunk.newNoNewline) {\n trailingNewline = false;\n } else if (hunk.oldNoNewline || wasEmpty) {\n trailingNewline = true;\n }\n }\n });\n\n if (lines.length === 0) {\n return \"\";\n }\n\n // A final line that kept its \"\\r\" but now ends the file without a newline\n // is left alone; only the newline itself is toggled.\n return lines.join(\"\\n\") + (trailingNewline ? \"\\n\" : \"\");\n}\n"],"mappings":";;;;AAIA,MAAM,QAAQ,SAA0B,KAAK,SAAS,IAAI,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI;;AAGlF,MAAM,SAAS,SAAyB,KAAK,IAAI,CAAC,CAAC,QAAQ;;AAG3D,SAAS,MAAM,SAAgE;CAC7E,IAAI,YAAY,IACd,OAAO;EAAE,OAAO,CAAC;EAAG,iBAAiB;CAAM;CAG7C,MAAM,QAAQ,QAAQ,MAAM,IAAI;CAChC,MAAM,kBAAkB,MAAM,MAAM,SAAS,OAAO;CAEpD,IAAI,iBACF,MAAM,IAAI;CAGZ,OAAO;EAAE,OAAO;EAAO;CAAgB;AACzC;;AAGA,SAAS,SAAS,OAA0B;CAC1C,MAAM,OAAO,MAAM,QAAQ,SAAS,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC;CAEzD,OAAO,OAAO,KAAK,OAAO,KAAK,MAAM;AACvC;;AAGA,SAAS,UACP,OACA,KACA,UACA,MACA,WACQ;CACR,IAAI,IAAI,WAAW,GACjB,OAAO,KAAK,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG,MAAM,MAAM;CAGxD,MAAM,OAAO,MAAM,SAAS,IAAI;CAChC,MAAM,SAAS,IAAI,IAAI,SAAS;CAChC,MAAM,WAAW,UACf,SAAS,QACT,SAAS,QACT,OAAO,OAAO,MAAM,WAAW,UAAU,MAAM,QAAQ,OAAiB,MAAM,IAAI;CAEpF,KAAK,IAAI,WAAW,GAAG,YAAY,MAAM,QAAQ,YAAY;EAC3D,IAAI,QAAQ,WAAW,QAAQ,GAC7B,OAAO,WAAW;EAGpB,IAAI,WAAW,KAAK,QAAQ,WAAW,QAAQ,GAC7C,OAAO,WAAW;CAEtB;CAEA,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,WAAW,SAAiB,OAAoB,aAA6B;CAC3F,MAAM,EAAE,OAAO,iBAAiB,oBAAoB,MAAM,OAAO;CACjE,MAAM,WAAW,MAAM,WAAW;CAClC,MAAM,MAAM,SAAS,KAAK,IAAI,OAAO;CACrC,IAAI,kBAAkB;CACtB,IAAI,SAAS;CACb,IAAI,QAAQ;CAEZ,MAAM,SAAS,MAAM,cAAc;EACjC,MAAM,MAAM,KAAK,MAAM,QAAQ,SAAS,KAAK,OAAO,GAAG,CAAC,CAAC,KAAK,SAAS,KAAK,IAAI;EAEhF,MAAM,YAAY,IAAI,WAAW,IAAI,KAAK,WAAW,KAAK,WAAW,KAAK;EAC1E,IAAI,QAAQ,UAAU,OAAO,KAAK,UAAU,QAAQ,IAAI;EAExD,IAAI,UAAU,IACZ,QAAQ,UAAU,OAAO,KAAK,UAAU,QAAQ,KAAK;EAGvD,IAAI,UAAU,IAAI;GAChB,MAAM,QAAQ,IAAI,MAAM,SAAS,KAAK,KAAK,MAAM,EAAE,KAAK,IAAI,MAAM;GAElE,MAAM,IAAI,mBACR,QAAQ,YAAY,EAAE,MAAM,YAAY,wDAAwD,UAAU,KAAK,KAAK,4BAA4B,KAAK,UAAU,MAAM,MAAM,GAAG,EAAE,CAAC,EAAE,GAAG,+CACtL;IAAE,MAAM;IAAe,MAAM;IAAa;GAAU,CACtD;EACF;EAEA,MAAM,cAAwB,CAAC;EAC/B,IAAI,WAAW;EAEf,KAAK,MAAM,QAAQ,KAAK,OACtB,IAAI,KAAK,OAAO,KAAK;GACnB,YAAY,KAAK,MAAM,SAAmB;GAC1C;EACF,OAAO,IAAI,KAAK,OAAO,KACrB;OAEA,YAAY,KAAK,KAAK,OAAO,GAAG;EAIpC,MAAM,OAAO,OAAO,IAAI,QAAQ,GAAG,WAAW;EAC9C,SAAS,QAAQ,YAAY;EAC7B,SAAS,YAAY,SAAS,IAAI;EAElC,IAAI,UAAU,MAAM,QAAQ;GAC1B,IAAI,KAAK,cACP,kBAAkB;QACb,IAAI,KAAK,gBAAgB,UAC9B,kBAAkB;EAEtB;CACF,CAAC;CAED,IAAI,MAAM,WAAW,GACnB,OAAO;CAKT,OAAO,MAAM,KAAK,IAAI,KAAK,kBAAkB,OAAO;AACtD"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ApplyPatchInput, ApplyPatchResult } from "../contracts/tool-io.type.mjs";
|
|
2
|
+
import { WorkspaceOps } from "../contracts/workspace-ops.contract.mjs";
|
|
3
|
+
import "../contracts/index.mjs";
|
|
4
|
+
//#region ../../packages/ai-workspace/src/patch/apply-patch-generic.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* `applyPatch` for a custom {@link WorkspaceOps} that does not provide its
|
|
7
|
+
* own: the same engine and the same all-or-nothing behaviour, built on the
|
|
8
|
+
* ops it does have (`exists`, `readFile`, `writeFile`, `remove`). Every
|
|
9
|
+
* hunk of every file is checked in memory first; the writes then run, and if
|
|
10
|
+
* one fails the ones already done are put back.
|
|
11
|
+
*/
|
|
12
|
+
export declare function applyPatchWithOps(ops: WorkspaceOps, input: ApplyPatchInput): Promise<ApplyPatchResult>;
|
|
13
|
+
//#endregion
|
|
14
|
+
//# sourceMappingURL=apply-patch-generic.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply-patch-generic.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/patch/apply-patch-generic.ts"],"mappings":";;;;;;;;;;;wBAiDgB,kBACd,KAAK,cACL,OAAO,kBACN,QAAQ"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { WorkspaceEditError } from "../errors.mjs";
|
|
2
|
+
import { executePatch } from "./execute-patch.mjs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fs } from "@warlock.js/fs";
|
|
5
|
+
|
|
6
|
+
//#region ../../packages/ai-workspace/src/patch/apply-patch-generic.ts
|
|
7
|
+
/** Most lines the generic reader asks `readFile` for. */
|
|
8
|
+
const MAX_READ_LINES = 1e6;
|
|
9
|
+
/**
|
|
10
|
+
* Raw content of a file through `ops.readFile`, which returns numbered lines.
|
|
11
|
+
* The numbering is stripped and the trailing newline restored by checking the
|
|
12
|
+
* result against the file's hash, so the content is exact or the read fails.
|
|
13
|
+
*/
|
|
14
|
+
async function readRaw(ops, filePath) {
|
|
15
|
+
const result = await ops.readFile(filePath, {
|
|
16
|
+
offset: 1,
|
|
17
|
+
limit: MAX_READ_LINES
|
|
18
|
+
});
|
|
19
|
+
if (result.truncated || result.bytesCapped) throw new WorkspaceEditError(`"${filePath}" is too large to read in full; it cannot be patched.`, {
|
|
20
|
+
type: "patch-invalid",
|
|
21
|
+
path: filePath
|
|
22
|
+
});
|
|
23
|
+
const joined = (result.totalLines === 0 ? [] : result.content.split("\n").map((line) => line.replace(/^ *\d+\t/, ""))).join("\n");
|
|
24
|
+
for (const candidate of [
|
|
25
|
+
joined + "\n",
|
|
26
|
+
joined,
|
|
27
|
+
""
|
|
28
|
+
]) if (fs.hash.string(candidate) === result.hash) return candidate;
|
|
29
|
+
throw new WorkspaceEditError(`Could not read "${filePath}" exactly; it cannot be patched.`, {
|
|
30
|
+
type: "patch-invalid",
|
|
31
|
+
path: filePath
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `applyPatch` for a custom {@link WorkspaceOps} that does not provide its
|
|
36
|
+
* own: the same engine and the same all-or-nothing behaviour, built on the
|
|
37
|
+
* ops it does have (`exists`, `readFile`, `writeFile`, `remove`). Every
|
|
38
|
+
* hunk of every file is checked in memory first; the writes then run, and if
|
|
39
|
+
* one fails the ones already done are put back.
|
|
40
|
+
*/
|
|
41
|
+
function applyPatchWithOps(ops, input) {
|
|
42
|
+
const chain = /* @__PURE__ */ new Map();
|
|
43
|
+
return executePatch(input, {
|
|
44
|
+
resolve: async (inputPath) => {
|
|
45
|
+
const key = path.posix.normalize(inputPath.replace(/\\/g, "/"));
|
|
46
|
+
return {
|
|
47
|
+
absolutePath: key,
|
|
48
|
+
relativePath: key
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
exists: (key) => ops.exists(key),
|
|
52
|
+
read: (key) => readRaw(ops, key),
|
|
53
|
+
write: async (key, content) => {
|
|
54
|
+
await ops.writeFile(key, content);
|
|
55
|
+
},
|
|
56
|
+
remove: (key) => ops.remove(key),
|
|
57
|
+
withLock: async (key, task) => {
|
|
58
|
+
const current = (chain.get(key) ?? Promise.resolve()).then(task, task);
|
|
59
|
+
chain.set(key, current.catch(() => void 0));
|
|
60
|
+
return current;
|
|
61
|
+
},
|
|
62
|
+
display: (key) => key
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
export { applyPatchWithOps };
|
|
68
|
+
//# sourceMappingURL=apply-patch-generic.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply-patch-generic.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/patch/apply-patch-generic.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fs } from \"@warlock.js/fs\";\nimport { WorkspaceEditError } from \"../errors\";\nimport type { ApplyPatchInput, ApplyPatchResult, WorkspaceOps } from \"../contracts\";\nimport { executePatch } from \"./execute-patch\";\n\n/** Most lines the generic reader asks `readFile` for. */\nconst MAX_READ_LINES = 1_000_000;\n\n/**\n * Raw content of a file through `ops.readFile`, which returns numbered lines.\n * The numbering is stripped and the trailing newline restored by checking the\n * result against the file's hash, so the content is exact or the read fails.\n */\nasync function readRaw(ops: WorkspaceOps, filePath: string): Promise<string> {\n const result = await ops.readFile(filePath, { offset: 1, limit: MAX_READ_LINES });\n\n if (result.truncated || result.bytesCapped) {\n throw new WorkspaceEditError(\n `\"${filePath}\" is too large to read in full; it cannot be patched.`,\n { type: \"patch-invalid\", path: filePath },\n );\n }\n\n const lines =\n result.totalLines === 0\n ? []\n : result.content.split(\"\\n\").map((line) => line.replace(/^ *\\d+\\t/, \"\"));\n const joined = lines.join(\"\\n\");\n\n for (const candidate of [joined + \"\\n\", joined, \"\"]) {\n if (fs.hash.string(candidate) === result.hash) {\n return candidate;\n }\n }\n\n throw new WorkspaceEditError(`Could not read \"${filePath}\" exactly; it cannot be patched.`, {\n type: \"patch-invalid\",\n path: filePath,\n });\n}\n\n/**\n * `applyPatch` for a custom {@link WorkspaceOps} that does not provide its\n * own: the same engine and the same all-or-nothing behaviour, built on the\n * ops it does have (`exists`, `readFile`, `writeFile`, `remove`). Every\n * hunk of every file is checked in memory first; the writes then run, and if\n * one fails the ones already done are put back.\n */\nexport function applyPatchWithOps(\n ops: WorkspaceOps,\n input: ApplyPatchInput,\n): Promise<ApplyPatchResult> {\n // Keys are normalised relative paths; the ops enforce their own jail on each call.\n const chain = new Map<string, Promise<unknown>>();\n\n return executePatch(input, {\n resolve: async (inputPath) => {\n const key = path.posix.normalize(inputPath.replace(/\\\\/g, \"/\"));\n\n return { absolutePath: key, relativePath: key };\n },\n exists: (key) => ops.exists(key),\n read: (key) => readRaw(ops, key),\n write: async (key, content) => {\n await ops.writeFile(key, content);\n },\n remove: (key) => ops.remove(key),\n withLock: async (key, task) => {\n const previous = chain.get(key) ?? Promise.resolve();\n const current = previous.then(task, task);\n\n chain.set(\n key,\n current.catch(() => undefined),\n );\n\n return current;\n },\n display: (key) => key,\n });\n}\n"],"mappings":";;;;;;;AAOA,MAAM,iBAAiB;;;;;;AAOvB,eAAe,QAAQ,KAAmB,UAAmC;CAC3E,MAAM,SAAS,MAAM,IAAI,SAAS,UAAU;EAAE,QAAQ;EAAG,OAAO;CAAe,CAAC;CAEhF,IAAI,OAAO,aAAa,OAAO,aAC7B,MAAM,IAAI,mBACR,IAAI,SAAS,wDACb;EAAE,MAAM;EAAiB,MAAM;CAAS,CAC1C;CAOF,MAAM,UAHJ,OAAO,eAAe,IAClB,CAAC,IACD,OAAO,QAAQ,MAAM,IAAI,CAAC,CAAC,KAAK,SAAS,KAAK,QAAQ,YAAY,EAAE,CAAC,EACvD,CAAC,KAAK,IAAI;CAE9B,KAAK,MAAM,aAAa;EAAC,SAAS;EAAM;EAAQ;CAAE,GAChD,IAAI,GAAG,KAAK,OAAO,SAAS,MAAM,OAAO,MACvC,OAAO;CAIX,MAAM,IAAI,mBAAmB,mBAAmB,SAAS,mCAAmC;EAC1F,MAAM;EACN,MAAM;CACR,CAAC;AACH;;;;;;;;AASA,SAAgB,kBACd,KACA,OAC2B;CAE3B,MAAM,wBAAQ,IAAI,IAA8B;CAEhD,OAAO,aAAa,OAAO;EACzB,SAAS,OAAO,cAAc;GAC5B,MAAM,MAAM,KAAK,MAAM,UAAU,UAAU,QAAQ,OAAO,GAAG,CAAC;GAE9D,OAAO;IAAE,cAAc;IAAK,cAAc;GAAI;EAChD;EACA,SAAS,QAAQ,IAAI,OAAO,GAAG;EAC/B,OAAO,QAAQ,QAAQ,KAAK,GAAG;EAC/B,OAAO,OAAO,KAAK,YAAY;GAC7B,MAAM,IAAI,UAAU,KAAK,OAAO;EAClC;EACA,SAAS,QAAQ,IAAI,OAAO,GAAG;EAC/B,UAAU,OAAO,KAAK,SAAS;GAE7B,MAAM,WADW,MAAM,IAAI,GAAG,KAAK,QAAQ,QAAQ,EAC3B,CAAC,KAAK,MAAM,IAAI;GAExC,MAAM,IACJ,KACA,QAAQ,YAAY,MAAS,CAC/B;GAEA,OAAO;EACT;EACA,UAAU,QAAQ;CACpB,CAAC;AACH"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { WorkspaceEditError } from "../errors.mjs";
|
|
2
|
+
import { applyHunks } from "./apply-hunks.mjs";
|
|
3
|
+
import { parsePatch } from "./parse-patch.mjs";
|
|
4
|
+
import { fs } from "@warlock.js/fs";
|
|
5
|
+
|
|
6
|
+
//#region ../../packages/ai-workspace/src/patch/execute-patch.ts
|
|
7
|
+
/** Largest file, in UTF-8 bytes, `applyPatch` will read and rewrite. */
|
|
8
|
+
const MAX_PATCH_FILE_BYTES = 10485760;
|
|
9
|
+
async function executePatch(input, io) {
|
|
10
|
+
{
|
|
11
|
+
const filePatches = parsePatch(input.patch);
|
|
12
|
+
const targets = await Promise.all(filePatches.map(async (filePatch) => ({
|
|
13
|
+
filePatch,
|
|
14
|
+
from: filePatch.oldPath === null ? null : await io.resolve(filePatch.oldPath),
|
|
15
|
+
to: filePatch.newPath === null ? null : await io.resolve(filePatch.newPath)
|
|
16
|
+
})));
|
|
17
|
+
const lockPaths = [...new Set(targets.flatMap((target) => [target.from?.absolutePath, target.to?.absolutePath].filter((value) => value !== void 0)))].sort();
|
|
18
|
+
const run = async () => {
|
|
19
|
+
const staged = /* @__PURE__ */ new Map();
|
|
20
|
+
const original = /* @__PURE__ */ new Map();
|
|
21
|
+
const results = [];
|
|
22
|
+
const load = async (absolutePath, shown) => {
|
|
23
|
+
if (staged.has(absolutePath)) return staged.get(absolutePath) ?? null;
|
|
24
|
+
if (!await io.exists(absolutePath)) {
|
|
25
|
+
original.set(absolutePath, null);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const stat = io.stat ? await io.stat(absolutePath) : void 0;
|
|
29
|
+
if (stat !== void 0 && stat.type !== "file") throw new WorkspaceEditError(`"${shown}" is not a regular file; it cannot be patched.`, {
|
|
30
|
+
type: "file-missing",
|
|
31
|
+
path: shown
|
|
32
|
+
});
|
|
33
|
+
if (stat !== void 0 && stat.size > MAX_PATCH_FILE_BYTES) throw new WorkspaceEditError(`"${shown}" is larger than ${MAX_PATCH_FILE_BYTES} bytes; it cannot be patched.`, {
|
|
34
|
+
type: "patch-invalid",
|
|
35
|
+
path: shown
|
|
36
|
+
});
|
|
37
|
+
const content = await io.read(absolutePath);
|
|
38
|
+
if (content.includes("\0")) throw new WorkspaceEditError(`"${shown}" is a binary file; binary files cannot be patched.`, {
|
|
39
|
+
type: "binary-file",
|
|
40
|
+
path: shown
|
|
41
|
+
});
|
|
42
|
+
original.set(absolutePath, content);
|
|
43
|
+
return content;
|
|
44
|
+
};
|
|
45
|
+
for (const { filePatch, from, to } of targets) {
|
|
46
|
+
const shownFrom = from?.relativePath || filePatch.oldPath || "";
|
|
47
|
+
const shownTo = to?.relativePath || filePatch.newPath || "";
|
|
48
|
+
if (filePatch.kind === "create") {
|
|
49
|
+
const absolutePath = to.absolutePath;
|
|
50
|
+
if (await load(absolutePath, shownTo) !== null) throw new WorkspaceEditError(`"${shownTo}" already exists; the patch cannot create it.`, {
|
|
51
|
+
type: "file-exists",
|
|
52
|
+
path: shownTo
|
|
53
|
+
});
|
|
54
|
+
const content = applyHunks("", filePatch.hunks, shownTo);
|
|
55
|
+
staged.set(absolutePath, content);
|
|
56
|
+
results.push({
|
|
57
|
+
path: shownTo,
|
|
58
|
+
action: "created",
|
|
59
|
+
hunks: filePatch.hunks.length,
|
|
60
|
+
hash: fs.hash.string(content)
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const source = from.absolutePath;
|
|
65
|
+
const current = await load(source, shownFrom);
|
|
66
|
+
if (current === null) throw new WorkspaceEditError(`"${shownFrom}" does not exist; the patch cannot change it.`, {
|
|
67
|
+
type: "file-missing",
|
|
68
|
+
path: shownFrom
|
|
69
|
+
});
|
|
70
|
+
const updated = applyHunks(current, filePatch.hunks, shownFrom);
|
|
71
|
+
if (filePatch.kind === "delete") {
|
|
72
|
+
if (updated !== "") throw new WorkspaceEditError(`The patch deletes "${shownFrom}" but its hunks do not remove all of the file's content.`, {
|
|
73
|
+
type: "hunk-failed",
|
|
74
|
+
path: shownFrom,
|
|
75
|
+
hunkIndex: Math.max(filePatch.hunks.length - 1, 0)
|
|
76
|
+
});
|
|
77
|
+
staged.set(source, null);
|
|
78
|
+
results.push({
|
|
79
|
+
path: shownFrom,
|
|
80
|
+
action: "deleted",
|
|
81
|
+
hunks: filePatch.hunks.length
|
|
82
|
+
});
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (filePatch.kind === "rename") {
|
|
86
|
+
const destination = to.absolutePath;
|
|
87
|
+
if (destination !== source && await load(destination, shownTo) !== null) throw new WorkspaceEditError(`"${shownTo}" already exists; the patch cannot rename "${shownFrom}" onto it.`, {
|
|
88
|
+
type: "file-exists",
|
|
89
|
+
path: shownTo
|
|
90
|
+
});
|
|
91
|
+
staged.set(source, null);
|
|
92
|
+
staged.set(destination, updated);
|
|
93
|
+
results.push({
|
|
94
|
+
path: shownTo,
|
|
95
|
+
action: "renamed",
|
|
96
|
+
oldPath: shownFrom,
|
|
97
|
+
hunks: filePatch.hunks.length,
|
|
98
|
+
hash: fs.hash.string(updated)
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
staged.set(source, updated);
|
|
103
|
+
results.push({
|
|
104
|
+
path: shownFrom,
|
|
105
|
+
action: "modified",
|
|
106
|
+
hunks: filePatch.hunks.length,
|
|
107
|
+
hash: fs.hash.string(updated)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (input.dryRun === true) return {
|
|
111
|
+
files: results,
|
|
112
|
+
dryRun: true
|
|
113
|
+
};
|
|
114
|
+
const touched = [];
|
|
115
|
+
try {
|
|
116
|
+
for (const [absolutePath, content] of staged) if (content !== null && content !== (original.get(absolutePath) ?? null)) {
|
|
117
|
+
await io.write(absolutePath, content);
|
|
118
|
+
touched.push(absolutePath);
|
|
119
|
+
}
|
|
120
|
+
for (const [absolutePath, content] of staged) if (content === null && original.get(absolutePath) !== null && original.has(absolutePath)) {
|
|
121
|
+
await io.remove(absolutePath);
|
|
122
|
+
touched.push(absolutePath);
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const failures = [];
|
|
126
|
+
for (const absolutePath of touched.reverse()) try {
|
|
127
|
+
const before = original.get(absolutePath) ?? null;
|
|
128
|
+
if (before === null) {
|
|
129
|
+
if (await io.exists(absolutePath)) await io.remove(absolutePath);
|
|
130
|
+
} else await io.write(absolutePath, before);
|
|
131
|
+
} catch {
|
|
132
|
+
failures.push(io.display(absolutePath));
|
|
133
|
+
}
|
|
134
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
135
|
+
throw new WorkspaceEditError(`Writing the patch failed (${reason}); ${failures.length === 0 ? "every change was rolled back" : `could not restore: ${failures.join(", ")}`}.`, {
|
|
136
|
+
type: "patch-invalid",
|
|
137
|
+
path: ""
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
files: results,
|
|
142
|
+
dryRun: false
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
return lockPaths.reduceRight((inner, lockPath) => () => io.withLock(lockPath, inner), run)();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
150
|
+
export { executePatch };
|
|
151
|
+
//# sourceMappingURL=execute-patch.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"execute-patch.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/patch/execute-patch.ts"],"sourcesContent":["import { fs } from \"@warlock.js/fs\";\nimport { WorkspaceEditError } from \"../errors\";\nimport type { ApplyPatchFileResult, ApplyPatchInput, ApplyPatchResult } from \"../contracts\";\nimport { applyHunks } from \"./apply-hunks\";\nimport { parsePatch } from \"./parse-patch\";\n\n/** Largest file, in UTF-8 bytes, `applyPatch` will read and rewrite. */\nconst MAX_PATCH_FILE_BYTES = 10 * 1024 * 1024;\n\n/** The file operations the patch engine runs on; the workspace ops layer and the generic fallback each provide one. */\nexport interface PatchIO {\n /** Turn a patch path into a stable key plus a display path (and refuse a path outside the workspace). */\n resolve(inputPath: string): Promise<{ absolutePath: string; relativePath: string }>;\n exists(key: string): Promise<boolean>;\n stat?(key: string): Promise<{ type: string; size: number }>;\n /** Raw file content. */\n read(key: string): Promise<string>;\n /** Write content, creating parent directories. */\n write(key: string, content: string): Promise<void>;\n remove(key: string): Promise<void>;\n withLock<T>(key: string, task: () => Promise<T>): Promise<T>;\n display(key: string): string;\n}\n\nexport async function executePatch(input: ApplyPatchInput, io: PatchIO): Promise<ApplyPatchResult> {\n {\n const filePatches = parsePatch(input.patch);\n\n // Resolve every path through the jail first (symlinks, `..`, deny globs).\n const targets = await Promise.all(\n filePatches.map(async (filePatch) => ({\n filePatch,\n from: filePatch.oldPath === null ? null : await io.resolve(filePatch.oldPath),\n to: filePatch.newPath === null ? null : await io.resolve(filePatch.newPath),\n })),\n );\n\n const lockPaths = [\n ...new Set(\n targets.flatMap((target) =>\n [target.from?.absolutePath, target.to?.absolutePath].filter(\n (value): value is string => value !== undefined,\n ),\n ),\n ),\n ].sort();\n\n const run = async (): Promise<ApplyPatchResult> => {\n // Virtual tree: absolute path -> content (null = removed). Later file\n // patches see the result of earlier ones.\n const staged = new Map<string, string | null>();\n const original = new Map<string, string | null>();\n const results: ApplyPatchFileResult[] = [];\n\n const load = async (absolutePath: string, shown: string): Promise<string | null> => {\n if (staged.has(absolutePath)) {\n return staged.get(absolutePath) ?? null;\n }\n\n if (!(await io.exists(absolutePath))) {\n original.set(absolutePath, null);\n\n return null;\n }\n\n const stat = io.stat ? await io.stat(absolutePath) : undefined;\n\n if (stat !== undefined && stat.type !== \"file\") {\n throw new WorkspaceEditError(`\"${shown}\" is not a regular file; it cannot be patched.`, {\n type: \"file-missing\",\n path: shown,\n });\n }\n\n if (stat !== undefined && stat.size > MAX_PATCH_FILE_BYTES) {\n throw new WorkspaceEditError(\n `\"${shown}\" is larger than ${MAX_PATCH_FILE_BYTES} bytes; it cannot be patched.`,\n { type: \"patch-invalid\", path: shown },\n );\n }\n\n const content = await io.read(absolutePath);\n\n if (content.includes(\"\\0\")) {\n throw new WorkspaceEditError(\n `\"${shown}\" is a binary file; binary files cannot be patched.`,\n {\n type: \"binary-file\",\n path: shown,\n },\n );\n }\n\n original.set(absolutePath, content);\n\n return content;\n };\n\n for (const { filePatch, from, to } of targets) {\n const shownFrom = from?.relativePath || filePatch.oldPath || \"\";\n const shownTo = to?.relativePath || filePatch.newPath || \"\";\n\n if (filePatch.kind === \"create\") {\n const absolutePath = (to as NonNullable<typeof to>).absolutePath;\n\n if ((await load(absolutePath, shownTo)) !== null) {\n throw new WorkspaceEditError(\n `\"${shownTo}\" already exists; the patch cannot create it.`,\n {\n type: \"file-exists\",\n path: shownTo,\n },\n );\n }\n\n const content = applyHunks(\"\", filePatch.hunks, shownTo);\n\n staged.set(absolutePath, content);\n results.push({\n path: shownTo,\n action: \"created\",\n hunks: filePatch.hunks.length,\n hash: fs.hash.string(content),\n });\n\n continue;\n }\n\n const source = (from as NonNullable<typeof from>).absolutePath;\n const current = await load(source, shownFrom);\n\n if (current === null) {\n throw new WorkspaceEditError(\n `\"${shownFrom}\" does not exist; the patch cannot change it.`,\n {\n type: \"file-missing\",\n path: shownFrom,\n },\n );\n }\n\n const updated = applyHunks(current, filePatch.hunks, shownFrom);\n\n if (filePatch.kind === \"delete\") {\n if (updated !== \"\") {\n throw new WorkspaceEditError(\n `The patch deletes \"${shownFrom}\" but its hunks do not remove all of the file's content.`,\n {\n type: \"hunk-failed\",\n path: shownFrom,\n hunkIndex: Math.max(filePatch.hunks.length - 1, 0),\n },\n );\n }\n\n staged.set(source, null);\n results.push({ path: shownFrom, action: \"deleted\", hunks: filePatch.hunks.length });\n\n continue;\n }\n\n if (filePatch.kind === \"rename\") {\n const destination = (to as NonNullable<typeof to>).absolutePath;\n\n if (destination !== source && (await load(destination, shownTo)) !== null) {\n throw new WorkspaceEditError(\n `\"${shownTo}\" already exists; the patch cannot rename \"${shownFrom}\" onto it.`,\n { type: \"file-exists\", path: shownTo },\n );\n }\n\n staged.set(source, null);\n staged.set(destination, updated);\n results.push({\n path: shownTo,\n action: \"renamed\",\n oldPath: shownFrom,\n hunks: filePatch.hunks.length,\n hash: fs.hash.string(updated),\n });\n\n continue;\n }\n\n staged.set(source, updated);\n results.push({\n path: shownFrom,\n action: \"modified\",\n hunks: filePatch.hunks.length,\n hash: fs.hash.string(updated),\n });\n }\n\n if (input.dryRun === true) {\n return { files: results, dryRun: true };\n }\n\n // Commit. Anything that fails part-way is rolled back to the original state.\n const touched: string[] = [];\n\n try {\n for (const [absolutePath, content] of staged) {\n if (content !== null && content !== (original.get(absolutePath) ?? null)) {\n await io.write(absolutePath, content);\n touched.push(absolutePath);\n }\n }\n\n for (const [absolutePath, content] of staged) {\n if (\n content === null &&\n original.get(absolutePath) !== null &&\n original.has(absolutePath)\n ) {\n await io.remove(absolutePath);\n touched.push(absolutePath);\n }\n }\n } catch (error) {\n const failures: string[] = [];\n\n for (const absolutePath of touched.reverse()) {\n try {\n const before = original.get(absolutePath) ?? null;\n\n if (before === null) {\n if (await io.exists(absolutePath)) {\n await io.remove(absolutePath);\n }\n } else {\n await io.write(absolutePath, before);\n }\n } catch {\n failures.push(io.display(absolutePath));\n }\n }\n\n const reason = error instanceof Error ? error.message : String(error);\n\n throw new WorkspaceEditError(\n `Writing the patch failed (${reason}); ${failures.length === 0 ? \"every change was rolled back\" : `could not restore: ${failures.join(\", \")}`}.`,\n { type: \"patch-invalid\", path: \"\" },\n );\n }\n\n return { files: results, dryRun: false };\n };\n\n // Hold every touched path's lock (sorted, so concurrent patches cannot deadlock).\n const locked = lockPaths.reduceRight<() => Promise<ApplyPatchResult>>(\n (inner, lockPath) => () => io.withLock(lockPath, inner),\n run,\n );\n\n return locked();\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region ../../packages/ai-workspace/src/patch/parse-patch.d.ts
|
|
2
|
+
/** One line of a hunk: context (`" "`), removal (`"-"`) or addition (`"+"`). */
|
|
3
|
+
export interface PatchLine {
|
|
4
|
+
/** What the line does. */
|
|
5
|
+
op: " " | "-" | "+";
|
|
6
|
+
/** The line's text, without its line ending. */
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
/** One `@@ … @@` block of a file's patch. */
|
|
10
|
+
export interface PatchHunk {
|
|
11
|
+
/** 1-based first line in the old file (the line *after* which text is inserted when `oldCount` is 0). */
|
|
12
|
+
oldStart: number;
|
|
13
|
+
/** Number of old-file lines the hunk covers. */
|
|
14
|
+
oldCount: number;
|
|
15
|
+
/** The hunk body. */
|
|
16
|
+
lines: PatchLine[];
|
|
17
|
+
/** The old side ends without a trailing newline. */
|
|
18
|
+
oldNoNewline: boolean;
|
|
19
|
+
/** The new side ends without a trailing newline. */
|
|
20
|
+
newNoNewline: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** What a file's patch does to the tree. */
|
|
23
|
+
export type FilePatchKind = "modify" | "create" | "delete" | "rename";
|
|
24
|
+
/** Everything a unified diff says about one file. */
|
|
25
|
+
export interface FilePatch {
|
|
26
|
+
kind: FilePatchKind;
|
|
27
|
+
/** Path before the change (`null` for a create). */
|
|
28
|
+
oldPath: string | null;
|
|
29
|
+
/** Path after the change (`null` for a delete). */
|
|
30
|
+
newPath: string | null;
|
|
31
|
+
hunks: PatchHunk[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse a unified diff (plain or `git diff` style) into per-file patches:
|
|
35
|
+
* modifications, creates (`--- /dev/null`), deletes (`+++ /dev/null`) and
|
|
36
|
+
* renames (`rename from` / `rename to`). A binary patch, a copy, or text that
|
|
37
|
+
* is not a diff throws a `WorkspaceEditError` (`patch-invalid` /
|
|
38
|
+
* `binary-file`) naming the problem.
|
|
39
|
+
*
|
|
40
|
+
* A CRLF-wrapped patch is accepted: one trailing `\r` is dropped from every
|
|
41
|
+
* line.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parsePatch(text: string): FilePatch[];
|
|
44
|
+
//#endregion
|
|
45
|
+
//# sourceMappingURL=parse-patch.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-patch.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/patch/parse-patch.ts"],"mappings":";;iBAGiB;;EAEf;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA,OAAO;;EAEP;;EAEA;;;YAIU;;iBAGK;EACf,MAAM;;EAEN;;EAEA;EACA,OAAO;;;;;;;;;;;;wBA2DO,WAAW,eAAe"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { WorkspaceEditError } from "../errors.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/ai-workspace/src/patch/parse-patch.ts
|
|
4
|
+
/** Longest patch text accepted, in characters. */
|
|
5
|
+
const MAX_PATCH_CHARS = 2097152;
|
|
6
|
+
/** Most files one patch may touch. */
|
|
7
|
+
const MAX_PATCH_FILES = 200;
|
|
8
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
9
|
+
function invalid(message, path = "") {
|
|
10
|
+
return new WorkspaceEditError(message, {
|
|
11
|
+
type: "patch-invalid",
|
|
12
|
+
path
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Unquote a git-quoted path (`"a/with\ttab"`) and strip a trailing timestamp. */
|
|
16
|
+
function cleanPath(raw) {
|
|
17
|
+
let value = raw.trim();
|
|
18
|
+
if (value.startsWith("\"")) {
|
|
19
|
+
const end = value.lastIndexOf("\"");
|
|
20
|
+
return (end > 0 ? value.slice(1, end) : value.slice(1)).replace(/\\(["\\tn])/g, (_match, char) => char === "t" ? " " : char === "n" ? "\n" : char);
|
|
21
|
+
}
|
|
22
|
+
const tab = value.indexOf(" ");
|
|
23
|
+
if (tab !== -1) value = value.slice(0, tab);
|
|
24
|
+
return value.trimEnd();
|
|
25
|
+
}
|
|
26
|
+
/** `a/x` → `x`; `/dev/null` → `null`. */
|
|
27
|
+
function stripPrefix(raw, git) {
|
|
28
|
+
const value = cleanPath(raw);
|
|
29
|
+
if (value === "/dev/null") return null;
|
|
30
|
+
return git && /^[ab]\//.test(value) ? value.slice(2) : value;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse a unified diff (plain or `git diff` style) into per-file patches:
|
|
34
|
+
* modifications, creates (`--- /dev/null`), deletes (`+++ /dev/null`) and
|
|
35
|
+
* renames (`rename from` / `rename to`). A binary patch, a copy, or text that
|
|
36
|
+
* is not a diff throws a `WorkspaceEditError` (`patch-invalid` /
|
|
37
|
+
* `binary-file`) naming the problem.
|
|
38
|
+
*
|
|
39
|
+
* A CRLF-wrapped patch is accepted: one trailing `\r` is dropped from every
|
|
40
|
+
* line.
|
|
41
|
+
*/
|
|
42
|
+
function parsePatch(text) {
|
|
43
|
+
if (text.length > 2097152) throw invalid(`The patch is larger than ${MAX_PATCH_CHARS} characters; split it up.`);
|
|
44
|
+
const lines = text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
45
|
+
const files = [];
|
|
46
|
+
let current;
|
|
47
|
+
const finish = () => {
|
|
48
|
+
if (current === void 0) return;
|
|
49
|
+
const state = current;
|
|
50
|
+
current = void 0;
|
|
51
|
+
let dashOld = state.oldPath;
|
|
52
|
+
let dashNew = state.newPath;
|
|
53
|
+
if (!state.git) {
|
|
54
|
+
const oldOk = dashOld === void 0 || dashOld === null || dashOld.startsWith("a/");
|
|
55
|
+
const newOk = dashNew === void 0 || dashNew === null || dashNew.startsWith("b/");
|
|
56
|
+
if (oldOk && newOk) {
|
|
57
|
+
dashOld = typeof dashOld === "string" ? dashOld.slice(2) : dashOld;
|
|
58
|
+
dashNew = typeof dashNew === "string" ? dashNew.slice(2) : dashNew;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let oldPath = state.renameFrom ?? dashOld;
|
|
62
|
+
let newPath = state.renameTo ?? dashNew;
|
|
63
|
+
if (state.isNew) oldPath = null;
|
|
64
|
+
if (state.isDeleted) newPath = null;
|
|
65
|
+
if (oldPath === void 0 && newPath === void 0) throw invalid("A file section of the patch names no file.");
|
|
66
|
+
if (oldPath === void 0) oldPath = newPath ?? null;
|
|
67
|
+
if (newPath === void 0) newPath = oldPath;
|
|
68
|
+
const kind = oldPath === null ? "create" : newPath === null ? "delete" : oldPath !== newPath ? "rename" : "modify";
|
|
69
|
+
if (state.hunks.length === 0 && kind === "modify") return;
|
|
70
|
+
files.push({
|
|
71
|
+
kind,
|
|
72
|
+
oldPath,
|
|
73
|
+
newPath,
|
|
74
|
+
hunks: state.hunks
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
const open = (git) => {
|
|
78
|
+
finish();
|
|
79
|
+
current = {
|
|
80
|
+
git,
|
|
81
|
+
oldPath: void 0,
|
|
82
|
+
newPath: void 0,
|
|
83
|
+
isNew: false,
|
|
84
|
+
isDeleted: false,
|
|
85
|
+
hunks: []
|
|
86
|
+
};
|
|
87
|
+
return current;
|
|
88
|
+
};
|
|
89
|
+
for (let index = 0; index < lines.length; index++) {
|
|
90
|
+
const line = lines[index];
|
|
91
|
+
if (line.startsWith("diff --git ")) {
|
|
92
|
+
const section = open(true);
|
|
93
|
+
const rest = line.slice(11);
|
|
94
|
+
const split = rest.indexOf(" b/");
|
|
95
|
+
if (rest.startsWith("a/") && split !== -1) {
|
|
96
|
+
section.oldPath = rest.slice(2, split);
|
|
97
|
+
section.newPath = rest.slice(split + 3);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (line.startsWith("--- ") && lines[index + 1]?.startsWith("+++ ") === true && (current === void 0 || !current.git && current.hunks.length > 0)) open(false);
|
|
102
|
+
else if (current === void 0) {
|
|
103
|
+
if (line.startsWith("*** Begin Patch")) throw invalid("This is the \"*** Begin Patch\" format, not a unified diff. Send a unified diff (`--- a/file` / `+++ b/file` / `@@` hunks).");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const section = current;
|
|
107
|
+
if (line.startsWith("GIT binary patch") || /^Binary files .* differ$/.test(line)) {
|
|
108
|
+
const shown = section.newPath ?? section.oldPath ?? "a file";
|
|
109
|
+
throw new WorkspaceEditError(`The patch changes a binary file (${shown}); binary files cannot be patched.`, {
|
|
110
|
+
type: "binary-file",
|
|
111
|
+
path: shown ?? ""
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (line.startsWith("copy from ") || line.startsWith("copy to ")) throw invalid("Copies are not supported in a patch; create the new file instead.");
|
|
115
|
+
if (line.startsWith("rename from ")) {
|
|
116
|
+
section.renameFrom = cleanPath(line.slice(12));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (line.startsWith("rename to ")) {
|
|
120
|
+
section.renameTo = cleanPath(line.slice(10));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (line.startsWith("new file mode")) {
|
|
124
|
+
section.isNew = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (line.startsWith("deleted file mode")) {
|
|
128
|
+
section.isDeleted = true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (line.startsWith("--- ") && section.hunks.length === 0) {
|
|
132
|
+
section.oldPath = stripPrefix(line.slice(4), section.git);
|
|
133
|
+
if (section.oldPath === null) section.isNew = true;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (line.startsWith("+++ ") && section.hunks.length === 0) {
|
|
137
|
+
section.newPath = stripPrefix(line.slice(4), section.git);
|
|
138
|
+
if (section.newPath === null) section.isDeleted = true;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const header = HUNK_HEADER.exec(line);
|
|
142
|
+
if (header !== null) {
|
|
143
|
+
const oldStart = Number(header[1]);
|
|
144
|
+
const oldCount = header[2] === void 0 ? 1 : Number(header[2]);
|
|
145
|
+
let newCount = header[4] === void 0 ? 1 : Number(header[4]);
|
|
146
|
+
let remainingOld = oldCount;
|
|
147
|
+
const hunk = {
|
|
148
|
+
oldStart,
|
|
149
|
+
oldCount,
|
|
150
|
+
lines: [],
|
|
151
|
+
oldNoNewline: false,
|
|
152
|
+
newNoNewline: false
|
|
153
|
+
};
|
|
154
|
+
while (remainingOld > 0 || newCount > 0 || lines[index + 1]?.startsWith("\\")) {
|
|
155
|
+
const body = lines[index + 1];
|
|
156
|
+
if (body === void 0) break;
|
|
157
|
+
if (body.startsWith("\\")) {
|
|
158
|
+
const last = hunk.lines[hunk.lines.length - 1];
|
|
159
|
+
if (last?.op !== "+") hunk.oldNoNewline = true;
|
|
160
|
+
if (last?.op !== "-") hunk.newNoNewline = true;
|
|
161
|
+
index++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (remainingOld <= 0 && newCount <= 0) break;
|
|
165
|
+
const op = body.charAt(0);
|
|
166
|
+
if (op === "+" && newCount > 0) {
|
|
167
|
+
hunk.lines.push({
|
|
168
|
+
op: "+",
|
|
169
|
+
text: body.slice(1)
|
|
170
|
+
});
|
|
171
|
+
newCount--;
|
|
172
|
+
} else if (op === "-" && remainingOld > 0) {
|
|
173
|
+
hunk.lines.push({
|
|
174
|
+
op: "-",
|
|
175
|
+
text: body.slice(1)
|
|
176
|
+
});
|
|
177
|
+
remainingOld--;
|
|
178
|
+
} else if ((op === " " || body === "") && remainingOld > 0 && newCount > 0) {
|
|
179
|
+
hunk.lines.push({
|
|
180
|
+
op: " ",
|
|
181
|
+
text: body.slice(1)
|
|
182
|
+
});
|
|
183
|
+
remainingOld--;
|
|
184
|
+
newCount--;
|
|
185
|
+
} else break;
|
|
186
|
+
index++;
|
|
187
|
+
}
|
|
188
|
+
if (remainingOld > 0 || newCount > 0) throw invalid(`Hunk ${section.hunks.length + 1} of ${section.newPath ?? section.oldPath ?? "the patch"} is truncated or its line counts in the @@ header are wrong.`, section.newPath ?? section.oldPath ?? "");
|
|
189
|
+
section.hunks.push(hunk);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
finish();
|
|
194
|
+
if (files.length === 0) throw invalid("The patch contains no file changes. Send a unified diff with ---/+++ headers and @@ hunks.");
|
|
195
|
+
if (files.length > 200) throw invalid(`The patch touches more than ${200} files; split it up.`);
|
|
196
|
+
return files;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
//#endregion
|
|
200
|
+
export { MAX_PATCH_CHARS, parsePatch };
|
|
201
|
+
//# sourceMappingURL=parse-patch.mjs.map
|