fix: address false negatives in diff matching and enhance hunk processing
- Updated the root cause analysis for false negatives in diff matching, clarifying that context lines are discarded during matching. - Improved the `structuredPatch()` method to merge hunks based on a new threshold, enhancing accuracy in matching snippets. - Implemented reconstruction of "old side" and "new side" content from hunks, including context lines to prevent false negatives. - Enhanced the `CliStatusBanner` component to improve user feedback on CLI authentication status. - Updated the change review slice to incorporate file chunk counts for better hunk decision handling.
This commit is contained in:
parent
960bd2fa30
commit
43dd94cd2c
4 changed files with 90 additions and 57 deletions
|
|
@ -22,36 +22,58 @@ Source: 3 parallel research agents (~260k tokens total)
|
|||
|
||||
### A1. FALSE NEGATIVES (критичнее)
|
||||
|
||||
**Root cause**: Различие whitespace между snippet и hunk.
|
||||
**Root cause (уточнён Round 3.1)**: НЕ whitespace (предыдущий анализ был неверен — Edit tool хранит точный текст с indentation). Реальная причина: **context lines** в hunk отбрасываются при matching.
|
||||
|
||||
- `ChangeExtractorService` хранит `oldString`/`newString` как есть из Edit tool_use — **без leading whitespace**
|
||||
- `structuredPatch()` генерирует hunk lines с indentation из файла: `" const x = 1;"`
|
||||
- `.slice(1)` убирает только `+`/`-` prefix, оставляя indentation
|
||||
- `.includes()` сравнивает `"const x"` vs `" const x"` → **не находит**
|
||||
**Механизм**:
|
||||
- `HunkSnippetMatcher` берёт только `+` и `-` строки из хунка, отбрасывая context (` ` prefix)
|
||||
- `removedContent` = join только `-` строк → контекстные строки МЕЖДУ изменёнными строками теряются
|
||||
- Snippet `oldString` содержит ВСЕ строки (включая context), т.к. это точная подстрока файла
|
||||
- `includes()` в обе стороны фейлится: ни `removedContent ⊂ oldString`, ни наоборот
|
||||
|
||||
**Конкретный пример**:
|
||||
**Concrete proof** (из ресёрча):
|
||||
```typescript
|
||||
// Файл с 3 Edit-ами в строках, разделённых < 3 строками:
|
||||
const [state, setState] = useState(0); // ← Edit 1
|
||||
const [data, setData] = useState(null); // ← Edit 2
|
||||
const [loading, setLoading] = useState(false); // ← Edit 3
|
||||
// Claude's Edit:
|
||||
// old_string = "interface UserConfig {\n name: string;\n age: number;\n email: string;\n active: boolean;\n premium: boolean;\n}"
|
||||
// new_string = "interface UserSettings {\n name: string;\n age: number;\n email: string;\n active: boolean;\n isPremium: boolean;\n}"
|
||||
|
||||
// structuredPatch() hunk:
|
||||
// -interface UserConfig { ← removed
|
||||
// name: string; ← CONTEXT (discarded!)
|
||||
// age: number; ← CONTEXT (discarded!)
|
||||
// email: string; ← CONTEXT (discarded!)
|
||||
// active: boolean; ← CONTEXT (discarded!)
|
||||
// - premium: boolean; ← removed
|
||||
// +interface UserSettings { ← added
|
||||
// + isPremium: boolean; ← added
|
||||
|
||||
// removedContent = "interface UserConfig {\n premium: boolean;"
|
||||
// oldString = "interface UserConfig {\n name: string;\n age: number;\n email: string;\n active: boolean;\n premium: boolean;\n}"
|
||||
// removedContent.includes(oldString) → NO
|
||||
// oldString.includes(removedContent) → NO (context lines break contiguity)
|
||||
// ❌ FALSE NEGATIVE — snippet не матчится к своему хунку
|
||||
```
|
||||
|
||||
`structuredPatch()` сливает их в ОДИН хунк (context window = 3 строки).
|
||||
**`structuredPatch()` merge threshold**: `context * 2` = 8 строк (default context=4). Хунки мержатся если gap ≤ 8 строк.
|
||||
|
||||
- Snippet: `oldString = "const [state, setState] = useState(0);"`
|
||||
- Hunk removed: `" const [state, setState] = useState(0);"` (с отступом)
|
||||
- `includes()` → false
|
||||
**Частота**: ВЫСОКАЯ. Любой Edit где Claude захватывает блок с неизменёнными строками внутри:
|
||||
- Переименование interface/class + изменение полей
|
||||
- Смена параметров функции + изменение body
|
||||
- Конфигурационные объекты (часть полей меняется, часть нет)
|
||||
|
||||
**Решение**: Нормализация whitespace при matching:
|
||||
**Решение**: Реконструировать "old side" и "new side" хунка включая context lines:
|
||||
```typescript
|
||||
const normalize = (s: string) =>
|
||||
s.split('\n').map(l => l.trim()).filter(l => l).join('\n');
|
||||
// Вместо только +/- строк:
|
||||
const oldSideContent = hunk.lines
|
||||
.filter(l => l.startsWith(' ') || l.startsWith('-'))
|
||||
.map(l => l.slice(1)).join('\n');
|
||||
const newSideContent = hunk.lines
|
||||
.filter(l => l.startsWith(' ') || l.startsWith('+'))
|
||||
.map(l => l.slice(1)).join('\n');
|
||||
// oldSideContent.includes(snippet.oldString) → TRUE ✓
|
||||
// newSideContent.includes(snippet.newString) → TRUE ✓
|
||||
```
|
||||
|
||||
**Риск**: минимальный — whitespace-only diff в Edit почти невозможен (Claude всегда меняет контент).
|
||||
|
||||
**Оценка**: фиксит ~90% false negatives от merged hunks.
|
||||
**Уверенность**: 9.5/10 что реальный баг. 9/10 что fix через old/new side reconstruction работает.
|
||||
|
||||
### A2. FALSE POSITIVES
|
||||
|
||||
|
|
|
|||
|
|
@ -37,17 +37,22 @@ export class HunkSnippetMatcher {
|
|||
const hunk = patch.hunks[hunkIdx];
|
||||
const snippetSet = new Set<number>();
|
||||
|
||||
// Extract added/removed content from hunk
|
||||
const addedLines = hunk.lines.filter((l) => l.startsWith('+')).map((l) => l.slice(1));
|
||||
const removedLines = hunk.lines.filter((l) => l.startsWith('-')).map((l) => l.slice(1));
|
||||
const addedContent = addedLines.join('\n');
|
||||
const removedContent = removedLines.join('\n');
|
||||
// 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];
|
||||
|
||||
// Content overlap: check if snippet's strings appear in hunk's diff content
|
||||
if (this.hasContentOverlap(snippet, addedContent, removedContent)) {
|
||||
if (this.hasContentOverlap(snippet, oldSideContent, newSideContent)) {
|
||||
snippetSet.add(sIdx);
|
||||
}
|
||||
}
|
||||
|
|
@ -116,40 +121,30 @@ export class HunkSnippetMatcher {
|
|||
// ── Private helpers ──
|
||||
|
||||
/**
|
||||
* Check if a snippet's content overlaps with hunk's added/removed content.
|
||||
* 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,
|
||||
hunkAddedContent: string,
|
||||
hunkRemovedContent: string
|
||||
hunkOldSide: string,
|
||||
hunkNewSide: string
|
||||
): boolean {
|
||||
// Skip empty snippets
|
||||
if (!snippet.newString && !snippet.oldString) return false;
|
||||
|
||||
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
|
||||
// For Write: check if hunk's added content is a substring of snippet's newString
|
||||
if (snippet.newString && hunkAddedContent) {
|
||||
return snippet.newString.includes(hunkAddedContent);
|
||||
// For Write: snippet.newString is the full file content — check if hunk's new side is within it
|
||||
if (snippet.newString && hunkNewSide) {
|
||||
return snippet.newString.includes(hunkNewSide);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// For Edit/MultiEdit: check bidirectional overlap
|
||||
const matchesNew = snippet.newString
|
||||
? hunkAddedContent.includes(snippet.newString) || snippet.newString.includes(hunkAddedContent)
|
||||
: false;
|
||||
// For Edit/MultiEdit: check if snippet falls within hunk's file range
|
||||
const matchesOld = snippet.oldString ? hunkOldSide.includes(snippet.oldString) : false;
|
||||
const matchesNew = snippet.newString ? hunkNewSide.includes(snippet.newString) : false;
|
||||
|
||||
const matchesOld = snippet.oldString
|
||||
? hunkRemovedContent.includes(snippet.oldString) ||
|
||||
snippet.oldString.includes(hunkRemovedContent)
|
||||
: false;
|
||||
|
||||
// Both directions match = high confidence
|
||||
if (matchesNew && matchesOld) return true;
|
||||
|
||||
// Single direction match = acceptable for Edit
|
||||
if (matchesNew || matchesOld) return true;
|
||||
|
||||
return false;
|
||||
return matchesOld || matchesNew;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,9 +161,18 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
const [showLoginTerminal, setShowLoginTerminal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
void fetchCliStatus();
|
||||
}
|
||||
if (!isElectron) return;
|
||||
|
||||
void fetchCliStatus();
|
||||
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
void fetchCliStatus();
|
||||
},
|
||||
10 * 60 * 1000
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isElectron, fetchCliStatus]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
|
|
@ -447,6 +456,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
|
||||
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
|
||||
</span>
|
||||
{cliStatus.authLoggedIn && (
|
||||
<span className="text-xs" style={{ color: '#4ade80' }}>
|
||||
Authenticated
|
||||
</span>
|
||||
)}
|
||||
{cliStatus.updateAvailable && cliStatus.latestVersion && (
|
||||
<span className="text-xs" style={{ color: '#60a5fa' }}>
|
||||
→ v{cliStatus.latestVersion}
|
||||
|
|
|
|||
|
|
@ -540,7 +540,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
}
|
||||
|
||||
// Build FileReviewDecision[] from hunkDecisions/fileDecisions
|
||||
const { hunkDecisions, fileDecisions, activeChangeSet } = get();
|
||||
const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet } = get();
|
||||
if (!activeChangeSet) {
|
||||
set({ applying: false });
|
||||
return;
|
||||
|
|
@ -552,7 +552,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
const fileDecision = fileDecisions[file.filePath] ?? 'pending';
|
||||
const hunkDecs: Record<number, HunkDecision> = {};
|
||||
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = `${file.filePath}:${i}`;
|
||||
hunkDecs[i] = hunkDecisions[key] ?? 'pending';
|
||||
}
|
||||
|
|
@ -599,7 +600,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
taskId?: string,
|
||||
memberName?: string
|
||||
) => {
|
||||
const { hunkDecisions, fileDecisions, activeChangeSet } = get();
|
||||
const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet } = get();
|
||||
if (!activeChangeSet) return;
|
||||
|
||||
const file = activeChangeSet.files.find((f) => f.filePath === filePath);
|
||||
|
|
@ -607,7 +608,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
|
||||
const fileDecision = fileDecisions[filePath] ?? 'pending';
|
||||
const hunkDecs: Record<number, HunkDecision> = {};
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
const count = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
|
||||
for (let i = 0; i < count; i++) {
|
||||
hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending';
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue