diff --git a/docs/use-cases/titan-paradigm.md b/docs/use-cases/titan-paradigm.md index d9d1a6e9..11f10e15 100644 --- a/docs/use-cases/titan-paradigm.md +++ b/docs/use-cases/titan-paradigm.md @@ -6,7 +6,7 @@ ## The Problem -In a [viral LinkedIn post](https://www.linkedin.com/posts/johannesr314_claude-vibecoding-activity-7432157088828678144-CiI_), **Johannes R.**, Senior Software Engineer at Google, described the #1 challenge of "vibe coding": keeping a fast-moving codebase from rotting. +In a [LinkedIn post](https://www.linkedin.com/posts/johannesr314_claude-vibecoding-activity-7432157088828678144-CiI_), **Johannes R.**, Senior Software Engineer at Google, described the #1 challenge of "vibe coding": keeping a fast-moving codebase from rotting. His answer isn't a better prompt. It's a different architecture. diff --git a/src/builder.js b/src/builder.js index 12a4ec4b..130a5c8e 100644 --- a/src/builder.js +++ b/src/builder.js @@ -496,10 +496,19 @@ export async function buildGraph(rootDir, opts = {}) { const deleteMetricsForFile = db.prepare( 'DELETE FROM node_metrics WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)', ); + let deleteComplexityForFile; + try { + deleteComplexityForFile = db.prepare( + 'DELETE FROM function_complexity WHERE node_id IN (SELECT id FROM nodes WHERE file = ?)', + ); + } catch { + deleteComplexityForFile = null; + } for (const relPath of removed) { deleteEmbeddingsForFile?.run(relPath); deleteEdgesForFile.run({ f: relPath }); deleteMetricsForFile.run(relPath); + deleteComplexityForFile?.run(relPath); deleteNodesForFile.run(relPath); } for (const item of parseChanges) { @@ -507,6 +516,7 @@ export async function buildGraph(rootDir, opts = {}) { deleteEmbeddingsForFile?.run(relPath); deleteEdgesForFile.run({ f: relPath }); deleteMetricsForFile.run(relPath); + deleteComplexityForFile?.run(relPath); deleteNodesForFile.run(relPath); } @@ -787,10 +797,26 @@ export async function buildGraph(rootDir, opts = {}) { for (const call of symbols.calls) { if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue; let caller = null; + let callerSpan = Infinity; for (const def of symbols.definitions) { if (def.line <= call.line) { - const row = getNodeId.get(def.name, def.kind, relPath, def.line); - if (row) caller = row; + const end = def.endLine || Infinity; + if (call.line <= end) { + // Call is inside this definition's range — pick narrowest + const span = end - def.line; + if (span < callerSpan) { + const row = getNodeId.get(def.name, def.kind, relPath, def.line); + if (row) { + caller = row; + callerSpan = span; + } + } + } else if (!caller) { + // Fallback: def starts before call but call is past end + // Only use if we haven't found an enclosing scope yet + const row = getNodeId.get(def.name, def.kind, relPath, def.line); + if (row) caller = row; + } } } if (!caller) caller = fileNodeRow; @@ -980,6 +1006,14 @@ export async function buildGraph(rootDir, opts = {}) { debug(`Role classification failed: ${err.message}`); } + // Compute per-function complexity metrics (cognitive, cyclomatic, nesting) + try { + const { buildComplexityMetrics } = await import('./complexity.js'); + await buildComplexityMetrics(db, allSymbols, rootDir, engineOpts); + } catch (err) { + debug(`Complexity analysis failed: ${err.message}`); + } + const nodeCount = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c; info(`Graph built: ${nodeCount} nodes, ${edgeCount} edges`); info(`Stored in ${dbPath}`); diff --git a/src/cli.js b/src/cli.js index 2eaf644c..8b3ed6e1 100644 --- a/src/cli.js +++ b/src/cli.js @@ -712,6 +712,55 @@ program }); }); +program + .command('complexity [target]') + .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth)') + .option('-d, --db ', 'Path to graph.db') + .option('-n, --limit ', 'Max results', '20') + .option('--sort ', 'Sort by: cognitive | cyclomatic | nesting', 'cognitive') + .option('--above-threshold', 'Only functions exceeding warn thresholds') + .option('-f, --file ', 'Scope to file (partial match)') + .option('-k, --kind ', 'Filter by 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') + .action(async (target, opts) => { + if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + process.exit(1); + } + const { complexity } = await import('./complexity.js'); + complexity(opts.db, { + target, + limit: parseInt(opts.limit, 10), + sort: opts.sort, + aboveThreshold: opts.aboveThreshold, + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + }); + }); + +program + .command('branch-compare ') + .description('Compare code structure between two branches/refs') + .option('--depth ', 'Max transitive caller depth', '3') + .option('-T, --no-tests', 'Exclude test/spec files') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('-j, --json', 'Output as JSON') + .option('-f, --format ', 'Output format: text, mermaid, json', 'text') + .action(async (base, target, opts) => { + const { branchCompare } = await import('./branch-compare.js'); + await branchCompare(base, target, { + engine: program.opts().engine, + depth: parseInt(opts.depth, 10), + noTests: resolveNoTests(opts), + json: opts.json, + format: opts.format, + }); + }); + program .command('watch [dir]') .description('Watch project for file changes and incrementally update the graph') diff --git a/src/complexity.js b/src/complexity.js new file mode 100644 index 00000000..88e71f95 --- /dev/null +++ b/src/complexity.js @@ -0,0 +1,520 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { loadConfig } from './config.js'; +import { openReadonlyOrFail } from './db.js'; +import { info } from './logger.js'; +import { isTestFile } from './queries.js'; + +// ─── Language-Specific Node Type Registry ───────────────────────────────── + +const JS_TS_RULES = { + // Structural increments (cognitive +1, cyclomatic varies) + branchNodes: new Set([ + 'if_statement', + 'else_clause', + 'switch_statement', + 'for_statement', + 'for_in_statement', + 'while_statement', + 'do_statement', + 'catch_clause', + 'ternary_expression', + ]), + // Cyclomatic-only: each case adds a path + caseNodes: new Set(['switch_case']), + // Logical operators: cognitive +1 per sequence change, cyclomatic +1 each + logicalOperators: new Set(['&&', '||', '??']), + logicalNodeType: 'binary_expression', + // Optional chaining: cyclomatic only + optionalChainType: 'optional_chain_expression', + // Nesting-sensitive: these increment nesting depth + nestingNodes: new Set([ + 'if_statement', + 'switch_statement', + 'for_statement', + 'for_in_statement', + 'while_statement', + 'do_statement', + 'catch_clause', + 'ternary_expression', + ]), + // Function-like nodes (increase nesting when nested) + functionNodes: new Set([ + 'function_declaration', + 'function_expression', + 'arrow_function', + 'method_definition', + 'generator_function', + 'generator_function_declaration', + ]), +}; + +export const COMPLEXITY_RULES = new Map([ + ['javascript', JS_TS_RULES], + ['typescript', JS_TS_RULES], + ['tsx', JS_TS_RULES], +]); + +// ─── Algorithm: Single-Traversal DFS ────────────────────────────────────── + +/** + * Compute cognitive complexity, cyclomatic complexity, and max nesting depth + * for a function's AST subtree in a single DFS walk. + * + * @param {object} functionNode - tree-sitter node for the function body + * @param {string} language - Language ID (e.g. 'javascript', 'typescript') + * @returns {{ cognitive: number, cyclomatic: number, maxNesting: number } | null} + */ +export function computeFunctionComplexity(functionNode, language) { + const rules = COMPLEXITY_RULES.get(language); + if (!rules) return null; + + let cognitive = 0; + let cyclomatic = 1; // McCabe starts at 1 + let maxNesting = 0; + + function walk(node, nestingLevel, isTopFunction) { + if (!node) return; + + const type = node.type; + + // Track nesting depth + if (nestingLevel > maxNesting) maxNesting = nestingLevel; + + // Handle logical operators in binary expressions + if (type === rules.logicalNodeType) { + const op = node.child(1)?.type; + if (op && rules.logicalOperators.has(op)) { + // Cyclomatic: +1 for every logical operator + cyclomatic++; + + // Cognitive: +1 only when operator changes from the previous sibling sequence + // Walk up to check if parent is same type with same operator + const parent = node.parent; + let sameSequence = false; + if (parent && parent.type === rules.logicalNodeType) { + const parentOp = parent.child(1)?.type; + if (parentOp === op) { + sameSequence = true; + } + } + if (!sameSequence) { + cognitive++; + } + + // Walk children manually to avoid double-counting + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i), nestingLevel, false); + } + return; + } + } + + // Handle optional chaining (cyclomatic only) + if (type === rules.optionalChainType) { + cyclomatic++; + } + + // Handle branch/control flow nodes + if (rules.branchNodes.has(type)) { + const isElseIf = type === 'if_statement' && node.parent && node.parent.type === 'else_clause'; + + if (type === 'else_clause') { + // else: +1 cognitive structural, no nesting increment, no cyclomatic + // But only if it's a plain else (not else-if) + const firstChild = node.namedChild(0); + if (firstChild && firstChild.type === 'if_statement') { + // This is else-if: the if_statement child will handle its own increment + // Just walk children without additional increment + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i), nestingLevel, false); + } + return; + } + // Plain else + cognitive++; + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i), nestingLevel, false); + } + return; + } + + if (isElseIf) { + // else-if: +1 structural cognitive, +1 cyclomatic, NO nesting increment + cognitive++; + cyclomatic++; + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i), nestingLevel, false); + } + return; + } + + // Regular branch node + cognitive += 1 + nestingLevel; // structural + nesting + cyclomatic++; + + // switch_statement doesn't add cyclomatic itself (cases do), but adds cognitive + if (type === 'switch_statement') { + cyclomatic--; // Undo the ++ above; cases handle cyclomatic + } + + if (rules.nestingNodes.has(type)) { + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i), nestingLevel + 1, false); + } + return; + } + } + + // Handle case nodes (cyclomatic only) + if (rules.caseNodes.has(type)) { + cyclomatic++; + } + + // Handle nested function definitions (increase nesting) + if (!isTopFunction && rules.functionNodes.has(type)) { + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i), nestingLevel + 1, false); + } + return; + } + + // Walk children + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i), nestingLevel, false); + } + } + + walk(functionNode, 0, true); + + return { cognitive, cyclomatic, maxNesting }; +} + +// ─── Build-Time: Compute Metrics for Changed Files ──────────────────────── + +/** + * Find the function body node in a parse tree that matches a given line range. + */ +function findFunctionNode(rootNode, startLine, _endLine, rules) { + // tree-sitter lines are 0-indexed + const targetStart = startLine - 1; + + let best = null; + + function search(node) { + const nodeStart = node.startPosition.row; + const nodeEnd = node.endPosition.row; + + // Prune branches outside range + if (nodeEnd < targetStart || nodeStart > targetStart + 1) return; + + if (rules.functionNodes.has(node.type) && nodeStart === targetStart) { + // Found a function node at the right position — pick it + if (!best || nodeEnd - nodeStart < best.endPosition.row - best.startPosition.row) { + best = node; + } + } + + for (let i = 0; i < node.childCount; i++) { + search(node.child(i)); + } + } + + search(rootNode); + return best; +} + +/** + * Re-parse changed files with WASM tree-sitter, find function AST subtrees, + * compute complexity, and upsert into function_complexity table. + * + * @param {object} db - open better-sqlite3 database (read-write) + * @param {Map} fileSymbols - Map + * @param {string} rootDir - absolute project root path + * @param {object} [engineOpts] - engine options (unused; always uses WASM for AST) + */ +export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOpts) { + const { createParsers, getParser } = await import('./parser.js'); + const parsers = await createParsers(); + + // Map extensions to language IDs + const { LANGUAGE_REGISTRY } = await import('./parser.js'); + const extToLang = new Map(); + for (const entry of LANGUAGE_REGISTRY) { + for (const ext of entry.extensions) { + extToLang.set(ext, entry.id); + } + } + + const upsert = db.prepare( + 'INSERT OR REPLACE INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting) VALUES (?, ?, ?, ?)', + ); + const getNodeId = db.prepare( + "SELECT id FROM nodes WHERE name = ? AND kind IN ('function','method') AND file = ? AND line = ?", + ); + + let analyzed = 0; + + const tx = db.transaction(() => { + for (const [relPath, symbols] of fileSymbols) { + const ext = path.extname(relPath).toLowerCase(); + const langId = extToLang.get(ext); + if (!langId) continue; + + const rules = COMPLEXITY_RULES.get(langId); + if (!rules) continue; + + const absPath = path.join(rootDir, relPath); + let code; + try { + code = fs.readFileSync(absPath, 'utf-8'); + } catch { + continue; + } + + const parser = getParser(parsers, absPath); + if (!parser) continue; + + let tree; + try { + tree = parser.parse(code); + } catch { + continue; + } + + for (const def of symbols.definitions) { + if (def.kind !== 'function' && def.kind !== 'method') continue; + if (!def.line) continue; + + const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, rules); + if (!funcNode) continue; + + const result = computeFunctionComplexity(funcNode, langId); + if (!result) continue; + + const row = getNodeId.get(def.name, relPath, def.line); + if (!row) continue; + + upsert.run(row.id, result.cognitive, result.cyclomatic, result.maxNesting); + analyzed++; + } + } + }); + + tx(); + + if (analyzed > 0) { + info(`Complexity: ${analyzed} functions analyzed`); + } +} + +// ─── Query-Time Functions ───────────────────────────────────────────────── + +/** + * Return structured complexity data for querying. + * + * @param {string} [customDbPath] - Path to graph.db + * @param {object} [opts] - Options + * @param {string} [opts.target] - Function name filter (partial match) + * @param {number} [opts.limit] - Max results (default: 20) + * @param {string} [opts.sort] - Sort by: cognitive | cyclomatic | nesting (default: cognitive) + * @param {boolean} [opts.aboveThreshold] - Only functions above warn thresholds + * @param {string} [opts.file] - Filter by file (partial match) + * @param {string} [opts.kind] - Filter by symbol kind + * @param {boolean} [opts.noTests] - Exclude test files + * @returns {{ functions: object[], summary: object, thresholds: object }} + */ +export function complexityData(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + const limit = opts.limit || 20; + const sort = opts.sort || 'cognitive'; + const noTests = opts.noTests || false; + const aboveThreshold = opts.aboveThreshold || false; + const target = opts.target || null; + const fileFilter = opts.file || null; + const kindFilter = opts.kind || null; + + // Load thresholds from config + const config = loadConfig(process.cwd()); + const thresholds = config.manifesto?.rules || { + cognitive: { warn: 15, fail: null }, + cyclomatic: { warn: 10, fail: null }, + maxNesting: { warn: 4, fail: null }, + }; + + // Build query + let where = "WHERE n.kind IN ('function','method')"; + const params = []; + + if (noTests) { + where += ` AND n.file NOT LIKE '%.test.%' + AND n.file NOT LIKE '%.spec.%' + AND n.file NOT LIKE '%__test__%' + AND n.file NOT LIKE '%__tests__%' + AND n.file NOT LIKE '%.stories.%'`; + } + if (target) { + where += ' AND n.name LIKE ?'; + params.push(`%${target}%`); + } + if (fileFilter) { + where += ' AND n.file LIKE ?'; + params.push(`%${fileFilter}%`); + } + if (kindFilter) { + where += ' AND n.kind = ?'; + params.push(kindFilter); + } + + let having = ''; + if (aboveThreshold) { + const conditions = []; + if (thresholds.cognitive?.warn != null) { + conditions.push(`fc.cognitive >= ${thresholds.cognitive.warn}`); + } + if (thresholds.cyclomatic?.warn != null) { + conditions.push(`fc.cyclomatic >= ${thresholds.cyclomatic.warn}`); + } + if (thresholds.maxNesting?.warn != null) { + conditions.push(`fc.max_nesting >= ${thresholds.maxNesting.warn}`); + } + if (conditions.length > 0) { + having = `AND (${conditions.join(' OR ')})`; + } + } + + const orderMap = { + cognitive: 'fc.cognitive DESC', + cyclomatic: 'fc.cyclomatic DESC', + nesting: 'fc.max_nesting DESC', + }; + const orderBy = orderMap[sort] || 'fc.cognitive DESC'; + + let rows; + try { + rows = db + .prepare( + `SELECT n.name, n.kind, n.file, n.line, n.end_line, + fc.cognitive, fc.cyclomatic, fc.max_nesting + FROM function_complexity fc + JOIN nodes n ON fc.node_id = n.id + ${where} ${having} + ORDER BY ${orderBy} + LIMIT ?`, + ) + .all(...params, limit); + } catch { + db.close(); + return { functions: [], summary: null, thresholds }; + } + + // Post-filter test files if needed (belt-and-suspenders for isTestFile) + const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows; + + const functions = filtered.map((r) => { + const exceeds = []; + if (thresholds.cognitive?.warn != null && r.cognitive >= thresholds.cognitive.warn) + exceeds.push('cognitive'); + if (thresholds.cyclomatic?.warn != null && r.cyclomatic >= thresholds.cyclomatic.warn) + exceeds.push('cyclomatic'); + if (thresholds.maxNesting?.warn != null && r.max_nesting >= thresholds.maxNesting.warn) + exceeds.push('maxNesting'); + + return { + name: r.name, + kind: r.kind, + file: r.file, + line: r.line, + endLine: r.end_line || null, + cognitive: r.cognitive, + cyclomatic: r.cyclomatic, + maxNesting: r.max_nesting, + exceeds: exceeds.length > 0 ? exceeds : undefined, + }; + }); + + // Summary stats + let summary = null; + try { + const allRows = db + .prepare( + `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting + FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id + WHERE n.kind IN ('function','method') + ${noTests ? `AND n.file NOT LIKE '%.test.%' AND n.file NOT LIKE '%.spec.%' AND n.file NOT LIKE '%__test__%' AND n.file NOT LIKE '%__tests__%' AND n.file NOT LIKE '%.stories.%'` : ''}`, + ) + .all(); + + if (allRows.length > 0) { + summary = { + analyzed: allRows.length, + avgCognitive: +(allRows.reduce((s, r) => s + r.cognitive, 0) / allRows.length).toFixed(1), + avgCyclomatic: +(allRows.reduce((s, r) => s + r.cyclomatic, 0) / allRows.length).toFixed(1), + maxCognitive: Math.max(...allRows.map((r) => r.cognitive)), + maxCyclomatic: Math.max(...allRows.map((r) => r.cyclomatic)), + aboveWarn: allRows.filter( + (r) => + (thresholds.cognitive?.warn != null && r.cognitive >= thresholds.cognitive.warn) || + (thresholds.cyclomatic?.warn != null && r.cyclomatic >= thresholds.cyclomatic.warn) || + (thresholds.maxNesting?.warn != null && r.max_nesting >= thresholds.maxNesting.warn), + ).length, + }; + } + } catch { + /* ignore */ + } + + db.close(); + return { functions, summary, thresholds }; +} + +/** + * Format complexity output for CLI display. + */ +export function complexity(customDbPath, opts = {}) { + const data = complexityData(customDbPath, opts); + + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + if (data.functions.length === 0) { + if (data.summary === null) { + console.log( + '\nNo complexity data found. Run "codegraph build" first to analyze your codebase.\n', + ); + } else { + console.log('\nNo functions match the given filters.\n'); + } + return; + } + + const header = opts.aboveThreshold ? 'Functions Above Threshold' : 'Function Complexity'; + console.log(`\n# ${header}\n`); + + // Table header + console.log( + ` ${'Function'.padEnd(40)} ${'File'.padEnd(30)} ${'Cog'.padStart(4)} ${'Cyc'.padStart(4)} ${'Nest'.padStart(5)}`, + ); + console.log( + ` ${'─'.repeat(40)} ${'─'.repeat(30)} ${'─'.repeat(4)} ${'─'.repeat(4)} ${'─'.repeat(5)}`, + ); + + for (const fn of data.functions) { + const name = fn.name.length > 38 ? `${fn.name.slice(0, 37)}…` : fn.name; + const file = fn.file.length > 28 ? `…${fn.file.slice(-27)}` : fn.file; + const warn = fn.exceeds ? ' !' : ''; + console.log( + ` ${name.padEnd(40)} ${file.padEnd(30)} ${String(fn.cognitive).padStart(4)} ${String(fn.cyclomatic).padStart(4)} ${String(fn.maxNesting).padStart(5)}${warn}`, + ); + } + + if (data.summary) { + const s = data.summary; + console.log( + `\n ${s.analyzed} functions analyzed | avg cognitive: ${s.avgCognitive} | avg cyclomatic: ${s.avgCyclomatic} | ${s.aboveWarn} above threshold`, + ); + } + console.log(); +} diff --git a/src/config.js b/src/config.js index 05d501c9..29dec215 100644 --- a/src/config.js +++ b/src/config.js @@ -24,6 +24,13 @@ export const DEFAULTS = { llm: { provider: null, model: null, baseUrl: null, apiKey: null, apiKeyCommand: null }, search: { defaultMinScore: 0.2, rrfK: 60, topK: 15 }, ci: { failOnCycles: false, impactThreshold: null }, + manifesto: { + rules: { + cognitive: { warn: 15, fail: null }, + cyclomatic: { warn: 10, fail: null }, + maxNesting: { warn: 4, fail: null }, + }, + }, coChange: { since: '1 year ago', minSupport: 3, diff --git a/src/db.js b/src/db.js index 236e0d7c..87a07354 100644 --- a/src/db.js +++ b/src/db.js @@ -110,6 +110,20 @@ export const MIGRATIONS = [ ); `, }, + { + version: 8, + up: ` + CREATE TABLE IF NOT EXISTS function_complexity ( + node_id INTEGER PRIMARY KEY, + cognitive INTEGER NOT NULL, + cyclomatic INTEGER NOT NULL, + max_nesting INTEGER NOT NULL, + FOREIGN KEY(node_id) REFERENCES nodes(id) + ); + CREATE INDEX IF NOT EXISTS idx_fc_cognitive ON function_complexity(cognitive DESC); + CREATE INDEX IF NOT EXISTS idx_fc_cyclomatic ON function_complexity(cyclomatic DESC); + `, + }, ]; export function getBuildMeta(db, key) { diff --git a/src/index.js b/src/index.js index 3b4b4d92..2801ca9d 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,8 @@ * import { buildGraph, queryNameData, findCycles, exportDOT } from 'codegraph'; */ +// Branch comparison +export { branchCompareData, branchCompareMermaid } from './branch-compare.js'; // Graph building export { buildGraph, collectFiles, loadPathAliases, resolveImportPath } from './builder.js'; // Co-change analysis @@ -16,6 +18,13 @@ export { computeCoChanges, scanGitHistory, } from './cochange.js'; +// Complexity metrics +export { + COMPLEXITY_RULES, + complexity, + complexityData, + computeFunctionComplexity, +} from './complexity.js'; // Configuration export { loadConfig } from './config.js'; // Shared constants diff --git a/src/mcp.js b/src/mcp.js index e5e3f1fc..93dc8300 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -403,6 +403,35 @@ const BASE_TOOLS = [ }, }, }, + { + name: 'complexity', + description: + 'Show per-function complexity metrics (cognitive, cyclomatic, max nesting depth). Sorted by most complex first.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Function name filter (partial match)' }, + file: { type: 'string', description: 'Scope to file (partial match)' }, + limit: { type: 'number', description: 'Max results', default: 20 }, + sort: { + type: 'string', + enum: ['cognitive', 'cyclomatic', 'nesting'], + description: 'Sort metric', + default: 'cognitive', + }, + above_threshold: { + type: 'boolean', + description: 'Only functions exceeding warn thresholds', + default: false, + }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + kind: { + type: 'string', + description: 'Filter by symbol kind (function, method, class, etc.)', + }, + }, + }, + }, ]; const LIST_REPOS_TOOL = { @@ -720,6 +749,19 @@ export async function startMCPServer(customDbPath, options = {}) { }); break; } + case 'complexity': { + const { complexityData } = await import('./complexity.js'); + result = complexityData(dbPath, { + target: args.name, + file: args.file, + limit: args.limit, + sort: args.sort, + aboveThreshold: args.above_threshold, + noTests: args.no_tests, + kind: args.kind, + }); + break; + } case 'list_repos': { const { listRepos, pruneRegistry } = await import('./registry.js'); pruneRegistry(); diff --git a/src/queries.js b/src/queries.js index dea8dc5d..29894c43 100644 --- a/src/queries.js +++ b/src/queries.js @@ -1363,6 +1363,29 @@ export function statsData(customDbPath, opts = {}) { const roles = {}; for (const r of roleRows) roles[r.role] = r.c; + // Complexity summary + let complexity = null; + try { + const cRows = db + .prepare( + `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting + FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id + WHERE n.kind IN ('function','method') ${testFilter}`, + ) + .all(); + if (cRows.length > 0) { + complexity = { + analyzed: cRows.length, + avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1), + avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1), + maxCognitive: Math.max(...cRows.map((r) => r.cognitive)), + maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)), + }; + } + } catch { + /* table may not exist in older DBs */ + } + db.close(); return { nodes: { total: totalNodes, byKind: nodesByKind }, @@ -1373,6 +1396,7 @@ export function statsData(customDbPath, opts = {}) { embeddings, quality, roles, + complexity, }; } @@ -1485,6 +1509,14 @@ export function stats(customDbPath, opts = {}) { } } + // Complexity + if (data.complexity) { + const cx = data.complexity; + console.log( + `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}`, + ); + } + console.log(); } @@ -1947,6 +1979,25 @@ export function contextData(name, customDbPath, opts = {}) { }); } + // Complexity metrics + let complexityMetrics = null; + try { + const cRow = db + .prepare( + 'SELECT cognitive, cyclomatic, max_nesting FROM function_complexity WHERE node_id = ?', + ) + .get(node.id); + if (cRow) { + complexityMetrics = { + cognitive: cRow.cognitive, + cyclomatic: cRow.cyclomatic, + maxNesting: cRow.max_nesting, + }; + } + } catch { + /* table may not exist */ + } + return { name: node.name, kind: node.kind, @@ -1956,6 +2007,7 @@ export function contextData(name, customDbPath, opts = {}) { endLine: node.end_line || null, source, signature, + complexity: complexityMetrics, callees, callers, relatedTests, @@ -1990,6 +2042,16 @@ export function context(name, customDbPath, opts = {}) { console.log(); } + // Complexity + if (r.complexity) { + const cx = r.complexity; + console.log('## Complexity'); + console.log( + ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}`, + ); + console.log(); + } + // Source if (r.source) { console.log('## Source'); @@ -2208,6 +2270,25 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { .filter((r) => isTestFile(r.file)) .map((r) => ({ file: r.file })); + // Complexity metrics + let complexityMetrics = null; + try { + const cRow = db + .prepare( + 'SELECT cognitive, cyclomatic, max_nesting FROM function_complexity WHERE node_id = ?', + ) + .get(node.id); + if (cRow) { + complexityMetrics = { + cognitive: cRow.cognitive, + cyclomatic: cRow.cyclomatic, + maxNesting: cRow.max_nesting, + }; + } + } catch { + /* table may not exist */ + } + return { name: node.name, kind: node.kind, @@ -2218,6 +2299,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { lineCount, summary, signature, + complexity: complexityMetrics, callees, callers, relatedTests, @@ -2367,6 +2449,13 @@ export function explain(target, customDbPath, opts = {}) { if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); } + if (r.complexity) { + const cx = r.complexity; + console.log( + `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}`, + ); + } + if (r.callees.length > 0) { console.log(`\n${indent} Calls (${r.callees.length}):`); for (const c of r.callees) { diff --git a/tests/integration/build.test.js b/tests/integration/build.test.js index cc1c9971..4ef0e4b7 100644 --- a/tests/integration/build.test.js +++ b/tests/integration/build.test.js @@ -357,3 +357,48 @@ describe('three-tier incremental builds', () => { expect(output).toContain('No changes detected'); }); }); + +describe('nested function caller attribution', () => { + let nestDir, nestDbPath; + + beforeAll(async () => { + nestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-nested-')); + // File with an outer function containing a nested helper that is called + fs.writeFileSync( + path.join(nestDir, 'nested.js'), + [ + 'function outer() {', + ' function inner() {', + ' return 42;', + ' }', + ' return inner();', + '}', + '', + ].join('\n'), + ); + await buildGraph(nestDir, { skipRegistry: true }); + nestDbPath = path.join(nestDir, '.codegraph', 'graph.db'); + }); + + afterAll(() => { + if (nestDir) fs.rmSync(nestDir, { recursive: true, force: true }); + }); + + test('enclosing function is the caller of a nested function, not a self-call', () => { + const db = new Database(nestDbPath, { readonly: true }); + const edges = db + .prepare(` + SELECT s.name as caller, t.name as callee FROM edges e + JOIN nodes s ON e.source_id = s.id + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' + `) + .all(); + db.close(); + const pairs = edges.map((e) => `${e.caller}->${e.callee}`); + // outer() calls inner() — should produce outer->inner edge + expect(pairs).toContain('outer->inner'); + // Should NOT have inner->inner self-call (the old bug) + expect(pairs).not.toContain('inner->inner'); + }); +}); diff --git a/tests/integration/complexity.test.js b/tests/integration/complexity.test.js new file mode 100644 index 00000000..d4db314e --- /dev/null +++ b/tests/integration/complexity.test.js @@ -0,0 +1,157 @@ +/** + * Integration tests for complexity metrics. + * + * End-to-end: build graph from fixture → verify complexity stored → + * verify complexityData() returns correct results. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { complexityData } from '../../src/complexity.js'; +import { initSchema } from '../../src/db.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line, endLine = null) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)') + .run(name, kind, file, line, endLine).lastInsertRowid; +} + +function insertComplexity(db, nodeId, cognitive, cyclomatic, maxNesting) { + db.prepare( + 'INSERT INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting) VALUES (?, ?, ?, ?)', + ).run(nodeId, cognitive, cyclomatic, maxNesting); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-complexity-')); + fs.mkdirSync(path.join(tmpDir, '.codegraph')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // Function nodes with varying complexity + const fn1 = insertNode(db, 'simpleAdd', 'function', 'src/math.js', 1, 3); + const fn2 = insertNode(db, 'processItems', 'function', 'src/processor.js', 5, 40); + const fn3 = insertNode(db, 'validateInput', 'function', 'src/validator.js', 1, 20); + const fn4 = insertNode(db, 'handleRequest', 'method', 'src/handler.js', 10, 50); + const fn5 = insertNode(db, 'testHelper', 'function', 'tests/helper.test.js', 1, 10); + + // Insert complexity data + insertComplexity(db, fn1, 0, 1, 0); // trivial + insertComplexity(db, fn2, 18, 8, 4); // above cognitive warn + insertComplexity(db, fn3, 12, 11, 3); // above cyclomatic warn + insertComplexity(db, fn4, 25, 15, 5); // above all thresholds + insertComplexity(db, fn5, 5, 3, 2); // test file + + db.close(); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Tests ───────────────────────────────────────────────────────────── + +describe('complexityData', () => { + test('returns all functions sorted by cognitive (default)', () => { + const data = complexityData(dbPath); + expect(data.functions.length).toBeGreaterThanOrEqual(4); + // Default sort: cognitive DESC + expect(data.functions[0].name).toBe('handleRequest'); + expect(data.functions[0].cognitive).toBe(25); + expect(data.functions[1].name).toBe('processItems'); + }); + + test('returns summary stats', () => { + const data = complexityData(dbPath); + expect(data.summary).not.toBeNull(); + expect(data.summary.analyzed).toBeGreaterThanOrEqual(4); + expect(data.summary.avgCognitive).toBeGreaterThan(0); + expect(data.summary.maxCognitive).toBe(25); + }); + + test('returns thresholds from config', () => { + const data = complexityData(dbPath); + expect(data.thresholds).toBeDefined(); + expect(data.thresholds.cognitive).toBeDefined(); + expect(data.thresholds.cyclomatic).toBeDefined(); + expect(data.thresholds.maxNesting).toBeDefined(); + }); + + test('filters by target name', () => { + const data = complexityData(dbPath, { target: 'validate' }); + expect(data.functions.length).toBe(1); + expect(data.functions[0].name).toBe('validateInput'); + }); + + test('filters by file', () => { + const data = complexityData(dbPath, { file: 'handler' }); + expect(data.functions.length).toBe(1); + expect(data.functions[0].name).toBe('handleRequest'); + }); + + test('filters by kind', () => { + const data = complexityData(dbPath, { kind: 'method' }); + expect(data.functions.length).toBe(1); + expect(data.functions[0].kind).toBe('method'); + }); + + test('sort by cyclomatic', () => { + const data = complexityData(dbPath, { sort: 'cyclomatic' }); + expect(data.functions[0].cyclomatic).toBeGreaterThanOrEqual(data.functions[1].cyclomatic); + }); + + test('sort by nesting', () => { + const data = complexityData(dbPath, { sort: 'nesting' }); + expect(data.functions[0].maxNesting).toBeGreaterThanOrEqual(data.functions[1].maxNesting); + }); + + test('limit results', () => { + const data = complexityData(dbPath, { limit: 2 }); + expect(data.functions.length).toBeLessThanOrEqual(2); + }); + + test('noTests excludes test files', () => { + const data = complexityData(dbPath, { noTests: true }); + for (const fn of data.functions) { + expect(fn.file).not.toMatch(/\.test\./); + } + }); + + test('aboveThreshold only returns functions exceeding warn', () => { + const data = complexityData(dbPath, { aboveThreshold: true }); + // simpleAdd (0,1,0) should be excluded + const names = data.functions.map((f) => f.name); + expect(names).not.toContain('simpleAdd'); + // handleRequest (25,15,5) should be included + expect(names).toContain('handleRequest'); + }); + + test('exceeds field marks threshold violations', () => { + const data = complexityData(dbPath); + const handler = data.functions.find((f) => f.name === 'handleRequest'); + expect(handler.exceeds).toBeDefined(); + expect(handler.exceeds).toContain('cognitive'); + expect(handler.exceeds).toContain('cyclomatic'); + expect(handler.exceeds).toContain('maxNesting'); + + const simple = data.functions.find((f) => f.name === 'simpleAdd'); + expect(simple.exceeds).toBeUndefined(); + }); + + test('empty result when no matches', () => { + const data = complexityData(dbPath, { target: 'nonexistent_xyz' }); + expect(data.functions.length).toBe(0); + }); +}); diff --git a/tests/unit/complexity.test.js b/tests/unit/complexity.test.js new file mode 100644 index 00000000..fc064df4 --- /dev/null +++ b/tests/unit/complexity.test.js @@ -0,0 +1,254 @@ +/** + * Unit tests for src/complexity.js + * + * Hand-crafted code snippets parsed with tree-sitter to verify + * exact cognitive/cyclomatic/nesting values. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { COMPLEXITY_RULES, computeFunctionComplexity } from '../../src/complexity.js'; +import { createParsers } from '../../src/parser.js'; + +let jsParser; + +beforeAll(async () => { + const parsers = await createParsers(); + jsParser = parsers.get('javascript'); +}); + +function parse(code) { + const tree = jsParser.parse(code); + return tree.rootNode; +} + +function getFunctionBody(root) { + const rules = COMPLEXITY_RULES.get('javascript'); + function find(node) { + if (rules.functionNodes.has(node.type)) return node; + for (let i = 0; i < node.childCount; i++) { + const result = find(node.child(i)); + if (result) return result; + } + return null; + } + return find(root); +} + +function analyze(code) { + const root = parse(code); + const funcNode = getFunctionBody(root); + if (!funcNode) throw new Error('No function found in code snippet'); + return computeFunctionComplexity(funcNode, 'javascript'); +} + +describe('computeFunctionComplexity', () => { + it('returns null for unsupported languages', () => { + const result = computeFunctionComplexity({}, 'unknown_lang'); + expect(result).toBeNull(); + }); + + it('simple function — no branching', () => { + const result = analyze(` + function simple(a, b) { + return a + b; + } + `); + expect(result).toEqual({ cognitive: 0, cyclomatic: 1, maxNesting: 0 }); + }); + + it('single if statement', () => { + const result = analyze(` + function check(x) { + if (x > 0) { + return true; + } + return false; + } + `); + expect(result).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('nested if', () => { + const result = analyze(` + function nested(x, y) { + if (x > 0) { + if (y > 0) { + return true; + } + } + return false; + } + `); + expect(result).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('if / else-if / else chain', () => { + const result = analyze(` + function classify(x) { + if (x > 0) { + return 'positive'; + } else if (x < 0) { + return 'negative'; + } else { + return 'zero'; + } + } + `); + expect(result).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 1 }); + }); + + it('switch statement with cases', () => { + const result = analyze(` + function sw(x) { + switch (x) { + case 1: return 'one'; + case 2: return 'two'; + default: return 'other'; + } + } + `); + expect(result.cognitive).toBe(1); + expect(result.cyclomatic).toBe(3); + expect(result.maxNesting).toBe(1); + }); + + it('logical operators — same operator sequence', () => { + const result = analyze(` + function check(a, b, c) { + if (a && b && c) { + return true; + } + } + `); + expect(result.cognitive).toBe(2); + expect(result.cyclomatic).toBe(4); + }); + + it('logical operators — mixed operators', () => { + const result = analyze(` + function check(a, b, c) { + if (a && b || c) { + return true; + } + } + `); + expect(result.cognitive).toBe(3); + expect(result.cyclomatic).toBe(4); + }); + + it('for loop with nested if', () => { + const result = analyze(` + function search(arr, target) { + for (let i = 0; i < arr.length; i++) { + if (arr[i] === target) { + return i; + } + } + return -1; + } + `); + expect(result).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('try/catch', () => { + const result = analyze(` + function safeParse(str) { + try { + return JSON.parse(str); + } catch (e) { + return null; + } + } + `); + expect(result).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('ternary expression', () => { + const result = analyze(` + function abs(x) { + return x >= 0 ? x : -x; + } + `); + expect(result).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('nested lambda increases nesting', () => { + const result = analyze(` + function outer() { + const inner = () => { + if (true) { + return 1; + } + }; + } + `); + expect(result.cognitive).toBe(2); + expect(result.cyclomatic).toBe(2); + expect(result.maxNesting).toBe(2); + }); + + it('while loop', () => { + const result = analyze(` + function countdown(n) { + while (n > 0) { + n--; + } + } + `); + expect(result).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('do-while loop', () => { + const result = analyze(` + function atLeastOnce(n) { + do { + n--; + } while (n > 0); + } + `); + expect(result).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('complex realistic function', () => { + const result = analyze(` + function processItems(items, options) { + if (!items || items.length === 0) { + return []; + } + const results = []; + for (const item of items) { + if (item.type === 'A') { + if (item.value > 10) { + results.push(item); + } + } else if (item.type === 'B') { + try { + results.push(transform(item)); + } catch (e) { + if (options?.strict) { + throw e; + } + } + } + } + return results; + } + `); + expect(result.cognitive).toBeGreaterThan(5); + expect(result.cyclomatic).toBeGreaterThan(3); + expect(result.maxNesting).toBeGreaterThanOrEqual(3); + }); +}); + +describe('COMPLEXITY_RULES', () => { + it('supports javascript, typescript, tsx', () => { + expect(COMPLEXITY_RULES.has('javascript')).toBe(true); + expect(COMPLEXITY_RULES.has('typescript')).toBe(true); + expect(COMPLEXITY_RULES.has('tsx')).toBe(true); + }); + + it('returns undefined for unsupported languages', () => { + expect(COMPLEXITY_RULES.has('python')).toBe(false); + expect(COMPLEXITY_RULES.has('go')).toBe(false); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index e7f958de..8587be8a 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -30,6 +30,7 @@ const ALL_TOOL_NAMES = [ 'node_roles', 'execution_flow', 'list_entry_points', + 'complexity', 'list_repos', ]; @@ -987,4 +988,82 @@ describe('startMCPServer handler dispatch', () => { vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js'); vi.doUnmock('../../src/queries.js'); }); + + it('dispatches complexity to complexityData', async () => { + const handlers = {}; + + vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({ + Server: class MockServer { + setRequestHandler(name, handler) { + handlers[name] = handler; + } + async connect() {} + }, + })); + vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: class MockTransport {}, + })); + vi.doMock('@modelcontextprotocol/sdk/types.js', () => ({ + ListToolsRequestSchema: 'tools/list', + CallToolRequestSchema: 'tools/call', + })); + + vi.doMock('../../src/queries.js', () => ({ + queryNameData: vi.fn(), + impactAnalysisData: vi.fn(), + moduleMapData: vi.fn(), + fileDepsData: vi.fn(), + fnDepsData: vi.fn(), + fnImpactData: vi.fn(), + contextData: vi.fn(), + explainData: vi.fn(), + whereData: vi.fn(), + diffImpactData: vi.fn(), + listFunctionsData: vi.fn(), + rolesData: vi.fn(), + pathData: vi.fn(() => ({ from: 'a', to: 'b', found: false })), + })); + + const complexityMock = vi.fn(() => ({ + functions: [{ name: 'buildGraph', cognitive: 50, cyclomatic: 20, maxNesting: 5 }], + summary: { analyzed: 1, avgCognitive: 50, avgCyclomatic: 20 }, + thresholds: { cognitive: { warn: 15 }, cyclomatic: { warn: 10 }, maxNesting: { warn: 4 } }, + })); + vi.doMock('../../src/complexity.js', () => ({ + complexityData: complexityMock, + })); + + const { startMCPServer } = await import('../../src/mcp.js'); + await startMCPServer('/tmp/test.db'); + + const result = await handlers['tools/call']({ + params: { + name: 'complexity', + arguments: { + name: 'buildGraph', + file: 'src/builder.js', + limit: 10, + sort: 'cyclomatic', + above_threshold: true, + no_tests: true, + kind: 'function', + }, + }, + }); + expect(result.isError).toBeUndefined(); + expect(complexityMock).toHaveBeenCalledWith('/tmp/test.db', { + target: 'buildGraph', + file: 'src/builder.js', + limit: 10, + sort: 'cyclomatic', + aboveThreshold: true, + noTests: true, + kind: 'function', + }); + + vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); + vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js'); + vi.doUnmock('../../src/queries.js'); + vi.doUnmock('../../src/complexity.js'); + }); });