Skip to content
Merged
79 changes: 73 additions & 6 deletions src/domain/analysis/exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
Expand All @@ -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();
}
Expand Down Expand Up @@ -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
Expand All @@ -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;

Expand All @@ -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,
};
});
}
58 changes: 54 additions & 4 deletions src/presentation/queries-cli/exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
}
40 changes: 40 additions & 0 deletions tests/integration/exports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading