Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -3393,6 +3393,19 @@ def _key(label: str) -> str:
enclosing_type.setdefault(tgt, src)
method_index[(src, _key(tnode.get("label", "")))] = tgt

# Properties and fields arrive as `defines`, not `method`, so they are absent from
# method_index and a member READ has nothing to bind to. Indexed separately rather than
# folded in: a call must never resolve to a property, nor a read to a method.
member_index: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") != "defines":
continue
src, tgt = e.get("source"), e.get("target")
tnode = node_by_id.get(tgt)
if tnode is None:
continue
member_index[(src, _key(tnode.get("label", "")))] = tgt

# Base-class chain from `inherits` edges (C# files only). The type-reference
# pass has already re-pointed each resolvable base to its real definition and
# left unresolvable ones on dangling sourceless stubs — a stub target marks
Expand Down Expand Up @@ -3442,6 +3455,30 @@ def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None:
frontier.extend(bases_of.get(nid, []))
return next(iter(hits)) if len(hits) == 1 else None

def _member_on_type_or_bases(type_nid: str, member_key: str) -> str | None:
"""The property/field declaration on the type or its resolvable base chain.

Same walk and same guards as the method lookup -- a declaration on the type wins,
an unresolved base anywhere the walk reaches yields nothing, and anything other
than exactly one hit yields nothing.
"""
hits: set[str] = set()
seen: set[str] = set()
frontier = [type_nid]
while frontier:
nid = frontier.pop()
if nid in seen:
continue
seen.add(nid)
member_nid = member_index.get((nid, member_key))
if member_nid:
hits.add(member_nid)
continue
if nid in unresolved_base:
return None
frontier.extend(bases_of.get(nid, []))
return next(iter(hits)) if len(hits) == 1 else None

def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
src_file: str) -> str | None:
"""Resolve a declared type name to exactly one definition node id.
Expand Down Expand Up @@ -3531,6 +3568,43 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
"weight": 1.0,
})

# Property/field reads on a typed receiver (`ctx.Items`). Same resolution path as a
# member call -- the extractor stamps the receiver's declared type, this resolves that
# name with the namespace/using/alias scoping above, then looks the member up on the
# type or its bases. Without it a property was reachable only from its own declaration,
# so "what reads this member" had no answer at all.
#
# The edge is `references[member_access]`, not `calls`: reading a property is not an
# invocation, and conflating the two would corrupt call-graph queries.
seen_member_reads: set[tuple[str, str]] = set()
for ma in all_raw_calls:
if ma.get("lang") != "csharp" or not ma.get("member_read"):
continue
caller = ma.get("caller_nid")
member = ma.get("member")
if not caller or not member:
continue
src_file = ma.get("source_file", "")
if ma.get("receiver_is_this"):
# `this.Member` -- the type is the class declaring the accessing method.
type_nid = enclosing_type.get(caller)
else:
type_name = ma.get("receiver_type")
if not type_name:
continue
type_nid = _resolve_type_name_nid(type_name, node_by_id.get(caller), src_file)
if not type_nid:
continue # ambiguous or absent -> no edge, never a guess
member_nid = _member_on_type_or_bases(type_nid, _key(member))
if not member_nid or member_nid == caller:
continue
if (caller, member_nid) in seen_member_reads:
continue
seen_member_reads.add((caller, member_nid))
all_edges.append(_semantic_reference_edge(
caller, member_nid, "member_access", src_file, ma.get("source_location"),
))


def _resolve_java_member_calls(
per_file: list[dict],
Expand Down
66 changes: 66 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ def _csharp_namespace_id(dotted_name: str) -> str:

REFERENCE_CONTEXTS = frozenset({
"field", "parameter_type", "return_type", "generic_arg", "attribute", "value", "type",
# A property/field READ on a typed receiver (`ctx.Items`). Distinct from `field`, which
# marks a declaration's own type reference: this one records a use site.
"member_access",
})

def _source_location(line: int | str | None) -> str | None:
Expand Down Expand Up @@ -5670,6 +5673,69 @@ def walk_calls(
_js_collect_pattern_idents(param, source, caught)
extra_locals = extra_locals | frozenset(caught)

# C#: record a property/field READ on a typed receiver (`ctx.Items`). The call
# branches above only see a member_access_expression when it is an invocation's
# `function`, so a member used as a VALUE produced no edge at all and a property
# was reachable only from its own declaration. Resolution is deferred to extract()
# for the same reason member calls are: the declaring type may be in another file.
#
# Skipped when this node is an invocation's `function`, which the member-call path
# already claims -- otherwise `ctx.Save()` would emit both a call and a read. The
# receiver of a chained call is NOT skipped: `ctx.Items.Any()` genuinely reads
# `Items` before calling on it.
if (
config.ts_module == "tree_sitter_c_sharp"
and node.type == "member_access_expression"
):
parent = node.parent
is_call_target = (
parent is not None
and parent.type == "invocation_expression"
and parent.child_by_field_name("function") is node
)
if not is_call_target:
ma_recv = node.child_by_field_name("expression")
ma_name = node.child_by_field_name("name")
# Only a simple or `this` receiver can be typed from the binding table;
# anything else (an indexer, a call result) needs inference we do not do.
# The grammar names this node `this`; `this_expression` is accepted too so a
# grammar rename does not silently drop the case.
recv_is_this = ma_recv is not None and ma_recv.type in ("this", "this_expression")
recv_name = (
_read_text(ma_recv, source)
if ma_recv is not None and ma_recv.type == "identifier"
else None
)
named = ma_name is not None and ma_name.type == "identifier"
if (recv_name or recv_is_this) and named:
# Carried on raw_calls, not a channel of its own, because a
# caller_nid is rewritten by four separate id passes in extract()
# (merge-away, id_remap, sym_remap, collision disambiguation). A
# parallel list would have to repeat all four and silently emit
# dangling edges the day one was missed. `member_read` marks these
# and no `callee` is set, so every existing raw_calls consumer -- all
# of which require `callee` or `is_member_call` -- ignores them.
entry = {
"caller_nid": caller_nid,
"lang": "csharp",
"member_read": True,
"member": _read_text(ma_name, source),
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
}
if recv_is_this:
# `this` names the enclosing type, which the binding table does not
# carry; the resolver reads it off the caller's declaring class.
entry["receiver_is_this"] = True
raw_calls.append(entry)
else:
ma_type = _csharp_scoped_receiver_type(
receiver_types, recv_name, node.start_byte
)
if ma_type:
entry["receiver_type"] = ma_type
raw_calls.append(entry)

for child in node.children:
walk_calls(child, caller_nid, receiver_types, extra_locals)

Expand Down
211 changes: 211 additions & 0 deletions tests/test_csharp_member_access_edges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""Reading a C# property/field on a typed receiver should link the accessing method to it.

The member-call resolver (#1609) binds `recv.Method()` to the receiver's declared type, but a
member used as a VALUE -- `ctx.Items` -- is not an invocation, so no branch ever saw it. A
property was therefore reachable only from its own declaration: "which methods read this
member" had no answer, and a type exposed purely through properties looked unused.

Resolution reuses the member-call path, so the guards it already carries apply here too: an
untypable receiver produces nothing rather than a guess, and an ambiguous type name is
skipped. These tests pin both the new edges and those refusals.
"""
from __future__ import annotations

