diff --git a/src/domain/analysis/exports.js b/src/domain/analysis/exports.js index 7c086d6e..629ae792 100644 --- a/src/domain/analysis/exports.js +++ b/src/domain/analysis/exports.js @@ -34,7 +34,17 @@ export function exportsData(file, customDbPath, opts = {}) { if (fileResults.length === 0) { return paginateResult( - { file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 }, + { + file, + results: [], + reexports: [], + reexportedSymbols: [], + totalExported: 0, + totalInternal: 0, + totalUnused: 0, + totalReexported: 0, + totalReexportedUnused: 0, + }, 'results', { limit: opts.limit, offset: opts.offset }, ); @@ -46,11 +56,28 @@ export function exportsData(file, customDbPath, opts = {}) { file: first.file, results: first.results, reexports: first.reexports, + reexportedSymbols: first.reexportedSymbols, totalExported: first.totalExported, totalInternal: first.totalInternal, totalUnused: first.totalUnused, + totalReexported: first.totalReexported, + totalReexportedUnused: first.totalReexportedUnused, }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + const paginated = paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + // Paginate reexportedSymbols with the same limit/offset (match paginateResult behaviour) + if (opts.limit != null) { + const off = opts.offset || 0; + paginated.reexportedSymbols = paginated.reexportedSymbols.slice(off, off + opts.limit); + // Update _pagination.hasMore to account for reexportedSymbols (barrel-only files + // have empty results[], so hasMore would always be false without this) + if (paginated._pagination) { + const reexTotal = opts.unused ? base.totalReexportedUnused : base.totalReexported; + const resultsHasMore = paginated._pagination.hasMore; + const reexHasMore = off + opts.limit < reexTotal; + paginated._pagination.hasMore = resultsHasMore || reexHasMore; + } + } + return paginated; } finally { db.close(); } @@ -87,9 +114,7 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused, displayOpts) } const internalCount = symbols.length - exported.length; - const results = exported.map((s) => { - const fileLines = getFileLines(fn.file); - + const buildSymbolResult = (s, fileLines) => { let consumers = db .prepare( `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id @@ -109,7 +134,9 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused, displayOpts) consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })), consumerCount: consumers.length, }; - }); + }; + + const results = exported.map((s) => buildSymbolResult(s, getFileLines(fn.file))); const totalUnused = results.filter((r) => r.consumerCount === 0).length; @@ -122,18 +149,58 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused, displayOpts) .all(fn.id) .map((r) => ({ file: r.file })); + // For barrel files: gather symbols re-exported from target modules + const reexportTargets = db + .prepare( + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'reexports'`, + ) + .all(fn.id); + + const reexportedSymbols = []; + for (const target of reexportTargets) { + let targetExported; + if (hasExportedCol) { + targetExported = db + .prepare( + "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", + ) + .all(target.file); + } else { + // Fallback: same heuristic as direct exports — symbols called from other files + const targetSymbols = findNodesByFile(db, target.file); + const exportedIds = findCrossFileCallTargets(db, target.file); + targetExported = targetSymbols.filter((s) => exportedIds.has(s.id)); + } + for (const s of targetExported) { + const fileLines = getFileLines(target.file); + reexportedSymbols.push({ + ...buildSymbolResult(s, fileLines), + originFile: target.file, + }); + } + } + let filteredResults = results; + let filteredReexported = reexportedSymbols; if (unused) { filteredResults = results.filter((r) => r.consumerCount === 0); + filteredReexported = reexportedSymbols.filter((r) => r.consumerCount === 0); } + const totalReexported = reexportedSymbols.length; + const totalReexportedUnused = reexportedSymbols.filter((r) => r.consumerCount === 0).length; + return { file: fn.file, results: filteredResults, reexports, + reexportedSymbols: filteredReexported, totalExported: exported.length, totalInternal: internalCount, totalUnused, + totalReexported, + totalReexportedUnused, }; }); } diff --git a/src/presentation/queries-cli/exports.js b/src/presentation/queries-cli/exports.js index fe06f731..445255a6 100644 --- a/src/presentation/queries-cli/exports.js +++ b/src/presentation/queries-cli/exports.js @@ -30,11 +30,39 @@ function printExportSymbols(results) { } } +function printReexportedSymbols(reexportedSymbols) { + // Group by origin file + const byOrigin = new Map(); + for (const sym of reexportedSymbols) { + if (!byOrigin.has(sym.originFile)) byOrigin.set(sym.originFile, []); + byOrigin.get(sym.originFile).push(sym); + } + + for (const [originFile, syms] of byOrigin) { + console.log(`\n from ${originFile}:`); + for (const sym of syms) { + const icon = kindIcon(sym.kind); + const sig = sym.signature?.params ? `(${sym.signature.params})` : ''; + const role = sym.role ? ` [${sym.role}]` : ''; + console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`); + if (sym.consumers.length === 0) { + console.log(' (no consumers)'); + } else { + for (const c of sym.consumers) { + console.log(` <- ${c.name} (${c.file}:${c.line})`); + } + } + } + } +} + export function fileExports(file, customDbPath, opts = {}) { const data = exportsData(file, customDbPath, opts); if (outputResult(data, 'results', opts)) return; - if (data.results.length === 0) { + const hasReexported = data.reexportedSymbols && data.reexportedSymbols.length > 0; + + if (data.results.length === 0 && !hasReexported) { if (opts.unused) { console.log(`No unused exports found for "${file}".`); } else { @@ -43,11 +71,33 @@ export function fileExports(file, customDbPath, opts = {}) { return; } - printExportHeader(data, opts); - printExportSymbols(data.results); + if (data.results.length > 0) { + printExportHeader(data, opts); + printExportSymbols(data.results); + } + + if (hasReexported) { + const totalReexported = opts.unused + ? (data.totalReexportedUnused ?? data.reexportedSymbols.length) + : (data.totalReexported ?? data.reexportedSymbols.length); + if (data.results.length === 0) { + if (opts.unused) { + console.log( + `\n# ${data.file} — barrel file (${totalReexported} unused re-exported symbol${totalReexported !== 1 ? 's' : ''} from sub-modules)\n`, + ); + } else { + console.log( + `\n# ${data.file} — barrel file (${totalReexported} re-exported symbol${totalReexported !== 1 ? 's' : ''} from sub-modules)\n`, + ); + } + } else { + console.log(`\n Re-exported symbols (${totalReexported} from sub-modules):`); + } + printReexportedSymbols(data.reexportedSymbols); + } if (data.reexports.length > 0) { - console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`); + console.log(`\n Re-exported by: ${data.reexports.map((r) => r.file).join(', ')}`); } console.log(); } diff --git a/tests/integration/exports.test.js b/tests/integration/exports.test.js index 623f19b6..390bf7c1 100644 --- a/tests/integration/exports.test.js +++ b/tests/integration/exports.test.js @@ -199,4 +199,44 @@ describe('exportsData', () => { expect(data._pagination.total).toBe(1); expect(data._pagination.hasMore).toBe(false); }); + + test('barrel file shows re-exported symbols from target modules', () => { + const data = exportsData('barrel.js', dbPath); + expect(data.file).toBe('barrel.js'); + // barrel.js has no own exports + expect(data.results).toEqual([]); + expect(data.totalExported).toBe(0); + // but it surfaces re-exported symbols from lib.js + expect(data.reexportedSymbols.length).toBe(3); // add, multiply, unusedFn + const names = data.reexportedSymbols.map((s) => s.name).sort(); + expect(names).toEqual(['add', 'multiply', 'unusedFn']); + // each re-exported symbol has originFile + for (const sym of data.reexportedSymbols) { + expect(sym.originFile).toBe('lib.js'); + } + // consumer info is preserved + const addSym = data.reexportedSymbols.find((s) => s.name === 'add'); + expect(addSym.consumerCount).toBe(2); + // re-export counters reflect barrel symbols + expect(data.totalReexported).toBe(3); + expect(data.totalReexportedUnused).toBe(1); // unusedFn + }); + + test('barrel file --unused filters re-exported symbols', () => { + const data = exportsData('barrel.js', dbPath, { unused: true }); + expect(data.results).toEqual([]); + expect(data.reexportedSymbols.length).toBe(1); + expect(data.reexportedSymbols[0].name).toBe('unusedFn'); + expect(data.reexportedSymbols[0].consumerCount).toBe(0); + // counters still reflect totals (not filtered) + expect(data.totalReexported).toBe(3); + expect(data.totalReexportedUnused).toBe(1); + }); + + test('reexportedSymbols is empty array for non-barrel files', () => { + const data = exportsData('lib.js', dbPath); + expect(data.reexportedSymbols).toEqual([]); + expect(data.totalReexported).toBe(0); + expect(data.totalReexportedUnused).toBe(0); + }); });