Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/examples/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,8 @@ With Mermaid output:

Show exported symbols of a file with per-symbol consumers — who calls each export and from where.

Each entry in a symbol's `consumers[]` array carries a `consumerKind` field: `"symbol"` for a real caller/constructor (`name`/`line` are a genuine call-site), or `"file"` for a whole-file reference such as `import type { X }`, where there is no specific calling symbol — `name` equals `file` and `line` is always `0`.

```json
{
"tool": "file_exports",
Expand Down
23 changes: 20 additions & 3 deletions src/domain/analysis/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import { paginateResult } from '../../shared/paginate.js';
import type { BetterSqlite3Database, NodeRow, StmtCache } from '../../types.js';
import { resolveAnalysisOpts, withReadonlyDb } from './query-helpers.js';

const _consumersStmtCache: StmtCache<{ name: string; file: string; line: number }> = new WeakMap();
const _consumersStmtCache: StmtCache<{
name: string;
file: string;
line: number;
sourceKind: string;
}> = new WeakMap();
const _reexportsFromStmtCache: StmtCache<{ file: string }> = new WeakMap();
const _reexportsToStmtCache: StmtCache<{ file: string }> = new WeakMap();
const _reexportSymbolsStmtCache: StmtCache<NodeRow> = new WeakMap();
Expand Down Expand Up @@ -165,7 +170,7 @@ function exportsFileImpl(
const consumersStmt = cachedStmt(
_consumersStmtCache,
db,
`SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
`SELECT n.name, n.file, n.line, n.kind AS sourceKind FROM edges e JOIN nodes n ON e.source_id = n.id
WHERE e.target_id = ? AND e.kind IN ('calls', 'imports-type')`,
);
const reexportsFromStmt = cachedStmt(
Expand Down Expand Up @@ -203,6 +208,7 @@ function exportsFileImpl(
name: string;
file: string;
line: number;
sourceKind: string;
}>;
if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file));

Expand All @@ -214,7 +220,18 @@ function exportsFileImpl(
role: s.role || null,
signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null,
summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null,
consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })),
// `consumerKind` discriminates a real caller/constructor symbol
// (source is a function/method/class node with a genuine call-site
// line) from a whole-file reference such as `import type { X }`
// (source is the importing file node itself — see the comment on
// `consumersStmt` above). Renderers must not treat `name`/`line` on
// a `'file'` entry as a caller symbol/call-site (#1830).
consumers: consumers.map((c) => ({
name: c.name,
file: c.file,
line: c.line,
consumerKind: c.sourceKind === 'file' ? ('file' as const) : ('symbol' as const),
})),
consumerCount: consumers.length,
};
};
Expand Down
19 changes: 17 additions & 2 deletions src/presentation/queries-cli/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ interface ExportConsumer {
name: string;
file: string;
line: number;
/**
* `'symbol'` — a real caller/constructor (`name`/`line` are a genuine
* call-site). `'file'` — a whole-file reference such as
* `import type { X }`, where `name` equals `file` and `line` is always
* `0` because there is no specific call-site to report (#1830).
*/
consumerKind: 'file' | 'symbol';
}

/** Render one consumer entry, without a fabricated call-site line for file-level entries. */
function formatConsumer(c: ExportConsumer): string {
if (c.consumerKind === 'file') {
return `${c.file} (type-only import)`;
}
return `${c.name} (${c.file}:${c.line})`;
}

interface ExportSymbol {
Expand Down Expand Up @@ -65,7 +80,7 @@ function printExportSymbols(results: ExportSymbol[]): void {
console.log(' (no consumers)');
} else {
for (const c of sym.consumers) {
console.log(` <- ${c.name} (${c.file}:${c.line})`);
console.log(` <- ${formatConsumer(c)}`);
}
}
}
Expand All @@ -89,7 +104,7 @@ function printReexportSymbol(sym: ExportSymbol, indent: string): void {
console.log(`${indent} (no consumers)`);
} else {
for (const c of sym.consumers) {
console.log(`${indent} <- ${c.name} (${c.file}:${c.line})`);
console.log(`${indent} <- ${formatConsumer(c)}`);
}
}
}
Expand Down
18 changes: 17 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2024,10 +2024,26 @@ export interface FileExportEntry {
role: Role | null;
signature: string | null;
summary: string | null;
consumers: Array<{ name: string; file: string; line: number }>;
consumers: Array<FileExportConsumer>;
consumerCount: number;
}