import textwrap
from pathlib import Path

from graphify.extract import extract


def _graph(tmp_path: Path, files: dict[str, str]) -> dict:
paths = []
for name, body in files.items():
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(body).lstrip(), encoding="utf-8")
paths.append(path)
return extract(paths, cache_root=tmp_path)


def _reads(graph: dict, *, from_label: str) -> set[tuple[str, str]]:
"""(label, source_location) of members read by `from_label`."""
nodes = {n["id"]: n for n in graph["nodes"]}
out = set()
for edge in graph["edges"]:
if edge.get("relation") != "references" or edge.get("context") != "member_access":
continue
if nodes.get(edge.get("source"), {}).get("label") != from_label:
continue
target = nodes.get(edge.get("target"), {})
if target.get("label"):
out.add((target["label"], target.get("source_location")))
return out


def _labels(reads: set[tuple[str, str]]) -> set[str]:
return {label for label, _ in reads}


MODEL = """
namespace App.Data;
public class Widget { public int Id { get; set; } }
public class Gadget { public int Id { get; set; } }
public class Box<T> { }
public class Store
{
public Box<Widget> Widgets { get; set; }
public Box<Gadget> Gadgets { get; set; }
public string Label { get; set; }
public void Save() { }
}
"""

READER = """
namespace App.Use;
using App.Data;

public class Reader
{
private Store _store;
public void ReadsOneSet() { var q = _store.Widgets; }
public void ReadsTheOtherSet() { var q = _store.Gadgets; }
public void ReadsAScalar() { var s = _store.Label; }
public void ReadsThenCalls() { _store.Widgets.ToString(); }
public void CallsAMethod() { _store.Save(); }
public void ReadsAnUnknownMember() { var x = _store.Missing; }
}
"""


def test_reading_a_property_links_the_accessing_method(tmp_path):
graph = _graph(tmp_path, {"Model.cs": MODEL, "Reader.cs": READER})
assert "Widgets" in _labels(_reads(graph, from_label=".ReadsOneSet()"))


