Files
openclaw/src/agents/apply-patch.ts
Peter Steinberger bb46b79d3c refactor: internalize OpenClaw agent runtime (#85341)
* refactor: extract agent core package

Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts.

* refactor: extract shared llm runtime

Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout.

* refactor: remove pi runtime internals

Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code.

* refactor: tighten agent session runtime

Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts.

* refactor: remove static model and pi auth paths

Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities.

* refactor: remove legacy provider compat paths

* docs: remove agent parity notes

* fix: skip provider wildcard metadata parsing

* refactor: share session extension sdk loading

* refactor: inline acpx proxy error formatter

* refactor: fold edit recovery into edit tool

* fix: accept extension batch separator

* test: align startup provider plugin expectations

* fix: restore provider-scoped release discovery

* test: align static asset packaging expectations

* fix: run static provider catalogs during scoped discovery

* fix: add provider entry catalogs for scoped live discovery

* fix: load lightweight provider catalog entries

* fix: refresh provider-scoped plugin metadata

* fix: keep provider catalog entries on release live path

* fix: keep static manifest models in release live checks

* fix: harden release model discovery

* fix: reduce OpenAI live cache probe reasoning

* fix: disable OpenAI cache probe reasoning

* ci: extend OpenAI gateway live timeout

* fix: extend live gateway model budget

* fix: stabilize release validation regressions

* fix: honor provider aliases in model rows

* fix: stabilize release validation lanes

* fix: stabilize release memory qa

* ci: stabilize release validation lanes

* ci: prefer ipv4 for live docker node calls

* fix: restore shared tool-call stream wrapper

* ci: remove legacy pi test shard alias

* fix: clean up embedded agent test drift

* fix: stabilize runtime alias status

* fix: clean up embedded agent ci drift

* fix: restore release ci invariants

* fix: clean up post-rebase runtime drift

* fix: restore release ci checks

* fix: restore release ci after rebase

* fix: remove stale pi runtime path

* test: align compaction runtime expectations

* test: update plugin prerelease expectations

* fix: handle claude live tool approvals

* fix: stabilize release validation gates

* fix: finish agent runtime import

* test: finish post-rebase agent runtime mocks

* fix: keep codex compaction native

* fix: stabilize codex app-server hook tests

* test: isolate codex diagnostic active run

* test: remove codex diagnostic completion race

# Conflicts:
#	extensions/codex/src/app-server/run-attempt.test.ts

* ci: fix full release manifest performance run id

* refactor: narrow llm plugin sdk boundary

* chore: drop generated google boundary stamps

* fix: repair rebase fallout

* fix: clean up rebased runtime references

* fix: decode codex jwt payloads as base64url

* fix: preserve shipped pi runtime alias

* fix: add scoped sdk virtual modules

* fix: decode llm codex oauth jwt as base64url

* fix: avoid stale vertex adc negative cache

* fix: harden tool arg decoding and codeql path

* fix: keep vertex adc negative checks live

* refactor: consolidate codex jwt and edit helpers

* fix: await codex oauth node runtime imports

* fix: preserve sdk tool and notice contracts

* fix: preserve shipped compat config boundaries

* fix: align codex oauth callback host

* fix: terminate agent-core loop streams on failure

* fix: keep codex oauth callback alive during fallback

* ci: include session tools in critical codeql scans

* fix: keep Cloudflare Anthropic provider auth header

* docs: redirect legacy pi runtime pages

* fix: honor bundled web provider compat discovery

* fix: protect session output spill files

* fix: keep legacy agent dir env blocked

* fix: contain auto-discovered skill symlinks

* fix: harden agent core sdk proxy surfaces

* fix: restore approval reaction sdk compat

* fix: keep live docker runs bounded

* fix: keep codex oauth redirect host aligned

* fix: resolve post-rebase agent runtime drift

* fix: redact anthropic oauth parse failures

* fix: preserve responses strict tool shaping

* fix: repair agent runtime rebase cleanup

* docs: redirect retired parity pages

* fix: bound auto-discovered resources to roots

* fix: repair post-rebase agent test drift

* fix: preserve bundled provider allowlist migration

* fix: preserve manifest-owned provider aliases

* fix: declare photon image dependency

* fix: keep provider headers out of proxy body

* fix: preserve shipped env aliases

* fix: refresh control ui i18n generated state

* fix: quote read fallback paths

* fix: preview edits through configured backend

* test: satisfy core test typecheck

* fix: preserve ZAI usage auth fallback

* test: repair codex diagnostic test

* fix: repair agent runtime rebase drift

* test: finish embedded runner import rename

* fix: repair agent runtime rebase integrations

* test: align compaction oauth fallback expectations

* fix: allow sdk-auth session models

* fix: update doctor tool schema import

* fix: preserve bedrock plugin region

* fix: stream harmony-like prose immediately

* ci: include session runtime in codeql shards

* fix: repair latest rebase integrations

* fix: honor explicit codex websocket transport

* fix: keep openai-compatible credentials provider-scoped

* fix: refresh sdk api baseline after rebase

* fix: route cli runtime aliases through openclaw harness

* test: rename stale harness mock expectation

* test: rename embedded agent overflow calls

* test: clean embedded auth test wording

* test: use openclaw stream types in deepinfra cache test

* fix: refresh sdk api baseline on latest main

* fix: honor bundled discovery compat allowlists

* fix: refresh sdk api baseline after latest rebase

* fix: remove stale rebase imports

* test: rename stale model catalog mock

* test: mock renamed doctor runtime modules

* fix: map canonical kimi env auth

* fix: use internal model registry in bench script

* fix: migrate deepinfra provider catalog entry

* fix: enforce builtin tool suppression

* fix: route compaction auth and proxy payloads safely

* refactor: prune unused llm registry leftovers

* test: update codex hooks session import

* test: fix model picker ci coverage

* test: align model picker auth mock types
2026-05-27 19:24:04 +01:00

676 lines
19 KiB
TypeScript

import syncFs from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { Type } from "typebox";
import { openRootFile, type RootFileOpenResult } from "../infra/boundary-file-read.js";
import { root as fsRoot } from "../infra/fs-safe.js";
import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js";
import { applyUpdateHunk } from "./apply-patch-update.js";
import { toRelativeSandboxPath, resolvePathFromInput } from "./path-policy.js";
import type { AgentTool } from "./runtime/index.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
const BEGIN_PATCH_MARKER = "*** Begin Patch";
const END_PATCH_MARKER = "*** End Patch";
const ADD_FILE_MARKER = "*** Add File: ";
const DELETE_FILE_MARKER = "*** Delete File: ";
const UPDATE_FILE_MARKER = "*** Update File: ";
const MOVE_TO_MARKER = "*** Move to: ";
const EOF_MARKER = "*** End of File";
const CHANGE_CONTEXT_MARKER = "@@ ";
const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
type AddFileHunk = {
kind: "add";
path: string;
contents: string;
};
type DeleteFileHunk = {
kind: "delete";
path: string;
};
type UpdateFileChunk = {
changeContext?: string;
oldLines: string[];
newLines: string[];
isEndOfFile: boolean;
};
type UpdateFileHunk = {
kind: "update";
path: string;
movePath?: string;
chunks: UpdateFileChunk[];
};
type Hunk = AddFileHunk | DeleteFileHunk | UpdateFileHunk;
export type ApplyPatchSummary = {
added: string[];
modified: string[];
deleted: string[];
};
type ApplyPatchResult = {
summary: ApplyPatchSummary;
text: string;
};
type ApplyPatchToolDetails = {
summary: ApplyPatchSummary;
};
type SandboxApplyPatchConfig = {
root: string;
bridge: SandboxFsBridge;
};
type ApplyPatchOptions = {
cwd: string;
sandbox?: SandboxApplyPatchConfig;
/** Restrict patch paths to the workspace root (cwd). Default: true. Set false to opt out. */
workspaceOnly?: boolean;
signal?: AbortSignal;
};
const applyPatchSchema = Type.Object({
input: Type.String({
description: "Patch content using the *** Begin Patch/End Patch format.",
}),
});
export function createApplyPatchTool(
options: { cwd?: string; sandbox?: SandboxApplyPatchConfig; workspaceOnly?: boolean } = {},
): AgentTool<typeof applyPatchSchema, ApplyPatchToolDetails> {
const cwd = options.cwd ?? process.cwd();
const sandbox = options.sandbox;
const workspaceOnly = options.workspaceOnly !== false;
return {
name: "apply_patch",
label: "apply_patch",
description:
"Apply a patch to one or more files using the apply_patch format. The input should include *** Begin Patch and *** End Patch markers.",
parameters: applyPatchSchema,
execute: async (_toolCallId, args, signal) => {
const params = args as { input?: string };
const input = typeof params.input === "string" ? params.input : "";
if (!input.trim()) {
throw new Error("Provide a patch input.");
}
if (signal?.aborted) {
const err = new Error("Aborted");
err.name = "AbortError";
throw err;
}
const result = await applyPatch(input, {
cwd,
sandbox,
workspaceOnly,
signal,
});
return {
content: [{ type: "text", text: result.text }],
details: { summary: result.summary },
};
},
};
}
export async function applyPatch(
input: string,
options: ApplyPatchOptions,
): Promise<ApplyPatchResult> {
const parsed = parsePatchText(input);
if (parsed.hunks.length === 0) {
throw new Error("No files were modified.");
}
const summary: ApplyPatchSummary = {
added: [],
modified: [],
deleted: [],
};
const seen = {
added: new Set<string>(),
modified: new Set<string>(),
deleted: new Set<string>(),
};
const fileOps = resolvePatchFileOps(options);
for (const hunk of parsed.hunks) {
if (options.signal?.aborted) {
const err = new Error("Aborted");
err.name = "AbortError";
throw err;
}
if (hunk.kind === "add") {
const target = await resolvePatchPath(hunk.path, options);
await assertPatchParentPath(hunk.path, options);
await ensureDir(target.resolved, fileOps);
await fileOps.writeFile(target.resolved, hunk.contents);
recordSummary(summary, seen, "added", target.display);
continue;
}
if (hunk.kind === "delete") {
const target = await resolvePatchPath(hunk.path, options, PATH_ALIAS_POLICIES.unlinkTarget);
await fileOps.remove(target.resolved);
recordSummary(summary, seen, "deleted", target.display);
continue;
}
const target = await resolvePatchPath(hunk.path, options);
const applied = await applyUpdateHunk(target.resolved, hunk.chunks, {
readFile: (path) => fileOps.readFile(path),
});
if (hunk.movePath) {
const moveTarget = await resolvePatchPath(hunk.movePath, options);
await assertPatchParentPath(hunk.movePath, options);
await ensureDir(moveTarget.resolved, fileOps);
const moveResolvesToSource =
path.resolve(moveTarget.resolved) === path.resolve(target.resolved);
await fileOps.writeFile(
moveResolvesToSource ? target.resolved : moveTarget.resolved,
applied,
);
if (!moveResolvesToSource) {
await fileOps.remove(target.resolved);
}
recordSummary(
summary,
seen,
"modified",
moveResolvesToSource ? target.display : moveTarget.display,
);
} else {
await fileOps.writeFile(target.resolved, applied);
recordSummary(summary, seen, "modified", target.display);
}
}
return {
summary,
text: formatSummary(summary),
};
}
function recordSummary(
summary: ApplyPatchSummary,
seen: {
added: Set<string>;
modified: Set<string>;
deleted: Set<string>;
},
bucket: keyof ApplyPatchSummary,
value: string,
) {
if (seen[bucket].has(value)) {
return;
}
seen[bucket].add(value);
summary[bucket].push(value);
}
function formatSummary(summary: ApplyPatchSummary): string {
const lines = ["Success. Updated the following files:"];
for (const file of summary.added) {
lines.push(`A ${file}`);
}
for (const file of summary.modified) {
lines.push(`M ${file}`);
}
for (const file of summary.deleted) {
lines.push(`D ${file}`);
}
return lines.join("\n");
}
type PatchFileOps = {
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, content: string) => Promise<void>;
remove: (filePath: string) => Promise<void>;
mkdirp: (dir: string) => Promise<void>;
};
function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps {
if (options.sandbox) {
const { root, bridge } = options.sandbox;
return {
readFile: async (filePath) => {
const buf = await bridge.readFile({ filePath, cwd: root });
return buf.toString("utf8");
},
writeFile: (filePath, content) => bridge.writeFile({ filePath, cwd: root, data: content }),
remove: (filePath) => bridge.remove({ filePath, cwd: root, force: false }),
mkdirp: (dir) => bridge.mkdirp({ filePath: dir, cwd: root }),
};
}
const workspaceOnly = options.workspaceOnly !== false;
const rootPromise = workspaceOnly ? fsRoot(options.cwd) : undefined;
return {
readFile: async (filePath) => {
if (!workspaceOnly) {
return await fs.readFile(filePath, "utf8");
}
const opened = await openRootFile({
absolutePath: filePath,
rootPath: options.cwd,
boundaryLabel: "workspace root",
});
assertBoundaryRead(opened, filePath);
try {
return syncFs.readFileSync(opened.fd, "utf8");
} finally {
syncFs.closeSync(opened.fd);
}
},
writeFile: async (filePath, content) => {
if (!workspaceOnly) {
await fs.writeFile(filePath, content, "utf8");
return;
}
const relative = toRelativeSandboxPath(options.cwd, filePath);
await (await rootPromise)?.write(relative, content, { encoding: "utf8" });
},
remove: async (filePath) => {
if (!workspaceOnly) {
await fs.rm(filePath);
return;
}
const relative = toRelativeSandboxPath(options.cwd, filePath);
await (await rootPromise)?.remove(relative);
},
mkdirp: async (dir) => {
if (!workspaceOnly) {
await fs.mkdir(dir, { recursive: true });
return;
}
const relative = toRelativeSandboxPath(options.cwd, dir, { allowRoot: true });
const root = await rootPromise;
if (!root) {
return;
}
if (relative === "" || relative === ".") {
await root.ensureRoot();
return;
}
await root.mkdir(relative);
},
};
}
async function ensureDir(filePath: string, ops: PatchFileOps) {
const parent = path.dirname(filePath);
if (!parent || parent === ".") {
return;
}
await ops.mkdirp(parent);
}
async function assertPatchParentPath(filePath: string, options: ApplyPatchOptions) {
if (options.workspaceOnly === false || options.sandbox) {
return;
}
const parent = path.dirname(filePath);
if (!parent || parent === ".") {
return;
}
await assertSandboxPath({
filePath: parent,
cwd: options.cwd,
root: options.cwd,
});
await assertNoExistingParentAliases({
parentPath: resolvePathFromInput(parent, options.cwd),
rootPath: options.cwd,
});
}
async function assertNoExistingParentAliases(params: { parentPath: string; rootPath: string }) {
const rootPath = path.resolve(params.rootPath);
const parentPath = path.resolve(params.parentPath);
const relative = path.relative(rootPath, parentPath);
if (!relative || relative === "" || relativePathEscapesRoot(relative)) {
return;
}
let current = rootPath;
for (const segment of relative.split(path.sep)) {
if (!segment) {
continue;
}
current = path.join(current, segment);
const stat = await fs.lstat(current).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
});
if (!stat) {
return;
}
if (stat.isSymbolicLink()) {
throw new Error(`Path alias under sandbox root: ${path.relative(rootPath, current)}`);
}
}
}
async function resolvePatchPath(
filePath: string,
options: ApplyPatchOptions,
aliasPolicy: PathAliasPolicy = PATH_ALIAS_POLICIES.strict,
): Promise<{ resolved: string; display: string }> {
if (options.sandbox) {
const resolved = options.sandbox.bridge.resolvePath({
filePath,
cwd: options.cwd,
});
if (options.workspaceOnly !== false && resolved.hostPath) {
await assertSandboxPath({
filePath: resolved.hostPath,
cwd: options.cwd,
root: options.cwd,
allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink,
allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink,
});
}
return {
resolved: resolved.hostPath ?? resolved.containerPath,
display: resolved.relativePath || resolved.containerPath,
};
}
const workspaceOnly = options.workspaceOnly !== false;
const resolved = workspaceOnly
? (
await assertSandboxPath({
filePath,
cwd: options.cwd,
root: options.cwd,
allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink,
allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink,
})
).resolved
: resolvePathFromInput(filePath, options.cwd);
return {
resolved,
display: toDisplayPath(resolved, options.cwd),
};
}
function assertBoundaryRead(
opened: RootFileOpenResult,
targetPath: string,
): asserts opened is Extract<RootFileOpenResult, { ok: true }> {
if (opened.ok) {
return;
}
const reason = opened.reason === "validation" ? "unsafe path" : "path not found";
throw new Error(`Failed boundary read for ${targetPath} (${reason})`);
}
function toDisplayPath(resolved: string, cwd: string): string {
const relative = path.relative(cwd, resolved);
if (!relative || relative === "") {
return path.basename(resolved);
}
if (relativePathEscapesRoot(relative)) {
return resolved;
}
return relative;
}
function relativePathEscapesRoot(relativePath: string): boolean {
return (
relativePath === ".." ||
relativePath.startsWith("../") ||
relativePath.startsWith("..\\") ||
path.isAbsolute(relativePath)
);
}
function parsePatchText(input: string): { hunks: Hunk[]; patch: string } {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Invalid patch: input is empty.");
}
const lines = trimmed.split(/\r?\n/);
const validated = checkPatchBoundariesLenient(lines);
const hunks: Hunk[] = [];
const lastLineIndex = validated.length - 1;
let remaining = validated.slice(1, lastLineIndex);
let lineNumber = 2;
while (remaining.length > 0) {
const { hunk, consumed } = parseOneHunk(remaining, lineNumber);
hunks.push(hunk);
lineNumber += consumed;
remaining = remaining.slice(consumed);
}
return { hunks, patch: validated.join("\n") };
}
function checkPatchBoundariesLenient(lines: string[]): string[] {
const strictError = checkPatchBoundariesStrict(lines);
if (!strictError) {
return lines;
}
if (lines.length < 4) {
throw new Error(strictError);
}
const first = lines[0];
const last = lines.at(-1);
if (
last &&
(first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') &&
last.endsWith("EOF")
) {
const inner = lines.slice(1, -1);
const innerError = checkPatchBoundariesStrict(inner);
if (!innerError) {
return inner;
}
throw new Error(innerError);
}
throw new Error(strictError);
}
function checkPatchBoundariesStrict(lines: string[]): string | null {
const firstLine = lines[0]?.trim();
const lastLine = lines[lines.length - 1]?.trim();
if (firstLine === BEGIN_PATCH_MARKER && lastLine === END_PATCH_MARKER) {
return null;
}
if (firstLine !== BEGIN_PATCH_MARKER) {
return "The first line of the patch must be '*** Begin Patch'";
}
return "The last line of the patch must be '*** End Patch'";
}
function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; consumed: number } {
if (lines.length === 0) {
throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`);
}
const firstLine = lines[0].trim();
if (firstLine.startsWith(ADD_FILE_MARKER)) {
const targetPath = firstLine.slice(ADD_FILE_MARKER.length);
let contents = "";
let consumed = 1;
for (const addLine of lines.slice(1)) {
if (addLine.startsWith("+")) {
contents += `${addLine.slice(1)}\n`;
consumed += 1;
} else {
break;
}
}
return {
hunk: { kind: "add", path: targetPath, contents },
consumed,
};
}
if (firstLine.startsWith(DELETE_FILE_MARKER)) {
const targetPath = firstLine.slice(DELETE_FILE_MARKER.length);
return {
hunk: { kind: "delete", path: targetPath },
consumed: 1,
};
}
if (firstLine.startsWith(UPDATE_FILE_MARKER)) {
const targetPath = firstLine.slice(UPDATE_FILE_MARKER.length);
let remaining = lines.slice(1);
let consumed = 1;
let movePath: string | undefined;
const moveCandidate = remaining[0]?.trim();
if (moveCandidate?.startsWith(MOVE_TO_MARKER)) {
movePath = moveCandidate.slice(MOVE_TO_MARKER.length);
remaining = remaining.slice(1);
consumed += 1;
}
const chunks: UpdateFileChunk[] = [];
while (remaining.length > 0) {
if (remaining[0].trim() === "") {
remaining = remaining.slice(1);
consumed += 1;
continue;
}
if (remaining[0].startsWith("***")) {
break;
}
const { chunk, consumed: chunkLines } = parseUpdateFileChunk(
remaining,
lineNumber + consumed,
chunks.length === 0,
);
chunks.push(chunk);
remaining = remaining.slice(chunkLines);
consumed += chunkLines;
}
if (chunks.length === 0) {
throw new Error(
`Invalid patch hunk at line ${lineNumber}: Update file hunk for path '${targetPath}' is empty`,
);
}
return {
hunk: {
kind: "update",
path: targetPath,
movePath,
chunks,
},
consumed,
};
}
throw new Error(
`Invalid patch hunk at line ${lineNumber}: '${lines[0]}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`,
);
}
function parseUpdateFileChunk(
lines: string[],
lineNumber: number,
allowMissingContext: boolean,
): { chunk: UpdateFileChunk; consumed: number } {
if (lines.length === 0) {
throw new Error(
`Invalid patch hunk at line ${lineNumber}: Update hunk does not contain any lines`,
);
}
let changeContext: string | undefined;
let startIndex = 0;
if (lines[0] === EMPTY_CHANGE_CONTEXT_MARKER) {
startIndex = 1;
} else if (lines[0].startsWith(CHANGE_CONTEXT_MARKER)) {
changeContext = lines[0].slice(CHANGE_CONTEXT_MARKER.length);
startIndex = 1;
} else if (!allowMissingContext) {
throw new Error(
`Invalid patch hunk at line ${lineNumber}: Expected update hunk to start with a @@ context marker, got: '${lines[0]}'`,
);
}
if (startIndex >= lines.length) {
throw new Error(
`Invalid patch hunk at line ${lineNumber + 1}: Update hunk does not contain any lines`,
);
}
const chunk: UpdateFileChunk = {
changeContext,
oldLines: [],
newLines: [],
isEndOfFile: false,
};
let parsedLines = 0;
for (const line of lines.slice(startIndex)) {
if (line === EOF_MARKER) {
if (parsedLines === 0) {
throw new Error(
`Invalid patch hunk at line ${lineNumber + 1}: Update hunk does not contain any lines`,
);
}
chunk.isEndOfFile = true;
parsedLines += 1;
break;
}
const marker = line[0];
if (!marker) {
chunk.oldLines.push("");
chunk.newLines.push("");
parsedLines += 1;
continue;
}
if (marker === " ") {
const content = line.slice(1);
chunk.oldLines.push(content);
chunk.newLines.push(content);
parsedLines += 1;
continue;
}
if (marker === "+") {
chunk.newLines.push(line.slice(1));
parsedLines += 1;
continue;
}
if (marker === "-") {
chunk.oldLines.push(line.slice(1));
parsedLines += 1;
continue;
}
if (parsedLines === 0) {
throw new Error(
`Invalid patch hunk at line ${lineNumber + 1}: Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
);
}
break;
}
return { chunk, consumed: parsedLines + startIndex };
}