fix(opencode): prevent destructive edit matches (#30932)
This commit is contained in:
@@ -89,10 +89,14 @@ export const EditTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
if (params.oldString === "") {
|
||||
const existed = yield* afs.existsSafe(filePath)
|
||||
const source = existed ? yield* Bom.readFile(afs, filePath) : { bom: false, text: "" }
|
||||
if (existed) {
|
||||
throw new Error(
|
||||
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
|
||||
)
|
||||
}
|
||||
const next = Bom.split(params.newString)
|
||||
const desiredBom = source.bom || next.bom
|
||||
contentOld = source.text
|
||||
const desiredBom = next.bom
|
||||
contentOld = ""
|
||||
contentNew = next.text
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
yield* ctx.ask({
|
||||
@@ -111,7 +115,7 @@ export const EditTool = Tool.define(
|
||||
yield* events.publish(FileSystem.Event.Edited, { file: filePath })
|
||||
yield* events.publish(Watcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
event: "add",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -213,8 +217,8 @@ export const EditTool = Tool.define(
|
||||
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
||||
|
||||
// Similarity thresholds for block anchor fallback matching
|
||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
||||
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
|
||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65
|
||||
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65
|
||||
|
||||
/**
|
||||
* Levenshtein distance algorithm implementation
|
||||
@@ -296,6 +300,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25))
|
||||
|
||||
// Collect all candidate positions where both anchors match
|
||||
const candidates: Array<{ startLine: number; endLine: number }> = []
|
||||
@@ -307,7 +312,10 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
// Look for the matching last line after this first line
|
||||
for (let j = i + 2; j < originalLines.length; j++) {
|
||||
if (originalLines[j].trim() === lastLineSearch) {
|
||||
candidates.push({ startLine: i, endLine: j })
|
||||
const actualBlockSize = j - i + 1
|
||||
if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
|
||||
candidates.push({ startLine: i, endLine: j })
|
||||
}
|
||||
break // Only match the first occurrence of the last line
|
||||
}
|
||||
}
|
||||
@@ -324,7 +332,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const actualBlockSize = endLine - startLine + 1
|
||||
|
||||
let similarity = 0
|
||||
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
||||
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
||||
|
||||
if (linesToCheck > 0) {
|
||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||
@@ -373,7 +381,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const actualBlockSize = endLine - startLine + 1
|
||||
|
||||
let similarity = 0
|
||||
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
||||
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
||||
|
||||
if (linesToCheck > 0) {
|
||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||
@@ -675,6 +683,11 @@ export function replace(content: string, oldString: string, newString: string, r
|
||||
if (oldString === newString) {
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
if (oldString === "") {
|
||||
throw new Error(
|
||||
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
|
||||
)
|
||||
}
|
||||
|
||||
let notFound = true
|
||||
|
||||
@@ -693,6 +706,11 @@ export function replace(content: string, oldString: string, newString: string, r
|
||||
const index = content.indexOf(search)
|
||||
if (index === -1) continue
|
||||
notFound = false
|
||||
if (isDisproportionateMatch(search, oldString)) {
|
||||
throw new Error(
|
||||
"Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.",
|
||||
)
|
||||
}
|
||||
if (replaceAll) {
|
||||
return content.replaceAll(search, newString)
|
||||
}
|
||||
@@ -709,3 +727,11 @@ export function replace(content: string, oldString: string, newString: string, r
|
||||
}
|
||||
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
|
||||
}
|
||||
|
||||
function isDisproportionateMatch(search: string, oldString: string) {
|
||||
const oldLines = oldString.split("\n").length
|
||||
const searchLines = search.split("\n").length
|
||||
if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true
|
||||
if (oldLines === 1) return false
|
||||
return search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user