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:
@@ -332,11 +332,63 @@ describe("searchWorkspaceEntries", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("suffix mode finds files under hidden workspace directories", async () => {
|
it("suffix mode resolves exact workspace file paths before broad traversal", async () => {
|
||||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
const targetPath = path.join(
|
||||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
workspaceDir,
|
||||||
|
"packages",
|
||||||
|
"server",
|
||||||
|
"src",
|
||||||
|
"services",
|
||||||
|
"quota-fetcher",
|
||||||
|
"providers",
|
||||||
|
"local.ts",
|
||||||
|
);
|
||||||
|
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
writeFileSync(targetPath, "");
|
||||||
|
|
||||||
const results = await searchWorkspaceEntries({
|
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,
|
cwd: workspaceDir,
|
||||||
query: "settings.local.json",
|
query: "settings.local.json",
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -344,8 +396,34 @@ describe("searchWorkspaceEntries", () => {
|
|||||||
includeDirectories: false,
|
includeDirectories: false,
|
||||||
matchMode: "suffix",
|
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 () => {
|
it("does not suggest hidden directories even when includeDirectories is true", async () => {
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
|
|||||||
"__pycache__",
|
"__pycache__",
|
||||||
".git",
|
".git",
|
||||||
]);
|
]);
|
||||||
|
const TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES = new Set([
|
||||||
|
".agents",
|
||||||
|
".claude",
|
||||||
|
".codex",
|
||||||
|
".github",
|
||||||
|
".paseo",
|
||||||
|
".vscode",
|
||||||
|
]);
|
||||||
|
|
||||||
export async function searchHomeDirectories(
|
export async function searchHomeDirectories(
|
||||||
options: SearchHomeDirectoriesOptions,
|
options: SearchHomeDirectoriesOptions,
|
||||||
@@ -161,6 +169,19 @@ export async function searchWorkspaceEntries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const matchMode = options.matchMode ?? "fuzzy";
|
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") {
|
if (queryParts.isPathQuery && matchMode !== "suffix") {
|
||||||
return searchWorkspaceWithinParentDirectory({
|
return searchWorkspaceWithinParentDirectory({
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
@@ -176,7 +197,7 @@ export async function searchWorkspaceEntries(
|
|||||||
matchMode === "suffix"
|
matchMode === "suffix"
|
||||||
? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/")
|
? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/")
|
||||||
: queryParts.searchTerm;
|
: queryParts.searchTerm;
|
||||||
return searchWorkspaceAcrossTree({
|
const entries = await searchWorkspaceAcrossTree({
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
limit,
|
limit,
|
||||||
@@ -186,6 +207,66 @@ export async function searchWorkspaceEntries(
|
|||||||
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
||||||
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
|
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 {
|
function normalizeLimit(limit: number | undefined): number {
|
||||||
@@ -879,7 +960,14 @@ async function listWorkspaceChildEntries(input: {
|
|||||||
if (isIgnoredSuggestionDirectoryName(dirent.name)) {
|
if (isIgnoredSuggestionDirectoryName(dirent.name)) {
|
||||||
return false;
|
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.
|
// `.claude/settings.local.json` can resolve, but hidden files (e.g.
|
||||||
// `.DS_Store`) should never be suggested.
|
// `.DS_Store`) should never be suggested.
|
||||||
if (dirent.isFile() && isHiddenDirectoryName(dirent.name)) {
|
if (dirent.isFile() && isHiddenDirectoryName(dirent.name)) {
|
||||||
@@ -985,6 +1073,10 @@ function isIgnoredSuggestionDirectoryName(name: string): boolean {
|
|||||||
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
|
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 {
|
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
||||||
directoryListCache.set(cacheKey, entry);
|
directoryListCache.set(cacheKey, entry);
|
||||||
pruneDirectoryListCache();
|
pruneDirectoryListCache();
|
||||||
|
|||||||
Reference in New Issue
Block a user