Skip to content

fix(parser): preserve full C# nested type identity - #937

Draft
merlincat11 wants to merge 6 commits into
tirth8205:mainfrom
merlincat11:fix/934-csharp-nested-type-identity
Draft

fix(parser): preserve full C# nested type identity#937
merlincat11 wants to merge 6 commits into
tirth8205:mainfrom
merlincat11:fix/934-csharp-nested-type-identity

Conversation

@merlincat11

@merlincat11 merlincat11 commented Aug 30, 2026

Copy link
Copy Markdown

Summary

Method nodes inside a nested class were qualified with only the immediate enclosing
class, so two nested classes sharing a name collapsed their methods into a single
node — Details.QueryHandler.Handle and Edit.QueryHandler.Handle both became
QueryHandler.Handle, silently losing one method with no uncertainty caveat. Only
one level of nesting was ever retained.

  • accumulate the containing-type path when recursing into a class body, as Julia already did
  • emit CONTAINS edges against the full path
  • resolve C# receivers against the new dotted parent names
  • rebuild persistent graphs once through the existing version gate shared with C++

Fixes #934

Why the resolver moves with the parser

These are one change, not two. parent_name is now a dotted path, and the old
receiver lookup reduced a scope to its last segment (scope.rsplit(".", 1)[-1]).
Shipping the parser fix alone would miss every nested type and stop resolving C#
receiver calls entirely.

Resolution model

A receiver is matched against a containing-type path exactly, in two phases:

  1. lexical — the receiver under each enclosing type, innermost first
    (Outer.Consumer.D.E, then Outer.D.E)
  2. exact — the receiver itself, then forms with a leading namespace removed

There is deliberately no suffix fallback. Selecting a nested type from a bare
receiver would bind names C# cannot bind: a bare QueryHandler does not name
Details.QueryHandler, which has to be qualified. A lone indexed candidate would
otherwise be rewritten as a single match with no visibility check at all, and the
real type is often in a dependency that was never indexed.

Phase 1 matters because inside Outer.Consumer a receiver D.E denotes Outer.D.E
even when a top-level D.E exists.

Phase 2 exists only because namespaces are absent from parent_name — they live in
csharp_namespaces_by_file — so App.Report.ExportHandler is stored as
Report.ExportHandler. The only thing a receiver may drop is a verified namespace
prefix.
A shortened form must prove the part it dropped is the namespace declared
by the candidate's defining file:

  • file declares exactly App → resolves
  • file declares Other → unresolved
  • file declares both App and Other → unresolved, because file-level evidence
    cannot show which namespace encloses the candidate
  • dropping a type name (Details.QueryHandlerQueryHandler) → unresolved,
    since Details is not a namespace

The multi-namespace case is conservative on purpose: it leaves genuine matches
unresolved rather than fabricating an edge. Resolving them properly needs per-node
namespace information rather than per-file, which is a follow-up.

Gated to C#. _SCOPED_LANGUAGES is php, rust, csharp; PHP and Rust are untouched.

Scope

This PR is the identity fix and nothing else. Short-name addressing of a dotted
nested path (children_of Details.QueryHandler) was split out to #942 — it carried a
schema migration, an index, and Java-FQN precedence questions unrelated to this bug.
Nested types are addressable here by qualified name, which is what demonstrates the fix.

Safety

  • receivers only ever match a containing-type path exactly
  • the sole permitted reduction is dropping an unambiguously verified namespace
  • ambiguous or unverifiable receivers stay unresolved instead of being guessed
  • PHP and Rust scoped resolution is unchanged
  • no schema change

Testing

  • uv run pytest tests/ --tb=short -q (2996 passed, 9 skipped, 2 xpassed)
  • distinct identities and CONTAINS parents for Details.QueryHandler vs Edit.QueryHandler
  • three-level nesting (A.B.C.M)
  • lexical shadowing: nested Shadower.D.E beats top-level D.E
  • namespaced receiver resolves when the file declares exactly that namespace
  • receiver stays unresolved when the dropped name is the wrong namespace,
    one of several namespaces in the file, or a type rather than a namespace
  • bare receiver never selects a nested type, unique candidate or not
  • legacy identity rebuild through the version gate
  • uv run ruff check code_review_graph/
  • uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.40 (MEDIUM) — 34 changed function(s)/class(es), 0 affected flow(s), 16 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.40 medium code_review_graph/incremental.py::record_identity_error code_review_graph/incremental.py:1287 no
