import { createHash, randomBytes } from "node:crypto"; import type { Readable, Writable } from "node:stream"; export type PatchHunk = | { kind: "add"; path: string; content: string } | { kind: "delete"; path: string } | { kind: "update"; path: string; movePath: string | null; chunks: UpdateChunk[] }; export interface UpdateChunk { changeContext: string | null; sourceStartLine: number | null; oldLines: string[]; newLines: string[]; contextLinePairs: Array<{ oldIndex: number; newIndex: number }>; contextLineCount: number; addedLineCount: number; deletedLineCount: number; isEndOfFile: boolean; } export interface PatchParseResult { patch: string; hunks: PatchHunk[]; hints: string[]; } export interface PatchUpdateResult { oldContent: string; newContent: string; } export interface ApplyPatchV2Outcome { hunk: number; action: PatchHunk["kind"] | "move"; status: "applied" | "failed"; path: string; targetPath?: string; change?: string; partialChanges?: string[]; error?: { name?: string; message: string; details?: unknown; }; } export interface ApplyPatchV2Options { executor: ApplyPatchV2Executor; stdin: Readable; stdout: Writable; stderr?: Writable; argv?: string[]; timing?: ApplyPatchV2TimingOptions; } export interface ApplyPatchV2Executor { run?: (command: string[], input?: string) => Promise; fs?: ApplyPatchV2FileSystem; } export interface ApplyPatchV2FileSystem { stat(path: string): Promise; readBlock(path: string, blockIndex: number, blockBytes: number): Promise; writeFile(path: string, content: Buffer): Promise; deleteFile(path: string): Promise; } export interface ApplyPatchV2FileStat { bytes: number; sha256: string; } export interface ApplyPatchV2RemoteResult { exitCode: number; stdout: string; stderr: string; } export interface ApplyPatchV2TimingOptions { providerId?: string; route?: string; transport?: "backend-core-broker" | "frontend-websocket" | "local" | "virtual"; thresholdMs?: number; } export interface ApplyPatchV2TimingSummary { code: "apply-patch-v2-timing"; level: "info" | "warning"; status: "succeeded" | "failed"; providerId?: string; route?: string; transport?: ApplyPatchV2TimingOptions["transport"]; durationMs: number; thresholdMs: number; slow: boolean; patchBytes: number; fileCount: number; hunkCount: number; changedCount: number; remoteOperationCount: number; remoteOperationCounts: Record; remoteElapsedMs: number; remoteFailureCount: number; message: string; note: string; } export class ApplyPatchV2Error extends Error { constructor(message: string, public readonly details: Record = {}) { super(message); this.name = "ApplyPatchV2Error"; } } type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" }; type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string }; type Replacement = [start: number, oldLength: number, newLines: string[]]; interface ApplyPatchV2Plan { changed: string[]; outcomes: ApplyPatchV2Outcome[]; } export interface ApplyPatchV2RemoteMetrics { remoteOperationCount: number; remoteOperationCounts: Record; remoteElapsedMs: number; remoteFailureCount: number; } const beginMarker = "*** Begin Patch"; const endMarker = "*** End Patch"; const environmentIdMarker = "*** Environment ID: "; const addFileMarker = "*** Add File: "; const deleteFileMarker = "*** Delete File: "; const updateFileMarker = "*** Update File: "; const moveToMarker = "*** Move to: "; const emptyChangeContextMarker = "@@"; const changeContextMarker = "@@ "; const eofMarker = "*** End of File"; const defaultApplyPatchV2SlowWarningMs = 10_000; export function isApplyPatchV2HelpArgs(args: string[] = []): boolean { const first = args[0] ?? ""; return first === "help" || first === "--help" || first === "-h"; } export function applyPatchV2HelpPayload() { return { ok: true, command: "trans apply-patch < patch.diff", summary: "Apply a standard apply_patch patch to a remote route with the local v2 line-based engine.", usage: [ "trans G14:/root/hwlab/.worktree/task apply-patch < patch.diff", "trans D601:/tmp apply-patch < patch.diff" ], input: { required: true, firstLine: beginMarker, lastLine: endMarker }, timing: { stderrPrefix: "UNIDESK_APPLY_PATCH_TIMING", fields: [ "durationMs", "patchBytes", "fileCount", "hunkCount", "changedCount", "remoteOperationCount", "remoteOperationCounts", "remoteElapsedMs", "remoteFailureCount", "providerId", "route", "transport" ], stdoutCompatibility: "Timing is written to stderr so success stdout remains Codex apply_patch-compatible." }, 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 one extra space prefix and changed lines starting with - or +; for a column-0 source line `const x`, write ` const x`, and for a two-space-indented source line write three spaces total. Unified-diff line-range headers are accepted with hints for MiniMax compatibility.", "Prefer `trans apply-patch < /tmp/patch.diff` for long patches, Windows paths, or quoting-sensitive content.", "If `failed to find expected lines` reports stale or oversized context for a block/function replacement, re-read the exact current block and retry with a smaller Update File hunk or split hunks around unique anchors.", "MiniMax compatibility: stray @@ or unprefixed content inside Add File, unprefixed Update File context lines, and extra hunk/body lines after Delete File, are accepted with stderr hints.", "MiniMax/MXCX concatenated patch compatibility: repeated nested Begin Patch / End Patch markers are accepted with stderr hints, but the CLI still uses only the v2 engine and never auto-falls back to apply-patch-v1." ], 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.", "Prefer canonical @@ or @@ context over unified diff headers such as `@@ -1,3 +1,4 @@`; v2 accepts those headers with a hint.", "If multiple printf/heredoc fragments were concatenated, keep one outer Begin/End envelope or include complete nested envelopes; v2 will hint on nested markers instead of trying the legacy helper.", "Do not recover from apply-patch context mismatch by switching to download/upload, remote Python/Perl/sed, or whole-file rewrites; retry with corrected v2 hunks.", "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." }; } export function parseApplyPatchV2(patchText: string): PatchParseResult { const patch = stripLenientHeredoc(patchText).trim(); const lines = patch.length === 0 ? [] : patch.split(/\r?\n/u); const first = lines[0]?.trim(); const last = lines[lines.length - 1]?.trim(); if (first !== beginMarker) throw new ApplyPatchV2Error(`invalid patch: first line must be '${beginMarker}'`); if (last !== endMarker) throw new ApplyPatchV2Error(`invalid patch: last line must be '${endMarker}'`); const hunks: PatchHunk[] = []; const hints: string[] = []; let index = 1; const environmentLine = lines[index]?.trimStart() ?? ""; if (environmentLine.startsWith(environmentIdMarker)) { const environmentId = environmentLine.slice(environmentIdMarker.length).trim(); if (environmentId.length === 0) throw new ApplyPatchV2Error("apply_patch environment_id cannot be empty", { line: index + 1 }); index += 1; } while (index < lines.length - 1) { const line = lines[index]?.trim() ?? ""; if (line.length === 0) { index += 1; continue; } if (line === beginMarker) { pushUniqueHint( hints, "apply-patch hint: ignored nested MiniMax-style Begin Patch marker", `apply-patch hint: ignored nested MiniMax-style Begin Patch marker on line ${index + 1}; keep exactly one ${beginMarker}/${endMarker} envelope around all hunks.`, ); index += 1; continue; } if (line === endMarker) { pushUniqueHint( hints, "apply-patch hint: ignored nested MiniMax-style End Patch marker", `apply-patch hint: ignored nested MiniMax-style End Patch marker on line ${index + 1}; keep exactly one ${beginMarker}/${endMarker} envelope around all hunks.`, ); index += 1; continue; } if (line.startsWith(addFileMarker)) { const filePath = validatePatchPath(line.slice(addFileMarker.length), index + 1); index += 1; const added: string[] = []; while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) { const addLine = lines[index] ?? ""; if (addLine.startsWith("+")) { if (addLine.length > 1 && addLine.slice(1).trim().length === 0) { hints.push(`apply-patch hint: accepted MiniMax-style whitespace-only Add File line in ${filePath} on line ${index + 1}; prefer a line containing only + for blank lines.`); added.push(""); index += 1; continue; } added.push(addLine.slice(1)); index += 1; continue; } if (addLine.trimStart().startsWith("@@")) { hints.push(`apply-patch hint: accepted MiniMax-style @@ inside Add File ${filePath} on line ${index + 1}; Add File does not need @@.`); index += 1; continue; } 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(""); index += 1; continue; } 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: joinLinesWithFinalNewline(added) }); continue; } if (line.startsWith(deleteFileMarker)) { const filePath = validatePatchPath(line.slice(deleteFileMarker.length), index + 1); index += 1; const extraLines: number[] = []; while (index < lines.length - 1 && !isFileHeader(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 MiniMax-style 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(updateFileMarker)) { const filePath = validatePatchPath(line.slice(updateFileMarker.length), index + 1); index += 1; let movePath: string | null = null; if ((lines[index] ?? "").startsWith(moveToMarker)) { movePath = validatePatchPath((lines[index] ?? "").slice(moveToMarker.length), index + 1); index += 1; } const chunks: UpdateChunk[] = []; while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) { if ((lines[index] ?? "").trim().length === 0) { index += 1; continue; } const parsed = parseUpdateChunk(lines, index, chunks.length === 0, filePath, hints); chunks.push(parsed.chunk); index = parsed.nextIndex; } if (chunks.length === 0) throw new ApplyPatchV2Error("update file hunk is empty", { line: index + 1, path: filePath }); hunks.push({ kind: "update", path: filePath, movePath, chunks }); continue; } throw new ApplyPatchV2Error("invalid hunk header", { line: index + 1, text: line }); } return { patch, hunks, hints }; } export function deriveUpdatedContent(filePath: string, originalContent: string, chunks: UpdateChunk[]): PatchUpdateResult { const originalLines = splitContentLines(originalContent); const replacements = computeReplacements(filePath, originalLines, chunks); const newLines = applyReplacements(originalLines, replacements); const newContent = joinLinesWithFinalNewline(newLines); return { oldContent: originalContent, newContent }; } export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise { if (isApplyPatchV2HelpArgs(options.argv)) { options.stdout.write(`${JSON.stringify(applyPatchV2HelpPayload(), null, 2)}\n`); return 0; } const stderr = options.stderr ?? process.stderr; if ((options.argv?.length ?? 0) > 0) { stderr.write("ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options.\n"); return 2; } const patchText = await readStreamText(options.stdin); if (!patchText.trim()) { stderr.write("ssh apply-patch requires patch text on stdin.\n"); return 2; } const startedAtMs = Date.now(); const metrics = createApplyPatchV2RemoteMetrics(); const executor = instrumentApplyPatchV2Executor(options.executor, metrics); let parsed: PatchParseResult | null = null; let plan: ApplyPatchV2Plan | null = null; try { parsed = parseApplyPatchV2(patchText); plan = await applyPatchV2Hunks(executor, parsed.hunks); for (const hint of parsed.hints) stderr.write(`${hint}\n`); options.stdout.write("Success. Updated the following files:\n"); for (const item of plan.changed) options.stdout.write(`${item}\n`); stderr.write(formatApplyPatchV2TimingSummary(applyPatchV2TimingSummary({ status: "succeeded", patchText, parsed, plan, metrics, startedAtMs, options: options.timing, }))); return 0; } catch (error) { const failureSummary = applyPatchV2TimingSummary({ status: "failed", patchText, parsed, plan, metrics, startedAtMs, options: options.timing, }); if (options.stderr === undefined) throw error; options.stderr.write(formatApplyPatchFailure(error)); options.stderr.write(formatApplyPatchV2TimingSummary(failureSummary)); return 1; } } function createApplyPatchV2RemoteMetrics(): ApplyPatchV2RemoteMetrics { return { remoteOperationCount: 0, remoteOperationCounts: {}, remoteElapsedMs: 0, remoteFailureCount: 0 }; } function instrumentApplyPatchV2Executor(executor: ApplyPatchV2Executor, metrics: ApplyPatchV2RemoteMetrics): ApplyPatchV2Executor { return { ...executor, run: executor.run === undefined ? undefined : async (command, input) => { const operation = applyPatchV2OperationName(command); const started = Date.now(); metrics.remoteOperationCount += 1; metrics.remoteOperationCounts[operation] = (metrics.remoteOperationCounts[operation] ?? 0) + 1; try { const result = await executor.run!(command, input); metrics.remoteElapsedMs += Math.max(0, Date.now() - started); if (result.exitCode !== 0) metrics.remoteFailureCount += 1; return result; } catch (error) { metrics.remoteElapsedMs += Math.max(0, Date.now() - started); metrics.remoteFailureCount += 1; throw error; } }, fs: executor.fs === undefined ? undefined : instrumentApplyPatchV2FileSystem(executor.fs, metrics), }; } function instrumentApplyPatchV2FileSystem(fs: ApplyPatchV2FileSystem, metrics: ApplyPatchV2RemoteMetrics): ApplyPatchV2FileSystem { return { stat: (path) => recordApplyPatchV2FsOperation(metrics, "fs.stat", () => fs.stat(path)), readBlock: (path, blockIndex, blockBytes) => recordApplyPatchV2FsOperation(metrics, "fs.readBlock", () => fs.readBlock(path, blockIndex, blockBytes)), writeFile: (path, content) => recordApplyPatchV2FsOperation(metrics, "fs.writeFile", () => fs.writeFile(path, content)), deleteFile: (path) => recordApplyPatchV2FsOperation(metrics, "fs.deleteFile", () => fs.deleteFile(path)), }; } async function recordApplyPatchV2FsOperation(metrics: ApplyPatchV2RemoteMetrics, operation: string, run: () => Promise): Promise { const started = Date.now(); metrics.remoteOperationCount += 1; metrics.remoteOperationCounts[operation] = (metrics.remoteOperationCounts[operation] ?? 0) + 1; try { const result = await run(); metrics.remoteElapsedMs += Math.max(0, Date.now() - started); return result; } catch (error) { metrics.remoteElapsedMs += Math.max(0, Date.now() - started); metrics.remoteFailureCount += 1; throw error; } } function applyPatchV2OperationName(command: string[]): string { const index = command.indexOf("unidesk-apply-patch-v2"); return index >= 0 ? command[index + 1] ?? "unknown" : command[0] ?? "unknown"; } export function applyPatchV2TimingSummary(options: { status: ApplyPatchV2TimingSummary["status"]; patchText: string; parsed: PatchParseResult | null; plan: ApplyPatchV2Plan | null; metrics: ApplyPatchV2RemoteMetrics; startedAtMs: number; finishedAtMs?: number; options?: ApplyPatchV2TimingOptions; }): ApplyPatchV2TimingSummary { const finishedAtMs = options.finishedAtMs ?? Date.now(); const durationMs = Math.max(0, Math.round(finishedAtMs - options.startedAtMs)); const thresholdMs = options.options?.thresholdMs ?? defaultApplyPatchV2SlowWarningMs; const slow = durationMs > thresholdMs; const patchBytes = Buffer.byteLength(options.patchText, "utf8"); const fileCount = countApplyPatchV2Files(options.parsed?.hunks ?? []); const hunkCount = options.parsed?.hunks.length ?? 0; const changedCount = options.plan?.changed.length ?? applyPatchV2ChangedCountFromOutcomes(options.plan?.outcomes ?? []); const durationSeconds = Number((durationMs / 1000).toFixed(3)); const thresholdSeconds = Number((thresholdMs / 1000).toFixed(3)); return { code: "apply-patch-v2-timing", level: slow ? "warning" : "info", status: options.status, ...(options.options?.providerId === undefined ? {} : { providerId: options.options.providerId }), ...(options.options?.route === undefined ? {} : { route: options.options.route }), ...(options.options?.transport === undefined ? {} : { transport: options.options.transport }), durationMs, thresholdMs, slow, patchBytes, fileCount, hunkCount, changedCount, remoteOperationCount: options.metrics.remoteOperationCount, remoteOperationCounts: options.metrics.remoteOperationCounts, remoteElapsedMs: Math.max(0, Math.round(options.metrics.remoteElapsedMs)), remoteFailureCount: options.metrics.remoteFailureCount, message: slow ? `apply-patch completed in ${durationSeconds}s, above the ${thresholdSeconds}s warning threshold; inspect remoteOperationCounts and remoteElapsedMs before repeating high-frequency patch work.` : `apply-patch completed in ${durationSeconds}s.`, note: "Timing summary is written to stderr so stdout remains Codex apply_patch-compatible.", }; } export function formatApplyPatchV2TimingSummary(summary: ApplyPatchV2TimingSummary): string { return `UNIDESK_APPLY_PATCH_TIMING ${JSON.stringify(summary)}\n`; } function countApplyPatchV2Files(hunks: PatchHunk[]): number { const files = new Set(); for (const hunk of hunks) { files.add(hunk.path); if (hunk.kind === "update" && hunk.movePath !== null) files.add(hunk.movePath); } return files.size; } function applyPatchV2ChangedCountFromOutcomes(outcomes: ApplyPatchV2Outcome[]): number { return outcomes.filter((outcome) => outcome.status === "applied").length; } async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise { if (hunks.length === 0) throw new ApplyPatchV2Error("No files were modified."); const states = new Map(); const changed: string[] = []; const outcomes: ApplyPatchV2Outcome[] = []; async function readPlannedText(filePath: string): Promise { const state = states.get(filePath); if (state !== undefined) { if (!state.exists) throw new ApplyPatchV2Error("cannot update a file deleted earlier in this patch", { path: filePath }); return state.content; } const content = await readRemoteText(executor, filePath); states.set(filePath, { exists: true, content }); return content; } async function applyWrite(filePath: string, content: string): Promise { await executePlannedOperation(executor, { kind: "write", path: filePath, content }); states.set(filePath, { exists: true, content }); } async function applyDelete(filePath: string): Promise { const state = states.get(filePath); if (state === undefined) { await ensureRemoteFileExists(executor, filePath); } else if (!state.exists) { throw new ApplyPatchV2Error("cannot delete a file deleted earlier in this patch", { path: filePath }); } await executePlannedOperation(executor, { kind: "delete", path: filePath }); states.set(filePath, { exists: false, content: "" }); } for (let index = 0; index < hunks.length; index += 1) { const hunk = hunks[index] as PatchHunk; const changedBefore = changed.length; try { if (hunk.kind === "add") { await applyWrite(hunk.path, hunk.content); pushChanged(changed, `A ${hunk.path}`); outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `A ${hunk.path}` }); continue; } if (hunk.kind === "delete") { await applyDelete(hunk.path); pushChanged(changed, `D ${hunk.path}`); outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `D ${hunk.path}` }); continue; } const originalContent = await readPlannedText(hunk.path); const update = deriveUpdatedContent(hunk.path, originalContent, hunk.chunks); if (hunk.movePath !== null && hunk.movePath !== hunk.path) { await applyWrite(hunk.movePath, update.newContent); pushChanged(changed, `M ${hunk.movePath}`); await applyDelete(hunk.path); outcomes.push({ ...outcomeBase(hunk, index), action: "move", status: "applied", change: `M ${hunk.movePath}` }); continue; } await applyWrite(hunk.path, update.newContent); pushChanged(changed, `M ${hunk.path}`); outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `M ${hunk.path}` }); } catch (error) { const partialChanges = changed.slice(changedBefore); outcomes.push({ ...outcomeBase(hunk, index), status: "failed", ...(partialChanges.length > 0 ? { partialChanges } : {}), error: errorSummary(error), }); throw new ApplyPatchV2Error(error instanceof Error ? error.message : String(error), { partialChanges: changed, outcomes, failed: outcomes.find((item) => item.status === "failed") ?? null, cause: error instanceof ApplyPatchV2Error ? error.details : undefined, }); } } return { changed, outcomes }; } function pushChanged(changed: string[], item: string): void { if (!changed.includes(item)) changed.push(item); } function outcomeBase(hunk: PatchHunk, index: number): Omit { if (hunk.kind === "update" && hunk.movePath !== null && hunk.movePath !== hunk.path) { return { hunk: index + 1, action: "move", path: hunk.path, targetPath: hunk.movePath }; } return { hunk: index + 1, action: hunk.kind, path: hunk.path }; } function errorSummary(error: unknown): ApplyPatchV2Outcome["error"] { if (error instanceof ApplyPatchV2Error) { return { name: error.name, message: error.message, details: error.details }; } if (error instanceof Error) { return { name: error.name, message: error.message }; } return { message: String(error) }; } function formatApplyPatchFailure(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const details = error instanceof ApplyPatchV2Error ? error.details : {}; const outcomes = Array.isArray(details.outcomes) ? details.outcomes as ApplyPatchV2Outcome[] : []; const lines = [`${message.trimEnd()}`]; appendParserFailureHint(lines, message, details); appendExpectedLinesFailureHint(lines, details); if (outcomes.length > 1 || outcomes.some((item) => item.status === "applied")) { lines.push("Patch status:"); appendOutcomeSection(lines, "Applied before failure:", outcomes.filter((item) => item.status === "applied")); appendOutcomeSection(lines, "Failed:", outcomes.filter((item) => item.status === "failed")); } return `${lines.join("\n")}\n`; } function appendParserFailureHint(lines: string[], message: string, details: Record): void { const text = typeof details.text === "string" ? details.text : ""; const parserLine = typeof details.line === "number" ? `line ${details.line}` : ""; if (message === "invalid hunk header") { const shown = text.length === 0 ? "" : ` (${JSON.stringify(text)})`; lines.push(`Hint: unexpected patch body ${parserLine}${shown}. For concatenated MiniMax/MXCX fragments, keep exactly one outer *** Begin Patch / *** End Patch envelope or make each nested fragment a complete Begin/End pair.`); lines.push("Hint: apply-patch remains the v2 engine only; fix the patch text or run `trans apply-patch --help`, do not retry by switching to apply-patch-v1."); return; } if (message.startsWith("invalid patch:")) { lines.push("Hint: apply-patch expects a single stdin patch whose first non-wrapper line is *** Begin Patch and last line is *** End Patch. Quoted heredocs such as `cat <<'PATCH' > /tmp/patch.diff` preserve the required leading spaces."); lines.push("Hint: apply-patch remains the v2 engine only; v1 is never auto-selected for malformed MiniMax/MXCX patch envelopes."); } } function appendExpectedLinesFailureHint(lines: string[], details: Record): void { const cause = recordValue(details.cause) ?? details; const expected = typeof cause.expected === "string" ? cause.expected : ""; if (expected.length === 0) return; const path = typeof cause.path === "string" ? cause.path : "target file"; const chunk = typeof cause.chunk === "number" ? ` hunk ${cause.chunk}` : ""; lines.push(`Expected lines for ${path}${chunk}:`); appendQuotedBlock(lines, expected, 20, 1600); appendExpectedLineDiagnostics(lines, cause); lines.push("Hint: re-read the target file around this hunk. In Update File hunks, every context line needs a leading space prefix; for a column-0 source line like `]);`, write ` ]);`."); lines.push("Hint: this is still handled by apply-patch v2. Correct the hunk context or prefixes; do not switch the same patch to apply-patch-v1."); } function appendExpectedLineDiagnostics(lines: string[], cause: Record): void { const diagnostics = recordValue(cause.diagnostics); if (diagnostics === null) return; const candidates = Array.isArray(diagnostics.firstExpectedLineCandidates) ? diagnostics.firstExpectedLineCandidates.filter((item): item is number => typeof item === "number") : []; const firstExpectedLine = typeof diagnostics.firstExpectedLine === "string" ? diagnostics.firstExpectedLine : ""; if (firstExpectedLine.length > 0 && candidates.length > 0) { const suffix = Boolean(diagnostics.firstExpectedLineCandidatesTruncated) ? "+" : ""; lines.push(`First expected line appears near target line(s): ${candidates.join(", ")}${suffix}`); } if (typeof diagnostics.bestPrefixMatchedLines === "number" && diagnostics.bestPrefixMatchedLines > 0 && typeof diagnostics.bestPrefixStartLine === "number") { lines.push(`Best partial context match: ${diagnostics.bestPrefixMatchedLines} expected line(s) matched starting near line ${diagnostics.bestPrefixStartLine}.`); } if (diagnostics.likelyMissingAddedPrefixes === true) { lines.push("Hint: this hunk looks like a large insertion whose new lines were written as context. Prefix every inserted line with +, keep only a few real existing context lines before/after the insertion, and regenerate the patch instead of editing it with sed."); } if (diagnostics.likelyStaleOrOversizedContext === true) { lines.push("Hint: this looks like stale or oversized context for a block/function replacement. Do not switch to download/upload, remote Python/Perl/sed, or a whole-file rewrite. Re-read the exact current block and retry apply-patch with a smaller hunk, or split the edit into hunks around unique anchors."); } } function recordValue(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function appendQuotedBlock(lines: string[], text: string, maxLines: number, maxChars: number): void { const sourceLines = text.split("\n"); let usedChars = 0; let omitted = 0; for (const [index, line] of sourceLines.entries()) { const quoted = JSON.stringify(line); if (index >= maxLines || usedChars + quoted.length > maxChars) { omitted = sourceLines.length - index; break; } lines.push(` ${quoted}`); usedChars += quoted.length; } if (omitted > 0) lines.push(` ... ${omitted} more expected line(s) omitted`); } function appendOutcomeSection(lines: string[], title: string, outcomes: ApplyPatchV2Outcome[]): void { if (outcomes.length === 0) return; lines.push(title); for (const outcome of outcomes) { lines.push(` ${formatOutcome(outcome)}`); } } function formatOutcome(outcome: ApplyPatchV2Outcome): string { const target = outcome.targetPath === undefined ? outcome.path : `${outcome.path} -> ${outcome.targetPath}`; const base = `hunk ${outcome.hunk} ${outcome.action} ${target}`; if (outcome.status === "applied") return outcome.change === undefined ? base : `${outcome.change} (${base})`; const error = outcome.error?.message ?? "failed"; const partial = outcome.partialChanges === undefined || outcome.partialChanges.length === 0 ? "" : `; partial changes: ${outcome.partialChanges.join(", ")}`; return `${base}: ${error}${partial}`; } async function ensureRemoteFileExists(executor: ApplyPatchV2Executor, target: string): Promise { if (executor.fs) { await executor.fs.stat(target); return; } await checkedRemoteV2(executor, "stat", [target]); } async function executePlannedOperation(executor: ApplyPatchV2Executor, operation: PlannedOperation): Promise { if (operation.kind === "write") { await writeRemoteText(executor, operation.path, operation.content); return; } if (executor.fs) { await executor.fs.deleteFile(operation.path); return; } await checkedRemoteV2(executor, "delete", [operation.path]); } const readBlockBytes = 45_000; const writeB64ArgvLimit = 48_000; const writeB64ChunkChars = 12_000; const remoteReadBlockMarker = "UNIDESK_APPLY_PATCH_V2_BLOCK"; async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise { if (executor.fs) { const stat = await executor.fs.stat(target); if (!Number.isSafeInteger(stat.bytes) || stat.bytes < 0 || !/^[0-9a-f]{64}$/u.test(stat.sha256)) { throw new ApplyPatchV2Error("remote apply-patch v2 fs stat returned invalid metadata", { path: target, stat }); } const chunks: Buffer[] = []; let actualBytes = 0; for (let blockIndex = 0; actualBytes < stat.bytes; blockIndex += 1) { const chunk = await executor.fs.readBlock(target, blockIndex, readBlockBytes); if (chunk.length === 0) { throw new ApplyPatchV2Error("remote apply-patch v2 fs read returned an empty block before EOF", { path: target, blockIndex, expectedBytes: stat.bytes, actualBytes, }); } chunks.push(chunk); actualBytes += chunk.length; } const contentBuffer = Buffer.concat(chunks); if (contentBuffer.length !== stat.bytes) { throw new ApplyPatchV2Error("remote apply-patch v2 fs read byte count mismatch", { path: target, expectedBytes: stat.bytes, actualBytes: contentBuffer.length, }); } const actualSha256 = sha256Hex(contentBuffer); if (actualSha256 !== stat.sha256) { throw new ApplyPatchV2Error("remote apply-patch v2 fs read sha256 mismatch", { path: target, expectedSha256: stat.sha256, actualSha256, }); } return contentBuffer.toString("utf8"); } const stat = await checkedRemoteV2(executor, "stat", [target]); const [bytesText, expectedSha256] = stat.stdout.trim().split(/\s+/u); const expectedBytes = Number(bytesText); if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || !/^[0-9a-f]{64}$/u.test(expectedSha256 ?? "")) { throw new ApplyPatchV2Error("remote apply-patch v2 stat returned invalid metadata", { path: target, stdout: stat.stdout.slice(0, 500), stderr: stat.stderr.slice(-500) }); } const chunks: Buffer[] = []; let actualBytes = 0; for (let blockIndex = 0; actualBytes < expectedBytes; blockIndex += 1) { const read = await checkedRemoteV2(executor, "read-b64-block", [target, String(blockIndex), String(readBlockBytes)]); const expectedChunkBytes = Math.min(readBlockBytes, expectedBytes - actualBytes); const chunk = decodeRemoteReadBlock(read.stdout, target, blockIndex, expectedChunkBytes); if (chunk.length === 0) { throw new ApplyPatchV2Error("remote apply-patch v2 read returned an empty block before EOF", { path: target, blockIndex, expectedBytes, actualBytes, }); } chunks.push(chunk); actualBytes += chunk.length; } const contentBuffer = Buffer.concat(chunks); if (contentBuffer.length !== expectedBytes) { throw new ApplyPatchV2Error("remote apply-patch v2 read byte count mismatch", { path: target, expectedBytes, actualBytes: contentBuffer.length, }); } const actualSha256 = sha256Hex(contentBuffer); if (actualSha256 !== expectedSha256) { throw new ApplyPatchV2Error("remote apply-patch v2 read sha256 mismatch", { path: target, expectedSha256, actualSha256, }); } return contentBuffer.toString("utf8"); } function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: number, expectedChunkBytes: number): Buffer { const lines = stdout.split(/\r?\n/u); const markerIndex = lines.findIndex((line) => line.startsWith(`${remoteReadBlockMarker} `)); let encoded = ""; let declaredBytes: number | null = null; let declaredSha256: string | null = null; if (markerIndex >= 0) { const [, bytesText, sha256] = lines[markerIndex].split(/\s+/u); declaredBytes = Number(bytesText); declaredSha256 = sha256 ?? null; if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0 || !/^[0-9a-f]{64}$/u.test(declaredSha256 ?? "")) { throw new ApplyPatchV2Error("remote apply-patch v2 read block returned invalid metadata", { path: target, blockIndex, marker: lines[markerIndex].slice(0, 200), }); } encoded = (lines[markerIndex + 1] ?? "").replace(/\s+/gu, ""); } else { encoded = stdout.replace(/\s+/gu, ""); } const decoded = encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64"); if (declaredBytes !== null) { if (decoded.length !== declaredBytes) { throw new ApplyPatchV2Error("remote apply-patch v2 read block byte count mismatch", { path: target, blockIndex, expectedBytes: declaredBytes, actualBytes: decoded.length, }); } const actualSha256 = sha256Hex(decoded); if (actualSha256 !== declaredSha256) { throw new ApplyPatchV2Error("remote apply-patch v2 read block sha256 mismatch", { path: target, blockIndex, expectedSha256: declaredSha256, actualSha256, }); } } return decoded.length > expectedChunkBytes ? decoded.subarray(0, expectedChunkBytes) : decoded; } async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise { const contentBuffer = Buffer.from(content, "utf8"); if (executor.fs) { await executor.fs.writeFile(target, contentBuffer); return; } const encoded = contentBuffer.toString("base64"); const expectedBytes = String(contentBuffer.length); const expectedSha256 = sha256Hex(contentBuffer); if (encoded.length <= writeB64ArgvLimit) { await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, writeB64ChunkChars)]); return; } try { await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded); return; } catch { // Some SSH/websocket bridges cap stdin payloads without a stable public // contract. The stdin path is still the fast path; fall back to small argv // chunks only after the remote sha/byte guard proves no partial write moved. } const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`; await checkedRemoteV2(executor, "write-b64-begin", [target, token]); for (const chunk of chunkString(encoded, writeB64ChunkChars)) { await checkedRemoteV2(executor, "write-b64-append", [target, token, chunk]); } await checkedRemoteV2(executor, "write-b64-commit", [target, token, expectedBytes, expectedSha256]); } type RemoteV2Operation = | "stat" | "read-b64-block" | "write-b64-argv" | "write-b64-stdin" | "write-b64-begin" | "write-b64-append" | "write-b64-commit" | "delete"; async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: RemoteV2Operation, args: string[], input?: string): Promise<{ stdout: string }> { if (!executor.run) { throw new ApplyPatchV2Error("remote apply-patch v2 executor does not provide a command runner", { operation, args }); } const result = await executor.run(remoteV2Script(operation, args), input); if (result.exitCode === 0) return result; throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", { operation, args, exitCode: result.exitCode, stdout: result.stdout.slice(-2000), stderr: result.stderr.slice(-4000), }); } function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[] { const script = [ "set -eu", "sha256_file() {", " if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi", " if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi", " if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi", " printf 'missing sha256 tool\\n' >&2", " return 127", "}", "verify_tmp() {", " target=$1", " tmp=$2", " expected_bytes=$3", " expected_sha256=$4", " actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')", " if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then", " rm -f -- \"$tmp\"", " printf 'v2 decoded byte count mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_bytes\" \"$actual_bytes\" >&2", " exit 23", " fi", " actual_sha256=$(sha256_file \"$tmp\")", " if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then", " rm -f -- \"$tmp\"", " printf 'v2 decoded sha256 mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_sha256\" \"$actual_sha256\" >&2", " exit 24", " fi", "}", "set_tmp_paths() {", " target=$1", " token=$2", " case \"$token\" in ''|*[!a-zA-Z0-9_.-]*) printf 'invalid v2 temp token\\n' >&2; exit 2;; esac", " base=${target##*/}", " dir=.", " case \"$target\" in */*) dir=${target%/*};; esac", " tmp=\"$dir/.${base}.unidesk-v2-${token}.tmp\"", " tmp_b64=\"$tmp.b64\"", "}", "op=$1", "shift", "case \"$op\" in", " stat)", " target=$1", " if [ ! -e \"$target\" ]; then printf 'file not found: %s\\n' \"$target\" >&2; exit 1; fi", " if [ -d \"$target\" ]; then printf 'not a file: %s\\n' \"$target\" >&2; exit 1; fi", " bytes=$(wc -c < \"$target\" | tr -d '[:space:]')", " digest=$(sha256_file \"$target\")", " printf '%s %s\\n' \"$bytes\" \"$digest\"", " ;;", " read-b64-block)", " target=$1", " block_index=$2", " block_size=$3", " case \"$block_index:$block_size\" in *[!0-9:]*|:*) printf 'invalid read block args\\n' >&2; exit 2;; esac", " tmp=$(mktemp)", " dd if=\"$target\" bs=\"$block_size\" skip=\"$block_index\" count=1 2>/dev/null > \"$tmp\"", " bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')", " digest=$(sha256_file \"$tmp\")", ` printf '${remoteReadBlockMarker} %s %s\\n' "$bytes" "$digest"`, " base64 < \"$tmp\" | tr -d '\\n'", " printf '\\n'", " rm -f -- \"$tmp\"", " ;;", " write-b64-argv)", " target=$1", " expected_bytes=$2", " expected_sha256=$3", " shift 3", " case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac", " base=${target##*/}", " dir=.", " case \"$target\" in */*) dir=${target%/*};; esac", " tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"", " : > \"$tmp.b64\"", " for chunk in \"$@\"; do printf '%s' \"$chunk\" >> \"$tmp.b64\"; done", " base64 -d < \"$tmp.b64\" > \"$tmp\"", " rm -f -- \"$tmp.b64\"", " verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"", " mv -f -- \"$tmp\" \"$target\"", " actual_sha256=$(sha256_file \"$target\")", " if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi", " ;;", " write-b64-stdin)", " target=$1", " expected_bytes=$2", " expected_sha256=$3", " case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac", " base=${target##*/}", " dir=.", " case \"$target\" in */*) dir=${target%/*};; esac", " tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"", " if ! base64 -d > \"$tmp\"; then rm -f -- \"$tmp\"; printf 'v2 base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi", " verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"", " mv -f -- \"$tmp\" \"$target\"", " actual_sha256=$(sha256_file \"$target\")", " if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi", " ;;", " write-b64-begin)", " target=$1", " case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac", " set_tmp_paths \"$target\" \"$2\"", " : > \"$tmp_b64\"", " ;;", " write-b64-append)", " target=$1", " set_tmp_paths \"$target\" \"$2\"", " printf '%s' \"$3\" >> \"$tmp_b64\"", " ;;", " write-b64-commit)", " target=$1", " set_tmp_paths \"$target\" \"$2\"", " expected_bytes=$3", " expected_sha256=$4", " if ! base64 -d < \"$tmp_b64\" > \"$tmp\"; then rm -f -- \"$tmp\" \"$tmp_b64\"; printf 'v2 base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi", " rm -f -- \"$tmp_b64\"", " verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"", " mv -f -- \"$tmp\" \"$target\"", " actual_sha256=$(sha256_file \"$target\")", " if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi", " ;;", " delete)", " target=$1", " if [ ! -e \"$target\" ]; then printf 'file not found: %s\\n' \"$target\" >&2; exit 1; fi", " rm -- \"$target\"", " ;;", " *)", " printf 'unsupported op: %s\\n' \"$op\" >&2", " exit 2", " ;;", "esac", ].join("\n"); return ["sh", "-c", script, "unidesk-apply-patch-v2", operation, ...args]; } function sha256Hex(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); } function chunkString(value: string, chunkSize: number): string[] { const chunks: string[] = []; for (let index = 0; index < value.length; index += chunkSize) { chunks.push(value.slice(index, index + chunkSize)); } return chunks.length > 0 ? chunks : [""]; } function readStreamText(stream: Readable): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); stream.on("error", reject); stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); stream.resume(); }); } function stripLenientHeredoc(text: string): string { const trimmed = text.trim(); const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u); const first = lines[0] ?? ""; const last = lines[lines.length - 1] ?? ""; if ((first === "<= 4) { return lines.slice(1, -1).join("\n"); } return text; } function validatePatchPath(value: string, line: number): string { const filePath = value.trim(); if (filePath.length === 0) throw new ApplyPatchV2Error("patch path cannot be empty", { line }); return filePath; } function isFileHeader(line: string): boolean { const trimmed = line.trim(); return trimmed.startsWith(addFileMarker) || trimmed.startsWith(deleteFileMarker) || trimmed.startsWith(updateFileMarker) || trimmed === beginMarker || trimmed === endMarker; } function isUpdateChunkHeader(line: string): boolean { return line === emptyChangeContextMarker || line.startsWith(changeContextMarker) || parseUnifiedDiffHunkHeader(line) !== null; } function pushUniqueHint(hints: string[], prefix: string, hint: string): void { if (!hints.some((existing) => existing.startsWith(prefix))) hints.push(hint); } function parseUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean, filePath: string, hints: string[]): { chunk: UpdateChunk; nextIndex: number } { let index = startIndex; let changeContext: string | null = null; let sourceStartLine: number | null = null; const first = lines[index] ?? ""; if (first === emptyChangeContextMarker) { index += 1; } else { const unifiedHeader = parseUnifiedDiffHunkHeader(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(changeContextMarker)) { changeContext = first.slice(changeContextMarker.length); index += 1; } else if (!allowMissingContext) { throw new ApplyPatchV2Error("expected update chunk to start with @@ context marker", { 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): void { 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 (isFileHeader(line)) break; if (parsed > 0 && isUpdateChunkHeader(line)) break; if (line === eofMarker) { if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { 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 { pushUniqueHint( hints, `apply-patch hint: accepted unprefixed Update File context line in ${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 new ApplyPatchV2Error("update chunk does not contain any lines", { line: startIndex + 1 }); return { chunk: { changeContext, sourceStartLine, oldLines, newLines, contextLinePairs, contextLineCount, addedLineCount, deletedLineCount, isEndOfFile }, nextIndex: index }; } function parseUnifiedDiffHunkHeader(line: string): { oldStart: number } | null { const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@(?:\s+.*)?$/u.exec(line); if (match === null) return null; const oldStart = Number(match[1] ?? "0"); if (!Number.isSafeInteger(oldStart) || oldStart < 0) return null; return { oldStart }; } function splitContentLines(content: string): string[] { const lines = content.split("\n"); if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); return lines; } function joinLinesWithFinalNewline(lines: string[]): string { if (lines.length === 0) return ""; return `${lines.join("\n")}\n`; } function computeReplacements(filePath: string, originalLines: string[], chunks: UpdateChunk[]): Replacement[] { const replacements: Replacement[] = []; let lineIndex = 0; for (const [chunkIndex, chunk] of chunks.entries()) { if (chunk.changeContext !== null) { const foundContext = seekSequence(originalLines, [chunk.changeContext], lineIndex, false); if (foundContext === null) { throw new ApplyPatchV2Error("failed to find update context", { path: filePath, chunk: chunkIndex + 1, context: chunk.changeContext }); } lineIndex = foundContext + 1; } if (chunk.oldLines.length === 0) { const insertAt = originalLines.length > 0 && originalLines[originalLines.length - 1] === "" ? originalLines.length - 1 : originalLines.length; replacements.push([insertAt, 0, chunk.newLines]); continue; } let pattern = chunk.oldLines; const preferredStart = chunk.sourceStartLine === null ? lineIndex : Math.max(lineIndex, chunk.sourceStartLine - 1); let found = seekSequenceWithFallback(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 = seekSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile); } if (found === null) { throw new ApplyPatchV2Error("failed to find expected lines", { path: filePath, chunk: chunkIndex + 1, expected: chunk.oldLines.join("\n"), diagnostics: expectedLineDiagnostics(originalLines, chunk, preferredStart), }); } replacements.push([found, pattern.length, preserveMatchedContextLines(originalLines, found, newLines, chunk.contextLinePairs, pattern.length)]); lineIndex = found + pattern.length; } assertNonOverlappingReplacements(filePath, replacements, originalLines.length); return replacements; } function expectedLineDiagnostics(originalLines: string[], chunk: UpdateChunk, preferredStart: number): Record { const firstExpectedLine = chunk.oldLines.find((line) => line.trim().length > 0) ?? ""; const firstExpectedLineCandidates = firstExpectedLine.length === 0 ? [] : candidateLineNumbers(originalLines, firstExpectedLine, 8); const prefix = bestPrefixMatch(originalLines, chunk.oldLines, firstExpectedLine, preferredStart); const missingAddedPrefixes = likelyMissingAddedPrefixes(chunk, prefix.matchedLines); return { firstExpectedLine, firstExpectedLineCandidates, firstExpectedLineCandidatesTruncated: firstExpectedLine.length > 0 && candidateLineNumbers(originalLines, firstExpectedLine, 9).length > firstExpectedLineCandidates.length, bestPrefixMatchedLines: prefix.matchedLines, bestPrefixStartLine: prefix.startLine, likelyMissingAddedPrefixes: missingAddedPrefixes, likelyStaleOrOversizedContext: !missingAddedPrefixes && likelyStaleOrOversizedContext(chunk, prefix.matchedLines), }; } function candidateLineNumbers(lines: string[], expectedLine: string, limit: number): number[] { const result: number[] = []; for (let index = 0; index < lines.length; index += 1) { if (lineEquivalent(lines[index] ?? "", expectedLine)) { result.push(index + 1); if (result.length >= limit) break; } } return result; } function bestPrefixMatch(lines: string[], expectedLines: string[], firstExpectedLine: string, preferredStart: number): { startLine: number | null; matchedLines: 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 && !lineEquivalent(lines[index] ?? "", firstExpectedLine)) continue; let matched = 0; while (index + matched < lines.length && matched < expectedLines.length && lineEquivalent(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 && !lineEquivalent(lines[index] ?? "", firstExpectedLine)) continue; let matched = 0; while (index + matched < lines.length && matched < expectedLines.length && lineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1; if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched }; } return best; } function likelyMissingAddedPrefixes(chunk: UpdateChunk, bestPrefixMatchedLines: number): boolean { 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 likelyStaleOrOversizedContext(chunk: UpdateChunk, bestPrefixMatchedLines: number): boolean { 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 lineEquivalent(left: string, right: string): boolean { return left === right || left.trimEnd() === right.trimEnd() || left.trim() === right.trim() || normalizeLine(left) === normalizeLine(right); } function preserveMatchedContextLines(originalLines: string[], found: number, newLines: string[], contextLinePairs: UpdateChunk["contextLinePairs"], matchedOldLength: number): string[] { 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 seekSequenceWithFallback(lines: string[], pattern: string[], preferredStart: number, fallbackStart: number, eof: boolean): number | null { const preferred = seekSequence(lines, pattern, preferredStart, eof); if (preferred !== null || preferredStart === fallbackStart) return preferred; return seekSequence(lines, pattern, fallbackStart, eof); } function applyReplacements(lines: string[], replacements: Replacement[]): string[] { const result = [...lines]; for (const [start, oldLen, newSegment] of [...replacements].reverse()) { result.splice(start, oldLen, ...newSegment); } return result; } function assertNonOverlappingReplacements(filePath: string, replacements: Replacement[], lineCount: number): void { const sorted = [...replacements].sort((left, right) => left[0] - right[0]); let previousEnd = 0; for (const [start, oldLen] of sorted) { if (start < 0 || oldLen < 0 || start + oldLen > lineCount) { throw new ApplyPatchV2Error("computed replacement is outside file bounds", { path: filePath, start, oldLen, lineCount, }); } if (start < previousEnd) { throw new ApplyPatchV2Error("computed replacements overlap", { path: filePath, start, previousEnd, }); } previousEnd = Math.max(previousEnd, start + oldLen); } } function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | null { if (pattern.length === 0) return start; if (pattern.length > lines.length) return null; const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start; const attempts: Array<(value: string) => string> = [ (value) => value, (value) => value.trimEnd(), (value) => value.trim(), normalizeLine, ]; 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 normalizeLine(value: string): 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, " "); }