diff --git a/graphify/extract.py b/graphify/extract.py index 5caaaa3ab..3d6772265 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: diff --git a/tests/test_ts_inheritance.py b/tests/test_ts_inheritance.py new file mode 100644 index 000000000..cf7f8f2af --- /dev/null +++ b/tests/test_ts_inheritance.py @@ -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 { x: T; }\n" + "interface G extends Base { 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")