fix: handle # compound-path separator in fingerprintFile (#358)
Some checks are pending
CI / semgrep (push) Waiting to run

The Cursor provider encodes workspace context into source paths using a
`#cursor-ws=<tag>` suffix (e.g. `state.vscdb#cursor-ws=__orphan__`).
`fingerprintFile` only had a fallback for `:` separators (OpenCode
sessions), so Cursor sources silently returned null on macOS/Linux where
paths contain no colons, causing them to be skipped entirely.

Add a `#` fallback before the existing `:` check. The first `stat()`
on the full path still succeeds for real files containing `#`, so there
is no regression for legitimate paths.

Includes 4 new test cases covering both separators, the combined case,
and the null case for non-existent base files.
This commit is contained in:
René Lachmann 2026-05-19 13:21:17 +02:00 committed by GitHub
parent c9487e7b0a
commit 3542407f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 51 additions and 0 deletions

View file

@ -239,6 +239,21 @@ export async function fingerprintFile(filePath: string): Promise<FileFingerprint
const s = await stat(filePath)
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch {
// Providers encode extra context into source paths using virtual suffixes:
// - Cursor: `<dbPath>#cursor-ws=<workspace>` (workspace-aware routing)
// - OpenCode: `<dbPath>:<sessionId>` (session scoping)
// These compound paths don't exist on disk; strip the suffix to stat the
// underlying file. Try `#` first (rare in real paths), then `:` (must use
// lastIndexOf to tolerate Windows drive letters like C:\...).
const hashIdx = filePath.indexOf('#')
if (hashIdx > 0) {
try {
const s = await stat(filePath.slice(0, hashIdx))
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch {
// fall through to colon check
}
}
const colonIdx = filePath.lastIndexOf(':')
if (colonIdx > 0) {
try {

View file

@ -185,6 +185,42 @@ describe('fingerprintFile', () => {
const fp = await fingerprintFile('/no/such/file')
expect(fp).toBeNull()
})
it('resolves compound path with # separator (Cursor workspace)', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'state.vscdb')
await writeFile(filePath, 'cursor-data')
const fp = await fingerprintFile(`${filePath}#cursor-ws=__orphan__`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(11)
})
it('resolves compound path with : separator (OpenCode session)', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'opencode.db')
await writeFile(filePath, 'opencode-data')
const fp = await fingerprintFile(`${filePath}:ses_abc123`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(13)
})
it('returns null when base file does not exist for compound path', async () => {
const fp = await fingerprintFile('/no/such/file.db#cursor-ws=workspace')
expect(fp).toBeNull()
})
it('prefers # separator over : when both present', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'state.vscdb')
await writeFile(filePath, 'both-seps')
// Path has both # and : — should strip at # first and find the base file
const fp = await fingerprintFile(`${filePath}#cursor-ws=ws:extra-colon`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(9)
})
})
// ── reconcileFile ──────────────────────────────────────────────────────