From b97af8c5e9321cb772e7e9e1c4877676343f65b1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 17:58:12 -0700 Subject: [PATCH 1/3] feat: halstead metrics and maintainability index for code health scoring Add formal code health metrics per function: Halstead volume, difficulty, effort, bugs estimate, LOC/SLOC, and the industry-standard Maintainability Index (MI) normalized to 0-100 (Microsoft convention). MI below 20 flags functions needing refactoring attention. - DB migration v9: 14 new columns on function_complexity table - HALSTEAD_RULES registry for JS/TS/TSX operator/operand classification - computeHalsteadMetrics(), computeLOCMetrics(), computeMaintainabilityIndex() - complexity command: MI column in default view, --health flag for full Halstead view, --sort mi|volume|effort|bugs|loc options - stats/context/explain commands surface MI per function - MCP tool schema updated with new sort values and health property - Config: maintainabilityIndex threshold { warn: 20, fail: null } - 29 new tests (18 unit + 11 integration) Impact: 16 functions changed, 30 affected Impact: 16 functions changed, 30 affected --- src/cli.js | 10 +- src/complexity.js | 374 +++++++++++++++++++++++++-- src/config.js | 1 + src/db.js | 20 ++ src/index.js | 4 + src/mcp.js | 10 +- src/queries.js | 22 +- tests/integration/complexity.test.js | 184 ++++++++++++- tests/unit/complexity.test.js | 199 +++++++++++++- 9 files changed, 786 insertions(+), 38 deletions(-) diff --git a/src/cli.js b/src/cli.js index b7c13d17..955052a9 100644 --- a/src/cli.js +++ b/src/cli.js @@ -714,11 +714,16 @@ program program .command('complexity [target]') - .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth)') + .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth, MI)') .option('-d, --db ', 'Path to graph.db') .option('-n, --limit ', 'Max results', '20') - .option('--sort ', 'Sort by: cognitive | cyclomatic | nesting', 'cognitive') + .option( + '--sort ', + 'Sort by: cognitive | cyclomatic | nesting | mi | volume | effort | bugs | loc', + 'cognitive', + ) .option('--above-threshold', 'Only functions exceeding warn thresholds') + .option('--health', 'Show health metrics (Halstead, MI) columns') .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') @@ -735,6 +740,7 @@ program limit: parseInt(opts.limit, 10), sort: opts.sort, aboveThreshold: opts.aboveThreshold, + health: opts.health, file: opts.file, kind: opts.kind, noTests: resolveNoTests(opts), diff --git a/src/complexity.js b/src/complexity.js index d3686361..1a6af1de 100644 --- a/src/complexity.js +++ b/src/complexity.js @@ -55,6 +55,259 @@ export const COMPLEXITY_RULES = new Map([ ['tsx', JS_TS_RULES], ]); +// ─── Halstead Operator/Operand Classification ──────────────────────────── + +const JS_TS_HALSTEAD = { + operatorLeafTypes: new Set([ + // Arithmetic + '+', + '-', + '*', + '/', + '%', + '**', + // Assignment + '=', + '+=', + '-=', + '*=', + '/=', + '%=', + '**=', + '<<=', + '>>=', + '>>>=', + '&=', + '|=', + '^=', + '&&=', + '||=', + '??=', + // Comparison + '==', + '===', + '!=', + '!==', + '<', + '>', + '<=', + '>=', + // Logical + '&&', + '||', + '!', + '??', + // Bitwise + '&', + '|', + '^', + '~', + '<<', + '>>', + '>>>', + // Unary + '++', + '--', + // Keywords as operators + 'typeof', + 'instanceof', + 'new', + 'return', + 'throw', + 'yield', + 'await', + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'try', + 'catch', + 'finally', + // Arrow, spread, ternary, access + '=>', + '...', + '?', + ':', + '.', + '?.', + // Delimiters counted as operators + ',', + ';', + ]), + operandLeafTypes: new Set([ + 'identifier', + 'property_identifier', + 'shorthand_property_identifier', + 'shorthand_property_identifier_pattern', + 'number', + 'string_fragment', + 'regex_pattern', + 'true', + 'false', + 'null', + 'undefined', + 'this', + 'super', + 'private_property_identifier', + ]), + compoundOperators: new Set([ + 'call_expression', + 'subscript_expression', + 'new_expression', + 'template_substitution', + ]), + skipTypes: new Set(['type_annotation', 'type_parameters', 'return_type', 'implements_clause']), +}; + +export const HALSTEAD_RULES = new Map([ + ['javascript', JS_TS_HALSTEAD], + ['typescript', JS_TS_HALSTEAD], + ['tsx', JS_TS_HALSTEAD], +]); + +// ─── Halstead Metrics Computation ───────────────────────────────────────── + +/** + * Compute Halstead metrics for a function's AST subtree. + * + * @param {object} functionNode - tree-sitter node for the function + * @param {string} language - Language ID + * @returns {{ n1: number, n2: number, bigN1: number, bigN2: number, vocabulary: number, length: number, volume: number, difficulty: number, effort: number, bugs: number } | null} + */ +export function computeHalsteadMetrics(functionNode, language) { + const rules = HALSTEAD_RULES.get(language); + if (!rules) return null; + + const operators = new Map(); // type -> count + const operands = new Map(); // text -> count + + function walk(node) { + if (!node) return; + + // Skip type annotation subtrees + if (rules.skipTypes.has(node.type)) return; + + // Compound operators (non-leaf): count the node type as an operator + if (rules.compoundOperators.has(node.type)) { + operators.set(node.type, (operators.get(node.type) || 0) + 1); + } + + // Leaf nodes: classify as operator or operand + if (node.childCount === 0) { + if (rules.operatorLeafTypes.has(node.type)) { + operators.set(node.type, (operators.get(node.type) || 0) + 1); + } else if (rules.operandLeafTypes.has(node.type)) { + const text = node.text; + operands.set(text, (operands.get(text) || 0) + 1); + } + } + + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)); + } + } + + walk(functionNode); + + const n1 = operators.size; // distinct operators + const n2 = operands.size; // distinct operands + let bigN1 = 0; // total operators + for (const c of operators.values()) bigN1 += c; + let bigN2 = 0; // total operands + for (const c of operands.values()) bigN2 += c; + + const vocabulary = n1 + n2; + const length = bigN1 + bigN2; + + // Guard against zero + const volume = vocabulary > 0 ? length * Math.log2(vocabulary) : 0; + const difficulty = n2 > 0 ? (n1 / 2) * (bigN2 / n2) : 0; + const effort = difficulty * volume; + const bugs = volume / 3000; + + return { + n1, + n2, + bigN1, + bigN2, + vocabulary, + length, + volume: +volume.toFixed(2), + difficulty: +difficulty.toFixed(2), + effort: +effort.toFixed(2), + bugs: +bugs.toFixed(4), + }; +} + +// ─── LOC Metrics Computation ────────────────────────────────────────────── + +/** + * Compute LOC metrics from a function node's source text. + * + * @param {object} functionNode - tree-sitter node + * @returns {{ loc: number, sloc: number, commentLines: number }} + */ +export function computeLOCMetrics(functionNode) { + const text = functionNode.text; + const lines = text.split('\n'); + const loc = lines.length; + + let commentLines = 0; + let blankLines = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '') { + blankLines++; + } else if ( + trimmed.startsWith('//') || + trimmed.startsWith('/*') || + trimmed.startsWith('*') || + trimmed.startsWith('*/') + ) { + commentLines++; + } + } + + const sloc = Math.max(1, loc - blankLines - commentLines); + return { loc, sloc, commentLines }; +} + +// ─── Maintainability Index ──────────────────────────────────────────────── + +/** + * Compute normalized Maintainability Index (0-100 scale). + * + * Original SEI formula: MI = 171 - 5.2*ln(V) - 0.23*G - 16.2*ln(LOC) + 50*sin(sqrt(2.4*CM)) + * Microsoft normalization: max(0, min(100, MI * 100/171)) + * + * @param {number} volume - Halstead volume + * @param {number} cyclomatic - Cyclomatic complexity + * @param {number} sloc - Source lines of code + * @param {number} [commentRatio] - Comment ratio (0-1), optional + * @returns {number} Normalized MI (0-100) + */ +export function computeMaintainabilityIndex(volume, cyclomatic, sloc, commentRatio) { + // Guard against zero/negative values in logarithms + const safeVolume = Math.max(volume, 1); + const safeSLOC = Math.max(sloc, 1); + + let mi = 171 - 5.2 * Math.log(safeVolume) - 0.23 * cyclomatic - 16.2 * Math.log(safeSLOC); + + if (commentRatio != null && commentRatio > 0) { + mi += 50 * Math.sin(Math.sqrt(2.4 * commentRatio)); + } + + // Microsoft normalization: 0-100 scale + const normalized = Math.max(0, Math.min(100, (mi * 100) / 171)); + return +normalized.toFixed(1); +} + // ─── Algorithm: Single-Traversal DFS ────────────────────────────────────── /** @@ -264,7 +517,14 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp const { getParser } = await import('./parser.js'); const upsert = db.prepare( - 'INSERT OR REPLACE INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting) VALUES (?, ?, ?, ?)', + `INSERT OR REPLACE INTO function_complexity + (node_id, cognitive, cyclomatic, max_nesting, + loc, sloc, comment_lines, + halstead_n1, halstead_n2, halstead_big_n1, halstead_big_n2, + halstead_vocabulary, halstead_length, halstead_volume, + halstead_difficulty, halstead_effort, halstead_bugs, + maintainability_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); const getNodeId = db.prepare( "SELECT id FROM nodes WHERE name = ? AND kind IN ('function','method') AND file = ? AND line = ?", @@ -336,10 +596,36 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp const result = computeFunctionComplexity(funcNode, langId); if (!result) continue; + const halstead = computeHalsteadMetrics(funcNode, langId); + const loc = computeLOCMetrics(funcNode); + + const volume = halstead ? halstead.volume : 0; + const commentRatio = loc.loc > 0 ? loc.commentLines / loc.loc : 0; + const mi = computeMaintainabilityIndex(volume, result.cyclomatic, loc.sloc, commentRatio); + const row = getNodeId.get(def.name, relPath, def.line); if (!row) continue; - upsert.run(row.id, result.cognitive, result.cyclomatic, result.maxNesting); + upsert.run( + row.id, + result.cognitive, + result.cyclomatic, + result.maxNesting, + loc.loc, + loc.sloc, + loc.commentLines, + halstead ? halstead.n1 : 0, + halstead ? halstead.n2 : 0, + halstead ? halstead.bigN1 : 0, + halstead ? halstead.bigN2 : 0, + halstead ? halstead.vocabulary : 0, + halstead ? halstead.length : 0, + volume, + halstead ? halstead.difficulty : 0, + halstead ? halstead.effort : 0, + halstead ? halstead.bugs : 0, + mi, + ); analyzed++; } @@ -425,6 +711,9 @@ export function complexityData(customDbPath, opts = {}) { if (thresholds.maxNesting?.warn != null) { conditions.push(`fc.max_nesting >= ${thresholds.maxNesting.warn}`); } + if (thresholds.maintainabilityIndex?.warn != null) { + conditions.push(`fc.maintainability_index <= ${thresholds.maintainabilityIndex.warn}`); + } if (conditions.length > 0) { having = `AND (${conditions.join(' OR ')})`; } @@ -434,6 +723,11 @@ export function complexityData(customDbPath, opts = {}) { cognitive: 'fc.cognitive DESC', cyclomatic: 'fc.cyclomatic DESC', nesting: 'fc.max_nesting DESC', + mi: 'fc.maintainability_index ASC', + volume: 'fc.halstead_volume DESC', + effort: 'fc.halstead_effort DESC', + bugs: 'fc.halstead_bugs DESC', + loc: 'fc.loc DESC', }; const orderBy = orderMap[sort] || 'fc.cognitive DESC'; @@ -442,7 +736,9 @@ export function complexityData(customDbPath, opts = {}) { rows = db .prepare( `SELECT n.name, n.kind, n.file, n.line, n.end_line, - fc.cognitive, fc.cyclomatic, fc.max_nesting + fc.cognitive, fc.cyclomatic, fc.max_nesting, + fc.loc, fc.sloc, fc.maintainability_index, + fc.halstead_volume, fc.halstead_difficulty, fc.halstead_effort, fc.halstead_bugs FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id ${where} ${having} @@ -466,6 +762,12 @@ export function complexityData(customDbPath, opts = {}) { exceeds.push('cyclomatic'); if (thresholds.maxNesting?.warn != null && r.max_nesting >= thresholds.maxNesting.warn) exceeds.push('maxNesting'); + if ( + thresholds.maintainabilityIndex?.warn != null && + r.maintainability_index > 0 && + r.maintainability_index <= thresholds.maintainabilityIndex.warn + ) + exceeds.push('maintainabilityIndex'); return { name: r.name, @@ -476,6 +778,15 @@ export function complexityData(customDbPath, opts = {}) { cognitive: r.cognitive, cyclomatic: r.cyclomatic, maxNesting: r.max_nesting, + loc: r.loc || 0, + sloc: r.sloc || 0, + maintainabilityIndex: r.maintainability_index || 0, + halstead: { + volume: r.halstead_volume || 0, + difficulty: r.halstead_difficulty || 0, + effort: r.halstead_effort || 0, + bugs: r.halstead_bugs || 0, + }, exceeds: exceeds.length > 0 ? exceeds : undefined, }; }); @@ -485,7 +796,7 @@ export function complexityData(customDbPath, opts = {}) { try { const allRows = db .prepare( - `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting + `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index 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.%'` : ''}`, @@ -493,17 +804,23 @@ export function complexityData(customDbPath, opts = {}) { .all(); if (allRows.length > 0) { + const miValues = allRows.map((r) => r.maintainability_index || 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)), + avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), + minMI: +Math.min(...miValues).toFixed(1), 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), + (thresholds.maxNesting?.warn != null && r.max_nesting >= thresholds.maxNesting.warn) || + (thresholds.maintainabilityIndex?.warn != null && + r.maintainability_index > 0 && + r.maintainability_index <= thresholds.maintainabilityIndex.warn), ).length, }; } @@ -540,27 +857,48 @@ export function complexity(customDbPath, opts = {}) { 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)}`, - ); + if (opts.health) { + // Health-focused view with Halstead + MI columns + console.log( + ` ${'Function'.padEnd(35)} ${'File'.padEnd(25)} ${'MI'.padStart(5)} ${'Vol'.padStart(7)} ${'Diff'.padStart(6)} ${'Effort'.padStart(9)} ${'Bugs'.padStart(6)} ${'LOC'.padStart(5)} ${'SLOC'.padStart(5)}`, + ); + console.log( + ` ${'─'.repeat(35)} ${'─'.repeat(25)} ${'─'.repeat(5)} ${'─'.repeat(7)} ${'─'.repeat(6)} ${'─'.repeat(9)} ${'─'.repeat(6)} ${'─'.repeat(5)} ${'─'.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 ? ' !' : ''; + for (const fn of data.functions) { + const name = fn.name.length > 33 ? `${fn.name.slice(0, 32)}…` : fn.name; + const file = fn.file.length > 23 ? `…${fn.file.slice(-22)}` : fn.file; + const miWarn = fn.exceeds?.includes('maintainabilityIndex') ? '!' : ' '; + console.log( + ` ${name.padEnd(35)} ${file.padEnd(25)} ${String(fn.maintainabilityIndex).padStart(5)}${miWarn}${String(fn.halstead.volume).padStart(7)} ${String(fn.halstead.difficulty).padStart(6)} ${String(fn.halstead.effort).padStart(9)} ${String(fn.halstead.bugs).padStart(6)} ${String(fn.loc).padStart(5)} ${String(fn.sloc).padStart(5)}`, + ); + } + } else { + // Default view with MI column appended + console.log( + ` ${'Function'.padEnd(40)} ${'File'.padEnd(30)} ${'Cog'.padStart(4)} ${'Cyc'.padStart(4)} ${'Nest'.padStart(5)} ${'MI'.padStart(5)}`, + ); console.log( - ` ${name.padEnd(40)} ${file.padEnd(30)} ${String(fn.cognitive).padStart(4)} ${String(fn.cyclomatic).padStart(4)} ${String(fn.maxNesting).padStart(5)}${warn}`, + ` ${'─'.repeat(40)} ${'─'.repeat(30)} ${'─'.repeat(4)} ${'─'.repeat(4)} ${'─'.repeat(5)} ${'─'.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 ? ' !' : ''; + const mi = fn.maintainabilityIndex > 0 ? String(fn.maintainabilityIndex) : '-'; + console.log( + ` ${name.padEnd(40)} ${file.padEnd(30)} ${String(fn.cognitive).padStart(4)} ${String(fn.cyclomatic).padStart(4)} ${String(fn.maxNesting).padStart(5)} ${mi.padStart(5)}${warn}`, + ); + } } if (data.summary) { const s = data.summary; + const miPart = s.avgMI != null ? ` | avg MI: ${s.avgMI}` : ''; console.log( - `\n ${s.analyzed} functions analyzed | avg cognitive: ${s.avgCognitive} | avg cyclomatic: ${s.avgCyclomatic} | ${s.aboveWarn} above threshold`, + `\n ${s.analyzed} functions analyzed | avg cognitive: ${s.avgCognitive} | avg cyclomatic: ${s.avgCyclomatic}${miPart} | ${s.aboveWarn} above threshold`, ); } console.log(); diff --git a/src/config.js b/src/config.js index c51b6a26..b0bceaac 100644 --- a/src/config.js +++ b/src/config.js @@ -29,6 +29,7 @@ export const DEFAULTS = { cognitive: { warn: 15, fail: null }, cyclomatic: { warn: 10, fail: null }, maxNesting: { warn: 4, fail: null }, + maintainabilityIndex: { warn: 20, fail: null }, importCount: { warn: null, fail: null }, exportCount: { warn: null, fail: null }, lineCount: { warn: null, fail: null }, diff --git a/src/db.js b/src/db.js index 87a07354..9a35f322 100644 --- a/src/db.js +++ b/src/db.js @@ -124,6 +124,26 @@ export const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_fc_cyclomatic ON function_complexity(cyclomatic DESC); `, }, + { + version: 9, + up: ` + ALTER TABLE function_complexity ADD COLUMN loc INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN sloc INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN comment_lines INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_n1 INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_n2 INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_big_n1 INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_big_n2 INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_vocabulary INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_length INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_volume REAL DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_difficulty REAL DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_effort REAL DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_bugs REAL DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN maintainability_index REAL DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_fc_mi ON function_complexity(maintainability_index ASC); + `, + }, ]; export function getBuildMeta(db, key) { diff --git a/src/index.js b/src/index.js index 11a15a61..742e1e31 100644 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,10 @@ export { complexity, complexityData, computeFunctionComplexity, + computeHalsteadMetrics, + computeLOCMetrics, + computeMaintainabilityIndex, + HALSTEAD_RULES, } from './complexity.js'; // Configuration export { loadConfig } from './config.js'; diff --git a/src/mcp.js b/src/mcp.js index c9b5e542..b7532f78 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -406,7 +406,7 @@ const BASE_TOOLS = [ { name: 'complexity', description: - 'Show per-function complexity metrics (cognitive, cyclomatic, max nesting depth). Sorted by most complex first.', + 'Show per-function complexity metrics (cognitive, cyclomatic, nesting, Halstead, Maintainability Index). Sorted by most complex first.', inputSchema: { type: 'object', properties: { @@ -415,7 +415,7 @@ const BASE_TOOLS = [ limit: { type: 'number', description: 'Max results', default: 20 }, sort: { type: 'string', - enum: ['cognitive', 'cyclomatic', 'nesting'], + enum: ['cognitive', 'cyclomatic', 'nesting', 'mi', 'volume', 'effort', 'bugs', 'loc'], description: 'Sort metric', default: 'cognitive', }, @@ -424,6 +424,11 @@ const BASE_TOOLS = [ description: 'Only functions exceeding warn thresholds', default: false, }, + health: { + type: 'boolean', + description: 'Include Halstead and Maintainability Index metrics', + default: false, + }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, kind: { type: 'string', @@ -799,6 +804,7 @@ export async function startMCPServer(customDbPath, options = {}) { limit: args.limit, sort: args.sort, aboveThreshold: args.above_threshold, + health: args.health, noTests: args.no_tests, kind: args.kind, }); diff --git a/src/queries.js b/src/queries.js index e7549516..4c7f84a5 100644 --- a/src/queries.js +++ b/src/queries.js @@ -1368,18 +1368,21 @@ export function statsData(customDbPath, opts = {}) { try { const cRows = db .prepare( - `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting + `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index 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) { + const miValues = cRows.map((r) => r.maintainability_index || 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)), + avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), + minMI: +Math.min(...miValues).toFixed(1), }; } } catch { @@ -1521,8 +1524,9 @@ export async function stats(customDbPath, opts = {}) { // Complexity if (data.complexity) { const cx = data.complexity; + const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : ''; console.log( - `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}`, + `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`, ); } @@ -2001,7 +2005,7 @@ export function contextData(name, customDbPath, opts = {}) { try { const cRow = db .prepare( - 'SELECT cognitive, cyclomatic, max_nesting FROM function_complexity WHERE node_id = ?', + 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?', ) .get(node.id); if (cRow) { @@ -2009,6 +2013,8 @@ export function contextData(name, customDbPath, opts = {}) { cognitive: cRow.cognitive, cyclomatic: cRow.cyclomatic, maxNesting: cRow.max_nesting, + maintainabilityIndex: cRow.maintainability_index || 0, + halsteadVolume: cRow.halstead_volume || 0, }; } } catch { @@ -2062,9 +2068,10 @@ export function context(name, customDbPath, opts = {}) { // Complexity if (r.complexity) { const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : ''; console.log('## Complexity'); console.log( - ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}`, + ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`, ); console.log(); } @@ -2292,7 +2299,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { try { const cRow = db .prepare( - 'SELECT cognitive, cyclomatic, max_nesting FROM function_complexity WHERE node_id = ?', + 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?', ) .get(node.id); if (cRow) { @@ -2300,6 +2307,8 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { cognitive: cRow.cognitive, cyclomatic: cRow.cyclomatic, maxNesting: cRow.max_nesting, + maintainabilityIndex: cRow.maintainability_index || 0, + halsteadVolume: cRow.halstead_volume || 0, }; } } catch { @@ -2468,8 +2477,9 @@ export function explain(target, customDbPath, opts = {}) { if (r.complexity) { const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : ''; console.log( - `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}`, + `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`, ); } diff --git a/tests/integration/complexity.test.js b/tests/integration/complexity.test.js index d4db314e..20fec19c 100644 --- a/tests/integration/complexity.test.js +++ b/tests/integration/complexity.test.js @@ -21,10 +21,52 @@ function insertNode(db, name, kind, file, line, endLine = null) { .run(name, kind, file, line, endLine).lastInsertRowid; } -function insertComplexity(db, nodeId, cognitive, cyclomatic, maxNesting) { +function insertComplexity( + db, + nodeId, + cognitive, + cyclomatic, + maxNesting, + { + loc = 10, + sloc = 8, + commentLines = 1, + volume = 100, + difficulty = 5, + effort = 500, + bugs = 0.03, + mi = 60, + } = {}, +) { db.prepare( - 'INSERT INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting) VALUES (?, ?, ?, ?)', - ).run(nodeId, cognitive, cyclomatic, maxNesting); + `INSERT INTO function_complexity + (node_id, cognitive, cyclomatic, max_nesting, + loc, sloc, comment_lines, + halstead_n1, halstead_n2, halstead_big_n1, halstead_big_n2, + halstead_vocabulary, halstead_length, halstead_volume, + halstead_difficulty, halstead_effort, halstead_bugs, + maintainability_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + nodeId, + cognitive, + cyclomatic, + maxNesting, + loc, + sloc, + commentLines, + 10, + 15, + 30, + 40, + 25, + 70, + volume, + difficulty, + effort, + bugs, + mi, + ); } // ─── Fixture DB ──────────────────────────────────────────────────────── @@ -47,12 +89,52 @@ beforeAll(() => { 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 + // Insert complexity data with health metrics + insertComplexity(db, fn1, 0, 1, 0, { + loc: 3, + sloc: 2, + volume: 20, + difficulty: 1, + effort: 20, + bugs: 0.007, + mi: 90, + }); + insertComplexity(db, fn2, 18, 8, 4, { + loc: 35, + sloc: 28, + volume: 800, + difficulty: 15, + effort: 12000, + bugs: 0.27, + mi: 35, + }); + insertComplexity(db, fn3, 12, 11, 3, { + loc: 20, + sloc: 16, + volume: 500, + difficulty: 10, + effort: 5000, + bugs: 0.17, + mi: 45, + }); + insertComplexity(db, fn4, 25, 15, 5, { + loc: 40, + sloc: 32, + volume: 1500, + difficulty: 25, + effort: 37500, + bugs: 0.5, + mi: 15, + }); + insertComplexity(db, fn5, 5, 3, 2, { + loc: 10, + sloc: 8, + volume: 100, + difficulty: 5, + effort: 500, + bugs: 0.03, + mi: 65, + }); db.close(); }); @@ -154,4 +236,88 @@ describe('complexityData', () => { const data = complexityData(dbPath, { target: 'nonexistent_xyz' }); expect(data.functions.length).toBe(0); }); + + // ─── Halstead / MI Tests ───────────────────────────────────────────── + + test('functions include halstead and MI data', () => { + const data = complexityData(dbPath); + const fn = data.functions.find((f) => f.name === 'processItems'); + expect(fn.maintainabilityIndex).toBe(35); + expect(fn.loc).toBe(35); + expect(fn.sloc).toBe(28); + expect(fn.halstead).toBeDefined(); + expect(fn.halstead.volume).toBe(800); + expect(fn.halstead.difficulty).toBe(15); + expect(fn.halstead.effort).toBe(12000); + expect(fn.halstead.bugs).toBe(0.27); + }); + + test('sort by mi (ascending — worst first)', () => { + const data = complexityData(dbPath, { sort: 'mi' }); + expect(data.functions.length).toBeGreaterThanOrEqual(2); + // MI ascending: lowest MI first + expect(data.functions[0].maintainabilityIndex).toBeLessThanOrEqual( + data.functions[1].maintainabilityIndex, + ); + }); + + test('sort by volume (descending)', () => { + const data = complexityData(dbPath, { sort: 'volume' }); + expect(data.functions[0].halstead.volume).toBeGreaterThanOrEqual( + data.functions[1].halstead.volume, + ); + }); + + test('sort by effort (descending)', () => { + const data = complexityData(dbPath, { sort: 'effort' }); + expect(data.functions[0].halstead.effort).toBeGreaterThanOrEqual( + data.functions[1].halstead.effort, + ); + }); + + test('sort by bugs (descending)', () => { + const data = complexityData(dbPath, { sort: 'bugs' }); + expect(data.functions[0].halstead.bugs).toBeGreaterThanOrEqual(data.functions[1].halstead.bugs); + }); + + test('sort by loc (descending)', () => { + const data = complexityData(dbPath, { sort: 'loc' }); + expect(data.functions[0].loc).toBeGreaterThanOrEqual(data.functions[1].loc); + }); + + test('aboveThreshold considers MI threshold', () => { + const data = complexityData(dbPath, { aboveThreshold: true }); + const names = data.functions.map((f) => f.name); + // handleRequest has MI=15, below warn=20 → should be included + expect(names).toContain('handleRequest'); + }); + + test('exceeds includes maintainabilityIndex for low-MI functions', () => { + const data = complexityData(dbPath); + const handler = data.functions.find((f) => f.name === 'handleRequest'); + expect(handler.exceeds).toContain('maintainabilityIndex'); + + const simple = data.functions.find((f) => f.name === 'simpleAdd'); + expect(simple.exceeds).toBeUndefined(); + }); + + test('summary includes avgMI and minMI', () => { + const data = complexityData(dbPath); + expect(data.summary.avgMI).toBeDefined(); + expect(data.summary.minMI).toBeDefined(); + expect(data.summary.avgMI).toBeGreaterThan(0); + expect(data.summary.minMI).toBeLessThanOrEqual(data.summary.avgMI); + }); + + test('JSON output contains halstead object', () => { + const data = complexityData(dbPath); + for (const fn of data.functions) { + expect(fn.halstead).toBeDefined(); + expect(typeof fn.halstead.volume).toBe('number'); + expect(typeof fn.halstead.difficulty).toBe('number'); + expect(typeof fn.halstead.effort).toBe('number'); + expect(typeof fn.halstead.bugs).toBe('number'); + expect(typeof fn.maintainabilityIndex).toBe('number'); + } + }); }); diff --git a/tests/unit/complexity.test.js b/tests/unit/complexity.test.js index fc064df4..af56a161 100644 --- a/tests/unit/complexity.test.js +++ b/tests/unit/complexity.test.js @@ -6,7 +6,14 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; -import { COMPLEXITY_RULES, computeFunctionComplexity } from '../../src/complexity.js'; +import { + COMPLEXITY_RULES, + computeFunctionComplexity, + computeHalsteadMetrics, + computeLOCMetrics, + computeMaintainabilityIndex, + HALSTEAD_RULES, +} from '../../src/complexity.js'; import { createParsers } from '../../src/parser.js'; let jsParser; @@ -252,3 +259,193 @@ describe('COMPLEXITY_RULES', () => { expect(COMPLEXITY_RULES.has('go')).toBe(false); }); }); + +// ─── Halstead Metrics ───────────────────────────────────────────────────── + +function analyzeHalstead(code) { + const root = parse(code); + const funcNode = getFunctionBody(root); + if (!funcNode) throw new Error('No function found in code snippet'); + return computeHalsteadMetrics(funcNode, 'javascript'); +} + +describe('computeHalsteadMetrics', () => { + it('returns null for unsupported language', () => { + const result = computeHalsteadMetrics({}, 'unknown_lang'); + expect(result).toBeNull(); + }); + + it('simple function has n1>0, n2>0, volume>0', () => { + const result = analyzeHalstead(` + function add(a, b) { + return a + b; + } + `); + expect(result).not.toBeNull(); + expect(result.n1).toBeGreaterThan(0); + expect(result.n2).toBeGreaterThan(0); + expect(result.volume).toBeGreaterThan(0); + expect(result.difficulty).toBeGreaterThan(0); + expect(result.effort).toBeGreaterThan(0); + expect(result.bugs).toBeGreaterThan(0); + }); + + it('empty function body does not crash', () => { + const result = analyzeHalstead(` + function empty() {} + `); + expect(result).not.toBeNull(); + expect(result.vocabulary).toBeGreaterThanOrEqual(0); + expect(Number.isFinite(result.volume)).toBe(true); + expect(Number.isFinite(result.difficulty)).toBe(true); + }); + + it('complex function has greater volume than simple', () => { + const simple = analyzeHalstead(` + function add(a, b) { return a + b; } + `); + const complex = analyzeHalstead(` + function process(items, options) { + const results = []; + for (let i = 0; i < items.length; i++) { + if (items[i].type === 'A') { + results.push(items[i].value * 2 + options.offset); + } else if (items[i].type === 'B') { + results.push(items[i].value / 3 - options.offset); + } + } + return results; + } + `); + expect(complex.volume).toBeGreaterThan(simple.volume); + }); + + it('repeated operands increase difficulty', () => { + // Same identifier used many times vs distinct identifiers + const repeated = analyzeHalstead(` + function rep(x) { + return x + x + x + x + x; + } + `); + const distinct = analyzeHalstead(` + function dist(a, b, c, d, e) { + return a + b + c + d + e; + } + `); + // With more distinct operands, difficulty per operand is lower + expect(repeated.difficulty).toBeGreaterThan(distinct.difficulty); + }); +}); + +describe('HALSTEAD_RULES', () => { + it('supports javascript, typescript, tsx', () => { + expect(HALSTEAD_RULES.has('javascript')).toBe(true); + expect(HALSTEAD_RULES.has('typescript')).toBe(true); + expect(HALSTEAD_RULES.has('tsx')).toBe(true); + }); + + it('does not support python or go', () => { + expect(HALSTEAD_RULES.has('python')).toBe(false); + expect(HALSTEAD_RULES.has('go')).toBe(false); + }); +}); + +// ─── LOC Metrics ────────────────────────────────────────────────────────── + +describe('computeLOCMetrics', () => { + it('counts lines correctly', () => { + const root = parse(` + function multi(a, b) { + // comment + const x = a + b; + + return x; + } + `); + const funcNode = getFunctionBody(root); + const result = computeLOCMetrics(funcNode); + expect(result.loc).toBeGreaterThan(1); + expect(result.sloc).toBeGreaterThan(0); + expect(result.commentLines).toBeGreaterThanOrEqual(1); + }); + + it('detects comment lines', () => { + const root = parse(` + function commented() { + // line comment + /* block comment */ + * star comment + return 1; + } + `); + const funcNode = getFunctionBody(root); + const result = computeLOCMetrics(funcNode); + expect(result.commentLines).toBeGreaterThanOrEqual(3); + }); + + it('SLOC excludes blanks and comments', () => { + const root = parse(` + function blank() { + + // comment + + return 1; + } + `); + const funcNode = getFunctionBody(root); + const result = computeLOCMetrics(funcNode); + expect(result.sloc).toBeLessThan(result.loc); + }); + + it('single-line function', () => { + const root = parse('function one() { return 1; }'); + const funcNode = getFunctionBody(root); + const result = computeLOCMetrics(funcNode); + expect(result.loc).toBe(1); + expect(result.sloc).toBe(1); + expect(result.commentLines).toBe(0); + }); +}); + +// ─── Maintainability Index ──────────────────────────────────────────────── + +describe('computeMaintainabilityIndex', () => { + it('trivial function has high MI (>70)', () => { + // Low volume, low cyclomatic, low SLOC → high MI + const mi = computeMaintainabilityIndex(10, 1, 3); + expect(mi).toBeGreaterThan(70); + }); + + it('complex function has low MI (<30)', () => { + // High volume, high cyclomatic, high SLOC → low MI + const mi = computeMaintainabilityIndex(5000, 30, 200); + expect(mi).toBeLessThan(30); + }); + + it('comments improve MI', () => { + const without = computeMaintainabilityIndex(500, 10, 50); + const with_ = computeMaintainabilityIndex(500, 10, 50, 0.3); + expect(with_).toBeGreaterThan(without); + }); + + it('normalized to 0-100 range', () => { + // Very high values should clamp to 0 + const low = computeMaintainabilityIndex(100000, 100, 5000); + expect(low).toBeGreaterThanOrEqual(0); + expect(low).toBeLessThanOrEqual(100); + + // Very low values should clamp near 100 + const high = computeMaintainabilityIndex(1, 1, 1); + expect(high).toBeGreaterThanOrEqual(0); + expect(high).toBeLessThanOrEqual(100); + }); + + it('handles zero guards (no NaN/Infinity)', () => { + const result = computeMaintainabilityIndex(0, 0, 0); + expect(Number.isFinite(result)).toBe(true); + expect(Number.isNaN(result)).toBe(false); + + const result2 = computeMaintainabilityIndex(0, 0, 0, 0); + expect(Number.isFinite(result2)).toBe(true); + }); +}); From 67318af7f930b2efb9faaad93f64ca0882854a1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 17:39:12 -0700 Subject: [PATCH 2/3] feat: pagination and streaming for bounded, context-friendly query results Add offset/limit pagination to data functions so MCP clients get bounded results with metadata to request more, and CLI consumers can process results incrementally via NDJSON. - New src/paginate.js with paginate(), paginateResult(), MCP_DEFAULTS, MCP_MAX_LIMIT utilities - Pagination support in listFunctionsData, queryNameData, whereData, rolesData, listEntryPointsData - Export limiting for DOT/Mermaid (truncation comments) and JSON (edge pagination) - MCP tool schemas updated with limit/offset props and sensible defaults (e.g. list_functions: 100, query_function: 50) - CLI --limit, --offset, --ndjson flags on query, where, roles, flow - Programmatic API exports from index.js - 33 new integration tests covering all pagination scenarios Impact: 19 functions changed, 18 affected --- src/cli.js | 35 ++- src/export.js | 26 +- src/flow.js | 15 +- src/index.js | 2 + src/mcp.js | 38 ++- src/paginate.js | 70 ++++++ src/queries.js | 34 ++- tests/integration/pagination.test.js | 363 +++++++++++++++++++++++++++ tests/unit/mcp.test.js | 10 +- 9 files changed, 578 insertions(+), 15 deletions(-) create mode 100644 src/paginate.js create mode 100644 tests/integration/pagination.test.js diff --git a/src/cli.js b/src/cli.js index 955052a9..1c77ab83 100644 --- a/src/cli.js +++ b/src/cli.js @@ -100,8 +100,17 @@ program .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) => { - queryName(name, opts.db, { noTests: resolveNoTests(opts), json: opts.json }); + 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, + }); }); program @@ -282,13 +291,23 @@ program .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 (!name && !opts.file) { console.error('Provide a symbol name or use --file '); process.exit(1); } const target = opts.file || name; - where(target, opts.db, { file: !!opts.file, noTests: resolveNoTests(opts), json: opts.json }); + where(target, opts.db, { + file: !!opts.file, + 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 @@ -604,6 +623,9 @@ program .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('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action((opts) => { if (opts.role && !VALID_ROLES.includes(opts.role)) { console.error(`Invalid role "${opts.role}". Valid roles: ${VALID_ROLES.join(', ')}`); @@ -614,6 +636,9 @@ program file: opts.file, 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, }); }); @@ -692,6 +717,9 @@ program .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(async (name, opts) => { if (!name && !opts.list) { console.error('Provide a function/entry point name or use --list to see all entry points.'); @@ -709,6 +737,9 @@ program 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, }); }); diff --git a/src/export.js b/src/export.js index bbcbcdeb..e13ca5ef 100644 --- a/src/export.js +++ b/src/export.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { paginateResult } from './paginate.js'; import { isTestFile } from './queries.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; @@ -10,6 +11,7 @@ export function exportDOT(db, opts = {}) { const fileLevel = opts.fileLevel !== false; const noTests = opts.noTests || false; const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + const edgeLimit = opts.limit; const lines = [ 'digraph codegraph {', ' rankdir=LR;', @@ -30,6 +32,8 @@ export function exportDOT(db, opts = {}) { `) .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + const totalFileEdges = edges.length; + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); // Try to use directory nodes from DB (built by structure analysis) const hasDirectoryNodes = @@ -95,6 +99,9 @@ export function exportDOT(db, opts = {}) { for (const { source, target } of edges) { lines.push(` "${source}" -> "${target}";`); } + if (edgeLimit && totalFileEdges > edgeLimit) { + lines.push(` // Truncated: showing ${edges.length} of ${totalFileEdges} edges`); + } } else { let edges = db .prepare(` @@ -111,6 +118,8 @@ export function exportDOT(db, opts = {}) { .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + const totalFnEdges = edges.length; + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); for (const e of edges) { const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_'); @@ -119,6 +128,9 @@ export function exportDOT(db, opts = {}) { lines.push(` ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`); lines.push(` ${sId} -> ${tId};`); } + if (edgeLimit && totalFnEdges > edgeLimit) { + lines.push(` // Truncated: showing ${edges.length} of ${totalFnEdges} edges`); + } } lines.push('}'); @@ -169,6 +181,7 @@ export function exportMermaid(db, opts = {}) { const noTests = opts.noTests || false; const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; const direction = opts.direction || 'LR'; + const edgeLimit = opts.limit; const lines = [`flowchart ${direction}`]; let nodeCounter = 0; @@ -190,6 +203,8 @@ export function exportMermaid(db, opts = {}) { `) .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + const totalMermaidFileEdges = edges.length; + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); // Collect all files referenced in edges const allFiles = new Set(); @@ -248,6 +263,9 @@ export function exportMermaid(db, opts = {}) { for (const { source, target, labels } of edgeMap.values()) { lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`); } + if (edgeLimit && totalMermaidFileEdges > edgeLimit) { + lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFileEdges} edges`); + } } else { let edges = db .prepare(` @@ -265,6 +283,8 @@ export function exportMermaid(db, opts = {}) { .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + const totalMermaidFnEdges = edges.length; + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); // Group nodes by file for subgraphs const fileNodes = new Map(); @@ -301,6 +321,9 @@ export function exportMermaid(db, opts = {}) { const tId = nodeId(`${e.target_file}::${e.target_name}`); lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`); } + if (edgeLimit && totalMermaidFnEdges > edgeLimit) { + lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFnEdges} edges`); + } // Role styling — query roles for all referenced nodes const allKeys = [...nodeIdMap.keys()]; @@ -348,5 +371,6 @@ export function exportJSON(db, opts = {}) { .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); - return { nodes, edges }; + const base = { nodes, edges }; + return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }); } diff --git a/src/flow.js b/src/flow.js index 77d3130f..93381652 100644 --- a/src/flow.js +++ b/src/flow.js @@ -6,6 +6,7 @@ */ import { openReadonlyOrFail } from './db.js'; +import { paginateResult } from './paginate.js'; import { isTestFile, kindIcon } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; @@ -69,7 +70,8 @@ export function listEntryPointsData(dbPath, opts = {}) { } db.close(); - return { entries, byType, count: entries.length }; + const base = { entries, byType, count: entries.length }; + return paginateResult(base, 'entries', { limit: opts.limit, offset: opts.offset }); } /** @@ -285,7 +287,16 @@ function findBestMatch(db, name, opts = {}) { */ export function flow(name, dbPath, opts = {}) { if (opts.list) { - const data = listEntryPointsData(dbPath, { noTests: opts.noTests }); + const data = listEntryPointsData(dbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (opts.ndjson) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + for (const e of data.entries) console.log(JSON.stringify(e)); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; diff --git a/src/index.js b/src/index.js index 742e1e31..2b539e12 100644 --- a/src/index.js +++ b/src/index.js @@ -70,6 +70,8 @@ export { setVerbose } from './logger.js'; export { manifesto, manifestoData, RULE_DEFS } from './manifesto.js'; // Native engine export { isNativeAvailable } from './native.js'; +// Pagination utilities +export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult } from './paginate.js'; // Unified parser API export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js'; diff --git a/src/mcp.js b/src/mcp.js index b7532f78..abd41893 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -8,6 +8,7 @@ import { createRequire } from 'node:module'; import { findCycles } from './cycles.js'; import { findDbPath } from './db.js'; +import { MCP_DEFAULTS, MCP_MAX_LIMIT } from './paginate.js'; import { ALL_SYMBOL_KINDS, diffImpactMermaid, VALID_ROLES } from './queries.js'; const REPO_PROP = { @@ -17,6 +18,11 @@ const REPO_PROP = { }, }; +const PAGINATION_PROPS = { + limit: { type: 'number', description: 'Max results to return (pagination)' }, + offset: { type: 'number', description: 'Skip this many results (pagination, default: 0)' }, +}; + const BASE_TOOLS = [ { name: 'query_function', @@ -31,6 +37,7 @@ const BASE_TOOLS = [ default: 2, }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['name'], }, @@ -214,6 +221,7 @@ const BASE_TOOLS = [ default: false, }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['target'], }, @@ -266,6 +274,7 @@ const BASE_TOOLS = [ description: 'File-level graph (true) or function-level (false)', default: true, }, + ...PAGINATION_PROPS, }, required: ['format'], }, @@ -280,6 +289,7 @@ const BASE_TOOLS = [ file: { type: 'string', description: 'Filter by file path (partial match)' }, pattern: { type: 'string', description: 'Filter by function name (partial match)' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, }, }, @@ -319,6 +329,7 @@ const BASE_TOOLS = [ }, file: { type: 'string', description: 'Scope to a specific file (partial match)' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, }, }, @@ -400,6 +411,7 @@ const BASE_TOOLS = [ type: 'object', properties: { no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, }, }, @@ -604,7 +616,11 @@ export async function startMCPServer(customDbPath, options = {}) { let result; switch (name) { case 'query_function': - result = queryNameData(args.name, dbPath, { noTests: args.no_tests }); + 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, + }); break; case 'file_deps': result = fileDepsData(args.file, dbPath, { noTests: args.no_tests }); @@ -666,6 +682,8 @@ export async function startMCPServer(customDbPath, options = {}) { result = whereData(args.target, dbPath, { file: args.file_mode, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.where, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'diff_impact': @@ -705,15 +723,21 @@ export async function startMCPServer(customDbPath, options = {}) { const { exportDOT, exportMermaid, exportJSON } = await import('./export.js'); const db = new Database(findDbPath(dbPath), { readonly: true }); const fileLevel = args.file_level !== false; + const exportLimit = args.limit + ? Math.min(args.limit, MCP_MAX_LIMIT) + : MCP_DEFAULTS.export_graph; switch (args.format) { case 'dot': - result = exportDOT(db, { fileLevel }); + result = exportDOT(db, { fileLevel, limit: exportLimit }); break; case 'mermaid': - result = exportMermaid(db, { fileLevel }); + result = exportMermaid(db, { fileLevel, limit: exportLimit }); break; case 'json': - result = exportJSON(db); + result = exportJSON(db, { + limit: exportLimit, + offset: args.offset ?? 0, + }); break; default: db.close(); @@ -735,6 +759,8 @@ export async function startMCPServer(customDbPath, options = {}) { file: args.file, pattern: args.pattern, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.list_functions, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'node_roles': @@ -742,6 +768,8 @@ export async function startMCPServer(customDbPath, options = {}) { role: args.role, file: args.file, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.node_roles, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'structure': { @@ -793,6 +821,8 @@ export async function startMCPServer(customDbPath, options = {}) { 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, }); break; } diff --git a/src/paginate.js b/src/paginate.js new file mode 100644 index 00000000..522a60bb --- /dev/null +++ b/src/paginate.js @@ -0,0 +1,70 @@ +/** + * Pagination utilities for bounded, context-friendly query results. + * + * Offset/limit pagination — the DB is a read-only snapshot so data doesn't + * change between pages; offset/limit is simpler and maps directly to SQL. + */ + +/** Default limits applied by MCP tool handlers (not by the programmatic API). */ +export const MCP_DEFAULTS = { + list_functions: 100, + query_function: 50, + where: 50, + node_roles: 100, + list_entry_points: 100, + export_graph: 500, +}; + +/** Hard cap to prevent abuse via MCP. */ +export const MCP_MAX_LIMIT = 1000; + +/** + * Paginate an array. + * + * When `limit` is undefined the input is returned unchanged (no-op). + * + * @param {any[]} items + * @param {{ limit?: number, offset?: number }} opts + * @returns {{ items: any[], pagination?: { total: number, offset: number, limit: number, hasMore: boolean, returned: number } }} + */ +export function paginate(items, { limit, offset } = {}) { + if (limit === undefined && limit !== 0) { + return { items }; + } + const total = items.length; + const off = Math.max(0, Math.min(offset || 0, total)); + const lim = Math.max(0, limit); + const page = items.slice(off, off + lim); + return { + items: page, + pagination: { + total, + offset: off, + limit: lim, + hasMore: off + lim < total, + returned: page.length, + }, + }; +} + +/** + * Apply pagination to a named array field on a result object. + * + * When `limit` is undefined the result is returned unchanged (backward compat). + * When active, `_pagination` metadata is added to the result. + * + * @param {object} result - The result object (e.g. `{ count: 42, functions: [...] }`) + * @param {string} field - The array field name to paginate (e.g. `'functions'`) + * @param {{ limit?: number, offset?: number }} opts + * @returns {object} - Result with paginated field + `_pagination` (if active) + */ +export function paginateResult(result, field, { limit, offset } = {}) { + if (limit === undefined && limit !== 0) { + return result; + } + const arr = result[field]; + if (!Array.isArray(arr)) return result; + + const { items, pagination } = paginate(arr, { limit, offset }); + return { ...result, [field]: items, _pagination: pagination }; +} diff --git a/src/queries.js b/src/queries.js index 4c7f84a5..91b4989d 100644 --- a/src/queries.js +++ b/src/queries.js @@ -5,6 +5,7 @@ import { coChangeForFiles } from './cochange.js'; import { findCycles } from './cycles.js'; import { findDbPath, openReadonlyOrFail } from './db.js'; import { debug } from './logger.js'; +import { paginateResult } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; /** @@ -248,7 +249,8 @@ export function queryNameData(name, customDbPath, opts = {}) { }); db.close(); - return { query: name, results }; + const base = { query: name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function impactAnalysisData(file, customDbPath, opts = {}) { @@ -1153,7 +1155,8 @@ export function listFunctionsData(customDbPath, opts = {}) { if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); db.close(); - return { count: rows.length, functions: rows }; + const base = { count: rows.length, functions: rows }; + return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); } export function statsData(customDbPath, opts = {}) { @@ -1544,7 +1547,16 @@ export async function stats(customDbPath, opts = {}) { // ─── Human-readable output (original formatting) ─────────────────────── export function queryName(name, customDbPath, opts = {}) { - const data = queryNameData(name, customDbPath, { noTests: opts.noTests }); + const data = queryNameData(name, customDbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (opts.ndjson) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + for (const r of data.results) console.log(JSON.stringify(r)); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -2626,11 +2638,17 @@ export function whereData(target, customDbPath, opts = {}) { const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests); db.close(); - return { target, mode: fileMode ? 'file' : 'symbol', results }; + const base = { target, mode: fileMode ? 'file' : 'symbol', results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function where(target, customDbPath, opts = {}) { const data = whereData(target, customDbPath, opts); + if (opts.ndjson) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + for (const r of data.results) console.log(JSON.stringify(r)); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -2712,11 +2730,17 @@ export function rolesData(customDbPath, opts = {}) { } db.close(); - return { count: rows.length, summary, symbols: rows }; + const base = { count: rows.length, summary, symbols: rows }; + return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset }); } export function roles(customDbPath, opts = {}) { const data = rolesData(customDbPath, opts); + if (opts.ndjson) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + for (const s of data.symbols) console.log(JSON.stringify(s)); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; diff --git a/tests/integration/pagination.test.js b/tests/integration/pagination.test.js new file mode 100644 index 00000000..4bf652f8 --- /dev/null +++ b/tests/integration/pagination.test.js @@ -0,0 +1,363 @@ +/** + * Integration tests for pagination utilities and paginated data functions. + * + * Tests cover: + * - paginate() utility: no-op, slicing, hasMore, offset clamping, returned count + * - paginateResult() utility: wraps result, preserves fields, no-op without limit + * - listFunctionsData with pagination + * - rolesData with pagination (summary still full) + * - queryNameData with pagination + * - whereData with pagination + * - listEntryPointsData with pagination + * - MCP default limits + * - Export limiting (DOT/Mermaid truncation, JSON edge pagination) + */ + +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 { initSchema } from '../../src/db.js'; +import { exportDOT, exportJSON, exportMermaid } from '../../src/export.js'; +import { listEntryPointsData } from '../../src/flow.js'; +import { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult } from '../../src/paginate.js'; +import { listFunctionsData, queryNameData, rolesData, whereData } from '../../src/queries.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line, role = null) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line, role) VALUES (?, ?, ?, ?, ?)') + .run(name, kind, file, line, role).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, 0)', + ).run(sourceId, targetId, kind, confidence); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath, dbForExport; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pagination-')); + 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); + + // File nodes + const fA = insertNode(db, 'a.js', 'file', 'a.js', 0); + const fB = insertNode(db, 'b.js', 'file', 'b.js', 0); + const fC = insertNode(db, 'c.js', 'file', 'c.js', 0); + + // Function nodes with roles + const fn1 = insertNode(db, 'alpha', 'function', 'a.js', 1, 'entry'); + const fn2 = insertNode(db, 'beta', 'function', 'a.js', 10, 'core'); + const fn3 = insertNode(db, 'gamma', 'function', 'b.js', 1, 'utility'); + const fn4 = insertNode(db, 'delta', 'function', 'b.js', 10, 'leaf'); + const fn5 = insertNode(db, 'epsilon', 'function', 'c.js', 1, 'core'); + insertNode(db, 'route:GET /health', 'function', 'c.js', 20, 'entry'); + + // Import edges + insertEdge(db, fA, fB, 'imports'); + insertEdge(db, fB, fC, 'imports'); + insertEdge(db, fA, fC, 'imports'); + + // Call edges + insertEdge(db, fn1, fn2, 'calls'); + insertEdge(db, fn2, fn3, 'calls'); + insertEdge(db, fn3, fn4, 'calls'); + insertEdge(db, fn1, fn5, 'calls'); + insertEdge(db, fn5, fn4, 'calls'); + + db.close(); + + // Keep a read-only handle for export tests + dbForExport = new Database(dbPath, { readonly: true }); +}); + +afterAll(() => { + if (dbForExport) dbForExport.close(); + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── paginate() utility ─────────────────────────────────────────────── + +describe('paginate()', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + test('no-op without limit', () => { + const result = paginate(items, {}); + expect(result.items).toEqual(items); + expect(result.pagination).toBeUndefined(); + }); + + test('no-op with undefined limit', () => { + const result = paginate(items, { limit: undefined }); + expect(result.items).toEqual(items); + expect(result.pagination).toBeUndefined(); + }); + + test('correct slicing with limit', () => { + const result = paginate(items, { limit: 3 }); + expect(result.items).toEqual([1, 2, 3]); + expect(result.pagination).toEqual({ + total: 10, + offset: 0, + limit: 3, + hasMore: true, + returned: 3, + }); + }); + + test('offset + limit', () => { + const result = paginate(items, { limit: 3, offset: 5 }); + expect(result.items).toEqual([6, 7, 8]); + expect(result.pagination.offset).toBe(5); + expect(result.pagination.hasMore).toBe(true); + }); + + test('hasMore is false at end', () => { + const result = paginate(items, { limit: 3, offset: 8 }); + expect(result.items).toEqual([9, 10]); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.returned).toBe(2); + }); + + test('offset clamping beyond length', () => { + const result = paginate(items, { limit: 5, offset: 100 }); + expect(result.items).toEqual([]); + expect(result.pagination.returned).toBe(0); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.offset).toBe(10); + }); + + test('negative offset treated as 0', () => { + const result = paginate(items, { limit: 2, offset: -5 }); + expect(result.items).toEqual([1, 2]); + expect(result.pagination.offset).toBe(0); + }); + + test('limit 0 returns empty page', () => { + const result = paginate(items, { limit: 0 }); + expect(result.items).toEqual([]); + expect(result.pagination.total).toBe(10); + expect(result.pagination.returned).toBe(0); + }); +}); + +// ─── paginateResult() utility ───────────────────────────────────────── + +describe('paginateResult()', () => { + const result = { count: 5, functions: ['a', 'b', 'c', 'd', 'e'], extra: 'preserved' }; + + test('no-op without limit', () => { + const out = paginateResult(result, 'functions', {}); + expect(out).toEqual(result); + expect(out._pagination).toBeUndefined(); + }); + + test('wraps result correctly', () => { + const out = paginateResult(result, 'functions', { limit: 2 }); + expect(out.functions).toEqual(['a', 'b']); + expect(out._pagination.total).toBe(5); + expect(out._pagination.hasMore).toBe(true); + expect(out._pagination.returned).toBe(2); + }); + + test('preserves other fields', () => { + const out = paginateResult(result, 'functions', { limit: 2 }); + expect(out.count).toBe(5); + expect(out.extra).toBe('preserved'); + }); + + test('non-array field returns result unchanged', () => { + const obj = { count: 1, data: 'not-an-array' }; + const out = paginateResult(obj, 'data', { limit: 5 }); + expect(out).toEqual(obj); + }); +}); + +// ─── listFunctionsData with pagination ──────────────────────────────── + +describe('listFunctionsData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = listFunctionsData(dbPath); + expect(data.functions.length).toBeGreaterThanOrEqual(5); + expect(data._pagination).toBeUndefined(); + }); + + test('returns page with _pagination', () => { + const data = listFunctionsData(dbPath, { limit: 2 }); + expect(data.functions).toHaveLength(2); + expect(data._pagination).toBeDefined(); + expect(data._pagination.total).toBeGreaterThanOrEqual(5); + expect(data._pagination.hasMore).toBe(true); + expect(data._pagination.returned).toBe(2); + }); + + test('second page via offset', () => { + const page1 = listFunctionsData(dbPath, { limit: 2, offset: 0 }); + const page2 = listFunctionsData(dbPath, { limit: 2, offset: 2 }); + const names1 = page1.functions.map((f) => f.name); + const names2 = page2.functions.map((f) => f.name); + // Pages should not overlap + for (const n of names2) { + expect(names1).not.toContain(n); + } + }); +}); + +// ─── rolesData with pagination ──────────────────────────────────────── + +describe('rolesData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = rolesData(dbPath); + expect(data.symbols.length).toBeGreaterThanOrEqual(5); + expect(data._pagination).toBeUndefined(); + }); + + test('summary contains full aggregation even when paginated', () => { + const full = rolesData(dbPath); + const paginated = rolesData(dbPath, { limit: 2 }); + // Summary should be identical (computed before pagination) + expect(paginated.summary).toEqual(full.summary); + expect(paginated.count).toBe(full.count); + expect(paginated.symbols).toHaveLength(2); + expect(paginated._pagination.total).toBe(full.count); + }); +}); + +// ─── queryNameData with pagination ──────────────────────────────────── + +describe('queryNameData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = queryNameData('a', dbPath); + expect(data._pagination).toBeUndefined(); + }); + + test('paginated results', () => { + const data = queryNameData('a', dbPath, { limit: 1 }); + expect(data.results).toHaveLength(1); + expect(data._pagination).toBeDefined(); + expect(data._pagination.returned).toBe(1); + }); + + test('second page returns remaining', () => { + const full = queryNameData('a', dbPath); + if (full.results.length > 1) { + const page2 = queryNameData('a', dbPath, { limit: 1, offset: 1 }); + expect(page2.results[0].name).toBe(full.results[1].name); + } + }); +}); + +// ─── whereData with pagination ──────────────────────────────────────── + +describe('whereData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = whereData('alpha', dbPath); + expect(data._pagination).toBeUndefined(); + }); + + test('paginated results', () => { + // 'a' should match multiple symbols + const full = whereData('a', dbPath); + if (full.results.length > 1) { + const paginated = whereData('a', dbPath, { limit: 1 }); + expect(paginated.results).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + expect(paginated._pagination.total).toBe(full.results.length); + } + }); +}); + +// ─── listEntryPointsData with pagination ────────────────────────────── + +describe('listEntryPointsData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = listEntryPointsData(dbPath); + expect(data._pagination).toBeUndefined(); + expect(data.entries.length).toBeGreaterThanOrEqual(1); + }); + + test('paginated entries', () => { + const full = listEntryPointsData(dbPath); + const paginated = listEntryPointsData(dbPath, { limit: 1 }); + expect(paginated.entries).toHaveLength(Math.min(1, full.entries.length)); + if (full.entries.length > 1) { + expect(paginated._pagination.hasMore).toBe(true); + } + }); +}); + +// ─── MCP default limits ────────────────────────────────────────────── + +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.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); + }); + + test('MCP_MAX_LIMIT is 1000', () => { + expect(MCP_MAX_LIMIT).toBe(1000); + }); + + test('MCP handler applies default limit to listFunctionsData', () => { + // Simulate what the MCP handler does + const limit = Math.min(MCP_DEFAULTS.list_functions, MCP_MAX_LIMIT); + const data = listFunctionsData(dbPath, { limit, offset: 0 }); + expect(data._pagination).toBeDefined(); + expect(data._pagination.limit).toBe(100); + }); +}); + +// ─── Export limiting ───────────────────────────────────────────────── + +describe('export limiting', () => { + test('DOT truncation comment when limit exceeded', () => { + const dot = exportDOT(dbForExport, { fileLevel: true, limit: 1 }); + expect(dot).toContain('// Truncated: showing'); + }); + + test('DOT no truncation comment when under limit', () => { + const dot = exportDOT(dbForExport, { fileLevel: true, limit: 1000 }); + expect(dot).not.toContain('// Truncated'); + }); + + test('Mermaid truncation comment when limit exceeded', () => { + const mermaid = exportMermaid(dbForExport, { fileLevel: true, limit: 1 }); + expect(mermaid).toContain('%% Truncated: showing'); + }); + + test('Mermaid no truncation when under limit', () => { + const mermaid = exportMermaid(dbForExport, { fileLevel: true, limit: 1000 }); + expect(mermaid).not.toContain('%% Truncated'); + }); + + test('JSON edge pagination', () => { + const full = exportJSON(dbForExport); + if (full.edges.length > 1) { + const paginated = exportJSON(dbForExport, { limit: 1 }); + expect(paginated.edges).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + expect(paginated._pagination.total).toBe(full.edges.length); + expect(paginated._pagination.hasMore).toBe(true); + } + }); + + test('JSON no pagination without limit', () => { + const result = exportJSON(dbForExport); + expect(result._pagination).toBeUndefined(); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index fd6427d5..a221fafb 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -503,6 +503,8 @@ describe('startMCPServer handler dispatch', () => { file: 'utils', pattern: 'parse', noTests: undefined, + limit: 100, + offset: 0, }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); @@ -560,6 +562,8 @@ describe('startMCPServer handler dispatch', () => { expect(result.isError).toBeUndefined(); expect(queryMock).toHaveBeenCalledWith('test', '/resolved/path/.codegraph/graph.db', { noTests: undefined, + limit: 50, + offset: 0, }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); @@ -720,7 +724,11 @@ describe('startMCPServer handler dispatch', () => { params: { name: 'query_function', arguments: { name: 'test', repo: 'my-repo' } }, }); expect(result.isError).toBeUndefined(); - expect(queryMock).toHaveBeenCalledWith('test', '/resolved/db', { noTests: undefined }); + expect(queryMock).toHaveBeenCalledWith('test', '/resolved/db', { + noTests: undefined, + limit: 50, + offset: 0, + }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js'); From e81fd6efed055a228035547bda45dc168617cf12 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 17:58:48 -0700 Subject: [PATCH 3/3] fix: exclude uncomputed MI=0 rows from threshold filtering The --above-threshold filter for maintainabilityIndex now requires MI > 0, consistent with the exceeds array logic that already guards against uncomputed rows from pre-migration databases. Impact: 1 functions changed, 4 affected Impact: 1 functions changed, 1 affected --- src/complexity.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/complexity.js b/src/complexity.js index 1a6af1de..6830201e 100644 --- a/src/complexity.js +++ b/src/complexity.js @@ -712,7 +712,9 @@ export function complexityData(customDbPath, opts = {}) { conditions.push(`fc.max_nesting >= ${thresholds.maxNesting.warn}`); } if (thresholds.maintainabilityIndex?.warn != null) { - conditions.push(`fc.maintainability_index <= ${thresholds.maintainabilityIndex.warn}`); + conditions.push( + `fc.maintainability_index > 0 AND fc.maintainability_index <= ${thresholds.maintainabilityIndex.warn}`, + ); } if (conditions.length > 0) { having = `AND (${conditions.join(' OR ')})`;