Compare commits

...

15 Commits

Author SHA1 Message Date
abeatrix f2212deb3d feat: add native tool call tracking to telemetry
Add `isNativeToolCall` property to ToolUse interface to distinguish between native tool calls and other tool invocation methods. This flag is set in the ToolUseHandler and propagated through to telemetry capture for analytics purposes.

Changes:
- Add optional `isNativeToolCall` boolean field to ToolUse interface
- Mark native tool calls with the flag in ToolUseHandler
- Thread the flag through to telemetryService.captureToolUsage() calls across all tool handlers
- Remove redundant `= undefined` initializations in parser
2025-11-04 13:25:00 -08:00
Bee 36bdf643a6 Merge branch 'main' into bee/apply-patch-tool 2025-11-05 04:45:37 +08:00
abeatrix e9b8991e5e update diff editor on stream 2025-11-03 15:03:39 -08:00
abeatrix 06cc49f115 Merge branch 'main' into bee/apply-patch-tool 2025-11-03 13:06:16 -08:00
Bee 87c28fc422 Merge branch 'main' into bee/apply-patch-tool 2025-11-01 07:48:03 +08:00
abeatrix 096ba34948 Update ClineMessage 2025-10-31 16:45:19 -07:00
abeatrix 07dedee284 fix test 2025-10-31 15:16:30 -07:00
abeatrix dde81ec0af fix test 2025-10-31 14:59:24 -07:00
abeatrix 90807717a7 captureToolUsage 2025-10-30 18:03:56 -07:00
abeatrix bef69c3be2 typo 2025-10-30 17:59:23 -07:00
abeatrix 7b60a97771 Merge branch 'main' of https://github.com/cline/cline into bee/apply-patch-tool 2025-10-30 17:58:31 -07:00
abeatrix fca81a1eba add feedback 2025-10-30 17:54:20 -07:00
abeatrix 6b7ce5d7c7 Update unit tests 2025-10-30 16:36:25 -07:00
abeatrix 833d8c7395 Add diagnostic to result 2025-10-30 15:29:07 -07:00
abeatrix 49e6bc2c1f feat: refactor APPLY_PATCH tool
Replace separate file creation and editing tools with unified APPLY_PATCH tool for the native-gpt-5 model variant. This consolidation simplifies file operations through a single patch-based interface.

Changes:
- Replace FILE_NEW and FILE_EDIT tools with APPLY_PATCH in config for gpt-5 with native tool calling
- Disable EDITING_FILES system prompt section (no longer needed)
- Remove EDITING_FILES section from base template
- Refactor ApplyPatchHandler with improved architecture:
  - Extract patch parsing logic into PatchParser utility class
  - Extract file operations into FileProviderOperations utility
  - Extract path resolution into PathResolver utility
  - Add comprehensive error handling with DiffError types
  - Improve type safety with shared Patch types
- Add extensive test coverage for PatchParser including:
  - Edge cases (empty files, large files, unicode)
  - Error conditions (malformed patches, invalid operations)
  - Complex scenarios (multiple chunks, context matching)
- Export PATCH_MARKERS and BASH_WRAPPERS for reusability

