@oh-my-pi/pi-coding-agent 14.5.3 → 14.5.5
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/CHANGELOG.md +44 -0
- package/examples/extensions/plan-mode.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +7 -7
- package/src/config/prompt-templates.ts +103 -8
- package/src/config/settings-schema.ts +14 -13
- package/src/config/settings.ts +1 -1
- package/src/cursor.ts +4 -4
- package/src/edit/index.ts +111 -109
- package/src/edit/line-hash.ts +33 -3
- package/src/edit/modes/apply-patch.ts +6 -4
- package/src/edit/modes/atom.lark +27 -0
- package/src/edit/modes/atom.ts +1057 -841
- package/src/edit/modes/hashline.ts +9 -10
- package/src/edit/modes/patch.ts +23 -19
- package/src/edit/modes/replace.ts +19 -15
- package/src/edit/renderer.ts +65 -8
- package/src/edit/streaming.ts +47 -77
- package/src/extensibility/extensions/types.ts +11 -11
- package/src/extensibility/hooks/types.ts +6 -6
- package/src/lsp/edits.ts +8 -5
- package/src/lsp/index.ts +4 -4
- package/src/lsp/utils.ts +7 -7
- package/src/mcp/discoverable-tool-metadata.ts +1 -1
- package/src/mcp/manager.ts +3 -3
- package/src/mcp/tool-bridge.ts +4 -4
- package/src/memories/index.ts +1 -1
- package/src/modes/acp/acp-event-mapper.ts +1 -1
- package/src/modes/components/session-observer-overlay.ts +1 -1
- package/src/modes/components/settings-defs.ts +3 -3
- package/src/modes/components/tree-selector.ts +2 -2
- package/src/modes/utils/ui-helpers.ts +31 -7
- package/src/prompts/agents/explore.md +1 -1
- package/src/prompts/agents/librarian.md +2 -2
- package/src/prompts/agents/plan.md +2 -2
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/task.md +2 -2
- package/src/prompts/system/plan-mode-active.md +1 -1
- package/src/prompts/system/system-prompt.md +34 -31
- package/src/prompts/tools/apply-patch.md +0 -2
- package/src/prompts/tools/atom.md +81 -63
- package/src/prompts/tools/bash.md +7 -4
- package/src/prompts/tools/checkpoint.md +1 -1
- package/src/prompts/tools/find.md +6 -1
- package/src/prompts/tools/hashline.md +10 -11
- package/src/prompts/tools/patch.md +13 -13
- package/src/prompts/tools/read.md +4 -4
- package/src/prompts/tools/replace.md +3 -3
- package/src/prompts/tools/{grep.md → search.md} +4 -4
- package/src/sdk.ts +19 -9
- package/src/session/agent-session.ts +65 -0
- package/src/system-prompt.ts +15 -5
- package/src/task/executor.ts +5 -0
- package/src/task/index.ts +10 -1
- package/src/tools/ast-edit.ts +4 -6
- package/src/tools/ast-grep.ts +4 -6
- package/src/tools/bash.ts +1 -1
- package/src/tools/file-recorder.ts +6 -6
- package/src/tools/find.ts +11 -13
- package/src/tools/index.ts +7 -7
- package/src/tools/path-utils.ts +31 -4
- package/src/tools/read.ts +12 -6
- package/src/tools/renderers.ts +2 -2
- package/src/tools/{grep.ts → search.ts} +32 -40
- package/src/tools/write.ts +8 -4
- package/src/web/search/index.ts +1 -1
- package/src/edit/block.ts +0 -308
- package/src/edit/indent.ts +0 -150
package/src/edit/modes/atom.ts
CHANGED
|
@@ -1,577 +1,552 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* Atom edit mode.
|
|
2
3
|
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* The runtime resolves those verbs into internal anchor-scoped edits and still
|
|
6
|
-
* reuses hashline's staleness scheme (`computeLineHash`) verbatim.
|
|
4
|
+
* Single-string compact wire format. Each file section starts with `---path`;
|
|
5
|
+
* each following line is one statement:
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* { path, loc: "5th", pre: [...], splice: [...], post: [...] } // line verbs combinable
|
|
15
|
-
* { path, loc: "$", pre: [...] | post: [...] | sed: {...} } // file-scoped
|
|
16
|
-
*
|
|
17
|
-
* `splice: []` deletes; `splice: [""]` replaces with a single blank line. These
|
|
18
|
-
* apply uniformly to single-line and bracketed (region) locators.
|
|
19
|
-
*
|
|
20
|
-
* Bracket forms in `loc` are reserved for `splice` (region replacement). `pre`,
|
|
21
|
-
* `post`, and `sed` reject bracketed locators — they are line-only.
|
|
22
|
-
*
|
|
23
|
-
* For deleting or moving files, the agent should use bash.
|
|
7
|
+
* @Lid move cursor to just after the anchored line
|
|
8
|
+
* Lid=TEXT set the anchored line to TEXT and move cursor after it
|
|
9
|
+
* -Lid delete the anchored line and move cursor to its slot
|
|
10
|
+
* +TEXT insert TEXT at the cursor
|
|
11
|
+
* $ move cursor to beginning of file
|
|
12
|
+
* ^ move cursor to end of file
|
|
24
13
|
*/
|
|
25
14
|
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import * as path from "node:path";
|
|
26
17
|
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
18
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
27
19
|
import { type Static, Type } from "@sinclair/typebox";
|
|
28
20
|
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
29
21
|
import type { ToolSession } from "../../tools";
|
|
30
|
-
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
31
|
-
import {
|
|
22
|
+
import { assertEditableFile, assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
23
|
+
import {
|
|
24
|
+
invalidateFsScanAfterDelete,
|
|
25
|
+
invalidateFsScanAfterRename,
|
|
26
|
+
invalidateFsScanAfterWrite,
|
|
27
|
+
} from "../../tools/fs-cache-invalidation";
|
|
32
28
|
import { outputMeta } from "../../tools/output-meta";
|
|
33
29
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
34
|
-
import { checkBodyBraceBalance, type DelimiterKind, findEnclosingBlock } from "../block";
|
|
35
30
|
import { generateDiffString } from "../diff";
|
|
36
|
-
import {
|
|
37
|
-
import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
|
|
31
|
+
import { computeLineHash } from "../line-hash";
|
|
38
32
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
39
33
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
40
34
|
import {
|
|
41
35
|
ANCHOR_REBASE_WINDOW,
|
|
42
36
|
type Anchor,
|
|
43
37
|
buildCompactHashlineDiffPreview,
|
|
44
|
-
formatFullAnchorRequirement,
|
|
45
38
|
HashlineMismatchError,
|
|
46
39
|
type HashMismatch,
|
|
47
|
-
hashlineParseText,
|
|
48
|
-
parseTag,
|
|
49
40
|
tryRebaseAnchor,
|
|
50
41
|
} from "./hashline";
|
|
51
42
|
|
|
52
43
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
53
44
|
// Schema
|
|
54
45
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
55
|
-
const textSchema = Type.Array(Type.String());
|
|
56
46
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
* The runtime validator (`resolveAtomToolEdit`) enforces legal locator/verb
|
|
60
|
-
* combinations. Keeping the schema flat reduces tool-definition size and gives
|
|
61
|
-
* weaker models fewer branching shapes to sample from.
|
|
62
|
-
*/
|
|
63
|
-
export const atomEditSchema = Type.Object(
|
|
64
|
-
{
|
|
65
|
-
loc: Type.String({
|
|
66
|
-
description: "edit location",
|
|
67
|
-
examples: ["1ab", "$", "src/foo.ts:1ab"],
|
|
68
|
-
}),
|
|
69
|
-
splice: Type.Optional(textSchema),
|
|
70
|
-
pre: Type.Optional(textSchema),
|
|
71
|
-
post: Type.Optional(textSchema),
|
|
72
|
-
sed: Type.Optional(
|
|
73
|
-
Type.Object(
|
|
74
|
-
{
|
|
75
|
-
pat: Type.String({ description: "regex" }),
|
|
76
|
-
rep: Type.String({ description: "expression to replace with" }),
|
|
77
|
-
g: Type.Optional(Type.Boolean({ description: "global flag", default: false })),
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
additionalProperties: false,
|
|
81
|
-
},
|
|
82
|
-
),
|
|
83
|
-
),
|
|
84
|
-
},
|
|
85
|
-
{ additionalProperties: false },
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
export const atomEditParamsSchema = Type.Object(
|
|
89
|
-
{
|
|
90
|
-
path: Type.Optional(Type.String({ description: "default file path for edits" })),
|
|
91
|
-
edits: Type.Array(atomEditSchema, { description: "edit ops" }),
|
|
92
|
-
},
|
|
93
|
-
{ additionalProperties: false },
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
export type AtomToolEdit = Static<typeof atomEditSchema>;
|
|
47
|
+
export const atomEditParamsSchema = Type.Object({ input: Type.String() });
|
|
48
|
+
|
|
97
49
|
export type AtomParams = Static<typeof atomEditParamsSchema>;
|
|
98
50
|
|
|
99
51
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
100
|
-
//
|
|
52
|
+
// Parser
|
|
101
53
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
102
54
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
| { op: "del"; pos: Anchor }
|
|
108
|
-
| { op: "append_file"; lines: string[] }
|
|
109
|
-
| { op: "prepend_file"; lines: string[] }
|
|
110
|
-
| { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
|
|
111
|
-
| { op: "sed_file"; spec: SedSpec; expression: string }
|
|
112
|
-
| { op: "splice_block"; pos: Anchor; spec: SpliceBlockSpec; bracket: BracketShape };
|
|
113
|
-
|
|
114
|
-
export interface SedSpec {
|
|
115
|
-
pattern: string;
|
|
116
|
-
replacement: string;
|
|
117
|
-
global: boolean;
|
|
118
|
-
}
|
|
55
|
+
// Permissive: any 2 lowercase letters. Invalid hashes flow through to a
|
|
56
|
+
// HashlineMismatchError downstream, matching the other hashline-backed modes.
|
|
57
|
+
const LID_RE = /^([1-9]\d*)([a-z]{2})/;
|
|
58
|
+
const LID_EXACT_RE = /^([1-9]\d*)([a-z]{2})$/;
|
|
119
59
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
60
|
+
interface ParsedAnchor {
|
|
61
|
+
line: number;
|
|
62
|
+
hash: string;
|
|
123
63
|
}
|
|
124
64
|
|
|
125
|
-
type
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (dot <= 0) return undefined;
|
|
139
|
-
return base.slice(dot + 1).toLowerCase();
|
|
140
|
-
}
|
|
65
|
+
type ParsedOp = { op: "set"; text: string; allowOldNewRepair: boolean } | { op: "delete" };
|
|
66
|
+
|
|
67
|
+
type AnchorStmt =
|
|
68
|
+
| { kind: "bare_anchor"; anchor: ParsedAnchor; lineNum: number }
|
|
69
|
+
| { kind: "anchor_op"; anchor: ParsedAnchor; op: ParsedOp; lineNum: number }
|
|
70
|
+
| { kind: "bof"; lineNum: number }
|
|
71
|
+
| { kind: "eof"; lineNum: number };
|
|
72
|
+
|
|
73
|
+
type InsertStmt = {
|
|
74
|
+
kind: "insert";
|
|
75
|
+
text: string;
|
|
76
|
+
lineNum: number;
|
|
77
|
+
};
|
|
141
78
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
79
|
+
type DiffishAddStmt = {
|
|
80
|
+
kind: "diffish_add";
|
|
81
|
+
anchor: ParsedAnchor;
|
|
82
|
+
text: string;
|
|
83
|
+
lineNum: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type DeleteWithOldStmt = {
|
|
87
|
+
kind: "delete_with_old";
|
|
88
|
+
anchor: ParsedAnchor;
|
|
89
|
+
old: string;
|
|
90
|
+
lineNum: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type ParsedStmt = AnchorStmt | InsertStmt | DiffishAddStmt | DeleteWithOldStmt;
|
|
94
|
+
|
|
95
|
+
type AtomCursor = { kind: "bof" } | { kind: "eof" } | { kind: "anchor"; anchor: Anchor };
|
|
96
|
+
|
|
97
|
+
export type AtomEdit =
|
|
98
|
+
| { kind: "insert"; cursor: AtomCursor; text: string; lineNum: number; index: number }
|
|
99
|
+
| { kind: "set"; anchor: Anchor; text: string; lineNum: number; index: number; allowOldNewRepair: boolean }
|
|
100
|
+
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
|
|
101
|
+
|
|
102
|
+
interface AtomApplyResult {
|
|
103
|
+
lines: string;
|
|
104
|
+
firstChangedLine?: number;
|
|
105
|
+
warnings?: string[];
|
|
106
|
+
noopEdits?: AtomNoopEdit[];
|
|
146
107
|
}
|
|
147
108
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
109
|
+
interface AtomNoopEdit {
|
|
110
|
+
editIndex: number;
|
|
111
|
+
loc: string;
|
|
112
|
+
reason: string;
|
|
113
|
+
current: string;
|
|
114
|
+
}
|
|
151
115
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
|
|
157
|
-
// optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
|
|
158
|
-
// captured as a content hint for anchor disambiguation.
|
|
159
|
-
const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`);
|
|
160
|
-
|
|
161
|
-
// Splits `path:loc` references where the right side starts with a valid anchor
|
|
162
|
-
// (single `\d+<bigram>` or `<anchor>-<anchor>` range, optionally followed by a
|
|
163
|
-
// content suffix using `|` or `:`). The non-greedy `(.+?)` picks the leftmost
|
|
164
|
-
// colon whose RHS is a real anchor, so colons inside the loc's content suffix
|
|
165
|
-
// (TS type annotations, etc.) don't break the split. Drive-letter prefixes like
|
|
166
|
-
// `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
|
|
167
|
-
// fails the anchor pattern.
|
|
168
|
-
const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
|
|
169
|
-
const PATH_LOC_SPLIT_RE = new RegExp(
|
|
170
|
-
`^(.+?):([\\[(]?${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?[\\])]?)$`,
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
174
|
-
let next: Record<string, unknown> | undefined;
|
|
175
|
-
const fields = edit as Record<string, unknown>;
|
|
176
|
-
for (const key of ATOM_OPTIONAL_KEYS) {
|
|
177
|
-
if (fields[key] !== null) continue;
|
|
178
|
-
next ??= { ...fields };
|
|
179
|
-
delete next[key];
|
|
180
|
-
}
|
|
181
|
-
return (next ?? fields) as AtomToolEdit;
|
|
116
|
+
interface IndexedAnchorEdit {
|
|
117
|
+
edit: Extract<AtomEdit, { kind: "insert" | "set" | "delete" }>;
|
|
118
|
+
idx: number;
|
|
182
119
|
}
|
|
183
120
|
|
|
184
|
-
|
|
121
|
+
function cloneCursor(cursor: AtomCursor): AtomCursor {
|
|
122
|
+
if (cursor.kind !== "anchor") return cursor;
|
|
123
|
+
return { kind: "anchor", anchor: { ...cursor.anchor } };
|
|
124
|
+
}
|
|
185
125
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
126
|
+
function parseLidStmt(body: string, lineNum: number): AnchorStmt | null {
|
|
127
|
+
const m = LID_RE.exec(body);
|
|
128
|
+
if (!m) return null;
|
|
189
129
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
* anchor so the validator emits a content-rich mismatch error.
|
|
197
|
-
*
|
|
198
|
-
* If we cannot recover even a line number, throw a usage-style error with the
|
|
199
|
-
* raw reference quoted.
|
|
200
|
-
*/
|
|
201
|
-
function parseAnchor(raw: string, opName: string): Anchor {
|
|
202
|
-
if (typeof raw !== "string" || raw.length === 0) {
|
|
203
|
-
throw new Error(`${opName} requires ${formatFullAnchorRequirement()}.`);
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
return parseTag(raw);
|
|
207
|
-
} catch {
|
|
208
|
-
const lineMatch = /^\s*[>+-]*\s*(\d+)/.exec(raw);
|
|
209
|
-
if (lineMatch) {
|
|
210
|
-
const line = Number.parseInt(lineMatch[1], 10);
|
|
211
|
-
if (line >= 1) {
|
|
212
|
-
// Sentinel hash that will never match a real line, forcing the validator
|
|
213
|
-
// to report a mismatch with the actual hash + line content.
|
|
214
|
-
return { line, hash: "??" };
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
throw new Error(
|
|
218
|
-
`${opName} requires ${formatFullAnchorRequirement(raw)} Could not find a line number in the anchor.`,
|
|
219
|
-
);
|
|
130
|
+
const ln = Number.parseInt(m[1], 10);
|
|
131
|
+
const hash = m[2];
|
|
132
|
+
const rest = body.slice(m[0].length);
|
|
133
|
+
const anchor = { line: ln, hash };
|
|
134
|
+
if (rest.length === 0) {
|
|
135
|
+
return { kind: "bare_anchor", anchor, lineNum };
|
|
220
136
|
}
|
|
137
|
+
|
|
138
|
+
const replacement = /^[ \t]*([=|])(.*)$/.exec(rest);
|
|
139
|
+
if (!replacement) return null;
|
|
140
|
+
return {
|
|
141
|
+
kind: "anchor_op",
|
|
142
|
+
anchor,
|
|
143
|
+
op: { op: "set", text: replacement[2], allowOldNewRepair: replacement[1] === "|" },
|
|
144
|
+
lineNum,
|
|
145
|
+
};
|
|
221
146
|
}
|
|
222
147
|
|
|
223
|
-
function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
148
|
+
function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
|
|
149
|
+
const trimmedBody = body.trimStart();
|
|
150
|
+
const exact = LID_EXACT_RE.exec(trimmedBody);
|
|
151
|
+
if (exact) {
|
|
152
|
+
const ln = Number.parseInt(exact[1], 10);
|
|
153
|
+
return [{ kind: "anchor_op", anchor: { line: ln, hash: exact[2] }, op: { op: "delete" }, lineNum }];
|
|
228
154
|
}
|
|
229
|
-
}
|
|
230
155
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const entry = stripNullAtomFields(edit);
|
|
237
|
-
let loc = entry.loc;
|
|
238
|
-
let pathOverride: string | undefined;
|
|
239
|
-
if (typeof loc === "string") {
|
|
240
|
-
const split = loc.match(PATH_LOC_SPLIT_RE);
|
|
241
|
-
if (split) {
|
|
242
|
-
pathOverride = split[1];
|
|
243
|
-
loc = split[2]!;
|
|
244
|
-
}
|
|
156
|
+
const m = LID_RE.exec(trimmedBody);
|
|
157
|
+
if (m && (trimmedBody[m[0].length] === "|" || trimmedBody[m[0].length] === "=")) {
|
|
158
|
+
const ln = Number.parseInt(m[1], 10);
|
|
159
|
+
const old = trimmedBody.slice(m[0].length + 1);
|
|
160
|
+
return [{ kind: "delete_with_old", anchor: { line: ln, hash: m[2] }, old, lineNum }];
|
|
245
161
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
162
|
+
if (m && trimmedBody[m[0].length] === " ") {
|
|
163
|
+
const ln = Number.parseInt(m[1], 10);
|
|
164
|
+
const text = trimmedBody.slice(m[0].length + 1);
|
|
165
|
+
return [
|
|
166
|
+
{ kind: "anchor_op", anchor: { line: ln, hash: m[2] }, op: { op: "delete" }, lineNum },
|
|
167
|
+
{ kind: "insert", text, lineNum },
|
|
168
|
+
];
|
|
251
169
|
}
|
|
252
|
-
return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
|
|
253
|
-
}
|
|
254
170
|
|
|
255
|
-
|
|
256
|
-
edits: readonly AtomToolEdit[],
|
|
257
|
-
topLevelPath: string | undefined,
|
|
258
|
-
): (AtomToolEdit & { path: string })[] {
|
|
259
|
-
return edits.map((edit, i) => resolveAtomEntryPath(edit, topLevelPath, i));
|
|
171
|
+
return null;
|
|
260
172
|
}
|
|
261
173
|
|
|
262
|
-
function
|
|
263
|
-
const
|
|
264
|
-
|
|
174
|
+
function throwMalformedLidDiagnostic(line: string, lineNum: number, raw: string): never {
|
|
175
|
+
const text = line.trimStart();
|
|
176
|
+
const withoutLegacyMove = text.startsWith("@@ ") ? text.slice(3).trimStart() : text;
|
|
177
|
+
const withoutMove = withoutLegacyMove.startsWith("@") ? withoutLegacyMove.slice(1) : withoutLegacyMove;
|
|
178
|
+
const withoutDelete = withoutMove.startsWith("-") ? withoutMove.slice(1).trimStart() : withoutMove;
|
|
265
179
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
const hasLeading = leading === "[" || leading === "(";
|
|
269
|
-
const hasTrailing = trailing === "]" || trailing === ")";
|
|
270
|
-
if ((leading === "(" && trailing === "]") || (leading === "[" && trailing === ")")) {
|
|
180
|
+
const partial = /^([a-z]{2})(?=[ \t]*[=|])/.exec(withoutDelete);
|
|
181
|
+
if (partial) {
|
|
271
182
|
throw new Error(
|
|
272
|
-
`
|
|
183
|
+
`Diff line ${lineNum}: \`${partial[1]}\` is not a full Lid. Use the full Lid from read output, e.g. \`119${partial[1]}\`.`,
|
|
273
184
|
);
|
|
274
185
|
}
|
|
275
186
|
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const dash = inner.indexOf("-");
|
|
283
|
-
if (dash > 0) {
|
|
284
|
-
const left = inner.slice(0, dash);
|
|
285
|
-
const right = inner.slice(dash + 1);
|
|
286
|
-
if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
|
|
287
|
-
throw new Error(
|
|
288
|
-
`Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
const pos = parseAnchor(inner, "loc");
|
|
293
|
-
// Capture an optional content suffix after the anchor: `82zu| for (...)`.
|
|
294
|
-
// The suffix acts as a hint for anchor disambiguation when the model's hash
|
|
295
|
-
// is wrong but the content reveals the intended line.
|
|
296
|
-
const hint = extractAnchorContentHint(inner);
|
|
297
|
-
if (hint !== undefined) {
|
|
298
|
-
pos.contentHint = hint;
|
|
187
|
+
const missing = /^([1-9]\d*)(?=[ \t]*[=|]|$)/.exec(withoutDelete);
|
|
188
|
+
if (missing) {
|
|
189
|
+
const prefix = text.startsWith("@@ ") ? `@@ ${missing[1]}` : missing[1];
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Diff line ${lineNum}: \`${prefix}\` is missing the two-letter Lid suffix. Use the full Lid from read output, e.g. \`${prefix.startsWith("@@ ") ? "@@ " : ""}${missing[1]}ab\`.`,
|
|
192
|
+
);
|
|
299
193
|
}
|
|
300
194
|
|
|
301
|
-
|
|
302
|
-
if (leading === "[" && trailing === "]") bracket = "node";
|
|
303
|
-
else if (leading === "[") bracket = "left_incl";
|
|
304
|
-
else if (leading === "(" && trailing === ")") bracket = "body";
|
|
305
|
-
else if (leading === "(") bracket = "left_excl";
|
|
306
|
-
else if (trailing === "]") bracket = "right_incl";
|
|
307
|
-
else if (trailing === ")") bracket = "right_excl";
|
|
308
|
-
return { kind: "anchor", pos, bracket };
|
|
195
|
+
throw new Error(`Diff line ${lineNum}: cannot parse "${raw}".`);
|
|
309
196
|
}
|
|
310
197
|
|
|
311
|
-
function
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
198
|
+
function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
199
|
+
// Strip trailing CR (CRLF tolerance).
|
|
200
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
201
|
+
if (line.length === 0) return [];
|
|
202
|
+
|
|
203
|
+
// `+TEXT` inserts at the cursor. Everything after `+` is content. A
|
|
204
|
+
// `+Lid|TEXT` or `+Lid=TEXT` line is a diff-ish add (unified-diff trap):
|
|
205
|
+
// emit a tagged stmt so the normalizer can fuse it with a preceding `-Lid`.
|
|
206
|
+
if (line[0] === "+") {
|
|
207
|
+
const body = line.slice(1);
|
|
208
|
+
const m = LID_RE.exec(body);
|
|
209
|
+
if (m) {
|
|
210
|
+
const sep = body[m[0].length];
|
|
211
|
+
if (sep === "=" || sep === "|") {
|
|
212
|
+
const ln = Number.parseInt(m[1], 10);
|
|
213
|
+
const text = body.slice(m[0].length + 1);
|
|
214
|
+
return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, text, lineNum }];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
323
217
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
218
|
+
// Auto-fix: `+@Lid` and `+-Lid` are almost always typos where the agent
|
|
219
|
+
// prefixed a cursor-move or delete op with `+`. Insert content matching
|
|
220
|
+
// these op shapes is essentially never legitimate in source code, and
|
|
221
|
+
// silently emitting them as literal text corrupts the file (e.g. a stray
|
|
222
|
+
// `@12ly` line in a C++ source). Split into the op + a blank `+` insert
|
|
223
|
+
// so the line count of the edit script is preserved for any downstream
|
|
224
|
+
// offset-sensitive logic.
|
|
225
|
+
if (body.length > 1 && (body[0] === "@" || body[0] === "-")) {
|
|
226
|
+
try {
|
|
227
|
+
const opStmts = parseDiffLine(body, lineNum);
|
|
228
|
+
const allOps = opStmts.length > 0 && opStmts.every(s => s.kind !== "insert" && s.kind !== "diffish_add");
|
|
229
|
+
if (allOps) {
|
|
230
|
+
return [...opStmts, { kind: "insert", text: "", lineNum }];
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// Body looked op-shaped but failed to parse; fall through to literal insert.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return [{ kind: "insert", text: body, lineNum }];
|
|
327
237
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (
|
|
332
|
-
|
|
238
|
+
|
|
239
|
+
// Canonical file-scope locators.
|
|
240
|
+
if (line === "$") return [{ kind: "bof", lineNum }];
|
|
241
|
+
if (line === "^") return [{ kind: "eof", lineNum }];
|
|
242
|
+
|
|
243
|
+
// `-Lid` deletes the anchored line. Leniently accept `- Lid` and the
|
|
244
|
+
// historical `-Lid TEXT` delete-then-insert recovery.
|
|
245
|
+
if (line[0] === "-") {
|
|
246
|
+
const parsed = parseDeleteStmt(line.slice(1), lineNum);
|
|
247
|
+
if (parsed) return parsed;
|
|
248
|
+
throw new Error(`Diff line ${lineNum}: \`-\` must be followed by a Lid (e.g. \`-5xx\`). Got "${raw}".`);
|
|
333
249
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
250
|
+
|
|
251
|
+
// Legacy move prefix. Runtime accepts old locators and common slipped edit
|
|
252
|
+
// operations, while the grammar/prompt bias models to canonical syntax.
|
|
253
|
+
if (line.startsWith("@@ ")) {
|
|
254
|
+
const body = line.slice(3);
|
|
255
|
+
if (body === "BOF") return [{ kind: "bof", lineNum }];
|
|
256
|
+
if (body === "EOF") return [{ kind: "eof", lineNum }];
|
|
257
|
+
|
|
258
|
+
const deleteStmt = body.startsWith("-") ? parseDeleteStmt(body.slice(1), lineNum) : null;
|
|
259
|
+
if (deleteStmt) return deleteStmt;
|
|
260
|
+
|
|
261
|
+
const lidStmt = parseLidStmt(body, lineNum);
|
|
262
|
+
if (lidStmt) return [lidStmt];
|
|
263
|
+
|
|
264
|
+
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
338
265
|
}
|
|
339
|
-
|
|
340
|
-
|
|
266
|
+
|
|
267
|
+
// Canonical `@Lid` cursor moves. Leniently recover `@Lid=TEXT`,
|
|
268
|
+
// `@Lid|TEXT`, `@$`, and `@^`.
|
|
269
|
+
if (line[0] === "@") {
|
|
270
|
+
const body = line.slice(1);
|
|
271
|
+
if (body === "$") return [{ kind: "bof", lineNum }];
|
|
272
|
+
if (body === "^") return [{ kind: "eof", lineNum }];
|
|
273
|
+
const lidStmt = parseLidStmt(body, lineNum);
|
|
274
|
+
if (lidStmt) return [lidStmt];
|
|
275
|
+
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
341
276
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
277
|
+
|
|
278
|
+
// `Lid=TEXT` sets the anchored line. Legacy `Lid|TEXT` remains accepted.
|
|
279
|
+
// A bare `Lid` is a cursor move.
|
|
280
|
+
const lidStmt = parseLidStmt(line, lineNum);
|
|
281
|
+
if (lidStmt) return [lidStmt];
|
|
282
|
+
|
|
283
|
+
if (/^[a-z]{2}(?=[ \t]*[=|])/.test(line) || /^[1-9]\d*(?=[ \t]*[=|]|$)/.test(line)) {
|
|
284
|
+
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
349
285
|
}
|
|
350
|
-
return { pattern: pat, replacement: rep, global };
|
|
351
|
-
}
|
|
352
286
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
return JSON.stringify(obj);
|
|
287
|
+
// Reject any line that doesn't match a recognized op. Common case: a model
|
|
288
|
+
// emitted multi-line content after a `Lid=` or similar without `+` prefixes,
|
|
289
|
+
// or pasted raw context. Silently treating these as inserts corrupts files.
|
|
290
|
+
const preview = line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Diff line ${lineNum}: unrecognized op. Lines must start with \`+\`, \`-\`, \`@\`, \`$\`, \`^\`, or a Lid (\`Lid=TEXT\`). To insert literal text use \`+TEXT\`. Got "${preview}".`,
|
|
293
|
+
);
|
|
361
294
|
}
|
|
362
295
|
|
|
363
|
-
function
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
296
|
+
function tokenizeDiff(diff: string): ParsedStmt[] {
|
|
297
|
+
const out: ParsedStmt[] = [];
|
|
298
|
+
const lines = diff.split("\n");
|
|
299
|
+
for (let i = 0; i < lines.length; i++) {
|
|
300
|
+
const lineNum = i + 1;
|
|
301
|
+
const stmts = parseDiffLine(lines[i], lineNum);
|
|
302
|
+
for (const stmt of stmts) {
|
|
303
|
+
// Last-set-wins: when the same anchor (line+hash) gets a second `set`,
|
|
304
|
+
// drop the earlier one. Models sometimes echo the OLD line and then the
|
|
305
|
+
// NEW line as replacements (e.g. `119yh|OLD` / `119yh|NEW`); the last is
|
|
306
|
+
// the intended value.
|
|
307
|
+
if (stmt.kind === "anchor_op" && stmt.op.op === "set") {
|
|
308
|
+
const key = `${stmt.anchor.line}:${stmt.anchor.hash}`;
|
|
309
|
+
for (let j = out.length - 1; j >= 0; j--) {
|
|
310
|
+
const prior = out[j];
|
|
311
|
+
if (
|
|
312
|
+
prior.kind === "anchor_op" &&
|
|
313
|
+
prior.op.op === "set" &&
|
|
314
|
+
`${prior.anchor.line}:${prior.anchor.hash}` === key
|
|
315
|
+
) {
|
|
316
|
+
out.splice(j, 1);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
out.push(stmt);
|
|
322
|
+
}
|
|
368
323
|
}
|
|
369
|
-
return
|
|
370
|
-
result: currentLine.slice(0, idx) + spec.replacement + currentLine.slice(idx + spec.pattern.length),
|
|
371
|
-
matched: true,
|
|
372
|
-
};
|
|
324
|
+
return normalizeHunks(out);
|
|
373
325
|
}
|
|
374
326
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
327
|
+
// Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
|
|
328
|
+
// hunks and reorder so adds land at the FIRST delete's slot (block
|
|
329
|
+
// replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
|
|
330
|
+
// `set`. Standalone `+Lid|TEXT` and `+Lid|TEXT` referencing a Lid not in
|
|
331
|
+
function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
|
|
332
|
+
const isDelete = (s: ParsedStmt): boolean =>
|
|
333
|
+
(s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
|
|
334
|
+
const isAdd = (s: ParsedStmt): boolean => s.kind === "insert" || s.kind === "diffish_add";
|
|
335
|
+
const out: ParsedStmt[] = [];
|
|
336
|
+
let i = 0;
|
|
337
|
+
while (i < stmts.length) {
|
|
338
|
+
const stmt = stmts[i];
|
|
339
|
+
if (!isDelete(stmt)) {
|
|
340
|
+
if (stmt.kind === "diffish_add") {
|
|
341
|
+
const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Diff line ${stmt.lineNum}: \`+${lid}|...\` looks like a unified-diff replacement marker. Use \`${lid}=TEXT\` to replace, or precede with \`-${lid}\` to delete-then-replace.`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
out.push(stmt);
|
|
347
|
+
i++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const deletes: ParsedStmt[] = [];
|
|
351
|
+
while (i < stmts.length && isDelete(stmts[i])) {
|
|
352
|
+
deletes.push(stmts[i]);
|
|
353
|
+
i++;
|
|
354
|
+
}
|
|
355
|
+
const adds: ParsedStmt[] = [];
|
|
356
|
+
while (i < stmts.length && isAdd(stmts[i])) {
|
|
357
|
+
adds.push(stmts[i]);
|
|
358
|
+
i++;
|
|
359
|
+
}
|
|
360
|
+
const deletedLids = new Set(
|
|
361
|
+
deletes.map(d => {
|
|
362
|
+
const a = (d as { anchor: ParsedAnchor }).anchor;
|
|
363
|
+
return `${a.line}${a.hash}`;
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
for (const add of adds) {
|
|
367
|
+
if (add.kind !== "diffish_add") continue;
|
|
368
|
+
const lid = `${add.anchor.line}${add.anchor.hash}`;
|
|
369
|
+
if (!deletedLids.has(lid)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Diff line ${add.lineNum}: \`+${lid}|...\` references a Lid that was not deleted in the preceding run. Use \`${lid}=TEXT\` to replace, or precede with \`-${lid}\`.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Split the delete run into file-contiguous sub-runs. The block
|
|
376
|
+
// reorder (inserts land at the FIRST delete's slot) is meaningful only
|
|
377
|
+
// when the deletes describe a single contiguous file range. When the
|
|
378
|
+
// agent stacks deletes that target far-apart lines (e.g. `-186 -197
|
|
379
|
+
// -198 -199` to remove a debug line at 186 AND replace 197-199), each
|
|
380
|
+
// far-apart delete moves the cursor on its own; only the LAST
|
|
381
|
+
// contiguous group should attract the inserts.
|
|
382
|
+
const subruns = splitContiguousDeletes(deletes);
|
|
383
|
+
for (let r = 0; r < subruns.length - 1; r++) {
|
|
384
|
+
for (const d of subruns[r]) out.push(d);
|
|
385
|
+
}
|
|
386
|
+
const lastDeletes = subruns[subruns.length - 1];
|
|
387
|
+
|
|
388
|
+
// Single-line case: 1 delete in the last sub-run + 1 diffish_add same Lid → fuse to set.
|
|
389
|
+
if (lastDeletes.length === 1 && adds.length === 1 && adds[0].kind === "diffish_add") {
|
|
390
|
+
const dAnchor = (lastDeletes[0] as { anchor: ParsedAnchor }).anchor;
|
|
391
|
+
const a = adds[0];
|
|
392
|
+
if (a.anchor.line === dAnchor.line && a.anchor.hash === dAnchor.hash) {
|
|
393
|
+
out.push({
|
|
394
|
+
kind: "anchor_op",
|
|
395
|
+
anchor: a.anchor,
|
|
396
|
+
op: { op: "set", text: a.text, allowOldNewRepair: false },
|
|
397
|
+
lineNum: a.lineNum,
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Block: emit lastDeletes[0], then all inserts (which land at lastDeletes[0]'s slot
|
|
403
|
+
// because the cursor binds to lastDeletes[0] before the inserts), then the
|
|
404
|
+
// remaining lastDeletes.
|
|
405
|
+
out.push(lastDeletes[0]);
|
|
406
|
+
for (const add of adds) {
|
|
407
|
+
const text = add.kind === "insert" ? add.text : (add as DiffishAddStmt).text;
|
|
408
|
+
out.push({ kind: "insert", text, lineNum: add.lineNum });
|
|
409
|
+
}
|
|
410
|
+
for (let j = 1; j < lastDeletes.length; j++) {
|
|
411
|
+
out.push(lastDeletes[j]);
|
|
398
412
|
}
|
|
399
413
|
}
|
|
400
|
-
|
|
401
|
-
// containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
|
|
402
|
-
// intend as literal code. Trying a literal match before reporting failure
|
|
403
|
-
// recovers the obvious intent without changing semantics for patterns that
|
|
404
|
-
// already match as regex.
|
|
405
|
-
const literal = applyLiteralSed(currentLine, spec);
|
|
406
|
-
if (literal.matched) {
|
|
407
|
-
return { ...literal, literalFallback: true };
|
|
408
|
-
}
|
|
409
|
-
if (compileError !== undefined) {
|
|
410
|
-
return { result: currentLine, matched: false, error: compileError };
|
|
411
|
-
}
|
|
412
|
-
return { result: currentLine, matched: false };
|
|
414
|
+
return out;
|
|
413
415
|
}
|
|
414
416
|
|
|
415
|
-
function
|
|
416
|
-
|
|
417
|
-
const verbs = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
|
|
418
|
-
return verbs.length > 0 ? verbs.join("+") : "unknown";
|
|
417
|
+
function makeAnchor(anchor: ParsedAnchor): Anchor {
|
|
418
|
+
return { line: anchor.line, hash: anchor.hash };
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
-
function
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
421
|
+
function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
|
|
422
|
+
if (deletes.length === 0) return [];
|
|
423
|
+
const getLine = (s: ParsedStmt): number => {
|
|
424
|
+
if (s.kind === "anchor_op") return s.anchor.line;
|
|
425
|
+
if (s.kind === "delete_with_old") return s.anchor.line;
|
|
426
|
+
throw new Error("internal: splitContiguousDeletes received non-delete stmt");
|
|
427
|
+
};
|
|
428
|
+
const subruns: ParsedStmt[][] = [];
|
|
429
|
+
let current: ParsedStmt[] = [deletes[0]];
|
|
430
|
+
for (let i = 1; i < deletes.length; i++) {
|
|
431
|
+
if (getLine(deletes[i]) === getLine(deletes[i - 1]) + 1) {
|
|
432
|
+
current.push(deletes[i]);
|
|
433
|
+
} else {
|
|
434
|
+
subruns.push(current);
|
|
435
|
+
current = [deletes[i]];
|
|
436
|
+
}
|
|
431
437
|
}
|
|
438
|
+
subruns.push(current);
|
|
439
|
+
return subruns;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
443
|
+
// Build cursor-program from ParsedStmt[]
|
|
444
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
432
445
|
|
|
433
|
-
|
|
434
|
-
const
|
|
446
|
+
export function parseAtom(diff: string): AtomEdit[] {
|
|
447
|
+
const edits: AtomEdit[] = [];
|
|
448
|
+
let cursor: AtomCursor = { kind: "eof" };
|
|
449
|
+
let index = 0;
|
|
435
450
|
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
|
|
451
|
+
for (const stmt of tokenizeDiff(diff)) {
|
|
452
|
+
if (stmt.kind === "insert") {
|
|
453
|
+
edits.push({ kind: "insert", cursor: cloneCursor(cursor), text: stmt.text, lineNum: stmt.lineNum, index });
|
|
454
|
+
index++;
|
|
455
|
+
continue;
|
|
442
456
|
}
|
|
443
|
-
|
|
444
|
-
|
|
457
|
+
|
|
458
|
+
if (stmt.kind === "bof") {
|
|
459
|
+
cursor = { kind: "bof" };
|
|
460
|
+
continue;
|
|
445
461
|
}
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
462
|
+
if (stmt.kind === "eof") {
|
|
463
|
+
cursor = { kind: "eof" };
|
|
464
|
+
continue;
|
|
449
465
|
}
|
|
450
|
-
return resolved;
|
|
451
|
-
}
|
|
452
466
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
);
|
|
467
|
+
if (stmt.kind === "delete_with_old") {
|
|
468
|
+
const anchor = makeAnchor(stmt.anchor);
|
|
469
|
+
cursor = { kind: "anchor", anchor: { ...anchor } };
|
|
470
|
+
edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index, oldAssertion: stmt.old });
|
|
471
|
+
index++;
|
|
472
|
+
continue;
|
|
460
473
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
);
|
|
474
|
+
|
|
475
|
+
if (stmt.kind === "diffish_add") {
|
|
476
|
+
throw new Error("Internal atom error: unresolved diff-ish add reached parseAtom.");
|
|
465
477
|
}
|
|
466
|
-
const kind = resolveBlockDelimiterForPath(path);
|
|
467
|
-
const body = hashlineParseText(entry.splice);
|
|
468
|
-
resolved.push({ op: "splice_block", pos: loc.pos, spec: { body, kind }, bracket: loc.bracket });
|
|
469
|
-
return resolved;
|
|
470
|
-
}
|
|
471
478
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (entry.sed === undefined) {
|
|
482
|
-
resolved.push({ op: "del", pos: loc.pos });
|
|
479
|
+
const anchor = makeAnchor(stmt.anchor);
|
|
480
|
+
cursor = { kind: "anchor", anchor: { ...anchor } };
|
|
481
|
+
if (stmt.kind === "bare_anchor") continue;
|
|
482
|
+
|
|
483
|
+
if (stmt.op.op === "set") {
|
|
484
|
+
if (stmt.op.text.includes("\r")) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`Diff line ${stmt.lineNum}: set value contains a carriage return; use a single-line value.`,
|
|
487
|
+
);
|
|
483
488
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// matching `sed`. The explicit replacement wins; the redundant `sed` would
|
|
495
|
-
// otherwise trigger a confusing `Conflicting ops` rejection.
|
|
496
|
-
if (!spliceIsExplicitReplacement) {
|
|
497
|
-
const spec = parseSedSpec(entry.sed, editIndex);
|
|
498
|
-
resolved.push({ op: "sed", pos: loc.pos, spec, expression: formatSedExpression(spec) });
|
|
489
|
+
edits.push({
|
|
490
|
+
kind: "set",
|
|
491
|
+
anchor,
|
|
492
|
+
text: stmt.op.text,
|
|
493
|
+
lineNum: stmt.lineNum,
|
|
494
|
+
index,
|
|
495
|
+
allowOldNewRepair: stmt.op.allowOldNewRepair,
|
|
496
|
+
});
|
|
497
|
+
index++;
|
|
498
|
+
continue;
|
|
499
499
|
}
|
|
500
|
+
|
|
501
|
+
edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index });
|
|
502
|
+
index++;
|
|
500
503
|
}
|
|
501
|
-
|
|
504
|
+
|
|
505
|
+
return edits;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
|
|
509
|
+
const body = diff
|
|
510
|
+
.split("\n")
|
|
511
|
+
.map(line => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
|
512
|
+
.filter(line => line.trim().length > 0)
|
|
513
|
+
.slice(0, 3)
|
|
514
|
+
.map(line => ` ${line}`)
|
|
515
|
+
.join("\n");
|
|
516
|
+
const preview = body.length > 0 ? `\nReceived only locator/context lines:\n${body}` : "";
|
|
517
|
+
return `Cursor moved but no mutation found. Add +TEXT to insert, -Lid to delete, or Lid=TEXT to replace.${preview}`;
|
|
502
518
|
}
|
|
503
519
|
|
|
504
520
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
505
|
-
//
|
|
521
|
+
// Apply cursor-program
|
|
506
522
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
507
523
|
|
|
508
|
-
function
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
case "post":
|
|
513
|
-
case "del":
|
|
514
|
-
case "sed":
|
|
515
|
-
case "splice_block":
|
|
516
|
-
yield edit.pos;
|
|
517
|
-
return;
|
|
518
|
-
default:
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* Search for a line near `anchor.line` whose trimmed content equals the
|
|
525
|
-
* anchor's content hint. Returns the closest match (preferring lines below the
|
|
526
|
-
* requested anchor on ties) or `null` when no line matches. Strict equality on
|
|
527
|
-
* trimmed content keeps this conservative \u2014 we only retarget when there is no
|
|
528
|
-
* ambiguity about the model's intent.
|
|
529
|
-
*/
|
|
530
|
-
function findLineByContentHint(anchor: Anchor, fileLines: string[]): number | null {
|
|
531
|
-
const hint = anchor.contentHint?.trim();
|
|
532
|
-
if (!hint) return null;
|
|
533
|
-
const lo = Math.max(1, anchor.line - ANCHOR_REBASE_WINDOW);
|
|
534
|
-
const hi = Math.min(fileLines.length, anchor.line + ANCHOR_REBASE_WINDOW);
|
|
535
|
-
let best: { line: number; distance: number } | null = null;
|
|
536
|
-
for (let line = lo; line <= hi; line++) {
|
|
537
|
-
if (fileLines[line - 1].trim() !== hint) continue;
|
|
538
|
-
const distance = Math.abs(line - anchor.line);
|
|
539
|
-
if (best === null || distance < best.distance) {
|
|
540
|
-
best = { line, distance };
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
return best?.line ?? null;
|
|
524
|
+
function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
|
|
525
|
+
if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
|
|
526
|
+
if (edit.cursor.kind === "anchor") return [edit.cursor.anchor];
|
|
527
|
+
return [];
|
|
544
528
|
}
|
|
545
529
|
|
|
546
530
|
function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
|
|
547
531
|
const mismatches: HashMismatch[] = [];
|
|
532
|
+
const rebasedAnchors = new Map<Anchor, HashMismatch>();
|
|
533
|
+
const rebasedMutatingAnchors: { original: string; rebased: number; hash: string }[] = [];
|
|
548
534
|
for (const edit of edits) {
|
|
549
|
-
for (const anchor of
|
|
535
|
+
for (const anchor of getAtomEditAnchors(edit)) {
|
|
550
536
|
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
551
537
|
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
552
538
|
}
|
|
553
539
|
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
|
|
554
540
|
if (actualHash === anchor.hash) continue;
|
|
555
|
-
|
|
556
|
-
// `82zu| for (...)`), prefer rebasing to the line that actually matches
|
|
557
|
-
// that content. This avoids false positives from hash-only rebasing where
|
|
558
|
-
// a coincidentally matching hash on a nearby line silently retargets the
|
|
559
|
-
// edit to the wrong line.
|
|
560
|
-
const hinted = findLineByContentHint(anchor, fileLines);
|
|
561
|
-
if (hinted !== null) {
|
|
562
|
-
const original = `${anchor.line}${anchor.hash}`;
|
|
563
|
-
const hintedHash = computeLineHash(hinted, fileLines[hinted - 1]);
|
|
564
|
-
anchor.line = hinted;
|
|
565
|
-
anchor.hash = hintedHash;
|
|
566
|
-
warnings.push(
|
|
567
|
-
`Auto-rebased anchor ${original} → ${hinted}${hintedHash} (matched the content hint provided after the anchor).`,
|
|
568
|
-
);
|
|
569
|
-
continue;
|
|
570
|
-
}
|
|
541
|
+
|
|
571
542
|
const rebased = tryRebaseAnchor(anchor, fileLines);
|
|
572
543
|
if (rebased !== null) {
|
|
573
544
|
const original = `${anchor.line}${anchor.hash}`;
|
|
545
|
+
rebasedAnchors.set(anchor, { line: anchor.line, expected: anchor.hash, actual: actualHash });
|
|
574
546
|
anchor.line = rebased;
|
|
547
|
+
if (edit.kind === "set" || edit.kind === "delete") {
|
|
548
|
+
rebasedMutatingAnchors.push({ original, rebased, hash: anchor.hash });
|
|
549
|
+
}
|
|
575
550
|
warnings.push(
|
|
576
551
|
`Auto-rebased anchor ${original} → ${rebased}${anchor.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
|
|
577
552
|
);
|
|
@@ -580,198 +555,231 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
580
555
|
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
|
|
581
556
|
}
|
|
582
557
|
}
|
|
558
|
+
|
|
559
|
+
// Rebase cap: a single stale anchor (e.g. unrelated upstream edit) is fine,
|
|
560
|
+
// but multiple mutating anchors all rebasing is the signature of a miscounted
|
|
561
|
+
// block edit (agent stacked `Lid=X` ops over a contiguous range whose new
|
|
562
|
+
// length differs from the old). Refuse so the agent retries with the
|
|
563
|
+
// `-Lid`+`+TEXT` block-rewrite recipe instead of silently corrupting the file.
|
|
564
|
+
if (rebasedMutatingAnchors.length > 1) {
|
|
565
|
+
const detail = rebasedMutatingAnchors.map(r => `${r.original} → ${r.rebased}${r.hash}`).join(", ");
|
|
566
|
+
throw new Error(
|
|
567
|
+
`Refusing edit: ${rebasedMutatingAnchors.length} mutating anchors needed auto-rebase (${detail}). ` +
|
|
568
|
+
"This usually means a `Lid=X` chain was used to rewrite a contiguous block whose new length differs from the old. " +
|
|
569
|
+
"Rewrite the block by deleting each original line with `-Lid` (one per line) and emitting the new content as `+TEXT` lines.",
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Detect post-rebase conflicts. If any conflicting anchor was rebased, surface
|
|
574
|
+
// the original hash mismatch instead — the rebase itself is what created the
|
|
575
|
+
// conflict, and the model needs to fix the stale anchor, not deduplicate.
|
|
576
|
+
const seenLines = new Map<number, Anchor>();
|
|
577
|
+
for (const edit of edits) {
|
|
578
|
+
if (edit.kind !== "set" && edit.kind !== "delete") continue;
|
|
579
|
+
const existing = seenLines.get(edit.anchor.line);
|
|
580
|
+
if (existing) {
|
|
581
|
+
const rebasedA = rebasedAnchors.get(edit.anchor);
|
|
582
|
+
const rebasedB = rebasedAnchors.get(existing);
|
|
583
|
+
if (rebasedA) mismatches.push(rebasedA);
|
|
584
|
+
else if (rebasedB) mismatches.push(rebasedB);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
seenLines.set(edit.anchor.line, edit.anchor);
|
|
588
|
+
}
|
|
583
589
|
return mismatches;
|
|
584
590
|
}
|
|
585
591
|
|
|
586
|
-
function
|
|
587
|
-
// For each anchor line, at most one mutating op (splice/del). Multiple `sed`
|
|
588
|
-
// ops on the same line are allowed and applied sequentially. `pre`/`post`
|
|
589
|
-
// (insert ops) may coexist with them — they don't mutate the anchor line.
|
|
592
|
+
function validateNoConflictingAtomMutations(edits: AtomEdit[]): void {
|
|
590
593
|
const mutatingPerLine = new Map<number, string>();
|
|
591
594
|
for (const edit of edits) {
|
|
592
|
-
if (edit.
|
|
593
|
-
const existing = mutatingPerLine.get(edit.
|
|
595
|
+
if (edit.kind !== "set" && edit.kind !== "delete") continue;
|
|
596
|
+
const existing = mutatingPerLine.get(edit.anchor.line);
|
|
594
597
|
if (existing) {
|
|
595
|
-
if (existing === "sed" && edit.op === "sed") continue;
|
|
596
598
|
throw new Error(
|
|
597
|
-
`Conflicting ops on anchor line ${edit.
|
|
598
|
-
|
|
599
|
+
`Conflicting ops on anchor line ${edit.anchor.line}: \`${existing}\` and \`${edit.kind}\`. ` +
|
|
600
|
+
"At most one mutating op (set/delete) is allowed per anchor.",
|
|
599
601
|
);
|
|
600
602
|
}
|
|
601
|
-
mutatingPerLine.set(edit.
|
|
603
|
+
mutatingPerLine.set(edit.anchor.line, edit.kind);
|
|
602
604
|
}
|
|
603
605
|
}
|
|
604
606
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
editIndex: number;
|
|
611
|
-
loc: string;
|
|
612
|
-
reason: string;
|
|
613
|
-
current: string;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
interface SpliceBlockApplyResult {
|
|
617
|
-
text: string;
|
|
618
|
-
firstChangedLine: number | undefined;
|
|
607
|
+
function repairAtomOldNewSetLine(currentLine: string, nextLine: string): string {
|
|
608
|
+
const marker = `${currentLine}|`;
|
|
609
|
+
if (!nextLine.startsWith(marker)) return nextLine;
|
|
610
|
+
const repaired = nextLine.slice(marker.length);
|
|
611
|
+
return repaired.length > 0 ? repaired : nextLine;
|
|
619
612
|
}
|
|
620
613
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
while (offset < text.length && currentLine < line) {
|
|
627
|
-
if (text[offset] === "\n") currentLine++;
|
|
628
|
-
offset++;
|
|
614
|
+
function insertAtStart(fileLines: string[], lines: string[]): void {
|
|
615
|
+
if (lines.length === 0) return;
|
|
616
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
617
|
+
fileLines.splice(0, 1, ...lines);
|
|
618
|
+
return;
|
|
629
619
|
}
|
|
630
|
-
|
|
620
|
+
fileLines.splice(0, 0, ...lines);
|
|
631
621
|
}
|
|
632
622
|
|
|
633
|
-
function
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
623
|
+
function insertAtEnd(fileLines: string[], lines: string[]): number | undefined {
|
|
624
|
+
if (lines.length === 0) return undefined;
|
|
625
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
626
|
+
fileLines.splice(0, 1, ...lines);
|
|
627
|
+
return 1;
|
|
628
|
+
}
|
|
629
|
+
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
|
|
630
|
+
const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
631
|
+
fileLines.splice(insertIdx, 0, ...lines);
|
|
632
|
+
return insertIdx + 1;
|
|
637
633
|
}
|
|
638
634
|
|
|
639
|
-
function
|
|
640
|
-
|
|
641
|
-
return text[offset] === "\n" ? offset + 1 : offset;
|
|
635
|
+
function isSameFileCursor(a: AtomCursor, b: AtomCursor): boolean {
|
|
636
|
+
return a.kind === b.kind && a.kind !== "anchor";
|
|
642
637
|
}
|
|
643
638
|
|
|
644
|
-
function
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
case "right_incl":
|
|
656
|
-
return "anchor]";
|
|
657
|
-
case "right_excl":
|
|
658
|
-
return "anchor)";
|
|
639
|
+
function collectFileInsertRuns(
|
|
640
|
+
fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
|
|
641
|
+
): Array<{ cursor: AtomCursor; lines: string[] }> {
|
|
642
|
+
const runs: Array<{ cursor: AtomCursor; lines: string[] }> = [];
|
|
643
|
+
for (const edit of fileInserts.sort((a, b) => a.index - b.index)) {
|
|
644
|
+
const prev = runs[runs.length - 1];
|
|
645
|
+
if (prev && isSameFileCursor(prev.cursor, edit.cursor)) {
|
|
646
|
+
prev.lines.push(edit.text);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
runs.push({ cursor: edit.cursor, lines: [edit.text] });
|
|
659
650
|
}
|
|
651
|
+
return runs;
|
|
660
652
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
warnings: string[],
|
|
666
|
-
): SpliceBlockApplyResult {
|
|
667
|
-
// Sort by anchor line descending so applying earlier ops doesn't shift
|
|
668
|
-
// later anchors. (Multiple splice_block ops within one call are assumed
|
|
669
|
-
// non-overlapping; overlapping ranges are not supported.)
|
|
670
|
-
const sorted = [...edits].sort((a, b) => b.pos.line - a.pos.line);
|
|
671
|
-
const destStyle = detectIndentStyle(originalText);
|
|
672
|
-
let text = originalText;
|
|
653
|
+
function applyFileCursorInserts(
|
|
654
|
+
fileLines: string[],
|
|
655
|
+
fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
|
|
656
|
+
): number | undefined {
|
|
673
657
|
let firstChangedLine: number | undefined;
|
|
658
|
+
const trackFirstChanged = (line: number) => {
|
|
659
|
+
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
660
|
+
};
|
|
674
661
|
|
|
675
|
-
for (const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
662
|
+
for (const run of collectFileInsertRuns(fileInserts)) {
|
|
663
|
+
if (run.cursor.kind === "bof") {
|
|
664
|
+
insertAtStart(fileLines, run.lines);
|
|
665
|
+
trackFirstChanged(1);
|
|
666
|
+
continue;
|
|
680
667
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
`(${replacedLineCount} lines, 1 of ${found.enclosingCount} enclosing \`${kind}\` blocks).`,
|
|
685
|
-
);
|
|
686
|
-
const balanceErr = checkBodyBraceBalance(edit.spec.body.join("\n"), kind);
|
|
687
|
-
if (balanceErr) {
|
|
688
|
-
throw new Error(`splice at anchor ${edit.pos.line}: ${balanceErr}`);
|
|
668
|
+
if (run.cursor.kind === "eof") {
|
|
669
|
+
const changedLine = insertAtEnd(fileLines, run.lines);
|
|
670
|
+
if (changedLine !== undefined) trackFirstChanged(changedLine);
|
|
689
671
|
}
|
|
672
|
+
}
|
|
690
673
|
|
|
691
|
-
|
|
692
|
-
|
|
674
|
+
return firstChangedLine;
|
|
675
|
+
}
|
|
693
676
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
677
|
+
function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
|
|
678
|
+
if (edit.kind !== "insert") return edit.anchor;
|
|
679
|
+
if (edit.cursor.kind !== "anchor") {
|
|
680
|
+
throw new Error("Internal atom error: file-scoped insert reached anchor application.");
|
|
681
|
+
}
|
|
682
|
+
return edit.cursor.anchor;
|
|
683
|
+
}
|
|
697
684
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
replaceStart = found.bodyStart;
|
|
740
|
-
replaceEnd = found.bodyEnd;
|
|
741
|
-
break;
|
|
685
|
+
// Heuristic: detect (and when safe, auto-fix) lines that became adjacent
|
|
686
|
+
// duplicates of themselves after the edit, when they were not adjacent
|
|
687
|
+
// duplicates before. This is the signature of a botched block rewrite that
|
|
688
|
+
// missed one delete on the front or back of the deletion range, leaving a
|
|
689
|
+
// stale copy of a line the agent already re-emitted (e.g. inserting a new
|
|
690
|
+
// closing `}` while the original `}` was never deleted, producing `}\n}`).
|
|
691
|
+
//
|
|
692
|
+
// Auto-fix is gated on bracket balance: we only remove the duplicate line if
|
|
693
|
+
// its removal restores the original file's `{}`/`()`/`[]` delta. That makes
|
|
694
|
+
// the fix safe in the common case (a stray closing brace shifts balance by
|
|
695
|
+
// one) and conservative when the duplicate is intentional (balance unchanged
|
|
696
|
+
// → warning only). When two adjacent lines are textually identical, removing
|
|
697
|
+
// either yields the same content, so we don't have to decide which is "the
|
|
698
|
+
// stale copy" — we just remove one and verify balance restores.
|
|
699
|
+
function detectAndAutoFixDuplicates(
|
|
700
|
+
originalLines: string[],
|
|
701
|
+
finalLines: string[],
|
|
702
|
+
): { fixed: string[] | null; warnings: string[] } {
|
|
703
|
+
const countAdjacent = (lines: string[]): Map<string, number> => {
|
|
704
|
+
const counts = new Map<string, number>();
|
|
705
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
706
|
+
if (lines[i] !== lines[i + 1]) continue;
|
|
707
|
+
if (lines[i].trim().length === 0) continue;
|
|
708
|
+
counts.set(lines[i], (counts.get(lines[i]) ?? 0) + 1);
|
|
709
|
+
}
|
|
710
|
+
return counts;
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const computeBalance = (lines: string[]): { brace: number; paren: number; bracket: number } => {
|
|
714
|
+
let brace = 0;
|
|
715
|
+
let paren = 0;
|
|
716
|
+
let bracket = 0;
|
|
717
|
+
for (const line of lines) {
|
|
718
|
+
for (const ch of line) {
|
|
719
|
+
if (ch === "{") brace++;
|
|
720
|
+
else if (ch === "}") brace--;
|
|
721
|
+
else if (ch === "(") paren++;
|
|
722
|
+
else if (ch === ")") paren--;
|
|
723
|
+
else if (ch === "[") bracket++;
|
|
724
|
+
else if (ch === "]") bracket--;
|
|
742
725
|
}
|
|
743
726
|
}
|
|
727
|
+
return { brace, paren, bracket };
|
|
728
|
+
};
|
|
744
729
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
730
|
+
const balancesEqual = (
|
|
731
|
+
a: { brace: number; paren: number; bracket: number },
|
|
732
|
+
b: { brace: number; paren: number; bracket: number },
|
|
733
|
+
): boolean => a.brace === b.brace && a.paren === b.paren && a.bracket === b.bracket;
|
|
734
|
+
|
|
735
|
+
const orig = countAdjacent(originalLines);
|
|
736
|
+
const fin = countAdjacent(finalLines);
|
|
737
|
+
const newDupPositions: number[] = [];
|
|
738
|
+
for (let i = 0; i + 1 < finalLines.length; i++) {
|
|
739
|
+
if (finalLines[i] !== finalLines[i + 1]) continue;
|
|
740
|
+
if (finalLines[i].trim().length === 0) continue;
|
|
741
|
+
const text = finalLines[i];
|
|
742
|
+
if ((fin.get(text) ?? 0) <= (orig.get(text) ?? 0)) continue;
|
|
743
|
+
newDupPositions.push(i);
|
|
744
|
+
}
|
|
748
745
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
746
|
+
if (newDupPositions.length === 0) return { fixed: null, warnings: [] };
|
|
747
|
+
|
|
748
|
+
const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
|
|
749
|
+
|
|
750
|
+
// Auto-fix only when there is exactly one new adjacent duplicate AND the
|
|
751
|
+
// edit shifted bracket balance. Removing one of the two identical lines
|
|
752
|
+
// must restore the original delta exactly.
|
|
753
|
+
if (newDupPositions.length === 1) {
|
|
754
|
+
const pos = newDupPositions[0];
|
|
755
|
+
const origBalance = computeBalance(originalLines);
|
|
756
|
+
const finalBalance = computeBalance(finalLines);
|
|
757
|
+
if (!balancesEqual(origBalance, finalBalance)) {
|
|
758
|
+
const trial = finalLines.slice(0, pos).concat(finalLines.slice(pos + 1));
|
|
759
|
+
if (balancesEqual(computeBalance(trial), origBalance)) {
|
|
760
|
+
return {
|
|
761
|
+
fixed: trial,
|
|
762
|
+
warnings: [
|
|
763
|
+
`Auto-fixed: removed duplicate line ${pos + 1} (${formatPreview(finalLines[pos])}); the edit left two adjacent identical lines and bracket balance was off. Verify the result.`,
|
|
764
|
+
],
|
|
765
|
+
};
|
|
766
|
+
}
|
|
752
767
|
}
|
|
753
768
|
}
|
|
754
|
-
return { text, firstChangedLine };
|
|
755
|
-
}
|
|
756
769
|
|
|
757
|
-
|
|
758
|
-
|
|
770
|
+
const warnings = newDupPositions.slice(0, 3).map(pos => {
|
|
771
|
+
return `Suspicious duplicate: lines ${pos + 1} and ${pos + 2} are both ${formatPreview(finalLines[pos])}. The edit may have left a stale copy of a line you meant to replace — verify the result.`;
|
|
772
|
+
});
|
|
773
|
+
return { fixed: null, warnings };
|
|
759
774
|
}
|
|
760
775
|
|
|
761
|
-
export function applyAtomEdits(
|
|
762
|
-
text: string,
|
|
763
|
-
edits: AtomEdit[],
|
|
764
|
-
): {
|
|
765
|
-
lines: string;
|
|
766
|
-
firstChangedLine: number | undefined;
|
|
767
|
-
warnings?: string[];
|
|
768
|
-
noopEdits?: AtomNoopEdit[];
|
|
769
|
-
} {
|
|
776
|
+
export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult {
|
|
770
777
|
if (edits.length === 0) {
|
|
771
778
|
return { lines: text, firstChangedLine: undefined };
|
|
772
779
|
}
|
|
773
780
|
|
|
774
781
|
const fileLines = text.split("\n");
|
|
782
|
+
const originalLines = fileLines.slice();
|
|
775
783
|
const warnings: string[] = [];
|
|
776
784
|
let firstChangedLine: number | undefined;
|
|
777
785
|
const noopEdits: AtomNoopEdit[] = [];
|
|
@@ -780,75 +788,31 @@ export function applyAtomEdits(
|
|
|
780
788
|
if (mismatches.length > 0) {
|
|
781
789
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
782
790
|
}
|
|
783
|
-
|
|
784
|
-
// entries), the `del` is almost always a hallucinated cleanup the model added on top
|
|
785
|
-
// of the real replacement. Drop the `del` silently so the replacement wins, matching
|
|
786
|
-
// the in-entry handling for `splice: []` paired with `sed`.
|
|
787
|
-
const replacedLines = new Set<number>();
|
|
788
|
-
for (const e of edits) {
|
|
789
|
-
if (e.op === "splice" || e.op === "sed") replacedLines.add(e.pos.line);
|
|
790
|
-
}
|
|
791
|
-
let effective = edits;
|
|
792
|
-
if (replacedLines.size > 0) {
|
|
793
|
-
effective = edits.filter(e => !(e.op === "del" && replacedLines.has(e.pos.line)));
|
|
794
|
-
}
|
|
795
|
-
validateNoConflictingAnchorOps(effective);
|
|
796
|
-
|
|
797
|
-
// splice_block ops own their entire block range. To keep line numbers sane,
|
|
798
|
-
// they cannot mix with other anchor-scoped ops in the same call. They may
|
|
799
|
-
// coexist with each other (sorted by openLine descending so earlier ops
|
|
800
|
-
// don't shift later anchors).
|
|
801
|
-
const spliceBlockEdits = effective.filter(
|
|
802
|
-
(e): e is Extract<AtomEdit, { op: "splice_block" }> => e.op === "splice_block",
|
|
803
|
-
);
|
|
804
|
-
if (spliceBlockEdits.length > 0) {
|
|
805
|
-
const result = applySpliceBlockEdits(text, spliceBlockEdits, warnings);
|
|
806
|
-
if (result.firstChangedLine !== undefined) {
|
|
807
|
-
if (firstChangedLine === undefined || result.firstChangedLine < firstChangedLine) {
|
|
808
|
-
firstChangedLine = result.firstChangedLine;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
// Continue pipeline against the post-splice_block text.
|
|
812
|
-
fileLines.length = 0;
|
|
813
|
-
for (const line of result.text.split("\n")) fileLines.push(line);
|
|
814
|
-
effective = effective.filter(e => e.op !== "splice_block");
|
|
815
|
-
}
|
|
791
|
+
validateNoConflictingAtomMutations(edits);
|
|
816
792
|
|
|
817
793
|
const trackFirstChanged = (line: number) => {
|
|
818
|
-
if (firstChangedLine === undefined || line < firstChangedLine)
|
|
819
|
-
firstChangedLine = line;
|
|
820
|
-
}
|
|
794
|
+
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
821
795
|
};
|
|
822
796
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
|
|
832
|
-
effective.forEach((edit, idx) => {
|
|
833
|
-
if (edit.op === "append_file") appendEdits.push({ edit, idx });
|
|
834
|
-
else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
|
|
835
|
-
else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
|
|
836
|
-
else anchorEdits.push({ edit, idx });
|
|
797
|
+
const anchorEdits: IndexedAnchorEdit[] = [];
|
|
798
|
+
const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
|
|
799
|
+
edits.forEach((edit, idx) => {
|
|
800
|
+
if (edit.kind === "insert" && edit.cursor.kind !== "anchor") {
|
|
801
|
+
fileInserts.push(edit);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
anchorEdits.push({ edit, idx });
|
|
837
805
|
});
|
|
838
806
|
|
|
839
|
-
|
|
840
|
-
// single splice. This makes the per-anchor outcome independent of index
|
|
841
|
-
// shifts caused by sibling ops (e.g. `post` paired with `del` on the same
|
|
842
|
-
// anchor, or repeated `pre`/`post` inserts that previously reversed).
|
|
843
|
-
const byLine = new Map<number, Indexed<AnchorEdit>[]>();
|
|
807
|
+
const byLine = new Map<number, IndexedAnchorEdit[]>();
|
|
844
808
|
for (const entry of anchorEdits) {
|
|
845
|
-
const line = entry.edit.
|
|
846
|
-
|
|
847
|
-
if (
|
|
848
|
-
bucket
|
|
849
|
-
|
|
809
|
+
const line = getAnchorForAnchorEdit(entry.edit).line;
|
|
810
|
+
const bucket = byLine.get(line);
|
|
811
|
+
if (bucket) {
|
|
812
|
+
bucket.push(entry);
|
|
813
|
+
} else {
|
|
814
|
+
byLine.set(line, [entry]);
|
|
850
815
|
}
|
|
851
|
-
bucket.push(entry);
|
|
852
816
|
}
|
|
853
817
|
|
|
854
818
|
const anchorLines = [...byLine.keys()].sort((a, b) => b - a);
|
|
@@ -862,237 +826,454 @@ export function applyAtomEdits(
|
|
|
862
826
|
let replacement: string[] = [currentLine];
|
|
863
827
|
let replacementSet = false;
|
|
864
828
|
let anchorMutated = false;
|
|
865
|
-
let anchorDeleted = false;
|
|
866
|
-
const beforeLines: string[] = [];
|
|
867
829
|
const afterLines: string[] = [];
|
|
868
830
|
|
|
869
831
|
for (const { edit } of bucket) {
|
|
870
|
-
switch (edit.
|
|
871
|
-
case "
|
|
872
|
-
|
|
873
|
-
break;
|
|
874
|
-
case "post":
|
|
875
|
-
afterLines.push(...edit.lines);
|
|
832
|
+
switch (edit.kind) {
|
|
833
|
+
case "insert":
|
|
834
|
+
afterLines.push(edit.text);
|
|
876
835
|
break;
|
|
877
|
-
case "
|
|
878
|
-
replacement = [];
|
|
879
|
-
replacementSet = true;
|
|
880
|
-
anchorDeleted = true;
|
|
881
|
-
break;
|
|
882
|
-
case "splice":
|
|
883
|
-
replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
|
|
836
|
+
case "set":
|
|
837
|
+
replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
|
|
884
838
|
replacementSet = true;
|
|
885
839
|
anchorMutated = true;
|
|
886
840
|
break;
|
|
887
|
-
case "
|
|
888
|
-
|
|
889
|
-
const { result, matched, error, literalFallback } = applySedToLine(input, edit.spec);
|
|
890
|
-
if (error) {
|
|
891
|
-
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${error}`);
|
|
892
|
-
}
|
|
893
|
-
if (!matched) {
|
|
841
|
+
case "delete":
|
|
842
|
+
if (edit.oldAssertion !== undefined && edit.oldAssertion !== currentLine) {
|
|
894
843
|
throw new Error(
|
|
895
|
-
`
|
|
844
|
+
`Diff line ${edit.lineNum}: \`-${edit.anchor.line}${edit.anchor.hash}\` asserts the deleted line is ${JSON.stringify(edit.oldAssertion)}, but the file has ${JSON.stringify(currentLine)}. Re-anchor and retry.`,
|
|
896
845
|
);
|
|
897
846
|
}
|
|
898
|
-
|
|
899
|
-
warnings.push(
|
|
900
|
-
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Escape regex metacharacters in the pattern to match as a regex.`,
|
|
901
|
-
);
|
|
902
|
-
}
|
|
903
|
-
replacement = [result];
|
|
847
|
+
replacement = [];
|
|
904
848
|
replacementSet = true;
|
|
905
849
|
anchorMutated = true;
|
|
906
850
|
break;
|
|
907
|
-
}
|
|
908
851
|
}
|
|
909
852
|
}
|
|
910
853
|
|
|
911
|
-
const noOp = !replacementSet && beforeLines.length === 0 && afterLines.length === 0;
|
|
912
|
-
if (noOp) continue;
|
|
913
|
-
|
|
914
|
-
const originalLine = fileLines[idx];
|
|
915
854
|
const replacementProducesNoChange =
|
|
916
|
-
|
|
917
|
-
afterLines.length === 0 &&
|
|
918
|
-
replacement.length === 1 &&
|
|
919
|
-
replacement[0] === originalLine;
|
|
855
|
+
afterLines.length === 0 && replacement.length === 1 && replacement[0] === currentLine;
|
|
920
856
|
if (replacementProducesNoChange) {
|
|
921
857
|
const firstEdit = bucket[0]?.edit;
|
|
922
|
-
const
|
|
923
|
-
const reason = "replacement is identical to the current line content";
|
|
858
|
+
const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
|
|
924
859
|
noopEdits.push({
|
|
925
860
|
editIndex: bucket[0]?.idx ?? 0,
|
|
926
|
-
loc
|
|
927
|
-
reason
|
|
928
|
-
|
|
861
|
+
loc: anchor ? `${anchor.line}${anchor.hash}` : `${line}`,
|
|
862
|
+
reason:
|
|
863
|
+
firstEdit?.kind === "set"
|
|
864
|
+
? "replacement is identical to the current line content; use `Lid=NEW_TEXT` and do not copy an unchanged read line"
|
|
865
|
+
: "replacement is identical to the current line content",
|
|
866
|
+
current: currentLine,
|
|
929
867
|
});
|
|
930
868
|
continue;
|
|
931
869
|
}
|
|
932
870
|
|
|
933
|
-
const combined = [...
|
|
871
|
+
const combined = [...replacement, ...afterLines];
|
|
934
872
|
fileLines.splice(idx, 1, ...combined);
|
|
935
|
-
|
|
936
|
-
if (beforeLines.length > 0 || anchorMutated || anchorDeleted) {
|
|
873
|
+
if (anchorMutated) {
|
|
937
874
|
trackFirstChanged(line);
|
|
938
875
|
} else if (afterLines.length > 0) {
|
|
939
876
|
trackFirstChanged(line + 1);
|
|
940
877
|
}
|
|
878
|
+
if (!replacementSet && afterLines.length === 0) continue;
|
|
941
879
|
}
|
|
942
880
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
} else {
|
|
951
|
-
// Insert in reverse cumulative order so later splices push earlier
|
|
952
|
-
// content further down, preserving the original op order.
|
|
953
|
-
fileLines.splice(0, 0, ...edit.lines);
|
|
954
|
-
}
|
|
955
|
-
trackFirstChanged(1);
|
|
881
|
+
const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
|
|
882
|
+
if (fileFirstChangedLine !== undefined) trackFirstChanged(fileFirstChangedLine);
|
|
883
|
+
|
|
884
|
+
const dupCheck = detectAndAutoFixDuplicates(originalLines, fileLines);
|
|
885
|
+
if (dupCheck.fixed !== null) {
|
|
886
|
+
fileLines.length = 0;
|
|
887
|
+
fileLines.push(...dupCheck.fixed);
|
|
956
888
|
}
|
|
889
|
+
for (const w of dupCheck.warnings) warnings.push(w);
|
|
957
890
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
891
|
+
return {
|
|
892
|
+
lines: fileLines.join("\n"),
|
|
893
|
+
firstChangedLine,
|
|
894
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
895
|
+
...(noopEdits.length > 0 && firstChangedLine === undefined ? { noopEdits } : {}),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
900
|
+
// Wire-format split: extract `---` headers from the input string.
|
|
901
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
902
|
+
|
|
903
|
+
const FILE_HEADER_PREFIX = "---";
|
|
904
|
+
const REMOVE_FILE_OPERATION = "!rm";
|
|
905
|
+
const MOVE_FILE_OPERATION = "!mv";
|
|
906
|
+
|
|
907
|
+
type AtomWholeFileOperation =
|
|
908
|
+
| { kind: "delete"; lineNum: number }
|
|
909
|
+
| { kind: "move"; destination: string; lineNum: number };
|
|
910
|
+
|
|
911
|
+
interface AtomInputSection {
|
|
912
|
+
path: string;
|
|
913
|
+
diff: string;
|
|
914
|
+
wholeFileOperation?: AtomWholeFileOperation;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export interface SplitAtomOptions {
|
|
918
|
+
cwd?: string;
|
|
919
|
+
path?: string;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function isBlankHeaderPreamble(line: string): boolean {
|
|
923
|
+
return line.replace(/\r$/, "").trim().length === 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function unquoteAtomPath(pathText: string): string {
|
|
927
|
+
if (pathText.length < 2) return pathText;
|
|
928
|
+
const first = pathText[0];
|
|
929
|
+
const last = pathText[pathText.length - 1];
|
|
930
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
931
|
+
return pathText.slice(1, -1);
|
|
932
|
+
}
|
|
933
|
+
return pathText;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function normalizeAtomPath(rawPath: string, cwd?: string): string {
|
|
937
|
+
const unquoted = unquoteAtomPath(rawPath.trim());
|
|
938
|
+
if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
|
|
939
|
+
|
|
940
|
+
const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
|
|
941
|
+
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
942
|
+
return isWithinCwd ? relative || "." : unquoted;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function parseAtomHeaderLine(line: string, cwd?: string): string | null {
|
|
946
|
+
if (!line.startsWith(FILE_HEADER_PREFIX)) return null;
|
|
947
|
+
let body = line.slice(FILE_HEADER_PREFIX.length);
|
|
948
|
+
if (body.startsWith(" ")) body = body.slice(1);
|
|
949
|
+
const parsedPath = normalizeAtomPath(body, cwd);
|
|
950
|
+
if (parsedPath.length === 0) {
|
|
951
|
+
throw new Error(`atom input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
|
952
|
+
}
|
|
953
|
+
return parsedPath;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
|
|
957
|
+
const trimmed = rawPath.trim();
|
|
958
|
+
if (trimmed.length === 0) {
|
|
959
|
+
throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const quote = trimmed[0];
|
|
963
|
+
if (quote === '"' || quote === "'") {
|
|
964
|
+
if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
|
|
965
|
+
throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
|
|
968
966
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
fileLines.splice(insertIdx, 0, ...edit.lines);
|
|
972
|
-
trackFirstChanged(insertIdx + 1);
|
|
967
|
+
} else if (/\s/.test(trimmed)) {
|
|
968
|
+
throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
|
|
973
969
|
}
|
|
974
970
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
971
|
+
const destination = normalizeAtomPath(trimmed, cwd);
|
|
972
|
+
if (destination.length === 0) {
|
|
973
|
+
throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
|
|
974
|
+
}
|
|
975
|
+
return destination;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function parseAtomWholeFileOperationLine(
|
|
979
|
+
rawLine: string,
|
|
980
|
+
lineNum: number,
|
|
981
|
+
cwd?: string,
|
|
982
|
+
): AtomWholeFileOperation | null {
|
|
983
|
+
const line = rawLine.replace(/\r$/, "").trimEnd();
|
|
984
|
+
if (line === REMOVE_FILE_OPERATION) {
|
|
985
|
+
return { kind: "delete", lineNum };
|
|
986
|
+
}
|
|
987
|
+
if (line.startsWith(`${REMOVE_FILE_OPERATION} `) || line.startsWith(`${REMOVE_FILE_OPERATION}\t`)) {
|
|
988
|
+
throw new Error(`Atom line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (line === MOVE_FILE_OPERATION) {
|
|
992
|
+
throw new Error(`Atom line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
|
|
993
|
+
}
|
|
994
|
+
if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
|
|
995
|
+
const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
|
|
996
|
+
return {
|
|
997
|
+
kind: "move",
|
|
998
|
+
destination: parseSingleAtomPathArgument(rawDestination, MOVE_FILE_OPERATION, lineNum, cwd),
|
|
999
|
+
lineNum,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function getAtomWholeFileOperation(
|
|
1007
|
+
sectionPath: string,
|
|
1008
|
+
lines: string[],
|
|
1009
|
+
cwd?: string,
|
|
1010
|
+
): AtomWholeFileOperation | undefined {
|
|
1011
|
+
let operation: AtomWholeFileOperation | undefined;
|
|
1012
|
+
let operationToken = "";
|
|
1013
|
+
let hasLineEdit = false;
|
|
1014
|
+
|
|
1015
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1016
|
+
const lineNum = i + 1;
|
|
1017
|
+
const line = lines[i].replace(/\r$/, "");
|
|
1018
|
+
if (line.trim().length === 0) continue;
|
|
1019
|
+
|
|
1020
|
+
const parsed = parseAtomWholeFileOperationLine(line, lineNum, cwd);
|
|
1021
|
+
if (parsed) {
|
|
1022
|
+
if (operation) {
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`Atom section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
|
|
993
1025
|
);
|
|
994
|
-
warnedLiteralFallback = true;
|
|
995
|
-
}
|
|
996
|
-
if (r.result !== line) {
|
|
997
|
-
fileLines[i] = r.result;
|
|
998
|
-
trackFirstChanged(i + 1);
|
|
999
1026
|
}
|
|
1027
|
+
operation = parsed;
|
|
1028
|
+
operationToken = parsed.kind === "delete" ? REMOVE_FILE_OPERATION : MOVE_FILE_OPERATION;
|
|
1029
|
+
continue;
|
|
1000
1030
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${lastCompileError}`);
|
|
1004
|
-
}
|
|
1005
|
-
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
|
|
1006
|
-
}
|
|
1031
|
+
|
|
1032
|
+
hasLineEdit = true;
|
|
1007
1033
|
}
|
|
1008
1034
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1035
|
+
if (operation && hasLineEdit) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
`Atom section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return operation;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function hasAtomHeaderLine(input: string): boolean {
|
|
1045
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
1046
|
+
return stripped.split("\n").some(rawLine => rawLine.replace(/\r$/, "").startsWith(FILE_HEADER_PREFIX));
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function containsRecognizableAtomOperations(input: string): boolean {
|
|
1050
|
+
for (const rawLine of input.split("\n")) {
|
|
1051
|
+
const line = rawLine.replace(/\r$/, "");
|
|
1052
|
+
if (line.length === 0) continue;
|
|
1053
|
+
if (line[0] === "+") return true;
|
|
1054
|
+
if (line === "$" || line === "^") return true;
|
|
1055
|
+
if (/^- ?[1-9]\d*[a-z]{2}(?: .*)?$/.test(line)) return true;
|
|
1056
|
+
if (/^@?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?$/.test(line)) return true;
|
|
1057
|
+
if (/^@@ (?:BOF|EOF|(?:- ?)?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?)$/.test(line)) return true;
|
|
1058
|
+
}
|
|
1059
|
+
return false;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function stripLeadingBlankLines(input: string): string {
|
|
1063
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
1064
|
+
const lines = stripped.split("\n");
|
|
1065
|
+
while (lines.length > 0 && isBlankHeaderPreamble(lines[0] ?? "")) {
|
|
1066
|
+
lines.shift();
|
|
1067
|
+
}
|
|
1068
|
+
return lines.join("\n");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
|
|
1072
|
+
if (hasAtomHeaderLine(input) || !options.path || !containsRecognizableAtomOperations(input)) {
|
|
1073
|
+
return input;
|
|
1074
|
+
}
|
|
1075
|
+
const fallbackPath = normalizeAtomPath(options.path, options.cwd);
|
|
1076
|
+
if (fallbackPath.length === 0) return input;
|
|
1077
|
+
return `${FILE_HEADER_PREFIX}${fallbackPath}\n${input}`;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function getTextContent(result: AgentToolResult<EditToolDetails>): string {
|
|
1081
|
+
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
|
|
1085
|
+
if (result.details === undefined) {
|
|
1086
|
+
return { diff: "" };
|
|
1087
|
+
}
|
|
1088
|
+
return result.details;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Split the wire-format `input` string into `{ path, diff }`. The first
|
|
1093
|
+
* non-empty line MUST be `---<path>` or `--- <path>`. Tolerates a leading BOM.
|
|
1094
|
+
*/
|
|
1095
|
+
export function splitAtomInput(input: string, options: SplitAtomOptions = {}): { path: string; diff: string } {
|
|
1096
|
+
const [section] = splitAtomInputs(input, options);
|
|
1097
|
+
return section;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export function splitAtomInputs(input: string, options: SplitAtomOptions = {}): AtomInputSection[] {
|
|
1101
|
+
const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
|
|
1102
|
+
const lines = stripped.split("\n");
|
|
1103
|
+
const firstLine = (lines[0] ?? "").replace(/\r$/, "");
|
|
1104
|
+
if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
|
|
1105
|
+
throw new Error(
|
|
1106
|
+
`atom input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${JSON.stringify(
|
|
1107
|
+
firstLine.slice(0, 120),
|
|
1108
|
+
)}`,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const sections: AtomInputSection[] = [];
|
|
1113
|
+
let currentPath = "";
|
|
1114
|
+
let currentLines: string[] = [];
|
|
1115
|
+
const flush = () => {
|
|
1116
|
+
if (currentPath.length === 0) return;
|
|
1117
|
+
const wholeFileOperation = getAtomWholeFileOperation(currentPath, currentLines, options.cwd);
|
|
1118
|
+
sections.push({
|
|
1119
|
+
path: currentPath,
|
|
1120
|
+
diff: currentLines.join("\n"),
|
|
1121
|
+
...(wholeFileOperation ? { wholeFileOperation } : {}),
|
|
1122
|
+
});
|
|
1123
|
+
currentLines = [];
|
|
1014
1124
|
};
|
|
1125
|
+
|
|
1126
|
+
for (const rawLine of lines) {
|
|
1127
|
+
const line = rawLine.replace(/\r$/, "");
|
|
1128
|
+
const headerPath = parseAtomHeaderLine(line, options.cwd);
|
|
1129
|
+
if (headerPath !== null) {
|
|
1130
|
+
flush();
|
|
1131
|
+
currentPath = headerPath;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
currentLines.push(rawLine);
|
|
1135
|
+
}
|
|
1136
|
+
flush();
|
|
1137
|
+
return sections;
|
|
1015
1138
|
}
|
|
1016
1139
|
|
|
1017
|
-
//
|
|
1140
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1018
1141
|
// Executor
|
|
1019
1142
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1020
1143
|
|
|
1021
1144
|
export interface ExecuteAtomSingleOptions {
|
|
1022
1145
|
session: ToolSession;
|
|
1023
|
-
|
|
1024
|
-
|
|
1146
|
+
input: string;
|
|
1147
|
+
path?: string;
|
|
1025
1148
|
signal?: AbortSignal;
|
|
1026
1149
|
batchRequest?: LspBatchRequest;
|
|
1027
1150
|
writethrough: WritethroughCallback;
|
|
1028
1151
|
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
1029
1152
|
}
|
|
1030
1153
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1154
|
+
interface ReadAtomFileResult {
|
|
1155
|
+
exists: boolean;
|
|
1156
|
+
rawContent: string;
|
|
1157
|
+
}
|
|
1035
1158
|
|
|
1036
|
-
|
|
1159
|
+
async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
|
|
1160
|
+
try {
|
|
1161
|
+
return { exists: true, rawContent: await Bun.file(absolutePath).text() };
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (isEnoent(error)) return { exists: false, rawContent: "" };
|
|
1164
|
+
throw error;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1037
1167
|
|
|
1038
|
-
|
|
1168
|
+
function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
|
|
1169
|
+
return edits.some(edit => edit.kind === "set" || edit.kind === "delete" || edit.cursor.kind === "anchor");
|
|
1170
|
+
}
|
|
1039
1171
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1172
|
+
function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
|
|
1173
|
+
let diagnostic = `Edits to ${path} resulted in no changes being made.`;
|
|
1174
|
+
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
1175
|
+
const details = result.noopEdits
|
|
1176
|
+
.map(e => {
|
|
1177
|
+
const preview =
|
|
1178
|
+
e.current.length > 0
|
|
1179
|
+
? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
|
|
1180
|
+
: "";
|
|
1181
|
+
return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
|
|
1182
|
+
})
|
|
1183
|
+
.join("\n");
|
|
1184
|
+
diagnostic += `\n${details}`;
|
|
1042
1185
|
}
|
|
1186
|
+
return diagnostic;
|
|
1187
|
+
}
|
|
1043
1188
|
|
|
1044
|
-
|
|
1189
|
+
async function executeAtomWholeFileOperation(
|
|
1190
|
+
options: ExecuteAtomSingleOptions & AtomInputSection & { wholeFileOperation: AtomWholeFileOperation },
|
|
1191
|
+
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
1192
|
+
const { session, path: sectionPath, wholeFileOperation } = options;
|
|
1193
|
+
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
1045
1194
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
if (!sourceExists) {
|
|
1050
|
-
const lines: string[] = [];
|
|
1051
|
-
for (const edit of contentEdits) {
|
|
1052
|
-
if (edit.op === "append_file") {
|
|
1053
|
-
lines.push(...edit.lines);
|
|
1054
|
-
} else if (edit.op === "prepend_file") {
|
|
1055
|
-
lines.unshift(...edit.lines);
|
|
1056
|
-
} else {
|
|
1057
|
-
throw new Error(`File not found: ${path}`);
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1195
|
+
if (sectionPath.endsWith(".ipynb")) {
|
|
1196
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1197
|
+
}
|
|
1060
1198
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1199
|
+
if (wholeFileOperation.kind === "delete") {
|
|
1200
|
+
enforcePlanModeWrite(session, sectionPath, { op: "delete" });
|
|
1201
|
+
await assertEditableFile(absolutePath, sectionPath);
|
|
1202
|
+
try {
|
|
1203
|
+
await fs.unlink(absolutePath);
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
|
|
1206
|
+
throw error;
|
|
1207
|
+
}
|
|
1208
|
+
invalidateFsScanAfterDelete(absolutePath);
|
|
1063
1209
|
return {
|
|
1064
|
-
content: [{ type: "text", text: `
|
|
1065
|
-
details: {
|
|
1066
|
-
diff: "",
|
|
1067
|
-
op: "create",
|
|
1068
|
-
meta: outputMeta().get(),
|
|
1069
|
-
},
|
|
1210
|
+
content: [{ type: "text", text: `Deleted ${sectionPath}` }],
|
|
1211
|
+
details: { diff: "", op: "delete", meta: outputMeta().get() },
|
|
1070
1212
|
};
|
|
1071
1213
|
}
|
|
1072
1214
|
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1215
|
+
const destinationPath = wholeFileOperation.destination;
|
|
1216
|
+
if (destinationPath.endsWith(".ipynb")) {
|
|
1217
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
|
|
1221
|
+
const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
|
|
1222
|
+
if (absoluteDestinationPath === absolutePath) {
|
|
1223
|
+
throw new Error("rename path is the same as source path");
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
await assertEditableFile(absolutePath, sectionPath);
|
|
1227
|
+
try {
|
|
1228
|
+
await fs.mkdir(path.dirname(absoluteDestinationPath), { recursive: true });
|
|
1229
|
+
await fs.rename(absolutePath, absoluteDestinationPath);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
|
|
1232
|
+
throw error;
|
|
1233
|
+
}
|
|
1234
|
+
invalidateFsScanAfterRename(absolutePath, absoluteDestinationPath);
|
|
1235
|
+
|
|
1236
|
+
return {
|
|
1237
|
+
content: [{ type: "text", text: `Moved ${sectionPath} to ${destinationPath}` }],
|
|
1238
|
+
details: { diff: "", op: "update", move: destinationPath, meta: outputMeta().get() },
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async function executeAtomSection(
|
|
1243
|
+
options: ExecuteAtomSingleOptions & AtomInputSection,
|
|
1244
|
+
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
1245
|
+
const { session, path, diff, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
1246
|
+
if (options.wholeFileOperation) {
|
|
1247
|
+
return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const edits = parseAtom(diff);
|
|
1251
|
+
if (edits.length === 0 && diff.trim().length > 0) {
|
|
1252
|
+
throw new Error(formatNoAtomEditDiagnostic(path, diff));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
enforcePlanModeWrite(session, path, { op: "update" });
|
|
1256
|
+
|
|
1257
|
+
if (path.endsWith(".ipynb") && edits.length > 0) {
|
|
1258
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const absolutePath = resolvePlanPath(session, path);
|
|
1262
|
+
const source = await readAtomFile(absolutePath);
|
|
1263
|
+
if (!source.exists && hasAnchorScopedEdit(edits)) {
|
|
1264
|
+
throw new Error(`File not found: ${path}`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (source.exists) {
|
|
1268
|
+
assertEditableFileContent(source.rawContent, path);
|
|
1269
|
+
}
|
|
1075
1270
|
|
|
1076
|
-
const { bom, text } = stripBom(rawContent);
|
|
1271
|
+
const { bom, text } = stripBom(source.rawContent);
|
|
1077
1272
|
const originalEnding = detectLineEnding(text);
|
|
1078
1273
|
const originalNormalized = normalizeToLF(text);
|
|
1079
|
-
|
|
1080
|
-
const result = applyAtomEdits(originalNormalized, contentEdits);
|
|
1274
|
+
const result = applyAtomEdits(originalNormalized, edits);
|
|
1081
1275
|
if (originalNormalized === result.lines) {
|
|
1082
|
-
|
|
1083
|
-
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
1084
|
-
const details = result.noopEdits
|
|
1085
|
-
.map(e => {
|
|
1086
|
-
const preview =
|
|
1087
|
-
e.current.length > 0
|
|
1088
|
-
? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
|
|
1089
|
-
: "";
|
|
1090
|
-
return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
|
|
1091
|
-
})
|
|
1092
|
-
.join("\n");
|
|
1093
|
-
diagnostic += `\n${details}`;
|
|
1094
|
-
}
|
|
1095
|
-
throw new Error(diagnostic);
|
|
1276
|
+
throw new Error(formatNoChangeDiagnostic(path, result));
|
|
1096
1277
|
}
|
|
1097
1278
|
|
|
1098
1279
|
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
|
|
@@ -1110,31 +1291,66 @@ export async function executeAtomSingle(
|
|
|
1110
1291
|
const meta = outputMeta()
|
|
1111
1292
|
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
1112
1293
|
.get();
|
|
1113
|
-
|
|
1114
|
-
const resultText = `Updated ${path}`;
|
|
1115
1294
|
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
1116
|
-
const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${
|
|
1117
|
-
preview.preview ? "" : " (no textual diff preview)"
|
|
1118
|
-
}`;
|
|
1119
1295
|
const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
1120
|
-
const previewBlock = preview.preview ? `\n
|
|
1296
|
+
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
1297
|
+
const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
|
|
1121
1298
|
|
|
1122
1299
|
return {
|
|
1123
1300
|
content: [
|
|
1124
1301
|
{
|
|
1125
1302
|
type: "text",
|
|
1126
|
-
text: `${resultText}
|
|
1303
|
+
text: `${resultText}${previewBlock}${warningsBlock}`,
|
|
1127
1304
|
},
|
|
1128
1305
|
],
|
|
1129
1306
|
details: {
|
|
1130
1307
|
diff: diffResult.diff,
|
|
1131
1308
|
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
|
1132
1309
|
diagnostics,
|
|
1133
|
-
op: "update",
|
|
1310
|
+
op: source.exists ? "update" : "create",
|
|
1134
1311
|
meta,
|
|
1135
1312
|
},
|
|
1136
1313
|
};
|
|
1137
1314
|
}
|
|
1138
1315
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1316
|
+
export async function executeAtomSingle(
|
|
1317
|
+
options: ExecuteAtomSingleOptions,
|
|
1318
|
+
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
1319
|
+
const sections = splitAtomInputs(options.input, { cwd: options.session.cwd, path: options.path });
|
|
1320
|
+
if (sections.length === 1) {
|
|
1321
|
+
const [section] = sections;
|
|
1322
|
+
return executeAtomSection({ ...options, ...section });
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const results = [];
|
|
1326
|
+
for (const section of sections) {
|
|
1327
|
+
results.push({
|
|
1328
|
+
path: section.path,
|
|
1329
|
+
result: await executeAtomSection({ ...options, ...section }),
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
return {
|
|
1334
|
+
content: [
|
|
1335
|
+
{
|
|
1336
|
+
type: "text",
|
|
1337
|
+
text: results.map(({ result }) => getTextContent(result)).join("\n\n"),
|
|
1338
|
+
},
|
|
1339
|
+
],
|
|
1340
|
+
details: {
|
|
1341
|
+
diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
|
|
1342
|
+
perFileResults: results.map(({ path, result }) => {
|
|
1343
|
+
const details = getEditDetails(result);
|
|
1344
|
+
return {
|
|
1345
|
+
path,
|
|
1346
|
+
diff: details.diff,
|
|
1347
|
+
firstChangedLine: details.firstChangedLine,
|
|
1348
|
+
diagnostics: details.diagnostics,
|
|
1349
|
+
op: details.op,
|
|
1350
|
+
move: details.move,
|
|
1351
|
+
meta: details.meta,
|
|
1352
|
+
};
|
|
1353
|
+
}),
|
|
1354
|
+
},
|
|
1355
|
+
};
|
|
1356
|
+
}
|