0.40 medium code_review_graph/parser.py::CodeParser._base_type_name code_review_graph/parser.py:5450 no
0.40 medium code_review_graph/parser.py::CodeParser._resolve_typed_method_target code_review_graph/parser.py:5482 no
0.40 medium code_review_graph/parser.py::CodeParser._scope_join code_review_graph/parser.py:7440 no
0.40 medium code_review_graph/parser.py::CodeParser._resolve_julia_import_alias code_review_graph/parser.py:7530 no
0.35 low code_review_graph/scoped_resolver.py::_csharp_lexical_scopes code_review_graph/scoped_resolver.py:114 no
0.35 low code_review_graph/scoped_resolver.py::_scope_and_method code_review_graph/scoped_resolver.py:218 no
0.30 low code_review_graph/parser.py::CodeParser._typed_bindings_from_node code_review_graph/parser.py:5275 no
0.30 low code_review_graph/parser.py::CodeParser._store_typed_binding code_review_graph/parser.py:5431 no
0.30 low code_review_graph/parser.py::CodeParser._collect_julia_scoped_import_names code_review_graph/parser.py:13066 no

Test gaps

  • code_review_graph/incremental.py::record_identity_error (code_review_graph/incremental.py:1287)
  • code_review_graph/parser.py::CodeParser._typed_bindings_from_node (code_review_graph/parser.py:5275)
  • code_review_graph/parser.py::CodeParser._store_typed_binding (code_review_graph/parser.py:5431)
  • code_review_graph/parser.py::CodeParser._base_type_name (code_review_graph/parser.py:5450)
  • code_review_graph/parser.py::CodeParser._resolve_typed_method_target (code_review_graph/parser.py:5482)
  • ...and 11 more without direct tests

Token savings: this graph-backed report used ~239,855 fewer tokens (~98%) than reading every changed file in full (estimated, chars/4 approximation).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

@merlincat11
merlincat11 marked this pull request as ready for review August 30, 2026 12:16
@merlincat11
merlincat11 marked this pull request as draft August 30, 2026 14:24
Method nodes inside a nested class were qualified with only the immediate
enclosing class, so two nested classes sharing a name collapsed their methods
into one node: Details.QueryHandler.Handle and Edit.QueryHandler.Handle both
became QueryHandler.Handle, silently losing one method with no uncertainty
caveat. Only one level of nesting was ever retained.

Accumulate the containing-type path when recursing into a class body, as Julia
already did, and emit CONTAINS edges against the full path.

The resolver has to move with the parser rather than in a separate change:
parent_name is now a dotted path, so the old receiver lookup -- which reduced a
scope to its last segment -- would miss every nested type and stop resolving C#
receiver calls entirely. Receivers now resolve in C# lookup order, with every
exact candidate exhausted before any suffix heuristic:

  1. lexical - the receiver under each enclosing type, innermost first
  2. exact   - the receiver itself, then progressively shorter paths
  3. suffix  - the suffix map, matching a containing-type tail

Ordering is load-bearing twice over. Inside Outer.Consumer a receiver D.E means
Outer.D.E even when a top-level D.E exists. And because C# namespaces are not
part of parent_name -- they live in csharp_namespaces_by_file -- a namespaced
receiver App.Report.ExportHandler only matches exactly once shortened to
Report.ExportHandler; letting a suffix hit land first would bind an unrelated
nested type and, being unique, bypass the namespace disambiguator.

Persistent graphs rebuild once through the existing version gate shared with
C++, so stored identities pick up the new format.

Fixes tirth8205#934
The suffix map let a bare receiver select a nested type that is not lexically
visible. A bare QueryHandler does not name Details.QueryHandler in C# -- that
has to be qualified -- but the map held QueryHandler -> Details.QueryHandler,
so a lone indexed candidate took the single_match path and rewrote the call
with no visibility check at all. The real type is often in a dependency that
was never indexed, which is exactly when the map is most confident and most
wrong.

Leaving such a receiver unresolved is the correct answer. Removing the fallback
turns out to cost nothing: the lexical phase covers receivers bound through an
enclosing type, and the exact phase, which shortens progressively, covers
namespace-qualified receivers whose namespace is absent from parent_name. The
whole suite passes without it.

Renames test_longer_suffix_match_beats_shorter_exact_type, which described the
removed mechanism rather than what it asserts.
@merlincat11

