diff --git a/src/cli/commands/ast.js b/src/cli/commands/ast.js index 1588804a..0ba10575 100644 --- a/src/cli/commands/ast.js +++ b/src/cli/commands/ast.js @@ -16,11 +16,7 @@ export const command = { astQuery(pattern, opts.db, { kind: opts.kind, file: opts.file, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - ndjson: opts.ndjson, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/audit.js b/src/cli/commands/audit.js index c52df18e..abc6db05 100644 --- a/src/cli/commands/audit.js +++ b/src/cli/commands/audit.js @@ -1,6 +1,7 @@ import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { audit } from '../../presentation/audit.js'; import { explain } from '../../presentation/queries-cli.js'; +import { config } from '../shared/options.js'; export const command = { name: 'audit ', @@ -24,14 +25,11 @@ export const command = { } }, execute([target], opts, ctx) { + const qOpts = ctx.resolveQueryOpts(opts); if (opts.quick) { explain(target, opts.db, { depth: parseInt(opts.depth, 10), - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...qOpts, }); return; } @@ -39,9 +37,9 @@ export const command = { depth: parseInt(opts.depth, 10), file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - config: ctx.config, + noTests: qOpts.noTests, + json: qOpts.json, + config, }); }, }; diff --git a/src/cli/commands/cfg.js b/src/cli/commands/cfg.js index 0ab00544..7a2fff03 100644 --- a/src/cli/commands/cfg.js +++ b/src/cli/commands/cfg.js @@ -20,11 +20,7 @@ export const command = { format: opts.format, file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - ndjson: opts.ndjson, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js index a85ca995..24cd9a63 100644 --- a/src/cli/commands/check.js +++ b/src/cli/commands/check.js @@ -1,5 +1,6 @@ import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { ConfigError } from '../../shared/errors.js'; +import { config } from '../shared/options.js'; export const command = { name: 'check [ref]', @@ -25,6 +26,7 @@ export const command = { ], async execute([ref], opts, ctx) { const isDiffMode = ref || opts.staged; + const qOpts = ctx.resolveQueryOpts(opts); if (!isDiffMode && !opts.rules) { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { @@ -36,12 +38,7 @@ export const command = { manifesto(opts.db, { file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - config: ctx.config, + ...qOpts, }); return; } @@ -55,9 +52,9 @@ export const command = { signatures: opts.signatures || undefined, boundaries: opts.boundaries || undefined, depth: opts.depth ? parseInt(opts.depth, 10) : undefined, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - config: ctx.config, + noTests: qOpts.noTests, + json: qOpts.json, + config, }); if (opts.rules) { @@ -70,12 +67,7 @@ export const command = { manifesto(opts.db, { file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - config: ctx.config, + ...qOpts, }); } }, diff --git a/src/cli/commands/children.js b/src/cli/commands/children.js index 3412dcf4..f6b2686c 100644 --- a/src/cli/commands/children.js +++ b/src/cli/commands/children.js @@ -22,10 +22,7 @@ export const command = { children(name, opts.db, { file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/communities.js b/src/cli/commands/communities.js index 0fa07d28..030d1ae4 100644 --- a/src/cli/commands/communities.js +++ b/src/cli/commands/communities.js @@ -13,11 +13,7 @@ export const command = { functions: opts.functions, resolution: parseFloat(opts.resolution), drift: opts.drift, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/context.js b/src/cli/commands/context.js index 7a4e53da..16210f55 100644 --- a/src/cli/commands/context.js +++ b/src/cli/commands/context.js @@ -23,12 +23,8 @@ export const command = { file: opts.file, kind: opts.kind, noSource: !opts.source, - noTests: ctx.resolveNoTests(opts), includeTests: opts.withTestSource, - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/cycles.js b/src/cli/commands/cycles.js index 614473af..1f9e336f 100644 --- a/src/cli/commands/cycles.js +++ b/src/cli/commands/cycles.js @@ -1,5 +1,5 @@ -import { openReadonlyOrFail } from '../../db/index.js'; import { findCycles, formatCycles } from '../../domain/graph/cycles.js'; +import { openGraph } from '../shared/open-graph.js'; export const command = { name: 'cycles', @@ -12,12 +12,16 @@ export const command = { ['-j, --json', 'Output as JSON'], ], execute(_args, opts, ctx) { - const db = openReadonlyOrFail(opts.db); - const cycles = findCycles(db, { - fileLevel: !opts.functions, - noTests: ctx.resolveNoTests(opts), - }); - db.close(); + const { db, close } = openGraph(opts); + let cycles; + try { + cycles = findCycles(db, { + fileLevel: !opts.functions, + noTests: ctx.resolveNoTests(opts), + }); + } finally { + close(); + } if (opts.json) { console.log(JSON.stringify({ cycles, count: cycles.length }, null, 2)); diff --git a/src/cli/commands/dataflow.js b/src/cli/commands/dataflow.js index c32dde63..7c22f087 100644 --- a/src/cli/commands/dataflow.js +++ b/src/cli/commands/dataflow.js @@ -20,13 +20,9 @@ export const command = { dataflow(name, opts.db, { file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - ndjson: opts.ndjson, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, impact: opts.impact, depth: parseInt(opts.depth, 10), + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/deps.js b/src/cli/commands/deps.js index a7644eff..fc3194f5 100644 --- a/src/cli/commands/deps.js +++ b/src/cli/commands/deps.js @@ -6,11 +6,7 @@ export const command = { queryOpts: true, execute([file], opts, ctx) { fileDeps(file, opts.db, { - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/diff-impact.js b/src/cli/commands/diff-impact.js index 4cc5e253..49456dba 100644 --- a/src/cli/commands/diff-impact.js +++ b/src/cli/commands/diff-impact.js @@ -19,13 +19,8 @@ export const command = { ref, staged: opts.staged, depth: parseInt(opts.depth, 10), - noTests: ctx.resolveNoTests(opts), - json: opts.json, format: opts.format, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - config: ctx.config, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/export.js b/src/cli/commands/export.js index fd69c51a..213f39c8 100644 --- a/src/cli/commands/export.js +++ b/src/cli/commands/export.js @@ -1,5 +1,4 @@ import fs from 'node:fs'; -import { openReadonlyOrFail } from '../../db/index.js'; import { exportDOT, exportGraphML, @@ -8,6 +7,7 @@ import { exportMermaid, exportNeo4jCSV, } from '../../features/export.js'; +import { openGraph } from '../shared/open-graph.js'; export const command = { name: 'export', @@ -23,7 +23,7 @@ export const command = { ['-o, --output ', 'Write to file instead of stdout'], ], execute(_args, opts, ctx) { - const db = openReadonlyOrFail(opts.db); + const { db, close } = openGraph(opts); const exportOpts = { fileLevel: !opts.functions, noTests: ctx.resolveNoTests(opts), @@ -32,38 +32,41 @@ export const command = { }; let output; - switch (opts.format) { - case 'mermaid': - output = exportMermaid(db, exportOpts); - break; - case 'json': - output = JSON.stringify(exportJSON(db, exportOpts), null, 2); - break; - case 'graphml': - output = exportGraphML(db, exportOpts); - break; - case 'graphson': - output = JSON.stringify(exportGraphSON(db, exportOpts), null, 2); - break; - case 'neo4j': { - const csv = exportNeo4jCSV(db, exportOpts); - if (opts.output) { - const base = opts.output.replace(/\.[^.]+$/, '') || opts.output; - fs.writeFileSync(`${base}-nodes.csv`, csv.nodes, 'utf-8'); - fs.writeFileSync(`${base}-relationships.csv`, csv.relationships, 'utf-8'); - db.close(); - console.log(`Exported to ${base}-nodes.csv and ${base}-relationships.csv`); - return; + try { + switch (opts.format) { + case 'mermaid': + output = exportMermaid(db, exportOpts); + break; + case 'json': + output = JSON.stringify(exportJSON(db, exportOpts), null, 2); + break; + case 'graphml': + output = exportGraphML(db, exportOpts); + break; + case 'graphson': + output = JSON.stringify(exportGraphSON(db, exportOpts), null, 2); + break; + case 'neo4j': { + const csv = exportNeo4jCSV(db, exportOpts); + if (opts.output) { + const base = opts.output.replace(/\.[^.]+$/, '') || opts.output; + fs.writeFileSync(`${base}-nodes.csv`, csv.nodes, 'utf-8'); + fs.writeFileSync(`${base}-relationships.csv`, csv.relationships, 'utf-8'); + console.log(`Exported to ${base}-nodes.csv and ${base}-relationships.csv`); + } else { + output = `--- nodes.csv ---\n${csv.nodes}\n\n--- relationships.csv ---\n${csv.relationships}`; + } + break; } - output = `--- nodes.csv ---\n${csv.nodes}\n\n--- relationships.csv ---\n${csv.relationships}`; - break; + default: + output = exportDOT(db, exportOpts); + break; } - default: - output = exportDOT(db, exportOpts); - break; + } finally { + close(); } - db.close(); + if (output === undefined) return; if (opts.output) { fs.writeFileSync(opts.output, output, 'utf-8'); diff --git a/src/cli/commands/exports.js b/src/cli/commands/exports.js index 2334f1ba..7983a7eb 100644 --- a/src/cli/commands/exports.js +++ b/src/cli/commands/exports.js @@ -7,12 +7,8 @@ export const command = { options: [['--unused', 'Show only exports with zero consumers (dead exports)']], execute([file], opts, ctx) { fileExports(file, opts.db, { - noTests: ctx.resolveNoTests(opts), - json: opts.json, unused: opts.unused || false, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/flow.js b/src/cli/commands/flow.js index 90c2c424..b281d302 100644 --- a/src/cli/commands/flow.js +++ b/src/cli/commands/flow.js @@ -26,11 +26,7 @@ export const command = { depth: parseInt(opts.depth, 10), file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/fn-impact.js b/src/cli/commands/fn-impact.js index 5c64831f..7715e031 100644 --- a/src/cli/commands/fn-impact.js +++ b/src/cli/commands/fn-impact.js @@ -20,11 +20,7 @@ export const command = { depth: parseInt(opts.depth, 10), file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/impact.js b/src/cli/commands/impact.js index fa4c5585..885704c3 100644 --- a/src/cli/commands/impact.js +++ b/src/cli/commands/impact.js @@ -6,11 +6,7 @@ export const command = { queryOpts: true, execute([file], opts, ctx) { impactAnalysis(file, opts.db, { - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/plot.js b/src/cli/commands/plot.js index 030d00c8..8624e9d8 100644 --- a/src/cli/commands/plot.js +++ b/src/cli/commands/plot.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { openReadonlyOrFail } from '../../db/index.js'; +import { openGraph } from '../shared/open-graph.js'; export const command = { name: 'plot', @@ -24,41 +24,50 @@ export const command = { async execute(_args, opts, ctx) { const { generatePlotHTML, loadPlotConfig } = await import('../../features/graph-enrichment.js'); const os = await import('node:os'); - const db = openReadonlyOrFail(opts.db); + const { db, close } = openGraph(opts); let plotCfg; - if (opts.config) { - try { - plotCfg = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); - } catch (e) { - console.error(`Failed to load config: ${e.message}`); - db.close(); - process.exitCode = 1; - return; + let html; + try { + if (opts.config) { + try { + plotCfg = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); + } catch (e) { + console.error(`Failed to load config: ${e.message}`); + process.exitCode = 1; + return; + } + } else { + plotCfg = loadPlotConfig(process.cwd()); + } + + if (opts.cluster) plotCfg.clusterBy = opts.cluster; + if (opts.colorBy) plotCfg.colorBy = opts.colorBy; + if (opts.sizeBy) plotCfg.sizeBy = opts.sizeBy; + if (opts.seed) plotCfg.seedStrategy = opts.seed; + if (opts.seedCount) plotCfg.seedCount = parseInt(opts.seedCount, 10); + if (opts.overlay) { + const parts = opts.overlay.split(',').map((s) => s.trim()); + if (!plotCfg.overlays) plotCfg.overlays = {}; + if (parts.includes('complexity')) plotCfg.overlays.complexity = true; + if (parts.includes('risk')) plotCfg.overlays.risk = true; } - } else { - plotCfg = loadPlotConfig(process.cwd()); - } - if (opts.cluster) plotCfg.clusterBy = opts.cluster; - if (opts.colorBy) plotCfg.colorBy = opts.colorBy; - if (opts.sizeBy) plotCfg.sizeBy = opts.sizeBy; - if (opts.seed) plotCfg.seedStrategy = opts.seed; - if (opts.seedCount) plotCfg.seedCount = parseInt(opts.seedCount, 10); - if (opts.overlay) { - const parts = opts.overlay.split(',').map((s) => s.trim()); - if (!plotCfg.overlays) plotCfg.overlays = {}; - if (parts.includes('complexity')) plotCfg.overlays.complexity = true; - if (parts.includes('risk')) plotCfg.overlays.risk = true; + html = generatePlotHTML(db, { + fileLevel: !opts.functions, + noTests: ctx.resolveNoTests(opts), + minConfidence: parseFloat(opts.minConfidence), + config: plotCfg, + }); + } finally { + close(); } - const html = generatePlotHTML(db, { - fileLevel: !opts.functions, - noTests: ctx.resolveNoTests(opts), - minConfidence: parseFloat(opts.minConfidence), - config: plotCfg, - }); - db.close(); + if (!html) { + console.error('generatePlotHTML returned no output'); + process.exitCode = 1; + return; + } const outPath = opts.output || path.join(os.tmpdir(), `codegraph-plot-${Date.now()}.html`); fs.writeFileSync(outPath, html, 'utf-8'); diff --git a/src/cli/commands/query.js b/src/cli/commands/query.js index ac758116..f23edcf5 100644 --- a/src/cli/commands/query.js +++ b/src/cli/commands/query.js @@ -38,11 +38,7 @@ export const command = { depth: parseInt(opts.depth, 10), file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); } }, diff --git a/src/cli/commands/roles.js b/src/cli/commands/roles.js index 0380bee4..df756333 100644 --- a/src/cli/commands/roles.js +++ b/src/cli/commands/roles.js @@ -24,11 +24,7 @@ export const command = { roles(opts.db, { role: opts.role, file: opts.file, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/sequence.js b/src/cli/commands/sequence.js index 6daa0a6c..6bb1e167 100644 --- a/src/cli/commands/sequence.js +++ b/src/cli/commands/sequence.js @@ -21,12 +21,8 @@ export const command = { depth: parseInt(opts.depth, 10), file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, dataflow: opts.dataflow, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/structure.js b/src/cli/commands/structure.js index bc72aa07..c3bca706 100644 --- a/src/cli/commands/structure.js +++ b/src/cli/commands/structure.js @@ -16,14 +16,15 @@ export const command = { ], async execute([dir], opts, ctx) { const { structureData, formatStructure } = await import('../../presentation/structure.js'); + const qOpts = ctx.resolveQueryOpts(opts); const data = structureData(opts.db, { directory: dir, depth: opts.depth ? parseInt(opts.depth, 10) : undefined, sort: opts.sort, full: opts.full, - noTests: ctx.resolveNoTests(opts), - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + noTests: qOpts.noTests, + limit: qOpts.limit, + offset: qOpts.offset, }); if (!ctx.outputResult(data, 'directories', opts)) { console.log(formatStructure(data)); diff --git a/src/cli/commands/where.js b/src/cli/commands/where.js index 9f0d37a9..e3824eb1 100644 --- a/src/cli/commands/where.js +++ b/src/cli/commands/where.js @@ -14,11 +14,7 @@ export const command = { const target = opts.file || name; where(target, opts.db, { file: !!opts.file, - noTests: ctx.resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/index.js b/src/cli/index.js index 057bae31..fce95998 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -5,7 +5,13 @@ import { Command } from 'commander'; import { setVerbose } from '../infrastructure/logger.js'; import { checkForUpdates, printUpdateNotification } from '../infrastructure/update-check.js'; import { ConfigError } from '../shared/errors.js'; -import { applyQueryOpts, config, formatSize, resolveNoTests } from './shared/options.js'; +import { + applyQueryOpts, + config, + formatSize, + resolveNoTests, + resolveQueryOpts, +} from './shared/options.js'; import { outputResult } from './shared/output.js'; const __cliDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1')); @@ -35,7 +41,7 @@ program }); /** Shared context passed to every command's execute(). */ -const ctx = { config, resolveNoTests, formatSize, outputResult, program }; +const ctx = { config, resolveNoTests, resolveQueryOpts, formatSize, outputResult, program }; /** * Register a command definition onto a Commander parent. diff --git a/src/cli/shared/open-graph.js b/src/cli/shared/open-graph.js new file mode 100644 index 00000000..3f6e171d --- /dev/null +++ b/src/cli/shared/open-graph.js @@ -0,0 +1,13 @@ +import { openReadonlyOrFail } from '../../db/index.js'; + +/** + * Open the graph database in readonly mode with a clean close() handle. + * + * @param {object} [opts] + * @param {string} [opts.db] - Custom path to graph.db + * @returns {{ db: import('better-sqlite3').Database, close: () => void }} + */ +export function openGraph(opts = {}) { + const db = openReadonlyOrFail(opts.db); + return { db, close: () => db.close() }; +} diff --git a/src/cli/shared/options.js b/src/cli/shared/options.js index 7f7552c3..13711a14 100644 --- a/src/cli/shared/options.js +++ b/src/cli/shared/options.js @@ -15,7 +15,9 @@ export function applyQueryOpts(cmd) { .option('-j, --json', 'Output as JSON') .option('--limit ', 'Max results to return') .option('--offset ', 'Skip N results (default: 0)') - .option('--ndjson', 'Newline-delimited JSON output'); + .option('--ndjson', 'Newline-delimited JSON output') + .option('--table', 'Output as aligned table') + .option('--csv', 'Output as CSV'); } /** @@ -30,6 +32,24 @@ export function resolveNoTests(opts) { return config.query?.excludeTests || false; } +/** + * Extract the common query option fields shared by most analysis commands. + * + * Spreads cleanly into per-command option objects: + * `{ ...resolveQueryOpts(opts), depth: parseInt(opts.depth, 10) }` + */ +export function resolveQueryOpts(opts) { + return { + noTests: resolveNoTests(opts), + json: opts.json, + ndjson: opts.ndjson, + table: opts.table, + csv: opts.csv, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + }; +} + export function formatSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 389df681..7565115b 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -1,11 +1,128 @@ import { printNdjson } from '../shared/paginate.js'; +import { formatTable, truncEnd } from './table.js'; /** - * Shared JSON / NDJSON output dispatch for CLI wrappers. + * Flatten a nested object into dot-notation keys. + * Arrays are JSON-stringified; nested objects are recursed. + * + * Note: this assumes input objects do not contain literal dot-notation keys + * (e.g. `{ "a.b": 1 }`). If they do, flattened keys will silently collide + * with nested paths (e.g. `{ a: { b: 2 } }` also produces `"a.b"`). + */ +function flattenObject(obj, prefix = '') { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) { + Object.assign(result, flattenObject(value, fullKey)); + } else if (Array.isArray(value)) { + result[fullKey] = JSON.stringify(value); + } else { + result[fullKey] = value; + } + } + return result; +} + +/** + * Flatten items array and derive column names. + * Shared by printCsv and printAutoTable. + * @returns {{ flatItems: object[], columns: string[] } | null} + */ +function prepareFlatItems(data, field) { + const items = field ? data[field] : data; + if (!Array.isArray(items)) return null; + + const flatItems = items.map((item) => + typeof item === 'object' && item !== null && !Array.isArray(item) + ? flattenObject(item) + : { value: item }, + ); + const columns = (() => { + const keys = new Set(); + for (const item of flatItems) for (const key of Object.keys(item)) keys.add(key); + return [...keys]; + })(); + if (columns.length === 0) columns.push('value'); + + return { flatItems, columns }; +} + +/** Escape a value for CSV output (LF line endings). */ +function escapeCsv(val) { + const str = val == null ? '' : String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +/** + * Print data as CSV to stdout. + * @param {object} data - Result object from a *Data() function + * @param {string} field - Array field name (e.g. 'results') + */ +function printCsv(data, field) { + const prepared = prepareFlatItems(data, field); + if (!prepared) return false; + const { flatItems, columns } = prepared; + + console.log(columns.map(escapeCsv).join(',')); + for (const row of flatItems) { + console.log(columns.map((col) => escapeCsv(row[col])).join(',')); + } + return true; +} + +const MAX_COL_WIDTH = 40; + +/** + * Print data as an aligned table to stdout. + * @param {object} data - Result object from a *Data() function + * @param {string} field - Array field name (e.g. 'results') + */ +function printAutoTable(data, field) { + const prepared = prepareFlatItems(data, field); + if (!prepared) return false; + const { flatItems, columns } = prepared; + + const colDefs = columns.map((col) => { + const maxLen = flatItems.reduce( + (max, item) => Math.max(max, String(item[col] ?? '').length), + col.length, + ); + const isNumeric = + flatItems.length > 0 && + flatItems.every((item) => { + const v = item[col]; + return v == null || v === '' || (typeof v !== 'boolean' && Number.isFinite(Number(v))); + }); + return { + header: col, + width: Math.min(maxLen, MAX_COL_WIDTH), + align: isNumeric ? 'right' : 'left', + }; + }); + + const rows = flatItems.map((item) => + columns.map((col) => truncEnd(String(item[col] ?? ''), MAX_COL_WIDTH)), + ); + + console.log(formatTable({ columns: colDefs, rows })); + return true; +} + +/** + * Shared JSON / NDJSON / table / CSV output dispatch for CLI wrappers. * * @param {object} data - Result object from a *Data() function * @param {string} field - Array field name for NDJSON streaming (e.g. 'results') - * @param {object} opts - CLI options ({ json?, ndjson? }) + * @param {object} opts - CLI options ({ json?, ndjson?, table?, csv? }) * @returns {boolean} true if output was handled (caller should return early) */ export function outputResult(data, field, opts) { @@ -17,5 +134,11 @@ export function outputResult(data, field, opts) { console.log(JSON.stringify(data, null, 2)); return true; } + if (opts.csv) { + return printCsv(data, field) !== false; + } + if (opts.table) { + return printAutoTable(data, field) !== false; + } return false; }