From 3571c043304e6c2c8bbd6c3f9e410eafff651ebb Mon Sep 17 00:00:00 2001 From: Michael Denyer <97485362+michael-denyer@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:52:02 +0100 Subject: [PATCH 01/13] fix: emit CALLS edges for module-scope code (#285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser gated CALLS edge emission on `enclosing_func` being set, so calls made from module scope (top-level script glue, CLI entrypoints, `if __name__ == "__main__"` blocks, and Jupyter/Databricks notebook cells) produced zero CALLS edges. Any function invoked only from those contexts was flagged as dead by `find_dead_code`, even when the function was the entire reason the script existed. Notebooks are particularly affected because every cell is module-scope by definition, so the existing notebook parser (PR #69) emitted nodes and IMPORTS_FROM edges but no CALLS edges — making the dead-code detector's notebook coverage vacuous. Fix: when `enclosing_func` is None, attribute the CALLS edge to the File node instead of dropping it. Matches the existing convention used by `_extract_value_references` and CONTAINS edges. Applied to all 5 gated emission sites: generic Python/JS/TS path, JSX components, Elixir, Solidity `emit`, and R. Downstream: `detect_entry_points` now filters File-sourced CALLS via `get_all_call_targets(include_file_sources=False)` so script-only callees remain detectable as entry points (otherwise `run_job()` called from `script.py` module scope would look "called" by `script.py` and disappear from flow analysis). Verified end-to-end against a Databricks `.ipynb` that calls `Predict.extract_data_from_sample_ids()` from cell-level code: edge count went from 0 to 14 CALLS edges, and `find_dead_code` no longer flags the method. Tests: - `test_module_scope_calls_attributed_to_file` — bare `.py` script - `test_module_scope_calls_in_notebook` — `.ipynb` file - `test_detect_entry_points_module_scope_caller_is_still_root` — flow analysis treats File-sourced CALLS correctly - `test_module_scope_caller_prevents_dead_code_flag` — end-to-end parse → store → find_dead_code - `test_if_main_block_caller_prevents_dead_code_flag` — same for `__main__` block (cherry picked from commit fe383c7e767cb2c11421538fd9ef8f3252d1d3ec) --- code_review_graph/parser.py | 116 +++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 47 deletions(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index f681263a..ca91ac53 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -2095,26 +2095,27 @@ def _extract_elixir_constructs( return True # ---- Everything else = a regular function/method call ---------- - # Emit a CALLS edge when we're inside a function (same rule as - # the generic _extract_calls path). - if enclosing_func: - # For dotted calls like `IO.puts(msg)`, prefer the dotted - # identifier; for bare calls use the first identifier. - call_name = ident - caller = self._qualify( - enclosing_func, file_path, enclosing_class, - ) - target = self._resolve_call_target( - call_name, file_path, language, - import_map or {}, defined_names or set(), - ) - edges.append(EdgeInfo( - kind="CALLS", - source=caller, - target=target, - file_path=file_path, - line=node.start_point[0] + 1, - )) + # Module-scope calls attribute to the File node (same rule as the + # generic _extract_calls path). + # For dotted calls like `IO.puts(msg)`, prefer the dotted + # identifier; for bare calls use the first identifier. + call_name = ident + caller = ( + self._qualify(enclosing_func, file_path, enclosing_class) + if enclosing_func + else file_path + ) + target = self._resolve_call_target( + call_name, file_path, language, + import_map or {}, defined_names or set(), + ) + edges.append(EdgeInfo( + kind="CALLS", + source=caller, + target=target, + file_path=file_path, + line=node.start_point[0] + 1, + )) # Recurse into arguments + do_block so nested calls are caught. for sub in node.children: if sub.type in ("arguments", "do_block"): @@ -3021,9 +3022,17 @@ def _extract_calls( ) return True - if call_name and enclosing_func: - caller = self._qualify( - enclosing_func, file_path, enclosing_class, + if call_name: + # Module-scope calls (no enclosing function) are attributed to + # the File node. Matches the existing convention for CONTAINS + # edges and _extract_value_references. Without this fallback, + # any function called only from top-level script glue, CLI + # entrypoints, or Jupyter/Databricks notebook cells is flagged + # as dead by find_dead_code. + caller = ( + self._qualify(enclosing_func, file_path, enclosing_class) + if enclosing_func + else file_path ) target = self._resolve_call_target( call_name, file_path, language, @@ -3056,17 +3065,21 @@ def _extract_jsx_component_call( Treat uppercase component tags such as ```` as call-like edges so caller/impact queries can cross the JSX boundary. Intrinsic DOM tags (``
``) are ignored. - """ - if not enclosing_func: - return + Module-scope JSX (e.g. a top-level ```` render call) attributes + to the File node. + """ target = self._resolve_jsx_component_target( child, language, file_path, import_map or {}, defined_names or set(), ) if not target: return - caller = self._qualify(enclosing_func, file_path, enclosing_class) + caller = ( + self._qualify(enclosing_func, file_path, enclosing_class) + if enclosing_func + else file_path + ) edges.append(EdgeInfo( kind="CALLS", source=caller, @@ -3347,15 +3360,20 @@ def _extract_solidity_constructs( Returns True if the child was fully handled and should skip default recursion. """ - # Emit statements: emit EventName(...) -> CALLS edge - if node_type == "emit_statement" and enclosing_func: + # Emit statements: emit EventName(...) -> CALLS edge. + # Module-scope emits attribute to the File node. + if node_type == "emit_statement": for sub in child.children: if sub.type == "expression": for ident in sub.children: if ident.type == "identifier": - caller = self._qualify( - enclosing_func, file_path, - enclosing_class, + caller = ( + self._qualify( + enclosing_func, file_path, + enclosing_class, + ) + if enclosing_func + else file_path ) edges.append(EdgeInfo( kind="CALLS", @@ -4644,21 +4662,25 @@ def _handle_r_call( import_map, defined_names, ) - if enclosing_func: - call_name = self._get_call_name(node, language, source) - if call_name: - caller = self._qualify(enclosing_func, file_path, enclosing_class) - target = self._resolve_call_target( - call_name, file_path, language, - import_map or {}, defined_names or set(), - ) - edges.append(EdgeInfo( - kind="CALLS", - source=caller, - target=target, - file_path=file_path, - line=node.start_point[0] + 1, - )) + # Module-scope R calls attribute to the File node. + call_name = self._get_call_name(node, language, source) + if call_name: + caller = ( + self._qualify(enclosing_func, file_path, enclosing_class) + if enclosing_func + else file_path + ) + target = self._resolve_call_target( + call_name, file_path, language, + import_map or {}, defined_names or set(), + ) + edges.append(EdgeInfo( + kind="CALLS", + source=caller, + target=target, + file_path=file_path, + line=node.start_point[0] + 1, + )) self._extract_from_tree( node, source, language, file_path, nodes, edges, From ed44b4e2850792c79ef535055cae0a1e7e8c89a4 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 18 Apr 2026 21:52:59 +0100 Subject: [PATCH 02/13] feat: .ksh extension and shebang-based language detection for extension-less scripts (#276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parser improvements that expand code-review-graph's file coverage to extension-less Unix scripts and Korn shell files. Feature 1: .ksh extension → bash parser (#235) ----------------------------------------------- Register .ksh (Korn shell) with tree-sitter-bash alongside the existing .sh / .bash / .zsh entries shipped in v2.3.0. Korn shell is close enough to bash syntactically that tree-sitter-bash handles the structural features the graph captures correctly. Context: in the close comment on PR #230, @tirth8205 explicitly flagged this as worth adding: "The .ksh extension in particular looks worth adding — I didn't include it in #227." Tests: test_detects_language extended with .ksh assertion; test_ksh_extension_parses_as_bash — end-to-end regression test that copies sample.sh to a temp .ksh file, parses it, and asserts identical function set and edge counts. Feature 2: shebang-based language detection (#237) -------------------------------------------------- detect_language() was extension-only — any file with no extension returned None and was silently skipped. This misses a huge category of production files: git hooks, CI scripts, bin/ entry points, installers. New SHEBANG_INTERPRETER_TO_LANGUAGE table maps common interpreter basenames to languages already registered: bash/sh/zsh/ksh/dash/ash -> bash python/python2/python3/pypy/pypy3 -> python node/nodejs -> javascript ruby, perl, lua, Rscript, php New _detect_language_from_shebang(path) static method reads the first 256 bytes, handles direct form (#!/bin/bash), env indirection (#!/usr/bin/env bash), env -S flags, trailing flags (#!/bin/bash -e), CRLF, binary content, and strict UTF-8 decoding. detect_language() now falls back to the shebang probe for files with no extension (suffix == ""). Files with a known extension are never re-read — extension-based detection stays authoritative. Tests (16 new in test_parser.py): every interpreter mapping, env -S flag, trailing flags, missing shebang, empty file, binary content, unknown interpreter, extension-does-not-get-overridden, and end-to-end parse_file producing function nodes from an extension-less bash script. Files changed ------------- - code_review_graph/parser.py — .ksh mapping + SHEBANG_INTERPRETER_TO_LANGUAGE table + _detect_language_from_shebang() + detect_language() fallback - tests/test_multilang.py — .ksh detection + end-to-end ksh parsing test - tests/test_parser.py — 16 shebang detection tests (cherry picked from commit e6e3144857b722591b8a2c1313f9288ded1d3022) --- code_review_graph/parser.py | 118 +++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index ca91ac53..63173d8e 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -127,6 +127,41 @@ class EdgeInfo: ".gd": "gdscript", } +# Shebang interpreter → language mapping for extension-less Unix scripts. +# Each key is the **basename** of the interpreter path as it appears after +# ``#!`` (or after ``#!/usr/bin/env``). Only languages already registered +# above are listed — this file strictly routes extension-less scripts, it +# does NOT introduce new languages on its own. See issue #237. +SHEBANG_INTERPRETER_TO_LANGUAGE: dict[str, str] = { + # POSIX / bash-compatible shells — all routed through tree-sitter-bash + "bash": "bash", + "sh": "bash", + "zsh": "bash", + "ksh": "bash", + "dash": "bash", + "ash": "bash", + # Python (every common variant) + "python": "python", + "python2": "python", + "python3": "python", + "pypy": "python", + "pypy3": "python", + # JavaScript via Node + "node": "javascript", + "nodejs": "javascript", + # Ruby / Perl / Lua / R / PHP + "ruby": "ruby", + "perl": "perl", + "lua": "lua", + "Rscript": "r", + "php": "php", +} + +# Maximum bytes to read from the head of a file when probing for a shebang. +# 256 is enough for any reasonable shebang line (``#!/usr/bin/env python3 -u\n`` +# is ~30 chars) while keeping the worst-case read tiny even on fat binaries. +_SHEBANG_PROBE_BYTES = 256 + # Tree-sitter node type mappings per language # Maps (language) -> dict of semantic role -> list of TS node types _CLASS_TYPES: dict[str, list[str]] = { @@ -640,7 +675,88 @@ def _get_parser(self, language: str): # type: ignore[arg-type] return self._parsers[language] def detect_language(self, path: Path) -> Optional[str]: - return EXTENSION_TO_LANGUAGE.get(path.suffix.lower()) + """Map a file path to its language name. + + Extension-based lookup is tried first. For extension-less files + (typical for Unix scripts like ``bin/myapp`` or ``.git/hooks/pre-commit``) + we fall back to reading the first line for a shebang. Files that + already have a known extension are never re-read — shebang probing + only runs when the extension lookup returns ``None`` **and** the path + has no suffix at all. See issue #237. + """ + suffix = path.suffix.lower() + lang = EXTENSION_TO_LANGUAGE.get(suffix) + if lang is not None: + return lang + # Only probe shebang for files without any extension — "README", "LICENSE", + # and other extension-less text files also fall here, but the probe is a + # cheap 256-byte read that returns None when no shebang is found. + if suffix == "": + return self._detect_language_from_shebang(path) + return None + + @staticmethod + def _detect_language_from_shebang(path: Path) -> Optional[str]: + """Inspect the first line of ``path`` for a shebang interpreter. + + Returns the mapped language name or ``None`` if the file has no + shebang, is unreadable, or names an interpreter we don't map. + + Accepted shapes:: + + #!/bin/bash + #!/usr/bin/env python3 + #!/usr/bin/env -S node --experimental-vm-modules + #!/usr/bin/bash -e + + Only the basename of the interpreter is consulted. Trailing flags + after the interpreter are ignored. Windows-style ``\r\n`` line + endings are handled. Binary files read as garbage bytes simply + fail the ``#!`` prefix check and return ``None``. + """ + try: + with path.open("rb") as fh: + head = fh.read(_SHEBANG_PROBE_BYTES) + except (OSError, PermissionError): + return None + if not head.startswith(b"#!"): + return None + + # Take just the first line, stripped of leading "#!" and any + # surrounding whitespace. Split on NUL to defend against accidental + # binary content following a ``#!`` prefix. + first_line = head.split(b"\n", 1)[0].split(b"\0", 1)[0] + try: + line = first_line[2:].decode("utf-8", errors="strict").strip() + except UnicodeDecodeError: + return None + if not line: + return None + + tokens = line.split() + if not tokens: + return None + + first = tokens[0] + # `/usr/bin/env` indirection: the interpreter is the next token. + # `/usr/bin/env -S node --flag` is also valid — skip any leading + # ``-`` options after env. + if first.endswith("/env") or first == "env": + interpreter_token: Optional[str] = None + for tok in tokens[1:]: + if tok.startswith("-"): + # ``-S`` takes no argument in most envs; skip and continue. + continue + interpreter_token = tok + break + if interpreter_token is None: + return None + interpreter = interpreter_token.rsplit("/", 1)[-1] + else: + # Direct form: ``#!/bin/bash`` or ``#!/usr/local/bin/python3``. + interpreter = first.rsplit("/", 1)[-1] + + return SHEBANG_INTERPRETER_TO_LANGUAGE.get(interpreter) def parse_file(self, path: Path) -> tuple[list[NodeInfo], list[EdgeInfo]]: """Parse a single file and return extracted nodes and edges.""" From 72cdd9501353709b6f510f9099451251e9e42ffc Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 18 Apr 2026 21:52:56 +0100 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20resolve=20Windows=20test=20failure?= =?UTF-8?q?s=20=E2=80=94=20UTF-8,=20CRLF,=20stop=5Fat=20boundary=20(#274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five root-cause fixes that together resolve every pre-existing Windows test failure on main. Each fix has targeted regression tests; the net effect is full green CI on Windows (8 failures -> 0). Bug 1: get_data_dir() writes non-UTF-8 .gitignore on Windows (#239) -------------------------------------------------------------------- write_text() called without encoding="utf-8". The em-dash in the header is U+2014 which Python encodes as cp1252 byte 0x97 on Windows. Any later UTF-8 read fails with UnicodeDecodeError. Fix: add encoding="utf-8" (matches sibling _ensure_repo_gitignore). Test: test_auto_gitignore_is_valid_utf8 — asserts UTF-8 byte sequence, rejects cp1252 byte. Bug 2: Databricks notebook detection fails on CRLF line endings (#239) ---------------------------------------------------------------------- source.startswith(b"# Databricks notebook source\n") hard-codes LF. Windows git checkout (core.autocrlf=true) produces CRLF. All Databricks handling silently bypassed — 4 tests fail. Fix: parse first line robustly, strip trailing \r before exact match. Tests: test_databricks_header_crlf_line_endings, test_databricks_header_lf_line_endings_still_work, test_databricks_header_prefix_false_positive_rejected. Bug 3: Stale FastMCP API in async regression guard (#239) --------------------------------------------------------- test_heavy_tools_are_coroutines called mcp.get_tools() which does not exist in fastmcp>=2.14.0 (pinned in pyproject.toml). The guard has been silently broken since it was written — the protection promised by PR #231 for #46/#136 was never actually enforced. Fix: resolve tools via getattr(crg_main, name) like the sibling test. Drop @pytest.mark.asyncio since no event loop is needed. Test: test_regression_guard_does_not_depend_on_fastmcp_internals — AST-walks the guard source to ensure no mcp internal API references. Bug 4-5: find_repo_root walks above test sandbox (#241) ------------------------------------------------------- test_returns_none_without_git and test_falls_back_to_start fail on any machine where tmp_path has a git-initialized ancestor (dotfiles repo at ~/.git — very common on developer machines). Fix: add optional stop_at parameter to find_repo_root() and find_project_root(). When set, the walk examines stop_at for .git and then stops. Default is None (existing walk-to-root behavior). Fully backward-compatible — all 7 production callers unchanged. Tests: test_stop_at_prevents_escape_to_outer_git, test_stop_at_finds_git_at_boundary, test_stop_at_forwarded_to_find_repo_root. Files changed ------------- - code_review_graph/incremental.py — encoding fix + stop_at API - code_review_graph/parser.py — CRLF-tolerant Databricks detection - tests/test_incremental.py — gitignore UTF-8 guard + stop_at tests - tests/test_main.py — fixed async guard + meta-guard - tests/test_notebook.py — CRLF + LF + false-positive guards (cherry picked from commit aa627fb730c9dd276fef401281ef255faff94267) --- code_review_graph/parser.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index 63173d8e..cf50200d 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -788,11 +788,20 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E if language == "notebook": return self._parse_notebook(path, source) - # Databricks .py notebook exports - if language == "python" and source.startswith( - b"# Databricks notebook source\n", - ): - return self._parse_databricks_py_notebook(path, source) + # Databricks .py notebook exports. The header is ALWAYS the very + # first line, but the file may have CRLF line endings on Windows + # (git's core.autocrlf=true default). Match the first line robustly + # after stripping any trailing ``\r`` so the detection works on both + # platforms. See issue #239. + if language == "python": + first_newline = source.find(b"\n") + first_line = ( + source[:first_newline].rstrip(b"\r") + if first_newline != -1 + else source.rstrip(b"\r") + ) + if first_line == b"# Databricks notebook source": + return self._parse_databricks_py_notebook(path, source) # ReScript: regex-based parser (no tree-sitter grammar bundled). if language == "rescript": From 0d763309cb5039318e0eb62d7c830001cbd0fe9f Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 18 Apr 2026 17:29:05 +0200 Subject: [PATCH 04/13] parser julia-specific helpers (cherry picked from commit f0929226b70e93f7b65afa129c53e1fe9c3e51fb) --- code_review_graph/parser.py | 506 +++++++++++++++++++++++++++++++++++- 1 file changed, 498 insertions(+), 8 deletions(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index cf50200d..177ba27f 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -206,7 +206,9 @@ class EdgeInfo: "elixir": [], "zig": ["container_declaration"], "powershell": ["class_statement"], - "julia": ["struct_definition", "abstract_definition"], + "julia": [ + "struct_definition", "abstract_definition", "module_definition", + ], } _FUNCTION_TYPES: dict[str, list[str]] = { @@ -253,9 +255,12 @@ class EdgeInfo: "elixir": [], "zig": ["fn_proto", "fn_decl"], "powershell": ["function_statement"], + # Julia: short-form functions `f(x) = expr` parse as `assignment` nodes + # (not a dedicated definition node) and are handled in + # _extract_julia_constructs. "julia": [ "function_definition", - "short_function_definition", + "macro_definition", ], } @@ -334,7 +339,11 @@ class EdgeInfo: "elixir": [], "zig": ["call_expression", "builtin_call_expr"], "powershell": ["command_expression"], - "julia": ["call_expression"], + "julia": [ + "call_expression", + "broadcast_call_expression", + "macrocall_expression", + ], } # Patterns that indicate a test function @@ -361,6 +370,8 @@ class EdgeInfo: re.compile(r".*Test\.java$"), re.compile(r".*_test\.resi?$"), re.compile(r".*\.test\.resi?$"), + re.compile(r"test/runtests\.jl$"), + re.compile(r"test/.*\.jl$"), ] _TEST_RUNNER_NAMES = frozenset({ @@ -1922,6 +1933,19 @@ def _extract_from_tree( ): continue + # --- Julia-specific constructs --- + # Short-form functions (`f(x) = expr`) parse as ``assignment``, + # ``include("file.jl")`` as a call_expression, exports as + # ``export_statement``, and macrocalls (including ``@testset``) + # need recursion into bodies that may themselves contain + # function definitions (e.g. ``@inline function f ... end``). + if language == "julia" and self._extract_julia_constructs( + child, node_type, source, language, file_path, + nodes, edges, enclosing_class, enclosing_func, + import_map, defined_names, _depth, + ): + continue + # --- Dart call detection (see #87) --- # tree-sitter-dart does not wrap calls in a single # ``call_expression`` node; instead the pattern is @@ -1970,7 +1994,7 @@ def _extract_from_tree( if node_type in func_types and self._extract_functions( child, source, language, file_path, nodes, edges, enclosing_class, import_map, defined_names, - _depth, + _depth, enclosing_func, ): continue @@ -2408,6 +2432,249 @@ def _extract_r_constructs( return False + # ------------------------------------------------------------------ + # Julia-specific helpers + # ------------------------------------------------------------------ + + def _julia_short_func_name(self, call_expr) -> Optional[str]: + """Extract the name from a ``call_expression`` that is the LHS of + a short-form function ``f(x) = expr`` or ``Base.f(x) = expr``. + """ + for child in call_expr.children: + if child.type == "identifier": + return child.text.decode("utf-8", errors="replace") + if child.type == "field_expression": + for ident in reversed(child.children): + if ident.type == "identifier": + return ident.text.decode("utf-8", errors="replace") + return None + return None + + def _julia_string_arg(self, call_expr) -> Optional[str]: + """Return the first string literal argument of a call_expression.""" + for child in call_expr.children: + if child.type != "argument_list": + continue + for arg in child.children: + if arg.type == "string_literal": + for sub in arg.children: + if sub.type == "content": + return sub.text.decode("utf-8", errors="replace") + raw = arg.text.decode("utf-8", errors="replace") + return raw.strip('"').strip("'") + return None + + def _julia_call_first_identifier(self, call_expr) -> Optional[str]: + """First identifier of a ``call_expression`` (the function being + called). Used to detect ``include("...")``. + """ + for child in call_expr.children: + if child.type == "identifier": + return child.text.decode("utf-8", errors="replace") + return None + + def _extract_julia_constructs( + self, + child, + node_type: str, + source: bytes, + language: str, + file_path: str, + nodes: list[NodeInfo], + edges: list[EdgeInfo], + enclosing_class: Optional[str], + enclosing_func: Optional[str], + import_map: Optional[dict[str, str]], + defined_names: Optional[set[str]], + _depth: int, + ) -> bool: + """Handle Julia-specific constructs the type tables can't cover. + + Returns True if the child was fully handled and should be skipped + by the main dispatch loop. + """ + # --- Short-form function: assignment with call_expression LHS --- + # ``f(x) = expr`` or ``Base.f(x) = expr``. Anything else with an + # ``=`` (plain variable, const) is left to the generic path. + if node_type == "assignment": + lhs = child.children[0] if child.children else None + if lhs is not None and lhs.type == "call_expression": + name = self._julia_short_func_name(lhs) + if name: + is_test = _is_test_function(name, file_path, ()) + kind = "Test" if is_test else "Function" + qualified = self._qualify( + name, file_path, enclosing_class, + ) + nodes.append(NodeInfo( + kind=kind, + name=name, + file_path=file_path, + line_start=child.start_point[0] + 1, + line_end=child.end_point[0] + 1, + language=language, + parent_name=enclosing_class, + is_test=is_test, + )) + container = ( + self._qualify(enclosing_class, file_path, None) + if enclosing_class + else file_path + ) + edges.append(EdgeInfo( + kind="CONTAINS", + source=container, + target=qualified, + file_path=file_path, + line=child.start_point[0] + 1, + )) + # Recurse into the RHS only (children after the ``=`` + # operator) with this function as the enclosing scope + # so internal calls wire up correctly. Visiting the + # whole assignment would re-treat the LHS + # ``call_expression`` as a self-call. + seen_op = False + for sub in child.children: + if not seen_op: + if sub.type == "operator": + seen_op = True + continue + self._extract_from_tree( + sub, source, language, file_path, nodes, edges, + enclosing_class=enclosing_class, + enclosing_func=name, + import_map=import_map, + defined_names=defined_names, + _depth=_depth + 1, + ) + return True + + # --- Skip call_expression nodes that are actually function + # signatures (``function foo(x) ... end`` has a ``signature > + # call_expression`` that describes the definition, not a call). + if node_type == "call_expression": + parent = child.parent + if parent is not None and parent.type == "signature": + return True + + # --- include("file.jl") -> IMPORTS_FROM edge --- + if node_type == "call_expression": + if self._julia_call_first_identifier(child) == "include": + path_arg = self._julia_string_arg(child) + if path_arg: + resolved = self._resolve_module_to_file( + path_arg, file_path, language, + ) + edges.append(EdgeInfo( + kind="IMPORTS_FROM", + source=file_path, + target=resolved if resolved else path_arg, + file_path=file_path, + line=child.start_point[0] + 1, + )) + # Fall through - let generic call dispatch also record + # the CALLS edge and recurse for nested calls. + return False + + # --- export_statement -> REFERENCES edges --- + if node_type == "export_statement": + source_qual = ( + self._qualify(enclosing_class, file_path, None) + if enclosing_class + else file_path + ) + for sub in child.children: + if sub.type == "identifier": + name = sub.text.decode("utf-8", errors="replace") + edges.append(EdgeInfo( + kind="REFERENCES", + source=source_qual, + target=name, + file_path=file_path, + line=child.start_point[0] + 1, + extra={"julia_export": True}, + )) + return True + + # --- macrocall_expression --- + if node_type == "macrocall_expression": + macro_name = None + for sub in child.children: + if sub.type == "macro_identifier": + for ident in sub.children: + if ident.type == "identifier": + macro_name = ident.text.decode( + "utf-8", errors="replace", + ) + break + break + + if macro_name == "testset": + # @testset "desc" begin ... end + desc = None + body_parent = None + for sub in child.children: + if sub.type != "macro_argument_list": + continue + body_parent = sub + for arg in sub.children: + if arg.type == "string_literal": + for c in arg.children: + if c.type == "content": + desc = c.text.decode( + "utf-8", errors="replace", + ) + break + break + line_no = child.start_point[0] + 1 + synth_base = f"testset:{desc}" if desc else "testset" + synth_name = f"{synth_base}@L{line_no}" + qualified = self._qualify( + synth_name, file_path, enclosing_class, + ) + nodes.append(NodeInfo( + kind="Test", + name=synth_name, + file_path=file_path, + line_start=child.start_point[0] + 1, + line_end=child.end_point[0] + 1, + language=language, + parent_name=enclosing_class, + is_test=True, + )) + container = ( + self._qualify( + enclosing_func, file_path, enclosing_class, + ) + if enclosing_func + else file_path + ) + edges.append(EdgeInfo( + kind="CONTAINS", + source=container, + target=qualified, + file_path=file_path, + line=child.start_point[0] + 1, + )) + if body_parent is not None: + self._extract_from_tree( + body_parent, source, language, file_path, nodes, edges, + enclosing_class=enclosing_class, + enclosing_func=synth_name, + import_map=import_map, defined_names=defined_names, + _depth=_depth + 1, + ) + return True + + # Other macrocalls: let the generic CALLS path emit the edge, + # but also recurse into the macro_argument_list so that any + # function defs nested under @inline / @generated / etc. get + # captured. We return False so the generic dispatcher still + # runs for the CALLS edge. + return False + + return False + # ------------------------------------------------------------------ # Lua-specific helpers # ------------------------------------------------------------------ @@ -2938,6 +3205,7 @@ def _extract_functions( import_map: Optional[dict[str, str]], defined_names: Optional[set[str]], _depth: int, + enclosing_func: Optional[str] = None, ) -> bool: """Extract a function/method definition node. @@ -2976,7 +3244,17 @@ def _extract_functions( is_test = _is_test_function(name, file_path, decorators) kind = "Test" if is_test else "Function" - qualified = self._qualify(name, file_path, enclosing_class) + + # Julia: nested functions (``function inner`` inside another + # ``function outer``) should wire up to their enclosing function, + # not skip past it to the enclosing class/module. + parent_name = enclosing_class + container_scope = enclosing_class + if language == "julia" and enclosing_func: + parent_name = enclosing_func + container_scope = enclosing_func + + qualified = self._qualify(name, file_path, parent_name) params = self._get_params(child, language, source) ret_type = self._get_return_type(child, language, source) @@ -2987,7 +3265,7 @@ def _extract_functions( line_start=child.start_point[0] + 1, line_end=child.end_point[0] + 1, language=language, - parent_name=enclosing_class, + parent_name=parent_name, params=params, return_type=ret_type, is_test=is_test, @@ -2996,8 +3274,8 @@ def _extract_functions( # CONTAINS edge container = ( - self._qualify(enclosing_class, file_path, None) - if enclosing_class + self._qualify(container_scope, file_path, None) + if container_scope else file_path ) edges.append(EdgeInfo( @@ -3008,6 +3286,49 @@ def _extract_functions( line=child.start_point[0] + 1, )) + # Julia: ``function Base.show(io, x)`` extends a foreign module's + # method. Record a REFERENCES edge from the function to the + # qualifier module so cross-module links stay visible even though + # the function's local name is just the method name. + if language == "julia" and child.type == "function_definition": + for sub in child.children: + if sub.type != "signature": + continue + call_expr = None + for inner in sub.children: + if inner.type == "where_expression": + for w in inner.children: + if w.type == "call_expression": + call_expr = w + break + break + if inner.type == "call_expression": + call_expr = inner + break + if call_expr is None: + break + if call_expr.children and call_expr.children[0].type == "field_expression": + field_expr = call_expr.children[0] + parts: list[str] = [] + for ident in field_expr.children: + if ident.type == "identifier": + parts.append( + ident.text.decode("utf-8", errors="replace"), + ) + # Module qualifier = everything except the final method + # name. + if len(parts) >= 2: + qualifier = ".".join(parts[:-1]) + edges.append(EdgeInfo( + kind="REFERENCES", + source=qualified, + target=qualifier, + file_path=file_path, + line=child.start_point[0] + 1, + extra={"julia_qualified_def": True}, + )) + break + # Solidity: modifier invocations on functions -> CALLS edges if language == "solidity": for sub in child.children: @@ -4153,6 +4474,76 @@ def _get_name(self, node, language: str, kind: str) -> Optional[str]: for sub in child.children: if sub.type == "type_identifier": return sub.text.decode("utf-8", errors="replace") + # Julia: functions / macros nest the name inside + # ``signature > call_expression > identifier``. Qualified names + # (``function Base.show``) store the method name as the last + # identifier of a ``field_expression``. ``where`` clauses wrap the + # call in a ``where_expression``. + # Structs and abstract types put the name inside ``type_head``, + # possibly wrapped in ``binary_expression`` (``<:``) or + # ``parametrized_type_expression`` (``{T}``). + if language == "julia": + if node.type in ("function_definition", "macro_definition"): + for child in node.children: + if child.type == "signature": + call = child + # Unwrap where_expression: signature > where_expression > call_expression + for sub in call.children: + if sub.type == "where_expression": + call = sub + break + for sub in call.children: + if sub.type == "call_expression": + for target in sub.children: + if target.type == "identifier": + return target.text.decode( + "utf-8", errors="replace", + ) + if target.type == "field_expression": + # Qualified: last identifier is method name + for ident in reversed(target.children): + if ident.type == "identifier": + return ident.text.decode( + "utf-8", errors="replace", + ) + return None + return None + if node.type in ("struct_definition", "abstract_definition"): + for child in node.children: + if child.type == "type_head": + # Direct identifier: struct Foo ... end + for sub in child.children: + if sub.type == "identifier": + return sub.text.decode( + "utf-8", errors="replace", + ) + # Subtyped: type_head > binary_expression > identifier (first) + for sub in child.children: + if sub.type == "binary_expression": + for ident in sub.children: + if ident.type == "identifier": + return ident.text.decode( + "utf-8", errors="replace", + ) + if ident.type == "parametrized_type_expression": + for p in ident.children: + if p.type == "identifier": + return p.text.decode( + "utf-8", errors="replace", + ) + return None + return None + # Parametric (no <:): type_head > parametrized_type_expression + for sub in child.children: + if sub.type == "parametrized_type_expression": + for p in sub.children: + if p.type == "identifier": + return p.text.decode( + "utf-8", errors="replace", + ) + return None + return None + # Most languages use a 'name' child for child in node.children: if child.type in ( @@ -4325,6 +4716,43 @@ def _get_bases(self, node, language: str, source: bytes) -> list[str]: ident.text.decode("utf-8", errors="replace") ) break + elif language == "julia": + # Julia: struct Foo <: Bar / abstract type Foo <: Bar end + # AST: type_head > binary_expression with operator "<:" and + # identifier children; the identifier AFTER the operator is the + # supertype. + if node.type in ("struct_definition", "abstract_definition"): + for child in node.children: + if child.type != "type_head": + continue + for sub in child.children: + if sub.type != "binary_expression": + continue + has_subtype_op = False + for op_child in sub.children: + if ( + op_child.type == "operator" + and op_child.text == b"<:" + ): + has_subtype_op = True + break + if not has_subtype_op: + continue + idents = [ + c for c in sub.children if c.type == "identifier" + ] + # First identifier is the type being defined; the + # second (if present) is the supertype. + if len(idents) >= 2: + bases.append( + idents[1].text.decode("utf-8", errors="replace"), + ) + elif len(idents) == 1: + # Could be `Parametric{T} <: Super` where the + # first side is parametrized_type_expression. + bases.append( + idents[0].text.decode("utf-8", errors="replace"), + ) return bases def _extract_import(self, node, language: str, source: bytes) -> list[str]: @@ -4438,6 +4866,51 @@ def _find_string_literal(n) -> Optional[str]: val = _find_string_literal(node) if val: imports.append(val) + elif language == "julia": + # using/import statements. Children can be: + # - identifier (simple: `using Foo`) + # - import_path (dotted: `using Foo.Bar`) + # - selected_import (`using Foo: bar, baz` — first child is the + # module as identifier/import_path, remaining identifiers after + # the ':' are imported names to record as ``Module.name``) + def _import_path_text(n) -> str: + parts: list[str] = [] + for sub in n.children: + if sub.type == "identifier": + parts.append(sub.text.decode("utf-8", errors="replace")) + return ".".join(parts) + + for child in node.children: + if child.type == "identifier": + imports.append( + child.text.decode("utf-8", errors="replace"), + ) + elif child.type == "import_path": + path = _import_path_text(child) + if path: + imports.append(path) + elif child.type == "selected_import": + module_name: Optional[str] = None + seen_colon = False + for sub in child.children: + if sub.type == ":": + seen_colon = True + continue + if not seen_colon: + if sub.type == "identifier": + module_name = sub.text.decode( + "utf-8", errors="replace", + ) + elif sub.type == "import_path": + path = _import_path_text(sub) + if path: + module_name = path + else: + if sub.type == "identifier" and module_name: + imported = sub.text.decode( + "utf-8", errors="replace", + ) + imports.append(f"{module_name}.{imported}") else: # Fallback: just record the text imports.append(text) @@ -4451,6 +4924,23 @@ def _get_call_name(self, node, language: str, source: bytes) -> Optional[str]: first = node.children[0] + # Julia macrocall: ``@test expr`` — name is inside + # ``macro_identifier > identifier``. Prefix with ``@`` to distinguish + # from ordinary calls. + if language == "julia" and node.type == "macrocall_expression": + for child in node.children: + if child.type == "macro_identifier": + for sub in child.children: + if sub.type == "identifier": + raw = sub.text.decode("utf-8", errors="replace") + return f"@{raw}" + return None + return None + + # Julia broadcast call: ``sin.(x)`` — same structure as + # call_expression (first child is identifier or field_expression) + # so the generic paths below handle it. + # Scala: instance_expression (new Foo(...)) – extract the type name if node.type == "instance_expression": for child in node.children: From a525af5290508bbb97c7352905ec7cfc51466e52 Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 18 Apr 2026 17:43:51 +0200 Subject: [PATCH 05/13] add parametric constructors (cherry picked from commit 425810bd1de1e4edea906429b924d80a286b965f) --- code_review_graph/parser.py | 49 +++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index 177ba27f..43c334c6 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -2438,7 +2438,8 @@ def _extract_r_constructs( def _julia_short_func_name(self, call_expr) -> Optional[str]: """Extract the name from a ``call_expression`` that is the LHS of - a short-form function ``f(x) = expr`` or ``Base.f(x) = expr``. + a short-form function ``f(x) = expr`` or ``Base.f(x) = expr`` or + ``Foo{T}(x) = expr``. """ for child in call_expr.children: if child.type == "identifier": @@ -2448,6 +2449,11 @@ def _julia_short_func_name(self, call_expr) -> Optional[str]: if ident.type == "identifier": return ident.text.decode("utf-8", errors="replace") return None + if child.type == "parametrized_type_expression": + for ident in child.children: + if ident.type == "identifier": + return ident.text.decode("utf-8", errors="replace") + return None return None def _julia_string_arg(self, call_expr) -> Optional[str]: @@ -2498,6 +2504,13 @@ def _extract_julia_constructs( # ``=`` (plain variable, const) is left to the generic path. if node_type == "assignment": lhs = child.children[0] if child.children else None + # Unwrap typed LHS: ``f(x)::RetT = expr`` parses as + # ``assignment > typed_expression > call_expression``. + if lhs is not None and lhs.type == "typed_expression": + for sub in lhs.children: + if sub.type == "call_expression": + lhs = sub + break if lhs is not None and lhs.type == "call_expression": name = self._julia_short_func_name(lhs) if name: @@ -3295,13 +3308,22 @@ def _extract_functions( if sub.type != "signature": continue call_expr = None - for inner in sub.children: - if inner.type == "where_expression": - for w in inner.children: - if w.type == "call_expression": - call_expr = w - break + scope = sub + # Peel where_expression / typed_expression wrappers so we + # land on the inner call_expression regardless of + # ``func(x) where T`` or ``func(x)::T`` sugar. + for _ in range(2): + found_wrapper = False + for inner in scope.children: + if inner.type in ( + "where_expression", "typed_expression", + ): + scope = inner + found_wrapper = True + break + if not found_wrapper: break + for inner in scope.children: if inner.type == "call_expression": call_expr = inner break @@ -4492,6 +4514,12 @@ def _get_name(self, node, language: str, kind: str) -> Optional[str]: if sub.type == "where_expression": call = sub break + # Unwrap typed_expression: signature > typed_expression > call_expression + # (``function foo(x)::ReturnType``) + for sub in call.children: + if sub.type == "typed_expression": + call = sub + break for sub in call.children: if sub.type == "call_expression": for target in sub.children: @@ -4506,6 +4534,13 @@ def _get_name(self, node, language: str, kind: str) -> Optional[str]: return ident.text.decode( "utf-8", errors="replace", ) + if target.type == "parametrized_type_expression": + # Parametric constructor: Foo{T}(x) = ... + for p in target.children: + if p.type == "identifier": + return p.text.decode( + "utf-8", errors="replace", + ) return None return None if node.type in ("struct_definition", "abstract_definition"): From d6206e5fa7ef0cae9a56a2ffb2015833110b7784 Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 18 Apr 2026 17:46:15 +0200 Subject: [PATCH 06/13] support for @enum and public (cherry picked from commit 536fd4bfe4ff0ba996d68a90ff18f836b03c4b66) --- code_review_graph/parser.py | 90 +++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index 43c334c6..cb846d85 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -2589,13 +2589,22 @@ def _extract_julia_constructs( # the CALLS edge and recurse for nested calls. return False - # --- export_statement -> REFERENCES edges --- - if node_type == "export_statement": + # --- export_statement / public_statement -> REFERENCES edges --- + # ``public`` (1.11+) is a softer variant of ``export`` — symbols + # are part of the public API but not brought into scope by + # ``using``. Track both so review tools can answer "what's the + # public surface of this module?". + if node_type in ("export_statement", "public_statement"): source_qual = ( self._qualify(enclosing_class, file_path, None) if enclosing_class else file_path ) + marker = ( + "julia_export" + if node_type == "export_statement" + else "julia_public" + ) for sub in child.children: if sub.type == "identifier": name = sub.text.decode("utf-8", errors="replace") @@ -2605,7 +2614,7 @@ def _extract_julia_constructs( target=name, file_path=file_path, line=child.start_point[0] + 1, - extra={"julia_export": True}, + extra={marker: True}, )) return True @@ -2622,6 +2631,81 @@ def _extract_julia_constructs( break break + if macro_name == "enum": + # @enum Color RED BLUE GREEN + # First argument is the enum type name; the rest are + # variant names. Model the type as a Class and each + # variant as a Function child, so callers referencing a + # variant resolve to something in the graph. + type_name: Optional[str] = None + variant_identifiers: list = [] + for sub in child.children: + if sub.type != "macro_argument_list": + continue + for arg in sub.children: + if arg.type != "identifier": + continue + if type_name is None: + type_name = arg.text.decode( + "utf-8", errors="replace", + ) + else: + variant_identifiers.append(arg) + break + if type_name: + line_start = child.start_point[0] + 1 + line_end = child.end_point[0] + 1 + qualified_type = self._qualify( + type_name, file_path, enclosing_class, + ) + nodes.append(NodeInfo( + kind="Class", + name=type_name, + file_path=file_path, + line_start=line_start, + line_end=line_end, + language=language, + parent_name=enclosing_class, + extra={"julia_kind": "enum"}, + )) + container = ( + self._qualify(enclosing_class, file_path, None) + if enclosing_class + else file_path + ) + edges.append(EdgeInfo( + kind="CONTAINS", + source=container, + target=qualified_type, + file_path=file_path, + line=line_start, + )) + for variant in variant_identifiers: + vname = variant.text.decode( + "utf-8", errors="replace", + ) + qualified_v = self._qualify( + vname, file_path, type_name, + ) + nodes.append(NodeInfo( + kind="Function", + name=vname, + file_path=file_path, + line_start=variant.start_point[0] + 1, + line_end=variant.end_point[0] + 1, + language=language, + parent_name=type_name, + extra={"julia_kind": "enum_variant"}, + )) + edges.append(EdgeInfo( + kind="CONTAINS", + source=qualified_type, + target=qualified_v, + file_path=file_path, + line=variant.start_point[0] + 1, + )) + return True + if macro_name == "testset": # @testset "desc" begin ... end desc = None From 78a58a00a2bb90fca69567ece324df6df5de172d Mon Sep 17 00:00:00 2001 From: Krishnan Mahadevan Date: Sun, 19 Apr 2026 02:21:01 +0530 Subject: [PATCH 07/13] fix(java): extract bare type names from superclass/super_interfaces nodes (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-java wraps extends/implements clauses in superclass and super_interfaces nodes whose .text includes the keyword. The _get_bases() function was storing the full text (e.g. "implements UserRepository") as the INHERITS edge target instead of just the type name. This caused inheritors_of queries to fail — the query looks up edges by the qualified class name or bare name, neither of which matches "implements UserRepository". Adds a Java-specific branch in _get_bases() that drills into the AST children to extract type_identifier nodes (including generic_type for parameterized interfaces like IBar). C#/Kotlin are unaffected and retain the existing behavior. (cherry picked from commit 8104eb7b4f66c4405b728fcf9afc69853ca5daff) --- code_review_graph/parser.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index cb846d85..83072d19 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -4748,7 +4748,23 @@ def _get_bases(self, node, language: str, source: bytes) -> list[str]: for arg in child.children: if arg.type in ("identifier", "attribute"): bases.append(arg.text.decode("utf-8", errors="replace")) - elif language in ("java", "csharp", "kotlin"): + elif language == "java": + # Java: superclass and super_interfaces wrap the keyword + # (extends/implements) around type_identifier children. + # Taking .text would include the keyword (e.g. "implements Foo"). + # Drill into the children to extract bare type names. + for child in node.children: + if child.type == "superclass": + for sub in child.children: + if sub.type in ("type_identifier", "generic_type"): + bases.append(sub.text.decode("utf-8", errors="replace")) + elif child.type == "super_interfaces": + for sub in child.children: + if sub.type == "type_list": + for ident in sub.children: + if ident.type in ("type_identifier", "generic_type"): + bases.append(ident.text.decode("utf-8", errors="replace")) + elif language in ("csharp", "kotlin"): # Look for superclass/interfaces in extends/implements clauses for child in node.children: if child.type in ( From 6f25b1d42cf59c81a460d25ed95ae732437084e3 Mon Sep 17 00:00:00 2001 From: Krishnan Mahadevan Date: Sun, 19 Apr 2026 02:16:29 +0530 Subject: [PATCH 08/13] fix(java): extract method name from identifier, not return type (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-java places type_identifier (return type) before identifier (method name) in method_declaration nodes. The generic _get_name() loop matched type_identifier first, causing methods to be indexed under their return type instead of their actual name. For example: * `public String getName()` was indexed as "String" instead of "getName", and * `public ConfigBean getUtilityIngestionBean()`was indexed as "ConfigBean". This broke callers_of,callees_of, and children_of queries for any Java method with a non-void, non-generic return type. Adds a Java-specific branch in _get_name() that returns the first identifier child for method_declaration nodes, following the same pattern as the Go fix (field_identifier) from PR #166. Kotlin & Scala is unaffected — its syntax places the name before the return type. (cherry picked from commit 9a88f20c0bfd176fb2f4b1f14775c06b62eaffef) --- code_review_graph/parser.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index 83072d19..7c82966e 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -4572,6 +4572,16 @@ def _get_name(self, node, language: str, kind: str) -> Optional[str]: for child in node.children: if child.type == "field_identifier": return child.text.decode("utf-8", errors="replace") + # Java methods: tree-sitter-java puts type_identifier or generic_type + # (return type) before identifier (method name). Must run before + # the generic loop, which would match the return type's + # type_identifier (e.g. "String", "ConfigBean"). + # Constructors are fine — they have no return type node. + # Kotlin is unaffected: its syntax places the name before the type. + if language == "java" and node.type == "method_declaration": + for child in node.children: + if child.type == "identifier": + return child.text.decode("utf-8", errors="replace") # Swift extensions: name is inside user_type > type_identifier # (e.g. `extension MyClass: Protocol { ... }`) if language == "swift" and node.type == "class_declaration": From 2e151a0a24019192ff0a13d6d59b853c5d364797 Mon Sep 17 00:00:00 2001 From: Krishnan Mahadevan Date: Sun, 19 Apr 2026 02:21:04 +0530 Subject: [PATCH 09/13] feat(java): resolve Java imports to file paths (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Java imports like `import com.example.auth.User` were stored as raw dot-notation strings because _do_resolve_module() had no Java branch. This caused `importers_of` queries to return 0 — the query looks for file path targets, but the stored edges had raw import strings. Adds a Java branch that converts dot-notation to a relative path (com/example/auth/User.java) and walks up from the caller's directory to find the source root. This resolves same-source-root imports (the common case in Maven modules). Also handles: - Static imports (import static pkg.Class.member) — strips the member name and resolves to the class file - Wildcard imports (import pkg.*) — skipped, can't resolve to one file - JDK/library imports (java.util.*) — remain unresolved (no local file) (cherry picked from commit 088c281293bfb0c1be86f7e239f19e631659487f) --- code_review_graph/parser.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index 7c82966e..7ca03336 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -4323,6 +4323,38 @@ def _do_resolve_module( # ``dart:core`` / ``dart:async`` etc. are SDK libraries we do # not track; fall through to return None. + elif language == "java": + # ``import com.example.pkg.ClassName;`` — convert dot-notation + # to a relative path and walk up from the caller's directory to + # find the source root. Wildcards (``import pkg.*``) and static + # member imports (``import static pkg.Class.member``) that don't + # resolve as-is are retried after dropping the last segment + # (the member name). + if module.endswith(".*"): + return None # wildcard import — can't resolve to one file + rel_path = module.replace(".", "/") + ".java" + current = caller_dir + while True: + target = current / rel_path + if target.is_file(): + return str(target.resolve()) + if current == current.parent: + break + current = current.parent + # Static import: ``pkg.Class.member`` — strip member, try again + dot = module.rfind(".") + if dot > 0: + class_module = module[:dot] + rel_path2 = class_module.replace(".", "/") + ".java" + current = caller_dir + while True: + target = current / rel_path2 + if target.is_file(): + return str(target.resolve()) + if current == current.parent: + break + current = current.parent + return None def _find_dart_pubspec_root( From 3bd076e462ec0650e11083f64dd95ba17e92e930 Mon Sep 17 00:00:00 2001 From: Maksym Mosiura Date: Sat, 18 Apr 2026 13:51:07 -0700 Subject: [PATCH 10/13] feat: add GDScript (Godot) as a supported language (#316) (cherry picked from commit 112a44221f03c184c4972fcc3573db9762f11c98) --- code_review_graph/parser.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index 7ca03336..b319baed 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -209,6 +209,9 @@ class EdgeInfo: "julia": [ "struct_definition", "abstract_definition", "module_definition", ], + # GDScript: inner classes use ``class Name:`` (class_definition); the + # file-level ``class_name Name`` gives the script itself an identity. + "gdscript": ["class_definition", "class_name_statement"], } _FUNCTION_TYPES: dict[str, list[str]] = { @@ -262,6 +265,8 @@ class EdgeInfo: "function_definition", "macro_definition", ], + # GDScript: ``func name(args) -> ReturnType:`` — includes ``static func``. + "gdscript": ["function_definition"], } _IMPORT_TYPES: dict[str, list[str]] = { @@ -302,6 +307,11 @@ class EdgeInfo: "powershell": [], # Julia: import/using are import_statement nodes. "julia": ["import_statement", "using_statement"], + # GDScript has no ``import`` keyword. The closest analogue is + # ``extends OtherClass`` / ``extends "res://path.gd"``, which establishes + # a hard dependency on the parent script. preload()/load() calls remain + # as ordinary CALLS edges. + "gdscript": ["extends_statement"], } _CALL_TYPES: dict[str, list[str]] = { @@ -344,6 +354,9 @@ class EdgeInfo: "broadcast_call_expression", "macrocall_expression", ], + # GDScript: bare calls produce ``call``; ``obj.method()`` is an + # ``attribute`` node whose right-hand side is an ``attribute_call``. + "gdscript": ["call", "attribute_call"], } # Patterns that indicate a test function @@ -5088,6 +5101,25 @@ def _import_path_text(n) -> str: "utf-8", errors="replace", ) imports.append(f"{module_name}.{imported}") + elif language == "gdscript": + # ``extends Node`` → type > identifier("Node") + # ``extends "res://path.gd"`` → string literal + # ``extends SomeClass.Nested`` → type node (keep full text) + for child in node.children: + if child.type == "type": + txt = child.text.decode("utf-8", errors="replace").strip() + if txt: + imports.append(txt) + elif child.type == "string": + val = child.text.decode("utf-8", errors="replace").strip("'\"") + if val: + imports.append(val) + elif child.type == "identifier": + # Fallback: some grammar variants expose the parent type as + # a bare identifier next to the ``extends`` keyword. + txt = child.text.decode("utf-8", errors="replace") + if txt and txt != "extends": + imports.append(txt) else: # Fallback: just record the text imports.append(text) From 1e05d73a4c45913541a511a8e560a5dce36a9831 Mon Sep 17 00:00:00 2001 From: Bakul Bansal <24uec277@lnmiit.ac.in> Date: Sun, 19 Apr 2026 02:21:59 +0530 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20PHP=20CALL=20extraction=20?= =?UTF-8?q?=E2=80=94=20method,=20static,=20and=20unqualified=20function=20?= =?UTF-8?q?calls=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit d660e02dae0236bfd7018b5acc41b0dfd8ef8cc4) --- code_review_graph/parser.py | 39 ++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index b319baed..19ae3d40 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -333,7 +333,12 @@ class EdgeInfo: ], "kotlin": ["call_expression"], "swift": ["call_expression"], - "php": ["function_call_expression", "member_call_expression"], + "php": [ + "function_call_expression", + "member_call_expression", + "scoped_call_expression", + "nullsafe_member_call_expression", + ], "scala": ["call_expression", "instance_expression", "generic_function"], "solidity": ["call_expression"], "lua": ["function_call"], @@ -5149,6 +5154,38 @@ def _get_call_name(self, node, language: str, source: bytes) -> Optional[str]: # Julia broadcast call: ``sin.(x)`` — same structure as # call_expression (first child is identifier or field_expression) # so the generic paths below handle it. + if language == "php": + def _normalize_php_name(text: str) -> str: + # PHP global/function names can be prefixed with '\\'. + return text.lstrip("\\") + + if node.type == "function_call_expression": + for child in node.children: + if child.type in ("name", "qualified_name"): + raw = child.text.decode("utf-8", errors="replace") + return _normalize_php_name(raw) + return None + + if node.type in ( + "member_call_expression", + "nullsafe_member_call_expression", + ): + for child in reversed(node.children): + if child.type == "name": + return child.text.decode("utf-8", errors="replace") + return None + + if node.type == "scoped_call_expression": + parts = [] + for child in node.children: + if child.type in ("name", "qualified_name"): + raw = child.text.decode("utf-8", errors="replace") + parts.append(_normalize_php_name(raw)) + if len(parts) >= 2: + return f"{parts[0]}::{parts[-1]}" + if parts: + return parts[0] + return None # Scala: instance_expression (new Foo(...)) – extract the type name if node.type == "instance_expression": From 7a3c91580682012449a87a220d2a658ab7912b0f Mon Sep 17 00:00:00 2001 From: npkriami18 Date: Tue, 21 Apr 2026 19:35:51 +0530 Subject: [PATCH 12/13] fix(main): restore _apply_tool_filter on fastmcp>=3 and refresh tests Two pre-existing failures on main, both caused by the 'a3a043b' feature landing against the fastmcp 2.x private API and never being updated for the pinned fastmcp>=3: 1. _apply_tool_filter accessed mcp._tool_manager._tools which was removed when fastmcp rewrote the tool registry. Replaced with the public async mcp.list_tools() + mcp.local_provider.remove_tool(), with a thread-pool fallback for callers that already have a running event loop (tests, future integrations). 2. TestServeMainTransport.test_stdio_calls_mcp_run_stdio asserted exact kwargs {transport: stdio} but PR #290 (cc169af) added show_banner=False to the production call without refreshing the test. Updated the assertion. 3. TestApplyToolFilter relied on mcp._tool_manager._tools for its snapshot/restore fixture and on await mcp.get_tools() for its assertions. Neither exists on fastmcp>=3. Rewrote the fixture to snapshot via await mcp.list_tools() + restore via mcp.add_tool(), and the assertions to use list_tools() directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- code_review_graph/main.py | 28 ++++++++++++++++++--- tests/test_main.py | 51 ++++++++++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/code_review_graph/main.py b/code_review_graph/main.py index 733336e1..2c5c44f2 100644 --- a/code_review_graph/main.py +++ b/code_review_graph/main.py @@ -934,6 +934,7 @@ def _apply_tool_filter(tools: str | None = None) -> None: # via env var CRG_TOOLS=query_graph_tool,semantic_search_nodes_tool """ + import asyncio import os raw = tools or os.environ.get("CRG_TOOLS") @@ -942,10 +943,31 @@ def _apply_tool_filter(tools: str | None = None) -> None: allowed = {t.strip() for t in raw.split(",") if t.strip()} if not allowed: return - registered = list(mcp._tool_manager._tools.keys()) - for name in registered: + # FastMCP >=3 exposes tool enumeration via the async ``list_tools`` + # method. ``_apply_tool_filter`` is typically called from + # ``main()`` before the MCP event loop starts, but tests may invoke + # it from within a running event loop — in that case ``asyncio.run`` + # raises ``RuntimeError``. Fall back to running the coroutine on a + # dedicated short-lived loop in a worker thread. Earlier code path + # relied on ``mcp._tool_manager._tools`` which is a private + # attribute that was removed in fastmcp>=3.0. + def _list_tool_names() -> list[str]: + coro_factory = mcp.list_tools + try: + asyncio.get_running_loop() + except RuntimeError: + return [t.name for t in asyncio.run(coro_factory())] + import concurrent.futures + + def _runner() -> list[str]: + return [t.name for t in asyncio.run(coro_factory())] + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(_runner).result() + + for name in _list_tool_names(): if name not in allowed: - mcp.remove_tool(name) + mcp.local_provider.remove_tool(name) diff --git a/tests/test_main.py b/tests/test_main.py index 61fbbf49..1a536c47 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -16,6 +16,17 @@ from code_review_graph import main as crg_main +@pytest.fixture(autouse=True) +def _isolate_crg_tools_env(monkeypatch): + """Always strip CRG_TOOLS so that any test invoking ``crg_main.main`` + does not accidentally permanently shrink the global tool registry + when the suite runs under a developer environment that exports + ``CRG_TOOLS``. Without this the snapshot/restore in + ``TestApplyToolFilter._restore_tools`` only sees the already-filtered + set and cannot restore the dropped tools.""" + monkeypatch.delenv("CRG_TOOLS", raising=False) + + class TestResolveRepoRoot: """Precedence rules for _resolve_repo_root (see #222 follow-up).""" @@ -59,7 +70,7 @@ def fake_run(**kwargs): monkeypatch.setattr(crg_main.mcp, "run", fake_run) crg_main.main(repo_root=None) - assert calls == [{"transport": "stdio"}] + assert calls == [{"transport": "stdio", "show_banner": False}] def test_http_calls_mcp_run_with_host_port(self, monkeypatch): calls: list[dict] = [] @@ -225,25 +236,37 @@ class TestApplyToolFilter: def _restore_tools(self): """Snapshot registered tools before test, restore after. - _apply_tool_filter calls ``mcp.remove_tool()`` which is - permanent. We restore by re-adding from the saved snapshot. + ``_apply_tool_filter`` calls ``mcp.remove_tool()`` which is + permanent. We snapshot the list of Tool objects via the public + ``list_tools()`` async API (FastMCP >=3) and re-register them + after the test body runs. """ - original = dict(crg_main.mcp._tool_manager._tools) + import asyncio + + original = asyncio.run(crg_main.mcp.list_tools()) yield - crg_main.mcp._tool_manager._tools.clear() - crg_main.mcp._tool_manager._tools.update(original) + current_names = { + t.name for t in asyncio.run(crg_main.mcp.list_tools()) + } + for tool in original: + if tool.name not in current_names: + crg_main.mcp.add_tool(tool) @pytest.fixture(autouse=True) def _clean_env(self, monkeypatch): """Ensure CRG_TOOLS is not set from the outer environment.""" monkeypatch.delenv("CRG_TOOLS", raising=False) + @staticmethod + async def _tool_names() -> set[str]: + return {t.name for t in await crg_main.mcp.list_tools()} + @pytest.mark.asyncio async def test_no_filter_keeps_all_tools(self): """When neither --tools nor CRG_TOOLS is set, all tools remain.""" - before = set((await crg_main.mcp.get_tools()).keys()) + before = await self._tool_names() crg_main._apply_tool_filter(None) - after = set((await crg_main.mcp.get_tools()).keys()) + after = await self._tool_names() assert before == after @pytest.mark.asyncio @@ -251,7 +274,7 @@ async def test_filter_via_argument(self): """The ``tools`` argument keeps only the listed tools.""" keep = "query_graph_tool,semantic_search_nodes_tool" crg_main._apply_tool_filter(keep) - remaining = set((await crg_main.mcp.get_tools()).keys()) + remaining = await self._tool_names() assert remaining == {"query_graph_tool", "semantic_search_nodes_tool"} @pytest.mark.asyncio @@ -259,7 +282,7 @@ async def test_filter_via_env_var(self, monkeypatch): """The ``CRG_TOOLS`` env var works as fallback.""" monkeypatch.setenv("CRG_TOOLS", "query_graph_tool") crg_main._apply_tool_filter(None) - remaining = set((await crg_main.mcp.get_tools()).keys()) + remaining = await self._tool_names() assert remaining == {"query_graph_tool"} @pytest.mark.asyncio @@ -267,21 +290,21 @@ async def test_argument_takes_precedence_over_env(self, monkeypatch): """CLI --tools wins over CRG_TOOLS env var.""" monkeypatch.setenv("CRG_TOOLS", "list_repos_tool") crg_main._apply_tool_filter("query_graph_tool") - remaining = set((await crg_main.mcp.get_tools()).keys()) + remaining = await self._tool_names() assert remaining == {"query_graph_tool"} @pytest.mark.asyncio async def test_empty_string_is_noop(self): """An empty string should not remove all tools.""" - before = set((await crg_main.mcp.get_tools()).keys()) + before = await self._tool_names() crg_main._apply_tool_filter("") - after = set((await crg_main.mcp.get_tools()).keys()) + after = await self._tool_names() assert before == after @pytest.mark.asyncio async def test_whitespace_handling(self): """Spaces around tool names are stripped.""" crg_main._apply_tool_filter(" query_graph_tool , semantic_search_nodes_tool ") - remaining = set((await crg_main.mcp.get_tools()).keys()) + remaining = await self._tool_names() assert remaining == {"query_graph_tool", "semantic_search_nodes_tool"} From ac8e119dc1ca8370cb405b0ed48f247d2a2debc6 Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 22 Apr 2026 17:34:22 +0800 Subject: [PATCH 13/13] Fix C++ method name extraction for scoped/destructor/operator definitions _get_name() returned wrong or missing names for C++ method definitions whose name is expressed via qualified_identifier (Class::method), destructor_name (~Class), or operator_name (operator==): - Ret Class::method() -> stored as "Ret" (return type) - void Class::method() -> silently dropped (no node emitted) - Class::~Class() -> silently dropped - bool Class::operator==() -> silently dropped Root cause: after recursing into function_declarator, the generic identifier loop only recognises identifier/name/type_identifier/ property_identifier/simple_identifier/constant. qualified_identifier, destructor_name, and operator_name are not in that list, so the recursive call returns None and the outer call falls through to the generic loop on function_definition, matching the sibling return-type type_identifier as the function name (or nothing, for void). Fix: handle qualified_identifier (peeling nested scopes for Outer::Inner::method), destructor_name, operator_name, and field_identifier explicitly inside function_declarator, before the generic fallthrough. Verified on a real-world Qt C++ codebase (~2,770 files): node count 6,903 -> 23,815 (3.4x), edges 42,997 -> 182,077 (4.2x). Caller-graph queries that returned empty before now return correct caller lists. --- code_review_graph/parser.py | 39 +++++++++++++++++++++++++++++++++++ tests/test_parser.py | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index f681263a..12427d71 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -3978,6 +3978,45 @@ def _get_name(self, node, language: str, kind: str) -> Optional[str]: result = self._get_name(child, language, kind) if result: return result + # C++: inside function_declarator, the name appears as + # qualified_identifier (Class::method), destructor_name (~Class), + # operator_name (operator==), or field_identifier. The generic + # loop below only recognizes 'identifier'/'type_identifier', + # so scoped method definitions would otherwise fall through and + # match the outer return-type type_identifier as the function name. + # Nested scopes (Outer::Inner::method) produce nested + # qualified_identifier nodes — peel until we find the leaf name. + if language == "cpp" and node.type == "function_declarator": + def _leaf_name(qi): + # Walk right-to-left: the rightmost identifier/ + # destructor_name/operator_name is the method name. + # If the rightmost child is itself a qualified_identifier + # (nested scope), recurse into it. + for sub in reversed(qi.children): + if sub.type in ( + "identifier", + "destructor_name", + "operator_name", + ): + return sub.text.decode( + "utf-8", errors="replace") + if sub.type == "qualified_identifier": + inner = _leaf_name(sub) + if inner: + return inner + return None + for child in node.children: + if child.type == "qualified_identifier": + name = _leaf_name(child) + if name: + return name + if child.type in ( + "field_identifier", + "destructor_name", + "operator_name", + ): + return child.text.decode( + "utf-8", errors="replace") # Objective-C method_definition: the method name is the first # ``identifier`` child (first part of the selector). Multi-part diff --git a/tests/test_parser.py b/tests/test_parser.py index b38ff11b..9c946f03 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1209,3 +1209,44 @@ def test_elixir_top_level_dotted_call_attributes_to_file(self): and e.target.endswith("puts") ] assert len(top_level) == 1 + + def test_cpp_scoped_method_names(self, tmp_path): + """C++ scoped method definitions must extract the leaf method name, + not the return-type identifier. + + Regression: previously ``Ret Class::method()`` indexed as ``Ret`` + (return type) and ``void Class::method()`` was silently dropped + because _get_name() fell through to the generic identifier loop, + which did not recognise qualified_identifier, destructor_name, or + operator_name nodes inside function_declarator. + """ + src = b""" +void PlaybackExtension::resetStateForPool() {} +quint64 PlaybackExtension::startTimestamp() const { return 0; } +PlaybackExtension::~PlaybackExtension() {} +~PlaybackExtension() {} +bool operator==(const A& a, const B& b) { return true; } +bool MyClass::operator<(const MyClass& o) const { return true; } +void foo() {} +int SnapshotController::getHandleIndex() { return 0; } +bool PlaybackWidget::AllocateResourceStrategy::allocateExtensionResource(int i) { return true; } +void A::B::C::deep() {} +ExtensionID PlaybackExtension::ID() const { return {}; } +""" + p = tmp_path / "x.cpp" + p.write_bytes(src) + nodes, _ = self.parser.parse_file(p) + names = [n.name for n in nodes if n.kind == "Function"] + assert names == [ + "resetStateForPool", + "startTimestamp", + "~PlaybackExtension", + "~PlaybackExtension", + "operator==", + "operator<", + "foo", + "getHandleIndex", + "allocateExtensionResource", + "deep", + "ID", + ]