diff --git a/src/claude/format.ts b/src/claude/format.ts index e45bf9c52..b1c5d8466 100644 --- a/src/claude/format.ts +++ b/src/claude/format.ts @@ -25,12 +25,16 @@ export function freshnessSegment(s: Stats): string { export function renderStatusline( stats: Stats | null, session: SessionState | null, - ctx: { ctxPct: number | null }, + ctx: { ctxPct: number | null; workspace?: { built: number; total: number } | null }, ): string[] { if (!stats) { return [C.muted('◤ graft · not built · run ') + C.text('graft build')]; } const top = [C.muted('◤ ') + C.indigo('graft'), C.text(`${stats.nodeCount} nodes / ${stats.edgeCount} edges`)]; + // At a workspace parent the counts cover only the children built so far. When + // that is not all of them, say so; otherwise nothing hints that one is missing. + const ws = ctx.workspace; + if (ws && ws.built < ws.total) top.splice(1, 0, C.amber(`workspace ${ws.built}/${ws.total} repos built`)); top.push(freshnessSegment(stats)); const saved = session?.savedTokens ?? 0; if (saved > 0) { diff --git a/src/claude/stats.ts b/src/claude/stats.ts index 92f9d7d86..36d389706 100644 --- a/src/claude/stats.ts +++ b/src/claude/stats.ts @@ -1,8 +1,8 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { GraphV1 } from '../graph/types.js'; import type { Stats } from './state.js'; -import { resolveContextDir } from '../util/state.js'; +import { readJson, resolveContextDir } from '../util/state.js'; export function readWiring(projectDir: string): GraphV1 | null { try { @@ -10,9 +10,10 @@ export function readWiring(projectDir: string): GraphV1 | null { } catch { return null; } } -export function computeStats( - w: GraphV1, -): Pick { +/** The part of {@link Stats} that comes from a graph rather than from the hooks. */ +export type GraphStats = Pick; + +export function computeStats(w: GraphV1): GraphStats { const nodes = w.nodes ?? []; const edges = w.edges ?? []; const readyCount = nodes.filter((n) => n.summary_state === 'ready').length; @@ -24,3 +25,67 @@ export function computeStats( readyCount, }; } + +/** + * A workspace parent holds no graph of its own: its `graft/` carries only + * `workspace.json`, and the nodes live in each child's `/graft/`. Read + * at the parent, `readWiring` is null — so the statusline said "not built" and + * the Stop-hook sync left `dirty` set forever, while every child had built fine + * and every federated query worked (#433). The parent's numbers are the + * children's, summed: the same graphs `ask`/`grep`/`callers` federate over. + * + * Deliberately not `loadWorkspaceGraphs`: the statusline is a pure read on the + * render path, and that module pulls the whole `ask` engine in with it. The + * child path mirrors `contextDirFor(join(root, child))` with no override — + * children never inherit the parent's `--dir`, exactly as in the federated + * commands. Null when `projectDir` is not a workspace parent, or when no listed + * child has a graph yet, which is the one case that really is "not built". + */ +export function computeWorkspaceStats(projectDir: string): GraphStats | null { + const children = workspaceChildren(projectDir); + if (!children) return null; + const parts: GraphStats[] = []; + for (const child of children) { + try { + const raw = readFileSync(childWiringPath(projectDir, child), 'utf8'); + parts.push(computeStats(JSON.parse(raw) as GraphV1)); + } catch { /* listed but not built yet — counted by workspaceCoverage, not summed */ } + } + if (parts.length === 0) return null; + const sum = (k: 'nodeCount' | 'edgeCount' | 'totalCount' | 'readyCount') => parts.reduce((n, p) => n + p[k], 0); + return { + nodeCount: sum('nodeCount'), + edgeCount: sum('edgeCount'), + languages: [...new Set(parts.flatMap((p) => p.languages))].sort(), + totalCount: sum('totalCount'), + readyCount: sum('readyCount'), + }; +} + +/** + * How many of a workspace parent's listed children have a graph. The summed + * counts above cover only the built ones, so without this the bar gives no hint + * that a child is missing. Kept apart from the sum because the statusline stops + * calling that once the Stop-hook sync has written real counts into the parent's + * cache, and a child can still be unbuilt then. A child counts as built when its + * `wiring.json` exists; only a stat per child, since this runs on every render. + * Null when `projectDir` is not a workspace parent. + */ +export function workspaceCoverage(projectDir: string): { built: number; total: number } | null { + const children = workspaceChildren(projectDir); + if (!children) return null; + const built = children.filter((child) => existsSync(childWiringPath(projectDir, child))).length; + return { built, total: children.length }; +} + +/** The children listed in the parent's `workspace.json`, or null when there are none. */ +function workspaceChildren(projectDir: string): string[] | null { + const ws = readJson<{ version: number; children: string[] }>(join(resolveContextDir(projectDir), 'workspace.json')); + if (!ws || !Array.isArray(ws.children) || ws.children.length === 0) return null; + return ws.children; +} + +/** A child's graph, at `/graft/` with no override (see computeWorkspaceStats). */ +function childWiringPath(projectDir: string, child: string): string { + return join(projectDir, child, 'graft', '.graph', 'wiring.json'); +} diff --git a/src/claude/statusline.ts b/src/claude/statusline.ts index e4c78a4c4..61443a4d6 100644 --- a/src/claude/statusline.ts +++ b/src/claude/statusline.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { renderStatusline, renderSubagent } from './format.js'; import { readStats, readSession, emptyStats, type Stats } from './state.js'; -import { readWiring, computeStats } from './stats.js'; +import { readWiring, computeStats, computeWorkspaceStats, workspaceCoverage } from './stats.js'; /** * The statusline's fast path is the hook-maintained cache (graft/.cache/stats.json). @@ -12,12 +12,18 @@ import { readWiring, computeStats } from './stats.js'; * is missing. The graph carries no drift signal, so it reads as synced until the next * edit repopulates the cache. Still a pure read (no subprocess): the cache is preferred * because it carries live dirty/stale state. + * + * A workspace parent has neither a graph nor, until the first sync, a non-empty cache: + * the hooks write a zeroed `dirty` cache there and the graphs sit in the children. The + * counts then come from the children (#433) and the cache still supplies the live flags. */ export function resolveStats(dir: string): Stats | null { const cached = readStats(dir); if (cached && cached.nodeCount > 0) return cached; const wiring = readWiring(dir); if (wiring) return { ...emptyStats(), ...computeStats(wiring) }; + const workspace = computeWorkspaceStats(dir); + if (workspace) return { ...(cached ?? emptyStats()), ...workspace }; return null; } @@ -29,7 +35,9 @@ export function main(): void { const agent = input?.agent?.name; if (agent) { process.stdout.write(renderSubagent(agent, session)); return; } const stats = resolveStats(dir); + // Read on every render, not from the cache: a child can be unbuilt after the sync. + const workspace = stats ? workspaceCoverage(dir) : null; const raw = input?.context_window?.used_percentage; const ctxPct = typeof raw === 'number' ? Math.round(raw) : null; - process.stdout.write(renderStatusline(stats, session, { ctxPct }).join('\n')); + process.stdout.write(renderStatusline(stats, session, { ctxPct, workspace }).join('\n')); } diff --git a/src/claude/sync-run.ts b/src/claude/sync-run.ts index e07acb8eb..966988158 100644 --- a/src/claude/sync-run.ts +++ b/src/claude/sync-run.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; -import { readWiring, computeStats } from './stats.js'; +import { readWiring, computeStats, computeWorkspaceStats } from './stats.js'; import { patchStats, releaseLock, resolveContextDir } from './state.js'; import { graftCliPath } from './paths.js'; @@ -19,11 +19,13 @@ function realBuild(dir: string): void { export function runSync(dir: string, build: (d: string) => void = realBuild): void { try { build(dir); + // A workspace parent's build writes the graphs into the children, not here (#433). const w = readWiring(dir); - if (!w) { patchStats(dir, { syncing: false }); return; } // build ran but output unreadable — stay dirty, retry + const computed = w ? computeStats(w) : computeWorkspaceStats(dir); + if (!computed) { patchStats(dir, { syncing: false }); return; } // build ran but output unreadable — stay dirty, retry patchStats(dir, { dirty: false, staleCount: 0, syncing: false, syncedAt: new Date().toISOString(), - ...computeStats(w), + ...computed, }); } catch { patchStats(dir, { syncing: false }); // leave dirty=true; retry next turn diff --git a/src/cli.ts b/src/cli.ts index 4ca77ff18..8f3a232c5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,6 +38,7 @@ import { runWorkspaceCheck, runWorkspaceGrep, runWorkspaceMap, + runWorkspaceSkeleton, } from "./graph/workspace-cli.js"; import { formatInitEpilogue } from "./cli-epilogue.js"; import { planInit, selectedWrites } from "./hosts/plan.js"; @@ -639,8 +640,12 @@ program .action(async (file: string, dirArg: string | undefined, opts: { json?: boolean; refresh?: boolean }) => { const dir = noteQuery(queryRoot(dirArg)); await refreshBefore(dir, opts); - const { skeleton, formatSkeleton } = await import("./ask/ask.js"); const globalOpts = program.opts<{ dir?: string }>(); + if (readWorkspace(dir, globalOpts.dir)) { + runWorkspaceSkeleton(dir, globalOpts.dir, file, { json: opts.json }); + return; + } + const { skeleton, formatSkeleton } = await import("./ask/ask.js"); const r = skeleton(dir, file, { contextDir: globalOpts.dir }); if (opts.json) console.log(JSON.stringify(r, null, 2)); else process.stdout.write(formatSkeleton(r)); diff --git a/src/graph/workspace-cli.ts b/src/graph/workspace-cli.ts index f6dc57e87..fff7be3ff 100644 --- a/src/graph/workspace-cli.ts +++ b/src/graph/workspace-cli.ts @@ -9,7 +9,7 @@ import { Graft } from "../engine.js"; import { contextDirFor, ensureGitignored } from "../context/node-file.js"; import { patchBuildConfig, type BuildConfig } from "../util/state.js"; import type { EngineConfig } from "../ai/providers.js"; -import { formatAsk } from "../ask/ask.js"; +import { formatAsk, formatSkeleton } from "../ask/ask.js"; import type { Direction } from "./traverse.js"; import { federateAsk, @@ -17,6 +17,7 @@ import { federateCheck, federateGrep, federateMap, + federateSkeleton, formatGrepResult, migrationNote, splitWorkspace, @@ -123,6 +124,17 @@ export function runWorkspaceMap( process.stdout.write(federateMap(root, override, { maxDirs: opts.maxDirs })); } +export function runWorkspaceSkeleton( + root: string, + override: string | undefined, + file: string, + opts: { json?: boolean }, +): void { + const r = federateSkeleton(root, override, file); + if (opts.json) console.log(JSON.stringify(r, null, 2)); + else process.stdout.write(formatSkeleton(r)); +} + export async function runWorkspaceCheck(root: string, override?: string): Promise { const { text, ok } = await federateCheck(root, override); process.stdout.write(text); diff --git a/src/graph/workspace.ts b/src/graph/workspace.ts index d371586fb..d5611f349 100644 --- a/src/graph/workspace.ts +++ b/src/graph/workspace.ts @@ -14,7 +14,7 @@ * are combined by score (see `ask/fuse.ts`). Separate child repositories have * no such shared scale, which is exactly the case rank fusion is for. * `grep`/`map`/`check`/`callers` run per child and merge, always labeled - * `/`. + * `/`; `skeleton` routes one file to the child that owns it. * * This module owns the pure/core pieces (the format, its readers/writers, graph * loading, and the federated command bodies that return renderable data). The @@ -39,9 +39,11 @@ import { wiringPath } from "./write.js"; import type { GraphV1 } from "./types.js"; import { ask, + skeleton, type AskHit, type AskRankingMetadata, type AskResult, + type SkeletonResult, } from "../ask/ask.js"; import { fileFirstRoundRobin, @@ -694,6 +696,49 @@ export function federateCallers( return { text, found: true }; } +/** The exact prefix `skeleton` puts on its own ambiguity note. */ +const AMBIGUOUS = "ambiguous — matches: "; + +/** + * `graft skeleton` at the parent (#433). `/path` addresses one child + * directly — the shape every federated pointer is printed in, so a path copied + * out of `ask` or `callers` output works as-is. A bare path or basename is + * looked up in every child and must land in exactly one. The child's own + * `skeleton` does the work, so the answer reads exactly as it would from inside + * the child; only the file gets its `/` prefix. + */ +export function federateSkeleton(root: string, override: string | undefined, file: string): SkeletonResult { + const wg = loadWorkspaceGraphs(root, override); + const prefixed = (child: string, r: SkeletonResult): SkeletonResult => ({ + ...r, + file: `${child}/${r.file}`, + // A within-child "ambiguous" note lists child-relative paths; keep them openable from here. + ...(r.note?.startsWith(AMBIGUOUS) + ? { note: AMBIGUOUS + r.note.slice(AMBIGUOUS.length).split(", ").map((p) => `${child}/${p}`).join(", ") } + : {}), + }); + + const slash = file.indexOf("/"); + const head = slash > 0 ? file.slice(0, slash) : ""; + if (head && wg.missing.includes(head)) { + return { file, entries: [], note: `${head}/ has no graph yet — run graft build` }; + } + const direct = head ? wg.loaded.find((c) => c.child === head) : undefined; + if (direct) return prefixed(direct.child, skeleton(join(root, direct.child), file.slice(slash + 1))); + + const hits = wg.loaded + .map(({ child }) => ({ child, r: skeleton(join(root, child), file) })) + .filter(({ r }) => r.entries.length > 0 || r.note?.startsWith(AMBIGUOUS)); + if (hits.length === 1) return prefixed(hits[0].child, hits[0].r); + if (hits.length > 1) { + const paths = hits.map(({ child, r }) => `${child}/${r.file}`).sort(); + return { file, entries: [], note: `${AMBIGUOUS}${paths.join(", ")}` }; + } + const cov = coverageNote(wg); + const base = `no definitions indexed for this file in any of the ${wg.loaded.length} workspace repo(s)`; + return { file, entries: [], note: cov ? `${base}\n${cov}` : base }; +} + /** * Split a parent into a workspace: build each git child (via the supplied * `buildChild` callback, so this stays free of any engine/LLM dependency), diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 1167ae307..3ee4380e9 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -22,6 +22,7 @@ import { federateCheck, federateGrep, federateMap, + federateSkeleton, readWorkspace, } from '../graph/workspace.js'; import type { NodeV1 } from '../graph/types.js'; @@ -147,9 +148,9 @@ function renderMatches( } /** When the MCP server is rooted at a workspace parent, the ask/callers/grep/ - * map/check tools federate across the children — identical to the CLI. Returns - * null for tools that don't federate (skeleton is per-file), so the caller - * falls through to the normal single-graph path. */ + * map/check/file_api tools federate across the children — identical to the + * CLI. Returns null for a tool that doesn't, so the caller falls through to the + * normal single-graph path. */ async function callWorkspaceTool( root: string, dirOverride: string | undefined, @@ -193,6 +194,12 @@ async function callWorkspaceTool( const { text } = await federateCheck(root, dirOverride); return { text, isError: false }; } + case 'graft_file_api': { + const file = String(args.file ?? ''); + if (!file) return { text: 'graft_file_api requires a file', isError: true }; + const r = federateSkeleton(root, dirOverride, file); + return { text: formatSkeleton(r), isError: !r.entries.length && !!r.note }; + } default: return null; } diff --git a/test/claude-format.test.ts b/test/claude-format.test.ts index 1d52ea371..a31407643 100644 --- a/test/claude-format.test.ts +++ b/test/claude-format.test.ts @@ -228,3 +228,13 @@ test('renderStatusline shows tokens alone until a turn has been billed', () => { assert.match(line, /~100,000 tok saved/); assert.doesNotMatch(line, /\$/); }); + +test('renderStatusline names a workspace parent with an unbuilt child, and only then', () => { + const stats = { ...emptyStats(), nodeCount: 3, edgeCount: 1, totalCount: 3 }; + const partial = strip(renderStatusline(stats, null, { ctxPct: null, workspace: { built: 1, total: 2 } })[0]); + assert.match(partial, /graft · workspace 1\/2 repos built · 3 nodes \/ 1 edges · /); + const all = strip(renderStatusline(stats, null, { ctxPct: null, workspace: { built: 2, total: 2 } })[0]); + assert.doesNotMatch(all, /workspace/, 'every child built: the counts are the whole workspace'); + const plain = strip(renderStatusline(stats, null, { ctxPct: null })[0]); + assert.doesNotMatch(plain, /workspace/, 'a plain repo gets no workspace segment'); +}); diff --git a/test/claude-hooks.test.ts b/test/claude-hooks.test.ts index 91d240747..89283d0b8 100644 --- a/test/claude-hooks.test.ts +++ b/test/claude-hooks.test.ts @@ -98,6 +98,31 @@ test('runSync clears dirty/syncing, recomputes stats, releases lock', () => { assert.equal(acquireLock(d), true, 'lock released, so reacquire succeeds'); }); +test('runSync at a workspace parent takes its counts from the children (#433)', () => { + // The parent's build writes the graphs into /graft/, never here — so + // readWiring(parent) is null and, before this, the sync left dirty set forever + // and the bar stuck on "not built" although the build had succeeded. + const p = mkdtempSync(join(tmpdir(), 'graft-sync-ws-')); + mkdirSync(join(p, 'graft'), { recursive: true }); + writeFileSync(join(p, 'graft', 'workspace.json'), JSON.stringify({ version: 1, children: ['api', 'web'] })); + for (const [child, n] of [['api', 3], ['web', 4]] as const) { + mkdirSync(join(p, child, 'graft', '.graph'), { recursive: true }); + writeFileSync(join(p, child, 'graft', '.graph', 'wiring.json'), + JSON.stringify({ meta: { nodeCount: n, edgeCount: n - 1, languages: ['typescript'] }, nodes: [], edges: [] })); + } + writeStats(p, { ...emptyStats(), dirty: true, syncing: true, staleCount: 1 }); + acquireLock(p); + runSync(p, () => { /* the children are already built above */ }); + const s = readStats(p)!; + assert.equal(s.dirty, false, 'the sync completed, so the parent is no longer dirty'); + assert.equal(s.syncing, false); + assert.equal(s.staleCount, 0); + assert.equal(s.nodeCount, 7, 'summed over the children'); + assert.equal(s.edgeCount, 5); + assert.ok(s.syncedAt); + assert.equal(acquireLock(p), true, 'lock released'); +}); + test("runSync's default build passes --dir to graft build when GRAFT_DIR is set", () => { const d = mkdtempSync(join(tmpdir(), 'graft-sync-dir-')); mkdirSync(join(d, 'elsewhere', '.graph'), { recursive: true }); diff --git a/test/claude-statusline.test.ts b/test/claude-statusline.test.ts index 98e184730..523ebb394 100644 --- a/test/claude-statusline.test.ts +++ b/test/claude-statusline.test.ts @@ -1,13 +1,20 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { resolveStats } from '../src/claude/statusline.js'; +import { workspaceCoverage } from '../src/claude/stats.js'; import { renderStatusline } from '../src/claude/format.js'; import { writeStats, emptyStats } from '../src/claude/state.js'; const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); +// Absolute, so the spawned statusline resolves tsx whatever its cwd. +const TSX = pathToFileURL(createRequire(join(process.cwd(), 'x.js')).resolve('tsx')).href; +const STATUSLINE = pathToFileURL(resolve(process.cwd(), 'src/claude/statusline.ts')).href; function repo(): string { return mkdtempSync(join(tmpdir(), 'graft-sl-')); } function writeWiring(dir: string, obj: unknown): void { @@ -69,3 +76,87 @@ test('a 0-node cache without wiring.json is still not built', () => { assert.match(line, /not built/); assert.match(line, /graft build/); }); + +/** + * A workspace parent (#433): its graft/ holds only workspace.json, the graphs + * live in each child's graft/. Before this, the bar read "not built" at the + * parent although every child had built and every federated query worked. + */ +function workspace(children: Record): string { + const p = repo(); + mkdirSync(join(p, 'graft'), { recursive: true }); + writeFileSync(join(p, 'graft', 'workspace.json'), JSON.stringify({ version: 1, children: Object.keys(children) })); + for (const [child, w] of Object.entries(children)) { + if (!w) continue; // listed, not built yet + writeWiring(join(p, child), { + meta: { nodeCount: w.nodes, edgeCount: w.edges, languages: w.langs }, + nodes: [{ id: 'a', summary_state: 'ready' }, { id: 'b', summary_state: 'pending' }], + edges: [], + }); + } + return p; +} + +test('a workspace parent sums its children instead of reading as not built (#433)', () => { + const p = workspace({ api: { nodes: 10, edges: 5, langs: ['typescript'] }, web: { nodes: 20, edges: 7, langs: ['python', 'typescript'] } }); + const s = resolveStats(p)!; + assert.equal(s.nodeCount, 30); + assert.equal(s.edgeCount, 12); + assert.deepEqual(s.languages, ['python', 'typescript'], 'union of the children, sorted'); + assert.equal(s.readyCount, 2, 'summed across children'); + assert.equal(s.dirty, false, 'no cache, no drift signal — same as the single-repo wiring fallback'); + const line = strip(renderStatusline(s, null, { ctxPct: null })[0]); + assert.match(line, /30 nodes \/ 12 edges/); + assert.doesNotMatch(line, /not built/); +}); + +test('a zeroed dirty cache at a workspace parent keeps its flags and takes the counts from the children', () => { + // This is the exact state the hooks leave behind: PostToolUse patches a + // fresh, all-zero cache with dirty: true, and there is no wiring.json here. + const p = workspace({ api: { nodes: 10, edges: 5, langs: ['typescript'] }, web: { nodes: 20, edges: 7, langs: ['go'] } }); + writeStats(p, { ...emptyStats(), dirty: true, staleCount: 2, lastFile: 'a.ts' }); + const s = resolveStats(p)!; + assert.equal(s.nodeCount, 30, 'counts come from the children'); + assert.equal(s.dirty, true, 'the live drift state the cache carries survives'); + assert.equal(s.staleCount, 2); + assert.equal(s.lastFile, 'a.ts'); +}); + +test('a workspace parent whose children are not built yet is still not built', () => { + const p = workspace({ api: null, web: null }); + assert.equal(resolveStats(p), null); +}); + +test('an unbuilt child is skipped; the built ones still count', () => { + const p = workspace({ api: { nodes: 10, edges: 5, langs: ['typescript'] }, web: null }); + const s = resolveStats(p)!; + assert.equal(s.nodeCount, 10); + assert.equal(s.edgeCount, 5); +}); + +test('workspaceCoverage counts the built children, and is null for a plain repo', () => { + assert.deepEqual(workspaceCoverage(workspace({ api: { nodes: 10, edges: 5, langs: [] }, web: null })), { built: 1, total: 2 }); + assert.deepEqual(workspaceCoverage(workspace({ api: { nodes: 10, edges: 5, langs: [] }, web: { nodes: 1, edges: 0, langs: [] } })), { built: 2, total: 2 }); + const plain = repo(); + writeWiring(plain, { meta: { nodeCount: 1, edgeCount: 0, languages: [] }, nodes: [], edges: [] }); + assert.equal(workspaceCoverage(plain), null); +}); + +test('the statusline at a workspace parent says how many children are built, also after the sync', () => { + const p = workspace({ api: { nodes: 10, edges: 5, langs: ['typescript'] }, web: null }); + const render = () => { + const r = spawnSync(process.execPath, ['--import', TSX, '-e', `import(${JSON.stringify(STATUSLINE)}).then((m) => m.main())`], { + input: JSON.stringify({ session_id: 't' }), + encoding: 'utf8', + env: { ...process.env, CLAUDE_PROJECT_DIR: p }, + timeout: 60_000, + }); + assert.equal(r.status, 0, r.stderr); + return strip(r.stdout).split('\n')[0]; + }; + assert.match(render(), /workspace 1\/2 repos built · 10 nodes \/ 5 edges/); + // Once the Stop-hook sync has written real counts, the bar reads the parent's cache + // and never sums the children again; the unbuilt child must still show. + writeStats(p, { ...emptyStats(), nodeCount: 10, edgeCount: 5, syncedAt: new Date().toISOString() }); + assert.match(render(), /workspace 1\/2 repos built · 10 nodes \/ 5 edges · ✓ synced/); +}); diff --git a/test/mcp-tools.test.ts b/test/mcp-tools.test.ts index a3ccf6905..04a8df1c2 100644 --- a/test/mcp-tools.test.ts +++ b/test/mcp-tools.test.ts @@ -340,3 +340,36 @@ test('an old name dispatches for real, not just in the alias map', async () => { assert.equal(check.isError, false); assert.ok(!check.text.startsWith('[graft] refreshed'), 'old check name still skips the refresh'); }); + +/** A parent with two git children, built through the CLI so the parent gets + * workspace.json and each child its own graft/ — the #433 shape. */ +function builtWorkspace(): string { + const p = mkdtempSync(join(tmpdir(), 'graft-mcptools-ws-')); + for (const [child, file, src] of [ + ['api', 'src/a.ts', 'export function alphaHandler(): number {\n return 1;\n}\n'], + ['web', 'src/b.ts', 'export function betaHandler(): number {\n return 2;\n}\n'], + ] as const) { + mkdirSync(join(p, child, '.git'), { recursive: true }); + mkdirSync(join(p, child, 'src'), { recursive: true }); + writeFileSync(join(p, child, file), src); + } + execFileSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', 'build', p], { stdio: 'pipe' }); + return p; +} + +test('graft_file_api at a workspace root routes the file to the child that owns it (#433)', async () => { + const p = builtWorkspace(); + const direct = await callTool(p, 'graft_file_api', { file: 'api/src/a.ts' }); + assert.equal(direct.isError, false, direct.text); + assert.match(direct.text, /graft skeleton — api\/src\/a\.ts/); + assert.match(direct.text, /alphaHandler/); + + const bare = await callTool(p, 'graft_file_api', { file: 'b.ts' }); + assert.equal(bare.isError, false, bare.text); + assert.match(bare.text, /graft skeleton — web\/src\/b\.ts/); + assert.match(bare.text, /betaHandler/); + + const miss = await callTool(p, 'graft_file_api', { file: 'nope.ts' }); + assert.equal(miss.isError, true); + assert.match(miss.text, /no definitions indexed for this file in any of the 2 workspace repo/); +}); diff --git a/test/workspace.test.ts b/test/workspace.test.ts index 89430bb57..ec5801c0c 100644 --- a/test/workspace.test.ts +++ b/test/workspace.test.ts @@ -17,6 +17,7 @@ import { federateCheck, federateCallers, federateGrep, + federateSkeleton, isWorkspaceBuildRoot, } from "../src/graph/workspace.js"; import { formatAsk } from "../src/ask/ask.js"; @@ -533,3 +534,59 @@ test("readWorkspace: rejects foreign/invalid json as not-a-workspace", () => { assert.deepEqual(readWorkspace(p), { version: 1, children: ["a", "x"] }); rmSync(p, { recursive: true, force: true }); }); + +/** + * `skeleton` at the parent (#433): before this it loaded the parent's own graph, + * which a workspace never has, and answered "no wiring graph — run graft build" + * for a repo that had just been built. + */ +test("federateSkeleton: /path addresses that child directly, and the answer keeps the prefix", async () => { + const p = workspaceFx(REPOS); + await buildWorkspace(p); + const r = federateSkeleton(p, undefined, "repoA/a.ts"); + assert.equal(r.file, "repoA/a.ts", "openable from the parent, like every federated pointer"); + assert.deepEqual(r.entries.map((e) => e.name).sort(), ["alphaHandler", "helperThing"]); + assert.equal(r.note, undefined); + rmSync(p, { recursive: true, force: true }); +}); + +test("federateSkeleton: a bare path or basename is looked up in every child", async () => { + const p = workspaceFx(REPOS); + await buildWorkspace(p); + const r = federateSkeleton(p, undefined, "b.ts"); + assert.equal(r.file, "repoB/b.ts"); + assert.deepEqual(r.entries.map((e) => e.name), ["betaHandler"]); + rmSync(p, { recursive: true, force: true }); +}); + +test("federateSkeleton: the same basename in two children is ambiguous, and the note names both with their child", async () => { + const p = workspaceFx({ + repoA: { "src/util.ts": "export function fromA() { return 1; }\n" }, + repoB: { "src/util.ts": "export function fromB() { return 2; }\n" }, + }); + await buildWorkspace(p); + const r = federateSkeleton(p, undefined, "util.ts"); + assert.equal(r.entries.length, 0); + assert.equal(r.note, "ambiguous — matches: repoA/src/util.ts, repoB/src/util.ts"); + // …and the child prefix disambiguates. + assert.deepEqual(federateSkeleton(p, undefined, "repoB/src/util.ts").entries.map((e) => e.name), ["fromB"]); + rmSync(p, { recursive: true, force: true }); +}); + +test("federateSkeleton: a file no child knows, and a child that has no graph yet", async () => { + const p = workspaceFx(REPOS); + await buildWorkspace(p); + const miss = federateSkeleton(p, undefined, "nope.ts"); + assert.equal(miss.entries.length, 0); + assert.match(miss.note ?? "", /^no definitions indexed for this file in any of the 2 workspace repo\(s\)$/); + + // A third child listed in workspace.json but never built: addressing it + // says so, instead of "no definitions", and the coverage line appears on a miss. + mkdirSync(join(p, "repoC", ".git"), { recursive: true }); + writeWorkspace(p, { version: 1, children: ["repoA", "repoB", "repoC"] }); + const unbuilt = federateSkeleton(p, undefined, "repoC/x.ts"); + assert.equal(unbuilt.note, "repoC/ has no graph yet — run graft build"); + const missWithCoverage = federateSkeleton(p, undefined, "nope.ts"); + assert.match(missWithCoverage.note ?? "", /run graft build to cover repoC/); + rmSync(p, { recursive: true, force: true }); +});