From b00a6e9a4745e27dcb73687501652c17174437c3 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:51:08 -0600 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20add=20cli=20composability=20helpe?= =?UTF-8?q?rs=20=E2=80=94=20openGraph=20helper,=20resolveQueryOpts,=20univ?= =?UTF-8?q?ersal=20output=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add openGraph(opts) helper to eliminate DB-open/close boilerplate in cycles, export, and plot commands. Add resolveQueryOpts(opts) to extract the 5 repeated option fields (noTests, json, ndjson, limit, offset) plus new table/csv into one call. Refactor 20 command files to use the spread pattern. Extend outputResult() with --table (auto-column aligned table) and --csv (RFC 4180 with nested object flattening) output formats. Add --table and --csv options to applyQueryOpts(). Impact: 31 functions changed, 71 affected --- src/cli/commands/ast.js | 6 +- src/cli/commands/audit.js | 11 +-- src/cli/commands/cfg.js | 6 +- src/cli/commands/check.js | 17 ++-- src/cli/commands/children.js | 5 +- src/cli/commands/communities.js | 6 +- src/cli/commands/context.js | 6 +- src/cli/commands/cycles.js | 6 +- src/cli/commands/dataflow.js | 6 +- src/cli/commands/deps.js | 6 +- src/cli/commands/diff-impact.js | 6 +- src/cli/commands/export.js | 8 +- src/cli/commands/exports.js | 6 +- src/cli/commands/flow.js | 6 +- src/cli/commands/fn-impact.js | 6 +- src/cli/commands/impact.js | 6 +- src/cli/commands/plot.js | 8 +- src/cli/commands/query.js | 6 +- src/cli/commands/roles.js | 6 +- src/cli/commands/sequence.js | 6 +- src/cli/commands/structure.js | 7 +- src/cli/commands/where.js | 6 +- src/cli/index.js | 10 ++- src/cli/shared/open-graph.js | 13 ++++ src/cli/shared/options.js | 22 +++++- src/presentation/result-formatter.js | 111 ++++++++++++++++++++++++++- 26 files changed, 191 insertions(+), 117 deletions(-) create mode 100644 src/cli/shared/open-graph.js 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 caa0b747..35809326 100644 --- a/src/cli/commands/audit.js +++ b/src/cli/commands/audit.js @@ -24,14 +24,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,8 +36,8 @@ export const command = { depth: parseInt(opts.depth, 10), file: opts.file, kind: opts.kind, - noTests: ctx.resolveNoTests(opts), - json: opts.json, + noTests: qOpts.noTests, + json: qOpts.json, }); }, }; diff --git a/src/cli/commands/cfg.js b/src/cli/commands/cfg.js index 20ef05bb..b0f99c28 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 8c5f29ca..d37b2958 100644 --- a/src/cli/commands/check.js +++ b/src/cli/commands/check.js @@ -25,6 +25,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,11 +37,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, + ...qOpts, }); return; } @@ -54,8 +51,8 @@ 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, + noTests: qOpts.noTests, + json: qOpts.json, }); if (opts.rules) { @@ -68,11 +65,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, + ...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 42312b06..24d60b0e 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..c30ed1fd 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,12 @@ export const command = { ['-j, --json', 'Output as JSON'], ], execute(_args, opts, ctx) { - const db = openReadonlyOrFail(opts.db); + const { db, close } = openGraph(opts); const cycles = findCycles(db, { fileLevel: !opts.functions, noTests: ctx.resolveNoTests(opts), }); - db.close(); + 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 4118b5b3..1f57eafd 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 15fd67c9..49456dba 100644 --- a/src/cli/commands/diff-impact.js +++ b/src/cli/commands/diff-impact.js @@ -19,12 +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, + ...ctx.resolveQueryOpts(opts), }); }, }; diff --git a/src/cli/commands/export.js b/src/cli/commands/export.js index fd69c51a..d1979f73 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), @@ -51,7 +51,7 @@ export const command = { 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(); + close(); console.log(`Exported to ${base}-nodes.csv and ${base}-relationships.csv`); return; } @@ -63,7 +63,7 @@ export const command = { break; } - db.close(); + close(); 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 20814a3c..2a28cee9 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 8dafe532..dd4c4ff1 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,7 +24,7 @@ export const command = { async execute(_args, opts, ctx) { const { generatePlotHTML, loadPlotConfig } = await import('../../viewer.js'); const os = await import('node:os'); - const db = openReadonlyOrFail(opts.db); + const { db, close } = openGraph(opts); let plotCfg; if (opts.config) { @@ -32,7 +32,7 @@ export const command = { plotCfg = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); } catch (e) { console.error(`Failed to load config: ${e.message}`); - db.close(); + close(); process.exitCode = 1; return; } @@ -58,7 +58,7 @@ export const command = { minConfidence: parseFloat(opts.minConfidence), config: plotCfg, }); - db.close(); + close(); 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 343a9fac..156fd636 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 1e469f54..4673f62b 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('../../commands/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..b4475b4a 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -1,11 +1,110 @@ 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. + */ +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.assign(result, flattenObject(value, fullKey)); + } else if (Array.isArray(value)) { + result[fullKey] = JSON.stringify(value); + } else { + result[fullKey] = value; + } + } + return result; +} + +/** + * Auto-detect column keys from an array of objects. + * Returns stable insertion-order keys across all items. + */ +function autoColumns(items) { + const keys = new Set(); + for (const item of items) { + for (const key of Object.keys(flattenObject(item))) keys.add(key); + } + return [...keys]; +} + +/** Escape a value for RFC 4180 CSV output. */ +function escapeCsv(val) { + const str = val == null ? '' : String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + 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 items = field ? data[field] : data; + if (!Array.isArray(items) || items.length === 0) return; + + const flatItems = items.map((item) => + typeof item === 'object' && item !== null ? flattenObject(item) : { value: item }, + ); + const columns = autoColumns(items.filter((i) => typeof i === 'object' && i !== null)); + if (columns.length === 0) columns.push('value'); + + console.log(columns.map(escapeCsv).join(',')); + for (const row of flatItems) { + console.log(columns.map((col) => escapeCsv(row[col])).join(',')); + } +} + +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 items = field ? data[field] : data; + if (!Array.isArray(items) || items.length === 0) return; + + const flatItems = items.map((item) => + typeof item === 'object' && item !== null ? flattenObject(item) : { value: item }, + ); + const columns = autoColumns(items.filter((i) => typeof i === 'object' && i !== null)); + if (columns.length === 0) columns.push('value'); + + const colDefs = columns.map((col) => { + const maxLen = Math.max(col.length, ...flatItems.map((item) => String(item[col] ?? '').length)); + const isNumeric = flatItems.every((item) => { + const v = item[col]; + return v == null || v === '' || 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 })); +} + +/** + * 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 +116,13 @@ export function outputResult(data, field, opts) { console.log(JSON.stringify(data, null, 2)); return true; } + if (opts.csv) { + printCsv(data, field); + return true; + } + if (opts.table) { + printAutoTable(data, field); + return true; + } return false; } From a28cf6f3260a1a3fb5c9319375e2fb014bb17877 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:07:44 -0600 Subject: [PATCH 02/13] fix: escapeCsv RFC 4180 compliance and column detection from flattened items Impact: 3 functions changed, 2 affected --- src/presentation/result-formatter.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index b4475b4a..f4e8ff73 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -35,7 +35,7 @@ function autoColumns(items) { /** Escape a value for RFC 4180 CSV output. */ function escapeCsv(val) { const str = val == null ? '' : String(val); - if (str.includes(',') || str.includes('"') || str.includes('\n')) { + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { return `"${str.replace(/"/g, '""')}"`; } return str; @@ -53,7 +53,7 @@ function printCsv(data, field) { const flatItems = items.map((item) => typeof item === 'object' && item !== null ? flattenObject(item) : { value: item }, ); - const columns = autoColumns(items.filter((i) => typeof i === 'object' && i !== null)); + const columns = autoColumns(flatItems); if (columns.length === 0) columns.push('value'); console.log(columns.map(escapeCsv).join(',')); @@ -76,7 +76,7 @@ function printAutoTable(data, field) { const flatItems = items.map((item) => typeof item === 'object' && item !== null ? flattenObject(item) : { value: item }, ); - const columns = autoColumns(items.filter((i) => typeof i === 'object' && i !== null)); + const columns = autoColumns(flatItems); if (columns.length === 0) columns.push('value'); const colDefs = columns.map((col) => { From 323e7046aebf1886f38d74b1e112c1198c7c0c41 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:15:55 -0600 Subject: [PATCH 03/13] fix: use reduce instead of spread for column width to avoid stack overflow on large sets Impact: 1 functions changed, 1 affected --- src/presentation/result-formatter.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index f4e8ff73..af4936ff 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -80,7 +80,10 @@ function printAutoTable(data, field) { if (columns.length === 0) columns.push('value'); const colDefs = columns.map((col) => { - const maxLen = Math.max(col.length, ...flatItems.map((item) => String(item[col] ?? '').length)); + const maxLen = flatItems.reduce( + (max, item) => Math.max(max, String(item[col] ?? '').length), + col.length, + ); const isNumeric = flatItems.every((item) => { const v = item[col]; return v == null || v === '' || Number.isFinite(Number(v)); From 280acc9e23660bd905386db8ec0f99f294ac289c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:42:23 -0600 Subject: [PATCH 04/13] fix: remove redundant double-flattening and tone down RFC 4180 claim Impact: 2 functions changed, 1 affected --- src/presentation/result-formatter.js | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index af4936ff..b2249623 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -20,19 +20,7 @@ function flattenObject(obj, prefix = '') { return result; } -/** - * Auto-detect column keys from an array of objects. - * Returns stable insertion-order keys across all items. - */ -function autoColumns(items) { - const keys = new Set(); - for (const item of items) { - for (const key of Object.keys(flattenObject(item))) keys.add(key); - } - return [...keys]; -} - -/** Escape a value for RFC 4180 CSV output. */ +/** 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')) { @@ -53,7 +41,11 @@ function printCsv(data, field) { const flatItems = items.map((item) => typeof item === 'object' && item !== null ? flattenObject(item) : { value: item }, ); - const columns = autoColumns(flatItems); + 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'); console.log(columns.map(escapeCsv).join(',')); @@ -76,7 +68,11 @@ function printAutoTable(data, field) { const flatItems = items.map((item) => typeof item === 'object' && item !== null ? flattenObject(item) : { value: item }, ); - const columns = autoColumns(flatItems); + 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'); const colDefs = columns.map((col) => { From d958251e0638b4f18589fbebc11a18b40fe47984 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:56:47 -0600 Subject: [PATCH 05/13] fix: guard flattenObject against non-POJO objects like Date and RegExp Impact: 1 functions changed, 3 affected --- src/presentation/result-formatter.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index b2249623..98c10b61 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -9,7 +9,12 @@ 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)) { + 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); From 026d923424818b1e862a688806b9784b9c53ddcf Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:12:57 -0600 Subject: [PATCH 06/13] fix: exclude booleans from numeric column detection and add try/finally for close() - result-formatter: skip boolean values in numeric column heuristic (Number(true) === 1 was misclassifying boolean columns as numeric) - cycles/export/plot commands: wrap DB operations in try/finally to ensure close() is called even if an export function throws Impact: 4 functions changed, 1 affected --- src/cli/commands/cycles.js | 14 ++++--- src/cli/commands/export.js | 59 ++++++++++++++------------- src/cli/commands/plot.js | 61 +++++++++++++++------------- src/presentation/result-formatter.js | 2 +- 4 files changed, 72 insertions(+), 64 deletions(-) diff --git a/src/cli/commands/cycles.js b/src/cli/commands/cycles.js index c30ed1fd..1f9e336f 100644 --- a/src/cli/commands/cycles.js +++ b/src/cli/commands/cycles.js @@ -13,11 +13,15 @@ export const command = { ], execute(_args, opts, ctx) { const { db, close } = openGraph(opts); - const cycles = findCycles(db, { - fileLevel: !opts.functions, - noTests: ctx.resolveNoTests(opts), - }); - close(); + 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/export.js b/src/cli/commands/export.js index d1979f73..2cafed82 100644 --- a/src/cli/commands/export.js +++ b/src/cli/commands/export.js @@ -32,39 +32,40 @@ 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'); - 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`); + return; + } + 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(); } - close(); - if (opts.output) { fs.writeFileSync(opts.output, output, 'utf-8'); console.log(`Exported ${opts.format} to ${opts.output}`); diff --git a/src/cli/commands/plot.js b/src/cli/commands/plot.js index 58a71ced..8ae0810f 100644 --- a/src/cli/commands/plot.js +++ b/src/cli/commands/plot.js @@ -27,38 +27,41 @@ export const command = { 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}`); - 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()); } - } 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; - } + 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; + } - const html = generatePlotHTML(db, { - fileLevel: !opts.functions, - noTests: ctx.resolveNoTests(opts), - minConfidence: parseFloat(opts.minConfidence), - config: plotCfg, - }); - close(); + html = generatePlotHTML(db, { + fileLevel: !opts.functions, + noTests: ctx.resolveNoTests(opts), + minConfidence: parseFloat(opts.minConfidence), + config: plotCfg, + }); + } finally { + close(); + } const outPath = opts.output || path.join(os.tmpdir(), `codegraph-plot-${Date.now()}.html`); fs.writeFileSync(outPath, html, 'utf-8'); diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 98c10b61..2f0da305 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -87,7 +87,7 @@ function printAutoTable(data, field) { ); const isNumeric = flatItems.every((item) => { const v = item[col]; - return v == null || v === '' || Number.isFinite(Number(v)); + return v == null || v === '' || (typeof v !== 'boolean' && Number.isFinite(Number(v))); }); return { header: col, From 57bab899fb2d242505fae5dd9b7dc7200df6550a Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:23:19 -0600 Subject: [PATCH 07/13] fix: guard top-level arrays in flattenObject and emit CSV header for empty sets Impact: 2 functions changed, 1 affected --- src/presentation/result-formatter.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 2f0da305..4439d39b 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -41,10 +41,12 @@ function escapeCsv(val) { */ function printCsv(data, field) { const items = field ? data[field] : data; - if (!Array.isArray(items) || items.length === 0) return; + if (!Array.isArray(items)) return; const flatItems = items.map((item) => - typeof item === 'object' && item !== null ? flattenObject(item) : { value: item }, + typeof item === 'object' && item !== null && !Array.isArray(item) + ? flattenObject(item) + : { value: item }, ); const columns = (() => { const keys = new Set(); @@ -71,7 +73,9 @@ function printAutoTable(data, field) { if (!Array.isArray(items) || items.length === 0) return; const flatItems = items.map((item) => - typeof item === 'object' && item !== null ? flattenObject(item) : { value: item }, + typeof item === 'object' && item !== null && !Array.isArray(item) + ? flattenObject(item) + : { value: item }, ); const columns = (() => { const keys = new Set(); From 263ebe025ba3c193ebde36888ee96cb3090a23c6 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:37:58 -0600 Subject: [PATCH 08/13] fix: emit table header for empty result sets consistent with CSV path Impact: 1 functions changed, 1 affected --- src/presentation/result-formatter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 4439d39b..83af8e54 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -70,7 +70,7 @@ const MAX_COL_WIDTH = 40; */ function printAutoTable(data, field) { const items = field ? data[field] : data; - if (!Array.isArray(items) || items.length === 0) return; + if (!Array.isArray(items)) return; const flatItems = items.map((item) => typeof item === 'object' && item !== null && !Array.isArray(item) From bf6f551066714c8933fd2fd1c4e5aa18b64fe457 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:55:59 -0600 Subject: [PATCH 09/13] fix: address Greptile review feedback on PR #461 - Make printCsv/printAutoTable return false when items is not an array, and propagate that in outputResult so callers see the failure - Remove config from resolveQueryOpts spread; audit and check now import config directly, other consumers use their existing loadConfig fallback - Restructure export neo4j branch to avoid return inside try/finally; use output===undefined guard after the block instead --- src/cli/commands/audit.js | 3 ++- src/cli/commands/check.js | 3 ++- src/cli/commands/export.js | 6 ++++-- src/cli/shared/options.js | 1 - src/presentation/result-formatter.js | 12 ++++++------ 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/audit.js b/src/cli/commands/audit.js index b687367e..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 ', @@ -38,7 +39,7 @@ export const command = { kind: opts.kind, noTests: qOpts.noTests, json: qOpts.json, - config: qOpts.config, + config, }); }, }; diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js index 580c488f..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]', @@ -53,7 +54,7 @@ export const command = { depth: opts.depth ? parseInt(opts.depth, 10) : undefined, noTests: qOpts.noTests, json: qOpts.json, - config: qOpts.config, + config, }); if (opts.rules) { diff --git a/src/cli/commands/export.js b/src/cli/commands/export.js index 2cafed82..213f39c8 100644 --- a/src/cli/commands/export.js +++ b/src/cli/commands/export.js @@ -53,9 +53,9 @@ export const command = { 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`); - return; + } else { + output = `--- nodes.csv ---\n${csv.nodes}\n\n--- relationships.csv ---\n${csv.relationships}`; } - output = `--- nodes.csv ---\n${csv.nodes}\n\n--- relationships.csv ---\n${csv.relationships}`; break; } default: @@ -66,6 +66,8 @@ export const command = { close(); } + if (output === undefined) return; + if (opts.output) { fs.writeFileSync(opts.output, output, 'utf-8'); console.log(`Exported ${opts.format} to ${opts.output}`); diff --git a/src/cli/shared/options.js b/src/cli/shared/options.js index 618b067a..13711a14 100644 --- a/src/cli/shared/options.js +++ b/src/cli/shared/options.js @@ -47,7 +47,6 @@ export function resolveQueryOpts(opts) { csv: opts.csv, limit: opts.limit ? parseInt(opts.limit, 10) : undefined, offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - config, }; } diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 83af8e54..0b0a7fcd 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -41,7 +41,7 @@ function escapeCsv(val) { */ function printCsv(data, field) { const items = field ? data[field] : data; - if (!Array.isArray(items)) return; + if (!Array.isArray(items)) return false; const flatItems = items.map((item) => typeof item === 'object' && item !== null && !Array.isArray(item) @@ -59,6 +59,7 @@ function printCsv(data, field) { for (const row of flatItems) { console.log(columns.map((col) => escapeCsv(row[col])).join(',')); } + return true; } const MAX_COL_WIDTH = 40; @@ -70,7 +71,7 @@ const MAX_COL_WIDTH = 40; */ function printAutoTable(data, field) { const items = field ? data[field] : data; - if (!Array.isArray(items)) return; + if (!Array.isArray(items)) return false; const flatItems = items.map((item) => typeof item === 'object' && item !== null && !Array.isArray(item) @@ -105,6 +106,7 @@ function printAutoTable(data, field) { ); console.log(formatTable({ columns: colDefs, rows })); + return true; } /** @@ -125,12 +127,10 @@ export function outputResult(data, field, opts) { return true; } if (opts.csv) { - printCsv(data, field); - return true; + return printCsv(data, field) !== false; } if (opts.table) { - printAutoTable(data, field); - return true; + return printAutoTable(data, field) !== false; } return false; } From f4256184ebc1c020f8c16f5af034efb99dcfc396 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:42:33 -0600 Subject: [PATCH 10/13] fix: guard vacuous isNumeric on empty result sets --- src/presentation/result-formatter.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 0b0a7fcd..23dc5f4a 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -90,10 +90,12 @@ function printAutoTable(data, field) { (max, item) => Math.max(max, String(item[col] ?? '').length), col.length, ); - const isNumeric = flatItems.every((item) => { - const v = item[col]; - return v == null || v === '' || (typeof v !== 'boolean' && Number.isFinite(Number(v))); - }); + 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), From 1336b9086d75639f72468418bbbc6e5ebdc7fae5 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:47:43 -0600 Subject: [PATCH 11/13] fix: document dot-notation key collision assumption in flattenObject --- src/presentation/result-formatter.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 23dc5f4a..895c68b4 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -4,6 +4,10 @@ import { formatTable, truncEnd } from './table.js'; /** * 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 = {}; From b9974e2e97334a2f74253cc2410d8d024d310780 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:47:51 -0600 Subject: [PATCH 12/13] fix: add defensive guard for undefined plot HTML output --- src/cli/commands/plot.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/cli/commands/plot.js b/src/cli/commands/plot.js index 8ae0810f..8624e9d8 100644 --- a/src/cli/commands/plot.js +++ b/src/cli/commands/plot.js @@ -63,6 +63,12 @@ export const command = { 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'); console.log(`Plot written to ${outPath}`); From 7e94da723fd9509d7d9740f2159272dba4c639c5 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:03:35 -0600 Subject: [PATCH 13/13] refactor: extract prepareFlatItems helper to deduplicate CSV/table logic Extracts the identical flat-items mapping and column-derivation block from printCsv and printAutoTable into a shared prepareFlatItems helper, so future format additions or bug fixes only need one code path. --- src/presentation/result-formatter.js | 58 ++++++++++++++-------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 895c68b4..7565115b 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -29,23 +29,14 @@ function flattenObject(obj, prefix = '') { return result; } -/** 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') + * Flatten items array and derive column names. + * Shared by printCsv and printAutoTable. + * @returns {{ flatItems: object[], columns: string[] } | null} */ -function printCsv(data, field) { +function prepareFlatItems(data, field) { const items = field ? data[field] : data; - if (!Array.isArray(items)) return false; + if (!Array.isArray(items)) return null; const flatItems = items.map((item) => typeof item === 'object' && item !== null && !Array.isArray(item) @@ -59,6 +50,28 @@ function printCsv(data, field) { })(); 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(',')); @@ -74,20 +87,9 @@ const MAX_COL_WIDTH = 40; * @param {string} field - Array field name (e.g. 'results') */ function printAutoTable(data, field) { - const items = field ? data[field] : data; - if (!Array.isArray(items)) return false; - - 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'); + const prepared = prepareFlatItems(data, field); + if (!prepared) return false; + const { flatItems, columns } = prepared; const colDefs = columns.map((col) => { const maxLen = flatItems.reduce(