feat(patch): added boundary duplication detection to prevent off-by-one range errors

- Added boundary duplication warning to detect off-by-one range errors in replace_range and replace_line operations.
- Updated hashline tool documentation with boundary duplication trap guidance to prevent closing delimiter duplication.
This commit is contained in:
can1357
2026-03-22 22:42:42 +01:00
parent cac628ccce
commit f09c5bd67c
3 changed files with 34 additions and 1 deletions
+1 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Renamed hashline edit operation types: `append` → `append_at`, `prepend` → `prepend_at`, `append_eof` → `append_file`, `prepend_bof` → `prepend_file`
@@ -11,6 +10,7 @@
### Added
- Added boundary duplication warning when replace_range or replace_line operations include a last inserted line that matches the next surviving line, helping detect off-by-one range errors
- Added git branch isolation for autoresearch sessions via `ensureAutoresearchBranch()` to safely revert failed experiments
- Added branch status line to autoresearch initialization and resume prompts showing created or reused branch name
- Added `Files in Scope`, `Off Limits`, and `Constraints` sections to autoresearch.md template for explicit scope definition
@@ -539,6 +539,38 @@ export function applyHashlineEdits(
}
maybeAutocorrectEscapedTabIndentation(edits, warnings);
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
// Warn when a replace_range/replace_line's last inserted line duplicates the next surviving line.
// This catches the common boundary-overreach pattern where the agent includes a closing delimiter
// in the replacement but sets `end` to the line before the delimiter, causing duplication.
for (const edit of edits) {
let endLine: number;
switch (edit.op) {
case "replace_line":
endLine = edit.pos.line;
break;
case "replace_range":
endLine = edit.end.line;
break;
default:
continue;
}
if (edit.lines.length === 0) continue;
const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end`
if (nextSurvivingIdx >= originalFileLines.length) continue;
const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
const lastInsertedLine = edit.lines[edit.lines.length - 1];
const trimmedNext = nextSurvivingLine.trim();
const trimmedLast = lastInsertedLine.trim();
// Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation
if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
const tag = formatLineTag(endLine + 1, nextSurvivingLine);
warnings.push(
`Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
`If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
);
}
}
// Deduplicate identical edits targeting the same line(s)
const seenEditKeys = new Map<string, number>();
const dedupIndices = new Set<number>();
@@ -127,6 +127,7 @@ When adding a sibling declaration, prefer `prepend_at` on the next declaration.
- `replace_range` requires both `pos` and `end`. All other anchored ops require `pos` only.
- `append_file` and `prepend_file` do not take anchors.
- Replace exactly the owned span. If `lines` re-emits content beyond `end`, it will duplicate.
- **Boundary duplication trap**: when replacing a block, `end` must be the **last line of the block** (e.g. the closing `}`), not the last *content* line before it. Otherwise the closing delimiter survives and your replacement adds a second copy.
- Do not target shared boundary lines such as `} else {`, `} catch (…) {`, `}),`, or `},{`.
- For a block, either replace only the body or replace the whole block. Do not split block boundaries.
- `lines` must be literal file content with matching indentation. If the file uses tabs, use real tabs.