pi-hashline-edit-pro 5.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -20
- package/package.json +1 -1
- package/prompts/copy-guidelines.md +1 -0
- package/prompts/insert-guidelines.md +1 -1
- package/prompts/insert.md +2 -2
- package/prompts/move-guidelines.md +1 -0
- package/prompts/replace-guidelines.md +2 -0
- package/prompts/replace-within-guidelines.md +1 -1
- package/prompts/replace-within.md +2 -2
- package/prompts/replace.md +2 -2
- package/src/batch.ts +16 -8
- package/src/constants.ts +2 -2
- package/src/copy-move.ts +6 -2
- package/src/edit-common.ts +11 -5
- package/src/edit-fidelity.ts +443 -34
- package/src/hashline/index.ts +1 -0
- package/src/hashline/resolve.ts +23 -0
- package/src/insert.ts +4 -6
- package/src/prompts.ts +12 -1
- package/src/replace-render.ts +1 -1
- package/src/replace-response.ts +5 -4
- package/src/replace-within.ts +2 -5
- package/src/replace.ts +13 -8
- package/src/utils.ts +10 -3
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ Edge cases:
|
|
|
188
188
|
| --- | --- |
|
|
189
189
|
| `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
|
|
190
190
|
| `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
|
|
191
|
-
| `replacement_lines` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved;
|
|
191
|
+
| `replacement_lines` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
192
192
|
|
|
193
193
|
Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
194
194
|
|
|
@@ -201,6 +201,7 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
|
201
201
|
```
|
|
202
202
|
|
|
203
203
|
Single line: use the same anchor for `remove_from` and `remove_to`. `replace_from`/`replace_to` and `from`/`to` work as aliases.
|
|
204
|
+
A deletion keeps blank lines at the edges of the removed range, so the separators around a block survive the edit; target a blank line on its own to delete it.
|
|
204
205
|
|
|
205
206
|
The extension checks the request before any file I/O, so a bad request never touches the file.
|
|
206
207
|
|
|
@@ -208,7 +209,7 @@ Auto-fixable slips fall into two groups. Fixed silently: a reversed range, embed
|
|
|
208
209
|
|
|
209
210
|
Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
|
|
210
211
|
|
|
211
|
-
Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read
|
|
212
|
+
Every line in the removed range must match what was last shown to you, except that a pure deletion (an empty replacement) verifies only the first and last line of the range and removes the interior as it currently stands. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`; only lines strictly between the boundaries of a pure deletion are exempt.
|
|
212
213
|
|
|
213
214
|
An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
|
|
214
215
|
|
|
@@ -230,9 +231,9 @@ A `replace_within` call is never grouped into a batch; it commits on its own lik
|
|
|
230
231
|
| --- | --- |
|
|
231
232
|
| `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
|
|
232
233
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
233
|
-
| `lines` | The exact text to insert, as one string: `""` inserts
|
|
234
|
+
| `lines` | The exact text to insert, as one string: `""` inserts one blank line (the same as `"\n"`), and a trailing line break sets the last line's ending instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
234
235
|
|
|
235
|
-
Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place.
|
|
236
|
+
Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. An empty `lines` payload inserts one blank line. To seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
236
237
|
|
|
237
238
|
Example: add a line after `Emno│`:
|
|
238
239
|
|
|
@@ -244,7 +245,7 @@ The same safety machinery as `replace` applies: undo is saved before the write (
|
|
|
244
245
|
|
|
245
246
|
### copy
|
|
246
247
|
|
|
247
|
-
`copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `insert_after` selects the destination line, and it may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served. With `requirePath` on, `path` must match the source or the destination file.
|
|
248
|
+
`copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `insert_after` selects the destination line, and it may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source's first and last lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served; the interior of the range is transferred verbatim and does not need to have been shown. With `requirePath` on, `path` must match the source or the destination file.
|
|
248
249
|
|
|
249
250
|
| Field | Description |
|
|
250
251
|
| --- | --- |
|
|
@@ -308,8 +309,8 @@ Multiple `replace` and `insert` calls on the same file in one assistant message
|
|
|
308
309
|
|
|
309
310
|
- A call outside a batch commits before its result returns.
|
|
310
311
|
- A `copy`, `move`, or `replace_within` call is never grouped into a batch: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
|
|
311
|
-
- A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
|
|
312
|
-
- If a batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits until the last call succeeds.
|
|
312
|
+
- A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N (queued)`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
|
|
313
|
+
- If a batch aborts, nothing is written: the failing call's error ends with `Aborts batch N.` and reports that the whole batch was discarded, and an earlier member's row renders the abort message instead of the queued placeholder. Nothing commits until the last call succeeds.
|
|
313
314
|
- A batch member accepts the same request shapes and auto-fixes as a standalone call.
|
|
314
315
|
|
|
315
316
|
Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`.
|
|
@@ -322,7 +323,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
|
|
|
322
323
|
|
|
323
324
|
Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
|
|
324
325
|
|
|
325
|
-
After `replace`, `replace_within`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
326
|
+
After `replace`, `replace_within`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
326
327
|
|
|
327
328
|
An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
|
|
328
329
|
|
|
@@ -419,7 +420,7 @@ Codes starting with `E_` are errors: nothing was written, with one exception. `F
|
|
|
419
420
|
Most common, with the fix:
|
|
420
421
|
|
|
421
422
|
- `[E_STALE_ANCHOR]`: the anchor is not owned in this session. Call `read` for fresh anchors and retry.
|
|
422
|
-
- `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown. The error already returns the current range with fresh anchors; retry with those.
|
|
423
|
+
- `[E_RANGE_STALE]`: a line in the replaced range changed on disk or was never shown (a pure deletion checks only its first and last line). The error already returns the current range with fresh anchors; retry with those.
|
|
423
424
|
- `[E_FILE_TOO_LARGE]`: the file exceeds the 1,353,139-line hashline limit or the 100MB size limit. Use `write` for very large files.
|
|
424
425
|
- `[E_STORE_UNAVAILABLE]`: no SQLite runtime. Run pi under Node 22.19+ or a Bun build that ships `bun:sqlite`.
|
|
425
426
|
- `[E_WRITE_HASH_ECHO]`: a `write` content line contains a copied served row. Remove the anchors and retry.
|
|
@@ -440,15 +441,20 @@ Full reference:
|
|
|
440
441
|
| `[W_INVALID_PATCH]` | A `replacement_lines` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
441
442
|
| `[W_BARE_HASH_PREFIX]` | A `replacement_lines` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
442
443
|
| `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
|
|
443
|
-
| `[H_LITERAL_ESCAPE]` |
|
|
444
|
-
| `[H_UNICODE_LOST]` | The
|
|
444
|
+
| `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because JSON decoding happens once, before the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `lines: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
|
|
445
|
+
| `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the character, its column, and the reference anchor, for example `[H_UNICODE_LOST] U+2060 missing at col 31; Kq3f│ has it; resend with U+2060 if unintended.` When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
|
|
446
|
+
| `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names both code points, the column, and the reference anchor, for example `[H_UNICODE_SWAPPED] U+200D at col 21 where Kq3f│ has U+200B; resend with U+200B if unintended.` The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
|
|
447
|
+
| `[H_TRAILING_WHITESPACE]` | The new text differs from the replaced line only in trailing whitespace. Anchor checksums trim trailing whitespace, so the change does not invalidate the anchor; the one-line hint names the old and new trailing-whitespace counts and the column, for example `[H_TRAILING_WHITESPACE] 1 trailing whitespace character at col 7; Kq3f│ had 0.` |
|
|
448
|
+
| `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
|
|
449
|
+
| `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
|
|
450
|
+
| `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between Aaaa│ and Dddd│.` |
|
|
445
451
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
|
|
446
452
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
447
453
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
448
454
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
449
455
|
| `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
|
|
450
456
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
|
|
451
|
-
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
|
|
457
|
+
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
|
|
452
458
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
453
459
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
|
454
460
|
| `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
|
|
@@ -464,7 +470,7 @@ Full reference:
|
|
|
464
470
|
## Troubleshooting
|
|
465
471
|
|
|
466
472
|
- Stale anchors. `[E_STALE_ANCHOR]` means an anchor is not owned in this session: it was never shown to you, or its line was edited or the file was rewritten since. Call `read` for fresh anchors and retry.
|
|
467
|
-
- Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
|
|
473
|
+
- Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown; a pure deletion only needs its first and last line shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
|
|
468
474
|
- Multi-conversation hosts. Anchors, served records, and ownership logs are resolved per calling session, so a tool call in one conversation is never answered by another conversation's registry; a foreign anchor fails with `[E_STALE_ANCHOR]`. Interactive previews are the one exception: pi does not pass the session into render callbacks, so when one process serves several conversations at once a preview can fall back to the most recently active session and show a stale or wrong-file diff. Previews never write files or claim anchors; run the call for the authoritative result.
|
|
469
475
|
- Undo scope. `undo_last_change` records are keyed by file path, not by session, so in a multi-conversation host any conversation that names the file can revert its most recent `replace` or `insert`, even one made by another conversation. Anchor ownership remains session-scoped; only undo is shared.
|
|
470
476
|
- Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
@@ -524,13 +530,7 @@ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline
|
|
|
524
530
|
|
|
525
531
|
## Benchmark
|
|
526
532
|
|
|
527
|
-
pi-hashline-edit-pro is
|
|
528
|
-
|
|
529
|
-
The current release scores 98.7% in the explorer's harness-family view for `pi-hashline-edit-pro`, with 99.6% or better final exactness on every model route.
|
|
530
|
-
|
|
531
|
-
First exact means the first attempt, with no recovery round, reproduced the expected bytes exactly. Final exact is the same after the benchmark's allowed recovery rounds. Quality is `0.75 × first exact + 0.25 × final exact`, so first-attempt accuracy dominates. The family rollup is the median quality across the complete, eligible model-route configurations scored with this harness; partial and quarantined configurations stay visible in the dataset but do not enter the median.
|
|
532
|
-
|
|
533
|
-
Per-model and per-version numbers, plus the spread behind the headline value, are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest); raw observations and scoring rules are in the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark) and the [methodology](https://github.com/alexshpunt/explicit-edit-benchmark/blob/main/docs/methodology.md). Treat the score as one measurement of this suite at one point in time.
|
|
533
|
+
pi-hashline-edit-pro is scored on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party suite of 226 deterministic, byte-exact editing tasks. Per-model results and the scoring rules are in the [benchmark explorer](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Api-hashline-edit-pro%40latest) and the [dataset](https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark).
|
|
534
534
|
|
|
535
535
|
## Development
|
|
536
536
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -1 +1,2 @@
|
|
|
1
1
|
- `copy`: the same anchor in `source_from` and `source_to` copies one line; copied lines get fresh anchors in the post-edit diff and the source rows keep theirs.
|
|
2
|
+
- `copy`: for a block copy, this tool is the cheap path: only the source's two boundary rows and the destination line need serving, the interior transfers verbatim, and the block lands in one commit. Rebuilding the file with shell commands costs range verification, the post-edit diff, and undo; to append at the end, use the destination's last served line as `insert_after`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- `insert`:
|
|
1
|
+
- `insert`: compare both edges of the inserted block in the post-edit diff against the request; a missing or extra `+anchor│` blank row at either boundary is the classic insert slip.
|
package/prompts/insert.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`. `lines` is one string holding the exact text to insert;
|
|
1
|
+
Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`. `lines` is one string holding the exact text to insert (an empty string inserts one blank line); JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line break sets the last line's ending instead of adding a blank line, so a blank line next to the anchor line must be an extra break in `lines`: `"x\n"` leaves the anchor line directly after `x`, and `"x\n\n"` leaves one blank line between them (`direction: "after"` mirrors this at the start of `lines`).
|
|
2
2
|
|
|
3
|
-
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
3
|
+
Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
|
|
@@ -1 +1,2 @@
|
|
|
1
1
|
- `move`: a cross-file move records one undo entry per file — undo both sides. Lines between source and target may be re-anchored.
|
|
2
|
+
- `move`: for a block move, this tool is the cheap path: only the source's two boundary rows and the destination line need serving, the interior moves verbatim, and rebuilding the file with shell commands costs range verification, the post-edit diff, and undo; to append at the end, use the destination's last served line as `insert_after`.
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
- `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone.
|
|
2
|
+
- `replace`: a pure deletion (`replacement_lines: ""`) is the cheap way to clear a large range: it verifies only the first and last line against the served record and removes the interior as it currently stands.
|
|
3
|
+
- `replace`: to delete several blocks, `anchor_grep` serves each block's first line and a short `read` around each block serves its closing line; then one message of pure-deletion `replace` calls (`remove_from`/`remove_to` per block, `replacement_lines: ""`) removes them all as one batch.
|
|
2
4
|
- `replace`: `replacement_lines` is one string; a pasted `anchor│` prefix is stripped; single line: same anchor for `remove_from` and `remove_to`.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- `replace_within`: use it instead of `replace` to change part of a line, so every character the request does not name is preserved as served.
|
|
2
2
|
- `replace_within`: `replace_old` must be copied exactly from the served row and occur once in the range; a missing or repeated match is refused and returns the current rows.
|
|
3
|
-
- `replace_within`:
|
|
3
|
+
- `replace_within`: when the same `replace_old` occurs many times, `anchor_grep` with `literal: true` serves every matching row in one call, and one `replace_within` per row is cheap — only the named bytes change, so a wrong match costs one line, not a rewritten file.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `replace_old` is the exact text to find inside that range, copied from the served row; it must occur exactly once. `replace_new` replaces just that match, and every other character stays untouched.
|
|
2
2
|
|
|
3
|
-
Example: read served `Hasu│ {"
|
|
3
|
+
Example: read served `Hasu│ {"name": "widget", "size": "small"},`. Call { "replace_from": "Hasu", "replace_to": "Hasu", "replace_old": "small", "replace_new": "large" }. The line becomes ` {"name": "widget", "size": "large"},` and the post-edit diff carries fresh anchors.
|
|
4
4
|
|
|
5
|
-
Both strings are exact text;
|
|
5
|
+
Both strings are exact text; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Matching uses LF line breaks and excludes the last line's terminator. A missing match is refused with the current rows, a repeated match with the matching line numbers, so the retry needs no read. Nothing but the matched text changes.
|
package/prompts/replace.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `replacement_lines` is one string with the exact replacement text: `""` deletes the range, and
|
|
1
|
+
Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `replacement_lines` is one string with the exact replacement text: `""` deletes the range. JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. The text is written exactly as given, and nothing else in the file changes. A trailing line break sets the last line's ending instead of adding a blank line: `"x\n"` leaves the next line directly after `x`, and `"x\n\n"` leaves one blank line between them.
|
|
2
2
|
To change only part of a line without retyping the rest, use `replace_within` instead; it preserves every character the request does not name.
|
|
3
3
|
|
|
4
|
-
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
4
|
+
Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
|
|
5
5
|
|
|
6
6
|
Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "replacement_lines": "new line 1\nnew line 2" }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new line 1`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
|
package/src/batch.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
changedRange,
|
|
13
13
|
lineHashes,
|
|
14
14
|
planEdit,
|
|
15
|
+
preserveDeletionSeparators,
|
|
15
16
|
MAX_HASH_LINES,
|
|
16
17
|
type HEdit,
|
|
17
18
|
type PlannedEdit,
|
|
@@ -121,6 +122,7 @@ type NormalizedEditArgs =
|
|
|
121
122
|
| { kind: "insert"; anchor: string; path?: string };
|
|
122
123
|
|
|
123
124
|
const MAX_TRACKED_BATCHES = 256;
|
|
125
|
+
const BATCH_DISCARDED_NOTE = "Nothing was written; the whole batch was discarded.";
|
|
124
126
|
|
|
125
127
|
const plan = new Map<string, PlannedMember>();
|
|
126
128
|
const batches = new Map<number, BatchState>();
|
|
@@ -346,7 +348,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
346
348
|
content: [
|
|
347
349
|
{
|
|
348
350
|
type: "text",
|
|
349
|
-
text: `In batch ${member.display}`,
|
|
351
|
+
text: `In batch ${member.display} (queued)`,
|
|
350
352
|
},
|
|
351
353
|
],
|
|
352
354
|
details: {
|
|
@@ -364,7 +366,8 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
364
366
|
export function withAbortSuffix(message: string, display: number): string {
|
|
365
367
|
const suffix = `Aborts batch ${display}.`;
|
|
366
368
|
if (message.includes(suffix)) return message;
|
|
367
|
-
|
|
369
|
+
const ended = message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
|
|
370
|
+
return `${ended} ${BATCH_DISCARDED_NOTE}`;
|
|
368
371
|
}
|
|
369
372
|
|
|
370
373
|
const ERROR_CODE_RE = /\[(E_[A-Z0-9_]+)\]/;
|
|
@@ -393,7 +396,9 @@ function firstFailureCause(runtime: BatchState): string | undefined {
|
|
|
393
396
|
const error = runtime.firstError;
|
|
394
397
|
if (!(error instanceof Error)) return undefined;
|
|
395
398
|
const suffix = ` Aborts batch ${runtime.display}.`;
|
|
396
|
-
const
|
|
399
|
+
const discarded = ` ${BATCH_DISCARDED_NOTE}`;
|
|
400
|
+
const withoutDiscarded = error.message.endsWith(discarded) ? error.message.slice(0, -discarded.length) : error.message;
|
|
401
|
+
const message = withoutDiscarded.endsWith(suffix) ? withoutDiscarded.slice(0, -suffix.length) : withoutDiscarded;
|
|
397
402
|
const firstLine = message.split("\n")[0]?.trim() ?? "";
|
|
398
403
|
if (firstLine.length === 0) return undefined;
|
|
399
404
|
if (!firstLine.endsWith(":")) return firstLine;
|
|
@@ -404,10 +409,12 @@ function firstFailureCause(runtime: BatchState): string | undefined {
|
|
|
404
409
|
function abortedBatchMessage(runtime: BatchState): string {
|
|
405
410
|
const failure = runtime.failure;
|
|
406
411
|
if (failure?.code !== undefined) {
|
|
407
|
-
return `[E_OP_ABORTED] Batch ${runtime.display} aborted: [${failure.kind}] Call Nr ${failure.order} errored [${failure.code}]`;
|
|
412
|
+
return `[E_OP_ABORTED] Batch ${runtime.display} aborted: [${failure.kind}] Call Nr ${failure.order} errored [${failure.code}]. ${BATCH_DISCARDED_NOTE}`;
|
|
408
413
|
}
|
|
409
414
|
const cause = firstFailureCause(runtime);
|
|
410
|
-
|
|
415
|
+
if (cause === undefined) return `[E_OP_ABORTED] Batch ${runtime.display} aborted. ${BATCH_DISCARDED_NOTE}`;
|
|
416
|
+
const ended = cause.endsWith(".") || cause.endsWith("!") || cause.endsWith("?") ? cause : `${cause}.`;
|
|
417
|
+
return `[E_OP_ABORTED] Batch ${runtime.display} aborted: ${ended} ${BATCH_DISCARDED_NOTE}`;
|
|
411
418
|
}
|
|
412
419
|
|
|
413
420
|
function batchAbortedError(runtime: BatchState): Error {
|
|
@@ -482,9 +489,10 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
482
489
|
throw error;
|
|
483
490
|
}
|
|
484
491
|
const displayPath = runtime.paths?.displayPath ?? input.targetPath;
|
|
492
|
+
const effectiveHedit = preserveDeletionSeparators(input.hedit, base.baseLines, base.hashes);
|
|
485
493
|
let planned: PlannedEdit;
|
|
486
494
|
try {
|
|
487
|
-
planned = planEdit(base.content,
|
|
495
|
+
planned = planEdit(base.content, effectiveHedit, base.hashes, {
|
|
488
496
|
filePath: displayPath,
|
|
489
497
|
servedHashes: runtime.served,
|
|
490
498
|
signal: input.signal,
|
|
@@ -519,8 +527,8 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
519
527
|
...(carryIndex !== undefined ? { carryIndex } : {}),
|
|
520
528
|
start,
|
|
521
529
|
end,
|
|
522
|
-
fromHash:
|
|
523
|
-
toHash:
|
|
530
|
+
fromHash: planned.resolved.hash_bounds[0].hash,
|
|
531
|
+
toHash: planned.resolved.hash_bounds[1].hash,
|
|
524
532
|
newLines: [...newLines],
|
|
525
533
|
...(separators !== undefined ? { separators } : {}),
|
|
526
534
|
warnings: [...input.extraWarnings, ...planned.warnings],
|
package/src/constants.ts
CHANGED
|
@@ -17,10 +17,10 @@ export const NEW_CONTENT_NOT_STRING_MSG =
|
|
|
17
17
|
`[E_BAD_SHAPE] "replacement_lines" must be a string holding the exact text to write. Use "" to delete the range and "\\n" for one blank line; line breaks inside the string separate lines.`;
|
|
18
18
|
|
|
19
19
|
export const LINES_NOT_STRING_MSG =
|
|
20
|
-
`[E_BAD_SHAPE] "lines" must be a string holding the exact text to insert. Use "
|
|
20
|
+
`[E_BAD_SHAPE] "lines" must be a string holding the exact text to insert. Use "\\n" for one blank line; line breaks inside the string separate lines.`;
|
|
21
21
|
|
|
22
22
|
export const NUL_CONTENT_MSG =
|
|
23
|
-
`[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry. An empty replacement ([]) deletes a range
|
|
23
|
+
`[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry. An empty replacement ([]) deletes a range.`;
|
|
24
24
|
|
|
25
25
|
export const ANCHOR_POOL_EXHAUSTED_PREFIX =
|
|
26
26
|
"[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
|
package/src/copy-move.ts
CHANGED
|
@@ -158,7 +158,8 @@ export function buildTransferEdit(input: {
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
assertRangeVerified(fileLines, preload.fileHashes, insertLine, insertLine, served, displayPath);
|
|
161
|
-
assertRangeVerified(fileLines, preload.fileHashes, sourceStart,
|
|
161
|
+
assertRangeVerified(fileLines, preload.fileHashes, sourceStart, sourceStart, served, displayPath);
|
|
162
|
+
assertRangeVerified(fileLines, preload.fileHashes, sourceEnd, sourceEnd, served, displayPath);
|
|
162
163
|
const sourceLines = fileLines.slice(sourceStart - 1, sourceEnd);
|
|
163
164
|
const sourceEndings = endingsForRange(preload.endingSeparators, sourceStart, sourceEnd);
|
|
164
165
|
if (kind === "copy") {
|
|
@@ -272,7 +273,8 @@ async function prepareCrossTransfer(input: {
|
|
|
272
273
|
throw error;
|
|
273
274
|
}
|
|
274
275
|
try {
|
|
275
|
-
assertRangeVerified(sourceLines, sourcePreload.fileHashes, sourceStart,
|
|
276
|
+
assertRangeVerified(sourceLines, sourcePreload.fileHashes, sourceStart, sourceStart, sourceServed, sourceDisplay);
|
|
277
|
+
assertRangeVerified(sourceLines, sourcePreload.fileHashes, sourceEnd, sourceEnd, sourceServed, sourceDisplay);
|
|
276
278
|
} catch (error) {
|
|
277
279
|
await adopt(sourcePreload.absolutePath, error);
|
|
278
280
|
throw error;
|
|
@@ -567,6 +569,7 @@ async function executeCrossFile(
|
|
|
567
569
|
signal,
|
|
568
570
|
preloadedNorm: prepared.sourcePreload,
|
|
569
571
|
allowEmpty: true,
|
|
572
|
+
preserveDeletionSeparators: false,
|
|
570
573
|
});
|
|
571
574
|
return commitMovePair({
|
|
572
575
|
source: { pipe: sourcePipe, displayPath: prepared.sourceDisplay, absolutePath: source.absolute, mutationTargetPath: source.resolved, foldedAnchorLines: 0 },
|
|
@@ -631,6 +634,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
631
634
|
noPersist: true,
|
|
632
635
|
preloadedNorm: prepared.sourcePreload,
|
|
633
636
|
allowEmpty: true,
|
|
637
|
+
preserveDeletionSeparators: false,
|
|
634
638
|
signal,
|
|
635
639
|
});
|
|
636
640
|
const destinationPreview = previewFromPipe(destinationPipe);
|
package/src/edit-common.ts
CHANGED
|
@@ -38,10 +38,15 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function preferenceGuideline(flags: EditToolFlags): string {
|
|
42
|
+
const tools = gatedEditOps(["read", "replace", "replace_within", "insert", "copy", "move", "undo_last_change"], flags);
|
|
43
|
+
return `Prefer the hashline tools for anything that touches files: ${joinOps(tools, { backtick: true })}.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
export function withReplacePrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
42
47
|
let description = base.description;
|
|
43
48
|
const snippetParts = [base.snippet];
|
|
44
|
-
let guidelines = [...base.guidelines];
|
|
49
|
+
let guidelines = [preferenceGuideline(flags), ...base.guidelines];
|
|
45
50
|
if (!flags.autoRead) {
|
|
46
51
|
description = description.replace(/\n\nExample:[\s\S]*$/, "");
|
|
47
52
|
guidelines = guidelines.filter((guideline) => !guideline.includes("post-edit diff"));
|
|
@@ -64,14 +69,15 @@ export function withReplacePrompts(base: { description: string; snippet: string;
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
72
|
+
const preference = preferenceGuideline(flags);
|
|
67
73
|
if (flags.autoReadAllActive) {
|
|
68
74
|
const rewritten = base.guidelines
|
|
69
75
|
.filter((guideline) => !guideline.includes("call again after an edit"))
|
|
70
|
-
return { description: base.description, snippet: base.snippet, guidelines: [...rewritten] };
|
|
76
|
+
return { description: base.description, snippet: base.snippet, guidelines: [preference, ...rewritten] };
|
|
71
77
|
}
|
|
72
78
|
const withoutAutoReadAll = base.guidelines.filter((guideline) => !guideline.includes("E_AUTO_READ_ALL"))
|
|
73
|
-
if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...withoutAutoReadAll] };
|
|
74
|
-
const guidelines = [...withoutAutoReadAll];
|
|
79
|
+
if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [preference, ...withoutAutoReadAll] };
|
|
80
|
+
const guidelines = [preference, ...withoutAutoReadAll];
|
|
75
81
|
const mapped = guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
|
|
76
82
|
return { description: base.description, snippet: base.snippet, guidelines: mapped };
|
|
77
83
|
}
|
|
@@ -96,7 +102,7 @@ export function withInsertPrompts(base: { description: string; snippet: string;
|
|
|
96
102
|
export function withReplaceWithinPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
97
103
|
const descriptionParts = [base.description];
|
|
98
104
|
const snippetParts = [base.snippet];
|
|
99
|
-
const guidelines = base.guidelines
|
|
105
|
+
const guidelines = [...base.guidelines];
|
|
100
106
|
if (flags.requirePath) {
|
|
101
107
|
descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
|
|
102
108
|
snippetParts.push("; include `path` (required)");
|
package/src/edit-fidelity.ts
CHANGED
|
@@ -1,24 +1,43 @@
|
|
|
1
1
|
import { splitLines } from "./utils";
|
|
2
|
+
import { HASH_SEP, canon } from "./hashline";
|
|
2
3
|
import type { DiffSpan } from "./replace-diff";
|
|
3
4
|
|
|
4
5
|
const INVISIBLE_RE = /\p{Default_Ignorable_Code_Point}/u;
|
|
5
6
|
const LOOKALIKE_SPACE_RE = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/u;
|
|
6
7
|
const LOOKALIKE_DASH_RE = /[\u2010-\u2015\u2212]/u;
|
|
7
8
|
const LOOKALIKE_QUOTE_RE = /[\u2018\u2019\u201c\u201d]/u;
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
0x2060: "word joiner",
|
|
20
|
-
0xfeff: "zero-width no-break space",
|
|
9
|
+
const FULLWIDTH_PUNCT_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
10
|
+
[0xff01, 0xff0f],
|
|
11
|
+
[0xff1a, 0xff20],
|
|
12
|
+
[0xff3b, 0xff40],
|
|
13
|
+
[0xff5b, 0xff5e],
|
|
14
|
+
];
|
|
15
|
+
const IDEOGRAPHIC_PUNCT_SUBSTITUTES: Readonly<Record<string, string>> = {
|
|
16
|
+
"\u3001": ",",
|
|
17
|
+
"\u3002": ".",
|
|
18
|
+
"\uff61": ".",
|
|
19
|
+
"\uff64": ",",
|
|
21
20
|
};
|
|
21
|
+
const FULLWIDTH_PUNCT_SUBSTITUTES: Readonly<Record<string, string>> = Object.fromEntries(
|
|
22
|
+
FULLWIDTH_PUNCT_RANGES.flatMap(([start, end]): Array<[string, string]> =>
|
|
23
|
+
Array.from({ length: end - start + 1 }, (_, offset): [string, string] => {
|
|
24
|
+
const code = start + offset;
|
|
25
|
+
return [String.fromCodePoint(code), String.fromCodePoint(code - 0xfee0)];
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
const LOOKALIKE_PUNCT_RE = new RegExp(
|
|
30
|
+
`[${FULLWIDTH_PUNCT_RANGES.map(([start, end]) => `${String.fromCodePoint(start)}-${String.fromCodePoint(end)}`).join("")}${Object.keys(IDEOGRAPHIC_PUNCT_SUBSTITUTES).join("")}]`,
|
|
31
|
+
"u",
|
|
32
|
+
);
|
|
33
|
+
const MAX_FIDELITY_HINTS = 3;
|
|
34
|
+
const INSERT_REFERENCE_WINDOW = 12;
|
|
35
|
+
const INDENT_REFERENCE_WINDOW = 1;
|
|
36
|
+
const SIMILARITY_RUN = 10;
|
|
37
|
+
const ECHO_TEXT_RE = /[\p{L}\p{N}]/u;
|
|
38
|
+
const INDENT_SIMILARITY_RUN = 14;
|
|
39
|
+
const CALL_HEAD_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\(/;
|
|
40
|
+
const INSERT_GRAM_BUDGET = 200_000;
|
|
22
41
|
|
|
23
42
|
const LOOKALIKE_SUBSTITUTES: Readonly<Record<string, string>> = {
|
|
24
43
|
"\u00a0": " ",
|
|
@@ -48,23 +67,33 @@ const LOOKALIKE_SUBSTITUTES: Readonly<Record<string, string>> = {
|
|
|
48
67
|
"\u2019": "'",
|
|
49
68
|
"\u201c": "\"",
|
|
50
69
|
"\u201d": "\"",
|
|
70
|
+
...FULLWIDTH_PUNCT_SUBSTITUTES,
|
|
71
|
+
...IDEOGRAPHIC_PUNCT_SUBSTITUTES,
|
|
51
72
|
};
|
|
52
73
|
|
|
74
|
+
const REPLACEMENT_CHAR = String.fromCodePoint(0xfffd);
|
|
53
75
|
export function isFidelitySensitiveChar(char: string): boolean {
|
|
54
|
-
return INVISIBLE_RE.test(char) || LOOKALIKE_SPACE_RE.test(char) || LOOKALIKE_DASH_RE.test(char) || LOOKALIKE_QUOTE_RE.test(char);
|
|
76
|
+
return char === REPLACEMENT_CHAR || INVISIBLE_RE.test(char) || LOOKALIKE_SPACE_RE.test(char) || LOOKALIKE_DASH_RE.test(char) || LOOKALIKE_QUOTE_RE.test(char) || LOOKALIKE_PUNCT_RE.test(char);
|
|
55
77
|
}
|
|
56
78
|
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
const hex = `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
60
|
-
const name = CHAR_NAMES[codePoint];
|
|
61
|
-
return name === undefined ? hex : `${hex} (${name})`;
|
|
79
|
+
function formatCodePoint(char: string): string {
|
|
80
|
+
return `U+${char.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
62
81
|
}
|
|
63
82
|
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
function plural(count: number, noun: string): string {
|
|
84
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ReferenceRow {
|
|
88
|
+
line: string;
|
|
89
|
+
index: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hiddenCharHint(char: string, reference: ReferenceRow, anchor: string | undefined): string {
|
|
93
|
+
const column = [...reference.line].indexOf(char) + 1;
|
|
94
|
+
const codePoint = formatCodePoint(char);
|
|
95
|
+
const referenceNote = anchor === undefined ? "" : `; ${anchor}${HASH_SEP} has it`;
|
|
96
|
+
return `[H_UNICODE_LOST] ${codePoint} missing at col ${column}${referenceNote}; resend with ${codePoint} if unintended.`;
|
|
68
97
|
}
|
|
69
98
|
|
|
70
99
|
function withCharsRestored(line: string, chars: readonly string[], replacementFor: (char: string) => string): string {
|
|
@@ -79,30 +108,410 @@ function isDeliberateCharFix(oldLine: string, newLine: string, lostChars: readon
|
|
|
79
108
|
return withCharsRestored(oldLine, lostChars, () => "") === newLine;
|
|
80
109
|
}
|
|
81
110
|
|
|
82
|
-
|
|
111
|
+
interface IndexedLine {
|
|
112
|
+
line: string;
|
|
113
|
+
index: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface SwappedChar {
|
|
117
|
+
oldChar: string;
|
|
118
|
+
newChar: string;
|
|
119
|
+
column: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface SwapCandidate {
|
|
123
|
+
reference: ReferenceRow;
|
|
124
|
+
swapped: SwappedChar;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function containsSensitiveChar(line: string): boolean {
|
|
128
|
+
return line.includes(REPLACEMENT_CHAR) || INVISIBLE_RE.test(line) || LOOKALIKE_SPACE_RE.test(line) || LOOKALIKE_DASH_RE.test(line) || LOOKALIKE_QUOTE_RE.test(line) || LOOKALIKE_PUNCT_RE.test(line);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeSensitiveChars(line: string): string {
|
|
132
|
+
let normalized = "";
|
|
133
|
+
for (const char of line) normalized += isFidelitySensitiveChar(char) ? "?" : char;
|
|
134
|
+
return normalized;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isLookalikeSwap(oldChar: string, newChar: string): boolean {
|
|
138
|
+
if (isFidelitySensitiveChar(oldChar) && isFidelitySensitiveChar(newChar)) return true;
|
|
139
|
+
return LOOKALIKE_SUBSTITUTES[newChar] === oldChar;
|
|
140
|
+
}
|
|
141
|
+
function sharesFidelityClass(left: string, right: string): boolean {
|
|
142
|
+
if (left === REPLACEMENT_CHAR || right === REPLACEMENT_CHAR) return isFidelitySensitiveChar(left) || isFidelitySensitiveChar(right);
|
|
143
|
+
return (
|
|
144
|
+
(INVISIBLE_RE.test(left) && INVISIBLE_RE.test(right)) ||
|
|
145
|
+
(LOOKALIKE_SPACE_RE.test(left) && LOOKALIKE_SPACE_RE.test(right)) ||
|
|
146
|
+
(LOOKALIKE_DASH_RE.test(left) && LOOKALIKE_DASH_RE.test(right)) ||
|
|
147
|
+
(LOOKALIKE_QUOTE_RE.test(left) && LOOKALIKE_QUOTE_RE.test(right)) ||
|
|
148
|
+
(LOOKALIKE_PUNCT_RE.test(left) && LOOKALIKE_PUNCT_RE.test(right)) ||
|
|
149
|
+
LOOKALIKE_SUBSTITUTES[left] === right
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function findSubstitute(char: string, reference: ReferenceRow, payload: readonly string[]): string | undefined {
|
|
154
|
+
const referenceChars = [...reference.line];
|
|
155
|
+
const column = referenceChars.indexOf(char);
|
|
156
|
+
for (const line of payload) {
|
|
157
|
+
const candidateChars = [...line];
|
|
158
|
+
if (candidateChars.length !== referenceChars.length) continue;
|
|
159
|
+
const candidate = candidateChars[column];
|
|
160
|
+
if (candidate !== undefined && candidate !== char && sharesFidelityClass(char, candidate)) return candidate;
|
|
161
|
+
}
|
|
162
|
+
for (const line of payload) {
|
|
163
|
+
for (const candidate of line) {
|
|
164
|
+
if (candidate !== char && sharesFidelityClass(char, candidate)) return candidate;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function swappedCharPair(oldLine: string, newLine: string): SwappedChar | undefined {
|
|
171
|
+
const oldChars = [...oldLine];
|
|
172
|
+
const newChars = [...newLine];
|
|
173
|
+
if (oldChars.length !== newChars.length) return undefined;
|
|
174
|
+
let swapped: SwappedChar | undefined;
|
|
175
|
+
for (let index = 0; index < oldChars.length; index += 1) {
|
|
176
|
+
const oldChar = oldChars[index]!;
|
|
177
|
+
const newChar = newChars[index]!;
|
|
178
|
+
if (oldChar === newChar) continue;
|
|
179
|
+
if (!isLookalikeSwap(oldChar, newChar)) return undefined;
|
|
180
|
+
swapped ??= { oldChar, newChar, column: index + 1 };
|
|
181
|
+
}
|
|
182
|
+
return swapped;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function swappedCandidate(line: string, matches: IndexedLine[], spanStart: number, spanEnd: number): SwapCandidate | undefined {
|
|
186
|
+
const candidates: SwapCandidate[] = [];
|
|
187
|
+
for (const match of matches) {
|
|
188
|
+
if (match.line === line) continue;
|
|
189
|
+
const swapped = swappedCharPair(match.line, line);
|
|
190
|
+
if (swapped === undefined) continue;
|
|
191
|
+
candidates.push({ reference: { line: match.line, index: match.index }, swapped });
|
|
192
|
+
}
|
|
193
|
+
return candidates.find((candidate) => candidate.reference.index >= spanStart && candidate.reference.index <= spanEnd) ?? candidates[0];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function swappedCharHint(swapped: SwappedChar, anchor: string | undefined): string {
|
|
197
|
+
const label = anchor === undefined ? "the replaced line" : `${anchor}${HASH_SEP}`;
|
|
198
|
+
const expected = formatCodePoint(swapped.oldChar);
|
|
199
|
+
return `[H_UNICODE_SWAPPED] ${formatCodePoint(swapped.newChar)} at col ${swapped.column} where ${label} has ${expected}; resend with ${expected} if unintended.`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function trailingWhitespaceHint(oldLine: string, newLine: string, anchor: string | undefined): string {
|
|
203
|
+
const oldTrail = oldLine.length - oldLine.trimEnd().length;
|
|
204
|
+
const newTrail = newLine.length - newLine.trimEnd().length;
|
|
205
|
+
const column = newLine.trimEnd().length + 1;
|
|
206
|
+
const label = anchor === undefined ? "the replaced line" : `${anchor}${HASH_SEP}`;
|
|
207
|
+
return `[H_TRAILING_WHITESPACE] ${plural(newTrail, "trailing whitespace character")} at col ${column}; ${label} had ${oldTrail}.`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function leadingWhitespace(line: string): string {
|
|
211
|
+
const trimmed = line.trimStart();
|
|
212
|
+
return line.slice(0, line.length - trimmed.length);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sameCallHead(payloadLine: string, referenceLine: string): boolean {
|
|
216
|
+
const left = CALL_HEAD_RE.exec(payloadLine.trimStart())?.[0];
|
|
217
|
+
const right = CALL_HEAD_RE.exec(referenceLine.trimStart())?.[0];
|
|
218
|
+
return left !== undefined && left === right;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function indentMismatchHint(payloadLine: string, reference: ReferenceRow, anchor: string | undefined): string | undefined {
|
|
222
|
+
const payloadIndent = leadingWhitespace(payloadLine);
|
|
223
|
+
const referenceIndent = leadingWhitespace(reference.line);
|
|
224
|
+
if (payloadIndent.length >= referenceIndent.length || !referenceIndent.startsWith(payloadIndent)) return undefined;
|
|
225
|
+
const grams = gramSet([payloadLine], INDENT_SIMILARITY_RUN);
|
|
226
|
+
const sharesLongRun = grams !== undefined && sharesRun(reference.line, grams, INDENT_SIMILARITY_RUN);
|
|
227
|
+
if (!sharesLongRun && !sameCallHead(payloadLine, reference.line)) return undefined;
|
|
228
|
+
const label = anchor === undefined ? "the nearby line" : `${anchor}${HASH_SEP}`;
|
|
229
|
+
return `[H_INDENT_MISMATCH] new line has ${plural(payloadIndent.length, "leading whitespace character")}; ${label} has ${referenceIndent.length}.`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function referenceRows(lines: string[], start: number, end: number): ReferenceRow[] {
|
|
233
|
+
const from = Math.max(0, start - INSERT_REFERENCE_WINDOW);
|
|
234
|
+
const to = Math.min(lines.length - 1, end + INSERT_REFERENCE_WINDOW);
|
|
235
|
+
const rows: ReferenceRow[] = [];
|
|
236
|
+
for (let index = from; index <= to; index++) rows.push({ line: lines[index]!, index });
|
|
237
|
+
return rows;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isBlankLine(line: string | undefined): boolean {
|
|
241
|
+
return (line ?? "").trim().length === 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function separatorMovedHint(
|
|
245
|
+
oldLines: string[],
|
|
246
|
+
anchorIndex: number,
|
|
247
|
+
carried: number | undefined,
|
|
248
|
+
payload: string[],
|
|
249
|
+
anchor: string | undefined,
|
|
250
|
+
): string | undefined {
|
|
251
|
+
if (carried === undefined || payload.length < 2) return undefined;
|
|
252
|
+
const anchorLine = oldLines[anchorIndex] ?? "";
|
|
253
|
+
if (anchorLine.trim().length === 0) return undefined;
|
|
254
|
+
let side: "before" | "after";
|
|
255
|
+
if (carried === 0) {
|
|
256
|
+
if (anchorIndex + 1 >= oldLines.length || !isBlankLine(oldLines[anchorIndex + 1])) return undefined;
|
|
257
|
+
if ((payload[0] ?? "").trim().length === 0) return undefined;
|
|
258
|
+
side = "after";
|
|
259
|
+
} else if (carried === payload.length) {
|
|
260
|
+
if (anchorIndex === 0 || !isBlankLine(oldLines[anchorIndex - 1])) return undefined;
|
|
261
|
+
if ((payload[payload.length - 1] ?? "").trim().length === 0) return undefined;
|
|
262
|
+
side = "before";
|
|
263
|
+
} else {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
const label = anchor === undefined ? "the anchor line" : `${anchor}${HASH_SEP}`;
|
|
267
|
+
return `[H_SEPARATOR_MOVED] blank separator ${side === "before" ? "above" : "below"} ${label} was displaced; add a blank line ${side} ${label} if unintended.`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function gramSet(lines: readonly string[], size: number, requireText = false): Set<string> | undefined {
|
|
271
|
+
const grams = new Set<string>();
|
|
272
|
+
let total = 0;
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
total += line.length;
|
|
275
|
+
if (total > INSERT_GRAM_BUDGET) return undefined;
|
|
276
|
+
const chars = [...line];
|
|
277
|
+
for (let index = 0; index + size <= chars.length; index += 1) {
|
|
278
|
+
const gram = chars.slice(index, index + size).join("");
|
|
279
|
+
if (requireText && !ECHO_TEXT_RE.test(gram)) continue;
|
|
280
|
+
grams.add(gram);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return grams;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sharesRun(line: string, grams: Set<string>, size: number): boolean {
|
|
287
|
+
const chars = [...line];
|
|
288
|
+
for (let index = 0; index + size <= chars.length; index += 1) {
|
|
289
|
+
if (grams.has(chars.slice(index, index + size).join(""))) return true;
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const LITERAL_ESCAPE_HINT_PREFIX = "[H_LITERAL_ESCAPE] ";
|
|
295
|
+
const LITERAL_ESCAPE_TEXT_RE = /^\[H_LITERAL_ESCAPE\] [^:]*: "(.*)" written as literal text$/;
|
|
296
|
+
const MAX_LITERAL_ESCAPE_ROWS = 3;
|
|
297
|
+
|
|
298
|
+
function literalEscapeColumn(line: string, escape: string): number {
|
|
299
|
+
const index = line.indexOf(escape);
|
|
300
|
+
return index < 0 ? 1 : [...line.slice(0, index)].length + 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function literalEscapeHintRows(
|
|
304
|
+
resultContent: string,
|
|
305
|
+
spans: readonly DiffSpan[],
|
|
306
|
+
resultHashes: readonly string[],
|
|
307
|
+
escape: string,
|
|
308
|
+
): ReferenceRow[] {
|
|
309
|
+
const newLines = splitLines(resultContent);
|
|
310
|
+
const rows: ReferenceRow[] = [];
|
|
311
|
+
let offset = 0;
|
|
312
|
+
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
|
|
313
|
+
const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
|
|
314
|
+
const inserted = newLines.slice(span.start + offset, span.start + offset + span.replacementCount);
|
|
315
|
+
for (let index = 0; index < inserted.length; index += 1) {
|
|
316
|
+
if (span.carry === index) continue;
|
|
317
|
+
const line = inserted[index]!;
|
|
318
|
+
if (!line.includes(escape)) continue;
|
|
319
|
+
const resultIndex = span.start + offset + index;
|
|
320
|
+
if (resultIndex >= 0 && resultIndex < resultHashes.length) rows.push({ line, index: resultIndex });
|
|
321
|
+
}
|
|
322
|
+
offset += span.replacementCount - removedCount;
|
|
323
|
+
}
|
|
324
|
+
return rows;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function actualEscapeName(escape: string): string {
|
|
328
|
+
const hex = /^\\u([0-9a-fA-F]{4})$/.exec(escape)?.[1];
|
|
329
|
+
if (hex !== undefined) return `U+${hex.toUpperCase()}`;
|
|
330
|
+
if (escape.length === 2) {
|
|
331
|
+
if (escape[1] === "n") return "a line break";
|
|
332
|
+
if (escape[1] === "r") return "a carriage return";
|
|
333
|
+
if (escape[1] === "t") return "a tab";
|
|
334
|
+
if (escape[1] === '"') return "a double quote";
|
|
335
|
+
}
|
|
336
|
+
return "the character";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function annotateLiteralEscapeHints(
|
|
340
|
+
hints: string[],
|
|
341
|
+
resultContent: string,
|
|
342
|
+
spans: readonly DiffSpan[] | undefined,
|
|
343
|
+
resultHashes: readonly string[],
|
|
344
|
+
): string[] {
|
|
345
|
+
if (spans === undefined || spans.length === 0) return hints;
|
|
346
|
+
return hints.map((hint) => {
|
|
347
|
+
if (!hint.startsWith(LITERAL_ESCAPE_HINT_PREFIX)) return hint;
|
|
348
|
+
const escape = LITERAL_ESCAPE_TEXT_RE.exec(hint)?.[1];
|
|
349
|
+
if (escape === undefined || escape.length === 0) return hint;
|
|
350
|
+
const rows = literalEscapeHintRows(resultContent, spans, resultHashes, escape);
|
|
351
|
+
if (rows.length === 0) return hint;
|
|
352
|
+
if (rows.length > MAX_LITERAL_ESCAPE_ROWS) {
|
|
353
|
+
return `${hint} on ${plural(rows.length, "row")}; undo_last_change + resend with ${actualEscapeName(escape)} if unintended.`;
|
|
354
|
+
}
|
|
355
|
+
const locations = rows.map((row) => `${resultHashes[row.index]!}${HASH_SEP} col ${literalEscapeColumn(row.line, escape)}`).join(", ");
|
|
356
|
+
return `${hint} (${locations}); resend with ${actualEscapeName(escape)} if unintended.`;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function separatorLostHint(oldLines: string[], span: DiffSpan, originalHashes?: readonly string[]): string | undefined {
|
|
361
|
+
if (span.replacementCount !== 0 || span.end < span.start) return undefined;
|
|
362
|
+
const beforeIndex = span.start - 1;
|
|
363
|
+
const afterIndex = span.end + 1;
|
|
364
|
+
if (beforeIndex < 0 || afterIndex >= oldLines.length) return undefined;
|
|
365
|
+
for (let index = span.start; index <= span.end; index += 1) {
|
|
366
|
+
if (!isBlankLine(oldLines[index])) return undefined;
|
|
367
|
+
}
|
|
368
|
+
if (isBlankLine(oldLines[beforeIndex]) || isBlankLine(oldLines[afterIndex])) return undefined;
|
|
369
|
+
const before = originalHashes?.[beforeIndex];
|
|
370
|
+
const after = originalHashes?.[afterIndex];
|
|
371
|
+
if (before === undefined || after === undefined) return undefined;
|
|
372
|
+
return `[H_SEPARATOR_LOST] deletion removed ${plural(span.end - span.start + 1, "blank line")} between ${before}${HASH_SEP} and ${after}${HASH_SEP}.`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function fidelityHints(
|
|
376
|
+
originalContent: string,
|
|
377
|
+
resultContent: string,
|
|
378
|
+
spans: readonly DiffSpan[] | undefined,
|
|
379
|
+
originalHashes?: readonly string[],
|
|
380
|
+
options?: { separatorMoved?: boolean; indentHints?: boolean },
|
|
381
|
+
): string[] {
|
|
83
382
|
if (spans === undefined || spans.length === 0) return [];
|
|
84
383
|
const oldLines = splitLines(originalContent);
|
|
85
384
|
const newLines = splitLines(resultContent);
|
|
86
|
-
const
|
|
385
|
+
const hints: string[] = [];
|
|
386
|
+
const seen = new Set<string>();
|
|
387
|
+
let sensitiveIndex: Map<string, IndexedLine[]> | undefined;
|
|
388
|
+
const sensitiveLines = (): Map<string, IndexedLine[]> => {
|
|
389
|
+
if (sensitiveIndex === undefined) {
|
|
390
|
+
sensitiveIndex = new Map();
|
|
391
|
+
for (let index = 0; index < oldLines.length; index += 1) {
|
|
392
|
+
const line = oldLines[index]!;
|
|
393
|
+
if (!containsSensitiveChar(line)) continue;
|
|
394
|
+
const key = normalizeSensitiveChars(line);
|
|
395
|
+
const list = sensitiveIndex.get(key) ?? [];
|
|
396
|
+
list.push({ line, index });
|
|
397
|
+
sensitiveIndex.set(key, list);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return sensitiveIndex;
|
|
401
|
+
};
|
|
87
402
|
let offset = 0;
|
|
88
403
|
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
|
|
89
404
|
const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
|
|
90
405
|
const removed = removedCount > 0 ? oldLines.slice(span.start, span.end + 1) : [];
|
|
91
406
|
const inserted = newLines.slice(span.start + offset, span.start + offset + span.replacementCount);
|
|
92
407
|
if (removed.length > 0 && inserted.length > 0) {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
408
|
+
const carried = span.carry;
|
|
409
|
+
const payload = carried === undefined ? inserted : inserted.filter((_, index) => index !== carried);
|
|
410
|
+
const payloadText = payload.join("\n");
|
|
411
|
+
const references: ReferenceRow[] = carried === undefined
|
|
412
|
+
? removed.map((line, index) => ({ line, index: span.start + index }))
|
|
413
|
+
: referenceRows(oldLines, span.start, span.end);
|
|
414
|
+
const insertGate = carried === undefined ? null : (gramSet(payload, SIMILARITY_RUN, true) ?? new Set<string>());
|
|
415
|
+
const swapKeys = new Set<string>();
|
|
416
|
+
const swaps: SwapCandidate[] = [];
|
|
417
|
+
let candidates: Map<string, IndexedLine[]> | undefined;
|
|
418
|
+
for (const line of payload) {
|
|
419
|
+
if (!containsSensitiveChar(line)) continue;
|
|
420
|
+
candidates ??= sensitiveLines();
|
|
421
|
+
const matches = [...(candidates.get(normalizeSensitiveChars(line)) ?? []), ...references];
|
|
422
|
+
const candidate = swappedCandidate(line, matches, span.start, span.end);
|
|
423
|
+
if (candidate === undefined) continue;
|
|
424
|
+
const key = `${candidate.reference.index}:${candidate.swapped.oldChar}`;
|
|
425
|
+
if (swapKeys.has(key)) continue;
|
|
426
|
+
swapKeys.add(key);
|
|
427
|
+
swaps.push(candidate);
|
|
428
|
+
}
|
|
429
|
+
const missing = new Map<string, ReferenceRow>();
|
|
430
|
+
for (const reference of references) {
|
|
431
|
+
if (insertGate !== null && !sharesRun(reference.line, insertGate, SIMILARITY_RUN)) continue;
|
|
432
|
+
for (const char of reference.line) {
|
|
433
|
+
if (missing.has(char) || !isFidelitySensitiveChar(char) || payloadText.includes(char)) continue;
|
|
434
|
+
if (swapKeys.has(`${reference.index}:${char}`)) continue;
|
|
435
|
+
missing.set(char, reference);
|
|
98
436
|
}
|
|
99
437
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
438
|
+
if (carried === undefined && removed.length === 1 && inserted.length === 1) {
|
|
439
|
+
const chars = [...missing.keys()];
|
|
440
|
+
if (isDeliberateCharFix(removed[0]!, inserted[0]!, chars)) missing.clear();
|
|
103
441
|
}
|
|
442
|
+
for (const [char, reference] of missing) {
|
|
443
|
+
if (seen.has(char) || hints.length >= MAX_FIDELITY_HINTS) continue;
|
|
444
|
+
seen.add(char);
|
|
445
|
+
const substitute = findSubstitute(char, reference, payload);
|
|
446
|
+
if (substitute !== undefined) {
|
|
447
|
+
seen.add(`swap:${reference.index}:${char}`);
|
|
448
|
+
hints.push(swappedCharHint({ oldChar: char, newChar: substitute, column: [...reference.line].indexOf(char) + 1 }, originalHashes?.[reference.index]));
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
hints.push(hiddenCharHint(char, reference, originalHashes?.[reference.index]));
|
|
452
|
+
}
|
|
453
|
+
for (const candidate of swaps) {
|
|
454
|
+
if (hints.length >= MAX_FIDELITY_HINTS) break;
|
|
455
|
+
const key = `swap:${candidate.reference.index}:${candidate.swapped.oldChar}`;
|
|
456
|
+
if (seen.has(key)) continue;
|
|
457
|
+
seen.add(key);
|
|
458
|
+
hints.push(swappedCharHint(candidate.swapped, originalHashes?.[candidate.reference.index]));
|
|
459
|
+
}
|
|
460
|
+
const indentReferenceFor = (payloadIndex: number): ReferenceRow | undefined => {
|
|
461
|
+
if (options?.indentHints === false) return undefined;
|
|
462
|
+
if (carried !== undefined) {
|
|
463
|
+
let nearest: ReferenceRow | undefined;
|
|
464
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
465
|
+
for (const candidate of references) {
|
|
466
|
+
if (candidate.index < span.start - INDENT_REFERENCE_WINDOW || candidate.index > span.end + INDENT_REFERENCE_WINDOW) continue;
|
|
467
|
+
if (indentMismatchHint(payload[payloadIndex]!, candidate, undefined) === undefined) continue;
|
|
468
|
+
const distance = Math.abs(candidate.index - span.start);
|
|
469
|
+
if (distance < nearestDistance) {
|
|
470
|
+
nearest = candidate;
|
|
471
|
+
nearestDistance = distance;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return nearest;
|
|
475
|
+
}
|
|
476
|
+
if (removed.length === payload.length) {
|
|
477
|
+
return { line: removed[payloadIndex]!, index: span.start + payloadIndex };
|
|
478
|
+
}
|
|
479
|
+
return undefined;
|
|
480
|
+
};
|
|
481
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
482
|
+
if (hints.length >= MAX_FIDELITY_HINTS) break;
|
|
483
|
+
const payloadLine = payload[index]!;
|
|
484
|
+
const reference = indentReferenceFor(index);
|
|
485
|
+
if (reference === undefined) continue;
|
|
486
|
+
const indentHint = indentMismatchHint(payloadLine, reference, originalHashes?.[reference.index]);
|
|
487
|
+
if (indentHint === undefined) continue;
|
|
488
|
+
const key = `indent:${reference.index}:${payloadLine}`;
|
|
489
|
+
if (seen.has(key)) continue;
|
|
490
|
+
seen.add(key);
|
|
491
|
+
hints.push(indentHint);
|
|
492
|
+
}
|
|
493
|
+
if (options?.separatorMoved) {
|
|
494
|
+
const separatorHint = separatorMovedHint(oldLines, span.start, carried, payload, originalHashes?.[span.start]);
|
|
495
|
+
if (separatorHint !== undefined && hints.length < MAX_FIDELITY_HINTS) hints.push(separatorHint);
|
|
496
|
+
}
|
|
497
|
+
if (carried === undefined && removed.length === inserted.length) {
|
|
498
|
+
for (let index = 0; index < removed.length; index += 1) {
|
|
499
|
+
if (hints.length >= MAX_FIDELITY_HINTS) break;
|
|
500
|
+
const oldLine = removed[index]!;
|
|
501
|
+
const newLine = inserted[index]!;
|
|
502
|
+
if (oldLine === newLine || canon(oldLine) !== canon(newLine)) continue;
|
|
503
|
+
const key = `trailing:${span.start + index}`;
|
|
504
|
+
if (seen.has(key)) continue;
|
|
505
|
+
seen.add(key);
|
|
506
|
+
hints.push(trailingWhitespaceHint(oldLine, newLine, originalHashes?.[span.start + index]));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (removed.length > 0 && inserted.length === 0) {
|
|
511
|
+
const lost = separatorLostHint(oldLines, span, originalHashes);
|
|
512
|
+
if (lost !== undefined && hints.length < MAX_FIDELITY_HINTS) hints.push(lost);
|
|
104
513
|
}
|
|
105
514
|
offset += span.replacementCount - removedCount;
|
|
106
515
|
}
|
|
107
|
-
return
|
|
516
|
+
return hints;
|
|
108
517
|
}
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/resolve.ts
CHANGED
|
@@ -272,6 +272,27 @@ export function swapReversedRanges(
|
|
|
272
272
|
return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
export function preserveDeletionSeparators(edit: HEdit, fileLines: string[], fileHashes: string[]): HEdit {
|
|
276
|
+
if (edit.content_lines.length > 0) return edit;
|
|
277
|
+
const lineByHash = new Map<string, number>();
|
|
278
|
+
for (let index = 0; index < fileHashes.length; index++) {
|
|
279
|
+
const hash = fileHashes[index]!;
|
|
280
|
+
if (!lineByHash.has(hash)) lineByHash.set(hash, index);
|
|
281
|
+
}
|
|
282
|
+
const fromLine = lineByHash.get(edit.hash_bounds[0].hash);
|
|
283
|
+
const toLine = lineByHash.get(edit.hash_bounds[1].hash);
|
|
284
|
+
if (fromLine === undefined || toLine === undefined) return edit;
|
|
285
|
+
const rangeStart = Math.min(fromLine, toLine);
|
|
286
|
+
const rangeEnd = Math.max(fromLine, toLine);
|
|
287
|
+
const isBlank = (index: number): boolean => (fileLines[index] ?? "").trim().length === 0;
|
|
288
|
+
let start = rangeStart;
|
|
289
|
+
let end = rangeEnd;
|
|
290
|
+
while (start <= end && isBlank(start)) start += 1;
|
|
291
|
+
while (end >= start && isBlank(end)) end -= 1;
|
|
292
|
+
if (start > end || (start === rangeStart && end === rangeEnd)) return edit;
|
|
293
|
+
return { ...edit, hash_bounds: [{ hash: fileHashes[start]! }, { hash: fileHashes[end]! }] };
|
|
294
|
+
}
|
|
295
|
+
|
|
275
296
|
export function valEdit(
|
|
276
297
|
edit: HEdit,
|
|
277
298
|
fileLines: string[],
|
|
@@ -380,10 +401,12 @@ export function assertRangeServed(
|
|
|
380
401
|
const startLine = resolved.hash_bounds[0].line;
|
|
381
402
|
const endLine = resolved.hash_bounds[1].line;
|
|
382
403
|
const mismatchLines: number[] = [];
|
|
404
|
+
const deletion = resolved.content_lines.length === 0;
|
|
383
405
|
for (let line = startLine; line <= endLine; line++) {
|
|
384
406
|
const hash = fileHashes[line - 1]!;
|
|
385
407
|
const content = fileLines[line - 1]!;
|
|
386
408
|
const servedContent = served?.get(hash);
|
|
409
|
+
if (servedContent === undefined && deletion && line !== startLine && line !== endLine) continue;
|
|
387
410
|
if (servedContent === undefined || servedContent !== lineChecksum(content)) mismatchLines.push(line);
|
|
388
411
|
}
|
|
389
412
|
if (mismatchLines.length === 0) return;
|
package/src/insert.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { stripAnchorRow } from "./hashline/resolve";
|
|
|
11
11
|
import { withAnchorSession } from "./anchor-registry";
|
|
12
12
|
import { loadP, loadGuide } from "./prompts";
|
|
13
13
|
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
14
|
-
import { isRec,
|
|
14
|
+
import { isRec, literalEscapeHints, splitLines } from "./utils";
|
|
15
15
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
16
16
|
import type { RPreview, RRState } from "./replace-render";
|
|
17
17
|
export { assertInsertReq, type InsertReq };
|
|
@@ -27,7 +27,7 @@ const insertDirectionSchema = Type.Union(
|
|
|
27
27
|
);
|
|
28
28
|
const insertLinesSchema = Type.String({
|
|
29
29
|
description:
|
|
30
|
-
'The exact text to insert
|
|
30
|
+
'The exact text to insert; an empty string inserts one blank line. "\\n" is one blank line, and a trailing line break sets the last line\'s ending instead of adding a blank line. Never include the anchor line.',
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
const insertPathRequiredSchema = Type.String({
|
|
@@ -70,7 +70,7 @@ export function buildInsertEdit(
|
|
|
70
70
|
ref: Anchor,
|
|
71
71
|
path: string,
|
|
72
72
|
): { editParams: HTEdit; anchorLine: string | undefined; contentSeparators: (LineEnding | undefined)[] } {
|
|
73
|
-
const parsed = parsePayloadText(req.lines);
|
|
73
|
+
const parsed = parsePayloadText(req.lines.length === 0 ? "\n" : req.lines);
|
|
74
74
|
const fileLines = splitLines(preload.normalized);
|
|
75
75
|
const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, path);
|
|
76
76
|
const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
|
|
@@ -177,9 +177,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
177
177
|
const canonical = normReq(params);
|
|
178
178
|
assertInsertReq(canonical);
|
|
179
179
|
const req = canonical;
|
|
180
|
-
const insertWarnings: string[] = [];
|
|
181
|
-
const literalEscape = literalEscapeHint([req.lines], "lines");
|
|
182
|
-
if (literalEscape !== undefined) insertWarnings.push(literalEscape);
|
|
180
|
+
const insertWarnings: string[] = [...literalEscapeHints([req.lines], "lines")];
|
|
183
181
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
184
182
|
anchor: req.anchor,
|
|
185
183
|
providedPath: req.path,
|
package/src/prompts.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { errCode } from "./utils";
|
|
2
3
|
|
|
3
4
|
export function loadP(relativePath: string): string {
|
|
4
5
|
return readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export function loadGuide(relativePath: string): string[] {
|
|
8
|
-
|
|
9
|
+
let raw: string;
|
|
10
|
+
try {
|
|
11
|
+
raw = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (errCode(error) === "ENOENT") {
|
|
14
|
+
console.warn(`hashline: missing prompt file ${relativePath}; continuing without those guidelines`);
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
return raw
|
|
9
20
|
.split("\n")
|
|
10
21
|
.map((line) => line.trim())
|
|
11
22
|
.filter((line) => line.startsWith("- "))
|
package/src/replace-render.ts
CHANGED
|
@@ -380,7 +380,7 @@ export function renderEditResult(
|
|
|
380
380
|
if (abortedMessage !== undefined) {
|
|
381
381
|
return reuseText(context, `\n${highlightBatchRefs(abortedMessage, theme)}`);
|
|
382
382
|
}
|
|
383
|
-
return reuseText(context, theme.fg("warning", `In batch ${batch.id}`));
|
|
383
|
+
return reuseText(context, theme.fg("warning", `In batch ${batch.id} (queued)`));
|
|
384
384
|
}
|
|
385
385
|
if (context.isError) {
|
|
386
386
|
return renderedText
|
package/src/replace-response.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { NEdit } from "./hashline";
|
|
|
2
2
|
import type { ReplaceDetails } from "./replace";
|
|
3
3
|
import { genDiff, genPatch, type DiffSpan } from "./replace-diff";
|
|
4
4
|
import { visLines, clipLine } from "./utils";
|
|
5
|
-
import { fidelityHints } from "./edit-fidelity";
|
|
5
|
+
import { annotateLiteralEscapeHints, fidelityHints } from "./edit-fidelity";
|
|
6
6
|
|
|
7
7
|
export type TResult = {
|
|
8
8
|
content: Array<{ type: "text"; text: string }>;
|
|
@@ -140,9 +140,10 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
140
140
|
const diffResult = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes, undefined, spans);
|
|
141
141
|
const addedLines = editMeta.addedLines;
|
|
142
142
|
const removedLines = editMeta.removedLines;
|
|
143
|
-
const fidelity = fidelityHints(originalNormalized, result, spans);
|
|
143
|
+
const fidelity = fidelityHints(originalNormalized, result, spans, originalHashes, { separatorMoved: verb === "inserted" || verb === "edited", indentHints: verb !== "copied" && verb !== "moved" });
|
|
144
144
|
const { warnings: noticeWarnings, hints } = splitNotices(fidelity.length > 0 ? [...(warnings ?? []), ...fidelity] : warnings);
|
|
145
|
-
const
|
|
145
|
+
const annotatedHints = annotateLiteralEscapeHints(hints, result, spans, resultHashes);
|
|
146
|
+
const noticesBlock = `${warnBlock(noticeWarnings)}${hintBlock(annotatedHints)}`;
|
|
146
147
|
const successPrefix = `Successfully ${verb} in ${path}.`;
|
|
147
148
|
const lineSummary = addedLines > 0 || removedLines > 0
|
|
148
149
|
? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
|
|
@@ -177,7 +178,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
177
178
|
metrics,
|
|
178
179
|
diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
|
|
179
180
|
...(noticeWarnings.length ? { warnings: [...noticeWarnings] } : {}),
|
|
180
|
-
...(
|
|
181
|
+
...(annotatedHints.length ? { hints: [...annotatedHints] } : {}),
|
|
181
182
|
},
|
|
182
183
|
};
|
|
183
184
|
}
|
package/src/replace-within.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
normalizeReplaceWithinRequest,
|
|
14
14
|
type ReplaceWithinReq,
|
|
15
15
|
} from "./payload-contract";
|
|
16
|
-
import {
|
|
16
|
+
import { literalEscapeHints, splitLines } from "./utils";
|
|
17
17
|
import { toLF } from "./normalize";
|
|
18
18
|
import { MAX_RANGE_STALE_LINES } from "./constants";
|
|
19
19
|
import {
|
|
@@ -178,10 +178,7 @@ export function buildReplaceWithinToolDef(flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
178
178
|
const req = normalized;
|
|
179
179
|
const { refs, warnings } = parseWithinAnchors(req);
|
|
180
180
|
await throwIfStrictInput(warnings);
|
|
181
|
-
const hints = [
|
|
182
|
-
literalEscapeHint([req.replace_old], "replace_old"),
|
|
183
|
-
literalEscapeHint([req.replace_new], "replace_new"),
|
|
184
|
-
].filter((hint): hint is string => hint !== undefined);
|
|
181
|
+
const hints = [...literalEscapeHints([req.replace_old], "replace_old"), ...literalEscapeHints([req.replace_new], "replace_new")];
|
|
185
182
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
186
183
|
removeFrom: req.replace_from,
|
|
187
184
|
removeTo: req.replace_to,
|
package/src/replace.ts
CHANGED
|
@@ -10,12 +10,13 @@ import {
|
|
|
10
10
|
} from "./replace-diff";
|
|
11
11
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
12
12
|
import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
|
-
import {
|
|
13
|
+
import { literalEscapeHints, splitLines } from "./utils";
|
|
14
14
|
import { loadP, loadGuide } from "./prompts";
|
|
15
15
|
import { type FileIdentity } from "./fs-write";
|
|
16
16
|
import { applyEdit,
|
|
17
17
|
lineHashes,
|
|
18
18
|
resEdit,
|
|
19
|
+
preserveDeletionSeparators,
|
|
19
20
|
MAX_HASH_LINES,
|
|
20
21
|
RangeStaleError,
|
|
21
22
|
AnchorMismatchError,
|
|
@@ -84,6 +85,7 @@ export interface ExecPipelineOptions {
|
|
|
84
85
|
allowEmpty?: boolean;
|
|
85
86
|
stripWarning?: StripWarningLocation;
|
|
86
87
|
endingOverrides?: (LineEnding | undefined)[];
|
|
88
|
+
preserveDeletionSeparators?: boolean;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
|
|
@@ -157,12 +159,15 @@ export async function execPipeline(
|
|
|
157
159
|
targetPath, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, allocation: options?.noPersist ? "shadow" : "real", preloadedNorm: options?.preloadedNorm },
|
|
158
160
|
);
|
|
159
161
|
const displayPath = toDisplayPath(cwd, absolutePath, targetPath);
|
|
162
|
+
const effectiveEdit = options?.preserveDeletionSeparators === false
|
|
163
|
+
? anchoredEdit
|
|
164
|
+
: preserveDeletionSeparators(anchoredEdit, splitLines(originalNormalized), originalHashes);
|
|
160
165
|
|
|
161
166
|
let anchorResult: ReturnType<typeof applyEdit>;
|
|
162
167
|
try {
|
|
163
168
|
anchorResult = applyEdit(
|
|
164
169
|
originalNormalized,
|
|
165
|
-
|
|
170
|
+
effectiveEdit,
|
|
166
171
|
options?.signal,
|
|
167
172
|
originalHashes,
|
|
168
173
|
displayPath,
|
|
@@ -187,10 +192,10 @@ export async function execPipeline(
|
|
|
187
192
|
const warnings = [...editWarnings, ...(anchorResult.warnings ?? [])];
|
|
188
193
|
await throwIfStrictInput(warnings);
|
|
189
194
|
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|
|
190
|
-
|
|
195
|
+
effectiveEdit, originalHashes, isNoop,
|
|
191
196
|
);
|
|
192
197
|
|
|
193
|
-
const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes,
|
|
198
|
+
const pipeSpan = isNoop ? undefined : spanForEdit(originalHashes, effectiveEdit.hash_bounds[0].hash, effectiveEdit.hash_bounds[1].hash, result);
|
|
194
199
|
const pipeSpans = pipeSpan ? [pipeSpan] : undefined;
|
|
195
200
|
return {
|
|
196
201
|
path: displayPath,
|
|
@@ -210,7 +215,7 @@ export async function execPipeline(
|
|
|
210
215
|
totalRemovedLines,
|
|
211
216
|
identity,
|
|
212
217
|
...(pipeSpans ? { spans: pipeSpans } : {}),
|
|
213
|
-
...(
|
|
218
|
+
...(effectiveEdit.content_separators !== undefined ? { contentSeparators: effectiveEdit.content_separators } : {}),
|
|
214
219
|
};
|
|
215
220
|
}
|
|
216
221
|
|
|
@@ -285,7 +290,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
285
290
|
const canonical = normReq(params);
|
|
286
291
|
assertReq(canonical);
|
|
287
292
|
const normalizedParams = canonical;
|
|
288
|
-
const
|
|
293
|
+
const literalEscapes = literalEscapeHints([normalizedParams.replacement_lines], "replacement_lines");
|
|
289
294
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
290
295
|
removeFrom: normalizedParams.remove_from,
|
|
291
296
|
removeTo: normalizedParams.remove_to,
|
|
@@ -310,7 +315,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
310
315
|
absolutePath,
|
|
311
316
|
mutationTargetPath,
|
|
312
317
|
editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
|
|
313
|
-
prefixWarnings:
|
|
318
|
+
prefixWarnings: literalEscapes,
|
|
314
319
|
signal,
|
|
315
320
|
});
|
|
316
321
|
}
|
|
@@ -329,7 +334,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
329
334
|
cwd: ctx.cwd,
|
|
330
335
|
signal,
|
|
331
336
|
hedit: built.edit,
|
|
332
|
-
extraWarnings: [...
|
|
337
|
+
extraWarnings: [...literalEscapes, ...built.warnings],
|
|
333
338
|
});
|
|
334
339
|
});
|
|
335
340
|
});
|
package/src/utils.ts
CHANGED
|
@@ -380,6 +380,7 @@ function normalizeEditLines(record: Record<string, unknown>): void {
|
|
|
380
380
|
const LITERAL_ESCAPE_RE = /\\(?:u([0-9a-fA-F]{4})|([ntr"]))/g;
|
|
381
381
|
|
|
382
382
|
const REAL_LINE_BREAK_RE = /[\n\r]/;
|
|
383
|
+
const MAX_LITERAL_ESCAPE_HINTS = 3;
|
|
383
384
|
|
|
384
385
|
function isSurrogateEscapePair(line: string, index: number, hex: string): boolean {
|
|
385
386
|
const code = Number.parseInt(hex, 16);
|
|
@@ -398,7 +399,9 @@ function isSurrogateEscapePair(line: string, index: number, hex: string): boolea
|
|
|
398
399
|
return false;
|
|
399
400
|
}
|
|
400
401
|
|
|
401
|
-
export function
|
|
402
|
+
export function literalEscapeHints(lines: string[], label: string): string[] {
|
|
403
|
+
const hints: string[] = [];
|
|
404
|
+
const seen = new Set<string>();
|
|
402
405
|
for (const line of lines) {
|
|
403
406
|
if (!line.includes("\\")) continue;
|
|
404
407
|
const hasRealBreak = REAL_LINE_BREAK_RE.test(line);
|
|
@@ -410,8 +413,12 @@ export function literalEscapeHint(lines: string[], label: string): string | unde
|
|
|
410
413
|
if (hex.toLowerCase() === "dddd") continue;
|
|
411
414
|
if (isSurrogateEscapePair(line, match.index, hex)) continue;
|
|
412
415
|
}
|
|
413
|
-
|
|
416
|
+
const text = match[0];
|
|
417
|
+
if (seen.has(text)) continue;
|
|
418
|
+
seen.add(text);
|
|
419
|
+
hints.push(`[H_LITERAL_ESCAPE] ${label}: "${text}" written as literal text`);
|
|
420
|
+
if (hints.length >= MAX_LITERAL_ESCAPE_HINTS) return hints;
|
|
414
421
|
}
|
|
415
422
|
}
|
|
416
|
-
return
|
|
423
|
+
return hints;
|
|
417
424
|
}
|