diff --git a/src/batch.js b/src/batch.js index ba849990..c6657c83 100644 --- a/src/batch.js +++ b/src/batch.js @@ -90,3 +90,91 @@ 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']`. + * Trims whitespace, filters empties. Passes through object items unchanged. + * + * @param {Array} targets + * @returns {Array} + */ +export function splitTargets(targets) { + const out = []; + for (const item of targets) { + if (typeof item !== 'string') { + out.push(item); + continue; + } + for (const part of item.split(',')) { + const trimmed = part.trim(); + if (trimmed) out.push(trimmed); + } + } + return out; +} + +/** + * Multi-command batch orchestration — run different commands per target. + * + * @param {Array<{command: string, target: string, opts?: object}>} items + * @param {string} [customDbPath] + * @param {object} [sharedOpts] - Default opts merged under per-item opts + * @returns {{ mode: 'multi', total: number, succeeded: number, failed: number, results: object[] }} + */ +export function multiBatchData(items, customDbPath, sharedOpts = {}) { + const results = []; + let succeeded = 0; + let failed = 0; + + for (const item of items) { + const { command, target, opts: itemOpts } = item; + const entry = BATCH_COMMANDS[command]; + + if (!entry) { + results.push({ + command, + target, + ok: false, + error: `Unknown batch command "${command}". Valid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`, + }); + failed++; + continue; + } + + const merged = { ...sharedOpts, ...itemOpts }; + + try { + let data; + if (entry.sig === 'dbOnly') { + data = entry.fn(customDbPath, { ...merged, target }); + } else { + data = entry.fn(target, customDbPath, merged); + } + results.push({ command, target, ok: true, data }); + succeeded++; + } catch (err) { + results.push({ command, target, ok: false, error: err.message }); + failed++; + } + } + + 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/cli.js b/src/cli.js index 8ee3157b..e5b95942 100644 --- a/src/cli.js +++ b/src/cli.js @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Command } from 'commander'; import { audit } from './audit.js'; -import { BATCH_COMMANDS, batch } from './batch.js'; +import { BATCH_COMMANDS, batch, batchQuery, multiBatchData, splitTargets } from './batch.js'; import { buildGraph } from './builder.js'; import { loadConfig } from './config.js'; import { findCycles, formatCycles } from './cycles.js'; @@ -1287,20 +1287,89 @@ program } let targets; - if (opts.fromFile) { - const raw = fs.readFileSync(opts.fromFile, 'utf-8').trim(); - if (raw.startsWith('[')) { - targets = JSON.parse(raw); + try { + if (opts.fromFile) { + const raw = fs.readFileSync(opts.fromFile, 'utf-8').trim(); + if (raw.startsWith('[')) { + targets = JSON.parse(raw); + } else { + targets = raw.split(/\r?\n/).filter(Boolean); + } + } else if (opts.stdin) { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + targets = raw.startsWith('[') ? JSON.parse(raw) : raw.split(/\r?\n/).filter(Boolean); } else { - targets = raw.split(/\r?\n/).filter(Boolean); + targets = splitTargets(positionalTargets); } - } else if (opts.stdin) { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8').trim(); - targets = raw.startsWith('[') ? JSON.parse(raw) : raw.split(/\r?\n/).filter(Boolean); + } catch (err) { + console.error(`Failed to parse targets: ${err.message}`); + process.exit(1); + } + + if (!targets || targets.length === 0) { + console.error('No targets provided. Pass targets as arguments, --from-file, or --stdin.'); + process.exit(1); + } + + const batchOpts = { + depth: opts.depth ? parseInt(opts.depth, 10) : undefined, + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + }; + + // Multi-command mode: items from --from-file / --stdin may be objects with { command, target } + const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command; + if (isMulti) { + const data = multiBatchData(targets, opts.db, batchOpts); + console.log(JSON.stringify(data, null, 2)); } else { - targets = positionalTargets; + batch(command, targets, opts.db, batchOpts); + } + }); + +program + .command('batch-query [targets...]') + .description( + `Batch symbol lookup — resolve multiple references in one call.\nDefaults to 'where' command. Accepts comma-separated targets.\nValid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`, + ) + .option('-d, --db ', 'Path to graph.db') + .option('-c, --command ', 'Query command to run (default: where)', 'where') + .option('--from-file ', 'Read targets from file (JSON array or newline-delimited)') + .option('--stdin', 'Read targets from stdin (JSON array)') + .option('--depth ', 'Traversal depth passed to underlying command') + .option('-f, --file ', 'Scope to file (partial match)') + .option('-k, --kind ', 'Filter by symbol kind') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .action(async (positionalTargets, opts) => { + if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`); + process.exit(1); + } + + let targets; + try { + if (opts.fromFile) { + const raw = fs.readFileSync(opts.fromFile, 'utf-8').trim(); + if (raw.startsWith('[')) { + targets = JSON.parse(raw); + } else { + targets = raw.split(/\r?\n/).filter(Boolean); + } + } else if (opts.stdin) { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + targets = raw.startsWith('[') ? JSON.parse(raw) : raw.split(/\r?\n/).filter(Boolean); + } else { + targets = splitTargets(positionalTargets); + } + } catch (err) { + console.error(`Failed to parse targets: ${err.message}`); + process.exit(1); } if (!targets || targets.length === 0) { @@ -1309,13 +1378,14 @@ program } const batchOpts = { + command: opts.command, depth: opts.depth ? parseInt(opts.depth, 10) : undefined, file: opts.file, kind: opts.kind, noTests: resolveNoTests(opts), }; - batch(command, targets, opts.db, batchOpts); + batchQuery(targets, opts.db, batchOpts); }); program.parse(); diff --git a/src/index.js b/src/index.js index 7c012b2d..968204bb 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,14 @@ // Audit (composite report) export { audit, auditData } from './audit.js'; // Batch querying -export { BATCH_COMMANDS, batch, batchData } from './batch.js'; +export { + BATCH_COMMANDS, + batch, + batchData, + batchQuery, + multiBatchData, + splitTargets, +} from './batch.js'; // Architecture boundary rules export { evaluateBoundaries, PRESETS, validateBoundaryConfig } from './boundaries.js'; // Branch comparison diff --git a/tests/integration/batch.test.js b/tests/integration/batch.test.js index 6e302036..133fd3e9 100644 --- a/tests/integration/batch.test.js +++ b/tests/integration/batch.test.js @@ -17,7 +17,7 @@ import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { BATCH_COMMANDS, batchData } from '../../src/batch.js'; +import { BATCH_COMMANDS, batchData, multiBatchData, splitTargets } from '../../src/batch.js'; import { initSchema } from '../../src/db.js'; // ─── Helpers ─────────────────────────────────────────────────────────── @@ -210,11 +210,12 @@ describe('batchData — complexity (dbOnly signature)', () => { // ─── CLI smoke test ────────────────────────────────────────────────── describe('batch CLI', () => { + const cliPath = path.resolve( + path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1')), + '../../src/cli.js', + ); + test('outputs valid JSON', () => { - const cliPath = path.resolve( - path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1')), - '../../src/cli.js', - ); const out = execFileSync('node', [cliPath, 'batch', 'query', 'authenticate', '--db', dbPath], { encoding: 'utf-8', timeout: 30_000, @@ -224,4 +225,148 @@ describe('batch CLI', () => { expect(parsed.total).toBe(1); expect(parsed.results).toHaveLength(1); }); + + test('batch accepts comma-separated positional targets', () => { + const out = execFileSync( + 'node', + [cliPath, 'batch', 'where', 'authenticate,validateToken', '--db', dbPath], + { encoding: 'utf-8', timeout: 30_000 }, + ); + const parsed = JSON.parse(out); + expect(parsed.command).toBe('where'); + expect(parsed.total).toBe(2); + expect(parsed.results).toHaveLength(2); + expect(parsed.results.map((r) => r.target)).toEqual(['authenticate', 'validateToken']); + }); +}); + +// ─── splitTargets ───────────────────────────────────────────────────── + +describe('splitTargets', () => { + test('splits comma-separated strings', () => { + expect(splitTargets(['a,b', 'c'])).toEqual(['a', 'b', 'c']); + }); + + test('trims whitespace', () => { + expect(splitTargets([' a , b '])).toEqual(['a', 'b']); + }); + + test('filters empty segments', () => { + expect(splitTargets(['a,,b', '', 'c'])).toEqual(['a', 'b', 'c']); + }); + + test('passes through object items unchanged', () => { + const obj = { command: 'where', target: 'foo' }; + expect(splitTargets([obj, 'a,b'])).toEqual([obj, 'a', 'b']); + }); + + test('handles empty input', () => { + expect(splitTargets([])).toEqual([]); + }); +}); + +// ─── multiBatchData ─────────────────────────────────────────────────── + +describe('multiBatchData', () => { + test('mixed commands all succeed', () => { + const items = [ + { command: 'where', target: 'authenticate' }, + { command: 'fn-impact', target: 'validateToken' }, + { command: 'explain', target: 'src/auth.js' }, + ]; + const result = multiBatchData(items, dbPath); + expect(result.mode).toBe('multi'); + expect(result.total).toBe(3); + expect(result.succeeded).toBe(3); + expect(result.failed).toBe(0); + for (const r of result.results) { + expect(r.ok).toBe(true); + expect(r.command).toBeDefined(); + expect(r.data).toBeDefined(); + } + }); + + test('invalid command captured per-item without breaking others', () => { + const items = [ + { command: 'where', target: 'authenticate' }, + { command: 'not-a-command', target: 'foo' }, + { command: 'query', target: 'handleRoute' }, + ]; + const result = multiBatchData(items, dbPath); + expect(result.total).toBe(3); + expect(result.succeeded).toBe(2); + expect(result.failed).toBe(1); + expect(result.results[0].ok).toBe(true); + expect(result.results[1].ok).toBe(false); + expect(result.results[1].error).toMatch(/Unknown batch command/); + expect(result.results[2].ok).toBe(true); + }); + + test('per-item opts override shared opts', () => { + const items = [{ command: 'context', target: 'authenticate', opts: { depth: 1 } }]; + const result = multiBatchData(items, dbPath, { depth: 5 }); + expect(result.succeeded).toBe(1); + expect(result.results[0].ok).toBe(true); + }); + + test('empty items returns empty results', () => { + const result = multiBatchData([], dbPath); + expect(result.mode).toBe('multi'); + expect(result.total).toBe(0); + expect(result.succeeded).toBe(0); + expect(result.failed).toBe(0); + expect(result.results).toEqual([]); + }); + + test('error from data function captured per-item', () => { + const badDb = path.join(tmpDir, '.codegraph', 'nonexistent.db'); + const items = [ + { command: 'query', target: 'authenticate' }, + { command: 'where', target: 'foo' }, + ]; + const result = multiBatchData(items, badDb); + expect(result.total).toBe(2); + expect(result.failed).toBe(2); + for (const r of result.results) { + expect(r.ok).toBe(false); + expect(r.error).toBeDefined(); + } + }); +}); + +// ─── batch-query CLI ────────────────────────────────────────────────── + +describe('batch-query CLI', () => { + const cliPath = path.resolve( + path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1')), + '../../src/cli.js', + ); + + test('comma-separated targets default to where command', () => { + const out = execFileSync( + 'node', + [cliPath, 'batch-query', 'authenticate,validateToken', '--db', dbPath], + { encoding: 'utf-8', timeout: 30_000 }, + ); + const parsed = JSON.parse(out); + expect(parsed.command).toBe('where'); + expect(parsed.total).toBe(2); + expect(parsed.results).toHaveLength(2); + expect(parsed.results.map((r) => r.target)).toEqual(['authenticate', 'validateToken']); + for (const r of parsed.results) { + expect(r.ok).toBe(true); + } + }); + + test('--command override works', () => { + const out = execFileSync( + 'node', + [cliPath, 'batch-query', 'authenticate', '--command', 'fn-impact', '--db', dbPath], + { encoding: 'utf-8', timeout: 30_000 }, + ); + const parsed = JSON.parse(out); + expect(parsed.command).toBe('fn-impact'); + expect(parsed.total).toBe(1); + expect(parsed.results[0].ok).toBe(true); + }); });