fix: align hwpod apply-patch with v2 engine
This commit is contained in:
@@ -38,6 +38,24 @@ test("hwpod-cli workspace apply-patch passes stdin patch to hwpod-node plan", as
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-cli workspace apply-patch accepts --patch-content alias", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-test-patch-content-"));
|
||||
const specPath = path.join(root, "hwpod-spec.yaml");
|
||||
const patch = "*** Begin Patch\n*** Update File: app.c\n@@\n-old\n+new\n*** End Patch\n";
|
||||
try {
|
||||
await writeFile(specPath, hwpodSpecText(), "utf8");
|
||||
const result = await runHwpodCli(["workspace", "apply-patch", "--spec", specPath, "--patch-content", patch, "--dry-run"], {
|
||||
now: () => "2026-06-06T00:00:00.000Z"
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.payload.intent, "workspace.apply-patch");
|
||||
assert.equal(result.payload.plan.ops[0].args.patch, patch);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function hwpodSpecText() {
|
||||
return "kind: Hwpod\nmetadata:\n name: test-hwpod\nspec:\n nodeBinding:\n nodeId: test-node\n workspace:\n path: F:\\\\Work\\\\HWLAB-CASE-F103\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n";
|
||||
}
|
||||
|
||||
@@ -340,6 +340,92 @@ test("hwpod-node preserves CRLF when applying workspace patches", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-node apply-patch accepts UniDesk v2 compatible unified headers and unprefixed context", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-v2-"));
|
||||
try {
|
||||
await writeFile(path.join(root, "main.c"), [
|
||||
"#include <stdio.h>",
|
||||
"int main(void) {",
|
||||
" printf(\"OLD\\r\\n\");",
|
||||
" return 0;",
|
||||
"}",
|
||||
""
|
||||
].join("\n"), "utf8");
|
||||
const result = await executeHwpodNodeOpsPlan({
|
||||
contractVersion: "hwpod-node-ops-v1",
|
||||
planId: "hwpod_plan_patch_v2",
|
||||
hwpodId: "hwpod-local",
|
||||
nodeId: "pc-host-1",
|
||||
ops: [{
|
||||
opId: "op_patch",
|
||||
op: "workspace.apply-patch",
|
||||
args: {
|
||||
workspacePath: root,
|
||||
patch: [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: main.c",
|
||||
"@@ -1,5 +1,6 @@",
|
||||
"#include <stdio.h>",
|
||||
"int main(void) {",
|
||||
"- printf(\"OLD\\r\\n\");",
|
||||
"+ printf(\"NEW\\r\\n\");",
|
||||
"+ printf(\"TRACE\\r\\n\");",
|
||||
" return 0;",
|
||||
"}",
|
||||
"*** End Patch",
|
||||
""
|
||||
].join("\n")
|
||||
}
|
||||
}]
|
||||
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
||||
const content = await readFile(path.join(root, "main.c"), "utf8");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.results[0].output.engine, "codex-apply-patch-v2-compatible");
|
||||
assert.match(result.results[0].output.hints.join("\n"), /unified-diff hunk header/u);
|
||||
assert.match(result.results[0].output.hints.join("\n"), /unprefixed Update File context line/u);
|
||||
assert.match(content, /printf\("NEW\\r\\n"\);/u);
|
||||
assert.match(content, /printf\("TRACE\\r\\n"\);/u);
|
||||
assert.doesNotMatch(content, /printf\("NEW\r\n"\);/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-node apply-patch rejects raw unified diff with a precise format blocker", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-raw-diff-"));
|
||||
try {
|
||||
await writeFile(path.join(root, "main.c"), "int value = 1;\n", "utf8");
|
||||
const result = await executeHwpodNodeOpsPlan({
|
||||
contractVersion: "hwpod-node-ops-v1",
|
||||
planId: "hwpod_plan_patch_raw_diff",
|
||||
hwpodId: "hwpod-local",
|
||||
nodeId: "pc-host-1",
|
||||
ops: [{
|
||||
opId: "op_patch",
|
||||
op: "workspace.apply-patch",
|
||||
args: {
|
||||
workspacePath: root,
|
||||
patch: [
|
||||
"--- a/main.c",
|
||||
"+++ b/main.c",
|
||||
"@@ -1 +1 @@",
|
||||
"-int value = 1;",
|
||||
"+int value = 2;",
|
||||
""
|
||||
].join("\n")
|
||||
}
|
||||
}]
|
||||
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.results[0].blocker.code, "unsupported_apply_patch_format");
|
||||
assert.equal(result.results[0].blocker.details.engine, "codex-apply-patch-v2-compatible");
|
||||
assert.equal(result.results[0].blocker.details.format, "unified-diff");
|
||||
assert.match(result.results[0].blocker.summary, /Codex\/UniDesk apply_patch v2 envelope/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-node returns apply-patch diagnostics when context is missing", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-diagnostics-"));
|
||||
try {
|
||||
|
||||
@@ -43,7 +43,7 @@ async function readCliStdinForCommand(argv: string[]): Promise<string | undefine
|
||||
const parsed = parseOptions(argv);
|
||||
const command = parsed._[0] || "help";
|
||||
const subcommand = parsed._[1] || "";
|
||||
if (command === "workspace" && ["apply-patch", "write"].includes(subcommand) && parsed.patch === undefined && parsed.patchText === undefined && parsed.patchBase64 === undefined && parsed.content === undefined && parsed.contentText === undefined && parsed.contentBase64 === undefined && !process.stdin.isTTY) {
|
||||
if (command === "workspace" && ["apply-patch", "write"].includes(subcommand) && parsed.patch === undefined && parsed.patchText === undefined && parsed.patchContent === undefined && parsed.patchBase64 === undefined && parsed.content === undefined && parsed.contentText === undefined && parsed.contentBase64 === undefined && !process.stdin.isTTY) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
@@ -199,7 +199,7 @@ function cliHelp() {
|
||||
],
|
||||
workspace: {
|
||||
cat: "Read a subject workspace file through hwpod-node; path is relative to spec.workspace.path. `read` is a CLI alias for `cat`.",
|
||||
applyPatch: "Apply a Codex apply_patch envelope through hwpod-node. Patch content may come from stdin, --patch/--patchText, or --patchBase64.",
|
||||
applyPatch: "Apply a Codex/UniDesk apply_patch v2 envelope through hwpod-node. Patch content may come from stdin, --patch/--patchText/--patch-content, or --patchBase64. Raw unified diff is rejected with a format hint.",
|
||||
write: "Write a whole file with optional --expected-sha, --line-ending preserve|lf|crlf and dry-run diff output.",
|
||||
replace: "Replace exact text in a subject file with SHA/diff diagnostics; pass --all for global replacement.",
|
||||
insertAfter: "Insert one or more lines after an exact anchor without shell quoting."
|
||||
@@ -248,13 +248,17 @@ function hwpodWorkspaceHelp(subcommand = "") {
|
||||
"hwpod workspace write --path <path> --content <text> --expected-sha <sha>",
|
||||
"hwpod workspace replace --path <path> --find <text> --replace <text> --expected-sha <sha>",
|
||||
"hwpod workspace insert-after --path <path> --anchor <text> --line <text> --expected-sha <sha>",
|
||||
"cat patch.txt | hwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason <reason>"
|
||||
"cat patch.txt | hwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason <reason>",
|
||||
"hwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --patch-content '<apply_patch v2 envelope>'"
|
||||
],
|
||||
aliases: {
|
||||
read: "workspace.cat",
|
||||
file: "--path",
|
||||
remotePath: "--path"
|
||||
remotePath: "--path",
|
||||
patchContent: "--patch"
|
||||
},
|
||||
applyPatchEngine: "codex-apply-patch-v2-compatible",
|
||||
applyPatchBoundary: "workspace apply-patch accepts a Codex/UniDesk v2 envelope. Put target paths in *** Update/Add/Delete File headers; --remote-path is only a path alias for file-oriented workspace commands, not a substitute for envelope headers.",
|
||||
boundary: "Workspace file operations are the standard editing path; do not use shell quoting for subject text edits unless debugging cmd.run itself."
|
||||
});
|
||||
}
|
||||
@@ -421,7 +425,7 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
|
||||
if (subcommand === "ls") return { intent: "workspace.ls", args: { path: text(parsed.path ?? parsed._[2] ?? ".") } };
|
||||
if (subcommand === "cat" || subcommand === "read") return { intent: "workspace.cat", args: { path: requiredWorkspacePath(parsed, 2) } };
|
||||
if (subcommand === "rg") return { intent: "workspace.rg", args: { pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"), path: text(parsed.path ?? parsed._[3] ?? "."), ignoreCase: parsed.ignoreCase === true } };
|
||||
if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
|
||||
if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? parsed.patchContent ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
|
||||
if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredWorkspacePath(parsed, 2), content: patchText(parsed.content ?? parsed.contentText ?? stdinText), contentBase64: text(parsed.contentBase64), expectedSha: text(parsed.expectedSha), lineEnding: text(parsed.lineEnding), finalNewline: parsed.finalNewline === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
|
||||
if (subcommand === "replace") return { intent: "workspace.replace", args: cleanTextEditArgs({ path: requiredWorkspacePath(parsed, 2), find: requiredRawString(parsed.find ?? parsed._[3], "find"), replace: rawString(parsed.replace ?? parsed.replacement ?? parsed._[4]), expectedSha: text(parsed.expectedSha), all: parsed.all === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
|
||||
if (subcommand === "insert-after") return { intent: "workspace.insert-after", args: cleanTextEditArgs({ path: requiredWorkspacePath(parsed, 2), anchor: requiredRawString(parsed.anchor ?? parsed.marker ?? parsed._[3], "anchor"), line: requiredRawString(parsed.line ?? parsed.content ?? parsed.insert ?? parsed.text ?? parsed._[4], "line"), expectedSha: text(parsed.expectedSha), allowMultiple: parsed.allowMultiple === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
|
||||
|
||||
+474
-70
@@ -386,10 +386,11 @@ async function workspaceRg(args: any) {
|
||||
}
|
||||
|
||||
async function workspaceApplyPatch(args: any) {
|
||||
const patch = text(args.patch) || (text(args.patchBase64) ? Buffer.from(text(args.patchBase64), "base64").toString("utf8") : "");
|
||||
if (!patch) throw cliError("patch_required", "patch or patchBase64 is required");
|
||||
const patch = text(args.patch) || text(args.patchContent) || (text(args.patchBase64) ? Buffer.from(text(args.patchBase64), "base64").toString("utf8") : "");
|
||||
if (!patch) throw cliError("patch_required", "patch, patchContent, or patchBase64 is required", { acceptedArgs: ["patch", "patchContent", "patchBase64"], engine: APPLY_PATCH_ENGINE });
|
||||
const cwd = workspaceRoot(args);
|
||||
return { cwd, changes: await applyPatchEnvelope(cwd, patch) };
|
||||
const result = await applyPatchEnvelope(cwd, patch);
|
||||
return { cwd, ...result };
|
||||
}
|
||||
|
||||
async function workspaceWrite(args: any) {
|
||||
@@ -514,78 +515,481 @@ function normalizeCommandRun(value: any, index: number) {
|
||||
return { command, argv, commandLine: text(value.commandLine), timeoutMs: value.timeoutMs };
|
||||
}
|
||||
|
||||
const APPLY_PATCH_ENGINE = "codex-apply-patch-v2-compatible";
|
||||
const APPLY_PATCH_BEGIN_MARKER = "*** Begin Patch";
|
||||
const APPLY_PATCH_END_MARKER = "*** End Patch";
|
||||
const APPLY_PATCH_ENVIRONMENT_MARKER = "*** Environment ID: ";
|
||||
const APPLY_PATCH_ADD_FILE_MARKER = "*** Add File: ";
|
||||
const APPLY_PATCH_DELETE_FILE_MARKER = "*** Delete File: ";
|
||||
const APPLY_PATCH_UPDATE_FILE_MARKER = "*** Update File: ";
|
||||
const APPLY_PATCH_MOVE_TO_MARKER = "*** Move to: ";
|
||||
const APPLY_PATCH_EOF_MARKER = "*** End of File";
|
||||
|
||||
type HwpodPatchHunk =
|
||||
| { kind: "add"; path: string; content: string }
|
||||
| { kind: "delete"; path: string }
|
||||
| { kind: "update"; path: string; movePath: string | null; chunks: HwpodPatchChunk[] };
|
||||
|
||||
type HwpodPatchChunk = {
|
||||
changeContext: string | null;
|
||||
sourceStartLine: number | null;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
contextLinePairs: Array<{ oldIndex: number; newIndex: number }>;
|
||||
contextLineCount: number;
|
||||
addedLineCount: number;
|
||||
deletedLineCount: number;
|
||||
isEndOfFile: boolean;
|
||||
};
|
||||
|
||||
type HwpodPatchReplacement = [start: number, oldLength: number, newLines: string[]];
|
||||
|
||||
async function applyPatchEnvelope(root: string, patch: string) {
|
||||
const lines = patch.replace(/\r\n?/gu, "\n").split("\n");
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
if (lines[0] !== "*** Begin Patch" || lines[lines.length - 1] !== "*** End Patch") {
|
||||
throw cliError("invalid_apply_patch_envelope", "patch must start with *** Begin Patch and end with *** End Patch");
|
||||
const parsed = parseHwpodApplyPatchV2(patch);
|
||||
const changes: any[] = [];
|
||||
const outcomes: any[] = [];
|
||||
for (let index = 0; index < parsed.hunks.length; index += 1) {
|
||||
const hunk = parsed.hunks[index];
|
||||
try {
|
||||
const change = await applyParsedPatchHunk(root, hunk);
|
||||
changes.push(change);
|
||||
outcomes.push({ hunk: index + 1, action: change.action, path: change.path, targetPath: change.targetPath ?? undefined, status: "applied", change });
|
||||
} catch (error: any) {
|
||||
const failed = {
|
||||
hunk: index + 1,
|
||||
action: hunk.kind,
|
||||
path: hunk.path,
|
||||
targetPath: hunk.kind === "update" ? hunk.movePath ?? undefined : undefined,
|
||||
status: "failed",
|
||||
error: { code: error?.code || "apply_patch_v2_failed", message: error?.message || String(error), details: error?.details }
|
||||
};
|
||||
outcomes.push(failed);
|
||||
throw cliError(error?.code || "apply_patch_v2_failed", error?.message || String(error), { ...(error?.details ?? {}), engine: APPLY_PATCH_ENGINE, partialChanges: changes, outcomes, failed, cause: error?.details });
|
||||
}
|
||||
}
|
||||
const changes = [];
|
||||
for (let index = 1; index < lines.length - 1;) {
|
||||
const header = lines[index];
|
||||
if (header.startsWith("*** Add File: ")) {
|
||||
const relativePath = header.slice("*** Add File: ".length).trim();
|
||||
const filePath = resolvePatchFile(root, relativePath);
|
||||
const addLines = [];
|
||||
index += 1;
|
||||
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
||||
if (!lines[index].startsWith("+")) throw cliError("invalid_add_file_hunk", `add file lines must start with + for ${relativePath}`);
|
||||
addLines.push(lines[index].slice(1));
|
||||
index += 1;
|
||||
}
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${addLines.join("\n")}\n`, "utf8");
|
||||
changes.push({ action: "add", path: relativePath, lines: addLines.length });
|
||||
continue;
|
||||
}
|
||||
if (header.startsWith("*** Delete File: ")) {
|
||||
const relativePath = header.slice("*** Delete File: ".length).trim();
|
||||
await rm(resolvePatchFile(root, relativePath), { force: false });
|
||||
changes.push({ action: "delete", path: relativePath });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (header.startsWith("*** Update File: ")) {
|
||||
const relativePath = header.slice("*** Update File: ".length).trim();
|
||||
const filePath = resolvePatchFile(root, relativePath);
|
||||
const hunks = [];
|
||||
index += 1;
|
||||
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
||||
if (!lines[index].startsWith("@@")) throw cliError("invalid_update_hunk", `update hunk must start with @@ for ${relativePath}`);
|
||||
index += 1;
|
||||
const hunkLines = [];
|
||||
while (index < lines.length - 1 && !lines[index].startsWith("@@") && !lines[index].startsWith("*** ")) {
|
||||
const prefix = lines[index][0];
|
||||
if (![" ", "-", "+"].includes(prefix)) throw cliError("invalid_update_hunk_line", `hunk lines must start with space, -, or + for ${relativePath}`);
|
||||
hunkLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
hunks.push(hunkLines);
|
||||
}
|
||||
const applied = await applyUpdateHunks(filePath, hunks, relativePath);
|
||||
changes.push({ action: "update", path: relativePath, hunks: hunks.length, replacements: applied });
|
||||
continue;
|
||||
}
|
||||
throw cliError("unsupported_apply_patch_operation", `unsupported patch operation: ${header}`);
|
||||
}
|
||||
return changes;
|
||||
if (changes.length === 0) throw cliError("apply_patch_no_changes", "No files were modified.", { engine: APPLY_PATCH_ENGINE });
|
||||
return { engine: APPLY_PATCH_ENGINE, changes, hints: parsed.hints, outcomes };
|
||||
}
|
||||
|
||||
async function applyUpdateHunks(filePath: string, hunks: string[][], relativePath: string) {
|
||||
const before = await readTextFileState(filePath);
|
||||
let fileLines = splitNormalizedLines(before.normalizedContent, before.hadFinalNewline);
|
||||
let searchStart = 0;
|
||||
for (const hunk of hunks) {
|
||||
const oldLines = hunk.filter((line) => line[0] !== "+").map((line) => line.slice(1));
|
||||
const newLines = hunk.filter((line) => line[0] !== "-").map((line) => line.slice(1));
|
||||
const at = findLineSequence(fileLines, oldLines, searchStart);
|
||||
if (at < 0) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, diagnosticDetails(before, oldLines.join("\n"), relativePath));
|
||||
fileLines = [...fileLines.slice(0, at), ...newLines, ...fileLines.slice(at + oldLines.length)];
|
||||
searchStart = at + newLines.length;
|
||||
async function applyParsedPatchHunk(root: string, hunk: HwpodPatchHunk) {
|
||||
if (hunk.kind === "add") {
|
||||
const filePath = resolvePatchFile(root, hunk.path);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, hunk.content, "utf8");
|
||||
return { action: "add", path: hunk.path, lines: splitPatchContentLines(hunk.content).length };
|
||||
}
|
||||
const afterNormalized = `${fileLines.join("\n")}${before.hadFinalNewline ? "\n" : ""}`;
|
||||
await writeFile(filePath, normalizeContentLineEndings(afterNormalized, before.lineEnding, false), "utf8");
|
||||
return hunks.length;
|
||||
if (hunk.kind === "delete") {
|
||||
await rm(resolvePatchFile(root, hunk.path), { force: false });
|
||||
return { action: "delete", path: hunk.path };
|
||||
}
|
||||
const filePath = resolvePatchFile(root, hunk.path);
|
||||
const update = await derivePatchUpdate(filePath, hunk.chunks, hunk.path);
|
||||
if (hunk.movePath !== null && hunk.movePath !== hunk.path) {
|
||||
const targetPath = resolvePatchFile(root, hunk.movePath);
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, update.afterContent, "utf8");
|
||||
await rm(filePath, { force: false });
|
||||
return { action: "move", path: hunk.path, targetPath: hunk.movePath, hunks: hunk.chunks.length, replacements: update.replacements };
|
||||
}
|
||||
await writeFile(filePath, update.afterContent, "utf8");
|
||||
return { action: "update", path: hunk.path, hunks: hunk.chunks.length, replacements: update.replacements };
|
||||
}
|
||||
|
||||
function parseHwpodApplyPatchV2(patch: string): { hunks: HwpodPatchHunk[]; hints: string[] } {
|
||||
const patchText = stripLenientPatchHeredoc(patch).trim();
|
||||
const lines = patchText.length === 0 ? [] : patchText.split(/\r?\n/u);
|
||||
const first = lines[0]?.trim();
|
||||
const last = lines[lines.length - 1]?.trim();
|
||||
if (first !== APPLY_PATCH_BEGIN_MARKER || last !== APPLY_PATCH_END_MARKER) throwInvalidApplyPatchEnvelope(patchText, first, last);
|
||||
|
||||
const hunks: HwpodPatchHunk[] = [];
|
||||
const hints: string[] = [];
|
||||
let index = 1;
|
||||
if ((lines[index] ?? "").trimStart().startsWith(APPLY_PATCH_ENVIRONMENT_MARKER)) {
|
||||
const environmentId = (lines[index] ?? "").slice(APPLY_PATCH_ENVIRONMENT_MARKER.length).trim();
|
||||
if (!environmentId) throw cliError("invalid_apply_patch_environment", "apply_patch environment_id cannot be empty", { engine: APPLY_PATCH_ENGINE, line: index + 1 });
|
||||
index += 1;
|
||||
}
|
||||
while (index < lines.length - 1) {
|
||||
const line = (lines[index] ?? "").trim();
|
||||
if (!line) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (line === APPLY_PATCH_BEGIN_MARKER || line === APPLY_PATCH_END_MARKER) {
|
||||
pushUniquePatchHint(hints, `ignored nested ${line}`, `apply-patch hint: ignored nested ${line} marker on line ${index + 1}; keep one outer envelope around all hunks.`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(APPLY_PATCH_ADD_FILE_MARKER)) {
|
||||
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_ADD_FILE_MARKER.length), index + 1);
|
||||
index += 1;
|
||||
const added: string[] = [];
|
||||
while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) {
|
||||
const addLine = lines[index] ?? "";
|
||||
if (addLine.startsWith("+")) {
|
||||
added.push(addLine.length > 1 && addLine.slice(1).trim().length === 0 ? "" : addLine.slice(1));
|
||||
} else if (addLine.trimStart().startsWith("@@")) {
|
||||
hints.push(`apply-patch hint: accepted @@ inside Add File ${filePath} on line ${index + 1}; Add File does not need @@.`);
|
||||
} else if (addLine.trim().length === 0) {
|
||||
hints.push(`apply-patch hint: accepted a bare blank line inside Add File ${filePath} on line ${index + 1}; prefer a line containing only +.`);
|
||||
added.push("");
|
||||
} else {
|
||||
hints.push(`apply-patch hint: accepted unprefixed Add File content in ${filePath} on line ${index + 1}; prefix new-file content lines with +.`);
|
||||
added.push(addLine);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
hunks.push({ kind: "add", path: filePath, content: joinPatchLinesWithFinalNewline(added) });
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(APPLY_PATCH_DELETE_FILE_MARKER)) {
|
||||
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_DELETE_FILE_MARKER.length), index + 1);
|
||||
index += 1;
|
||||
const extraLines: number[] = [];
|
||||
while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) {
|
||||
if ((lines[index] ?? "").trim().length > 0) extraLines.push(index + 1);
|
||||
index += 1;
|
||||
}
|
||||
if (extraLines.length > 0) hints.push(`apply-patch hint: ignored extra hunk/body lines after Delete File ${filePath} on line ${extraLines[0]}; Delete File only needs the header.`);
|
||||
hunks.push({ kind: "delete", path: filePath });
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(APPLY_PATCH_UPDATE_FILE_MARKER)) {
|
||||
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_UPDATE_FILE_MARKER.length), index + 1);
|
||||
index += 1;
|
||||
let movePath: string | null = null;
|
||||
if ((lines[index] ?? "").startsWith(APPLY_PATCH_MOVE_TO_MARKER)) {
|
||||
movePath = validateApplyPatchPath((lines[index] ?? "").slice(APPLY_PATCH_MOVE_TO_MARKER.length), index + 1);
|
||||
index += 1;
|
||||
}
|
||||
const chunks: HwpodPatchChunk[] = [];
|
||||
while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) {
|
||||
if ((lines[index] ?? "").trim().length === 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const parsed = parseApplyPatchUpdateChunk(lines, index, chunks.length === 0, filePath, hints);
|
||||
chunks.push(parsed.chunk);
|
||||
index = parsed.nextIndex;
|
||||
}
|
||||
if (chunks.length === 0) throw cliError("invalid_update_hunk", "update file hunk is empty", { engine: APPLY_PATCH_ENGINE, line: index + 1, path: filePath });
|
||||
hunks.push({ kind: "update", path: filePath, movePath, chunks });
|
||||
continue;
|
||||
}
|
||||
throw cliError("unsupported_apply_patch_operation", `unsupported patch operation: ${line}`, { engine: APPLY_PATCH_ENGINE, line: index + 1, text: line });
|
||||
}
|
||||
return { hunks, hints };
|
||||
}
|
||||
|
||||
function parseApplyPatchUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean, filePath: string, hints: string[]) {
|
||||
let index = startIndex;
|
||||
let changeContext: string | null = null;
|
||||
let sourceStartLine: number | null = null;
|
||||
const first = lines[index] ?? "";
|
||||
if (first === "@@") {
|
||||
index += 1;
|
||||
} else {
|
||||
const unifiedHeader = parseApplyPatchUnifiedHunkHeader(first);
|
||||
if (unifiedHeader !== null) {
|
||||
sourceStartLine = unifiedHeader.oldStart;
|
||||
hints.push(`apply-patch hint: accepted unified-diff hunk header in ${filePath} on line ${startIndex + 1}; canonical apply_patch uses @@ or @@ context without line ranges.`);
|
||||
index += 1;
|
||||
} else if (first.startsWith("@@ ")) {
|
||||
changeContext = first.slice("@@ ".length);
|
||||
index += 1;
|
||||
} else if (!allowMissingContext) {
|
||||
throw cliError("invalid_update_hunk", "expected update chunk to start with @@ context marker", { engine: APPLY_PATCH_ENGINE, line: startIndex + 1, text: first });
|
||||
}
|
||||
}
|
||||
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
const contextLinePairs: Array<{ oldIndex: number; newIndex: number }> = [];
|
||||
let parsed = 0;
|
||||
let isEndOfFile = false;
|
||||
let contextLineCount = 0;
|
||||
let addedLineCount = 0;
|
||||
let deletedLineCount = 0;
|
||||
function pushContextLine(value: string) {
|
||||
contextLinePairs.push({ oldIndex: oldLines.length, newIndex: newLines.length });
|
||||
oldLines.push(value);
|
||||
newLines.push(value);
|
||||
contextLineCount += 1;
|
||||
}
|
||||
while (index < lines.length - 1) {
|
||||
const line = lines[index] ?? "";
|
||||
if (isApplyPatchFileHeader(line)) break;
|
||||
if (parsed > 0 && isApplyPatchUpdateChunkHeader(line)) break;
|
||||
if (line === APPLY_PATCH_EOF_MARKER) {
|
||||
if (parsed === 0) throw cliError("invalid_update_hunk", "update chunk does not contain any lines", { engine: APPLY_PATCH_ENGINE, line: index + 1 });
|
||||
isEndOfFile = true;
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
const marker = line[0] ?? "";
|
||||
if (marker === " ") {
|
||||
pushContextLine(line.slice(1));
|
||||
} else if (marker === "+") {
|
||||
newLines.push(line.slice(1));
|
||||
addedLineCount += 1;
|
||||
} else if (marker === "-") {
|
||||
oldLines.push(line.slice(1));
|
||||
deletedLineCount += 1;
|
||||
} else if (line.length === 0) {
|
||||
pushContextLine("");
|
||||
} else {
|
||||
pushUniquePatchHint(hints, `unprefixed context ${filePath}`, `apply-patch hint: accepted unprefixed Update File context line in ${filePath} on line ${index + 1}; prefix context lines with one extra space in addition to source indentation.`);
|
||||
pushContextLine(line);
|
||||
}
|
||||
parsed += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (parsed === 0) throw cliError("invalid_update_hunk", "update chunk does not contain any lines", { engine: APPLY_PATCH_ENGINE, line: startIndex + 1 });
|
||||
return { chunk: { changeContext, sourceStartLine, oldLines, newLines, contextLinePairs, contextLineCount, addedLineCount, deletedLineCount, isEndOfFile }, nextIndex: index };
|
||||
}
|
||||
|
||||
async function derivePatchUpdate(filePath: string, chunks: HwpodPatchChunk[], relativePath: string) {
|
||||
const before = await readTextFileState(filePath);
|
||||
const originalLines = splitPatchContentLines(before.normalizedContent);
|
||||
const replacements = computePatchReplacements(relativePath, before, originalLines, chunks);
|
||||
const afterNormalized = joinPatchLinesWithFinalNewline(applyPatchReplacements(originalLines, replacements));
|
||||
const afterContent = normalizeContentLineEndings(afterNormalized, before.lineEnding, false);
|
||||
return { before, afterContent, replacements: replacements.length };
|
||||
}
|
||||
|
||||
function computePatchReplacements(relativePath: string, before: any, originalLines: string[], chunks: HwpodPatchChunk[]): HwpodPatchReplacement[] {
|
||||
const replacements: HwpodPatchReplacement[] = [];
|
||||
let lineIndex = 0;
|
||||
for (const [chunkIndex, chunk] of chunks.entries()) {
|
||||
if (chunk.changeContext !== null) {
|
||||
const foundContext = seekPatchSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
||||
if (foundContext === null) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, { ...diagnosticDetails(before, chunk.changeContext, relativePath), engine: APPLY_PATCH_ENGINE, chunk: chunkIndex + 1, context: chunk.changeContext });
|
||||
lineIndex = foundContext + 1;
|
||||
}
|
||||
if (chunk.oldLines.length === 0) {
|
||||
replacements.push([originalLines.length, 0, chunk.newLines]);
|
||||
continue;
|
||||
}
|
||||
let pattern = chunk.oldLines;
|
||||
const preferredStart = chunk.sourceStartLine === null ? lineIndex : Math.max(lineIndex, chunk.sourceStartLine - 1);
|
||||
let found = seekPatchSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile);
|
||||
let newLines = chunk.newLines;
|
||||
if (found === null && pattern[pattern.length - 1] === "") {
|
||||
pattern = pattern.slice(0, -1);
|
||||
newLines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines;
|
||||
found = seekPatchSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile);
|
||||
}
|
||||
if (found === null) {
|
||||
const expected = chunk.oldLines.join("\n");
|
||||
throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, {
|
||||
...diagnosticDetails(before, expected, relativePath),
|
||||
engine: APPLY_PATCH_ENGINE,
|
||||
chunk: chunkIndex + 1,
|
||||
expected,
|
||||
diagnostics: expectedPatchLineDiagnostics(originalLines, chunk, preferredStart)
|
||||
});
|
||||
}
|
||||
replacements.push([found, pattern.length, preservePatchMatchedContextLines(originalLines, found, newLines, chunk.contextLinePairs, pattern.length)]);
|
||||
lineIndex = found + pattern.length;
|
||||
}
|
||||
assertPatchReplacements(relativePath, replacements, originalLines.length);
|
||||
return replacements;
|
||||
}
|
||||
|
||||
function throwInvalidApplyPatchEnvelope(patchText: string, first?: string, last?: string): never {
|
||||
const format = detectPatchTextFormat(patchText);
|
||||
if (format === "unified-diff") {
|
||||
throw cliError("unsupported_apply_patch_format", "hwpod workspace.apply-patch uses the Codex/UniDesk apply_patch v2 envelope; raw unified diff is not accepted", {
|
||||
engine: APPLY_PATCH_ENGINE,
|
||||
format,
|
||||
expectedFirstLine: APPLY_PATCH_BEGIN_MARKER,
|
||||
expectedLastLine: APPLY_PATCH_END_MARKER,
|
||||
hint: "Wrap edits in *** Begin Patch / *** Update File: <relative-path> / @@ / *** End Patch, or use workspace.write for a whole-file rewrite."
|
||||
});
|
||||
}
|
||||
throw cliError("invalid_apply_patch_envelope", `patch must start with ${APPLY_PATCH_BEGIN_MARKER} and end with ${APPLY_PATCH_END_MARKER}`, { engine: APPLY_PATCH_ENGINE, firstLine: first ?? null, lastLine: last ?? null });
|
||||
}
|
||||
|
||||
function detectPatchTextFormat(patchText: string) {
|
||||
const trimmed = patchText.trimStart();
|
||||
if (trimmed.startsWith("diff --git") || trimmed.startsWith("--- ") || trimmed.startsWith("@@ ")) return "unified-diff";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function stripLenientPatchHeredoc(textValue: string) {
|
||||
const trimmed = textValue.trim();
|
||||
const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u);
|
||||
const first = lines[0] ?? "";
|
||||
const last = lines[lines.length - 1] ?? "";
|
||||
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF") && lines.length >= 4) return lines.slice(1, -1).join("\n");
|
||||
return textValue;
|
||||
}
|
||||
|
||||
function validateApplyPatchPath(value: string, line: number) {
|
||||
const filePath = value.trim();
|
||||
if (!filePath || path.isAbsolute(filePath)) throw cliError("invalid_patch_path", "patch file paths must be relative", { engine: APPLY_PATCH_ENGINE, line, path: filePath });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function isApplyPatchFileHeader(line: string) {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.startsWith(APPLY_PATCH_ADD_FILE_MARKER) || trimmed.startsWith(APPLY_PATCH_DELETE_FILE_MARKER) || trimmed.startsWith(APPLY_PATCH_UPDATE_FILE_MARKER) || trimmed === APPLY_PATCH_BEGIN_MARKER || trimmed === APPLY_PATCH_END_MARKER;
|
||||
}
|
||||
|
||||
function isApplyPatchUpdateChunkHeader(line: string) {
|
||||
return line === "@@" || line.startsWith("@@ ") || parseApplyPatchUnifiedHunkHeader(line) !== null;
|
||||
}
|
||||
|
||||
function parseApplyPatchUnifiedHunkHeader(line: string) {
|
||||
const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@(?:\s+.*)?$/u.exec(line);
|
||||
if (match === null) return null;
|
||||
const oldStart = Number(match[1] ?? "0");
|
||||
return Number.isSafeInteger(oldStart) && oldStart >= 0 ? { oldStart } : null;
|
||||
}
|
||||
|
||||
function splitPatchContentLines(content: string) {
|
||||
const lines = content.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function joinPatchLinesWithFinalNewline(lines: string[]) {
|
||||
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function applyPatchReplacements(lines: string[], replacements: HwpodPatchReplacement[]) {
|
||||
const result = [...lines];
|
||||
for (const [start, oldLength, newSegment] of [...replacements].reverse()) result.splice(start, oldLength, ...newSegment);
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertPatchReplacements(relativePath: string, replacements: HwpodPatchReplacement[], lineCount: number) {
|
||||
const sorted = [...replacements].sort((left, right) => left[0] - right[0]);
|
||||
let previousEnd = 0;
|
||||
for (const [start, oldLength] of sorted) {
|
||||
if (start < 0 || oldLength < 0 || start + oldLength > lineCount) throw cliError("apply_patch_replacement_out_of_bounds", "computed replacement is outside file bounds", { engine: APPLY_PATCH_ENGINE, path: relativePath, start, oldLength, lineCount });
|
||||
if (start < previousEnd) throw cliError("apply_patch_replacement_overlap", "computed replacements overlap", { engine: APPLY_PATCH_ENGINE, path: relativePath, start, previousEnd });
|
||||
previousEnd = Math.max(previousEnd, start + oldLength);
|
||||
}
|
||||
}
|
||||
|
||||
function seekPatchSequenceWithFallback(lines: string[], pattern: string[], preferredStart: number, fallbackStart: number, eof: boolean) {
|
||||
const preferred = seekPatchSequence(lines, pattern, preferredStart, eof);
|
||||
if (preferred !== null || preferredStart === fallbackStart) return preferred;
|
||||
return seekPatchSequence(lines, pattern, fallbackStart, eof);
|
||||
}
|
||||
|
||||
function seekPatchSequence(lines: string[], pattern: string[], start: number, eof: boolean) {
|
||||
if (pattern.length === 0) return start;
|
||||
if (pattern.length > lines.length) return null;
|
||||
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : Math.max(0, start);
|
||||
const attempts: Array<(value: string) => string> = [
|
||||
(value) => value,
|
||||
(value) => value.trimEnd(),
|
||||
(value) => value.trim(),
|
||||
normalizePatchLine
|
||||
];
|
||||
for (const normalize of attempts) {
|
||||
for (let index = searchStart; index <= lines.length - pattern.length; index += 1) {
|
||||
let ok = true;
|
||||
for (let offset = 0; offset < pattern.length; offset += 1) {
|
||||
if (normalize(lines[index + offset] ?? "") !== normalize(pattern[offset] ?? "")) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function preservePatchMatchedContextLines(originalLines: string[], found: number, newLines: string[], contextLinePairs: HwpodPatchChunk["contextLinePairs"], matchedOldLength: number) {
|
||||
if (contextLinePairs.length === 0) return newLines;
|
||||
const result = [...newLines];
|
||||
for (const pair of contextLinePairs) {
|
||||
if (pair.oldIndex >= matchedOldLength || pair.newIndex >= result.length) continue;
|
||||
const originalLine = originalLines[found + pair.oldIndex];
|
||||
if (originalLine !== undefined) result[pair.newIndex] = originalLine;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function expectedPatchLineDiagnostics(originalLines: string[], chunk: HwpodPatchChunk, preferredStart: number) {
|
||||
const firstExpectedLine = chunk.oldLines.find((line) => line.trim().length > 0) ?? "";
|
||||
const firstExpectedLineCandidates = firstExpectedLine ? patchCandidateLineNumbers(originalLines, firstExpectedLine, 8) : [];
|
||||
const prefix = bestPatchPrefixMatch(originalLines, chunk.oldLines, firstExpectedLine, preferredStart);
|
||||
const missingAddedPrefixes = likelyPatchMissingAddedPrefixes(chunk, prefix.matchedLines);
|
||||
return {
|
||||
firstExpectedLine,
|
||||
firstExpectedLineCandidates,
|
||||
firstExpectedLineCandidatesTruncated: firstExpectedLine.length > 0 && patchCandidateLineNumbers(originalLines, firstExpectedLine, 9).length > firstExpectedLineCandidates.length,
|
||||
bestPrefixMatchedLines: prefix.matchedLines,
|
||||
bestPrefixStartLine: prefix.startLine,
|
||||
likelyMissingAddedPrefixes: missingAddedPrefixes,
|
||||
likelyStaleOrOversizedContext: !missingAddedPrefixes && likelyPatchStaleOrOversizedContext(chunk, prefix.matchedLines)
|
||||
};
|
||||
}
|
||||
|
||||
function patchCandidateLineNumbers(lines: string[], expectedLine: string, limit: number) {
|
||||
const result: number[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (patchLineEquivalent(lines[index] ?? "", expectedLine)) {
|
||||
result.push(index + 1);
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function bestPatchPrefixMatch(lines: string[], expectedLines: string[], firstExpectedLine: string, preferredStart: number) {
|
||||
let best = { startLine: null as number | null, matchedLines: 0 };
|
||||
if (expectedLines.length === 0) return best;
|
||||
for (let index = Math.max(0, preferredStart); index < lines.length; index += 1) {
|
||||
if (firstExpectedLine.length > 0 && !patchLineEquivalent(lines[index] ?? "", firstExpectedLine)) continue;
|
||||
let matched = 0;
|
||||
while (index + matched < lines.length && matched < expectedLines.length && patchLineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1;
|
||||
if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched };
|
||||
if (matched === expectedLines.length) break;
|
||||
}
|
||||
if (best.matchedLines > 0 || preferredStart <= 0) return best;
|
||||
for (let index = 0; index < Math.min(preferredStart, lines.length); index += 1) {
|
||||
if (firstExpectedLine.length > 0 && !patchLineEquivalent(lines[index] ?? "", firstExpectedLine)) continue;
|
||||
let matched = 0;
|
||||
while (index + matched < lines.length && matched < expectedLines.length && patchLineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1;
|
||||
if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function likelyPatchMissingAddedPrefixes(chunk: HwpodPatchChunk, bestPrefixMatchedLines: number) {
|
||||
if (chunk.deletedLineCount > 0) return false;
|
||||
if (chunk.oldLines.length < 8) return false;
|
||||
if (chunk.addedLineCount > 2) return false;
|
||||
if (chunk.contextLineCount < 8) return false;
|
||||
return bestPrefixMatchedLines > 0 && bestPrefixMatchedLines < chunk.oldLines.length;
|
||||
}
|
||||
|
||||
function likelyPatchStaleOrOversizedContext(chunk: HwpodPatchChunk, bestPrefixMatchedLines: number) {
|
||||
if (chunk.oldLines.length < 4) return false;
|
||||
if (bestPrefixMatchedLines < 2 || bestPrefixMatchedLines >= chunk.oldLines.length) return false;
|
||||
return chunk.addedLineCount > 0 || chunk.deletedLineCount > 0 || chunk.contextLineCount >= 4;
|
||||
}
|
||||
|
||||
function patchLineEquivalent(left: string, right: string) {
|
||||
return left === right || left.trimEnd() === right.trimEnd() || left.trim() === right.trim() || normalizePatchLine(left) === normalizePatchLine(right);
|
||||
}
|
||||
|
||||
function normalizePatchLine(value: string) {
|
||||
return value.trim().replace(/[\u2010-\u2015\u2212]/gu, "-")
|
||||
.replace(/[\u2018\u2019\u201A\u201B]/gu, "'")
|
||||
.replace(/[\u201C\u201D\u201E\u201F]/gu, "\"")
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/gu, " ");
|
||||
}
|
||||
|
||||
function pushUniquePatchHint(hints: string[], prefix: string, hint: string) {
|
||||
if (!hints.some((existing) => existing.includes(prefix))) hints.push(hint);
|
||||
}
|
||||
|
||||
async function readTextFileState(filePath: string) {
|
||||
|
||||
Reference in New Issue
Block a user