diff --git a/src/cli.js b/src/cli.js index 500031f7..48e8d29a 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, batchQuery, multiBatchData, splitTargets } from './batch.js'; +import { BATCH_COMMANDS, batch, multiBatchData, splitTargets } from './batch.js'; import { buildGraph } from './builder.js'; import { loadConfig } from './config.js'; import { findCycles, formatCycles } from './cycles.js'; @@ -142,6 +142,7 @@ program process.exit(1); } if (opts.path) { + console.error('Note: "query --path" is deprecated, use "codegraph path " instead'); symbolPath(name, opts.path, opts.db, { maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10, edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined, @@ -166,6 +167,36 @@ program } }); +program + .command('path ') + .description('Find shortest path between two symbols') + .option('-d, --db ', 'Path to graph.db') + .option('--reverse', 'Follow edges backward') + .option('--kinds ', 'Comma-separated edge kinds to follow (default: calls)') + .option('--from-file ', 'Disambiguate source symbol by file') + .option('--to-file ', 'Disambiguate target symbol by file') + .option('--depth ', 'Max traversal depth', '10') + .option('-k, --kind ', 'Filter to a specific symbol kind') + .option('-T, --no-tests', 'Exclude test/spec files from results') + .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') + .option('-j, --json', 'Output as JSON') + .action((from, to, opts) => { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); + process.exit(1); + } + symbolPath(from, to, opts.db, { + maxDepth: opts.depth ? parseInt(opts.depth, 10) : 10, + edgeKinds: opts.kinds ? opts.kinds.split(',').map((s) => s.trim()) : undefined, + reverse: opts.reverse, + fromFile: opts.fromFile, + toFile: opts.toFile, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + }); + }); + program .command('impact ') .description('Show what depends on this file (transitive)') @@ -341,43 +372,36 @@ program }); }); -program - .command('explain ') - .description('Structural summary of a file or function (no LLM needed)') - .option('-d, --db ', 'Path to graph.db') - .option('--depth ', 'Recursively explain dependencies up to N levels deep', '0') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .option('--limit ', 'Max results to return') - .option('--offset ', 'Skip N results (default: 0)') - .option('--ndjson', 'Newline-delimited JSON output') - .action((target, opts) => { - explain(target, opts.db, { - depth: parseInt(opts.depth, 10), - noTests: resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - }); - }); - program .command('audit ') .description('Composite report: explain + impact + health metrics per function') .option('-d, --db ', 'Path to graph.db') - .option('--depth ', 'Impact analysis depth', '3') + .option('--quick', 'Structural summary only (skip impact analysis and health metrics)') + .option('--depth ', 'Impact/explain depth', '3') .option('-f, --file ', 'Scope to file (partial match)') .option('-k, --kind ', 'Filter by symbol kind') .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return (quick mode)') + .option('--offset ', 'Skip N results (quick mode)') + .option('--ndjson', 'Newline-delimited JSON output (quick mode)') .action((target, opts) => { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); } + if (opts.quick) { + explain(target, opts.db, { + depth: parseInt(opts.depth, 10), + noTests: resolveNoTests(opts), + json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); + return; + } audit(target, opts.db, { depth: parseInt(opts.depth, 10), file: opts.file, @@ -443,18 +467,48 @@ program program .command('check [ref]') - .description('Run validation predicates against git changes (CI gate)') + .description( + 'CI gate: run manifesto rules (no args), diff predicates (with ref/--staged), or both (--rules)', + ) .option('-d, --db ', 'Path to graph.db') .option('--staged', 'Analyze staged changes') + .option('--rules', 'Also run manifesto rules alongside diff predicates') .option('--cycles', 'Assert no dependency cycles involve changed files') .option('--blast-radius ', 'Assert no function exceeds N transitive callers') .option('--signatures', 'Assert no function declaration lines were modified') .option('--boundaries', 'Assert no cross-owner boundary violations') .option('--depth ', 'Max BFS depth for blast radius (default: 3)') + .option('-f, --file ', 'Scope to file (partial match, manifesto mode)') + .option('-k, --kind ', 'Filter by symbol kind (manifesto mode)') .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return (manifesto mode)') + .option('--offset ', 'Skip N results (manifesto mode)') + .option('--ndjson', 'Newline-delimited JSON output (manifesto mode)') .action(async (ref, opts) => { + const isDiffMode = ref || opts.staged; + + if (!isDiffMode && !opts.rules) { + // No ref, no --staged → run manifesto rules on whole codebase + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); + process.exit(1); + } + const { manifesto } = await import('./manifesto.js'); + manifesto(opts.db, { + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); + return; + } + + // Diff predicates mode const { check } = await import('./check.js'); check(opts.db, { ref, @@ -467,6 +521,24 @@ program noTests: resolveNoTests(opts), json: opts.json, }); + + // If --rules, also run manifesto after diff predicates + if (opts.rules) { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); + process.exit(1); + } + const { manifesto } = await import('./manifesto.js'); + manifesto(opts.db, { + file: opts.file, + kind: opts.kind, + noTests: resolveNoTests(opts), + json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); + } }); // ─── New commands ──────────────────────────────────────────────────────── @@ -925,38 +997,6 @@ program } }); -program - .command('hotspots') - .description( - 'Find structural hotspots: files or directories with extreme fan-in, fan-out, or symbol density', - ) - .option('-d, --db ', 'Path to graph.db') - .option('-n, --limit ', 'Number of results', '10') - .option('--metric ', 'fan-in | fan-out | density | coupling', 'fan-in') - .option('--level ', 'file | directory', 'file') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-j, --json', 'Output as JSON') - .option('--offset ', 'Skip N results (default: 0)') - .option('--ndjson', 'Newline-delimited JSON output') - .action(async (opts) => { - const { hotspotsData, formatHotspots } = await import('./structure.js'); - const data = hotspotsData(opts.db, { - metric: opts.metric, - level: opts.level, - limit: parseInt(opts.limit, 10), - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - noTests: resolveNoTests(opts), - }); - if (opts.ndjson) { - printNdjson(data, 'hotspots'); - } else if (opts.json) { - console.log(JSON.stringify(data, null, 2)); - } else { - console.log(formatHotspots(data)); - } - }); - program .command('roles') .description('Show node role classification: entry, core, utility, adapter, dead, leaf') @@ -1226,35 +1266,6 @@ program }); }); -program - .command('manifesto') - .description('Evaluate manifesto rules (pass/fail verdicts for code health)') - .option('-d, --db ', 'Path to graph.db') - .option('-T, --no-tests', 'Exclude test/spec files from results') - .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') - .option('-f, --file ', 'Scope to file (partial match)') - .option('-k, --kind ', 'Filter by symbol kind') - .option('-j, --json', 'Output as JSON') - .option('--limit ', 'Max results to return') - .option('--offset ', 'Skip N results (default: 0)') - .option('--ndjson', 'Newline-delimited JSON output') - .action(async (opts) => { - if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); - process.exit(1); - } - const { manifesto } = await import('./manifesto.js'); - manifesto(opts.db, { - file: opts.file, - kind: opts.kind, - noTests: resolveNoTests(opts), - json: opts.json, - limit: opts.limit ? parseInt(opts.limit, 10) : undefined, - offset: opts.offset ? parseInt(opts.offset, 10) : undefined, - ndjson: opts.ndjson, - }); - }); - program .command('communities') .description('Detect natural module boundaries using Louvain community detection') @@ -1289,7 +1300,16 @@ program ) .option('-d, --db ', 'Path to graph.db') .option('-n, --limit ', 'Max results to return', '20') - .option('--sort ', 'Sort metric: risk | complexity | churn | fan-in | mi', 'risk') + .option( + '--level ', + 'Granularity: function (default) | file | directory. File/directory level shows hotspots', + 'function', + ) + .option( + '--sort ', + 'Sort metric: risk | complexity | churn | fan-in | mi (function level); fan-in | fan-out | density | coupling (file/directory level)', + 'risk', + ) .option('--min-score ', 'Only show symbols with risk score >= threshold') .option('--role ', 'Filter by role (entry, core, utility, adapter, leaf, dead)') .option('-f, --file ', 'Scope to a specific file (partial match)') @@ -1301,6 +1321,27 @@ program .option('--ndjson', 'Newline-delimited JSON output') .option('--weights ', 'Custom weights JSON (e.g. \'{"fanIn":1,"complexity":0}\')') .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 metric = opts.sort === 'risk' ? 'fan-in' : opts.sort; + const data = hotspotsData(opts.db, { + metric, + level: opts.level, + limit: parseInt(opts.limit, 10), + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + noTests: resolveNoTests(opts), + }); + if (opts.ndjson) { + printNdjson(data, 'hotspots'); + } else if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(formatHotspots(data)); + } + return; + } + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); process.exit(1); @@ -1513,62 +1554,4 @@ program } }); -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 && !EVERY_SYMBOL_KIND.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.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) { - console.error('No targets provided. Pass targets as arguments, --from-file, or --stdin.'); - process.exit(1); - } - - const batchOpts = { - command: opts.command, - depth: opts.depth ? parseInt(opts.depth, 10) : undefined, - file: opts.file, - kind: opts.kind, - noTests: resolveNoTests(opts), - }; - - batchQuery(targets, opts.db, batchOpts); - }); - program.parse(); diff --git a/src/mcp.js b/src/mcp.js index b9b897be..e965a4a8 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -810,12 +810,12 @@ export async function startMCPServer(customDbPath, options = {}) { impactAnalysisData, moduleMapData, fileDepsData, + exportsData, fnDepsData, fnImpactData, pathData, contextData, childrenData, - exportsData, explainData, whereData, diffImpactData, diff --git a/src/queries.js b/src/queries.js index 7f99501f..a35f4f46 100644 --- a/src/queries.js +++ b/src/queries.js @@ -3185,11 +3185,11 @@ function exportsFileImpl(db, target, noTests, getFileLines) { }; }); - // Reexport edges from this file node + // Files that re-export this file (barrel → this file) const reexports = db .prepare( - `SELECT n.file FROM edges e JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'reexports'`, + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'reexports'`, ) .all(fn.id) .map((r) => ({ file: r.file })); diff --git a/tests/integration/batch.test.js b/tests/integration/batch.test.js index 85d7775d..a4c5e6db 100644 --- a/tests/integration/batch.test.js +++ b/tests/integration/batch.test.js @@ -327,40 +327,3 @@ describe('multiBatchData', () => { } }); }); - -// ─── 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); - }); -}); diff --git a/tests/integration/cli.test.js b/tests/integration/cli.test.js index c225ae19..a366cd8c 100644 --- a/tests/integration/cli.test.js +++ b/tests/integration/cli.test.js @@ -160,21 +160,62 @@ describe('CLI smoke tests', () => { expect(data).toHaveProperty('count'); }); - // ─── Hotspots ────────────────────────────────────────────────────── - test('hotspots --json returns valid JSON with hotspots', () => { - const out = run('hotspots', '--db', dbPath, '--json'); + // ─── Triage --level (formerly hotspots) ───────────────────────────── + test('triage --level file --json returns valid JSON with hotspots', () => { + const out = run('triage', '--level', 'file', '--db', dbPath, '--json'); const data = JSON.parse(out); expect(data).toHaveProperty('hotspots'); expect(data).toHaveProperty('metric'); expect(data).toHaveProperty('level'); }); - test('hotspots --level directory returns directory hotspots', () => { - const out = run('hotspots', '--db', dbPath, '--level', 'directory', '--json'); + test('triage --level directory --json returns directory hotspots', () => { + const out = run('triage', '--level', 'directory', '--db', dbPath, '--json'); const data = JSON.parse(out); expect(data.level).toBe('directory'); }); + // ─── Audit --quick (formerly explain) ────────────────────────────── + test('audit --quick --json returns structural summary', () => { + const out = run('audit', 'math.js', '--quick', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data).toHaveProperty('target'); + }); + + // ─── Path (standalone) ───────────────────────────────────────────── + test('path --json returns valid JSON with path info', () => { + const out = run('path', 'sumOfSquares', 'add', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data).toHaveProperty('found'); + expect(data).toHaveProperty('path'); + expect(data).toHaveProperty('hops'); + }); + + // ─── Query --path deprecation ────────────────────────────────────── + test('query --path prints deprecation warning to stderr', () => { + const { spawnSync } = require('node:child_process'); + const result = spawnSync( + 'node', + [CLI, 'query', 'sumOfSquares', '--path', 'add', '--db', dbPath, '--json'], + { + cwd: tmpDir, + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome }, + }, + ); + expect(result.stderr).toContain('deprecated'); + }); + + // ─── Check (manifesto mode) ──────────────────────────────────────── + test('check --json with no ref/staged runs manifesto rules', () => { + const out = run('check', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data).toHaveProperty('rules'); + expect(data).toHaveProperty('summary'); + expect(data).toHaveProperty('passed'); + }); + // ─── Info ──────────────────────────────────────────────────────────── test('info outputs engine diagnostics', () => { const out = run('info'); diff --git a/tests/integration/exports.test.js b/tests/integration/exports.test.js new file mode 100644 index 00000000..0088c91e --- /dev/null +++ b/tests/integration/exports.test.js @@ -0,0 +1,157 @@ +/** + * Integration tests for the `exports` command (exportsData). + * + * Test graph: + * + * Files: lib.js, app.js, barrel.js, lib.test.js + * + * Symbols in lib.js: add (function, line 1), multiply (function, line 10), helper (function, line 20) + * Symbols in app.js: main (function, line 1) + * Symbols in lib.test.js: testAdd (function, line 1) + * + * Call edges: + * main → add (cross-file) + * main → multiply (cross-file) + * add → helper (same-file, internal) + * testAdd → add (cross-file, from test) + * + * Reexport edge: + * barrel.js → lib.js (kind: 'reexports') + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { initSchema } from '../../src/db.js'; +import { exportsData } from '../../src/queries.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)') + .run(name, kind, file, line).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, 0)', + ).run(sourceId, targetId, kind, confidence); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-exports-')); + fs.mkdirSync(path.join(tmpDir, '.codegraph')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // File nodes + const fLib = insertNode(db, 'lib.js', 'file', 'lib.js', 0); + const fApp = insertNode(db, 'app.js', 'file', 'app.js', 0); + const fBarrel = insertNode(db, 'barrel.js', 'file', 'barrel.js', 0); + const fTest = insertNode(db, 'lib.test.js', 'file', 'lib.test.js', 0); + + // Function nodes in lib.js + const add = insertNode(db, 'add', 'function', 'lib.js', 1); + const multiply = insertNode(db, 'multiply', 'function', 'lib.js', 10); + const helper = insertNode(db, 'helper', 'function', 'lib.js', 20); + + // Function nodes in app.js + const main = insertNode(db, 'main', 'function', 'app.js', 1); + + // Function nodes in lib.test.js + const testAdd = insertNode(db, 'testAdd', 'function', 'lib.test.js', 1); + + // Import edges + insertEdge(db, fApp, fLib, 'imports'); + insertEdge(db, fTest, fLib, 'imports'); + + // Call edges + insertEdge(db, main, add, 'calls'); // cross-file: app.js → lib.js + insertEdge(db, main, multiply, 'calls'); // cross-file: app.js → lib.js + insertEdge(db, add, helper, 'calls'); // same-file: lib.js internal + insertEdge(db, testAdd, add, 'calls'); // cross-file: test → lib.js + + // Reexport edge: barrel.js re-exports lib.js + insertEdge(db, fBarrel, fLib, 'reexports'); + + db.close(); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Tests ───────────────────────────────────────────────────────────── + +describe('exportsData', () => { + test('returns exported symbols with consumers', () => { + const data = exportsData('lib.js', dbPath); + expect(data.file).toBe('lib.js'); + expect(data.results.length).toBe(2); // add, multiply + + const addExport = data.results.find((r) => r.name === 'add'); + expect(addExport).toBeDefined(); + expect(addExport.kind).toBe('function'); + expect(addExport.line).toBe(1); + // main and testAdd both call add from other files + expect(addExport.consumers.length).toBe(2); + expect(addExport.consumers.map((c) => c.name).sort()).toEqual(['main', 'testAdd']); + + const mulExport = data.results.find((r) => r.name === 'multiply'); + expect(mulExport).toBeDefined(); + expect(mulExport.consumers.length).toBe(1); + expect(mulExport.consumers[0].name).toBe('main'); + + // helper is internal (same-file caller only) + const helperExport = data.results.find((r) => r.name === 'helper'); + expect(helperExport).toBeUndefined(); + }); + + test('totalExported and totalInternal counts', () => { + const data = exportsData('lib.js', dbPath); + expect(data.totalExported).toBe(2); + expect(data.totalInternal).toBe(1); // helper + }); + + test('reexports detected', () => { + const data = exportsData('lib.js', dbPath); + expect(data.reexports.length).toBe(1); + expect(data.reexports[0].file).toBe('barrel.js'); + }); + + test('noTests filters test consumers', () => { + const data = exportsData('lib.js', dbPath, { noTests: true }); + const addExport = data.results.find((r) => r.name === 'add'); + expect(addExport).toBeDefined(); + // testAdd from lib.test.js should be filtered out + expect(addExport.consumers.length).toBe(1); + expect(addExport.consumers[0].name).toBe('main'); + expect(addExport.consumerCount).toBe(1); + }); + + test('empty result for unknown file', () => { + const data = exportsData('nonexistent.js', dbPath); + expect(data.results).toEqual([]); + expect(data.totalExported).toBe(0); + expect(data.totalInternal).toBe(0); + }); + + test('pagination works', () => { + const data = exportsData('lib.js', dbPath, { limit: 1 }); + expect(data.results.length).toBe(1); + expect(data._pagination).toBeDefined(); + expect(data._pagination.total).toBe(2); + expect(data._pagination.hasMore).toBe(true); + expect(data._pagination.returned).toBe(1); + }); +});