170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
|
import { structuredPatch } from 'diff';
|
|
|
|
import type { SnippetDiff } from '@shared/types';
|
|
|
|
/**
|
|
* Reliable hunk↔snippet matcher using content overlap analysis.
|
|
*
|
|
* Uses bidirectional substring matching between hunk added/removed lines
|
|
* and snippet newString/oldString to determine which snippets correspond
|
|
* to which diff hunks.
|
|
*
|
|
* Replaces the previous 1:1 hunkIndex→snippetIndex assumption.
|
|
*/
|
|
export class HunkSnippetMatcher {
|
|
/**
|
|
* Match hunk indices to their corresponding snippets.
|
|
* Returns a Map where each hunk index maps to the set of matching snippet indices.
|
|
*
|
|
* @param snippets — MUST be pre-filtered (no isError entries).
|
|
* Returned indices are relative to this array.
|
|
*/
|
|
matchHunksToSnippets(
|
|
original: string,
|
|
modified: string,
|
|
hunkIndices: number[],
|
|
snippets: SnippetDiff[]
|
|
): Map<number, Set<number>> {
|
|
if (snippets.length === 0) return new Map();
|
|
|
|
const patch = structuredPatch('file', 'file', original, modified);
|
|
if (!patch.hunks || patch.hunks.length === 0) return new Map();
|
|
|
|
const mapping = new Map<number, Set<number>>();
|
|
|
|
for (const hunkIdx of hunkIndices) {
|
|
if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
|
|
const hunk = patch.hunks[hunkIdx];
|
|
const snippetSet = new Set<number>();
|
|
const strongMatches = new Set<number>();
|
|
|
|
// Reconstruct old/new side of hunk INCLUDING context lines.
|
|
// Context lines (` ` prefix) are critical — without them, snippets whose
|
|
// oldString spans unchanged lines between changed lines can't be matched.
|
|
const oldSideContent = hunk.lines
|
|
.filter((l) => !l.startsWith('+'))
|
|
.map((l) => l.slice(1))
|
|
.join('\n');
|
|
const newSideContent = hunk.lines
|
|
.filter((l) => !l.startsWith('-'))
|
|
.map((l) => l.slice(1))
|
|
.join('\n');
|
|
|
|
for (let sIdx = 0; sIdx < snippets.length; sIdx++) {
|
|
const snippet = snippets[sIdx];
|
|
|
|
if (this.hasContentOverlap(snippet, oldSideContent, newSideContent)) {
|
|
snippetSet.add(sIdx);
|
|
}
|
|
|
|
// Strong match: contextHash matches the hunk's contextual fingerprint.
|
|
// This reduces false positives when repeated patterns exist in a file.
|
|
if (snippet.contextHash) {
|
|
const h = computeDiffContextHash(oldSideContent, newSideContent);
|
|
if (h === snippet.contextHash) {
|
|
strongMatches.add(sIdx);
|
|
}
|
|
}
|
|
}
|
|
|
|
mapping.set(hunkIdx, strongMatches.size > 0 ? strongMatches : snippetSet);
|
|
}
|
|
|
|
return mapping;
|
|
}
|
|
|
|
/**
|
|
* Find the correct position of a snippet's newString in the content,
|
|
* disambiguating when multiple occurrences exist.
|
|
*/
|
|
findSnippetPosition(snippet: SnippetDiff, content: string): number {
|
|
const { newString, oldString } = snippet;
|
|
if (!newString) return -1; // Deletion — can't find empty string reliably
|
|
|
|
const firstPos = content.indexOf(newString);
|
|
if (firstPos === -1) return -1;
|
|
|
|
// Fast path: only one occurrence — no ambiguity
|
|
const lastPos = content.lastIndexOf(newString);
|
|
if (firstPos === lastPos) return firstPos;
|
|
|
|
// Multiple occurrences — collect all positions
|
|
const positions: number[] = [];
|
|
let searchStart = 0;
|
|
while (true) {
|
|
const pos = content.indexOf(newString, searchStart);
|
|
if (pos === -1) break;
|
|
positions.push(pos);
|
|
searchStart = pos + 1;
|
|
}
|
|
|
|
// Disambiguate using oldString context
|
|
if (oldString) {
|
|
const oldTokens = oldString
|
|
.split(/\s+/)
|
|
.filter((t) => t.length > 3)
|
|
.slice(0, 20); // Limit tokens to prevent excessive scanning
|
|
|
|
if (oldTokens.length > 0) {
|
|
let bestPos = firstPos;
|
|
let bestScore = 0;
|
|
|
|
for (const pos of positions) {
|
|
const nearbyStart = Math.max(0, pos - 500);
|
|
const nearbyEnd = Math.min(content.length, pos + newString.length + 500);
|
|
const nearby = content.substring(nearbyStart, nearbyEnd);
|
|
|
|
const matchScore = oldTokens.filter((t) => nearby.includes(t)).length;
|
|
if (matchScore > bestScore) {
|
|
bestScore = matchScore;
|
|
bestPos = pos;
|
|
}
|
|
}
|
|
|
|
return bestPos;
|
|
}
|
|
}
|
|
|
|
return firstPos;
|
|
}
|
|
|
|
// ── Private helpers ──
|
|
|
|
/**
|
|
* Check if a snippet's content overlaps with a hunk's reconstructed file ranges.
|
|
*
|
|
* @param hunkOldSide — reconstructed original file text within hunk range (context + removed lines)
|
|
* @param hunkNewSide — reconstructed modified file text within hunk range (context + added lines)
|
|
*/
|
|
private hasContentOverlap(
|
|
snippet: SnippetDiff,
|
|
hunkOldSide: string,
|
|
hunkNewSide: string
|
|
): boolean {
|
|
if (!snippet.newString && !snippet.oldString) return false;
|
|
|
|
if (
|
|
snippet.type === 'write-new' ||
|
|
snippet.type === 'write-update' ||
|
|
snippet.type === 'notebook-edit' ||
|
|
snippet.type === 'shell-snapshot' ||
|
|
snippet.type === 'hook-snapshot'
|
|
) {
|
|
// Full-file and snapshot changes are intentionally excluded from localized hunk↔snippet matching.
|
|
// They are handled by whole-file reject logic or hunk-level inverse patch.
|
|
return false;
|
|
}
|
|
|
|
// For Edit/MultiEdit: check if snippet falls within hunk's file range
|
|
const hasOld = snippet.oldString.length > 0;
|
|
const hasNew = snippet.newString.length > 0;
|
|
const matchesOld = hasOld ? hunkOldSide.includes(snippet.oldString) : false;
|
|
const matchesNew = hasNew ? hunkNewSide.includes(snippet.newString) : false;
|
|
|
|
// Prefer stricter matching when both sides exist to avoid over-matching.
|
|
if (hasOld && hasNew) return matchesOld && matchesNew;
|
|
if (hasOld) return matchesOld;
|
|
return matchesNew;
|
|
}
|
|
}
|