diff --git a/src/cli.ts b/src/cli.ts index 8b7051e67..0c59efd0c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1057,7 +1057,7 @@ grouped(program.command("grep"), GROUP.find) const globalOpts = program.opts<{ dir?: string }>(); if (readWorkspace(dir, globalOpts.dir)) { runWorkspaceGrep(dir, globalOpts.dir, pattern, { - ignoreCase: opts.ignoreCase, fixed: opts.fixed, json: opts.json, + ignoreCase: opts.ignoreCase, fixed: opts.fixed, in: opts.in, json: opts.json, }); return; } diff --git a/src/graph/workspace-cli.ts b/src/graph/workspace-cli.ts index 09755639f..04d54f6fc 100644 --- a/src/graph/workspace-cli.ts +++ b/src/graph/workspace-cli.ts @@ -114,9 +114,9 @@ export function runWorkspaceGrep( root: string, override: string | undefined, pattern: string, - opts: { ignoreCase?: boolean; fixed?: boolean; json?: boolean }, + opts: { ignoreCase?: boolean; fixed?: boolean; in?: string; json?: boolean }, ): void { - const { result, coverage } = federateGrep(root, override, pattern, { ignoreCase: opts.ignoreCase, fixed: opts.fixed }); + const { result, coverage } = federateGrep(root, override, pattern, { ignoreCase: opts.ignoreCase, fixed: opts.fixed, in: opts.in }); if (opts.json) { console.log(JSON.stringify(result, null, 2)); return; diff --git a/src/graph/workspace.ts b/src/graph/workspace.ts index 13c8a0437..661984918 100644 --- a/src/graph/workspace.ts +++ b/src/graph/workspace.ts @@ -51,6 +51,7 @@ import { fuseScopes, STRONG_FLOOR, HIGH_FLOOR, type ScopedDoc } from "../ask/fus import { grepGraph, type GrepGroup, type GrepResult } from "../search/grep.js"; import { formatGrepResult, zeroHitNote } from "../search/grep-cli.js"; import { withSavings, type Savings } from "../context/savings.js"; +import { normalizePathPrefix } from "../util/paths.js"; import { cmd } from "../brand.js"; /** The parent index written to `/graft/workspace.json`. Nodes/edges @@ -165,6 +166,19 @@ export function coverageNote(g: WorkspaceGraphs): string { return `${g.loaded.length} of ${total} workspace repos have graphs; run ${cmd("graft build")} to cover ${g.missing.join(", ")}`; } +function resolveWorkspaceIn( + wg: WorkspaceGraphs, + scope?: string, +): { onlyChild?: string; childIn?: string } { + if (!scope) return {}; + const [onlyChild, ...rest] = normalizePathPrefix(scope).split("/"); + const allChildren = [...wg.loaded.map((l) => l.child), ...wg.missing].sort(); + if (!allChildren.includes(onlyChild)) { + throw new Error(`no workspace repo "${onlyChild}" - repos: ${allChildren.join(", ")}`); + } + return { onlyChild, childIn: rest.length ? rest.join("/") : undefined }; +} + /** Prefix a hit pointer with its child dir so a `path:span` (or concept path * list) opens correctly from the parent. Leaves free-text fragments alone. */ function prefixPointer(child: string, pointer: string): string { @@ -250,18 +264,7 @@ export function federateAsk( // `--in` scopes to a single child (and, past the first segment, a sub-scope // within it). A prefix naming no known child at all is a caller mistake. - let onlyChild: string | undefined; - let childIn: string | undefined; - if (opts.in) { - const prefix = opts.in.replace(/\/+$/, ""); - const [name, ...rest] = prefix.split("/"); - const allChildren = [...wg.loaded.map((l) => l.child), ...wg.missing].sort(); - if (!allChildren.includes(name)) { - throw new Error(`no workspace repo "${name}" - repos: ${allChildren.join(", ")}`); - } - onlyChild = name; - childIn = rest.length ? rest.join("/") : undefined; - } + const { onlyChild, childIn } = resolveWorkspaceIn(wg, opts.in); // Pass 1: run each child's ask; keep its hits + RAW top-hit coverage. const runs: ChildRun[] = []; @@ -565,9 +568,10 @@ export function federateGrep( root: string, override: string | undefined, pattern: string, - opts: { ignoreCase?: boolean; fixed?: boolean } = {}, + opts: { ignoreCase?: boolean; fixed?: boolean; in?: string } = {}, ): { result: GrepResult; coverage: string } { const wg = loadWorkspaceGraphs(root, override); + const { onlyChild, childIn } = resolveWorkspaceIn(wg, opts.in); const groups: GrepGroup[] = []; let filesSearched = 0; let totalHits = 0; @@ -576,9 +580,11 @@ export function federateGrep( let savedChars = 0; for (const { child, graph } of wg.loaded) { + if (onlyChild && child !== onlyChild) continue; const r = grepGraph(graph, join(root, child), pattern, { ignoreCase: opts.ignoreCase, fixed: opts.fixed, + in: childIn, }); filesSearched += r.filesSearched; totalHits += r.totalHits; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9543428fb..5feb29775 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -182,6 +182,7 @@ async function callWorkspaceTool( const { result, coverage } = federateGrep(root, dirOverride, pattern, { ignoreCase: typeof args.ignore_case === 'boolean' ? args.ignore_case : undefined, fixed: typeof args.fixed === 'boolean' ? args.fixed : undefined, + in: typeof args.in === 'string' && args.in ? args.in : undefined, }); const text = result.totalHits === 0 ? zeroHitNote(result) : formatGrepResult(result); return { text: coverage ? `${text}\n${coverage}` : text, isError: false }; diff --git a/test/workspace.test.ts b/test/workspace.test.ts index 89430bb57..9e4e0e1ac 100644 --- a/test/workspace.test.ts +++ b/test/workspace.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -20,6 +21,8 @@ import { isWorkspaceBuildRoot, } from "../src/graph/workspace.js"; import { formatAsk } from "../src/ask/ask.js"; +import { grepGraph } from "../src/search/grep.js"; +import { callTool } from "../src/mcp/tools.js"; import type { GraphV1 } from "../src/graph/types.js"; /** A parent dir with git children (each a `.git` dir + one source file). */ @@ -48,6 +51,16 @@ const REPOS = { repoB: { "b.ts": "export function betaHandler() { return 2; }\n" }, }; +const GREP_REPOS = { + repoA: { + "src/a.ts": REPOS.repoA["a.ts"], + "src-extra/extra.ts": "export function outsideHandler() { return 3; }\n", + }, + repoAB: { "a.ts": "export function siblingHandler() { return 4; }\n" }, + repoB: REPOS.repoB, + repoEmpty: {}, +}; + /** N unrelated functions, to pad a child's corpus to a realistic size so * coverage/idf reflect scale (junk coverage rises with corpus size). */ function pad(n: number): string { @@ -367,6 +380,98 @@ test("grep federation merges groups across children with child-prefixed paths", rmSync(p, { recursive: true, force: true }); }); +test("workspace grep scopes to an exact child and its indexed paths", async () => { + const p = workspaceFx(GREP_REPOS); + try { + await buildWorkspace(p); + const graph = loadWorkspaceGraphs(p).loaded.find((r) => r.child === "repoA")!.graph; + for (const [scope, childIn] of [ + ["repoA", undefined], + ["repoA/", undefined], + ["repoA/src", "src"], + ["./repoA/src/", "src"], + [join("repoA", "src"), "src"], + ]) { + const { result } = federateGrep(p, undefined, "Handler", { in: scope }); + const standalone = grepGraph(graph, join(p, "repoA"), "Handler", { in: childIn }); + assert.equal(result.filesSearched, standalone.filesSearched); + assert.equal(result.totalHits, standalone.totalHits); + assert.deepEqual(result.truncated, standalone.truncated); + assert.deepEqual(result.saved, standalone.saved); + assert.deepEqual(result.groups.map((g) => g.path).sort(), childIn + ? ["repoA/src/a.ts"] + : ["repoA/src-extra/extra.ts", "repoA/src/a.ts"]); + assert.ok(result.groups.every((g) => g.symbol?.path === g.path)); + } + assert.throws(() => federateGrep(p, undefined, "Handler", { in: "repo" }), + /no workspace repo "repo".*repoA.*repoAB.*repoB.*repoEmpty/s); + assert.throws(() => federateGrep(p, undefined, "Handler", { in: "repoA/nowhere" }), + /nothing indexed under/); + const empty = federateGrep(p, undefined, "Handler", { in: "repoEmpty" }); + assert.equal(empty.result.totalHits, 0); + assert.equal(empty.result.filesSearched, 0); + assert.equal(empty.coverage, ""); + const ask = federateAsk(p, undefined, "handler", { in: "./repoA/src/" }); + assert.ok(ask.hits.length > 0); + assert.ok(ask.hits.every((hit) => hit.pointer.startsWith("repoA/src/"))); + } finally { + rmSync(p, { recursive: true, force: true }); + } +}); + +test("workspace grep CLI forwards scopes and rejects invalid paths", async () => { + const p = workspaceFx(GREP_REPOS); + try { + await buildWorkspace(p); + const args = ["--import", "tsx", "src/cli.ts", "grep", "Handler", p]; + const stdout = execFileSync(process.execPath, + [...args, "--in", "repoA/src", "--json", "--no-refresh"], { encoding: "utf8" }); + const result = JSON.parse(stdout); + assert.equal(result.filesSearched, 1); + assert.equal(result.totalHits, 1); + assert.deepEqual(result.groups.map((g: { path: string }) => g.path), ["repoA/src/a.ts"]); + for (const [scope, error] of [ + ["repo", /no workspace repo "repo".*repoA.*repoB/s], + ["repoA/nowhere", /nothing indexed under/], + ] as const) { + const invalid = spawnSync(process.execPath, + [...args, "--in", scope, "--json", "--no-refresh"], { encoding: "utf8" }); + assert.equal(invalid.status, 1); + assert.match(invalid.stderr, error); + } + const noHits = spawnSync(process.execPath, + ["--import", "tsx", "src/cli.ts", "grep", "absent-needle", p, + "--in", "repoA/src", "--json", "--no-refresh"], { encoding: "utf8" }); + assert.equal(noHits.status, 0); + assert.equal(JSON.parse(noHits.stdout).totalHits, 0); + } finally { + rmSync(p, { recursive: true, force: true }); + } +}); + +test("workspace graft_find_all forwards scopes and returns scope errors", async () => { + const p = workspaceFx(GREP_REPOS); + try { + await buildWorkspace(p); + const result = await callTool(p, "graft_find_all", { pattern: "Handler", in: "repoA/src" }); + assert.equal(result.isError, false); + assert.match(result.text, /repoA\/src\/a\.ts/); + assert.doesNotMatch(result.text, /repoB\/|repoAB\/|src-extra/); + for (const [scope, error] of [ + ["repo", /no workspace repo "repo".*repoA.*repoB/s], + ["repoA/nowhere", /nothing indexed under/], + ] as const) { + const invalid = await callTool(p, "graft_find_all", { pattern: "Handler", in: scope }); + assert.equal(invalid.isError, true); + assert.match(invalid.text, error); + } + const noHits = await callTool(p, "graft_find_all", { pattern: "absent-needle", in: "repoA/src" }); + assert.equal(noHits.isError, false); + } finally { + rmSync(p, { recursive: true, force: true }); + } +}); + test("one unbuilt child is surfaced, not silently skipped", async () => { const p = workspaceFx({ ...REPOS, @@ -383,6 +488,10 @@ test("one unbuilt child is surfaced, not silently skipped", async () => { coverageNote(wg), "2 of 3 workspace repos have graphs; run graft build to cover repoC", ); + const scoped = federateGrep(p, undefined, "Handler", { in: "repoC" }); + assert.equal(scoped.result.filesSearched, 0); + assert.equal(scoped.result.totalHits, 0); + assert.equal(scoped.coverage, coverageNote(wg)); rmSync(p, { recursive: true, force: true }); });