Fix exact workspace file suggestions

Resolve exact suffix path queries before falling back to broad search, and keep broad hidden-directory traversal limited to known workspace config dirs.
This commit is contained in:
Mohamed Boudra
2026-06-20 19:13:25 +07:00
parent 6a3856b639
commit 2c4e443ad7
2 changed files with 176 additions and 6 deletions

View File

@@ -332,11 +332,63 @@ describe("searchWorkspaceEntries", () => {
]);
});
it("suffix mode finds files under hidden workspace directories", async () => {
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
it("suffix mode resolves exact workspace file paths before broad traversal", async () => {
const targetPath = path.join(
workspaceDir,
"packages",
"server",
"src",
"services",
"quota-fetcher",
"providers",
"local.ts",
);
mkdirSync(path.dirname(targetPath), { recursive: true });
writeFileSync(targetPath, "");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "packages/server/src/services/quota-fetcher/providers/local.ts",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
maxEntriesScanned: 1,
});
expect(results).toEqual([
{
path: "packages/server/src/services/quota-fetcher/providers/local.ts",
kind: "file",
},
]);
});
it("suffix mode resolves explicit hidden file paths without broad hidden traversal", async () => {
const targetPath = path.join(workspaceDir, ".dev", "paseo-home", "daemon.log");
mkdirSync(path.dirname(targetPath), { recursive: true });
writeFileSync(targetPath, "daemon log\n");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: ".dev/paseo-home/daemon.log",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
maxEntriesScanned: 1,
});
expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]);
});
it("suffix mode finds files under allowlisted hidden workspace directories", async () => {
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
writeFileSync(path.join(workspaceDir, ".github", "workflows", "ci.yml"), "");
const claudeResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "settings.local.json",
limit: 20,
@@ -344,8 +396,34 @@ describe("searchWorkspaceEntries", () => {
includeDirectories: false,
matchMode: "suffix",
});
const githubResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "ci.yml",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
});
expect(results).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]);
expect(claudeResults).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]);
expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]);
});
it("does not broadly traverse unlisted hidden workspace directories", async () => {
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), "");
writeFileSync(path.join(workspaceDir, "src", "needle.ts"), "");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "needle.ts",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
});
expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]);
});
it("does not suggest hidden directories even when includeDirectories is true", async () => {

View File

@@ -92,6 +92,14 @@ const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
"__pycache__",
".git",
]);
const TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES = new Set([
".agents",
".claude",
".codex",
".github",
".paseo",
".vscode",
]);
export async function searchHomeDirectories(
options: SearchHomeDirectoriesOptions,
@@ -161,6 +169,19 @@ export async function searchWorkspaceEntries(
}
const matchMode = options.matchMode ?? "fuzzy";
const exactEntry =
queryParts.isPathQuery && matchMode === "suffix"
? await resolveWorkspaceExactEntry({
workspaceRoot,
query: options.query,
includeDirectories,
includeFiles,
})
: null;
if (exactEntry && limit <= 1) {
return [exactEntry];
}
if (queryParts.isPathQuery && matchMode !== "suffix") {
return searchWorkspaceWithinParentDirectory({
workspaceRoot,
@@ -176,7 +197,7 @@ export async function searchWorkspaceEntries(
matchMode === "suffix"
? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/")
: queryParts.searchTerm;
return searchWorkspaceAcrossTree({
const entries = await searchWorkspaceAcrossTree({
workspaceRoot,
searchTerm,
limit,
@@ -186,6 +207,66 @@ export async function searchWorkspaceEntries(
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
});
return exactEntry ? prependWorkspaceEntry(exactEntry, entries).slice(0, limit) : entries;
}
async function resolveWorkspaceExactEntry(input: {
workspaceRoot: string;
query: string;
includeDirectories: boolean;
includeFiles: boolean;
}): Promise<WorkspaceSuggestionEntry | null> {
const normalized = input.query
.trim()
.replace(/\\/g, "/")
.replace(/^\.\/+/, "")
.replace(/\/{2,}/g, "/");
if (!normalized) {
return null;
}
const candidatePath = path.isAbsolute(normalized)
? path.resolve(normalized)
: path.resolve(input.workspaceRoot, normalized);
let resolvedPath: string;
try {
resolvedPath = await realpath(candidatePath);
} catch {
return null;
}
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
return null;
}
const stats = await stat(resolvedPath).catch(() => null);
if (!stats) {
return null;
}
if (stats.isFile() && input.includeFiles) {
return {
path: normalizeRelativePath(input.workspaceRoot, resolvedPath),
kind: "file",
};
}
if (stats.isDirectory() && input.includeDirectories) {
return {
path: normalizeRelativePath(input.workspaceRoot, resolvedPath),
kind: "directory",
};
}
return null;
}
function prependWorkspaceEntry(
entry: WorkspaceSuggestionEntry,
entries: WorkspaceSuggestionEntry[],
): WorkspaceSuggestionEntry[] {
return [
entry,
...entries.filter(
(candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path,
),
];
}
function normalizeLimit(limit: number | undefined): number {
@@ -879,7 +960,14 @@ async function listWorkspaceChildEntries(input: {
if (isIgnoredSuggestionDirectoryName(dirent.name)) {
return false;
}
// Hidden directories must remain traversable so file links like
if (
isHiddenDirectoryName(dirent.name) &&
!dirent.isFile() &&
!isTraversableHiddenWorkspaceDirectoryName(dirent.name)
) {
return false;
}
// Allowlisted hidden directories remain traversable so file links like
// `.claude/settings.local.json` can resolve, but hidden files (e.g.
// `.DS_Store`) should never be suggested.
if (dirent.isFile() && isHiddenDirectoryName(dirent.name)) {
@@ -985,6 +1073,10 @@ function isIgnoredSuggestionDirectoryName(name: string): boolean {
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
}
function isTraversableHiddenWorkspaceDirectoryName(name: string): boolean {
return TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES.has(name);
}
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
directoryListCache.set(cacheKey, entry);
pruneDirectoryListCache();