fix(dead-code): a type that declare module or declare global merges outward is not dead (#2418)

The viewer's Dead code screen listed TypeScript interfaces that augment a
type declared outside the project: chart.js's `PluginOptionsByType` in
`declare module 'chart.js' { … }` (ghostfolio), `interface Window` in
`declare global { … }` (angular-realworld). The library or the runtime
reads them through the type they merge into, so nothing in the repository
names them, and the index recorded them as unexported file-level
interfaces.

TypeScript exports what such a body declares without the keyword. The
binder makes an ambient module body an export context unless it holds an
export declaration (`export {}`, `export =`, `export default x`), and a
namespace nested in one is ambient too. Both extractors now set isExported
that way: isAmbientExport in languages/typescript.ts, shared by TSX and
ArkTS, and is_ambient_export in the kernel's tsjs walker. The exported rule
then drops them, counted under `exported`, and that count's label now
names them. `declare namespace X` is unchanged: in a module file X belongs
to that file.

The resolver sees them as exported too. A file holding `declare global`
was already treated as exporting, so nothing moves there. A `declare
module` member becomes reachable across files by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-07 08:21:08 +00:00
committed by GitHub
parent d8a7f868b3
commit e0bbb6620f
9 changed files with 412 additions and 9 deletions
+45 -2
View File
@@ -1030,7 +1030,8 @@ impl<'t> Walker<'t> {
None
}
/// isExported: walk the parent chain for an export_statement.
/// isExported: walk the parent chain for an export_statement, then ask
/// whether a `declare module '…'` / `declare global` body exports it.
fn is_exported(&self, node: Node) -> bool {
let mut cur = node.parent();
while let Some(p) = cur {
@@ -1039,7 +1040,7 @@ impl<'t> Walker<'t> {
}
cur = p.parent();
}
false
is_ambient_export(node)
}
fn has_keyword_child(&self, node: Node, kw: &str) -> bool {
@@ -1136,6 +1137,48 @@ fn body_of(node: Node) -> Option<Node> {
resolve_field_body(node).or_else(|| node.child_by_field_name("body"))
}
/// isAmbientExport (languages/typescript.ts): declared in a `declare module
/// 'x' { … }` or `declare global { … }` body, which TypeScript exports without
/// an `export` keyword unless the body holds an export declaration of its own.
fn is_ambient_export(node: Node) -> bool {
let mut cur = node.parent();
while let Some(p) = cur {
if p.kind() == "statement_block" && has_export_declaration(p) {
return false;
}
if p.kind() == "ambient_declaration" {
// `declare global` is the keyword and a bare block, with no field names.
for i in 0..p.child_count() {
if p.child(i).is_some_and(|c| c.kind() == "global") {
return true;
}
}
if let Some(declared) = p.named_child(0) {
if declared.kind() == "module"
&& declared.child_by_field_name("name").is_some_and(|n| n.kind() == "string")
{
return true;
}
}
}
cur = p.parent();
}
false
}
/// hasExportDeclaration: a statement that exports by name or by assignment
/// (`export {…}`, `export =`, `export default x`), not by declaring.
fn has_export_declaration(block: Node) -> bool {
for i in 0..block.named_child_count() {
if let Some(statement) = block.named_child(i) {
if statement.kind() == "export_statement" && statement.child_by_field_name("declaration").is_none() {
return true;
}
}
}
false
}
fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef {
match s {
Some(s) => arena.put(s),