@yandy0725/pi-coding-tools 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/apply.ts DELETED
@@ -1,306 +0,0 @@
1
- import { mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
2
- import path from "node:path";
3
- import type { ParsedPatch, PatchChunk } from "./parse";
4
- import { parseNonEmptyPatch, seekSequence, splitFileLines } from "./parse";
5
- import { writeFileAtomic } from "./write-file-atomic";
6
-
7
- export type ApplyPatchFailure = {
8
- filePath: string;
9
- operation: "add" | "delete" | "update";
10
- message: string;
11
- };
12
-
13
- export type ApplyPatchRecoveryInstructions = {
14
- mustReadFiles: string[];
15
- mustNotReadFiles: string[];
16
- };
17
-
18
- export type ApplyPatchProgress = {
19
- applied: number;
20
- failed: number;
21
- total: number;
22
- };
23
-
24
- export type ApplyPatchProgressCallback = (progress: ApplyPatchProgress) => Promise<void> | void;
25
-
26
- export type ApplyPatchResult = {
27
- summaries: string[];
28
- appliedFiles: string[];
29
- failures: ApplyPatchFailure[];
30
- hasPartialSuccess: boolean;
31
- recoveryInstructions: ApplyPatchRecoveryInstructions;
32
- details: {
33
- fuzz: number;
34
- };
35
- };
36
-
37
- export class PatchApplicationError extends Error {
38
- constructor(message: string) {
39
- super(message);
40
- this.name = "PatchApplicationError";
41
- }
42
- }
43
-
44
- export class ApplyPatchError extends Error {
45
- public readonly failures: ApplyPatchFailure[];
46
- public readonly result: ApplyPatchResult;
47
-
48
- constructor(message: string, result: ApplyPatchResult) {
49
- super(message);
50
- this.name = "ApplyPatchError";
51
- this.failures = result.failures;
52
- this.result = result;
53
- }
54
-
55
- hasPartialSuccess(): boolean {
56
- return this.result.hasPartialSuccess;
57
- }
58
- }
59
-
60
- function hasErrorCode(error: unknown, code: string): boolean {
61
- return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
62
- }
63
-
64
- function isPathWithinWorkspace(workspacePath: string, candidatePath: string): boolean {
65
- const relativePath = path.relative(workspacePath, candidatePath);
66
- return (
67
- relativePath === "" ||
68
- (!relativePath.startsWith(`..${path.sep}`) && relativePath !== ".." && !path.isAbsolute(relativePath))
69
- );
70
- }
71
-
72
- async function findExistingAncestor(directoryPath: string, workspacePath: string): Promise<string> {
73
- let currentPath = directoryPath;
74
- while (isPathWithinWorkspace(workspacePath, currentPath)) {
75
- try {
76
- await stat(currentPath);
77
- return currentPath;
78
- } catch (error) {
79
- if (!hasErrorCode(error, "ENOENT")) {
80
- throw error;
81
- }
82
- }
83
-
84
- const parentPath = path.dirname(currentPath);
85
- if (parentPath === currentPath) {
86
- break;
87
- }
88
- currentPath = parentPath;
89
- }
90
-
91
- throw new PatchApplicationError(`Patch path escapes workspace: ${directoryPath}`);
92
- }
93
-
94
- async function resolvePatchPath(cwd: string, filePath: string): Promise<string> {
95
- const workspacePath = await realpath(cwd);
96
- const absolutePath = path.resolve(workspacePath, filePath);
97
- if (!isPathWithinWorkspace(workspacePath, absolutePath)) {
98
- throw new PatchApplicationError(`Patch path escapes workspace: ${filePath}`);
99
- }
100
-
101
- const existingAncestor = await findExistingAncestor(path.dirname(absolutePath), workspacePath);
102
- const realAncestor = await realpath(existingAncestor);
103
- if (!isPathWithinWorkspace(workspacePath, realAncestor)) {
104
- throw new PatchApplicationError(`Patch path escapes workspace: ${filePath}`);
105
- }
106
-
107
- return absolutePath;
108
- }
109
-
110
- export function replaceChunks(
111
- content: string,
112
- filePath: string,
113
- chunks: PatchChunk[],
114
- ): { content: string; fuzz: number } {
115
- const originalLines = splitFileLines(content);
116
- const replacements: { start: number; oldLength: number; newLines: string[] }[] = [];
117
- let lineIndex = 0;
118
- let fuzz = 0;
119
-
120
- for (const chunk of chunks) {
121
- for (const changeContext of chunk.changeContexts) {
122
- if (!changeContext) {
123
- continue;
124
- }
125
- const contextMatch = seekSequence(originalLines, [changeContext], lineIndex, false);
126
- if (contextMatch === undefined) {
127
- throw new PatchApplicationError(`Failed to find context '${changeContext}' in ${filePath}`);
128
- }
129
- fuzz += contextMatch.fuzz;
130
- lineIndex = contextMatch.index + 1;
131
- }
132
-
133
- if (chunk.oldLines.length === 0) {
134
- const insertionIndex =
135
- originalLines[originalLines.length - 1] === "" ? originalLines.length - 1 : originalLines.length;
136
- replacements.push({ start: insertionIndex, oldLength: 0, newLines: chunk.newLines });
137
- continue;
138
- }
139
-
140
- let pattern = chunk.oldLines;
141
- let newLines = chunk.newLines;
142
- let foundAt = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
143
- if (foundAt === undefined && pattern[pattern.length - 1] === "") {
144
- pattern = pattern.slice(0, -1);
145
- if (newLines[newLines.length - 1] === "") {
146
- newLines = newLines.slice(0, -1);
147
- }
148
- foundAt = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
149
- }
150
-
151
- if (foundAt === undefined) {
152
- throw new PatchApplicationError(`Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`);
153
- }
154
-
155
- fuzz += foundAt.fuzz;
156
- replacements.push({ start: foundAt.index, oldLength: pattern.length, newLines });
157
- lineIndex = foundAt.index + pattern.length;
158
- }
159
-
160
- const nextLines = [...originalLines];
161
- for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
162
- nextLines.splice(replacement.start, replacement.oldLength, ...replacement.newLines);
163
- }
164
- nextLines.push("");
165
- return { content: nextLines.join("\n"), fuzz };
166
- }
167
-
168
- async function applySingleHunk(
169
- cwd: string,
170
- hunk: ParsedPatch,
171
- ): Promise<{ summary: string; appliedFile: string; fuzz: number }> {
172
- const absolutePath = await resolvePatchPath(cwd, hunk.filePath);
173
- if (hunk.type === "add") {
174
- await mkdir(path.dirname(absolutePath), { recursive: true });
175
- await writeFileAtomic(absolutePath, hunk.content);
176
- return { summary: `add: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0 };
177
- }
178
-
179
- if (hunk.type === "delete") {
180
- await stat(absolutePath);
181
- await rm(absolutePath);
182
- return { summary: `delete: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0 };
183
- }
184
-
185
- const currentContent = await readFile(absolutePath, "utf-8");
186
- const chunkResult =
187
- hunk.chunks.length === 0
188
- ? { content: currentContent, fuzz: 0 }
189
- : replaceChunks(currentContent, hunk.filePath, hunk.chunks);
190
- const nextContent = chunkResult.content;
191
-
192
- if (hunk.movePath) {
193
- const absoluteMovePath = await resolvePatchPath(cwd, hunk.movePath);
194
- await mkdir(path.dirname(absoluteMovePath), { recursive: true });
195
- await writeFileAtomic(absoluteMovePath, nextContent);
196
- if (absoluteMovePath !== absolutePath) {
197
- await rm(absolutePath);
198
- }
199
- return {
200
- summary: `move: ${hunk.filePath} -> ${hunk.movePath}`,
201
- appliedFile: hunk.movePath,
202
- fuzz: chunkResult.fuzz,
203
- };
204
- }
205
-
206
- await writeFileAtomic(absolutePath, nextContent);
207
- return { summary: `update: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: chunkResult.fuzz };
208
- }
209
-
210
- async function notifyApplyPatchProgress(
211
- onProgress: ApplyPatchProgressCallback | undefined,
212
- progress: ApplyPatchProgress,
213
- ): Promise<void> {
214
- try {
215
- await onProgress?.(progress);
216
- } catch {
217
- // Rendering progress must not affect patch application or recovery details.
218
- }
219
- }
220
-
221
- function createRecoveryInstructions(
222
- result: Pick<ApplyPatchResult, "appliedFiles" | "failures">,
223
- ): ApplyPatchRecoveryInstructions {
224
- const mustReadFiles = [...new Set(result.failures.map((failure) => failure.filePath))];
225
- const mustReadFileSet = new Set(mustReadFiles);
226
- const mustNotReadFiles = [...new Set(result.appliedFiles.filter((filePath) => !mustReadFileSet.has(filePath)))];
227
- return { mustReadFiles, mustNotReadFiles };
228
- }
229
-
230
- export async function applyPatch(cwd: string, patchText: string): Promise<string[]> {
231
- const hunks = parseNonEmptyPatch(patchText);
232
-
233
- const summaries: string[] = [];
234
- const appliedFiles: string[] = [];
235
- for (const hunk of hunks) {
236
- try {
237
- const { summary, appliedFile } = await applySingleHunk(cwd, hunk);
238
- summaries.push(summary);
239
- appliedFiles.push(appliedFile);
240
- } catch (error) {
241
- const message = error instanceof Error ? error.message : String(error);
242
- const failure = { filePath: hunk.filePath, operation: hunk.type, message } satisfies ApplyPatchFailure;
243
- const result: ApplyPatchResult = {
244
- summaries,
245
- appliedFiles,
246
- failures: [failure],
247
- hasPartialSuccess: appliedFiles.length > 0,
248
- recoveryInstructions: createRecoveryInstructions({
249
- appliedFiles,
250
- failures: [failure],
251
- }),
252
- details: { fuzz: 0 },
253
- };
254
- throw new ApplyPatchError(message, result);
255
- }
256
- }
257
-
258
- return summaries;
259
- }
260
-
261
- export async function applyParsedPatchDetailed(
262
- cwd: string,
263
- hunks: ParsedPatch[],
264
- onProgress?: ApplyPatchProgressCallback,
265
- ): Promise<ApplyPatchResult> {
266
- const summaries: string[] = [];
267
- const appliedFiles: string[] = [];
268
- const failures: ApplyPatchFailure[] = [];
269
- let fuzz = 0;
270
-
271
- for (const hunk of hunks) {
272
- try {
273
- const { summary, appliedFile, fuzz: hunkFuzz } = await applySingleHunk(cwd, hunk);
274
- summaries.push(summary);
275
- appliedFiles.push(appliedFile);
276
- fuzz += hunkFuzz;
277
- } catch (error) {
278
- const message = error instanceof Error ? error.message : String(error);
279
- failures.push({ filePath: hunk.filePath, operation: hunk.type, message });
280
- }
281
- await notifyApplyPatchProgress(onProgress, {
282
- applied: appliedFiles.length,
283
- failed: failures.length,
284
- total: hunks.length,
285
- });
286
- }
287
-
288
- const result: ApplyPatchResult = {
289
- summaries,
290
- appliedFiles,
291
- failures,
292
- hasPartialSuccess: appliedFiles.length > 0 && failures.length > 0,
293
- recoveryInstructions: { mustReadFiles: [], mustNotReadFiles: [] },
294
- details: { fuzz },
295
- };
296
- result.recoveryInstructions = createRecoveryInstructions(result);
297
- return result;
298
- }
299
-
300
- export async function applyPatchDetailed(
301
- cwd: string,
302
- patchText: string,
303
- onProgress?: ApplyPatchProgressCallback,
304
- ): Promise<ApplyPatchResult> {
305
- return applyParsedPatchDetailed(cwd, parseNonEmptyPatch(patchText), onProgress);
306
- }
package/src/parse.ts DELETED
@@ -1,307 +0,0 @@
1
- export type ParsedPatch =
2
- | { type: "add"; filePath: string; content: string }
3
- | { type: "delete"; filePath: string }
4
- | { type: "update"; filePath: string; movePath?: string; chunks: PatchChunk[] };
5
-
6
- export type PatchChunk = {
7
- changeContexts: string[];
8
- oldLines: string[];
9
- newLines: string[];
10
- isEndOfFile: boolean;
11
- };
12
-
13
- export class PatchParseError extends Error {
14
- constructor(message: string) {
15
- super(message);
16
- this.name = "PatchParseError";
17
- }
18
- }
19
-
20
- export const APPLY_PATCH_FREEFORM_DESCRIPTION =
21
- "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.";
22
- export const APPLY_PATCH_LARK_GRAMMAR = `start: begin_patch hunk+ end_patch
23
- begin_patch: "*** Begin Patch" LF
24
- end_patch: "*** End Patch" LF?
25
-
26
- hunk: add_hunk | delete_hunk | update_hunk
27
- add_hunk: "*** Add File: " filename LF add_line+
28
- delete_hunk: "*** Delete File: " filename LF
29
- update_hunk: "*** Update File: " filename LF change_move? change?
30
-
31
- filename: /(.+)/
32
- add_line: "+" /(.*)/ LF -> line
33
-
34
- change_move: "*** Move to: " filename LF
35
- change: (change_context | change_line)+ eof_line?
36
- change_context: ("@@" | "@@ " /(.+)/) LF
37
- change_line: ("+" | "-" | " ") /(.*)/ LF
38
- eof_line: "*** End of File" LF
39
-
40
- %import common.LF
41
- `;
42
-
43
- export function normalizePatchText(patchText: string): string {
44
- return patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
45
- }
46
-
47
- export function stripHeredoc(input: string): string {
48
- const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/);
49
- if (heredocMatch) {
50
- return heredocMatch[2] ?? input;
51
- }
52
- return input;
53
- }
54
-
55
- export function normalizeSeekLine(line: string): string {
56
- return line
57
- .trim()
58
- .replace(/[‐‑‒–—―−]/g, "-")
59
- .replace(/[‘’‚‛]/g, "'")
60
- .replace(/[“”„‟]/g, '"')
61
- .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
62
- }
63
-
64
- export function seekSequence(
65
- lines: string[],
66
- pattern: string[],
67
- start: number,
68
- eof: boolean,
69
- ): { index: number; fuzz: 0 | 1 | 100 | 10000 } | undefined {
70
- if (pattern.length === 0) {
71
- return { index: start, fuzz: 0 };
72
- }
73
- if (pattern.length > lines.length) {
74
- return undefined;
75
- }
76
-
77
- const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
78
- const lastStart = lines.length - pattern.length;
79
- const matches = (index: number, compare: (left: string, right: string) => boolean): boolean => {
80
- for (let patternIndex = 0; patternIndex < pattern.length; patternIndex++) {
81
- const line = lines[index + patternIndex];
82
- const expected = pattern[patternIndex];
83
- if (line === undefined || expected === undefined || !compare(line, expected)) {
84
- return false;
85
- }
86
- }
87
- return true;
88
- };
89
- const matchesPrepared = (index: number, preparedLines: string[], preparedPattern: string[]): boolean => {
90
- for (let patternIndex = 0; patternIndex < preparedPattern.length; patternIndex++) {
91
- const line = preparedLines[index + patternIndex];
92
- const expected = preparedPattern[patternIndex];
93
- if (line === undefined || expected === undefined || line !== expected) {
94
- return false;
95
- }
96
- }
97
- return true;
98
- };
99
-
100
- for (let index = searchStart; index <= lastStart; index++) {
101
- if (matches(index, (line, expected) => line === expected)) {
102
- return { index, fuzz: 0 };
103
- }
104
- }
105
- const linesTrimEnd = lines.map((line) => line.trimEnd());
106
- const patternTrimEnd = pattern.map((line) => line.trimEnd());
107
- for (let index = searchStart; index <= lastStart; index++) {
108
- if (matchesPrepared(index, linesTrimEnd, patternTrimEnd)) {
109
- return { index, fuzz: 1 };
110
- }
111
- }
112
- const linesTrim = lines.map((line) => line.trim());
113
- const patternTrim = pattern.map((line) => line.trim());
114
- for (let index = searchStart; index <= lastStart; index++) {
115
- if (matchesPrepared(index, linesTrim, patternTrim)) {
116
- return { index, fuzz: 100 };
117
- }
118
- }
119
- const linesNormalized = lines.map(normalizeSeekLine);
120
- const patternNormalized = pattern.map(normalizeSeekLine);
121
- for (let index = searchStart; index <= lastStart; index++) {
122
- if (matchesPrepared(index, linesNormalized, patternNormalized)) {
123
- return { index, fuzz: 10000 };
124
- }
125
- }
126
-
127
- return undefined;
128
- }
129
-
130
- export function extractPatchedPaths(patchText: string): string[] {
131
- const normalized = stripHeredoc(normalizePatchText(patchText));
132
- const matches = normalized.matchAll(/^\*\*\* (?:(?:Add|Delete|Update) File|Move to): (.+)$/gm);
133
- return Array.from(matches, (match) => match[1] ?? "");
134
- }
135
-
136
- export function parsePatch(patchText: string): ParsedPatch[] {
137
- const normalized = stripHeredoc(normalizePatchText(patchText).trim()).trim();
138
- const lines = normalized.split("\n");
139
- const beginIndex = lines[0]?.trim() === "*** Begin Patch" ? 0 : -1;
140
- const lastLine = lines[lines.length - 1];
141
- const endIndex = lastLine?.trim() === "*** End Patch" ? lines.length - 1 : -1;
142
-
143
- if (beginIndex === -1 || endIndex === -1 || endIndex < beginIndex) {
144
- throw new PatchParseError("Invalid patch format: expected *** Begin Patch ... *** End Patch envelope");
145
- }
146
-
147
- const hunks: ParsedPatch[] = [];
148
- let index = beginIndex + 1;
149
- while (index < endIndex) {
150
- const line = lines[index] ?? "";
151
- if (!line.startsWith("*** ")) {
152
- index++;
153
- continue;
154
- }
155
-
156
- if (line.startsWith("*** Add File: ")) {
157
- const filePath = line.slice("*** Add File: ".length);
158
- index++;
159
- const contentLines: string[] = [];
160
- while (index < endIndex) {
161
- const nextLine = lines[index] ?? "";
162
- if (nextLine.startsWith("*** ")) {
163
- break;
164
- }
165
- if (!nextLine.startsWith("+")) {
166
- throw new PatchParseError("Invalid patch format: Add File lines must start with '+'");
167
- }
168
- contentLines.push(nextLine.slice(1));
169
- index++;
170
- }
171
- hunks.push({
172
- type: "add",
173
- filePath,
174
- content: contentLines.length === 0 ? "" : `${contentLines.join("\n")}\n`,
175
- });
176
- continue;
177
- }
178
-
179
- if (line.startsWith("*** Delete File: ")) {
180
- hunks.push({ type: "delete", filePath: line.slice("*** Delete File: ".length) });
181
- index++;
182
- continue;
183
- }
184
-
185
- if (line.startsWith("*** Update File: ")) {
186
- const filePath = line.slice("*** Update File: ".length);
187
- index++;
188
- let movePath: string | undefined;
189
- if ((lines[index] ?? "").startsWith("*** Move to: ")) {
190
- movePath = (lines[index] ?? "").slice("*** Move to: ".length);
191
- index++;
192
- }
193
-
194
- const chunks: PatchChunk[] = [];
195
- while (index < endIndex) {
196
- const nextLine = lines[index] ?? "";
197
- if (nextLine.trim() === "") {
198
- index++;
199
- continue;
200
- }
201
- if (nextLine.startsWith("*** ")) {
202
- break;
203
- }
204
-
205
- const allowMissingContext = chunks.length === 0;
206
- const changeContexts: string[] = [];
207
- if (nextLine.startsWith("@@")) {
208
- while (index < endIndex) {
209
- const contextLine = lines[index] ?? "";
210
- if (contextLine === "@@") {
211
- index++;
212
- continue;
213
- }
214
- if (contextLine.startsWith("@@ ")) {
215
- changeContexts.push(contextLine.slice("@@ ".length));
216
- index++;
217
- continue;
218
- }
219
- break;
220
- }
221
- } else if (!allowMissingContext) {
222
- throw new PatchParseError(`Expected update hunk to start with a @@ context marker, got: '${nextLine}'`);
223
- }
224
-
225
- const oldLines: string[] = [];
226
- const newLines: string[] = [];
227
- let isEndOfFile = false;
228
- let parsedLines = 0;
229
- while (index < endIndex) {
230
- const hunkLine = lines[index] ?? "";
231
- if (hunkLine === "*** End of File") {
232
- if (parsedLines === 0) {
233
- throw new PatchParseError("Update hunk does not contain any lines");
234
- }
235
- isEndOfFile = true;
236
- index++;
237
- break;
238
- }
239
- if (hunkLine.startsWith("@@") || hunkLine.startsWith("*** ")) {
240
- break;
241
- }
242
- const prefix = hunkLine[0];
243
- const value = hunkLine.slice(1);
244
- if (prefix === undefined) {
245
- oldLines.push("");
246
- newLines.push("");
247
- } else if (prefix === " ") {
248
- oldLines.push(value);
249
- newLines.push(value);
250
- } else if (prefix === "-") {
251
- oldLines.push(value);
252
- } else if (prefix === "+") {
253
- newLines.push(value);
254
- } else if (parsedLines > 0) {
255
- break;
256
- } else {
257
- throw new PatchParseError(
258
- `Unexpected line found in update hunk: '${hunkLine}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
259
- );
260
- }
261
- parsedLines++;
262
- index++;
263
- }
264
-
265
- if (parsedLines === 0) {
266
- throw new PatchParseError("Update hunk does not contain any lines");
267
- }
268
- chunks.push({ changeContexts, oldLines, newLines, isEndOfFile });
269
- }
270
- if (chunks.length === 0 && !movePath) {
271
- throw new PatchParseError(`Update file hunk for path '${filePath}' is empty`);
272
- }
273
-
274
- hunks.push(
275
- movePath !== undefined ? { type: "update", filePath, movePath, chunks } : { type: "update", filePath, chunks },
276
- );
277
- continue;
278
- }
279
-
280
- throw new PatchParseError(
281
- `'${line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`,
282
- );
283
- }
284
-
285
- return hunks;
286
- }
287
-
288
- export function parseNonEmptyPatch(patchText: string): ParsedPatch[] {
289
- const hunks = parsePatch(patchText);
290
- if (hunks.length > 0) {
291
- return hunks;
292
- }
293
-
294
- const normalized = normalizePatchText(patchText).trim();
295
- if (normalized === "*** Begin Patch\n*** End Patch") {
296
- throw new PatchParseError("patch rejected: empty patch");
297
- }
298
- throw new PatchParseError("apply_patch verification failed: no hunks found");
299
- }
300
-
301
- export function splitFileLines(content: string): string[] {
302
- const lines = normalizePatchText(content).split("\n");
303
- if (lines[lines.length - 1] === "") {
304
- lines.pop();
305
- }
306
- return lines;
307
- }