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")