From 1ce7511f3cb4ae726264787ca0b325059944466d Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:33:25 -0600 Subject: [PATCH 1/2] feat: extract CLI wrappers into src/commands/ directory (Phase 3.2) Separate CLI display logic from data functions across 15 analysis modules. Each command now lives in src/commands/.js while *Data() functions remain in their original modules (preserving MCP dynamic imports). - Create 16 command files in src/commands/ (audit, batch, cfg, check, cochange, communities, complexity, dataflow, flow, branch-compare, manifesto, owners, sequence, structure, triage, query barrel) - Add shared CommandRunner lifecycle in src/infrastructure/command-runner.js - Move result-formatter.js and test-filter.js to src/infrastructure/ - Update all imports in cli.js, index.js, queries-cli.js, and 7 other modules - Remove ~1,059 lines of CLI wrapper code from original analysis modules Impact: 33 functions changed, 19 affected --- docs/roadmap/ROADMAP.md | 7 +- src/ast.js | 3 +- src/audit.js | 89 +-------------- src/batch.js | 25 ---- src/boundaries.js | 2 +- src/branch-compare.js | 97 +--------------- src/cfg.js | 59 +--------- src/check.js | 85 +------------- src/cli.js | 39 +++---- src/cochange.js | 40 +------ src/commands/audit.js | 88 ++++++++++++++ src/commands/batch.js | 26 +++++ src/commands/branch-compare.js | 97 ++++++++++++++++ src/commands/cfg.js | 55 +++++++++ src/commands/check.js | 82 +++++++++++++ src/commands/cochange.js | 37 ++++++ src/commands/communities.js | 69 +++++++++++ src/commands/complexity.js | 77 +++++++++++++ src/commands/dataflow.js | 110 ++++++++++++++++++ src/commands/flow.js | 70 ++++++++++++ src/commands/manifesto.js | 77 +++++++++++++ src/commands/owners.js | 52 +++++++++ src/commands/query.js | 21 ++++ src/commands/sequence.js | 33 ++++++ src/commands/structure.js | 64 +++++++++++ src/commands/triage.js | 49 ++++++++ src/communities.js | 75 +----------- src/complexity.js | 79 +------------ src/cycles.js | 2 +- src/dataflow.js | 114 +------------------ src/export.js | 2 +- src/flow.js | 72 +----------- src/index.js | 42 +++---- src/infrastructure/command-runner.js | 27 +++++ src/{ => infrastructure}/result-formatter.js | 2 +- src/{ => infrastructure}/test-filter.js | 0 src/manifesto.js | 76 ------------- src/owners.js | 57 +--------- src/queries-cli.js | 2 +- src/queries.js | 4 +- src/sequence.js | 37 +----- src/structure.js | 64 +---------- src/triage.js | 55 +-------- src/viewer.js | 2 +- 44 files changed, 1106 insertions(+), 1059 deletions(-) create mode 100644 src/commands/audit.js create mode 100644 src/commands/batch.js create mode 100644 src/commands/branch-compare.js create mode 100644 src/commands/cfg.js create mode 100644 src/commands/check.js create mode 100644 src/commands/cochange.js create mode 100644 src/commands/communities.js create mode 100644 src/commands/complexity.js create mode 100644 src/commands/dataflow.js create mode 100644 src/commands/flow.js create mode 100644 src/commands/manifesto.js create mode 100644 src/commands/owners.js create mode 100644 src/commands/query.js create mode 100644 src/commands/sequence.js create mode 100644 src/commands/structure.js create mode 100644 src/commands/triage.js create mode 100644 src/infrastructure/command-runner.js rename src/{ => infrastructure}/result-formatter.js (92%) rename src/{ => infrastructure}/test-filter.js (100%) diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 30fc822b..1f33e6c0 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -612,9 +612,10 @@ Rewrite the CFG algorithm as a node-level visitor that builds basic blocks and e - ✅ `queries.js` CLI wrappers → `queries-cli.js` (15 functions) - ✅ Shared `result-formatter.js` (`outputResult` for JSON/NDJSON dispatch) - ✅ Shared `test-filter.js` (`isTestFile` predicate) -- 🔲 Extract CLI wrappers from remaining modules (audit, batch, check, cochange, communities, complexity, cfg, dataflow, flow, manifesto, owners, structure, triage, branch-compare) -- 🔲 Introduce `CommandRunner` shared lifecycle -- 🔲 Per-command `src/commands/` directory structure +- ✅ Extract CLI wrappers from remaining modules (audit, batch, check, cochange, communities, complexity, cfg, dataflow, flow, manifesto, owners, structure, triage, branch-compare, sequence) +- ✅ Introduce `CommandRunner` shared lifecycle (`src/infrastructure/command-runner.js`) +- ✅ Per-command `src/commands/` directory structure (16 command files) +- ✅ Move shared utilities to `src/infrastructure/` (result-formatter.js, test-filter.js) Eliminate the `*Data()` / `*()` dual-function pattern replicated across 19 modules. Every analysis module (queries, audit, batch, check, cochange, communities, complexity, cfg, dataflow, ast, flow, manifesto, owners, structure, triage, branch-compare, viewer) currently implements both data extraction AND CLI formatting. diff --git a/src/ast.js b/src/ast.js index 014d45d0..7aae4223 100644 --- a/src/ast.js +++ b/src/ast.js @@ -12,11 +12,10 @@ import { buildExtensionSet } from './ast-analysis/shared.js'; import { walkWithVisitors } from './ast-analysis/visitor.js'; import { createAstStoreVisitor } from './ast-analysis/visitors/ast-store-visitor.js'; import { openReadonlyOrFail } from './db.js'; +import { outputResult } from './infrastructure/result-formatter.js'; import { debug } from './logger.js'; import { paginateResult } from './paginate.js'; -import { outputResult } from './result-formatter.js'; - // ─── Constants ──────────────────────────────────────────────────────── export const AST_NODE_KINDS = ['call', 'new', 'string', 'regex', 'throw', 'await']; diff --git a/src/audit.js b/src/audit.js index bb140a03..63e5c08b 100644 --- a/src/audit.js +++ b/src/audit.js @@ -9,10 +9,9 @@ import path from 'node:path'; import { loadConfig } from './config.js'; import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { RULE_DEFS } from './manifesto.js'; -import { explainData, kindIcon } from './queries.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; +import { explainData } from './queries.js'; // ─── Threshold resolution ─────────────────────────────────────────── @@ -336,87 +335,3 @@ function defaultHealth() { thresholdBreaches: [], }; } - -// ─── CLI formatter ────────────────────────────────────────────────── - -export function audit(target, customDbPath, opts = {}) { - const data = auditData(target, customDbPath, opts); - - if (outputResult(data, null, opts)) return; - - if (data.functions.length === 0) { - console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); - return; - } - - console.log(`\n# Audit: ${target} (${data.kind})`); - console.log(` ${data.functions.length} function(s) analyzed\n`); - - for (const fn of data.functions) { - const lineRange = fn.endLine ? `${fn.line}-${fn.endLine}` : `${fn.line}`; - const roleTag = fn.role ? ` [${fn.role}]` : ''; - console.log(`## ${kindIcon(fn.kind)} ${fn.name} (${fn.kind})${roleTag}`); - console.log(` ${fn.file}:${lineRange}${fn.lineCount ? ` (${fn.lineCount} lines)` : ''}`); - if (fn.summary) console.log(` ${fn.summary}`); - if (fn.signature) { - if (fn.signature.params != null) console.log(` Parameters: (${fn.signature.params})`); - if (fn.signature.returnType) console.log(` Returns: ${fn.signature.returnType}`); - } - - // Health metrics - if (fn.health.cognitive != null) { - console.log(`\n Health:`); - console.log( - ` Cognitive: ${fn.health.cognitive} Cyclomatic: ${fn.health.cyclomatic} Nesting: ${fn.health.maxNesting}`, - ); - console.log(` MI: ${fn.health.maintainabilityIndex}`); - if (fn.health.halstead.volume) { - console.log( - ` Halstead: vol=${fn.health.halstead.volume} diff=${fn.health.halstead.difficulty} effort=${fn.health.halstead.effort} bugs=${fn.health.halstead.bugs}`, - ); - } - if (fn.health.loc) { - console.log( - ` LOC: ${fn.health.loc} SLOC: ${fn.health.sloc} Comments: ${fn.health.commentLines}`, - ); - } - } - - // Threshold breaches - if (fn.health.thresholdBreaches.length > 0) { - console.log(`\n Threshold Breaches:`); - for (const b of fn.health.thresholdBreaches) { - const icon = b.level === 'fail' ? 'FAIL' : 'WARN'; - console.log(` [${icon}] ${b.metric}: ${b.value} >= ${b.threshold}`); - } - } - - // Impact - console.log(`\n Impact: ${fn.impact.totalDependents} transitive dependent(s)`); - for (const [level, nodes] of Object.entries(fn.impact.levels)) { - console.log(` Level ${level}: ${nodes.map((n) => n.name).join(', ')}`); - } - - // Call edges - if (fn.callees.length > 0) { - console.log(`\n Calls (${fn.callees.length}):`); - for (const c of fn.callees) { - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - if (fn.callers.length > 0) { - console.log(`\n Called by (${fn.callers.length}):`); - for (const c of fn.callers) { - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - if (fn.relatedTests.length > 0) { - console.log(`\n Tests (${fn.relatedTests.length}):`); - for (const t of fn.relatedTests) { - console.log(` ${t.file}`); - } - } - - console.log(); - } -} diff --git a/src/batch.js b/src/batch.js index 17494dc0..cdb25dfc 100644 --- a/src/batch.js +++ b/src/batch.js @@ -83,14 +83,6 @@ export function batchData(command, targets, customDbPath, opts = {}) { return { command, total: targets.length, succeeded, failed, results }; } -/** - * CLI wrapper — calls batchData and prints JSON to stdout. - */ -export function batch(command, targets, customDbPath, opts = {}) { - const data = batchData(command, targets, customDbPath, opts); - console.log(JSON.stringify(data, null, 2)); -} - /** * Expand comma-separated positional args into individual entries. * `['a,b', 'c']` → `['a', 'b', 'c']`. @@ -161,20 +153,3 @@ export function multiBatchData(items, customDbPath, sharedOpts = {}) { return { mode: 'multi', total: items.length, succeeded, failed, results }; } - -/** - * CLI wrapper for batch-query — detects multi-command mode (objects with .command) - * or falls back to single-command batchData (default: 'where'). - */ -export function batchQuery(targets, customDbPath, opts = {}) { - const { command: defaultCommand = 'where', ...rest } = opts; - const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command; - - let data; - if (isMulti) { - data = multiBatchData(targets, customDbPath, rest); - } else { - data = batchData(defaultCommand, targets, customDbPath, rest); - } - console.log(JSON.stringify(data, null, 2)); -} diff --git a/src/boundaries.js b/src/boundaries.js index e32b0523..6bf92fd4 100644 --- a/src/boundaries.js +++ b/src/boundaries.js @@ -1,5 +1,5 @@ +import { isTestFile } from './infrastructure/test-filter.js'; import { debug } from './logger.js'; -import { isTestFile } from './test-filter.js'; // ─── Glob-to-Regex ─────────────────────────────────────────────────── diff --git a/src/branch-compare.js b/src/branch-compare.js index 1935f5fd..29172c7e 100644 --- a/src/branch-compare.js +++ b/src/branch-compare.js @@ -12,9 +12,8 @@ import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { buildGraph } from './builder.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { kindIcon } from './queries.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; // ─── Git Helpers ──────────────────────────────────────────────────────── @@ -477,97 +476,3 @@ export function branchCompareMermaid(data) { return lines.join('\n'); } - -// ─── Text Formatting ──────────────────────────────────────────────────── - -function formatText(data) { - if (data.error) return `Error: ${data.error}`; - - const lines = []; - const shortBase = data.baseSha.slice(0, 7); - const shortTarget = data.targetSha.slice(0, 7); - - lines.push(`branch-compare: ${data.baseRef}..${data.targetRef}`); - lines.push(` Base: ${data.baseRef} (${shortBase})`); - lines.push(` Target: ${data.targetRef} (${shortTarget})`); - lines.push(` Files changed: ${data.changedFiles.length}`); - - if (data.added.length > 0) { - lines.push(''); - lines.push(` + Added (${data.added.length} symbol${data.added.length !== 1 ? 's' : ''}):`); - for (const sym of data.added) { - lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`); - } - } - - if (data.removed.length > 0) { - lines.push(''); - lines.push( - ` - Removed (${data.removed.length} symbol${data.removed.length !== 1 ? 's' : ''}):`, - ); - for (const sym of data.removed) { - lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`); - if (sym.impact && sym.impact.length > 0) { - lines.push( - ` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`, - ); - } - } - } - - if (data.changed.length > 0) { - lines.push(''); - lines.push( - ` ~ Changed (${data.changed.length} symbol${data.changed.length !== 1 ? 's' : ''}):`, - ); - for (const sym of data.changed) { - const parts = []; - if (sym.changes.lineCount !== 0) { - parts.push(`lines: ${sym.base.lineCount} -> ${sym.target.lineCount}`); - } - if (sym.changes.fanIn !== 0) { - parts.push(`fan_in: ${sym.base.fanIn} -> ${sym.target.fanIn}`); - } - if (sym.changes.fanOut !== 0) { - parts.push(`fan_out: ${sym.base.fanOut} -> ${sym.target.fanOut}`); - } - const detail = parts.length > 0 ? ` (${parts.join(', ')})` : ''; - lines.push( - ` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.base.line}${detail}`, - ); - if (sym.impact && sym.impact.length > 0) { - lines.push( - ` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`, - ); - } - } - } - - const s = data.summary; - lines.push(''); - lines.push( - ` Summary: +${s.added} added, -${s.removed} removed, ~${s.changed} changed` + - ` -> ${s.totalImpacted} caller${s.totalImpacted !== 1 ? 's' : ''} impacted` + - (s.filesAffected > 0 - ? ` across ${s.filesAffected} file${s.filesAffected !== 1 ? 's' : ''}` - : ''), - ); - - return lines.join('\n'); -} - -// ─── CLI Display Function ─────────────────────────────────────────────── - -export async function branchCompare(baseRef, targetRef, opts = {}) { - const data = await branchCompareData(baseRef, targetRef, opts); - - if (opts.format === 'json') opts = { ...opts, json: true }; - if (outputResult(data, null, opts)) return; - - if (opts.format === 'mermaid') { - console.log(branchCompareMermaid(data)); - return; - } - - console.log(formatText(data)); -} diff --git a/src/cfg.js b/src/cfg.js index 2f699471..80336710 100644 --- a/src/cfg.js +++ b/src/cfg.js @@ -16,12 +16,10 @@ import { import { walkWithVisitors } from './ast-analysis/visitor.js'; import { createCfgVisitor } from './ast-analysis/visitors/cfg-visitor.js'; import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { info } from './logger.js'; import { paginateResult } from './paginate.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; - // Re-export for backward compatibility export { CFG_RULES }; export { _makeCfgRules as makeCfgRules }; @@ -472,58 +470,3 @@ function edgeStyle(kind) { if (kind === 'continue') return ', color=blue, style=dashed'; return ''; } - -// ─── CLI Printer ──────────────────────────────────────────────────────── - -/** - * CLI display for cfg command. - */ -export function cfg(name, customDbPath, opts = {}) { - const data = cfgData(name, customDbPath, opts); - - if (outputResult(data, 'results', opts)) return; - - if (data.warning) { - console.log(`\u26A0 ${data.warning}`); - return; - } - if (data.results.length === 0) { - console.log(`No symbols matching "${name}".`); - return; - } - - const format = opts.format || 'text'; - if (format === 'dot') { - console.log(cfgToDOT(data)); - return; - } - if (format === 'mermaid') { - console.log(cfgToMermaid(data)); - return; - } - - // Text format - for (const r of data.results) { - console.log(`\n${r.kind} ${r.name} (${r.file}:${r.line})`); - console.log('\u2500'.repeat(60)); - console.log(` Blocks: ${r.summary.blockCount} Edges: ${r.summary.edgeCount}`); - - if (r.blocks.length > 0) { - console.log('\n Blocks:'); - for (const b of r.blocks) { - const loc = b.startLine - ? ` L${b.startLine}${b.endLine && b.endLine !== b.startLine ? `-${b.endLine}` : ''}` - : ''; - const label = b.label ? ` (${b.label})` : ''; - console.log(` [${b.index}] ${b.type}${label}${loc}`); - } - } - - if (r.edges.length > 0) { - console.log('\n Edges:'); - for (const e of r.edges) { - console.log(` B${e.source} \u2192 B${e.target} [${e.kind}]`); - } - } - } -} diff --git a/src/check.js b/src/check.js index 8a25cb1e..dd53de50 100644 --- a/src/check.js +++ b/src/check.js @@ -4,9 +4,8 @@ import path from 'node:path'; import { loadConfig } from './config.js'; import { findCycles } from './cycles.js'; import { findDbPath, openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { matchOwners, parseCodeowners } from './owners.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; // ─── Diff Parser ────────────────────────────────────────────────────── @@ -348,85 +347,3 @@ export function checkData(customDbPath, opts = {}) { db.close(); } } - -// ─── CLI Display ────────────────────────────────────────────────────── - -/** - * CLI formatter — prints check results and exits with code 1 on failure. - */ -export function check(customDbPath, opts = {}) { - const data = checkData(customDbPath, opts); - - if (data.error) { - console.error(data.error); - process.exit(1); - } - - if (outputResult(data, null, opts)) { - if (!data.passed) process.exit(1); - return; - } - - console.log('\n# Check Results\n'); - - if (data.predicates.length === 0) { - console.log(' No changes detected.\n'); - return; - } - - console.log( - ` Changed files: ${data.summary.changedFiles} New files: ${data.summary.newFiles}\n`, - ); - - for (const pred of data.predicates) { - const icon = pred.passed ? 'PASS' : 'FAIL'; - console.log(` [${icon}] ${pred.name}`); - - if (!pred.passed) { - if (pred.name === 'cycles' && pred.cycles) { - for (const cycle of pred.cycles.slice(0, 10)) { - console.log(` ${cycle.join(' -> ')}`); - } - if (pred.cycles.length > 10) { - console.log(` ... and ${pred.cycles.length - 10} more`); - } - } - if (pred.name === 'blast-radius' && pred.violations) { - for (const v of pred.violations.slice(0, 10)) { - console.log( - ` ${v.name} (${v.kind}) at ${v.file}:${v.line} — ${v.transitiveCallers} callers (max: ${pred.threshold})`, - ); - } - if (pred.violations.length > 10) { - console.log(` ... and ${pred.violations.length - 10} more`); - } - } - if (pred.name === 'signatures' && pred.violations) { - for (const v of pred.violations.slice(0, 10)) { - console.log(` ${v.name} (${v.kind}) at ${v.file}:${v.line}`); - } - if (pred.violations.length > 10) { - console.log(` ... and ${pred.violations.length - 10} more`); - } - } - if (pred.name === 'boundaries' && pred.violations) { - for (const v of pred.violations.slice(0, 10)) { - console.log(` ${v.from} -> ${v.to} (${v.edgeKind})`); - } - if (pred.violations.length > 10) { - console.log(` ... and ${pred.violations.length - 10} more`); - } - } - } - if (pred.note) { - console.log(` ${pred.note}`); - } - } - - const s = data.summary; - console.log(`\n ${s.total} predicates | ${s.passed} passed | ${s.failed} failed\n`); - - if (!data.passed) { - process.exit(1); - } -} diff --git a/src/cli.js b/src/cli.js index 3df60e50..cb76a9f3 100644 --- a/src/cli.js +++ b/src/cli.js @@ -3,9 +3,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { Command } from 'commander'; -import { audit } from './audit.js'; -import { BATCH_COMMANDS, batch, multiBatchData, splitTargets } from './batch.js'; +import { BATCH_COMMANDS, multiBatchData, splitTargets } from './batch.js'; import { buildGraph } from './builder.js'; +import { audit } from './commands/audit.js'; +import { batch } from './commands/batch.js'; import { loadConfig } from './config.js'; import { findCycles, formatCycles } from './cycles.js'; import { openReadonlyOrFail } from './db.js'; @@ -24,6 +25,7 @@ import { exportMermaid, exportNeo4jCSV, } from './export.js'; +import { outputResult } from './infrastructure/result-formatter.js'; import { setVerbose } from './logger.js'; import { EVERY_SYMBOL_KIND, VALID_ROLES } from './queries.js'; import { @@ -49,7 +51,6 @@ 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'; @@ -469,7 +470,7 @@ program console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } - const { manifesto } = await import('./manifesto.js'); + const { manifesto } = await import('./commands/manifesto.js'); manifesto(opts.db, { file: opts.file, kind: opts.kind, @@ -483,7 +484,7 @@ program } // Diff predicates mode - const { check } = await import('./check.js'); + const { check } = await import('./commands/check.js'); check(opts.db, { ref, staged: opts.staged, @@ -502,7 +503,7 @@ program console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } - const { manifesto } = await import('./manifesto.js'); + const { manifesto } = await import('./commands/manifesto.js'); manifesto(opts.db, { file: opts.file, kind: opts.kind, @@ -952,7 +953,7 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--ndjson', 'Newline-delimited JSON output') .action(async (dir, opts) => { - const { structureData, formatStructure } = await import('./structure.js'); + const { structureData, formatStructure } = await import('./commands/structure.js'); const data = structureData(opts.db, { directory: dir, depth: opts.depth ? parseInt(opts.depth, 10) : undefined, @@ -1013,8 +1014,8 @@ program .option('--offset ', 'Skip N results (default: 0)') .option('--ndjson', 'Newline-delimited JSON output') .action(async (file, opts) => { - const { analyzeCoChanges, coChangeData, coChangeTopData, formatCoChange, formatCoChangeTop } = - await import('./cochange.js'); + const { analyzeCoChanges, coChangeData, coChangeTopData } = await import('./cochange.js'); + const { formatCoChange, formatCoChangeTop } = await import('./commands/cochange.js'); if (opts.analyze) { const result = analyzeCoChanges(opts.db, { @@ -1076,7 +1077,7 @@ QUERY_OPTS( console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } - const { flow } = await import('./flow.js'); + const { flow } = await import('./commands/flow.js'); flow(name, opts.db, { list: opts.list, depth: parseInt(opts.depth, 10), @@ -1106,7 +1107,7 @@ QUERY_OPTS( console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } - const { sequence } = await import('./sequence.js'); + const { sequence } = await import('./commands/sequence.js'); sequence(name, opts.db, { depth: parseInt(opts.depth, 10), file: opts.file, @@ -1134,7 +1135,7 @@ QUERY_OPTS( console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } - const { dataflow } = await import('./dataflow.js'); + const { dataflow } = await import('./commands/dataflow.js'); dataflow(name, opts.db, { file: opts.file, kind: opts.kind, @@ -1157,7 +1158,7 @@ QUERY_OPTS(program.command('cfg ').description('Show control flow graph fo console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } - const { cfg } = await import('./cfg.js'); + const { cfg } = await import('./commands/cfg.js'); cfg(name, opts.db, { format: opts.format, file: opts.file, @@ -1194,7 +1195,7 @@ program console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } - const { complexity } = await import('./complexity.js'); + const { complexity } = await import('./commands/complexity.js'); complexity(opts.db, { target, limit: parseInt(opts.limit, 10), @@ -1243,7 +1244,7 @@ QUERY_OPTS( .option('--resolution ', 'Louvain resolution parameter (default 1.0)', '1.0') .option('--drift', 'Show only drift analysis') .action(async (opts) => { - const { communities } = await import('./communities.js'); + const { communities } = await import('./commands/communities.js'); communities(opts.db, { functions: opts.functions, resolution: parseFloat(opts.resolution), @@ -1286,7 +1287,7 @@ program .action(async (opts) => { if (opts.level === 'file' || opts.level === 'directory') { // Delegate to hotspots for file/directory level - const { hotspotsData, formatHotspots } = await import('./structure.js'); + const { hotspotsData, formatHotspots } = await import('./commands/structure.js'); const metric = opts.sort === 'risk' ? 'fan-in' : opts.sort; const data = hotspotsData(opts.db, { metric, @@ -1318,7 +1319,7 @@ program process.exit(1); } } - const { triage } = await import('./triage.js'); + const { triage } = await import('./commands/triage.js'); triage(opts.db, { limit: parseInt(opts.limit, 10), offset: opts.offset ? parseInt(opts.offset, 10) : undefined, @@ -1346,7 +1347,7 @@ program .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') .action(async (target, opts) => { - const { owners } = await import('./owners.js'); + const { owners } = await import('./commands/owners.js'); owners(opts.db, { owner: opts.owner, boundary: opts.boundary, @@ -1366,7 +1367,7 @@ program .option('-j, --json', 'Output as JSON') .option('-f, --format ', 'Output format: text, mermaid, json', 'text') .action(async (base, target, opts) => { - const { branchCompare } = await import('./branch-compare.js'); + const { branchCompare } = await import('./commands/branch-compare.js'); await branchCompare(base, target, { engine: program.opts().engine, depth: parseInt(opts.depth, 10), diff --git a/src/cochange.js b/src/cochange.js index 52769410..182e4d9e 100644 --- a/src/cochange.js +++ b/src/cochange.js @@ -10,9 +10,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { normalizePath } from './constants.js'; import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { warn } from './logger.js'; import { paginateResult } from './paginate.js'; -import { isTestFile } from './test-filter.js'; /** * Scan git history and return parsed commit data. @@ -419,44 +419,6 @@ export function coChangeForFiles(files, db, opts = {}) { return results; } -/** - * Format co-change data for CLI output (single file). - */ -export function formatCoChange(data) { - if (data.error) return data.error; - if (data.partners.length === 0) return `No co-change partners found for ${data.file}`; - - const lines = [`\nCo-change partners for ${data.file}:\n`]; - for (const p of data.partners) { - const pct = `${(p.jaccard * 100).toFixed(0)}%`.padStart(4); - const commits = `${p.commitCount} commits`.padStart(12); - lines.push(` ${pct} ${commits} ${p.file}`); - } - if (data.meta?.analyzedAt) { - lines.push(`\n Analyzed: ${data.meta.analyzedAt} | Window: ${data.meta.since || 'all'}`); - } - return lines.join('\n'); -} - -/** - * Format top co-change pairs for CLI output (global view). - */ -export function formatCoChangeTop(data) { - if (data.error) return data.error; - if (data.pairs.length === 0) return 'No co-change pairs found.'; - - const lines = ['\nTop co-change pairs:\n']; - for (const p of data.pairs) { - const pct = `${(p.jaccard * 100).toFixed(0)}%`.padStart(4); - const commits = `${p.commitCount} commits`.padStart(12); - lines.push(` ${pct} ${commits} ${p.fileA} <-> ${p.fileB}`); - } - if (data.meta?.analyzedAt) { - lines.push(`\n Analyzed: ${data.meta.analyzedAt} | Window: ${data.meta.since || 'all'}`); - } - return lines.join('\n'); -} - // ─── Internal Helpers ──────────────────────────────────────────────────── function resolveCoChangeFile(db, file) { diff --git a/src/commands/audit.js b/src/commands/audit.js new file mode 100644 index 00000000..6cfeb3d7 --- /dev/null +++ b/src/commands/audit.js @@ -0,0 +1,88 @@ +import { auditData } from '../audit.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; +import { kindIcon } from '../queries.js'; + +/** + * CLI formatter for the audit command. + */ +export function audit(target, customDbPath, opts = {}) { + const data = auditData(target, customDbPath, opts); + + if (outputResult(data, null, opts)) return; + + if (data.functions.length === 0) { + console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); + return; + } + + console.log(`\n# Audit: ${target} (${data.kind})`); + console.log(` ${data.functions.length} function(s) analyzed\n`); + + for (const fn of data.functions) { + const lineRange = fn.endLine ? `${fn.line}-${fn.endLine}` : `${fn.line}`; + const roleTag = fn.role ? ` [${fn.role}]` : ''; + console.log(`## ${kindIcon(fn.kind)} ${fn.name} (${fn.kind})${roleTag}`); + console.log(` ${fn.file}:${lineRange}${fn.lineCount ? ` (${fn.lineCount} lines)` : ''}`); + if (fn.summary) console.log(` ${fn.summary}`); + if (fn.signature) { + if (fn.signature.params != null) console.log(` Parameters: (${fn.signature.params})`); + if (fn.signature.returnType) console.log(` Returns: ${fn.signature.returnType}`); + } + + // Health metrics + if (fn.health.cognitive != null) { + console.log(`\n Health:`); + console.log( + ` Cognitive: ${fn.health.cognitive} Cyclomatic: ${fn.health.cyclomatic} Nesting: ${fn.health.maxNesting}`, + ); + console.log(` MI: ${fn.health.maintainabilityIndex}`); + if (fn.health.halstead.volume) { + console.log( + ` Halstead: vol=${fn.health.halstead.volume} diff=${fn.health.halstead.difficulty} effort=${fn.health.halstead.effort} bugs=${fn.health.halstead.bugs}`, + ); + } + if (fn.health.loc) { + console.log( + ` LOC: ${fn.health.loc} SLOC: ${fn.health.sloc} Comments: ${fn.health.commentLines}`, + ); + } + } + + // Threshold breaches + if (fn.health.thresholdBreaches.length > 0) { + console.log(`\n Threshold Breaches:`); + for (const b of fn.health.thresholdBreaches) { + const icon = b.level === 'fail' ? 'FAIL' : 'WARN'; + console.log(` [${icon}] ${b.metric}: ${b.value} >= ${b.threshold}`); + } + } + + // Impact + console.log(`\n Impact: ${fn.impact.totalDependents} transitive dependent(s)`); + for (const [level, nodes] of Object.entries(fn.impact.levels)) { + console.log(` Level ${level}: ${nodes.map((n) => n.name).join(', ')}`); + } + + // Call edges + if (fn.callees.length > 0) { + console.log(`\n Calls (${fn.callees.length}):`); + for (const c of fn.callees) { + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + if (fn.callers.length > 0) { + console.log(`\n Called by (${fn.callers.length}):`); + for (const c of fn.callers) { + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + if (fn.relatedTests.length > 0) { + console.log(`\n Tests (${fn.relatedTests.length}):`); + for (const t of fn.relatedTests) { + console.log(` ${t.file}`); + } + } + + console.log(); + } +} diff --git a/src/commands/batch.js b/src/commands/batch.js new file mode 100644 index 00000000..60fdd3c1 --- /dev/null +++ b/src/commands/batch.js @@ -0,0 +1,26 @@ +import { batchData, multiBatchData } from '../batch.js'; + +/** + * CLI wrapper — calls batchData and prints JSON to stdout. + */ +export function batch(command, targets, customDbPath, opts = {}) { + const data = batchData(command, targets, customDbPath, opts); + console.log(JSON.stringify(data, null, 2)); +} + +/** + * CLI wrapper for batch-query — detects multi-command mode (objects with .command) + * or falls back to single-command batchData (default: 'where'). + */ +export function batchQuery(targets, customDbPath, opts = {}) { + const { command: defaultCommand = 'where', ...rest } = opts; + const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command; + + let data; + if (isMulti) { + data = multiBatchData(targets, customDbPath, rest); + } else { + data = batchData(defaultCommand, targets, customDbPath, rest); + } + console.log(JSON.stringify(data, null, 2)); +} diff --git a/src/commands/branch-compare.js b/src/commands/branch-compare.js new file mode 100644 index 00000000..aa1f2ef7 --- /dev/null +++ b/src/commands/branch-compare.js @@ -0,0 +1,97 @@ +import { branchCompareData, branchCompareMermaid } from '../branch-compare.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; +import { kindIcon } from '../queries.js'; + +// ─── Text Formatting ──────────────────────────────────────────────────── + +function formatText(data) { + if (data.error) return `Error: ${data.error}`; + + const lines = []; + const shortBase = data.baseSha.slice(0, 7); + const shortTarget = data.targetSha.slice(0, 7); + + lines.push(`branch-compare: ${data.baseRef}..${data.targetRef}`); + lines.push(` Base: ${data.baseRef} (${shortBase})`); + lines.push(` Target: ${data.targetRef} (${shortTarget})`); + lines.push(` Files changed: ${data.changedFiles.length}`); + + if (data.added.length > 0) { + lines.push(''); + lines.push(` + Added (${data.added.length} symbol${data.added.length !== 1 ? 's' : ''}):`); + for (const sym of data.added) { + lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`); + } + } + + if (data.removed.length > 0) { + lines.push(''); + lines.push( + ` - Removed (${data.removed.length} symbol${data.removed.length !== 1 ? 's' : ''}):`, + ); + for (const sym of data.removed) { + lines.push(` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.line}`); + if (sym.impact && sym.impact.length > 0) { + lines.push( + ` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`, + ); + } + } + } + + if (data.changed.length > 0) { + lines.push(''); + lines.push( + ` ~ Changed (${data.changed.length} symbol${data.changed.length !== 1 ? 's' : ''}):`, + ); + for (const sym of data.changed) { + const parts = []; + if (sym.changes.lineCount !== 0) { + parts.push(`lines: ${sym.base.lineCount} -> ${sym.target.lineCount}`); + } + if (sym.changes.fanIn !== 0) { + parts.push(`fan_in: ${sym.base.fanIn} -> ${sym.target.fanIn}`); + } + if (sym.changes.fanOut !== 0) { + parts.push(`fan_out: ${sym.base.fanOut} -> ${sym.target.fanOut}`); + } + const detail = parts.length > 0 ? ` (${parts.join(', ')})` : ''; + lines.push( + ` [${kindIcon(sym.kind)}] ${sym.name} -- ${sym.file}:${sym.base.line}${detail}`, + ); + if (sym.impact && sym.impact.length > 0) { + lines.push( + ` ^ ${sym.impact.length} transitive caller${sym.impact.length !== 1 ? 's' : ''} affected`, + ); + } + } + } + + const s = data.summary; + lines.push(''); + lines.push( + ` Summary: +${s.added} added, -${s.removed} removed, ~${s.changed} changed` + + ` -> ${s.totalImpacted} caller${s.totalImpacted !== 1 ? 's' : ''} impacted` + + (s.filesAffected > 0 + ? ` across ${s.filesAffected} file${s.filesAffected !== 1 ? 's' : ''}` + : ''), + ); + + return lines.join('\n'); +} + +// ─── CLI Display Function ─────────────────────────────────────────────── + +export async function branchCompare(baseRef, targetRef, opts = {}) { + const data = await branchCompareData(baseRef, targetRef, opts); + + if (opts.format === 'json') opts = { ...opts, json: true }; + if (outputResult(data, null, opts)) return; + + if (opts.format === 'mermaid') { + console.log(branchCompareMermaid(data)); + return; + } + + console.log(formatText(data)); +} diff --git a/src/commands/cfg.js b/src/commands/cfg.js new file mode 100644 index 00000000..c0f6c13b --- /dev/null +++ b/src/commands/cfg.js @@ -0,0 +1,55 @@ +import { cfgData, cfgToDOT, cfgToMermaid } from '../cfg.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI display for cfg command. + */ +export function cfg(name, customDbPath, opts = {}) { + const data = cfgData(name, customDbPath, opts); + + if (outputResult(data, 'results', opts)) return; + + if (data.warning) { + console.log(`\u26A0 ${data.warning}`); + return; + } + if (data.results.length === 0) { + console.log(`No symbols matching "${name}".`); + return; + } + + const format = opts.format || 'text'; + if (format === 'dot') { + console.log(cfgToDOT(data)); + return; + } + if (format === 'mermaid') { + console.log(cfgToMermaid(data)); + return; + } + + // Text format + for (const r of data.results) { + console.log(`\n${r.kind} ${r.name} (${r.file}:${r.line})`); + console.log('\u2500'.repeat(60)); + console.log(` Blocks: ${r.summary.blockCount} Edges: ${r.summary.edgeCount}`); + + if (r.blocks.length > 0) { + console.log('\n Blocks:'); + for (const b of r.blocks) { + const loc = b.startLine + ? ` L${b.startLine}${b.endLine && b.endLine !== b.startLine ? `-${b.endLine}` : ''}` + : ''; + const label = b.label ? ` (${b.label})` : ''; + console.log(` [${b.index}] ${b.type}${label}${loc}`); + } + } + + if (r.edges.length > 0) { + console.log('\n Edges:'); + for (const e of r.edges) { + console.log(` B${e.source} \u2192 B${e.target} [${e.kind}]`); + } + } + } +} diff --git a/src/commands/check.js b/src/commands/check.js new file mode 100644 index 00000000..b3ae6d1b --- /dev/null +++ b/src/commands/check.js @@ -0,0 +1,82 @@ +import { checkData } from '../check.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI formatter — prints check results and exits with code 1 on failure. + */ +export function check(customDbPath, opts = {}) { + const data = checkData(customDbPath, opts); + + if (data.error) { + console.error(data.error); + process.exit(1); + } + + if (outputResult(data, null, opts)) { + if (!data.passed) process.exit(1); + return; + } + + console.log('\n# Check Results\n'); + + if (data.predicates.length === 0) { + console.log(' No changes detected.\n'); + return; + } + + console.log( + ` Changed files: ${data.summary.changedFiles} New files: ${data.summary.newFiles}\n`, + ); + + for (const pred of data.predicates) { + const icon = pred.passed ? 'PASS' : 'FAIL'; + console.log(` [${icon}] ${pred.name}`); + + if (!pred.passed) { + if (pred.name === 'cycles' && pred.cycles) { + for (const cycle of pred.cycles.slice(0, 10)) { + console.log(` ${cycle.join(' -> ')}`); + } + if (pred.cycles.length > 10) { + console.log(` ... and ${pred.cycles.length - 10} more`); + } + } + if (pred.name === 'blast-radius' && pred.violations) { + for (const v of pred.violations.slice(0, 10)) { + console.log( + ` ${v.name} (${v.kind}) at ${v.file}:${v.line} — ${v.transitiveCallers} callers (max: ${pred.threshold})`, + ); + } + if (pred.violations.length > 10) { + console.log(` ... and ${pred.violations.length - 10} more`); + } + } + if (pred.name === 'signatures' && pred.violations) { + for (const v of pred.violations.slice(0, 10)) { + console.log(` ${v.name} (${v.kind}) at ${v.file}:${v.line}`); + } + if (pred.violations.length > 10) { + console.log(` ... and ${pred.violations.length - 10} more`); + } + } + if (pred.name === 'boundaries' && pred.violations) { + for (const v of pred.violations.slice(0, 10)) { + console.log(` ${v.from} -> ${v.to} (${v.edgeKind})`); + } + if (pred.violations.length > 10) { + console.log(` ... and ${pred.violations.length - 10} more`); + } + } + } + if (pred.note) { + console.log(` ${pred.note}`); + } + } + + const s = data.summary; + console.log(`\n ${s.total} predicates | ${s.passed} passed | ${s.failed} failed\n`); + + if (!data.passed) { + process.exit(1); + } +} diff --git a/src/commands/cochange.js b/src/commands/cochange.js new file mode 100644 index 00000000..21802cc2 --- /dev/null +++ b/src/commands/cochange.js @@ -0,0 +1,37 @@ +/** + * Format co-change data for CLI output (single file). + */ +export function formatCoChange(data) { + if (data.error) return data.error; + if (data.partners.length === 0) return `No co-change partners found for ${data.file}`; + + const lines = [`\nCo-change partners for ${data.file}:\n`]; + for (const p of data.partners) { + const pct = `${(p.jaccard * 100).toFixed(0)}%`.padStart(4); + const commits = `${p.commitCount} commits`.padStart(12); + lines.push(` ${pct} ${commits} ${p.file}`); + } + if (data.meta?.analyzedAt) { + lines.push(`\n Analyzed: ${data.meta.analyzedAt} | Window: ${data.meta.since || 'all'}`); + } + return lines.join('\n'); +} + +/** + * Format top co-change pairs for CLI output (global view). + */ +export function formatCoChangeTop(data) { + if (data.error) return data.error; + if (data.pairs.length === 0) return 'No co-change pairs found.'; + + const lines = ['\nTop co-change pairs:\n']; + for (const p of data.pairs) { + const pct = `${(p.jaccard * 100).toFixed(0)}%`.padStart(4); + const commits = `${p.commitCount} commits`.padStart(12); + lines.push(` ${pct} ${commits} ${p.fileA} <-> ${p.fileB}`); + } + if (data.meta?.analyzedAt) { + lines.push(`\n Analyzed: ${data.meta.analyzedAt} | Window: ${data.meta.since || 'all'}`); + } + return lines.join('\n'); +} diff --git a/src/commands/communities.js b/src/commands/communities.js new file mode 100644 index 00000000..db21afba --- /dev/null +++ b/src/commands/communities.js @@ -0,0 +1,69 @@ +import { communitiesData } from '../communities.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI entry point: run community detection and print results. + */ +export function communities(customDbPath, opts = {}) { + const data = communitiesData(customDbPath, opts); + + if (outputResult(data, 'communities', opts)) return; + + if (data.summary.communityCount === 0) { + console.log( + '\nNo communities detected. The graph may be too small or disconnected.\n' + + 'Run "codegraph build" first to populate the graph.\n', + ); + return; + } + + const mode = opts.functions ? 'Function' : 'File'; + console.log(`\n# ${mode}-Level Communities\n`); + console.log( + ` ${data.summary.communityCount} communities | ${data.summary.nodeCount} nodes | modularity: ${data.summary.modularity} | drift: ${data.summary.driftScore}%\n`, + ); + + if (!opts.drift) { + for (const c of data.communities) { + const dirs = Object.entries(c.directories) + .sort((a, b) => b[1] - a[1]) + .map(([d, n]) => `${d} (${n})`) + .join(', '); + console.log(` Community ${c.id} (${c.size} members): ${dirs}`); + if (c.members) { + const shown = c.members.slice(0, 8); + for (const m of shown) { + const kind = m.kind ? ` [${m.kind}]` : ''; + console.log(` - ${m.name}${kind} ${m.file}`); + } + if (c.members.length > 8) { + console.log(` ... and ${c.members.length - 8} more`); + } + } + } + } + + // Drift analysis + const d = data.drift; + if (d.splitCandidates.length > 0 || d.mergeCandidates.length > 0) { + console.log(`\n# Drift Analysis (score: ${data.summary.driftScore}%)\n`); + + if (d.splitCandidates.length > 0) { + console.log(' Split candidates (directories spanning multiple communities):'); + for (const s of d.splitCandidates.slice(0, 10)) { + console.log(` - ${s.directory} → ${s.communityCount} communities`); + } + } + + if (d.mergeCandidates.length > 0) { + console.log(' Merge candidates (communities spanning multiple directories):'); + for (const m of d.mergeCandidates.slice(0, 10)) { + console.log( + ` - Community ${m.communityId} (${m.size} members) → ${m.directoryCount} dirs: ${m.directories.join(', ')}`, + ); + } + } + } + + console.log(); +} diff --git a/src/commands/complexity.js b/src/commands/complexity.js new file mode 100644 index 00000000..419064c2 --- /dev/null +++ b/src/commands/complexity.js @@ -0,0 +1,77 @@ +import { complexityData } from '../complexity.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * Format complexity output for CLI display. + */ +export function complexity(customDbPath, opts = {}) { + const data = complexityData(customDbPath, opts); + + if (outputResult(data, 'functions', opts)) return; + + if (data.functions.length === 0) { + if (data.summary === null) { + if (data.hasGraph) { + console.log( + '\nNo complexity data found, but a graph exists. Run "codegraph build --no-incremental" to populate complexity metrics.\n', + ); + } else { + console.log( + '\nNo complexity data found. Run "codegraph build" first to analyze your codebase.\n', + ); + } + } else { + console.log('\nNo functions match the given filters.\n'); + } + return; + } + + const header = opts.aboveThreshold ? 'Functions Above Threshold' : 'Function Complexity'; + console.log(`\n# ${header}\n`); + + if (opts.health) { + // Health-focused view with Halstead + MI columns + console.log( + ` ${'Function'.padEnd(35)} ${'File'.padEnd(25)} ${'MI'.padStart(5)} ${'Vol'.padStart(7)} ${'Diff'.padStart(6)} ${'Effort'.padStart(9)} ${'Bugs'.padStart(6)} ${'LOC'.padStart(5)} ${'SLOC'.padStart(5)}`, + ); + console.log( + ` ${'─'.repeat(35)} ${'─'.repeat(25)} ${'─'.repeat(5)} ${'─'.repeat(7)} ${'─'.repeat(6)} ${'─'.repeat(9)} ${'─'.repeat(6)} ${'─'.repeat(5)} ${'─'.repeat(5)}`, + ); + + for (const fn of data.functions) { + const name = fn.name.length > 33 ? `${fn.name.slice(0, 32)}…` : fn.name; + const file = fn.file.length > 23 ? `…${fn.file.slice(-22)}` : fn.file; + const miWarn = fn.exceeds?.includes('maintainabilityIndex') ? '!' : ' '; + console.log( + ` ${name.padEnd(35)} ${file.padEnd(25)} ${String(fn.maintainabilityIndex).padStart(5)}${miWarn}${String(fn.halstead.volume).padStart(7)} ${String(fn.halstead.difficulty).padStart(6)} ${String(fn.halstead.effort).padStart(9)} ${String(fn.halstead.bugs).padStart(6)} ${String(fn.loc).padStart(5)} ${String(fn.sloc).padStart(5)}`, + ); + } + } else { + // Default view with MI column appended + console.log( + ` ${'Function'.padEnd(40)} ${'File'.padEnd(30)} ${'Cog'.padStart(4)} ${'Cyc'.padStart(4)} ${'Nest'.padStart(5)} ${'MI'.padStart(5)}`, + ); + console.log( + ` ${'─'.repeat(40)} ${'─'.repeat(30)} ${'─'.repeat(4)} ${'─'.repeat(4)} ${'─'.repeat(5)} ${'─'.repeat(5)}`, + ); + + for (const fn of data.functions) { + const name = fn.name.length > 38 ? `${fn.name.slice(0, 37)}…` : fn.name; + const file = fn.file.length > 28 ? `…${fn.file.slice(-27)}` : fn.file; + const warn = fn.exceeds ? ' !' : ''; + const mi = fn.maintainabilityIndex > 0 ? String(fn.maintainabilityIndex) : '-'; + console.log( + ` ${name.padEnd(40)} ${file.padEnd(30)} ${String(fn.cognitive).padStart(4)} ${String(fn.cyclomatic).padStart(4)} ${String(fn.maxNesting).padStart(5)} ${mi.padStart(5)}${warn}`, + ); + } + } + + if (data.summary) { + const s = data.summary; + const miPart = s.avgMI != null ? ` | avg MI: ${s.avgMI}` : ''; + console.log( + `\n ${s.analyzed} functions analyzed | avg cognitive: ${s.avgCognitive} | avg cyclomatic: ${s.avgCyclomatic}${miPart} | ${s.aboveWarn} above threshold`, + ); + } + console.log(); +} diff --git a/src/commands/dataflow.js b/src/commands/dataflow.js new file mode 100644 index 00000000..258f0d9c --- /dev/null +++ b/src/commands/dataflow.js @@ -0,0 +1,110 @@ +import { dataflowData, dataflowImpactData } from '../dataflow.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; + +/** + * CLI display for dataflow command. + */ +export function dataflow(name, customDbPath, opts = {}) { + if (opts.impact) { + return dataflowImpact(name, customDbPath, opts); + } + + const data = dataflowData(name, customDbPath, opts); + + if (outputResult(data, 'results', opts)) return; + + if (data.warning) { + console.log(`⚠ ${data.warning}`); + return; + } + if (data.results.length === 0) { + console.log(`No symbols matching "${name}".`); + return; + } + + for (const r of data.results) { + console.log(`\n${r.kind} ${r.name} (${r.file}:${r.line})`); + console.log('─'.repeat(60)); + + if (r.flowsTo.length > 0) { + console.log('\n Data flows TO:'); + for (const f of r.flowsTo) { + const conf = f.confidence < 1.0 ? ` [${(f.confidence * 100).toFixed(0)}%]` : ''; + console.log(` → ${f.target} (${f.file}:${f.line}) arg[${f.paramIndex}]${conf}`); + } + } + + if (r.flowsFrom.length > 0) { + console.log('\n Data flows FROM:'); + for (const f of r.flowsFrom) { + const conf = f.confidence < 1.0 ? ` [${(f.confidence * 100).toFixed(0)}%]` : ''; + console.log(` ← ${f.source} (${f.file}:${f.line}) arg[${f.paramIndex}]${conf}`); + } + } + + if (r.returns.length > 0) { + console.log('\n Return value consumed by:'); + for (const c of r.returns) { + console.log(` → ${c.consumer} (${c.file}:${c.line}) ${c.expression}`); + } + } + + if (r.returnedBy.length > 0) { + console.log('\n Uses return value of:'); + for (const p of r.returnedBy) { + console.log(` ← ${p.producer} (${p.file}:${p.line}) ${p.expression}`); + } + } + + if (r.mutates.length > 0) { + console.log('\n Mutates:'); + for (const m of r.mutates) { + console.log(` ✎ ${m.expression} (line ${m.line})`); + } + } + + if (r.mutatedBy.length > 0) { + console.log('\n Mutated by:'); + for (const m of r.mutatedBy) { + console.log(` ✎ ${m.source} — ${m.expression} (line ${m.line})`); + } + } + } +} + +/** + * CLI display for dataflow --impact. + */ +function dataflowImpact(name, customDbPath, opts = {}) { + const data = dataflowImpactData(name, customDbPath, { + noTests: opts.noTests, + depth: opts.depth ? Number(opts.depth) : 5, + file: opts.file, + kind: opts.kind, + limit: opts.limit, + offset: opts.offset, + }); + + if (outputResult(data, 'results', opts)) return; + + if (data.warning) { + console.log(`⚠ ${data.warning}`); + return; + } + if (data.results.length === 0) { + console.log(`No symbols matching "${name}".`); + return; + } + + for (const r of data.results) { + console.log( + `\n${r.kind} ${r.name} (${r.file}:${r.line}) — ${r.totalAffected} data-dependent consumer${r.totalAffected !== 1 ? 's' : ''}`, + ); + for (const [level, items] of Object.entries(r.levels)) { + console.log(` Level ${level}:`); + for (const item of items) { + console.log(` ${item.name} (${item.file}:${item.line})`); + } + } + } +} diff --git a/src/commands/flow.js b/src/commands/flow.js new file mode 100644 index 00000000..630bcff4 --- /dev/null +++ b/src/commands/flow.js @@ -0,0 +1,70 @@ +import { flowData, listEntryPointsData } from '../flow.js'; +import { outputResult } from '../infrastructure/result-formatter.js'; +import { kindIcon } from '../queries.js'; + +/** + * CLI formatter — text or JSON output. + */ +export function flow(name, dbPath, opts = {}) { + if (opts.list) { + const data = listEntryPointsData(dbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (outputResult(data, 'entries', opts)) return; + if (data.count === 0) { + console.log('No entry points found. Run "codegraph build" first.'); + return; + } + console.log(`\nEntry points (${data.count} total):\n`); + for (const [type, entries] of Object.entries(data.byType)) { + console.log(` ${type} (${entries.length}):`); + for (const e of entries) { + console.log(` [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`); + } + console.log(); + } + return; + } + + const data = flowData(name, dbPath, opts); + if (outputResult(data, 'steps', opts)) return; + + if (!data.entry) { + console.log(`No matching entry point or function found for "${name}".`); + return; + } + + const e = data.entry; + const typeTag = e.type !== 'exported' ? ` (${e.type})` : ''; + console.log(`\nFlow from: [${kindIcon(e.kind)}] ${e.name}${typeTag} ${e.file}:${e.line}`); + console.log( + `Depth: ${data.depth} Reached: ${data.totalReached} nodes Leaves: ${data.leaves.length}`, + ); + if (data.truncated) { + console.log(` (truncated at depth ${data.depth})`); + } + console.log(); + + if (data.steps.length === 0) { + console.log(' (leaf node — no callees)'); + return; + } + + for (const step of data.steps) { + console.log(` depth ${step.depth}:`); + for (const n of step.nodes) { + const isLeaf = data.leaves.some((l) => l.name === n.name && l.file === n.file); + const leafTag = isLeaf ? ' [leaf]' : ''; + console.log(` [${kindIcon(n.kind)}] ${n.name} ${n.file}:${n.line}${leafTag}`); + } + } + + if (data.cycles.length > 0) { + console.log('\n Cycles detected:'); + for (const c of data.cycles) { + console.log(` ${c.from} -> ${c.to} (at depth ${c.depth})`); + } + } +} diff --git a/src/commands/manifesto.js b/src/commands/manifesto.js new file mode 100644 index 00000000..8044f61c --- /dev/null +++ b/src/commands/manifesto.js @@ -0,0 +1,77 @@ +import { outputResult } from '../infrastructure/result-formatter.js'; +import { manifestoData } from '../manifesto.js'; + +/** + * CLI formatter — prints manifesto results and exits with code 1 on failure. + */ +export function manifesto(customDbPath, opts = {}) { + const data = manifestoData(customDbPath, opts); + + if (outputResult(data, 'violations', opts)) { + if (!data.passed) process.exit(1); + return; + } + + console.log('\n# Manifesto Rules\n'); + + // Rules table + console.log( + ` ${'Rule'.padEnd(20)} ${'Level'.padEnd(10)} ${'Status'.padEnd(8)} ${'Warn'.padStart(6)} ${'Fail'.padStart(6)} ${'Violations'.padStart(11)}`, + ); + console.log( + ` ${'─'.repeat(20)} ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(6)} ${'─'.repeat(11)}`, + ); + + for (const rule of data.rules) { + const warn = rule.thresholds.warn != null ? String(rule.thresholds.warn) : '—'; + const fail = rule.thresholds.fail != null ? String(rule.thresholds.fail) : '—'; + const statusIcon = rule.status === 'pass' ? 'pass' : rule.status === 'warn' ? 'WARN' : 'FAIL'; + console.log( + ` ${rule.name.padEnd(20)} ${rule.level.padEnd(10)} ${statusIcon.padEnd(8)} ${warn.padStart(6)} ${fail.padStart(6)} ${String(rule.violationCount).padStart(11)}`, + ); + } + + // Summary + const s = data.summary; + console.log( + `\n ${s.total} rules | ${s.passed} passed | ${s.warned} warned | ${s.failed} failed | ${s.violationCount} violations`, + ); + + // Violations detail + if (data.violations.length > 0) { + const failViolations = data.violations.filter((v) => v.level === 'fail'); + const warnViolations = data.violations.filter((v) => v.level === 'warn'); + + if (failViolations.length > 0) { + console.log(`\n## Failures (${failViolations.length})\n`); + for (const v of failViolations.slice(0, 20)) { + const loc = v.line ? `${v.file}:${v.line}` : v.file; + console.log( + ` [FAIL] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, + ); + } + if (failViolations.length > 20) { + console.log(` ... and ${failViolations.length - 20} more`); + } + } + + if (warnViolations.length > 0) { + console.log(`\n## Warnings (${warnViolations.length})\n`); + for (const v of warnViolations.slice(0, 20)) { + const loc = v.line ? `${v.file}:${v.line}` : v.file; + console.log( + ` [WARN] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, + ); + } + if (warnViolations.length > 20) { + console.log(` ... and ${warnViolations.length - 20} more`); + } + } + } + + console.log(); + + if (!data.passed) { + process.exit(1); + } +} diff --git a/src/commands/owners.js b/src/commands/owners.js new file mode 100644 index 00000000..3923cce0 --- /dev/null +++ b/src/commands/owners.js @@ -0,0 +1,52 @@ +import { outputResult } from '../infrastructure/result-formatter.js'; +import { ownersData } from '../owners.js'; + +/** + * CLI display function for the `owners` command. + */ +export function owners(customDbPath, opts = {}) { + const data = ownersData(customDbPath, opts); + if (outputResult(data, null, opts)) return; + + if (!data.codeownersFile) { + console.log('No CODEOWNERS file found.'); + return; + } + + console.log(`\nCODEOWNERS: ${data.codeownersFile}\n`); + + const s = data.summary; + console.log( + ` Coverage: ${s.coveragePercent}% (${s.ownedFiles}/${s.totalFiles} files owned, ${s.ownerCount} owners)\n`, + ); + + if (s.byOwner.length > 0) { + console.log(' Owners:\n'); + for (const o of s.byOwner) { + console.log(` ${o.owner} ${o.fileCount} files`); + } + console.log(); + } + + if (data.files.length > 0 && opts.owner) { + console.log(` Files owned by ${opts.owner}:\n`); + for (const f of data.files) { + console.log(` ${f.file}`); + } + console.log(); + } + + if (data.boundaries.length > 0) { + console.log(` Cross-owner boundaries: ${data.boundaries.length} edges\n`); + const shown = data.boundaries.slice(0, 30); + for (const b of shown) { + const srcOwner = b.from.owners.join(', ') || '(unowned)'; + const tgtOwner = b.to.owners.join(', ') || '(unowned)'; + console.log(` ${b.from.name} [${srcOwner}] -> ${b.to.name} [${tgtOwner}]`); + } + if (data.boundaries.length > 30) { + console.log(` ... and ${data.boundaries.length - 30} more`); + } + console.log(); + } +} diff --git a/src/commands/query.js b/src/commands/query.js new file mode 100644 index 00000000..63c4db64 --- /dev/null +++ b/src/commands/query.js @@ -0,0 +1,21 @@ +/** + * Re-export all query CLI wrappers from queries-cli.js. + * This barrel file provides the standard src/commands/ import path. + */ +export { + children, + context, + diffImpact, + explain, + fileDeps, + fileExports, + fnDeps, + fnImpact, + impactAnalysis, + moduleMap, + queryName, + roles, + stats, + symbolPath, + where, +} from '../queries-cli.js'; diff --git a/src/commands/sequence.js b/src/commands/sequence.js new file mode 100644 index 00000000..3b0a2a9e --- /dev/null +++ b/src/commands/sequence.js @@ -0,0 +1,33 @@ +import { outputResult } from '../infrastructure/result-formatter.js'; +import { kindIcon } from '../queries.js'; +import { sequenceData, sequenceToMermaid } from '../sequence.js'; + +/** + * CLI entry point — format sequence data as mermaid, JSON, or ndjson. + */ +export function sequence(name, dbPath, opts = {}) { + const data = sequenceData(name, dbPath, opts); + + if (outputResult(data, 'messages', opts)) return; + + // Default: mermaid format + if (!data.entry) { + console.log(`No matching function found for "${name}".`); + return; + } + + const e = data.entry; + console.log(`\nSequence from: [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`); + console.log(`Participants: ${data.participants.length} Messages: ${data.totalMessages}`); + if (data.truncated) { + console.log(` (truncated at depth ${data.depth})`); + } + console.log(); + + if (data.messages.length === 0) { + console.log(' (leaf node — no callees)'); + return; + } + + console.log(sequenceToMermaid(data)); +} diff --git a/src/commands/structure.js b/src/commands/structure.js new file mode 100644 index 00000000..0c426d46 --- /dev/null +++ b/src/commands/structure.js @@ -0,0 +1,64 @@ +import path from 'node:path'; +import { hotspotsData, moduleBoundariesData, structureData } from '../structure.js'; + +export { structureData, hotspotsData, moduleBoundariesData }; + +export function formatStructure(data) { + if (data.count === 0) return 'No directory structure found. Run "codegraph build" first.'; + + const lines = [`\nProject structure (${data.count} directories):\n`]; + for (const d of data.directories) { + const cohStr = d.cohesion !== null ? ` cohesion=${d.cohesion.toFixed(2)}` : ''; + const depth = d.directory.split('/').length - 1; + const indent = ' '.repeat(depth); + lines.push( + `${indent}${d.directory}/ (${d.fileCount} files, ${d.symbolCount} symbols, <-${d.fanIn} ->${d.fanOut}${cohStr})`, + ); + for (const f of d.files) { + lines.push( + `${indent} ${path.basename(f.file)} ${f.lineCount}L ${f.symbolCount}sym <-${f.fanIn} ->${f.fanOut}`, + ); + } + } + if (data.warning) { + lines.push(''); + lines.push(`⚠ ${data.warning}`); + } + return lines.join('\n'); +} + +export function formatHotspots(data) { + if (data.hotspots.length === 0) return 'No hotspots found. Run "codegraph build" first.'; + + const lines = [`\nHotspots by ${data.metric} (${data.level}-level, top ${data.limit}):\n`]; + let rank = 1; + for (const h of data.hotspots) { + const extra = + h.kind === 'directory' + ? `${h.fileCount} files, cohesion=${h.cohesion !== null ? h.cohesion.toFixed(2) : 'n/a'}` + : `${h.lineCount || 0}L, ${h.symbolCount || 0} symbols`; + lines.push( + ` ${String(rank++).padStart(2)}. ${h.name} <-${h.fanIn || 0} ->${h.fanOut || 0} (${extra})`, + ); + } + return lines.join('\n'); +} + +export function formatModuleBoundaries(data) { + if (data.count === 0) return `No modules found with cohesion >= ${data.threshold}.`; + + const lines = [`\nModule boundaries (cohesion >= ${data.threshold}, ${data.count} modules):\n`]; + for (const m of data.modules) { + lines.push( + ` ${m.directory}/ cohesion=${m.cohesion.toFixed(2)} (${m.fileCount} files, ${m.symbolCount} symbols)`, + ); + lines.push(` Incoming: ${m.fanIn} edges Outgoing: ${m.fanOut} edges`); + if (m.files.length > 0) { + lines.push( + ` Files: ${m.files.slice(0, 5).join(', ')}${m.files.length > 5 ? ` ... +${m.files.length - 5}` : ''}`, + ); + } + lines.push(''); + } + return lines.join('\n'); +} diff --git a/src/commands/triage.js b/src/commands/triage.js new file mode 100644 index 00000000..2ca8d136 --- /dev/null +++ b/src/commands/triage.js @@ -0,0 +1,49 @@ +import { outputResult } from '../infrastructure/result-formatter.js'; +import { triageData } from '../triage.js'; + +/** + * Print triage results to console. + */ +export function triage(customDbPath, opts = {}) { + const data = triageData(customDbPath, opts); + + if (outputResult(data, 'items', opts)) return; + + if (data.items.length === 0) { + if (data.summary.total === 0) { + console.log('\nNo symbols found. Run "codegraph build" first.\n'); + } else { + console.log('\nNo symbols match the given filters.\n'); + } + return; + } + + console.log('\n# Risk Audit Queue\n'); + + console.log( + ` ${'Symbol'.padEnd(35)} ${'File'.padEnd(28)} ${'Role'.padEnd(8)} ${'Score'.padStart(6)} ${'Fan-In'.padStart(7)} ${'Cog'.padStart(4)} ${'Churn'.padStart(6)} ${'MI'.padStart(5)}`, + ); + console.log( + ` ${'─'.repeat(35)} ${'─'.repeat(28)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(7)} ${'─'.repeat(4)} ${'─'.repeat(6)} ${'─'.repeat(5)}`, + ); + + for (const it of data.items) { + const name = it.name.length > 33 ? `${it.name.slice(0, 32)}…` : it.name; + const file = it.file.length > 26 ? `…${it.file.slice(-25)}` : it.file; + const role = (it.role || '-').padEnd(8); + const score = it.riskScore.toFixed(2).padStart(6); + const fanIn = String(it.fanIn).padStart(7); + const cog = String(it.cognitive).padStart(4); + const churn = String(it.churn).padStart(6); + const mi = it.maintainabilityIndex > 0 ? String(it.maintainabilityIndex).padStart(5) : ' -'; + console.log( + ` ${name.padEnd(35)} ${file.padEnd(28)} ${role} ${score} ${fanIn} ${cog} ${churn} ${mi}`, + ); + } + + const s = data.summary; + console.log( + `\n ${s.analyzed} symbols scored (of ${s.total} total) | avg: ${s.avgScore.toFixed(2)} | max: ${s.maxScore.toFixed(2)} | sort: ${opts.sort || 'risk'}`, + ); + console.log(); +} diff --git a/src/communities.js b/src/communities.js index e5a33b33..b0d05639 100644 --- a/src/communities.js +++ b/src/communities.js @@ -2,9 +2,8 @@ import path from 'node:path'; import Graph from 'graphology'; import louvain from 'graphology-communities-louvain'; import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { paginateResult } from './paginate.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; // ─── Graph Construction ─────────────────────────────────────────────── @@ -232,75 +231,3 @@ export function communitySummaryForStats(customDbPath, opts = {}) { const data = communitiesData(customDbPath, { ...opts, drift: true }); return data.summary; } - -// ─── CLI Display ────────────────────────────────────────────────────── - -/** - * CLI entry point: run community detection and print results. - * - * @param {string} [customDbPath] - * @param {object} [opts] - */ -export function communities(customDbPath, opts = {}) { - const data = communitiesData(customDbPath, opts); - - if (outputResult(data, 'communities', opts)) return; - - if (data.summary.communityCount === 0) { - console.log( - '\nNo communities detected. The graph may be too small or disconnected.\n' + - 'Run "codegraph build" first to populate the graph.\n', - ); - return; - } - - const mode = opts.functions ? 'Function' : 'File'; - console.log(`\n# ${mode}-Level Communities\n`); - console.log( - ` ${data.summary.communityCount} communities | ${data.summary.nodeCount} nodes | modularity: ${data.summary.modularity} | drift: ${data.summary.driftScore}%\n`, - ); - - if (!opts.drift) { - for (const c of data.communities) { - const dirs = Object.entries(c.directories) - .sort((a, b) => b[1] - a[1]) - .map(([d, n]) => `${d} (${n})`) - .join(', '); - console.log(` Community ${c.id} (${c.size} members): ${dirs}`); - if (c.members) { - const shown = c.members.slice(0, 8); - for (const m of shown) { - const kind = m.kind ? ` [${m.kind}]` : ''; - console.log(` - ${m.name}${kind} ${m.file}`); - } - if (c.members.length > 8) { - console.log(` ... and ${c.members.length - 8} more`); - } - } - } - } - - // Drift analysis - const d = data.drift; - if (d.splitCandidates.length > 0 || d.mergeCandidates.length > 0) { - console.log(`\n# Drift Analysis (score: ${data.summary.driftScore}%)\n`); - - if (d.splitCandidates.length > 0) { - console.log(' Split candidates (directories spanning multiple communities):'); - for (const s of d.splitCandidates.slice(0, 10)) { - console.log(` - ${s.directory} → ${s.communityCount} communities`); - } - } - - if (d.mergeCandidates.length > 0) { - console.log(' Merge candidates (communities spanning multiple directories):'); - for (const m of d.mergeCandidates.slice(0, 10)) { - console.log( - ` - Community ${m.communityId} (${m.size} members) → ${m.directoryCount} dirs: ${m.directories.join(', ')}`, - ); - } - } - } - - console.log(); -} diff --git a/src/complexity.js b/src/complexity.js index 58797947..a97bab30 100644 --- a/src/complexity.js +++ b/src/complexity.js @@ -14,12 +14,10 @@ import { walkWithVisitors } from './ast-analysis/visitor.js'; import { createComplexityVisitor } from './ast-analysis/visitors/complexity-visitor.js'; import { loadConfig } from './config.js'; import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { info } from './logger.js'; import { paginateResult } from './paginate.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; - // Re-export rules for backward compatibility export { COMPLEXITY_RULES, HALSTEAD_RULES }; @@ -807,78 +805,3 @@ export function* iterComplexity(customDbPath, opts = {}) { db.close(); } } - -/** - * Format complexity output for CLI display. - */ -export function complexity(customDbPath, opts = {}) { - const data = complexityData(customDbPath, opts); - - if (outputResult(data, 'functions', opts)) return; - - if (data.functions.length === 0) { - if (data.summary === null) { - if (data.hasGraph) { - console.log( - '\nNo complexity data found, but a graph exists. Run "codegraph build --no-incremental" to populate complexity metrics.\n', - ); - } else { - console.log( - '\nNo complexity data found. Run "codegraph build" first to analyze your codebase.\n', - ); - } - } else { - console.log('\nNo functions match the given filters.\n'); - } - return; - } - - const header = opts.aboveThreshold ? 'Functions Above Threshold' : 'Function Complexity'; - console.log(`\n# ${header}\n`); - - if (opts.health) { - // Health-focused view with Halstead + MI columns - console.log( - ` ${'Function'.padEnd(35)} ${'File'.padEnd(25)} ${'MI'.padStart(5)} ${'Vol'.padStart(7)} ${'Diff'.padStart(6)} ${'Effort'.padStart(9)} ${'Bugs'.padStart(6)} ${'LOC'.padStart(5)} ${'SLOC'.padStart(5)}`, - ); - console.log( - ` ${'─'.repeat(35)} ${'─'.repeat(25)} ${'─'.repeat(5)} ${'─'.repeat(7)} ${'─'.repeat(6)} ${'─'.repeat(9)} ${'─'.repeat(6)} ${'─'.repeat(5)} ${'─'.repeat(5)}`, - ); - - for (const fn of data.functions) { - const name = fn.name.length > 33 ? `${fn.name.slice(0, 32)}…` : fn.name; - const file = fn.file.length > 23 ? `…${fn.file.slice(-22)}` : fn.file; - const miWarn = fn.exceeds?.includes('maintainabilityIndex') ? '!' : ' '; - console.log( - ` ${name.padEnd(35)} ${file.padEnd(25)} ${String(fn.maintainabilityIndex).padStart(5)}${miWarn}${String(fn.halstead.volume).padStart(7)} ${String(fn.halstead.difficulty).padStart(6)} ${String(fn.halstead.effort).padStart(9)} ${String(fn.halstead.bugs).padStart(6)} ${String(fn.loc).padStart(5)} ${String(fn.sloc).padStart(5)}`, - ); - } - } else { - // Default view with MI column appended - console.log( - ` ${'Function'.padEnd(40)} ${'File'.padEnd(30)} ${'Cog'.padStart(4)} ${'Cyc'.padStart(4)} ${'Nest'.padStart(5)} ${'MI'.padStart(5)}`, - ); - console.log( - ` ${'─'.repeat(40)} ${'─'.repeat(30)} ${'─'.repeat(4)} ${'─'.repeat(4)} ${'─'.repeat(5)} ${'─'.repeat(5)}`, - ); - - for (const fn of data.functions) { - const name = fn.name.length > 38 ? `${fn.name.slice(0, 37)}…` : fn.name; - const file = fn.file.length > 28 ? `…${fn.file.slice(-27)}` : fn.file; - const warn = fn.exceeds ? ' !' : ''; - const mi = fn.maintainabilityIndex > 0 ? String(fn.maintainabilityIndex) : '-'; - console.log( - ` ${name.padEnd(40)} ${file.padEnd(30)} ${String(fn.cognitive).padStart(4)} ${String(fn.cyclomatic).padStart(4)} ${String(fn.maxNesting).padStart(5)} ${mi.padStart(5)}${warn}`, - ); - } - } - - if (data.summary) { - const s = data.summary; - const miPart = s.avgMI != null ? ` | avg MI: ${s.avgMI}` : ''; - console.log( - `\n ${s.analyzed} functions analyzed | avg cognitive: ${s.avgCognitive} | avg cyclomatic: ${s.avgCyclomatic}${miPart} | ${s.aboveWarn} above threshold`, - ); - } - console.log(); -} diff --git a/src/cycles.js b/src/cycles.js index 812f8104..6accf7ab 100644 --- a/src/cycles.js +++ b/src/cycles.js @@ -1,5 +1,5 @@ +import { isTestFile } from './infrastructure/test-filter.js'; import { loadNative } from './native.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 0e91ba9c..0c954cdb 100644 --- a/src/dataflow.js +++ b/src/dataflow.js @@ -20,12 +20,10 @@ import { import { walkWithVisitors } from './ast-analysis/visitor.js'; import { createDataflowVisitor } from './ast-analysis/visitors/dataflow-visitor.js'; import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { info } from './logger.js'; import { paginateResult } from './paginate.js'; - import { ALL_SYMBOL_KINDS, normalizeSymbol } from './queries.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; // Re-export for backward compatibility export { DATAFLOW_RULES }; @@ -618,113 +616,3 @@ export function dataflowImpactData(name, customDbPath, opts = {}) { db.close(); } } - -// ── Display formatters ────────────────────────────────────────────────────── - -/** - * CLI display for dataflow command. - */ -export function dataflow(name, customDbPath, opts = {}) { - if (opts.impact) { - return dataflowImpact(name, customDbPath, opts); - } - - const data = dataflowData(name, customDbPath, opts); - - if (outputResult(data, 'results', opts)) return; - - if (data.warning) { - console.log(`⚠ ${data.warning}`); - return; - } - if (data.results.length === 0) { - console.log(`No symbols matching "${name}".`); - return; - } - - for (const r of data.results) { - console.log(`\n${r.kind} ${r.name} (${r.file}:${r.line})`); - console.log('─'.repeat(60)); - - if (r.flowsTo.length > 0) { - console.log('\n Data flows TO:'); - for (const f of r.flowsTo) { - const conf = f.confidence < 1.0 ? ` [${(f.confidence * 100).toFixed(0)}%]` : ''; - console.log(` → ${f.target} (${f.file}:${f.line}) arg[${f.paramIndex}]${conf}`); - } - } - - if (r.flowsFrom.length > 0) { - console.log('\n Data flows FROM:'); - for (const f of r.flowsFrom) { - const conf = f.confidence < 1.0 ? ` [${(f.confidence * 100).toFixed(0)}%]` : ''; - console.log(` ← ${f.source} (${f.file}:${f.line}) arg[${f.paramIndex}]${conf}`); - } - } - - if (r.returns.length > 0) { - console.log('\n Return value consumed by:'); - for (const c of r.returns) { - console.log(` → ${c.consumer} (${c.file}:${c.line}) ${c.expression}`); - } - } - - if (r.returnedBy.length > 0) { - console.log('\n Uses return value of:'); - for (const p of r.returnedBy) { - console.log(` ← ${p.producer} (${p.file}:${p.line}) ${p.expression}`); - } - } - - if (r.mutates.length > 0) { - console.log('\n Mutates:'); - for (const m of r.mutates) { - console.log(` ✎ ${m.expression} (line ${m.line})`); - } - } - - if (r.mutatedBy.length > 0) { - console.log('\n Mutated by:'); - for (const m of r.mutatedBy) { - console.log(` ✎ ${m.source} — ${m.expression} (line ${m.line})`); - } - } - } -} - -/** - * CLI display for dataflow --impact. - */ -function dataflowImpact(name, customDbPath, opts = {}) { - const data = dataflowImpactData(name, customDbPath, { - noTests: opts.noTests, - depth: opts.depth ? Number(opts.depth) : 5, - file: opts.file, - kind: opts.kind, - limit: opts.limit, - offset: opts.offset, - }); - - if (outputResult(data, 'results', opts)) return; - - if (data.warning) { - console.log(`⚠ ${data.warning}`); - return; - } - if (data.results.length === 0) { - console.log(`No symbols matching "${name}".`); - return; - } - - for (const r of data.results) { - console.log( - `\n${r.kind} ${r.name} (${r.file}:${r.line}) — ${r.totalAffected} data-dependent consumer${r.totalAffected !== 1 ? 's' : ''}`, - ); - for (const [level, items] of Object.entries(r.levels)) { - console.log(` Level ${level}:`); - for (const item of items) { - console.log(` ${item.name} (${item.file}:${item.line})`); - } - } - } -} diff --git a/src/export.js b/src/export.js index 60996872..4a3a7c91 100644 --- a/src/export.js +++ b/src/export.js @@ -1,6 +1,6 @@ import path from 'node:path'; +import { isTestFile } from './infrastructure/test-filter.js'; import { paginateResult } from './paginate.js'; -import { isTestFile } from './test-filter.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; diff --git a/src/flow.js b/src/flow.js index 0ca1d212..e3af32bd 100644 --- a/src/flow.js +++ b/src/flow.js @@ -6,11 +6,10 @@ */ import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { paginateResult } from './paginate.js'; -import { CORE_SYMBOL_KINDS, findMatchingNodes, kindIcon } from './queries.js'; -import { outputResult } from './result-formatter.js'; +import { CORE_SYMBOL_KINDS, findMatchingNodes } from './queries.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. @@ -227,70 +226,3 @@ export function flowData(name, dbPath, opts = {}) { db.close(); } } - -/** - * CLI formatter — text or JSON output. - */ -export function flow(name, dbPath, opts = {}) { - if (opts.list) { - const data = listEntryPointsData(dbPath, { - noTests: opts.noTests, - limit: opts.limit, - offset: opts.offset, - }); - if (outputResult(data, 'entries', opts)) return; - if (data.count === 0) { - console.log('No entry points found. Run "codegraph build" first.'); - return; - } - console.log(`\nEntry points (${data.count} total):\n`); - for (const [type, entries] of Object.entries(data.byType)) { - console.log(` ${type} (${entries.length}):`); - for (const e of entries) { - console.log(` [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`); - } - console.log(); - } - return; - } - - const data = flowData(name, dbPath, opts); - if (outputResult(data, 'steps', opts)) return; - - if (!data.entry) { - console.log(`No matching entry point or function found for "${name}".`); - return; - } - - const e = data.entry; - const typeTag = e.type !== 'exported' ? ` (${e.type})` : ''; - console.log(`\nFlow from: [${kindIcon(e.kind)}] ${e.name}${typeTag} ${e.file}:${e.line}`); - console.log( - `Depth: ${data.depth} Reached: ${data.totalReached} nodes Leaves: ${data.leaves.length}`, - ); - if (data.truncated) { - console.log(` (truncated at depth ${data.depth})`); - } - console.log(); - - if (data.steps.length === 0) { - console.log(' (leaf node — no callees)'); - return; - } - - for (const step of data.steps) { - console.log(` depth ${step.depth}:`); - for (const n of step.nodes) { - const isLeaf = data.leaves.some((l) => l.name === n.name && l.file === n.file); - const leafTag = isLeaf ? ' [leaf]' : ''; - console.log(` [${kindIcon(n.kind)}] ${n.name} ${n.file}:${n.line}${leafTag}`); - } - } - - if (data.cycles.length > 0) { - console.log('\n Cycles detected:'); - for (const c of data.cycles) { - console.log(` ${c.from} -> ${c.to} (at depth ${c.depth})`); - } - } -} diff --git a/src/index.js b/src/index.js index d47d9cac..6720cc3a 100644 --- a/src/index.js +++ b/src/index.js @@ -8,13 +8,11 @@ // AST node queries export { AST_NODE_KINDS, astQuery, astQueryData } from './ast.js'; // Audit (composite report) -export { audit, auditData } from './audit.js'; +export { auditData } from './audit.js'; // Batch querying export { BATCH_COMMANDS, - batch, batchData, - batchQuery, multiBatchData, splitTargets, } from './batch.js'; @@ -29,13 +27,12 @@ export { buildCFGData, buildFunctionCFG, CFG_RULES, - cfg, cfgData, cfgToDOT, cfgToMermaid, } from './cfg.js'; // Check (CI validation predicates) -export { check, checkData } from './check.js'; +export { checkData } from './check.js'; // Co-change analysis export { analyzeCoChanges, @@ -45,12 +42,23 @@ export { computeCoChanges, scanGitHistory, } from './cochange.js'; +export { audit } from './commands/audit.js'; +export { batch, batchQuery } from './commands/batch.js'; +export { cfg } from './commands/cfg.js'; +export { check } from './commands/check.js'; +export { communities } from './commands/communities.js'; +export { complexity } from './commands/complexity.js'; +export { dataflow } from './commands/dataflow.js'; +export { manifesto } from './commands/manifesto.js'; +export { owners } from './commands/owners.js'; +export { sequence } from './commands/sequence.js'; +export { formatHotspots, formatModuleBoundaries, formatStructure } from './commands/structure.js'; +export { triage } from './commands/triage.js'; // Community detection -export { communities, communitiesData, communitySummaryForStats } from './communities.js'; +export { communitiesData, communitySummaryForStats } from './communities.js'; // Complexity metrics export { COMPLEXITY_RULES, - complexity, complexityData, computeFunctionComplexity, computeHalsteadMetrics, @@ -69,7 +77,6 @@ export { findCycles, formatCycles } from './cycles.js'; // Dataflow analysis export { buildDataflowEdges, - dataflow, dataflowData, dataflowImpactData, dataflowPathData, @@ -123,14 +130,18 @@ export { } from './export.js'; // Execution flow tracing export { entryPointType, flowData, listEntryPointsData } from './flow.js'; +// Result formatting +export { outputResult } from './infrastructure/result-formatter.js'; +// Test file detection +export { isTestFile, TEST_PATTERN } from './infrastructure/test-filter.js'; // Logger export { setVerbose } from './logger.js'; // Manifesto rule engine -export { manifesto, manifestoData, RULE_DEFS } from './manifesto.js'; +export { manifestoData, RULE_DEFS } from './manifesto.js'; // Native engine export { isNativeAvailable } from './native.js'; // Ownership (CODEOWNERS) -export { matchOwners, owners, ownersData, ownersForFiles, parseCodeowners } from './owners.js'; +export { matchOwners, ownersData, ownersForFiles, parseCodeowners } from './owners.js'; // Pagination utilities export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult, printNdjson } from './paginate.js'; // Unified parser API @@ -198,10 +209,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'; +export { sequenceData, sequenceToMermaid } from './sequence.js'; // Snapshot management export { snapshotDelete, @@ -216,17 +225,12 @@ export { buildStructure, classifyNodeRoles, FRAMEWORK_ENTRY_PREFIXES, - formatHotspots, - formatModuleBoundaries, - formatStructure, hotspotsData, 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'; +export { triageData } from './triage.js'; // Interactive HTML viewer export { generatePlotHTML, loadPlotConfig } from './viewer.js'; // Watch mode diff --git a/src/infrastructure/command-runner.js b/src/infrastructure/command-runner.js new file mode 100644 index 00000000..4b0c023d --- /dev/null +++ b/src/infrastructure/command-runner.js @@ -0,0 +1,27 @@ +import { outputResult } from './result-formatter.js'; + +/** + * Run a command through the shared lifecycle: + * 1. Execute dataFn to get data + * 2. Try JSON/NDJSON output via outputResult() + * 3. Fall back to formatFn() for human-readable text + * 4. Handle exit codes for CI gate commands + * + * @param {Function} dataFn - Returns result data object + * @param {Function} formatFn - Formats data for human-readable output (receives data, returns string|void) + * @param {object} [opts] + * @param {string|null} [opts.ndjsonField] - Array field name for NDJSON streaming + * @param {boolean} [opts.exitOnFail] - Call process.exit(1) when data.passed === false + * @returns {object} The data object from dataFn + */ +export function runCommand(dataFn, formatFn, opts = {}) { + const data = dataFn(); + if (outputResult(data, opts.ndjsonField ?? null, opts)) { + if (opts.exitOnFail && data.passed === false) process.exit(1); + return data; + } + const text = formatFn(data); + if (text) console.log(text); + if (opts.exitOnFail && data.passed === false) process.exit(1); + return data; +} diff --git a/src/result-formatter.js b/src/infrastructure/result-formatter.js similarity index 92% rename from src/result-formatter.js rename to src/infrastructure/result-formatter.js index 1562b29b..98aa8ea1 100644 --- a/src/result-formatter.js +++ b/src/infrastructure/result-formatter.js @@ -1,4 +1,4 @@ -import { printNdjson } from './paginate.js'; +import { printNdjson } from '../paginate.js'; /** * Shared JSON / NDJSON output dispatch for CLI wrappers. diff --git a/src/test-filter.js b/src/infrastructure/test-filter.js similarity index 100% rename from src/test-filter.js rename to src/infrastructure/test-filter.js diff --git a/src/manifesto.js b/src/manifesto.js index 120816f0..234391fd 100644 --- a/src/manifesto.js +++ b/src/manifesto.js @@ -4,7 +4,6 @@ import { findCycles } from './cycles.js'; import { openReadonlyOrFail } from './db.js'; import { debug } from './logger.js'; import { paginateResult } from './paginate.js'; -import { outputResult } from './result-formatter.js'; // ─── Rule Definitions ───────────────────────────────────────────────── @@ -428,78 +427,3 @@ export function manifestoData(customDbPath, opts = {}) { db.close(); } } - -/** - * CLI formatter — prints manifesto results and exits with code 1 on failure. - */ -export function manifesto(customDbPath, opts = {}) { - const data = manifestoData(customDbPath, opts); - - if (outputResult(data, 'violations', opts)) { - if (!data.passed) process.exit(1); - return; - } - - console.log('\n# Manifesto Rules\n'); - - // Rules table - console.log( - ` ${'Rule'.padEnd(20)} ${'Level'.padEnd(10)} ${'Status'.padEnd(8)} ${'Warn'.padStart(6)} ${'Fail'.padStart(6)} ${'Violations'.padStart(11)}`, - ); - console.log( - ` ${'─'.repeat(20)} ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(6)} ${'─'.repeat(11)}`, - ); - - for (const rule of data.rules) { - const warn = rule.thresholds.warn != null ? String(rule.thresholds.warn) : '—'; - const fail = rule.thresholds.fail != null ? String(rule.thresholds.fail) : '—'; - const statusIcon = rule.status === 'pass' ? 'pass' : rule.status === 'warn' ? 'WARN' : 'FAIL'; - console.log( - ` ${rule.name.padEnd(20)} ${rule.level.padEnd(10)} ${statusIcon.padEnd(8)} ${warn.padStart(6)} ${fail.padStart(6)} ${String(rule.violationCount).padStart(11)}`, - ); - } - - // Summary - const s = data.summary; - console.log( - `\n ${s.total} rules | ${s.passed} passed | ${s.warned} warned | ${s.failed} failed | ${s.violationCount} violations`, - ); - - // Violations detail - if (data.violations.length > 0) { - const failViolations = data.violations.filter((v) => v.level === 'fail'); - const warnViolations = data.violations.filter((v) => v.level === 'warn'); - - if (failViolations.length > 0) { - console.log(`\n## Failures (${failViolations.length})\n`); - for (const v of failViolations.slice(0, 20)) { - const loc = v.line ? `${v.file}:${v.line}` : v.file; - console.log( - ` [FAIL] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, - ); - } - if (failViolations.length > 20) { - console.log(` ... and ${failViolations.length - 20} more`); - } - } - - if (warnViolations.length > 0) { - console.log(`\n## Warnings (${warnViolations.length})\n`); - for (const v of warnViolations.slice(0, 20)) { - const loc = v.line ? `${v.file}:${v.line}` : v.file; - console.log( - ` [WARN] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`, - ); - } - if (warnViolations.length > 20) { - console.log(` ... and ${warnViolations.length - 20} more`); - } - } - } - - console.log(); - - if (!data.passed) { - process.exit(1); - } -} diff --git a/src/owners.js b/src/owners.js index b38e4d45..e0fef2e3 100644 --- a/src/owners.js +++ b/src/owners.js @@ -1,8 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { findDbPath, openReadonlyOrFail } from './db.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; +import { isTestFile } from './infrastructure/test-filter.js'; // ─── CODEOWNERS Parsing ────────────────────────────────────────────── @@ -303,57 +302,3 @@ export function ownersData(customDbPath, opts = {}) { db.close(); } } - -// ─── CLI Display ───────────────────────────────────────────────────── - -/** - * CLI display function for the `owners` command. - * @param {string} [customDbPath] - * @param {object} [opts] - */ -export function owners(customDbPath, opts = {}) { - const data = ownersData(customDbPath, opts); - if (outputResult(data, null, opts)) return; - - if (!data.codeownersFile) { - console.log('No CODEOWNERS file found.'); - return; - } - - console.log(`\nCODEOWNERS: ${data.codeownersFile}\n`); - - const s = data.summary; - console.log( - ` Coverage: ${s.coveragePercent}% (${s.ownedFiles}/${s.totalFiles} files owned, ${s.ownerCount} owners)\n`, - ); - - if (s.byOwner.length > 0) { - console.log(' Owners:\n'); - for (const o of s.byOwner) { - console.log(` ${o.owner} ${o.fileCount} files`); - } - console.log(); - } - - if (data.files.length > 0 && opts.owner) { - console.log(` Files owned by ${opts.owner}:\n`); - for (const f of data.files) { - console.log(` ${f.file}`); - } - console.log(); - } - - if (data.boundaries.length > 0) { - console.log(` Cross-owner boundaries: ${data.boundaries.length} edges\n`); - const shown = data.boundaries.slice(0, 30); - for (const b of shown) { - const srcOwner = b.from.owners.join(', ') || '(unowned)'; - const tgtOwner = b.to.owners.join(', ') || '(unowned)'; - console.log(` ${b.from.name} [${srcOwner}] -> ${b.to.name} [${tgtOwner}]`); - } - if (data.boundaries.length > 30) { - console.log(` ... and ${data.boundaries.length - 30} more`); - } - console.log(); - } -} diff --git a/src/queries-cli.js b/src/queries-cli.js index de507f21..f4981a31 100644 --- a/src/queries-cli.js +++ b/src/queries-cli.js @@ -7,6 +7,7 @@ */ import path from 'node:path'; +import { outputResult } from './infrastructure/result-formatter.js'; import { childrenData, contextData, @@ -26,7 +27,6 @@ import { statsData, whereData, } from './queries.js'; -import { outputResult } from './result-formatter.js'; // ─── symbolPath ───────────────────────────────────────────────────────── diff --git a/src/queries.js b/src/queries.js index 4c839366..66f2ea0c 100644 --- a/src/queries.js +++ b/src/queries.js @@ -13,15 +13,15 @@ import { openReadonlyOrFail, testFilterSQL, } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { ALL_SYMBOL_KINDS } from './kinds.js'; import { debug } from './logger.js'; import { ownersForFiles } from './owners.js'; import { paginateResult } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; -import { isTestFile } from './test-filter.js'; // Re-export from dedicated module for backward compat -export { isTestFile, TEST_PATTERN } from './test-filter.js'; +export { isTestFile, TEST_PATTERN } from './infrastructure/test-filter.js'; /** * Resolve a file path relative to repoRoot, rejecting traversal outside the repo. diff --git a/src/sequence.js b/src/sequence.js index e8147b1d..6ee2b186 100644 --- a/src/sequence.js +++ b/src/sequence.js @@ -7,11 +7,10 @@ */ import { openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { paginateResult } from './paginate.js'; -import { findMatchingNodes, kindIcon } from './queries.js'; -import { outputResult } from './result-formatter.js'; +import { findMatchingNodes } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; -import { isTestFile } from './test-filter.js'; // ─── Alias generation ──────────────────────────────────────────────── @@ -330,35 +329,3 @@ export function sequenceToMermaid(seqResult) { return lines.join('\n'); } - -// ─── CLI formatter ─────────────────────────────────────────────────── - -/** - * CLI entry point — format sequence data as mermaid, JSON, or ndjson. - */ -export function sequence(name, dbPath, opts = {}) { - const data = sequenceData(name, dbPath, opts); - - if (outputResult(data, 'messages', opts)) return; - - // Default: mermaid format - if (!data.entry) { - console.log(`No matching function found for "${name}".`); - return; - } - - const e = data.entry; - console.log(`\nSequence from: [${kindIcon(e.kind)}] ${e.name} ${e.file}:${e.line}`); - console.log(`Participants: ${data.participants.length} Messages: ${data.totalMessages}`); - if (data.truncated) { - console.log(` (truncated at depth ${data.depth})`); - } - console.log(); - - if (data.messages.length === 0) { - console.log(' (leaf node — no callees)'); - return; - } - - console.log(sequenceToMermaid(data)); -} diff --git a/src/structure.js b/src/structure.js index 7c547de7..fed04890 100644 --- a/src/structure.js +++ b/src/structure.js @@ -1,9 +1,9 @@ import path from 'node:path'; import { normalizePath } from './constants.js'; import { openReadonlyOrFail, testFilterSQL } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { debug } from './logger.js'; import { paginateResult } from './paginate.js'; -import { isTestFile } from './test-filter.js'; // ─── Build-time: insert directory nodes, contains edges, and metrics ──── @@ -640,68 +640,6 @@ export function moduleBoundariesData(customDbPath, opts = {}) { } } -// ─── Formatters ─────────────────────────────────────────────────────── - -export function formatStructure(data) { - if (data.count === 0) return 'No directory structure found. Run "codegraph build" first.'; - - const lines = [`\nProject structure (${data.count} directories):\n`]; - for (const d of data.directories) { - const cohStr = d.cohesion !== null ? ` cohesion=${d.cohesion.toFixed(2)}` : ''; - const depth = d.directory.split('/').length - 1; - const indent = ' '.repeat(depth); - lines.push( - `${indent}${d.directory}/ (${d.fileCount} files, ${d.symbolCount} symbols, <-${d.fanIn} ->${d.fanOut}${cohStr})`, - ); - for (const f of d.files) { - lines.push( - `${indent} ${path.basename(f.file)} ${f.lineCount}L ${f.symbolCount}sym <-${f.fanIn} ->${f.fanOut}`, - ); - } - } - if (data.warning) { - lines.push(''); - lines.push(`⚠ ${data.warning}`); - } - return lines.join('\n'); -} - -export function formatHotspots(data) { - if (data.hotspots.length === 0) return 'No hotspots found. Run "codegraph build" first.'; - - const lines = [`\nHotspots by ${data.metric} (${data.level}-level, top ${data.limit}):\n`]; - let rank = 1; - for (const h of data.hotspots) { - const extra = - h.kind === 'directory' - ? `${h.fileCount} files, cohesion=${h.cohesion !== null ? h.cohesion.toFixed(2) : 'n/a'}` - : `${h.lineCount || 0}L, ${h.symbolCount || 0} symbols`; - lines.push( - ` ${String(rank++).padStart(2)}. ${h.name} <-${h.fanIn || 0} ->${h.fanOut || 0} (${extra})`, - ); - } - return lines.join('\n'); -} - -export function formatModuleBoundaries(data) { - if (data.count === 0) return `No modules found with cohesion >= ${data.threshold}.`; - - const lines = [`\nModule boundaries (cohesion >= ${data.threshold}, ${data.count} modules):\n`]; - for (const m of data.modules) { - lines.push( - ` ${m.directory}/ cohesion=${m.cohesion.toFixed(2)} (${m.fileCount} files, ${m.symbolCount} symbols)`, - ); - lines.push(` Incoming: ${m.fanIn} edges Outgoing: ${m.fanOut} edges`); - if (m.files.length > 0) { - lines.push( - ` Files: ${m.files.slice(0, 5).join(', ')}${m.files.length > 5 ? ` ... +${m.files.length - 5}` : ''}`, - ); - } - lines.push(''); - } - return lines.join('\n'); -} - // ─── Helpers ────────────────────────────────────────────────────────── function getSortFn(sortBy) { diff --git a/src/triage.js b/src/triage.js index 687dfa76..c5d88405 100644 --- a/src/triage.js +++ b/src/triage.js @@ -1,8 +1,7 @@ import { findNodesForTriage, openReadonlyOrFail } from './db.js'; +import { isTestFile } from './infrastructure/test-filter.js'; import { warn } from './logger.js'; import { paginateResult } from './paginate.js'; -import { outputResult } from './result-formatter.js'; -import { isTestFile } from './test-filter.js'; // ─── Constants ──────────────────────────────────────────────────────── @@ -172,58 +171,6 @@ export function triageData(customDbPath, opts = {}) { } } -// ─── CLI Formatter ──────────────────────────────────────────────────── - -/** - * Print triage results to console. - * - * @param {string} [customDbPath] - * @param {object} [opts] - */ -export function triage(customDbPath, opts = {}) { - const data = triageData(customDbPath, opts); - - if (outputResult(data, 'items', opts)) return; - - if (data.items.length === 0) { - if (data.summary.total === 0) { - console.log('\nNo symbols found. Run "codegraph build" first.\n'); - } else { - console.log('\nNo symbols match the given filters.\n'); - } - return; - } - - console.log('\n# Risk Audit Queue\n'); - - console.log( - ` ${'Symbol'.padEnd(35)} ${'File'.padEnd(28)} ${'Role'.padEnd(8)} ${'Score'.padStart(6)} ${'Fan-In'.padStart(7)} ${'Cog'.padStart(4)} ${'Churn'.padStart(6)} ${'MI'.padStart(5)}`, - ); - console.log( - ` ${'─'.repeat(35)} ${'─'.repeat(28)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(7)} ${'─'.repeat(4)} ${'─'.repeat(6)} ${'─'.repeat(5)}`, - ); - - for (const it of data.items) { - const name = it.name.length > 33 ? `${it.name.slice(0, 32)}…` : it.name; - const file = it.file.length > 26 ? `…${it.file.slice(-25)}` : it.file; - const role = (it.role || '-').padEnd(8); - const score = it.riskScore.toFixed(2).padStart(6); - const fanIn = String(it.fanIn).padStart(7); - const cog = String(it.cognitive).padStart(4); - const churn = String(it.churn).padStart(6); - const mi = it.maintainabilityIndex > 0 ? String(it.maintainabilityIndex).padStart(5) : ' -'; - console.log( - ` ${name.padEnd(35)} ${file.padEnd(28)} ${role} ${score} ${fanIn} ${cog} ${churn} ${mi}`, - ); - } - - const s = data.summary; - console.log( - `\n ${s.analyzed} symbols scored (of ${s.total} total) | avg: ${s.avgScore.toFixed(2)} | max: ${s.maxScore.toFixed(2)} | sort: ${opts.sort || 'risk'}`, - ); - console.log(); -} - // ─── Utilities ──────────────────────────────────────────────────────── function round4(n) { diff --git a/src/viewer.js b/src/viewer.js index a123477f..c4a06b78 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 './test-filter.js'; +import { isTestFile } from './infrastructure/test-filter.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; From fe60bd2066d171685bd9b2936bc3d9aa911ca2ce Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:39:29 -0600 Subject: [PATCH 2/2] fix: remove unused command-runner.js, correct ROADMAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandRunner was created but never adopted by any command file — the 16 commands vary too much (async, multi-mode, process.exit) for a single lifecycle helper today. Remove dead code and mark as future work in the ROADMAP rather than claiming it as done. --- docs/roadmap/ROADMAP.md | 2 +- src/infrastructure/command-runner.js | 27 --------------------------- 2 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 src/infrastructure/command-runner.js diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 1f33e6c0..43ff87b0 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -613,9 +613,9 @@ Rewrite the CFG algorithm as a node-level visitor that builds basic blocks and e - ✅ Shared `result-formatter.js` (`outputResult` for JSON/NDJSON dispatch) - ✅ Shared `test-filter.js` (`isTestFile` predicate) - ✅ Extract CLI wrappers from remaining modules (audit, batch, check, cochange, communities, complexity, cfg, dataflow, flow, manifesto, owners, structure, triage, branch-compare, sequence) -- ✅ Introduce `CommandRunner` shared lifecycle (`src/infrastructure/command-runner.js`) - ✅ Per-command `src/commands/` directory structure (16 command files) - ✅ Move shared utilities to `src/infrastructure/` (result-formatter.js, test-filter.js) +- 🔲 Introduce `CommandRunner` shared lifecycle (command files vary too much for a single pattern today — revisit once commands stabilize) Eliminate the `*Data()` / `*()` dual-function pattern replicated across 19 modules. Every analysis module (queries, audit, batch, check, cochange, communities, complexity, cfg, dataflow, ast, flow, manifesto, owners, structure, triage, branch-compare, viewer) currently implements both data extraction AND CLI formatting. diff --git a/src/infrastructure/command-runner.js b/src/infrastructure/command-runner.js deleted file mode 100644 index 4b0c023d..00000000 --- a/src/infrastructure/command-runner.js +++ /dev/null @@ -1,27 +0,0 @@ -import { outputResult } from './result-formatter.js'; - -/** - * Run a command through the shared lifecycle: - * 1. Execute dataFn to get data - * 2. Try JSON/NDJSON output via outputResult() - * 3. Fall back to formatFn() for human-readable text - * 4. Handle exit codes for CI gate commands - * - * @param {Function} dataFn - Returns result data object - * @param {Function} formatFn - Formats data for human-readable output (receives data, returns string|void) - * @param {object} [opts] - * @param {string|null} [opts.ndjsonField] - Array field name for NDJSON streaming - * @param {boolean} [opts.exitOnFail] - Call process.exit(1) when data.passed === false - * @returns {object} The data object from dataFn - */ -export function runCommand(dataFn, formatFn, opts = {}) { - const data = dataFn(); - if (outputResult(data, opts.ndjsonField ?? null, opts)) { - if (opts.exitOnFail && data.passed === false) process.exit(1); - return data; - } - const text = formatFn(data); - if (text) console.log(text); - if (opts.exitOnFail && data.passed === false) process.exit(1); - return data; -}