def test_two_properties_do_not_cross_attribute(tmp_path):
"""Each read must bind to its own member, not to any member of the type."""
graph = _graph(tmp_path, {"Model.cs": MODEL, "Reader.cs": READER})
assert _labels(_reads(graph, from_label=".ReadsOneSet()")) == {"Widgets"}
assert _labels(_reads(graph, from_label=".ReadsTheOtherSet()")) == {"Gadgets"}


def test_a_scalar_property_is_linked_too(tmp_path):
graph = _graph(tmp_path, {"Model.cs": MODEL, "Reader.cs": READER})
assert "Label" in _labels(_reads(graph, from_label=".ReadsAScalar()"))


def test_the_member_and_not_the_same_named_type_is_linked(tmp_path):
"""`Widgets` the property must win over any same-named type in the corpus."""
graph = _graph(tmp_path, {
"Model.cs": MODEL,
"Collide.cs": "namespace App.Data;\npublic class Widgets { }\n",
"Reader.cs": READER,
})
reads = _reads(graph, from_label=".ReadsOneSet()")
assert reads, "expected a member_access edge"
# The property is declared inside Model.cs; the colliding class is its own file.
nodes = {n["id"]: n for n in graph["nodes"]}
targets = [
n for n in nodes.values()
if n.get("label") == "Widgets" and n.get("source_file", "").endswith("Model.cs")
]
assert targets, "the property node should be the one linked"


def test_a_chained_call_still_records_the_read(tmp_path):
"""`ctx.Items.Any()` reads Items before calling on it."""
graph = _graph(tmp_path, {"Model.cs": MODEL, "Reader.cs": READER})
assert "Widgets" in _labels(_reads(graph, from_label=".ReadsThenCalls()"))


def test_a_method_call_is_not_recorded_as_a_read(tmp_path):
"""`_store.Save()` is a call; emitting a read as well would double-count it."""
graph = _graph(tmp_path, {"Model.cs": MODEL, "Reader.cs": READER})
assert _reads(graph, from_label=".CallsAMethod()") == set()


def test_an_unknown_member_on_a_known_type_links_nothing(tmp_path):
graph = _graph(tmp_path, {"Model.cs": MODEL, "Reader.cs": READER})
assert _reads(graph, from_label=".ReadsAnUnknownMember()") == set()


def test_an_untyped_receiver_links_nothing(tmp_path):
"""No declared type for the receiver means no edge, rather than a bare-name guess."""
graph = _graph(tmp_path, {
"Model.cs": MODEL,
"Loose.cs": """
namespace App.Use;
public class Loose
{
public void Reads(object thing) { var x = thing.Widgets; }
}
""",
})
assert _reads(graph, from_label=".Reads()") == set()


def test_an_inherited_property_resolves_through_the_base(tmp_path):
graph = _graph(tmp_path, {
"Model.cs": MODEL,
"Derived.cs": """
namespace App.Data;
public class BigStore : Store { }
""",
"Reader.cs": """
namespace App.Use;
using App.Data;

public class BigReader
{
private BigStore _store;
public void ReadsInherited() { var q = _store.Widgets; }
}
""",
})
assert "Widgets" in _labels(_reads(graph, from_label=".ReadsInherited()"))


def test_this_qualified_read_is_linked(tmp_path):
graph = _graph(tmp_path, {
"Own.cs": """
namespace App.Data;
public class Holder
{
public string Name { get; set; }
public void ReadsOwn() { var n = this.Name; }
}
""",
})
assert "Name" in _labels(_reads(graph, from_label=".ReadsOwn()"))


def test_both_endpoints_of_every_edge_exist(tmp_path):
"""No dangling ids.

A caller_nid is rewritten by several id passes (merge-away, prefix remap, symbol
remap, collision disambiguation) after extraction. Carrying these entries on
`raw_calls` inherits every one of those rewrites; a parallel channel had to repeat
them, and missing one produced edges whose source was not a node -- invisible except
as a query that silently returns nothing.
"""
graph = _graph(tmp_path, {"Model.cs": MODEL, "Reader.cs": READER})
ids = {n["id"] for n in graph["nodes"]}
dangling = [
(e["source"], e["target"]) for e in graph["edges"]
if e.get("context") == "member_access"
and (e["source"] not in ids or e["target"] not in ids)
]
assert not dangling, f"dangling member_access edges: {dangling}"


def test_the_edge_is_a_reference_not_a_call(tmp_path):
"""A property read must not appear in the call graph."""
graph = _graph(tmp_path, {"Model.cs": MODEL, "Reader.cs": READER})
nodes = {n["id"]: n for n in graph["nodes"]}
for edge in graph["edges"]:
if nodes.get(edge.get("source"), {}).get("label") != ".ReadsOneSet()":
continue
target = nodes.get(edge.get("target"), {})
if target.get("label") == "Widgets":
assert edge.get("relation") == "references"
assert edge.get("context") == "member_access"
Loading