Skip to content

Commit de06a35

Browse files
committed
fix: improve search/replace matching with fuzzy fallbacks
- Try exact match → whitespace-normalized → stripped - Pipeline now fetches files for ALL processed findings, not just top 5 - Increase file content limit from 4000→6000 chars in prompt - Better debug logging for search text mismatches
1 parent 26151e6 commit de06a35

2 files changed

Lines changed: 77 additions & 19 deletions

File tree

contribai/generator/engine.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def _build_generation_prompt(self, finding: Finding, context: RepoContext) -> st
166166
if current_content:
167167
prompt += (
168168
f"\n## Current File Content ({finding.file_path})\n"
169-
f"```\n{current_content[:4000]}\n```\n"
169+
f"```\n{current_content[:6000]}\n```\n"
170170
)
171171

172172
prompt += "\n## Output Format\nReturn your changes as a JSON object.\n\n"
@@ -256,35 +256,84 @@ def _parse_changes(self, response: str, context: RepoContext) -> list[FileChange
256256
# Search/replace mode — apply edits to original content
257257
original = context.relevant_files.get(path, "")
258258
if not original:
259-
logger.warning("No original content for %s, skipping edits", path)
259+
logger.warning(
260+
"No original content for %s (finding file not fetched), skipping edits",
261+
path,
262+
)
260263
continue
261264

262265
new_content = original
263266
edits_applied = 0
267+
edits_total = len(item["edits"])
264268
for edit in item["edits"]:
265269
search = edit.get("search", "")
266270
replace = edit.get("replace", "")
267271
if not search:
268272
continue
273+
274+
matched = False
275+
276+
# Try 1: Exact match
269277
if search in new_content:
270278
new_content = new_content.replace(search, replace, 1)
279+
matched = True
280+
281+
# Try 2: Normalize trailing whitespace per line
282+
if not matched:
283+
norm_search = "\n".join(line.rstrip() for line in search.split("\n"))
284+
norm_content = "\n".join(
285+
line.rstrip() for line in new_content.split("\n")
286+
)
287+
if norm_search in norm_content:
288+
# Find position in normalized, apply to original
289+
idx = norm_content.index(norm_search)
290+
# Map back: count newlines to find line range
291+
start_line = norm_content[:idx].count("\n")
292+
end_line = start_line + norm_search.count("\n")
293+
lines = new_content.split("\n")
294+
lines[start_line : end_line + 1] = replace.split("\n")
295+
new_content = "\n".join(lines)
296+
matched = True
297+
logger.debug(
298+
"Fuzzy match (whitespace normalized) for %s",
299+
path,
300+
)
301+
302+
# Try 3: Strip all leading/trailing whitespace
303+
if not matched:
304+
stripped_search = search.strip()
305+
if len(stripped_search) > 20 and stripped_search in new_content:
306+
new_content = new_content.replace(
307+
stripped_search, replace.strip(), 1
308+
)
309+
matched = True
310+
logger.debug(
311+
"Fuzzy match (stripped) for %s",
312+
path,
313+
)
314+
315+
if matched:
271316
edits_applied += 1
272317
else:
273318
logger.warning(
274-
"Search text not found in %s: %s...",
319+
"Search text not found in %s (tried exact + fuzzy). "
320+
"Search[:%d]: %.80s...",
275321
path,
276-
search[:60],
322+
len(search),
323+
search.replace("\n", "\\n"),
277324
)
278325

279-
if edits_applied == 0:
280-
logger.warning("No edits applied to %s", path)
281-
continue
282-
283326
logger.info(
284-
"Applied %d search/replace edits to %s",
285-
edits_applied,
327+
"Edits for %s: %d/%d applied",
286328
path,
329+
edits_applied,
330+
edits_total,
287331
)
332+
333+
if edits_applied == 0:
334+
logger.warning("No edits applied to %s, skipping file", path)
335+
continue
336+
288337
changes.append(
289338
FileChange(
290339
path=path,

contribai/orchestrator/pipeline.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -291,18 +291,27 @@ async def _process_repo(
291291
analysis.analysis_duration_sec,
292292
)
293293

294-
# Build context for generation
294+
# Build context for generation — fetch files for ALL findings we'll process
295295
file_tree = await self._github.get_file_tree(repo.owner, repo.name)
296296
relevant_files: dict[str, str] = {}
297-
for finding in analysis.top_findings[:5]:
297+
# Deduplicate file paths across all findings we'll process
298+
file_paths_to_fetch = []
299+
for finding in analysis.top_findings[:max_prs]:
298300
if finding.file_path and finding.file_path not in relevant_files:
299-
try:
300-
content = await self._github.get_file_content(
301-
repo.owner, repo.name, finding.file_path
302-
)
303-
relevant_files[finding.file_path] = content
304-
except Exception:
305-
pass
301+
file_paths_to_fetch.append(finding.file_path)
302+
303+
for fpath in file_paths_to_fetch:
304+
try:
305+
content = await self._github.get_file_content(repo.owner, repo.name, fpath)
306+
relevant_files[fpath] = content
307+
except Exception:
308+
logger.debug("Could not fetch %s", fpath)
309+
310+
logger.info(
311+
"Fetched %d/%d unique files for code gen",
312+
len(relevant_files),
313+
len(file_paths_to_fetch),
314+
)
306315

307316
from contribai.core.models import RepoContext
308317

0 commit comments

Comments
 (0)