feat(hashline-edit): add autocorrect, BOM/CRLF normalization, and file creation support
Implements key features from oh-my-pi to improve agent editing success rates: - Autocorrect v1: single-line merge expansion, wrapped line restoration, paired indent restoration (autocorrect-replacement-lines.ts) - BOM/CRLF normalization: canonicalize on read, restore on write (file-text-canonicalization.ts) - Pre-validate all hashes before mutation (edit-ordering.ts) - File creation via append/prepend operations (new types + executor logic) - Modular refactoring: split edit-operations.ts into focused modules (primitives, ordering, deduplication, diff, executor) - Enhanced tool description with operation choice guide and recovery hints All 50 tests pass. TypeScript clean. Build successful.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { autocorrectReplacementLines } from "./autocorrect-replacement-lines"
|
||||
import {
|
||||
restoreLeadingIndent,
|
||||
stripInsertAnchorEcho,
|
||||
stripInsertBeforeEcho,
|
||||
stripInsertBoundaryEcho,
|
||||
stripRangeBoundaryEcho,
|
||||
toNewLines,
|
||||
} from "./edit-text-normalization"
|
||||
import { parseLineRef, validateLineRef } from "./validation"
|
||||
|
||||
interface EditApplyOptions {
|
||||
skipValidation?: boolean
|
||||
}
|
||||
|
||||
function shouldValidate(options?: EditApplyOptions): boolean {
|
||||
return options?.skipValidation !== true
|
||||
}
|
||||
|
||||
export function applySetLine(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
newText: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const originalLine = lines[line - 1] ?? ""
|
||||
const corrected = autocorrectReplacementLines([originalLine], toNewLines(newText))
|
||||
const replacement = corrected.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(originalLine, entry)
|
||||
})
|
||||
result.splice(line - 1, 1, ...replacement)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyReplaceLines(
|
||||
lines: string[],
|
||||
startAnchor: string,
|
||||
endAnchor: string,
|
||||
newText: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) {
|
||||
validateLineRef(lines, startAnchor)
|
||||
validateLineRef(lines, endAnchor)
|
||||
}
|
||||
|
||||
const { line: startLine } = parseLineRef(startAnchor)
|
||||
const { line: endLine } = parseLineRef(endAnchor)
|
||||
|
||||
if (startLine > endLine) {
|
||||
throw new Error(
|
||||
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const originalRange = lines.slice(startLine - 1, endLine)
|
||||
const stripped = stripRangeBoundaryEcho(lines, startLine, endLine, toNewLines(newText))
|
||||
const corrected = autocorrectReplacementLines(originalRange, stripped)
|
||||
const restored = corrected.map((entry, idx) => {
|
||||
if (idx !== 0) return entry
|
||||
return restoreLeadingIndent(lines[startLine - 1], entry)
|
||||
})
|
||||
result.splice(startLine - 1, endLine - startLine + 1, ...restored)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertAfter(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_after requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBefore(
|
||||
lines: string[],
|
||||
anchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) validateLineRef(lines, anchor)
|
||||
const { line } = parseLineRef(anchor)
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_before requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBetween(
|
||||
lines: string[],
|
||||
afterAnchor: string,
|
||||
beforeAnchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) {
|
||||
validateLineRef(lines, afterAnchor)
|
||||
validateLineRef(lines, beforeAnchor)
|
||||
}
|
||||
const { line: afterLine } = parseLineRef(afterAnchor)
|
||||
const { line: beforeLine } = parseLineRef(beforeAnchor)
|
||||
if (beforeLine <= afterLine) {
|
||||
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
|
||||
}
|
||||
result.splice(beforeLine - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyAppend(lines: string[], text: string | string[]): string[] {
|
||||
const normalized = toNewLines(text)
|
||||
if (normalized.length === 0) {
|
||||
throw new Error("append requires non-empty text")
|
||||
}
|
||||
return [...lines, ...normalized]
|
||||
}
|
||||
|
||||
export function applyPrepend(lines: string[], text: string | string[]): string[] {
|
||||
const normalized = toNewLines(text)
|
||||
if (normalized.length === 0) {
|
||||
throw new Error("prepend requires non-empty text")
|
||||
}
|
||||
return [...normalized, ...lines]
|
||||
}
|
||||
|
||||
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
|
||||
if (!content.includes(oldText)) {
|
||||
throw new Error(`Text not found: "${oldText}"`)
|
||||
}
|
||||
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
|
||||
return content.replaceAll(oldText, replacement)
|
||||
}
|
||||
Reference in New Issue
Block a user