capture TS interface-extends and same-file class heritage (fixes #1095)

Two gaps in TypeScript inheritance:

1. interface heritage is an extends_type_clause node, not class_heritage,
   so the walker never saw it and interface extends produced no inherits
   edge. Add an extends_type_clause branch reusing _ts_heritage_clause_entries
   (handles multiple extends).

2. a same-file superclass has no import alias, so the use-fact resolver
   (which only consulted the import table) dropped it; only imported bases
   resolved. Add a same-file fallback against the file's own symbol_nodes,
   scoped to inherits/implements so it does not duplicate same-file calls
   that already resolve via the call-graph pass. Import resolution still
   takes precedence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-31 16:47:52 +01:00
co-authored by Claude Sonnet 4.6
parent 690b4e5d3e
commit 88a8e3bc91
2 changed files with 113 additions and 4 deletions
+22 -4
View File
@@ -6445,11 +6445,19 @@ def _apply_symbol_resolution_facts(
for use_fact in facts.uses:
file_path = use_fact.file_path.resolve()
target_id = None
unresolved_origin = local_aliases_by_file.get(file_path, {}).get(use_fact.local_name)
if unresolved_origin is None:
continue
origin_path, origin_symbol = resolve_exported_origin(*unresolved_origin)
target_id = symbol_nodes.get((origin_path, origin_symbol))
if unresolved_origin is not None:
origin_path, origin_symbol = resolve_exported_origin(*unresolved_origin)
target_id = symbol_nodes.get((origin_path, origin_symbol))
if target_id is None and use_fact.relation in ("inherits", "implements"):
# Same-file fallback for HERITAGE only: a base declared in the same
# file (`class X extends Y`, `interface A extends B`) has no import
# alias, so resolve it directly against the file's own symbol nodes.
# Scoped to heritage because same-file calls/uses already resolve via
# the dedicated call-graph pass; widening this would duplicate those
# edges. Import resolution still takes precedence (#1095).
target_id = symbol_nodes.get((file_path, use_fact.local_name))
if target_id is None:
continue
add_edge(
@@ -6707,6 +6715,16 @@ def _ts_walk_class_members(class_node, source: bytes, path: Path, class_nid: str
_SymbolUseFact(path, class_nid, name, "implements", "type",
clause.start_point[0] + 1)
)
elif child.type == "extends_type_clause":
# Interface heritage (`interface A extends B, C`) is an
# extends_type_clause node, NOT a class_heritage. Its base entries
# are the same node types extends_clause holds, so the helper is
# reusable. Without this branch interface inheritance is dropped (#1095).
for name in _ts_heritage_clause_entries(child, source):
facts.uses.append(
_SymbolUseFact(path, class_nid, name, "inherits", "type",
child.start_point[0] + 1)
)
body = class_node.child_by_field_name("body")
if body is None:
+91
View File
@@ -0,0 +1,91 @@
"""Regression tests for issue #1095: TypeScript inheritance capture.
Two gaps on v0.8.26:
1. `interface A extends B` produced no `inherits` edge (walker only looked at
`class_heritage`, but interface heritage is an `extends_type_clause` node).
2. `class X extends Y` where Y is same-file produced no edge (the use-fact
resolver only consulted the import table, never same-file symbol nodes).
Files live under a `src/` subdir so the one-parent-level node-ID stem is stable
(a root-level file would derive its stem from the tmp dir name).
"""
from pathlib import Path
from graphify.extract import _file_stem, _make_id, extract
def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def _has_inherits(result: dict, src_file: str, src_sym: str,
tgt_file: str, tgt_sym: str, relation: str = "inherits") -> bool:
src = _make_id(_file_stem(Path(src_file)), src_sym)
tgt = _make_id(_file_stem(Path(tgt_file)), tgt_sym)
return any(
(e["source"], e["target"], e["relation"]) == (src, tgt, relation)
for e in result["edges"]
)
def test_interface_extends_same_file(tmp_path):
f = _write(tmp_path / "src" / "a.ts",
"export interface Base { x: number; }\n"
"export interface Derived extends Base { y: number; }\n")
result = extract([f], cache_root=tmp_path)
assert _has_inherits(result, "src/a.ts", "Derived", "src/a.ts", "Base")
def test_interface_extends_multiple_same_file(tmp_path):
f = _write(tmp_path / "src" / "a.ts",
"interface A { a: number; }\n"
"interface B { b: number; }\n"
"interface M extends A, B { m: number; }\n")
result = extract([f], cache_root=tmp_path)
assert _has_inherits(result, "src/a.ts", "M", "src/a.ts", "A")
assert _has_inherits(result, "src/a.ts", "M", "src/a.ts", "B")
def test_class_extends_same_file(tmp_path):
f = _write(tmp_path / "src" / "a.ts",
"class Animal {}\n"
"class Dog extends Animal {}\n")
result = extract([f], cache_root=tmp_path)
assert _has_inherits(result, "src/a.ts", "Dog", "src/a.ts", "Animal")
def test_interface_extends_generic_base_same_file(tmp_path):
f = _write(tmp_path / "src" / "a.ts",
"interface Base<T> { x: T; }\n"
"interface G extends Base<number> { y: number; }\n")
result = extract([f], cache_root=tmp_path)
assert _has_inherits(result, "src/a.ts", "G", "src/a.ts", "Base")
def test_interface_extends_imported(tmp_path):
_write(tmp_path / "src" / "b.ts", "export interface Imported { z: number; }\n")
f = _write(tmp_path / "src" / "a.ts",
"import { Imported } from './b';\n"
"export interface D extends Imported { d: number; }\n")
result = extract([tmp_path / "src" / "a.ts", tmp_path / "src" / "b.ts"], cache_root=tmp_path)
assert _has_inherits(result, "src/a.ts", "D", "src/b.ts", "Imported")
def test_imported_class_extends_still_works(tmp_path):
"""Regression guard: the originally-working imported-class case must stay."""
_write(tmp_path / "src" / "b.ts", "export class Imported {}\n")
f = _write(tmp_path / "src" / "a.ts",
"import { Imported } from './b';\n"
"class Cat extends Imported {}\n")
result = extract([tmp_path / "src" / "a.ts", tmp_path / "src" / "b.ts"], cache_root=tmp_path)
assert _has_inherits(result, "src/a.ts", "Cat", "src/b.ts", "Imported")
def test_class_implements_same_file_interface(tmp_path):
f = _write(tmp_path / "src" / "a.ts",
"interface Walker { walk(): void; }\n"
"class Person implements Walker { walk() {} }\n")
result = extract([f], cache_root=tmp_path)
assert _has_inherits(result, "src/a.ts", "Person", "src/a.ts", "Walker", relation="implements")