From 9129959b35f95055eace6567175bead179e4e432 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 04:42:17 -0600 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20command/query=20separation=20?= =?UTF-8?q?=E2=80=94=20extract=20CLI=20wrappers,=20shared=20output=20helpe?= =?UTF-8?q?r,=20test-filter=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 15 CLI display wrappers (~800 lines) from queries.js into queries-cli.js, reducing it by 26% (3433 → 2544 lines). Add shared outputResult() helper that replaces the 4–6 line JSON/NDJSON dispatch pattern repeated in every CLI wrapper. Extract isTestFile() into test-filter.js, breaking the circular-ish dependency where 17 modules imported it from queries.js. Add QUERY_OPTS CLI preset consolidating 7 repeated options across 14 commands (~98 lines removed). Zero breaking changes — all public API exports preserved via index.js re-exports. 569 tests pass, 0 lint errors. Impact: 34 functions changed, 170 affected --- src/ast.js | 13 +- src/audit.js | 9 +- src/boundaries.js | 2 +- src/branch-compare.js | 10 +- src/cfg.js | 14 +- src/check.js | 6 +- src/cli.js | 280 ++++------ src/cochange.js | 2 +- src/communities.js | 14 +- src/complexity.js | 14 +- src/cycles.js | 2 +- src/dataflow.js | 26 +- src/export.js | 2 +- src/flow.js | 20 +- src/index.js | 23 +- src/manifesto.js | 11 +- src/owners.js | 8 +- src/queries-cli.js | 866 ++++++++++++++++++++++++++++++ src/queries.js | 899 +------------------------------- src/result-formatter.js | 21 + src/sequence.js | 16 +- src/structure.js | 2 +- src/test-filter.js | 7 + src/triage.js | 14 +- src/viewer.js | 2 +- tests/unit/queries-unit.test.js | 7 +- 26 files changed, 1080 insertions(+), 1210 deletions(-) create mode 100644 src/queries-cli.js create mode 100644 src/result-formatter.js create mode 100644 src/test-filter.js diff --git a/src/ast.js b/src/ast.js index e75f5c65..0e5d005b 100644 --- a/src/ast.js +++ b/src/ast.js @@ -9,8 +9,9 @@ import path from 'node:path'; import { openReadonlyOrFail } from './db.js'; import { debug } from './logger.js'; -import { paginateResult, printNdjson } from './paginate.js'; +import { paginateResult } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; +import { outputResult } from './result-formatter.js'; // ─── Constants ──────────────────────────────────────────────────────── @@ -382,15 +383,7 @@ export function astQueryData(pattern, customDbPath, opts = {}) { export function astQuery(pattern, customDbPath, opts = {}) { const data = astQueryData(pattern, customDbPath, opts); - if (opts.ndjson) { - printNdjson(data, 'results'); - return; - } - - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } + if (outputResult(data, 'results', opts)) return; // Human-readable output if (data.results.length === 0) { diff --git a/src/audit.js b/src/audit.js index 70b1ec61..bb140a03 100644 --- a/src/audit.js +++ b/src/audit.js @@ -10,7 +10,9 @@ import path from 'node:path'; import { loadConfig } from './config.js'; import { openReadonlyOrFail } from './db.js'; import { RULE_DEFS } from './manifesto.js'; -import { explainData, isTestFile, kindIcon } from './queries.js'; +import { explainData, kindIcon } from './queries.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── Threshold resolution ─────────────────────────────────────────── @@ -340,10 +342,7 @@ function defaultHealth() { export function audit(target, customDbPath, opts = {}) { const data = auditData(target, customDbPath, opts); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } + if (outputResult(data, null, opts)) return; if (data.functions.length === 0) { console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); diff --git a/src/boundaries.js b/src/boundaries.js index 78bd29e9..e32b0523 100644 --- a/src/boundaries.js +++ b/src/boundaries.js @@ -1,5 +1,5 @@ import { debug } from './logger.js'; -import { isTestFile } from './queries.js'; +import { isTestFile } from './test-filter.js'; // ─── Glob-to-Regex ─────────────────────────────────────────────────── diff --git a/src/branch-compare.js b/src/branch-compare.js index d97983fe..dc178ad1 100644 --- a/src/branch-compare.js +++ b/src/branch-compare.js @@ -12,7 +12,9 @@ import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { buildGraph } from './builder.js'; -import { isTestFile, kindIcon } from './queries.js'; +import { kindIcon } from './queries.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── Git Helpers ──────────────────────────────────────────────────────── @@ -554,10 +556,8 @@ function formatText(data) { export async function branchCompare(baseRef, targetRef, opts = {}) { const data = await branchCompareData(baseRef, targetRef, opts); - if (opts.json || opts.format === 'json') { - console.log(JSON.stringify(data, null, 2)); - return; - } + if (opts.format === 'json') opts = { ...opts, json: true }; + if (outputResult(data, null, opts)) return; if (opts.format === 'mermaid') { console.log(branchCompareMermaid(data)); diff --git a/src/cfg.js b/src/cfg.js index d0f4d2e5..177340e2 100644 --- a/src/cfg.js +++ b/src/cfg.js @@ -10,9 +10,10 @@ import path from 'node:path'; import { COMPLEXITY_RULES } from './complexity.js'; import { openReadonlyOrFail } from './db.js'; import { info } from './logger.js'; -import { paginateResult, printNdjson } from './paginate.js'; +import { paginateResult } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; -import { isTestFile } from './queries.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── CFG Node Type Rules (extends COMPLEXITY_RULES) ────────────────────── @@ -1418,14 +1419,7 @@ function edgeStyle(kind) { export function cfg(name, customDbPath, opts = {}) { const data = cfgData(name, customDbPath, opts); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - if (opts.ndjson) { - printNdjson(data.results); - return; - } + if (outputResult(data, 'results', opts)) return; if (data.warning) { console.log(`\u26A0 ${data.warning}`); diff --git a/src/check.js b/src/check.js index 6dec3126..8a25cb1e 100644 --- a/src/check.js +++ b/src/check.js @@ -5,7 +5,8 @@ import { loadConfig } from './config.js'; import { findCycles } from './cycles.js'; import { findDbPath, openReadonlyOrFail } from './db.js'; import { matchOwners, parseCodeowners } from './owners.js'; -import { isTestFile } from './queries.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── Diff Parser ────────────────────────────────────────────────────── @@ -361,8 +362,7 @@ export function check(customDbPath, opts = {}) { process.exit(1); } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); + if (outputResult(data, null, opts)) { if (!data.passed) process.exit(1); return; } diff --git a/src/cli.js b/src/cli.js index af3c4f40..ed7ab4f2 100644 --- a/src/cli.js +++ b/src/cli.js @@ -25,12 +25,11 @@ import { exportNeo4jCSV, } from './export.js'; import { setVerbose } from './logger.js'; -import { printNdjson } from './paginate.js'; +import { EVERY_SYMBOL_KIND, VALID_ROLES } from './queries.js'; import { children, context, diffImpact, - EVERY_SYMBOL_KIND, explain, fileDeps, fileExports, @@ -41,9 +40,8 @@ import { roles, stats, symbolPath, - VALID_ROLES, where, -} from './queries.js'; +} from './queries-cli.js'; import { listRepos, pruneRegistry, @@ -51,6 +49,7 @@ import { registerRepo, unregisterRepo, } from './registry.js'; +import { outputResult } from './result-formatter.js'; import { snapshotDelete, snapshotList, snapshotRestore, snapshotSave } from './snapshot.js'; import { checkForUpdates, printUpdateNotification } from './update-check.js'; import { watchProject } from './watcher.js'; @@ -95,6 +94,17 @@ function resolveNoTests(opts) { return config.query?.excludeTests || false; } +/** Attach the common query options shared by most analysis commands. */ +const QUERY_OPTS = (cmd) => + cmd + .option('-d, --db ', 'Path to graph.db') + .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'); + function formatSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -122,10 +132,11 @@ program }); }); -program - .command('query ') - .description('Function-level dependency chain or shortest path between symbols') - .option('-d, --db ', 'Path to graph.db') +QUERY_OPTS( + program + .command('query ') + .description('Function-level dependency chain or shortest path between symbols'), +) .option('--depth ', 'Transitive caller depth', '3') .option('-f, --file ', 'Scope search to functions in this file (partial match)') .option('-k, --kind ', 'Filter to a specific symbol kind') @@ -134,12 +145,6 @@ program .option('--reverse', 'Path mode: follow edges backward') .option('--from-file ', 'Path mode: disambiguate source symbol by file') .option('--to-file ', 'Path mode: disambiguate target symbol by file') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .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 && !EVERY_SYMBOL_KIND.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); @@ -201,25 +206,17 @@ program }); }); -program - .command('impact ') - .description('Show what depends on this file (transitive)') - .option('-d, --db ', 'Path to graph.db') - .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, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - }); +QUERY_OPTS( + program.command('impact ').description('Show what depends on this file (transitive)'), +).action((file, opts) => { + 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 .command('map') @@ -247,36 +244,23 @@ program await stats(opts.db, { noTests: resolveNoTests(opts), json: opts.json }); }); -program - .command('deps ') - .description('Show what this file imports and what imports it') - .option('-d, --db ', 'Path to graph.db') - .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, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - }); +QUERY_OPTS( + program.command('deps ').description('Show what this file imports and what imports it'), +).action((file, opts) => { + 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 - .command('exports ') - .description('Show exported symbols with per-symbol consumers (who calls each export)') - .option('-d, --db ', 'Path to graph.db') - .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') +QUERY_OPTS( + program + .command('exports ') + .description('Show exported symbols with per-symbol consumers (who calls each export)'), +) .option('--unused', 'Show only exports with zero consumers') .action((file, opts) => { fileExports(file, opts.db, { @@ -289,19 +273,14 @@ program }); }); -program - .command('fn-impact ') - .description('Function-level impact: what functions break if this one changes') - .option('-d, --db ', 'Path to graph.db') +QUERY_OPTS( + program + .command('fn-impact ') + .description('Function-level impact: what functions break if this one changes'), +) .option('--depth ', 'Max transitive depth', '5') .option('-f, --file ', 'Scope search to functions in this file (partial match)') .option('-k, --kind ', 'Filter to a specific symbol kind') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .option('--limit ', 'Max results to return') - .option('--offset ', 'Skip N results (default: 0)') - .option('--ndjson', 'Newline-delimited JSON output') .action((name, opts) => { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); @@ -319,21 +298,16 @@ program }); }); -program - .command('context ') - .description('Full context for a function: source, deps, callers, tests, signature') - .option('-d, --db ', 'Path to graph.db') +QUERY_OPTS( + program + .command('context ') + .description('Full context for a function: source, deps, callers, tests, signature'), +) .option('--depth ', 'Include callee source up to N levels deep', '0') .option('-f, --file ', 'Scope search to functions in this file (partial match)') .option('-k, --kind ', 'Filter to a specific symbol kind') .option('--no-source', 'Metadata only (skip source extraction)') .option('--with-test-source', 'Include test source code') - .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 && !EVERY_SYMBOL_KIND.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); @@ -417,17 +391,12 @@ program }); }); -program - .command('where [name]') - .description('Find where a symbol is defined and used (minimal, fast lookup)') - .option('-d, --db ', 'Path to graph.db') +QUERY_OPTS( + program + .command('where [name]') + .description('Find where a symbol is defined and used (minimal, fast lookup)'), +) .option('-f, --file ', 'File overview: list symbols, imports, exports') - .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 '); @@ -444,19 +413,14 @@ program }); }); -program - .command('diff-impact [ref]') - .description('Show impact of git changes (unstaged, staged, or vs a ref)') - .option('-d, --db ', 'Path to graph.db') +QUERY_OPTS( + program + .command('diff-impact [ref]') + .description('Show impact of git changes (unstaged, staged, or vs a ref)'), +) .option('--staged', 'Analyze staged changes instead of unstaged') .option('--depth ', 'Max transitive caller depth', '3') - .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('-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, @@ -994,11 +958,7 @@ program limit: opts.limit ? parseInt(opts.limit, 10) : undefined, offset: opts.offset ? parseInt(opts.offset, 10) : undefined, }); - if (opts.ndjson) { - printNdjson(data, 'directories'); - } else if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - } else { + if (!outputResult(data, 'directories', opts)) { console.log(formatStructure(data)); } }); @@ -1081,41 +1041,28 @@ program if (file) { const data = coChangeData(file, opts.db, queryOpts); - if (opts.ndjson) { - printNdjson(data, 'partners'); - } else if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - } else { + if (!outputResult(data, 'partners', opts)) { console.log(formatCoChange(data)); } } else { const data = coChangeTopData(opts.db, queryOpts); - if (opts.ndjson) { - printNdjson(data, 'pairs'); - } else if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - } else { + if (!outputResult(data, 'pairs', opts)) { console.log(formatCoChangeTop(data)); } } }); -program - .command('flow [name]') - .description( - 'Trace execution flow forward from an entry point (route, command, event) through callees to leaves', - ) +QUERY_OPTS( + program + .command('flow [name]') + .description( + 'Trace execution flow forward from an entry point (route, command, event) through callees to leaves', + ), +) .option('--list', 'List all entry points grouped by type') .option('--depth ', 'Max forward traversal depth', '10') - .option('-d, --db ', 'Path to graph.db') .option('-f, --file ', 'Scope to a specific file (partial match)') .option('-k, --kind ', 'Filter by symbol kind') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .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.'); @@ -1139,20 +1086,17 @@ program }); }); -program - .command('sequence ') - .description('Generate a Mermaid sequence diagram from call graph edges (participants = files)') +QUERY_OPTS( + program + .command('sequence ') + .description( + 'Generate a Mermaid sequence diagram from call graph edges (participants = files)', + ), +) .option('--depth ', 'Max forward traversal depth', '10') .option('--dataflow', 'Annotate with parameter names and return arrows from dataflow table') - .option('-d, --db ', 'Path to graph.db') .option('-f, --file ', 'Scope to a specific file (partial match)') .option('-k, --kind ', 'Filter by symbol kind') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .option('--limit ', 'Max results to return') - .option('--offset ', 'Skip N results (default: 0)') - .option('--ndjson', 'Newline-delimited JSON output') .action(async (name, opts) => { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); @@ -1172,18 +1116,13 @@ program }); }); -program - .command('dataflow ') - .description('Show data flow for a function: parameters, return consumers, mutations') - .option('-d, --db ', 'Path to graph.db') +QUERY_OPTS( + program + .command('dataflow ') + .description('Show data flow for a function: parameters, return consumers, mutations'), +) .option('-f, --file ', 'Scope to file (partial match)') .option('-k, --kind ', 'Filter by symbol kind') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .option('--ndjson', 'Newline-delimited JSON output') - .option('--limit ', 'Max results to return') - .option('--offset ', 'Skip N results (default: 0)') .option('--impact', 'Show data-dependent blast radius') .option('--depth ', 'Max traversal depth', '5') .action(async (name, opts) => { @@ -1205,19 +1144,10 @@ program }); }); -program - .command('cfg ') - .description('Show control flow graph for a function') - .option('-d, --db ', 'Path to graph.db') +QUERY_OPTS(program.command('cfg ').description('Show control flow graph for a function')) .option('--format ', 'Output format: text, dot, mermaid', 'text') .option('-f, --file ', 'Scope to file (partial match)') .option('-k, --kind ', 'Filter by symbol kind') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .option('--ndjson', 'Newline-delimited JSON output') - .option('--limit ', 'Max results to return') - .option('--offset ', 'Skip N results (default: 0)') .action(async (name, opts) => { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); @@ -1276,18 +1206,13 @@ program }); }); -program - .command('ast [pattern]') - .description('Search stored AST nodes (calls, new, string, regex, throw, await) by pattern') - .option('-d, --db ', 'Path to graph.db') +QUERY_OPTS( + program + .command('ast [pattern]') + .description('Search stored AST nodes (calls, new, string, regex, throw, await) by pattern'), +) .option('-k, --kind ', 'Filter by AST node kind (call, new, string, regex, throw, await)') .option('-f, --file ', 'Scope to file (partial match)') - .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('--ndjson', 'Newline-delimited JSON output') - .option('--limit ', 'Max results to return') - .option('--offset ', 'Skip N results (default: 0)') .action(async (pattern, opts) => { const { AST_NODE_KINDS, astQuery } = await import('./ast.js'); if (opts.kind && !AST_NODE_KINDS.includes(opts.kind)) { @@ -1305,19 +1230,14 @@ program }); }); -program - .command('communities') - .description('Detect natural module boundaries using Louvain community detection') +QUERY_OPTS( + program + .command('communities') + .description('Detect natural module boundaries using Louvain community detection'), +) .option('--functions', 'Function-level instead of file-level') .option('--resolution ', 'Louvain resolution parameter (default 1.0)', '1.0') .option('--drift', 'Show only drift analysis') - .option('-d, --db ', 'Path to graph.db') - .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, { @@ -1371,11 +1291,7 @@ program offset: opts.offset ? parseInt(opts.offset, 10) : undefined, noTests: resolveNoTests(opts), }); - if (opts.ndjson) { - printNdjson(data, 'hotspots'); - } else if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - } else { + if (!outputResult(data, 'hotspots', opts)) { console.log(formatHotspots(data)); } return; diff --git a/src/cochange.js b/src/cochange.js index d1fb2ed3..52769410 100644 --- a/src/cochange.js +++ b/src/cochange.js @@ -12,7 +12,7 @@ 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'; +import { isTestFile } from './test-filter.js'; /** * Scan git history and return parsed commit data. diff --git a/src/communities.js b/src/communities.js index 926b611b..342a6b07 100644 --- a/src/communities.js +++ b/src/communities.js @@ -2,8 +2,9 @@ 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'; +import { paginateResult } from './paginate.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── Graph Construction ─────────────────────────────────────────────── @@ -240,14 +241,7 @@ 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; - } + if (outputResult(data, 'communities', opts)) return; if (data.summary.communityCount === 0) { console.log( diff --git a/src/complexity.js b/src/complexity.js index 1425d735..5fe54418 100644 --- a/src/complexity.js +++ b/src/complexity.js @@ -3,9 +3,10 @@ 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 { paginateResult } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; -import { isTestFile } from './queries.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── Language-Specific Node Type Registry ───────────────────────────────── @@ -2083,14 +2084,7 @@ export function* iterComplexity(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; - } + if (outputResult(data, 'functions', opts)) return; if (data.functions.length === 0) { if (data.summary === null) { diff --git a/src/cycles.js b/src/cycles.js index 675bce2f..812f8104 100644 --- a/src/cycles.js +++ b/src/cycles.js @@ -1,5 +1,5 @@ import { loadNative } from './native.js'; -import { isTestFile } from './queries.js'; +import { isTestFile } from './test-filter.js'; /** * Detect circular dependencies in the codebase using Tarjan's SCC algorithm. diff --git a/src/dataflow.js b/src/dataflow.js index e5b926d1..c0c4795f 100644 --- a/src/dataflow.js +++ b/src/dataflow.js @@ -15,7 +15,9 @@ import { openReadonlyOrFail } from './db.js'; import { info } from './logger.js'; import { paginateResult } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; -import { ALL_SYMBOL_KINDS, isTestFile, normalizeSymbol } from './queries.js'; +import { ALL_SYMBOL_KINDS, normalizeSymbol } from './queries.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── Language-Specific Dataflow Rules ──────────────────────────────────── @@ -1567,16 +1569,7 @@ export function dataflow(name, customDbPath, opts = {}) { const data = dataflowData(name, customDbPath, opts); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - if (opts.ndjson) { - for (const r of data.results) { - console.log(JSON.stringify(r)); - } - return; - } + if (outputResult(data, 'results', opts)) return; if (data.warning) { console.log(`⚠ ${data.warning}`); @@ -1650,16 +1643,7 @@ function dataflowImpact(name, customDbPath, opts = {}) { offset: opts.offset, }); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - if (opts.ndjson) { - for (const r of data.results) { - console.log(JSON.stringify(r)); - } - return; - } + if (outputResult(data, 'results', opts)) return; if (data.warning) { console.log(`⚠ ${data.warning}`); diff --git a/src/export.js b/src/export.js index e7687daa..60996872 100644 --- a/src/export.js +++ b/src/export.js @@ -1,6 +1,6 @@ import path from 'node:path'; import { paginateResult } from './paginate.js'; -import { isTestFile } from './queries.js'; +import { isTestFile } from './test-filter.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; diff --git a/src/flow.js b/src/flow.js index 61f39504..84bbc40b 100644 --- a/src/flow.js +++ b/src/flow.js @@ -6,9 +6,11 @@ */ import { openReadonlyOrFail } from './db.js'; -import { paginateResult, printNdjson } from './paginate.js'; -import { findMatchingNodes, isTestFile, kindIcon } from './queries.js'; +import { paginateResult } from './paginate.js'; +import { findMatchingNodes, kindIcon } from './queries.js'; +import { outputResult } from './result-formatter.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; +import { isTestFile } from './test-filter.js'; /** * Determine the entry point type from a node name based on framework prefixes. @@ -229,14 +231,7 @@ export function flow(name, dbPath, opts = {}) { limit: opts.limit, offset: opts.offset, }); - if (opts.ndjson) { - printNdjson(data, 'entries'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } + if (outputResult(data, 'entries', opts)) return; if (data.count === 0) { console.log('No entry points found. Run "codegraph build" first.'); return; @@ -253,10 +248,7 @@ export function flow(name, dbPath, opts = {}) { } const data = flowData(name, dbPath, opts); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } + if (outputResult(data, 'steps', opts)) return; if (!data.entry) { console.log(`No matching entry point or function found for "${name}".`); diff --git a/src/index.js b/src/index.js index 9f51e6cf..e6d72100 100644 --- a/src/index.js +++ b/src/index.js @@ -140,7 +140,6 @@ export { FALSE_POSITIVE_CALLER_THRESHOLD, FALSE_POSITIVE_NAMES, fileDepsData, - fileExports, fnDepsData, fnImpactData, impactAnalysisData, @@ -158,6 +157,24 @@ export { VALID_ROLES, whereData, } from './queries.js'; +// Query CLI display wrappers +export { + children, + context, + diffImpact, + explain, + fileDeps, + fileExports, + fnDeps, + fnImpact, + impactAnalysis, + moduleMap, + queryName, + roles, + stats, + symbolPath, + where, +} from './queries-cli.js'; // Registry (multi-repo) export { listRepos, @@ -169,6 +186,8 @@ export { saveRegistry, unregisterRepo, } from './registry.js'; +// Result formatting +export { outputResult } from './result-formatter.js'; // Sequence diagram generation export { sequence, sequenceData, sequenceToMermaid } from './sequence.js'; // Snapshot management @@ -192,6 +211,8 @@ export { moduleBoundariesData, structureData, } from './structure.js'; +// Test file detection +export { isTestFile, TEST_PATTERN } from './test-filter.js'; // Triage — composite risk audit export { triage, triageData } from './triage.js'; // Interactive HTML viewer diff --git a/src/manifesto.js b/src/manifesto.js index b3a17e2c..120816f0 100644 --- a/src/manifesto.js +++ b/src/manifesto.js @@ -3,7 +3,8 @@ 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'; +import { paginateResult } from './paginate.js'; +import { outputResult } from './result-formatter.js'; // ─── Rule Definitions ───────────────────────────────────────────────── @@ -434,13 +435,7 @@ 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 (outputResult(data, 'violations', opts)) { if (!data.passed) process.exit(1); return; } diff --git a/src/owners.js b/src/owners.js index fb8681df..99624a38 100644 --- a/src/owners.js +++ b/src/owners.js @@ -1,7 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { findDbPath, openReadonlyOrFail } from './db.js'; -import { isTestFile } from './queries.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── CODEOWNERS Parsing ────────────────────────────────────────────── @@ -310,10 +311,7 @@ export function ownersData(customDbPath, opts = {}) { */ export function owners(customDbPath, opts = {}) { const data = ownersData(customDbPath, opts); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } + if (outputResult(data, null, opts)) return; if (!data.codeownersFile) { console.log('No CODEOWNERS file found.'); diff --git a/src/queries-cli.js b/src/queries-cli.js new file mode 100644 index 00000000..0c0256cd --- /dev/null +++ b/src/queries-cli.js @@ -0,0 +1,866 @@ +/** + * queries-cli.js — CLI display wrappers for query data functions. + * + * Each function calls its corresponding *Data() function from queries.js, + * handles JSON/NDJSON output via outputResult(), then formats human-readable + * output for the terminal. + */ + +import path from 'node:path'; +import { + childrenData, + contextData, + diffImpactData, + diffImpactMermaid, + explainData, + exportsData, + fileDepsData, + fnDepsData, + fnImpactData, + impactAnalysisData, + kindIcon, + moduleMapData, + pathData, + queryNameData, + rolesData, + statsData, + whereData, +} from './queries.js'; +import { outputResult } from './result-formatter.js'; + +// ─── symbolPath ───────────────────────────────────────────────────────── + +export function symbolPath(from, to, customDbPath, opts = {}) { + const data = pathData(from, to, customDbPath, opts); + if (outputResult(data, null, opts)) return; + + if (data.error) { + console.log(data.error); + return; + } + + if (!data.found) { + const dir = data.reverse ? 'reverse ' : ''; + console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`); + if (data.fromCandidates.length > 1) { + console.log( + `\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`, + ); + } + if (data.toCandidates.length > 1) { + console.log( + ` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`, + ); + } + return; + } + + if (data.hops === 0) { + console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`); + const n = data.path[0]; + console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`); + return; + } + + const dir = data.reverse ? ' (reverse)' : ''; + console.log( + `\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`, + ); + for (let i = 0; i < data.path.length; i++) { + const n = data.path[i]; + const indent = ' '.repeat(i + 1); + if (i === 0) { + console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`); + } else { + console.log( + `${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`, + ); + } + } + + if (data.alternateCount > 0) { + console.log( + `\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`, + ); + } + console.log(); +} + +// ─── stats ────────────────────────────────────────────────────────────── + +export async function stats(customDbPath, opts = {}) { + const data = statsData(customDbPath, { noTests: opts.noTests }); + + // Community detection summary (async import for lazy-loading) + try { + const { communitySummaryForStats } = await import('./communities.js'); + data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); + } catch { + /* graphology may not be available */ + } + + if (outputResult(data, null, opts)) return; + + // Human-readable output + console.log('\n# Codegraph Stats\n'); + + // Nodes + console.log(`Nodes: ${data.nodes.total} total`); + const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]); + const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < kindParts.length; i += 3) { + const row = kindParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Edges + console.log(`\nEdges: ${data.edges.total} total`); + const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]); + const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < edgeParts.length; i += 3) { + const row = edgeParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Files + console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`); + const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]); + const langParts = langEntries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < langParts.length; i += 3) { + const row = langParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + + // Cycles + console.log( + `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`, + ); + + // Hotspots + if (data.hotspots.length > 0) { + console.log(`\nTop ${data.hotspots.length} coupling hotspots:`); + for (let i = 0; i < data.hotspots.length; i++) { + const h = data.hotspots[i]; + console.log( + ` ${String(i + 1).padStart(2)}. ${h.file.padEnd(35)} fan-in: ${String(h.fanIn).padStart(3)} fan-out: ${String(h.fanOut).padStart(3)}`, + ); + } + } + + // Embeddings + if (data.embeddings) { + const e = data.embeddings; + console.log( + `\nEmbeddings: ${e.count} vectors (${e.model || 'unknown'}, ${e.dim || '?'}d) built ${e.builtAt || 'unknown'}`, + ); + } else { + console.log('\nEmbeddings: not built'); + } + + // Quality + if (data.quality) { + const q = data.quality; + const cc = q.callerCoverage; + const cf = q.callConfidence; + console.log(`\nGraph Quality: ${q.score}/100`); + console.log( + ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`, + ); + console.log( + ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`, + ); + if (q.falsePositiveWarnings.length > 0) { + console.log(' False-positive warnings:'); + for (const fp of q.falsePositiveWarnings) { + console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`); + } + } + } + + // Roles + if (data.roles && Object.keys(data.roles).length > 0) { + const total = Object.values(data.roles).reduce((a, b) => a + b, 0); + console.log(`\nRoles: ${total} classified symbols`); + const roleParts = Object.entries(data.roles) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < roleParts.length; i += 3) { + const row = roleParts + .slice(i, i + 3) + .map((p) => p.padEnd(18)) + .join(''); + console.log(` ${row}`); + } + } + + // 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}${miPart}`, + ); + } + + // Communities + if (data.communities) { + const cm = data.communities; + console.log( + `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`, + ); + } + + console.log(); +} + +// ─── queryName ────────────────────────────────────────────────────────── + +export function queryName(name, customDbPath, opts = {}) { + const data = queryNameData(name, customDbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No results for "${name}"`); + return; + } + + console.log(`\nResults for "${name}":\n`); + for (const r of data.results) { + console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`); + if (r.callees.length > 0) { + console.log(` -> calls/uses:`); + for (const c of r.callees.slice(0, 15)) + console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); + if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`); + } + if (r.callers.length > 0) { + console.log(` <- called by:`); + for (const c of r.callers.slice(0, 15)) + console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); + if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`); + } + console.log(); + } +} + +// ─── impactAnalysis ───────────────────────────────────────────────────── + +export function impactAnalysis(file, customDbPath, opts = {}) { + const data = impactAnalysisData(file, customDbPath, opts); + if (outputResult(data, 'sources', opts)) return; + + if (data.sources.length === 0) { + console.log(`No file matching "${file}" in graph`); + return; + } + + console.log(`\nImpact analysis for files matching "${file}":\n`); + for (const s of data.sources) console.log(` # ${s} (source)`); + + const levels = data.levels; + if (Object.keys(levels).length === 0) { + console.log(` No dependents found.`); + } else { + for (const level of Object.keys(levels).sort((a, b) => a - b)) { + const nodes = levels[level]; + console.log( + `\n ${'--'.repeat(parseInt(level, 10))} Level ${level} (${nodes.length} files):`, + ); + for (const n of nodes.slice(0, 30)) + console.log(` ${' '.repeat(parseInt(level, 10))}^ ${n.file}`); + if (nodes.length > 30) console.log(` ... and ${nodes.length - 30} more`); + } + } + console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`); +} + +// ─── moduleMap ────────────────────────────────────────────────────────── + +export function moduleMap(customDbPath, limit = 20, opts = {}) { + const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests }); + if (outputResult(data, null, opts)) return; + + console.log(`\nModule map (top ${limit} most-connected nodes):\n`); + const dirs = new Map(); + for (const n of data.topNodes) { + if (!dirs.has(n.dir)) dirs.set(n.dir, []); + dirs.get(n.dir).push(n); + } + for (const [dir, files] of [...dirs].sort()) { + console.log(` [${dir}/]`); + for (const f of files) { + const coupling = f.inEdges + f.outEdges; + const bar = '#'.repeat(Math.min(coupling, 40)); + console.log( + ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} =${String(coupling).padStart(3)} ${bar}`, + ); + } + } + console.log( + `\n Total: ${data.stats.totalFiles} files, ${data.stats.totalNodes} symbols, ${data.stats.totalEdges} edges\n`, + ); +} + +// ─── fileDeps ─────────────────────────────────────────────────────────── + +export function fileDeps(file, customDbPath, opts = {}) { + const data = fileDepsData(file, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No file matching "${file}" in graph`); + return; + } + + for (const r of data.results) { + console.log(`\n# ${r.file}\n`); + console.log(` -> Imports (${r.imports.length}):`); + for (const i of r.imports) { + const typeTag = i.typeOnly ? ' (type-only)' : ''; + console.log(` -> ${i.file}${typeTag}`); + } + console.log(`\n <- Imported by (${r.importedBy.length}):`); + for (const i of r.importedBy) console.log(` <- ${i.file}`); + if (r.definitions.length > 0) { + console.log(`\n Definitions (${r.definitions.length}):`); + for (const d of r.definitions.slice(0, 30)) + console.log(` ${kindIcon(d.kind)} ${d.name} :${d.line}`); + if (r.definitions.length > 30) console.log(` ... and ${r.definitions.length - 30} more`); + } + console.log(); + } +} + +// ─── fnDeps ───────────────────────────────────────────────────────────── + +export function fnDeps(name, customDbPath, opts = {}) { + const data = fnDepsData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}\n`); + if (r.callees.length > 0) { + console.log(` -> Calls (${r.callees.length}):`); + for (const c of r.callees) + console.log(` -> ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + if (r.callers.length > 0) { + console.log(`\n <- Called by (${r.callers.length}):`); + for (const c of r.callers) { + const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; + console.log(` <- ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); + } + } + for (const [d, fns] of Object.entries(r.transitiveCallers)) { + console.log( + `\n ${'<-'.repeat(parseInt(d, 10))} Transitive callers (depth ${d}, ${fns.length}):`, + ); + for (const n of fns.slice(0, 20)) + console.log( + ` ${' '.repeat(parseInt(d, 10) - 1)}<- ${kindIcon(n.kind)} ${n.name} ${n.file}:${n.line}`, + ); + if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); + } + if (r.callees.length === 0 && r.callers.length === 0) { + console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`); + } + console.log(); + } +} + +// ─── context ──────────────────────────────────────────────────────────── + +export function context(name, customDbPath, opts = {}) { + const data = contextData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; + const roleTag = r.role ? ` [${r.role}]` : ''; + console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`); + + // Signature + if (r.signature) { + console.log('## Type/Shape Info'); + if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`); + if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`); + console.log(); + } + + // Children + if (r.children && r.children.length > 0) { + console.log(`## Children (${r.children.length})`); + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + console.log(); + } + + // 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}${miPart}`, + ); + console.log(); + } + + // Source + if (r.source) { + console.log('## Source'); + for (const line of r.source.split('\n')) { + console.log(` ${line}`); + } + console.log(); + } + + // Callees + if (r.callees.length > 0) { + console.log(`## Direct Dependencies (${r.callees.length})`); + for (const c of r.callees) { + const summary = c.summary ? ` — ${c.summary}` : ''; + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`); + if (c.source) { + for (const line of c.source.split('\n').slice(0, 10)) { + console.log(` | ${line}`); + } + } + } + console.log(); + } + + // Callers + if (r.callers.length > 0) { + console.log(`## Callers (${r.callers.length})`); + for (const c of r.callers) { + const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); + } + console.log(); + } + + // Related tests + if (r.relatedTests.length > 0) { + console.log('## Related Tests'); + for (const t of r.relatedTests) { + console.log(` ${t.file} — ${t.testCount} tests`); + for (const tn of t.testNames) { + console.log(` - ${tn}`); + } + if (t.source) { + console.log(' Source:'); + for (const line of t.source.split('\n').slice(0, 20)) { + console.log(` | ${line}`); + } + } + } + console.log(); + } + + if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) { + console.log( + ' (no call edges or tests found — may be invoked dynamically or via re-exports)', + ); + console.log(); + } + } +} + +// ─── children ─────────────────────────────────────────────────────────── + +export function children(name, customDbPath, opts = {}) { + const data = childrenData(name, customDbPath, opts); + if (outputResult(data, null, opts)) return; + + if (data.results.length === 0) { + console.log(`No symbol matching "${name}"`); + return; + } + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); + if (r.children.length === 0) { + console.log(' (no children)'); + } else { + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + } + } +} + +// ─── explain ──────────────────────────────────────────────────────────── + +export function explain(target, customDbPath, opts = {}) { + const data = explainData(target, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); + return; + } + + if (data.kind === 'file') { + for (const r of data.results) { + const publicCount = r.publicApi.length; + const internalCount = r.internal.length; + const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : ''; + console.log(`\n# ${r.file}`); + console.log( + ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`, + ); + + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`); + } + if (r.importedBy.length > 0) { + console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); + } + + if (r.publicApi.length > 0) { + console.log(`\n## Exported`); + for (const s of r.publicApi) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } + } + + if (r.internal.length > 0) { + console.log(`\n## Internal`); + for (const s of r.internal) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } + } + + if (r.dataFlow.length > 0) { + console.log(`\n## Data Flow`); + for (const df of r.dataFlow) { + console.log(` ${df.caller} -> ${df.callees.join(', ')}`); + } + } + console.log(); + } + } else { + function printFunctionExplain(r, indent = '') { + const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; + const lineInfo = r.lineCount ? `${r.lineCount} lines` : ''; + const summaryPart = r.summary ? ` | ${r.summary}` : ''; + const roleTag = r.role ? ` [${r.role}]` : ''; + const depthLevel = r._depth || 0; + const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#'); + console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`); + if (lineInfo || r.summary) { + console.log(`${indent} ${lineInfo}${summaryPart}`); + } + if (r.signature) { + if (r.signature.params != null) + console.log(`${indent} Parameters: (${r.signature.params})`); + if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); + } + + 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}${miPart}`, + ); + } + + if (r.callees.length > 0) { + console.log(`\n${indent} Calls (${r.callees.length}):`); + for (const c of r.callees) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + + if (r.callers.length > 0) { + console.log(`\n${indent} Called by (${r.callers.length}):`); + for (const c of r.callers) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + + if (r.relatedTests.length > 0) { + const label = r.relatedTests.length === 1 ? 'file' : 'files'; + console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); + for (const t of r.relatedTests) { + console.log(`${indent} ${t.file}`); + } + } + + if (r.callees.length === 0 && r.callers.length === 0) { + console.log( + `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`, + ); + } + + // Render recursive dependency details + if (r.depDetails && r.depDetails.length > 0) { + console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`); + for (const dep of r.depDetails) { + printFunctionExplain(dep, `${indent} `); + } + } + console.log(); + } + + for (const r of data.results) { + printFunctionExplain(r); + } + } +} + +// ─── where ────────────────────────────────────────────────────────────── + +export function where(target, customDbPath, opts = {}) { + const data = whereData(target, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log( + data.mode === 'file' + ? `No file matching "${target}" in graph` + : `No symbol matching "${target}" in graph`, + ); + return; + } + + if (data.mode === 'symbol') { + for (const r of data.results) { + const roleTag = r.role ? ` [${r.role}]` : ''; + const tag = r.exported ? ' (exported)' : ''; + console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`); + if (r.uses.length > 0) { + const useStrs = r.uses.map((u) => `${u.file}:${u.line}`); + console.log(` Used in: ${useStrs.join(', ')}`); + } else { + console.log(' No uses found'); + } + } + } else { + for (const r of data.results) { + console.log(`\n# ${r.file}`); + if (r.symbols.length > 0) { + const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`); + console.log(` Symbols: ${symStrs.join(', ')}`); + } + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.join(', ')}`); + } + if (r.importedBy.length > 0) { + console.log(` Imported by: ${r.importedBy.join(', ')}`); + } + if (r.exported.length > 0) { + console.log(` Exported: ${r.exported.join(', ')}`); + } + } + } + console.log(); +} + +// ─── roles ────────────────────────────────────────────────────────────── + +export function roles(customDbPath, opts = {}) { + const data = rolesData(customDbPath, opts); + if (outputResult(data, 'symbols', opts)) return; + + if (data.count === 0) { + console.log('No classified symbols found. Run "codegraph build" first.'); + return; + } + + const total = data.count; + console.log(`\nNode roles (${total} symbols):\n`); + + const summaryParts = Object.entries(data.summary) + .sort((a, b) => b[1] - a[1]) + .map(([role, count]) => `${role}: ${count}`); + console.log(` ${summaryParts.join(' ')}\n`); + + const byRole = {}; + for (const s of data.symbols) { + if (!byRole[s.role]) byRole[s.role] = []; + byRole[s.role].push(s); + } + + for (const [role, symbols] of Object.entries(byRole)) { + console.log(`## ${role} (${symbols.length})`); + for (const s of symbols.slice(0, 30)) { + console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`); + } + if (symbols.length > 30) { + console.log(` ... and ${symbols.length - 30} more`); + } + console.log(); + } +} + +// ─── fileExports ──────────────────────────────────────────────────────── + +export function fileExports(file, customDbPath, opts = {}) { + const data = exportsData(file, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + if (opts.unused) { + console.log(`No unused exports found for "${file}".`); + } else { + console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`); + } + return; + } + + if (opts.unused) { + console.log( + `\n# ${data.file} — ${data.totalUnused} unused export${data.totalUnused !== 1 ? 's' : ''} (of ${data.totalExported} exported)\n`, + ); + } else { + const unusedNote = data.totalUnused > 0 ? ` (${data.totalUnused} unused)` : ''; + console.log( + `\n# ${data.file} — ${data.totalExported} exported${unusedNote}, ${data.totalInternal} internal\n`, + ); + } + + for (const sym of data.results) { + const icon = kindIcon(sym.kind); + const sig = sym.signature?.params ? `(${sym.signature.params})` : ''; + const role = sym.role ? ` [${sym.role}]` : ''; + console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`); + if (sym.consumers.length === 0) { + console.log(' (no consumers)'); + } else { + for (const c of sym.consumers) { + console.log(` <- ${c.name} (${c.file}:${c.line})`); + } + } + } + + if (data.reexports.length > 0) { + console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`); + } + console.log(); +} + +// ─── fnImpact ─────────────────────────────────────────────────────────── + +export function fnImpact(name, customDbPath, opts = {}) { + const data = fnImpactData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No function/method/class matching "${name}"`); + return; + } + + for (const r of data.results) { + console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`); + if (Object.keys(r.levels).length === 0) { + console.log(` No callers found.`); + } else { + for (const [level, fns] of Object.entries(r.levels).sort((a, b) => a[0] - b[0])) { + const l = parseInt(level, 10); + console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`); + for (const f of fns.slice(0, 20)) + console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`); + if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); + } + } + console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`); + } +} + +// ─── diffImpact ───────────────────────────────────────────────────────── + +export function diffImpact(customDbPath, opts = {}) { + if (opts.format === 'mermaid') { + console.log(diffImpactMermaid(customDbPath, opts)); + return; + } + const data = diffImpactData(customDbPath, opts); + if (opts.format === 'json') opts = { ...opts, json: true }; + if (outputResult(data, 'affectedFunctions', opts)) return; + + if (data.error) { + console.log(data.error); + return; + } + if (data.changedFiles === 0) { + console.log('No changes detected.'); + return; + } + if (data.affectedFunctions.length === 0) { + console.log( + ' No function-level changes detected (changes may be in imports, types, or config).', + ); + return; + } + + console.log(`\ndiff-impact: ${data.changedFiles} files changed\n`); + console.log(` ${data.affectedFunctions.length} functions changed:\n`); + for (const fn of data.affectedFunctions) { + console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`); + if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`); + } + if (data.historicallyCoupled && data.historicallyCoupled.length > 0) { + console.log('\n Historically coupled (not in static graph):\n'); + for (const c of data.historicallyCoupled) { + const pct = `${(c.jaccard * 100).toFixed(0)}%`; + console.log( + ` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`, + ); + } + } + if (data.ownership) { + console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`); + console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`); + } + if (data.boundaryViolations && data.boundaryViolations.length > 0) { + console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`); + for (const v of data.boundaryViolations) { + console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`); + if (v.message) console.log(` ${v.message}`); + } + } + if (data.summary) { + let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`; + if (data.summary.historicallyCoupledCount > 0) { + summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`; + } + if (data.summary.ownersAffected > 0) { + summaryLine += `, ${data.summary.ownersAffected} owners affected`; + } + if (data.summary.boundaryViolationCount > 0) { + summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`; + } + console.log(`${summaryLine}\n`); + } +} diff --git a/src/queries.js b/src/queries.js index 24d53e32..f6eeb64e 100644 --- a/src/queries.js +++ b/src/queries.js @@ -8,7 +8,7 @@ import { findCycles } from './cycles.js'; import { findDbPath, openReadonlyOrFail } from './db.js'; import { debug } from './logger.js'; import { ownersForFiles } from './owners.js'; -import { paginateResult, printNdjson } from './paginate.js'; +import { paginateResult } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; /** @@ -21,10 +21,10 @@ function safePath(repoRoot, file) { return resolved; } -const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./; -export function isTestFile(filePath) { - return TEST_PATTERN.test(filePath); -} +// Re-export from dedicated module for backward compat +export { isTestFile, TEST_PATTERN } from './test-filter.js'; + +import { isTestFile } from './test-filter.js'; export const FALSE_POSITIVE_NAMES = new Set([ 'run', @@ -798,65 +798,6 @@ export function pathData(from, to, customDbPath, opts = {}) { }; } -export function symbolPath(from, to, customDbPath, opts = {}) { - const data = pathData(from, to, customDbPath, opts); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - - if (data.error) { - console.log(data.error); - return; - } - - if (!data.found) { - const dir = data.reverse ? 'reverse ' : ''; - console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`); - if (data.fromCandidates.length > 1) { - console.log( - `\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`, - ); - } - if (data.toCandidates.length > 1) { - console.log( - ` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`, - ); - } - return; - } - - if (data.hops === 0) { - console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`); - const n = data.path[0]; - console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`); - return; - } - - const dir = data.reverse ? ' (reverse)' : ''; - console.log( - `\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`, - ); - for (let i = 0; i < data.path.length; i++) { - const n = data.path[i]; - const indent = ' '.repeat(i + 1); - if (i === 0) { - console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`); - } else { - console.log( - `${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`, - ); - } - } - - if (data.alternateCount > 0) { - console.log( - `\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`, - ); - } - console.log(); -} - /** * Fix #2: Shell injection vulnerability. * Uses execFileSync instead of execSync to prevent shell interpretation of user input. @@ -1628,327 +1569,6 @@ export function statsData(customDbPath, opts = {}) { }; } -export async function stats(customDbPath, opts = {}) { - const data = statsData(customDbPath, { noTests: opts.noTests }); - - // Community detection summary (async import for lazy-loading) - try { - const { communitySummaryForStats } = await import('./communities.js'); - data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); - } catch { - /* graphology may not be available */ - } - - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - - // Human-readable output - console.log('\n# Codegraph Stats\n'); - - // Nodes - console.log(`Nodes: ${data.nodes.total} total`); - const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]); - const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`); - // Print in rows of 3 - for (let i = 0; i < kindParts.length; i += 3) { - const row = kindParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Edges - console.log(`\nEdges: ${data.edges.total} total`); - const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]); - const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < edgeParts.length; i += 3) { - const row = edgeParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Files - console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`); - const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]); - const langParts = langEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < langParts.length; i += 3) { - const row = langParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - - // Cycles - console.log( - `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`, - ); - - // Hotspots - if (data.hotspots.length > 0) { - console.log(`\nTop ${data.hotspots.length} coupling hotspots:`); - for (let i = 0; i < data.hotspots.length; i++) { - const h = data.hotspots[i]; - console.log( - ` ${String(i + 1).padStart(2)}. ${h.file.padEnd(35)} fan-in: ${String(h.fanIn).padStart(3)} fan-out: ${String(h.fanOut).padStart(3)}`, - ); - } - } - - // Embeddings - if (data.embeddings) { - const e = data.embeddings; - console.log( - `\nEmbeddings: ${e.count} vectors (${e.model || 'unknown'}, ${e.dim || '?'}d) built ${e.builtAt || 'unknown'}`, - ); - } else { - console.log('\nEmbeddings: not built'); - } - - // Quality - if (data.quality) { - const q = data.quality; - const cc = q.callerCoverage; - const cf = q.callConfidence; - console.log(`\nGraph Quality: ${q.score}/100`); - console.log( - ` Caller coverage: ${(cc.ratio * 100).toFixed(1)}% (${cc.covered}/${cc.total} functions have >=1 caller)`, - ); - console.log( - ` Call confidence: ${(cf.ratio * 100).toFixed(1)}% (${cf.highConf}/${cf.total} call edges are high-confidence)`, - ); - if (q.falsePositiveWarnings.length > 0) { - console.log(' False-positive warnings:'); - for (const fp of q.falsePositiveWarnings) { - console.log(` ! ${fp.name} (${fp.callerCount} callers) -- ${fp.file}:${fp.line}`); - } - } - } - - // Roles - if (data.roles && Object.keys(data.roles).length > 0) { - const total = Object.values(data.roles).reduce((a, b) => a + b, 0); - console.log(`\nRoles: ${total} classified symbols`); - const roleParts = Object.entries(data.roles) - .sort((a, b) => b[1] - a[1]) - .map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < roleParts.length; i += 3) { - const row = roleParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } - } - - // 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}${miPart}`, - ); - } - - // Communities - if (data.communities) { - const cm = data.communities; - console.log( - `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`, - ); - } - - console.log(); -} - -// ─── Human-readable output (original formatting) ─────────────────────── - -export function queryName(name, customDbPath, opts = {}) { - const data = queryNameData(name, customDbPath, { - noTests: opts.noTests, - limit: opts.limit, - offset: opts.offset, - }); - if (opts.ndjson) { - printNdjson(data, 'results'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - if (data.results.length === 0) { - console.log(`No results for "${name}"`); - return; - } - - console.log(`\nResults for "${name}":\n`); - for (const r of data.results) { - console.log(` ${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}`); - if (r.callees.length > 0) { - console.log(` -> calls/uses:`); - for (const c of r.callees.slice(0, 15)) - console.log(` -> ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); - if (r.callees.length > 15) console.log(` ... and ${r.callees.length - 15} more`); - } - if (r.callers.length > 0) { - console.log(` <- called by:`); - for (const c of r.callers.slice(0, 15)) - console.log(` <- ${c.name} (${c.edgeKind}) ${c.file}:${c.line}`); - if (r.callers.length > 15) console.log(` ... and ${r.callers.length - 15} more`); - } - console.log(); - } -} - -export function impactAnalysis(file, customDbPath, opts = {}) { - const data = impactAnalysisData(file, customDbPath, opts); - if (opts.ndjson) { - printNdjson(data, 'sources'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - if (data.sources.length === 0) { - console.log(`No file matching "${file}" in graph`); - return; - } - - console.log(`\nImpact analysis for files matching "${file}":\n`); - for (const s of data.sources) console.log(` # ${s} (source)`); - - const levels = data.levels; - if (Object.keys(levels).length === 0) { - console.log(` No dependents found.`); - } else { - for (const level of Object.keys(levels).sort((a, b) => a - b)) { - const nodes = levels[level]; - console.log( - `\n ${'--'.repeat(parseInt(level, 10))} Level ${level} (${nodes.length} files):`, - ); - for (const n of nodes.slice(0, 30)) - console.log(` ${' '.repeat(parseInt(level, 10))}^ ${n.file}`); - if (nodes.length > 30) console.log(` ... and ${nodes.length - 30} more`); - } - } - console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`); -} - -export function moduleMap(customDbPath, limit = 20, opts = {}) { - const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests }); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - - console.log(`\nModule map (top ${limit} most-connected nodes):\n`); - const dirs = new Map(); - for (const n of data.topNodes) { - if (!dirs.has(n.dir)) dirs.set(n.dir, []); - dirs.get(n.dir).push(n); - } - for (const [dir, files] of [...dirs].sort()) { - console.log(` [${dir}/]`); - for (const f of files) { - const coupling = f.inEdges + f.outEdges; - const bar = '#'.repeat(Math.min(coupling, 40)); - console.log( - ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} =${String(coupling).padStart(3)} ${bar}`, - ); - } - } - console.log( - `\n Total: ${data.stats.totalFiles} files, ${data.stats.totalNodes} symbols, ${data.stats.totalEdges} edges\n`, - ); -} - -export function fileDeps(file, customDbPath, opts = {}) { - const data = fileDepsData(file, customDbPath, opts); - if (opts.ndjson) { - printNdjson(data, 'results'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - if (data.results.length === 0) { - console.log(`No file matching "${file}" in graph`); - return; - } - - for (const r of data.results) { - console.log(`\n# ${r.file}\n`); - console.log(` -> Imports (${r.imports.length}):`); - for (const i of r.imports) { - const typeTag = i.typeOnly ? ' (type-only)' : ''; - console.log(` -> ${i.file}${typeTag}`); - } - console.log(`\n <- Imported by (${r.importedBy.length}):`); - for (const i of r.importedBy) console.log(` <- ${i.file}`); - if (r.definitions.length > 0) { - console.log(`\n Definitions (${r.definitions.length}):`); - for (const d of r.definitions.slice(0, 30)) - console.log(` ${kindIcon(d.kind)} ${d.name} :${d.line}`); - if (r.definitions.length > 30) console.log(` ... and ${r.definitions.length - 30} more`); - } - console.log(); - } -} - -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; - } - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - console.log(`\n${kindIcon(r.kind)} ${r.name} (${r.kind}) -- ${r.file}:${r.line}\n`); - if (r.callees.length > 0) { - console.log(` -> Calls (${r.callees.length}):`); - for (const c of r.callees) - console.log(` -> ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - if (r.callers.length > 0) { - console.log(`\n <- Called by (${r.callers.length}):`); - for (const c of r.callers) { - const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; - console.log(` <- ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); - } - } - for (const [d, fns] of Object.entries(r.transitiveCallers)) { - console.log( - `\n ${'<-'.repeat(parseInt(d, 10))} Transitive callers (depth ${d}, ${fns.length}):`, - ); - for (const n of fns.slice(0, 20)) - console.log( - ` ${' '.repeat(parseInt(d, 10) - 1)}<- ${kindIcon(n.kind)} ${n.name} ${n.file}:${n.line}`, - ); - if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); - } - if (r.callees.length === 0 && r.callers.length === 0) { - console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`); - } - console.log(); - } -} - // ─── Context helpers (private) ────────────────────────────────────────── function readSourceRange(repoRoot, file, startLine, endLine) { @@ -2298,115 +1918,6 @@ export function contextData(name, customDbPath, opts = {}) { 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; - } - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; - const roleTag = r.role ? ` [${r.role}]` : ''; - console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`); - - // Signature - if (r.signature) { - console.log('## Type/Shape Info'); - if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`); - if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`); - console.log(); - } - - // Children - if (r.children && r.children.length > 0) { - console.log(`## Children (${r.children.length})`); - for (const c of r.children) { - console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); - } - console.log(); - } - - // 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}${miPart}`, - ); - console.log(); - } - - // Source - if (r.source) { - console.log('## Source'); - for (const line of r.source.split('\n')) { - console.log(` ${line}`); - } - console.log(); - } - - // Callees - if (r.callees.length > 0) { - console.log(`## Direct Dependencies (${r.callees.length})`); - for (const c of r.callees) { - const summary = c.summary ? ` — ${c.summary}` : ''; - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`); - if (c.source) { - for (const line of c.source.split('\n').slice(0, 10)) { - console.log(` | ${line}`); - } - } - } - console.log(); - } - - // Callers - if (r.callers.length > 0) { - console.log(`## Callers (${r.callers.length})`); - for (const c of r.callers) { - const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); - } - console.log(); - } - - // Related tests - if (r.relatedTests.length > 0) { - console.log('## Related Tests'); - for (const t of r.relatedTests) { - console.log(` ${t.file} — ${t.testCount} tests`); - for (const tn of t.testNames) { - console.log(` - ${tn}`); - } - if (t.source) { - console.log(' Source:'); - for (const line of t.source.split('\n').slice(0, 20)) { - console.log(` | ${line}`); - } - } - } - console.log(); - } - - if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) { - console.log( - ' (no call edges or tests found — may be invoked dynamically or via re-exports)', - ); - console.log(); - } - } -} - // ─── childrenData ─────────────────────────────────────────────────────── export function childrenData(name, customDbPath, opts = {}) { @@ -2448,28 +1959,6 @@ export function childrenData(name, customDbPath, opts = {}) { return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } -export function children(name, customDbPath, opts = {}) { - const data = childrenData(name, customDbPath, opts); - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - if (data.results.length === 0) { - console.log(`No symbol matching "${name}"`); - return; - } - for (const r of data.results) { - console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); - if (r.children.length === 0) { - console.log(' (no children)'); - } else { - for (const c of r.children) { - console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); - } - } - } -} - // ─── explainData ──────────────────────────────────────────────────────── function isFileLikeTarget(target) { @@ -2731,136 +2220,6 @@ export function explainData(target, customDbPath, opts = {}) { 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; - } - if (data.results.length === 0) { - console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); - return; - } - - if (data.kind === 'file') { - for (const r of data.results) { - const publicCount = r.publicApi.length; - const internalCount = r.internal.length; - const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : ''; - console.log(`\n# ${r.file}`); - console.log( - ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`, - ); - - if (r.imports.length > 0) { - console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`); - } - if (r.importedBy.length > 0) { - console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); - } - - if (r.publicApi.length > 0) { - console.log(`\n## Exported`); - for (const s of r.publicApi) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } - - if (r.internal.length > 0) { - console.log(`\n## Internal`); - for (const s of r.internal) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } - - if (r.dataFlow.length > 0) { - console.log(`\n## Data Flow`); - for (const df of r.dataFlow) { - console.log(` ${df.caller} -> ${df.callees.join(', ')}`); - } - } - console.log(); - } - } else { - function printFunctionExplain(r, indent = '') { - const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; - const lineInfo = r.lineCount ? `${r.lineCount} lines` : ''; - const summaryPart = r.summary ? ` | ${r.summary}` : ''; - const roleTag = r.role ? ` [${r.role}]` : ''; - const depthLevel = r._depth || 0; - const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#'); - console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`); - if (lineInfo || r.summary) { - console.log(`${indent} ${lineInfo}${summaryPart}`); - } - if (r.signature) { - if (r.signature.params != null) - console.log(`${indent} Parameters: (${r.signature.params})`); - if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); - } - - 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}${miPart}`, - ); - } - - if (r.callees.length > 0) { - console.log(`\n${indent} Calls (${r.callees.length}):`); - for (const c of r.callees) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - - if (r.callers.length > 0) { - console.log(`\n${indent} Called by (${r.callers.length}):`); - for (const c of r.callers) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - - if (r.relatedTests.length > 0) { - const label = r.relatedTests.length === 1 ? 'file' : 'files'; - console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); - for (const t of r.relatedTests) { - console.log(`${indent} ${t.file}`); - } - } - - if (r.callees.length === 0 && r.callers.length === 0) { - console.log( - `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`, - ); - } - - // Render recursive dependency details - if (r.depDetails && r.depDetails.length > 0) { - console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`); - for (const dep of r.depDetails) { - printFunctionExplain(dep, `${indent} `); - } - } - console.log(); - } - - for (const r of data.results) { - printFunctionExplain(r); - } - } -} - // ─── whereData ────────────────────────────────────────────────────────── function getFileHash(db, file) { @@ -2997,59 +2356,6 @@ export function whereData(target, customDbPath, opts = {}) { 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) { - printNdjson(data, 'results'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - - if (data.results.length === 0) { - console.log( - data.mode === 'file' - ? `No file matching "${target}" in graph` - : `No symbol matching "${target}" in graph`, - ); - return; - } - - if (data.mode === 'symbol') { - for (const r of data.results) { - const roleTag = r.role ? ` [${r.role}]` : ''; - const tag = r.exported ? ' (exported)' : ''; - console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`); - if (r.uses.length > 0) { - const useStrs = r.uses.map((u) => `${u.file}:${u.line}`); - console.log(` Used in: ${useStrs.join(', ')}`); - } else { - console.log(' No uses found'); - } - } - } else { - for (const r of data.results) { - console.log(`\n# ${r.file}`); - if (r.symbols.length > 0) { - const symStrs = r.symbols.map((s) => `${s.name}:${s.line}`); - console.log(` Symbols: ${symStrs.join(', ')}`); - } - if (r.imports.length > 0) { - console.log(` Imports: ${r.imports.join(', ')}`); - } - if (r.importedBy.length > 0) { - console.log(` Imported by: ${r.importedBy.join(', ')}`); - } - if (r.exported.length > 0) { - console.log(` Exported: ${r.exported.join(', ')}`); - } - } - } - console.log(); -} - // ─── rolesData ────────────────────────────────────────────────────────── export function rolesData(customDbPath, opts = {}) { @@ -3090,48 +2396,6 @@ export function rolesData(customDbPath, opts = {}) { return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset }); } -export function roles(customDbPath, opts = {}) { - const data = rolesData(customDbPath, opts); - if (opts.ndjson) { - printNdjson(data, 'symbols'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - - if (data.count === 0) { - console.log('No classified symbols found. Run "codegraph build" first.'); - return; - } - - const total = data.count; - console.log(`\nNode roles (${total} symbols):\n`); - - const summaryParts = Object.entries(data.summary) - .sort((a, b) => b[1] - a[1]) - .map(([role, count]) => `${role}: ${count}`); - console.log(` ${summaryParts.join(' ')}\n`); - - const byRole = {}; - for (const s of data.symbols) { - if (!byRole[s.role]) byRole[s.role] = []; - byRole[s.role].push(s); - } - - for (const [role, symbols] of Object.entries(byRole)) { - console.log(`## ${role} (${symbols.length})`); - for (const s of symbols.slice(0, 30)) { - console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`); - } - if (symbols.length > 30) { - console.log(` ... and ${symbols.length - 30} more`); - } - console.log(); - } -} - // ─── exportsData ───────────────────────────────────────────────────── function exportsFileImpl(db, target, noTests, getFileLines, unused) { @@ -3279,156 +2543,3 @@ export function exportsData(file, customDbPath, opts = {}) { }; return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } - -export function fileExports(file, customDbPath, opts = {}) { - const data = exportsData(file, customDbPath, opts); - if (opts.ndjson) { - printNdjson(data, 'results'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } - - if (data.results.length === 0) { - if (opts.unused) { - console.log(`No unused exports found for "${file}".`); - } else { - console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`); - } - return; - } - - if (opts.unused) { - console.log( - `\n# ${data.file} — ${data.totalUnused} unused export${data.totalUnused !== 1 ? 's' : ''} (of ${data.totalExported} exported)\n`, - ); - } else { - const unusedNote = data.totalUnused > 0 ? ` (${data.totalUnused} unused)` : ''; - console.log( - `\n# ${data.file} — ${data.totalExported} exported${unusedNote}, ${data.totalInternal} internal\n`, - ); - } - - for (const sym of data.results) { - const icon = kindIcon(sym.kind); - const sig = sym.signature?.params ? `(${sym.signature.params})` : ''; - const role = sym.role ? ` [${sym.role}]` : ''; - console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`); - if (sym.consumers.length === 0) { - console.log(' (no consumers)'); - } else { - for (const c of sym.consumers) { - console.log(` <- ${c.name} (${c.file}:${c.line})`); - } - } - } - - if (data.reexports.length > 0) { - console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`); - } - console.log(); -} - -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; - } - if (data.results.length === 0) { - console.log(`No function/method/class matching "${name}"`); - return; - } - - for (const r of data.results) { - console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`); - if (Object.keys(r.levels).length === 0) { - console.log(` No callers found.`); - } else { - for (const [level, fns] of Object.entries(r.levels).sort((a, b) => a[0] - b[0])) { - const l = parseInt(level, 10); - console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`); - for (const f of fns.slice(0, 20)) - console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`); - if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); - } - } - console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`); - } -} - -export function diffImpact(customDbPath, opts = {}) { - if (opts.format === 'mermaid') { - console.log(diffImpactMermaid(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; - } - if (data.error) { - console.log(data.error); - return; - } - if (data.changedFiles === 0) { - console.log('No changes detected.'); - return; - } - if (data.affectedFunctions.length === 0) { - console.log( - ' No function-level changes detected (changes may be in imports, types, or config).', - ); - return; - } - - console.log(`\ndiff-impact: ${data.changedFiles} files changed\n`); - console.log(` ${data.affectedFunctions.length} functions changed:\n`); - for (const fn of data.affectedFunctions) { - console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`); - if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`); - } - if (data.historicallyCoupled && data.historicallyCoupled.length > 0) { - console.log('\n Historically coupled (not in static graph):\n'); - for (const c of data.historicallyCoupled) { - const pct = `${(c.jaccard * 100).toFixed(0)}%`; - console.log( - ` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`, - ); - } - } - if (data.ownership) { - console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`); - console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`); - } - if (data.boundaryViolations && data.boundaryViolations.length > 0) { - console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`); - for (const v of data.boundaryViolations) { - console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`); - if (v.message) console.log(` ${v.message}`); - } - } - if (data.summary) { - let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`; - if (data.summary.historicallyCoupledCount > 0) { - summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`; - } - if (data.summary.ownersAffected > 0) { - summaryLine += `, ${data.summary.ownersAffected} owners affected`; - } - if (data.summary.boundaryViolationCount > 0) { - summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`; - } - console.log(`${summaryLine}\n`); - } -} diff --git a/src/result-formatter.js b/src/result-formatter.js new file mode 100644 index 00000000..1562b29b --- /dev/null +++ b/src/result-formatter.js @@ -0,0 +1,21 @@ +import { printNdjson } from './paginate.js'; + +/** + * Shared JSON / NDJSON output dispatch for CLI wrappers. + * + * @param {object} data - Result object from a *Data() function + * @param {string} field - Array field name for NDJSON streaming (e.g. 'results') + * @param {object} opts - CLI options ({ json?, ndjson? }) + * @returns {boolean} true if output was handled (caller should return early) + */ +export function outputResult(data, field, opts) { + if (opts.ndjson) { + printNdjson(data, field); + return true; + } + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return true; + } + return false; +} diff --git a/src/sequence.js b/src/sequence.js index cb9c2541..840361d7 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -7,9 +7,11 @@ */ import { openReadonlyOrFail } from './db.js'; -import { paginateResult, printNdjson } from './paginate.js'; -import { findMatchingNodes, isTestFile, kindIcon } from './queries.js'; +import { paginateResult } from './paginate.js'; +import { findMatchingNodes, kindIcon } from './queries.js'; +import { outputResult } from './result-formatter.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; +import { isTestFile } from './test-filter.js'; // ─── Alias generation ──────────────────────────────────────────────── @@ -336,15 +338,7 @@ export function sequenceToMermaid(seqResult) { export function sequence(name, dbPath, opts = {}) { const data = sequenceData(name, dbPath, opts); - if (opts.ndjson) { - printNdjson(data, 'messages'); - return; - } - - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } + if (outputResult(data, 'messages', opts)) return; // Default: mermaid format if (!data.entry) { diff --git a/src/structure.js b/src/structure.js index f83445bd..ec56685d 100644 --- a/src/structure.js +++ b/src/structure.js @@ -3,7 +3,7 @@ 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'; +import { isTestFile } from './test-filter.js'; // ─── Build-time: insert directory nodes, contains edges, and metrics ──── diff --git a/src/test-filter.js b/src/test-filter.js new file mode 100644 index 00000000..c8064e64 --- /dev/null +++ b/src/test-filter.js @@ -0,0 +1,7 @@ +/** Pattern matching test/spec/stories files. */ +export const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./; + +/** Check whether a file path looks like a test file. */ +export function isTestFile(filePath) { + return TEST_PATTERN.test(filePath); +} diff --git a/src/triage.js b/src/triage.js index 193f9493..000397d0 100644 --- a/src/triage.js +++ b/src/triage.js @@ -1,7 +1,8 @@ import { openReadonlyOrFail } from './db.js'; import { warn } from './logger.js'; -import { paginateResult, printNdjson } from './paginate.js'; -import { isTestFile } from './queries.js'; +import { paginateResult } from './paginate.js'; +import { outputResult } from './result-formatter.js'; +import { isTestFile } from './test-filter.js'; // ─── Constants ──────────────────────────────────────────────────────── @@ -218,14 +219,7 @@ export function triageData(customDbPath, opts = {}) { export function triage(customDbPath, opts = {}) { const data = triageData(customDbPath, opts); - if (opts.ndjson) { - printNdjson(data, 'items'); - return; - } - if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - return; - } + if (outputResult(data, 'items', opts)) return; if (data.items.length === 0) { if (data.summary.total === 0) { diff --git a/src/viewer.js b/src/viewer.js index c0c4243d..a123477f 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import Graph from 'graphology'; import louvain from 'graphology-communities-louvain'; -import { isTestFile } from './queries.js'; +import { isTestFile } from './test-filter.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; diff --git a/tests/unit/queries-unit.test.js b/tests/unit/queries-unit.test.js index c1bccec3..939ece00 100644 --- a/tests/unit/queries-unit.test.js +++ b/tests/unit/queries-unit.test.js @@ -15,19 +15,16 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { initSchema } from '../../src/db.js'; +import { diffImpactData, diffImpactMermaid, fnDepsData, fnImpactData } from '../../src/queries.js'; import { diffImpact, - diffImpactData, - diffImpactMermaid, fileDeps, fnDeps, - fnDepsData, fnImpact, - fnImpactData, impactAnalysis, moduleMap, queryName, -} from '../../src/queries.js'; +} from '../../src/queries-cli.js'; // ─── Helpers ─────────────────────────────────────────────────────────── From 09ae2f8536a2ce14a8c3a16557d47599e56133e8 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:37:11 -0600 Subject: [PATCH 2/3] fix: use correct NDJSON streaming fields for children and map Impact: 2 functions changed, 0 affected --- src/queries-cli.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/queries-cli.js b/src/queries-cli.js index 0c0256cd..de507f21 100644 --- a/src/queries-cli.js +++ b/src/queries-cli.js @@ -291,7 +291,7 @@ export function impactAnalysis(file, customDbPath, opts = {}) { export function moduleMap(customDbPath, limit = 20, opts = {}) { const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests }); - if (outputResult(data, null, opts)) return; + if (outputResult(data, 'topNodes', opts)) return; console.log(`\nModule map (top ${limit} most-connected nodes):\n`); const dirs = new Map(); @@ -495,7 +495,7 @@ export function context(name, customDbPath, opts = {}) { export function children(name, customDbPath, opts = {}) { const data = childrenData(name, customDbPath, opts); - if (outputResult(data, null, opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No symbol matching "${name}"`); From 76ae3ee48de792cb3e942b5ec8a2c23e2fe4fcb2 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:52:23 -0600 Subject: [PATCH 3/3] fix: remove duplicate -j/--json option from diff-impact command --- src/cli.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/cli.js b/src/cli.js index ed7ab4f2..d299a7f0 100644 --- a/src/cli.js +++ b/src/cli.js @@ -413,11 +413,15 @@ QUERY_OPTS( }); }); -QUERY_OPTS( - program - .command('diff-impact [ref]') - .description('Show impact of git changes (unstaged, staged, or vs a ref)'), -) +program + .command('diff-impact [ref]') + .description('Show impact of git changes (unstaged, staged, or vs a ref)') + .option('-d, --db ', 'Path to graph.db') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .option('--staged', 'Analyze staged changes instead of unstaged') .option('--depth ', 'Max transitive caller depth', '3') .option('-f, --format ', 'Output format: text, mermaid, json', 'text')