fix(fs): bound glob pruning with directory-name filters

This commit is contained in:
_Kerman authored and imccyu committed 2026-10-26 12:04:34 +08:00
1 parent b1c8c9bbbc
commit f9ef03834a
5 files changed
+33 -13

No files matched your search

+2 -2
View File
@@ -42,8 +42,8 @@
en: 5a30f023dd86c345
zh: 558b6e80c62648ba
/deepseek-ai-dsh-tool-fs-search/understand-the-implementation/how-a-search-runs:
en: bffeacf8482af3f7
zh: 9754972cab5251fd
en: 7904c9f013272819
zh: 656763e9a7e97c1e
/deepseek-ai-dsh-tool-fs-search/understand-the-implementation/native-mode-benchmark:
en: 031654f59e63d07e
zh: a3b488fb1880d2f5
+1 -1
View File
@@ -105,7 +105,7 @@ Local workspace discovery is naturally a process-backed `rg` workflow, and putti
### How a search runs
Without an explicit `path`, `glob` prunes unrelated subtrees for patterns such as `packages/core/src/*.ts` when every directory component is a plain relative name outside VCS metadata. Ordered ripgrep globs exclude the tree, admit only the prefix directories, then apply the original file pattern. Directory-only includes cannot admit files with the same names. The search root, path separators, symlink handling, matching, and modification-time ordering stay unchanged; other patterns retain the original traversal.
Without an explicit `path`, `glob` prunes unrelated subtrees for patterns such as `packages/core/src/*.ts` when every directory component is a plain relative name outside VCS metadata. Ordered ripgrep globs exclude the tree, admit directories with the prefix's distinct names, then apply the original file pattern. Same-named directories elsewhere may also be traversed, but only the original pattern admits files. The added arguments are capped at 8 KiB; larger sets use the original traversal. The search root, path separators, symlink handling, matching, and modification-time ordering stay unchanged; other patterns retain the original traversal.
Each call resolves the packaged binary (`@vscode/ripgrep`, or the executable's `-rg` sidecar in a pkg single-file runtime), prepends `--no-config` so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn, and passes every model-controlled value as a plain argv element — no shell layer exists, so no quoting applies. Collect-mode budgets bound complete stdout and a stderr tail; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW` rather than parsing a silently-partial stream. The tools never read a raw spill path.
+1 -1
View File
@@ -105,7 +105,7 @@ Node 部署在受支持的 macOS、Linux 与 Windows 目标上获得 `@vscode/ri
### 搜索如何运行
未显式提供 `path` 时,如果目录部分的每一项都是普通相对名称且不属于 VCS 元数据目录,`glob` 会为 `packages/core/src/*.ts` 这样的模式跳过无关子树。按顺序添加的 ripgrep glob 先排除目录树,再仅放行前缀目录,最后应用原始文件模式。仅匹配目录的放行规则不会纳入同名文件。搜索根目录、路径分隔符、符号链接处理、匹配规则和修改时间排序保持不变;其他模式仍按原方式遍历。
未显式提供 `path` 时,如果目录部分的每一项都是普通相对名称且不属于 VCS 元数据目录,`glob` 会为 `packages/core/src/*.ts` 这样的模式跳过无关子树。按顺序添加的 ripgrep glob 先排除目录树,再按前缀中去重后的名称放行目录,最后应用原始文件模式。其他位置的同名目录也可能被遍历,但文件仍只按原模式匹配。新增参数上限为 8 KiB,超过时沿用原遍历方式。搜索根目录、路径分隔符、符号链接处理、匹配规则和修改时间排序保持不变;其他模式仍按原方式遍历。
每次调用解析打包二进制(`@vscode/ripgrep`,或 pkg 单文件运行时中可执行程序的 `-rg` 伴随文件),前置 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器,并把每个模型控制的值作为普通 argv 元素传入——不存在 shell 层,因此不涉及 shell 引号处理。collect 模式预算限制完整 stdout 与 stderr 尾部;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,而不是解析静默不完整的流。工具从不读取原始 spill 路径。
+6 -5
View File
@@ -82,8 +82,9 @@ export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInp
* a flag. `--sort=modified` orders by modification time, `--no-ignore
* --hidden` searches ignored and hidden files, and
* {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out. Simple directory prefixes
* prune unrelated subtrees through directory-only globs without changing the
* search root, path separators, or symlink handling.
* prune unrelated subtrees through directory-name globs without changing the
* search root, path separators, or symlink handling. Added pruning arguments
* over 8 KiB retain the original traversal to bound command-line growth.
*
* @param input - the validated arguments.
* @returns the complete ripgrep argument vector (excluding the binary itself).
@@ -93,10 +94,10 @@ export function buildGlobCommand(input: GlobInput): string[] {
const pruning: string[] = []
if (input.path === undefined && directories.length > 0
&& directories.every(part => /^[\w .-]+$/.test(part) && part !== '.' && part !== '..' && !GLOB_VCS_EXCLUDES.includes(part))) {
pruning.push('--glob=!**')
// Trailing slashes admit directories only, never files with the same names.
for (let depth = 1; depth <= directories.length; depth++) {
pruning.push(`--glob=/${directories.slice(0, depth).join('/')}/`)
const directoryGlob = `--glob={${[...new Set(directories)].join(',')}}/`
if (directoryGlob.length + '--glob=!**'.length + 2 <= 8 * 1024) {
pruning.push('--glob=!**', directoryGlob)
}
}
const parts = [
@@ -8,7 +8,7 @@
* suite runs on every platform without a system `rg` install; the
* fake-service suite (tools.spec.ts) carries the coverage gate.
*/
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import { executeModelToolFixture, executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -85,6 +85,8 @@ describe('search tools over the real subprocess service + the packaged rg', () =
await writeFile(join(dir, 'packages', 'core', 'src', 'old.ts'), '')
await writeFile(join(dir, 'packages', 'core', 'src', '.hidden.ts'), '')
await writeFile(join(dir, 'packages', 'core', 'src', 'nested', 'other.ts'), '')
await mkdir(join(dir, 'packages', 'src', 'core'), { recursive: true })
await writeFile(join(dir, 'packages', 'src', 'core', 'other.ts'), '')
await writeFile(join(dir, '.gitignore'), 'packages/\n')
await utimes(join(dir, 'packages', 'core', 'src', 'old.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1))
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
@@ -97,8 +99,8 @@ describe('search tools over the real subprocess service + the packaged rg', () =
join('packages', 'core', 'src', 'old.ts'),
join('packages', 'core', 'src', '.hidden.ts'),
] })
expect(spawn.mock.calls[0]?.[0].argv.slice(3, 8)).toEqual([
'--glob=!**', '--glob=/packages/', '--glob=/packages/core/', '--glob=/packages/core/src/', '--glob=packages/core/src/*.ts',
expect(spawn.mock.calls[0]?.[0].argv.slice(3, 6)).toEqual([
'--glob=!**', '--glob={packages,core,src}/', '--glob=packages/core/src/*.ts',
])
expect(spawn.mock.calls[0]?.[0].argv).not.toContain('--')
})
@@ -118,10 +120,27 @@ describe('search tools over the real subprocess service + the packaged rg', () =
expect(text(result)).toBe('No files found')
})
it.each([[1, 8170, 8192], [1, 8171, 0], [1500, 1, 23], [16, 240, 262]])('keeps %i directories of length %i searchable through Native execution', async (depth, length, pruningBytes) => {
const pattern = `${Array.from({ length: depth }, () => 'a'.repeat(length)).join('/')}/*.ts`
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
const result = await executeModelToolFixture(ctx, {
signal: testToolSignal,
callId: ToolCallId(`deep-glob-${++callCounter}`),
name: 'glob',
arguments: { pattern },
agent: agent() as never,
})
expect(result.isError).toBe(false)
expect(text(result)).toBe('No files found')
const argv = spawn.mock.calls[0]?.[0].argv ?? []
const pruning = argv.slice(3, argv.indexOf(`--glob=${pattern}`))
expect(pruning.reduce((bytes, arg) => bytes + Buffer.byteLength(arg) + 1, 0)).toBe(pruningBytes)
})
it('preserves case-sensitive matching while pruning directories', async () => {
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
expect(text(await call('glob', { pattern: 'SRC/*.ts' }, agent()))).toBe('No files found')
expect(spawn.mock.calls[0]?.[0].argv).toContain('--glob=/SRC/')
expect(spawn.mock.calls[0]?.[0].argv).toContain('--glob={SRC}/')
})
it('does not follow a directory link when narrowing a literal prefix', async () => {