@shanepadgett/tau-agent 0.1.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/README.md +30 -0
- package/docs/extending-tau-agent.md +119 -0
- package/docs/subagents.md +57 -0
- package/docs/tui.md +107 -0
- package/extensions/appshot/README.md +11 -0
- package/extensions/appshot/capture.swift +191 -0
- package/extensions/appshot/index.ts +245 -0
- package/extensions/appshot/native-helper.ts +102 -0
- package/extensions/attention/README.md +23 -0
- package/extensions/attention/index.ts +71 -0
- package/extensions/auto-name/README.md +26 -0
- package/extensions/auto-name/index.ts +163 -0
- package/extensions/branch/README.md +22 -0
- package/extensions/branch/index.ts +145 -0
- package/extensions/branch/panel.ts +148 -0
- package/extensions/clear-screen/README.md +29 -0
- package/extensions/clear-screen/index.ts +75 -0
- package/extensions/commit/README.md +15 -0
- package/extensions/commit/commit-plan.ts +328 -0
- package/extensions/commit/git-change-set.ts +268 -0
- package/extensions/commit/index.ts +138 -0
- package/extensions/commit/review-ui.ts +745 -0
- package/extensions/explore/README.md +7 -0
- package/extensions/explore/autoread.ts +140 -0
- package/extensions/explore/find.ts +147 -0
- package/extensions/explore/grep.ts +749 -0
- package/extensions/explore/index.ts +17 -0
- package/extensions/explore/limits.ts +22 -0
- package/extensions/explore/ls.ts +113 -0
- package/extensions/explore/path-display.ts +41 -0
- package/extensions/explore/path-tree.ts +150 -0
- package/extensions/explore/read.ts +160 -0
- package/extensions/explore/result.ts +29 -0
- package/extensions/explore/traverse.ts +226 -0
- package/extensions/footer/README.md +23 -0
- package/extensions/footer/index.ts +534 -0
- package/extensions/footer/settings.ts +13 -0
- package/extensions/ideas/README.md +10 -0
- package/extensions/ideas/browser.ts +66 -0
- package/extensions/ideas/index.ts +31 -0
- package/extensions/ideas/store.ts +32 -0
- package/extensions/image-gen/README.md +11 -0
- package/extensions/image-gen/client.ts +186 -0
- package/extensions/image-gen/index.ts +152 -0
- package/extensions/manage-sessions/README.md +12 -0
- package/extensions/manage-sessions/index.ts +58 -0
- package/extensions/manage-sessions/manager-ui.ts +323 -0
- package/extensions/manage-sessions/sessions.ts +126 -0
- package/extensions/patch/README.md +48 -0
- package/extensions/patch/executor.ts +355 -0
- package/extensions/patch/index.ts +170 -0
- package/extensions/patch/matcher.ts +236 -0
- package/extensions/patch/parser.ts +347 -0
- package/extensions/patch/render.ts +247 -0
- package/extensions/patch/summary.ts +35 -0
- package/extensions/publish/README.md +11 -0
- package/extensions/publish/index.ts +225 -0
- package/extensions/qna/README.md +45 -0
- package/extensions/qna/additional-context-body.ts +51 -0
- package/extensions/qna/body-render.ts +6 -0
- package/extensions/qna/choice-question-body.ts +322 -0
- package/extensions/qna/index.ts +245 -0
- package/extensions/qna/inline-editor-row.ts +56 -0
- package/extensions/qna/input-question-body.ts +89 -0
- package/extensions/qna/model.ts +317 -0
- package/extensions/qna/panel.ts +240 -0
- package/extensions/qna/ui.ts +16 -0
- package/extensions/reference/README.md +47 -0
- package/extensions/reference/index.ts +76 -0
- package/extensions/reference/panel.ts +874 -0
- package/extensions/reference/settings.ts +29 -0
- package/extensions/run-summary/README.md +5 -0
- package/extensions/run-summary/index.ts +98 -0
- package/extensions/silent-command-runner/README.md +25 -0
- package/extensions/silent-command-runner/index.ts +468 -0
- package/extensions/silent-command-runner/settings.ts +65 -0
- package/extensions/soul/README.md +19 -0
- package/extensions/soul/index.ts +20 -0
- package/extensions/soul/prompt.ts +254 -0
- package/extensions/soul/settings.ts +13 -0
- package/extensions/stash/README.md +48 -0
- package/extensions/stash/browser.ts +51 -0
- package/extensions/stash/index.ts +55 -0
- package/extensions/stash/store.ts +36 -0
- package/extensions/subagent/README.md +34 -0
- package/extensions/subagent/agents/scout.md +85 -0
- package/extensions/subagent/agents/web-research.md +95 -0
- package/extensions/subagent/agents.ts +196 -0
- package/extensions/subagent/index.ts +214 -0
- package/extensions/subagent/render.ts +89 -0
- package/extensions/subagent/run.ts +325 -0
- package/extensions/tau/README.md +17 -0
- package/extensions/tau/index.ts +122 -0
- package/extensions/tau-help/README.md +3 -0
- package/extensions/tau-help/help.md +125 -0
- package/extensions/tau-help/index.ts +68 -0
- package/extensions/turn-budget/README.md +14 -0
- package/extensions/turn-budget/index.ts +150 -0
- package/extensions/turn-budget/settings.ts +35 -0
- package/extensions/web/README.md +13 -0
- package/extensions/web/codesearch.ts +79 -0
- package/extensions/web/exa.ts +101 -0
- package/extensions/web/html.ts +67 -0
- package/extensions/web/index.ts +13 -0
- package/extensions/web/limits.ts +11 -0
- package/extensions/web/tool-output.ts +54 -0
- package/extensions/web/webfetch.ts +177 -0
- package/extensions/web/websearch.ts +100 -0
- package/package.json +57 -0
- package/prompts/.gitkeep +1 -0
- package/prompts/cavemanify.md +25 -0
- package/prompts/implement.md +25 -0
- package/prompts/interview.md +48 -0
- package/prompts/plan-feature.md +30 -0
- package/prompts/plan-implementation.md +50 -0
- package/schemas/tau.schema.json +205 -0
- package/shared/agent-blocked.ts +12 -0
- package/shared/description.ts +28 -0
- package/shared/events.ts +135 -0
- package/shared/git.ts +41 -0
- package/shared/injected-context.ts +69 -0
- package/shared/jsonl-store.ts +108 -0
- package/shared/model-fallback/index.ts +278 -0
- package/shared/model-fallback/settings.ts +18 -0
- package/shared/model-fallback/types.ts +8 -0
- package/shared/ranges.ts +21 -0
- package/shared/settings/define.ts +15 -0
- package/shared/settings/files.ts +20 -0
- package/shared/settings/json.ts +41 -0
- package/shared/settings/load.ts +59 -0
- package/shared/settings/merge.ts +21 -0
- package/shared/settings/paths.ts +41 -0
- package/shared/settings/schema.ts +25 -0
- package/shared/settings/specs.ts +23 -0
- package/shared/text.ts +11 -0
- package/shared/tool-row-state.ts +43 -0
- package/skills/writing-preferences/SKILL.md +136 -0
- package/themes/.gitkeep +1 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { type LineRange, mergeLineRanges } from "../../shared/ranges.js";
|
|
2
|
+
|
|
3
|
+
export interface ChunkLine {
|
|
4
|
+
prefix: " " | "+" | "-";
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UpdateFileChunk {
|
|
9
|
+
changeContext?: string;
|
|
10
|
+
lines: ChunkLine[];
|
|
11
|
+
isEndOfFile: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SnapshotRange extends LineRange {}
|
|
15
|
+
|
|
16
|
+
export interface ApplyChunksResult {
|
|
17
|
+
content: string;
|
|
18
|
+
snapshotRanges: SnapshotRange[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TextParts {
|
|
22
|
+
bom: string;
|
|
23
|
+
text: string;
|
|
24
|
+
lineEnding: "\n" | "\r\n";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stripBom(content: string): { bom: string; text: string } {
|
|
28
|
+
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function detectLineEnding(content: string): "\n" | "\r\n" {
|
|
32
|
+
const crlfIndex = content.indexOf("\r\n");
|
|
33
|
+
const lfIndex = content.indexOf("\n");
|
|
34
|
+
if (lfIndex === -1 || crlfIndex === -1) return "\n";
|
|
35
|
+
return crlfIndex < lfIndex ? "\r\n" : "\n";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeToLf(content: string): string {
|
|
39
|
+
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function restoreLineEndings(content: string, lineEnding: "\n" | "\r\n"): string {
|
|
43
|
+
return lineEnding === "\r\n" ? content.replace(/\n/g, "\r\n") : content;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function splitText(content: string): TextParts {
|
|
47
|
+
const { bom, text } = stripBom(content);
|
|
48
|
+
return {
|
|
49
|
+
bom,
|
|
50
|
+
text: normalizeToLf(text),
|
|
51
|
+
lineEnding: detectLineEnding(text),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function splitLogicalLines(content: string): string[] {
|
|
56
|
+
const lines = content.split("\n");
|
|
57
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function countLogicalLines(content: string): number {
|
|
62
|
+
return splitLogicalLines(normalizeToLf(stripBom(content).text)).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeUnicodeText(value: string): string {
|
|
66
|
+
return value
|
|
67
|
+
.normalize("NFKC")
|
|
68
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
69
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
70
|
+
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
71
|
+
.replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeForFuzzyMatch(value: string): string {
|
|
75
|
+
return normalizeUnicodeText(value).trimEnd();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function matchesAt(source: string[], pattern: string[], start: number, normalize: (value: string) => string): boolean {
|
|
79
|
+
if (start < 0 || start + pattern.length > source.length) return false;
|
|
80
|
+
for (let offset = 0; offset < pattern.length; offset += 1) {
|
|
81
|
+
const sourceLine = source[start + offset];
|
|
82
|
+
const patternLine = pattern[offset];
|
|
83
|
+
if (sourceLine === undefined || patternLine === undefined) return false;
|
|
84
|
+
if (normalize(sourceLine) !== normalize(patternLine)) return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const NORMALIZERS: ReadonlyArray<(value: string) => string> = [
|
|
90
|
+
(value) => value,
|
|
91
|
+
(value) => value.trimEnd(),
|
|
92
|
+
(value) => value.trim(),
|
|
93
|
+
normalizeForFuzzyMatch,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
function findSequenceMatches(lines: string[], pattern: string[], start: number): number[] {
|
|
97
|
+
if (pattern.length === 0) return [Math.min(Math.max(start, 0), lines.length)];
|
|
98
|
+
for (const normalize of NORMALIZERS) {
|
|
99
|
+
const matches: number[] = [];
|
|
100
|
+
for (let index = Math.max(start, 0); index <= lines.length - pattern.length; index += 1) {
|
|
101
|
+
if (matchesAt(lines, pattern, index, normalize)) matches.push(index);
|
|
102
|
+
}
|
|
103
|
+
if (matches.length > 0) return matches;
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function seekSequence(lines: string[], pattern: string[], start: number): number {
|
|
109
|
+
return findSequenceMatches(lines, pattern, start)[0] ?? -1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildReplacement(chunk: UpdateFileChunk, fileLines: string[], matchIndex: number): string[] {
|
|
113
|
+
const result: string[] = [];
|
|
114
|
+
let fileOffset = 0;
|
|
115
|
+
for (const line of chunk.lines) {
|
|
116
|
+
if (line.prefix === "+") {
|
|
117
|
+
result.push(line.text);
|
|
118
|
+
} else if (line.prefix === "-") {
|
|
119
|
+
fileOffset += 1;
|
|
120
|
+
} else {
|
|
121
|
+
result.push(fileLines[matchIndex + fileOffset] ?? line.text);
|
|
122
|
+
fileOffset += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function applyChunksWithRanges(currentContent: string, chunks: UpdateFileChunk[]): ApplyChunksResult {
|
|
129
|
+
const parts = splitText(currentContent);
|
|
130
|
+
const lines = splitLogicalLines(parts.text);
|
|
131
|
+
const replacements: Array<{ index: number; deleteCount: number; insert: string[]; order: number }> = [];
|
|
132
|
+
const rawRanges: Array<{ index: number; deleteCount: number; insertCount: number }> = [];
|
|
133
|
+
let lineIndex = 0;
|
|
134
|
+
let order = 0;
|
|
135
|
+
|
|
136
|
+
for (let ci = 0; ci < chunks.length; ci += 1) {
|
|
137
|
+
const chunk = chunks[ci];
|
|
138
|
+
if (chunk === undefined) continue;
|
|
139
|
+
const oldLines = chunk.lines.filter((l) => l.prefix !== "+").map((l) => l.text);
|
|
140
|
+
|
|
141
|
+
if (chunk.changeContext) {
|
|
142
|
+
const contextIndex = seekSequence(lines, [chunk.changeContext], lineIndex);
|
|
143
|
+
if (contextIndex < 0) {
|
|
144
|
+
throw new UpdateChunkApplyError(
|
|
145
|
+
ci + 1,
|
|
146
|
+
chunks.length,
|
|
147
|
+
formatContextHint(chunk),
|
|
148
|
+
"could not find update context",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
lineIndex = contextIndex + 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (oldLines.length === 0) {
|
|
155
|
+
const insertIndex = lines.length;
|
|
156
|
+
const insert = chunk.lines.map((l) => l.text);
|
|
157
|
+
replacements.push({ index: insertIndex, deleteCount: 0, insert, order });
|
|
158
|
+
order += 1;
|
|
159
|
+
rawRanges.push({ index: insertIndex, deleteCount: 0, insertCount: insert.length });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let matchIndex = -1;
|
|
164
|
+
if (chunk.isEndOfFile) {
|
|
165
|
+
const eofIndex = lines.length - oldLines.length;
|
|
166
|
+
if (eofIndex >= lineIndex && seekSequence(lines, oldLines, eofIndex) === eofIndex) {
|
|
167
|
+
matchIndex = eofIndex;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (matchIndex < 0) matchIndex = seekSequence(lines, oldLines, lineIndex);
|
|
171
|
+
if (matchIndex < 0) {
|
|
172
|
+
throw new UpdateChunkApplyError(ci + 1, chunks.length, formatContextHint(chunk), "could not match");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const replacement = buildReplacement(chunk, lines, matchIndex);
|
|
176
|
+
replacements.push({ index: matchIndex, deleteCount: oldLines.length, insert: replacement, order });
|
|
177
|
+
order += 1;
|
|
178
|
+
rawRanges.push({ index: matchIndex, deleteCount: oldLines.length, insertCount: replacement.length });
|
|
179
|
+
lineIndex = matchIndex + oldLines.length;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const output = [...lines];
|
|
183
|
+
replacements
|
|
184
|
+
.sort((a, b) => b.index - a.index || b.order - a.order)
|
|
185
|
+
.forEach((r) => {
|
|
186
|
+
output.splice(r.index, r.deleteCount, ...r.insert);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const joined = output.join("\n");
|
|
190
|
+
const normalized = joined === "" ? "" : `${joined}\n`;
|
|
191
|
+
const next = parts.bom + restoreLineEndings(normalized, parts.lineEnding);
|
|
192
|
+
return { content: next, snapshotRanges: mergeLineRanges(toPostApplyRanges(rawRanges, output.length)) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toPostApplyRanges(
|
|
196
|
+
rawRanges: Array<{ index: number; deleteCount: number; insertCount: number }>,
|
|
197
|
+
lineCount: number,
|
|
198
|
+
): SnapshotRange[] {
|
|
199
|
+
const ranges: SnapshotRange[] = [];
|
|
200
|
+
let delta = 0;
|
|
201
|
+
for (const range of rawRanges) {
|
|
202
|
+
const postIndex = range.index + delta;
|
|
203
|
+
if (range.insertCount > 0) {
|
|
204
|
+
const startLine = Math.max(1, postIndex + 1);
|
|
205
|
+
const endLine = Math.min(lineCount, postIndex + range.insertCount);
|
|
206
|
+
if (endLine >= startLine) ranges.push({ startLine, endLine });
|
|
207
|
+
} else if (lineCount > 0) {
|
|
208
|
+
const anchor = Math.min(Math.max(1, postIndex + 1), lineCount);
|
|
209
|
+
ranges.push({ startLine: anchor, endLine: anchor });
|
|
210
|
+
}
|
|
211
|
+
delta += range.insertCount - range.deleteCount;
|
|
212
|
+
}
|
|
213
|
+
return ranges;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export class UpdateChunkApplyError extends Error {
|
|
217
|
+
chunkIndex: number;
|
|
218
|
+
totalChunks: number;
|
|
219
|
+
contextHint?: string;
|
|
220
|
+
|
|
221
|
+
constructor(chunkIndex: number, totalChunks: number, contextHint: string | undefined, message: string) {
|
|
222
|
+
super(message);
|
|
223
|
+
this.name = "UpdateChunkApplyError";
|
|
224
|
+
this.chunkIndex = chunkIndex;
|
|
225
|
+
this.totalChunks = totalChunks;
|
|
226
|
+
this.contextHint = contextHint;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function formatContextHint(chunk: UpdateFileChunk): string | undefined {
|
|
231
|
+
const raw = chunk.changeContext ?? chunk.lines.find((l) => l.text.trim().length > 0)?.text;
|
|
232
|
+
if (!raw) return undefined;
|
|
233
|
+
const compact = raw.replace(/\s+/g, " ").trim();
|
|
234
|
+
if (!compact) return undefined;
|
|
235
|
+
return compact.length > 80 ? `${compact.slice(0, 77)}...` : compact;
|
|
236
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import type { ChunkLine, UpdateFileChunk } from "./matcher.ts";
|
|
2
|
+
|
|
3
|
+
type ParsedSection =
|
|
4
|
+
| { type: "add"; path: string; content: string; linesAdded: number }
|
|
5
|
+
| { type: "replace"; path: string; content: string; linesAdded: number }
|
|
6
|
+
| {
|
|
7
|
+
type: "update";
|
|
8
|
+
path: string;
|
|
9
|
+
movePath?: string;
|
|
10
|
+
chunks: UpdateFileChunk[];
|
|
11
|
+
linesAdded: number;
|
|
12
|
+
linesRemoved: number;
|
|
13
|
+
}
|
|
14
|
+
| { type: "delete"; path: string };
|
|
15
|
+
|
|
16
|
+
export type PatchOperation =
|
|
17
|
+
| { type: "add"; sectionIndex: number; path: string; content: string; linesAdded: number }
|
|
18
|
+
| { type: "replace"; sectionIndex: number; path: string; content: string; linesAdded: number }
|
|
19
|
+
| {
|
|
20
|
+
type: "update";
|
|
21
|
+
sectionIndex: number;
|
|
22
|
+
path: string;
|
|
23
|
+
movePath?: string;
|
|
24
|
+
chunks: UpdateFileChunk[];
|
|
25
|
+
linesAdded: number;
|
|
26
|
+
linesRemoved: number;
|
|
27
|
+
}
|
|
28
|
+
| { type: "delete"; sectionIndex: number; path: string };
|
|
29
|
+
|
|
30
|
+
export interface PatchFailure {
|
|
31
|
+
phase: "parse" | "apply";
|
|
32
|
+
sectionIndex: number;
|
|
33
|
+
path?: string;
|
|
34
|
+
kind?: PatchOperation["type"];
|
|
35
|
+
chunkIndex?: number;
|
|
36
|
+
totalChunks?: number;
|
|
37
|
+
contextHint?: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ParsedPatch {
|
|
42
|
+
operations: PatchOperation[];
|
|
43
|
+
parseFailures: PatchFailure[];
|
|
44
|
+
totalSections: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
48
|
+
const END_PATCH_MARKER = "*** End Patch";
|
|
49
|
+
const ADD_FILE_MARKER = "*** Add File: ";
|
|
50
|
+
const REPLACE_FILE_MARKER = "*** Replace File: ";
|
|
51
|
+
const DELETE_FILE_MARKER = "*** Delete File: ";
|
|
52
|
+
const UPDATE_FILE_MARKER = "*** Update File: ";
|
|
53
|
+
const MOVE_TO_MARKER = "*** Move to: ";
|
|
54
|
+
const CHANGE_CONTEXT_MARKER = "@@";
|
|
55
|
+
const EOF_MARKER = "*** End of File";
|
|
56
|
+
|
|
57
|
+
const TOP_LEVEL_BOUNDARIES = [
|
|
58
|
+
END_PATCH_MARKER,
|
|
59
|
+
ADD_FILE_MARKER,
|
|
60
|
+
REPLACE_FILE_MARKER,
|
|
61
|
+
DELETE_FILE_MARKER,
|
|
62
|
+
UPDATE_FILE_MARKER,
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
function topLevelDirective(line: string): string {
|
|
66
|
+
return line.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function updateDirective(line: string): string {
|
|
70
|
+
return line.trimEnd();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isTopLevelBoundary(line: string): boolean {
|
|
74
|
+
const value = topLevelDirective(line);
|
|
75
|
+
return TOP_LEVEL_BOUNDARIES.some((b) => (b === END_PATCH_MARKER ? value === b : value.startsWith(b)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isUpdateBoundary(line: string): boolean {
|
|
79
|
+
const value = updateDirective(line);
|
|
80
|
+
return TOP_LEVEL_BOUNDARIES.some((b) => (b === END_PATCH_MARKER ? value === b : value.startsWith(b)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function requirePathFromDirective(value: string, originalLine: string, prefix: string): string {
|
|
84
|
+
const path = value.slice(prefix.length).trim();
|
|
85
|
+
if (!path) throw new Error(`Missing path for patch directive: ${originalLine}`);
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function requireTopLevelPath(line: string, prefix: string): string {
|
|
90
|
+
return requirePathFromDirective(topLevelDirective(line), line, prefix);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireUpdatePath(line: string, prefix: string): string {
|
|
94
|
+
return requirePathFromDirective(updateDirective(line), line, prefix);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isUpdateHeader(line: string): boolean {
|
|
98
|
+
return topLevelDirective(line).startsWith(UPDATE_FILE_MARKER);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function nextSectionBoundary(lines: string[], start: number, updateBody: boolean): number {
|
|
102
|
+
let index = start;
|
|
103
|
+
const limit = lines.length - 1;
|
|
104
|
+
while (index < limit) {
|
|
105
|
+
const line = lines[index];
|
|
106
|
+
if (line === undefined) break;
|
|
107
|
+
if (updateBody ? isUpdateBoundary(line) : isTopLevelBoundary(line)) break;
|
|
108
|
+
index += 1;
|
|
109
|
+
}
|
|
110
|
+
return index;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseAddBody(lines: string[], startIndex: number): { content: string; lineCount: number; nextIndex: number } {
|
|
114
|
+
const body: string[] = [];
|
|
115
|
+
let index = startIndex;
|
|
116
|
+
while (index < lines.length) {
|
|
117
|
+
const line = lines[index];
|
|
118
|
+
if (line === undefined || !line.startsWith("+")) break;
|
|
119
|
+
body.push(line.slice(1));
|
|
120
|
+
index += 1;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
content: body.length === 0 ? "" : `${body.join("\n")}\n`,
|
|
124
|
+
lineCount: body.length,
|
|
125
|
+
nextIndex: index,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface MutableChunk {
|
|
130
|
+
changeContext?: string;
|
|
131
|
+
lines: ChunkLine[];
|
|
132
|
+
isEndOfFile: boolean;
|
|
133
|
+
hasLines: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createChunk(changeContext?: string): MutableChunk {
|
|
137
|
+
return { changeContext, lines: [], isEndOfFile: false, hasLines: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function finalizeChunk(target: MutableChunk | undefined, chunks: UpdateFileChunk[], path: string): void {
|
|
141
|
+
if (!target) return;
|
|
142
|
+
if (!target.hasLines) throw new Error(`Update file patch has an empty chunk: ${path}`);
|
|
143
|
+
chunks.push({
|
|
144
|
+
changeContext: target.changeContext,
|
|
145
|
+
lines: target.lines,
|
|
146
|
+
isEndOfFile: target.isEndOfFile,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseChunkLine(rawLine: string): { prefix: " " | "+" | "-"; text: string } {
|
|
151
|
+
if (rawLine.length === 0) return { prefix: " ", text: "" };
|
|
152
|
+
const prefix = rawLine[0];
|
|
153
|
+
if (prefix !== " " && prefix !== "+" && prefix !== "-") {
|
|
154
|
+
throw new Error(`Invalid update hunk line: ${rawLine}`);
|
|
155
|
+
}
|
|
156
|
+
return { prefix, text: rawLine.slice(1) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseUpdateBody(
|
|
160
|
+
lines: string[],
|
|
161
|
+
startIndex: number,
|
|
162
|
+
operationPath: string,
|
|
163
|
+
): { movePath?: string; chunks: UpdateFileChunk[]; linesAdded: number; linesRemoved: number; nextIndex: number } {
|
|
164
|
+
const chunks: UpdateFileChunk[] = [];
|
|
165
|
+
let index = startIndex;
|
|
166
|
+
let movePath: string | undefined;
|
|
167
|
+
let current: MutableChunk | undefined;
|
|
168
|
+
let sawAnyChunk = false;
|
|
169
|
+
let linesAdded = 0;
|
|
170
|
+
let linesRemoved = 0;
|
|
171
|
+
|
|
172
|
+
while (index < lines.length) {
|
|
173
|
+
const line = lines[index];
|
|
174
|
+
if (line === undefined) break;
|
|
175
|
+
const currentDirective = updateDirective(line);
|
|
176
|
+
if (isUpdateBoundary(line)) break;
|
|
177
|
+
|
|
178
|
+
if (currentDirective.startsWith(MOVE_TO_MARKER)) {
|
|
179
|
+
if (sawAnyChunk || current) throw new Error(`Move to must appear before update chunks: ${operationPath}`);
|
|
180
|
+
movePath = requireUpdatePath(line, MOVE_TO_MARKER);
|
|
181
|
+
index += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (currentDirective === CHANGE_CONTEXT_MARKER || currentDirective.startsWith(`${CHANGE_CONTEXT_MARKER} `)) {
|
|
186
|
+
finalizeChunk(current, chunks, operationPath);
|
|
187
|
+
const context =
|
|
188
|
+
currentDirective === CHANGE_CONTEXT_MARKER
|
|
189
|
+
? undefined
|
|
190
|
+
: currentDirective.slice(CHANGE_CONTEXT_MARKER.length + 1).trim();
|
|
191
|
+
current = createChunk(context && context.length > 0 ? context : undefined);
|
|
192
|
+
sawAnyChunk = true;
|
|
193
|
+
index += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!current) {
|
|
198
|
+
if (sawAnyChunk || chunks.length > 0) {
|
|
199
|
+
throw new Error(`Only the first update chunk may omit @@: ${operationPath}`);
|
|
200
|
+
}
|
|
201
|
+
current = createChunk();
|
|
202
|
+
sawAnyChunk = true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (currentDirective === EOF_MARKER) {
|
|
206
|
+
current.isEndOfFile = true;
|
|
207
|
+
index += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const parsedLine = parseChunkLine(line);
|
|
212
|
+
current.hasLines = true;
|
|
213
|
+
|
|
214
|
+
if (parsedLine.prefix === "+") linesAdded += 1;
|
|
215
|
+
if (parsedLine.prefix === "-") linesRemoved += 1;
|
|
216
|
+
|
|
217
|
+
current.lines.push({ prefix: parsedLine.prefix, text: parsedLine.text });
|
|
218
|
+
index += 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
finalizeChunk(current, chunks, operationPath);
|
|
222
|
+
if (chunks.length === 0 && !movePath)
|
|
223
|
+
throw new Error(`Update file patch is missing chunk content: ${operationPath}`);
|
|
224
|
+
|
|
225
|
+
return { movePath, chunks, linesAdded, linesRemoved, nextIndex: index };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseSection(sectionLines: string[]): ParsedSection {
|
|
229
|
+
const header = sectionLines[0] ?? "";
|
|
230
|
+
|
|
231
|
+
const headerDirective = topLevelDirective(header);
|
|
232
|
+
|
|
233
|
+
if (headerDirective.startsWith(ADD_FILE_MARKER) || headerDirective.startsWith(REPLACE_FILE_MARKER)) {
|
|
234
|
+
const isReplace = headerDirective.startsWith(REPLACE_FILE_MARKER);
|
|
235
|
+
const prefix = isReplace ? REPLACE_FILE_MARKER : ADD_FILE_MARKER;
|
|
236
|
+
const path = requireTopLevelPath(header, prefix);
|
|
237
|
+
const body = parseAddBody(sectionLines, 1);
|
|
238
|
+
if (body.nextIndex !== sectionLines.length) {
|
|
239
|
+
throw new Error(`Malformed ${isReplace ? "Replace" : "Add"} File section: ${path}`);
|
|
240
|
+
}
|
|
241
|
+
return { type: isReplace ? "replace" : "add", path, content: body.content, linesAdded: body.lineCount };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (headerDirective.startsWith(DELETE_FILE_MARKER)) {
|
|
245
|
+
const path = requireTopLevelPath(header, DELETE_FILE_MARKER);
|
|
246
|
+
if (sectionLines.length !== 1) throw new Error(`Malformed Delete File section: ${path}`);
|
|
247
|
+
return { type: "delete", path };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (headerDirective.startsWith(UPDATE_FILE_MARKER)) {
|
|
251
|
+
const path = requireTopLevelPath(header, UPDATE_FILE_MARKER);
|
|
252
|
+
const updateBody = parseUpdateBody(sectionLines, 1, path);
|
|
253
|
+
if (updateBody.nextIndex !== sectionLines.length) throw new Error(`Malformed Update File section: ${path}`);
|
|
254
|
+
return {
|
|
255
|
+
type: "update",
|
|
256
|
+
path,
|
|
257
|
+
movePath: updateBody.movePath,
|
|
258
|
+
chunks: updateBody.chunks,
|
|
259
|
+
linesAdded: updateBody.linesAdded,
|
|
260
|
+
linesRemoved: updateBody.linesRemoved,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
throw new Error(`Unexpected patch line: ${header}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseHeaderMetadata(header: string): Pick<PatchFailure, "kind" | "path"> {
|
|
268
|
+
const headerDirective = topLevelDirective(header);
|
|
269
|
+
if (headerDirective.startsWith(ADD_FILE_MARKER) || headerDirective.startsWith(REPLACE_FILE_MARKER)) {
|
|
270
|
+
return {
|
|
271
|
+
kind: headerDirective.startsWith(REPLACE_FILE_MARKER) ? "replace" : "add",
|
|
272
|
+
path: headerDirective.slice(headerDirective.indexOf(": ") + 2).trim() || undefined,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (headerDirective.startsWith(DELETE_FILE_MARKER)) {
|
|
276
|
+
return { kind: "delete", path: headerDirective.slice(DELETE_FILE_MARKER.length).trim() || undefined };
|
|
277
|
+
}
|
|
278
|
+
if (headerDirective.startsWith(UPDATE_FILE_MARKER)) {
|
|
279
|
+
return { kind: "update", path: headerDirective.slice(UPDATE_FILE_MARKER.length).trim() || undefined };
|
|
280
|
+
}
|
|
281
|
+
return {};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function envelopeFailure(message: string): ParsedPatch {
|
|
285
|
+
return { operations: [], parseFailures: [{ phase: "parse", sectionIndex: 0, message }], totalSections: 1 };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function parsePatch(input: string): ParsedPatch {
|
|
289
|
+
const normalized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
|
290
|
+
if (!normalized) return envelopeFailure("Patch must not be empty.");
|
|
291
|
+
|
|
292
|
+
const lines = normalized.split("\n");
|
|
293
|
+
if (topLevelDirective(lines[0] ?? "") !== BEGIN_PATCH_MARKER)
|
|
294
|
+
return envelopeFailure("Patch must start with *** Begin Patch.");
|
|
295
|
+
if (topLevelDirective(lines[lines.length - 1] ?? "") !== END_PATCH_MARKER)
|
|
296
|
+
return envelopeFailure("Patch must end with *** End Patch.");
|
|
297
|
+
|
|
298
|
+
const operations: PatchOperation[] = [];
|
|
299
|
+
const parseFailures: PatchFailure[] = [];
|
|
300
|
+
let index = 1;
|
|
301
|
+
let sectionIndex = 0;
|
|
302
|
+
|
|
303
|
+
while (index < lines.length - 1) {
|
|
304
|
+
const line = lines[index];
|
|
305
|
+
if (line === undefined) break;
|
|
306
|
+
if (line.trim() === "") {
|
|
307
|
+
index += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const lineDirective = topLevelDirective(line);
|
|
312
|
+
if (!isTopLevelBoundary(line) || lineDirective === END_PATCH_MARKER) {
|
|
313
|
+
parseFailures.push({ phase: "parse", sectionIndex, message: `Unexpected patch line: ${line}` });
|
|
314
|
+
index += 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
sectionIndex += 1;
|
|
319
|
+
const sectionStart = index;
|
|
320
|
+
const nextBoundary = nextSectionBoundary(lines, index + 1, isUpdateHeader(line));
|
|
321
|
+
|
|
322
|
+
const sectionLines = lines.slice(sectionStart, nextBoundary);
|
|
323
|
+
try {
|
|
324
|
+
operations.push({ ...parseSection(sectionLines), sectionIndex });
|
|
325
|
+
} catch (error) {
|
|
326
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
327
|
+
parseFailures.push({
|
|
328
|
+
phase: "parse",
|
|
329
|
+
sectionIndex,
|
|
330
|
+
...parseHeaderMetadata(sectionLines[0] ?? ""),
|
|
331
|
+
message,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
index = nextBoundary;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (operations.length === 0 && parseFailures.length === 0) {
|
|
339
|
+
parseFailures.push({ phase: "parse", sectionIndex: 0, message: "Patch contains no operations." });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
operations,
|
|
344
|
+
parseFailures,
|
|
345
|
+
totalSections: Math.max(sectionIndex, operations.length + parseFailures.length),
|
|
346
|
+
};
|
|
347
|
+
}
|