From 17fbcba97423e96693bfc2b689b090abd4ea45ff Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 13:25:38 -0600 Subject: [PATCH 1/2] fix: credit import-type usages as exports consumers codegraph exports --json (and the --unused dead-export filter it feeds) reported zero consumers for interfaces/types that are demonstrably imported and used elsewhere via `import type { X }`, even though `codegraph deps ` correctly showed the importing file in `importedBy`. Example: ChaContext in src/domain/graph/builder/cha.ts is imported (type-only) by build-edges.ts and native-orchestrator.ts, but `codegraph exports` showed consumerCount: 0. Root cause: exportsFileImpl's per-symbol consumers query (domain/analysis/exports.ts) only looked at kind = 'calls' edges. The builder already emits a symbol-level `imports-type` edge for `import type { X }` statements (source = importing file node, target = the specific imported symbol -- see emitTypeOnlySymbolEdges in build-edges.ts/incremental.ts), which `codegraph deps` reads from, but the exports consumer query never looked at this edge kind. Role classification (features/structure.ts) already includes 'imports-type' in its fan-in formula, so `codegraph roles --role dead` was unaffected -- this was purely a gap in exports's independent consumer list. Fix: widen the consumers query to `kind IN ('calls', 'imports-type')`, matching the edge-kind set already used everywhere else in the codebase for cross-file usage credit (structure.ts, graph-enrichment.ts, boundaries.ts, dependencies.ts). No native Rust changes needed -- domain/analysis/exports.ts is pure query-layer code that reads the already-built edges table and has no engine-specific mirror. Deliberately does NOT add `extends`/`implements`: investigation found those edges are resolved by symbol name only, with no file/import scoping (buildClassHierarchyEdges in both build-edges.ts/incremental.ts and the native emit_hierarchy_edges), so they link same-named declarations across unrelated files (verified: this repo's own graph has false `implements`/`extends` edges between unrelated fixture classes across languages and a `Repository` interface in src/types.ts). Filed as #1812. Also filed #1813 for a related but distinct gap: `import { type X }` inline per-specifier modifiers aren't tracked as type-only in either engine's extractor, so such X still gets no credit even after this fix. Added tests/integration/exports.test.ts coverage: an interface consumed only via a symbol-level `imports-type` edge gets consumerCount >= 1 and is excluded from --unused, while a genuinely unreferenced interface still shows 0 consumers. Verified against this repo's own graph: `codegraph exports src/domain/graph/builder/cha.ts -T --json` now credits ChaContext with 2 consumers (build-edges.ts, native-orchestrator.ts). Full test suite (201 files, 3359 tests) and lint pass clean. docs check acknowledged: internal bug fix to exports consumer computation, no new feature/language/CLI surface/architecture change -- README.md, CLAUDE.md, and ROADMAP.md are unaffected. Fixes #1724 Impact: 1 functions changed, 3 affected --- src/domain/analysis/exports.ts | 15 ++++++- tests/integration/exports.test.ts | 73 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 62f97c434..3c9710df6 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -164,11 +164,24 @@ function exportsFileImpl( "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", ) : null; + // Consumers include real call/construct edges plus `imports-type` edges — + // the symbol-level edge emitted for `import type { X }` statements (source + // is the importing *file* node, since the import statement references the + // type rather than a specific function). Without this, interfaces/types + // that are only ever used as type annotations are misclassified as dead + // exports even though `codegraph deps` already reports the importing file + // via this same edge (#1724). + // + // `extends`/`implements` edges are deliberately NOT included here: they are + // resolved by symbol name only, with no file/import scoping (see + // buildClassHierarchyEdges), so they can link same-named declarations + // across unrelated files — crediting them as consumers would surface false + // positives instead of fixing false negatives. const consumersStmt = cachedStmt( _consumersStmtCache, db, `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'calls'`, + WHERE e.target_id = ? AND e.kind IN ('calls', 'imports-type')`, ); const reexportsFromStmt = cachedStmt( _reexportsFromStmtCache, diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 467621280..f869ec115 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -240,3 +240,76 @@ describe('exportsData', () => { expect(data.totalReexportedUnused).toBe(0); }); }); + +// ─── import type / type-only consumer crediting (#1724) ────────────────── +// +// Regression coverage for: interfaces/types that are only ever consumed via +// `import type { X }` (never called/constructed) were reported as zero- +// consumer dead exports, even though the builder already emits a +// symbol-level `imports-type` edge (source = importing file node, target = +// the specific imported symbol) for exactly this case. `codegraph deps` +// already surfaced the file-level import correctly; `exportsData`'s +// per-symbol consumer query only looked at `kind = 'calls'` and missed it. + +describe('exportsData — import type consumer crediting (#1724)', () => { + let tmpDir2: string, dbPath2: string; + + beforeAll(() => { + tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-exports-typeonly-')); + fs.mkdirSync(path.join(tmpDir2, '.codegraph')); + dbPath2 = path.join(tmpDir2, '.codegraph', 'graph.db'); + + const db = new Database(dbPath2); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // File nodes + insertNode(db, 'types.ts', 'file', 'types.ts', 0); + const fConsumer = insertNode(db, 'consumer.ts', 'file', 'consumer.ts', 0); + + // Interface exported from types.ts, referenced elsewhere only via a type + // annotation (never called/constructed). + const configIface = insertNode(db, 'Config', 'interface', 'types.ts', 1); + // Interface exported from types.ts, genuinely never referenced anywhere. + const unusedIface = insertNode(db, 'Unused', 'interface', 'types.ts', 10); + + const markExported = db.prepare('UPDATE nodes SET exported = 1 WHERE id = ?'); + markExported.run(configIface); + markExported.run(unusedIface); + + // consumer.ts does `import type { Config } from './types'`. The builder + // emits the symbol-level edge with the importing *file* as source (see + // emitTypeOnlySymbolEdges in domain/graph/builder/stages/build-edges.ts + // and incremental.ts) since the import statement — not a specific + // function — is what references the type. + insertEdge(db, fConsumer, configIface, 'imports-type'); + + db.close(); + }); + + afterAll(() => { + if (tmpDir2) fs.rmSync(tmpDir2, { recursive: true, force: true }); + }); + + test('interface consumed only via `import type` is credited with a consumer', () => { + const data = exportsData('types.ts', dbPath2); + const config = data.results.find((r) => r.name === 'Config'); + expect(config).toBeDefined(); + expect(config.consumerCount).toBe(1); + expect(config.consumers.length).toBe(1); + expect(config.consumers[0].file).toBe('consumer.ts'); + }); + + 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(); + }); + + test('interface with no references anywhere is still classified unused', () => { + const data = exportsData('types.ts', dbPath2, { unused: true }); + const unused = data.results.find((r) => r.name === 'Unused'); + expect(unused).toBeDefined(); + expect(unused.consumerCount).toBe(0); + expect(unused.consumers).toEqual([]); + }); +}); From 4580d6973da381db19b389a6ddbde80e3cee7461 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 18:39:00 -0600 Subject: [PATCH 2/2] fix: assert file-level name/line for imports-type consumer entries (#1814) --- tests/integration/exports.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index f869ec115..f8818d498 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -298,6 +298,10 @@ describe('exportsData — import type consumer crediting (#1724)', () => { expect(config.consumerCount).toBe(1); expect(config.consumers.length).toBe(1); expect(config.consumers[0].file).toBe('consumer.ts'); + // imports-type edges source from the importing *file* node, not a + // function — so name/line carry file-level values, not a call site. + 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', () => {