mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 08:46:43 +00:00
fix(extract): C# inline-declared + partial-class receivers, Kotlin anonymous-object members (#2346, #2332, #2347)
- C# receivers declared inline via out-var / is / case / switch-arm patterns are now typed into the per-method table, so their member calls resolve (#2346). - partial class halves across files now merge to one class node (new _merge_csharp_partial_class_nodes pass, mirroring the Swift-extension merge), so cross-half member calls resolve instead of splitting the class (#2332). - Kotlin anonymous-object (object : Foo {}) members now get nodes, contains/ implements edges, and their calls resolve (#2347). All in-corpus only, never a wrong edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
97656fae2d
commit
4a9613f726
@@ -2082,6 +2082,93 @@ def _merge_swift_extensions(
|
||||
all_edges[:] = rewritten
|
||||
|
||||
|
||||
def _merge_csharp_partial_class_nodes(
|
||||
per_file: list[dict],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Collapse C# `partial class Foo` halves split across files into ONE node
|
||||
(#2332).
|
||||
|
||||
The per-file extractor mints class ids with the file stem, so each file
|
||||
declaring `partial class Foo` produces its own `Foo` node: members split
|
||||
across the halves and cross-half calls don't resolve (two candidate types
|
||||
make every receiver-typed lookup bail as ambiguous). Group partial-stamped
|
||||
type nodes by (namespace, label) — same-named types in different namespaces
|
||||
are distinct types, non-partial same-named types are separate declarations,
|
||||
and nested partials are excluded (their ids omit the enclosing type, so a
|
||||
same-named nested pair under different outers would falsely merge). The
|
||||
canonical node is the sorted-first half by (source_file, source_location,
|
||||
id); every edge endpoint and raw-call caller is remapped onto it. Member
|
||||
node ids are left untouched — only the class-level nodes collapse.
|
||||
|
||||
Must run BEFORE _disambiguate_colliding_node_ids / _rewire_unique_stub_nodes /
|
||||
_resolve_csharp_type_references and the resolver registry, so every later
|
||||
pass sees one definition per partial type.
|
||||
"""
|
||||
groups: dict[tuple[str, str], list[dict]] = {}
|
||||
for n in all_nodes:
|
||||
if not str(n.get("source_file", "")).endswith(".cs"):
|
||||
continue
|
||||
if n.get("file_type") != "code":
|
||||
continue
|
||||
md = n.get("metadata") or {}
|
||||
if not md.get("is_partial") or md.get("is_nested_type"):
|
||||
continue
|
||||
label = n.get("label")
|
||||
if not label:
|
||||
continue
|
||||
groups.setdefault((str(md.get("namespace", "")), str(label)), []).append(n)
|
||||
|
||||
remap: dict[str, str] = {}
|
||||
for members in groups.values():
|
||||
if len(members) < 2:
|
||||
continue
|
||||
members.sort(key=lambda n: (
|
||||
str(n.get("source_file", "")),
|
||||
str(n.get("source_location", "")),
|
||||
str(n.get("id", "")),
|
||||
))
|
||||
canonical_nid = members[0]["id"]
|
||||
for other in members[1:]:
|
||||
if other["id"] != canonical_nid:
|
||||
remap[other["id"]] = canonical_nid
|
||||
|
||||
if not remap:
|
||||
return
|
||||
|
||||
all_nodes[:] = [n for n in all_nodes if n.get("id") not in remap]
|
||||
|
||||
# Each half's file keeps a `contains` edge to the canonical type — multiple
|
||||
# files containing one node is the intended shape (same as the Swift
|
||||
# extension merge): the type owns the members, the files own their slice.
|
||||
# Self-loops are dropped, exact duplicates dedup.
|
||||
rewritten: list[dict] = []
|
||||
seen_keys: set[tuple] = set()
|
||||
for e in all_edges:
|
||||
src = remap.get(e.get("source"), e.get("source"))
|
||||
tgt = remap.get(e.get("target"), e.get("target"))
|
||||
if src == tgt:
|
||||
continue
|
||||
e["source"] = src
|
||||
e["target"] = tgt
|
||||
key = (src, tgt, e.get("relation"), e.get("source_file"), e.get("source_location"))
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
rewritten.append(e)
|
||||
all_edges[:] = rewritten
|
||||
|
||||
# raw_calls carry caller_nid, consumed by the member-call resolvers and the
|
||||
# cross-file call pass after this merge — a top-level raw call whose caller
|
||||
# is a merged-away class half must follow it onto the canonical node.
|
||||
for result in per_file:
|
||||
for rc in result.get("raw_calls", []) or []:
|
||||
cn = rc.get("caller_nid")
|
||||
if cn in remap:
|
||||
rc["caller_nid"] = remap[cn]
|
||||
|
||||
|
||||
def _resolve_swift_member_calls(
|
||||
per_file: list[dict],
|
||||
all_nodes: list[dict],
|
||||
@@ -5088,6 +5175,7 @@ def extract(
|
||||
# graph is identical regardless of scan root (#2072).
|
||||
_repoint_python_package_imports(paths, all_nodes, all_edges, root)
|
||||
_merge_swift_extensions(per_file, all_nodes, all_edges)
|
||||
_merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges)
|
||||
_disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root)
|
||||
_canonicalize_csharp_namespace_nodes(all_nodes, all_edges)
|
||||
# PHP namespace/use disambiguation must run BEFORE the unique-stub rewire:
|
||||
|
||||
@@ -1547,6 +1547,23 @@ def _csharp_method_receiver_types(
|
||||
)
|
||||
break
|
||||
bind(_read_text(name_node, source), type_name)
|
||||
elif node.type in ("declaration_expression", "declaration_pattern"):
|
||||
# #2346: inline-declared receivers. `out Sect s` is a
|
||||
# declaration_expression; `is Leaf lf`, `is not Node nd`,
|
||||
# `case Twig tw:` and a switch-arm `Stem st =>` are
|
||||
# declaration_patterns — all carry `type` + `name` fields and
|
||||
# bind the name for the rest of the method. `out var v`
|
||||
# (implicit_type) yields None from _csharp_receiver_type_name
|
||||
# and poisons the name method-locally, matching the
|
||||
# untypable-local rule above (no guess).
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node is not None and name_node.type == "identifier":
|
||||
bind(
|
||||
_read_text(name_node, source),
|
||||
_csharp_receiver_type_name(
|
||||
node.child_by_field_name("type"), source
|
||||
),
|
||||
)
|
||||
stack.extend(node.children)
|
||||
|
||||
table = dict(field_types)
|
||||
@@ -2470,8 +2487,25 @@ def _extract_generic(
|
||||
class_nid = _make_id(stem, ".".join(namespace_stack), class_name)
|
||||
line = node.start_point[0] + 1
|
||||
metadata = None
|
||||
if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid:
|
||||
metadata = {"is_nested_type": True}
|
||||
if config.ts_module == "tree_sitter_c_sharp":
|
||||
if parent_class_nid:
|
||||
metadata = {"is_nested_type": True}
|
||||
# #2332: `partial class Foo` split across files mints one node
|
||||
# per file (the id carries the file stem). Stamp the halves so
|
||||
# the corpus-level _merge_csharp_partial_class_nodes pass can
|
||||
# collapse them onto one canonical node. Grammar: `partial` is
|
||||
# a `modifier` direct child of the type declaration.
|
||||
if t in (
|
||||
"class_declaration",
|
||||
"struct_declaration",
|
||||
"interface_declaration",
|
||||
"record_declaration",
|
||||
) and any(
|
||||
c.type == "modifier" and _read_text(c, source) == "partial"
|
||||
for c in node.children
|
||||
):
|
||||
metadata = dict(metadata or {})
|
||||
metadata["is_partial"] = True
|
||||
add_node(class_nid, class_name, line, metadata=metadata)
|
||||
callable_def_nids.add(class_nid) # a class is callable (constructor)
|
||||
callable_class_nids.add(class_nid) # ...but only via its constructor (#2137)
|
||||
@@ -3688,6 +3722,90 @@ def _extract_generic(
|
||||
if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid:
|
||||
csharp_method_scopes[id(body)] = (node, parent_class_nid)
|
||||
function_bodies.append((func_nid, body))
|
||||
if config.ts_module == "tree_sitter_kotlin":
|
||||
# #2347: Kotlin anonymous objects (`object : Foo { … }`,
|
||||
# node type `object_literal`). The function branch never
|
||||
# recurses into bodies and object_literal is not a
|
||||
# class_type, so the literal's members (and every call
|
||||
# inside them) got no nodes at all. Scan this body for
|
||||
# object_literal descendants — without crossing a nested
|
||||
# function_declaration boundary (a local fun's literals
|
||||
# are not this function's) and without descending into a
|
||||
# found literal — then emit an owner node per literal and
|
||||
# walk its class_body exactly like the class branch, so
|
||||
# members and their calls flow through the normal
|
||||
# machinery (walk_calls' function_boundary_types already
|
||||
# keep the enclosing function from absorbing them).
|
||||
_kt_literals = []
|
||||
_kt_stack = list(body.children)
|
||||
while _kt_stack:
|
||||
_kt_node = _kt_stack.pop()
|
||||
if _kt_node.type == "function_declaration":
|
||||
continue
|
||||
if _kt_node.type == "object_literal":
|
||||
_kt_literals.append(_kt_node)
|
||||
continue
|
||||
_kt_stack.extend(_kt_node.children)
|
||||
_kt_literals.sort(key=lambda n: n.start_byte)
|
||||
for lit in _kt_literals:
|
||||
lit_line = lit.start_point[0] + 1
|
||||
# Supertypes from the literal's delegation_specifiers,
|
||||
# shaped like the Kotlin class-branch handling:
|
||||
# constructor_invocation -> inherits, bare user_type
|
||||
# (or explicit_delegation) -> implements.
|
||||
lit_bases: list[tuple[str, str]] = []
|
||||
for dchild in lit.children:
|
||||
if dchild.type != "delegation_specifiers":
|
||||
continue
|
||||
for spec in dchild.children:
|
||||
if spec.type != "delegation_specifier":
|
||||
continue
|
||||
relation = "implements"
|
||||
user_type_node = None
|
||||
for sub in spec.children:
|
||||
if sub.type == "constructor_invocation":
|
||||
relation = "inherits"
|
||||
for inner in sub.children:
|
||||
if inner.type == "user_type":
|
||||
user_type_node = inner
|
||||
break
|
||||
break
|
||||
if sub.type == "user_type":
|
||||
user_type_node = sub
|
||||
break
|
||||
if sub.type == "explicit_delegation":
|
||||
for inner in sub.children:
|
||||
if inner.type == "user_type":
|
||||
user_type_node = inner
|
||||
break
|
||||
break
|
||||
base = _kotlin_user_type_name(
|
||||
user_type_node, source
|
||||
)
|
||||
if base:
|
||||
lit_bases.append((base, relation))
|
||||
obj_label = (
|
||||
lit_bases[0][0] if lit_bases
|
||||
else f"object@L{lit_line}"
|
||||
)
|
||||
obj_nid = _make_id(
|
||||
func_nid, f"object:{obj_label}", f"L{lit_line}"
|
||||
)
|
||||
add_node(obj_nid, obj_label, lit_line)
|
||||
add_edge(func_nid, obj_nid, "contains", lit_line)
|
||||
callable_def_nids.add(obj_nid)
|
||||
callable_class_nids.add(obj_nid)
|
||||
for base, relation in lit_bases:
|
||||
base_nid = ensure_named_node(base, lit_line)
|
||||
if base_nid != obj_nid:
|
||||
add_edge(obj_nid, base_nid, relation, lit_line)
|
||||
lit_body = next(
|
||||
(c for c in lit.children if c.type == "class_body"),
|
||||
None,
|
||||
)
|
||||
if lit_body is not None:
|
||||
for child in lit_body.children:
|
||||
walk(child, parent_class_nid=obj_nid)
|
||||
return
|
||||
|
||||
# JS/TS arrow functions and C# namespaces — language-specific extra handling
|
||||
|
||||
@@ -452,3 +452,141 @@ def test_method_chained_off_new_expression_resolves(tmp_path):
|
||||
"run" in s and label.get(t) == ".Combine()"
|
||||
for s, t in calls
|
||||
), f"chained call off new Merger(...) not captured: {[(s, label.get(t)) for s, t in calls]}"
|
||||
|
||||
|
||||
# ── Inline-declared receivers (#2346) ─────────────────────────────────────────
|
||||
# `out T x`, `is T x`, `is not T x`, `case T x:` and switch-arm `T x =>` all
|
||||
# introduce a binding the receiver table never saw — `x.Method()` on any of
|
||||
# them silently dropped the edge. `out var x` stays untypable (poison, never a
|
||||
# guess), and the existing bind/poison conflict rules apply unchanged.
|
||||
|
||||
|
||||
_TWO_GO = (
|
||||
"public class Sect { public bool Go() => true; }\n"
|
||||
"public class Twig { public bool Go() => false; }\n"
|
||||
)
|
||||
|
||||
|
||||
def test_out_declared_receiver_resolves(tmp_path):
|
||||
"""`b.TryGet(out Sect s)` binds s: Sect — `s.Go()` resolves to Sect.Go."""
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
_TWO_GO +
|
||||
"public class Box { public bool TryGet(out Sect s) { s = new Sect(); return true; } }\n"
|
||||
"public class R {\n"
|
||||
" public bool A(Box b) { if (b.TryGet(out Sect s)) { return s.Go(); } return false; }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
r_a = _find(r, ".A()", "_r_a")
|
||||
sect_go = _find(r, ".Go()", "sect")
|
||||
twig_go = _find(r, ".Go()", "twig")
|
||||
assert (r_a, sect_go) in calls, "out-declared receiver must resolve to its declared type"
|
||||
assert (r_a, twig_go) not in calls
|
||||
|
||||
|
||||
def test_out_var_receiver_stays_unbound(tmp_path):
|
||||
"""`out var v` carries no type name — `v.Go()` must emit NO edge (poison,
|
||||
not a guess)."""
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
_TWO_GO +
|
||||
"public class Box { public bool TryGet(out Sect s) { s = new Sect(); return true; } }\n"
|
||||
"public class R {\n"
|
||||
" public bool B(Box b) { b.TryGet(out var v); return v.Go(); }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
assert not any("_r_b" in s and "go" in t.lower() for s, t in calls), \
|
||||
"`out var` receiver is untypable — no edge to either Go()"
|
||||
|
||||
|
||||
def test_is_pattern_receiver_resolves(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
_TWO_GO +
|
||||
"public class R {\n"
|
||||
" public bool A(object o) { if (o is Sect s) { return s.Go(); } return false; }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
r_a = _find(r, ".A()", "_r_a")
|
||||
sect_go = _find(r, ".Go()", "sect")
|
||||
twig_go = _find(r, ".Go()", "twig")
|
||||
assert (r_a, sect_go) in calls, "is-pattern receiver must resolve"
|
||||
assert (r_a, twig_go) not in calls
|
||||
|
||||
|
||||
def test_is_not_pattern_receiver_resolves(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
_TWO_GO +
|
||||
"public class R {\n"
|
||||
" public bool A(object o) { if (o is not Sect s) { return false; } return s.Go(); }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
r_a = _find(r, ".A()", "_r_a")
|
||||
sect_go = _find(r, ".Go()", "sect")
|
||||
twig_go = _find(r, ".Go()", "twig")
|
||||
assert (r_a, sect_go) in calls, "is-not-pattern receiver must resolve"
|
||||
assert (r_a, twig_go) not in calls
|
||||
|
||||
|
||||
def test_case_pattern_receiver_resolves(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
_TWO_GO +
|
||||
"public class R {\n"
|
||||
" public bool A(object o) {\n"
|
||||
" switch (o) { case Sect s: return s.Go(); }\n"
|
||||
" return false;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
r_a = _find(r, ".A()", "_r_a")
|
||||
sect_go = _find(r, ".Go()", "sect")
|
||||
twig_go = _find(r, ".Go()", "twig")
|
||||
assert (r_a, sect_go) in calls, "case-pattern receiver must resolve"
|
||||
assert (r_a, twig_go) not in calls
|
||||
|
||||
|
||||
def test_switch_arm_pattern_receiver_resolves(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
_TWO_GO +
|
||||
"public class R {\n"
|
||||
" public bool A(object o) {\n"
|
||||
" return o switch { Sect s => s.Go(), _ => false };\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
r_a = _find(r, ".A()", "_r_a")
|
||||
sect_go = _find(r, ".Go()", "sect")
|
||||
twig_go = _find(r, ".Go()", "twig")
|
||||
assert (r_a, sect_go) in calls, "switch-expression-arm receiver must resolve"
|
||||
assert (r_a, twig_go) not in calls
|
||||
|
||||
|
||||
def test_sibling_pattern_rebind_conflict_poisons(tmp_path):
|
||||
"""The same name pattern-bound to two DIFFERENT types in one method: raw
|
||||
calls carry no lexical position, so neither candidate may win — no edge."""
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
_TWO_GO +
|
||||
"public class R {\n"
|
||||
" public bool A(object o) {\n"
|
||||
" if (o is Sect x) { return x.Go(); }\n"
|
||||
" if (o is Twig x) { return x.Go(); }\n"
|
||||
" return false;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
r_a = _find(r, ".A()", "_r_a")
|
||||
sect_go = _find(r, ".Go()", "sect")
|
||||
twig_go = _find(r, ".Go()", "twig")
|
||||
assert (r_a, sect_go) not in calls, "conflicting pattern bindings must poison the name"
|
||||
assert (r_a, twig_go) not in calls, "conflicting pattern bindings must poison the name"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""C# partial classes split across files (#2332).
|
||||
|
||||
`partial class Foo` declared in two files minted TWO class nodes (the node id
|
||||
carries the per-file stem), so the type's members split across the halves and
|
||||
every receiver-typed lookup on `Foo` bailed as ambiguous — cross-half calls
|
||||
never resolved. `_merge_csharp_partial_class_nodes` collapses the halves onto
|
||||
one canonical node, keyed by (namespace, label); same-named types in other
|
||||
namespaces, non-partial declarations, and nested partial types are left alone.
|
||||
"""
|
||||
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():
|
||||
p = tmp_path / name
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(body)
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache")
|
||||
finally:
|
||||
os.chdir(old)
|
||||
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
|
||||
return calls, r
|
||||
|
||||
|
||||
def _nodes_labeled(r, label):
|
||||
return [n for n in r["nodes"] if n["label"] == label]
|
||||
|
||||
|
||||
def _find(r, label, id_contains):
|
||||
return next(n["id"] for n in r["nodes"]
|
||||
if n["label"] == label and id_contains in n["id"])
|
||||
|
||||
|
||||
_HALVES = {
|
||||
"FooPartA.cs": (
|
||||
"namespace App {\n"
|
||||
" public partial class Foo {\n"
|
||||
" public void Alpha() {}\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
"FooPartB.cs": (
|
||||
"namespace App {\n"
|
||||
" public partial class Foo {\n"
|
||||
" public void Beta() { Alpha(); }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_partial_halves_merge_to_one_class_node(tmp_path):
|
||||
calls, r = _extract(tmp_path, _HALVES)
|
||||
foos = _nodes_labeled(r, "Foo")
|
||||
assert len(foos) == 1, f"partial halves must collapse to ONE class node, got {foos}"
|
||||
# Both halves' members hang off the canonical node.
|
||||
foo_nid = foos[0]["id"]
|
||||
methods = {e["target"] for e in r["edges"]
|
||||
if e["relation"] == "method" and e["source"] == foo_nid}
|
||||
labels = {n["label"] for n in r["nodes"] if n["id"] in methods}
|
||||
assert {".Alpha()", ".Beta()"} <= labels, \
|
||||
f"canonical Foo must own members from BOTH halves, got {labels}"
|
||||
|
||||
|
||||
def test_cross_file_caller_resolves_into_both_halves(tmp_path):
|
||||
calls, r = _extract(tmp_path, {
|
||||
**_HALVES,
|
||||
"Caller.cs": (
|
||||
"namespace App {\n"
|
||||
" public class Caller {\n"
|
||||
" public void Run(Foo f) { f.Alpha(); f.Beta(); }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
})
|
||||
run = _find(r, ".Run()", "caller")
|
||||
alpha = _find(r, ".Alpha()", "foo")
|
||||
beta = _find(r, ".Beta()", "foo")
|
||||
assert (run, alpha) in calls, "receiver-typed call into half A must resolve"
|
||||
assert (run, beta) in calls, "receiver-typed call into half B must resolve"
|
||||
|
||||
|
||||
def test_cross_half_unqualified_in_class_call_resolves(tmp_path):
|
||||
"""Beta() in half B calls Alpha() declared in half A — an in-class
|
||||
unqualified call that spans the file boundary."""
|
||||
calls, r = _extract(tmp_path, _HALVES)
|
||||
alpha = _find(r, ".Alpha()", "foo")
|
||||
beta = _find(r, ".Beta()", "foo")
|
||||
assert (beta, alpha) in calls, "cross-half unqualified in-class call must resolve"
|
||||
|
||||
|
||||
def test_same_name_different_namespace_not_merged(tmp_path):
|
||||
calls, r = _extract(tmp_path, {
|
||||
"A.cs": (
|
||||
"namespace Alpha { public partial class Foo { public void FromA() {} } }\n"
|
||||
),
|
||||
"B.cs": (
|
||||
"namespace Beta { public partial class Foo { public void FromB() {} } }\n"
|
||||
),
|
||||
})
|
||||
foos = _nodes_labeled(r, "Foo")
|
||||
assert len(foos) == 2, \
|
||||
f"same-named partials in DIFFERENT namespaces are distinct types: {foos}"
|
||||
|
||||
|
||||
def test_non_partial_same_name_not_merged(tmp_path):
|
||||
calls, r = _extract(tmp_path, {
|
||||
"A.cs": (
|
||||
"namespace App { public partial class Foo { public void FromA() {} } }\n"
|
||||
),
|
||||
"B.cs": (
|
||||
"namespace App { public class Foo { public void FromB() {} } }\n"
|
||||
),
|
||||
})
|
||||
foos = _nodes_labeled(r, "Foo")
|
||||
assert len(foos) == 2, \
|
||||
f"a non-partial declaration never merges with a partial half: {foos}"
|
||||
|
||||
|
||||
def test_nested_partial_not_merged(tmp_path):
|
||||
"""Nested partial types are excluded: their ids omit the enclosing type
|
||||
name, so same-named nested pairs would falsely merge across outers."""
|
||||
calls, r = _extract(tmp_path, {
|
||||
"A.cs": (
|
||||
"namespace App {\n"
|
||||
" public partial class Outer {\n"
|
||||
" public partial class Inner { public void FromA() {} }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
"B.cs": (
|
||||
"namespace App {\n"
|
||||
" public partial class Outer {\n"
|
||||
" public partial class Inner { public void FromB() {} }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
})
|
||||
outers = _nodes_labeled(r, "Outer")
|
||||
inners = _nodes_labeled(r, "Inner")
|
||||
assert len(outers) == 1, "top-level partial halves still merge"
|
||||
assert len(inners) == 2, \
|
||||
f"nested partial types must NOT merge (id has no outer qualifier): {inners}"
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Kotlin anonymous-object members (#2347).
|
||||
|
||||
`object : Foo { ... }` (node type `object_literal`) got no nodes at all:
|
||||
object_literal is not a class_type (it has no name), and the function branch
|
||||
never recurses into function bodies — so the literal's members AND every call
|
||||
inside them were silently dropped. The extractor now emits an owner node per
|
||||
literal (labeled after its first supertype), a `contains` edge from the
|
||||
enclosing function, the implements/inherits edge to the supertype, and walks
|
||||
the literal's class_body like a class so members flow normally.
|
||||
"""
|
||||
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():
|
||||
p = tmp_path / name
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(body)
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache")
|
||||
finally:
|
||||
os.chdir(old)
|
||||
return r
|
||||
|
||||
|
||||
def _edges(r, relation):
|
||||
return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation}
|
||||
|
||||
|
||||
def _find(r, label, id_contains=""):
|
||||
return next(n["id"] for n in r["nodes"]
|
||||
if n["label"] == label and id_contains in n["id"])
|
||||
|
||||
|
||||
_REGISTRY = {
|
||||
"Registry.kt": (
|
||||
"interface EventListener {\n"
|
||||
" fun process(e: Event)\n"
|
||||
"}\n"
|
||||
"class Event\n"
|
||||
"class Registry {\n"
|
||||
" fun register() {\n"
|
||||
" val listener = object : EventListener {\n"
|
||||
" fun process(e: Event) { handleSomething(e) }\n"
|
||||
" fun handleSomething(e: Event) { }\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_object_literal_members_get_nodes_and_method_edges(tmp_path):
|
||||
r = _extract(tmp_path, _REGISTRY)
|
||||
obj_nid = _find(r, "EventListener", "object")
|
||||
process = _find(r, ".process()", "object")
|
||||
handle = _find(r, ".handleSomething()", "object")
|
||||
methods = _edges(r, "method")
|
||||
assert (obj_nid, process) in methods, "anonymous-object member must hang off the owner"
|
||||
assert (obj_nid, handle) in methods, "anonymous-object member must hang off the owner"
|
||||
# The owner itself is contained by the enclosing function.
|
||||
register = _find(r, ".register()", "registry")
|
||||
assert (register, obj_nid) in _edges(r, "contains"), \
|
||||
"the enclosing function contains the anonymous object"
|
||||
|
||||
|
||||
def test_object_literal_implements_supertype(tmp_path):
|
||||
r = _extract(tmp_path, _REGISTRY)
|
||||
obj_nid = _find(r, "EventListener", "object")
|
||||
iface = next(n["id"] for n in r["nodes"]
|
||||
if n["label"] == "EventListener" and "object" not in n["id"])
|
||||
assert (obj_nid, iface) in _edges(r, "implements"), \
|
||||
"object : EventListener must implement the in-corpus interface"
|
||||
|
||||
|
||||
def test_object_literal_member_calls_sibling_member(tmp_path):
|
||||
r = _extract(tmp_path, _REGISTRY)
|
||||
process = _find(r, ".process()", "object")
|
||||
handle = _find(r, ".handleSomething()", "object")
|
||||
assert (process, handle) in _edges(r, "calls"), \
|
||||
"a call between two anonymous-object members must resolve"
|
||||
|
||||
|
||||
def test_two_object_literals_in_one_function_do_not_collide(tmp_path):
|
||||
r = _extract(tmp_path, {
|
||||
"Make.kt": (
|
||||
"interface Alpha {\n"
|
||||
" fun one()\n"
|
||||
"}\n"
|
||||
"interface Beta {\n"
|
||||
" fun two()\n"
|
||||
"}\n"
|
||||
"class Maker {\n"
|
||||
" fun make() {\n"
|
||||
" val a = object : Alpha {\n"
|
||||
" fun one() { }\n"
|
||||
" }\n"
|
||||
" val b = object : Beta {\n"
|
||||
" fun two() { }\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
})
|
||||
obj_a = _find(r, "Alpha", "object")
|
||||
obj_b = _find(r, "Beta", "object")
|
||||
assert obj_a != obj_b
|
||||
methods = _edges(r, "method")
|
||||
one = _find(r, ".one()", "object")
|
||||
two = _find(r, ".two()", "object")
|
||||
assert (obj_a, one) in methods
|
||||
assert (obj_b, two) in methods
|
||||
assert (obj_a, two) not in methods, "members must not leak across sibling literals"
|
||||
assert (obj_b, one) not in methods, "members must not leak across sibling literals"
|
||||
|
||||
|
||||
def test_named_object_and_plain_class_unchanged(tmp_path):
|
||||
"""Keep-the-bar: named `object` declarations and plain classes extract
|
||||
exactly as before — the literal handling is purely additive."""
|
||||
r = _extract(tmp_path, {
|
||||
"Mix.kt": (
|
||||
"object Singleton {\n"
|
||||
" fun go() { }\n"
|
||||
"}\n"
|
||||
"class Plain {\n"
|
||||
" fun run() { go2() }\n"
|
||||
" fun go2() { }\n"
|
||||
"}\n"
|
||||
),
|
||||
})
|
||||
singleton = _find(r, "Singleton")
|
||||
plain = _find(r, "Plain")
|
||||
methods = _edges(r, "method")
|
||||
go = _find(r, ".go()")
|
||||
run = _find(r, ".run()")
|
||||
go2 = _find(r, ".go2()")
|
||||
assert (singleton, go) in methods
|
||||
assert (plain, run) in methods and (plain, go2) in methods
|
||||
assert (run, go2) in _edges(r, "calls")
|
||||
assert not any("object" in n["id"] and n["label"].startswith("object@")
|
||||
for n in r["nodes"]), "no phantom object-literal owner nodes"
|
||||
Reference in New Issue
Block a user