diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index 465050de..fca0c65d 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -115,6 +115,37 @@ export function applyPatchV2HelpPayload() { firstLine: beginMarker, lastLine: endMarker }, + rules: [ + "Add File has no @@ hunk marker: put content immediately after `*** Add File: ` and prefix every content line with +.", + "A blank line in Add File is a line containing only +.", + "Update File uses @@ or @@ context markers, followed by context lines starting with space and changed lines starting with - or +.", + "Prefer `trans apply-patch < /tmp/patch.diff` for long patches, Windows paths, or quoting-sensitive content." + ], + examples: { + addFile: [ + beginMarker, + `${addFileMarker}path/to/new.txt`, + "+first line", + "+", + "+third line after a blank line", + endMarker + ].join("\n"), + updateFile: [ + beginMarker, + `${updateFileMarker}path/to/existing.txt`, + emptyChangeContextMarker, + " context line", + "-old line", + "+new line", + " more context", + endMarker + ].join("\n") + }, + commonPitfalls: [ + "Do not put @@ after `*** Add File:`; @@ is only for Update File.", + "Do not paste unified diff headers such as `@@ -1,3 +1,4 @@`.", + "Do not use remote Python/Perl/sed heredocs for text patches when `trans apply-patch` is available." + ], note: "apply-patch reads patch text from stdin and uses the v2 engine by default. Use `apply-patch-v1` only for the legacy helper." }; } @@ -148,7 +179,7 @@ export function parseApplyPatchV2(patchText: string): PatchParseResult { while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) { const addLine = lines[index] ?? ""; if (!addLine.startsWith("+")) { - throw new ApplyPatchV2Error("invalid add file line; every added line must start with +", { line: index + 1, path: filePath }); + throw invalidAddFileLineError(addLine, index + 1, filePath); } added.push(addLine.slice(1)); index += 1; @@ -189,6 +220,26 @@ export function parseApplyPatchV2(patchText: string): PatchParseResult { return { patch, hunks }; } +function invalidAddFileLineError(lineText: string, line: number, filePath: string): ApplyPatchV2Error { + const details = { line, path: filePath, text: lineText }; + if (lineText.trimStart().startsWith("@@")) { + return new ApplyPatchV2Error( + "invalid add file line: remove @@ for Add File. Add File sections do not use hunk markers; put content directly after the header and prefix every content line with +.", + details, + ); + } + if (lineText.trim().length === 0) { + return new ApplyPatchV2Error( + "invalid add file line: blank lines in Add File must be written as a line containing only +.", + details, + ); + } + return new ApplyPatchV2Error( + "invalid add file line: every Add File content line must start with +. Add File has no @@ marker; use @@ only under Update File.", + details, + ); +} + export function deriveUpdatedContent(filePath: string, originalContent: string, chunks: UpdateChunk[]): PatchUpdateResult { const originalLines = splitContentLines(originalContent); const replacements = computeReplacements(filePath, originalLines, chunks); diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 3b35f554..e695a689 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -905,6 +905,24 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition(addBeforeFailedUpdateV2.files.hwpod?.includes("device-pod-cli.mjs"), "v2 should preserve an earlier Add File from a large patch when a later hunk misses", addBeforeFailedUpdateV2); assertCondition(addBeforeFailedUpdateV2.files["scripts/artifact-publish.mjs"] === "actual artifact line\n", "v2 must leave the failed later file unchanged", addBeforeFailedUpdateV2); + const addFileWithHunkMarkerV2 = await applyPatchV2FixtureAttempt([ + "*** Begin Patch", + "*** Add File: bad-add.txt", + "@@", + "+content", + "*** End Patch", + "", + ].join("\n"), {}, { stderrOutput: true }); + assertCondition(addFileWithHunkMarkerV2.exitCode === 1 && addFileWithHunkMarkerV2.error === null, "v2 CLI path should return a visible parse error for Add File @@ misuse", addFileWithHunkMarkerV2); + assertCondition(addFileWithHunkMarkerV2.stdout === "", "v2 Add File parse failures should keep Codex-style empty stdout", addFileWithHunkMarkerV2); + assertCondition( + addFileWithHunkMarkerV2.stderr.includes("remove @@ for Add File") + && addFileWithHunkMarkerV2.stderr.includes("prefix every content line with +"), + "v2 Add File @@ misuse should tell agents exactly how to repair the patch", + addFileWithHunkMarkerV2.stderr, + ); + assertCondition(!addFileWithHunkMarkerV2.commands.some((command) => command.startsWith("write-b64") || command.startsWith("delete")), "v2 Add File parse failures must not touch remote files", addFileWithHunkMarkerV2.commands); + const sequentialCompoundV2 = await applyPatchV2Fixture([ "*** Begin Patch", "*** Update File: sequence.txt",