diff --git a/docs/examples/MCP.md b/docs/examples/MCP.md index 318433a43..7321990a1 100644 --- a/docs/examples/MCP.md +++ b/docs/examples/MCP.md @@ -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", diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index ff32eb212..9de21cec1 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -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 = new WeakMap(); @@ -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( @@ -203,6 +208,7 @@ function exportsFileImpl( name: string; file: string; line: number; + sourceKind: string; }>; if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file)); @@ -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, }; }; diff --git a/src/presentation/queries-cli/exports.ts b/src/presentation/queries-cli/exports.ts index 44c6b7831..534b52743 100644 --- a/src/presentation/queries-cli/exports.ts +++ b/src/presentation/queries-cli/exports.ts @@ -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 { @@ -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)}`); } } } @@ -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)}`); } } } diff --git a/src/types.ts b/src/types.ts index ecc8bf832..451490958 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; 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 { diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 3133b12fd..f0f56fb9b 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -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(); @@ -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(); diff --git a/tests/presentation/queries-cli.test.ts b/tests/presentation/queries-cli.test.ts index 18fd76ea4..5c57e8c0f 100644 --- a/tests/presentation/queries-cli.test.ts +++ b/tests/presentation/queries-cli.test.ts @@ -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', @@ -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',