From e54fc2f3910e2f39e842afcf0654d8cb90f0426c Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 04:36:33 +0300 Subject: [PATCH] fix(cli): require exact path roots Agent: vitruvius --- src/cli/find-path-cli.test.ts | 53 ++++++++++++++++++++++++++++++++++- src/cli/local.ts | 3 +- src/lib/local/find.ts | 28 +++++++++++++----- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/cli/find-path-cli.test.ts b/src/cli/find-path-cli.test.ts index d97ff81..a4b8927 100644 --- a/src/cli/find-path-cli.test.ts +++ b/src/cli/find-path-cli.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; interface CliResult { stdout: string; @@ -111,4 +111,55 @@ describe("search find --path", () => { rmSync(dir, { recursive: true, force: true }); } }, 60_000); + + test("rejects root-name and root-id collisions plus an unconfigured filesystem path", async () => { + const dir = mkdtempSync(join(tmpdir(), "search-find-path-collisions-")); + const indexedRoot = join(dir, "indexed"); + const unconfiguredRoot = join(dir, "unconfigured"); + const collidingName = `path-name-collision-${Date.now()}`; + mkdirSync(indexedRoot, { recursive: true }); + mkdirSync(unconfiguredRoot, { recursive: true }); + writeFileSync(join(indexedRoot, "executor-collision.txt"), "executor must stay scoped\n"); + const env = testEnv(dir); + + try { + const added = await runCli( + ["index", "add", indexedRoot, "--name", collidingName, "--json"], + env, + ); + expect(added.exitCode).toBe(0); + const rootId = JSON.parse(added.stdout).root.id as string; + + for (const ref of [collidingName, rootId]) { + const collision = await runCli( + ["find", "executor", "--path", ref, "--json", "--no-refresh"], + env, + ); + expect(collision.exitCode).not.toBe(0); + expect(collision.stdout).toBe(""); + expect(collision.stderr).toContain(`Index root not found: ${resolve(ref)}`); + expect(collision.stderr).not.toContain(indexedRoot); + + const genericRoot = await runCli( + ["find", "executor", "--root", ref, "--json", "--no-refresh"], + env, + ); + expect(genericRoot.exitCode).toBe(0); + expect(genericRoot.stderr).toBe(""); + expect(JSON.parse(genericRoot.stdout).results).toEqual([ + expect.objectContaining({ path: join(indexedRoot, "executor-collision.txt") }), + ]); + } + + const missing = await runCli( + ["find", "executor", "--path", unconfiguredRoot, "--json", "--no-refresh"], + env, + ); + expect(missing.exitCode).not.toBe(0); + expect(missing.stdout).toBe(""); + expect(missing.stderr).toContain(`Index root not found: ${unconfiguredRoot}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/src/cli/local.ts b/src/cli/local.ts index a4fc333..04058da 100644 --- a/src/cli/local.ts +++ b/src/cli/local.ts @@ -130,7 +130,8 @@ export function registerLocalCommands(program: Command): void { try { response = findLocal(query, { kind: opts.kind as FindKind, - root: opts.path ?? opts.root, + root: opts.root, + rootPath: opts.path, ext: opts.ext, dir: opts.dir, limit: parsePositiveInt(opts.limit, "--limit"), diff --git a/src/lib/local/find.ts b/src/lib/local/find.ts index cffd2fe..c0b68cf 100644 --- a/src/lib/local/find.ts +++ b/src/lib/local/find.ts @@ -4,6 +4,7 @@ import { getRoot, scheduleAutoRefreshStaleRoots, listRoots, + normalizeRootPath, rootHealth, type IndexRoot, type RootHealth, @@ -34,6 +35,8 @@ export interface FindMatch { export interface FindOptions extends LocalQueryOptions { kind?: FindKind; + /** Limit to one configured root by normalized filesystem path only. */ + rootPath?: string; /** true refreshes synchronously, false skips refresh scheduling, undefined schedules async refresh. */ refresh?: boolean; /** Treat the query as a regular expression (grep-style, line-based). */ @@ -78,11 +81,22 @@ export interface FindResponse { * when a caller supplied a path. Once roots exist, a scoped query must name a * real root and must report only that root in its population metadata. */ -function queryRoots(rootRef: string | undefined, db?: Database): IndexRoot[] { +function queryRoots( + rootRef: string | undefined, + rootPath: string | undefined, + db?: Database, +): IndexRoot[] { const roots = listRoots(db); - if (!rootRef || roots.length === 0) return roots; + if ((!rootRef && !rootPath) || roots.length === 0) return roots; - const root = getRoot(rootRef, db); + if (rootPath) { + const normalizedPath = normalizeRootPath(rootPath); + const root = roots.find((candidate) => candidate.path === normalizedPath); + if (!root) throw new Error(`Index root not found: ${normalizedPath}`); + return [root]; + } + + const root = getRoot(rootRef!, db); if (!root) throw new Error(`Index root not found: ${rootRef}`); return [root]; } @@ -139,7 +153,7 @@ export function findLocal(query: string, opts: FindOptions = {}, db?: Database): throw new Error(`Invalid kind "${kind}" — use file, content, or both.`); } const limit = clampLimit(opts.limit); - let roots = queryRoots(opts.root, db); + let roots = queryRoots(opts.root, opts.rootPath, db); if (!hasReadyQueryRoot(roots)) { // Kick recovery before giving up: a wedged root is exactly the case the @@ -150,7 +164,7 @@ export function findLocal(query: string, opts: FindOptions = {}, db?: Database): if (opts.refresh === true) autoRefreshStaleRoots(db); else if (opts.refresh !== false) scheduleAutoRefreshStaleRoots(db); - roots = queryRoots(opts.root, db); + roots = queryRoots(opts.root, opts.rootPath, db); if (hasReadyQueryRoot(roots)) return findLocal(query, { ...opts, refresh: false }, db); return { @@ -167,10 +181,10 @@ export function findLocal(query: string, opts: FindOptions = {}, db?: Database): if (opts.refresh === true) autoRefreshStaleRoots(db); else if (opts.refresh !== false) scheduleAutoRefreshStaleRoots(db); - roots = queryRoots(opts.root, db); + roots = queryRoots(opts.root, opts.rootPath, db); const queryOpts: LocalQueryOptions = { - root: opts.root, + root: opts.rootPath ? roots[0]?.id : opts.root, ext: opts.ext, dir: opts.dir, limit,