Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/blast-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ concurrency:
jobs:
publish:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
Expand Down Expand Up @@ -252,7 +252,7 @@ jobs:
# 1 MB files. Removed with the PR, not on a timer.
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/graph/workspace-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 19 additions & 13 deletions src/graph/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<parent>/graft/workspace.json`. Nodes/edges
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
109 changes: 109 additions & 0 deletions test/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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). */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
});

Expand Down
Loading