diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index e99c03fa36..2dcce1801c 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -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 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 654572efdc..66428a7198 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -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. diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index ad1e346345..cf1d553114 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -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 路径。 diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index ef63803e89..26b5052723 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -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 = [ diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index bcd4207e42..c25348221a 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -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 () => {