Roslyn Navigator 0.10.0: symbol resolution correctness, response contracts, and two new tools - #26
Merged
Merged
Conversation
A reload mints fresh ProjectIds, so every previously cached Compilation is keyed on a dead id. Two of the four LoadSolutionAsync call sites cleared the cache first; the roots-discovery and error-retry paths did not, leaving orphaned entries that never evict and pin whole Roslyn compilations in memory. Moving the clear into LoadSolutionAsync itself makes it unconditional and removes the obligation from callers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SymbolResolver passed the caller's string straight to GetSymbolsWithName,
which matches a symbol's declared name only. Any qualified input therefore
found nothing: find_references("OrderService.CreateOrderAsync") returned an
empty result, indistinguishable from a symbol with no references.
Resolution now normalizes the request (dropping parameter lists, generic
arguments, and metadata arity), looks up the final segment, then filters
candidates on a segment-aligned suffix match of their namespace/containing-type
chain. Suffix matching is anchored on '.' so "derService.GetOrderAsync" cannot
match OrderService.
This also gives callers a way to disambiguate a name reused across types
without knowing its file or line, which matters most for interface members
where every implementation shares the name.
get_symbol_source drops its own dotted-name parsing in favour of the shared
path, losing the fallback that silently returned an unrelated symbol when the
containingType hint did not match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine tools returned an empty result set when a symbol could not be resolved,
making "this name does not exist" indistinguishable from "this symbol exists
and has no references". An agent reading that empty response concludes the code
is dead — from a typo.
Worse, ResolveSymbolAsync ended in `return symbols[0]`. A bare name matching
several types silently picked whichever project Roslyn enumerated first and
answered with full confidence about the wrong symbol. The existing
FindCallers("GetByIdAsync") test was passing on exactly this behaviour: five
declarations match that name in the sample solution.
Tools now return ErrorResponse with a stable code — SymbolNotFound,
AmbiguousMatch, WrongSymbolKind, FileNotFound, NoSource — and AmbiguousMatch
carries the candidate list so the caller can re-query with a qualified name
rather than guess.
Overloads, partial declarations, and per-target-framework duplicates share a
qualified name and kind, so they group as one logical symbol; only genuinely
distinct symbols raise AmbiguousMatch.
SymbolResolution carries the outcome as a struct rather than a tuple so
MemberNotNullWhen tells the compiler the symbol is non-null past the guard —
no null-forgiving operators, no nullability warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Responses carried Count and TotalFound but never said whether the cap had actually dropped anything. Callers had to infer it by comparing the two, and the boundary case — Count equal to the limit with nothing dropped — reads as truncated under the obvious check. List-returning tools now report Truncated and Limit directly. Truncated is the single field to branch on; Limit tells the caller what to raise on a re-query instead of guessing at the tool's default. Paging.Apply centralises the cap so the contract cannot drift between tools. find_references and find_nuget_packages keep their own capping — the first builds snippets lazily as locations stream in, the second caps packages across projects rather than the project list it returns — but report the same fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
find_symbol, find_references, find_callers, find_implementations, and find_overrides returned generated declarations and call sites indistinguishable from hand-written ones. An agent following those results edits a .g.cs file that the next build overwrites. Each result now carries IsGenerated, derived from the classifier the anti-pattern detectors already use — .g.cs/.Designer.cs/obj-path conventions plus an <auto-generated> header — extended with Roslyn's IsImplicitlyDeclared to cover compiler-synthesized members that have no declaration at all. GeneratedCodeIndex memoizes per syntax tree: a result set typically spans few files and many symbols, so leading trivia is read once per file rather than once per hit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
find_dead_code inferred "unused" from a zero-reference count, which is exactly
what a reflection-bound type looks like. A class resolved through
Type.GetType("MyApp.LegacyPlugin") has no references at all, and the tool
reported it at high confidence — an invitation to a breaking deletion.
Findings are now graded. A name appearing in any string literal in the solution
drops to low confidence with the reason attached: that is precisely how
reflection names a type it never references. The existing convention-suffix
heuristic stays at medium, and only symbols with neither signal remain high.
The result also reports AssemblyScanningDetected. When a solution registers
types by scanning — Scrutor, MediatR, FluentValidation assembly registration,
ApplyConfigurationsFromAssembly — every zero-reference result is softer, and the
caller should know that without inspecting each finding.
The literal index covers the whole solution rather than the requested scope,
since a project scoped out of the scan can still be the one naming the type.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turning an exception into a place to look was manual work: read the trace, guess which frames are yours, grep for the method, open the file. The frames that matter are also the ones hardest to grep for, because the compiler rewrites async methods, lambdas, and local functions into generated types whose names do not appear in source — a throw inside an async method surfaces as OrderService+<CancelOrderAsync>d__4.MoveNext(). The tool undoes those rewrites (async state machines, lambdas hoisted into closure or display classes, local functions, generic arity, nested-type separators, constructors), resolves each frame against the solution, and returns file, line, and the source line itself. Frames carry InSolution so framework noise can be filtered, and the result points at FirstSolutionFrame — the topmost frame in the user's own code, which is where an investigation almost always starts. The line reported by the trace wins over the declaration line, since that is where it actually threw. Only source declarations are indexed, so "resolves" and "is the user's code" are the same test — no separate allowlist of framework namespaces to maintain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Answering "what breaks if I change this?" meant four separate calls — find_references for the call sites, find_implementations and find_overrides for the declarations that must move in lockstep, get_project_graph for the reach — and then stitching the results together by hand. The tool does it in one pass and adds what the individual calls cannot see: - Implementations and overrides are counted separately from references, because they are not references. A signature change forces them to change too, so a reference count on its own understates the work. - References are grouped by project and by file: one says how far the change reaches, the other says where the edits land. - Transitive callers walk the call graph breadth-first to a bounded depth, with visited symbols tracked across levels so recursion terminates. - CrossesAssemblyBoundary flags changes to a public surface, where consumers outside this solution can break without any local reference to show for it. Risk carries its rationale rather than a bare label, since "high" from implementations-in-lockstep calls for different handling than "high" from sheer call-site count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Change detection compared csproj timestamps, so any rewrite triggered a full solution reload — the most expensive thing this server does. A branch switch rewrites every project file whether or not its content differs, and so does a formatter or a checkout of the same commit, each costing a complete MSBuild re-evaluation for nothing. Build files now carry a content hash alongside the timestamp. The timestamp stays as the pre-filter, so the steady-state cost is still one stat per file; only a file that moved gets hashed, and an identical rewrite costs one hash instead of a reload. Its recorded timestamp is refreshed on the way through so the next poll short-circuits at the stat. Directory.Build.props, Directory.Packages.props, Directory.Build.targets, global.json, and nuget.config are now tracked too. They govern the compilation of every project beneath them, but they are not documents and not csproj files, so a version bump in Directory.Packages.props was previously invisible until something else forced a reload. An unreadable build file hashes to null, which compares unequal to everything and so counts as changed — a locked file errs toward reloading rather than serving stale analysis. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Detecting newly added source files meant enumerating every .cs file under every project directory, and it ran inline inside the refresh that precedes every tool call. Past the 60-second cooldown, one unlucky request paid for a full recursive walk of the repository before any analysis started — and which request paid was arbitrary. The walk now runs on a background task, at most one at a time. A tool call schedules it and returns; the finding is picked up by a later refresh via an interlocked flag. Results are at most one cooldown stale, which is exactly the staleness the inline version already had — the cooldown was always the bound, not the walk. The scan takes the shutdown token and checks it per project and per file, so disposal does not wait on a large enumeration, and Dispose caps its wait at two seconds regardless. It also captures the solution snapshot once up front, since a concurrent reload can swap the field mid-walk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Installing the global tool requires a .NET 10 SDK on the host, and on macOS and Linux a wrapper-script `dotnet` with DOTNET_ROOT unset makes that setup fail — the case the troubleshooting section already documents. A container removes the host SDK from the equation entirely. Built with the SDK's own container publishing rather than a Dockerfile, matching the kit's container-publish guidance, so there is no separate build recipe to keep in sync with the csproj. The base image is the SDK, not aspnet or runtime. MSBuildLocator needs a real SDK installation to open a solution; on a runtime image the server starts cleanly and then fails every call with "No .NET SDKs were found" — the exact problem the container was meant to avoid. Verified as far as this machine allows: the publish resolves the properties, produces the linux-x64 output, and reaches image assembly against the SDK base image. The final push needs a running Docker daemon, which is not available here, so the image itself has not been run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ontracts Tool count 20 -> 22 across the plugin manifest, marketplace entry, and READMEs. The MCP README gains the three contracts this release introduced, since none of them are discoverable from a tool list: the accepted symbol-name forms, the error codes and what distinguishes each, and the Truncated/Limit paging fields. Also documents the IsGenerated flag and the container install path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI requires plugin.json, marketplace.json, and the newest CHANGELOG release heading to agree. The manifests were bumped to 0.12.0 but the entry was still sitting under [Unreleased], so the version-consistency gate failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CHANGELOG and MCP README credited an external package as the prompt for this release. The notes stand on their own without naming it, and describing our release relative to someone else's package is not information a reader of these notes needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Correctness pass on the Roslyn MCP server: symbol resolution, response contracts, and workspace reload behaviour — plus two new tools.
Fourteen commits, one per fix, each with tests.
Correctness
Symbol resolution accepted only bare declared names.
find_references("OrderService.CreateOrderAsync")returned an empty result — indistinguishable from a symbol with no references. Qualified names now resolve, with parameter lists, generic arguments, and metadata arity stripped, and suffix matching anchored on segment boundaries soderService.Getcannot matchOrderService.Get.Ambiguity was resolved by
return symbols[0]. A bare name matching several types silently answered about whichever one Roslyn enumerated first, with full confidence. This was not hypothetical: the existingFindCallers("GetByIdAsync")test was passing on exactly this behaviour — five declarations match that name in the fixture solution.Nine tools returned an empty list when a symbol could not be resolved, so "this name does not exist" and "this symbol exists and has no references" were the same response. An agent reading that concludes code is dead, from a typo. Tools now return
SymbolNotFound/AmbiguousMatch(with candidates) /WrongSymbolKind/FileNotFound/NoSource.Truncation was left to a
CountvsTotalFoundcomparison, which reads as truncated when the result set happens to be exactly the limit. Now stated directly asTruncated+Limit, produced by a sharedPaginghelper so the contract cannot drift between tools.find_dead_codeinferred "unused" from a zero-reference count — which is exactly what a reflection-bound type looks like. A class resolved throughType.GetType("MyApp.LegacyPlugin")was reported at high confidence, inviting a breaking deletion. Findings are now graded, withAssemblyScanningDetectedflagging solutions where every result is softer.A reload left the compilation cache populated. Fresh
ProjectIds meant orphaned entries that never evict and pin whole Roslyn compilations in memory. Two of fourLoadSolutionAsynccall sites cleared it; the clear moved inside.Navigation results did not distinguish generated source, so an agent could follow a hit into a
.g.csfile the next build overwrites. Now flagged withIsGenerated.New tools (20 → 22)
resolve_stack_trace— maps an exception onto the solution. The frames that matter are the ones hardest to grep for, because the compiler rewrites async methods, lambdas, and local functions into generated types whose names never appear in source. Undoes those rewrites, marks which frames are yours, and points atFirstSolutionFrame.analyze_change_impact— "what breaks if I change this?" in one call. Implementations and overrides are counted separately from references, because they are not references: a signature change forces them to change too, so a reference count alone understates the work. Adds assembly-boundary exposure and a risk rating with its rationale.Infrastructure
Reloads were triggered by csproj timestamps, so a branch switch or formatter rewrite caused a full MSBuild re-evaluation for identical content. Build files now carry a content hash with the timestamp kept as a pre-filter. This also closed a real gap:
Directory.Build.props,Directory.Packages.props,Directory.Build.targets,global.json, andnuget.configwere entirely untracked despite governing every project beneath them.The structural scan for new source files ran inline, so one arbitrary tool call per cooldown paid for a recursive walk of the repository. Now runs in the background with the same staleness bound.
Container distribution via SDK container publishing (no Dockerfile, per the kit's own
container-publishguidance). The base image is the SDK, notruntime—MSBuildLocatorneeds a real SDK installation, and on a runtime image the server starts cleanly then fails every call with "No .NET SDKs were found".Verification
dotnet format --verify-no-changescleanNot verified: the container image was never run. The publish resolves properties, produces the linux-x64 output, and reaches image assembly — then stops at "Cannot find docker/podman executable", as no Docker daemon is available on the machine this was built on. Everything up to the daemon handoff is confirmed; the image itself is not.
Not included: npm distribution. That needs an npm account and publish pipeline — a separate call.
Versions
CWM.RoslynNavigator 0.9.0 → 0.10.0, plugin 0.11.0 → 0.12.0, tool count updated across the manifest, marketplace entry, and READMEs. The MCP README documents the three new contracts (symbol-name forms, error codes, paging fields), since none are discoverable from a tool list.
🤖 Generated with Claude Code