pi-hashline-edit-pro 0.3.1 → 0.3.3

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,23 +1,9 @@
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
1
 
9
2
  import { throwIfAborted } from "../runtime";
10
3
  import { HASHLINE_BARE_PREFIX_RE } from "./hash";
11
4
  import { parseHashRef, hashlineParseText, type Anchor } from "./parse";
12
5
 
13
- // ─── Types ──────────────────────────────────────────────────────────────
14
6
 
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
7
  export type ResolvedAnchor = {
22
8
  line: number;
23
9
  hash: string;
@@ -29,11 +15,6 @@ export type HashlineEdit =
29
15
  | { op: "append"; pos?: Anchor; lines: string[] }
30
16
  | { op: "prepend"; pos?: Anchor; lines: string[] };
31
17
 
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
18
  export type ResolvedHashlineEdit =
38
19
  | {
39
20
  op: "replace";
@@ -56,17 +37,6 @@ export interface NoopEdit {
56
37
  currentContent: string;
57
38
  }
58
39
 
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
40
  export type HashlineToolEdit = {
71
41
  op: string;
72
42
  pos?: string;
@@ -79,17 +49,7 @@ export type HashlineToolEdit = {
79
49
  newText?: string;
80
50
  };
81
51
 
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
- */
52
+
93
53
  function resolveAnchor(
94
54
  ref: Anchor,
95
55
  fileLines: string[],
@@ -112,7 +72,6 @@ function resolveAnchor(
112
72
  return { ref, kind: "ambiguous", candidates: hashMatches };
113
73
  }
114
74
 
115
- // ─── Mismatch formatting ────────────────────────────────────────────────
116
75
 
117
76
  export function formatMismatchError(
118
77
  mismatches: HashMismatch[],
@@ -148,12 +107,12 @@ export function formatMismatchError(
148
107
  const lines = sample
149
108
  .map((line) => {
150
109
  const content = fileLines[line - 1] ?? "";
151
- return ` ${line}: ${fileHashes[line - 1]}:${content}`;
110
+ return ` ${line}: ${fileHashes[line - 1]}│${content}`;
152
111
  })
153
112
  .join("\n");
154
- out.push(
155
- ` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`,
156
- );
113
+ out.push(
114
+ ` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`,
115
+ );
157
116
  }
158
117
  }
159
118
 
@@ -161,7 +120,7 @@ export function formatMismatchError(
161
120
  out.push("Current state (first lines):");
162
121
  const sampleSize = Math.min(fileLines.length, 5);
163
122
  for (let i = 0; i < sampleSize; i++) {
164
- out.push(`>>> ${fileHashes[i]}:${fileLines[i]}`);
123
+ out.push(`>>> ${fileHashes[i]}│${fileLines[i]}`);
165
124
  }
166
125
  if (fileLines.length > sampleSize) {
167
126
  out.push(`... ${fileLines.length - sampleSize} more.`);
@@ -170,26 +129,7 @@ export function formatMismatchError(
170
129
  return out.join("\n");
171
130
  }
172
131
 
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
- */
132
+
193
133
  const ITEM_KEYS = new Set(["op", "pos", "start", "end", "lines"]);
194
134
  function isStringArray(value: unknown): value is string[] {
195
135
  return (
@@ -269,15 +209,7 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
269
209
  const op = edit.op;
270
210
  switch (op) {
271
211
  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.
212
+ // Normalize lines: [""] to lines: [] for deletion.
281
213
  const replaceLines = hashlineParseText(edit.lines ?? null);
282
214
  const normalizedLines =
283
215
  replaceLines.length === 1 && replaceLines[0] === ""
@@ -312,7 +244,6 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
312
244
  return result;
313
245
  }
314
246
 
315
- // ─── Anchor validation ──────────────────────────────────────────────────
316
247
 
317
248
  function maybeWarnSuspiciousUnicodeEscapePlaceholder(
318
249
  edits: HashlineEdit[],
@@ -327,28 +258,6 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(
327
258
  }
328
259
  }
329
260
 
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: `#` prefix + 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
261
  export function assertNoBareHashPrefixLines(
353
262
  edits: HashlineEdit[],
354
263
  fileLines: string[],
@@ -360,8 +269,6 @@ export function assertNoBareHashPrefixLines(
360
269
  `assertNoBareHashPrefixLines: fileHashes.length (${fileHashes.length}) must match fileLines.length (${fileLines.length}).`,
361
270
  );
362
271
  }
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
272
  const suspects: { line: string; hash: string; editIndex: number; lineIndex: number }[] = [];
366
273
  for (let editIndex = 0; editIndex < edits.length; editIndex++) {
367
274
  const edit = edits[editIndex]!;
@@ -377,10 +284,8 @@ export function assertNoBareHashPrefixLines(
377
284
  const fileHashSet = new Set(fileHashes);
378
285
  const matched = suspects.filter((s) => fileHashSet.has(s.hash));
379
286
  const matchedCount = matched.length;
380
- const exampleLine = `${suspects[0]!.hash}:${suspects[0]!.line}`;
287
+ const exampleLine = `${suspects[0]!.hash}│${suspects[0]!.line}`;
381
288
 
382
- // For Python files, return a warning instead of throwing — Python uses
383
- // `else:`, `except:`, `elif:` etc. which trigger the bare-prefix detector.
384
289
  if (isPython) {
385
290
  const hint = matchedCount > 0
386
291
  ? `${matchedCount} prefix(es) match file line hashes.`
@@ -394,22 +299,10 @@ export function assertNoBareHashPrefixLines(
394
299
  : `${matchedCount} match file line hashes — likely a copied hash.`;
395
300
 
396
301
  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.`
302
+ `[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 HASHcontent from read output.`
398
303
  );
399
304
  }
400
305
 
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
306
  export function validateAnchorEdits(
414
307
  edits: HashlineEdit[],
415
308
  fileLines: string[],
@@ -451,7 +344,7 @@ export function validateAnchorEdits(
451
344
  case "replace": {
452
345
  const startResolved = tryResolve(edit.start);
453
346
  const endResolved = tryResolve(edit.end);
454
- if (!startResolved || !endResolved) {
347
+ if (!startResolved || !endResolved) {
455
348
  continue;
456
349
  }
457
350
  if (startResolved.line > endResolved.line) {
@@ -548,5 +441,4 @@ export function validateAnchorEdits(
548
441
  return { resolved, mismatches };
549
442
  }
550
443
 
551
- // Re-export for apply module
552
444
  export { maybeWarnSuspiciousUnicodeEscapePlaceholder };
package/src/read.ts CHANGED
@@ -95,12 +95,6 @@ export function formatHashlineReadPreview(
95
95
  ? Math.min(startLine - 1 + limit, totalLines)
96
96
  : totalLines;
97
97
  const selected = allLines.slice(startLine - 1, endIdx);
98
- // The runtime precomputes the full per-line hash array so the model sees
99
- // hashes consistent with what validation will compare against. Callers that
100
- // already have one (the read tool, chained-edit tests) can pass it in;
101
- // otherwise we recompute here for the visible window — acceptable for
102
- // preview formatting because the hashes themselves are identical regardless
103
- // of which slice of the file we look at.
104
98
  const allHashes = precomputedHashes ?? computeLineHashes(text);
105
99
  const selectedHashes = allHashes.slice(startLine - 1, endIdx);
106
100
  const formatted = formatHashlineRegion(selectedHashes, selected);