diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fa1df316..456639297 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,15 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- Fix: type-reference / inheritance edge gaps closed across seven languages (all thanks @Synvoya):
+ - Scala: `var` field declarations now emit type `references` like `val` (#1587).
+ - PowerShell: class base types after `:` now emit `inherits` (first) / `implements` (rest), matching the C# convention (#1588).
+ - Objective-C: protocol-to-protocol adoption (`@protocol Derived `) now emits an `implements` edge (#1589).
+ - PHP: promoted constructor properties (`__construct(private Repo $r)`) now emit type `references` (method + class field) (#1590).
+ - C#: auto-properties (`public Widget Main { get; set; }`) now emit type `references` like fields, including generic args (#1591).
+ - C++: base-class template arguments (`class Car : Base`) now emit `generic_arg` references, matching the Java behavior (#1592).
+ - Swift: enum associated-value types (`case started(Session)`) now emit `references` (#1593).
+- Fix: cross-file name resolution now respects case in case-sensitive languages (#1581, thanks @sheik-hiiobd). Resolution matched identifiers case-insensitively for every language, so in Python/Rust/Go/Java/etc. `from pathlib import Path` resolved to an unrelated shell-script `export PATH=...` node — a single variable becoming the corpus's #1 god-node (266 false incoming edges on one real repo), inflating god-node rankings, `affected` blast-radius, and community assignment. Both the cross-file call resolver and the type-reference stub-rewire now match by exact case; only genuinely case-insensitive languages (PHP functions/classes, SQL, Nim) still fold. For case-sensitive languages this only ever removes false edges.
- Fix: Julia qualified / relative / scoped-selected imports now emit edges (#1580, thanks @Synvoya). Only bare `using Foo` was handled; `using Base.Threads` (scoped), `using ..Parent` (relative import_path), and the scoped package of `import Base.Threads: nthreads` were dropped.
- Fix: Rust tuple-struct field types now emit `references` edges (#1582, thanks @Synvoya). `struct Wrapper(Logger, Vec);` referenced nothing — positional fields nest under `ordered_field_declaration_list` with no `field_declaration` wrapper, the same shape as tuple enum variants (#1579); that path wasn't traversed for structs.
- Fix: SystemVerilog class properties with leading qualifiers now emit field `references` (#1583, thanks @Synvoya). The field regex only matched unqualified ` ;`, so `rand Config x;` / `protected Base b;` (qualifier + type + name) failed to match and their type references were dropped.
diff --git a/graphify/extract.py b/graphify/extract.py
index 6e8ab9964..4a99636b0 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -2598,16 +2598,37 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path:
def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
nodes: list, edges: list, seen_ids: set, function_bodies: list,
- parent_class_nid: str | None, add_node_fn, add_edge_fn) -> bool:
+ parent_class_nid: str | None, add_node_fn, add_edge_fn,
+ ensure_named_node_fn) -> bool:
"""Handle enum_entry for Swift. Returns True if handled."""
if node.type == "enum_entry" and parent_class_nid:
+ line = node.start_point[0] + 1
for child in node.children:
if child.type == "simple_identifier":
case_name = _read_text(child, source)
case_nid = _make_id(parent_class_nid, case_name)
- line = node.start_point[0] + 1
add_node_fn(case_nid, case_name, line)
add_edge_fn(parent_class_nid, case_nid, "case_of", line)
+ # Associated-value types nest as `enum_type_parameters -> user_type ->
+ # type_identifier` (a sibling of the case-name simple_identifier). The
+ # case-name loop above never descends into them, so `case started(Session)`
+ # used to drop the Event -> Session reference entirely. Mirror the Swift
+ # property/parameter emit style: collect the type refs and emit a
+ # `references` edge from the ENUM node to each collected type.
+ for child in node.children:
+ if child.type != "enum_type_parameters":
+ continue
+ for grand in child.children:
+ if not grand.is_named:
+ continue
+ refs: list[tuple[str, str]] = []
+ _swift_collect_type_refs(grand, source, False, refs)
+ for ref_name, role in refs:
+ ctx = "generic_arg" if role == "generic_arg" else "type"
+ target_nid = ensure_named_node_fn(ref_name, line)
+ if target_nid != parent_class_nid:
+ add_edge_fn(parent_class_nid, target_nid, "references",
+ line, context=ctx)
return True
return False
@@ -3668,6 +3689,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
continue
for sub in child.children:
base = ""
+ template_args_node = None
if sub.type == "type_identifier":
base = _read_text(sub, source)
elif sub.type == "qualified_identifier":
@@ -3679,6 +3701,12 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
elif sub.type == "template_type":
tname = sub.child_by_field_name("name")
base = _read_text(tname, source) if tname else _read_text(sub, source)
+ # The base's template_argument_list carries generic
+ # type arguments (class Car : public Base). The
+ # Java handler (_emit_java_parent_type) emits these as
+ # generic_arg references; C++ dropped them because we
+ # only emitted the `inherits` edge on the base name.
+ template_args_node = sub.child_by_field_name("arguments")
else:
continue
if not base:
@@ -3696,6 +3724,19 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
+ # Emit a generic_arg reference for each type argument on the
+ # base (Base -> Car references Dep). _cpp_collect_type_refs
+ # handles nested/qualified args (Base>) too.
+ if template_args_node is not None:
+ arg_refs: list[tuple[str, str]] = []
+ for arg in template_args_node.children:
+ if arg.is_named:
+ _cpp_collect_type_refs(arg, source, True, arg_refs)
+ for ref_name, _role in arg_refs:
+ target_nid = ensure_named_node(ref_name, line)
+ if target_nid != class_nid:
+ add_edge(class_nid, target_nid, "references",
+ line, context="generic_arg")
# Find body and recurse
body = _find_body(node, config)
@@ -3786,6 +3827,35 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
"references", line, context="field", metadata=metadata)
return
+ if (config.ts_module == "tree_sitter_c_sharp"
+ and t == "property_declaration"
+ and parent_class_nid):
+ # C# auto-properties (`public Widget Main { get; set; }`) are the
+ # idiomatic way to declare state, yet only field_declaration was
+ # handled — so property types produced no references edge. Unlike a
+ # field, a property exposes its type on the node directly (no
+ # variable_declaration wrapper), so read it straight off the `type`
+ # field. Use _csharp_collect_type_refs (like the Java/PHP/Kotlin
+ # siblings) so `List` yields both the List field ref and the
+ # Widget generic_arg ref.
+ type_node = node.child_by_field_name("type")
+ if type_node is not None:
+ line = node.start_point[0] + 1
+ refs: list[tuple[str, str, bool, str]] = []
+ _csharp_collect_type_refs(type_node, source, False, refs)
+ for ref_name, role, qualified, qualifier in refs:
+ ctx = "generic_arg" if role == "generic_arg" else "field"
+ target_nid = ensure_named_node(ref_name, line)
+ if target_nid != parent_class_nid:
+ metadata = {"ref_token": ref_name}
+ if qualified:
+ metadata["qualified"] = True
+ if qualifier:
+ metadata["ref_qualifier"] = qualifier
+ add_edge(parent_class_nid, target_nid, "references",
+ line, context=ctx, metadata=metadata)
+ return
+
if (config.ts_module == "tree_sitter_java"
and t == "field_declaration"
and parent_class_nid):
@@ -3868,7 +3938,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
return
if (config.ts_module == "tree_sitter_scala"
- and t == "val_definition"
+ and t in ("val_definition", "var_definition")
and parent_class_nid):
type_node = node.child_by_field_name("type")
if type_node is not None:
@@ -4069,8 +4139,14 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
break
if params_container is not None:
for p in params_container.children:
- if p.type != "simple_parameter":
+ # PHP 8 constructor property promotion (`__construct(private
+ # Repo $repo)`) parses the promoted param as
+ # property_promotion_parameter, not simple_parameter. Its
+ # type sits in the same direct named child shape, so accept
+ # both here; a promoted param is additionally a class field.
+ if p.type not in ("simple_parameter", "property_promotion_parameter"):
continue
+ is_promoted = p.type == "property_promotion_parameter"
type_node = None
for sub in p.children:
if sub.type in ("named_type", "primitive_type", "nullable_type",
@@ -4084,6 +4160,13 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
target_nid = ensure_named_node(ref_name, line)
if target_nid != func_nid:
add_edge(func_nid, target_nid, "references", line, context=ctx)
+ # A promoted param declares a real class field; mirror
+ # the property_declaration field-context edge so the
+ # type is discoverable as a class field too.
+ if is_promoted and parent_class_nid and target_nid != parent_class_nid:
+ fctx = "generic_arg" if role == "generic_arg" else "field"
+ add_edge(parent_class_nid, target_nid, "references",
+ line, context=fctx)
return_node = _php_method_return_type_node(node)
if return_node is not None:
refs = []
@@ -4307,7 +4390,8 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
if config.ts_module == "tree_sitter_swift":
if _swift_extra_walk(node, source, file_nid, stem, str_path,
nodes, edges, seen_ids, function_bodies,
- parent_class_nid, add_node, add_edge):
+ parent_class_nid, add_node, add_edge,
+ ensure_named_node):
return
# Python's `@property` / `@staticmethod` / `@classmethod` wrap the
@@ -8495,6 +8579,22 @@ def walk(node, parent_class_nid: str | None = None) -> None:
class_nid = _make_id(stem, class_name)
add_node(class_nid, class_name, line)
add_edge(file_nid, class_nid, "contains", line)
+ # Base type(s) after ':'. PowerShell has no syntactic base vs
+ # interface split, so (matching the C# convention) treat the
+ # first base as the superclass (inherits) and the rest as
+ # interfaces (implements). Bases are the simple_name children
+ # after the ':' token.
+ colon_seen = False
+ base_index = 0
+ for child in node.children:
+ if child.type == ":":
+ colon_seen = True
+ elif colon_seen and child.type == "simple_name":
+ base_nid = ensure_named_node(_read_text(child, source), line)
+ if base_nid != class_nid:
+ rel = "inherits" if base_index == 0 else "implements"
+ add_edge(class_nid, base_nid, rel, line)
+ base_index += 1
for child in node.children:
walk(child, parent_class_nid=class_nid)
return
@@ -9032,9 +9132,27 @@ def _canonicalize_csharp_namespace_nodes(all_nodes: list[dict], all_edges: list[
all_nodes[:] = [node for node in all_nodes if id(node) not in drop_node_ids]
-def _node_label_key(node: dict) -> str:
+# Languages whose identifiers are case-insensitive, so cross-file name resolution
+# may fold case. Everywhere else, case is semantic (`Path` the class vs `PATH` the
+# env var are distinct) and folding manufactures false edges / super-hubs (#1581).
+_CASE_INSENSITIVE_EXTS = frozenset({
+ ".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", # PHP fns/classes
+ ".sql", # SQL identifiers
+ ".nim", ".nims", ".nimble", # Nim (style-insensitive)
+})
+
+
+def _lang_is_case_insensitive(source_file: object) -> bool:
+ """True when the file's language resolves identifiers case-insensitively (#1581)."""
+ if not source_file:
+ return False
+ return Path(str(source_file)).suffix.lower() in _CASE_INSENSITIVE_EXTS
+
+
+def _node_label_key(node: dict, fold: bool = False) -> str:
label = str(node.get("label", "")).strip()
- return re.sub(r"[^a-zA-Z0-9]+", "", label).lower()
+ key = re.sub(r"[^a-zA-Z0-9]+", "", label)
+ return key.lower() if fold else key
def _is_type_like_definition(node: dict) -> bool:
@@ -9052,7 +9170,8 @@ def _is_type_like_definition(node: dict) -> bool:
def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None:
"""Map unresolved no-source stubs to a unique real definition with the same label."""
- real_by_label: dict[str, list[dict]] = {}
+ real_by_label: dict[str, list[dict]] = {} # exact-case (all languages)
+ real_by_label_ci: dict[str, list[dict]] = {} # case-INSENSITIVE-language reals only
stubs: list[dict] = []
for node in nodes:
@@ -9061,7 +9180,13 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None:
continue
if node.get("source_file"):
if _is_type_like_definition(node):
+ # Match stubs case-SENSITIVELY: a `Path` reference must not rewire to a
+ # `PATH` env var (#1581). Fold only for genuinely case-insensitive
+ # languages, where `foo` legitimately resolves to `Foo`.
real_by_label.setdefault(key, []).append(node)
+ if _lang_is_case_insensitive(node.get("source_file")):
+ real_by_label_ci.setdefault(
+ _node_label_key(node, fold=True), []).append(node)
continue
stubs.append(node)
@@ -9072,7 +9197,12 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None:
continue
candidates = real_by_label.get(_node_label_key(stub), [])
if len(candidates) != 1:
- continue
+ # No unique exact match — fall back to a case-insensitive match, but
+ # only against case-insensitive-language definitions (so a case-sensitive
+ # `PATH` can never absorb a `Path` reference).
+ candidates = real_by_label_ci.get(_node_label_key(stub, fold=True), [])
+ if len(candidates) != 1:
+ continue
target_id = candidates[0].get("id")
if isinstance(target_id, str) and target_id and target_id != stub_id:
remap[stub_id] = target_id
@@ -11579,6 +11709,18 @@ def walk(node, parent_nid: str | None = None) -> None:
proto_nid = _make_id(stem, name)
add_node(proto_nid, f"<{name}>", line)
add_edge(file_nid, proto_nid, "contains", line)
+ # Adopted protocols: `@protocol Derived `. These
+ # nest under a protocol_reference_list node (distinct from the
+ # parameterized_arguments node used by @interface adoption), so
+ # they were never emitted. Emit an `implements` edge for each,
+ # matching how @interface protocol adoption is handled.
+ for child in node.children:
+ if child.type == "protocol_reference_list":
+ for sub in child.children:
+ if sub.type == "identifier":
+ base_nid = ensure_named_node(_read(sub), line)
+ if base_nid != proto_nid:
+ add_edge(proto_nid, base_nid, "implements", line)
for child in node.children:
walk(child, proto_nid)
return
@@ -15464,15 +15606,21 @@ def extract(
# Build label -> node_id index for cross-file call resolution.
# Skip rationale nodes (their labels are docstring text, not callable
# identifiers, and they were polluting matches for short names — #563).
- global_label_to_nids: dict[str, list[str]] = {}
+ global_label_to_nids: dict[str, list[str]] = {} # exact-case (all languages)
+ global_label_to_nids_ci: dict[str, list[str]] = {} # case-INSENSITIVE-language nodes
for n in all_nodes:
if n.get("file_type") == "rationale" or n.get("type") == "namespace":
continue
raw = n.get("label", "")
normalised = raw.strip("()").lstrip(".")
if normalised:
- key = normalised.lower()
- global_label_to_nids.setdefault(key, []).append(n["id"])
+ # Case is semantic in most languages, so index (and match, below) by exact
+ # case — folding collapses `Path` (class) into `PATH` (env var) and makes a
+ # single shell variable the #1 god-node (#1581). Only case-insensitive
+ # languages (PHP/SQL/Nim) also get a folded key for legitimate fold-matching.
+ global_label_to_nids.setdefault(normalised, []).append(n["id"])
+ if _lang_is_case_insensitive(n.get("source_file")):
+ global_label_to_nids_ci.setdefault(normalised.lower(), []).append(n["id"])
# Callable-def ids for the indirect_call callable guard, read from the `_callable`
# marker on the FINAL (post-remap) nodes — so a callback resolves only to a real
@@ -15532,7 +15680,13 @@ def extract(
# and collides with any top-level function named "log" in the corpus.
if rc.get("is_member_call"):
continue
- candidates = global_label_to_nids.get(callee.lower(), [])
+ # Exact-case match first (case is semantic). Fold only when the CALLING
+ # file's language is case-insensitive, and only against the folded index of
+ # case-insensitive-language definitions — so a Python `Path()` call can never
+ # resolve to a shell `PATH` node (#1581).
+ candidates = global_label_to_nids.get(callee, [])
+ if not candidates and _lang_is_case_insensitive(rc.get("source_file")):
+ candidates = global_label_to_nids_ci.get(callee.lower(), [])
if not candidates:
continue
caller = rc["caller_nid"]
diff --git a/tests/fixtures/sample.cpp b/tests/fixtures/sample.cpp
index f48f83355..aa68e6ec7 100644
--- a/tests/fixtures/sample.cpp
+++ b/tests/fixtures/sample.cpp
@@ -36,6 +36,17 @@ struct RetryingHttpClient : HttpClient {
int maxRetries;
};
+template
+class Connection {
+public:
+ T resource;
+};
+
+class PooledClient : public Connection {
+public:
+ int poolSize;
+};
+
int main() {
HttpClient client("https://api.example.com");
std::string response = client.get("/users");
diff --git a/tests/fixtures/sample.cs b/tests/fixtures/sample.cs
index 4ee1b06e1..729651c61 100644
--- a/tests/fixtures/sample.cs
+++ b/tests/fixtures/sample.cs
@@ -21,6 +21,10 @@ public class DataProcessor : Processor, IProcessor
{
private readonly HttpClient _client;
+ public Processor Owner { get; set; }
+
+ public List Workers { get; set; }
+
public DataProcessor()
{
_client = new HttpClient();
diff --git a/tests/fixtures/sample.m b/tests/fixtures/sample.m
index 2f1209a4b..4fd0f9374 100644
--- a/tests/fixtures/sample.m
+++ b/tests/fixtures/sample.m
@@ -40,3 +40,15 @@ - (void)fetch {
}
@end
+
+@protocol Base
+
+- (void)baseMethod;
+
+@end
+
+@protocol Derived
+
+- (void)derivedMethod;
+
+@end
diff --git a/tests/fixtures/sample.php b/tests/fixtures/sample.php
index 1397f5631..5ff337af4 100644
--- a/tests/fixtures/sample.php
+++ b/tests/fixtures/sample.php
@@ -66,6 +66,13 @@ public function log(): void
}
}
+class Service
+{
+ public function __construct(private Result $result, string $label)
+ {
+ }
+}
+
function parseResponse(string $raw): array
{
return json_decode($raw, true);
diff --git a/tests/fixtures/sample.ps1 b/tests/fixtures/sample.ps1
index 2cdb6aa78..43c27fd7d 100644
--- a/tests/fixtures/sample.ps1
+++ b/tests/fixtures/sample.ps1
@@ -30,3 +30,19 @@ class DataProcessor {
Set-Content -Path $path -Value $this.Source
}
}
+
+class Shape {
+ [string]$Kind
+
+ [double] Area() {
+ return 0.0
+ }
+}
+
+class Circle : Shape {
+ [double]$Radius
+
+ [double] Area() {
+ return 3.14159 * $this.Radius * $this.Radius
+ }
+}
diff --git a/tests/fixtures/sample.scala b/tests/fixtures/sample.scala
index 95755a877..8a35888d8 100644
--- a/tests/fixtures/sample.scala
+++ b/tests/fixtures/sample.scala
@@ -7,6 +7,7 @@ abstract class BaseClient
class HttpClient(config: Config) extends BaseClient with Loggable {
val source: Config = config
+ var fallback: BaseClient = null
def get(path: String): String = {
buildRequest("GET", path)
diff --git a/tests/fixtures/sample.swift b/tests/fixtures/sample.swift
index 0a51d2fa1..64d5a42eb 100644
--- a/tests/fixtures/sample.swift
+++ b/tests/fixtures/sample.swift
@@ -51,6 +51,7 @@ enum NetworkError {
case timeout
case connectionFailed
case unauthorized
+ case failed(Config)
func describe() -> String {
return "error"
diff --git a/tests/test_case_sensitive_resolution.py b/tests/test_case_sensitive_resolution.py
new file mode 100644
index 000000000..5838b02bc
--- /dev/null
+++ b/tests/test_case_sensitive_resolution.py
@@ -0,0 +1,87 @@
+"""Cross-file name resolution respects case in case-sensitive languages (#1581).
+
+Case is semantic in most languages: `Path` (a class), `PATH` (an env var), and
+`path` (a variable) are distinct. Cross-file resolution used to fold case for every
+language, so `from pathlib import Path` (ubiquitous) resolved to a shell script's
+`export PATH=...` node — turning one shell variable into the corpus's #1 god-node.
+
+These tests pin: case-sensitive languages match by exact case (removing that false
+edge), while genuinely case-insensitive languages (PHP) still fold.
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from graphify.extract import extract
+
+
+def _extract(tmp_path, files: dict[str, str]):
+ for name, body in files.items():
+ (tmp_path / name).write_text(body)
+ old = os.getcwd()
+ try:
+ os.chdir(tmp_path)
+ r = extract([Path(n) for n in files], cache_root=tmp_path)
+ finally:
+ os.chdir(old)
+ return r
+
+
+def _labels(r):
+ return {n["id"]: n["label"] for n in r["nodes"]}
+
+
+def test_python_Path_does_not_resolve_to_shell_PATH(tmp_path):
+ r = _extract(tmp_path, {
+ "run.sh": "export PATH=/usr/local/bin:$PATH\n",
+ "mod.py": (
+ "from pathlib import Path\n"
+ "def load(p: Path) -> Path:\n return Path(p)\n"
+ "def other():\n return load(Path('x'))\n"
+ ),
+ })
+ lbl = _labels(r)
+ path_nid = next((n["id"] for n in r["nodes"] if n["label"] == "PATH"), None)
+ assert path_nid is not None
+ # No edge from the Python functions should land on the shell PATH node
+ false_edges = [
+ e for e in r["edges"]
+ if e["target"] == path_nid and lbl.get(e["source"], "").startswith(("load", "other"))
+ ]
+ assert not false_edges, f"Python Path leaked onto shell PATH: {false_edges}"
+ # PATH keeps only its own `defines` edge (from run.sh), not a false super-hub
+ assert sum(1 for e in r["edges"] if e["target"] == path_nid) <= 1
+
+
+def test_case_sensitive_cross_file_ref_respects_case(tmp_path):
+ r = _extract(tmp_path, {
+ "consts.rs": 'pub const PATH: &str = "/x";\n',
+ "use.rs": "struct Wrap(Path);\n", # `Path` — no such node in the corpus
+ })
+ lbl = _labels(r)
+ path_nid = next((n["id"] for n in r["nodes"] if n["label"] == "PATH"), None)
+ xref = [e for e in r["edges"] if e["target"] == path_nid and lbl.get(e["source"]) == "Wrap"]
+ assert not xref, "a `Path` reference must not resolve to a case-differing `PATH`"
+
+
+def test_exact_case_cross_file_still_resolves(tmp_path):
+ r = _extract(tmp_path, {
+ "h.py": "def helper():\n return 1\n",
+ "m.py": "from h import helper\ndef go():\n return helper()\n",
+ })
+ lbl = _labels(r)
+ calls = {(lbl.get(e["source"]), lbl.get(e["target"]))
+ for e in r["edges"] if e["relation"] == "calls"}
+ assert ("go()", "helper()") in calls
+
+
+def test_php_case_insensitive_resolution_preserved(tmp_path):
+ r = _extract(tmp_path, {
+ "lib.php": "` must emit the inherits
+ edge to Connection AND a generic_arg reference to the HttpClient type argument,
+ matching the Java base-class behaviour (_emit_java_parent_type)."""
+ r = extract_cpp(FIXTURES / "sample.cpp")
+ assert ("PooledClient", "Connection") in _edge_labels(r, "inherits")
+ assert ("PooledClient", "HttpClient") in _edge_labels(r, "references", "generic_arg")
+
+
# ── CUDA ──────────────────────────────────────────────────────────────────────
# CUDA is a C++ superset, so .cu/.cuh route through the C++ (tree-sitter-cpp)
# extractor. These tests guard that __global__/__device__ kernels, host
@@ -542,6 +551,17 @@ def test_csharp_field_type_references_have_field_context():
), "DataProcessor field declarations should reference HttpClient with field context"
+def test_csharp_property_type_references_have_field_context():
+ r = extract_csharp(FIXTURES / "sample.cs")
+ field_refs = _edge_labels(r, "references", "field")
+ # `public Processor Owner { get; set; }` — property type -> field ref.
+ assert ("DataProcessor", "Processor") in field_refs
+ # `public List Workers { get; set; }` — the List container -> field.
+ assert ("DataProcessor", "List") in field_refs
+ # ...and the generic argument -> generic_arg.
+ assert ("DataProcessor", "Processor") in _edge_labels(r, "references", "generic_arg")
+
+
def test_csharp_call_edges_have_call_context():
r = extract_csharp(FIXTURES / "sample.cs")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
@@ -655,6 +675,11 @@ def test_scala_val_definition_field_context():
assert ("HttpClient", "Config") in _edge_labels(r, "references", "field")
+def test_scala_var_definition_field_context():
+ r = extract_scala(FIXTURES / "sample.scala")
+ assert ("HttpClient", "BaseClient") in _edge_labels(r, "references", "field")
+
+
def test_scala_method_return_type_context():
r = extract_scala(FIXTURES / "sample.scala")
assert ("create", "HttpClient") in _edge_labels(r, "references", "return_type")
@@ -772,6 +797,16 @@ def test_php_property_parameter_and_return_contexts():
assert ("run", "Result") in _edge_labels(r, "references", "return_type")
+def test_php_constructor_property_promotion_contexts():
+ # PHP 8 constructor property promotion: a promoted param is both a
+ # constructor parameter (parameter_type) and a class field (field).
+ r = extract_php(FIXTURES / "sample.php")
+ assert ("Service", "Result") in _edge_labels(r, "references", "field")
+ assert ("__construct", "Result") in _edge_labels(r, "references", "parameter_type")
+ # A non-promoted param must not leak a field edge onto the class.
+ assert ("Service", "string") not in _edge_labels(r, "references", "field")
+
+
# ── Swift ────────────────────────────────────────────────────────────────────
def test_swift_no_error():
@@ -865,6 +900,10 @@ def test_swift_enum_cases_have_case_of_edge():
case_edges = [e for e in r["edges"] if e["relation"] == "case_of"]
assert len(case_edges) >= 2
+def test_swift_enum_associated_value_type_emits_references():
+ r = extract_swift(FIXTURES / "sample.swift")
+ assert ("NetworkError", "Config") in _edge_labels(r, "references", "type")
+
def test_swift_finds_deinit():
r = extract_swift(FIXTURES / "sample.swift")
assert any("deinit" in l for l in _labels(r))
@@ -1059,6 +1098,16 @@ def test_objc_splits_inherits_and_implements():
assert ("Animal", "SampleDelegate") in _edge_labels(r, "implements")
+def test_objc_protocol_adopts_protocol():
+ """`@protocol Derived ` must emit an implements edge Derived->Base.
+ Protocol-on-protocol adoption nests under a protocol_reference_list node
+ (distinct from the parameterized_arguments node used by @interface
+ adoption), so the edge was previously dropped. Protocol nodes are labeled
+ ``, so the edge reads (, )."""
+ r = extract_objc(FIXTURES / "sample.m")
+ assert ("", "") in _edge_labels(r, "implements")
+
+
def test_objc_property_type_context():
r = extract_objc(FIXTURES / "sample.m")
assert ("Animal", "NSString") in _edge_labels(r, "references", "field")
@@ -1626,6 +1675,13 @@ def test_powershell_finds_class_and_method():
assert any("Transform" in l for l in labels)
+def test_powershell_class_base_type_emits_inherits_edge():
+ # `class Circle : Shape` — the base type after ':' was previously dropped
+ # because the handler only read the first simple_name (the class name).
+ r = extract_powershell(FIXTURES / "sample.ps1")
+ assert ("Circle", "Shape") in _edge_labels(r, "inherits")
+
+
def test_powershell_property_field_type_context():
r = extract_powershell(FIXTURES / "sample.ps1")
assert ("DataProcessor", "string") in _edge_labels(r, "references", "field")