csharp: gate the lone-member guard keep on namespace visibility - #519
Merged
Conversation
zzet
approved these changes
Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Field-testing on my production C# codebase: every LINQ
.Where(...)thetype environment cannot type gets bound to the one indexed method named
Where— a static helper in an internal tooling namespace no callerimports — at 0.7
ast_inferred. The forward edges are wrong, and thereverse side is worse:
callerson that helper returns every LINQ-usingmethod in the repo, which makes a niche utility look load-bearing.
.Whereis not special. Any name that shadows a BCL or library memberand has exactly one indexed homonym fails the same way. Measured on my
repo after the fix, the affected population was ~7,000 call edges across
50 names —
Join,Where,Any,StartsWith,Round, plus libraryshapes like FluentValidation's
WithMessage/NotEmptyand Newtonsoft'sSerializeObject. The true targets are all external (System.Linq,string.Join, vendor packages), so they are not in the graph and cannever out-argue the indexed decoy.
Root cause
Two mechanisms, designed as a pair:
resolveMethodCall): amember call with exactly one same-name method candidate is treated as
a grounded inference and stamped
ast_inferred/0.7 — the commentexplicitly counts on the guard's lone-definition exception to keep it.
loneMemberDefnKeep(cross_pkg_guard.go) then does exactly that: thebind fails import-reachability, but a member call with an unknown
receiver type and a lone in-repo definition of the name is kept —
"there is nowhere else the call could bind."
That last premise is what breaks: for BCL-shadowing names there IS
somewhere else the call binds — outside the graph. The lone indexed
homonym is not the only candidate, just the only visible one.
Fix
For C# targets,
loneMemberDefnKeepnow demands one more piece ofcorroboration before keeping: the candidate's namespace (
scope_ns)must be visible from the calling file — its own namespace chain or a
using directive, project-scoped globals included, reusing the same
namespace-set evidence the extension-visibility machinery already
maintains.
The gate is narrowing-only, matching the existing extension-visibility
policy: a file with no recorded usings is stale or partial data, not
evidence of absence, so the keep is lost only when using evidence exists
and the namespace is not in it. (The suite's
TestResolveCSharpExtension_NothingVisibleFallsBackToUniquepins thatpolicy and caught my first, stricter cut — the shipped version keeps it
green.)
The rationale mirrors the language where it can: for the member shapes
that can bind cross-namespace without the file naming the type —
extension methods and class-qualified statics — namespace visibility is
the language rule, not a heuristic. For instance calls through
var-typed receivers it is a heuristic, and the trade is deliberate: arare silent miss (healed by csharp-types enrichment whenever the
receiver becomes typeable) instead of a systematic false family.
Scoped by
target.Language == "csharp"; no other language's keepchanges. Reverted sites take the guard's normal path — back to the
unresolved placeholder with the
guard_revertedstamp, honest insteadof wrong.
Behavior notes
untypeable receiver that really is that type, now sits unresolved
until enrichment can type the receiver. With using evidence absent the
old keep still applies.
on both the batch and incremental paths), fully on a rebuild —
enrichment alone cannot retarget these, since the receivers are
precisely the ones it cannot type.
extension_methodresolution and are kept (or not) by their ownvisibility rule, which this gate deliberately parallels.
Tests
TDD, red watched first on the exact field shape:
TestCSharpLoneMember_UnimportedNamespaceReverts— real extractor +ResolveAll; an unresolvable.Where(...)with the loneWherein anever-imported namespace failed red bound at 0.7
ast_inferred, greennow reverts to the unresolved placeholder (with a non-vacuity counter
so an ID-scheme drift can't green it silently).
TestCSharpLoneMember_ImportedNamespaceKeeps— control: the sameshape with the namespace imported keeps the grounded 0.7 bind.
TestResolveCSharpExtension_NothingVisibleFallsBackToUnique— thepre-existing pin that shaped the final design, still green.
Suites: resolver failure name-set byte-identical to clean main on my
Windows box (pre-existing platform failures only, worktree-verified);
tstypes and languages fully green.
Validation
Deployed on a rebuilt binary with a from-scratch index:
bind to zero — the declared fix signal — with every other pinned cell
stable, including the facade-fix cells from csharp: stop member calls from binding to the calling method itself #516 (their heal path
routes through import-reachable targets and never consults this keep).
.Wherefalse edges from theoriginal report are gone, the decoy's caller list dropped from
repo-wide to its three real same-subsystem callers, the ~7,000-edge
lone-homonym population across 50 names is honest-unresolved, and the
1,000+ extension-method binds are unchanged.