Files
claude-code/packages/builtin-tools/src/tools/FileEditTool/utils.ts
claude-code-best cd222b8e65 Fixture/flick (#1280)
* fix: 终端内容溢出 viewport 时的重影 bug

主屏幕模式下 frame 持续溢出 viewport 时,cursor-restore LF 把内容滚入 scrollback
导致相对光标追踪漂移,可见区 diff 落到错误行产生重影(重复 banner / 错位)。
扩展 log-update overflow 分支为无条件 fullReset(含 \x1b[3J 清 scrollback),
并将主屏 self-healing 清屏从 ERASE_SCREEN (CSI 2 J) 换成 ERASE_DOWN (CSI J),
避免 xterm.js / VSCode 集成终端的 scrollback 边界副作用。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 3 个孤立诊断脚本

- scripts/verify-autofix-pr.ts: 一次性 autofix-pr 验证脚本,全仓零引用
- scripts/smoke-test-commands.ts: 开发期冒烟测试脚本,无任何 import
- scripts/probe-subscription-endpoints.ts: 手动 endpoint 探针,无引用

均不在 package.json scripts、build.ts、vite.config.ts、CI workflows 中。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 self-hosted-runner stub 及其 cli.tsx fast-path

- 删除 src/self-hosted-runner/main.ts(自动生成的 Promise.resolve() stub)
- 同步移除 src/entrypoints/cli.tsx 中 feature('SELF_HOSTED_RUNNER') 守卫的 fast-path 分支
- 该 flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码

删除 stub 单独会留下未解析的动态 import,必须协同拆除。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 agentSdkTypes 中三个 not-implemented stub

移除 watchScheduledTasks、buildMissedTaskNotification、connectRemoteControl 三个 stub 函数(函数体仅 throw new Error('not implemented')),以及仅被这些 stub 引用的孤儿类型(ScheduledTasksHandle、ConnectRemoteControlOptions、RemoteControlHandle、InboundPrompt 等)。

全仓零外部引用。buildMissedTaskNotification 在 src/utils/cronScheduler.ts 有真实可用实现,未受影响。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 Cursor.ts 中未引用的 kill ring 访问器

- 删除 getKillRingItem、getKillRingSize、clearKillRing、canYankPop(全仓零引用的独立 export)
- 移除 VIM_WORD_CHAR_REGEX 的 export 关键字(仍由 isVimWordChar 内部使用,保留常量本体)

kill ring 特性本身仍活跃(getLastKill/pushToKillRing/yankPop 在 useSearchInput/useTextInput 使用),仅这几个孤儿 helper 未接入。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 insights.ts 中未引用的导出

- 删除 deduplicateSessionBranches(全仓零调用,含 JSDoc)
- 删除 buildExportData(全仓零调用,原 S3 上传路径实际用 HTML 而非 JSON)
- InsightsExport 仅移除 export 关键字(保留类型本体,仍作为内部返回类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 autonomyCommandSpec.ts 中未引用的导出

- 删除 AUTONOMY_CLI(CLI 子命令描述对象,零引用;handler 仅用 AUTONOMY_USAGE)
- 删除 AUTONOMY_COMMAND_DESCRIPTION(值已在 main.tsx:5181 内联)
- ParsedAutonomyCommand 仅移除 export 关键字(保留类型作为 parseAutonomyArgs 返回类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 binaryCheck/claudeAiLimits/codeIndexing 中未引用的导出

- binaryCheck.ts: 删除 clearBinaryCache(零调用,binaryCache 仍由 isBinaryInstalled 使用)
- claudeAiLimits.ts: 删除 RATE_LIMIT_DISPLAY_NAMES 常量 + getRateLimitDisplayName(互为唯一消费者)
- codeIndexing.ts: 删除 detectCodeIndexingFromMcpTool(同胞 detectCodeIndexingFromCommand/McpServerName 仍活跃)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除多处仅内部使用的 export 关键字

下列符号均仅在本文件内被引用,export 关键字冗余;保留符号本体不动:

- internalLogging.ts: getContainerId(line 88 内部调用)
- api/errors.ts: isMediaSizeError(line 151 内部调用)
- api/withRetry.ts: parseMaxTokensContextOverflowError(line 389/724 内部调用)
- statsCache.ts: STATS_CACHE_VERSION(7 处内部使用)
- startupProfiler.ts: logStartupPerf(line 128 内部调用)
- bashCommandHelpers.ts: CommandIdentityCheckers(3 处内部参数类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 清理注释代码块与 legacy shim

注释代码(已死的、引用不存在符号的注释块):
- Onboarding.tsx: 注释化的 preflight if-block(引用不存在的 preflightStep)
- ultraplan.tsx: 两处引用不存在符号的注释(ULTRAPLAN_INSTRUCTIONS、getUltraplanModel)
- types/hooks.ts: 禁用的 type-fest IsEqual 类型断言块
- types/global.d.ts: 已被真实模块取代的 Ultraplan ambient declares
- types/textInputTypes.ts: 注释化的 onMessage interface 成员

legacy shim:
- cli/bg.ts: 删除 handleBgFlag 别名 export(同胞 handleBgStart 已被所有调用点使用)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 ccshareResume stub 及 main.tsx 的 ccshare fast-path

- 删除 src/utils/ccshareResume.ts(parseCcshareId 恒返回 null、loadCcshare 恒抛错的 stub)
- 同步移除 src/main.tsx 中 USER_TYPE === 'ant' 守卫下的 if (ccshareId) {...} else {...} 双分支
- 提升 else 块(文件路径 resume 处理)为直接进入 if (options.resume) 块内

ccshare 是 Anthropic 内部特性(go/ccshare URL),stub 未实现导致 ccshareId 恒为 null,整个 ccshare 分支永不进入;保留的文件路径 resume 路径不变。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 environment-runner stub 及其 cli.tsx fast-path

与 self-hosted-runner 相同模式的 sibling(工作流 1 verifier 建议同步处理):

- 删除 src/environment-runner/main.ts(自动生成的 Promise.resolve() stub)
- 同步移除 src/entrypoints/cli.tsx 中 feature('BYOC_ENVIRONMENT_RUNNER') 守卫的 fast-path 分支
- 清理两个空目录(src/self-hosted-runner/、src/environment-runner/)

BYOC_ENVIRONMENT_RUNNER flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除孤立诊断脚本 probe-local-wiring.ts

#!/usr/bin/env bun shebang 的手动诊断脚本,全仓零引用,不在 package.json/build.ts/vite.config.ts/CI workflows 中。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 ultrareview preflight stub 及其测试

- 删除 src/services/api/ultrareviewPreflight.ts(自动生成的 stub)
- 删除 src/commands/review/UltrareviewPreflightDialog.tsx(依赖前者的 UI stub)
- 删除 src/services/api/__tests__/ultrareviewPreflight.test.ts(测试已删代码)
- 同步移除 ultrareviewCommand.test.tsx 中对 UltrareviewPreflightDialog 的 mock

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 cachedMCConfig stub 及 prompts.ts 的 CACHED_MICROCOMPACT 死代码

- 删除 src/services/compact/cachedMCConfig.ts(自动生成的 stub)
- 同步移除 src/constants/prompts.ts 中依赖该 stub 的代码:
  - getCachedMCConfigForFRC 变量(feature('CACHED_MICROCOMPACT') 守卫的 require)
  - getFunctionResultClearingSection 函数(约 18 行)
  - systemPrompt 数组中的 frc section 调用与注册

CACHED_MICROCOMPACT 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 goalAudit stub 及其测试引用

- 删除 src/services/goal/goalAudit.ts(导出 COMPLETION_AUDIT_RULES/BLOCKED_AUDIT_RULES/isGoalTerminal 等未引用的 stub)
- 同步移除 tests/integration/goal-lifecycle.test.ts 中对 goalAudit 的 import 和一个测试用例(budget_limited is terminal)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 agentSdkTypes 第二批 not-implemented stub

移除运行时函数体仅为 throw new Error 或 placeholder 的 stub:
- createSdkMcpToolDefinition、createSdkMcpServer
- query 函数重载与实现
- unstable_v2_* 系列函数
- session 操作 stub(getSessionMessages/listSessions/getSessionInfo/renameSession/tagSession/forkSession)
- AbortError 类

保留所有 export type 重导出和类型别名(仍是公共类型面)。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 Tool.ts 中 backwards-compat 重导出 shim

删除 "// Re-export progress types for backwards compatibility" 注释块及其重导出语句。所有消费方已直接从 src/types/tools.js 导入,无需重导出转发。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 bootstrap/state.ts 中 4 个未引用的 export

- clearRegisteredHooks(STATE.registeredHooks 仍由其他函数管理)
- getInvokedSkills(getInvokedSkillsForAgent 是活跃入口)
- getSessionSource(setSessionSource 仍活跃,sessionSource state 字段保留)
- markScrollActivity(scrollDraining/getIsScrollDraining/waitForScrollDrain 仍活跃)

仅删除孤儿访问器,不动模块级 state 副作用。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 src/ 下多处未引用的导出

涉及 18 个文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除:

- bridge/bridgeStatusUtil.ts、components/TrustDialog/utils.ts、context/stats.tsx
- keybindings/loadUserBindings.ts、memdir/paths.ts、remote/sdkMessageAdapter.ts
- services/acp/utils.ts(删除 nodeToWebReadable,全仓零引用)
- services/api/metricsOptOut.ts、services/lsp/LSPDiagnosticRegistry.ts、services/lsp/manager.ts
- services/mcp/utils.ts、services/skillLearning/projectContext.ts
- services/teamMemorySync/secretScanner.ts、services/teamMemorySync/watcher.ts
- skills/loadSkillsDir.ts、utils/attachments.ts、utils/filePersistence/filePersistence.ts
- utils/messageQueueManager.ts

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 packages/ 下多处未引用的导出

涉及 11 个 workspace 包文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除:

- @ant/ink/core/termio/csi.ts(eraseLine)
- acp-link/manager/types.ts、acp-link/ws-message.ts
- builtin-tools/AgentTool/agentMemory.ts、BashTool/bashSecurity.ts、BashTool/sedEditParser.ts
- builtin-tools/ConfigTool/supportedSettings.ts、FileEditTool/utils.ts
- remote-control-server/store.ts、transport/event-bus.ts、types/messages.ts

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* Revert "fix: 终端内容溢出 viewport 时的重影 bug"

This reverts commit 3d18e1da58.

* revert: 移除主屏幕周期性 self-healing 重绘

回退 f69c7051 中引入的 ink.tsx self-healing 机制(lastMainScreenHealTime 字段
+ 每 5 秒触发全量重绘 + needsEraseBeforePaint 主屏幕分支)。该机制在 workflow
面板持续刷新场景下表现为可见的"重复刷新",且修复效果不稳定。

alt-screen 的 needsEraseBeforePaint 路径和 prevFrameContaminated 字段保留,
它们仍服务于 handleResize / layout shift / selection 高亮。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix: /workflows 面板默认只显示运行中 run,根治 tab 行乱码

之前几次渲染层修复都失败,因为没动 tab 列表的数据源:打开 /workflows 会
自动 hydrate 最多 20 个历史 done/killed run,全部塞进一行 TabsBar,超出
终端宽度后 Ink 把字符画到屏外造成重影乱码。

- selectors.ts 加 filterActiveRuns(只留 status === 'running')和
  capTabsForDisplay(超额 fold 成 +N)两个 pure function
- WorkflowsPanel 接线 activeRuns:focus clamp、focused、nextTab/prevTab、
  TabsBar 全部基于过滤后的 activeRuns
- TabsBar 复用 truncateLabel 限制每个 tab 名 18 字符 + 最多 6 个 tab,
  多余显示 +N,从结构上钉死单行总宽度

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix: /workflows 面板 phase 状态在脚本省略 phase() 时显示错乱

ultracode canonical pipeline 脚本常在 agent() 直接传 opts.phase 而不调 phase()
hook,导致 phase_started 从未发出;同时 phase_done 只在下次 phase() 触发,上一
个 phase 在 run.phases 里一直停在 running。mergePhases 之前把 actual 当权威,
于是出现 "Map 8/8 全 done 还显示 running、Find 1/4 running 反而显示 pending"。

改为派生层修复:mergePhases 新增 derivePhaseStatus——actual.status==='done'
权威;否则有 agents 就按 agents 状态推(全 done→done,否则 running);否则看
actual 是否 running。再补一层遍历,让只在 agents 上出现的 phase 也进 sidebar。
不改 store 状态语义,已有 state.json 无需迁移。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs: 更新 readme

* fix: ACP 模式未读取 settings.local.json

entry.ts 在 ACP 握手期调用的 applySafeConfigEnvironmentVariables 触发了
loadSettingsFromDisk,此时 getOriginalCwd() 还是进程启动 cwd(非项目目录),
导致 localSettings/projectSettings 按错误路径解析为空并被 session cache 锁住,
后续 createSession 里 setOriginalCwd 也无法纠正。在 setOriginalCwd 与 chdir
之后清缓存并重新应用,让 settings.local.json 和项目级 env 对
readSettingsPermissionMode 及下游可见。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

---------

Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
2026-06-22 09:59:36 +08:00

601 lines
17 KiB
TypeScript

import { type StructuredPatchHunk, structuredPatch } from 'diff'
import { logError } from 'src/utils/log.js'
import { expandPath } from 'src/utils/path.js'
import { countCharInString } from 'src/utils/stringUtils.js'
import {
DIFF_TIMEOUT_MS,
getPatchForDisplay,
getPatchFromContents,
} from 'src/utils/diff.js'
import { errorMessage, isENOENT } from 'src/utils/errors.js'
import {
addLineNumbers,
convertLeadingTabsToSpaces,
readFileSyncCached,
} from 'src/utils/file.js'
import type { EditInput, FileEdit } from './types.js'
/**
* Strips trailing whitespace from each line in a string while preserving line endings
* @param str The string to process
* @returns The string with trailing whitespace removed from each line
*/
export function stripTrailingWhitespace(str: string): string {
// Handle different line endings: CRLF, LF, CR
// Use a regex that matches line endings and captures them
const lines = str.split(/(\r\n|\n|\r)/)
let result = ''
for (let i = 0; i < lines.length; i++) {
const part = lines[i]
if (part !== undefined) {
if (i % 2 === 0) {
// Even indices are line content
result += part.replace(/\s+$/, '')
} else {
// Odd indices are line endings
result += part
}
}
}
return result
}
/**
* Finds the exact string in the file content.
*
* @param fileContent The file content to search in
* @param searchString The string to search for
* @returns The search string if found, or null if not found
*/
export function findActualString(
fileContent: string,
searchString: string,
): string | null {
if (fileContent.includes(searchString)) {
return searchString
}
return null
}
/**
* Transform edits to ensure replace_all always has a boolean value
* @param edits Array of edits with optional replace_all
* @returns Array of edits with replace_all guaranteed to be boolean
*/
export function applyEditToFile(
originalContent: string,
oldString: string,
newString: string,
replaceAll: boolean = false,
): string {
const f = replaceAll
? (content: string, search: string, replace: string) =>
content.replaceAll(search, () => replace)
: (content: string, search: string, replace: string) =>
content.replace(search, () => replace)
if (newString !== '') {
return f(originalContent, oldString, newString)
}
const stripTrailingNewline =
!oldString.endsWith('\n') && originalContent.includes(oldString + '\n')
return stripTrailingNewline
? f(originalContent, oldString + '\n', newString)
: f(originalContent, oldString, newString)
}
/**
* Applies an edit to a file and returns the patch and updated file.
* Does not write the file to disk.
*/
export function getPatchForEdit({
filePath,
fileContents,
oldString,
newString,
replaceAll = false,
}: {
filePath: string
fileContents: string
oldString: string
newString: string
replaceAll?: boolean
}): { patch: StructuredPatchHunk[]; updatedFile: string } {
return getPatchForEdits({
filePath,
fileContents,
edits: [
{ old_string: oldString, new_string: newString, replace_all: replaceAll },
],
})
}
/**
* Applies a list of edits to a file and returns the patch and updated file.
* Does not write the file to disk.
*
* NOTE: The returned patch is to be used for display purposes only - it has spaces instead of tabs
*/
export function getPatchForEdits({
filePath,
fileContents,
edits,
}: {
filePath: string
fileContents: string
edits: FileEdit[]
}): { patch: StructuredPatchHunk[]; updatedFile: string } {
let updatedFile = fileContents
const appliedNewStrings: string[] = []
// Special case for empty files.
if (
!fileContents &&
edits.length === 1 &&
edits[0] &&
edits[0].old_string === '' &&
edits[0].new_string === ''
) {
const patch = getPatchForDisplay({
filePath,
fileContents,
edits: [
{
old_string: fileContents,
new_string: updatedFile,
replace_all: false,
},
],
})
return { patch, updatedFile: '' }
}
// Apply each edit and check if it actually changes the file
for (const edit of edits) {
// Strip trailing newlines from old_string before checking
const oldStringToCheck = edit.old_string.replace(/\n+$/, '')
// Check if old_string is a substring of any previously applied new_string
for (const previousNewString of appliedNewStrings) {
if (
oldStringToCheck !== '' &&
previousNewString.includes(oldStringToCheck)
) {
throw new Error(
'Cannot edit file: old_string is a substring of a new_string from a previous edit.',
)
}
}
const previousContent = updatedFile
updatedFile =
edit.old_string === ''
? edit.new_string
: applyEditToFile(
updatedFile,
edit.old_string,
edit.new_string,
edit.replace_all,
)
// If this edit didn't change anything, throw an error
if (updatedFile === previousContent) {
throw new Error('String not found in file. Failed to apply edit.')
}
// Track the new string that was applied
appliedNewStrings.push(edit.new_string)
}
if (updatedFile === fileContents) {
throw new Error(
'Original and edited file match exactly. Failed to apply edit.',
)
}
// We already have before/after content, so call getPatchFromContents directly.
// Previously this went through getPatchForDisplay with edits=[{old:fileContents,new:updatedFile}],
// which transforms fileContents twice (once as preparedFileContents, again as escapedOldString
// inside the reduce) and runs a no-op full-content .replace(). This saves ~20% on large files.
const patch = getPatchFromContents({
filePath,
oldContent: convertLeadingTabsToSpaces(fileContents),
newContent: convertLeadingTabsToSpaces(updatedFile),
})
return { patch, updatedFile }
}
// Cap on edited_text_file attachment snippets. Format-on-save of a large file
// previously injected the entire file per turn (observed max 16.1KB, ~14K
// tokens/session). 8KB preserves meaningful context while bounding worst case.
const DIFF_SNIPPET_MAX_BYTES = 8192
/**
* Used for attachments, to show snippets when files change.
*
* TODO: Unify this with the other snippet logic.
*/
export function getSnippetForTwoFileDiff(
fileAContents: string,
fileBContents: string,
): string {
const patch = structuredPatch(
'file.txt',
'file.txt',
fileAContents,
fileBContents,
undefined,
undefined,
{
context: 8,
timeout: DIFF_TIMEOUT_MS,
},
)
if (!patch) {
return ''
}
const full = patch.hunks
.map(_ => ({
startLine: _.oldStart,
content: _.lines
// Filter out deleted lines AND diff metadata lines
.filter(_ => !_.startsWith('-') && !_.startsWith('\\'))
.map(_ => _.slice(1))
.join('\n'),
}))
.map(addLineNumbers)
.join('\n...\n')
if (full.length <= DIFF_SNIPPET_MAX_BYTES) {
return full
}
// Truncate at the last line boundary that fits within the cap.
// Marker format matches BashTool/utils.ts.
const cutoff = full.lastIndexOf('\n', DIFF_SNIPPET_MAX_BYTES)
const kept =
cutoff > 0 ? full.slice(0, cutoff) : full.slice(0, DIFF_SNIPPET_MAX_BYTES)
const remaining = countCharInString(full, '\n', kept.length) + 1
return `${kept}\n\n... [${remaining} lines truncated] ...`
}
const CONTEXT_LINES = 4
/**
* Gets a snippet from a file showing the context around a patch with line numbers.
* @param originalFile The original file content before applying the patch
* @param patch The diff hunks to use for determining snippet location
* @param newFile The file content after applying the patch
* @returns The snippet text with line numbers and the starting line number
*/
export function getSnippetForPatch(
patch: StructuredPatchHunk[],
newFile: string,
): { formattedSnippet: string; startLine: number } {
if (patch.length === 0) {
// No changes, return empty snippet
return { formattedSnippet: '', startLine: 1 }
}
// Find the first and last changed lines across all hunks
let minLine = Infinity
let maxLine = -Infinity
for (const hunk of patch) {
if (hunk.oldStart < minLine) {
minLine = hunk.oldStart
}
// For the end line, we need to consider the new lines count since we're showing the new file
const hunkEnd = hunk.oldStart + (hunk.newLines || 0) - 1
if (hunkEnd > maxLine) {
maxLine = hunkEnd
}
}
// Calculate the range with context
const startLine = Math.max(1, minLine - CONTEXT_LINES)
const endLine = maxLine + CONTEXT_LINES
// Split the new file into lines and get the snippet
const fileLines = newFile.split(/\r?\n/)
const snippetLines = fileLines.slice(startLine - 1, endLine)
const snippet = snippetLines.join('\n')
// Add line numbers
const formattedSnippet = addLineNumbers({
content: snippet,
startLine,
})
return { formattedSnippet, startLine }
}
export function getEditsForPatch(patch: StructuredPatchHunk[]): FileEdit[] {
return patch.map(hunk => {
// Extract the changes from this hunk
const contextLines: string[] = []
const oldLines: string[] = []
const newLines: string[] = []
// Parse each line and categorize it
for (const line of hunk.lines) {
if (line.startsWith(' ')) {
// Context line - appears in both versions
contextLines.push(line.slice(1))
oldLines.push(line.slice(1))
newLines.push(line.slice(1))
} else if (line.startsWith('-')) {
// Deleted line - only in old version
oldLines.push(line.slice(1))
} else if (line.startsWith('+')) {
// Added line - only in new version
newLines.push(line.slice(1))
}
}
return {
old_string: oldLines.join('\n'),
new_string: newLines.join('\n'),
replace_all: false,
}
})
}
/**
* Contains replacements to de-sanitize strings from Claude
* Since Claude can't see any of these strings (sanitized in the API)
* It'll output the sanitized versions in the edit response
*/
const DESANITIZATIONS: Record<string, string> = {
'<fnr>': '<function_results>',
'<n>': '<name>',
'</n>': '</name>',
'<o>': '<output>',
'</o>': '</output>',
'<e>': '<error>',
'</e>': '</error>',
'<s>': '<system>',
'</s>': '</system>',
'<r>': '<result>',
'</r>': '</result>',
'< META_START >': '<META_START>',
'< META_END >': '<META_END>',
'< EOT >': '<EOT>',
'< META >': '<META>',
'< SOS >': '<SOS>',
'\n\nH:': '\n\nHuman:',
'\n\nA:': '\n\nAssistant:',
}
/**
* Normalizes a match string by applying specific replacements
* This helps handle when exact matches fail due to formatting differences
* @returns The normalized string and which replacements were applied
*/
function desanitizeMatchString(matchString: string): {
result: string
appliedReplacements: Array<{ from: string; to: string }>
} {
let result = matchString
const appliedReplacements: Array<{ from: string; to: string }> = []
for (const [from, to] of Object.entries(DESANITIZATIONS)) {
const beforeReplace = result
result = result.replaceAll(from, to)
if (beforeReplace !== result) {
appliedReplacements.push({ from, to })
}
}
return { result, appliedReplacements }
}
/**
* Normalize the input for the FileEditTool
* If the string to replace is not found in the file, try with a normalized version
* Returns the normalized input if successful, or the original input if not
*/
export function normalizeFileEditInput({
file_path,
edits,
}: {
file_path: string
edits: EditInput[]
}): {
file_path: string
edits: EditInput[]
} {
if (edits.length === 0) {
return { file_path, edits }
}
// Markdown uses two trailing spaces as a hard line break — stripping would
// silently change semantics. Skip stripTrailingWhitespace for .md/.mdx.
const isMarkdown = /\.(md|mdx)$/i.test(file_path)
try {
const fullPath = expandPath(file_path)
// Use cached file read to avoid redundant I/O operations.
// If the file doesn't exist, readFileSyncCached throws ENOENT which the
// catch below handles by returning the original input (no TOCTOU pre-check).
const fileContent = readFileSyncCached(fullPath)
return {
file_path,
edits: edits.map(({ old_string, new_string, replace_all }) => {
const normalizedNewString = isMarkdown
? new_string
: stripTrailingWhitespace(new_string)
// If exact string match works, keep it as is
if (fileContent.includes(old_string)) {
return {
old_string,
new_string: normalizedNewString,
replace_all,
}
}
// Try de-sanitize string if exact match fails
const { result: desanitizedOldString, appliedReplacements } =
desanitizeMatchString(old_string)
if (fileContent.includes(desanitizedOldString)) {
// Apply the same exact replacements to new_string
let desanitizedNewString = normalizedNewString
for (const { from, to } of appliedReplacements) {
desanitizedNewString = desanitizedNewString.replaceAll(from, to)
}
return {
old_string: desanitizedOldString,
new_string: desanitizedNewString,
replace_all,
}
}
return {
old_string,
new_string: normalizedNewString,
replace_all,
}
}),
}
} catch (error) {
// If there's any error reading the file, just return original input.
// ENOENT is expected when the file doesn't exist yet (e.g., new file).
if (!isENOENT(error)) {
logError(error)
}
}
return { file_path, edits }
}
/**
* Compare two sets of edits to determine if they are equivalent
* by applying both sets to the original content and comparing results.
* This handles cases where edits might be different but produce the same outcome.
*/
export function areFileEditsEquivalent(
edits1: FileEdit[],
edits2: FileEdit[],
originalContent: string,
): boolean {
// Fast path: check if edits are literally identical
if (
edits1.length === edits2.length &&
edits1.every((edit1, index) => {
const edit2 = edits2[index]
return (
edit2 !== undefined &&
edit1.old_string === edit2.old_string &&
edit1.new_string === edit2.new_string &&
edit1.replace_all === edit2.replace_all
)
})
) {
return true
}
// Try applying both sets of edits
let result1: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
null
let error1: string | null = null
let result2: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
null
let error2: string | null = null
try {
result1 = getPatchForEdits({
filePath: 'temp',
fileContents: originalContent,
edits: edits1,
})
} catch (e) {
error1 = errorMessage(e)
}
try {
result2 = getPatchForEdits({
filePath: 'temp',
fileContents: originalContent,
edits: edits2,
})
} catch (e) {
error2 = errorMessage(e)
}
// If both threw errors, they're equal only if the errors are the same
if (error1 !== null && error2 !== null) {
// Normalize error messages for comparison
return error1 === error2
}
// If one threw an error and the other didn't, they're not equal
if (error1 !== null || error2 !== null) {
return false
}
// Both succeeded - compare the results
return result1!.updatedFile === result2!.updatedFile
}
/**
* Unified function to check if two file edit inputs are equivalent.
* Handles file edits (FileEditTool).
*/
export function areFileEditsInputsEquivalent(
input1: {
file_path: string
edits: FileEdit[]
},
input2: {
file_path: string
edits: FileEdit[]
},
): boolean {
// Fast path: different files
if (input1.file_path !== input2.file_path) {
return false
}
// Fast path: literal equality
if (
input1.edits.length === input2.edits.length &&
input1.edits.every((edit1, index) => {
const edit2 = input2.edits[index]
return (
edit2 !== undefined &&
edit1.old_string === edit2.old_string &&
edit1.new_string === edit2.new_string &&
edit1.replace_all === edit2.replace_all
)
})
) {
return true
}
// Semantic comparison (requires file read). If the file doesn't exist,
// compare against empty content (no TOCTOU pre-check).
let fileContent = ''
try {
fileContent = readFileSyncCached(input1.file_path)
} catch (error) {
if (!isENOENT(error)) {
throw error
}
}
return areFileEditsEquivalent(input1.edits, input2.edits, fileContent)
}