Skip to content
153 changes: 153 additions & 0 deletions packages/code-link-cli/src/controller.rename.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import fs from "fs/promises"
import os from "os"
import path from "path"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { WebSocket } from "ws"
import { executeEffect } from "./controller.ts"
import type { Config } from "./types.ts"
import { hashFileContent } from "./utils/state-persistence.ts"

const { sendMessage } = vi.hoisted(() => ({
sendMessage: vi.fn(),
}))

vi.mock("./helpers/connection.ts", () => ({
initConnection: vi.fn(),
sendMessage,
}))

const mockSocket = {} as WebSocket

describe("rename confirmation bookkeeping", () => {
beforeEach(() => {
sendMessage.mockReset()
})

it("waits for file-synced before deleting old tracking", async () => {
sendMessage.mockResolvedValue(true)

const hashTracker = {
remember: vi.fn(),
shouldSkip: vi.fn(),
forget: vi.fn(),
clear: vi.fn(),
markDelete: vi.fn(),
shouldSkipDelete: vi.fn(),
clearDelete: vi.fn(),
}
const fileMetadataCache = {
recordDelete: vi.fn(),
}
const pendingRenames = new Map<string, { oldFileName: string; content: string }>()

await executeEffect(
{
type: "SEND_FILE_RENAME",
oldFileName: "Old.tsx",
newFileName: "New.tsx",
content: "export const New = () => null",
},
{
config: {
port: 0,
projectHash: "project",
projectDir: null,
filesDir: null,
dangerouslyAutoDelete: false,
allowUnsupportedNpm: false,
} satisfies Config,
hashTracker: hashTracker as never,
installer: null,
fileMetadataCache: fileMetadataCache as never,
pendingRenames,
userActions: {} as never,
syncState: {
mode: "watching",
socket: {} as never,
pendingRemoteChanges: [],
},
}
)

expect(sendMessage).toHaveBeenCalledWith(
expect.anything(),
{
type: "file-rename",
oldFileName: "Old.tsx",
newFileName: "New.tsx",
content: "export const New = () => null",
}
)
expect(hashTracker.forget).not.toHaveBeenCalled()
expect(hashTracker.remember).not.toHaveBeenCalled()
expect(fileMetadataCache.recordDelete).not.toHaveBeenCalled()
expect(pendingRenames.get("New.tsx")).toEqual({
oldFileName: "Old.tsx",
content: "export const New = () => null",
})
})

it("applies old-file cleanup after file-synced arrives", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-"))
const filesDir = path.join(tmpDir, "files")
await fs.mkdir(filesDir, { recursive: true })
await fs.writeFile(path.join(filesDir, "New.tsx"), "export const New = () => null", "utf-8")

const hashTracker = {
remember: vi.fn(),
shouldSkip: vi.fn(),
forget: vi.fn(),
clear: vi.fn(),
markDelete: vi.fn(),
shouldSkipDelete: vi.fn(),
clearDelete: vi.fn(),
}
const fileMetadataCache = {
recordSyncedSnapshot: vi.fn(),
recordDelete: vi.fn(),
}
const pendingRenames = new Map<string, { oldFileName: string; content: string }>([
["New.tsx", { oldFileName: "Old.tsx", content: "export const New = () => null" }],
])

await executeEffect(
{
type: "UPDATE_FILE_METADATA",
fileName: "New.tsx",
remoteModifiedAt: 1234,
},
{
config: {
port: 0,
projectHash: "project",
projectDir: tmpDir,
filesDir,
dangerouslyAutoDelete: false,
allowUnsupportedNpm: false,
} satisfies Config,
hashTracker: hashTracker as never,
installer: null,
fileMetadataCache: fileMetadataCache as never,
pendingRenames,
userActions: {} as never,
syncState: {
mode: "watching",
socket: mockSocket,
pendingRemoteChanges: [],
},
}
)

expect(fileMetadataCache.recordSyncedSnapshot).toHaveBeenCalledWith(
"New.tsx",
hashFileContent("export const New = () => null"),
1234
)
expect(hashTracker.forget).toHaveBeenCalledWith("Old.tsx")
expect(fileMetadataCache.recordDelete).toHaveBeenCalledWith("Old.tsx")
expect(hashTracker.remember).toHaveBeenCalledWith("New.tsx", "export const New = () => null")
expect(pendingRenames.has("New.tsx")).toBe(false)

await fs.rm(tmpDir, { recursive: true, force: true })
})
})
84 changes: 80 additions & 4 deletions packages/code-link-cli/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ type Effect =
type: "LOCAL_INITIATED_FILE_DELETE"
fileNames: string[]
}
| {
type: "SEND_FILE_RENAME"
oldFileName: string
newFileName: string
content: string
}
| { type: "PERSIST_STATE" }
| {
type: "SYNC_COMPLETE"
Expand All @@ -177,6 +183,11 @@ type Effect =
message: string
}

