Selaa lähdekoodia

feat(mcp): steer agents to codegraph during implementation + file-view node mode (#733)

* feat(mcp): steer agents to codegraph during implementation, not just Q&A

Two changes targeting agents that reach for Read during edits instead of codegraph:

1. Reframe the agent-facing steering (server-instructions + codegraph_node/explore
   descriptions): drop "consult BEFORE ... not during"; position codegraph_node as
   the Read upgrade for a named symbol (verbatim current on-disk source, safe to
   Edit from, + caller/callee trail), explore PRIMARY / node SECONDARY, with the
   "cached intelligence — better context, fewer tokens" framing.

2. File-view mode: codegraph_node now accepts a `file` with no `symbol` and returns
   that file's symbol map + graph role (its dependents), plus verbatim bodies with
   includeCode — so it can displace a path-keyed Read, not just a symbol lookup.
   Resolves a path or basename; dedups nested members; budget-capped.

To be A/B'd on an implementation task before shipping (per the retrieval doctrine:
steering changes must be measured, not assumed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): note codegraph_node file-view + implementation steering

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry 2 viikkoa sitten
vanhempi
sitoutus
7175dc456c
4 muutettua tiedostoa jossa 200 lisäystä ja 17 poistoa
  1. 1 0
      CHANGELOG.md
  2. 69 0
      __tests__/node-file-view.test.ts
  3. 15 8
      src/mcp/server-instructions.ts
  4. 115 9
      src/mcp/tools.ts

+ 1 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- The `codegraph_node` MCP tool now accepts a file path on its own (no symbol) and returns that file's symbols plus which files depend on it — and the full source with `includeCode`. It's a drop-in upgrade for reading a source file: the same content, plus the file's blast radius, in one call. The agent-facing guidance was also retuned so assistants reach for codegraph while *implementing* a change (not only when answering questions), since one codegraph call returns more accurate context for fewer tokens than re-reading files.
 - New `codegraph upgrade` command updates CodeGraph to the latest release in place — it detects how you installed (the standalone `install.sh` / `install.ps1` bundle, npm, or npx) and does the right thing for each, on macOS, Linux, and Windows. Use `codegraph upgrade --check` to see whether an update is available without installing, or `codegraph upgrade <version>` to move to a specific version. After upgrading it reminds you to re-index your projects so they pick up the newer engine's improvements. (#679)
 - `codegraph status` now flags when a project's index was built by an older engine than the one you're running and recommends re-indexing (also surfaced in `codegraph status --json`), so you know when a `codegraph index -f` or `codegraph sync` will add coverage a newer release introduced.
 - Cross-file impact and blast-radius coverage now spans **all 22 supported languages and 14 web frameworks**, each validated on a real-world repo — see the new coverage table in the README. This release ships the cross-file resolution behind it, including Lua and Luau `require`, Shopify OS 2.0 Liquid section templates, Delphi form code-behind, Rust cross-module calls and Rocket route macros, Swift Fluent relationships, and the SvelteKit / Nuxt / Vapor / Axum route conventions. The residual everywhere is genuine static-analysis frontiers (runtime dispatch, reflection / DI, framework-convention entry points), never hidden.

+ 69 - 0
__tests__/node-file-view.test.ts

