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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -707,6 +707,9 @@ trail --dir <path> # use a context dir other than <repo>/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
Expand Down
24 changes: 19 additions & 5 deletions src/claude/sync-run.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -91,6 +93,7 @@ export class Graft {
*/
graph(dir: string, opts: GraphRunOptions = {}): Promise<GraphBuildResult> {
return buildGraph(dir, {
extensions: opts.extensions,
contextDir: this.cfg.contextDir,
summarizer: opts.llm ? this.cruxSummarizer() : undefined,
concurrency: opts.concurrency,
Expand Down
10 changes: 7 additions & 3 deletions src/graph/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: `<root>/.context`). */
contextDir?: string;
/** Replay unchanged files from the extraction cache instead of re-parsing them
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -363,6 +366,7 @@ export async function buildGraph(
nodeCount: nodes.length,
edgeCount: edges.length,
languages: [...langs].sort(),
extensions,
scopes,
},
nodes,
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions src/graph/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/graph/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -88,12 +90,14 @@ export function writeFingerprint(
outDir: string,
entries: Record<string, ExtractEntry>,
onlyDirs?: string[],
extensions?: string[],
): boolean {
const files: Record<string, Print> = {};
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;
Expand Down Expand Up @@ -155,7 +159,7 @@ export function probeDrift(root: string, outDir: string): Drift | null {
const seen = new Set<string>();

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) {
Expand Down
16 changes: 10 additions & 6 deletions src/graph/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions src/graph/source-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -69,14 +74,17 @@ export function listSourceFiles(
followNestedRepos: readFollowNestedRepos(resolve(root)),
}),
onlyDirs?: ReadonlySet<string>,
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.
return filterByOnlyDirs(
repoFiles.filter(
(f) =>
!f.startsWith(outDir) &&
(!selected || selected.includes(extname(f).toLowerCase())) &&
(languageOf(f) !== null || genericLangOf(f) !== null || containerLangOf(f) !== null),
),
root,
Expand Down Expand Up @@ -105,9 +113,10 @@ export function listSourceStats(
outDir: string,
repoFiles?: string[],
onlyDirs?: ReadonlySet<string>,
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);
Expand Down
2 changes: 2 additions & 0 deletions src/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
2 changes: 1 addition & 1 deletion src/graph/workspace-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/mcp/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
17 changes: 16 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}
Expand Down
Loading
Loading