@d3ara1n/pi-hashline-edit 0.5.0 → 0.5.1

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # @d3ara1n/pi-hashline-edit
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@d3ara1n/pi-hashline-edit)](https://www.npmjs.com/package/@d3ara1n/pi-hashline-edit) [![npm downloads](https://img.shields.io/npm/dm/@d3ara1n/pi-hashline-edit)](https://www.npmjs.com/package/@d3ara1n/pi-hashline-edit) [![license](https://img.shields.io/npm/l/@d3ara1n/pi-hashline-edit)](https://www.npmjs.com/package/@d3ara1n/pi-hashline-edit) [![Explicit Edit Benchmark](https://img.shields.io/endpoint?url=https://huggingface.co/datasets/alexshpunt/explicit-edit-benchmark/resolve/main/badges/d3ara1n-pi-hashline-edit.json&style=flat-square)](https://huggingface.co/spaces/alexshpunt/benchmark-explorer?card=harness%3Ad3ara1n-pi-hashline-edit%40latest)
4
+
3
5
  > Hashline-style file editing for [pi](https://github.com/earendil-works/pi-coding-agent) — line-anchored edits verified by content hash (replacing `oldText`/`newText` matching), plus a location-blind `replace` tool for bulk + regex transforms.
4
6
 
5
7
  Edits reference lines by `LINE#HASH` anchors (copied from `read`/`grep` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
@@ -24,21 +26,27 @@ Routine local code editing in pi — the common case. If you spend turns fightin
24
26
 
25
27
  Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`edit` and plain `grep` calls delegate to the built-ins (`grep` calls using the extended params below still run locally, formatted without anchors) and `replace` refuses (it has no built-in counterpart).
26
28
 
27
- ## Model compatibility — field notes
29
+ ## Model Compatibility
30
+
31
+ Hashline replaces the edit protocol the main model was trained on, so real-world reliability depends on the model more than on anything else. Field observations from real sessions, one family per subsection (June 2026, single environment — directional, not benchmarks; vendors iterate fast, re-test on new releases).
32
+
33
+ ### DeepSeek family
34
+
35
+ **Avoid — silent corruption.** Weak tool-call construction (tested: DeepSeek V4 Flash): ~50% of edits fail on the built-in string-replace, and each fix takes several more rounds — string-replace failures are *divergent*, the model retries from the same wrong memory, but at least they are loud. Hashline's rejected anchors *converge* — a mismatched anchor returns the live content plus a ready-to-resend `LINE#HASH`, so one retry closes the loop — but the raw failure rate is high (~80%), and hashline adds a failure class the built-in edit doesn't have.
36
+
37
+ `insert_after` semantics invite wrong parameters even when the tool call itself is well-formed: the model fills `body` string-replace-style, copying the anchor line into it (observed on DeepSeek V4 Flash). The toolcall verifies and succeeds, and the line ends up duplicated. Nothing at the tool layer can catch this — the anchor is valid, the model's *intent* was wrong, and no prompt wording cures it (the schema description already forbids the copy). For a weak model, hashline effectively trades loud failures for silent ones: files come out corrupted edit by edit. Keep the plugin off for this profile; if you must run it, review the diff after every edit.
38
+
39
+ ### Kimi family
40
+
41
+ **Turn the plugin off.** Strong, but not trained on hashline (tested: Kimi K3): built-in string-replace is excellent while hashline draws frequent anchor mistakes. Hashline assumes anchor discipline — copy hashes verbatim from read output, never invent one; a model that hasn't internalized that fabricates anchors no matter how capable. When a strong model keeps hitting anchor errors, the fastest fix is disabling the plugin, not more retries.
28
42
 
29
- Real-world reliability depends on the model more than on anything else. Field observations from real sessions (June 2026, single environment — directional, not benchmarks; vendors iterate fast, re-test on new releases):
43
+ ### GLM family
30
44
 
31
- | Model profile | Tested example | Built-in string-replace | hashline-edit | Recommendation |
32
- |---|---|---|---|---|
33
- | Weak tool-call construction | DeepSeek V4 Flash | ~50% of edits fail; each fix takes several more `edit` rounds | ~80% of edits fail, but each failure converges in **one** retry | Keep hashline on — fewer total round-trips despite the higher failure rate |
34
- | Strong, but not trained on hashline | Kimi K3 | Excellent | Frequent anchor mistakes | Turn the plugin off — the built-in `edit` serves this profile better |
35
- | Strong, follows the schema as given | GLM 5.2 | Occasional not-found / whitespace friction | 100% — the friction disappears | The intended pairing |
45
+ **The intended pairing.** Strong and follows the schema as given (tested: GLM 5.2): the occasional not-found / whitespace friction of built-in string-replace disappears 100% in testing. Capable models never needed the wording in the first place: GLM used `insert_after` correctly even when the tool description didn't explain the op at all. The mismatch lives in the model, not the tool.
36
46
 
37
- What these numbers actually say:
47
+ ### GPT family
38
48
 
39
- - **A high failure rate on weak models is not a hashline problem.** DeepSeek V4 Flash fails at ~50% even on plain string-replace the root cause is misremembered file content, and no edit protocol fixes that. What changes is the *shape* of a failure: string-replace failures are divergent (the model retries from the same wrong memory, so fixes take multiple rounds), while hashline failures are convergent (a mismatched anchor returns the live content plus a ready-to-resend `LINE#HASH`, so one retry closes the loop without trusting the model's memory).
40
- - **Strong ≠ automatic win.** Hashline assumes anchor discipline — copy hashes verbatim from read output, never invent one. A model that hasn't internalized that will fabricate anchors no matter how capable it is. If a strong model keeps hitting anchor errors, the fastest fix is disabling the plugin, not more retries.
41
- - **Some errors are invisible to any edit protocol.** On weak models `insert_after` is sometimes misread as string-replace-style: the model copies the anchor line into `body` (observed on DeepSeek V4 Flash), the toolcall verifies and succeeds, and the line ends up duplicated in the file. Nothing at the tool layer can catch this — the anchor is valid; the model's *intent* was wrong, and no amount of prompt wording cures it (the schema description already forbids the copy). Capable models never needed the wording in the first place: GLM used `insert_after` correctly back when the tool description didn't explain the op at all. The mismatch lives in the model, not the tool.
49
+ **No reservations.** Structured ops are home turf for this family (tested: GPT 5.6 sol/terra/luna): no fabricated or mistyped anchors observed, and `insert_after` was never misused the anchor-line-into-`body` duplication (see DeepSeek above) never occurred. The few rejected anchors were the *expected* kind: a size-changing `edit` invalidating the hashes of the lines below it between edits the documented hash-drift tradeoff rather than a model error, and exactly what the self-healing rescan exists to rescue. Even those ran fewer than expected; small-sample observation, take it directionally.
42
50
 
43
51
  ## Gotchas (vs. the built-in `read`/`edit`)
44
52
 
@@ -67,8 +75,68 @@ A separate, location-blind tool for transforms `edit` can't express: replace **a
67
75
  - **Shifted-anchor recovery**: a mismatched anchor isn't a dead end. The applicator rescans ±`shiftRadius` lines for the original content — holding the original line number fixed and re-hashing each candidate (`hash(line, candidate) === cited` iff the candidate *is* the original) — and returns a ready-to-resend anchor on a unique hit, the candidate list when ambiguous, or the cited line's live content when nothing matches. The model retries without a re-read in the common drift case.
68
76
  - **Atomic batches, all failures collected**: every op in one `edit` is verified against the same snapshot; if any anchor fails, *all* failures (each with its recovery) are returned together and nothing is written — partial writes would shift lines and invalidate the very recovery info just returned.
69
77
  - **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` for the lines it produced (and the line that shifted into a deletion gap), so the next edit can cite them directly.
78
+ - **Byte-faithful writes**: `edit` rewrites only the lines you name. CRLF files keep CRLF, and a file whose last line has no terminator does not gain one — the state `splitLines` discards is captured before the edit and restored after it. `read` states the fact in its header (`· no trailing newline`), since the numbered rows cannot show it.
70
79
  - **No legacy compatibility on `edit`**: `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades) — so you always know whether hashline is actually in use. Bulk/regex replacement is a *separate* tool, `replace`, not an `edit` mode (see below).
71
80
 
81
+ ## Why line hashes, not file tags
82
+
83
+ ### The short version
84
+
85
+ This plugin is a line-hash editor: every line comes back tagged with a content hash that folds in its line number, and you edit by citing that `LINE#HASH`. That is, deliberately, the *original* hashline idea — the one omp shipped in February 2026 and then walked away from. omp's current engine anchors on a **whole-file** hash instead, and has since mid-2026. I looked at that route and stayed on the line-hash one on purpose. What follows is my case for that choice, stated as a tradeoff rather than a verdict — the honest limitations, including the ones that argue for the other route, come right after.
86
+
87
+ ### Where this comes from
88
+
89
+ The idea that a model should edit by pointing at a **stable, verifiable anchor** instead of retyping code it already saw is not mine — it is can1357's, argued in [*The Harness Problem*](https://blog.can.ac/2026/02/12/the-harness-problem/) (2026-02-12), and omp's first implementation was exactly this shape: every read line tagged with a short per-line content hash, edits expressed as structured `old`/`new` ops. That first design had a real problem, and omp and I fixed it in opposite directions.
90
+
91
+ **Identical lines collide.** A per-line *content* hash gives every `}`, every `)`, every blank line the same hash — and a hash that is not unique is not an anchor. omp's fix was to stop hashing lines and hash the **whole file** instead: a 4-hex file tag, with no per-line hash in `read` output at all. My fix was to fold the line number into each line's hash, so two identical lines at different positions can never share one — unique by construction, no file-level state required.
92
+
93
+ That single divergence cascades into everything else. (It also means the picture most people have of "hashline" is the line-hash one: omp's own docs site still describes per-line anchors that its current implementation no longer emits.)
94
+
95
+ ### The case against the file-tag route
96
+
97
+ I can't speak to omp's motives for the switch — collision-proofing a per-line hash is hard, and the file tag sidesteps it entirely. But the clearest *payoff* I see in a whole-file tag is one my design gives up: **awareness of edits made by anyone else.** If another process touches the file, its whole-file tag changes, the next anchored edit is rejected, and the model is forced to re-read fresh state. For a swarm of agents editing one tree, that is a genuinely good property. It costs three things I wasn't willing to pay:
98
+
99
+ - **The tag can't tell whether the model actually *read* a line.** A whole-file hash proves the file is byte-for-byte what it was — not that the model ever saw the specific line it is now rewriting. To stop hallucinated edits on never-displayed lines, omp carries a separate `seenLines` guard alongside the tag. A per-line hash *is* the proof of having-seen: you cannot cite `LINE#HASH` without having read that line's content. No side array needed.
100
+ - **It needs out-of-band state.** A 16-bit file tag "is not meaningful outside that store" (omp's own words) — it can collide, so omp keeps a `SnapshotStore`, an LRU of recent file versions, to disambiguate. My hashes are content-derived and self-contained: an anchor is verified by rehashing the current line at that number, at apply time, against nothing but the file on disk. **This plugin has no SnapshotStore, no seenLines array, nothing to keep in sync.** That is the point, not an omission.
101
+ - **The verification is coarse.** A whole-file tag couples every edit to the entire file: an unrelated change *anywhere* invalidates the tag and drags the whole patch through recovery. Line hashes verify only the lines you cite — an unrelated edit elsewhere never blocks you.
102
+
103
+ ### Why a JSON schema, not a text DSL
104
+
105
+ The other visible difference: omp delivers edits as a **text patch language** (`PUT`/`CUT`/`REM`/`MV` today, `SWAP`/`DEL`/`INS` before that, JSON before *that*). This plugin uses **structured JSON ops**. Two reasons:
106
+
107
+ - **Validation belongs to the tool, not the model.** A JSON schema rejects a malformed op at the parameter layer before any logic runs. A text DSL puts the burden of emitting exactly-correct syntax back on the model, and the error rate is high enough that the DSL route accretes layer after layer of lenient parsing and heuristic repair to compensate — omp has shipped both, including a documented incident where the repair silently dropped content. The whole reason to anchor by hash was to stop depending on the model reproducing text perfectly; a hand-written DSL quietly reintroduces that dependency on the other side of the call.
108
+ - **The token savings aren't worth it.** A DSL saves a handful of structural characters per op. Against a higher malformed-edit rate and the cost of format churn — omp's patch syntax has broken compatibly five-plus times, stranding third-party ports on dead dialects — that saving is noise.
109
+
110
+ ### Compared by capability
111
+
112
+ Not by version: omp's later generations refine the same whole-file-tag core, so the real comparison is *line-hash route* vs *file-tag route*.
113
+
114
+ | Capability | This plugin (line-hash) | omp (file-tag) |
115
+ |---|---|---|
116
+ | Anchor identity | per-line `LINE#HASH`, line number folded in | whole-file 4-hex `#TAG`, bare line numbers |
117
+ | Identical-line collisions | impossible by construction | n/a (no per-line hash); the file tag itself can collide in 16 bits |
118
+ | Out-of-band state | **none** | `SnapshotStore` (LRU) + `seenLines` guard |
119
+ | "Did the model read this line?" | proven by the anchor itself | needs the separate `seenLines` array |
120
+ | Verification granularity | only the cited lines (surgical) | whole file — any drift enters recovery |
121
+ | Concurrent external edits | not a goal; re-read to proceed | detected by design — tag change forces a re-read |
122
+ | Wire format | JSON schema ops | text patch DSL |
123
+ | Malformed edits | rejected at the schema layer | lenient parsing + heuristic repair |
124
+ | Format stability | one schema | ≥5 breaking format generations |
125
+ | Drift recovery | ±15-line rescan → fresh anchor, else re-read | line-remap replay, fail-closed |
126
+ | Expressiveness | fine-grained ops + separate `replace` | syntax blocks, cross-file registers, `REM`/`MV` |
127
+ | Read-time token cost | 2–4 chars per line | none at read time; cost moves to mismatch output |
128
+
129
+ ### Honest limitations
130
+
131
+ Including the ones that argue *for* the route I didn't take.
132
+
133
+ - **Hash drift after an edit.** Because the line number is part of the hash, inserting or deleting lines changes every subsequent line's hash. A successful edit hands back fresh anchors for the region it just produced, and the ±15-line rescan rescues nearby drift — but to cite lines well below a size-changing edit, you re-read. That is the price of stateless verification: I keep no snapshot that would track those shifts for you. I consider it a fair trade for having no out-of-band state to corrupt or resync; a clean core matters more to me than saving a read.
134
+ - **No multi-agent story.** If several agents edit one tree, the file-tag route's external-edit detection is a real advantage this design does not have.
135
+ - **Hash transcription is itself error-prone.** Anchoring assumes the model copies the hash verbatim. It doesn't always: *"It sees `483:d4` in the input, writes `483:3a` in the output. Every model does this, including Opus."* ([geometricagi, *AST Edits*](https://geometricagi.github.io/2026/04/02/ast-edits.html), 2026-04-02). This is a failure class the built-in string-replace does not have — see Model Compatibility above; on a model that hasn't internalized anchor discipline, turn the plugin off rather than fight it.
136
+ - **The edit format may not be your bottleneck at all.** An independent benchmark ([nwyin, *edit-bench*](https://nwyin.com/blogs/hashline-vs-replace-edit-bench.html)) found the hashline-vs-replace delta to be language-dependent — a real penalty on Python, neutral on TypeScript, a wash on Rust — and concluded that *"edit format is not the bottleneck"*: model-to-model differences dwarf format-to-format ones. It also found that the whitespace near-miss anchoring is meant to kill barely occurs — fuzzy matching triggered 0 times across 114 successful edits.
137
+ - **A silent success is worse than a loud failure.** Any anchor scheme is only as safe as its implementation. opencode's early hashline port returned `Updated` while writing to the wrong line ([issue #15424](https://github.com/anomalyco/opencode/issues/15424)) — a buggy anchor check manufactures false trust. This plugin's recovery is built to fail closed and hand back live content instead of guessing, but the warning generalizes.
138
+ - **Model dependence is real and not universally in my favor.** omp routes kimi, mimo, deepseek-v4-flash and step-3.7-flash *away* from hashline by default (they miscount anchors or drop the tag header). My own field notes agree on Kimi and disagree on DeepSeek — same model, opposite conclusions in different environments. There is no globally best edit format; there is a model × task × implementation triple.
139
+
72
140
  ## Protocol
73
141
 
74
142
  `read` output (each line anchored):
package/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
+ "homepage": "https://github.com/d3ara1n/pi-extensions/tree/main/packages/pi-hashline-edit#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/d3ara1n/pi-extensions/issues"
9
+ },
6
10
  "keywords": [
7
11
  "pi-package",
8
12
  "pi"
9
13
  ],
10
14
  "main": "src/index.ts",
11
15
  "scripts": {
12
- "test": "node --test src/core/*.test.ts src/pi/*.test.ts"
16
+ "test": "node --test src/core/*.test.ts src/pi/*.test.ts",
17
+ "test:integration": "node --test src/integration/*.test.ts"
13
18
  },
14
19
  "peerDependencies": {
15
20
  "@earendil-works/pi-coding-agent": "*"
@@ -171,6 +171,37 @@ test("CRLF line endings preserved", () => {
171
171
  if (r.ok) assert.equal(r.text, "A\r\nb\r\n");
172
172
  });
173
173
 
174
+ test("a file without a final newline keeps not having one", () => {
175
+ const text = "a\nb";
176
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 2), body: ["B"] }]);
177
+ assert.equal(r.ok, true);
178
+ if (r.ok) assert.equal(r.text, "a\nB");
179
+ });
180
+
181
+ test("CRLF without a final newline keeps not having one", () => {
182
+ const text = "a\r\nb";
183
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["A"] }]);
184
+ assert.equal(r.ok, true);
185
+ if (r.ok) assert.equal(r.text, "A\r\nb");
186
+ });
187
+
188
+ test("appending to a file without a final newline keeps it absent", () => {
189
+ const text = "a";
190
+ const r = applyEdits(text, [{ op: "append", body: ["b"] }]);
191
+ assert.equal(r.ok, true);
192
+ if (r.ok) assert.equal(r.text, "a\nb");
193
+ });
194
+
195
+ test("noop is still detected when the file lacks a final newline", () => {
196
+ // Before the final-newline fix, joinLines appended a terminator, so a
197
+ // byte-identical body looked like a change and the edit silently rewrote the
198
+ // file — the noop guard never fired.
199
+ const text = "a\nb";
200
+ const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
201
+ assert.equal(r.ok, false);
202
+ if (!r.ok) assert.equal(r.failure.kind, "noop");
203
+ });
204
+
174
205
  // --- shifted-anchor recovery ---
