feat(coding-agent/patch): added line hashes to compact diff preview

- Added line hashes to compact diff preview for unchanged and added lines to enable integrity verification.
- Modified compact diff preview to track line number synchronization between old and new files when processing insertions and deletions.
- Fixed line number parsing in compact diff preview to handle variable-width line number fields with leading whitespace.
- Extracted parsing and formatting logic into dedicated functions (parseNumberedDiffLine, formatCompactHashlineLine, syncOldLineCounters, syncNewLineCounters) for maintainability.
- Updated 4 test cases to verify line hash generation, line number synchronization, and handling of variable-width fields.
This commit is contained in:
can1357
2026-03-13 15:11:21 +01:00
parent 87c48cd39b
commit 24b0d3269c
3 changed files with 161 additions and 32 deletions
+7
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Added `code_search` tool supporting both Exa and grep.app providers for code snippet and documentation search
@@ -9,6 +10,8 @@
### Changed
- Updated compact diff preview to include line hashes for visibility and integrity verification of unchanged and added lines
- Modified compact diff preview to track line number synchronization between old and new files when processing insertions and deletions
- Simplified web search tools: removed `web_search_deep`, `web_search_crawl`, `web_search_linkedin`, and `web_search_company` tools
- Removed `exa.enableLinkedin` and `exa.enableCompany` settings; LinkedIn and company research are no longer available
- Refactored code search to use pluggable provider system instead of Exa-only implementation
@@ -20,6 +23,10 @@
- Removed Exa deep search tool (`exa_search_deep`)
- Removed Exa URL crawl tool (`exa_crawl`)
### Fixed
- Fixed line number parsing in compact diff preview to handle variable-width line number fields with leading whitespace
## [13.11.0] - 2026-03-12
### Added
+123 -16
View File
@@ -744,11 +744,133 @@ export interface CompactHashlineDiffOptions {
maxOutputLines?: number;
}
const NUMBERED_DIFF_LINE_RE = /^([ +-])(\d+)\|(.*)$/;
const NUMBERED_DIFF_LINE_RE = /^([ +-])(\s*\d+)\|(.*)$/;
const HASHLINE_PREVIEW_PLACEHOLDER = " ";
type DiffRunKind = " " | "+" | "-" | "meta";
type DiffRun = { kind: DiffRunKind; lines: string[] };
interface ParsedNumberedDiffLine {
kind: " " | "+" | "-";
lineNumber: number;
lineWidth: number;
content: string;
raw: string;
}
interface CompactPreviewCounters {
oldLine?: number;
newLine?: number;
}
function parseNumberedDiffLine(line: string): ParsedNumberedDiffLine | undefined {
const match = NUMBERED_DIFF_LINE_RE.exec(line);
if (!match) return undefined;
const kind = match[1];
if (kind !== " " && kind !== "+" && kind !== "-") return undefined;
const lineField = match[2];
const lineNumber = Number(lineField.trim());
if (!Number.isInteger(lineNumber)) return undefined;
return { kind, lineNumber, lineWidth: lineField.length, content: match[3], raw: line };
}
function syncOldLineCounters(counters: CompactPreviewCounters, lineNumber: number): void {
if (counters.oldLine === undefined || counters.newLine === undefined) {
counters.oldLine = lineNumber;
counters.newLine = lineNumber;
return;
}
const delta = lineNumber - counters.oldLine;
counters.oldLine = lineNumber;
counters.newLine += delta;
}
function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: number): void {
if (counters.oldLine === undefined || counters.newLine === undefined) {
counters.oldLine = lineNumber;
counters.newLine = lineNumber;
return;
}
const delta = lineNumber - counters.newLine;
counters.oldLine += delta;
counters.newLine = lineNumber;
}
function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, width: number, content: string): string {
const padded = String(lineNumber).padStart(width, " ");
return `${kind}${padded}#${computeLineHash(lineNumber, content)}|${content}`;
}
function formatCompactRemovedLine(lineNumber: number, width: number, content: string): string {
const padded = String(lineNumber).padStart(width, " ");
return `-${padded}${HASHLINE_PREVIEW_PLACEHOLDER}|${content}`;
}
function formatCompactPreviewLine(
line: string,
counters: CompactPreviewCounters,
): { kind: DiffRunKind; text: string } {
const parsed = parseNumberedDiffLine(line);
if (!parsed) return { kind: "meta", text: line };
if (parsed.content === "...") {
if (parsed.kind === "+") {
syncNewLineCounters(counters, parsed.lineNumber);
} else {
syncOldLineCounters(counters, parsed.lineNumber);
}
return { kind: parsed.kind, text: parsed.raw };
}
switch (parsed.kind) {
case "+": {
syncNewLineCounters(counters, parsed.lineNumber);
const newLine = counters.newLine;
if (newLine === undefined) return { kind: "+", text: parsed.raw };
const text = formatCompactHashlineLine("+", newLine, parsed.lineWidth, parsed.content);
counters.newLine = newLine + 1;
return { kind: "+", text };
}
case "-": {
syncOldLineCounters(counters, parsed.lineNumber);
const text = formatCompactRemovedLine(parsed.lineNumber, parsed.lineWidth, parsed.content);
counters.oldLine = parsed.lineNumber + 1;
return { kind: "-", text };
}
case " ": {
syncOldLineCounters(counters, parsed.lineNumber);
const newLine = counters.newLine;
if (newLine === undefined) return { kind: " ", text: parsed.raw };
const text = formatCompactHashlineLine(" ", newLine, parsed.lineWidth, parsed.content);
counters.oldLine = parsed.lineNumber + 1;
counters.newLine = newLine + 1;
return { kind: " ", text };
}
}
}
function splitDiffRuns(lines: string[]): DiffRun[] {
const runs: DiffRun[] = [];
const counters: CompactPreviewCounters = {};
for (const line of lines) {
const formatted = formatCompactPreviewLine(line, counters);
const prev = runs[runs.length - 1];
if (prev && prev.kind === formatted.kind) {
prev.lines.push(formatted.text);
continue;
}
runs.push({ kind: formatted.kind, lines: [formatted.text] });
}
return runs;
}
function collapseFromStart(lines: string[], maxLines: number, label: string): string[] {
if (lines.length <= maxLines) return lines;
const hidden = lines.length - maxLines;
@@ -767,21 +889,6 @@ function collapseFromMiddle(lines: string[], maxLines: number, label: string): s
return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)];
}
function splitDiffRuns(lines: string[]): DiffRun[] {
const runs: DiffRun[] = [];
for (const line of lines) {
const match = NUMBERED_DIFF_LINE_RE.exec(line);
const kind = (match?.[1] as " " | "+" | "-" | undefined) ?? "meta";
const prev = runs[runs.length - 1];
if (prev && prev.kind === kind) {
prev.lines.push(line);
continue;
}
runs.push({ kind, lines: [line] });
}
return runs;
}
/**
* Build a compact diff preview suitable for model-visible tool responses.
*
@@ -824,41 +824,56 @@ describe("applyHashlineEdits — errors", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("buildCompactHashlineDiffPreview", () => {
it("keeps trailing context for first unchanged run", () => {
const diff = [" 1|ctx-a", " 2|ctx-b", " 3|ctx-c", " 4|ctx-d", "+5|added"].join("\n");
it("keeps trailing context for first unchanged run and hashes visible lines", () => {
const diff = [" 1|ctx-a", " 2|ctx-b", " 3|ctx-c", " 4|ctx-d", "+ 5|added"].join("\n");
const preview = buildCompactHashlineDiffPreview(diff);
expect(preview.preview).not.toContain(" 1|ctx-a");
expect(preview.preview).not.toContain(" 2|ctx-b");
expect(preview.preview).toContain(" 3|ctx-c");
expect(preview.preview).toContain(" 4|ctx-d");
expect(preview.preview).not.toContain("ctx-a");
expect(preview.preview).not.toContain("ctx-b");
expect(preview.preview).toContain(` 3#${computeLineHash(3, "ctx-c")}|ctx-c`);
expect(preview.preview).toContain(` 4#${computeLineHash(4, "ctx-d")}|ctx-d`);
expect(preview.preview).toContain(" ... 2 more unchanged lines");
expect(preview.preview).toContain("+5|added");
expect(preview.preview).toContain(`+ 5#${computeLineHash(5, "added")}|added`);
});
it("collapses long addition runs and tracks line counts", () => {
const diff = [" 1|head", "+2|one", "+3|two", "+4|three", "+5|four", "-2|old"].join("\n");
it("collapses long addition runs and leaves removed lines unhashed", () => {
const diff = [" 1|head", "+ 2|one", "+ 3|two", "+ 4|three", "+ 5|four", "- 2|old"].join("\n");
const preview = buildCompactHashlineDiffPreview(diff);
expect(preview.preview).toContain("+2|one");
expect(preview.preview).toContain("+3|two");
expect(preview.preview).toContain(`+ 2#${computeLineHash(2, "one")}|one`);
expect(preview.preview).toContain(`+ 3#${computeLineHash(3, "two")}|two`);
expect(preview.preview).toContain(" ... 2 more added lines");
expect(preview.preview).toContain("- 2 |old");
expect(preview.preview).not.toContain(`- 2#${computeLineHash(2, "old")}|old`);
expect(preview.addedLines).toBe(4);
expect(preview.removedLines).toBe(1);
});
it("keeps leading context for last unchanged run", () => {
it("keeps leading context for last unchanged run and hashes visible lines", () => {
const diff = ["-10|old", "+10|new", " 11|ctx-a", " 12|ctx-b", " 13|ctx-c", " 14|ctx-d"].join("\n");
const preview = buildCompactHashlineDiffPreview(diff);
expect(preview.preview).toContain(" 11|ctx-a");
expect(preview.preview).toContain(" 12|ctx-b");
expect(preview.preview).not.toContain(" 13|ctx-c");
expect(preview.preview).not.toContain(" 14|ctx-d");
expect(preview.preview).toContain(`+10#${computeLineHash(10, "new")}|new`);
expect(preview.preview).toContain(` 11#${computeLineHash(11, "ctx-a")}|ctx-a`);
expect(preview.preview).toContain(` 12#${computeLineHash(12, "ctx-b")}|ctx-b`);
expect(preview.preview).not.toContain("ctx-c");
expect(preview.preview).not.toContain("ctx-d");
expect(preview.preview).toContain(" ... 2 more unchanged lines");
});
it("uses new-file line numbers for unchanged lines after insertions", () => {
const diff = ["+2|inserted", " 2|bravo", " 3|charlie"].join("\n");
const preview = buildCompactHashlineDiffPreview(diff);
expect(preview.preview).toContain(`+2#${computeLineHash(2, "inserted")}|inserted`);
expect(preview.preview).toContain(` 3#${computeLineHash(3, "bravo")}|bravo`);
expect(preview.preview).toContain(` 4#${computeLineHash(4, "charlie")}|charlie`);
expect(preview.preview).not.toContain(` 2#${computeLineHash(2, "bravo")}|bravo`);
});
});
// ═══════════════════════════════════════════════════════════════════════════