/**
* A single caller of an exported symbol. `consumerKind` discriminates two
* shapes that share this same struct:
* - `'symbol'` — a real caller/constructor: `name` is the calling
* function/method/class, `line` is the actual call-site line.
* - `'file'` — a whole-file reference such as `import type { X }`, where
* there is no specific calling symbol: `name` equals `file` and `line`
* is always `0` (no real call-site exists to report; see #1830).
*/
export interface FileExportConsumer {
name: string;
file: string;
line: number;
consumerKind: 'file' | 'symbol';
}

// ── Path ─────────────────────────────────────────────────────────────

export interface PathResult {
Expand Down
17 changes: 17 additions & 0 deletions tests/integration/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,14 @@ describe('exportsData', () => {
// 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']);
// Real callers are discriminated as symbol-level consumers (#1830)
expect(addExport.consumers.every((c) => c.consumerKind === 'symbol')).toBe(true);

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');
expect(mulExport.consumers[0].consumerKind).toBe('symbol');

const unusedExport = data.results.find((r) => r.name === 'unusedFn');
expect(unusedExport).toBeDefined();
Expand Down Expand Up @@ -300,6 +303,20 @@ describe('exportsData — import type consumer crediting (#1724)', () => {
expect(config.consumers[0].file).toBe('consumer.ts');
});

// Regression coverage for #1830: a file-level `imports-type` consumer entry
// must be discriminated from a real symbol-level caller so downstream
// renderers don't treat `consumer.ts` as a calling function or `0` as a
// real call-site line.
test('import-type consumer is discriminated as file-level, not symbol-level (#1830)', () => {
const data = exportsData('types.ts', dbPath2);
const config = data.results.find((r) => r.name === 'Config');
expect(config.consumers[0].consumerKind).toBe('file');
// The file-level source node's own name/file coincide (both are the
// importing file), unlike a real caller whose name is a function/method.
expect(config.consumers[0].name).toBe('consumer.ts');
expect(config.consumers[0].line).toBe(0);
});

test('interface consumed only via `import type` is excluded from --unused', () => {
const data = exportsData('types.ts', dbPath2, { unused: true });
expect(data.results.find((r) => r.name === 'Config')).toBeUndefined();
Expand Down
28 changes: 27 additions & 1 deletion tests/presentation/queries-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ describe('fileExports', () => {
line: 1,
role: 'utility',
signature: { params: 'a, b' },
consumers: [{ name: 'main', file: 'index.js', line: 5 }],
consumers: [{ name: 'main', file: 'index.js', line: 5, consumerKind: 'symbol' }],
},
{
name: 'subtract',
Expand All @@ -511,6 +511,32 @@ describe('fileExports', () => {
expect(out).toContain('(no consumers)');
});

it('renders a file-level consumer (import type) without a fabricated call-site line (#1830)', () => {
mocks.exportsData.mockReturnValue({
file: 'types.ts',
totalExported: 1,
totalInternal: 0,
totalUnused: 0,
results: [
{
name: 'Config',
kind: 'interface',
line: 1,
role: null,
signature: null,
consumers: [{ name: 'consumer.ts', file: 'consumer.ts', line: 0, consumerKind: 'file' }],
},
],
reexportedSymbols: [],
reexports: [],
});
fileExports('types.ts', '/db');
const out = output();
expect(out).toContain('consumer.ts (type-only import)');
// Must not render the fabricated `:0` as if it were a real call-site line.
expect(out).not.toContain('consumer.ts:0');
});

it('renders barrel file header when no direct exports', () => {
mocks.exportsData.mockReturnValue({
file: 'index.js',
Expand Down
Loading