diff --git a/src/cli.js b/src/cli.js index 41f14272..c6d72cf7 100644 --- a/src/cli.js +++ b/src/cli.js @@ -16,6 +16,7 @@ import { } from './embedder.js'; import { exportDOT, exportJSON, exportMermaid } from './export.js'; import { setVerbose } from './logger.js'; +import { printNdjson } from './paginate.js'; import { ALL_SYMBOL_KINDS, context, @@ -127,8 +128,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((file, opts) => { - impactAnalysis(file, opts.db, { noTests: resolveNoTests(opts), json: opts.json }); + impactAnalysis(file, 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 @@ -164,8 +174,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((file, opts) => { - fileDeps(file, opts.db, { noTests: resolveNoTests(opts), json: opts.json }); + fileDeps(file, 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 @@ -178,6 +197,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((name, opts) => { if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); @@ -189,6 +211,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, }); }); @@ -202,6 +227,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((name, opts) => { if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); @@ -213,6 +241,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, }); }); @@ -258,6 +289,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((name, opts) => { if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); @@ -271,6 +305,9 @@ program noTests: resolveNoTests(opts), includeTests: opts.withTestSource, json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, }); }); @@ -282,11 +319,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((target, opts) => { explain(target, opts.db, { depth: parseInt(opts.depth, 10), 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, }); }); @@ -327,6 +370,9 @@ program .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') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action((ref, opts) => { diffImpact(opts.db, { ref, @@ -335,6 +381,9 @@ program noTests: resolveNoTests(opts), json: opts.json, format: opts.format, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, }); }); @@ -640,6 +689,8 @@ program .option('--rrf-k ', 'RRF k parameter for multi-query ranking', '60') .option('--mode ', 'Search mode: hybrid, semantic, keyword (default: hybrid)') .option('-j, --json', 'Output as JSON') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action(async (query, opts) => { const validModes = ['hybrid', 'semantic', 'keyword']; if (opts.mode && !validModes.includes(opts.mode)) { @@ -671,6 +722,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(async (dir, opts) => { const { structureData, formatStructure } = await import('./structure.js'); const data = structureData(opts.db, { @@ -679,8 +733,12 @@ program sort: opts.sort, full: opts.full, noTests: resolveNoTests(opts), + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, }); - if (opts.json) { + if (opts.ndjson) { + printNdjson(data, 'directories'); + } else if (opts.json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(formatStructure(data)); @@ -699,15 +757,20 @@ 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('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action(async (opts) => { const { hotspotsData, formatHotspots } = await import('./structure.js'); const data = hotspotsData(opts.db, { metric: opts.metric, level: opts.level, limit: parseInt(opts.limit, 10), + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, noTests: resolveNoTests(opts), }); - if (opts.json) { + if (opts.ndjson) { + printNdjson(data, 'hotspots'); + } else if (opts.json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(formatHotspots(data)); @@ -757,6 +820,8 @@ 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('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action(async (file, opts) => { const { analyzeCoChanges, coChangeData, coChangeTopData, formatCoChange, formatCoChangeTop } = await import('./cochange.js'); @@ -783,20 +848,25 @@ program const queryOpts = { limit: parseInt(opts.limit, 10), + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, minJaccard: opts.minJaccard ? parseFloat(opts.minJaccard) : config.coChange?.minJaccard, noTests: resolveNoTests(opts), }; if (file) { const data = coChangeData(file, opts.db, queryOpts); - if (opts.json) { + if (opts.ndjson) { + printNdjson(data, 'partners'); + } else if (opts.json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(formatCoChange(data)); } } else { const data = coChangeTopData(opts.db, queryOpts); - if (opts.json) { + if (opts.ndjson) { + printNdjson(data, 'pairs'); + } else if (opts.json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(formatCoChangeTop(data)); @@ -860,6 +930,8 @@ 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('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action(async (target, opts) => { if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); @@ -869,6 +941,7 @@ program complexity(opts.db, { target, limit: parseInt(opts.limit, 10), + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, sort: opts.sort, aboveThreshold: opts.aboveThreshold, health: opts.health, @@ -876,6 +949,7 @@ program kind: opts.kind, noTests: resolveNoTests(opts), json: opts.json, + ndjson: opts.ndjson, }); }); @@ -888,6 +962,9 @@ program .option('-f, --file ', 'Scope to file (partial match)') .option('-k, --kind ', 'Filter by symbol kind') .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 (opts) => { if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); @@ -899,6 +976,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, }); }); @@ -912,6 +992,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 (opts) => { const { communities } = await import('./communities.js'); communities(opts.db, { @@ -920,6 +1003,9 @@ program drift: opts.drift, 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/cochange.js b/src/cochange.js index b08ce8db..d1fb2ed3 100644 --- a/src/cochange.js +++ b/src/cochange.js @@ -11,6 +11,7 @@ import path from 'node:path'; import { normalizePath } from './constants.js'; import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js'; import { warn } from './logger.js'; +import { paginateResult } from './paginate.js'; import { isTestFile } from './queries.js'; /** @@ -313,7 +314,8 @@ export function coChangeData(file, customDbPath, opts = {}) { const meta = getCoChangeMeta(db); closeDb(db); - return { file: resolvedFile, partners, meta }; + const base = { file: resolvedFile, partners, meta }; + return paginateResult(base, 'partners', { limit: opts.limit, offset: opts.offset }); } /** @@ -365,7 +367,8 @@ export function coChangeTopData(customDbPath, opts = {}) { const meta = getCoChangeMeta(db); closeDb(db); - return { pairs, meta }; + const base = { pairs, meta }; + return paginateResult(base, 'pairs', { limit: opts.limit, offset: opts.offset }); } /** diff --git a/src/communities.js b/src/communities.js index 7eba4071..926b611b 100644 --- a/src/communities.js +++ b/src/communities.js @@ -2,6 +2,7 @@ import path from 'node:path'; import Graph from 'graphology'; import louvain from 'graphology-communities-louvain'; import { openReadonlyOrFail } from './db.js'; +import { paginateResult, printNdjson } from './paginate.js'; import { isTestFile } from './queries.js'; // ─── Graph Construction ─────────────────────────────────────────────── @@ -201,7 +202,7 @@ export function communitiesData(customDbPath, opts = {}) { const driftScore = Math.round(((splitRatio + mergeRatio) / 2) * 100); - return { + const base = { communities: opts.drift ? [] : communities, modularity: +modularity.toFixed(4), drift: { splitCandidates, mergeCandidates }, @@ -212,6 +213,7 @@ export function communitiesData(customDbPath, opts = {}) { driftScore, }, }; + return paginateResult(base, 'communities', { limit: opts.limit, offset: opts.offset }); } /** @@ -238,6 +240,10 @@ export function communitySummaryForStats(customDbPath, opts = {}) { export function communities(customDbPath, opts = {}) { const data = communitiesData(customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'communities'); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; diff --git a/src/complexity.js b/src/complexity.js index 01ffee18..30bee701 100644 --- a/src/complexity.js +++ b/src/complexity.js @@ -3,6 +3,7 @@ import path from 'node:path'; import { loadConfig } from './config.js'; import { openReadonlyOrFail } from './db.js'; import { info } from './logger.js'; +import { paginateResult, printNdjson } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; import { isTestFile } from './queries.js'; @@ -1887,10 +1888,9 @@ export function complexityData(customDbPath, opts = {}) { FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id ${where} ${having} - ORDER BY ${orderBy} - LIMIT ?`, + ORDER BY ${orderBy}`, ) - .all(...params, limit); + .all(...params); } catch { db.close(); return { functions: [], summary: null, thresholds }; @@ -1980,7 +1980,88 @@ export function complexityData(customDbPath, opts = {}) { } db.close(); - return { functions, summary, thresholds }; + const base = { functions, summary, thresholds }; + return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); +} + +/** + * Generator: stream complexity rows one-by-one using .iterate() for memory efficiency. + * @param {string} [customDbPath] + * @param {object} [opts] + * @param {boolean} [opts.noTests] + * @param {string} [opts.file] + * @param {string} [opts.target] + * @param {string} [opts.kind] + * @param {string} [opts.sort] + * @yields {{ name: string, kind: string, file: string, line: number, cognitive: number, cyclomatic: number, maxNesting: number, loc: number, sloc: number }} + */ +export function* iterComplexity(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const sort = opts.sort || 'cognitive'; + + 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 (opts.target) { + where += ' AND n.name LIKE ?'; + params.push(`%${opts.target}%`); + } + if (opts.file) { + where += ' AND n.file LIKE ?'; + params.push(`%${opts.file}%`); + } + if (opts.kind) { + where += ' AND n.kind = ?'; + params.push(opts.kind); + } + + const orderMap = { + 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'; + + const stmt = db.prepare( + `SELECT n.name, n.kind, n.file, n.line, n.end_line, + fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.loc, fc.sloc + FROM function_complexity fc + JOIN nodes n ON fc.node_id = n.id + ${where} + ORDER BY ${orderBy}`, + ); + for (const r of stmt.iterate(...params)) { + if (noTests && isTestFile(r.file)) continue; + yield { + 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, + loc: r.loc || 0, + sloc: r.sloc || 0, + }; + } + } finally { + db.close(); + } } /** @@ -1989,6 +2070,10 @@ export function complexityData(customDbPath, opts = {}) { export function complexity(customDbPath, opts = {}) { const data = complexityData(customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'functions'); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; diff --git a/src/flow.js b/src/flow.js index 93381652..ab59fe45 100644 --- a/src/flow.js +++ b/src/flow.js @@ -6,7 +6,7 @@ */ import { openReadonlyOrFail } from './db.js'; -import { paginateResult } from './paginate.js'; +import { paginateResult, printNdjson } from './paginate.js'; import { isTestFile, kindIcon } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; @@ -204,7 +204,7 @@ export function flowData(name, dbPath, opts = {}) { } db.close(); - return { + const base = { entry, depth: maxDepth, steps, @@ -213,6 +213,7 @@ export function flowData(name, dbPath, opts = {}) { totalReached: visited.size - 1, // exclude the entry node itself truncated, }; + return paginateResult(base, 'steps', { limit: opts.limit, offset: opts.offset }); } /** @@ -293,8 +294,7 @@ export function flow(name, dbPath, opts = {}) { 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)); + printNdjson(data, 'entries'); return; } if (opts.json) { diff --git a/src/index.js b/src/index.js index b195d8c6..c9f5f862 100644 --- a/src/index.js +++ b/src/index.js @@ -30,6 +30,7 @@ export { computeLOCMetrics, computeMaintainabilityIndex, HALSTEAD_RULES, + iterComplexity, } from './complexity.js'; // Configuration export { loadConfig } from './config.js'; @@ -75,7 +76,7 @@ export { isNativeAvailable } from './native.js'; // Ownership (CODEOWNERS) export { matchOwners, owners, ownersData, ownersForFiles, parseCodeowners } from './owners.js'; // Pagination utilities -export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult } from './paginate.js'; +export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult, printNdjson } from './paginate.js'; // Unified parser API export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js'; @@ -92,6 +93,9 @@ export { fnDepsData, fnImpactData, impactAnalysisData, + iterListFunctions, + iterRoles, + iterWhere, kindIcon, moduleMapData, pathData, diff --git a/src/manifesto.js b/src/manifesto.js index 8fc907ff..3549860a 100644 --- a/src/manifesto.js +++ b/src/manifesto.js @@ -2,6 +2,7 @@ import { loadConfig } from './config.js'; import { findCycles } from './cycles.js'; import { openReadonlyOrFail } from './db.js'; import { debug } from './logger.js'; +import { paginateResult, printNdjson } from './paginate.js'; // ─── Rule Definitions ───────────────────────────────────────────────── @@ -354,12 +355,13 @@ export function manifestoData(customDbPath, opts = {}) { violationCount: violations.length, }; - return { + const base = { rules: ruleResults, violations, summary, passed: failViolations.length === 0, }; + return paginateResult(base, 'violations', { limit: opts.limit, offset: opts.offset }); } finally { db.close(); } @@ -371,6 +373,11 @@ export function manifestoData(customDbPath, opts = {}) { export function manifesto(customDbPath, opts = {}) { const data = manifestoData(customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'violations'); + if (!data.passed) process.exit(1); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); if (!data.passed) process.exit(1); diff --git a/src/mcp.js b/src/mcp.js index ee11bb3c..19732931 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -50,6 +50,7 @@ const BASE_TOOLS = [ properties: { file: { type: 'string', description: 'File path (partial match supported)' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['file'], }, @@ -62,6 +63,7 @@ const BASE_TOOLS = [ properties: { file: { type: 'string', description: 'File path to analyze' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['file'], }, @@ -103,6 +105,7 @@ const BASE_TOOLS = [ description: 'Filter to a specific symbol kind', }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['name'], }, @@ -126,6 +129,7 @@ const BASE_TOOLS = [ description: 'Filter to a specific symbol kind', }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['name'], }, @@ -190,6 +194,7 @@ const BASE_TOOLS = [ description: 'Include test file source code', default: false, }, + ...PAGINATION_PROPS, }, required: ['name'], }, @@ -203,6 +208,7 @@ const BASE_TOOLS = [ properties: { target: { type: 'string', description: 'File path or function name' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['target'], }, @@ -241,6 +247,7 @@ const BASE_TOOLS = [ enum: ['json', 'mermaid'], description: 'Output format (default: json)', }, + ...PAGINATION_PROPS, }, }, }, @@ -260,6 +267,7 @@ const BASE_TOOLS = [ description: 'Search mode: hybrid (BM25 + semantic, default), semantic (embeddings only), keyword (BM25 only)', }, + ...PAGINATION_PROPS, }, required: ['query'], }, @@ -318,6 +326,7 @@ const BASE_TOOLS = [ description: 'Return all files without limit', default: false, }, + ...PAGINATION_PROPS, }, }, }, @@ -358,6 +367,7 @@ const BASE_TOOLS = [ }, limit: { type: 'number', description: 'Number of results to return', default: 10 }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + offset: { type: 'number', description: 'Skip this many results (pagination, default: 0)' }, }, }, }, @@ -379,6 +389,7 @@ const BASE_TOOLS = [ default: 0.3, }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + offset: { type: 'number', description: 'Skip this many results (pagination, default: 0)' }, }, }, }, @@ -405,6 +416,7 @@ const BASE_TOOLS = [ description: 'Filter to a specific symbol kind', }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['name'], }, @@ -452,6 +464,7 @@ const BASE_TOOLS = [ type: 'string', description: 'Filter by symbol kind (function, method, class, etc.)', }, + offset: { type: 'number', description: 'Skip this many results (pagination, default: 0)' }, }, }, }, @@ -468,6 +481,7 @@ const BASE_TOOLS = [ type: 'string', description: 'Filter by symbol kind (function, method, class, etc.)', }, + ...PAGINATION_PROPS, }, }, }, @@ -494,6 +508,7 @@ const BASE_TOOLS = [ default: false, }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, }, }, @@ -671,10 +686,18 @@ export async function startMCPServer(customDbPath, options = {}) { }); break; case 'file_deps': - result = fileDepsData(args.file, dbPath, { noTests: args.no_tests }); + result = fileDepsData(args.file, dbPath, { + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.file_deps, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); break; case 'impact_analysis': - result = impactAnalysisData(args.file, dbPath, { noTests: args.no_tests }); + result = impactAnalysisData(args.file, dbPath, { + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.impact_analysis, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); break; case 'find_cycles': { const db = new Database(findDbPath(dbPath), { readonly: true }); @@ -692,6 +715,8 @@ export async function startMCPServer(customDbPath, options = {}) { file: args.file, kind: args.kind, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.fn_deps, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'fn_impact': @@ -700,6 +725,8 @@ export async function startMCPServer(customDbPath, options = {}) { file: args.file, kind: args.kind, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.fn_impact, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'symbol_path': @@ -721,10 +748,16 @@ export async function startMCPServer(customDbPath, options = {}) { noSource: args.no_source, noTests: args.no_tests, includeTests: args.include_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.context, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'explain': - result = explainData(args.target, dbPath, { noTests: args.no_tests }); + result = explainData(args.target, dbPath, { + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.explain, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); break; case 'where': result = whereData(args.target, dbPath, { @@ -748,12 +781,18 @@ export async function startMCPServer(customDbPath, options = {}) { ref: args.ref, depth: args.depth, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.diff_impact, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); } break; case 'semantic_search': { const mode = args.mode || 'hybrid'; - const searchOpts = { limit: args.limit, minScore: args.min_score }; + const searchOpts = { + limit: Math.min(args.limit ?? MCP_DEFAULTS.semantic_search, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + minScore: args.min_score, + }; if (mode === 'keyword') { const { ftsSearchData } = await import('./embedder.js'); @@ -864,6 +903,8 @@ export async function startMCPServer(customDbPath, options = {}) { depth: args.depth, sort: args.sort, full: args.full, + limit: Math.min(args.limit ?? MCP_DEFAULTS.structure, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; } @@ -872,7 +913,8 @@ export async function startMCPServer(customDbPath, options = {}) { result = hotspotsData(dbPath, { metric: args.metric, level: args.level, - limit: args.limit, + limit: Math.min(args.limit ?? MCP_DEFAULTS.hotspots, MCP_MAX_LIMIT), + offset: args.offset ?? 0, noTests: args.no_tests, }); break; @@ -881,12 +923,14 @@ export async function startMCPServer(customDbPath, options = {}) { const { coChangeData, coChangeTopData } = await import('./cochange.js'); result = args.file ? coChangeData(args.file, dbPath, { - limit: args.limit, + limit: Math.min(args.limit ?? MCP_DEFAULTS.co_changes, MCP_MAX_LIMIT), + offset: args.offset ?? 0, minJaccard: args.min_jaccard, noTests: args.no_tests, }) : coChangeTopData(dbPath, { - limit: args.limit, + limit: Math.min(args.limit ?? MCP_DEFAULTS.co_changes, MCP_MAX_LIMIT), + offset: args.offset ?? 0, minJaccard: args.min_jaccard, noTests: args.no_tests, }); @@ -899,6 +943,8 @@ export async function startMCPServer(customDbPath, options = {}) { file: args.file, kind: args.kind, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.execution_flow, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; } @@ -916,7 +962,8 @@ export async function startMCPServer(customDbPath, options = {}) { result = complexityData(dbPath, { target: args.name, file: args.file, - limit: args.limit, + limit: Math.min(args.limit ?? MCP_DEFAULTS.complexity, MCP_MAX_LIMIT), + offset: args.offset ?? 0, sort: args.sort, aboveThreshold: args.above_threshold, health: args.health, @@ -931,6 +978,8 @@ export async function startMCPServer(customDbPath, options = {}) { file: args.file, noTests: args.no_tests, kind: args.kind, + limit: Math.min(args.limit ?? MCP_DEFAULTS.manifesto, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; } @@ -941,6 +990,8 @@ export async function startMCPServer(customDbPath, options = {}) { resolution: args.resolution, drift: args.drift, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.communities, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; } diff --git a/src/paginate.js b/src/paginate.js index 7109f0bc..a93ec1da 100644 --- a/src/paginate.js +++ b/src/paginate.js @@ -7,12 +7,29 @@ /** Default limits applied by MCP tool handlers (not by the programmatic API). */ export const MCP_DEFAULTS = { + // Existing list_functions: 100, query_function: 50, where: 50, node_roles: 100, list_entry_points: 100, export_graph: 500, + // Smaller defaults for rich/nested results + fn_deps: 10, + fn_impact: 5, + context: 5, + explain: 10, + file_deps: 20, + diff_impact: 30, + impact_analysis: 20, + semantic_search: 20, + execution_flow: 50, + hotspots: 20, + co_changes: 20, + complexity: 30, + manifesto: 50, + communities: 20, + structure: 30, }; /** Hard cap to prevent abuse via MCP. */ @@ -68,3 +85,20 @@ export function paginateResult(result, field, { limit, offset } = {}) { const { items, pagination } = paginate(arr, { limit, offset }); return { ...result, [field]: items, _pagination: pagination }; } + +/** + * Print data as newline-delimited JSON (NDJSON). + * + * Emits a `_meta` line with pagination info (if present), then one JSON + * line per item in the named array field. + * + * @param {object} data - Result object (may contain `_pagination`) + * @param {string} field - Array field name to stream (e.g. `'results'`) + */ +export function printNdjson(data, field) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + const items = data[field]; + if (Array.isArray(items)) { + for (const item of items) console.log(JSON.stringify(item)); + } +} diff --git a/src/queries.js b/src/queries.js index 9b1929ab..2a8df478 100644 --- a/src/queries.js +++ b/src/queries.js @@ -6,7 +6,7 @@ import { findCycles } from './cycles.js'; import { findDbPath, openReadonlyOrFail } from './db.js'; import { debug } from './logger.js'; import { ownersForFiles } from './owners.js'; -import { paginateResult } from './paginate.js'; +import { paginateResult, printNdjson } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; /** @@ -392,7 +392,8 @@ export function fileDepsData(file, customDbPath, opts = {}) { }); db.close(); - return { file, results }; + const base = { file, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function fnDepsData(name, customDbPath, opts = {}) { @@ -512,7 +513,8 @@ export function fnDepsData(name, customDbPath, opts = {}) { }); db.close(); - return { name, results }; + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function fnImpactData(name, customDbPath, opts = {}) { @@ -526,7 +528,7 @@ export function fnImpactData(name, customDbPath, opts = {}) { return { name, results: [] }; } - const results = nodes.slice(0, 3).map((node) => { + const results = nodes.map((node) => { const visited = new Set([node.id]); const levels = {}; let frontier = [node.id]; @@ -565,7 +567,8 @@ export function fnImpactData(name, customDbPath, opts = {}) { }); db.close(); - return { name, results }; + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function pathData(from, to, customDbPath, opts = {}) { @@ -1016,7 +1019,7 @@ export function diffImpactData(customDbPath, opts = {}) { } db.close(); - return { + const base = { changedFiles: changedRanges.size, newFiles: [...newFiles], affectedFunctions: functionResults, @@ -1031,6 +1034,7 @@ export function diffImpactData(customDbPath, opts = {}) { ownersAffected: ownership ? ownership.affectedOwners.length : 0, }, }; + return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset }); } export function diffImpactMermaid(customDbPath, opts = {}) { @@ -1178,6 +1182,131 @@ export function listFunctionsData(customDbPath, opts = {}) { return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); } +/** + * Generator: stream functions one-by-one using .iterate() for memory efficiency. + * @param {string} [customDbPath] + * @param {object} [opts] + * @param {boolean} [opts.noTests] + * @param {string} [opts.file] + * @param {string} [opts.pattern] + * @yields {{ name: string, kind: string, file: string, line: number, role: string|null }} + */ +export function* iterListFunctions(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const kinds = ['function', 'method', 'class']; + const placeholders = kinds.map(() => '?').join(', '); + + const conditions = [`kind IN (${placeholders})`]; + const params = [...kinds]; + + if (opts.file) { + conditions.push('file LIKE ?'); + params.push(`%${opts.file}%`); + } + if (opts.pattern) { + conditions.push('name LIKE ?'); + params.push(`%${opts.pattern}%`); + } + + const stmt = db.prepare( + `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`, + ); + for (const row of stmt.iterate(...params)) { + if (noTests && isTestFile(row.file)) continue; + yield { name: row.name, kind: row.kind, file: row.file, line: row.line, role: row.role }; + } + } finally { + db.close(); + } +} + +/** + * Generator: stream role-classified symbols one-by-one. + * @param {string} [customDbPath] + * @param {object} [opts] + * @param {boolean} [opts.noTests] + * @param {string} [opts.role] + * @param {string} [opts.file] + * @yields {{ name: string, kind: string, file: string, line: number, role: string }} + */ +export function* iterRoles(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const conditions = ['role IS NOT NULL']; + const params = []; + + if (opts.role) { + conditions.push('role = ?'); + params.push(opts.role); + } + if (opts.file) { + conditions.push('file LIKE ?'); + params.push(`%${opts.file}%`); + } + + const stmt = db.prepare( + `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`, + ); + for (const row of stmt.iterate(...params)) { + if (noTests && isTestFile(row.file)) continue; + yield { name: row.name, kind: row.kind, file: row.file, line: row.line, role: row.role }; + } + } finally { + db.close(); + } +} + +/** + * Generator: stream symbol lookup results one-by-one. + * @param {string} target - Symbol name to search for (partial match) + * @param {string} [customDbPath] + * @param {object} [opts] + * @param {boolean} [opts.noTests] + * @yields {{ name: string, kind: string, file: string, line: number, role: string|null, exported: boolean, uses: object[] }} + */ +export function* iterWhere(target, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', '); + const stmt = db.prepare( + `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`, + ); + const crossFileCallersStmt = db.prepare( + `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`, + ); + const usesStmt = db.prepare( + `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls'`, + ); + for (const node of stmt.iterate(`%${target}%`, ...ALL_SYMBOL_KINDS)) { + if (noTests && isTestFile(node.file)) continue; + + const crossFileCallers = crossFileCallersStmt.get(node.id, node.file); + const exported = crossFileCallers.cnt > 0; + + let uses = usesStmt.all(node.id); + if (noTests) uses = uses.filter((u) => !isTestFile(u.file)); + + yield { + name: node.name, + kind: node.kind, + file: node.file, + line: node.line, + role: node.role || null, + exported, + uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })), + }; + } + } finally { + db.close(); + } +} + export function statsData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); const noTests = opts.noTests || false; @@ -1572,8 +1701,7 @@ export function queryName(name, customDbPath, opts = {}) { 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)); + printNdjson(data, 'results'); return; } if (opts.json) { @@ -1605,7 +1733,11 @@ export function queryName(name, customDbPath, opts = {}) { } export function impactAnalysis(file, customDbPath, opts = {}) { - const data = impactAnalysisData(file, customDbPath, { noTests: opts.noTests }); + const data = impactAnalysisData(file, customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'sources'); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -1664,7 +1796,11 @@ export function moduleMap(customDbPath, limit = 20, opts = {}) { } export function fileDeps(file, customDbPath, opts = {}) { - const data = fileDepsData(file, customDbPath, { noTests: opts.noTests }); + const data = fileDepsData(file, customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'results'); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -1695,6 +1831,10 @@ export function fileDeps(file, customDbPath, opts = {}) { export function fnDeps(name, customDbPath, opts = {}) { const data = fnDepsData(name, customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'results'); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -1863,8 +2003,7 @@ export function contextData(name, customDbPath, opts = {}) { return { name, results: [] }; } - // Limit to first 5 results - nodes = nodes.slice(0, 5); + // No hardcoded slice — pagination handles bounding via limit/offset // File-lines cache to avoid re-reading the same file const fileCache = new Map(); @@ -2069,11 +2208,16 @@ export function contextData(name, customDbPath, opts = {}) { }); db.close(); - return { name, results }; + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function context(name, customDbPath, opts = {}) { const data = contextData(name, customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'results'); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -2429,11 +2573,16 @@ export function explainData(target, customDbPath, opts = {}) { } db.close(); - return { target, kind, results }; + const base = { target, kind, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function explain(target, customDbPath, opts = {}) { const data = explainData(target, customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'results'); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -2664,8 +2813,7 @@ export function whereData(target, customDbPath, opts = {}) { 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)); + printNdjson(data, 'results'); return; } if (opts.json) { @@ -2756,8 +2904,7 @@ export function rolesData(customDbPath, opts = {}) { 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)); + printNdjson(data, 'symbols'); return; } if (opts.json) { @@ -2798,6 +2945,10 @@ export function roles(customDbPath, opts = {}) { export function fnImpact(name, customDbPath, opts = {}) { const data = fnImpactData(name, customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'results'); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -2830,6 +2981,10 @@ export function diffImpact(customDbPath, opts = {}) { return; } const data = diffImpactData(customDbPath, opts); + if (opts.ndjson) { + printNdjson(data, 'affectedFunctions'); + return; + } if (opts.json || opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; diff --git a/src/structure.js b/src/structure.js index ca92ed51..a4c28f41 100644 --- a/src/structure.js +++ b/src/structure.js @@ -2,6 +2,7 @@ import path from 'node:path'; import { normalizePath } from './constants.js'; import { openReadonlyOrFail } from './db.js'; import { debug } from './logger.js'; +import { paginateResult } from './paginate.js'; import { isTestFile } from './queries.js'; // ─── Build-time: insert directory nodes, contains edges, and metrics ──── @@ -463,7 +464,8 @@ export function structureData(customDbPath, opts = {}) { } } - return { directories: result, count: result.length }; + const base = { directories: result, count: result.length }; + return paginateResult(base, 'directories', { limit: opts.limit, offset: opts.offset }); } /** @@ -534,7 +536,8 @@ export function hotspotsData(customDbPath, opts = {}) { })); db.close(); - return { metric, level, limit, hotspots }; + const base = { metric, level, limit, hotspots }; + return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset }); } /** diff --git a/tests/integration/context.test.js b/tests/integration/context.test.js index 39070576..fd2779a7 100644 --- a/tests/integration/context.test.js +++ b/tests/integration/context.test.js @@ -229,10 +229,18 @@ describe('contextData', () => { expect(pfResult).toBeDefined(); }); - test('limits results to 5', () => { - // We only have a few functions, so this mainly checks the cap logic doesn't crash - const data = contextData('', dbPath); // empty name matches everything via LIKE '%%' - expect(data.results.length).toBeLessThanOrEqual(5); + test('limits results with pagination', () => { + // Without limit, all matches are returned (no hardcoded cap) + const all = contextData('', dbPath); // empty name matches everything via LIKE '%%' + expect(all.results.length).toBeGreaterThan(0); + + // With limit, results are capped and pagination metadata is present + const data = contextData('', dbPath, { limit: 2, offset: 0 }); + expect(data.results.length).toBeLessThanOrEqual(2); + if (all.results.length > 2) { + expect(data._pagination).toBeDefined(); + expect(data._pagination.hasMore).toBe(true); + } }); test('includeTests includes test source', () => { diff --git a/tests/integration/pagination.test.js b/tests/integration/pagination.test.js index 4bf652f8..46824881 100644 --- a/tests/integration/pagination.test.js +++ b/tests/integration/pagination.test.js @@ -21,8 +21,27 @@ 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'; +import { + MCP_DEFAULTS, + MCP_MAX_LIMIT, + paginate, + paginateResult, + printNdjson, +} from '../../src/paginate.js'; +import { + contextData, + explainData, + fileDepsData, + fnDepsData, + fnImpactData, + iterListFunctions, + iterRoles, + iterWhere, + listFunctionsData, + queryNameData, + rolesData, + whereData, +} from '../../src/queries.js'; // ─── Helpers ─────────────────────────────────────────────────────────── @@ -297,6 +316,259 @@ describe('listEntryPointsData with pagination', () => { }); }); +// ─── fileDepsData with pagination ───────────────────────────────────── + +describe('fileDepsData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = fileDepsData('a.js', dbPath); + expect(data._pagination).toBeUndefined(); + expect(data.results.length).toBeGreaterThan(0); + }); + + test('paginated results', () => { + const full = fileDepsData('', dbPath); + if (full.results.length > 1) { + const paginated = fileDepsData('', dbPath, { limit: 1 }); + expect(paginated.results).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + expect(paginated._pagination.hasMore).toBe(true); + } + }); +}); + +// ─── fnDepsData with pagination ────────────────────────────────────── + +describe('fnDepsData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = fnDepsData('alpha', dbPath); + expect(data._pagination).toBeUndefined(); + expect(data.results.length).toBeGreaterThan(0); + }); + + test('paginated results', () => { + const full = fnDepsData('a', dbPath); + if (full.results.length > 1) { + const paginated = fnDepsData('a', dbPath, { limit: 1 }); + expect(paginated.results).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + expect(paginated._pagination.hasMore).toBe(true); + } + }); +}); + +// ─── fnImpactData with pagination ──────────────────────────────────── + +describe('fnImpactData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = fnImpactData('alpha', dbPath); + expect(data._pagination).toBeUndefined(); + expect(data.results.length).toBeGreaterThan(0); + }); + + test('paginated results', () => { + const full = fnImpactData('a', dbPath); + if (full.results.length > 1) { + const paginated = fnImpactData('a', dbPath, { limit: 1 }); + expect(paginated.results).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + } + }); +}); + +// ─── contextData with pagination ───────────────────────────────────── + +describe('contextData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = contextData('alpha', dbPath); + expect(data._pagination).toBeUndefined(); + expect(data.results.length).toBeGreaterThan(0); + }); + + test('paginated results', () => { + const full = contextData('a', dbPath); + if (full.results.length > 1) { + const paginated = contextData('a', dbPath, { limit: 1 }); + expect(paginated.results).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + } + }); +}); + +// ─── explainData with pagination ───────────────────────────────────── + +describe('explainData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = explainData('a.js', dbPath); + expect(data._pagination).toBeUndefined(); + expect(data.results.length).toBeGreaterThan(0); + }); + + test('paginated results', () => { + const full = explainData('', dbPath); + if (full.results.length > 1) { + const paginated = explainData('', dbPath, { limit: 1 }); + expect(paginated.results).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + } + }); +}); + +// ─── MCP new defaults ──────────────────────────────────────────────── + +describe('MCP new defaults', () => { + test('MCP_DEFAULTS has new pagination keys', () => { + expect(MCP_DEFAULTS.fn_deps).toBe(10); + expect(MCP_DEFAULTS.fn_impact).toBe(5); + expect(MCP_DEFAULTS.context).toBe(5); + expect(MCP_DEFAULTS.explain).toBe(10); + expect(MCP_DEFAULTS.file_deps).toBe(20); + expect(MCP_DEFAULTS.diff_impact).toBe(30); + expect(MCP_DEFAULTS.semantic_search).toBe(20); + expect(MCP_DEFAULTS.execution_flow).toBe(50); + expect(MCP_DEFAULTS.hotspots).toBe(20); + expect(MCP_DEFAULTS.co_changes).toBe(20); + expect(MCP_DEFAULTS.complexity).toBe(30); + expect(MCP_DEFAULTS.manifesto).toBe(50); + expect(MCP_DEFAULTS.communities).toBe(20); + expect(MCP_DEFAULTS.structure).toBe(30); + }); +}); + +// ─── Iterator/Generator APIs ───────────────────────────────────────── + +describe('iterListFunctions', () => { + test('yields all functions matching listFunctionsData', () => { + const full = listFunctionsData(dbPath); + const iter = [...iterListFunctions(dbPath)]; + expect(iter.length).toBe(full.functions.length); + for (const item of iter) { + expect(item).toHaveProperty('name'); + expect(item).toHaveProperty('kind'); + expect(item).toHaveProperty('file'); + expect(item).toHaveProperty('line'); + } + }); + + test('early break closes DB (no leak)', () => { + let count = 0; + for (const _item of iterListFunctions(dbPath)) { + count++; + if (count >= 2) break; + } + expect(count).toBe(2); + // If the DB leaked, subsequent operations would fail + const data = listFunctionsData(dbPath); + expect(data.functions.length).toBeGreaterThan(0); + }); + + test('noTests filtering works', () => { + const all = [...iterListFunctions(dbPath)]; + const noTests = [...iterListFunctions(dbPath, { noTests: true })]; + // Should not include test files (fixture has none, so counts equal) + expect(noTests.length).toBeLessThanOrEqual(all.length); + }); +}); + +describe('iterRoles', () => { + test('yields all role-classified symbols', () => { + const full = rolesData(dbPath); + const iter = [...iterRoles(dbPath)]; + expect(iter.length).toBe(full.count); + for (const item of iter) { + expect(item.role).toBeTruthy(); + } + }); + + test('role filter works', () => { + const coreOnly = [...iterRoles(dbPath, { role: 'core' })]; + for (const item of coreOnly) { + expect(item.role).toBe('core'); + } + }); + + test('early break closes DB (no leak)', () => { + let count = 0; + for (const _item of iterRoles(dbPath)) { + count++; + if (count >= 1) break; + } + expect(count).toBe(1); + const data = rolesData(dbPath); + expect(data.count).toBeGreaterThan(0); + }); +}); + +describe('iterWhere', () => { + test('yields matching symbols with uses', () => { + const iter = [...iterWhere('alpha', dbPath)]; + expect(iter.length).toBeGreaterThan(0); + const alpha = iter.find((r) => r.name === 'alpha'); + expect(alpha).toBeDefined(); + expect(alpha).toHaveProperty('exported'); + expect(alpha).toHaveProperty('uses'); + expect(Array.isArray(alpha.uses)).toBe(true); + }); + + test('early break closes DB (no leak)', () => { + let count = 0; + for (const _item of iterWhere('a', dbPath)) { + count++; + if (count >= 1) break; + } + expect(count).toBe(1); + const data = whereData('alpha', dbPath); + expect(data.results.length).toBeGreaterThan(0); + }); +}); + +// ─── printNdjson utility ───────────────────────────────────────────── + +describe('printNdjson', () => { + test('outputs JSON lines for array field', () => { + const logs = []; + const origLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + try { + printNdjson({ items: [{ a: 1 }, { b: 2 }] }, 'items'); + expect(logs).toHaveLength(2); + expect(JSON.parse(logs[0])).toEqual({ a: 1 }); + expect(JSON.parse(logs[1])).toEqual({ b: 2 }); + } finally { + console.log = origLog; + } + }); + + test('emits _meta when _pagination exists', () => { + const logs = []; + const origLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + try { + printNdjson( + { items: [{ x: 1 }], _pagination: { total: 10, offset: 0, limit: 1, hasMore: true } }, + 'items', + ); + expect(logs).toHaveLength(2); + const meta = JSON.parse(logs[0]); + expect(meta._meta).toBeDefined(); + expect(meta._meta.total).toBe(10); + } finally { + console.log = origLog; + } + }); + + test('handles empty array', () => { + const logs = []; + const origLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + try { + printNdjson({ items: [] }, 'items'); + expect(logs).toHaveLength(0); + } finally { + console.log = origLog; + } + }); +}); + // ─── MCP default limits ────────────────────────────────────────────── describe('MCP defaults', () => { diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 1c082085..3df9d876 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -340,6 +340,8 @@ describe('startMCPServer handler dispatch', () => { file: 'src/app.js', kind: 'function', noTests: true, + limit: 10, + offset: 0, }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); @@ -392,7 +394,11 @@ describe('startMCPServer handler dispatch', () => { expect(result.isError).toBeUndefined(); expect(fnImpactMock).toHaveBeenCalledWith('handleClick', '/tmp/test.db', { depth: undefined, + file: undefined, + kind: undefined, noTests: undefined, + limit: 5, + offset: 0, }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); @@ -448,6 +454,8 @@ describe('startMCPServer handler dispatch', () => { ref: undefined, depth: undefined, noTests: undefined, + limit: 30, + offset: 0, }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); @@ -1067,8 +1075,10 @@ describe('startMCPServer handler dispatch', () => { target: 'buildGraph', file: 'src/builder.js', limit: 10, + offset: 0, sort: 'cyclomatic', aboveThreshold: true, + health: undefined, noTests: true, kind: 'function', });