This refactoring improves maintainability, testability, and provides a more robust patch application system for the GPT-5 model variant when native tool calling is enabled.
2025-10-30 14:14:52 -07:00
28 changed files with 2494 additions and 803 deletions
+66 -60
View File
@@ -1,62 +1,68 @@
{
"name": "cline",
"version": "1.0.3",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
"cline": "./bin/cline",
"cline-host": "./bin/cline-host"
},
"man": "./man/cline.1",
"scripts": {
"postinstall": "node postinstall.js"
},
"bundleDependencies": [
"@grpc/grpc-js",
"@grpc/reflection",
"better-sqlite3",
"grpc-health-check",
"open",
"vscode-uri"
],
"engines": {
"node": ">=20.0.0"
},
"keywords": [
"cline",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"llama",
"cli"
],
"author": {
"name": "Cline Bot Inc."
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline"
},
"homepage": "https://cline.bot",
"bugs": {
"url": "https://github.com/cline/cline/issues"
},
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.2.0",
"grpc-health-check": "^2.0.2",
"open": "^10.1.2",
"vscode-uri": "^3.1.0"
},
"os": ["darwin", "linux"],
"cpu": ["x64", "arm64"]
"name": "cline",
"version": "1.0.3",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
"cline": "./bin/cline",
"cline-host": "./bin/cline-host"
},
"man": "./man/cline.1",
"scripts": {
"postinstall": "node postinstall.js"
},
"bundleDependencies": [
"@grpc/grpc-js",
"@grpc/reflection",
"better-sqlite3",
"grpc-health-check",
"open",
"vscode-uri"
],
"engines": {
"node": ">=20.0.0"
},
"keywords": [
"cline",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"llama",
"cli"
],
"author": {
"name": "Cline Bot Inc."
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline"
},
"homepage": "https://cline.bot",
"bugs": {
"url": "https://github.com/cline/cline/issues"
},
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.2.0",
"grpc-health-check": "^2.0.2",
"open": "^10.1.2",
"vscode-uri": "^3.1.0"
},
"os": [
"darwin",
"linux"
],
"cpu": [
"x64",
"arm64"
]
}
@@ -68,6 +68,7 @@ export interface ToolUse {
// params is a partial record, allowing only some or none of the possible parameters to be used
params: Partial<Record<ToolParamName, string>>
partial: boolean
isNativeToolCall?: boolean
}
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
@@ -96,11 +97,11 @@ export interface ToolUse {
export function parseAssistantMessageV2(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContentStart = 0 // Index where the current text block started
let currentTextContent: TextContent | undefined = undefined
let currentTextContent: TextContent | undefined
let currentToolUseStart = 0 // Index *after* the opening tag of the current tool use
let currentToolUse: ToolUse | undefined = undefined
let currentToolUse: ToolUse | undefined
let currentParamValueStart = 0 // Index *after* the opening tag of the current param
let currentParamName: ToolParamName | undefined = undefined
let currentParamName: ToolParamName | undefined
// Precompute tags for faster lookups
const toolUseOpenTags = new Map<string, ToolUseName>()
+3 -1
View File
@@ -32,7 +32,7 @@ const ESCAPE_MAP: Record<string, string> = {
const ESCAPE_PATTERN = /\\[ntr"\\]/g
/**
* Handles streaming tool use blocks and converts them to Anthropic.ToolUseBlockParam format
* Handles streaming native tool use blocks and converts them to Anthropic.ToolUseBlockParam format
*/
export class ToolUseHandler {
private pendingToolUses = new Map<string, PendingToolUse>()
@@ -130,6 +130,7 @@ export class ToolUseHandler {
arguments: JSON.stringify(input),
},
partial: true,
isNativeToolCall: true,
})
} else {
const params: Record<string, string> = {}
@@ -143,6 +144,7 @@ export class ToolUseHandler {
name: pending.name as ClineDefaultTool,
params: params as any,
partial: true,
isNativeToolCall: true,
})
}
}
+2
View File
@@ -51,4 +51,6 @@ export interface ToolUse {
// params is a partial record, allowing only some or none of the possible parameters to be used
params: Partial<Record<ToolParamName, string>>
partial: boolean
// Whether this tool use was initiated by a native tool call
isNativeToolCall?: boolean
}
@@ -358,6 +358,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
if (request.nativeToolCallEnabled !== undefined) {
controller.stateManager.setGlobalState("nativeToolCallEnabled", !!request.nativeToolCallEnabled)
if (controller.task) {
telemetryService.captureFeatureToggle(
controller.task.ulid,
"native-tool-call",
request.nativeToolCallEnabled,
controller.task.api.getModel().id,
)
}
}
// Post updated state to webview
@@ -299,10 +299,9 @@ describe("Prompt System Integration Tests", () => {
`This is a new test case. Run with --update-snapshots to create the initial snapshot.`,
),
)
} else {
// Re-throw comparison errors
throw error
}
// Re-throw comparison errors
throw error
}
}
} catch (error) {
@@ -390,14 +389,8 @@ describe("Prompt System Integration Tests", () => {
this.timeout(30000)
const invalidContext = {} as SystemPromptContext
try {
const prompt = await getSystemPrompt(invalidContext)
expect(prompt).to.be.a("string")
} catch (error) {
// Error is acceptable for invalid context
expect(error).to.be.instanceOf(Error)
}
const { systemPrompt } = await getSystemPrompt(invalidContext)
expect(systemPrompt).to.be.a("string")
})
it("should handle undefined context properties", async function () {
@@ -1,5 +1,5 @@
import { ModelFamily } from "@/shared/prompts"
import { ClineTool } from "@/shared/tools"
import type { ClineTool } from "@/shared/tools"
import { ClineToolSet } from ".."
import { getSystemPromptComponents } from "../components"
import { registerClineToolSets } from "../tools"
@@ -68,18 +68,21 @@ export class PromptRegistry {
}
getModelFamily(context: SystemPromptContext) {
// Loop through all registered variants to find the first one that matches
for (const [id, v] of this.variants.entries()) {
try {
if (v.matcher(context)) {
return v.family
// Ensure providerInfo and model ID are available
if (context.providerInfo?.model?.id) {
// Loop through all registered variants to find the first one that matches
for (const [_, v] of this.variants.entries()) {
try {
if (v.matcher(context)) {
return v.family
}
} catch {
// Continue to next variant if matcher throws
}
} catch (error) {
console.warn(`Matcher function error for variant '${id}':`, error)
// Continue to next variant if matcher throws
}
}
// Fallback to generic variant if no match found
console.log("No matching variant found, falling back to generic")
return ModelFamily.GENERIC
}
/**
@@ -45,8 +45,9 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
.tools(
ClineDefaultTool.BASH,
ClineDefaultTool.FILE_READ,
ClineDefaultTool.FILE_NEW,
ClineDefaultTool.FILE_EDIT,
ClineDefaultTool.APPLY_PATCH,
// ClineDefaultTool.FILE_NEW, // Replaced by APPLY_PATCH
// ClineDefaultTool.FILE_EDIT, // Replaced by APPLY_PATCH
ClineDefaultTool.SEARCH,
ClineDefaultTool.LIST_FILES,
ClineDefaultTool.LIST_CODE_DEF,
@@ -80,6 +81,9 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
.overrideComponent(SystemPromptSection.FEEDBACK, {
template: GPT_5_TEMPLATE_OVERRIDES.FEEDBACK,
})
.overrideComponent(SystemPromptSection.EDITING_FILES, {
enabled: false,
})
.build()
// Compile-time validation
@@ -15,10 +15,6 @@ export const BASE = `{{${SystemPromptSection.AGENT_ROLE}}}
====
{{${SystemPromptSection.EDITING_FILES}}}
====
{{${SystemPromptSection.ACT_VS_PLAN}}}
====
@@ -80,7 +80,16 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
undefined,
block.isNativeToolCall,
)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`
@@ -92,10 +101,28 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
undefined,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
}
File diff suppressed because it is too large Load Diff
@@ -166,6 +166,7 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
true,
true,
workspaceContext,
block.isNativeToolCall,
)
} else {
// Manual approval flow
@@ -188,6 +189,7 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
false,
false,
workspaceContext,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
}
@@ -199,6 +201,7 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
@@ -88,7 +88,16 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
undefined,
block.isNativeToolCall,
)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to analyze code definitions in ${getWorkspaceBasename(absolutePath, "ListCodeDefinitionNamesToolHandler.notification")}`
@@ -100,10 +109,28 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
undefined,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
}
@@ -113,6 +113,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
true,
true,
workspaceContext,
block.isNativeToolCall,
)
} else {
// Manual approval flow
@@ -133,6 +134,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
false,
false,
workspaceContext,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
@@ -144,6 +146,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
}
@@ -111,6 +111,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
true,
true,
workspaceContext,
block.isNativeToolCall,
)
} else {
// Manual approval flow
@@ -131,6 +132,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
false,
false,
workspaceContext,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
@@ -142,6 +144,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
}
@@ -319,6 +319,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
true,
true,
workspaceContext,
block.isNativeToolCall,
)
} else {
// Manual approval flow
@@ -339,6 +340,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
false,
false,
workspaceContext,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
@@ -350,6 +352,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
}
@@ -95,7 +95,16 @@ export class UseMcpToolHandler implements IFullyManagedTool {
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
undefined,
block.isNativeToolCall,
)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`
@@ -107,10 +116,28 @@ export class UseMcpToolHandler implements IFullyManagedTool {
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
undefined,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
}
@@ -64,7 +64,16 @@ export class WebFetchToolHandler implements IFullyManagedTool {
// Auto-approve flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
telemetryService.captureToolUsage(config.ulid, "web_fetch", config.api.getModel().id, provider, true, true)
telemetryService.captureToolUsage(
config.ulid,
"web_fetch",
config.api.getModel().id,
provider,
true,
true,
undefined,
block.isNativeToolCall,
)
} else {
// Manual approval flow
showNotificationForApproval(
@@ -75,10 +84,28 @@ export class WebFetchToolHandler implements IFullyManagedTool {
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
undefined,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
}
@@ -182,6 +182,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
true,
true,
workspaceContext,
block.isNativeToolCall,
)
// we need an artificial delay to let the diagnostics catch up to the changes
@@ -235,6 +236,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
false,
false,
workspaceContext,
block.isNativeToolCall,
)
await config.services.diffViewProvider.revertChanges()
@@ -265,6 +267,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
}
@@ -404,6 +407,11 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
// Full original behavior - comprehensive error handling even for partial blocks
await config.callbacks.say("diff_error", relPath)
// Extract provider information for telemetry
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Extract error type from error message if possible
const errorType =
error instanceof Error && error.message.includes("does not match anything")
@@ -411,7 +419,14 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
: "other_diff_error"
// Add telemetry for diff edit failure
telemetryService.captureDiffEditFailure(config.ulid, config.api.getModel().id, errorType)
const isNativeToolCall = block.isNativeToolCall === true
telemetryService.captureDiffEditFailure(
config.ulid,
config.api.getModel().id,
provider,
errorType,
isNativeToolCall,
)
// Push tool result with detailed error using existing utilities
const errorResponse = formatResponse.toolError(
+12 -3
View File
@@ -35,7 +35,7 @@ export interface StronglyTypedUIHelpers {
askApproval: (messageType: ClineAsk, message: string) => Promise<boolean>
// Telemetry and notifications
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean) => void
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean, isNativeToolCall?: boolean) => void
showNotificationIfEnabled: (message: string) => void
// Config access - returns the proper typed config
@@ -57,13 +57,22 @@ export function createUIHelpers(config: TaskConfig): StronglyTypedUIHelpers {
const { response } = await config.callbacks.ask(messageType, message, false)
return response === "yesButtonClicked"
},
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean) => {
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean, isNativeToolCall?: boolean) => {
// Extract provider information for telemetry
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
telemetryService.captureToolUsage(config.ulid, toolName, config.api.getModel().id, provider, autoApproved, approved)
telemetryService.captureToolUsage(
config.ulid,
toolName,
config.api.getModel().id,
provider,
autoApproved,
approved,
undefined,
isNativeToolCall,
)
},
showNotificationIfEnabled: (message: string) => {
showNotificationForApproval(message, config.autoApprovalSettings.enableNotifications)
@@ -0,0 +1,50 @@
import type { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
export interface FileOpsResult {
finalContent?: string
deleted?: boolean
newProblemsMessage?: string
userEdits?: string
autoFormattingEdits?: string
}
/**
* Utility class for file operations via a DiffViewProvider
*/
export class FileProviderOperations {
constructor(private provider: DiffViewProvider) {}
async createFile(path: string, content: string): Promise<FileOpsResult> {
this.provider.editType = "create"
await this.provider.open(path)
await this.provider.update(content, true)
const result = await this.provider.saveChanges()
await this.provider.reset()
return result
}
async modifyFile(path: string, content: string): Promise<FileOpsResult> {
this.provider.editType = "modify"
await this.provider.open(path)
await this.provider.update(content, true)
const result = await this.provider.saveChanges()
await this.provider.reset()
return result
}
async deleteFile(path: string): Promise<void> {
this.provider.editType = "modify"
await this.provider.open(path)
await this.provider.revertChanges()
}
async moveFile(oldPath: string, newPath: string, content: string): Promise<FileOpsResult> {
const result = await this.createFile(newPath, content)
await this.deleteFile(oldPath)
return result
}
async getFileContent(): Promise<string | undefined> {
return this.provider.originalContent
}
}
+424
View File
@@ -0,0 +1,424 @@
import {
DiffError,
PATCH_MARKERS,
type Patch,
type PatchAction,
PatchActionType,
type PatchChunk,
type PatchWarning,
} from "@/shared/Patch"
import { canonicalize } from "@/shared/string"
/**
* Parser for Apply Patch content
*/
export class PatchParser {
private patch: Patch = { actions: {}, warnings: [] }
private index = 0
private fuzz = 0
private currentPath?: string
constructor(
private lines: string[],
private currentFiles: Record<string, string>,
) {}
parse(): { patch: Patch; fuzz: number } {
this.skipBeginSentinel()
while (this.hasMoreLines() && !this.isEndMarker()) {
this.parseNextAction()
}
// Clean up empty warnings array
if (this.patch.warnings?.length === 0) {
delete this.patch.warnings
}
return { patch: this.patch, fuzz: this.fuzz }
}
private addWarning(warning: PatchWarning): void {
if (!this.patch.warnings) {
this.patch.warnings = []
}
this.patch.warnings.push(warning)
}
private skipBeginSentinel(): void {
if (this.lines[this.index]?.startsWith(PATCH_MARKERS.BEGIN)) {
this.index++
}
}
private hasMoreLines(): boolean {
return this.index < this.lines.length
}
private isEndMarker(): boolean {
return this.lines[this.index]?.startsWith(PATCH_MARKERS.END) ?? false
}
private parseNextAction(): void {
const line = this.lines[this.index]
if (line.startsWith(PATCH_MARKERS.UPDATE)) {
this.parseUpdate(line.substring(PATCH_MARKERS.UPDATE.length).trim())
} else if (line.startsWith(PATCH_MARKERS.DELETE)) {
this.parseDelete(line.substring(PATCH_MARKERS.DELETE.length).trim())
} else if (line.startsWith(PATCH_MARKERS.ADD)) {
this.parseAdd(line.substring(PATCH_MARKERS.ADD.length).trim())
} else {
throw new DiffError(`Unknown line while parsing: ${line}`)
}
}
private checkDuplicate(path: string, operation: string): void {
if (path in this.patch.actions) {
throw new DiffError(`Duplicate ${operation} for file: ${path}`)
}
}
private parseUpdate(path: string): void {
this.checkDuplicate(path, "update")
this.currentPath = path
this.index++
const movePath = this.lines[this.index]?.startsWith(PATCH_MARKERS.MOVE)
? this.lines[this.index++].substring(PATCH_MARKERS.MOVE.length).trim()
: undefined
if (!(path in this.currentFiles)) {
throw new DiffError(`Update File Error: Missing File: ${path}`)
}
const text = this.currentFiles[path]!
const action = this.parseUpdateFile(text, path)
action.movePath = movePath
this.patch.actions[path] = action
this.currentPath = undefined
}
private parseUpdateFile(text: string, _path: string): PatchAction {
const action: PatchAction = { type: PatchActionType.UPDATE, chunks: [] }
const fileLines = text.split("\n")
let index = 0
const stopMarkers = [
PATCH_MARKERS.END,
PATCH_MARKERS.UPDATE,
PATCH_MARKERS.DELETE,
PATCH_MARKERS.ADD,
PATCH_MARKERS.END_FILE,
]
while (!stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
const defStr = this.lines[this.index]?.startsWith("@@ ") ? this.lines[this.index]!.substring(3) : undefined
const sectionStr = this.lines[this.index] === "@@" ? this.lines[this.index] : undefined
if (defStr !== undefined || sectionStr !== undefined) {
this.index++
} else if (index !== 0) {
throw new DiffError(`Invalid Line:\n${this.lines[this.index]}`)
}
// Try to find the @@ context marker in the file
if (defStr?.trim()) {
const canonDefStr = canonicalize(defStr.trim())
for (let i = index; i < fileLines.length; i++) {
if (canonicalize(fileLines[i]!) === canonDefStr || canonicalize(fileLines[i]!.trim()) === canonDefStr) {
index = i + 1
if (canonicalize(fileLines[i]!.trim()) === canonDefStr && canonicalize(fileLines[i]!) !== canonDefStr) {
this.fuzz++
}
break
}
}
}
const [nextChunkContext, chunks, endPatchIndex, eof] = peek(this.lines, this.index)
const [newIndex, fuzz, similarity] = findContext(fileLines, nextChunkContext, index, eof)
if (newIndex === -1) {
const ctxText = nextChunkContext.join("\n")
// Add warning but continue - skip this chunk
this.addWarning({
path: this.currentPath || _path,
chunkIndex: action.chunks.length,
message: `Could not find matching context (similarity: ${similarity.toFixed(2)}). Chunk skipped.`,
context: ctxText.length > 200 ? `${ctxText.substring(0, 200)}...` : ctxText,
})
// Move patch index forward to skip this chunk, but keep file position
// so subsequent chunks can still be found
this.index = endPatchIndex
// Don't advance file position - let next chunk search from current position
} else {
this.fuzz += fuzz
for (const chunk of chunks) {
chunk.origIndex += newIndex
action.chunks.push(chunk)
}
index = newIndex + nextChunkContext.length
this.index = endPatchIndex
}
}
return action
}
private parseDelete(path: string): void {
this.checkDuplicate(path, "delete")
if (!(path in this.currentFiles)) {
throw new DiffError(`Delete File Error: Missing File: ${path}`)
}
this.patch.actions[path] = { type: PatchActionType.DELETE, chunks: [] }
this.index++
}
private parseAdd(path: string): void {
this.checkDuplicate(path, "add")
if (path in this.currentFiles) {
throw new DiffError(`Add File Error: File already exists: ${path}`)
}
this.index++
const lines: string[] = []
const stopMarkers = [PATCH_MARKERS.END, PATCH_MARKERS.UPDATE, PATCH_MARKERS.DELETE, PATCH_MARKERS.ADD]
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index].startsWith(m.trim()))) {
const line = this.lines[this.index++]
if (!line.startsWith("+")) {
throw new DiffError(`Invalid Add File line (missing '+'): ${line}`)
}
lines.push(line.substring(1))
}
this.patch.actions[path] = { type: PatchActionType.ADD, newFile: lines.join("\n"), chunks: [] }
}
}
/**
* Calculate similarity between two strings (0-1 range)
*/
function calculateSimilarity(str1: string, str2: string): number {
const longer = str1.length > str2.length ? str1 : str2
const shorter = str1.length > str2.length ? str2 : str1
if (longer.length === 0) {
return 1.0
}
const editDistance = levenshteinDistance(shorter, longer)
return (longer.length - editDistance) / longer.length
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix: number[][] = []
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= str1.length; j++) {
matrix[0]![j] = j
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2[i - 1] === str1[j - 1]) {
matrix[i]![j] = matrix[i - 1]![j - 1]!
} else {
matrix[i]![j] = Math.min(
matrix[i - 1]![j - 1]! + 1, // substitution
matrix[i]![j - 1]! + 1, // insertion
matrix[i - 1]![j]! + 1, // deletion
)
}
}
}
return matrix[str2.length]![str1.length]!
}
/**
* Find context in file with fuzzy matching (whitespace tolerance)
* Returns [index, fuzz, similarity] where fuzz indicates match quality and similarity is best match score
*/
function findContext(lines: string[], context: string[], start: number, eof: boolean): [number, number, number] {
if (context.length === 0) {
return [start, 0, 1.0]
}
let bestSimilarity = 0
const findCore = (startIdx: number): [number, number, number] => {
// Pass 1: exact equality after canonicalization
const canonicalContext = canonicalize(context.join("\n"))
for (let i = startIdx; i < lines.length; i++) {
const segment = canonicalize(lines.slice(i, i + context.length).join("\n"))
if (segment === canonicalContext) {
return [i, 0, 1.0]
}
// Track best similarity for reporting
const similarity = calculateSimilarity(segment, canonicalContext)
if (similarity > bestSimilarity) {
bestSimilarity = similarity
}
}
// Pass 2: ignore trailing whitespace
for (let i = startIdx; i < lines.length; i++) {
const segment = canonicalize(
lines
.slice(i, i + context.length)
.map((s) => s.trimEnd())
.join("\n"),
)
const ctx = canonicalize(context.map((s) => s.trimEnd()).join("\n"))
if (segment === ctx) {
return [i, 1, 1.0]
}
}
// Pass 3: ignore all surrounding whitespace
for (let i = startIdx; i < lines.length; i++) {
const segment = canonicalize(
lines
.slice(i, i + context.length)
.map((s) => s.trim())
.join("\n"),
)
const ctx = canonicalize(context.map((s) => s.trim()).join("\n"))
if (segment === ctx) {
return [i, 100, 1.0]
}
}
// Pass 4: Partial matching with similarity threshold (66% match = 2/3 lines)
const SIMILARITY_THRESHOLD = 0.66
for (let i = startIdx; i < lines.length; i++) {
const segment = canonicalize(lines.slice(i, i + context.length).join("\n"))
const similarity = calculateSimilarity(segment, canonicalContext)
if (similarity >= SIMILARITY_THRESHOLD) {
return [i, 1000, similarity]
}
if (similarity > bestSimilarity) {
bestSimilarity = similarity
}
}
return [-1, 0, bestSimilarity]
}
if (eof) {
// Try from end first for EOF context
let [newIndex, fuzz, similarity] = findCore(lines.length - context.length)
if (newIndex !== -1) {
return [newIndex, fuzz, similarity]
}
;[newIndex, fuzz, similarity] = findCore(start)
return [newIndex, fuzz + 10000, similarity]
}
return findCore(start)
}
type PeekResult = [string[], PatchChunk[], number, boolean]
/**
* Peek ahead to extract the next section's context and chunks
* Returns [context, chunks, endIndex, isEOF]
*/
function peek(lines: string[], initialIndex: number): PeekResult {
let index = initialIndex
const old: string[] = []
let delLines: string[] = []
let insLines: string[] = []
const chunks: PatchChunk[] = []
let mode: "keep" | "add" | "delete" = "keep"
const stopMarkers = [
"@@",
PATCH_MARKERS.END,
PATCH_MARKERS.UPDATE,
PATCH_MARKERS.DELETE,
PATCH_MARKERS.ADD,
PATCH_MARKERS.END_FILE,
]
while (index < lines.length) {
const s = lines[index]!
if (stopMarkers.some((m) => s.startsWith(m.trim()))) {
break
}
if (s === "***") {
break
}
if (s.startsWith("***")) {
throw new DiffError(`Invalid line: ${s}`)
}
index++
const lastMode: "keep" | "add" | "delete" = mode
let line = s
if (line[0] === "+") {
mode = "add"
} else if (line[0] === "-") {
mode = "delete"
} else if (line[0] === " ") {
mode = "keep"
} else {
// Tolerate missing leading whitespace for context lines
mode = "keep"
line = ` ${line}`
}
line = line.slice(1)
if (mode === "keep" && lastMode !== mode) {
if (insLines.length || delLines.length) {
chunks.push({
origIndex: old.length - delLines.length,
delLines: delLines,
insLines: insLines,
})
}
delLines = []
insLines = []
}
if (mode === "delete") {
delLines.push(line)
old.push(line)
} else if (mode === "add") {
insLines.push(line)
} else {
old.push(line)
}
}
if (insLines.length || delLines.length) {
chunks.push({
origIndex: old.length - delLines.length,
delLines: delLines,
insLines: insLines,
})
}
if (index < lines.length && lines[index] === PATCH_MARKERS.END_FILE) {
index++
return [old, chunks, index, true]
}
return [old, chunks, index, false]
}
+45
View File
@@ -0,0 +1,45 @@
import { resolveWorkspacePath } from "@/core/workspace"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
/**
* Utility class for resolving and validating file paths within a task context
*/
export class PathResolver {
constructor(
private config: TaskConfig,
private validator: ToolValidator,
) {}
resolve(filePath: string, caller: string): { absolutePath: string; resolvedPath: string } | undefined {
try {
const pathResult = resolveWorkspacePath(this.config, filePath, caller)
return typeof pathResult === "string"
? { absolutePath: pathResult, resolvedPath: filePath }
: { absolutePath: pathResult.absolutePath, resolvedPath: pathResult.resolvedPath }
} catch {
return undefined
}
}
validate(resolvedPath: string): { ok: boolean; error?: string } {
return this.validator.checkClineIgnorePath(resolvedPath)
}
async resolveAndValidate(
filePath: string,
caller: string,
): Promise<{ absolutePath: string; resolvedPath: string } | undefined> {
const resolution = this.resolve(filePath, caller)
if (!resolution) {
return undefined
}
const validation = this.validate(resolution.resolvedPath)
if (!validation.ok) {
return undefined
}
return resolution
}
}
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -179,6 +179,8 @@ export class TelemetryService {
AUTO_COMPACT: "task.summarize_task",
// Tracks when slash commands or workflows are activated
SLASH_COMMAND_USED: "task.slash_command_used",
// Tracks when a feature is toggled on/off
FEATURE_TOGGLED: "task.feature_toggled",
// Tracks when individual Cline rules are toggled on/off
RULE_TOGGLED: "task.rule_toggled",
// Tracks when auto condense setting is toggled on/off
@@ -702,6 +704,7 @@ export class TelemetryService {
resolvedToNonPrimary: boolean
resolutionMethod: "hint" | "primary_fallback" | "path_detection"
},
isNativeToolCall = false,
) {
this.capture({
event: TelemetryService.EVENTS.TASK.TOOL_USED,
@@ -719,6 +722,7 @@ export class TelemetryService {
workspace_resolved_non_primary: workspaceContext.resolvedToNonPrimary,
workspace_resolution_method: workspaceContext.resolutionMethod,
}),
isNativeToolCall,
},
})
}
@@ -743,6 +747,7 @@ export class TelemetryService {
status: "started" | "success" | "error",
errorMessage?: string,
argumentKeys?: string[],
isNativeToolCall = false,
) {
this.capture({
event: TelemetryService.EVENTS.TASK.MCP_TOOL_CALLED,
@@ -753,6 +758,7 @@ export class TelemetryService {
status,
errorMessage,
argumentKeys,
isNativeToolCall,
},
})
}
@@ -789,7 +795,7 @@ export class TelemetryService {
* @param provider The API provider being used
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
*/
public captureDiffEditFailure(ulid: string, modelId: string, provider: string, errorType?: string) {
public captureDiffEditFailure(ulid: string, modelId: string, provider: string, errorType?: string, isNativeToolCall = false) {
this.capture({
event: TelemetryService.EVENTS.TASK.DIFF_EDIT_FAILED,
properties: {
@@ -797,6 +803,7 @@ export class TelemetryService {
errorType,
modelId,
provider,
isNativeToolCall,
},
})
}
@@ -1160,6 +1167,25 @@ export class TelemetryService {
})
}
/**
* Records when a feature is enabled/disabled by the user
* @param ulid Unique identifier for the task
* @param featureName The name of the feature being toggled
* @param enabled Whether the feature was enabled (true) or disabled (false)
* @param modelId The model ID being used when the toggle occurred
*/
public captureFeatureToggle(ulid: string, featureName: string, enabled: boolean, modelId: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.FEATURE_TOGGLED,
properties: {
ulid,
featureName,
enabled,
modelId,
},
})
}
/**
* Records when individual Cline rules are toggled on/off
* @param ulid Unique identifier for the task (to track rule changes within task context)
+65
View File
@@ -0,0 +1,65 @@
/**
* Apply Patch constants
*/
export const PATCH_MARKERS = {
BEGIN: "*** Begin Patch",
END: "*** End Patch",
ADD: "*** Add File: ",
UPDATE: "*** Update File: ",
DELETE: "*** Delete File: ",
MOVE: "*** Move to: ",
SECTION: "@@",
END_FILE: "*** End of File",
} as const
/**
* Expected bash wrappers for apply patch content
*/
export const BASH_WRAPPERS = ["%%bash", "apply_patch", "EOF", "```"] as const
/**
* Domains of patch actions
*/
export enum PatchActionType {
ADD = "add",
DELETE = "delete",
UPDATE = "update",
}
export interface PatchChunk {
origIndex: number // line index in original file where change starts
delLines: string[] // Lines to delete (without the "-" prefix)
insLines: string[] // Lines to insert (without the "+" prefix)
}
export interface PatchAction {
type: PatchActionType
newFile?: string
chunks: PatchChunk[]
movePath?: string
}
/**
* Warning information for skipped/problematic chunks
*/
export interface PatchWarning {
path: string
chunkIndex?: number
message: string
context?: string
}
/**
* Apply Patch structure
*/
export interface Patch {
actions: Record<string, PatchAction>
warnings?: PatchWarning[]
}
export class DiffError extends Error {
constructor(message: string) {
super(message)
this.name = "DiffError"
}
}
+298
View File
@@ -0,0 +1,298 @@
import { describe, it } from "mocha"
import "should"
import { canonicalize, preserveEscaping } from "./string"
describe("String: canonicalize", () => {
describe("Unicode normalization", () => {
it("should normalize composed and decomposed unicode", () => {
// é as single character vs e + combining acute
const composed = "café"
const decomposed = "cafe\u0301"
canonicalize(composed).should.equal(canonicalize(decomposed))
})
})
describe("Hyphen and dash normalization", () => {
it("should normalize regular hyphen to ASCII hyphen", () => {
canonicalize("hello-world").should.equal("hello-world")
})
it("should normalize HYPHEN (U+2010) to ASCII hyphen", () => {
canonicalize("hello\u2010world").should.equal("hello-world")
})
it("should normalize NO-BREAK HYPHEN (U+2011) to ASCII hyphen", () => {
canonicalize("hello\u2011world").should.equal("hello-world")
})
it("should normalize FIGURE DASH (U+2012) to ASCII hyphen", () => {
canonicalize("hello\u2012world").should.equal("hello-world")
})
it("should normalize EN DASH (U+2013) to ASCII hyphen", () => {
canonicalize("hello\u2013world").should.equal("hello-world")
})
it("should normalize EM DASH (U+2014) to ASCII hyphen", () => {
canonicalize("hello\u2014world").should.equal("hello-world")
})
it("should normalize MINUS SIGN (U+2212) to ASCII hyphen", () => {
canonicalize("hello\u2212world").should.equal("hello-world")
})
it("should normalize multiple different dashes", () => {
canonicalize("a\u2013b\u2014c\u2212d").should.equal("a-b-c-d")
})
})
describe("Double quote normalization", () => {
it("should keep ASCII double quotes unchanged", () => {
canonicalize('say "hello"').should.equal('say "hello"')
})
it("should normalize LEFT DOUBLE QUOTATION MARK (U+201C)", () => {
canonicalize("say \u201Chello\u201D").should.equal('say "hello"')
})
it("should normalize RIGHT DOUBLE QUOTATION MARK (U+201D)", () => {
canonicalize("say \u201Chello\u201D").should.equal('say "hello"')
})
it("should normalize DOUBLE LOW-9 QUOTATION MARK (U+201E)", () => {
canonicalize("say \u201Ehello\u201D").should.equal('say "hello"')
})
it("should normalize LEFT-POINTING DOUBLE ANGLE QUOTATION MARK (U+00AB)", () => {
canonicalize("say \u00ABhello\u00BB").should.equal('say "hello"')
})
it("should normalize RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (U+00BB)", () => {
canonicalize("say \u00ABhello\u00BB").should.equal('say "hello"')
})
})
describe("Single quote normalization", () => {
it("should keep ASCII apostrophe unchanged", () => {
canonicalize("it's here").should.equal("it's here")
})
it("should normalize LEFT SINGLE QUOTATION MARK (U+2018)", () => {
canonicalize("it\u2018s here").should.equal("it's here")
})
it("should normalize RIGHT SINGLE QUOTATION MARK (U+2019)", () => {
canonicalize("it\u2019s here").should.equal("it's here")
})
it("should normalize SINGLE HIGH-REVERSED-9 QUOTATION MARK (U+201B)", () => {
canonicalize("it\u201Bs here").should.equal("it's here")
})
})
describe("Space normalization", () => {
it("should keep regular spaces unchanged", () => {
canonicalize("hello world").should.equal("hello world")
})
it("should normalize NO-BREAK SPACE (U+00A0) to regular space", () => {
canonicalize("hello\u00A0world").should.equal("hello world")
})
it("should normalize NARROW NO-BREAK SPACE (U+202F) to regular space", () => {
canonicalize("hello\u202Fworld").should.equal("hello world")
})
it("should normalize multiple non-breaking spaces", () => {
canonicalize("a\u00A0b\u202Fc").should.equal("a b c")
})
})
describe("Escaped quote normalization", () => {
it("should normalize escaped backticks to unescaped", () => {
canonicalize("\\`code\\`").should.equal("`code`")
})
it("should normalize escaped single quotes to unescaped", () => {
canonicalize("\\'hello\\'").should.equal("'hello'")
})
it("should normalize escaped double quotes to unescaped", () => {
canonicalize('\\"hello\\"').should.equal('"hello"')
})
it("should handle multiple escaped quotes", () => {
canonicalize("\\`code\\` with \\'single\\' and \\\"double\\\"").should.equal("`code` with 'single' and \"double\"")
})
})
describe("Combined normalization", () => {
it("should normalize unicode punctuation and escaped quotes together", () => {
const input = "it\u2019s a \u201Ctest\u201D with\\`backticks\\` and\u2013dashes"
const expected = 'it\'s a "test" with`backticks` and-dashes'
canonicalize(input).should.equal(expected)
})
it("should handle complex real-world code snippet", () => {
const input = "const msg\u00A0=\u00A0\u201CHello\u2014world\u201D;"
const expected = 'const msg = "Hello-world";'
canonicalize(input).should.equal(expected)
})
it("should be idempotent", () => {
const input = "test\u2019s \u201Cvalue\u201D\u2013here"
const once = canonicalize(input)
const twice = canonicalize(once)
once.should.equal(twice)
})
})
describe("Edge cases", () => {
it("should handle empty strings", () => {
canonicalize("").should.equal("")
})
it("should handle strings with no special characters", () => {
canonicalize("hello world").should.equal("hello world")
})
it("should handle strings with only special characters", () => {
canonicalize("\u2013\u201C\u00A0").should.equal('-" ')
})
it("should handle multiline strings", () => {
const input = "line1\u2013test\nline2\u201Cquoted\u201D"
const expected = 'line1-test\nline2"quoted"'
canonicalize(input).should.equal(expected)
})
it("should handle strings with emoji", () => {
const input = "hello \u{1F44B} world"
const expected = "hello \u{1F44B} world"
canonicalize(input).should.equal(expected)
})
})
})
describe("preserveEscaping", () => {
describe("Backtick escaping", () => {
it("should preserve escaped backticks from original text", () => {
const original = "1. \\`file_path\\` MUST be an absolute path"
const newText = "1. `absolute_path` MUST be an absolute path"
const result = preserveEscaping(original, newText)
result.should.equal("1. \\`absolute_path\\` MUST be an absolute path")
})
it("should not add escaping if original has no escaped backticks", () => {
const original = "1. file_path MUST be an absolute path"
const newText = "1. `absolute_path` MUST be an absolute path"
const result = preserveEscaping(original, newText)
result.should.equal("1. `absolute_path` MUST be an absolute path")
})
it("should not double-escape already escaped backticks", () => {
const original = "1. \\`file_path\\` MUST be an absolute path"
const newText = "1. \\`absolute_path\\` MUST be an absolute path"
const result = preserveEscaping(original, newText)
result.should.equal("1. \\`absolute_path\\` MUST be an absolute path")
})
})
describe("Single quote escaping", () => {
it("should preserve escaped single quotes from original text", () => {
const original = "const str = \\'hello\\'"
const newText = "const str = 'world'"
const result = preserveEscaping(original, newText)
result.should.equal("const str = \\'world\\'")
})
it("should not add escaping if original has no escaped quotes", () => {
const original = "const str = hello"
const newText = "const str = 'world'"
const result = preserveEscaping(original, newText)
result.should.equal("const str = 'world'")
})
})
describe("Double quote escaping", () => {
it("should preserve escaped double quotes from original text", () => {
const original = 'const str = \\"hello\\"'
const newText = 'const str = "world"'
const result = preserveEscaping(original, newText)
result.should.equal('const str = \\"world\\"')
})
it("should not add escaping if original has no escaped quotes", () => {
const original = "const str = hello"
const newText = 'const str = "world"'
const result = preserveEscaping(original, newText)
result.should.equal('const str = "world"')
})
})
describe("Multiple escape types", () => {
it("should preserve multiple escape types from original", () => {
const original = "\`code\` with \'single\' and \"double\""
const newText = "`test` with 'foo' and \"bar\""
const result = preserveEscaping(original, newText)
result.should.equal("\`test\` with \'foo\' and \"bar\"")
})
it("should handle text with only some escape types", () => {
const original = "\\`code\\` with 'single'"
const newText = "`test` with 'foo' and \"bar\""
const result = preserveEscaping(original, newText)
result.should.equal("\\`test\\` with 'foo' and \"bar\"")
})
})
describe("Real-world patch scenario", () => {
it("should handle the documented use case from markdown files", () => {
const original =
"Expectation for required parameters:\n1. \\`file_path\\` MUST be an absolute path; otherwise an error will be thrown."
const newText =
"Expectation for required parameters:\n1. `absolute_path` MUST be an absolute path; otherwise an error will be thrown."
const result = preserveEscaping(original, newText)
result.should.equal(
"Expectation for required parameters:\n1. \\`absolute_path\\` MUST be an absolute path; otherwise an error will be thrown.",
)
})
it("should work with multiline patches", () => {
const original = "line 1 with \\`code\\`\nline 2 with \\`more\\`"
const newText = "line 1 with `test`\nline 2 with `changed`"
const result = preserveEscaping(original, newText)
result.should.equal("line 1 with \\`test\\`\nline 2 with \\`changed\\`")
})
})
describe("Edge cases", () => {
it("should handle empty original text", () => {
const original = ""
const newText = "`code`"
const result = preserveEscaping(original, newText)
result.should.equal("`code`")
})
it("should handle empty new text", () => {
const original = "\\`code\\`"
const newText = ""
const result = preserveEscaping(original, newText)
result.should.equal("")
})
it("should handle text with no quotes", () => {
const original = "hello world"
const newText = "goodbye world"
const result = preserveEscaping(original, newText)
result.should.equal("goodbye world")
})
it("should not affect text without matching escape patterns", () => {
const original = "\\`code\\`"
const newText = "no quotes here"
const result = preserveEscaping(original, newText)
result.should.equal("no quotes here")
})
})
})
+77
View File
@@ -0,0 +1,77 @@
/**
* Unicode punctuation normalisation helpers
* Makes patch matching resilient to visually identical but different Unicode code-points
*/
const PUNCT_EQUIV: Record<string, string> = {
// Hyphen / dash variants
"-": "-",
"\u2010": "-", // HYPHEN
"\u2011": "-", // NO-BREAK HYPHEN
"\u2012": "-", // FIGURE DASH
"\u2013": "-", // EN DASH
"\u2014": "-", // EM DASH
"\u2212": "-", // MINUS SIGN
// Double quotes
"\u0022": '"', // QUOTATION MARK
"\u201C": '"', // LEFT DOUBLE QUOTATION MARK
"\u201D": '"', // RIGHT DOUBLE QUOTATION MARK
"\u201E": '"', // DOUBLE LOW-9 QUOTATION MARK
"\u00AB": '"', // LEFT-POINTING DOUBLE ANGLE QUOTATION MARK
"\u00BB": '"', // RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
// Single quotes
"\u0027": "'", // APOSTROPHE
"\u2018": "'", // LEFT SINGLE QUOTATION MARK
"\u2019": "'", // RIGHT SINGLE QUOTATION MARK
"\u201B": "'", // SINGLE HIGH-REVERSED-9 QUOTATION MARK
// Spaces
"\u00A0": " ", // NO-BREAK SPACE
"\u202F": " ", // NARROW NO-BREAK SPACE
}
/**
* Canonicalize a string by normalizing unicode punctuation and quotes
*/
export function canonicalize(s: string): string {
// First normalize unicode and punctuation
let normalized = s.normalize("NFC").replace(/./gu, (c) => PUNCT_EQUIV[c] ?? c)
// Then normalize escaped/unescaped quotes to handle cases where:
// - patch has ` but file has \`
// - patch has ' but file has \'
// - patch has " but file has \"
normalized = normalized
.replace(/\\`/g, "`") // \` -> `
.replace(/\\'/g, "'") // \' -> '
.replace(/\\"/g, '"') // \" -> "
return normalized
}
/**
* Preserve the escaping style from original text when applying new text
* If original has \`, preserve that style in the replacement
*/
export function preserveEscaping(originalText: string, newText: string): string {
// Check if original has escaped backticks, quotes, or apostrophes
const hasEscapedBacktick = originalText.includes("\\`")
const hasEscapedSingleQuote = originalText.includes("\\'")
const hasEscapedDoubleQuote = originalText.includes('\\"')
let result = newText
// Apply escaping to match original style
if (hasEscapedBacktick && !newText.includes("\\`")) {
// Escape backslashes first, then backticks
result = result.replace(/\\/g, "\\\\").replace(/`/g, "\\`")
}
if (hasEscapedSingleQuote && !newText.includes("\\'")) {
// Escape backslashes first, then single quotes
result = result.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
}
if (hasEscapedDoubleQuote && !newText.includes('\\"')) {
// Escape backslashes first, then double quotes
result = result.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
}
return result
}