175
206
 
176
207
  test("shifted recovery: content moved down → found with a fresh anchor", () => {
package/src/core/apply.ts CHANGED
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import { computeLineHash } from "./hash.ts";
33
- import { detectLineEnding, joinLines, splitLines } from "./lines.ts";
33
+ import { detectLineEnding, hasFinalNewline, joinLines, splitLines } from "./lines.ts";
34
34
  import type { Anchor, AnchorFailure, AnchorRecovery, ApplyResult, Edit } from "./types.ts";
35
35
 
36
36
  /** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
@@ -218,7 +218,7 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4, shiftRadius
218
218
  result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
219
219
  }
220
220
 
221
- const newText = joinLines(result, ending);
221
+ const newText = joinLines(result, ending, hasFinalNewline(text));
222
222
  if (newText === text) {
223
223
  return {
224
224
  ok: false,
package/src/core/index.ts CHANGED
@@ -9,5 +9,5 @@
9
9
 
10
10
  export * from "./types.ts";
11
11
  export { computeLineHash, hashFileLines } from "./hash.ts";
12
- export { splitLines, joinLines, detectLineEnding } from "./lines.ts";
12
+ export { splitLines, joinLines, detectLineEnding, hasFinalNewline } from "./lines.ts";
13
13
  export { applyEdits } from "./apply.ts";
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { splitLines, joinLines, detectLineEnding } from "./lines.ts";
3
+ import { splitLines, joinLines, detectLineEnding, hasFinalNewline } from "./lines.ts";
4
4
 
5
5
  test("splitLines edge cases", () => {
6
6
  assert.deepEqual(splitLines(""), []);
@@ -23,6 +23,29 @@ test("joinLines restores line endings", () => {
23
23
  assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
24
24
  });
25
25
 
26
+ test("joinLines preserves the final-newline state when told to", () => {
27
+ assert.equal(joinLines(["a", "b"], "lf", false), "a\nb");
28
+ assert.equal(joinLines(["a", "b"], "crlf", false), "a\r\nb");
29
+ assert.equal(joinLines(["a"], "lf", false), "a");
30
+ assert.equal(joinLines([], "lf", false), "");
31
+ });
32
+
33
+ test("hasFinalNewline", () => {
34
+ assert.equal(hasFinalNewline("a\nb"), false);
35
+ assert.equal(hasFinalNewline("a\nb\n"), true);
36
+ assert.equal(hasFinalNewline("a\r\nb"), false);
37
+ assert.equal(hasFinalNewline("a\r\nb\r\n"), true);
38
+ assert.equal(hasFinalNewline("a"), false);
39
+ // no lines, no terminator — rejoining [] yields "" either way
40
+ assert.equal(hasFinalNewline(""), true);
41
+ });
42
+
43
+ test("split/join round-trips a document byte for byte", () => {
44
+ for (const text of ["a", "a\n", "a\nb", "a\nb\n", "a\n\n", "\n", "a\r\nb", "a\r\nb\r\n", ""]) {
45
+ assert.equal(joinLines(splitLines(text), detectLineEnding(text), hasFinalNewline(text)), text);
46
+ }
47
+ });
48
+
26
49
  test("detectLineEnding", () => {
27
50
  assert.equal(detectLineEnding("a\nb\n"), "lf");
28
51
  assert.equal(detectLineEnding("a\r\nb\r\n"), "crlf");
package/src/core/lines.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Line text helpers: split/join with CRLF normalization and line-ending
3
- * detection.
2
+ * Line text helpers: split/join with CRLF normalization, line-ending detection
3
+ * and final-newline fidelity.
4
4
  *
5
5
  * CRLF: splitLines strips the trailing `\r` from each line (hashes are based on
6
6
  * clean lines, matching the `\r`-free content the model copies from the
7
7
  * display); detectLineEnding records the original ending so joinLines can
8
8
  * restore it — guaranteeing a CRLF file keeps its endings after edit.
9
9
  *
10
+ * Final newline: splitLines discards whether the input ended with a terminator
11
+ * (a trailing newline terminates the last line, it does not create one).
12
+ * joinLines therefore takes that state as an argument rather than assuming it —
13
+ * a file that lacked a final newline must not silently gain one.
14
+ *
10
15
  * @module pi-hashline-edit/core
11
16
  */
12
17
 
@@ -34,9 +39,29 @@ export function detectLineEnding(text: string): LineEnding {
34
39
  return text.includes("\r\n") ? "crlf" : "lf";
35
40
  }
36
41
 
37
- /** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
38
- export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
42
+ /**
43
+ * Whether the text ends with a line terminator — the state splitLines discards
44
+ * and joinLines needs in order to reproduce a document byte for byte.
45
+ *
46
+ * The empty string has no lines and no terminator; it reports `true` so that
47
+ * rejoining its (also empty) line array — which yields `""` either way —
48
+ * round-trips.
49
+ */
50
+ export function hasFinalNewline(text: string): boolean {
51
+ return text === "" || text.endsWith("\n");
52
+ }
53
+
54
+ /**
55
+ * Join a line array back into text, restoring the given line ending (default LF)
56
+ * and final-newline state (default: terminates with a newline, the convention
57
+ * for freshly created content).
58
+ *
59
+ * Reconstructing an *existing* file must pass `hasFinalNewline(originalText)` so
60
+ * a missing terminator stays missing.
61
+ */
62
+ export function joinLines(lines: readonly string[], ending: LineEnding = "lf", finalNewline = true): string {
39
63
  if (lines.length === 0) return "";
40
64
  const sep = ending === "crlf" ? "\r\n" : "\n";
41
- return lines.join(sep) + sep;
65
+ const body = lines.join(sep);
66
+ return finalNewline ? body + sep : body;
42
67
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Explicit real-ripgrep integration coverage. This is excluded from the default
3
+ * test script and never delegates to Pi's built-in grep/download path.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { access, constants, mkdtemp, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { delimiter, join } from "node:path";
10
+ import { makeGrepOverrideWithBackend } from "../pi/grep-tool.ts";
11
+
12
+ async function findPathRg(): Promise<string | null> {
13
+ for (const directory of process.env.PATH?.split(delimiter) ?? []) {
14
+ if (!directory) continue;
15
+ const candidate = join(directory, "rg");
16
+ try {
17
+ await access(candidate, constants.X_OK);
18
+ return candidate;
19
+ } catch {}
20
+ }
21
+ return null;
22
+ }
23
+
24
+ const rgPath = await findPathRg();
25
+
26
+ test("real rg emits anchored matches from a temporary directory", {
27
+ skip: rgPath === null,
28
+ }, async () => {
29
+ const directory = await mkdtemp(join(tmpdir(), "hl-grep-integration-"));
30
+ try {
31
+ const file = join(directory, "fixture.ts");
32
+ await writeFile(file, "needle\nother\n");
33
+ const tool = makeGrepOverrideWithBackend(directory, {
34
+ findRg: async () => rgPath,
35
+ delegate: async () => {
36
+ throw new Error("integration test must not invoke the built-in grep delegate");
37
+ },
38
+ });
39
+
40
+ const result: any = await tool.execute("0", { pattern: "needle" }, undefined, undefined);
41
+ assert.match(result.content[0].text, /fixture\.ts · 1 match/);
42
+ assert.match(result.content[0].text, /1#[0-9A-Z]+│needle/);
43
+ } finally {
44
+ await rm(directory, { recursive: true, force: true });
45
+ }
46
+ });
@@ -49,6 +49,36 @@ test("read execute: text outputs LINE#HASH│content", async () => {
49
49
  });
50
50
  });
51
51
 
52
+ test("read execute: a missing final newline is stated in the header", async () => {
53
+ await withDir(async (dir) => {
54
+ await writeFile(join(dir, "f.txt"), "line1\nline2");
55
+ const bare: any = await call(makeReadOverride(dir), { path: "f.txt" });
56
+ assert.match(bare.content[0].text, /f\.txt · 2 lines · no trailing newline/);
57
+
58
+ await writeFile(join(dir, "g.txt"), "line1\nline2\n");
59
+ const terminated: any = await call(makeReadOverride(dir), { path: "g.txt" });
60
+ assert.doesNotMatch(terminated.content[0].text, /no trailing newline/);
61
+ });
62
+ });
63
+
64
+ test("edit execute: a file without a final newline stays byte-exact", async () => {
65
+ await withDir(async (dir) => {
66
+ const f = join(dir, "f.txt");
67
+ // The benchmark's literal-1-no-final-newline fixture: no terminator, plus a
68
+ // word-joiner the model cannot see. Only line 2 may change; the missing
69
+ // terminator must not turn into a new byte.
70
+ const text = "guard\nold value\u2060";
71
+ await writeFile(f, text);
72
+ await call(makeReadOverride(dir), { path: "f.txt" });
73
+ const r: any = await call(makeEditOverride(dir), {
74
+ path: "f.txt",
75
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["new value\u2060"] }],
76
+ });
77
+ assert.equal(r.isError, undefined, "should not be an error");
78
+ assert.equal(await readFile(f, "utf-8"), "guard\nnew value\u2060");
79
+ });
80
+ });
81
+
52
82
  test("edit execute: hashline round-trip (read → edit → file changed)", async () => {
53
83
  await withDir(async (dir) => {
54
84
  const f = join(dir, "f.txt");