pi-hashline-edit-pro 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,552 @@
1
+ /**
2
+ * Resolution — anchor resolution, edit validation, and mismatch formatting.
3
+ *
4
+ * This module owns the logic that resolves hash anchors to line numbers,
5
+ * validates edit structure, and formats mismatch errors. It is the bridge
6
+ * between the parsed edit requests and the apply pipeline.
7
+ */
8
+
9
+ import { throwIfAborted } from "../runtime";
10
+ import { HASH_LENGTH, HASHLINE_BARE_PREFIX_RE } from "./hash";
11
+ import { parseHashRef, hashlineParseText, type Anchor } from "./parse";
12
+
13
+ // ─── Types ──────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * The internal, post-resolution representation of an anchor. After
17
+ * `validateAnchorEdits` has resolved the hash to a line, the resulting
18
+ * `ResolvedAnchor` carries the line number plus whether the hash matched
19
+ * exactly (vs. falling back to no-anchor-found / not-found).
20
+ */
21
+ export type ResolvedAnchor = {
22
+ line: number;
23
+ hash: string;
24
+ hashMatched: boolean;
25
+ };
26
+
27
+ export type HashlineEdit =
28
+ | { op: "replace"; start: Anchor; end: Anchor; lines: string[] }
29
+ | { op: "append"; pos?: Anchor; lines: string[] }
30
+ | { op: "prepend"; pos?: Anchor; lines: string[] };
31
+
32
+ /**
33
+ * A `HashlineEdit` with all anchors resolved to line numbers. This is
34
+ * the shape consumed by `resolveEditToSpan` and the rest of the apply
35
+ * pipeline.
36
+ */
37
+ export type ResolvedHashlineEdit =
38
+ | {
39
+ op: "replace";
40
+ start: ResolvedAnchor;
41
+ end: ResolvedAnchor;
42
+ lines: string[];
43
+ }
44
+ | { op: "append"; pos?: ResolvedAnchor; lines: string[] }
45
+ | { op: "prepend"; pos?: ResolvedAnchor; lines: string[] };
46
+
47
+ interface HashMismatch {
48
+ ref: Anchor;
49
+ kind: "not_found" | "ambiguous";
50
+ candidates?: number[];
51
+ }
52
+
53
+ export interface NoopEdit {
54
+ editIndex: number;
55
+ loc: string;
56
+ currentContent: string;
57
+ }
58
+
59
+ /**
60
+ * Schema-level edit as received from the tool layer.
61
+ *
62
+ * `pos` is the anchor for `append`/`prepend`; `start` and `end` are the
63
+ * inclusive range anchors for `replace`. `lines` is canonicalized to an array.
64
+ *
65
+ * The `oldText` and `newText` fields are legacy — they exist on this type
66
+ * only because `normalizeEditRequest` may pass them through before
67
+ * `assertEditRequest` rejects them with `[E_LEGACY_SHAPE]`. They are
68
+ * never accepted by the edit pipeline. Do not use them in new code.
69
+ */
70
+ export type HashlineToolEdit = {
71
+ op: string;
72
+ pos?: string;
73
+ start?: string;
74
+ end?: string;
75
+ lines?: string[];
76
+ /** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
77
+ oldText?: string;
78
+ /** @deprecated Legacy field — rejected with [E_LEGACY_SHAPE] at validation time. */
79
+ newText?: string;
80
+ };
81
+
82
+ // ─── Anchor resolution ──────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Resolve an `Anchor` to a specific line in the file.
86
+ *
87
+ * Returns a `ResolvedAnchor` on success. Returns an error object on:
88
+ * - `not_found`: no line in the file has this hash
89
+ * - `ambiguous`: the hash matches multiple lines (the model must
90
+ * re-read to disambiguate; the runtime does not accept a
91
+ * `HASH:content` disambiguator on the wire)
92
+ */
93
+ function resolveAnchor(
94
+ ref: Anchor,
95
+ fileLines: string[],
96
+ fileHashes: string[],
97
+ ): ResolvedAnchor | HashMismatch {
98
+ const hashMatches: number[] = [];
99
+ for (let i = 0; i < fileHashes.length; i++) {
100
+ if (fileHashes[i] === ref.hash) hashMatches.push(i + 1);
101
+ }
102
+ if (hashMatches.length === 0) {
103
+ return { ref, kind: "not_found" };
104
+ }
105
+ if (hashMatches.length === 1) {
106
+ return {
107
+ line: hashMatches[0]!,
108
+ hash: ref.hash,
109
+ hashMatched: true,
110
+ };
111
+ }
112
+ return { ref, kind: "ambiguous", candidates: hashMatches };
113
+ }
114
+
115
+ // ─── Mismatch formatting ────────────────────────────────────────────────
116
+
117
+ export function formatMismatchError(
118
+ mismatches: HashMismatch[],
119
+ fileLines: string[],
120
+ fileHashes: string[],
121
+ ): string {
122
+ if (fileHashes.length !== fileLines.length) {
123
+ throw new Error(
124
+ `formatMismatchError: fileHashes.length (${fileHashes.length}) must match fileLines.length (${fileLines.length}).`,
125
+ );
126
+ }
127
+ const out: string[] = [];
128
+ const notFound = mismatches.filter((m) => m.kind === "not_found");
129
+ const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
130
+
131
+ if (notFound.length > 0) {
132
+ const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
133
+ out.push(
134
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}: ${refList}. Re-read the file to refresh.`
135
+ );
136
+ }
137
+ if (ambiguous.length > 0) {
138
+ if (out.length > 0) out.push("");
139
+ out.push(
140
+ `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}. Re-read the file to refresh.`
141
+ );
142
+ for (const m of ambiguous) {
143
+ const sample = (m.candidates ?? []).slice(0, 5);
144
+ const more =
145
+ (m.candidates?.length ?? 0) > sample.length
146
+ ? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
147
+ : "";
148
+ const lines = sample
149
+ .map((line) => {
150
+ const content = fileLines[line - 1] ?? "";
151
+ return ` ${line}: ${fileHashes[line - 1]}:${content}`;
152
+ })
153
+ .join("\n");
154
+ out.push(
155
+ ` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`,
156
+ );
157
+ }
158
+ }
159
+
160
+ out.push("");
161
+ out.push("Current state (first lines):");
162
+ const sampleSize = Math.min(fileLines.length, 5);
163
+ for (let i = 0; i < sampleSize; i++) {
164
+ out.push(`>>> ${fileHashes[i]}:${fileLines[i]}`);
165
+ }
166
+ if (fileLines.length > sampleSize) {
167
+ out.push(`... ${fileLines.length - sampleSize} more.`);
168
+ }
169
+
170
+ return out.join("\n");
171
+ }
172
+
173
+ // ─── Edit structure validation ──────────────────────────────────────────
174
+
175
+ /**
176
+ * Validate + parse flat tool-schema edits into typed internal representations.
177
+ *
178
+ * This is the single source of truth for per-edit structural validation (shape,
179
+ * op constraints, field types) and anchor parsing. `assertEditRequest` validates
180
+ * only the request envelope (path, returnMode, etc.) and delegates here for
181
+ * edit payload validation.
182
+ *
183
+ * Strict: provided anchors must parse successfully. Missing anchors are
184
+ * fine for append (→ EOF) and prepend (→ BOF), but a malformed anchor
185
+ * that was explicitly supplied is always an error.
186
+ *
187
+ * - replace + start + end → range replace (both anchors required; for a
188
+ * single-line replace, set start = end = the line's hash)
189
+ * - append + pos → append after that anchor
190
+ * - prepend + pos → prepend before that anchor
191
+ * - no anchors → file-level append/prepend (only for those ops)
192
+ */
193
+ const ITEM_KEYS = new Set(["op", "pos", "start", "end", "lines"]);
194
+ function isStringArray(value: unknown): value is string[] {
195
+ return (
196
+ Array.isArray(value) && value.every((item) => typeof item === "string")
197
+ );
198
+ }
199
+
200
+ function assertEditItem(edit: Record<string, unknown>, index: number): void {
201
+ const unknownKeys = Object.keys(edit).filter((key) => !ITEM_KEYS.has(key));
202
+ if (unknownKeys.length > 0) {
203
+ throw new Error(
204
+ `[E_BAD_SHAPE] Edit ${index} contains unknown or unsupported fields: ${unknownKeys.join(", ")}.`,
205
+ );
206
+ }
207
+
208
+ if (typeof edit.op !== "string") {
209
+ throw new Error(`[E_BAD_SHAPE] Edit ${index} requires an "op" string.`);
210
+ }
211
+ if (
212
+ edit.op !== "replace" &&
213
+ edit.op !== "append" &&
214
+ edit.op !== "prepend"
215
+ ) {
216
+ throw new Error(
217
+ `[E_BAD_OP] Edit ${index} uses unknown op "${edit.op}". Expected "replace", "append", or "prepend".`,
218
+ );
219
+ }
220
+ if ("pos" in edit && typeof edit.pos !== "string") {
221
+ throw new Error(
222
+ `[E_BAD_SHAPE] Edit ${index} field "pos" must be a string when provided.`,
223
+ );
224
+ }
225
+ if ("start" in edit && typeof edit.start !== "string") {
226
+ throw new Error(
227
+ `[E_BAD_SHAPE] Edit ${index} field "start" must be a string when provided.`,
228
+ );
229
+ }
230
+ if ("end" in edit && typeof edit.end !== "string") {
231
+ throw new Error(`[E_BAD_SHAPE] Edit ${index} field "end" must be a string when provided.`);
232
+ }
233
+ if (!("lines" in edit)) {
234
+ throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "lines" field.`);
235
+ }
236
+ if ("lines" in edit && !isStringArray(edit.lines)) {
237
+ throw new Error(`[E_BAD_SHAPE] Edit ${index} field "lines" must be a string array.`);
238
+ }
239
+ if (edit.op === "replace") {
240
+ if ("pos" in edit) {
241
+ throw new Error(
242
+ `[E_BAD_OP] Edit ${index} op "replace" uses "pos" — use "start" instead.`
243
+ );
244
+ }
245
+ if (typeof edit.start !== "string") {
246
+ throw new Error(
247
+ `[E_BAD_OP] Edit ${index} with op "replace" requires a "start" anchor string.`,
248
+ );
249
+ }
250
+ if (typeof edit.end !== "string") {
251
+ throw new Error(
252
+ `[E_BAD_OP] Edit ${index} with op "replace" requires an "end" anchor string.`,
253
+ );
254
+ }
255
+ }
256
+
257
+ if ((edit.op === "append" || edit.op === "prepend") && "end" in edit) {
258
+ throw new Error(
259
+ `[E_BAD_OP] Edit ${index} op "${edit.op}" does not support "end". Use "pos" or omit.`
260
+ );
261
+ }
262
+ }
263
+
264
+ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
265
+ const result: HashlineEdit[] = [];
266
+ for (const [index, edit] of edits.entries()) {
267
+ assertEditItem(edit as Record<string, unknown>, index);
268
+
269
+ const op = edit.op;
270
+ switch (op) {
271
+ case "replace": {
272
+ // Normalize `lines: [""]` (a single empty string) to `lines: []`
273
+ // (deletion). The "lines.length > 0" branch in resolveEditToSpan
274
+ // preserves the trailing newline of the last replaced line, so a
275
+ // single-element empty array would leave that newline behind and
276
+ // produce an extra blank line. Models commonly emit `[""]` to
277
+ // mean "delete this", and the deletion branch handles the
278
+ // trailing newline correctly. Note: this is `replace`-only;
279
+ // `append`/`prepend` legitimately use `[""]` to insert a blank
280
+ // line.
281
+ const replaceLines = hashlineParseText(edit.lines ?? null);
282
+ const normalizedLines =
283
+ replaceLines.length === 1 && replaceLines[0] === ""
284
+ ? []
285
+ : replaceLines;
286
+ result.push({
287
+ op: "replace",
288
+ start: parseHashRef(edit.start!),
289
+ end: parseHashRef(edit.end!),
290
+ lines: normalizedLines,
291
+ });
292
+ break;
293
+ }
294
+ case "append": {
295
+ result.push({
296
+ op: "append",
297
+ ...(edit.pos ? { pos: parseHashRef(edit.pos) } : {}),
298
+ lines: hashlineParseText(edit.lines ?? null),
299
+ });
300
+ break;
301
+ }
302
+ case "prepend": {
303
+ result.push({
304
+ op: "prepend",
305
+ ...(edit.pos ? { pos: parseHashRef(edit.pos) } : {}),
306
+ lines: hashlineParseText(edit.lines ?? null),
307
+ });
308
+ break;
309
+ }
310
+ }
311
+ }
312
+ return result;
313
+ }
314
+
315
+ // ─── Anchor validation ──────────────────────────────────────────────────
316
+
317
+ function maybeWarnSuspiciousUnicodeEscapePlaceholder(
318
+ edits: HashlineEdit[],
319
+ warnings: string[],
320
+ ): void {
321
+ for (const edit of edits) {
322
+ if (edit.lines.some((line) => /\\uDDDD/i.test(line))) {
323
+ warnings.push(
324
+ "Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.",
325
+ );
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Reject edit content that starts with a bare hash prefix. Companion to
332
+ * `assertNoDisplayPrefixes`, which rejects the unambiguous `+HASH:` form at
333
+ * the parse stage; this catches the bare `HASH:` form (after optional leading
334
+ * whitespace) at the apply stage. The first 5 characters of every `lines`
335
+ * entry are checked: 4 alphabet characters (A–Z, a–z, 0–9, `-`, `_`)
336
+ * followed by `:`.
337
+ *
338
+ * Bare `HASH:` prefixes in `lines` are almost always a model mistake — the
339
+ * model copied the hash prefix from a `read` output but dropped the rest of
340
+ * the rendered `HASH:content` form. We reject with `[E_BARE_HASH_PREFIX]`
341
+ * rather than warn, because a stray hash in the file content is a silent
342
+ * correctness bug (the line is written verbatim, no autocorrection) and
343
+ * because the cost of a false positive is small: the model can rephrase the
344
+ * line (e.g. quote it, escape the colon, or use a different identifier
345
+ * shape) and retry.
346
+ *
347
+ * The error message lists the offending lines, the suspect hash prefix for
348
+ * each, and whether any of them collide with a real file-line hash. A
349
+ * collision is a strong signal that the model was reading a `HASH:content`
350
+ * line and copied only the prefix.
351
+ */
352
+ export function assertNoBareHashPrefixLines(
353
+ edits: HashlineEdit[],
354
+ fileLines: string[],
355
+ fileHashes: string[],
356
+ filePath?: string,
357
+ ): string[] {
358
+ if (fileHashes.length !== fileLines.length) {
359
+ throw new Error(
360
+ `assertNoBareHashPrefixLines: fileHashes.length (${fileHashes.length}) must match fileLines.length (${fileLines.length}).`,
361
+ );
362
+ }
363
+ // Collect bare-prefix suspects up front: regex only. Almost every edit has
364
+ // none, so this lets the common path bail before paying for file hashes.
365
+ const suspects: { line: string; hash: string; editIndex: number; lineIndex: number }[] = [];
366
+ for (let editIndex = 0; editIndex < edits.length; editIndex++) {
367
+ const edit = edits[editIndex]!;
368
+ for (let lineIndex = 0; lineIndex < edit.lines.length; lineIndex++) {
369
+ const line = edit.lines[lineIndex]!;
370
+ const match = line.match(HASHLINE_BARE_PREFIX_RE);
371
+ if (match) suspects.push({ line, hash: match[1]!, editIndex, lineIndex });
372
+ }
373
+ }
374
+ if (suspects.length === 0) return [];
375
+
376
+ const isPython = filePath?.endsWith('.py');
377
+ const fileHashSet = new Set(fileHashes);
378
+ const matched = suspects.filter((s) => fileHashSet.has(s.hash));
379
+ const matchedCount = matched.length;
380
+ const exampleLine = `${suspects[0]!.hash}:${suspects[0]!.line}`;
381
+
382
+ // For Python files, return a warning instead of throwing — Python uses
383
+ // `else:`, `except:`, `elif:` etc. which trigger the bare-prefix detector.
384
+ if (isPython) {
385
+ const hint = matchedCount > 0
386
+ ? `${matchedCount} prefix(es) match file line hashes.`
387
+ : `None match file line hashes — likely Python syntax.`;
388
+ return [`[W_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (e.g. ${JSON.stringify(exampleLine)}). ${hint}`];
389
+ }
390
+
391
+ const linesHint =
392
+ matchedCount === 0
393
+ ? `None match file line hashes.`
394
+ : `${matchedCount} match file line hashes — likely a copied hash.`;
395
+
396
+ throw new Error(
397
+ `[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (e.g. ${JSON.stringify(exampleLine)}). ${linesHint} Use literal file content in "lines" — never paste HASH:content from read output.`
398
+ );
399
+ }
400
+
401
+ /**
402
+ * Validate + resolve hash-anchored edits against the current file content.
403
+ *
404
+ * For each anchor, the runtime:
405
+ * 1. Looks up the hash in the file's precomputed hash array.
406
+ * 2. If the hash uniquely matches a line, use it.
407
+ * 3. If the hash matches multiple lines (rare at 24 bits, but possible),
408
+ * this is `[E_AMBIGUOUS_ANCHOR]` — the model must re-read to refresh.
409
+ * 4. If the hash doesn't match any line, this is `[E_STALE_ANCHOR]`.
410
+ *
411
+ * Boundary / single-anchor / range warnings are appended to `warnings`.
412
+ */
413
+ export function validateAnchorEdits(
414
+ edits: HashlineEdit[],
415
+ fileLines: string[],
416
+ fileHashes: string[],
417
+ warnings: string[],
418
+ signal: AbortSignal | undefined,
419
+ ): { resolved: ResolvedHashlineEdit[]; mismatches: HashMismatch[] } {
420
+ if (fileHashes.length !== fileLines.length) {
421
+ throw new Error(
422
+ `validateAnchorEdits: fileHashes.length (${fileHashes.length}) must match fileLines.length (${fileLines.length}).`,
423
+ );
424
+ }
425
+ const resolved: ResolvedHashlineEdit[] = [];
426
+ const mismatches: HashMismatch[] = [];
427
+
428
+ const tryResolve = (ref: Anchor): ResolvedAnchor | undefined => {
429
+ const result = resolveAnchor(ref, fileLines, fileHashes);
430
+ if ("kind" in result) {
431
+ mismatches.push(result);
432
+ return undefined;
433
+ }
434
+ return result;
435
+ };
436
+
437
+ function describeEdit(edit: ResolvedHashlineEdit): string {
438
+ switch (edit.op) {
439
+ case "replace":
440
+ return `replace ${edit.start.hash}-${edit.end.hash}`;
441
+ case "append":
442
+ return edit.pos ? `append after ${edit.pos.hash}` : "append at EOF";
443
+ case "prepend":
444
+ return edit.pos ? `prepend before ${edit.pos.hash}` : "prepend at BOF";
445
+ }
446
+ }
447
+
448
+ for (const edit of edits) {
449
+ throwIfAborted(signal);
450
+ switch (edit.op) {
451
+ case "replace": {
452
+ const startResolved = tryResolve(edit.start);
453
+ const endResolved = tryResolve(edit.end);
454
+ if (!startResolved || !endResolved) {
455
+ continue;
456
+ }
457
+ if (startResolved.line > endResolved.line) {
458
+ throw new Error(
459
+ `[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.start.hash} and ${edit.end.hash}).`,
460
+ );
461
+ }
462
+ const endLine = endResolved.line;
463
+ const nextLine = fileLines[endLine];
464
+ const replacementLastLine = edit.lines.at(-1)?.trim();
465
+ if (
466
+ nextLine !== undefined &&
467
+ replacementLastLine &&
468
+ /[\p{L}\p{N}]/u.test(replacementLastLine) &&
469
+ replacementLastLine === nextLine.trim()
470
+ ) {
471
+ const resolvedEdit: ResolvedHashlineEdit = {
472
+ op: "replace",
473
+ start: startResolved,
474
+ end: endResolved,
475
+ lines: edit.lines,
476
+ };
477
+ warnings.push(
478
+ `Potential boundary duplication after ${describeEdit(resolvedEdit)}: the replacement ends with a line that matches the next surviving line after trim.`,
479
+ );
480
+ }
481
+ const prevLine = fileLines[startResolved.line - 2];
482
+ const replacementFirstLine = edit.lines[0]?.trim();
483
+ if (
484
+ prevLine !== undefined &&
485
+ replacementFirstLine &&
486
+ /[\p{L}\p{N}]/u.test(replacementFirstLine) &&
487
+ replacementFirstLine === prevLine.trim()
488
+ ) {
489
+ const resolvedEdit: ResolvedHashlineEdit = {
490
+ op: "replace",
491
+ start: startResolved,
492
+ end: endResolved,
493
+ lines: edit.lines,
494
+ };
495
+ warnings.push(
496
+ `Potential boundary duplication before ${describeEdit(resolvedEdit)}: the replacement starts with a line that matches the preceding surviving line after trim.`,
497
+ );
498
+ }
499
+ resolved.push({
500
+ op: "replace",
501
+ start: startResolved,
502
+ end: endResolved,
503
+ lines: edit.lines,
504
+ });
505
+ break;
506
+ }
507
+ case "append": {
508
+ let posResolved: ResolvedAnchor | undefined;
509
+ if (edit.pos) {
510
+ const r = tryResolve(edit.pos);
511
+ if (!r) continue;
512
+ posResolved = r;
513
+ }
514
+ if (edit.lines.length === 0) {
515
+ throw new Error(
516
+ "[E_BAD_OP] Append with empty lines payload. Provide content to insert or remove the edit.",
517
+ );
518
+ }
519
+ resolved.push({
520
+ op: "append",
521
+ ...(posResolved ? { pos: posResolved } : {}),
522
+ lines: edit.lines,
523
+ });
524
+ break;
525
+ }
526
+ case "prepend": {
527
+ let posResolved: ResolvedAnchor | undefined;
528
+ if (edit.pos) {
529
+ const r = tryResolve(edit.pos);
530
+ if (!r) continue;
531
+ posResolved = r;
532
+ }
533
+ if (edit.lines.length === 0) {
534
+ throw new Error(
535
+ "[E_BAD_OP] Prepend with empty lines payload. Provide content to insert or remove the edit.",
536
+ );
537
+ }
538
+ resolved.push({
539
+ op: "prepend",
540
+ ...(posResolved ? { pos: posResolved } : {}),
541
+ lines: edit.lines,
542
+ });
543
+ break;
544
+ }
545
+ }
546
+ }
547
+
548
+ return { resolved, mismatches };
549
+ }
550
+
551
+ // Re-export for apply module
552
+ export { maybeWarnSuspiciousUnicodeEscapePlaceholder };
@@ -0,0 +1,13 @@
1
+ import * as os from "os";
2
+ import { isAbsolute, resolve as resolvePath } from "path";
3
+
4
+ function expandPath(filePath: string): string {
5
+ if (filePath === "~") return os.homedir();
6
+ if (filePath.startsWith("~/")) return os.homedir() + filePath.slice(1);
7
+ return filePath;
8
+ }
9
+
10
+ export function resolveToCwd(filePath: string, cwd: string): string {
11
+ const expanded = expandPath(filePath);
12
+ return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
13
+ }