interface PendingRename {
oldFileName: string
content: string
}

/** Log helper */
function log(level: "info" | "debug" | "warn" | "success", message: string): Effect {
return { type: "LOG", level, message }
Expand Down Expand Up @@ -556,6 +567,23 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff
})
break
}

case "rename": {
if (content === undefined || !event.event.oldRelativePath) {
effects.push(log("warn", `Rename event missing data: ${relativePath}`))
return { state, effects }
}
effects.push(
log("debug", `Local rename detected: ${event.event.oldRelativePath} → ${relativePath}`),
{
type: "SEND_FILE_RENAME",
oldFileName: event.event.oldRelativePath,
newFileName: relativePath,
content,
}
)
break
}
}

return { state, effects }
Expand Down Expand Up @@ -675,11 +703,12 @@ async function executeEffect(
hashTracker: ReturnType<typeof createHashTracker>
installer: Installer | null
fileMetadataCache: FileMetadataCache
pendingRenames: Map<string, PendingRename>
userActions: PluginUserPromptCoordinator
syncState: SyncState
}
): Promise<SyncEvent[]> {
const { config, hashTracker, installer, fileMetadataCache, userActions, syncState } = context
const { config, hashTracker, installer, fileMetadataCache, pendingRenames, userActions, syncState } = context

switch (effect.type) {
case "INIT_WORKSPACE": {
Expand Down Expand Up @@ -852,12 +881,21 @@ async function executeEffect(

// Read current file content to compute hash
const currentContent = await readFileSafe(effect.fileName, config.filesDir)
const pendingRename = pendingRenames.get(effect.fileName)
const syncedContent = currentContent ?? pendingRename?.content ?? null

if (currentContent !== null) {
const contentHash = hashFileContent(currentContent)
if (syncedContent !== null) {
const contentHash = hashFileContent(syncedContent)
fileMetadataCache.recordSyncedSnapshot(effect.fileName, contentHash, effect.remoteModifiedAt)
}

if (pendingRename) {
hashTracker.forget(pendingRename.oldFileName)
fileMetadataCache.recordDelete(pendingRename.oldFileName)
hashTracker.remember(effect.fileName, pendingRename.content)
pendingRenames.delete(effect.fileName)
}

return []
}

Expand Down Expand Up @@ -903,6 +941,35 @@ async function executeEffect(
return []
}

case "SEND_FILE_RENAME": {
try {
if (!syncState.socket) {
warn(`No socket available to send rename ${effect.oldFileName} -> ${effect.newFileName}`)
return []
}

const sent = await sendMessage(syncState.socket, {
type: "file-rename",
oldFileName: effect.oldFileName,
newFileName: effect.newFileName,
content: effect.content,
})
if (!sent) {
warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`)
return []
}

pendingRenames.set(effect.newFileName, {
oldFileName: effect.oldFileName,
content: effect.content,
})
} catch (err) {
warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`)
}

return []
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename handler lacks echo prevention checks

Medium Severity

The new SEND_FILE_RENAME effect handler sends renames to the plugin without any echo prevention via hashTracker. The existing SEND_LOCAL_CHANGE checks hashTracker.shouldSkip and LOCAL_INITIATED_FILE_DELETE checks hashTracker.shouldSkipDelete, but SEND_FILE_RENAME checks neither. When remote-initiated writes (new file + deleted old file) happen close together, the watcher's rename detection can combine them into a rename event that bypasses the per-event echo guards, causing a spurious rename message to be sent back to the plugin.

Additional Locations (1)

Fix in Cursor Fix in Web


case "LOCAL_INITIATED_FILE_DELETE": {
// Echo prevention: filter out remote-initiated deletes
const filesToDelete = effect.fileNames.filter(fileName => {
Expand Down Expand Up @@ -1013,6 +1080,7 @@ export async function start(config: Config): Promise<void> {

const hashTracker = createHashTracker()
const fileMetadataCache = new FileMetadataCache()
const pendingRenames = new Map<string, PendingRename>()
let installer: Installer | null = null

// State machine state
Expand Down Expand Up @@ -1052,6 +1120,7 @@ export async function start(config: Config): Promise<void> {
hashTracker,
installer,
fileMetadataCache,
pendingRenames,
userActions,
syncState,
})
Expand Down Expand Up @@ -1208,6 +1277,13 @@ export async function start(config: Config): Promise<void> {
}
break

case "error":
if (message.fileName) {
pendingRenames.delete(message.fileName)
}
warn(message.message)
return

case "conflicts-resolved":
event = {
type: "CONFLICTS_RESOLVED",
Expand Down Expand Up @@ -1287,4 +1363,4 @@ export async function start(config: Config): Promise<void> {
}

// Export for testing
export { transition }
export { executeEffect, transition }
6 changes: 3 additions & 3 deletions packages/code-link-cli/src/helpers/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { autoResolveConflicts, DEFAULT_REMOTE_DRIFT_MS, detectConflicts } from "
function makeConflict(overrides: Partial<Conflict> = {}): Conflict {
return {
fileName: overrides.fileName ?? "Test.tsx",
localContent: "localContent" in overrides ? overrides.localContent : "local",
remoteContent: "remoteContent" in overrides ? overrides.remoteContent : "remote",
localContent: Object.hasOwn(overrides, "localContent") ? overrides.localContent ?? null : "local",
remoteContent: Object.hasOwn(overrides, "remoteContent") ? overrides.remoteContent ?? null : "remote",
localModifiedAt: overrides.localModifiedAt ?? Date.now(),
remoteModifiedAt: overrides.remoteModifiedAt ?? Date.now(),
lastSyncedAt: "lastSyncedAt" in overrides ? overrides.lastSyncedAt : Date.now(),
lastSyncedAt: Object.hasOwn(overrides, "lastSyncedAt") ? overrides.lastSyncedAt : Date.now(),
localClean: overrides.localClean,
}
}
Expand Down
9 changes: 5 additions & 4 deletions packages/code-link-cli/src/helpers/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ const REACT_DOM_TYPES_VERSION = "18.3.1"
const CORE_LIBRARIES = ["framer-motion", "framer"]
const JSON_EXTENSION_REGEX = /\.json$/i


/**
* Packages that are officially supported for type acquisition.
* Use --unsupported-npm flag to allow other packages.
Expand Down Expand Up @@ -228,7 +227,8 @@ export class Installer {
try {
await this.ata(filteredContent)
} catch (err) {
warn(`ATA failed for ${fileName}`, err as Error)
warn(`Type fetching failed for ${fileName}`)
debug(`ATA error for ${fileName}:`, err)
}
}

Expand Down Expand Up @@ -588,12 +588,13 @@ async function fetchWithRetry(

if (attempt < retries && isRetryable) {
const delay = attempt * 1_000
warn(`Fetch failed (${error.cause?.code ?? error.message}) for ${urlString}, retrying in ${delay}ms...`)
debug(`Fetch failed for ${urlString}, retrying...`, error)
await new Promise(resolve => setTimeout(resolve, delay))
continue
}

warn(`Fetch failed for ${urlString}`, error)
warn(`Fetch failed for ${urlString}`)
debug(`Fetch error details:`, error)
throw error
}
}
Expand Down
Loading
Loading