fix: align hwpod apply-patch with v2 engine

This commit is contained in:
Codex Agent
2026-06-07 15:00:26 +08:00
parent bfedec8f30
commit 7b5dd408fc
5 changed files with 589 additions and 76 deletions
+18
View File
@@ -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";
}
+86
View File
@@ -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 {
+9 -5
View File
@@ -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
View File
@@ -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) {