tests/test_csharp_method_owner_labels.py
Rama v8 · réplica de lectura del repositorio original.
"""C# method nodes display their owning type (#4883).
Every method used to be labeled `.Build()`, so `IReport.Build()`,
`PdfReport.Build()` and `CsvReport.Build()` printed identically and only the
line told them apart. The graph build now gives each C# method an
`Owner.Name()` display label. Ids and edges are unchanged, and extraction
still emits the bare `.Name()` form that call resolution matches on; a
persisted graph handed back as incremental resolution context is normalized
back to it.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from graphify.extract import extract
from graphify.extractors.csharp import (
bare_csharp_method_label,
csharp_method_label_reassignments,
)
_ISSUE_REPRO = (
"public interface IReport { string Build(); }\n"
"public class PdfReport : IReport { public string Build() { return \"pdf\"; } }\n"
"public class CsvReport : IReport { public string Build() { return \"csv\"; } }\n"
"public class Exporter {\n"
" private readonly IReport _report;\n"
" public Exporter(IReport report) { _report = report; }\n"
" public string Run() { return _report.Build(); }\n"
"}\n"
)
_SPLIT = {
"Report.cs": (
"public interface IReport { string Build(); }\n"
"public class PdfReport : IReport { public string Build() { return \"pdf\"; } }\n"
"public class CsvReport : IReport { public string Build() { return \"csv\"; } }\n"
),
"Exporter.cs": (
"public class Exporter {\n"
" private readonly IReport _report;\n"
" public Exporter(IReport report) { _report = report; }\n"
" public string Run() { return _report.Build(); }\n"
"}\n"
),
}
def _write(root: Path, files: dict[str, str]) -> None:
root.mkdir(parents=True, exist_ok=True)
for name, body in files.items():
(root / name).write_text(body, encoding="utf-8")
def _graph(root: Path) -> dict:
return json.loads((root / "graphify-out" / "graph.json").read_text(encoding="utf-8"))
def _labels(graph: dict) -> dict[str, str]:
return {n["id"]: n["label"] for n in graph["nodes"]}
def _edges(graph: dict, relations: tuple[str, ...]) -> set[tuple[str, str, str]]:
return {
(e["source"], e["relation"], e["target"])
for e in graph.get("links", graph.get("edges", []))
if e["relation"] in relations
}
@pytest.mark.parametrize("no_cluster", [False, True])
def test_issue_repro_methods_display_their_owning_type(tmp_path, no_cluster):
from graphify.watch import _rebuild_code
_write(tmp_path, {"Report.cs": _ISSUE_REPRO})
assert _rebuild_code(tmp_path, no_cluster=no_cluster, acquire_lock=False, force=True)
labels = _labels(_graph(tmp_path))
assert labels["report_ireport_build"] == "IReport.Build()"
assert labels["report_pdfreport_build"] == "PdfReport.Build()"
assert labels["report_csvreport_build"] == "CsvReport.Build()"
assert labels["report_exporter_run"] == "Exporter.Run()"
assert labels["report_ireport"] == "IReport"
assert labels["report"] == "Report.cs"
def test_ids_and_edges_match_the_raw_extraction(tmp_path):
from graphify.watch import _rebuild_code
_write(tmp_path, {"Report.cs": _ISSUE_REPRO})
raw = extract([tmp_path / "Report.cs"], root=tmp_path)
assert _rebuild_code(tmp_path, acquire_lock=False, force=True)
graph = _graph(tmp_path)
assert {n["id"] for n in graph["nodes"]} == {n["id"] for n in raw["nodes"]}
structural = ("method", "calls", "dispatches_to", "implements", "contains")
assert _edges(graph, structural) == {
(e["source"], e["relation"], e["target"])
for e in raw["edges"] if e["relation"] in structural
}
assert ("report_exporter_run", "calls", "report_ireport_build") in _edges(graph, ("calls",))
def test_extraction_keeps_the_bare_resolver_label(tmp_path):
_write(tmp_path, {"Report.cs": _ISSUE_REPRO})
raw = extract([tmp_path / "Report.cs"], root=tmp_path)
methods = sorted(n["label"] for n in raw["nodes"] if n["label"].endswith("()"))
# `.Exporter()` is Exporter's constructor.
assert methods == [".Build()", ".Build()", ".Build()", ".Exporter()", ".Run()"]
def test_incremental_rebuild_binds_into_an_unchanged_file(tmp_path):
"""Only the caller is re-extracted; the callee's persisted label is already
`IReport.Build()`. The call and the dispatch edges must match a full build."""
from graphify.watch import _rebuild_code
inc, full = tmp_path / "inc", tmp_path / "full"
for root in (inc, full):
_write(root, _SPLIT)
assert _rebuild_code(inc, no_cluster=True, acquire_lock=False, force=True)
assert _labels(_graph(inc))["report_ireport_build"] == "IReport.Build()"
for root in (inc, full):
p = root / "Exporter.cs"
p.write_text(p.read_text(encoding="utf-8") + "// touched\n", encoding="utf-8")
assert _rebuild_code(
inc, changed_paths=[inc / "Exporter.cs"], no_cluster=True, acquire_lock=False,
)
assert _rebuild_code(full, no_cluster=True, acquire_lock=False, force=True)
relations = ("calls", "dispatches_to")
assert _edges(_graph(inc), relations) == _edges(_graph(full), relations)
assert ("exporter_exporter_run", "calls", "report_ireport_build") in _edges(_graph(full), relations)
inc_labels, full_labels = _labels(_graph(inc)), _labels(_graph(full))
assert {nid: inc_labels.get(nid) for nid in full_labels} == full_labels
def test_resolution_context_labels_are_normalized_without_mutating_the_caller(tmp_path):
_write(tmp_path, _SPLIT)
context = [
{"id": "report_ireport", "label": "IReport", "source_file": "Report.cs",
"file_type": "code", "type": None},
{"id": "report_ireport_build", "label": "IReport.Build()", "source_file": "Report.cs",
"file_type": "code", "type": None, "_callable": True,
"metadata": {"arity": 0, "signature": ""}},
]
context_edges = [
{"source": "report", "target": "report_ireport", "relation": "contains",
"source_file": "Report.cs"},
{"source": "report_ireport", "target": "report_ireport_build", "relation": "method",
"source_file": "Report.cs"},
]
r = extract(
[tmp_path / "Exporter.cs"], root=tmp_path,
resolution_context_nodes=context, resolution_context_edges=context_edges,
)
assert ("exporter_exporter_run", "report_ireport_build") in {
(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"
}
assert context[1]["label"] == "IReport.Build()"
def test_rebuilding_from_qualified_labels_does_not_stack_the_owner():
from graphify.build import build_from_json
extraction = {
"nodes": [
{"id": "r_pdfreport", "label": "PdfReport", "file_type": "code",
"source_file": "Report.cs", "source_location": "L2", "_origin": "ast"},
{"id": "r_pdfreport_build", "label": "PdfReport.Build()", "file_type": "code",
"source_file": "Report.cs", "source_location": "L2", "_origin": "ast"},
],
"edges": [
{"source": "r_pdfreport", "target": "r_pdfreport_build", "relation": "method",
"confidence": "EXTRACTED", "source_file": "Report.cs"},
],
}
G = build_from_json(extraction)
assert G.nodes["r_pdfreport_build"]["label"] == "PdfReport.Build()"
G2 = build_from_json({
"nodes": [{"id": n, **d} for n, d in G.nodes(data=True)],
"edges": extraction["edges"],
})
assert G2.nodes["r_pdfreport_build"]["label"] == "PdfReport.Build()"
def test_qualified_methods_stay_out_of_god_nodes(tmp_path):
from graphify.analyze import _is_file_node
from graphify.build import build_from_json
_write(tmp_path, {"Report.cs": _ISSUE_REPRO})
G = build_from_json(extract([tmp_path / "Report.cs"], root=tmp_path))
assert G.nodes["report_pdfreport_build"]["label"] == "PdfReport.Build()"
assert _is_file_node(G, "report_pdfreport_build")
assert not _is_file_node(G, "report_pdfreport")
def test_semantic_method_ghost_still_collapses_onto_a_qualified_method():
"""#3705 parity: an LLM ghost `Build()` in the same file remaps onto the one
AST method even when that method's persisted label is `PdfReport.Build()`."""
from graphify.build import build_from_json
extraction = {
"nodes": [
{"id": "report_pdfreport_build", "label": "PdfReport.Build()", "file_type": "code",
"source_file": "Report.cs", "source_location": "L2", "_origin": "ast"},
{"id": "report_build", "label": "Build()", "file_type": "code",
"source_file": "Report.cs"},
{"id": "doc", "label": "Doc", "file_type": "document", "source_file": "d.md"},
],
"edges": [
{"source": "doc", "target": "report_build", "relation": "references",
"confidence": "INFERRED", "confidence_score": 0.85, "source_file": "d.md"},
],
}
G = build_from_json(extraction)
assert "report_build" not in G.nodes
assert G.has_edge("doc", "report_pdfreport_build")
def test_class_segment_less_method_alias_still_resolves_a_qualified_method():
"""The raw --no-cluster writer finalizes endpoints after relabeling, so the
`<file stem>_<method>` alias must still be read off `PdfReport.Build()`."""
from graphify.build import finalize_raw_graph_endpoints
data = {
"nodes": [
{"id": "report_pdfreport_build", "label": "PdfReport.Build()", "file_type": "code",
"source_file": "Report.cs", "source_location": "L2", "_origin": "ast"},
{"id": "doc", "label": "Doc", "file_type": "document", "source_file": "d.md"},
],
"edges": [
{"source": "doc", "target": "report_build", "relation": "references"},
],
}
finalize_raw_graph_endpoints(data)
assert [(e["source"], e["target"]) for e in data["edges"]] == [
("doc", "report_pdfreport_build"),
]
def test_other_languages_keep_bare_method_labels(tmp_path):
from graphify.build import build_from_json
_write(tmp_path, {
"Report.java": "class PdfReport { String build() { return \"pdf\"; } }\n",
"Report.swift": "class PdfReport { func build() -> String { return \"pdf\" } }\n",
})
G = build_from_json(extract(
[tmp_path / "Report.java", tmp_path / "Report.swift"], root=tmp_path,
))
method_labels = {
d["label"] for _, d in G.nodes(data=True) if str(d.get("label", "")).endswith("()")
}
assert method_labels == {".build()"}
def test_reassignment_fails_closed_on_missing_or_conflicting_owners():
nodes = [
("a", {"label": "A", "source_file": "x.cs"}),
("b", {"label": "B", "source_file": "x.cs"}),
("ns", {"label": "Acme.Reports", "source_file": "x.cs"}),
("m_two_owners", {"label": ".Two()", "source_file": "x.cs"}),
("m_no_owner", {"label": ".None()", "source_file": "x.cs"}),
("m_dotted_owner", {"label": ".Dotted()", "source_file": "x.cs"}),
("m_ok", {"label": ".Ok()", "source_file": "x.cs"}),
("m_java", {"label": ".Ok()", "source_file": "x.java"}),
]
edges = [
("a", "m_two_owners"), ("b", "m_two_owners"),
("ns", "m_dotted_owner"),
("a", "m_ok"),
("a", "m_java"),
]
assert csharp_method_label_reassignments(nodes, edges) == {"m_ok": "A.Ok()"}
@pytest.mark.parametrize(("label", "source_file", "expected"), [
(".Build()", "Report.cs", ".Build()"),
("PdfReport.Build()", "Report.cs", ".Build()"),
("PdfReport.Build()", "Report.java", None),
("PdfReport", "Report.cs", None),
("Build()", "Report.cs", None),
("Acme.PdfReport.Build()", "Report.cs", None),
("Report.cs", "Report.cs", None),
])
def test_bare_label_recognizes_only_method_shapes(label, source_file, expected):
assert bare_csharp_method_label(label, source_file) == expected