diff --git a/CLAUDE.md b/CLAUDE.md index 680ceb66..77b42284 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,8 @@ Codegraph is **our own tool**. Use it to analyze this repository before making c node src/cli.js build . # Build/update the graph (incremental) node src/cli.js map --limit 20 # Module overview & most-connected nodes node src/cli.js stats # Graph health and quality score -node src/cli.js fn -T # Function call chain (callers + callees) +node src/cli.js query -T # Function call chain (callers + callees) +node src/cli.js query --path -T # Shortest path between two symbols node src/cli.js deps src/.js # File-level imports and importers node src/cli.js diff-impact main # Impact of current branch vs main node src/cli.js complexity -T # Per-function complexity metrics diff --git a/README.md b/README.md index 6784fe9a..b09aefc2 100644 --- a/README.md +++ b/README.md @@ -237,12 +237,12 @@ codegraph explain # Function summary: signature, calls, callers, te ```bash codegraph impact # Transitive reverse dependency trace -codegraph fn # Function-level: callers, callees, call chain -codegraph fn --no-tests --depth 5 +codegraph query # Function-level: callers, callees, call chain +codegraph query --no-tests --depth 5 codegraph fn-impact # What functions break if this one changes -codegraph path # Shortest path between two symbols (A calls...calls B) -codegraph path --reverse # Follow edges backward -codegraph path --max-depth 5 --kinds calls,imports +codegraph query --path # Shortest path between two symbols (A calls...calls B) +codegraph query --path --reverse # Follow edges backward +codegraph query --path --depth 5 --kinds calls,imports codegraph diff-impact # Impact of unstaged git changes codegraph diff-impact --staged # Impact of staged changes codegraph diff-impact HEAD~3 # Impact vs a specific ref @@ -566,8 +566,8 @@ This project uses codegraph. The database is at `.codegraph/graph.db`. ### Other useful commands - `codegraph build .` — rebuild the graph (incremental by default) - `codegraph map` — module overview -- `codegraph fn -T` — function call chain -- `codegraph path -T` — shortest call path between two symbols +- `codegraph query -T` — function call chain (callers + callees) +- `codegraph query --path -T` — shortest call path between two symbols - `codegraph deps ` — file-level dependencies - `codegraph roles --role dead -T` — find dead code (unreferenced symbols) - `codegraph roles --role core -T` — find core symbols (high fan-in) diff --git a/src/batch.js b/src/batch.js index c6657c83..2a703a3c 100644 --- a/src/batch.js +++ b/src/batch.js @@ -15,7 +15,6 @@ import { fnDepsData, fnImpactData, impactAnalysisData, - queryNameData, whereData, } from './queries.js'; @@ -32,8 +31,7 @@ export const BATCH_COMMANDS = { context: { fn: contextData, sig: 'name' }, explain: { fn: explainData, sig: 'target' }, where: { fn: whereData, sig: 'target' }, - query: { fn: queryNameData, sig: 'name' }, - fn: { fn: fnDepsData, sig: 'name' }, + query: { fn: fnDepsData, sig: 'name' }, impact: { fn: impactAnalysisData, sig: 'file' }, deps: { fn: fileDepsData, sig: 'file' }, flow: { fn: flowData, sig: 'name' }, diff --git a/src/cli.js b/src/cli.js index e5b95942..ddd853aa 100644 --- a/src/cli.js +++ b/src/cli.js @@ -29,7 +29,6 @@ import { fnImpact, impactAnalysis, moduleMap, - queryName, roles, stats, symbolPath, @@ -106,8 +105,16 @@ program program .command('query ') - .description('Find a function/class, show callers and callees') + .description('Function-level dependency chain or shortest path between symbols') .option('-d, --db ', 'Path to graph.db') + .option('--depth ', 'Transitive caller depth', '3') + .option('-f, --file ', 'Scope search to functions in this file (partial match)') + .option('-k, --kind ', 'Filter to a specific symbol kind') + .option('--path ', 'Path mode: find shortest path to ') + .option('--kinds ', 'Path mode: comma-separated edge kinds to follow (default: calls)') + .option('--reverse', 'Path mode: follow edges backward') + .option('--from-file ', 'Path mode: disambiguate source symbol by file') + .option('--to-file ', 'Path mode: disambiguate target symbol by file') .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') @@ -115,13 +122,33 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--ndjson', 'Newline-delimited JSON output') .action((name, opts) => { - queryName(name, opts.db, { - noTests: resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - }); + if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + process.exit(1); + } + if (opts.path) { + symbolPath(name, opts.path, opts.db, { + maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10, + edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined, + reverse: opts.reverse, + fromFile: opts.fromFile, + toFile: opts.toFile, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + }); + } else { + fnDeps(name, opts.db, { + depth: parseInt(opts.depth, 10), + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); + } }); program @@ -190,36 +217,6 @@ program }); }); -program - .command('fn ') - .description('Function-level dependencies: callers, callees, and transitive call chain') - .option('-d, --db ', 'Path to graph.db') - .option('--depth ', 'Transitive caller depth', '3') - .option('-f, --file ', 'Scope search to functions in this file (partial match)') - .option('-k, --kind ', 'Filter to a specific symbol kind') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .option('--limit ', 'Max results to return') - .option('--offset ', 'Skip N results (default: 0)') - .option('--ndjson', 'Newline-delimited JSON output') - .action((name, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); - process.exit(1); - } - fnDeps(name, opts.db, { - depth: parseInt(opts.depth, 10), - file: opts.file, - kind: opts.kind, - noTests: resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - }); - }); - program .command('fn-impact ') .description('Function-level impact: what functions break if this one changes') @@ -250,36 +247,6 @@ program }); }); -program - .command('path ') - .description('Find shortest path between two symbols (A calls...calls B)') - .option('-d, --db ', 'Path to graph.db') - .option('--max-depth ', 'Maximum BFS depth', '10') - .option('--kinds ', 'Comma-separated edge kinds to follow (default: calls)') - .option('--reverse', 'Follow edges backward (B is called by...called by A)') - .option('--from-file ', 'Disambiguate source symbol by file (partial match)') - .option('--to-file ', 'Disambiguate target symbol by file (partial match)') - .option('-k, --kind ', 'Filter both symbols by kind') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .action((from, to, opts) => { - if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); - process.exit(1); - } - symbolPath(from, to, opts.db, { - maxDepth: parseInt(opts.maxDepth, 10), - edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined, - reverse: opts.reverse, - fromFile: opts.fromFile, - toFile: opts.toFile, - kind: opts.kind, - noTests: resolveNoTests(opts), - json: opts.json, - }); - }); - program .command('context ') .description('Full context for a function: source, deps, callers, tests, signature') @@ -980,7 +947,6 @@ program .option('--ndjson', 'Newline-delimited JSON output') .option('--limit ', 'Max results to return') .option('--offset ', 'Skip N results (default: 0)') - .option('--path ', 'Find data flow path to ') .option('--impact', 'Show data-dependent blast radius') .option('--depth ', 'Max traversal depth', '5') .action(async (name, opts) => { @@ -997,7 +963,6 @@ program ndjson: opts.ndjson, limit: opts.limit ? parseInt(opts.limit, 10) : undefined, offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - path: opts.path, impact: opts.impact, depth: opts.depth, }); diff --git a/src/dataflow.js b/src/dataflow.js index e0ae266b..3ac27a5c 100644 --- a/src/dataflow.js +++ b/src/dataflow.js @@ -1089,9 +1089,6 @@ export function dataflowImpactData(name, customDbPath, opts = {}) { * CLI display for dataflow command. */ export function dataflow(name, customDbPath, opts = {}) { - if (opts.path) { - return dataflowPath(name, opts.path, customDbPath, opts); - } if (opts.impact) { return dataflowImpact(name, customDbPath, opts); } @@ -1168,40 +1165,6 @@ export function dataflow(name, customDbPath, opts = {}) { } } -/** - * CLI display for dataflow --path. - */ -function dataflowPath(from, to, customDbPath, opts = {}) { - const data = dataflowPathData(from, to, customDbPath, { - noTests: opts.noTests, - maxDepth: opts.depth ? Number(opts.depth) : 10, - }); - - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - - if (data.warning) { - console.log(`⚠ ${data.warning}`); - return; - } - if (!data.found) { - console.log(data.error || `No data flow path found from "${from}" to "${to}".`); - return; - } - - console.log( - `\nData flow path: ${from} → ${to} (${data.hops} hop${data.hops !== 1 ? 's' : ''})\n`, - ); - for (let i = 0; i < data.path.length; i++) { - const p = data.path[i]; - const prefix = i === 0 ? ' ●' : ` ${'│ '.repeat(i - 1)}├─`; - const edge = p.edgeKind ? ` [${p.edgeKind}]` : ''; - console.log(`${prefix} ${p.name} (${p.file}:${p.line})${edge}`); - } -} - /** * CLI display for dataflow --impact. */ diff --git a/src/mcp.js b/src/mcp.js index 66cba606..405b09c2 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -25,17 +25,44 @@ const PAGINATION_PROPS = { const BASE_TOOLS = [ { - name: 'query_function', - description: 'Find callers and callees of a function by name', + name: 'query', + description: + 'Query the call graph: find callers/callees with transitive chain, or find shortest path between two symbols', inputSchema: { type: 'object', properties: { - name: { type: 'string', description: 'Function name to query (supports partial match)' }, + name: { type: 'string', description: 'Function/method/class name (partial match)' }, + mode: { + type: 'string', + enum: ['deps', 'path'], + description: 'deps (default): dependency chain. path: shortest path to target', + }, depth: { type: 'number', - description: 'Traversal depth for transitive callers', - default: 2, + description: 'Transitive depth (deps default: 3, path default: 10)', + }, + file: { + type: 'string', + description: 'Scope search to functions in this file (partial match)', }, + kind: { + type: 'string', + enum: ALL_SYMBOL_KINDS, + description: 'Filter by symbol kind', + }, + to: { type: 'string', description: 'Target symbol for path mode (required in path mode)' }, + edge_kinds: { + type: 'array', + items: { type: 'string' }, + description: 'Edge kinds to follow in path mode (default: ["calls"])', + }, + reverse: { + type: 'boolean', + description: 'Follow edges backward in path mode', + default: false, + }, + from_file: { type: 'string', description: 'Disambiguate source by file in path mode' }, + to_file: { type: 'string', description: 'Disambiguate target by file in path mode' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, ...PAGINATION_PROPS, }, @@ -87,29 +114,6 @@ const BASE_TOOLS = [ }, }, }, - { - name: 'fn_deps', - description: 'Show function-level dependency chain: what a function calls and what calls it', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Function/method/class name (partial match)' }, - depth: { type: 'number', description: 'Transitive caller depth', default: 3 }, - file: { - type: 'string', - description: 'Scope search to functions in this file (partial match)', - }, - kind: { - type: 'string', - enum: ALL_SYMBOL_KINDS, - description: 'Filter to a specific symbol kind', - }, - no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, - ...PAGINATION_PROPS, - }, - required: ['name'], - }, - }, { name: 'fn_impact', description: @@ -134,33 +138,6 @@ const BASE_TOOLS = [ required: ['name'], }, }, - { - name: 'symbol_path', - description: 'Find the shortest path between two symbols in the call graph (A calls...calls B)', - inputSchema: { - type: 'object', - properties: { - from: { type: 'string', description: 'Source symbol name (partial match)' }, - to: { type: 'string', description: 'Target symbol name (partial match)' }, - max_depth: { type: 'number', description: 'Maximum BFS depth', default: 10 }, - edge_kinds: { - type: 'array', - items: { type: 'string' }, - description: 'Edge kinds to follow (default: ["calls"])', - }, - reverse: { type: 'boolean', description: 'Follow edges backward', default: false }, - from_file: { type: 'string', description: 'Disambiguate source by file (partial match)' }, - to_file: { type: 'string', description: 'Disambiguate target by file (partial match)' }, - kind: { - type: 'string', - enum: ALL_SYMBOL_KINDS, - description: 'Filter both symbols by kind', - }, - no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, - }, - required: ['from', 'to'], - }, - }, { name: 'context', description: @@ -396,14 +373,19 @@ const BASE_TOOLS = [ { name: 'execution_flow', description: - 'Trace execution flow forward from an entry point (route, command, event) through callees to leaf functions. Answers "what happens when X is called?"', + 'Trace execution flow forward from an entry point through callees to leaves, or list all entry points with list=true', inputSchema: { type: 'object', properties: { name: { type: 'string', description: - 'Entry point or function name (e.g. "POST /login", "build"). Supports prefix-stripped matching.', + 'Entry point or function name (required unless list=true). Supports prefix-stripped matching.', + }, + list: { + type: 'boolean', + description: 'List all entry points grouped by type', + default: false, }, depth: { type: 'number', description: 'Max forward traversal depth', default: 10 }, file: { @@ -418,19 +400,6 @@ const BASE_TOOLS = [ no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, ...PAGINATION_PROPS, }, - required: ['name'], - }, - }, - { - name: 'list_entry_points', - description: - 'List all framework entry points (routes, commands, events) in the codebase, grouped by type', - inputSchema: { - type: 'object', - properties: { - no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, - ...PAGINATION_PROPS, - }, }, }, { @@ -568,10 +537,10 @@ const BASE_TOOLS = [ 'explain', 'where', 'query', - 'fn', 'impact', 'deps', 'flow', + 'dataflow', 'complexity', ], description: 'The query command to run for each target', @@ -658,18 +627,16 @@ const BASE_TOOLS = [ }, { name: 'dataflow', - description: - 'Show data flow edges: what data flows in/out of a function, return value consumers, mutations. Requires build --dataflow.', + description: 'Show data flow edges or data-dependent blast radius. Requires build --dataflow.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Function/method name (partial match)' }, mode: { type: 'string', - enum: ['edges', 'path', 'impact'], - description: 'edges (default), path, or impact', + enum: ['edges', 'impact'], + description: 'edges (default) or impact', }, - target: { type: 'string', description: 'Target symbol for path mode' }, depth: { type: 'number', description: 'Max depth for impact mode', default: 5 }, file: { type: 'string', description: 'Scope to file (partial match)' }, kind: { type: 'string', enum: ALL_SYMBOL_KINDS, description: 'Filter by symbol kind' }, @@ -766,7 +733,6 @@ export async function startMCPServer(customDbPath, options = {}) { // Lazy import query functions to avoid circular deps at module load const { - queryNameData, impactAnalysisData, moduleMapData, fileDepsData, @@ -824,13 +790,34 @@ export async function startMCPServer(customDbPath, options = {}) { let result; switch (name) { - case 'query_function': - result = queryNameData(args.name, dbPath, { - noTests: args.no_tests, - limit: Math.min(args.limit ?? MCP_DEFAULTS.query_function, MCP_MAX_LIMIT), - offset: args.offset ?? 0, - }); + case 'query': { + const qMode = args.mode || 'deps'; + if (qMode === 'path') { + if (!args.to) { + result = { error: 'path mode requires a "to" argument' }; + break; + } + result = pathData(args.name, args.to, dbPath, { + maxDepth: args.depth ?? 10, + edgeKinds: args.edge_kinds, + reverse: args.reverse, + fromFile: args.from_file, + toFile: args.to_file, + kind: args.kind, + noTests: args.no_tests, + }); + } else { + result = fnDepsData(args.name, dbPath, { + depth: args.depth, + file: args.file, + kind: args.kind, + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.query, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); + } break; + } case 'file_deps': result = fileDepsData(args.file, dbPath, { noTests: args.no_tests, @@ -855,16 +842,6 @@ export async function startMCPServer(customDbPath, options = {}) { case 'module_map': result = moduleMapData(dbPath, args.limit || 20, { noTests: args.no_tests }); break; - case 'fn_deps': - result = fnDepsData(args.name, dbPath, { - depth: args.depth, - file: args.file, - kind: args.kind, - noTests: args.no_tests, - limit: Math.min(args.limit ?? MCP_DEFAULTS.fn_deps, MCP_MAX_LIMIT), - offset: args.offset ?? 0, - }); - break; case 'fn_impact': result = fnImpactData(args.name, dbPath, { depth: args.depth, @@ -875,17 +852,6 @@ export async function startMCPServer(customDbPath, options = {}) { offset: args.offset ?? 0, }); break; - case 'symbol_path': - result = pathData(args.from, args.to, dbPath, { - maxDepth: args.max_depth, - edgeKinds: args.edge_kinds, - reverse: args.reverse, - fromFile: args.from_file, - toFile: args.to_file, - kind: args.kind, - noTests: args.no_tests, - }); - break; case 'context': result = contextData(args.name, dbPath, { depth: args.depth, @@ -1083,24 +1049,28 @@ export async function startMCPServer(customDbPath, options = {}) { break; } case 'execution_flow': { - const { flowData } = await import('./flow.js'); - result = flowData(args.name, dbPath, { - depth: args.depth, - file: args.file, - kind: args.kind, - noTests: args.no_tests, - limit: Math.min(args.limit ?? MCP_DEFAULTS.execution_flow, MCP_MAX_LIMIT), - offset: args.offset ?? 0, - }); - break; - } - case 'list_entry_points': { - const { listEntryPointsData } = await import('./flow.js'); - result = listEntryPointsData(dbPath, { - noTests: args.no_tests, - limit: Math.min(args.limit ?? MCP_DEFAULTS.list_entry_points, MCP_MAX_LIMIT), - offset: args.offset ?? 0, - }); + if (args.list) { + const { listEntryPointsData } = await import('./flow.js'); + result = listEntryPointsData(dbPath, { + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.execution_flow, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); + } else { + if (!args.name) { + result = { error: 'Provide a name or set list=true' }; + break; + } + const { flowData } = await import('./flow.js'); + result = flowData(args.name, dbPath, { + depth: args.depth, + file: args.file, + kind: args.kind, + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.execution_flow, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); + } break; } case 'complexity': { @@ -1197,18 +1167,8 @@ export async function startMCPServer(customDbPath, options = {}) { break; } case 'dataflow': { - const mode = args.mode || 'edges'; - if (mode === 'path') { - if (!args.target) { - result = { error: 'path mode requires a "target" argument' }; - break; - } - const { dataflowPathData } = await import('./dataflow.js'); - result = dataflowPathData(args.name, args.target, dbPath, { - noTests: args.no_tests, - maxDepth: args.depth ?? 10, - }); - } else if (mode === 'impact') { + const dfMode = args.mode || 'edges'; + if (dfMode === 'impact') { const { dataflowImpactData } = await import('./dataflow.js'); result = dataflowImpactData(args.name, dbPath, { depth: args.depth, @@ -1224,7 +1184,7 @@ export async function startMCPServer(customDbPath, options = {}) { file: args.file, kind: args.kind, noTests: args.no_tests, - limit: Math.min(args.limit ?? MCP_DEFAULTS.fn_deps, MCP_MAX_LIMIT), + limit: Math.min(args.limit ?? MCP_DEFAULTS.query, MCP_MAX_LIMIT), offset: args.offset ?? 0, }); } diff --git a/src/paginate.js b/src/paginate.js index bae52467..8802b65a 100644 --- a/src/paginate.js +++ b/src/paginate.js @@ -9,13 +9,11 @@ export const MCP_DEFAULTS = { // Existing list_functions: 100, - query_function: 50, + query: 10, where: 50, node_roles: 100, - list_entry_points: 100, export_graph: 500, // Smaller defaults for rich/nested results - fn_deps: 10, fn_impact: 5, context: 5, explain: 10, diff --git a/tests/integration/batch.test.js b/tests/integration/batch.test.js index 133fd3e9..85d7775d 100644 --- a/tests/integration/batch.test.js +++ b/tests/integration/batch.test.js @@ -114,12 +114,6 @@ describe('batchData — success', () => { expect(data.results[0].data.name).toBe('authenticate'); }); - test('fn: returns dependency chain', () => { - const data = batchData('fn', ['handleRoute'], dbPath); - expect(data.succeeded).toBe(1); - expect(data.results[0].ok).toBe(true); - }); - test('context: with depth option', () => { const data = batchData('context', ['authenticate'], dbPath, { depth: 1 }); expect(data.succeeded).toBe(1); @@ -177,10 +171,10 @@ describe('batchData — edge cases', () => { 'explain', 'where', 'query', - 'fn', 'impact', 'deps', 'flow', + 'dataflow', 'complexity', ]; for (const cmd of expected) { diff --git a/tests/integration/cli.test.js b/tests/integration/cli.test.js index 10eac6d2..c225ae19 100644 --- a/tests/integration/cli.test.js +++ b/tests/integration/cli.test.js @@ -101,9 +101,9 @@ describe('CLI smoke tests', () => { expect(data).toHaveProperty('results'); }); - // ─── Fn ────────────────────────────────────────────────────────────── - test('fn --json returns valid JSON with results', () => { - const out = run('fn', 'add', '--db', dbPath, '--json'); + // ─── Query (deps mode, formerly fn) ────────────────────────────────── + test('query --json returns fnDeps-style results', () => { + const out = run('query', 'add', '--db', dbPath, '--json'); const data = JSON.parse(out); expect(data).toHaveProperty('results'); }); @@ -115,9 +115,9 @@ describe('CLI smoke tests', () => { expect(data).toHaveProperty('results'); }); - // ─── Path ─────────────────────────────────────────────────────────── - test('path --json returns valid JSON with path info', () => { - const out = run('path', 'sumOfSquares', 'add', '--db', dbPath, '--json'); + // ─── Query (path mode, formerly path) ──────────────────────────────── + test('query --path --json returns valid JSON with path info', () => { + const out = run('query', 'sumOfSquares', '--path', 'add', '--db', dbPath, '--json'); const data = JSON.parse(out); expect(data).toHaveProperty('found'); expect(data).toHaveProperty('path'); diff --git a/tests/integration/pagination.test.js b/tests/integration/pagination.test.js index 46824881..f938ee1c 100644 --- a/tests/integration/pagination.test.js +++ b/tests/integration/pagination.test.js @@ -417,7 +417,7 @@ describe('explainData with pagination', () => { describe('MCP new defaults', () => { test('MCP_DEFAULTS has new pagination keys', () => { - expect(MCP_DEFAULTS.fn_deps).toBe(10); + expect(MCP_DEFAULTS.query).toBe(10); expect(MCP_DEFAULTS.fn_impact).toBe(5); expect(MCP_DEFAULTS.context).toBe(5); expect(MCP_DEFAULTS.explain).toBe(10); @@ -574,10 +574,9 @@ describe('printNdjson', () => { describe('MCP defaults', () => { test('MCP_DEFAULTS has expected keys', () => { expect(MCP_DEFAULTS.list_functions).toBe(100); - expect(MCP_DEFAULTS.query_function).toBe(50); + expect(MCP_DEFAULTS.query).toBe(10); expect(MCP_DEFAULTS.where).toBe(50); expect(MCP_DEFAULTS.node_roles).toBe(100); - expect(MCP_DEFAULTS.list_entry_points).toBe(100); expect(MCP_DEFAULTS.export_graph).toBe(500); }); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 395878ec..fc610c4b 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -9,14 +9,12 @@ import { describe, expect, it, vi } from 'vitest'; import { buildToolList, TOOLS } from '../../src/mcp.js'; const ALL_TOOL_NAMES = [ - 'query_function', + 'query', 'file_deps', 'impact_analysis', 'find_cycles', 'module_map', - 'fn_deps', 'fn_impact', - 'symbol_path', 'context', 'explain', 'where', @@ -29,7 +27,6 @@ const ALL_TOOL_NAMES = [ 'co_changes', 'node_roles', 'execution_flow', - 'list_entry_points', 'complexity', 'manifesto', 'communities', @@ -63,9 +60,15 @@ describe('TOOLS', () => { } }); - it('query_function requires name parameter', () => { - const qf = TOOLS.find((t) => t.name === 'query_function'); - expect(qf.inputSchema.required).toContain('name'); + it('query requires name parameter and has mode enum', () => { + const q = TOOLS.find((t) => t.name === 'query'); + expect(q.inputSchema.required).toContain('name'); + expect(q.inputSchema.properties).toHaveProperty('mode'); + expect(q.inputSchema.properties.mode.enum).toEqual(['deps', 'path']); + expect(q.inputSchema.properties).toHaveProperty('to'); + expect(q.inputSchema.properties).toHaveProperty('file'); + expect(q.inputSchema.properties).toHaveProperty('kind'); + expect(q.inputSchema.properties.kind.enum).toBeDefined(); }); it('file_deps requires file parameter', () => { @@ -89,16 +92,6 @@ describe('TOOLS', () => { expect(mm.inputSchema.required).toBeUndefined(); }); - it('fn_deps requires name parameter', () => { - const fd = TOOLS.find((t) => t.name === 'fn_deps'); - expect(fd.inputSchema.required).toContain('name'); - expect(fd.inputSchema.properties).toHaveProperty('depth'); - expect(fd.inputSchema.properties).toHaveProperty('no_tests'); - expect(fd.inputSchema.properties).toHaveProperty('file'); - expect(fd.inputSchema.properties).toHaveProperty('kind'); - expect(fd.inputSchema.properties.kind.enum).toBeDefined(); - }); - it('fn_impact requires name parameter', () => { const fi = TOOLS.find((t) => t.name === 'fn_impact'); expect(fi.inputSchema.required).toContain('name'); @@ -109,21 +102,6 @@ describe('TOOLS', () => { expect(fi.inputSchema.properties.kind.enum).toBeDefined(); }); - it('symbol_path requires from and to parameters', () => { - const sp = TOOLS.find((t) => t.name === 'symbol_path'); - expect(sp).toBeDefined(); - expect(sp.inputSchema.required).toContain('from'); - expect(sp.inputSchema.required).toContain('to'); - expect(sp.inputSchema.properties).toHaveProperty('max_depth'); - expect(sp.inputSchema.properties).toHaveProperty('edge_kinds'); - expect(sp.inputSchema.properties).toHaveProperty('reverse'); - expect(sp.inputSchema.properties).toHaveProperty('from_file'); - expect(sp.inputSchema.properties).toHaveProperty('to_file'); - expect(sp.inputSchema.properties).toHaveProperty('kind'); - expect(sp.inputSchema.properties.kind.enum).toBeDefined(); - expect(sp.inputSchema.properties).toHaveProperty('no_tests'); - }); - it('where requires target parameter', () => { const w = TOOLS.find((t) => t.name === 'where'); expect(w).toBeDefined(); @@ -243,7 +221,7 @@ describe('buildToolList', () => { describe('startMCPServer handler dispatch', () => { // We test the handler logic by mocking the SDK and capturing the registered handlers - it('dispatches query_function to queryNameData', async () => { + it('dispatches query to fnDepsData', async () => { const handlers = {}; // Mock the SDK modules @@ -265,7 +243,6 @@ describe('startMCPServer handler dispatch', () => { // Mock query functions vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(() => ({ query: 'test', results: [] })), impactAnalysisData: vi.fn(() => ({ file: 'test', sources: [] })), moduleMapData: vi.fn(() => ({ topNodes: [], stats: {} })), fileDepsData: vi.fn(() => ({ file: 'test', results: [] })), @@ -289,9 +266,9 @@ describe('startMCPServer handler dispatch', () => { expect(toolsList.tools.length).toBe(ALL_TOOL_NAMES.length - 1); expect(toolsList.tools.map((t) => t.name)).not.toContain('list_repos'); - // Test query_function dispatch + // Test query dispatch const result = await handlers['tools/call']({ - params: { name: 'query_function', arguments: { name: 'test' } }, + params: { name: 'query', arguments: { name: 'test' } }, }); expect(result.content[0].type).toBe('text'); expect(result.isError).toBeUndefined(); @@ -308,7 +285,7 @@ describe('startMCPServer handler dispatch', () => { vi.doUnmock('../../src/queries.js'); }); - it('dispatches fn_deps to fnDepsData', async () => { + it('dispatches query deps mode to fnDepsData with options', async () => { const handlers = {}; vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({ @@ -329,7 +306,6 @@ describe('startMCPServer handler dispatch', () => { const fnDepsMock = vi.fn(() => ({ name: 'myFn', results: [{ callers: [] }] })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -349,7 +325,7 @@ describe('startMCPServer handler dispatch', () => { const result = await handlers['tools/call']({ params: { - name: 'fn_deps', + name: 'query', arguments: { name: 'myFn', depth: 5, file: 'src/app.js', kind: 'function', no_tests: true }, }, }); @@ -389,7 +365,6 @@ describe('startMCPServer handler dispatch', () => { const fnImpactMock = vi.fn(() => ({ name: 'test', results: [] })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -446,7 +421,6 @@ describe('startMCPServer handler dispatch', () => { const diffImpactMock = vi.fn(() => ({ changedFiles: 2, affectedFunctions: [] })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -506,7 +480,6 @@ describe('startMCPServer handler dispatch', () => { functions: [{ name: 'a' }, { name: 'b' }, { name: 'c' }], })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -565,13 +538,12 @@ describe('startMCPServer handler dispatch', () => { ), })); - const queryMock = vi.fn(() => ({ query: 'test', results: [] })); + const fnDepsMock = vi.fn(() => ({ name: 'test', results: [] })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: queryMock, impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), - fnDepsData: vi.fn(), + fnDepsData: fnDepsMock, fnImpactData: vi.fn(), contextData: vi.fn(), explainData: vi.fn(), @@ -586,12 +558,15 @@ describe('startMCPServer handler dispatch', () => { await startMCPServer(undefined, { multiRepo: true }); const result = await handlers['tools/call']({ - params: { name: 'query_function', arguments: { name: 'test', repo: 'my-project' } }, + params: { name: 'query', arguments: { name: 'test', repo: 'my-project' } }, }); expect(result.isError).toBeUndefined(); - expect(queryMock).toHaveBeenCalledWith('test', '/resolved/path/.codegraph/graph.db', { + expect(fnDepsMock).toHaveBeenCalledWith('test', '/resolved/path/.codegraph/graph.db', { + depth: undefined, + file: undefined, + kind: undefined, noTests: undefined, - limit: 50, + limit: 10, offset: 0, }); @@ -623,7 +598,6 @@ describe('startMCPServer handler dispatch', () => { resolveRepoDbPath: vi.fn(() => undefined), })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -642,7 +616,7 @@ describe('startMCPServer handler dispatch', () => { await startMCPServer(undefined, { multiRepo: true }); const result = await handlers['tools/call']({ - params: { name: 'query_function', arguments: { name: 'test', repo: 'unknown-repo' } }, + params: { name: 'query', arguments: { name: 'test', repo: 'unknown-repo' } }, }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('unknown-repo'); @@ -676,7 +650,6 @@ describe('startMCPServer handler dispatch', () => { resolveRepoDbPath: vi.fn(() => '/some/path'), })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -695,7 +668,7 @@ describe('startMCPServer handler dispatch', () => { await startMCPServer(undefined, { allowedRepos: ['allowed-repo'] }); const result = await handlers['tools/call']({ - params: { name: 'query_function', arguments: { name: 'test', repo: 'blocked-repo' } }, + params: { name: 'query', arguments: { name: 'test', repo: 'blocked-repo' } }, }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('blocked-repo'); @@ -729,13 +702,12 @@ describe('startMCPServer handler dispatch', () => { resolveRepoDbPath: vi.fn(() => '/resolved/db'), })); - const queryMock = vi.fn(() => ({ query: 'test', results: [] })); + const fnDepsMock = vi.fn(() => ({ name: 'test', results: [] })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: queryMock, impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), - fnDepsData: vi.fn(), + fnDepsData: fnDepsMock, fnImpactData: vi.fn(), contextData: vi.fn(), explainData: vi.fn(), @@ -750,12 +722,15 @@ describe('startMCPServer handler dispatch', () => { await startMCPServer(undefined, { allowedRepos: ['my-repo'] }); const result = await handlers['tools/call']({ - params: { name: 'query_function', arguments: { name: 'test', repo: 'my-repo' } }, + params: { name: 'query', arguments: { name: 'test', repo: 'my-repo' } }, }); expect(result.isError).toBeUndefined(); - expect(queryMock).toHaveBeenCalledWith('test', '/resolved/db', { + expect(fnDepsMock).toHaveBeenCalledWith('test', '/resolved/db', { + depth: undefined, + file: undefined, + kind: undefined, noTests: undefined, - limit: 50, + limit: 10, offset: 0, }); @@ -793,7 +768,6 @@ describe('startMCPServer handler dispatch', () => { pruneRegistry: vi.fn(), })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -851,7 +825,6 @@ describe('startMCPServer handler dispatch', () => { pruneRegistry: vi.fn(), })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -900,7 +873,6 @@ describe('startMCPServer handler dispatch', () => { CallToolRequestSchema: 'tools/call', })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -919,7 +891,7 @@ describe('startMCPServer handler dispatch', () => { await startMCPServer('/tmp/test.db'); const result = await handlers['tools/call']({ - params: { name: 'query_function', arguments: { name: 'test', repo: 'some-repo' } }, + params: { name: 'query', arguments: { name: 'test', repo: 'some-repo' } }, }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Multi-repo access is disabled'); @@ -949,7 +921,6 @@ describe('startMCPServer handler dispatch', () => { CallToolRequestSchema: 'tools/call', })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -998,7 +969,6 @@ describe('startMCPServer handler dispatch', () => { CallToolRequestSchema: 'tools/call', })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(), @@ -1048,7 +1018,6 @@ describe('startMCPServer handler dispatch', () => { })); vi.doMock('../../src/queries.js', () => ({ - queryNameData: vi.fn(), impactAnalysisData: vi.fn(), moduleMapData: vi.fn(), fileDepsData: vi.fn(),