fix(exports): credit plain imports of TypeScript interfaces/type aliases#1978
Conversation
codegraph exports/dead-export analysis only credited a symbol-level
imports-type edge when the importing statement was syntactically marked
type-only (import type { X } or the inline import { type X } modifier).
TypeScript also allows importing an interface or type alias through a
plain import { X } from 'y' with no type keyword at all -- an extremely
common pattern in codebases that don't enforce
import/consistent-type-imports. Since interfaces and type aliases are
erased before runtime, they can never receive a calls edge either, so a
plain import was the only possible consumption signal and it was being
ignored -- permanently misclassifying every such interface/type alias as
a dead export.
Fix the root cause: classify imports-type crediting by the resolved
target's node kind (interface/type in a .ts/.tsx file), not by the
importing statement's syntax. A plain import of a value symbol (function,
class, const) is unaffected -- its consumption credit still requires a
real calls edge. Scoped to TypeScript files specifically, since other
languages reuse the 'interface'/'type' node kinds for constructs that are
runtime-observable (e.g. a Go type alias, a Java interface dispatched
through at runtime).
Mirrored in both engines: the WASM/JS full-build and incremental
pipelines (build-edges.ts, incremental.ts) and both native Rust import-edge
paths (the FFI-hybrid build_edges.rs and the native orchestrator's
import_edges.rs).
Impact: 13 functions changed, 25 affected
Greptile SummaryThis PR credits plain imports of erased TypeScript exports in dead-export analysis. The main changes are:
Confidence Score: 4/5The changed import-credit flow needs an extension-parity fix before merging.
src/shared/kinds.ts and crates/codegraph-core/src/shared/constants.rs Important Files Changed
Reviews (1): Last reviewed commit: "fix(exports): credit plain imports of Ty..." | Re-trigger Greptile |
| * instead of fixing a false positive. | ||
| */ | ||
| export function isTypeErasedImportTarget(kind: string, file: string): boolean { | ||
| return TYPE_ERASED_SYMBOL_KINDS.has(kind) && TYPESCRIPT_EXTENSIONS.has(path.extname(file)); |
There was a problem hiding this comment.
TypeScript Modules Stay Uncredited
When a plain import resolves to an interface or type alias in a .mts or .cts file, this check returns false because path.extname(file) is compared against a set containing only .ts and .tsx. Those TypeScript module files can still export erased declarations, so the new imports-type credit is skipped and codegraph exports can still report the used type export as dead.
|
|
||
| /// TypeScript source extensions — type annotations (and TS's compile-time-only | ||
| /// 'interface'/'type' declarations) only exist for these. | ||
| pub const TYPESCRIPT_EXTENSIONS: [&str; 2] = [".ts", ".tsx"]; |
There was a problem hiding this comment.
Native TypeScript Modules Stay Uncredited
The native builders use this list to decide whether a plain import can credit an erased TypeScript target, but it excludes .mts and .cts. A plain import of an interface or type from those module files therefore fails is_type_erased_import_target, so the native codegraph exports path can still mark the used export as dead.
| pub const TYPESCRIPT_EXTENSIONS: [&str; 2] = [".ts", ".tsx"]; | |
| pub const TYPESCRIPT_EXTENSIONS: [&str; 4] = [".ts", ".tsx", ".mts", ".cts"]; |
Codegraph Impact Analysis14 functions changed → 25 callers affected across 8 files
|
Summary
codegraph exports/dead-export analysis (the same query the pre-commit dead-export gate uses) only credited a symbol-levelimports-typeedge when the importing statement was syntactically marked type-only — a whole-statementimport type { X }(#1724) or the inlineimport { type X }modifier (#1813).TypeScript also allows importing an interface or type alias through a plain
import { X } from 'y'with notypekeyword at all — an extremely common pattern in codebases that don't enforceimport/consistent-type-imports. Since interfaces and type aliases are erased before runtime, they can never receive acallsedge either, so a plain import was the only possible consumption signal, and it was being ignored — permanently misclassifying every such interface/type alias as a dead export, even when it's genuinely used everywhere.Root cause & fix
imports-typecrediting was gated on the importing statement's syntax rather than the resolved target's actual kind. The fix classifies crediting by whether the resolved target is a TypeScript interface/type-alias declaration (isTypeErasedImportTargetinshared/kinds.ts, mirrored asis_type_erased_import_targetin Rust'sshared/constants.rs) — regardless of whether the import statement used thetypekeyword.callsedge, so genuinely-unused value imports still correctly show 0 consumers..ts/.tsxfiles, since other languages reuse the'interface'/'type'node kinds for constructs that are runtime-observable (e.g. a Gotypealias, a Javainterfacedispatched through at runtime) — crediting those on mere import would mask genuinely dead code instead of fixing a false positive.Dual-engine parity
Mirrored in all four import-edge-building code paths per this repo's dual-engine architecture:
src/domain/graph/builder/stages/build-edges.ts(JS/WASM full build)src/domain/graph/builder/incremental.ts(JS/WASM incremental / watch)crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs(native FFI-hybrid path, used e.g. on--forcefull rebuilds)crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs(native orchestrator's pure-Rust path — the primary path for--engine native)Test plan
tests/fixtures/issue-1833-plain-type-import/+tests/integration/issue-1833-plain-type-import.test.ts— a plainimport { Config, Mode } from './types.js'(notypekeyword) used only in type position; verifies bothConfig(interface) andMode(type alias) get a symbol-levelimports-typeedge and are reported as consumed viaexportsData/codegraph exports, on bothwasmandnativeengines. Also verifies a genuinely-unimported export (helper) still correctly reports 0 consumers.build_edges.rsandimport_edges.rscovering: plain import of a TS interface (credited), plain import of a TS value symbol (not credited), and plain import of a non-TypeScript'interface'-kind node e.g. Go (not credited — heuristic correctly scoped to TS).codegraph exports src/types.ts -Ton this repo's ownLanguageRegistryEntry) — confirmed the manual fix path, then added a minimal synthetic reproduction (import { Foo, Bar } from './types'with notypekeyword, used only as parameter/return types) that failed identically on both engines before the fix and passes after.npm test— 229 test files, 3773 passed (30 pre-existing skipped, 2 pre-existing todo).cargo test --libincrates/codegraph-core— 539 passed, including 18 tests inbuild_edges.rs'simport_edge_testsmodule and 11 inimport_edges.rs'stestsmodule (new tests added for this fix).npm run lint— clean (one intentionaluseImportTypewarning on the fixture itself, which deliberately uses the plain-import syntax under test; non-blocking).npm run buildandcargo check --lib/cargo clippy --lib— clean, no new warnings introduced.codegraph diff-impact --staged -Treviewed — blast radius limited to the modified import-edge-building functions and their direct/transitive callers, as expected.Fixes #1833