mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 07:29:33 +00:00
fix(csharp): merge nested and generic partial types by their full type path (#4363)
The partial merge grouped halves by (namespace, label) and skipped nested types, because nested ids omit the enclosing type. A nested partial type split across files stayed split, and `Box` / `Box<T>` partials were folded into one node although they are distinct types (#4249). - The C# extractor records each partial type's path inside its namespace, enclosing types first, each with its arity (`Outer`1.Inner`), as metadata.type_path; the merge keys on (assembly, namespace, type_path). - The cross-file call pass binds a C# unqualified call to the one candidate that is a member of the caller's own type, so a call into another half of a partial type is not lost to a same-named method elsewhere (#4252 does this in-file only). Ties fall through unchanged. - After C# type resolution, a merged partial type drops a placeholder base that sits next to a real base of the same name; one half resolving a sibling nested base and the other not left two bases, and base-chain lookups failed. - _AST_CACHE_SCHEMA 8 -> 9, so cached C# results without type_path are re-extracted. Fixes #4363. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5ba60ddf6
commit
92d9197787
6 files changed
+358
-32
No files matched your search
@@ -118,6 +118,12 @@ classes under different parents retain separate definitions, methods, and
|
||||
containment edges; top-level class ids are unchanged. AST cache schema 7 prevents
|
||||
older cached definitions from retaining the collapsed identities.
|
||||
|
||||
C# partial types merge across files by their path inside the namespace,
|
||||
enclosing types first and each with its arity (`` Outer`1.Inner ``), within one
|
||||
assembly. Nested halves merge only under the same enclosing chain, and `Foo` and
|
||||
`Foo<T>` stay separate types. AST cache schema 9 retires cached C# results that
|
||||
lack this path.
|
||||
|
||||
R extraction preserves symbol-only bindings such as `%||%` as separate nodes.
|
||||
When normal ID normalization removes an entire binding name, its UTF-8 bytes
|
||||
provide a deterministic operator suffix within the same lexical owner. Its double
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ except Exception:
|
||||
_EXTRACTOR_VERSION = "unknown"
|
||||
|
||||
# Bump when AST cache-key semantics change independently of the package version.
|
||||
_AST_CACHE_SCHEMA = 8 # Rust `use` groups: one imports_from edge per binding; prior (7): Python opaque-base/super(args) raw-call markers and nested-class enclosing ids.
|
||||
_AST_CACHE_SCHEMA = 9 # C# partial types carry `type_path`, the nested/generic partial merge key; prior (8): Rust `use` groups: one imports_from edge per binding; (7): Python opaque-base/super(args) raw-call markers and nested-class enclosing ids.
|
||||
|
||||
|
||||
# Per-process memo: distributions() scans site-packages metadata, which is
|
||||
|
||||
+96
-11
@@ -3844,13 +3844,15 @@ def _merge_csharp_partial_class_nodes(
|
||||
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 (assembly, 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 `partial` keyword only fuses declarations compiled into
|
||||
the SAME assembly, so the key also carries the nearest ancestor directory
|
||||
holding a `*.csproj`/`*.fsproj`/`*.vbproj` — same-named halves under
|
||||
type nodes by (assembly, namespace, type path) — same-named types in
|
||||
different namespaces are distinct types, non-partial same-named types are
|
||||
separate declarations. The type path carries the enclosing types and each
|
||||
arity (`` Outer`1.Inner ``): nested ids omit the enclosing type, so the
|
||||
path is what keeps same-named nested types under different outers apart,
|
||||
and the arity keeps `Foo` and `Foo<T>` apart. The `partial` keyword only
|
||||
fuses declarations compiled into the SAME assembly, so the key also
|
||||
carries the nearest ancestor directory holding a
|
||||
`*.csproj`/`*.fsproj`/`*.vbproj` — same-named halves under
|
||||
different project dirs are genuinely distinct types and stay apart. Halves
|
||||
with NO project file on any ancestor (up to the scan root) all key to ""
|
||||
and still merge together, so single-project/snippet corpora behave exactly
|
||||
@@ -3871,12 +3873,21 @@ def _merge_csharp_partial_class_nodes(
|
||||
if n.get("file_type") != "code":
|
||||
continue
|
||||
md = n.get("metadata") or {}
|
||||
if not md.get("is_partial") or md.get("is_nested_type"):
|
||||
if not md.get("is_partial"):
|
||||
continue
|
||||
label = n.get("label")
|
||||
if not label:
|
||||
# `type_path` names the type with its enclosing types and arities
|
||||
# (`` Outer`1.Inner ``), so nested halves only meet under the same
|
||||
# outer and `Foo` / `Foo<T>` stay apart (#4249). A nested type whose
|
||||
# path is unknown stays unmerged: its label alone would join
|
||||
# same-named types under different outers.
|
||||
type_path = md.get("type_path")
|
||||
if not type_path:
|
||||
if md.get("is_nested_type"):
|
||||
continue
|
||||
type_path = n.get("label")
|
||||
if not type_path:
|
||||
continue
|
||||
groups.setdefault((str(md.get("namespace", "")), str(label)), []).append(n)
|
||||
groups.setdefault((str(md.get("namespace", "")), str(type_path)), []).append(n)
|
||||
|
||||
if not any(len(members) >= 2 for members in groups.values()):
|
||||
return
|
||||
@@ -4015,6 +4026,52 @@ def _merge_csharp_partial_class_nodes(
|
||||
rc["caller_nid"] = remap[cn]
|
||||
|
||||
|
||||
def _drop_shadowed_csharp_partial_bases(all_nodes: list[dict], all_edges: list[dict]) -> None:
|
||||
"""Drop a merged partial type's placeholder base when a real base of the
|
||||
same name sits next to it.
|
||||
|
||||
Every half of a partial type may repeat the base list, and C# requires
|
||||
the halves to agree. Each half's base name is resolved in its own file's
|
||||
scope, so a half whose file the resolver cannot see the base from (a
|
||||
sibling nested base without `using static` for its holder, say) leaves a
|
||||
placeholder edge while another half binds the real type. After the merge the type then has two same-named bases, and
|
||||
lookups through the base chain bail as ambiguous. The placeholder is the
|
||||
unresolved spelling of the resolved base, so it goes.
|
||||
"""
|
||||
nodes_by_id = {str(n.get("id", "")): n for n in all_nodes}
|
||||
|
||||
def _is_partial_cs(nid: str) -> bool:
|
||||
n = nodes_by_id.get(nid) or {}
|
||||
return (
|
||||
str(n.get("source_file", "")).endswith(".cs")
|
||||
and bool((n.get("metadata") or {}).get("is_partial"))
|
||||
)
|
||||
|
||||
real_bases: dict[str, set[str]] = {}
|
||||
for e in all_edges:
|
||||
if e.get("relation") not in ("inherits", "implements"):
|
||||
continue
|
||||
src = str(e.get("source", ""))
|
||||
if not _is_partial_cs(src):
|
||||
continue
|
||||
tgt = nodes_by_id.get(str(e.get("target", ""))) or {}
|
||||
if tgt.get("source_file"):
|
||||
real_bases.setdefault(src, set()).add(str(tgt.get("label", "")))
|
||||
if not real_bases:
|
||||
return
|
||||
|
||||
def _shadowed(e: dict) -> bool:
|
||||
if e.get("relation") not in ("inherits", "implements"):
|
||||
return False
|
||||
names = real_bases.get(str(e.get("source", "")))
|
||||
if not names:
|
||||
return False
|
||||
tgt = nodes_by_id.get(str(e.get("target", ""))) or {}
|
||||
return not tgt.get("source_file") and str(tgt.get("label", "")) in names
|
||||
|
||||
all_edges[:] = [e for e in all_edges if not _shadowed(e)]
|
||||
|
||||
|
||||
UNRESOLVED_CALLS_KEY = "unresolved_calls"
|
||||
_MAX_PARKED_CALLS_PER_NODE = 64
|
||||
|
||||
@@ -8854,6 +8911,7 @@ def extract(
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("C# cross-file import resolution failed, skipping: %s", exc)
|
||||
_drop_shadowed_csharp_partial_bases(all_nodes, all_edges)
|
||||
|
||||
# Cross-file Bash source-backed call resolution: a call to a function defined
|
||||
# in a file this one `source`s is left unresolved by the per-file extractor
|
||||
@@ -9058,6 +9116,7 @@ def extract(
|
||||
str(n.get("label", ""))
|
||||
)
|
||||
_csharp_stub_resolver: CsharpNameResolver | None = None # built on first use
|
||||
_csharp_method_owner: dict[str, str] | None = None # method nid -> type nid, built on first use
|
||||
for rc in all_raw_calls:
|
||||
if rc.get("_ambiguous_python_import"):
|
||||
continue
|
||||
@@ -9205,9 +9264,35 @@ def extract(
|
||||
or (candidate_file_nid is not None and candidate_file_nid in imported_modules)
|
||||
)
|
||||
|
||||
# C#: an unqualified call binds to a member of the caller's own type
|
||||
# first. The extractor already does that in-file (#4252), but a partial
|
||||
# type's halves live in different files: after the merge they are one
|
||||
# type, and a same-named method elsewhere must not hide the other half's
|
||||
# member. Exactly one own-type candidate binds; a tie (overloads) falls
|
||||
# through to the generic tie-breakers unchanged.
|
||||
own_type_matches: list[str] = []
|
||||
if (
|
||||
len(candidates) > 1
|
||||
and rc.get("lang") == "csharp"
|
||||
and not rc.get("csharp_new")
|
||||
):
|
||||
if _csharp_method_owner is None:
|
||||
_csharp_method_owner = {
|
||||
e["target"]: e["source"] for e in all_edges
|
||||
if e.get("relation") == "method"
|
||||
}
|
||||
caller_owner = _csharp_method_owner.get(rc.get("caller_nid", ""))
|
||||
if caller_owner is not None:
|
||||
own_type_matches = [
|
||||
c for c in candidates if _csharp_method_owner.get(c) == caller_owner
|
||||
]
|
||||
|
||||
if len(candidates) == 1:
|
||||
tgt = candidates[0]
|
||||
has_import_evidence = go_exact_import or _has_import_evidence(tgt)
|
||||
elif len(own_type_matches) == 1:
|
||||
tgt = own_type_matches[0]
|
||||
has_import_evidence = True
|
||||
else:
|
||||
# Ambiguous name (defined in 2+ files). Don't bail outright (#1219):
|
||||
# if the caller has explicit import evidence pointing at exactly one
|
||||
|
||||
@@ -4750,6 +4750,10 @@ def _extract_generic(
|
||||
# extensions don't (file stem is part of the id), so they're collected here
|
||||
# for a corpus-level merge after every file has been parsed.
|
||||
swift_extensions: list[dict] = []
|
||||
# C# type id -> its name within the namespace, enclosing types first
|
||||
# (`` Outer`1.Inner ``). Partial types carry it as `type_path` so the
|
||||
# corpus-level partial merge can key nested and generic halves exactly.
|
||||
csharp_type_paths: dict[str, str] = {}
|
||||
# #1356: call expressions in property/field initializers (e.g.
|
||||
# `let vm = VM()`) live outside function bodies, so the call-walk never
|
||||
# reaches them. Collect (owner_nid, call_node) here and walk them too.
|
||||
@@ -4997,6 +5001,21 @@ def _extract_generic(
|
||||
config.ts_module == "tree_sitter_ruby" and class_nid in seen_ids
|
||||
)
|
||||
if config.ts_module == "tree_sitter_c_sharp":
|
||||
# The type's name within its namespace, enclosing types first,
|
||||
# each with its arity (`` Outer`1.Inner ``). The id omits the
|
||||
# enclosing types, so this is what tells two same-named nested
|
||||
# types under different outers apart. None when an enclosing
|
||||
# type's path is unknown.
|
||||
if parent_class_nid:
|
||||
_parent_path = csharp_type_paths.get(parent_class_nid)
|
||||
_type_path = (
|
||||
f"{_parent_path}.{class_name_for_id}"
|
||||
if _parent_path is not None else None
|
||||
)
|
||||
else:
|
||||
_type_path = class_name_for_id
|
||||
if _type_path is not None:
|
||||
csharp_type_paths.setdefault(class_nid, _type_path)
|
||||
if parent_class_nid:
|
||||
metadata = {"is_nested_type": True}
|
||||
# #2332: `partial class Foo` split across files mints one node
|
||||
@@ -5013,8 +5032,11 @@ def _extract_generic(
|
||||
c.type == "modifier" and _read_text(c, source) == "partial"
|
||||
for c in node.children
|
||||
):
|
||||
metadata = dict(metadata or {})
|
||||
metadata["is_partial"] = True
|
||||
partial_metadata: dict[str, object] = dict(metadata or {})
|
||||
partial_metadata["is_partial"] = True
|
||||
if _type_path is not None:
|
||||
partial_metadata["type_path"] = _type_path
|
||||
metadata = partial_metadata
|
||||
if config.ts_module == "tree_sitter_ruby":
|
||||
ruby_body = _find_body(node, config)
|
||||
if ruby_absolute_declaration or ruby_reopened or (
|
||||
|
||||
@@ -128,30 +128,166 @@ def test_non_partial_same_name_not_merged(tmp_path):
|
||||
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."""
|
||||
def _methods_of(r, nid):
|
||||
label_of = {n["id"]: n["label"] for n in r["nodes"]}
|
||||
return {label_of[e["target"]] for e in r["edges"]
|
||||
if e["relation"] == "method" and e["source"] == nid}
|
||||
|
||||
|
||||
_NESTED_HALVES = {
|
||||
"A.cs": (
|
||||
"namespace App {\n"
|
||||
" public partial class Outer {\n"
|
||||
" public partial class Inner { public void FromA() { FromB(); } }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
"B.cs": (
|
||||
"namespace App {\n"
|
||||
" public partial class Outer {\n"
|
||||
" public partial class Inner { public virtual void FromB() {} }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
# Same member names on an unrelated type, so a name-only fallback cannot
|
||||
# stand in for the merge.
|
||||
"Decoy.cs": (
|
||||
"namespace App {\n"
|
||||
" public class Decoy { public void FromA() {} public virtual void FromB() {} }\n"
|
||||
"}\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_nested_partial_halves_merge_under_the_same_outer(tmp_path):
|
||||
"""A nested partial type split across files is one type, like a top-level
|
||||
one: its halves collapse onto one node that owns both halves' members."""
|
||||
calls, r = _extract(tmp_path, _NESTED_HALVES)
|
||||
outers = _nodes_labeled(r, "Outer")
|
||||
inners = _nodes_labeled(r, "Inner")
|
||||
assert len(outers) == 1, "top-level partial halves still merge"
|
||||
assert len(inners) == 1, f"nested partial halves must merge: {inners}"
|
||||
assert _methods_of(r, inners[0]["id"]) == {".FromA()", ".FromB()"}
|
||||
# Still contained by the outer type, not by a file.
|
||||
assert any(e["relation"] == "contains" and e["source"] == outers[0]["id"]
|
||||
and e["target"] == inners[0]["id"] for e in r["edges"])
|
||||
|
||||
|
||||
def test_nested_partial_cross_half_unqualified_call_resolves(tmp_path):
|
||||
calls, r = _extract(tmp_path, _NESTED_HALVES)
|
||||
assert (_find(r, ".FromA()", "inner"), _find(r, ".FromB()", "inner")) in calls
|
||||
|
||||
|
||||
def test_cross_half_unqualified_call_beats_a_same_named_method_elsewhere(tmp_path):
|
||||
"""The in-file own-class binding (#4252) cannot see the other half of a
|
||||
partial type; a same-named method on another type must not make the
|
||||
cross-half call ambiguous."""
|
||||
calls, r = _extract(tmp_path, {
|
||||
"A.cs": (
|
||||
**_HALVES,
|
||||
"Decoy.cs": "namespace App { public class Decoy { public void Alpha() {} } }\n",
|
||||
})
|
||||
alpha = _find(r, ".Alpha()", "foo")
|
||||
beta = _find(r, ".Beta()", "foo")
|
||||
assert (beta, alpha) in calls
|
||||
assert (beta, _find(r, ".Alpha()", "decoy")) not in calls
|
||||
|
||||
|
||||
def test_nested_partial_base_call_resolves_into_the_other_half(tmp_path):
|
||||
"""`base.M()` on a nested partial base whose M is declared in the half
|
||||
that is not the merge's canonical node."""
|
||||
calls, r = _extract(tmp_path, {
|
||||
**_NESTED_HALVES,
|
||||
"Derived.cs": (
|
||||
"using static App.Outer;\n"
|
||||
"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"
|
||||
" public class Derived : Inner {\n"
|
||||
" public override void FromB() { base.FromB(); }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
})
|
||||
outers = _nodes_labeled(r, "Outer")
|
||||
derived_from_b = _find(r, ".FromB()", "derived")
|
||||
inner_from_b = _find(r, ".FromB()", "inner")
|
||||
assert (derived_from_b, inner_from_b) in calls
|
||||
|
||||
|
||||
def test_receiver_typed_calls_resolve_into_both_nested_halves(tmp_path):
|
||||
calls, r = _extract(tmp_path, {
|
||||
**_NESTED_HALVES,
|
||||
"Caller.cs": (
|
||||
"namespace App {\n"
|
||||
" public class Caller {\n"
|
||||
" public void Run(Outer.Inner i) { i.FromA(); i.FromB(); }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
})
|
||||
run = _find(r, ".Run()", "caller")
|
||||
assert (run, _find(r, ".FromA()", "inner")) in calls
|
||||
assert (run, _find(r, ".FromB()", "inner")) in calls
|
||||
|
||||
|
||||
def test_same_named_nested_partials_under_different_outers_not_merged(tmp_path):
|
||||
"""Nested ids omit the enclosing type; the merge must still keep
|
||||
`OuterA.Inner` and `OuterB.Inner` apart, each owning only its own halves."""
|
||||
def _half(outer, method):
|
||||
return (
|
||||
"namespace App {\n"
|
||||
f" public partial class {outer} {{\n"
|
||||
f" public partial class Inner {{ public void {method}() {{}} }}\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
calls, r = _extract(tmp_path, {
|
||||
"A1.cs": _half("OuterA", "A1"), "A2.cs": _half("OuterA", "A2"),
|
||||
"B1.cs": _half("OuterB", "B1"), "B2.cs": _half("OuterB", "B2"),
|
||||
})
|
||||
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}"
|
||||
assert len(inners) == 2, f"one Inner per outer: {inners}"
|
||||
owned = sorted(sorted(_methods_of(r, n["id"])) for n in inners)
|
||||
assert owned == [[".A1()", ".A2()"], [".B1()", ".B2()"]]
|
||||
|
||||
|
||||
def test_deeply_nested_partials_key_on_the_whole_enclosing_chain(tmp_path):
|
||||
def _half(mid, method):
|
||||
return (
|
||||
"namespace App {\n"
|
||||
" public partial class Outer {\n"
|
||||
f" public partial class {mid} {{\n"
|
||||
f" public partial class Inner {{ public void {method}() {{}} }}\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
calls, r = _extract(tmp_path, {
|
||||
"M1a.cs": _half("Mid1", "One"), "M1b.cs": _half("Mid1", "Two"),
|
||||
"M2.cs": _half("Mid2", "Three"),
|
||||
})
|
||||
inners = _nodes_labeled(r, "Inner")
|
||||
owned = sorted(sorted(_methods_of(r, n["id"])) for n in inners)
|
||||
assert owned == [[".One()", ".Two()"], [".Three()"]]
|
||||
|
||||
|
||||
def test_generic_and_non_generic_partials_not_merged(tmp_path):
|
||||
"""`Foo` and `Foo<T>` are distinct types with distinct ids (#4249); the
|
||||
merge must not fold them back together, top-level or as an outer."""
|
||||
calls, r = _extract(tmp_path, {
|
||||
"A.cs": "namespace App { public partial class Foo { public void A() {} } }\n",
|
||||
"B.cs": "namespace App { public partial class Foo<T> { public void B() {} } }\n",
|
||||
"C.cs": "namespace App { public partial class Foo { public void C() {} } }\n",
|
||||
"D.cs": (
|
||||
"namespace App { public partial class Box {"
|
||||
" public partial class Item { public void D() {} } } }\n"
|
||||
),
|
||||
"E.cs": (
|
||||
"namespace App { public partial class Box<T> {"
|
||||
" public partial class Item { public void E() {} } } }\n"
|
||||
),
|
||||
})
|
||||
foos = sorted(sorted(_methods_of(r, n["id"])) for n in _nodes_labeled(r, "Foo"))
|
||||
assert foos == [[".A()", ".C()"], [".B()"]]
|
||||
items = sorted(sorted(_methods_of(r, n["id"])) for n in _nodes_labeled(r, "Item"))
|
||||
assert items == [[".D()"], [".E()"]]
|
||||
|
||||
|
||||
_TWO_ASSEMBLIES = {
|
||||
@@ -344,3 +480,79 @@ def test_merge_does_not_dedup_edges_it_never_rewrote(tmp_path):
|
||||
]
|
||||
got = _merge(tmp_path, nodes, edges)
|
||||
assert got == {("x", "y", "calls", "EXTRACTED"), ("x", "y", "calls", "INFERRED")}
|
||||
|
||||
|
||||
def test_base_named_by_two_halves_resolves_once(tmp_path):
|
||||
"""Both halves name the base, a sibling nested type of the same holder.
|
||||
Only the half that imports the holder (`using static`) resolves it; the
|
||||
other leaves a placeholder. After the merge the type must not carry that
|
||||
placeholder next to the real base — it made the base-chain lookup for
|
||||
`base.Init()` look ambiguous and dropped the edge."""
|
||||
calls, r = _extract(tmp_path, {
|
||||
"Base.cs": (
|
||||
"namespace App {\n"
|
||||
" public static partial class Agents {\n"
|
||||
" public class Base { public virtual void Init() {} }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
"DA.cs": (
|
||||
"using static App.Agents;\n"
|
||||
"namespace App {\n"
|
||||
" public static partial class Agents {\n"
|
||||
" public partial class Derived : Base { public override void Init() { base.Init(); } }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
"DB.cs": (
|
||||
"namespace App {\n"
|
||||
" public static partial class Agents {\n"
|
||||
" public partial class Derived : Base { public void Other() {} }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
),
|
||||
"Decoy.cs": "namespace App { public class Decoy { public virtual void Init() {} } }\n",
|
||||
})
|
||||
derived = _nodes_labeled(r, "Derived")
|
||||
assert len(derived) == 1
|
||||
by_id = {n["id"]: n for n in r["nodes"]}
|
||||
bases = [by_id[e["target"]] for e in r["edges"]
|
||||
if e["relation"] in ("inherits", "implements") and e["source"] == derived[0]["id"]]
|
||||
assert [b["label"] for b in bases] == ["Base"], bases
|
||||
assert bases[0].get("source_file"), "the one base must be the real Agents.Base"
|
||||
assert (_find(r, ".Init()", "derived"), _find(r, ".Init()", "base")) in calls
|
||||
|
||||
|
||||
def test_schema_bump_retires_csharp_entries_cached_without_type_path(tmp_path, monkeypatch):
|
||||
"""An entry from schema 8 has no `type_path`; replayed, its nested halves
|
||||
stay split and `Box` / `Box<T>` fold back into one node."""
|
||||
import graphify.cache as cache_mod
|
||||
from graphify.cache import save_cached
|
||||
from graphify.extract import extract_csharp
|
||||
|
||||
files = {
|
||||
**{k: v for k, v in _NESTED_HALVES.items() if k != "Decoy.cs"},
|
||||
"Box.cs": "namespace App { public partial class Box { public void Plain() {} } }\n",
|
||||
"BoxOfT.cs": "namespace App { public partial class Box<T> { public void Typed() {} } }\n",
|
||||
}
|
||||
paths = []
|
||||
for name, body in files.items():
|
||||
p = tmp_path / name
|
||||
p.write_text(body)
|
||||
paths.append(p)
|
||||
current_schema = cache_mod._AST_CACHE_SCHEMA
|
||||
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "same-version")
|
||||
monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", 8) # last schema without type_path
|
||||
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set())
|
||||
for p in paths:
|
||||
stale = extract_csharp(p)
|
||||
for node in stale["nodes"]:
|
||||
(node.get("metadata") or {}).pop("type_path", None)
|
||||
save_cached(p, stale, root=tmp_path, cache_root=tmp_path, kind="ast")
|
||||
|
||||
monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current_schema)
|
||||
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set())
|
||||
r = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
|
||||
assert len(_nodes_labeled(r, "Inner")) == 1
|
||||
boxes = sorted(sorted(_methods_of(r, n["id"])) for n in _nodes_labeled(r, "Box"))
|
||||
assert boxes == [[".Plain()"], [".Typed()"]]
|
||||
@@ -1205,7 +1205,8 @@ def test_csharp_using_static_on_partial_holder_class(tmp_path: Path):
|
||||
|
||||
assert len(slice_a_defs) == 1
|
||||
assert len(slice_b_defs) == 1
|
||||
assert len(shared_defs) == 2, "nested partial SharedNested declarations remain unmerged in graph"
|
||||
assert len(shared_defs) == 1, "nested partial SharedNested halves merge under the same outer"
|
||||
assert shared_defs[0]["id"] in contains_targets
|
||||
assert slice_a_defs[0]["id"] in contains_targets
|
||||
assert slice_b_defs[0]["id"] in contains_targets
|
||||
|
||||
|
||||
Reference in new issue
Block a user