@@ -0,0 +1,69 @@
+/**
+ * codegraph_node FILE-VIEW mode: a bare `file` (no `symbol`) returns that file's
+ * symbol map + graph role (dependents), and verbatim bodies with includeCode —
+ * a Read replacement for a source file that also surfaces the blast radius.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+
+describe('codegraph_node file-view (Read replacement)', () => {
+  let dir: string;
+  let cg: CodeGraph;
+  let h: ToolHandler;
+
+  beforeEach(async () => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fileview-'));
+    fs.mkdirSync(path.join(dir, 'src'));
+    fs.writeFileSync(
+      path.join(dir, 'src', 'a.ts'),
+      'export function helper(x: number) {\n  return x + 1;\n}\nexport class Widget {\n  build() { return helper(1); }\n}\n',
+    );
+    fs.writeFileSync(
+      path.join(dir, 'src', 'b.ts'),
+      "import { helper } from './a';\nexport function useHelper() { return helper(2); }\n",
+    );
+    cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
+    await cg.indexAll();
+    h = new ToolHandler(cg);
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  const text = async (args: Record<string, unknown>): Promise<string> =>
+    (await h.execute('codegraph_node', args)).content.map((c) => c.text).join('\n');
+
+  it("a bare file (no symbol) returns the file's symbols + dependents", async () => {
+    const out = await text({ file: 'a.ts' });
+    expect(out).toContain('src/a.ts');
+    expect(out).toContain('helper');
+    expect(out).toContain('Widget');
+    expect(out).toMatch(/depended on by 1 file/i);
+    expect(out).toContain('src/b.ts'); // the dependent file (blast radius)
+  });
+
+  it('resolves by basename and returns verbatim bodies with includeCode', async () => {
+    const out = await text({ file: 'a.ts', includeCode: true });
+    expect(out).toContain('return x + 1'); // helper body
+    expect(out).toContain('class Widget'); // class body, verbatim
+    // It must NOT steer the agent back to Read — it is the Read replacement.
+    expect(out.toLowerCase()).not.toContain('read `src/a.ts`');
+  });
+
+  it('still works as a normal symbol lookup (no regression)', async () => {
+    const out = await text({ symbol: 'helper', includeCode: true });
+    expect(out).toContain('helper');
+    expect(out).toContain('return x + 1');
+  });
+
+  it('a miss returns a helpful message, not a crash', async () => {
+    const out = await text({ file: 'does-not-exist.ts' });
+    expect(out).toMatch(/no indexed file matches/i);
+  });
+});

+ 15 - 8
src/mcp/server-instructions.ts

@@ -17,15 +17,21 @@
  */
 export const SERVER_INSTRUCTIONS = `# Codegraph — code intelligence over an indexed knowledge graph
 
-Codegraph is a SQLite knowledge graph of every symbol, edge, and file
-in the workspace. Reads are sub-millisecond; the index lags writes by
-about a second through the file watcher. Consult it BEFORE writing or
-editing code, not during.
+Codegraph is a SQLite knowledge graph of every symbol, edge, and file in
+the workspace — pre-computed structure you would otherwise re-derive by
+reading files (cached intelligence: thousands of parse/trace decisions you
+don't pay to re-reason each run). Reads are sub-millisecond; the index lags
+writes by ~1s through the file watcher. Reach for it BEFORE *and* while
+writing or editing code — not just for questions: one call returns the
+verbatim source PLUS who calls it and what it affects, so you edit with the
+blast radius in view. More accurate context, in far fewer tokens and
+round-trips than reading files yourself.
 
-## Answer directly — don't delegate exploration
+## Use codegraph instead of reading files — for questions AND edits
 
-For "how does X work", architecture, trace, or where-is-X questions,
-answer DIRECTLY — usually with ONE \`codegraph_explore\` call.
+Whether you're answering "how does X work" or implementing a change (fixing
+a bug, adding a feature), reach for codegraph before you Read. For
+understanding, answer DIRECTLY — usually with ONE \`codegraph_explore\` call.
 \`codegraph_explore\` takes either a natural-language question or a bag of
 symbol/file names and returns the verbatim source of the relevant symbols
 grouped by file, so it is Read-equivalent and most often the ONLY
@@ -42,7 +48,7 @@ typically one to a few calls; a grep/read exploration is dozens.
 - **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, including dynamic-dispatch hops (callbacks, React re-render, JSX children) grep can't follow
 - **"What is the symbol named X?" (just its location)** → \`codegraph_search\`
 - **"What calls this?" / "What does this call?" / "What would changing this break?"** → \`codegraph_callers\` / \`codegraph_callees\` / \`codegraph_impact\`
-- **One specific symbol's full source (esp. a body \`codegraph_explore\` trimmed), or an OVERLOADED name** → \`codegraph_node\` (with \`includeCode\`): for an ambiguous name it returns EVERY matching definition's body in one call, so you never Read a file to find the right overload
+- **About to read or edit a symbol you can name** → \`codegraph_node\` (SECONDARY — the after-explore depth tool) instead of \`Read\`: it returns the **verbatim current on-disk source** (safe to base an \`Edit\` on) PLUS its caller/callee trail — the same bytes Read gives you, plus who calls it and what your change would break, for fewer tokens. For an OVERLOADED name it returns EVERY matching definition's body in one call, so you never Read a file to find the right overload. Or pass a FILE PATH alone (no symbol) to get that whole file's symbol map + what depends on it — a Read replacement for a source file
 - **"What's in directory X?"** → \`codegraph_files\`
 - **"Is the index ready / what's its size?"** → \`codegraph_status\`
 
@@ -59,6 +65,7 @@ typically one to a few calls; a grep/read exploration is dozens.
 - **Don't grep first** when looking up a symbol by name — \`codegraph_search\` is faster and returns kind + location + signature.
 - **Don't chain \`codegraph_search\` + \`codegraph_node\`** to understand an area — ONE \`codegraph_explore\` returns the relevant symbols' source together in a single round-trip.
 - **Don't loop \`codegraph_node\` over many symbols** — one \`codegraph_explore\` call returns them all grouped by file, while each separate call re-reads the whole context and costs far more. Use \`codegraph_node\` for a single symbol.
+- **Don't \`Read\` a file just to see or edit a symbol you can name** — \`codegraph_node\` returns the same current source plus its caller/callee trail in one call, for fewer tokens. Reach for raw \`Read\` only for what codegraph doesn't index (configs, docs) or when the staleness banner flags a file as pending re-index.
 - **After editing, check the staleness banner.** When a tool response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Every file NOT in that banner is fresh, so still trust codegraph. \`codegraph_status\` also lists pending files under "Pending sync".
 
 ## Limitations

+ 115 - 9
src/mcp/tools.ts

@@ -463,22 +463,22 @@ export const tools: ToolDefinition[] = [
   },
   {
     name: 'codegraph_node',
-    description: 'SECONDARY (after codegraph_explore): get ONE symbol in full — its location, signature, callers/callees trail, and verbatim body (includeCode=true). When the name is AMBIGUOUS (an overloaded method, or the same method name on different types), it returns EVERY matching definition\'s full body in a single call — so you never need to Read a file to find the specific overload you want. For a heavily-overloaded name, pass `file` (and/or `line`) to pin the exact definition — e.g. the `file:line` a trail or another tool already showed you. Reach for this when explore trimmed a body you need. Use codegraph_explore for several related symbols or the full flow.',
+    description: 'SECONDARY (after codegraph_explore): the Read upgrade for ONE symbol you can name. Returns its location, signature, the verbatim CURRENT on-disk source (includeCode=true — the same bytes Read would give you, safe to base an Edit on), AND its caller/callee trail in a single call — so before changing a symbol you already see what calls it and what your edit would break, for fewer tokens than reading the file. Prefer it over Read whenever you know the symbol name. Or pass `file` ALONE (no symbol) to get that whole source file\'s symbol map + what depends on it — a Read replacement for a file. When the name is AMBIGUOUS (an overloaded method, or the same name on different types) it returns EVERY matching definition\'s full body in one call — so you never Read a file to find the right overload; pass `file` (and/or `line`) to pin one. Use codegraph_explore for several related symbols or the full flow.',
     inputSchema: {
       type: 'object',
       properties: {
         symbol: {
           type: 'string',
-          description: 'Name of the symbol to get details for',
+          description: 'Name of the symbol to get details for. Omit it and pass `file` alone to get the whole file\'s symbols + dependents (a Read replacement).',
         },
         includeCode: {
           type: 'boolean',
-          description: 'Include full source code (default: false to minimize context)',
+          description: 'Include full source bodies (default: false to minimize context). In file mode, returns every symbol\'s body up to a size budget.',
           default: false,
         },
         file: {
           type: 'string',
-          description: 'Optional: disambiguate an overloaded name to the definition in this file (path or basename, e.g. "harness.rs").',
+          description: 'A file path or basename (e.g. "harness.rs", "src/auth/session.ts"). Pass it ALONE (no symbol) to get that whole file\'s symbol map + dependents — a Read replacement. Or pass it WITH a symbol to disambiguate an overloaded name to the definition in this file.',
         },
         line: {
           type: 'number',
@@ -486,12 +486,12 @@ export const tools: ToolDefinition[] = [
         },
         projectPath: projectPathProperty,
       },
-      required: ['symbol'],
+      required: [],
     },
   },
   {
     name: 'codegraph_explore',
-    description: 'PRIMARY TOOL — call FIRST for almost any question: how does X work, architecture, a bug, where/what is X, or surveying an area. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — do NOT re-open shown files). Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — answers without further search/node/Read/Grep.',
+    description: 'PRIMARY TOOL — call FIRST for almost any question OR before an edit: how does X work, architecture, a bug, where/what is X, surveying an area, or the symbols you are about to change. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — treat the shown source as already Read; do NOT re-open those files), plus the call path among them. Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — more accurate context, in far fewer tokens and round-trips than a search/Read/Grep loop.',
     inputSchema: {
       type: 'object',
       properties: {
@@ -2522,14 +2522,23 @@ export class ToolHandler {
    * Handle codegraph_node
    */
   private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
-    const symbol = this.validateString(args.symbol, 'symbol');
-    if (typeof symbol !== 'string') return symbol;
-
     const cg = this.getCodeGraph(args.projectPath as string | undefined);
     // Default to false to minimize context usage
     const includeCode = args.includeCode === true;
     const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
     const lineHint = typeof args.line === 'number' && args.line > 0 ? args.line : undefined;
+    const symbolRaw = typeof args.symbol === 'string' ? args.symbol.trim() : '';
+
+    // FILE-VIEW MODE: a bare `file` with no `symbol` returns that file's symbol
+    // map + graph role (which files depend on it) — and, with includeCode, the
+    // bodies. A Read replacement for "show me file X" that also surfaces the
+    // blast radius, so an edit is made with impact in view.
+    if (!symbolRaw && fileHint) {
+      return this.handleFileView(cg, fileHint, includeCode);
+    }
+
+    const symbol = this.validateString(args.symbol, 'symbol');
+    if (typeof symbol !== 'string') return symbol;
 
     let matches = this.findSymbolMatches(cg, symbol);
     if (matches.length === 0) {
@@ -2624,6 +2633,103 @@ export class ToolHandler {
     return this.textResult(this.truncateOutput(out.join('\n')));
   }
 
+  /**
+   * FILE-VIEW: resolve `fileArg` (path or basename) to an indexed file and
+   * return its symbol map + graph role (which files depend on it), plus bodies
+   * when `includeCode`. A Read replacement that also surfaces the blast radius.
+   */
+  private async handleFileView(cg: CodeGraph, fileArg: string, includeCode: boolean): Promise<ToolResult> {
+    const normalize = (p: string) => p.replace(/\\/g, '/').replace(/^(?:\.?\/+)+/, '').replace(/\/+$/, '');
+    const wantLower = normalize(fileArg).toLowerCase();
+    const allFiles = cg.getFiles();
+    if (allFiles.length === 0) return this.textResult('No files indexed. Run `codegraph index` first.');
+
+    let resolved = allFiles.find((f) => f.path.toLowerCase() === wantLower);
+    let candidates: typeof allFiles = [];
+    if (!resolved) {
+      candidates = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower));
+      if (candidates.length === 1) resolved = candidates[0];
+    }
+    if (!resolved && candidates.length === 0) {
+      candidates = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower));
+      if (candidates.length === 1) resolved = candidates[0];
+    }
+    if (!resolved && candidates.length > 1) {
+      return this.textResult(
+        [`"${fileArg}" matches ${candidates.length} indexed files — pass a longer path:`, '',
+          ...candidates.slice(0, 25).map((f) => `- ${f.path}`)].join('\n'),
+      );
+    }
+    if (!resolved) {
+      return this.textResult(
+        `No indexed file matches "${fileArg}". Codegraph indexes source files; configs/docs it doesn't parse won't appear — Read those directly.`,
+      );
+    }
+
+    const filePath = resolved.path;
+    const nodes = cg.getNodesInFile(filePath)
+      .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
+      .sort((a, b) => a.startLine - b.startLine);
+    const dependents = cg.getFileDependents(filePath);
+
+    const out: string[] = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}`];
+    if (dependents.length) {
+      out.push(
+        `Depended on by ${dependents.length} file${dependents.length === 1 ? '' : 's'}` +
+          `${dependents.length > 8 ? ' (first 8)' : ''}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? ', …' : ''}`,
+        '> Editing a symbol here can affect those files — run codegraph_impact on the specific symbol for its exact blast radius.',
+      );
+    } else {
+      out.push('No other indexed file depends on this one.');
+    }
+    out.push('');
+
+    if (nodes.length === 0) {
+      out.push('_No indexed symbols in this file (codegraph may track it but not parse it for symbols)._');
+      return this.textResult(this.truncateOutput(out.join('\n')));
+    }
+
+    if (!includeCode) {
+      out.push('### Symbols');
+      for (const n of nodes) {
+        const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : '';
+        out.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`);
+      }
+      out.push('', '> Call again with `includeCode:true` for the bodies, or `codegraph_node <name>` for one symbol in full.');
+      return this.textResult(this.truncateOutput(out.join('\n')));
+    }
+
+    // Render each OUTERMOST symbol's verbatim body (a container's body already
+    // includes its members, so skip anything covered) — no duplication, and no
+    // "read the file" container outline. Budget-capped.
+    out.push('### Source (verbatim — treat as already Read)');
+    const BODY_BUDGET = 14000;
+    const outermost = [...nodes].sort((a, b) =>
+      a.startLine - b.startLine || (b.endLine ?? b.startLine) - (a.endLine ?? a.startLine));
+    const covered: Array<[number, number]> = [];
+    let used = out.join('\n').length;
+    const listed: Node[] = [];
+    for (const n of outermost) {
+      const end = n.endLine ?? n.startLine;
+      if (covered.some(([s, e]) => s <= n.startLine && e >= end)) continue;
+      const code = await cg.getCode(n.id);
+      if (!code) continue;
+      const section = `#### \`${n.name}\` (${n.kind}) — :${n.startLine}\n\`\`\`\n${code}\n\`\`\``;
+      if (used + section.length <= BODY_BUDGET || used < 1500) {
+        out.push('', section);
+        used += section.length;
+        covered.push([n.startLine, end]);
+      } else {
+        listed.push(n);
+      }
+    }
+    if (listed.length) {
+      out.push('', `### ${listed.length} more symbol${listed.length === 1 ? '' : 's'} (over the size budget — fetch with codegraph_node <name>)`,
+        ...listed.slice(0, 30).map((n) => `- \`${n.name}\` (${n.kind}) — :${n.startLine}`));
+    }
+    return this.textResult(this.truncateOutput(out.join('\n')));
+  }
+
   /** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
   private async renderNodeSection(cg: CodeGraph, node: Node, includeCode: boolean): Promise<string> {
     let code: string | null = null;