Skip to content

Commit e38c256

Browse files
authored
feat: show re-exported symbols for barrel files (#515)
* feat: show re-exported symbols in exports command for barrel files Barrel/re-export files like src/db/index.js previously showed "No exported symbols found" despite being heavily-imported entry points. The exports command now follows outgoing reexport edges to gather symbols from target modules, displaying them grouped by origin file. Supports --unused filtering and JSON/MCP output. Impact: 5 functions changed, 4 affected * fix: add re-export counters and paginate reexportedSymbols - Add totalReexported and totalReexportedUnused fields so barrel file statistics accurately reflect re-exported symbols (previously both were always 0 for pure barrel files) - Apply limit/offset pagination to reexportedSymbols (previously returned in full regardless of pagination options) - Update integration tests to verify new counter fields * fix: deduplicate reexport targets and align pagination guard - SQL query now selects DISTINCT n.file (was DISTINCT n.id, n.file) to avoid duplicate symbol entries when multiple reexport edges target the same file - Pagination guard for reexportedSymbols now triggers only when opts.limit is set, matching paginateResult's own behaviour Impact: 2 functions changed, 2 affected * fix: use pre-pagination counters in barrel file CLI header The CLI header used reexportedSymbols.length which reflects the current page size after pagination. Now uses totalReexported/totalReexportedUnused from the domain layer to display accurate totals. Impact: 1 functions changed, 0 affected * fix: add fallback for reexported symbols and correct barrel pagination When hasExportedCol is false (older databases), reexported symbols now use the same findCrossFileCallTargets fallback as direct exports instead of silently returning an empty array. _pagination.hasMore now accounts for reexportedSymbols in barrel-only files where direct results are empty, preventing API consumers from truncating results without knowing more pages exist. Impact: 2 functions changed, 1 affected
1 parent 8199db7 commit e38c256

3 files changed

Lines changed: 167 additions & 10 deletions

File tree

src/domain/analysis/exports.js

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,17 @@ export function exportsData(file, customDbPath, opts = {}) {
3434

3535
if (fileResults.length === 0) {
3636
return paginateResult(
37-
{ file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 },
37+
{
38+
file,
39+
results: [],
40+
reexports: [],
41+
reexportedSymbols: [],
42+
totalExported: 0,
43+
totalInternal: 0,
44+
totalUnused: 0,
45+
totalReexported: 0,
46+
totalReexportedUnused: 0,
47+
},
3848
'results',
3949
{ limit: opts.limit, offset: opts.offset },
4050
);
@@ -46,11 +56,28 @@ export function exportsData(file, customDbPath, opts = {}) {
4656
file: first.file,
4757
results: first.results,
4858
reexports: first.reexports,
59+
reexportedSymbols: first.reexportedSymbols,
4960
totalExported: first.totalExported,
5061
totalInternal: first.totalInternal,
5162
totalUnused: first.totalUnused,
63+
totalReexported: first.totalReexported,
64+
totalReexportedUnused: first.totalReexportedUnused,
5265
};
53-
return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
66+
const paginated = paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
67+
// Paginate reexportedSymbols with the same limit/offset (match paginateResult behaviour)
68+
if (opts.limit != null) {
69+
const off = opts.offset || 0;
70+
paginated.reexportedSymbols = paginated.reexportedSymbols.slice(off, off + opts.limit);
71+
// Update _pagination.hasMore to account for reexportedSymbols (barrel-only files
72+
// have empty results[], so hasMore would always be false without this)
73+
if (paginated._pagination) {
74+
const reexTotal = opts.unused ? base.totalReexportedUnused : base.totalReexported;
75+
const resultsHasMore = paginated._pagination.hasMore;
76+
const reexHasMore = off + opts.limit < reexTotal;
77+
paginated._pagination.hasMore = resultsHasMore || reexHasMore;
78+
}
79+
}
80+
return paginated;
5481
} finally {
5582
db.close();
5683
}
@@ -87,9 +114,7 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused, displayOpts)
87114
}
88115
const internalCount = symbols.length - exported.length;
89116

