diff --git a/README.md b/README.md index 9a299004..fb640877 100644 --- a/README.md +++ b/README.md @@ -671,7 +671,7 @@ trail init --list-agents # list known agent ids and exit trail build [dir] # build the graft map in graft/: wiring graph + per-file cards (no LLM, no key) trail build --deep # add the LLM layer: concept nodes + per-symbol summary/crux (cached) trail build --check # build nothing: fail (exit 1) if graft/ has drifted from the code, for CI -trail build --deep -e .ts .py # with --deep: only summarize these extensions +trail build -e .ts .py # only index these extensions (also limits --deep summaries) trail build --no-reuse # re-parse every file instead of replaying unchanged ones from cache trail build --follow-submodules # include initialized submodules; persist the choice for builds + MCP refresh trail build --no-follow-submodules # exclude submodules again and persist that choice (the default) @@ -707,6 +707,9 @@ trail --dir # use a context dir other than /graft trail --version, -v # print the installed version and exit ``` +Extension selections also apply to automatic refreshes. Run an explicit +`trail build` without `-e` to index all supported extensions again. + Method calls resolve through the receiver's type, using constructor assignments (`self.router = APIRouter()`) and type annotations, not just the call-site name, so `callers`/`grep --in` return calls bound to the right diff --git a/src/claude/sync-run.ts b/src/claude/sync-run.ts index e07acb8e..ccafec19 100644 --- a/src/claude/sync-run.ts +++ b/src/claude/sync-run.ts @@ -1,19 +1,33 @@ import { execFileSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; +import { join } from 'node:path'; import { readWiring, computeStats } from './stats.js'; import { patchStats, releaseLock, resolveContextDir } from './state.js'; import { graftCliPath } from './paths.js'; +import { contextDirFor } from '../context/node-file.js'; +import { readGraph, wiringPath } from '../graph/write.js'; +import { readWorkspace, writeWorkspace } from '../graph/workspace.js'; +import { discoverWorkspaceChildren } from '../graph/scopes.js'; /** MONEY GUARD: plain `graft build` only — structural, $0, offline. Never --deep. */ function realBuild(dir: string): void { // GRAFT_TEST_CLI is the same seam hooks.ts's graftJson uses, so a test can // point this at a stub and inspect the exact argv it was invoked with. const cliPath = process.env.GRAFT_TEST_CLI ?? graftCliPath(); - const args = [cliPath, 'build', '.']; - // Mirrors `withContextDirArg` in hooks.ts: a no-op unless GRAFT_DIR is set, so an - // unconfigured repo's rebuild sees byte-identical argv to before this existed. - if (process.env.GRAFT_DIR) args.push('--dir', resolveContextDir(dir)); - execFileSync(process.execPath, args, { cwd: dir, stdio: 'ignore', timeout: 120000 }); + const workspace = readWorkspace(dir, resolveContextDir(dir)); + const children = workspace ? discoverWorkspaceChildren(dir).sort() : []; + const roots = workspace ? children.map((child) => join(dir, child)) : [dir]; + for (const root of roots) { + // A workspace's override belongs to its index, never its children's graphs. + const outDir = workspace ? contextDirFor(root) : resolveContextDir(root); + const args = [cliPath, 'build', '.']; + if (!workspace && process.env.GRAFT_DIR) args.push('--dir', outDir); + const extensions = readGraph(wiringPath(outDir))?.meta?.extensions; + if (extensions?.length) args.push('--extensions', ...extensions); + const env = workspace ? { ...process.env, GRAFT_DIR: undefined } : process.env; + execFileSync(process.execPath, args, { cwd: root, env, stdio: 'ignore', timeout: 120000 }); + } + if (workspace) writeWorkspace(dir, { version: 1, children }, resolveContextDir(dir)); } export function runSync(dir: string, build: (d: string) => void = realBuild): void { diff --git a/src/cli.ts b/src/cli.ts index eb139797..3830e512 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -632,6 +632,7 @@ grouped(program.command("build"), GROUP.setup) // Wiring graph — always; LLM meaning only with --deep. const g = await engine.graph(dir, { + extensions: opts.extensions, llm: deep, concurrency, reuse: opts.reuse, diff --git a/src/engine.ts b/src/engine.ts index 01216cf8..37f19859 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -40,6 +40,8 @@ export interface CheckRunOptions { } export interface GraphRunOptions { + /** Source extensions to include; absent or empty includes every supported language. */ + extensions?: string[]; /** Run the Tier-2 LLM meaning pass (summary + crux). Absent → Tier-1 only. */ llm?: boolean; /** Max files summarized in parallel during the LLM pass. */ @@ -91,6 +93,7 @@ export class Graft { */ graph(dir: string, opts: GraphRunOptions = {}): Promise { return buildGraph(dir, { + extensions: opts.extensions, contextDir: this.cfg.contextDir, summarizer: opts.llm ? this.cruxSummarizer() : undefined, concurrency: opts.concurrency, diff --git a/src/graph/build.ts b/src/graph/build.ts index 178f7f58..29e69c7b 100644 --- a/src/graph/build.ts +++ b/src/graph/build.ts @@ -33,7 +33,7 @@ import { } from "./extract-cache.js"; import { writeFingerprint } from "./fingerprint.js"; import { seedGraph, type SeedResult } from "./seed.js"; -import { filterByOnlyDirs, listSourceStats } from "./source-files.js"; +import { filterByOnlyDirs, listSourceStats, normalizeExtensions } from "./source-files.js"; import { resolveEdges, type GoModule } from "./resolve.js"; import { enrichGraph, type EnrichStats } from "./enrich.js"; import { readGraph, writeGraph, wiringPath } from "./write.js"; @@ -120,6 +120,8 @@ function applyMinSubstanceGuard(scopes: ScopeV1[], nodes: NodeV1[]): ScopeV1[] { } export interface GraphBuildOptions { + /** Source extensions to include; absent or empty includes every supported language. */ + extensions?: string[]; /** Override the output dir (default: `/.context`). */ contextDir?: string; /** Replay unchanged files from the extraction cache instead of re-parsing them @@ -216,7 +218,8 @@ export async function buildGraph( }); const onlyDirs = opts.onlyDirs && opts.onlyDirs.length > 0 ? new Set(opts.onlyDirs) : undefined; const repoFiles = filterByOnlyDirs(walked, root, onlyDirs); - const files = listSourceStats(root, outDir, repoFiles); + const extensions = normalizeExtensions(opts.extensions); + const files = listSourceStats(root, outDir, repoFiles, undefined, extensions); const discoveredScopes = discoverScopes(root, repoFiles); const nodes: NodeV1[] = []; @@ -363,6 +366,7 @@ export async function buildGraph( nodeCount: nodes.length, edgeCount: edges.length, languages: [...langs].sort(), + extensions, scopes, }, nodes, @@ -417,7 +421,7 @@ export async function buildGraph( // these source bytes." Nothing about the projections below — which is why it is // safe to write here, and why `graphOnly` builds (the query path, which stops // right after this line) are still recorded as fresh. - writeFingerprint(outDir, entries, opts.onlyDirs); + writeFingerprint(outDir, entries, opts.onlyDirs, extensions); // Tier-2 passive surface: project the nodes into per-file markdown cards, and // refresh the INDEX roster. Pure projection — no LLM, no network. diff --git a/src/graph/check.ts b/src/graph/check.ts index c503f00b..734d0abe 100644 --- a/src/graph/check.ts +++ b/src/graph/check.ts @@ -82,12 +82,12 @@ export async function checkGraph( } // Freshly extract Tier-1 nodes from the code on disk (same file set as build). - // A `--only-dir` build records its whitelist in the fingerprint; read it back - // so `check` diffs the same limited set instead of flagging every excluded - // file as "added". - const fpOnlyDirs = readFingerprint(outDir)?.onlyDirs; + // Read the saved filters so `check` diffs the same limited set. Extension + // selection lives in the graph itself and survives extractor-cache changes. + const fingerprint = readFingerprint(outDir); + const fpOnlyDirs = fingerprint?.onlyDirs; const onlyDirs = fpOnlyDirs && fpOnlyDirs.length > 0 ? new Set(fpOnlyDirs) : undefined; - const sourceFiles = listSourceFiles(root, outDir, undefined, onlyDirs); + const sourceFiles = listSourceFiles(root, outDir, undefined, onlyDirs, committed.meta?.extensions); // Container-tier grammars need the same warmup as the generic ones, for the same // reason: extraction below is synchronous. Missing that is what made `graft // check` report every `.vue` node as `removed` right after a clean build (#236) diff --git a/src/graph/fingerprint.ts b/src/graph/fingerprint.ts index f8fb7785..01296080 100644 --- a/src/graph/fingerprint.ts +++ b/src/graph/fingerprint.ts @@ -54,6 +54,8 @@ export interface Fingerprint { * — so the query-path freshness probe (which never sees a CLI flag) enumerates the * identical whitelisted set and excluded files are never phantom "added" drift. */ onlyDirs?: string[]; + /** Normalized source extension selection (`--extensions`); absent = all supported. */ + extensions?: string[]; } /** What moved since the last build. Empty in all three arrays = nothing to do. */ @@ -88,12 +90,14 @@ export function writeFingerprint( outDir: string, entries: Record, onlyDirs?: string[], + extensions?: string[], ): boolean { const files: Record = {}; for (const [rel, e] of Object.entries(entries)) files[rel] = [e.size, e.mtimeMs, e.hash]; try { const record: Fingerprint = { version: FINGERPRINT_VERSION, extractor: stamp(), files }; if (onlyDirs && onlyDirs.length > 0) record.onlyDirs = onlyDirs; + if (extensions && extensions.length > 0) record.extensions = extensions; writeJsonAtomic(fingerprintPath(outDir), record, true); pruneSidecars(join(outDir, CACHE_DIR), FINGERPRINT_PREFIX); return true; @@ -155,7 +159,7 @@ export function probeDrift(root: string, outDir: string): Drift | null { const seen = new Set(); const onlyDirs = fp.onlyDirs && fp.onlyDirs.length > 0 ? new Set(fp.onlyDirs) : undefined; - for (const f of listSourceStats(root, outDir, undefined, onlyDirs)) { + for (const f of listSourceStats(root, outDir, undefined, onlyDirs, fp.extensions)) { seen.add(f.rel); const print = fp.files[f.rel]; if (!print) { diff --git a/src/graph/refresh.ts b/src/graph/refresh.ts index b34621ec..cdc268f7 100644 --- a/src/graph/refresh.ts +++ b/src/graph/refresh.ts @@ -38,7 +38,7 @@ import { acquireLockIn, releaseLockIn } from "../util/state.js"; import { CACHE_DIR } from "../context/node-file.js"; import { buildGraph } from "./build.js"; import { driftCount, isClean, probeDrift, readFingerprint, type Drift } from "./fingerprint.js"; -import { invalidateGraphCaches } from "./load.js"; +import { invalidateGraphCaches, loadGraphCached } from "./load.js"; import { seedGraph, type SeedResult } from "./seed.js"; import { wiringPath } from "./write.js"; import { tag } from "../brand.js"; @@ -210,11 +210,15 @@ export async function ensureFreshGraph(root: string, opts: RefreshOptions = {}): // else. The markdown projections under `graft/` stay the `Stop` hook's job — // a query has no business rewriting the repo's `.gitignore` or churning every // card's mtime, and skipping them is most of what keeps this cheap. - // A `--only-dir` build recorded its whitelist in the fingerprint; re-apply it - // here so an auto-rebuild keeps the same limited file set instead of silently - // widening to the whole tree. - const onlyDirs = readFingerprint(outDir)?.onlyDirs; - await buildGraph(dir, { contextDir: opts.contextDir, graphOnly: true, onlyDirs }); + // Re-apply saved filters. Extension selection lives in the graph itself, + // so a missing or obsolete extractor fingerprint cannot widen the build. + const fingerprint = readFingerprint(outDir); + await buildGraph(dir, { + contextDir: opts.contextDir, + graphOnly: true, + onlyDirs: fingerprint?.onlyDirs, + extensions: loadGraphCached(outDir)?.meta?.extensions, + }); invalidateGraphCaches(outDir); return { refreshed: true, drift: drift ?? undefined, note: seedNote }; } finally { diff --git a/src/graph/source-files.ts b/src/graph/source-files.ts index b5d7c338..777579b4 100644 --- a/src/graph/source-files.ts +++ b/src/graph/source-files.ts @@ -7,7 +7,7 @@ * {@link listSourceFiles} so its existing importers are unaffected. */ import { statSync } from "node:fs"; -import { resolve } from "node:path"; +import { extname, resolve } from "node:path"; import { walkDir } from "../ingest/fs.js"; import { relPosix } from "../util/paths.js"; import { readFollowNestedRepos, readFollowSubmodules, readIncludeDirs } from "../util/state.js"; @@ -27,6 +27,11 @@ function normExt(e: string): string { return t.startsWith(".") ? t : `.${t}`; } +/** Canonical selection for the build and its persisted freshness record. */ +export function normalizeExtensions(exts?: readonly string[]): string[] | undefined { + return exts?.length ? [...new Set(exts.map(normExt))].sort() : undefined; +} + /** * The subset of user-supplied `-e` extensions that no parser claims (depth or breadth). * `graft build -e ".vue"` used to accept these silently and index nothing; the CLI warns @@ -69,7 +74,9 @@ export function listSourceFiles( followNestedRepos: readFollowNestedRepos(resolve(root)), }), onlyDirs?: ReadonlySet, + extensions?: readonly string[], ): string[] { + const selected = normalizeExtensions(extensions); // A file is a source file if a depth-tier grammar (languageOf), a breadth-tier // grammar (genericLangOf) or a container (containerLangOf) claims its extension. // All three must agree here or `build` and `check` would enumerate different sets. @@ -77,6 +84,7 @@ export function listSourceFiles( repoFiles.filter( (f) => !f.startsWith(outDir) && + (!selected || selected.includes(extname(f).toLowerCase())) && (languageOf(f) !== null || genericLangOf(f) !== null || containerLangOf(f) !== null), ), root, @@ -105,9 +113,10 @@ export function listSourceStats( outDir: string, repoFiles?: string[], onlyDirs?: ReadonlySet, + extensions?: readonly string[], ): SourceStat[] { const out: SourceStat[] = []; - for (const abs of listSourceFiles(root, outDir, repoFiles, onlyDirs)) { + for (const abs of listSourceFiles(root, outDir, repoFiles, onlyDirs, extensions)) { let s: { size: number; mtimeMs: number }; try { s = statSync(abs); diff --git a/src/graph/types.ts b/src/graph/types.ts index ff243e15..1e14353a 100644 --- a/src/graph/types.ts +++ b/src/graph/types.ts @@ -123,6 +123,8 @@ export interface GraphV1 { nodeCount: number; edgeCount: number; languages: string[]; + /** Normalized source selection, retained independently of extractor caches. */ + extensions?: string[]; /** Ranking scopes: posix path prefixes relative to the graph root, "" = root scope. * Absent (old graphs) ≡ [{ prefix: "", label: "" }]. Sorted by prefix length desc. */ scopes?: ScopeV1[]; diff --git a/src/graph/workspace-cli.ts b/src/graph/workspace-cli.ts index 09755639..adf64bc3 100644 --- a/src/graph/workspace-cli.ts +++ b/src/graph/workspace-cli.ts @@ -71,7 +71,7 @@ export async function runWorkspaceBuild(root: string, opts: WorkspaceBuildOption } const engine = new Graft({ ...opts.childConfig, contextDir: undefined }); if (opts.deep) await engine.init(childDir, { extensions: opts.extensions }); - const g = await engine.graph(childDir, { llm: opts.deep, concurrency: opts.concurrency }); + const g = await engine.graph(childDir, { llm: opts.deep, concurrency: opts.concurrency, extensions: opts.extensions }); console.log(`✓ ${childName}/: ${g.nodes} nodes, ${g.edges} edges, ${g.cards} cards [${g.languages.join(", ")}]`); for (const e of g.errors) console.error(`✗ ${childName}/: ${e}`); for (const l of g.nativeFallback?.languages ?? []) fellBack.languages.add(l); diff --git a/src/mcp/instructions.ts b/src/mcp/instructions.ts index c5f78878..a78473d1 100644 --- a/src/mcp/instructions.ts +++ b/src/mcp/instructions.ts @@ -42,18 +42,18 @@ export function toolSearchQuery(prefix = `mcp__${brand()}__`): string { /** Under trail, the same text with trail's tool names (see brand.ts `inBrand`). */ export function mcpInstructions(): string { return inBrand([ - 'This repo is indexed by graft: a prebuilt graph of every symbol, its file:line', + 'The graph maps symbols in indexed files, their file:line', 'span, and who calls what. Prefer these tools over grep/read — one call usually', 'replaces several file reads.', '', `**If these tools are deferred (names shown, schemas withheld), load them all in ONE lookup:** ToolSearch "${toolSearchQuery()}" — one round trip for the whole session. Never load them one at a time.`, '', '- graft_find_code — "how does X work" / "where is Y": ranked hits, code inlined.', - '- graft_find_all — when you need EVERY occurrence; find_code is top-N and misses some.', + '- graft_find_all — EVERY occurrence in indexed files; find_code is top-N.', '- graft_trace_calls — who calls it, what it calls, blast radius before a rename.', '- graft_file_api — a file\'s whole API in ~200 tokens.', '- graft_repo_map — orientation in an unfamiliar repo.', '', - 'Results already reflect uncommitted edits — the graph refreshes before each query.', + 'Results reflect edits and report extension limits. Search other files directly.', ].join('\n')); } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9543428f..50ea1c74 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3,6 +3,7 @@ * `callTool` never throws — hosts get soft errors as isError content. */ import { Graft } from '../engine.js'; +import { join } from 'node:path'; import { formatAsk, skeleton, formatSkeleton } from '../ask/ask.js'; import { formatCheckReport } from '../context/check.js'; import { formatGraphCheckReport } from '../graph/check.js'; @@ -203,6 +204,19 @@ async function callWorkspaceTool( * `graft_check_freshness` answer about a graph it just fixed, i.e. always "OK". */ const NO_REFRESH_TOOLS = new Set(['graft_check_freshness']); +/** A clean graph may intentionally omit supported languages. Keep that scope + * visible on every answer, including zero hits, without changing unfiltered replies. */ +function extensionScopeNote(root: string, override?: string, children?: string[]): string | null { + const scopes = children + ? children.map((child) => ({ label: `${child}/: `, outDir: contextDirFor(join(root, child)) })) + : [{ label: '', outDir: contextDirFor(root, override) }]; + const graphs = scopes.map((scope) => ({ ...scope, graph: loadGraphCached(scope.outDir) })); + if (!graphs.some(({ graph }) => graph?.meta?.extensions?.length)) return null; + const selections = graphs.map(({ label, graph }) => + label + (graph?.meta?.extensions?.join(', ') || (graph ? 'all supported' : 'not built'))); + return `Indexed extensions: ${selections.join('; ')}. Use ordinary source search for files outside these selections.`; +} + /** * The pre-0.8.1 tool names, still accepted, live in the dependency-free * `tool-names.ts` (shared with the engine-free session hooks). The names were the @@ -238,7 +252,8 @@ export async function callTool( } const fed = ws ? await callWorkspaceTool(root, dirOverride, name, args) : null; const res = fed ?? (await callSingleTool(root, name, args, dirOverride)); - return note ? { ...res, text: `${note}\n${res.text}` } : res; + const notes = [note, extensionScopeNote(root, dirOverride, ws?.children)].filter(Boolean); + return notes.length ? { ...res, text: `${notes.join('\n')}\n${res.text}` } : res; } catch (err) { return { text: err instanceof Error ? err.message : String(err), isError: true }; } diff --git a/test/claude-hooks.test.ts b/test/claude-hooks.test.ts index 91d24074..42dcb155 100644 --- a/test/claude-hooks.test.ts +++ b/test/claude-hooks.test.ts @@ -6,6 +6,8 @@ import { join } from 'node:path'; import { underGraft, main, lastFileScopeHint, promptAskTimeout } from '../src/claude/hooks.js'; import { readStats, readSession } from '../src/claude/state.js'; import { runSync } from '../src/claude/sync-run.js'; +import { buildGraph } from '../src/graph/build.js'; +import { fingerprintPath } from '../src/graph/fingerprint.js'; import { savingsLine } from '../src/context/savings.js'; import { CI_ENV_VARS } from '../src/telemetry/gate.js'; import { writeStats, emptyStats, acquireLock, resolveContextDir } from '../src/claude/state.js'; @@ -123,6 +125,29 @@ test("runSync's default build passes --dir to graft build when GRAFT_ } }); +test("runSync's default build retains extension selection after an extractor change", async () => { + const d = mkdtempSync(join(tmpdir(), 'graft-sync-extensions-')); + const argsFile = join(d, 'args-seen.json'); + const stub = join(d, 'build-stub.cjs'); + writeFileSync(stub, `require('fs').writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2)));`); + process.env.GRAFT_TEST_CLI = stub; + process.env.GRAFT_DIR = 'elsewhere'; + try { + await buildGraph(d, { contextDir: resolveContextDir(d), extensions: ['.go', '.rs'] }); + const fpPath = fingerprintPath(resolveContextDir(d)); + const fp = JSON.parse(readFileSync(fpPath, 'utf8')); + fp.extractor = 'prior-extractor'; + writeFileSync(fpPath, JSON.stringify(fp)); + runSync(d); + assert.deepEqual(JSON.parse(readFileSync(argsFile, 'utf8')), [ + 'build', '.', '--dir', resolveContextDir(d), '--extensions', '.go', '.rs', + ]); + } finally { + delete process.env.GRAFT_TEST_CLI; + delete process.env.GRAFT_DIR; + } +}); + test('runSync clears syncing even if build throws (money-safe failure)', () => { const d = mkdtempSync(join(tmpdir(), 'graft-sync-')); writeStats(d, { ...emptyStats(), dirty: true, syncing: true }); diff --git a/test/graph-extensions.test.ts b/test/graph-extensions.test.ts new file mode 100644 index 00000000..0d2b0e08 --- /dev/null +++ b/test/graph-extensions.test.ts @@ -0,0 +1,284 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { runSync } from "../src/claude/sync-run.js"; +import { buildGraph } from "../src/graph/build.js"; +import { checkGraph } from "../src/graph/check.js"; +import { fingerprintPath, isClean, probeDrift, readFingerprint } from "../src/graph/fingerprint.js"; +import { ensureFreshGraph } from "../src/graph/refresh.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; +import { readWorkspace, writeWorkspace } from "../src/graph/workspace.js"; +import { callTool } from "../src/mcp/tools.js"; +import { mcpInstructions } from "../src/mcp/instructions.js"; +import { homeEnv, runCli, tmpRepo } from "./helpers.js"; + +function mixedRepo(): string { + const dir = tmpRepo("extensions"); + writeFileSync(join(dir, "main.go"), "package main\nfunc main() {}\n"); + writeFileSync(join(dir, "widget.ts"), "export function widget() { return 1; }\n"); + return dir; +} + +function paths(dir: string): string[] { + const graph = readGraph(wiringPath(join(dir, "graft"))); + assert.ok(graph, "expected a built graph"); + return graph.nodes.filter((n) => n.kind === "file").map((n) => n.path).sort(); +} + +function invalidateExtractor(dir: string): void { + const path = fingerprintPath(join(dir, "graft")); + const fingerprint = JSON.parse(readFileSync(path, "utf8")); + fingerprint.extractor = "prior-extractor"; + writeFileSync(path, JSON.stringify(fingerprint)); +} + +test("graph checks retain extension scope after an extractor change", async () => { + const dir = mixedRepo(); + try { + await buildGraph(dir, { extensions: [".go"] }); + invalidateExtractor(dir); + assert.equal(readFingerprint(join(dir, "graft")), null); + assert.equal((await checkGraph(dir)).ok, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("automatic refresh retains extension scope after an extractor change", async () => { + const dir = mixedRepo(); + try { + await buildGraph(dir, { extensions: [".go"] }); + invalidateExtractor(dir); + assert.equal((await ensureFreshGraph(dir)).refreshed, true); + assert.deepEqual(paths(dir), ["main.go"]); + assert.deepEqual(readGraph(wiringPath(join(dir, "graft")))?.meta.extensions, [".go"]); + await buildGraph(dir); + assert.equal(readGraph(wiringPath(join(dir, "graft")))?.meta.extensions, undefined); + assert.deepEqual(paths(dir), ["main.go", "widget.ts"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +for (const extension of [".go", "go", ".GO"]) { + test(`build --extensions ${extension} limits the structural graph`, () => { + const dir = mixedRepo(); + const home = tmpRepo("extensions-home"); + try { + const result = runCli(["build", dir, "--extensions", extension], { home }); + assert.equal(result.status, 0, result.describe()); + assert.deepEqual(paths(dir), ["main.go"]); + assert.deepEqual(readFingerprint(join(dir, "graft"))?.extensions, [".go"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); +} + +test("extension filtering survives checks and automatic refresh", async () => { + const dir = mixedRepo(); + const out = join(dir, "graft"); + try { + await buildGraph(dir, { extensions: ["go"] }); + assert.deepEqual(paths(dir), ["main.go"]); + assert.ok(isClean(probeDrift(dir, out)!)); + assert.equal((await checkGraph(dir)).ok, true); + + writeFileSync(join(dir, "widget.ts"), "export const excludedEdit = 2;\n"); + writeFileSync(join(dir, "new.ts"), "export const excludedAddition = 3;\n"); + assert.ok(isClean(probeDrift(dir, out)!), "excluded edits and additions are not drift"); + assert.equal((await ensureFreshGraph(dir)).refreshed, false); + assert.equal((await checkGraph(dir)).ok, true); + + writeFileSync(join(dir, "main.go"), "package main\nfunc main() {}\nfunc added() {}\n"); + assert.deepEqual(probeDrift(dir, out)?.changed, ["main.go"]); + assert.equal((await checkGraph(dir)).ok, false); + assert.equal((await ensureFreshGraph(dir)).refreshed, true); + assert.deepEqual(paths(dir), ["main.go"]); + assert.deepEqual(readFingerprint(out)?.extensions, [".go"]); + assert.ok(isClean(probeDrift(dir, out)!)); + assert.equal((await checkGraph(dir)).ok, true); + + // An explicit build without a filter restores the default full file set. + await buildGraph(dir); + assert.deepEqual(paths(dir), ["main.go", "new.ts", "widget.ts"]); + assert.equal(readFingerprint(out)?.extensions, undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("extension filters combine with onlyDirs and do not admit unsupported files", async () => { + const dir = mixedRepo(); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "keep.ts"), "export const keep = 1;\n"); + writeFileSync(join(dir, "src", "skip.go"), "package skip\nfunc Skip() {}\n"); + writeFileSync(join(dir, "src", "notes.unknown"), "not source\n"); + await buildGraph(dir, { onlyDirs: ["src"], extensions: ["TS", ".unknown"] }); + assert.deepEqual(paths(dir), ["src/keep.ts"]); + assert.ok(isClean(probeDrift(dir, join(dir, "graft"))!)); + assert.equal((await checkGraph(dir)).ok, true); + + await buildGraph(dir, { extensions: [".unknown"] }); + assert.deepEqual(paths(dir), [], "an unsupported-only selection must not index everything"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("workspace builds forward the extension filter to every child graph", () => { + const parent = tmpRepo("extensions-workspace"); + const home = tmpRepo("extensions-workspace-home"); + try { + for (const name of ["one", "two"]) { + const dir = join(parent, name); + mkdirSync(join(dir, ".git"), { recursive: true }); + writeFileSync(join(dir, "main.go"), "package main\nfunc main() {}\n"); + writeFileSync(join(dir, "widget.ts"), "export const widget = 1;\n"); + } + const result = runCli(["build", parent, "--extensions", ".go"], { home }); + assert.equal(result.status, 0, result.describe()); + for (const name of ["one", "two"]) assert.deepEqual(paths(join(parent, name)), ["main.go"]); + } finally { + rmSync(parent, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +for (const override of [false, true]) { + test(`workspace background sync retains child selections (parent override: ${override})`, () => { + const parent = tmpRepo("extensions-sync-workspace"); + const home = tmpRepo("extensions-sync-home"); + const parentOut = override ? join(parent, "parent-map") : undefined; + const envBefore = { ...process.env }; + try { + for (const name of ["one", "two", "three"]) { + const dir = join(parent, name); + mkdirSync(join(dir, ".git"), { recursive: true }); + writeFileSync(join(dir, "main.go"), "package main\nfunc main() {}\n"); + writeFileSync(join(dir, "widget.ts"), "export const widget = 1;\n"); + } + const initial = runCli([...(parentOut ? ["--dir", parentOut] : []), "build", parent, "--extensions", ".go"], { home }); + assert.equal(initial.status, 0, initial.describe()); + for (const [name, extra] of [["two", ["--extensions", ".ts"]], ["three", []]] as const) { + const result = runCli(["build", join(parent, name), ...extra], { home }); + assert.equal(result.status, 0, result.describe()); + } + writeFileSync(join(parent, "one", "main.go"), "package main\nfunc AfterSync() {}\n"); + // Sync previously rediscovered workspace members through the CLI. Preserve + // that behavior for new children while retaining selections of existing ones. + mkdirSync(join(parent, "four", ".git"), { recursive: true }); + writeFileSync(join(parent, "four", "new.ts"), "export const newChild = 1;\n"); + + // The default sync really invokes the CLI; this source runner only supplies + // tsx when the child starts outside the package, just like runCli does above. + const shim = join(home, "cli.cjs"); + const tsx = pathToFileURL(createRequire(import.meta.url).resolve("tsx")).href; + writeFileSync(shim, `require('node:child_process').execFileSync(process.execPath, + ${JSON.stringify(["--import", tsx, resolve("src/cli.ts")])}.concat(process.argv.slice(2)), + { stdio: 'inherit' });`); + Object.assign(process.env, homeEnv(home), { GRAFT_TEST_CLI: shim }); + if (parentOut) process.env.GRAFT_DIR = parentOut; + else delete process.env.GRAFT_DIR; + runSync(parent); + assert.ok(readGraph(wiringPath(join(parent, "one", "graft")))?.nodes.some((n) => n.name === "AfterSync"), + "the background build must actually index the edit"); + assert.deepEqual(paths(join(parent, "one")), ["main.go"]); + assert.deepEqual(paths(join(parent, "two")), ["widget.ts"]); + assert.deepEqual(paths(join(parent, "three")), ["main.go", "widget.ts"]); + assert.deepEqual(paths(join(parent, "four")), ["new.ts"]); + assert.deepEqual(readWorkspace(parent, parentOut)?.children, ["four", "one", "three", "two"]); + } finally { + for (const name of ["HOME", "USERPROFILE", "GRAFT_DIR", "GRAFT_TEST_CLI"]) { + if (envBefore[name] === undefined) delete process.env[name]; + else process.env[name] = envBefore[name]; + } + rmSync(parent, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); +} + +test("filtering source extensions preserves Go module metadata for import resolution", async () => { + const dir = mixedRepo(); + try { + mkdirSync(join(dir, "store")); + writeFileSync(join(dir, "go.mod"), "module example.com/app\n\ngo 1.21\n"); + writeFileSync(join(dir, "main.go"), 'package main\nimport "example.com/app/store"\nfunc main() { store.New() }\n'); + writeFileSync(join(dir, "store", "store.go"), "package store\nfunc New() int { return 1 }\n"); + await buildGraph(dir, { extensions: [".go"] }); + assert.deepEqual(paths(dir), ["main.go", "store/store.go"]); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + assert.ok(graph.edges.some((e) => e.relation === "imports" && + e.source === "main.go" && e.target === "store/store.go")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("MCP retrieval discloses extension scope for clean and zero-hit results", async () => { + const dir = mixedRepo(); + try { + writeFileSync(join(dir, "widget.ts"), "export const onlyExcluded = 1;\n"); + await buildGraph(dir, { extensions: [".go"] }); + for (const [name, args] of [ + ["graft_find_all", { pattern: "onlyExcluded" }], + ["graft_find_code", { query: "main" }], + ["graft_file_api", { file: "main.go" }], + ["graft_trace_calls", { symbol: "main" }], + ["graft_repo_map", {}], + ["graft_check_freshness", {}], + ] as const) { + const result = await callTool(dir, name, args); + assert.equal(result.isError, false, result.text); + assert.match(result.text, /Indexed extensions: \.go\./, name); + assert.match(result.text, /ordinary source search/, name); + } + const zero = await callTool(dir, "graft_find_all", { pattern: "onlyExcluded" }); + assert.match(zero.text, /no hits/i); + assert.match(zero.text, /Indexed extensions: \.go\./); + assert.ok(isClean(probeDrift(dir, join(dir, "graft"))!)); + await buildGraph(dir); + const all = await callTool(dir, "graft_find_all", { pattern: "onlyExcluded" }); + assert.match(all.text, /widget\.ts/); + assert.doesNotMatch(all.text, /Indexed extensions:/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("MCP workspace retrieval names each child's extension scope", async () => { + const parent = tmpRepo("extensions-mcp-workspace"); + try { + const children = ["one", "two", "three"]; + for (const [index, name] of children.entries()) { + const dir = join(parent, name); + mkdirSync(dir); + writeFileSync(join(dir, "main.go"), "package main\nfunc main() {}\n"); + writeFileSync(join(dir, "widget.ts"), `export const ${index === 0 ? "onlyExcluded" : "widget"} = 1;\n`); + await buildGraph(dir, { extensions: [[".go"], [".ts"], undefined][index] }); + } + writeWorkspace(parent, { version: 1, children }); + const result = await callTool(parent, "graft_find_all", { pattern: "onlyExcluded" }); + assert.equal(result.isError, false, result.text); + assert.match(result.text, /no hits/i); + assert.match(result.text, /one\/: \.go/); + assert.match(result.text, /two\/: \.ts/); + assert.match(result.text, /three\/: all supported/); + assert.match(result.text, /ordinary source search/); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("MCP instructions qualify exhaustive searches by indexed files", () => { + const instructions = mcpInstructions(); + assert.match(instructions, /EVERY occurrence in indexed files/); + assert.doesNotMatch(instructions, /every symbol/); + assert.ok(instructions.length < 1000); +});