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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user