From b95ff1abfff22265ea7acb5ac3f05e5e19940a48 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 10 Feb 2026 16:27:18 +0100 Subject: [PATCH] feat(coding-agent): enhanced hash validation with detailed error reporting and improved field naming - Added HashlineMismatchError class with formatted error messages showing mismatched hashes with context lines. - Added HashMismatch type to represent hash validation errors with line number, expected, and actual hash values. - Enhanced hash validation in applyHashlineEdits to collect all hash mismatches before applying edits instead of failing on first mismatch. - Renamed HashlineEdit fields from 'src'/'dst' to 'old'/'new' for improved naming clarity and consistency. --- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/src/patch/hashline.ts | 125 +++++++++++++++--- packages/coding-agent/src/patch/index.ts | 16 ++- packages/coding-agent/src/patch/types.ts | 16 ++- .../src/prompts/tools/hashline.md | 22 +-- .../coding-agent/test/core/hashline.test.ts | 97 ++++++++++---- 6 files changed, 222 insertions(+), 59 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f91dc0bd1..c3c65da36 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Replaced `edit.patchMode` boolean setting with `edit.mode` enum; existing `edit.patchMode: true` configurations should use `edit.mode: patch` @@ -8,6 +9,8 @@ ### Added +- Added `HashlineMismatchError` class that displays grep-style output with `>>>` markers showing correct `LINE:HASH` references when hash validation fails +- Added `HashMismatch` type to represent individual hash mismatches with line number, expected hash, and actual hash - Added hashline edit mode for line-addressed edits using content hashes (LINE:HASH format) with integrity verification - Added `readHashLines` setting to include line hashes in read output for hashline edit mode - Added `edit.mode` setting (enum: replace, patch, hashline) to select edit tool variant, replacing `edit.patchMode` boolean @@ -27,6 +30,8 @@ ### Changed +- Renamed `HashlineEdit` fields from `src`/`dst` to `old`/`new` for clarity in replace, delete, and insert operations +- Enhanced hash validation in `applyHashlineEdits` to collect all mismatches before throwing, providing comprehensive error reporting with context lines - Changed `edit.patchMode` boolean setting to `edit.mode` enum (replace, patch, hashline) with default value patch - Changed edit tool to support three modes (replace, patch, hashline) instead of two, with dynamic mode selection based on model and settings - Changed read tool to prioritize hash lines over line numbers when both are requested diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index 309c70d13..450facd51 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -12,7 +12,7 @@ * Reference format: `"LINENUM:HASH"` (e.g. `"5:a3f2"`) */ -import type { HashlineEdit } from "./types"; +import type { HashlineEdit, HashMismatch } from "./types"; /** * Compute the 4-character hex hash of a single line. @@ -88,6 +88,79 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines: } } +// ═══════════════════════════════════════════════════════════════════════════ +// Hash Mismatch Error +// ═══════════════════════════════════════════════════════════════════════════ + +/** Number of context lines shown above/below each mismatched line */ +const MISMATCH_CONTEXT = 2; + +/** + * Error thrown when one or more hashline references have stale hashes. + * + * Displays grep-style output with `>>>` markers on mismatched lines, + * showing the correct `LINE:HASH` so the caller can fix all refs at once. + */ +export class HashlineMismatchError extends Error { + constructor( + public readonly mismatches: HashMismatch[], + public readonly fileLines: string[], + ) { + super(HashlineMismatchError.formatMessage(mismatches, fileLines)); + this.name = "HashlineMismatchError"; + } + + static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string { + const mismatchSet = new Map(); + for (const m of mismatches) { + mismatchSet.set(m.line, m); + } + + // Collect line ranges to display (mismatch lines + context) + const displayLines = new Set(); + for (const m of mismatches) { + const lo = Math.max(1, m.line - MISMATCH_CONTEXT); + const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT); + for (let i = lo; i <= hi; i++) { + displayLines.add(i); + } + } + + const sorted = [...displayLines].sort((a, b) => a - b); + const lines: string[] = []; + + lines.push( + `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Re-read the file.`, + ); + lines.push(""); + + let prevLine = -1; + for (const lineNum of sorted) { + // Gap separator between non-contiguous regions + if (prevLine !== -1 && lineNum > prevLine + 1) { + lines.push(" ..."); + } + prevLine = lineNum; + + const content = fileLines[lineNum - 1]; + const hash = computeLineHash(lineNum, content); + const prefix = `${lineNum}:${hash}`; + + if (mismatchSet.has(lineNum)) { + lines.push(`>>> ${prefix}| ${content}`); + } else { + lines.push(` ${prefix}| ${content}`); + } + } + + return lines.join("\n"); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Edit Application +// ═══════════════════════════════════════════════════════════════════════════ + /** * Apply an array of hashline edits to file content. * @@ -95,9 +168,9 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines: * so that earlier edits don't invalidate line numbers for later ones. * * Supported operations: - * - **Replace**: `src` has entries, `dst` has entries — replace src lines with dst - * - **Delete**: `src` has entries, `dst` is empty — delete the src lines - * - **Insert**: `src` is empty, `dst` has entries, `after` is set — insert after ref line + * - **Replace**: `old` has entries, `new` has entries — replace old lines with new + * - **Delete**: `old` has entries, `new` is empty — delete the old lines + * - **Insert**: `old` is empty, `new` has entries, `after` is set — insert after ref line * * @returns The modified content and the 1-indexed first changed line number */ @@ -112,7 +185,29 @@ export function applyHashlineEdits( const fileLines = content.split("\n"); let firstChangedLine: number | undefined; - // Classify and annotate edits with their effective line number for sorting + // Pre-validate all line refs and collect hash mismatches in one pass. + // Structural errors (out of range, malformed, non-consecutive) still throw immediately. + const mismatches: HashMismatch[] = []; + + for (const edit of edits) { + const refs: string[] = edit.old.length > 0 ? edit.old : edit.after ? [edit.after] : []; + for (const refStr of refs) { + const ref = parseLineRef(refStr); + if (ref.line < 1 || ref.line > fileLines.length) { + throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`); + } + const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]); + if (actualHash !== ref.hash.toLowerCase()) { + mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash }); + } + } + } + + if (mismatches.length > 0) { + throw new HashlineMismatchError(mismatches, fileLines); + } + + // Classify and annotate edits with their effective line number for sorting. const annotated = edits.map((edit, idx) => { const sortLine = getSortLine(edit, idx); return { edit, sortLine }; @@ -122,24 +217,24 @@ export function applyHashlineEdits( annotated.sort((a, b) => b.sortLine - a.sortLine); for (const { edit } of annotated) { - const isInsert = edit.src.length === 0; + const isInsert = edit.old.length === 0; if (isInsert) { // Insert after a referenced line if (!edit.after) { - throw new Error("Insert edit (empty src) requires an 'after' line reference."); + throw new Error("Insert edit (empty old) requires an 'after' line reference."); } const afterRef = parseLineRef(edit.after); validateLineRef(afterRef, fileLines); - // Insert dst lines after the referenced line (0-indexed splice position) + // Insert new lines after the referenced line (0-indexed splice position) const insertIdx = afterRef.line; // insert after this line = splice at this index - fileLines.splice(insertIdx, 0, ...edit.dst); + fileLines.splice(insertIdx, 0, ...edit.new); trackFirstChanged(afterRef.line + 1); } else { // Replace or Delete - const refs = edit.src.map(parseLineRef); + const refs = edit.old.map(parseLineRef); // Validate all refs for (const ref of refs) { @@ -153,8 +248,8 @@ export function applyHashlineEdits( const endLine = refs[refs.length - 1].line; const count = endLine - startLine + 1; - // Splice: remove `count` lines starting at startLine-1, insert dst - fileLines.splice(startLine - 1, count, ...edit.dst); + // Splice: remove `count` lines starting at startLine-1, insert new + fileLines.splice(startLine - 1, count, ...edit.new); trackFirstChanged(startLine); } @@ -173,12 +268,12 @@ export function applyHashlineEdits( /** * Determine the effective line number for sorting an edit (descending). - * For replace/delete: use the first src line. + * For replace/delete: use the first old line. * For insert: use the after line. */ function getSortLine(edit: HashlineEdit, idx: number): number { - if (edit.src.length > 0) { - return parseLineRef(edit.src[0]).line; + if (edit.old.length > 0) { + return parseLineRef(edit.old[0]).line; } if (edit.after) { return parseLineRef(edit.after).line; diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index f432383e3..141f6cdb8 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -49,7 +49,14 @@ export { computeEditDiff, computePatchDiff, generateDiffString, generateUnifiedD // Fuzzy matching export { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch as findEditMatch, findMatch, seekSequence } from "./fuzzy"; // Hashline -export { applyHashlineEdits, computeLineHash, formatHashLines, parseLineRef, validateLineRef } from "./hashline"; +export { + applyHashlineEdits, + computeLineHash, + formatHashLines, + HashlineMismatchError, + parseLineRef, + validateLineRef, +} from "./hashline"; // Normalization export { adjustIndentation, detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; // Parsing @@ -74,6 +81,7 @@ export type { FuzzyMatch, HashlineEdit, HashlineInput, + HashMismatch, MatchOutcome as EditMatchOutcome, MatchOutcome, Operation, @@ -111,9 +119,9 @@ export type PatchParams = { path: string; op?: string; rename?: string; diff?: s export type HashlineParams = { path: string; edits: HashlineEdit[] }; const hashlineEditItemSchema = Type.Object({ - src: Type.Array(Type.String({ description: 'Line references to replace (e.g. "5:abcd")' })), - dst: Type.Array(Type.String({ description: "Replacement content lines" })), - after: Type.Optional(Type.String({ description: "Insert after this line ref (only when src is empty)" })), + old: Type.Array(Type.String({ description: 'Line references to replace (e.g. "5:abcd")' })), + new: Type.Array(Type.String({ description: "Replacement content lines" })), + after: Type.Optional(Type.String({ description: "Insert after this line ref (only when old is empty)" })), }); const hashlineEditSchema = Type.Object({ diff --git a/packages/coding-agent/src/patch/types.ts b/packages/coding-agent/src/patch/types.ts index 67aae21ed..f6b5be813 100644 --- a/packages/coding-agent/src/patch/types.ts +++ b/packages/coding-agent/src/patch/types.ts @@ -198,10 +198,10 @@ export interface DiffError { /** A single edit operation in hashline mode */ export interface HashlineEdit { /** Line references to replace (e.g., ["5:abcd", "6:ef01"]) — empty for insert */ - src: string[]; + old: string[]; /** Replacement content lines — empty for delete */ - dst: string[]; - /** Insert after this line ref (e.g., "3:e7c4") — only when src is empty */ + new: string[]; + /** Insert after this line ref (e.g., "3:e7c4") — only when old is empty */ after?: string; } @@ -213,6 +213,16 @@ export interface HashlineInput { edits: HashlineEdit[]; } +/** A single hash mismatch found during validation */ +export interface HashMismatch { + /** 1-indexed line number */ + line: number; + /** Hash the caller provided */ + expected: string; + /** Hash computed from the current file content */ + actual: string; +} + // ═══════════════════════════════════════════════════════════════════════════ // Error Classes // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 13da86729..9c75a360f 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -6,13 +6,13 @@ Line-addressed edits using hash-verified line references. Read file with hashes **Workflow:** 1. Read target file (hashes are included automatically in output) 2. Identify lines to change by their `LINE:HASH` prefix -3. Submit edit with `src` (line refs to replace) and `dst` (new content) +3. Submit edit with `old` (line refs to replace) and `new` (new content) **Operations:** -- **Replace**: `src: ["5:abcd", "6:ef01"], dst: ["new line 1", "new line 2"]` — replaces lines 5-6 -- **Delete**: `src: ["5:abcd", "6:ef01"], dst: []` — deletes lines 5-6 -- **Insert**: `src: [], dst: ["inserted line"], after: "3:e7c4"` — inserts after line 3 +- **Replace**: `old: ["5:abcd", "6:ef01"], new: ["new line 1", "new line 2"]` — replaces lines 5-6 +- **Delete**: `old: ["5:abcd", "6:ef01"], new: []` — deletes lines 5-6 +- **Insert**: `old: [], new: ["inserted line"], after: "3:e7c4"` — inserts after line 3 **Rules:** -- `src` line refs must be consecutive (e.g., 5,6,7 — not 5,7,8) +- `old` line refs must be consecutive (e.g., 5,6,7 — not 5,7,8) - Multiple edits in one call are applied bottom-up (safe for non-overlapping edits) - Hashes verify file hasn't changed since your last read — stale hashes produce clear errors - Hashes are derived from both line content and line number (copy them verbatim from read output) @@ -29,28 +29,28 @@ Returns success/failure; on failure, error message indicates: - Always read target file before editing — line hashes come from the read output - If edit fails with hash mismatch, re-read the file to get fresh hashes - Never fabricate hashes — always copy from read output -- Each `src` entry is a line reference like `"5:abcd"`, each `dst` entry is plain content (no prefix) +- Each `old` entry is a line reference like `"5:abcd"`, each `new` entry is plain content (no prefix) -edit {"path":"src/app.py","edits":[{"src":["2:9b01"],"dst":[" print('Hello')"]}]} +edit {"path":"src/app.py","edits":[{"old":["2:9b01"],"new":[" print('Hello')"]}]} -edit {"path":"src/app.py","edits":[{"src":["5:abcd","6:ef01"],"dst":[]}]} +edit {"path":"src/app.py","edits":[{"old":["5:abcd","6:ef01"],"new":[]}]} -edit {"path":"src/app.py","edits":[{"src":[],"dst":[" # new comment"],"after":"3:e7c4"}]} +edit {"path":"src/app.py","edits":[{"old":[],"new":[" # new comment"],"after":"3:e7c4"}]} -edit {"path":"src/app.py","edits":[{"src":["10:f1a2"],"dst":[" return True"]},{"src":["3:c4d5"],"dst":[" x = 42"]}]} +edit {"path":"src/app.py","edits":[{"old":["10:f1a2"],"new":[" return True"]},{"old":["3:c4d5"],"new":[" x = 42"]}]} - Fabricating or guessing hash values - Using stale hashes after file has been modified -- Non-consecutive src line refs in a single edit +- Non-consecutive old line refs in a single edit - Overlapping edits in the same call \ No newline at end of file diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 139e3c4d9..07cf83b8c 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -4,6 +4,7 @@ import { computeLineHash, formatHashLines, type HashlineEdit, + HashlineMismatchError, parseLineRef, validateLineRef, } from "@oh-my-pi/pi-coding-agent/patch"; @@ -182,7 +183,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: [makeRef(2, "bbb")], dst: ["BBB"] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(2, "bbb")], new: ["BBB"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBBB\nccc"); @@ -191,7 +192,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces multiple consecutive lines", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [{ src: [makeRef(2, "bbb"), makeRef(3, "ccc")], dst: ["XXX", "YYY"] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(2, "bbb"), makeRef(3, "ccc")], new: ["XXX", "YYY"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nXXX\nYYY\nddd"); @@ -200,7 +201,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces with different line count (expand)", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: [makeRef(2, "bbb")], dst: ["line1", "line2", "line3"] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(2, "bbb")], new: ["line1", "line2", "line3"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nline1\nline2\nline3\nccc"); @@ -208,7 +209,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces with different line count (shrink)", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [{ src: [makeRef(2, "bbb"), makeRef(3, "ccc")], dst: ["ONE"] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(2, "bbb"), makeRef(3, "ccc")], new: ["ONE"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nONE\nddd"); @@ -216,7 +217,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces first line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ src: [makeRef(1, "first")], dst: ["FIRST"] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(1, "first")], new: ["FIRST"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("FIRST\nsecond\nthird"); @@ -225,7 +226,7 @@ describe("applyHashlineEdits — replace", () => { test("replaces last line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ src: [makeRef(3, "third")], dst: ["THIRD"] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(3, "third")], new: ["THIRD"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("first\nsecond\nTHIRD"); @@ -244,7 +245,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: [makeRef(2, "bbb")], dst: [] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(2, "bbb")], new: [] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nccc"); @@ -253,7 +254,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes multiple consecutive lines", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [{ src: [makeRef(2, "bbb"), makeRef(3, "ccc")], dst: [] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(2, "bbb"), makeRef(3, "ccc")], new: [] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nddd"); @@ -261,7 +262,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes first line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: [makeRef(1, "aaa")], dst: [] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(1, "aaa")], new: [] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("bbb\nccc"); @@ -269,7 +270,7 @@ describe("applyHashlineEdits — delete", () => { test("deletes last line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: [makeRef(3, "ccc")], dst: [] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(3, "ccc")], new: [] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb"); @@ -287,7 +288,7 @@ describe("applyHashlineEdits — insert", () => { test("inserts after a line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: [], dst: ["NEW"], after: makeRef(1, "aaa") }]; + const edits: HashlineEdit[] = [{ old: [], new: ["NEW"], after: makeRef(1, "aaa") }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); @@ -296,7 +297,7 @@ describe("applyHashlineEdits — insert", () => { test("inserts multiple lines", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: [], dst: ["x", "y", "z"], after: makeRef(1, "aaa") }]; + const edits: HashlineEdit[] = [{ old: [], new: ["x", "y", "z"], after: makeRef(1, "aaa") }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nx\ny\nz\nbbb"); @@ -304,7 +305,7 @@ describe("applyHashlineEdits — insert", () => { test("inserts after last line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: [], dst: ["NEW"], after: makeRef(2, "bbb") }]; + const edits: HashlineEdit[] = [{ old: [], new: ["NEW"], after: makeRef(2, "bbb") }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb\nNEW"); @@ -312,7 +313,7 @@ describe("applyHashlineEdits — insert", () => { test("insert without after ref throws", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: [], dst: ["NEW"] }]; + const edits: HashlineEdit[] = [{ old: [], new: ["NEW"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(/requires an 'after'/); }); @@ -330,8 +331,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies two non-overlapping replaces (bottom-up safe)", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [ - { src: [makeRef(2, "bbb")], dst: ["BBB"] }, - { src: [makeRef(4, "ddd")], dst: ["DDD"] }, + { old: [makeRef(2, "bbb")], new: ["BBB"] }, + { old: [makeRef(4, "ddd")], new: ["DDD"] }, ]; const result = applyHashlineEdits(content, edits); @@ -342,8 +343,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies replace + delete in one call", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { src: [makeRef(2, "bbb")], dst: ["BBB"] }, - { src: [makeRef(4, "ddd")], dst: [] }, + { old: [makeRef(2, "bbb")], new: ["BBB"] }, + { old: [makeRef(4, "ddd")], new: [] }, ]; const result = applyHashlineEdits(content, edits); @@ -353,8 +354,8 @@ describe("applyHashlineEdits — multiple edits", () => { test("applies replace + insert in one call", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ - { src: [makeRef(3, "ccc")], dst: ["CCC"] }, - { src: [], dst: ["INSERTED"], after: makeRef(1, "aaa") }, + { old: [makeRef(3, "ccc")], new: ["CCC"] }, + { old: [], new: ["INSERTED"], after: makeRef(1, "aaa") }, ]; const result = applyHashlineEdits(content, edits); @@ -380,29 +381,73 @@ describe("applyHashlineEdits — errors", () => { test("rejects stale hash", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ src: ["2:0000"], dst: ["BBB"] }]; + const edits: HashlineEdit[] = [{ old: ["2:0000"], new: ["BBB"] }]; - expect(() => applyHashlineEdits(content, edits)).toThrow(/has changed since last read/); + expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); + }); + + test("stale hash error shows >>> markers with correct hashes", () => { + const content = "aaa\nbbb\nccc\nddd\neee"; + const edits: HashlineEdit[] = [{ old: ["2:0000"], new: ["BBB"] }]; + + try { + applyHashlineEdits(content, edits); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(HashlineMismatchError); + const msg = (err as HashlineMismatchError).message; + // Should contain >>> marker on the mismatched line + expect(msg).toContain(">>>"); + // Should show the correct hash for line 2 + const correctHash = computeLineHash(2, "bbb"); + expect(msg).toContain(`2:${correctHash}| bbb`); + // Context lines should NOT have >>> markers + const lines = msg.split("\n"); + const contextLines = lines.filter(l => l.startsWith(" ") && l.includes("|")); + expect(contextLines.length).toBeGreaterThan(0); + } + }); + + test("stale hash error collects all mismatches", () => { + const content = "aaa\nbbb\nccc\nddd\neee"; + const edits: HashlineEdit[] = [ + { old: ["2:0000"], new: ["BBB"] }, + { old: ["4:0000"], new: ["DDD"] }, + ]; + + try { + applyHashlineEdits(content, edits); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(HashlineMismatchError); + const e = err as HashlineMismatchError; + expect(e.mismatches).toHaveLength(2); + expect(e.mismatches[0].line).toBe(2); + expect(e.mismatches[1].line).toBe(4); + // Both lines should have >>> markers + const markerLines = e.message.split("\n").filter(l => l.startsWith(">>>")); + expect(markerLines).toHaveLength(2); + } }); test("rejects out-of-range line", () => { const content = "aaa\nbbb"; const hash = computeLineHash(10, "aaa"); - const edits: HashlineEdit[] = [{ src: [`10:${hash}`], dst: ["X"] }]; + const edits: HashlineEdit[] = [{ old: [`10:${hash}`], new: ["X"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/); }); - test("rejects non-consecutive src lines", () => { + test("rejects non-consecutive old lines", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [{ src: [makeRef(1, "aaa"), makeRef(3, "ccc")], dst: ["X"] }]; + const edits: HashlineEdit[] = [{ old: [makeRef(1, "aaa"), makeRef(3, "ccc")], new: ["X"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(/consecutive/); }); test("rejects malformed line ref", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ src: ["garbage"], dst: ["X"] }]; + const edits: HashlineEdit[] = [{ old: ["garbage"], new: ["X"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(/Invalid line reference/); });