@oh-my-pi/pi-coding-agent 15.2.3 → 15.2.4

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.
@@ -1,8 +1,17 @@
1
1
  import { ABORT_MARKER, ABORT_WARNING, BEGIN_PATCH_MARKER, END_PATCH_MARKER, RANGE_INTERIOR_HASH } from "./constants";
2
- import { describeAnchorExamples, HL_EDIT_SEP, HL_HASH_CAPTURE_RE_RAW } from "./hash";
2
+ import {
3
+ describeAnchorExamples,
4
+ HL_FILE_PREFIX,
5
+ HL_HASH_CAPTURE_RE_RAW,
6
+ HL_OP_CHARS,
7
+ HL_OP_INSERT_AFTER,
8
+ HL_OP_INSERT_BEFORE,
9
+ HL_OP_REPLACE,
10
+ } from "./hash";
3
11
  import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
4
12
 
5
13
  const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
14
+ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
15
 
7
16
  function parseLid(raw: string, lineNum: number): Anchor {
8
17
  const match = LID_CAPTURE_RE.exec(raw);
@@ -22,12 +31,8 @@ interface ParsedRange {
22
31
 
23
32
  function parseRange(raw: string, lineNum: number): ParsedRange {
24
33
  if (!raw.includes("..")) {
25
- throw new Error(
26
- `line ${lineNum}: explicit ranges are required for delete/replace. ` +
27
- `Repeat the same anchor on both sides for a one-line edit (for example, ` +
28
- `${describeAnchorExamples("119")}..${describeAnchorExamples("119")}); ` +
29
- `got ${JSON.stringify(raw)}.`,
30
- );
34
+ const start = parseLid(raw, lineNum);
35
+ return { start, end: { ...start } };
31
36
  }
32
37
  const [startRaw, endRaw, extra] = raw.split("..");
33
38
  if (extra !== undefined || !startRaw || !endRaw) {
@@ -64,157 +69,61 @@ function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after
64
69
  return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
65
70
  }
66
71
 
67
- const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
68
- const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
69
- const DELETE_OP_RE = /^-\s*(\S+)$/;
70
- const REPLACE_OP_RE = /^=\s*(\S+)$/;
72
+ const INSERT_BEFORE_OP_RE = new RegExp(`^${regexEscape(HL_OP_INSERT_BEFORE)}\\s*(\\S+)\\s*$`);
73
+ const INSERT_AFTER_OP_RE = new RegExp(`^${regexEscape(HL_OP_INSERT_AFTER)}\\s*(\\S+)\\s*$`);
74
+ const REPLACE_OP_RE = new RegExp(`^${regexEscape(HL_OP_REPLACE)}\\s*([^\\s+<\\-=]\\S*)\\s*$`);
75
+
76
+ function isEnvelopeOrAbortMarkerLine(line: string): boolean {
77
+ const trimmed = line.trimEnd();
78
+ return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER;
79
+ }
80
+
81
+ function isPayloadTerminatorLine(line: string): boolean {
82
+ const first = line[0];
83
+ return (
84
+ first === HL_FILE_PREFIX ||
85
+ (first !== undefined && HL_OP_CHARS.includes(first)) ||
86
+ isEnvelopeOrAbortMarkerLine(line)
87
+ );
88
+ }
71
89
 
72
90
  export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
73
91
  if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
74
92
  if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
75
93
  return cursor;
76
94
  }
77
- /**
78
- * Returns true when every non-empty payload line looks like the `~ TEXT` readability-padding
79
- * typo: exactly one leading space followed by a non-space character (or a bare single space).
80
- *
81
- * Indented file content (Python 4-space, YAML/JSON/Markdown 2-space, etc.) starts with two or
82
- * more leading spaces, so this heuristic ignores legitimate indentation while still flagging
83
- * the common `~ beta` mistake that silently corrupts file content with a stray space.
84
- */
85
- function hasUniformSeparatorPadding(payload: string[]): boolean {
86
- let any = false;
87
- for (const text of payload) {
88
- if (text.length === 0) continue;
89
- if (text.charCodeAt(0) !== 0x20) return false;
90
- // Two or more leading spaces is real indentation, not separator padding.
91
- if (text.length > 1 && text.charCodeAt(1) === 0x20) return false;
92
- any = true;
93
- }
94
- return any;
95
- }
96
-
97
- /**
98
- * File extensions where leading single-space indentation is plausible legitimate file content
99
- * (off-side-rule languages, structured-indent data formats, prose with continuation indent).
100
- * For these we suppress the separator-padding warning entirely — the heuristic's false-positive
101
- * cost on a real edit outweighs the rare chance it catches a `~ TEXT` typo.
102
- */
103
- const INDENT_SENSITIVE_EXTS: Record<string, true> = {
104
- ".py": true,
105
- ".pyi": true,
106
- ".pyx": true,
107
- ".pyw": true,
108
- ".yml": true,
109
- ".yaml": true,
110
- ".md": true,
111
- ".mdx": true,
112
- ".markdown": true,
113
- ".rst": true,
114
- ".adoc": true,
115
- ".asciidoc": true,
116
- ".toml": true,
117
- ".json": true,
118
- ".jsonc": true,
119
- ".json5": true,
120
- ".ndjson": true,
121
- ".jsonl": true,
122
- ".tf": true,
123
- ".tfvars": true,
124
- ".hcl": true,
125
- ".nix": true,
126
- ".coffee": true,
127
- ".litcoffee": true,
128
- ".haml": true,
129
- ".slim": true,
130
- ".pug": true,
131
- ".jade": true,
132
- ".sass": true,
133
- ".styl": true,
134
- ".nim": true,
135
- ".cr": true,
136
- ".elm": true,
137
- ".fs": true,
138
- ".fsi": true,
139
- ".fsx": true,
140
- };
141
-
142
- function isIndentationSensitivePath(path: string | undefined): boolean {
143
- if (!path) return false;
144
- const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
145
- const dot = path.lastIndexOf(".");
146
- if (dot <= slash) return false;
147
- const ext = path.slice(dot).toLowerCase();
148
- return INDENT_SENSITIVE_EXTS[ext] === true;
149
- }
150
95
 
151
96
  function collectPayload(
152
97
  lines: string[],
153
98
  startIndex: number,
154
99
  opLineNum: number,
155
100
  requirePayload: boolean,
156
- checkPadding: boolean,
157
- ): { payload: string[]; nextIndex: number; paddingWarning?: string } {
101
+ ): { payload: string[]; nextIndex: number } {
158
102
  const payload: string[] = [];
159
103
  let index = startIndex;
160
104
  while (index < lines.length) {
161
105
  const line = lines[index];
162
- if (line.startsWith(HL_EDIT_SEP)) {
163
- payload.push(line.slice(HL_EDIT_SEP.length).trimEnd());
164
- index++;
165
- continue;
166
- }
167
- // Silently recover from a missing payload prefix on an otherwise blank
168
- // line: if more payload follows (possibly past further blanks), treat
169
- // each intervening blank as an empty `${HL_EDIT_SEP}` payload line.
170
- // Additionally, when the op explicitly requires payload (`+`/`<`) and
171
- // we have not collected any yet, accept the blank(s) themselves as the
172
- // empty payload — common typo of forgetting the `${HL_EDIT_SEP}` prefix
173
- // when inserting a blank line.
174
- if (line.length === 0) {
175
- let lookahead = index + 1;
176
- while (lookahead < lines.length && lines[lookahead].length === 0) {
177
- lookahead++;
178
- }
179
- const followedByPayload = lookahead < lines.length && lines[lookahead].startsWith(HL_EDIT_SEP);
180
- const acceptBareBlank = requirePayload && payload.length === 0;
181
- if (followedByPayload || acceptBareBlank) {
182
- for (let j = index; j < lookahead; j++) payload.push("");
183
- index = lookahead;
184
- continue;
185
- }
186
- }
187
- break;
106
+ if (isPayloadTerminatorLine(line)) break;
107
+ payload.push(line);
108
+ index++;
188
109
  }
189
110
  if (payload.length === 0 && requirePayload) {
190
- throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
111
+ throw new Error(
112
+ `line ${opLineNum}: ${HL_OP_INSERT_BEFORE} and ${HL_OP_INSERT_AFTER} operations require at least one verbatim payload line.`,
113
+ );
191
114
  }
192
- const paddingWarning =
193
- checkPadding && hasUniformSeparatorPadding(payload)
194
- ? `line ${opLineNum}: every payload line begins with exactly one space before non-space content, ` +
195
- `which looks like a readability gap after "${HL_EDIT_SEP}". The space becomes file content. ` +
196
- `Drop it unless the file genuinely uses a one-space indent.`
197
- : undefined;
198
- return { payload, nextIndex: index, paddingWarning };
115
+ return { payload, nextIndex: index };
199
116
  }
200
117
 
201
- export function parseHashline(diff: string, opts: ParseHashlineOptions = {}): HashlineEdit[] {
202
- return parseHashlineWithWarnings(diff, opts).edits;
118
+ export function parseHashline(diff: string): HashlineEdit[] {
119
+ return parseHashlineWithWarnings(diff).edits;
203
120
  }
204
121
 
205
- export interface ParseHashlineOptions {
206
- /** File path the diff targets. Used to suppress indent-sensitive false-positive warnings. */
207
- path?: string;
208
- }
209
-
210
- export function parseHashlineWithWarnings(
211
- diff: string,
212
- opts: ParseHashlineOptions = {},
213
- ): { edits: HashlineEdit[]; warnings: string[] } {
122
+ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
214
123
  const edits: HashlineEdit[] = [];
215
124
  const warnings: string[] = [];
216
125
  const lines = diff.split(/\r?\n/);
217
- const checkPadding = !isIndentationSensitivePath(opts.path);
126
+ if (diff.endsWith("\n") && lines.at(-1) === "") lines.pop();
218
127
  let editIndex = 0;
219
128
 
220
129
  const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
@@ -240,15 +149,11 @@ export function parseHashlineWithWarnings(
240
149
  i++;
241
150
  continue;
242
151
  }
243
- if (line.startsWith(HL_EDIT_SEP)) {
244
- throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
245
- }
246
152
 
247
153
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
248
154
  if (insertBeforeMatch) {
249
155
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
250
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true, checkPadding);
251
- if (paddingWarning) warnings.push(paddingWarning);
156
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
252
157
  for (const text of payload) pushInsert(cursor, text, lineNum);
253
158
  i = nextIndex;
254
159
  continue;
@@ -257,37 +162,26 @@ export function parseHashlineWithWarnings(
257
162
  const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
258
163
  if (insertAfterMatch) {
259
164
  const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
260
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true, checkPadding);
261
- if (paddingWarning) warnings.push(paddingWarning);
165
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
262
166
  for (const text of payload) pushInsert(cursor, text, lineNum);
263
167
  i = nextIndex;
264
168
  continue;
265
169
  }
266
170
 
267
- const deleteMatch = DELETE_OP_RE.exec(line);
268
- if (deleteMatch) {
269
- for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) {
270
- edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
271
- }
272
- i++;
273
- continue;
274
- }
275
-
276
171
  const replaceMatch = REPLACE_OP_RE.exec(line);
277
172
  if (replaceMatch) {
278
173
  const range = parseRange(replaceMatch[1], lineNum);
279
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, false, checkPadding);
280
- if (paddingWarning) warnings.push(paddingWarning);
281
- // `= A..B` with no payload blanks the range to a single empty line.
282
- const replacement = payload.length === 0 ? [""] : payload;
283
- for (const text of replacement) {
284
- edits.push({
285
- kind: "insert",
286
- cursor: { kind: "before_anchor", anchor: { ...range.start } },
287
- text,
288
- lineNum,
289
- index: editIndex++,
290
- });
174
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
175
+ if (payload.length > 0) {
176
+ for (const text of payload) {
177
+ edits.push({
178
+ kind: "insert",
179
+ cursor: { kind: "before_anchor", anchor: { ...range.start } },
180
+ text,
181
+ lineNum,
182
+ index: editIndex++,
183
+ });
184
+ }
291
185
  }
292
186
  for (const anchor of expandRange(range)) {
293
187
  edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
@@ -296,8 +190,15 @@ export function parseHashlineWithWarnings(
296
190
  continue;
297
191
  }
298
192
 
193
+ if (isPayloadTerminatorLine(line) || /^[-@\u00B6]/u.test(line)) {
194
+ throw new Error(
195
+ `line ${lineNum}: unrecognized op. Use ${HL_OP_INSERT_BEFORE}ANCHOR (insert before), ${HL_OP_INSERT_AFTER}ANCHOR (insert after), or ${HL_OP_REPLACE}A..B (replace/delete). ` +
196
+ `Got ${JSON.stringify(line)}.`,
197
+ );
198
+ }
199
+
299
200
  throw new Error(
300
- `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` +
201
+ `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, or ${HL_OP_REPLACE} operation. ` +
301
202
  `Got ${JSON.stringify(line)}.`,
302
203
  );
303
204
  }