90-
const results = exported.map((s) => {
91-
const fileLines = getFileLines(fn.file);
92-
117+
const buildSymbolResult = (s, fileLines) => {
93118
let consumers = db
94119
.prepare(
95120
`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)
109134
consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })),
110135
consumerCount: consumers.length,
111136
};
112-
});
137+
};
138+
139+
const results = exported.map((s) => buildSymbolResult(s, getFileLines(fn.file)));
113140

114141
const totalUnused = results.filter((r) => r.consumerCount === 0).length;
115142

@@ -122,18 +149,58 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused, displayOpts)
122149
.all(fn.id)
123150
.map((r) => ({ file: r.file }));
124151

152+
// For barrel files: gather symbols re-exported from target modules
153+
const reexportTargets = db
154+
.prepare(
155+
`SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id
156+
WHERE e.source_id = ? AND e.kind = 'reexports'`,
157+
)
158+
.all(fn.id);
159+
160+
const reexportedSymbols = [];
161+
for (const target of reexportTargets) {
162+
let targetExported;
163+
if (hasExportedCol) {
164+
targetExported = db
165+
.prepare(
166+
"SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line",
167+
)
168+
.all(target.file);
169+
} else {
170+
// Fallback: same heuristic as direct exports — symbols called from other files
171+
const targetSymbols = findNodesByFile(db, target.file);
172+
const exportedIds = findCrossFileCallTargets(db, target.file);
173+
targetExported = targetSymbols.filter((s) => exportedIds.has(s.id));
174+
}
175+
for (const s of targetExported) {
176+
const fileLines = getFileLines(target.file);
177+
reexportedSymbols.push({
178+
...buildSymbolResult(s, fileLines),
179+
originFile: target.file,
180+
});
181+
}
182+
}
183+
125184
let filteredResults = results;
185+
let filteredReexported = reexportedSymbols;
126186
if (unused) {
127187
filteredResults = results.filter((r) => r.consumerCount === 0);
188+
filteredReexported = reexportedSymbols.filter((r) => r.consumerCount === 0);
128189
}
129190

191+
const totalReexported = reexportedSymbols.length;
192+
const totalReexportedUnused = reexportedSymbols.filter((r) => r.consumerCount === 0).length;
193+
130194
return {
131195
file: fn.file,
132196
results: filteredResults,
133197
reexports,
198+
reexportedSymbols: filteredReexported,
134199
totalExported: exported.length,
135200
totalInternal: internalCount,
136201
totalUnused,
202+
totalReexported,
203+
totalReexportedUnused,
137204
};
138205
});
139206
}

src/presentation/queries-cli/exports.js

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,39 @@ function printExportSymbols(results) {
3030
}
3131
}
3232

33+
function printReexportedSymbols(reexportedSymbols) {
34+
// Group by origin file
35+
const byOrigin = new Map();
36+
for (const sym of reexportedSymbols) {
37+
if (!byOrigin.has(sym.originFile)) byOrigin.set(sym.originFile, []);
38+
byOrigin.get(sym.originFile).push(sym);
39+
}
40+
41+
for (const [originFile, syms] of byOrigin) {
42+
console.log(`\n from ${originFile}:`);
43+
for (const sym of syms) {
44+
const icon = kindIcon(sym.kind);
45+
const sig = sym.signature?.params ? `(${sym.signature.params})` : '';
46+
const role = sym.role ? ` [${sym.role}]` : '';
47+
console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`);
48+
if (sym.consumers.length === 0) {
49+
console.log(' (no consumers)');
50+
} else {
51+
for (const c of sym.consumers) {
52+
console.log(` <- ${c.name} (${c.file}:${c.line})`);
53+
}
54+
}
55+
}
56+
}
57+
}
58+
3359
export function fileExports(file, customDbPath, opts = {}) {
3460
const data = exportsData(file, customDbPath, opts);
3561
if (outputResult(data, 'results', opts)) return;
3662

37-
if (data.results.length === 0) {
63+
const hasReexported = data.reexportedSymbols && data.reexportedSymbols.length > 0;
64+
65+
if (data.results.length === 0 && !hasReexported) {
3866
if (opts.unused) {
3967
console.log(`No unused exports found for "${file}".`);
4068
} else {
@@ -43,11 +71,33 @@ export function fileExports(file, customDbPath, opts = {}) {
4371
return;
4472
}
4573

46-
printExportHeader(data, opts);
47-
printExportSymbols(data.results);
74+
if (data.results.length > 0) {
75+
printExportHeader(data, opts);
76+
printExportSymbols(data.results);
77+
}
78+
79+
if (hasReexported) {
80+
const totalReexported = opts.unused
81+
? (data.totalReexportedUnused ?? data.reexportedSymbols.length)
82+
: (data.totalReexported ?? data.reexportedSymbols.length);
83+
if (data.results.length === 0) {
84+
if (opts.unused) {
85+
console.log(
86+
`\n# ${data.file} — barrel file (${totalReexported} unused re-exported symbol${totalReexported !== 1 ? 's' : ''} from sub-modules)\n`,
87+
);
88+
} else {
89+
console.log(
90+
`\n# ${data.file} — barrel file (${totalReexported} re-exported symbol${totalReexported !== 1 ? 's' : ''} from sub-modules)\n`,
91+
);
92+
}
93+
} else {
94+
console.log(`\n Re-exported symbols (${totalReexported} from sub-modules):`);
95+
}
96+
printReexportedSymbols(data.reexportedSymbols);
97+
}
4898

4999
if (data.reexports.length > 0) {
50-
console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`);
100+
console.log(`\n Re-exported by: ${data.reexports.map((r) => r.file).join(', ')}`);
51101
}
52102
console.log();
53103
}

tests/integration/exports.test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,44 @@ describe('exportsData', () => {
199199
expect(data._pagination.total).toBe(1);
200200
expect(data._pagination.hasMore).toBe(false);
201201
});
202+
203+
test('barrel file shows re-exported symbols from target modules', () => {
204+
const data = exportsData('barrel.js', dbPath);
205+
expect(data.file).toBe('barrel.js');
206+
// barrel.js has no own exports
207+
expect(data.results).toEqual([]);
208+
expect(data.totalExported).toBe(0);
209+
// but it surfaces re-exported symbols from lib.js
210+
expect(data.reexportedSymbols.length).toBe(3); // add, multiply, unusedFn
211+
const names = data.reexportedSymbols.map((s) => s.name).sort();
212+
expect(names).toEqual(['add', 'multiply', 'unusedFn']);
213+
// each re-exported symbol has originFile
214+
for (const sym of data.reexportedSymbols) {
215+
expect(sym.originFile).toBe('lib.js');
216+
}
217+
// consumer info is preserved
218+
const addSym = data.reexportedSymbols.find((s) => s.name === 'add');
219+
expect(addSym.consumerCount).toBe(2);
220+
// re-export counters reflect barrel symbols
221+
expect(data.totalReexported).toBe(3);
222+
expect(data.totalReexportedUnused).toBe(1); // unusedFn
223+
});
224+
225+
test('barrel file --unused filters re-exported symbols', () => {
226+
const data = exportsData('barrel.js', dbPath, { unused: true });
227+
expect(data.results).toEqual([]);
228+
expect(data.reexportedSymbols.length).toBe(1);
229+
expect(data.reexportedSymbols[0].name).toBe('unusedFn');
230+
expect(data.reexportedSymbols[0].consumerCount).toBe(0);
231+
// counters still reflect totals (not filtered)
232+
expect(data.totalReexported).toBe(3);
233+
expect(data.totalReexportedUnused).toBe(1);
234+
});
235+
236+
test('reexportedSymbols is empty array for non-barrel files', () => {
237+
const data = exportsData('lib.js', dbPath);
238+
expect(data.reexportedSymbols).toEqual([]);
239+
expect(data.totalReexported).toBe(0);
240+
expect(data.totalReexportedUnused).toBe(0);
241+
});
202242
});

0 commit comments

Comments
 (0)