From a1a78da87830df2898c633d7dfb9e3c188d5c221 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:39:32 -0700 Subject: [PATCH] fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841) --- ...ssion-scanner-claude-unicode-scope.test.ts | 130 ++++++++++++++++++ .../session-scanner-scope-discovery.ts | 22 ++- src/main/ipc/filesystem-watcher-wsl.test.ts | 2 +- src/main/ipc/filesystem-watcher-wsl.ts | 12 +- src/main/ipc/filesystem-watcher.test.ts | 5 +- src/main/ipc/filesystem-watcher.ts | 22 +-- src/renderer/src/lib/path.test.ts | 9 ++ src/renderer/src/lib/path.ts | 48 +------ src/shared/cross-platform-path.test.ts | 59 ++++++++ src/shared/cross-platform-path.ts | 63 ++++++++- 10 files changed, 300 insertions(+), 72 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts diff --git a/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts b/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts new file mode 100644 index 00000000000..b36e7c588be --- /dev/null +++ b/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { resetProjectDirCwdCacheForTests } from './session-scanner-scope-discovery' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] + resetProjectDirCwdCacheForTests() +}) + +// Claude's own rule, deliberately spelled out rather than imported from the +// production encoder: these tests exist to catch Orca drifting away from how +// Claude actually names its directories, which a shared helper would hide. +// Claude maps every non-alphanumeric character of the raw cwd to '-', keeping +// case, and records the cwd it was launched with (NFC on macOS). +function claudeProjectDirName(cwd: string): string { + return cwd.normalize('NFC').replace(/[^a-zA-Z0-9]/g, '-') +} + +describe('scanAiVaultSessions — non-ASCII scope paths', () => { + it('lists Claude sessions when the workspace path is NFD and the transcript is NFC', async () => { + // Regression for #10832: macOS hands Orca decomposed (NFD) workspace paths + // while Claude Code writes NFC in both the project dir name and the recorded + // cwd, so every workspace with non-ASCII path segments listed zero sessions. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nfc-scope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + const workspaceNfc = '/Users/ada/내 드라이브/한국농어촌공사' + const workspaceNfd = workspaceNfc.normalize('NFD') + expect(workspaceNfd).not.toBe(workspaceNfc) + + const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspaceNfc)) + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'korean-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'korean-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: workspaceNfc, + gitBranch: 'main', + message: { role: 'user', content: '안녕하세요' } + } + ]) + ) + + // A newer ASCII session plus limit:1 exhausts the recency cap, so the Korean + // session can only surface through scope discovery — the path under test. + await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true }) + await writeFile( + join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'recent-session', + timestamp: '2026-06-01T10:00:00.000Z', + cwd: '/Users/ada/other', + gitBranch: 'main', + message: { role: 'user', content: 'newer' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin', + limit: 1, + scopePaths: [workspaceNfd] + }) + + expect(result.sessions.map((session) => session.sessionId)).toContain('korean-session') + }) + + it('lists Claude sessions for a Windows workspace whose path has uppercase segments', async () => { + // Claude derives the dir name from the raw cwd, so it keeps 'C--Users-Ada-repo'. + // Encoding from the lowercased comparison key produced 'c--users-ada-repo', + // which never matched — scoped discovery was dead on Windows entirely. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-win-scope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + const workspace = 'C:\\Users\\Ada\\repo' + const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspace)) + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'windows-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'windows-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: workspace, + gitBranch: 'main', + message: { role: 'user', content: 'hello' } + } + ]) + ) + await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true }) + await writeFile( + join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'recent-session', + timestamp: '2026-06-01T10:00:00.000Z', + cwd: '/Users/ada/other', + gitBranch: 'main', + message: { role: 'user', content: 'newer' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'win32', + limit: 1, + scopePaths: [workspace] + }) + + expect(result.sessions.map((session) => session.sessionId)).toContain('windows-session') + }) +}) diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index 41aac5f2700..b0a4792ffd6 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -4,7 +4,7 @@ import { createInterface } from 'node:readline' import { extname, join } from 'node:path' import { isPathInsideOrEqual, - normalizeRuntimePathForComparison + normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -91,7 +91,9 @@ function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set const prefixes = new Set() for (const scopePath of scopePaths) { for (const candidate of scopePathCandidates(scopePath)) { - prefixes.add(encodeClaudeProjectPath(candidate)) + for (const prefix of encodeClaudeProjectPaths(candidate)) { + prefixes.add(prefix) + } } } return prefixes @@ -102,8 +104,22 @@ function scopePathCandidates(scopePath: string): string[] { return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath] } +/** + * Why: Claude derives the directory name from the raw cwd, so encoding from the + * comparison key would lowercase Windows paths and never match on disk. Encode + * the raw path, plus its NFC spelling, since macOS hands us NFD (#10832). + */ +function encodeClaudeProjectPaths(pathValue: string): string[] { + const raw = encodeClaudeProjectPath(pathValue) + const composed = encodeClaudeProjectPath(pathValue.normalize('NFC')) + return raw === composed ? [raw] : [raw, composed] +} + function encodeClaudeProjectPath(pathValue: string): string { - return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-') + const separated = normalizeRuntimePathSeparators(pathValue) + const trimmed = + separated === '/' || /^[A-Za-z]:\/$/.test(separated) ? separated : separated.replace(/\/+$/, '') + return trimmed.replace(/[^a-zA-Z0-9]/g, '-') } function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet) { diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 4c8333d508e..5b1d1347a15 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -39,7 +39,7 @@ function snapshotFrame(entries: [type: string, mtime: string, path: string][]): .join('')}${SNAPSHOT_END}` } -type ScheduleBatchFlush = (rootKey: string, root: WatchedRoot) => void +type ScheduleBatchFlush = (root: WatchedRoot) => void type ScheduleBatchFlushMock = ReturnType> function makeDeps( diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index b73f58d5122..49ff8f20c7d 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -29,12 +29,14 @@ export type WatchedRoot = { subscription: WatcherSubscription listeners: Map batch: DebouncedBatch - rootPath?: string + // Why: the real on-disk path. Never substitute the watcher's rootKey — that is + // a comparison key (case/Unicode folded) and would reach the renderer as a path. + rootPath: string } export type WslWatcherDeps = { ignoreDirs: string[] - scheduleBatchFlush: (rootKey: string, root: WatchedRoot) => void + scheduleBatchFlush: (root: WatchedRoot) => void watchedRoots: Map } @@ -192,7 +194,7 @@ export async function createWslWatcher( } stopped = true markOverflowWithoutUncStat(root) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) deps.watchedRoots.delete(rootKey) } @@ -208,7 +210,7 @@ export async function createWslWatcher( if (events.length > 0) { queueWatcherEvents(root.batch, events) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) } } @@ -227,7 +229,7 @@ export async function createWslWatcher( if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) { streamBuffer = '' markOverflowWithoutUncStat(root) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) } return } diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 4687a6b71e1..b2f83baaf22 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -118,7 +118,7 @@ describe('registerFilesystemWatcherHandlers', () => { const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => reserveWatcherChild() ) - vi.mocked(createWslWatcher).mockImplementation(async () => { + vi.mocked(createWslWatcher).mockImplementation(async (_rootKey, worktreePath) => { const release = reserveWatcherChild() if (!release) { throw new WatcherChildCapacityError() @@ -126,7 +126,8 @@ describe('registerFilesystemWatcherHandlers', () => { return { subscription: { unsubscribe: vi.fn(async () => release()) }, listeners: new Map(), - batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 } + batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }, + rootPath: worktreePath } }) const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index b217f51e5b4..a4f13ff82ff 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -274,8 +274,8 @@ async function tryStatIsDirectory(filePath: string): Promise { +async function flushBatch(root: WatchedRoot): Promise { const overflowed = root.batch.overflowed const rawEvents = root.batch.events.splice(0) root.batch.overflowed = false @@ -300,7 +300,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) { // Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh. - emitOverflowPayload(rootKey, root) + emitOverflowPayload(root) return } @@ -320,7 +320,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { ) const payload: FsChangedPayload = { - worktreePath: root.rootPath ?? rootKey, + worktreePath: root.rootPath, events } @@ -331,7 +331,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { } } -function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { +function scheduleBatchFlush(root: WatchedRoot): void { const now = Date.now() if (root.batch.firstEventAt === 0) { @@ -343,7 +343,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) } - void flushBatch(rootKey, root) + void flushBatch(root) return } @@ -351,7 +351,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) } - root.batch.timer = setTimeout(() => void flushBatch(rootKey, root), DEBOUNCE_TRAILING_MS) + root.batch.timer = setTimeout(() => void flushBatch(root), DEBOUNCE_TRAILING_MS) } // ── Watcher creation ───────────────────────────────────────────────── @@ -380,7 +380,7 @@ async function createWatcher( const markWatcherInterrupted = (): void => { root.batch.overflowed = true - scheduleBatchFlush(rootKey, root) + scheduleBatchFlush(root) } // Why: fork the watcher process (issue #7547 — watcher.node teardown races crash the host); onInterruption marks overflow to refresh past the gap. @@ -390,7 +390,7 @@ async function createWatcher( if (err) { // Why: treat watcher errors as overflow so the renderer conservatively refreshes rather than trusting possibly-invalid caches (§7.2, §7.3). console.error(`[filesystem-watcher] error for ${rootKey}:`, err) - emitOverflowPayload(rootKey, root) + emitOverflowPayload(root) // Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3). if (root.batch.timer) { clearTimeout(root.batch.timer) @@ -406,7 +406,7 @@ async function createWatcher( } queueWatcherEvents(root.batch, events) - scheduleBatchFlush(rootKey, root) + scheduleBatchFlush(root) }, watcherOptions, { diff --git a/src/renderer/src/lib/path.test.ts b/src/renderer/src/lib/path.test.ts index c40ce80d757..42f4d59581e 100644 --- a/src/renderer/src/lib/path.test.ts +++ b/src/renderer/src/lib/path.test.ts @@ -39,4 +39,13 @@ describe('getRelativePathInsideRoot', () => { it('keeps POSIX path checks case-sensitive', () => { expect(getRelativePathInsideRoot('/Repo/Docs/Plan.md', '/repo')).toBeNull() }) + + it('matches a decomposed root against a composed file path', () => { + // Regression for #10832: a local copy of this comparison folded case but not + // Unicode, so macOS NFD roots missed their own NFC files entirely. + const composed = '/Users/ada/프로젝트' + expect(getRelativePathInsideRoot(`${composed}/Docs/Plan.md`, composed.normalize('NFD'))).toBe( + 'Docs/Plan.md' + ) + }) }) diff --git a/src/renderer/src/lib/path.ts b/src/renderer/src/lib/path.ts index 8437f4032ca..284ee247ad2 100644 --- a/src/renderer/src/lib/path.ts +++ b/src/renderer/src/lib/path.ts @@ -1,3 +1,5 @@ +import { relativePathInsideRoot } from '../../../shared/cross-platform-path' + function stripTrailingSeparators(path: string): string { return path.replace(/[\\/]+$/, '') } @@ -14,26 +16,6 @@ export function normalizeRelativePath(path: string): string { return stripLeadingSeparators(path).replace(/[\\/]+/g, '/') } -function normalizeAbsolutePath(path: string): string { - const isUncPath = /^[\\/]{2}[^\\/]/.test(path) - const normalized = path.replace(/[\\/]+/g, '/') - return isUncPath && !normalized.startsWith('//') ? `/${normalized}` : normalized -} - -function stripTrailingAbsoluteSeparators(path: string): string { - if (path === '/') { - return path - } - if (/^[A-Za-z]:\/$/.test(path)) { - return path - } - return path.replace(/\/+$/, '') -} - -function isCaseInsensitiveAbsolutePath(path: string): boolean { - return /^[A-Za-z]:(?:\/|$)/.test(path) || path.startsWith('//') -} - export function getRelativePathInsideRoot( filePath: string, rootPath: string | null @@ -41,29 +23,9 @@ export function getRelativePathInsideRoot( if (!rootPath) { return null } - - const normalizedFilePath = normalizeAbsolutePath(filePath) - const normalizedRoot = stripTrailingAbsoluteSeparators(normalizeAbsolutePath(rootPath)) - const comparisonFilePath = isCaseInsensitiveAbsolutePath(normalizedFilePath) - ? normalizedFilePath.toLowerCase() - : normalizedFilePath - const comparisonRoot = isCaseInsensitiveAbsolutePath(normalizedRoot) - ? normalizedRoot.toLowerCase() - : normalizedRoot - - if (comparisonFilePath === comparisonRoot) { - return '' - } - - // Why: POSIX and Windows drive roots already include their trailing separator. - const isRootPath = normalizedRoot === '/' || /^[A-Za-z]:\/$/.test(normalizedRoot) - const rootPrefix = isRootPath ? comparisonRoot : `${comparisonRoot}/` - if (!comparisonFilePath.startsWith(rootPrefix)) { - return null - } - - const sliceStart = isRootPath ? normalizedRoot.length : normalizedRoot.length + 1 - return normalizeRelativePath(normalizedFilePath.slice(sliceStart)) + // Why: delegate so renderer containment folds Unicode the same way the rest of + // the app does — a local copy silently missed NFD-vs-NFC matches (#10832). + return relativePathInsideRoot(rootPath, filePath) } export function basename(path: string): string { diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index 70d313ba63c..5e3ce6dd4de 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -70,6 +70,65 @@ describe('cross-platform path containment', () => { ).toBe('line\nbreak') }) + it('matches macOS NFD paths against agent-recorded NFC paths', () => { + // Regression for #10832: macOS file pickers hand Orca decomposed (NFD) paths + // while Claude Code records cwd and names its project dirs in NFC, so a + // non-ASCII workspace never matched its own sessions. + const nfc = '/Users/ada/내 드라이브/프로젝트' + const nfd = nfc.normalize('NFD') + expect(nfd).not.toBe(nfc) + + expect(normalizeRuntimePathForComparison(nfd)).toBe(normalizeRuntimePathForComparison(nfc)) + expect(isPathInsideOrEqual(nfd, `${nfc}/src`)).toBe(true) + expect(isPathInsideOrEqual(nfc, `${nfd}/src`)).toBe(true) + + // WSL UNC keys return before the trailing fold, so they need NFC too. + expect( + normalizeRuntimePathForComparison( + `\\\\wsl$\\Ubuntu\\home\\ada\\${'프로젝트'.normalize('NFD')}` + ) + ).toBe(normalizeRuntimePathForComparison(`\\\\wsl.localhost\\Ubuntu\\home\\ada\\프로젝트`)) + }) + + it('returns a byte-exact suffix when comparison folding changes length', () => { + // Comparison folding (NFC, case) is not length-preserving, so slicing the raw + // candidate by the folded root's length would cut mid-character and fabricate + // a path — callers rejoin this suffix and hit the filesystem with it. + const nfc = '/Users/ada/프로젝트' + const nfd = nfc.normalize('NFD') + for (const root of [nfc, nfd]) { + for (const candidate of [nfc, nfd]) { + expect(relativePathInsideRoot(root, `${candidate}/src/index.ts`)).toBe('src/index.ts') + } + } + + // Pre-existing over-slice: toLowerCase expands U+0130 to two UTF-16 units. + expect(relativePathInsideRoot('C:\\İş', 'C:\\İş\\src\\a.ts')).toBe('src/a.ts') + + // U+212A KELVIN SIGN folds to 'K', so the root and candidate must agree on + // Windows-ness or their segment counts desync and the suffix comes back ''. + expect(relativePathInsideRoot('\u212A:/a\\b', '\u212A:/a\\b/c')).toBe( + relativePathInsideRoot('K:/a\\b', 'K:/a\\b/c') + ) + + // Astral characters must not be cut mid-surrogate-pair. + expect(relativePathInsideRoot('/repo/🚀app', '/repo/🚀app/src/🎉file.ts')).toBe('src/🎉file.ts') + + // A UNC-shaped candidate under POSIX root '/' used to yield a leading slash, + // which is not a relative path. + expect(relativePathInsideRoot('/', '//server/share/x')).toBe('server/share/x') + + // WSL suffixes must stay decomposed: they name files on a Linux filesystem, + // where NFD and NFC are distinct entries. + const decomposed = '프로젝트'.normalize('NFD') + expect( + relativePathInsideRoot( + '\\\\wsl$\\Ubuntu\\home\\ada\\repo', + `\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo\\${decomposed}\\a.ts` + ) + ).toBe(`${decomposed}/a.ts`) + }) + it('resolves POSIX relative paths without using the process cwd', () => { expect(resolveRuntimePath('/repos/app/repo', '../worktrees/feature')).toBe( '/repos/app/worktrees/feature' diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index e329784719b..5e5b5432d6e 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -1,3 +1,5 @@ +const SLASH_CHAR_CODE = '/'.charCodeAt(0) + export function isWindowsAbsolutePathLike(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//') } @@ -10,7 +12,19 @@ export function normalizeRuntimePathSeparators(value: string): string { return normalized } -export function normalizeRuntimePathForComparison(value: string): string { +/** + * Comparison key only — never return this as, or splice it into, a real path. + * + * Why NFC: macOS file pickers and on-disk names yield NFD, while agents such as + * Claude Code record cwd and encode their project directory names in NFC. Both + * spell the same file, so a non-ASCII workspace otherwise never matches its own + * sessions (#10832). Folding here knowingly treats canonically equivalent names + * as one, which is exact on APFS but permissive on byte-exact Linux/SSH hosts — + * an acceptable trade, since only comparison keys are affected. + */ +export function normalizeRuntimePathForComparison(rawValue: string): string { + // Normalize before any folding so the WSL alias branch below is covered too. + const value = rawValue.normalize('NFC') const isWindowsPath = isWindowsAbsolutePathLike(value) // Why: backslash is a valid POSIX filename character; fold it only when the // path itself proves Windows drive/UNC semantics. @@ -68,8 +82,12 @@ export function isPathInsideOrEqual(rootPath: string, candidatePath: string): bo } export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null { + // Why: decide Windows-ness on the same NFC form the comparison key uses, or the + // two disagree (U+212A folds to 'K', making only one side a drive path) and the + // segment counts desync. Only the branch test sees NFC — the sliced string stays + // raw so the returned suffix remains byte-exact. const normalizedCandidate = trimRuntimePathTrailingSlash( - isWindowsAbsolutePathLike(candidatePath) + isWindowsAbsolutePathLike(candidatePath.normalize('NFC')) ? normalizeRuntimePathSeparators(candidatePath) : candidatePath.replace(/\/+/g, '/') ) @@ -84,11 +102,42 @@ export function relativePathInsideRoot(rootPath: string, candidatePath: string): if (!comparisonCandidate.startsWith(comparisonPrefix)) { return null } - // WSL comparison keys fold the UNC alias but preserve Linux path casing, so - // their suffix is both aligned across aliases and safe to return directly. - return comparisonRoot.startsWith('//wsl/') - ? comparisonCandidate.slice(comparisonPrefix.length) - : normalizedCandidate.slice(comparisonPrefix.length) + return sliceCandidatePastRootSegments(comparisonRoot, normalizedCandidate) +} + +/** + * Why: skip whole root segments rather than a character count. Comparison + * folding (NFC, case, UNC alias) changes length, so a folded-prefix length would + * cut the raw candidate mid-character and fabricate a path; segment positions + * survive every fold and keep the suffix byte-exact. Scanning rather than + * splitting keeps watcher event storms allocation-free. + */ +function sliceCandidatePastRootSegments(root: string, candidate: string): string { + let remainingRootSegments = 0 + let inRootSegment = false + for (let index = 0; index < root.length; index++) { + if (root.charCodeAt(index) === SLASH_CHAR_CODE) { + inRootSegment = false + } else if (!inRootSegment) { + inRootSegment = true + remainingRootSegments++ + } + } + + let inSegment = false + for (let index = 0; index < candidate.length; index++) { + if (candidate.charCodeAt(index) === SLASH_CHAR_CODE) { + inSegment = false + continue + } + if (!inSegment) { + inSegment = true + if (remainingRootSegments-- === 0) { + return candidate.slice(index) + } + } + } + return '' } function trimRuntimePathTrailingSlash(value: string): string {