Copy link
Copy Markdown
Author

The concern is real — I reproduced it before changing anything.

Your exact case (Details.QueryHandler nested, bare QueryHandler receiver in another file, using External;) resolved to Details.QueryHandler.Handle with scoped_via: "single_match". With one indexed candidate the suffix map hands back a unique answer, csharp_disambiguate is never reached, and no visibility check runs at all.

So I took the fix you suggested and dropped the containing-type suffix fallback entirely.

It turned out to cost nothing. Your read was right that the valid cases are already covered:

  • receivers bound through an enclosing type are handled by the lexical phase
  • namespace-qualified receivers are handled by the exact phase, which shortens progressively — App.Report.ExportHandler misses exactly, then matches at Report.ExportHandler, because the namespace is absent from parent_name

So the separate namespace-metadata validation you offered as a fallback plan isn't needed; the exact phase already gets there. The whole suite passes with the map removed (2994 passed, 9 skipped, 2 xpassed), and the resolver is now two phases that both match a containing-type path exactly, with no arbitrary suffix matching left in it.

Added test_bare_receiver_never_selects_a_nested_type, which asserts the call stays unresolved. I checked it fails against the previous commit with exactly the mislink you described, so it's a real regression test rather than one that passes vacuously:

AssertionError: {'LoneNested.cs::Wrapper.LoneHandler.Handle'}
assert {...} == {'Handle'}

Also renamed test_longer_suffix_match_beats_shorter_exact_type — it described the mechanism that's now gone rather than what it asserts.

Pushed as 0e9b891.

Phase 2 shortened a dotted receiver and treated each form as an exact lookup,
so App.Report.ExportHandler fell back to Report.ExportHandler and matched a
type sitting in namespace Other. One candidate takes the single_match path, so
csharp_disambiguate never ran and nothing downstream caught it. C# resolves the
leading identifier to a namespace or type and binds each later component
relative to it; it never discards App and restarts at Report.

Shortening is legitimate only because namespaces are absent from parent_name,
so a shortened form now has to prove the part it dropped really is a namespace
declared by the candidate's defining file, using the csharp_namespaces metadata
the resolver already builds. A genuine App.Report.ExportHandler whose file
declares namespace App still resolves; the mismatched one stays unresolved,
which is right when the requested type may simply not be indexed.

This also removes the last way a leading type name could be dropped: shortening
Details.QueryHandler to QueryHandler now fails the check, since Details is a
type rather than a namespace.
@merlincat11

Copy link
Copy Markdown
Author

Confirmed before changing anything — your case resolved to Wrong.cs::Report.ExportHandler.Run with scoped_via: "single_match", exactly as you described. One candidate means csharp_disambiguate never runs, so nothing downstream catches it.

Fixed in d62623b, and it's the check you proposed in your previous round. I said then that the exact phase made namespace validation unnecessary — that was wrong, and this is the case that shows it.

Shortening is only legitimate in the first place because namespaces are absent from parent_name. So a shortened form now has to prove the part it dropped really is a namespace declared by the candidate's defining file, using the csharp_namespaces metadata the resolver already builds:

  • App.Report.ExportHandlerReport.ExportHandler where the file declares namespace App — resolves
  • the same shortening where the file declares namespace Other — stays unresolved

Unresolved is the right answer there, since the requested type may simply not be indexed.

One useful side effect: this closes the last route by which a leading type name could be dropped. Shortening Details.QueryHandler to QueryHandler now fails the check too, because Details is a type rather than a namespace — the same class of error as the suffix map removed in the previous round, now ruled out structurally rather than by having deleted one code path.

Added test_shortened_receiver_requires_the_dropped_part_to_be_a_namespace. Verified it fails against 0e9b891 with the exact mislink, so it isn't passing vacuously:

AssertionError: {'WrongNamespace.cs::Ledger.AuditHandler.Run'}

2995 passed, 9 skipped, 2 xpassed; ruff and mypy clean.

…space

The namespace evidence is per file, and a C# file may declare several
namespaces; the parser records all of them. Membership alone therefore proved
only that the dropped name appeared somewhere in the candidate's file, not that
it enclosed the candidate. A file declaring both App and Other let
App.Ledger.AuditHandler bind an Other.Ledger.AuditHandler, which as the sole
candidate took the single_match path unchecked.

