From b8f41c77eb206a096f30ee50082cd0fa0f6b217b Mon Sep 17 00:00:00 2001 From: Synvoya <16019863+Synvoya@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:46:30 +1000 Subject: [PATCH] fix(fortran): emit calls edges for function invocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Function calls (`y = f(x)`) were silently dropped — only `subroutine_call` (`call sub(...)`) was handled in walk_calls. tree-sitter-fortran represents a function invocation as a `call_expression`, which had no branch, so every function-to-function call produced no edge. Handle `call_expression`. Because Fortran uses the same `name(...)` syntax for array indexing, the callee is resolved against procedures defined in the file (`target_nid in seen_ids`) before emitting — so array accesses like `arr(i)` cannot fabricate spurious `calls` edges. Adds a function + caller to the fixture and a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 13 +++++++++++++ tests/fixtures/sample.f90 | 13 +++++++++++++ tests/test_languages.py | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index 30388c1..96a7233 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -7507,6 +7507,19 @@ def extract_fortran(path: Path) -> dict: target_nid = _make_id(stem, callee) add_edge(scope_nid, target_nid, "calls", node.start_point[0] + 1, confidence="EXTRACTED", context="call") + # x = compute(args) — function invocations are `call_expression`, which + # shares Fortran's `name(...)` syntax with array indexing. Only emit a + # call edge when the callee resolves to a procedure defined in this file + # (an array variable produces no matching node), so array accesses can't + # fabricate spurious `calls` edges. + elif t == "call_expression": + name_node = next((c for c in node.children if c.type == "identifier"), None) + if name_node: + callee = _read_text(name_node, source).lower() + target_nid = _make_id(stem, callee) + if target_nid in seen_ids and target_nid != scope_nid: + add_edge(scope_nid, target_nid, "calls", node.start_point[0] + 1, + confidence="EXTRACTED", context="call") for child in node.children: walk_calls(child, scope_nid) diff --git a/tests/fixtures/sample.f90 b/tests/fixtures/sample.f90 index a78e18f..e409850 100644 --- a/tests/fixtures/sample.f90 +++ b/tests/fixtures/sample.f90 @@ -43,6 +43,19 @@ contains print *, "Area =", area end subroutine print_area + function double_val(x) result(y) + real, intent(in) :: x + real :: y + y = x * 2.0 + end function double_val + + subroutine report(radius) + real, intent(in) :: radius + real :: scaled + scaled = double_val(radius) + print *, scaled + end subroutine report + end module geometry diff --git a/tests/test_languages.py b/tests/test_languages.py index 0976b48..c082e2d 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1531,6 +1531,24 @@ def test_fortran_finds_calls(): assert len(call_edges) >= 1 +def test_fortran_finds_function_call(): + """`y = f(x)` function invocations must emit a calls edge. + + Function calls are `call_expression` (not `subroutine_call`); that node was + never handled, so every function-to-function call was dropped. The callee is + resolved against defined procedures so array indexing (`arr(i)`) can't + fabricate a spurious edge. + """ + r = extract_fortran(FIXTURES / "sample.f90") + labels = {n["id"]: n["label"] for n in r["nodes"]} + found = any( + "report" in labels.get(e["source"], "") + and "double_val" in labels.get(e["target"], "") + for e in r["edges"] if e["relation"] == "calls" + ) + assert found, "report() should have a calls edge to double_val()" + + def test_fortran_case_insensitive_names(): r = extract_fortran(FIXTURES / "sample.f90") labels = [n["label"] for n in r["nodes"]]