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.
This commit is contained in:
can1357
2026-02-10 16:27:18 +01:00
parent 611e24fea0
commit b95ff1abff
6 changed files with 222 additions and 59 deletions
+110 -15
View File
@@ -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<number, HashMismatch>();
for (const m of mismatches) {
mismatchSet.set(m.line, m);
}
// Collect line ranges to display (mismatch lines + context)
const displayLines = new Set<number>();
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;
+12 -4
View File
@@ -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({
+13 -3
View File
@@ -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
// ═══════════════════════════════════════════════════════════════════════════