oh-my-opencode/src/tools/hashline-edit/hashline-edit-executor.ts
minpeter b1203b9501 Fix hashline-edit deduplication and validation
- Canonicalize anchors in dedupe keys to handle whitespace variants
- Make lines field required in edit operations
- Only allow unanchored append/prepend to create missing files
- Reorder delete/rename validation to prevent edge cases
- Add allow_non_gpt_model and max_prompt_tokens to config schema
  ```
2026-02-27 01:37:19 +09:00

168 lines
5.4 KiB
TypeScript

import type { ToolContext } from "@opencode-ai/plugin/tool"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { applyHashlineEditsWithReport } from "./edit-operations"
import { countLineDiffs, generateUnifiedDiff } from "./diff-utils"
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
import type { HashlineEdit } from "./types"
import { HashlineMismatchError } from "./validation"
interface HashlineEditArgs {
filePath: string
edits: RawHashlineEdit[]
delete?: boolean
rename?: string
}
type ToolContextWithCallID = ToolContext & {
callID?: string
callId?: string
call_id?: string
}
type ToolContextWithMetadata = ToolContextWithCallID & {
metadata?: (value: unknown) => void
}
function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id
return undefined
}
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
if (edits.length === 0) return false
return edits.every((edit) => (edit.op === "append" || edit.op === "prepend") && !edit.pos)
}
function buildSuccessMeta(
effectivePath: string,
beforeContent: string,
afterContent: string,
noopEdits: number,
deduplicatedEdits: number
) {
const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath)
const { additions, deletions } = countLineDiffs(beforeContent, afterContent)
const beforeLines = beforeContent.split("\n")
const afterLines = afterContent.split("\n")
const maxLength = Math.max(beforeLines.length, afterLines.length)
let firstChangedLine: number | undefined
for (let index = 0; index < maxLength; index += 1) {
if ((beforeLines[index] ?? "") !== (afterLines[index] ?? "")) {
firstChangedLine = index + 1
break
}
}
return {
title: effectivePath,
metadata: {
filePath: effectivePath,
path: effectivePath,
file: effectivePath,
diff: unifiedDiff,
noopEdits,
deduplicatedEdits,
firstChangedLine,
filediff: {
file: effectivePath,
path: effectivePath,
filePath: effectivePath,
before: beforeContent,
after: afterContent,
additions,
deletions,
},
},
}
}
export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext): Promise<string> {
try {
const metadataContext = context as ToolContextWithMetadata
const filePath = args.filePath
const { delete: deleteMode, rename } = args
if (deleteMode && rename) {
return "Error: delete and rename cannot be used together"
}
if (deleteMode && args.edits.length > 0) {
return "Error: delete mode requires edits to be an empty array"
}
if (!deleteMode && (!args.edits || !Array.isArray(args.edits) || args.edits.length === 0)) {
return "Error: edits parameter must be a non-empty array"
}
const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits)
const file = Bun.file(filePath)
const exists = await file.exists()
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
return `Error: File not found: ${filePath}`
}
if (deleteMode) {
if (!exists) return `Error: File not found: ${filePath}`
await Bun.file(filePath).delete()
return `Successfully deleted ${filePath}`
}
const rawOldContent = exists ? Buffer.from(await file.arrayBuffer()).toString("utf8") : ""
const oldEnvelope = canonicalizeFileText(rawOldContent)
const applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits)
const canonicalNewContent = applyResult.content
if (canonicalNewContent === oldEnvelope.content && !rename) {
let diagnostic = `No changes made to ${filePath}. The edits produced identical content.`
if (applyResult.noopEdits > 0) {
diagnostic += ` No-op edits: ${applyResult.noopEdits}. Re-read the file and provide content that differs from current lines.`
}
return `Error: ${diagnostic}`
}
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
await Bun.write(filePath, writeContent)
if (rename && rename !== filePath) {
await Bun.write(rename, writeContent)
await Bun.file(filePath).delete()
}
const effectivePath = rename && rename !== filePath ? rename : filePath
const meta = buildSuccessMeta(
effectivePath,
oldEnvelope.content,
canonicalNewContent,
applyResult.noopEdits,
applyResult.deduplicatedEdits
)
if (typeof metadataContext.metadata === "function") {
metadataContext.metadata(meta)
}
const callID = resolveToolCallID(metadataContext)
if (callID) {
storeToolMetadata(context.sessionID, callID, meta)
}
if (rename && rename !== filePath) {
return `Moved ${filePath} to ${rename}`
}
return `Updated ${effectivePath}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof HashlineMismatchError) {
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
}
return `Error: ${message}`
}
}