Require the candidate's file to declare exactly the dropped namespace. Genuine
matches in multi-namespace files are now left unresolved, which is the right
trade for a resolver that must not fabricate edges.

Resolving those properly needs per-node namespace information rather than
per-file, which is a separate change to what the parser records.
@merlincat11

Copy link
Copy Markdown
Author

Confirmed — the file-level set really does leak. Your case resolved to Definitions.cs::Ledger.AuditHandler.Run via single_match, with the file metadata reading {"csharp_namespaces": ["App", "Other"]}. You put it exactly right: the check proved App belongs to the candidate's file, not to the candidate.

Took the conservative option, in b9c93f3:

if csharp_namespaces_by_file.get(file, set()) != {dropped_namespace}:
    reject

So a shortened form now requires the candidate's file to declare exactly the dropped namespace. Genuine matches in multi-namespace files are left unresolved, which as you say is the right trade for a resolver that must not fabricate edges.

I deliberately did not take the per-node namespace option here. It's the better architecture and I agree it's where this should end up, but it changes what the parser records per node, which is well outside a node-identity bug fix — this PR has already been split once for scope. Worth its own issue; happy to file it if you want, and I'd rather it be discussed on its own merits than smuggled in here.

Added test_dropped_namespace_must_be_the_files_only_namespace, verified failing against d62623b with the exact mislink:

AssertionError: {'MultiNamespace.cs::Journal.PostHandler.Post'}

The invariant across the last three rounds is now a single sentence: the only thing a receiver may drop is an unambiguously verified namespace prefix. Wrong namespace, one-of-several namespaces, and a leading type name are all rejected by the same check.

Also corrected the stale PR description — thanks for catching it. It no longer describes a third suffix phase, and now documents the multi-namespace limitation and its rationale.

2996 passed, 9 skipped, 2 xpassed; ruff and mypy clean.

An exact containing-type match is not an exact type match. Namespaces are
absent from parent_name, so a type declared

    namespace Other { class App { class Report { class ExportHandler } } }

is keyed as App.Report.ExportHandler and matches a receiver that means an
entirely different App.Report.ExportHandler, typically one from a referenced
assembly that was never indexed. Namespace validation only ran when the
receiver itself carried a namespace prefix, so this path went unchecked and,
as the sole candidate, took single_match.

When the receiver names no namespace, require the candidate's namespace to be
reachable from the call site: same file, the global namespace, a using
directive, or a namespace the caller declares. The evidence all already exists
in imports_by_file and csharp_namespaces_by_file.

This is not a regression from the branch -- main resolves the same call to the
same wrong node, and does so on the bare name ExportHandler rather than
requiring the full containing path to coincide, so the window was strictly
wider before. It is a long-standing consequence of namespaces being outside the
C# identity, now closed for the cases file-level evidence can decide.
@merlincat11

Copy link
Copy Markdown
Author

Fixed in 2fb7b6d, and your test case is in. One correction to the framing, though: this is not a regression from this branch.

I ran your exact case against main:

result
main Indexed.cs::ExportHandler.Run — resolved
this branch, before 2fb7b6d Indexed.cs::App.Report.ExportHandler.Run — resolved
this branch, now unresolved

main mislinks the same call to the same wrong type, and it does so matching the bare name ExportHandler, so on main any ExportHandler.Run anywhere collides. This branch already narrowed that to requiring the full containing path to coincide. So the bug is a long-standing consequence of namespaces sitting outside the C# identity, not something introduced here — worth being accurate about, since it affects whether it blocks this PR.

That said, you're right that it's real and that the PR asserted an invariant it didn't hold, so I've closed it. When a receiver names no namespace, the candidate's namespace must now be reachable from the call site: same file, global namespace, a using, or a namespace the caller declares. imports_by_file and csharp_namespaces_by_file already carry all of that, so no new metadata was needed — and no existing test changed behaviour.

Added test_exact_path_match_still_checks_the_candidate_namespace, verified failing against b9c93f3 with exactly your prediction:

AssertionError: {'WrongNamespaceExactPath.cs::Vault.Archive.StoreHandler.Store'}

On the architecture: I still think per-node namespaces belong in their own change rather than here. This PR fixes a node-identity bug and has already been split once for scope; folding a parser-level identity change into it would repeat exactly the mistake that split corrected. I'm not disputing that it's the right end state — file-level evidence remains genuinely unable to decide the multi-namespace case, and that limitation is documented in the PR description. I'd rather that land as its own reviewable change than as a fifth follow-up commit here.

