fix(fs-search): respect platform path separators

Group paths using node:path.sep so POSIX backslashes remain filename characters while Windows continues to treat them as separators.
This commit is contained in:
Tianyi Cui
2026-07-30 22:20:15 +08:00
parent 72220dd821
commit 48b0cb25fd
4 changed files with 36 additions and 10 deletions

View File

@@ -9,6 +9,7 @@
*/
import type { Context } from 'cordis'
import { sep } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
@@ -112,16 +113,25 @@ export interface GlobSample {
/** Remove the displayed search-root prefix before choosing a top-level group. */
function relativeToSearchRoot(path: string, root: string): string {
if (root === '.') return path.replace(/^\.[\\/]/, '')
const trimmedRoot = root.replace(/[\\/]+$/, '')
if (trimmedRoot.length === 0) return path.replace(/^[\\/]+/, '')
if (root === '.') return path.startsWith(`.${sep}`) ? path.slice(2) : path
let rootEnd = root.length
while (rootEnd > 0 && root[rootEnd - 1] === sep) rootEnd -= 1
const trimmedRoot = root.slice(0, rootEnd)
if (trimmedRoot.length === 0) return stripLeadingSeparators(path)
if (path === trimmedRoot) return ''
if (path.startsWith(`${trimmedRoot}/`) || path.startsWith(`${trimmedRoot}\\`)) {
if (path.startsWith(`${trimmedRoot}${sep}`)) {
return path.slice(trimmedRoot.length + 1)
}
return path
}
/** Strip only separators recognized by the execution platform. */
function stripLeadingSeparators(path: string): string {
let start = 0
while (path[start] === sep) start += 1
return path.slice(start)
}
/**
* The leading path segment of one display path — the top-level entry, relative
* to the search root, that the path sits under. A path with no separator is its
@@ -131,8 +141,8 @@ function relativeToSearchRoot(path: string, root: string): string {
* empty group.
*/
function topLevelSegment(path: string): string {
const trimmed = path.replace(/^[\\/]+/, '')
const cut = trimmed.search(/[\\/]/)
const trimmed = stripLeadingSeparators(path)
const cut = trimmed.indexOf(sep)
return cut === -1 ? trimmed : trimmed.slice(0, cut)
}

View File

@@ -12,7 +12,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { join } from 'node:path'
import { join, sep } from 'node:path'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
@@ -582,14 +582,28 @@ describe('cross-directory sampling', () => {
.toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/'))
.toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['C:\\root\\a\\one', 'C:\\root\\b\\two'], 2, 'C:\\root'))
.toEqual({ items: ['C:\\root\\a\\one', 'C:\\root\\b\\two'], shown: 2, total: 2 })
const rooted = [
['root', 'a', 'one'].join(sep),
['root', 'a', 'two'].join(sep),
['root', 'b', 'three'].join(sep),
]
expect(sampleAcrossTopLevel(rooted, 2, 'root'))
.toEqual({ items: [rooted[0], rooted[2]], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src'))
.toEqual({ items: ['other/a.ts'], shown: 1, total: 1 })
expect(sampleAcrossTopLevel(['src'], 1, 'src'))
.toEqual({ items: ['src'], shown: 1, total: 1 })
})
it.skipIf(process.platform === 'win32')('treats POSIX backslashes as filename characters', () => {
const paths = ['old\\one', 'old\\two', 'src/a']
expect(sampleAcrossTopLevel(paths, 2)).toEqual({
items: ['old\\one', 'old\\two'],
shown: 2,
total: 3,
})
})
it('handles more top-level groups than the JavaScript argument limit', () => {
const paths = Array.from({ length: 125_000 }, (_, index) => `dir-${index}/file.txt`)
expect(sampleAcrossTopLevel(paths, 100)).toMatchObject({ shown: 100, total: 125_000 })