2997 passed, 9 skipped, 2 xpassed; ruff and mypy clean.

…eivers too"

This reverts 2fb7b6d, which regressed a case main handles correctly.

The predicate treated the caller's own file as the only source of visibility
evidence. A global using in a different file applies project-wide, so a valid
call resolved on main and became unresolved here:

    GlobalUsings.cs  global using Other;
    Indexed.cs       namespace Other; class Vault { ... }
    Consumer.cs      Vault.Archive.StoreHandler handler;

It was also wrong in the other direction, and still is after the revert: same
file does not mean same namespace, and a using is scoped to its namespace body
rather than the whole file, so file-level evidence over-permits as readily as
it under-permits. Trading a false negative for a partial fix to a false
positive is not a good trade, and both directions have the same cause -- the
namespace is not in the identity, so no amount of file-level inference can
settle it.

Reverting restores parity with main: the wrong-namespace mislink remains, but
it is pre-existing, main hits it on the bare name, and this branch already
narrows it by requiring the full containing-type path to coincide.

Keeps the case as an xfail so the limitation is recorded rather than forgotten,
and adds a global-using test so any future visibility work has to preserve it.
@merlincat11

Copy link
Copy Markdown
Author

You're right on all three, and the third one is decisive — so I've reverted 2fb7b6d rather than patch it.

I measured each case against main:

case main 2fb7b6d after revert
same file, different namespaces RESOLVED (Both.cs::StoreHandler.Store) RESOLVED (wrong) RESOLVED (wrong)
global using in another file RESOLVED (correct) unresolved RESOLVED (correct)
exact path, wrong namespace RESOLVED (wrong) unresolved RESOLVED (wrong)

The global-using row is the one that matters: my predicate broke a call main gets right. Meanwhile it didn't fix your first two cases at all — same-file and file-scoped-using both still over-permit. So it under-permits and over-permits simultaneously, which is the signature of the wrong evidence rather than a bug in the rule. Trading a false negative for a partial fix to a false positive isn't a good trade, and adding global-using support would have fixed one row and left the other two for a sixth round.

After the revert the branch is at parity with main on every one of these: the wrong-namespace mislink remains, but it is pre-existing, main hits it on the bare name StoreHandler, and this branch already narrows it to requiring the full containing-type path to coincide.

Two tests to keep it honest:

  • test_exact_path_match_should_check_the_candidate_namespace — kept as xfail with the reason recorded, so the limitation is documented rather than forgotten, and flips to passing the day it's fixed properly
  • test_global_using_target_still_resolves — new, so any future visibility work has to preserve the case my patch broke

On the underlying point, I now agree with you without reservation: file-level namespace evidence cannot establish this invariant in either direction, and per-node namespaces are the actual fix. What I'd push back on is doing it in this PR. It is a parser-level change to the C# identity, it needs its own rebuild-gate consideration, and this PR is a node-identity bug fix that has already been split once for scope. Four rounds here have each closed one instance of the same root cause; the fifth should close the cause. I'd rather open that as its own issue referencing this thread than grow this one again.

Happy to file it now if you want, and to hold #937 until it lands if you'd prefer they ship together.

2997 passed, 9 skipped, 1 xfailed, 2 xpassed; ruff and mypy clean.

@merlincat11

Copy link
Copy Markdown
Author

Holding this PR pending #946.

Filed the root cause as its own issue: C# namespaces are absent from the graph identity, so namespace-sensitive resolution is undecidable from the file-level evidence available. It collects all five failing cases with their main-vs-branch measurements, the reverted attempt and why it failed in both directions, and the design questions worth settling first — sidecar vs identity change, namespace-body-scoped usings, and project-wide global usings.

To be clear about what this PR does and doesn't do in the meantime: it fixes #934, and it is at parity with main on every namespace case in #946 — none of them are regressions, and case 1 is strictly narrowed here. So this is not blocked on correctness so much as on building it against a foundation that can actually express the invariant, rather than adding a fifth file-level heuristic on top of four.

Marking it draft. Once #946 lands, the xfail here should flip to passing, and test_global_using_target_still_resolves should stay green.

Thanks for the persistence across these rounds — the same-file and global using cases in particular were things I'd have shipped wrong.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: C# nested type identity is truncated to one level, merging distinct methods into one node

2 participants