diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 6024f74..c98aac9 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -11,8 +11,8 @@
{
"name": "dotnet-claude-kit",
"source": "./",
- "description": "47 skills (incl. 16 slash-command workflows), 10 agents, 10 rules, 5 templates, 20-tool Roslyn MCP server for .NET 10 / C# 14",
- "version": "0.11.0",
+ "description": "47 skills (incl. 16 slash-command workflows), 10 agents, 10 rules, 5 templates, 22-tool Roslyn MCP server for .NET 10 / C# 14",
+ "version": "0.12.0",
"license": "MIT",
"keywords": [
"dotnet",
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 81d4a0c..434ac7f 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,8 +1,8 @@
{
"name": "dotnet-claude-kit",
"displayName": "dotnet Claude Kit",
- "version": "0.11.0",
- "description": "The definitive Claude Code companion for .NET developers. 47 skills (including 16 slash-command workflows), 10 agents, 10 rules, 5 templates, 20 MCP tools, and automation hooks for modern .NET 10 / C# 14.",
+ "version": "0.12.0",
+ "description": "The definitive Claude Code companion for .NET developers. 47 skills (including 16 slash-command workflows), 10 agents, 10 rules, 5 templates, 22 MCP tools, and automation hooks for modern .NET 10 / C# 14.",
"author": {
"name": "Mukesh Murugan",
"url": "https://codewithmukesh.com"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8e6a6e..27672fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,9 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [0.12.0] — 2026-08-07
+
+Roslyn Navigator correctness release. Symbol resolution, response contracts, and workspace reload behaviour are corrected, and two tools are added: stack trace resolution and change impact analysis.
+
+### Added
+- **2 new MCP tools (20 → 22)** in CWM.RoslynNavigator 0.10.0:
+ - `resolve_stack_trace` — maps a .NET exception onto the solution. Undoes compiler rewrites for async state machines, lambdas, and local functions (the frames hardest to grep for), marks which frames are yours, and points at the topmost one in your own code
+ - `analyze_change_impact` — "what breaks if I change this?" in one call: references grouped by project and by file, implementations and overrides that must change alongside a signature, transitive callers, assembly-boundary exposure, and a risk rating with its rationale
+- **Container distribution** for the MCP server via SDK container publishing (no Dockerfile), removing the host-SDK and `DOTNET_ROOT` setup that trips up macOS and Linux installs
### Changed
+- **CWM.RoslynNavigator 0.9.0 → 0.10.0 — symbol resolution and response-contract correctness pass**:
+ - **Qualified symbol names now resolve.** Lookup accepted only a bare declared name, so `find_references("OrderService.CreateOrderAsync")` returned empty — indistinguishable from a symbol with no references
+ - **Structured error codes replace empty results.** Nine tools returned an empty list for an unresolvable symbol, and ambiguity was resolved by `return symbols[0]` — a bare name matching several types answered about the wrong one with full confidence. Now `SymbolNotFound`, `AmbiguousMatch` (with candidates), `WrongSymbolKind`, `FileNotFound`, `NoSource`
+ - **`Truncated` and `Limit` on every list response**, so truncation is stated rather than inferred from `Count` vs `TotalFound` — a comparison that is wrong when the result set is exactly the limit
+ - **`IsGenerated` on navigation results**, so an agent does not edit a `.g.cs` file the next build overwrites
+ - **`find_dead_code` grades against reflection.** A type resolved via `Type.GetType("...")` has zero references and was reported at high confidence; a name in any string literal now drops to `low`, and `AssemblyScanningDetected` flags solutions that register types by scanning
+ - **Reloads only on real build-file changes.** csproj comparison was timestamp-only, so a branch switch re-evaluated MSBuild for identical content. Build files now carry a content hash, and `Directory.Build.props` / `Directory.Packages.props` / `Directory.Build.targets` / `global.json` / `nuget.config` are tracked — previously invisible despite governing every project beneath them
+ - **Structural file scan moved to the background**, so no tool call pays for a recursive directory walk
+ - **Fixed: compilation cache survived a reload**, leaving orphaned `ProjectId` entries pinning whole Roslyn compilations in memory
- **`hooks/pre-commit-antipattern.sh` rewritten** ([#23](https://github.com/codewithmukesh/dotnet-claude-kit/issues/23)) — the naive whole-file grep is replaced by a comment- and string-aware scanner (`hooks/lib/antipattern-scan.awk`) that mirrors the Roslyn detectors' rule IDs (AP001–AP004), severities, and `SourceKind` exemptions:
- Only the lines a commit **adds** are checked — a legacy `DateTime.Now` no longer blocks unrelated edits to the same file
- Comments, string, verbatim, and raw-string literals are stripped, tracking state across line boundaries
diff --git a/README.md b/README.md
index 84bee73..6ff1e10 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
Make Claude Code an expert .NET developer.
- 47 skills • 10 specialist agents • 16 slash commands • 10 rules • 5 project templates • 20 MCP tools • automation hooks
+ 47 skills • 10 specialist agents • 16 slash commands • 10 rules • 5 project templates • 22 MCP tools • automation hooks
Built for .NET 10 / C# 14. Architecture-aware. Token-efficient.
@@ -65,7 +65,7 @@ An **action layer** on top of the knowledge layer — Claude doesn't just know t
| Capability | What It Does |
|-----------|-------------|
-| **Surgical Code Analysis** | 20 Roslyn-powered MCP tools with guaranteed bounded responses. `get_symbol_source` reads ONE method body instead of the whole file. `get_file_outline` shows what's in a file before reading it. Every list-returning tool is capped with `TotalFound` — no tool can blow your context window. |
+| **Surgical Code Analysis** | 22 Roslyn-powered MCP tools with guaranteed bounded responses. `get_symbol_source` reads ONE method body instead of the whole file. `get_file_outline` shows what's in a file before reading it. `resolve_stack_trace` turns an exception into file:line in your own code. `analyze_change_impact` answers "what breaks if I change this?" in one call. Every list-returning tool reports `Truncated` — no tool can blow your context window. |
| **Architecture Enforcement** | `/arch-check` verifies the code still matches its declared architecture (VSA, Clean, DDD, Modular Monolith): dependency direction, layer violations, module leaks, cycles — with file:line evidence and fixes. |
| **Dependency Health** | `/outdated` reports stale packages, CVEs, and commercial-license traps (MediatR 13+, MassTransit 9+, FluentAssertions 8+, AutoMapper 15+) before an innocent update-all changes your legal position. |
| **Security Mapping** | `get_endpoint_map` inventories every route with its auth posture (`authorized`/`anonymous`/`unmarked`) in one token-cheap call — `/security-scan` starts every auth audit there. |
@@ -151,7 +151,7 @@ Replace `[ProjectName]`, update tech stack, choose your architecture.
-Start Claude Code — 47 skills, 10 agents, and 20 MCP tools activate automatically. Copy the 10 rules into your project's `.claude/rules/` to make them always-loaded.
+Start Claude Code — 47 skills, 10 agents, and 22 MCP tools activate automatically. Copy the 10 rules into your project's `.claude/rules/` to make them always-loaded.
That's it. Claude now writes .NET code the way a senior .NET engineer would.
@@ -413,7 +413,7 @@ dotnet-claude-kit/
├── .claude/rules/ # 10 always-loaded rules
├── templates/ # 5 drop-in CLAUDE.md templates
├── knowledge/ # Living reference documents + ADRs
-├── mcp/CWM.RoslynNavigator/ # Roslyn MCP server (20 tools)
+├── mcp/CWM.RoslynNavigator/ # Roslyn MCP server (22 tools)
├── mcp-configs/ # MCP server config templates
├── hooks/ # Claude Code hooks + git hooks + utilities
├── docs/ # Shorthand + longform guides
diff --git a/mcp/CWM.RoslynNavigator/README.md b/mcp/CWM.RoslynNavigator/README.md
index 9a51eac..33c9683 100644
--- a/mcp/CWM.RoslynNavigator/README.md
+++ b/mcp/CWM.RoslynNavigator/README.md
@@ -37,20 +37,74 @@ CWM.RoslynNavigator is a Model Context Protocol (MCP) server that provides Claud
| `get_nuget_packages` | PackageReference inventory per project with versions (CPM-aware, no network calls) |
| `get_endpoint_map` | ASP.NET Core route inventory: Minimal APIs (MapGroup-composed) + controllers, with auth posture per endpoint |
| `get_di_registrations` | DI registration map with duplicate detection and captive-dependency (singleton→scoped) risk flags |
+| `resolve_stack_trace` | Map an exception stack trace onto the solution — undoes async/lambda/local-function rewrites, marks which frames are yours |
+| `analyze_change_impact` | Blast radius of changing one symbol: references by project and file, implementations and overrides that must move with it, transitive callers, risk rating |
+
+### Symbol names
+
+Every tool that takes a symbol accepts three forms, from loosest to tightest:
+
+```
+CreateOrderAsync # bare — may be ambiguous
+OrderService.CreateOrderAsync # type-qualified
+MyApp.Orders.OrderService.CreateOrderAsync # fully qualified
+```
+
+Parameter lists, generic arguments, and metadata arity are ignored, so
+`IRepository.GetByIdAsync(Guid, CancellationToken)` resolves the same as
+`IRepository.GetByIdAsync`. Qualifiers match on segment boundaries — `derService.Get`
+will not match `OrderService.Get`.
+
+When a bare name matches several distinct symbols, tools return an `AmbiguousMatch` error
+listing the candidates rather than silently picking one. Overloads, partial declarations,
+and per-target-framework duplicates count as one symbol, not an ambiguity.
+
+### Errors
+
+Failures return a structured response with a stable `Error` code, never an empty result
+set — so "this symbol does not exist" is always distinguishable from "this symbol exists
+and has no references".
+
+| Code | Meaning |
+|------|---------|
+| `SymbolNotFound` | No symbol by that name. Check spelling, or search with `find_symbol`. |
+| `AmbiguousMatch` | Several distinct symbols match. `Candidates` lists them; re-query fully qualified, or pass `file`/`line`. |
+| `WrongSymbolKind` | Resolved, but to the wrong kind (e.g. a method where a type was required). |
+| `FileNotFound` | The path is not part of any project in the solution. |
+| `NoSource` | A metadata symbol, or one with no member declaration. |
+| `InvalidArgument` | Malformed input — an empty stack trace, for instance. |
### Result caps
-Every list-returning tool accepts a `maxResults` parameter and reports the uncapped match
-count as `TotalFound` in its response (`get_dependency_graph` reports a `Truncated` flag
-instead). Defaults: 50 for symbol/list tools (`find_symbol`, `find_references`,
+Every list-returning tool accepts `maxResults` and reports four fields:
+
+| Field | Meaning |
+|-------|---------|
+| `Count` | Entries in this response |
+| `TotalFound` | Entries that matched in total |
+| `Truncated` | Whether the cap dropped entries — **the field to branch on** |
+| `Limit` | The cap that was applied; re-query above it to see the rest |
+
+`Truncated` is stated rather than inferred from `Count` vs `TotalFound`, which gets the
+boundary case wrong when the result set happens to be exactly the size of the limit.
+
+Defaults: 50 for symbol/list tools (`find_symbol`, `find_references`,
`find_implementations`, `find_callers`, `find_overrides`, `find_dead_code`,
-`get_public_api`, `get_type_hierarchy`, `get_test_coverage_map`), 100 for
-`detect_antipatterns`, `get_diagnostics`, `get_endpoint_map`, `get_di_registrations`,
-`get_nuget_packages`, and `get_dependency_graph` nodes, 200 for `get_file_outline`
-members. `get_symbol_source` caps by characters instead (`maxChars`, default 8000, with a
-`Truncated` flag). When `TotalFound` exceeds the returned `Count`, re-query with a higher
-`maxResults`. `get_diagnostics` orders errors first and always includes per-severity
-totals, so a capped response never hides the important picture.
+`get_public_api`, `get_type_hierarchy`, `get_test_coverage_map`,
+`resolve_stack_trace`, `analyze_change_impact`), 100 for `detect_antipatterns`,
+`get_diagnostics`, `get_endpoint_map`, `get_di_registrations`, `get_nuget_packages`, and
+`get_dependency_graph` nodes, 200 for `get_file_outline` members. `get_symbol_source`
+caps by characters instead (`maxChars`, default 8000, with a `Truncated` flag).
+`get_diagnostics` orders errors first and always includes per-severity totals, so a
+capped response never hides the important picture.
+
+### Generated code
+
+`find_symbol`, `find_references`, `find_callers`, `find_implementations`, and
+`find_overrides` mark each result with `IsGenerated`, so an agent does not follow a hit
+into a `.g.cs` file that the next build overwrites. Detection uses file conventions
+(`.g.cs`, `.Designer.cs`, `obj/`), an `` header, and Roslyn's
+`IsImplicitlyDeclared` for compiler-synthesized members.
## Signal Quality
@@ -191,6 +245,43 @@ Then add to your project's `.mcp.json`:
dotnet run --project mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj -- --solution /path/to/your/Solution.sln
```
+### As a Container
+
+Use this when you do not want a .NET 10 SDK on the host — it sidesteps the
+`DOTNET_ROOT` setup that a global-tool install needs on macOS and Linux (see
+[Troubleshooting](#no-net-sdks-were-found-on-startup-macoslinux)).
+
+The image is produced by the .NET SDK's container publishing, so there is no
+Dockerfile to maintain:
+
+```bash
+cd mcp/CWM.RoslynNavigator
+dotnet publish src/CWM.RoslynNavigator.csproj -c Release --os linux --arch x64 /t:PublishContainer
+```
+
+That builds `cwm-roslyn-navigator:latest` locally. Register it with Claude Code by
+bind-mounting the solution at the image's working directory, `/workspace`:
+
+```bash
+claude mcp add --scope user cwm-roslyn-navigator -- \
+ docker run -i --rm -v "$PWD:/workspace" cwm-roslyn-navigator:latest --solution /workspace
+```
+
+`-i` is required: the server speaks MCP over stdio, so the container must keep
+stdin open. The mount is read-only as far as this server is concerned — every tool
+is read-only — but leave it writable if the same checkout is being built on the host.
+
+**Base image**: `mcr.microsoft.com/dotnet/sdk:10.0`, not a runtime image. `MSBuildLocator`
+needs a real SDK installation to open a solution; on `aspnet` or `runtime` the server
+starts and then fails every call with "No .NET SDKs were found".
+
+To publish somewhere other than the local daemon, set the registry on the command line:
+
+```bash
+dotnet publish src/CWM.RoslynNavigator.csproj -c Release --os linux --arch x64 \
+ /t:PublishContainer -p:ContainerRegistry=ghcr.io -p:ContainerRepository=you/cwm-roslyn-navigator
+```
+
## Solution Discovery
The server resolves the solution file in this order:
@@ -214,7 +305,7 @@ WorkspaceManager.cs → MSBuildWorkspace lifecycle, file watching, compilati
WorkspaceInitializer.cs → BackgroundService triggers workspace load on startup
SolutionDiscovery.cs → Auto-detect .sln/.slnx from args or working directory
SymbolResolver.cs → Cross-project symbol resolution with disambiguation
-Tools/ → MCP tool implementations (20 read-only tools)
+Tools/ → MCP tool implementations (22 read-only tools)
Responses/ → Token-optimized JSON response DTOs
```
@@ -268,6 +359,53 @@ dotnet run --project mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj -- -
## Changelog
+### 0.10.0
+
+Correctness pass on symbol resolution and response contracts.
+
+- **Qualified symbol names** — resolution accepted only a bare declared name, so
+ `find_references("OrderService.CreateOrderAsync")` returned an empty result,
+ indistinguishable from a symbol with no references. Type- and namespace-qualified
+ names now resolve, with parameter lists, generic arguments, and metadata arity
+ ignored, and suffix matching anchored on segment boundaries. See [Symbol names](#symbol-names).
+- **Structured errors** — nine tools returned an empty list when a symbol could not be
+ resolved, and `ResolveSymbolAsync` ended in `return symbols[0]`, so a bare name matching
+ several types silently answered about the wrong one with full confidence. Tools now
+ return `SymbolNotFound` / `AmbiguousMatch` (with candidates) / `WrongSymbolKind` /
+ `FileNotFound` / `NoSource`. See [Errors](#errors).
+- **`Truncated` and `Limit`** on every list response, so callers stop inferring
+ truncation from `Count` vs `TotalFound` — a comparison that gets the exactly-at-limit
+ case wrong. See [Result caps](#result-caps).
+- **`IsGenerated`** on navigation results, so an agent does not follow a hit into a
+ `.g.cs` file the next build overwrites. See [Generated code](#generated-code).
+- **`find_dead_code` grades against reflection** — a type resolved via
+ `Type.GetType("...")` has zero references and was reported at high confidence.
+ A name appearing in any string literal now drops to `low` with the reason attached,
+ and `AssemblyScanningDetected` reports whether the solution registers types by scanning
+ at all.
+- **New: `resolve_stack_trace`** — maps an exception onto the solution, undoing compiler
+ rewrites for async state machines, lambdas, and local functions, and pointing at the
+ topmost frame in your own code.
+- **New: `analyze_change_impact`** — blast radius for one symbol in a single call:
+ references by project and file, implementations and overrides that must change with a
+ signature, transitive callers, assembly-boundary exposure, and a rated risk with its
+ rationale.
+- **Reload only on real build-file changes** — csproj comparison was timestamp-only, so a
+ branch switch or formatter rewrite triggered a full MSBuild re-evaluation for identical
+ content. Build files now carry a content hash, with the timestamp kept as the pre-filter.
+ `Directory.Build.props`, `Directory.Packages.props`, `Directory.Build.targets`,
+ `global.json`, and `nuget.config` are tracked too — previously invisible despite
+ governing every project beneath them.
+- **Structural scan moved off the request path** — the recursive walk for newly added
+ source files ran inline, so one arbitrary tool call per cooldown paid for a full
+ directory enumeration. It now runs in the background with the same staleness bound.
+- **Container distribution** — `dotnet publish /t:PublishContainer` produces an image on
+ the SDK base (required by MSBuildLocator), removing the host-SDK and `DOTNET_ROOT`
+ setup. See [As a Container](#as-a-container).
+- **Fixed: compilation cache survived a reload** — a reload mints fresh `ProjectId`s, and
+ two of four `LoadSolutionAsync` call sites did not clear the cache first, leaving
+ orphaned entries pinning whole Roslyn compilations in memory.
+
### 0.9.0
Analysis accuracy pass. On a 34-project, 106K-line codebase, `detect_antipatterns`
diff --git a/mcp/CWM.RoslynNavigator/src/Analyzers/ReflectionIndex.cs b/mcp/CWM.RoslynNavigator/src/Analyzers/ReflectionIndex.cs
new file mode 100644
index 0000000..fd66b7b
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/src/Analyzers/ReflectionIndex.cs
@@ -0,0 +1,104 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace CWM.RoslynNavigator.Analyzers;
+
+///
+/// Solution-wide record of the two ways a symbol can be used without producing a
+/// reference Roslyn can find: named in a string literal, or picked up by an assembly
+/// scan. Both make a zero-reference result meaningless, so dead-code detection consults
+/// this before calling anything unused.
+///
+internal sealed class ReflectionIndex
+{
+ ///
+ /// Method names whose presence anywhere in the solution means types are being
+ /// discovered by scanning rather than by direct reference.
+ ///
+ private static readonly HashSet ScanningMethods = new(StringComparer.Ordinal)
+ {
+ "GetType",
+ "CreateInstance",
+ "GetTypes",
+ "GetExportedTypes",
+ "AddClasses",
+ "FromAssemblyOf",
+ "FromAssemblies",
+ "FromCallingAssembly",
+ "FromExecutingAssembly",
+ "FromApplicationDependencies",
+ "ApplyConfigurationsFromAssembly",
+ "RegisterServicesFromAssembly",
+ "RegisterServicesFromAssemblies",
+ "AddValidatorsFromAssembly",
+ "AddValidatorsFromAssemblyContaining",
+ "AddAutoMapper",
+ "AddMediatR",
+ "Scan"
+ };
+
+ private readonly HashSet _literalNames = new(StringComparer.Ordinal);
+
+ ///
+ /// True when the solution discovers types by scanning. Every zero-reference result is
+ /// softer in that case, so it is reported alongside the findings rather than silently.
+ ///
+ public bool AssemblyScanningDetected { get; private set; }
+
+ ///
+ /// Whether a symbol name appears in a string literal anywhere in the solution.
+ /// Dotted literals contribute their final segment too, so "MyApp.Jobs.CleanupJob"
+ /// also protects a type named CleanupJob.
+ ///
+ public bool IsNamedInStringLiteral(string name) => _literalNames.Contains(name);
+
+ public void Add(SyntaxNode root, CancellationToken ct)
+ {
+ foreach (var node in root.DescendantNodes())
+ {
+ ct.ThrowIfCancellationRequested();
+
+ switch (node)
+ {
+ case LiteralExpressionSyntax literal
+ when literal.IsKind(SyntaxKind.StringLiteralExpression):
+ AddLiteral(literal.Token.ValueText);
+ break;
+
+ // Only the constant parts of an interpolated string are usable as names,
+ // but a prefix like "MyApp.Handlers." still tells us nothing on its own,
+ // so interpolated strings are deliberately not indexed.
+
+ case InvocationExpressionSyntax invocation
+ when IsScanningCall(invocation):
+ AssemblyScanningDetected = true;
+ break;
+ }
+ }
+ }
+
+ private void AddLiteral(string value)
+ {
+ if (value.Length == 0 || value.Length > 512) return;
+
+ _literalNames.Add(value);
+
+ var lastDot = value.LastIndexOf('.');
+ if (lastDot >= 0 && lastDot < value.Length - 1)
+ _literalNames.Add(value[(lastDot + 1)..]);
+ }
+
+ private static bool IsScanningCall(InvocationExpressionSyntax invocation)
+ {
+ var name = invocation.Expression switch
+ {
+ MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText,
+ GenericNameSyntax generic => generic.Identifier.ValueText,
+ IdentifierNameSyntax identifier => identifier.Identifier.ValueText,
+ _ => null
+ };
+
+ return name is not null && ScanningMethods.Contains(name);
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/src/Analyzers/SourceKind.cs b/mcp/CWM.RoslynNavigator/src/Analyzers/SourceKind.cs
index b250f36..a201867 100644
--- a/mcp/CWM.RoslynNavigator/src/Analyzers/SourceKind.cs
+++ b/mcp/CWM.RoslynNavigator/src/Analyzers/SourceKind.cs
@@ -109,6 +109,14 @@ public static SourceKind Classify(
return SourceKind.Production;
}
+ ///
+ /// Whether a tree is source-generator or tooling output. Split out from
+ /// for callers that only need the generated/not decision and
+ /// have no project context — navigation tools flagging results, for instance.
+ ///
+ public static bool IsGenerated(SyntaxTree tree, string relativePath, CancellationToken ct) =>
+ IsGeneratedPath(relativePath) || HasAutoGeneratedHeader(tree, ct);
+
private static bool IsGeneratedPath(string path)
{
if (HasDirectorySegment(path, "obj") || HasDirectorySegment(path, "bin"))
diff --git a/mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj b/mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj
index 94b7cad..be40dd7 100644
--- a/mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj
+++ b/mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj
@@ -15,12 +15,12 @@
CWM.RoslynNavigator
- 0.9.0
+ 0.10.0
Mukesh Murugan
codewithmukesh
CWM.RoslynNavigator
CWM Roslyn Navigator — MCP Server for .NET
- A Model Context Protocol (MCP) server that gives AI coding assistants semantic understanding of .NET codebases via Roslyn. 20 read-only tools for symbol navigation, reference finding, type hierarchy, dependency graphs, anti-pattern detection, dead code analysis, diagnostics, file outlines, symbol source extraction, NuGet package inventory, endpoint maps, and DI registration analysis — all returning token-optimized responses. Part of the dotnet-claude-kit ecosystem.
+ A Model Context Protocol (MCP) server that gives AI coding assistants semantic understanding of .NET codebases via Roslyn. 22 read-only tools for symbol navigation, reference finding, type hierarchy, dependency graphs, anti-pattern detection, dead code analysis, diagnostics, file outlines, symbol source extraction, NuGet package inventory, endpoint maps, DI registration analysis, stack trace resolution, and change impact analysis — all returning token-optimized responses with structured error codes. Part of the dotnet-claude-kit ecosystem.
mcp;roslyn;dotnet;claude;ai;code-analysis;semantic-analysis;codebase-navigation;model-context-protocol;claude-code
MIT
https://github.com/codewithmukesh/dotnet-claude-kit
@@ -28,6 +28,16 @@
git
README.md
+
+ cwm-roslyn-navigator
+ mcr.microsoft.com/dotnet/sdk:10.0
+ /workspace
+
true
true
diff --git a/mcp/CWM.RoslynNavigator/src/GeneratedCodeIndex.cs b/mcp/CWM.RoslynNavigator/src/GeneratedCodeIndex.cs
new file mode 100644
index 0000000..2c38cd1
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/src/GeneratedCodeIndex.cs
@@ -0,0 +1,38 @@
+using CWM.RoslynNavigator.Analyzers;
+using Microsoft.CodeAnalysis;
+
+namespace CWM.RoslynNavigator;
+
+///
+/// Per-call memo for "is this tree generated?". A single navigation result set usually
+/// spans few files but many symbols, so classifying once per tree avoids re-reading
+/// leading trivia for every hit in the same file.
+///
+internal sealed class GeneratedCodeIndex(WorkspaceManager workspace)
+{
+ private readonly Dictionary _cache = [];
+
+ public bool IsGenerated(SyntaxTree? tree, CancellationToken ct)
+ {
+ if (tree is null) return false;
+
+ if (_cache.TryGetValue(tree, out var cached))
+ return cached;
+
+ var result = SourceClassifier.IsGenerated(tree, workspace.ToRelativePath(tree.FilePath), ct);
+ _cache[tree] = result;
+ return result;
+ }
+
+ ///
+ /// Whether a symbol is declared in generated source, or synthesized by the compiler
+ /// with no declaration at all (implicit constructors, record members).
+ ///
+ public bool IsGenerated(ISymbol symbol, CancellationToken ct)
+ {
+ if (symbol.IsImplicitlyDeclared) return true;
+
+ var syntaxRef = symbol.DeclaringSyntaxReferences.FirstOrDefault();
+ return syntaxRef is not null && IsGenerated(syntaxRef.SyntaxTree, ct);
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/src/Responses/Paging.cs b/mcp/CWM.RoslynNavigator/src/Responses/Paging.cs
new file mode 100644
index 0000000..3ac5a67
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/src/Responses/Paging.cs
@@ -0,0 +1,27 @@
+namespace CWM.RoslynNavigator.Responses;
+
+///
+/// Applies a result cap and describes the resulting page. Centralised so every
+/// list-returning tool reports truncation identically — a caller that sees
+/// Truncated=false knows it has the complete set without comparing counts.
+///
+internal static class Paging
+{
+ internal readonly record struct Page(List Items, int Count, int TotalFound, bool Truncated, int Limit);
+
+ public static Page Apply(IReadOnlyList all, int maxResults)
+ {
+ var limit = Math.Max(1, maxResults);
+ var truncated = all.Count > limit;
+ var items = truncated ? [.. all.Take(limit)] : new List(all);
+
+ return new Page(items, items.Count, all.Count, truncated, limit);
+ }
+
+ ///
+ /// Page describing an empty result set — used on the early-return paths where no
+ /// query ran at all.
+ ///
+ public static Page Empty(int maxResults) =>
+ new([], 0, 0, false, Math.Max(1, maxResults));
+}
diff --git a/mcp/CWM.RoslynNavigator/src/Responses/ToolResponses.cs b/mcp/CWM.RoslynNavigator/src/Responses/ToolResponses.cs
index f8c15e0..168ab1f 100644
--- a/mcp/CWM.RoslynNavigator/src/Responses/ToolResponses.cs
+++ b/mcp/CWM.RoslynNavigator/src/Responses/ToolResponses.cs
@@ -2,29 +2,67 @@ namespace CWM.RoslynNavigator.Responses;
// Token-optimized response records for MCP tools.
// All responses use minimal property names and avoid unnecessary nesting.
-// List-returning tools follow a shared truncation contract: Count is the number of
-// entries returned, TotalFound the number that matched — re-query with a higher
-// maxResults when TotalFound exceeds Count.
+//
+// List-returning tools share one paging contract, produced by Responses.Paging:
+// Count entries in this response
+// TotalFound entries that matched in total
+// Truncated true when the cap dropped entries — the single field to branch on
+// Limit the cap that was applied; re-query above it to see the rest
+// Truncated is stated rather than left to a Count/TotalFound comparison so a caller
+// never has to infer whether it is looking at a complete answer.
+//
+// Navigation results carry IsGenerated so a caller can skip source-generator and
+// tooling output rather than trying to edit a file that will be regenerated.
+//
// Note: intentionally many records in one file — these are pure DTOs and a file per
// two-line record would hurt navigability more than it helps.
-internal sealed record SymbolLocation(string Name, string Kind, string File, int Line, string Namespace);
+///
+/// Stable, enumerated failure codes. Callers branch on
+/// rather than parsing prose, and — critically — can tell "this symbol does not exist"
+/// apart from "this symbol exists and has no results".
+///
+internal static class ErrorCodes
+{
+ public const string SymbolNotFound = "SymbolNotFound";
+ public const string AmbiguousMatch = "AmbiguousMatch";
+ public const string WrongSymbolKind = "WrongSymbolKind";
+ public const string FileNotFound = "FileNotFound";
+ public const string NoSource = "NoSource";
+ public const string InvalidArgument = "InvalidArgument";
+}
+
+/// One of several symbols a qualified name could have meant.
+internal sealed record SymbolCandidate(string Qualified, string Kind, string File, int Line);
-internal sealed record SymbolSearchResult(List Symbols, int Count, int TotalFound);
+///
+/// Structured failure. is populated for AmbiguousMatch so the
+/// caller can re-query with a fully-qualified name instead of guessing.
+///
+internal sealed record ErrorResponse(
+ string Error,
+ string Message,
+ List? Candidates = null);
-internal sealed record ReferenceLocation(string File, int Line, string Snippet, string Kind);
+internal sealed record SymbolLocation(string Name, string Kind, string File, int Line, string Namespace, bool IsGenerated);
-internal sealed record ReferencesResult(List References, int Count, int TotalFound);
+internal sealed record SymbolSearchResult(List Symbols, int Count, int TotalFound, bool Truncated, int Limit);
-internal sealed record ImplementationInfo(string Type, string File, int Line);
+internal sealed record ReferenceLocation(string File, int Line, string Snippet, string Kind, bool IsGenerated);
-internal sealed record ImplementationsResult(List Implementations, int Count, int TotalFound);
+internal sealed record ReferencesResult(List References, int Count, int TotalFound, bool Truncated, int Limit);
+
+internal sealed record ImplementationInfo(string Type, string File, int Line, bool IsGenerated);
+
+internal sealed record ImplementationsResult(List Implementations, int Count, int TotalFound, bool Truncated, int Limit);
internal sealed record TypeHierarchyResult(
List BaseTypes,
List Interfaces,
List DerivedTypes,
- int TotalDerived);
+ int TotalDerived,
+ bool Truncated,
+ int Limit);
internal sealed record ProjectInfo(
string Name,
@@ -36,7 +74,7 @@ internal sealed record ProjectGraphResult(string Solution, List Pro
internal sealed record MemberInfo(string Kind, string Signature, string Accessibility);
-internal sealed record PublicApiResult(string Type, List Members, int Count, int TotalFound);
+internal sealed record PublicApiResult(string Type, List Members, int Count, int TotalFound, bool Truncated, int Limit);
internal sealed record DiagnosticInfo(string Id, string Severity, string Message, string File, int Line);
@@ -46,17 +84,19 @@ internal sealed record DiagnosticsResult(
int TotalFound,
int Errors,
int Warnings,
- int Info);
+ int Info,
+ bool Truncated,
+ int Limit);
internal sealed record StatusResponse(string State, string Message);
-internal sealed record CallerInfo(string Method, string ContainingType, string File, int Line);
+internal sealed record CallerInfo(string Method, string ContainingType, string File, int Line, bool IsGenerated);
-internal sealed record CallersResult(List Callers, int Count, int TotalFound);
+internal sealed record CallersResult(List Callers, int Count, int TotalFound, bool Truncated, int Limit);
-internal sealed record OverrideInfo(string Method, string ContainingType, string File, int Line);
+internal sealed record OverrideInfo(string Method, string ContainingType, string File, int Line, bool IsGenerated);
-internal sealed record OverridesResult(List Overrides, int Count, int TotalFound);
+internal sealed record OverridesResult(List Overrides, int Count, int TotalFound, bool Truncated, int Limit);
internal sealed record ParameterDetail(string Name, string Type, string? DefaultValue);
@@ -113,6 +153,8 @@ internal sealed record AntiPatternsResult(
List Violations,
int Count,
int TotalFound,
+ bool Truncated,
+ int Limit,
AntiPatternSummary? Summary = null);
// Dead code detection
@@ -129,7 +171,10 @@ internal sealed record DeadCodeResult(
List Symbols,
int Count,
int TotalFound,
- int ConventionFiltered = 0);
+ bool Truncated,
+ int Limit,
+ int ConventionFiltered = 0,
+ bool AssemblyScanningDetected = false);
// Circular dependency detection
internal sealed record CircularDependencyChain(List Chain, string Level);
@@ -158,6 +203,57 @@ internal sealed record TestCoverageMapResult(
int TestMethodCount = 0,
int TestClassCount = 0);
+// Change impact analysis
+internal sealed record ImpactProject(string Name, int References, bool IsTest);
+
+internal sealed record ImpactFile(string File, int References, bool IsTest);
+
+///
+/// The blast radius of changing one symbol. Answers "what breaks and how far does it
+/// reach" in a single call, so a caller does not have to stitch together find_references,
+/// find_implementations, find_overrides, and the project graph.
+///
+internal sealed record ChangeImpactResult(
+ string Symbol,
+ string Kind,
+ string Accessibility,
+ bool CrossesAssemblyBoundary,
+ int DirectReferences,
+ int AffectedProjects,
+ int AffectedFiles,
+ int TestReferences,
+ int ImplementationsToUpdate,
+ int OverridesToUpdate,
+ List Projects,
+ List Files,
+ List TransitiveCallers,
+ int TransitiveCallerCount,
+ bool Truncated,
+ int Limit,
+ string Risk,
+ string Rationale);
+
+// Stack trace resolution
+internal sealed record StackFrameInfo(
+ int Index,
+ string Method,
+ bool InSolution,
+ string? File,
+ int? Line,
+ int? DeclarationLine,
+ string? Snippet);
+
+internal sealed record StackTraceResult(
+ string? ExceptionType,
+ string? Message,
+ List Frames,
+ int Count,
+ int TotalFound,
+ bool Truncated,
+ int Limit,
+ int SolutionFrames,
+ int? FirstSolutionFrame);
+
// Symbol source
internal sealed record SymbolSourceResult(
string Name,
@@ -184,19 +280,21 @@ internal sealed record FileOutlineResult(
int UsingCount,
List Types,
int Count,
- int TotalFound);
+ int TotalFound,
+ bool Truncated,
+ int Limit);
// NuGet packages
internal sealed record PackageRef(string Id, string? Version);
internal sealed record ProjectPackages(string Name, string TargetFramework, bool Cpm, List Packages);
-internal sealed record NugetPackagesResult(List Projects, int Count, int TotalFound);
+internal sealed record NugetPackagesResult(List Projects, int Count, int TotalFound, bool Truncated, int Limit);
// Endpoint map
internal sealed record EndpointEntry(string Method, string Route, string Auth, string Kind, string File, int Line);
-internal sealed record EndpointMapResult(List Endpoints, int Count, int TotalFound);
+internal sealed record EndpointMapResult(List Endpoints, int Count, int TotalFound, bool Truncated, int Limit);
// DI registrations
internal sealed record DiRegistration(
@@ -217,4 +315,6 @@ internal sealed record DiRegistrationsResult(
List Duplicates,
List CaptiveRisks,
int Count,
- int TotalFound);
+ int TotalFound,
+ bool Truncated,
+ int Limit);
diff --git a/mcp/CWM.RoslynNavigator/src/StackTraceParser.cs b/mcp/CWM.RoslynNavigator/src/StackTraceParser.cs
new file mode 100644
index 0000000..4d97703
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/src/StackTraceParser.cs
@@ -0,0 +1,123 @@
+using System.Text.RegularExpressions;
+
+namespace CWM.RoslynNavigator;
+
+///
+/// Parses .NET exception output into frames that can be resolved against the solution.
+///
+/// The awkward part is that the compiler rewrites async methods, lambdas, and local
+/// functions into generated types whose names do not exist in source. A frame reading
+/// OrderService+<CreateAsync>d__5.MoveNext() has to be mapped back to
+/// OrderService.CreateAsync before any symbol lookup can succeed.
+///
+internal static partial class StackTraceParser
+{
+ /// A single "at ..." line, normalized back to a source-level name.
+ internal sealed record ParsedFrame(string RawMethod, string QualifiedName, string? File, int? Line);
+
+ /// The exception header, when the input includes one.
+ internal sealed record ParsedHeader(string ExceptionType, string Message);
+
+ [GeneratedRegex(@"^\s*at\s+(?.+?)(?:\s+in\s+(?.+?):line\s+(?\d+))?\s*$",
+ RegexOptions.ExplicitCapture)]
+ private static partial Regex FrameLine { get; }
+
+ [GeneratedRegex(@"^\s*(?[\w.`+]*(?:Exception|Error))\s*:\s*(?.+?)\s*$",
+ RegexOptions.ExplicitCapture)]
+ private static partial Regex HeaderLine { get; }
+
+ /// Async state machine: Type+<Method>d__12.MoveNext.
+ [GeneratedRegex(@"^(?.+)\+<(?[^>]+)>d__\d+\.MoveNext$", RegexOptions.ExplicitCapture)]
+ private static partial Regex AsyncStateMachine { get; }
+
+ /// Lambda, optionally hoisted into a display class: Type.<>c.<Method>b__3_0.
+ [GeneratedRegex(@"^(?.+?)(?:\.<>c(?:__DisplayClass[\w]*)?)?\.<(?[^>]+)>b__[\w|]+$",
+ RegexOptions.ExplicitCapture)]
+ private static partial Regex Lambda { get; }
+
+ /// Local function: Type.<Outer>g__Inner|3_0. Resolves to the enclosing method.
+ [GeneratedRegex(@"^(?.+)\.<(?[^>]+)>g__[\w]+\|[\w]+$", RegexOptions.ExplicitCapture)]
+ private static partial Regex LocalFunction { get; }
+
+ /// Generic method arity in a frame: Select[TSource,TResult].
+ [GeneratedRegex(@"\[[^\]]*\]", RegexOptions.ExplicitCapture)]
+ private static partial Regex GenericArity { get; }
+
+ public static ParsedHeader? ParseHeader(string stackTrace)
+ {
+ foreach (var line in EnumerateLines(stackTrace))
+ {
+ // The header precedes the frames; anything after the first "at " is not one.
+ if (FrameLine.IsMatch(line)) return null;
+
+ var match = HeaderLine.Match(line);
+ if (match.Success)
+ return new ParsedHeader(match.Groups["type"].Value, match.Groups["message"].Value);
+ }
+
+ return null;
+ }
+
+ public static List ParseFrames(string stackTrace)
+ {
+ var frames = new List();
+
+ foreach (var line in EnumerateLines(stackTrace))
+ {
+ var match = FrameLine.Match(line);
+ if (!match.Success) continue;
+
+ var rawMethod = match.Groups["method"].Value.Trim();
+ if (rawMethod.Length == 0) continue;
+
+ var file = match.Groups["file"].Success ? match.Groups["file"].Value.Trim() : null;
+ int? lineNumber = match.Groups["line"].Success && int.TryParse(match.Groups["line"].Value, out var n)
+ ? n
+ : null;
+
+ frames.Add(new ParsedFrame(rawMethod, NormalizeMethod(rawMethod), file, lineNumber));
+ }
+
+ return frames;
+ }
+
+ ///
+ /// Reduces a frame's method text to the qualified source name it came from, undoing
+ /// compiler rewrites for async methods, lambdas, and local functions.
+ ///
+ public static string NormalizeMethod(string rawMethod)
+ {
+ var text = rawMethod.Trim();
+
+ // Drop the parameter list — it carries types we cannot match on reliably.
+ var paren = text.IndexOf('(');
+ if (paren >= 0) text = text[..paren];
+
+ text = GenericArity.Replace(text, string.Empty).Trim();
+
+ if (AsyncStateMachine.Match(text) is { Success: true } async)
+ text = $"{async.Groups["owner"].Value}.{async.Groups["name"].Value}";
+ else if (LocalFunction.Match(text) is { Success: true } local)
+ text = $"{local.Groups["owner"].Value}.{local.Groups["name"].Value}";
+ else if (Lambda.Match(text) is { Success: true } lambda)
+ text = $"{lambda.Groups["owner"].Value}.{lambda.Groups["name"].Value}";
+
+ // Nested types use '+' in metadata but '.' in source.
+ text = text.Replace('+', '.');
+
+ // Constructors appear as .ctor/.cctor; the type name is the useful anchor.
+ if (text.EndsWith("..ctor", StringComparison.Ordinal))
+ text = text[..^6];
+ else if (text.EndsWith("..cctor", StringComparison.Ordinal))
+ text = text[..^7];
+
+ return text.Trim('.');
+ }
+
+ private static IEnumerable EnumerateLines(string text)
+ {
+ using var reader = new StringReader(text);
+ while (reader.ReadLine() is { } line)
+ yield return line;
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/src/SymbolResolution.cs b/mcp/CWM.RoslynNavigator/src/SymbolResolution.cs
new file mode 100644
index 0000000..68f0de3
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/src/SymbolResolution.cs
@@ -0,0 +1,35 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using CWM.RoslynNavigator.Responses;
+using Microsoft.CodeAnalysis;
+
+namespace CWM.RoslynNavigator;
+
+///
+/// Outcome of a symbol lookup: either a resolved symbol or a serialized
+/// ready to return from a tool. The
+/// annotations on let callers
+/// use without a null-forgiving operator after the guard clause.
+///
+internal readonly struct SymbolResolution
+{
+ private SymbolResolution(ISymbol? symbol, string? error)
+ {
+ Symbol = symbol;
+ Error = error;
+ }
+
+ public ISymbol? Symbol { get; }
+
+ /// Serialized , or null when resolution succeeded.
+ public string? Error { get; }
+
+ [MemberNotNullWhen(false, nameof(Symbol))]
+ [MemberNotNullWhen(true, nameof(Error))]
+ public bool Failed => Error is not null;
+
+ public static SymbolResolution Success(ISymbol symbol) => new(symbol, null);
+
+ public static SymbolResolution Failure(ErrorResponse error) =>
+ new(null, JsonSerializer.Serialize(error));
+}
diff --git a/mcp/CWM.RoslynNavigator/src/SymbolResolver.cs b/mcp/CWM.RoslynNavigator/src/SymbolResolver.cs
index 75320b3..20db072 100644
--- a/mcp/CWM.RoslynNavigator/src/SymbolResolver.cs
+++ b/mcp/CWM.RoslynNavigator/src/SymbolResolver.cs
@@ -1,16 +1,22 @@
+using System.Text;
+using System.Text.Json;
+using CWM.RoslynNavigator.Responses;
using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.FindSymbols;
namespace CWM.RoslynNavigator;
///
/// Shared utility for resolving symbol names to ISymbol instances across all projects in the solution.
-/// Supports disambiguation by file path and line number.
+/// Accepts bare names ("CreateOrderAsync"), type-qualified names ("OrderService.CreateOrderAsync"),
+/// and fully-qualified names ("SampleApi.OrderService.CreateOrderAsync"), and supports further
+/// disambiguation by file path and line number.
///
internal static class SymbolResolver
{
///
/// Finds all symbols matching the given name across the entire solution.
+ /// The name may be qualified; only the final segment is used for the declaration-table
+ /// lookup, with the leading segments applied as a suffix filter afterwards.
///
public static async Task> FindSymbolsByNameAsync(
WorkspaceManager workspace,
@@ -21,6 +27,12 @@ public static async Task> FindSymbolsByNameAsync(
var solution = workspace.GetSolution();
if (solution is null) return [];
+ var normalized = NormalizeRequestedName(name);
+ if (normalized.Length == 0) return [];
+
+ var simpleName = GetSimpleName(normalized);
+ var isQualified = normalized.Length != simpleName.Length;
+
var results = new List();
foreach (var projectId in solution.ProjectIds)
@@ -28,13 +40,16 @@ public static async Task> FindSymbolsByNameAsync(
var compilation = await workspace.GetCompilationAsync(projectId, ct);
if (compilation is null) continue;
- var symbols = compilation.GetSymbolsWithName(name, SymbolFilter.All, ct);
+ var symbols = compilation.GetSymbolsWithName(simpleName, SymbolFilter.All, ct);
foreach (var symbol in symbols)
{
if (kindFilter is not null && !MatchesKind(symbol, kindFilter))
continue;
+ if (isQualified && !MatchesQualifiedName(symbol, normalized))
+ continue;
+
results.Add(symbol);
}
}
@@ -42,8 +57,100 @@ public static async Task> FindSymbolsByNameAsync(
return results.Distinct(SymbolEqualityComparer.Default).ToList();
}
+ ///
+ /// Strips a parameter list, generic arguments, and metadata arity markers so that
+ /// "IRepository<Order>.GetByIdAsync(Guid, CancellationToken)" reduces to
+ /// "IRepository.GetByIdAsync". Segment separators are preserved.
+ ///
+ public static string NormalizeRequestedName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return string.Empty;
+
+ var span = name.AsSpan().Trim();
+
+ // A parameter list carries no information the declaration table can use.
+ var paren = span.IndexOf('(');
+ if (paren >= 0) span = span[..paren].TrimEnd();
+
+ var builder = new StringBuilder(span.Length);
+ var genericDepth = 0;
+
+ for (var i = 0; i < span.Length; i++)
+ {
+ var ch = span[i];
+
+ switch (ch)
+ {
+ case '<':
+ genericDepth++;
+ continue;
+ case '>' when genericDepth > 0:
+ genericDepth--;
+ continue;
+ }
+
+ if (genericDepth > 0) continue;
+
+ // Metadata arity: "IRepository`1" -> "IRepository"
+ if (ch == '`')
+ {
+ while (i + 1 < span.Length && char.IsAsciiDigit(span[i + 1])) i++;
+ continue;
+ }
+
+ if (char.IsWhiteSpace(ch)) continue;
+
+ builder.Append(ch);
+ }
+
+ return builder.ToString().Trim('.');
+ }
+
+ ///
+ /// Returns the final segment of a (possibly qualified) name — the symbol's own declared name.
+ ///
+ public static string GetSimpleName(string normalizedName)
+ {
+ var lastDot = normalizedName.LastIndexOf('.');
+ return lastDot < 0 ? normalizedName : normalizedName[(lastDot + 1)..];
+ }
+
+ ///
+ /// True when the requested qualified name matches the symbol exactly or is a
+ /// segment-aligned suffix of it. "OrderService.GetOrderAsync" matches
+ /// SampleApi.OrderService.GetOrderAsync but not SampleApi.ProductService.GetOrderAsync.
+ ///
+ public static bool MatchesQualifiedName(ISymbol symbol, string normalizedRequest)
+ {
+ var qualified = BuildQualifiedName(symbol);
+
+ return qualified.Equals(normalizedRequest, StringComparison.Ordinal)
+ || qualified.EndsWith($".{normalizedRequest}", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Builds the dotted namespace/containing-type/name chain for a symbol, using declared
+ /// names so generic arity and type arguments never appear (Roslyn's ContainingNamespace
+ /// already skips past containing types, so nested types resolve correctly).
+ ///
+ public static string BuildQualifiedName(ISymbol symbol)
+ {
+ var parts = new Stack();
+ parts.Push(symbol.Name);
+
+ for (var type = symbol.ContainingType; type is not null; type = type.ContainingType)
+ parts.Push(type.Name);
+
+ for (var ns = symbol.ContainingNamespace; ns is { IsGlobalNamespace: false }; ns = ns.ContainingNamespace)
+ parts.Push(ns.Name);
+
+ return string.Join('.', parts);
+ }
+
///
/// Finds a single symbol by name, optionally disambiguated by file and line.
+ /// Picks the first candidate when the name remains ambiguous — prefer
+ /// in tools, which reports the ambiguity instead.
///
public static async Task ResolveSymbolAsync(
WorkspaceManager workspace,
@@ -53,37 +160,90 @@ public static async Task> FindSymbolsByNameAsync(
CancellationToken ct = default)
{
var symbols = await FindSymbolsByNameAsync(workspace, name, ct: ct);
+ if (symbols.Count == 0) return null;
- if (symbols.Count == 0)
- return null;
+ return NarrowByLocation(symbols, file, line)[0];
+ }
- if (symbols.Count == 1)
- return symbols[0];
+ ///
+ /// Resolves a symbol, producing a serialized instead of null
+ /// when the name matches nothing or matches several distinct symbols.
+ ///
+ public static async Task ResolveOrErrorAsync(
+ WorkspaceManager workspace,
+ string name,
+ string? file = null,
+ int? line = null,
+ string? kindFilter = null,
+ CancellationToken ct = default)
+ {
+ var symbols = await FindSymbolsByNameAsync(workspace, name, kindFilter, ct);
- // Disambiguate by file
- if (file is not null)
+ if (symbols.Count == 0)
{
- var byFile = symbols.Where(s =>
- s.DeclaringSyntaxReferences.Any(r =>
- r.SyntaxTree.FilePath.EndsWith(file, StringComparison.OrdinalIgnoreCase)))
- .ToList();
+ return SymbolResolution.Failure(new ErrorResponse(
+ ErrorCodes.SymbolNotFound,
+ $"No symbol named '{name}' exists in the solution. Check the spelling, or search with find_symbol."));
+ }
- if (byFile.Count == 1)
- return byFile[0];
+ var narrowed = NarrowByLocation(symbols, file, line);
- if (byFile.Count > 1 && line.HasValue)
+ // Overloads, partial declarations, and per-target-framework duplicates all share a
+ // qualified name and kind — they are one logical symbol, not an ambiguity.
+ var distinct = narrowed
+ .GroupBy(s => (BuildQualifiedName(s), GetKindString(s)))
+ .ToList();
+
+ if (distinct.Count == 1)
+ return SymbolResolution.Success(narrowed[0]);
+
+ var candidates = distinct
+ .Take(MaxAmbiguityCandidates)
+ .Select(group =>
{
- // Further disambiguate by line
- return byFile.FirstOrDefault(s =>
- s.DeclaringSyntaxReferences.Any(r =>
- r.SyntaxTree.GetLineSpan(r.Span).StartLinePosition.Line + 1 == line.Value));
- }
+ var location = GetLocation(group.First());
+ return new SymbolCandidate(
+ Qualified: group.Key.Item1,
+ Kind: group.Key.Item2,
+ File: location is { } loc ? workspace.ToRelativePath(loc.File) : "unknown",
+ Line: location?.Line ?? 0);
+ })
+ .ToList();
- if (byFile.Count > 0)
- return byFile[0];
- }
+ return SymbolResolution.Failure(new ErrorResponse(
+ ErrorCodes.AmbiguousMatch,
+ $"'{name}' matches {distinct.Count} distinct symbols. Re-query with a fully-qualified name, or pass file/line.",
+ candidates));
+ }
+
+ private const int MaxAmbiguityCandidates = 10;
+
+ ///
+ /// Narrows candidates using an optional file path then line number. Falls back to the
+ /// wider set whenever a hint eliminates everything, so a stale hint never turns a
+ /// resolvable name into a miss.
+ ///
+ private static IReadOnlyList NarrowByLocation(
+ IReadOnlyList symbols,
+ string? file,
+ int? line)
+ {
+ if (symbols.Count <= 1 || file is null) return symbols;
+
+ var byFile = symbols
+ .Where(s => s.DeclaringSyntaxReferences.Any(r =>
+ r.SyntaxTree.FilePath.EndsWith(file, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+
+ if (byFile.Count == 0) return symbols;
+ if (byFile.Count == 1 || !line.HasValue) return byFile;
+
+ var byLine = byFile
+ .Where(s => s.DeclaringSyntaxReferences.Any(r =>
+ r.SyntaxTree.GetLineSpan(r.Span).StartLinePosition.Line + 1 == line.Value))
+ .ToList();
- return symbols[0];
+ return byLine.Count > 0 ? byLine : byFile;
}
///
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/AnalyzeChangeImpactTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/AnalyzeChangeImpactTool.cs
new file mode 100644
index 0000000..341a94e
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/src/Tools/AnalyzeChangeImpactTool.cs
@@ -0,0 +1,274 @@
+using System.ComponentModel;
+using System.Text.Json;
+using CWM.RoslynNavigator.Analyzers;
+using CWM.RoslynNavigator.Responses;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.FindSymbols;
+using ModelContextProtocol.Server;
+
+namespace CWM.RoslynNavigator.Tools;
+
+[McpServerToolType]
+public static class AnalyzeChangeImpactTool
+{
+ private const int MaxDepth = 5;
+
+ [McpServerTool(Name = "analyze_change_impact"), Description("Answer 'what breaks if I change this?' for one symbol. Reports direct references grouped by project and file, how many are tests, implementations and overrides that must change alongside a signature, transitive callers up to a depth, whether the change crosses an assembly boundary, and an overall risk rating with its rationale. Use before renaming, changing a signature, or deleting — it replaces stitching together find_references, find_implementations, find_overrides, and the project graph.")]
+ public static async Task ExecuteAsync(
+ WorkspaceManager workspace,
+ [Description("Symbol to analyze. Bare, type-qualified ('OrderService.CreateAsync'), or fully qualified.")] string symbolName,
+ [Description("Optional: file path to disambiguate")] string? file = null,
+ [Description("Optional: line number to disambiguate")] int? line = null,
+ [Description("How many call levels to walk for transitive callers (1-5)")] int depth = 2,
+ [Description("Maximum entries per list in the response")] int maxResults = 50,
+ CancellationToken ct = default)
+ {
+ var notReady = await workspace.EnsureReadyOrStatusAsync(ct);
+ if (notReady is not null) return notReady;
+
+ var solution = workspace.GetSolution();
+ if (solution is null)
+ return JsonSerializer.Serialize(new ErrorResponse(
+ ErrorCodes.InvalidArgument, "No solution is loaded."));
+
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, symbolName, file, line, ct: ct);
+ if (resolved.Failed) return resolved.Error;
+
+ var symbol = resolved.Symbol;
+ depth = Math.Clamp(depth, 1, MaxDepth);
+
+ var testProjects = await BuildTestProjectSetAsync(workspace, solution, ct);
+
+ // Direct references, grouped two ways: by project (how far the change reaches) and
+ // by file (where the edits land).
+ var perProject = new Dictionary(StringComparer.Ordinal);
+ var perFile = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var directReferences = 0;
+ var testReferences = 0;
+
+ var references = await SymbolFinder.FindReferencesAsync(symbol, solution, ct);
+
+ foreach (var reference in references)
+ {
+ foreach (var location in reference.Locations)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var document = location.Document;
+ var project = document.Project;
+ var isTest = testProjects.Contains(project.Id);
+
+ directReferences++;
+ if (isTest) testReferences++;
+
+ var projectEntry = perProject.GetValueOrDefault(project.Name);
+ perProject[project.Name] = (projectEntry.Count + 1, isTest);
+
+ var path = document.FilePath is { } p ? workspace.ToRelativePath(p) : "unknown";
+ var fileEntry = perFile.GetValueOrDefault(path);
+ perFile[path] = (fileEntry.Count + 1, isTest);
+ }
+ }
+
+ var (implementations, overrides) = await CountDependentDeclarationsAsync(symbol, solution, ct);
+
+ var transitive = symbol is IMethodSymbol method
+ ? await CollectTransitiveCallersAsync(method, solution, depth, ct)
+ : [];
+
+ var projectPage = Paging.Apply(
+ perProject
+ .Select(kvp => new ImpactProject(kvp.Key, kvp.Value.Count, kvp.Value.IsTest))
+ .OrderByDescending(p => p.References)
+ .ThenBy(p => p.Name, StringComparer.Ordinal)
+ .ToList(),
+ maxResults);
+
+ var filePage = Paging.Apply(
+ perFile
+ .Select(kvp => new ImpactFile(kvp.Key, kvp.Value.Count, kvp.Value.IsTest))
+ .OrderByDescending(f => f.References)
+ .ThenBy(f => f.File, StringComparer.OrdinalIgnoreCase)
+ .ToList(),
+ maxResults);
+
+ var callerPage = Paging.Apply(transitive, maxResults);
+
+ // A change to a symbol visible outside its own assembly can break consumers this
+ // solution cannot see, which no reference count will reveal.
+ var crossesAssembly = perProject.Count > 1
+ || symbol.DeclaredAccessibility is Accessibility.Public or Accessibility.Protected
+ or Accessibility.ProtectedOrInternal;
+
+ var (risk, rationale) = RateRisk(
+ symbol, directReferences, perProject.Count, implementations, overrides, crossesAssembly);
+
+ return JsonSerializer.Serialize(new ChangeImpactResult(
+ Symbol: SymbolResolver.BuildQualifiedName(symbol),
+ Kind: SymbolResolver.GetKindString(symbol),
+ Accessibility: symbol.DeclaredAccessibility.ToString().ToLowerInvariant(),
+ CrossesAssemblyBoundary: crossesAssembly,
+ DirectReferences: directReferences,
+ AffectedProjects: perProject.Count,
+ AffectedFiles: perFile.Count,
+ TestReferences: testReferences,
+ ImplementationsToUpdate: implementations,
+ OverridesToUpdate: overrides,
+ Projects: projectPage.Items,
+ Files: filePage.Items,
+ TransitiveCallers: callerPage.Items,
+ TransitiveCallerCount: transitive.Count,
+ Truncated: projectPage.Truncated || filePage.Truncated || callerPage.Truncated,
+ Limit: Math.Max(1, maxResults),
+ Risk: risk,
+ Rationale: rationale));
+ }
+
+ ///
+ /// Declarations that a signature change forces to change with it: interface
+ /// implementations and virtual-member overrides. These are not references, so a
+ /// reference count alone understates the work.
+ ///
+ private static async Task<(int Implementations, int Overrides)> CountDependentDeclarationsAsync(
+ ISymbol symbol,
+ Solution solution,
+ CancellationToken ct)
+ {
+ var implementations = 0;
+ var overrides = 0;
+
+ switch (symbol)
+ {
+ case INamedTypeSymbol { TypeKind: TypeKind.Interface } iface:
+ implementations = (await SymbolFinder.FindImplementationsAsync(
+ iface, solution, cancellationToken: ct)).Count();
+ break;
+
+ case INamedTypeSymbol type:
+ overrides = (await SymbolFinder.FindDerivedClassesAsync(
+ type, solution, cancellationToken: ct)).Count();
+ break;
+
+ case IMethodSymbol or IPropertySymbol or IEventSymbol:
+ if (symbol.ContainingType is { TypeKind: TypeKind.Interface })
+ {
+ implementations = (await SymbolFinder.FindImplementationsAsync(
+ symbol, solution, cancellationToken: ct)).Count();
+ }
+
+ if (symbol.IsVirtual || symbol.IsAbstract || symbol.IsOverride)
+ {
+ overrides = (await SymbolFinder.FindOverridesAsync(
+ symbol, solution, cancellationToken: ct)).Count();
+ }
+ break;
+ }
+
+ return (implementations, overrides);
+ }
+
+ ///
+ /// Breadth-first walk up the call graph. Visited symbols are tracked across levels so
+ /// a recursive or mutually-recursive call chain terminates.
+ ///
+ private static async Task> CollectTransitiveCallersAsync(
+ IMethodSymbol root,
+ Solution solution,
+ int depth,
+ CancellationToken ct)
+ {
+ var visited = new HashSet(SymbolEqualityComparer.Default) { root };
+ var ordered = new List();
+ var frontier = new List { root };
+
+ for (var level = 0; level < depth && frontier.Count > 0; level++)
+ {
+ var next = new List();
+
+ foreach (var current in frontier)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var callers = await SymbolFinder.FindCallersAsync(current, solution, ct);
+
+ foreach (var caller in callers)
+ {
+ if (!caller.IsDirect) continue;
+ if (!visited.Add(caller.CallingSymbol)) continue;
+
+ ordered.Add(SymbolResolver.BuildQualifiedName(caller.CallingSymbol));
+ next.Add(caller.CallingSymbol);
+ }
+ }
+
+ frontier = next;
+ }
+
+ return ordered;
+ }
+
+ private static async Task> BuildTestProjectSetAsync(
+ WorkspaceManager workspace,
+ Solution solution,
+ CancellationToken ct)
+ {
+ var testProjects = new HashSet();
+
+ foreach (var project in solution.Projects)
+ {
+ var compilation = await workspace.GetCompilationAsync(project.Id, ct);
+ if (compilation is null) continue;
+
+ if (SourceClassifier.IsTestProject(project, compilation))
+ testProjects.Add(project.Id);
+ }
+
+ return testProjects;
+ }
+
+ ///
+ /// Rates the change and says why. The rationale matters more than the label — a caller
+ /// acting on "high" needs to know whether that came from reach, from implementations
+ /// that must move in lockstep, or from visibility outside the assembly.
+ ///
+ private static (string Risk, string Rationale) RateRisk(
+ ISymbol symbol,
+ int directReferences,
+ int affectedProjects,
+ int implementations,
+ int overrides,
+ bool crossesAssembly)
+ {
+ var reasons = new List();
+
+ if (implementations > 0)
+ reasons.Add($"{implementations} implementation(s) must change with the signature");
+
+ if (overrides > 0)
+ reasons.Add($"{overrides} override(s)/derived type(s) must change with the signature");
+
+ if (affectedProjects > 1)
+ reasons.Add($"referenced from {affectedProjects} projects");
+
+ if (crossesAssembly && symbol.DeclaredAccessibility == Accessibility.Public)
+ reasons.Add("public surface — consumers outside this solution may break");
+
+ if (directReferences > 20)
+ reasons.Add($"{directReferences} call sites");
+
+ var risk = (implementations + overrides) > 0 || affectedProjects > 2 || directReferences > 20
+ ? "high"
+ : affectedProjects > 1 || directReferences > 5 || crossesAssembly
+ ? "medium"
+ : "low";
+
+ if (reasons.Count == 0)
+ {
+ reasons.Add(directReferences == 0
+ ? "no references found — the change is contained, but confirm it is not reached by reflection"
+ : $"{directReferences} call site(s) in a single project");
+ }
+
+ return (risk, string.Join("; ", reasons));
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/DetectAntiPatternsTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/DetectAntiPatternsTool.cs
index 8bb7912..3c98bc8 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/DetectAntiPatternsTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/DetectAntiPatternsTool.cs
@@ -41,7 +41,8 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new AntiPatternsResult([], 0, 0));
+ return JsonSerializer.Serialize(new AntiPatternsResult(
+ [], 0, 0, false, Math.Max(1, maxResults)));
var minSeverity = severity.Equals("error", StringComparison.OrdinalIgnoreCase)
? AntiPatternSeverity.Error
@@ -187,12 +188,14 @@ public AntiPatternsResult Build(
.Where(entry => entry.Violation.Confidence >= minConfidence)
.ToList();
+ var limit = Math.Max(1, maxResults);
+
var violations = eligible
.OrderByDescending(entry => entry.Violation.Confidence)
.ThenByDescending(entry => entry.Violation.Severity)
.ThenBy(entry => entry.RelativePath, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Violation.Line)
- .Take(maxResults)
+ .Take(limit)
.Select(entry => new AntiPatternInfo(
entry.Violation.Id,
entry.Violation.Severity.ToString().ToLowerInvariant(),
@@ -229,7 +232,9 @@ public AntiPatternsResult Build(
MigrationFiles: _migration,
SuppressionConfig: suppressions.ConfigPath);
- return new AntiPatternsResult(violations, violations.Count, eligible.Count, summary);
+ return new AntiPatternsResult(
+ violations, violations.Count, eligible.Count,
+ eligible.Count > violations.Count, limit, summary);
}
}
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/FindCallersTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/FindCallersTool.cs
index e6f6212..b3e0273 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/FindCallersTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/FindCallersTool.cs
@@ -10,10 +10,10 @@ namespace CWM.RoslynNavigator.Tools;
[McpServerToolType]
public static class FindCallersTool
{
- [McpServerTool(Name = "find_callers"), Description("Find all methods that call a specific method. Useful for impact analysis and understanding dependencies.")]
+ [McpServerTool(Name = "find_callers"), Description("Find all methods that call a specific method. Useful for impact analysis and understanding dependencies. Each caller carries IsGenerated so generated call sites can be skipped.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
- [Description("The method name to find callers for")] string methodName,
+ [Description("Method to find callers for. Bare, type-qualified ('OrderService.CreateAsync'), or fully qualified. Qualify when the name is reused across types.")] string methodName,
[Description("Optional: containing class name to disambiguate")] string? className = null,
[Description("Maximum results to return. TotalFound in the response reports the full count; re-query with a higher value if it exceeds Count.")] int maxResults = 50,
CancellationToken ct = default)
@@ -23,23 +23,23 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new CallersResult([], 0, 0));
+ return Serialize(Paging.Empty(maxResults));
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, methodName, ct: ct);
+ // A className hint is just a qualifier — fold it in rather than resolving twice.
+ var lookupName = className is not null && !methodName.Contains('.')
+ ? $"{className}.{methodName}"
+ : methodName;
- // If className is provided and the resolved symbol isn't in that type, try to find a better match
- if (symbol is not null && className is not null && symbol.ContainingType?.Name != className)
- {
- var allSymbols = await SymbolResolver.FindSymbolsByNameAsync(workspace, methodName, ct: ct);
- symbol = allSymbols.FirstOrDefault(s => s.ContainingType?.Name == className) ?? symbol;
- }
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, lookupName, ct: ct);
+ if (resolved.Failed) return resolved.Error;
- if (symbol is null)
- return JsonSerializer.Serialize(new CallersResult([], 0, 0));
+ var symbol = resolved.Symbol;
var callers = await SymbolFinder.FindCallersAsync(symbol, solution, ct);
+ var generated = new GeneratedCodeIndex(workspace);
var all = new List();
+
foreach (var caller in callers)
{
if (!caller.IsDirect) continue;
@@ -51,12 +51,15 @@ public static async Task ExecuteAsync(
Method: caller.CallingSymbol.Name,
ContainingType: caller.CallingSymbol.ContainingType?.Name ?? "unknown",
File: workspace.ToRelativePath(location.Value.File),
- Line: location.Value.Line));
+ Line: location.Value.Line,
+ IsGenerated: generated.IsGenerated(caller.CallingSymbol, ct)));
}
}
- var results = all.Take(Math.Max(1, maxResults)).ToList();
-
- return JsonSerializer.Serialize(new CallersResult(results, results.Count, all.Count));
+ return Serialize(Paging.Apply(all, maxResults));
}
+
+ private static string Serialize(Paging.Page page) =>
+ JsonSerializer.Serialize(new CallersResult(
+ page.Items, page.Count, page.TotalFound, page.Truncated, page.Limit));
}
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/FindDeadCodeTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/FindDeadCodeTool.cs
index 656e563..0d9e36f 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/FindDeadCodeTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/FindDeadCodeTool.cs
@@ -62,7 +62,7 @@ public static class FindDeadCodeTool
"Module", "Installer", "Registrar", "Factory", "Converter"
];
- [McpServerTool(Name = "find_dead_code"), Description("Find unused types, methods, and properties across the solution. Identifies symbols with zero references that are not public API entry points, interface implementations, or overrides. Symbols discovered by convention (EF entity configurations, hosted services, migrations, extension-method hosts) are filtered out because a reference search cannot see assembly scanning or DI registration; the count removed is reported as conventionFiltered. Remaining hits whose names match a convention suffix are returned at medium confidence with a note. Uses a fast identifier-match pre-filter: symbols whose exact name appears as an identifier token in another file are assumed referenced without a full reference search, so occasional false negatives are possible for heavily-reused names.")]
+ [McpServerTool(Name = "find_dead_code"), Description("Find unused types, methods, and properties across the solution. Identifies symbols with zero references that are not public API entry points, interface implementations, or overrides. Symbols discovered by convention (EF entity configurations, hosted services, migrations, extension-method hosts) are filtered out because a reference search cannot see assembly scanning or DI registration; the count removed is reported as conventionFiltered. Findings are graded: 'low' when the name appears in a string literal (likely reflection-bound), 'medium' when it matches a convention suffix, 'high' otherwise — delete only high-confidence hits without checking. AssemblyScanningDetected reports whether the solution registers types by scanning at all, which softens every result. Uses a fast identifier-match pre-filter: symbols whose exact name appears as an identifier token in another file are assumed referenced without a full reference search, so occasional false negatives are possible for heavily-reused names.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
[Description("Scope: 'file', 'project', or 'solution'")] string scope = "solution",
@@ -76,7 +76,7 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new DeadCodeResult([], 0, 0));
+ return JsonSerializer.Serialize(new DeadCodeResult([], 0, 0, false, Math.Max(1, maxResults)));
var candidates = new List<(ISymbol Symbol, string File, int Line)>();
var conventionFiltered = 0;
@@ -135,14 +135,25 @@ public static async Task ExecuteAsync(
.DistinctBy(c => c.Symbol.ToDisplayString())
.ToList();
- // Pre-collect all source texts for fast name-based pre-filter
+ // Pre-collect all source texts for fast name-based pre-filter, and index the two
+ // reflection paths a reference search is blind to. Both walks cover the whole
+ // solution, not just the requested scope: a type scoped out of the scan can still
+ // be the thing that names this one.
var sourceTexts = new Dictionary();
- foreach (var proj in projects)
+ var reflection = new ReflectionIndex();
+
+ foreach (var proj in solution.Projects)
{
var comp = await workspace.GetCompilationAsync(proj.Id, ct);
if (comp is null) continue;
+
foreach (var tree in comp.SyntaxTrees)
- sourceTexts.TryAdd(tree, (await tree.GetTextAsync(ct)).ToString());
+ {
+ if (!sourceTexts.TryAdd(tree, (await tree.GetTextAsync(ct)).ToString()))
+ continue;
+
+ reflection.Add(await tree.GetRootAsync(ct), ct);
+ }
}
// Check references for each candidate
@@ -172,7 +183,7 @@ public static async Task ExecuteAsync(
totalFound++;
if (deadCode.Count < maxResults)
{
- var conventionSuffix = MatchingConventionSuffix(symbol.Name);
+ var (confidence, note) = Grade(symbol, reflection);
deadCode.Add(new DeadCodeInfo(
Name: symbol.Name,
@@ -180,16 +191,16 @@ public static async Task ExecuteAsync(
File: workspace.ToRelativePath(symbolFile),
Line: symbolLine,
ContainingType: symbol.ContainingType?.Name,
- Confidence: conventionSuffix is null ? "high" : "medium",
- Note: conventionSuffix is null
- ? null
- : $"Name ends in '{conventionSuffix}' — verify it is not bound by reflection, DI scanning, or a source generator before removing"));
+ Confidence: confidence,
+ Note: note));
}
}
}
- return JsonSerializer.Serialize(
- new DeadCodeResult(deadCode, deadCode.Count, totalFound, conventionFiltered));
+ return JsonSerializer.Serialize(new DeadCodeResult(
+ deadCode, deadCode.Count, totalFound,
+ totalFound > deadCode.Count, Math.Max(1, maxResults), conventionFiltered,
+ reflection.AssemblyScanningDetected));
}
///
@@ -223,6 +234,31 @@ private static bool IsConventionDiscovered(ISymbol symbol)
return false;
}
+ ///
+ /// Rates how safe a zero-reference symbol is to delete. A name that appears in a string
+ /// literal is the strongest counter-signal available — that is exactly how reflection
+ /// binds a type it never references — so it outranks the weaker suffix heuristic.
+ ///
+ private static (string Confidence, string? Note) Grade(ISymbol symbol, ReflectionIndex reflection)
+ {
+ if (reflection.IsNamedInStringLiteral(symbol.Name))
+ {
+ return ("low",
+ $"'{symbol.Name}' appears in a string literal — likely resolved by reflection " +
+ "(Type.GetType, Activator.CreateInstance, or configuration). Confirm before removing.");
+ }
+
+ var conventionSuffix = MatchingConventionSuffix(symbol.Name);
+ if (conventionSuffix is not null)
+ {
+ return ("medium",
+ $"Name ends in '{conventionSuffix}' — verify it is not bound by reflection, " +
+ "DI scanning, or a source generator before removing");
+ }
+
+ return ("high", null);
+ }
+
private static string? MatchingConventionSuffix(string name)
{
foreach (var suffix in ConventionSuffixes)
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/FindImplementationsTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/FindImplementationsTool.cs
index 23722d7..9a283d1 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/FindImplementationsTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/FindImplementationsTool.cs
@@ -10,7 +10,7 @@ namespace CWM.RoslynNavigator.Tools;
[McpServerToolType]
public static class FindImplementationsTool
{
- [McpServerTool(Name = "find_implementations"), Description("Find all types that implement an interface or derive from a base class.")]
+ [McpServerTool(Name = "find_implementations"), Description("Find all types that implement an interface or derive from a base class. Each result carries IsGenerated so generated implementations can be skipped.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
[Description("The interface or base class name to find implementations for")] string interfaceName,
@@ -22,17 +22,25 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new ImplementationsResult([], 0, 0));
+ return Serialize(Paging.Empty(maxResults));
+
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, interfaceName, ct: ct);
+ if (resolved.Failed) return resolved.Error;
+
+ var symbol = resolved.Symbol;
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, interfaceName, ct: ct);
if (symbol is not INamedTypeSymbol typeSymbol)
- return JsonSerializer.Serialize(new ImplementationsResult([], 0, 0));
+ return JsonSerializer.Serialize(new ErrorResponse(
+ ErrorCodes.WrongSymbolKind,
+ $"'{interfaceName}' resolved to a {SymbolResolver.GetKindString(symbol)}, not an interface or class."));
var implementations = typeSymbol.TypeKind == TypeKind.Interface
? await SymbolFinder.FindImplementationsAsync(typeSymbol, solution, cancellationToken: ct)
: await SymbolFinder.FindDerivedClassesAsync(typeSymbol, solution, cancellationToken: ct);
+ var generated = new GeneratedCodeIndex(workspace);
var all = new List();
+
foreach (var impl in implementations)
{
var location = SymbolResolver.GetLocation(impl);
@@ -41,12 +49,15 @@ public static async Task ExecuteAsync(
all.Add(new ImplementationInfo(
impl.Name,
workspace.ToRelativePath(location.Value.File),
- location.Value.Line));
+ location.Value.Line,
+ generated.IsGenerated(impl, ct)));
}
}
- var results = all.Take(Math.Max(1, maxResults)).ToList();
-
- return JsonSerializer.Serialize(new ImplementationsResult(results, results.Count, all.Count));
+ return Serialize(Paging.Apply(all, maxResults));
}
+
+ private static string Serialize(Paging.Page page) =>
+ JsonSerializer.Serialize(new ImplementationsResult(
+ page.Items, page.Count, page.TotalFound, page.Truncated, page.Limit));
}
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/FindOverridesTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/FindOverridesTool.cs
index d629ef6..3cd7b6c 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/FindOverridesTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/FindOverridesTool.cs
@@ -10,7 +10,7 @@ namespace CWM.RoslynNavigator.Tools;
[McpServerToolType]
public static class FindOverridesTool
{
- [McpServerTool(Name = "find_overrides"), Description("Find all overrides of a virtual or abstract method across the solution. Useful for understanding polymorphic behavior.")]
+ [McpServerTool(Name = "find_overrides"), Description("Find all overrides of a virtual or abstract method across the solution. Useful for understanding polymorphic behavior. Each result carries IsGenerated so generated overrides can be skipped.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
[Description("The virtual or abstract method name to find overrides for")] string methodName,
@@ -23,22 +23,22 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new OverridesResult([], 0, 0));
+ return Serialize(Paging.Empty(maxResults));
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, methodName, ct: ct);
+ var lookupName = className is not null && !methodName.Contains('.')
+ ? $"{className}.{methodName}"
+ : methodName;
- if (symbol is not null && className is not null && symbol.ContainingType?.Name != className)
- {
- var allSymbols = await SymbolResolver.FindSymbolsByNameAsync(workspace, methodName, ct: ct);
- symbol = allSymbols.FirstOrDefault(s => s.ContainingType?.Name == className) ?? symbol;
- }
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, lookupName, ct: ct);
+ if (resolved.Failed) return resolved.Error;
- if (symbol is null)
- return JsonSerializer.Serialize(new OverridesResult([], 0, 0));
+ var symbol = resolved.Symbol;
var overrides = await SymbolFinder.FindOverridesAsync(symbol, solution, cancellationToken: ct);
+ var generated = new GeneratedCodeIndex(workspace);
var all = new List();
+
foreach (var overrideSymbol in overrides)
{
var location = SymbolResolver.GetLocation(overrideSymbol);
@@ -48,12 +48,15 @@ public static async Task ExecuteAsync(
Method: overrideSymbol.Name,
ContainingType: overrideSymbol.ContainingType?.Name ?? "unknown",
File: workspace.ToRelativePath(location.Value.File),
- Line: location.Value.Line));
+ Line: location.Value.Line,
+ IsGenerated: generated.IsGenerated(overrideSymbol, ct)));
}
}
- var results = all.Take(Math.Max(1, maxResults)).ToList();
-
- return JsonSerializer.Serialize(new OverridesResult(results, results.Count, all.Count));
+ return Serialize(Paging.Apply(all, maxResults));
}
+
+ private static string Serialize(Paging.Page page) =>
+ JsonSerializer.Serialize(new OverridesResult(
+ page.Items, page.Count, page.TotalFound, page.Truncated, page.Limit));
}
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/FindReferencesTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/FindReferencesTool.cs
index c13ba8c..b13b008 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/FindReferencesTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/FindReferencesTool.cs
@@ -13,10 +13,10 @@ namespace CWM.RoslynNavigator.Tools;
[McpServerToolType]
public static class FindReferencesTool
{
- [McpServerTool(Name = "find_references"), Description("Find all usages of a symbol across the solution. Returns file, line, snippet, and reference kind (usage, inheritance, invocation, instantiation, parameter, type-argument, assignment, implementation).")]
+ [McpServerTool(Name = "find_references"), Description("Find all usages of a symbol across the solution. Returns file, line, snippet, and reference kind (usage, inheritance, invocation, instantiation, parameter, type-argument, assignment, implementation). Each result carries IsGenerated so generated call sites can be skipped.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
- [Description("The symbol name to find references for")] string symbolName,
+ [Description("Symbol to find references for. Bare, type-qualified ('OrderService.CreateAsync'), or fully qualified. Qualify when the name is reused across types.")] string symbolName,
[Description("Optional: file path to disambiguate (e.g., 'IOrderRepository.cs')")] string? file = null,
[Description("Optional: line number to disambiguate")] int? line = null,
[Description("Maximum results to return. TotalFound in the response reports the full count; re-query with a higher value if it exceeds Count.")] int maxResults = 50,
@@ -25,17 +25,21 @@ public static async Task ExecuteAsync(
var notReady = await workspace.EnsureReadyOrStatusAsync(ct);
if (notReady is not null) return notReady;
+ var limit = Math.Max(1, maxResults);
+
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new ReferencesResult([], 0, 0));
+ return JsonSerializer.Serialize(new ReferencesResult([], 0, 0, false, limit));
+
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, symbolName, file, line, ct: ct);
+ if (resolved.Failed) return resolved.Error;
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, symbolName, file, line, ct);
- if (symbol is null)
- return JsonSerializer.Serialize(new ReferencesResult([], 0, 0));
+ var symbol = resolved.Symbol;
var references = await SymbolFinder.FindReferencesAsync(symbol, solution, ct);
var totalFound = references.Sum(r => r.Locations.Count());
+ var generated = new GeneratedCodeIndex(workspace);
var textCache = new Dictionary();
var results = new List();
var capped = false;
@@ -44,7 +48,7 @@ public static async Task ExecuteAsync(
{
foreach (var location in reference.Locations)
{
- if (results.Count >= maxResults) { capped = true; break; }
+ if (results.Count >= limit) { capped = true; break; }
var lineSpan = location.Location.GetLineSpan();
var document = solution.GetDocument(location.Document.Id);
@@ -64,12 +68,16 @@ public static async Task ExecuteAsync(
File: workspace.ToRelativePath(lineSpan.Path),
Line: lineSpan.StartLinePosition.Line + 1,
Snippet: snippet,
- Kind: ClassifyReferenceKind(location, ct)));
+ Kind: ClassifyReferenceKind(location, ct),
+ IsGenerated: generated.IsGenerated(location.Location.SourceTree, ct)));
}
if (capped) break;
}
- return JsonSerializer.Serialize(new ReferencesResult(results, results.Count, totalFound));
+ // Snippets are built lazily as locations stream in, so this tool caps in the loop
+ // rather than via Paging — the contract it reports is identical.
+ return JsonSerializer.Serialize(new ReferencesResult(
+ results, results.Count, totalFound, totalFound > results.Count, limit));
}
private static string ClassifyReferenceKind(
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/FindSymbolTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/FindSymbolTool.cs
index f5a29c5..7ea25e9 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/FindSymbolTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/FindSymbolTool.cs
@@ -8,10 +8,10 @@ namespace CWM.RoslynNavigator.Tools;
[McpServerToolType]
public static class FindSymbolTool
{
- [McpServerTool(Name = "find_symbol"), Description("Find where a type, method, or property is defined in the solution. Returns file path, line number, and namespace.")]
+ [McpServerTool(Name = "find_symbol"), Description("Find where a type, method, or property is defined in the solution. Returns file path, line number, namespace, and an IsGenerated flag marking source-generator or tooling output.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
- [Description("The symbol name to search for (e.g., 'OrderRepository', 'CreateAsync')")] string name,
+ [Description("Symbol name. Bare ('CreateAsync'), type-qualified ('OrderService.CreateAsync'), or fully qualified ('MyApp.Orders.OrderService.CreateAsync'). Qualifying narrows results when a name is reused across types.")] string name,
[Description("Filter by kind: 'type', 'class', 'interface', 'struct', 'enum', 'record', 'method', 'property', 'field', or 'any'")] string kind = "any",
[Description("Maximum results to return. TotalFound in the response reports the full count; re-query with a higher value if it exceeds Count.")] int maxResults = 50,
CancellationToken ct = default)
@@ -21,8 +21,9 @@ public static async Task ExecuteAsync(
var symbols = await SymbolResolver.FindSymbolsByNameAsync(workspace, name, kind, ct);
- var results = symbols
- .Take(Math.Max(1, maxResults))
+ var generated = new GeneratedCodeIndex(workspace);
+
+ var all = symbols
.Select(s =>
{
var location = SymbolResolver.GetLocation(s);
@@ -31,9 +32,13 @@ public static async Task ExecuteAsync(
Kind: SymbolResolver.GetKindString(s),
File: location is { } loc ? workspace.ToRelativePath(loc.File) : "unknown",
Line: location?.Line ?? 0,
- Namespace: s.ContainingNamespace?.ToDisplayString() ?? "global");
+ Namespace: s.ContainingNamespace?.ToDisplayString() ?? "global",
+ IsGenerated: generated.IsGenerated(s, ct));
}).ToList();
- return JsonSerializer.Serialize(new SymbolSearchResult(results, results.Count, symbols.Count));
+ var page = Paging.Apply(all, maxResults);
+
+ return JsonSerializer.Serialize(new SymbolSearchResult(
+ page.Items, page.Count, page.TotalFound, page.Truncated, page.Limit));
}
}
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetDependencyGraphTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetDependencyGraphTool.cs
index dceb134..4927c5c 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetDependencyGraphTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetDependencyGraphTool.cs
@@ -14,7 +14,7 @@ public static class GetDependencyGraphTool
[McpServerTool(Name = "get_dependency_graph"), Description("Get the call dependency graph for a method, showing all methods it calls recursively up to a specified depth. Useful for impact analysis and understanding code flow.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
- [Description("The method name to analyze")] string symbolName,
+ [Description("Method to analyze. Bare, type-qualified ('OrderService.CreateAsync'), or fully qualified.")] string symbolName,
[Description("Optional: file path to disambiguate")] string? file = null,
[Description("Optional: line number to disambiguate")] int? line = null,
[Description("Maximum recursion depth (1-5)")] int depth = 3,
@@ -28,9 +28,15 @@ public static async Task ExecuteAsync(
if (solution is null)
return JsonSerializer.Serialize(new DependencyGraphResult("unknown", [], 0, false));
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, symbolName, file, line, ct);
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, symbolName, file, line, ct: ct);
+ if (resolved.Failed) return resolved.Error;
+
+ var symbol = resolved.Symbol;
+
if (symbol is not IMethodSymbol rootMethod)
- return JsonSerializer.Serialize(new DependencyGraphResult(symbolName, [], 0, false));
+ return JsonSerializer.Serialize(new ErrorResponse(
+ ErrorCodes.WrongSymbolKind,
+ $"'{symbolName}' resolved to a {SymbolResolver.GetKindString(symbol)}, not a method."));
depth = Math.Clamp(depth, 1, 5);
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetDiRegistrationsTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetDiRegistrationsTool.cs
index b18fd63..e142f63 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetDiRegistrationsTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetDiRegistrationsTool.cs
@@ -30,7 +30,8 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new DiRegistrationsResult([], [], [], 0, 0));
+ return JsonSerializer.Serialize(new DiRegistrationsResult(
+ [], [], [], 0, 0, false, Math.Max(1, maxResults)));
var registrations = new List();
var typeDeclarations = new Dictionary(StringComparer.Ordinal);
@@ -62,10 +63,10 @@ public static async Task ExecuteAsync(
var duplicates = FindDuplicates(registrations);
var captiveRisks = FindCaptiveRisks(registrations, typeDeclarations);
- var results = registrations.Take(Math.Max(1, maxResults)).ToList();
+ var page = Paging.Apply(registrations, maxResults);
return JsonSerializer.Serialize(new DiRegistrationsResult(
- results, duplicates, captiveRisks, results.Count, registrations.Count));
+ page.Items, duplicates, captiveRisks, page.Count, page.TotalFound, page.Truncated, page.Limit));
}
///
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetDiagnosticsTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetDiagnosticsTool.cs
index 1fa3ad9..c9b203e 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetDiagnosticsTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetDiagnosticsTool.cs
@@ -23,7 +23,8 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new DiagnosticsResult([], 0, 0, 0, 0, 0));
+ return JsonSerializer.Serialize(new DiagnosticsResult(
+ [], 0, 0, 0, 0, 0, false, Math.Max(1, maxResults)));
var matched = new List<(Diagnostic Diagnostic, DiagnosticInfo Info)>();
@@ -65,16 +66,17 @@ public static async Task ExecuteAsync(
var info = matched.Count(m => m.Diagnostic.Severity == DiagnosticSeverity.Info);
// Errors first so truncation never hides the most important diagnostics
- var diagnostics = matched
+ var ordered = matched
.OrderByDescending(m => m.Diagnostic.Severity)
.ThenBy(m => m.Info.File, StringComparer.OrdinalIgnoreCase)
.ThenBy(m => m.Info.Line)
- .Take(Math.Max(1, maxResults))
.Select(m => m.Info)
.ToList();
+ var page = Paging.Apply(ordered, maxResults);
+
return JsonSerializer.Serialize(new DiagnosticsResult(
- diagnostics, diagnostics.Count, matched.Count, errors, warnings, info));
+ page.Items, page.Count, page.TotalFound, errors, warnings, info, page.Truncated, page.Limit));
}
private static async Task> GetCompilationsForFile(
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetEndpointMapTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetEndpointMapTool.cs
index 66425ae..0cfda82 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetEndpointMapTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetEndpointMapTool.cs
@@ -36,7 +36,7 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new EndpointMapResult([], 0, 0));
+ return Serialize(Paging.Empty(maxResults));
var all = new List();
var seenFiles = new HashSet(StringComparer.OrdinalIgnoreCase);
@@ -60,11 +60,13 @@ public static async Task ExecuteAsync(
}
}
- var results = all.Take(Math.Max(1, maxResults)).ToList();
-
- return JsonSerializer.Serialize(new EndpointMapResult(results, results.Count, all.Count));
+ return Serialize(Paging.Apply(all, maxResults));
}
+ private static string Serialize(Paging.Page page) =>
+ JsonSerializer.Serialize(new EndpointMapResult(
+ page.Items, page.Count, page.TotalFound, page.Truncated, page.Limit));
+
///
/// Syntax-only endpoint extraction for one file. Internal for direct unit testing.
///
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetFileOutlineTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetFileOutlineTool.cs
index f1f0093..dcfd6f8 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetFileOutlineTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetFileOutlineTool.cs
@@ -24,18 +24,21 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new FileOutlineResult("unknown", null, 0, [], 0, 0));
+ return JsonSerializer.Serialize(new FileOutlineResult(
+ "unknown", null, 0, [], 0, 0, false, Math.Max(1, maxResults)));
var document = solution.Projects
.SelectMany(p => p.Documents)
.FirstOrDefault(d => d.FilePath?.EndsWith(filePath, StringComparison.OrdinalIgnoreCase) == true);
if (document?.FilePath is null)
- return JsonSerializer.Serialize(new StatusResponse("NotFound", $"File '{filePath}' not found in the solution."));
+ return JsonSerializer.Serialize(new ErrorResponse(ErrorCodes.FileNotFound,
+ $"File '{filePath}' is not part of any project in the solution."));
var root = await document.GetSyntaxRootAsync(ct);
if (root is null)
- return JsonSerializer.Serialize(new StatusResponse("NotFound", $"File '{filePath}' has no syntax tree."));
+ return JsonSerializer.Serialize(new ErrorResponse(ErrorCodes.NoSource,
+ $"File '{filePath}' has no syntax tree."));
var usingCount = root.DescendantNodes(n => n is CompilationUnitSyntax or BaseNamespaceDeclarationSyntax)
.OfType()
@@ -60,7 +63,9 @@ public static async Task ExecuteAsync(
UsingCount: usingCount,
Types: types,
Count: budget.Returned,
- TotalFound: budget.Total));
+ TotalFound: budget.Total,
+ Truncated: budget.Total > budget.Returned,
+ Limit: Math.Max(1, maxResults)));
}
private sealed class OutlineBudget(int maxMembers)
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetNugetPackagesTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetNugetPackagesTool.cs
index aac687d..f8d817a 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetNugetPackagesTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetNugetPackagesTool.cs
@@ -23,7 +23,7 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new NugetPackagesResult([], 0, 0));
+ return JsonSerializer.Serialize(new NugetPackagesResult([], 0, 0, false, Math.Max(1, maxResults)));
maxResults = Math.Max(1, maxResults);
@@ -61,7 +61,10 @@ public static async Task ExecuteAsync(
Packages: kept));
}
- return JsonSerializer.Serialize(new NugetPackagesResult(results, returned, total));
+ // The cap applies to packages across projects, so truncation is measured on the
+ // package count rather than the project list this tool returns.
+ return JsonSerializer.Serialize(new NugetPackagesResult(
+ results, returned, total, total > returned, maxResults));
}
///
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetPublicApiTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetPublicApiTool.cs
index 5fc7c8d..02f37a1 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetPublicApiTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetPublicApiTool.cs
@@ -12,16 +12,22 @@ public static class GetPublicApiTool
[McpServerTool(Name = "get_public_api"), Description("Get the public members of a type without reading the full source file. Returns method signatures, properties, events.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
- [Description("The type name to get the public API for")] string typeName,
+ [Description("Type name. Bare ('OrderService') or namespace-qualified ('MyApp.Orders.OrderService').")] string typeName,
[Description("Maximum members to return. TotalFound in the response reports the full count; re-query with a higher value if it exceeds Count.")] int maxResults = 50,
CancellationToken ct = default)
{
var notReady = await workspace.EnsureReadyOrStatusAsync(ct);
if (notReady is not null) return notReady;
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, typeName, ct: ct);
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, typeName, ct: ct);
+ if (resolved.Failed) return resolved.Error;
+
+ var symbol = resolved.Symbol;
+
if (symbol is not INamedTypeSymbol typeSymbol)
- return JsonSerializer.Serialize(new PublicApiResult("not found", [], 0, 0));
+ return JsonSerializer.Serialize(new ErrorResponse(
+ ErrorCodes.WrongSymbolKind,
+ $"'{typeName}' resolved to a {SymbolResolver.GetKindString(symbol)}, not a type."));
var allMembers = typeSymbol.GetMembers()
.Where(m => m.DeclaredAccessibility == Accessibility.Public)
@@ -33,10 +39,11 @@ public static async Task ExecuteAsync(
Accessibility: m.DeclaredAccessibility.ToString().ToLowerInvariant()))
.ToList();
- var members = allMembers.Take(Math.Max(1, maxResults)).ToList();
+ var page = Paging.Apply(allMembers, maxResults);
var typeKind = SymbolResolver.GetKindString(typeSymbol);
- return JsonSerializer.Serialize(new PublicApiResult(typeKind, members, members.Count, allMembers.Count));
+ return JsonSerializer.Serialize(new PublicApiResult(
+ typeKind, page.Items, page.Count, page.TotalFound, page.Truncated, page.Limit));
}
private static string GetMemberKind(ISymbol symbol) => symbol switch
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetSymbolDetailTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetSymbolDetailTool.cs
index 0f92f7f..3faabe7 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetSymbolDetailTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetSymbolDetailTool.cs
@@ -12,23 +12,21 @@ public static class GetSymbolDetailTool
[McpServerTool(Name = "get_symbol_detail"), Description("Get detailed information about a symbol including full signature, parameters, return type, modifiers, and XML documentation. Perfect for understanding APIs without reading the whole file.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
- [Description("The symbol name (type, method, property)")] string symbolName,
+ [Description("Symbol name (type, method, property). Bare, type-qualified ('OrderService.CreateAsync'), or fully qualified.")] string symbolName,
[Description("Optional: containing type name for methods/properties")] string? containingType = null,
CancellationToken ct = default)
{
var notReady = await workspace.EnsureReadyOrStatusAsync(ct);
if (notReady is not null) return notReady;
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, symbolName, ct: ct);
+ var lookupName = containingType is not null && !symbolName.Contains('.')
+ ? $"{containingType}.{symbolName}"
+ : symbolName;
- if (symbol is not null && containingType is not null && symbol.ContainingType?.Name != containingType)
- {
- var allSymbols = await SymbolResolver.FindSymbolsByNameAsync(workspace, symbolName, ct: ct);
- symbol = allSymbols.FirstOrDefault(s => s.ContainingType?.Name == containingType) ?? symbol;
- }
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, lookupName, ct: ct);
+ if (resolved.Failed) return resolved.Error;
- if (symbol is null)
- return JsonSerializer.Serialize(new StatusResponse("NotFound", $"Symbol '{symbolName}' not found."));
+ var symbol = resolved.Symbol;
var location = SymbolResolver.GetLocation(symbol);
var summary = ExtractXmlSummary(symbol.GetDocumentationCommentXml());
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetSymbolSourceTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetSymbolSourceTool.cs
index dd17b65..67e3287 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetSymbolSourceTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetSymbolSourceTool.cs
@@ -28,37 +28,27 @@ public static async Task ExecuteAsync(
var notReady = await workspace.EnsureReadyOrStatusAsync(ct);
if (notReady is not null) return notReady;
- // Dotted name: last segment is the member, the one before it the containing type
- var name = symbolName;
- var dotIndex = symbolName.LastIndexOf('.');
- if (dotIndex > 0 && dotIndex < symbolName.Length - 1)
- {
- name = symbolName[(dotIndex + 1)..];
- containingType ??= symbolName[..dotIndex].Split('.')[^1];
- }
-
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, name, file, line, ct);
+ // Qualified names resolve natively in SymbolResolver. An explicit containingType
+ // argument is folded into the same qualified form, and only falls back to the bare
+ // name when that yields nothing (e.g. symbolName is itself the type).
+ var lookupName = containingType is not null && !symbolName.Contains('.')
+ ? $"{containingType}.{symbolName}"
+ : symbolName;
- if (symbol is not null && containingType is not null && symbol.ContainingType?.Name != containingType)
- {
- var allSymbols = await SymbolResolver.FindSymbolsByNameAsync(workspace, name, ct: ct);
- symbol = allSymbols.FirstOrDefault(s =>
- s.ContainingType?.Name == containingType || s.Name == containingType)
- ?? symbol;
- }
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, lookupName, file, line, ct: ct);
+ if (resolved.Failed) return resolved.Error;
- if (symbol is null)
- return JsonSerializer.Serialize(new StatusResponse("NotFound", $"Symbol '{symbolName}' not found."));
+ var symbol = resolved.Symbol;
var syntaxRef = symbol.DeclaringSyntaxReferences.FirstOrDefault();
if (syntaxRef is null)
- return JsonSerializer.Serialize(new StatusResponse("NotFound",
- $"Symbol '{symbolName}' has no source in this solution (metadata symbol)."));
+ return JsonSerializer.Serialize(new ErrorResponse(ErrorCodes.NoSource,
+ $"'{symbolName}' has no source in this solution (metadata symbol)."));
var node = (await syntaxRef.GetSyntaxAsync(ct)).FirstAncestorOrSelf();
if (node is null)
- return JsonSerializer.Serialize(new StatusResponse("NotFound",
- $"Symbol '{symbolName}' does not map to a member declaration."));
+ return JsonSerializer.Serialize(new ErrorResponse(ErrorCodes.NoSource,
+ $"'{symbolName}' does not map to a member declaration."));
maxChars = Math.Clamp(maxChars, MinChars, MaxChars);
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/GetTypeHierarchyTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/GetTypeHierarchyTool.cs
index 06a527f..e2b7745 100644
--- a/mcp/CWM.RoslynNavigator/src/Tools/GetTypeHierarchyTool.cs
+++ b/mcp/CWM.RoslynNavigator/src/Tools/GetTypeHierarchyTool.cs
@@ -13,7 +13,7 @@ public static class GetTypeHierarchyTool
[McpServerTool(Name = "get_type_hierarchy"), Description("Get the full inheritance chain, interfaces, and derived types for a type. For interfaces, derived types include both derived interfaces and implementing types.")]
public static async Task ExecuteAsync(
WorkspaceManager workspace,
- [Description("The type name to get the hierarchy for")] string typeName,
+ [Description("Type name. Bare ('OrderService') or namespace-qualified ('MyApp.Orders.OrderService').")] string typeName,
[Description("Maximum derived types to return. TotalDerived in the response reports the full count; re-query with a higher value if it exceeds the list length.")] int maxResults = 50,
CancellationToken ct = default)
{
@@ -22,11 +22,17 @@ public static async Task ExecuteAsync(
var solution = workspace.GetSolution();
if (solution is null)
- return JsonSerializer.Serialize(new TypeHierarchyResult([], [], [], 0));
+ return JsonSerializer.Serialize(new TypeHierarchyResult([], [], [], 0, false, Math.Max(1, maxResults)));
+
+ var resolved = await SymbolResolver.ResolveOrErrorAsync(workspace, typeName, ct: ct);
+ if (resolved.Failed) return resolved.Error;
+
+ var symbol = resolved.Symbol;
- var symbol = await SymbolResolver.ResolveSymbolAsync(workspace, typeName, ct: ct);
if (symbol is not INamedTypeSymbol typeSymbol)
- return JsonSerializer.Serialize(new TypeHierarchyResult([], [], [], 0));
+ return JsonSerializer.Serialize(new ErrorResponse(
+ ErrorCodes.WrongSymbolKind,
+ $"'{typeName}' resolved to a {SymbolResolver.GetKindString(symbol)}, not a type."));
// Get base types chain
var baseTypes = new List();
@@ -59,8 +65,9 @@ public static async Task ExecuteAsync(
allDerived.AddRange(derived.Select(d => d.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
}
- var derivedTypes = allDerived.Take(Math.Max(1, maxResults)).ToList();
+ var page = Paging.Apply(allDerived, maxResults);
- return JsonSerializer.Serialize(new TypeHierarchyResult(baseTypes, interfaces, derivedTypes, allDerived.Count));
+ return JsonSerializer.Serialize(new TypeHierarchyResult(
+ baseTypes, interfaces, page.Items, page.TotalFound, page.Truncated, page.Limit));
}
}
diff --git a/mcp/CWM.RoslynNavigator/src/Tools/ResolveStackTraceTool.cs b/mcp/CWM.RoslynNavigator/src/Tools/ResolveStackTraceTool.cs
new file mode 100644
index 0000000..8942f0a
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/src/Tools/ResolveStackTraceTool.cs
@@ -0,0 +1,126 @@
+using System.ComponentModel;
+using System.Text.Json;
+using CWM.RoslynNavigator.Responses;
+using Microsoft.CodeAnalysis;
+using ModelContextProtocol.Server;
+
+namespace CWM.RoslynNavigator.Tools;
+
+[McpServerToolType]
+public static class ResolveStackTraceTool
+{
+ [McpServerTool(Name = "resolve_stack_trace"), Description("Map a .NET exception stack trace onto the solution. Undoes compiler rewrites for async methods, lambdas, and local functions, then resolves each frame to a file and line. Frames are marked InSolution so framework noise can be skipped, and FirstSolutionFrame points at the topmost frame in your own code — usually where the investigation starts. Paste the trace verbatim; the exception header is parsed too.")]
+ public static async Task ExecuteAsync(
+ WorkspaceManager workspace,
+ [Description("The raw stack trace, including the exception header line if available")] string stackTrace,
+ [Description("Only return frames that resolve to solution source, dropping framework frames")] bool solutionOnly = false,
+ [Description("Maximum frames to return. TotalFound reports the full count.")] int maxResults = 50,
+ CancellationToken ct = default)
+ {
+ var notReady = await workspace.EnsureReadyOrStatusAsync(ct);
+ if (notReady is not null) return notReady;
+
+ if (string.IsNullOrWhiteSpace(stackTrace))
+ return JsonSerializer.Serialize(new ErrorResponse(
+ ErrorCodes.InvalidArgument, "stackTrace is empty."));
+
+ var header = StackTraceParser.ParseHeader(stackTrace);
+ var parsed = StackTraceParser.ParseFrames(stackTrace);
+
+ if (parsed.Count == 0)
+ return JsonSerializer.Serialize(new ErrorResponse(
+ ErrorCodes.InvalidArgument,
+ "No stack frames found. Expected lines of the form 'at Namespace.Type.Method(...)'."));
+
+ var resolved = new List();
+
+ for (var i = 0; i < parsed.Count; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var frame = await ResolveFrameAsync(workspace, parsed[i], i, ct);
+
+ if (solutionOnly && !frame.InSolution) continue;
+
+ resolved.Add(frame);
+ }
+
+ var page = Paging.Apply(resolved, maxResults);
+ var solutionFrames = resolved.Count(f => f.InSolution);
+ var firstSolutionFrame = resolved.FirstOrDefault(f => f.InSolution)?.Index;
+
+ return JsonSerializer.Serialize(new StackTraceResult(
+ ExceptionType: header?.ExceptionType,
+ Message: header?.Message,
+ Frames: page.Items,
+ Count: page.Count,
+ TotalFound: page.TotalFound,
+ Truncated: page.Truncated,
+ Limit: page.Limit,
+ SolutionFrames: solutionFrames,
+ FirstSolutionFrame: firstSolutionFrame));
+ }
+
+ private static async Task ResolveFrameAsync(
+ WorkspaceManager workspace,
+ StackTraceParser.ParsedFrame frame,
+ int index,
+ CancellationToken ct)
+ {
+ var symbols = await SymbolResolver.FindSymbolsByNameAsync(
+ workspace, frame.QualifiedName, ct: ct);
+
+ // Only source declarations are indexed, so anything that resolves is the user's
+ // own code; framework frames fall through with InSolution false.
+ var symbol = symbols.FirstOrDefault();
+ if (symbol is null)
+ {
+ return new StackFrameInfo(
+ Index: index,
+ Method: frame.QualifiedName,
+ InSolution: false,
+ File: frame.File,
+ Line: frame.Line,
+ DeclarationLine: null,
+ Snippet: null);
+ }
+
+ var location = SymbolResolver.GetLocation(symbol);
+ var file = location is { } loc ? workspace.ToRelativePath(loc.File) : frame.File;
+
+ // Prefer the line the trace reported — that is where it actually threw — and fall
+ // back to the declaration when the trace carried no PDB line info.
+ var line = frame.Line ?? location?.Line;
+ var snippet = await ReadSnippetAsync(workspace, symbol, line, ct);
+
+ return new StackFrameInfo(
+ Index: index,
+ Method: SymbolResolver.BuildQualifiedName(symbol),
+ InSolution: true,
+ File: file,
+ Line: line,
+ DeclarationLine: location?.Line,
+ Snippet: snippet);
+ }
+
+ private static async Task ReadSnippetAsync(
+ WorkspaceManager workspace,
+ ISymbol symbol,
+ int? line,
+ CancellationToken ct)
+ {
+ if (line is not > 0) return null;
+
+ var tree = symbol.DeclaringSyntaxReferences.FirstOrDefault()?.SyntaxTree;
+ if (tree is null) return null;
+
+ var document = workspace.GetSolution()?.GetDocument(tree);
+ if (document is null) return null;
+
+ var text = await document.GetTextAsync(ct);
+ var zeroBased = line.Value - 1;
+ if (zeroBased >= text.Lines.Count) return null;
+
+ return text.Lines[zeroBased].ToString().Trim();
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/src/WorkspaceManager.cs b/mcp/CWM.RoslynNavigator/src/WorkspaceManager.cs
index 2bb7e48..015453c 100644
--- a/mcp/CWM.RoslynNavigator/src/WorkspaceManager.cs
+++ b/mcp/CWM.RoslynNavigator/src/WorkspaceManager.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.Security.Cryptography;
using System.Text.Json;
using CWM.RoslynNavigator.Responses;
using Microsoft.CodeAnalysis;
@@ -24,9 +25,14 @@ public sealed class WorkspaceManager(ILogger logger, TimeProvi
private readonly ConcurrentDictionary _compilationCache = new();
private readonly ConcurrentDictionary _cacheAccessOrder = new();
private readonly ConcurrentDictionary _knownDocuments = new();
- private readonly ConcurrentDictionary _projectFileTimestamps = new();
+ private readonly ConcurrentDictionary _buildFiles = new();
private readonly ConcurrentDictionary _knownDocumentPaths = new(StringComparer.OrdinalIgnoreCase);
private long _accessCounter;
+ private int _loadCount;
+ private readonly CancellationTokenSource _backgroundCts = new();
+ private int _structuralScanRunning;
+ private int _newSourceFilesPending;
+ private Task _structuralScan = Task.CompletedTask;
private int _rootsAttempted; // 0 = not tried, 1 = tried
private long _lastRefreshTicks;
private long _lastStructuralScanTicks;
@@ -50,6 +56,17 @@ public sealed class WorkspaceManager(ILogger logger, TimeProvi
///
internal IServiceProvider? Services { get; set; }
+ ///
+ /// Number of compilations currently cached. Test seam for verifying reload eviction.
+ ///
+ internal int CachedCompilationCount => _compilationCache.Count;
+
+ ///
+ /// How many times the solution has been loaded. Test seam for verifying that a
+ /// no-op build-file rewrite does not trigger a reload.
+ ///
+ internal int LoadCount => _loadCount;
+
///
/// Loads the solution at the specified path. Call this once on startup.
///
@@ -62,6 +79,12 @@ public async Task LoadSolutionAsync(string solutionPath, CancellationToken ct =
_solutionPath = solutionPath;
_errorMessage = null;
+ // A reload mints fresh ProjectIds, so every cached Compilation is keyed on a
+ // dead id. Clearing here (rather than at each call site) keeps the roots-discovery
+ // and error-retry paths from resurrecting stale entries.
+ _compilationCache.Clear();
+ _cacheAccessOrder.Clear();
+
logger.LogInformation("Loading solution: {SolutionPath}", solutionPath);
// Dispose previous workspace to avoid leaking Roslyn Solution snapshots
@@ -77,6 +100,7 @@ public async Task LoadSolutionAsync(string solutionPath, CancellationToken ct =
});
_solution = await _workspace.OpenSolutionAsync(solutionPath, cancellationToken: ct);
+ Interlocked.Increment(ref _loadCount);
logger.LogInformation("Solution loaded: {ProjectCount} projects", _solution.ProjectIds.Count);
@@ -334,17 +358,22 @@ await Parallel.ForEachAsync(
}
///
- /// Records the last-write time of every document and project file in the solution.
+ /// Records the last-write time of every document and build file in the solution.
/// Called once after solution load to establish a baseline for staleness detection.
/// Stores file paths and project IDs alongside timestamps to avoid Roslyn lookups
/// during the per-call refresh hot path.
+ ///
+ /// Build files (.csproj plus the Directory.* files that feed into them) additionally
+ /// get a content hash. A reload is the most expensive thing this server does, and a
+ /// timestamp alone cannot tell a real edit from a rewrite with identical content —
+ /// which is what a branch switch or a formatter produces across many files at once.
///
private void SnapshotFileTimestamps()
{
if (_solution is null) return;
_knownDocuments.Clear();
- _projectFileTimestamps.Clear();
+ _buildFiles.Clear();
_knownDocumentPaths.Clear();
foreach (var projectId in _solution.ProjectIds)
@@ -353,9 +382,7 @@ private void SnapshotFileTimestamps()
if (project is null) continue;
if (project.FilePath is not null)
- {
- _projectFileTimestamps[project.FilePath] = File.GetLastWriteTimeUtc(project.FilePath);
- }
+ TrackBuildFile(project.FilePath);
foreach (var document in project.Documents)
{
@@ -370,12 +397,74 @@ private void SnapshotFileTimestamps()
}
}
- logger.LogInformation("Captured timestamps for {Count} documents across {ProjectCount} projects",
- _knownDocuments.Count, _projectFileTimestamps.Count);
+ foreach (var sharedFile in EnumerateSharedBuildFiles())
+ TrackBuildFile(sharedFile);
+
+ logger.LogInformation("Captured timestamps for {Count} documents across {BuildFileCount} build files",
+ _knownDocuments.Count, _buildFiles.Count);
+ }
+
+ ///
+ /// Directory-scoped MSBuild files change the compilation of every project beneath
+ /// them — a bumped version in Directory.Packages.props is as significant as a csproj
+ /// edit — but they are not documents, so nothing else in the workspace notices them.
+ ///
+ private IEnumerable EnumerateSharedBuildFiles()
+ {
+ var root = SolutionDirectory;
+ if (root is null || !Directory.Exists(root)) yield break;
+
+ string[] names =
+ [
+ "Directory.Build.props",
+ "Directory.Build.targets",
+ "Directory.Packages.props",
+ "global.json",
+ "nuget.config"
+ ];
+
+ foreach (var name in names)
+ {
+ var path = Path.Combine(root, name);
+ if (File.Exists(path)) yield return path;
+ }
+ }
+
+ private void TrackBuildFile(string path)
+ {
+ var writeTime = File.GetLastWriteTimeUtc(path);
+ if (writeTime.Year < 1900) return;
+
+ _buildFiles[path] = new BuildFileInfo(writeTime, ComputeHash(path));
+ }
+
+ ///
+ /// Content hash of a build file, or null when it cannot be read. A null hash compares
+ /// unequal to every other value, so an unreadable file is treated as changed rather
+ /// than silently assumed stable.
+ ///
+ private static string? ComputeHash(string path)
+ {
+ try
+ {
+ using var stream = File.OpenRead(path);
+ return Convert.ToHexString(SHA256.HashData(stream));
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
}
private readonly record struct DocumentInfo(string FilePath, ProjectId ProjectId, DateTime LastWriteUtc);
+ /// A csproj or Directory.* file, tracked by both timestamp and content.
+ private readonly record struct BuildFileInfo(DateTime LastWriteUtc, string? Hash);
+
///
/// Refreshes the workspace to reflect on-disk changes. Uses tiered cooldowns to
/// keep per-call overhead low: .csproj + document timestamps every 5s, full
@@ -391,29 +480,35 @@ public async Task RefreshChangedDocumentsAsync(CancellationToken ct = default)
if (now - lastRefresh < RefreshCooldownTicks) return;
Interlocked.Exchange(ref _lastRefreshTicks, now);
- // Phase 1: check .csproj timestamps (one stat per project)
- if (HasProjectFileChanged())
+ // Phase 1: check build files (one stat each, hashed only when the stat moved)
+ var changedBuildFiles = GetChangedBuildFiles();
+ if (changedBuildFiles.Count > 0)
{
- logger.LogInformation("Project file changed. Full reload needed.");
- _compilationCache.Clear();
- _cacheAccessOrder.Clear();
+ logger.LogInformation(
+ "Build files changed ({Files}). Reloading solution.",
+ string.Join(", ", changedBuildFiles.Select(Path.GetFileName)));
+
+ await LoadSolutionAsync(_solutionPath, ct);
+ return;
+ }
+
+ // Phase 2: act on whatever the last background scan found. The scan itself walks
+ // every project directory, so it never runs in-band — a tool call pays an
+ // interlocked read here, and the reload only once the scan has already found
+ // something. The finding is at most one cooldown stale, which is the same
+ // staleness the in-band version had.
+ if (Interlocked.Exchange(ref _newSourceFilesPending, 0) == 1)
+ {
+ logger.LogInformation("New source files detected. Reloading solution.");
await LoadSolutionAsync(_solutionPath, ct);
return;
}
- // Phase 2: scan for new source files (expensive directory walk, longer cooldown)
var lastStructural = Interlocked.Read(ref _lastStructuralScanTicks);
if (now - lastStructural >= StructuralScanCooldownTicks)
{
Interlocked.Exchange(ref _lastStructuralScanTicks, now);
- if (HasNewSourceFiles())
- {
- logger.LogInformation("New source files detected. Full reload needed.");
- _compilationCache.Clear();
- _cacheAccessOrder.Clear();
- await LoadSolutionAsync(_solutionPath, ct);
- return;
- }
+ StartStructuralScan();
}
// Phase 3: collect changed documents without holding the lock (stat calls are
@@ -451,20 +546,81 @@ public async Task RefreshChangedDocumentsAsync(CancellationToken ct = default)
}
}
- private bool HasProjectFileChanged()
+ ///
+ /// Build files whose content actually differs from the last load. The timestamp is a
+ /// cheap pre-filter — one stat per file — and only files that moved are hashed, so the
+ /// steady-state cost is unchanged while a no-op rewrite costs one hash instead of a
+ /// full solution reload.
+ ///
+ private List GetChangedBuildFiles()
{
- foreach (var (path, lastKnown) in _projectFileTimestamps)
+ var changed = new List();
+
+ foreach (var (path, known) in _buildFiles)
{
// GetLastWriteTimeUtc returns year 1601 for missing files — always < lastKnown
- if (File.GetLastWriteTimeUtc(path) > lastKnown)
- return true;
+ if (File.GetLastWriteTimeUtc(path) <= known.LastWriteUtc)
+ continue;
+
+ var hash = ComputeHash(path);
+ if (hash is not null && hash == known.Hash)
+ {
+ // Rewritten with identical content: refresh the timestamp so the next poll
+ // short-circuits at the stat, and skip the reload.
+ _buildFiles[path] = known with { LastWriteUtc = File.GetLastWriteTimeUtc(path) };
+ continue;
+ }
+
+ changed.Add(path);
}
- return false;
+
+ return changed;
+ }
+
+ ///
+ /// Kicks off the directory walk on a background thread, at most one at a time. Fire and
+ /// forget by design: the caller is servicing a tool request and must not wait on it.
+ ///
+ private void StartStructuralScan()
+ {
+ if (Interlocked.CompareExchange(ref _structuralScanRunning, 1, 0) != 0)
+ return;
+
+ _structuralScan = Task.Run(() =>
+ {
+ try
+ {
+ if (HasNewSourceFiles(_backgroundCts.Token))
+ Interlocked.Exchange(ref _newSourceFilesPending, 1);
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+ catch (Exception ex)
+ {
+ // A transient IO error during the walk costs one missed scan, not a failed
+ // tool call — the next cooldown schedules another.
+ logger.LogWarning(ex, "Structural scan failed; will retry on the next cooldown.");
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _structuralScanRunning, 0);
+ }
+ }, _backgroundCts.Token);
}
- private bool HasNewSourceFiles()
+ ///
+ /// Awaits the in-flight structural scan. Test seam — nothing in the request path
+ /// should ever wait on this.
+ ///
+ internal Task WaitForStructuralScanAsync() => _structuralScan;
+
+ private bool HasNewSourceFiles(CancellationToken ct)
{
- if (_solution is null) return false;
+ // Captured once: a concurrent reload can swap the field mid-walk.
+ var solution = _solution;
+ if (solution is null) return false;
var options = new EnumerationOptions
{
@@ -473,9 +629,11 @@ private bool HasNewSourceFiles()
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System
};
- foreach (var projectId in _solution.ProjectIds)
+ foreach (var projectId in solution.ProjectIds)
{
- var project = _solution.GetProject(projectId);
+ ct.ThrowIfCancellationRequested();
+
+ var project = solution.GetProject(projectId);
if (project?.FilePath is null) continue;
var projectDir = Path.GetDirectoryName(project.FilePath);
@@ -488,6 +646,8 @@ private bool HasNewSourceFiles()
foreach (var file in Directory.EnumerateFiles(projectDir, "*.cs", options))
{
+ ct.ThrowIfCancellationRequested();
+
if (file.StartsWith(binPrefix, StringComparison.OrdinalIgnoreCase) ||
file.StartsWith(objPrefix, StringComparison.OrdinalIgnoreCase))
continue;
@@ -502,6 +662,19 @@ private bool HasNewSourceFiles()
public void Dispose()
{
+ _backgroundCts.Cancel();
+
+ try
+ {
+ // Bounded so shutdown cannot block on a large directory walk.
+ _structuralScan.Wait(TimeSpan.FromSeconds(2));
+ }
+ catch (Exception)
+ {
+ // Cancellation or a failed scan; nothing left to salvage during teardown.
+ }
+
+ _backgroundCts.Dispose();
_workspace?.Dispose();
_writeLock.Dispose();
}
diff --git a/mcp/CWM.RoslynNavigator/tests/BuildFileChangeDetectionTests.cs b/mcp/CWM.RoslynNavigator/tests/BuildFileChangeDetectionTests.cs
new file mode 100644
index 0000000..60eef1e
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/BuildFileChangeDetectionTests.cs
@@ -0,0 +1,197 @@
+using CWM.RoslynNavigator.Tests.Fixtures;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace CWM.RoslynNavigator.Tests;
+
+///
+/// A solution reload is the most expensive operation the server performs. These tests pin
+/// down when one actually happens, using an isolated copy of the sample solution so build
+/// files can be mutated without touching the repository.
+///
+public sealed class BuildFileChangeDetectionTests : IDisposable
+{
+ private static readonly TimeSpan PastCooldown = TimeSpan.FromSeconds(10);
+
+ private readonly string _root;
+ private readonly TestClock _clock = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ private readonly WorkspaceManager _workspace;
+
+ public BuildFileChangeDetectionTests()
+ {
+ TestSolutionFixture.RegisterMSBuild();
+
+ _root = Path.Combine(Path.GetTempPath(), "cwm-rn-" + Guid.NewGuid().ToString("N"));
+ CopyDirectory(Path.GetDirectoryName(TestSolutionFixture.SampleSolutionPath)!, _root);
+
+ _workspace = new WorkspaceManager(
+ NullLoggerFactory.Instance.CreateLogger(), _clock);
+ }
+
+ private string SolutionPath => Path.Combine(_root, "SampleSolution.sln");
+ private string ApiProjectPath => Path.Combine(_root, "SampleApi", "SampleApi.csproj");
+
+ [Fact]
+ public async Task Refresh_ProjectFileRewrittenWithIdenticalContent_DoesNotReload()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await _workspace.LoadSolutionAsync(SolutionPath, ct);
+ var loadsAfterInitial = _workspace.LoadCount;
+
+ // What a branch switch or a formatter does: same bytes, new timestamp.
+ var content = await File.ReadAllTextAsync(ApiProjectPath, ct);
+ await File.WriteAllTextAsync(ApiProjectPath, content, ct);
+ File.SetLastWriteTimeUtc(ApiProjectPath, DateTime.UtcNow.AddMinutes(1));
+
+ _clock.Advance(PastCooldown);
+ await _workspace.RefreshChangedDocumentsAsync(ct);
+
+ Assert.Equal(loadsAfterInitial, _workspace.LoadCount);
+ Assert.Equal(WorkspaceState.Ready, _workspace.State);
+ }
+
+ [Fact]
+ public async Task Refresh_ProjectFileContentChanged_Reloads()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await _workspace.LoadSolutionAsync(SolutionPath, ct);
+ var loadsAfterInitial = _workspace.LoadCount;
+
+ var content = await File.ReadAllTextAsync(ApiProjectPath, ct);
+ await File.WriteAllTextAsync(
+ ApiProjectPath,
+ content.Replace("", " CS0219\n"),
+ ct);
+ File.SetLastWriteTimeUtc(ApiProjectPath, DateTime.UtcNow.AddMinutes(1));
+
+ _clock.Advance(PastCooldown);
+ await _workspace.RefreshChangedDocumentsAsync(ct);
+
+ Assert.Equal(loadsAfterInitial + 1, _workspace.LoadCount);
+ }
+
+ [Fact]
+ public async Task Refresh_DirectoryPackagesPropsChanged_Reloads()
+ {
+ var ct = TestContext.Current.CancellationToken;
+
+ // Not a document and not a csproj — previously invisible to change detection,
+ // despite governing every package version in the solution.
+ var propsPath = Path.Combine(_root, "Directory.Packages.props");
+ await File.WriteAllTextAsync(propsPath, "", ct);
+
+ await _workspace.LoadSolutionAsync(SolutionPath, ct);
+ var loadsAfterInitial = _workspace.LoadCount;
+
+ await File.WriteAllTextAsync(
+ propsPath, "", ct);
+ File.SetLastWriteTimeUtc(propsPath, DateTime.UtcNow.AddMinutes(1));
+
+ _clock.Advance(PastCooldown);
+ await _workspace.RefreshChangedDocumentsAsync(ct);
+
+ Assert.Equal(loadsAfterInitial + 1, _workspace.LoadCount);
+ }
+
+ [Fact]
+ public async Task Refresh_NewSourceFile_DetectedByBackgroundScanNotInline()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await _workspace.LoadSolutionAsync(SolutionPath, ct);
+ var loadsAfterInitial = _workspace.LoadCount;
+
+ await File.WriteAllTextAsync(
+ Path.Combine(_root, "SampleApi", "BrandNewType.cs"),
+ "namespace SampleApi;\n\ninternal sealed class BrandNewType;\n",
+ ct);
+
+ // First refresh only schedules the walk — it must not reload, and must not block
+ // on the directory enumeration.
+ _clock.Advance(TimeSpan.FromSeconds(90));
+ await _workspace.RefreshChangedDocumentsAsync(ct);
+ Assert.Equal(loadsAfterInitial, _workspace.LoadCount);
+
+ await _workspace.WaitForStructuralScanAsync();
+
+ // The next refresh acts on what the completed scan found.
+ _clock.Advance(PastCooldown);
+ await _workspace.RefreshChangedDocumentsAsync(ct);
+
+ Assert.Equal(loadsAfterInitial + 1, _workspace.LoadCount);
+ Assert.Contains(
+ _workspace.GetSolution()!.Projects.SelectMany(p => p.Documents),
+ d => d.Name == "BrandNewType.cs");
+ }
+
+ [Fact]
+ public async Task Refresh_NoNewSourceFiles_BackgroundScanDoesNotTriggerReload()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await _workspace.LoadSolutionAsync(SolutionPath, ct);
+ var loadsAfterInitial = _workspace.LoadCount;
+
+ _clock.Advance(TimeSpan.FromSeconds(90));
+ await _workspace.RefreshChangedDocumentsAsync(ct);
+ await _workspace.WaitForStructuralScanAsync();
+
+ _clock.Advance(PastCooldown);
+ await _workspace.RefreshChangedDocumentsAsync(ct);
+
+ Assert.Equal(loadsAfterInitial, _workspace.LoadCount);
+ }
+
+ [Fact]
+ public async Task Refresh_NothingChanged_DoesNotReload()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await _workspace.LoadSolutionAsync(SolutionPath, ct);
+ var loadsAfterInitial = _workspace.LoadCount;
+
+ _clock.Advance(PastCooldown);
+ await _workspace.RefreshChangedDocumentsAsync(ct);
+
+ Assert.Equal(loadsAfterInitial, _workspace.LoadCount);
+ }
+
+ public void Dispose()
+ {
+ _workspace.Dispose();
+ try
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ catch (IOException)
+ {
+ // A locked file in a temp copy is not worth failing a test over.
+ }
+ }
+
+ private static void CopyDirectory(string source, string destination)
+ {
+ Directory.CreateDirectory(destination);
+
+ // Filter on the path relative to the source root. The source itself sits under the
+ // test output directory, so an absolute-path check for "bin" matches everything.
+ foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(source, file);
+ if (IsBuildOutput(relative)) continue;
+
+ var target = Path.Combine(destination, relative);
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ File.Copy(file, target, overwrite: true);
+ }
+ }
+
+ private static bool IsBuildOutput(string relativePath)
+ {
+ foreach (var segment in relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
+ {
+ if (segment.Equals("bin", StringComparison.OrdinalIgnoreCase)
+ || segment.Equals("obj", StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/Fixtures/TestClock.cs b/mcp/CWM.RoslynNavigator/tests/Fixtures/TestClock.cs
new file mode 100644
index 0000000..666d535
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/Fixtures/TestClock.cs
@@ -0,0 +1,14 @@
+namespace CWM.RoslynNavigator.Tests.Fixtures;
+
+///
+/// Manually advanced clock. WorkspaceManager gates its refresh work behind cooldowns, so
+/// tests need to move time forward rather than sleep through them.
+///
+public sealed class TestClock(DateTimeOffset start) : TimeProvider
+{
+ private DateTimeOffset _now = start;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void Advance(TimeSpan by) => _now = _now.Add(by);
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/Fixtures/TestSolutionFixture.cs b/mcp/CWM.RoslynNavigator/tests/Fixtures/TestSolutionFixture.cs
index bd31c45..ccc0045 100644
--- a/mcp/CWM.RoslynNavigator/tests/Fixtures/TestSolutionFixture.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Fixtures/TestSolutionFixture.cs
@@ -45,6 +45,17 @@ private static void EnsureMSBuildRegistered()
}
}
+ ///
+ /// Locates the sample solution for tests that need their own isolated WorkspaceManager
+ /// instead of the shared one (e.g. reload behaviour, which mutates workspace state).
+ ///
+ public static string SampleSolutionPath => FindSampleSolutionPath();
+
+ ///
+ /// Registers MSBuild for tests that construct a WorkspaceManager directly.
+ ///
+ public static void RegisterMSBuild() => EnsureMSBuildRegistered();
+
private static string FindSampleSolutionPath()
{
// Walk up from the test output directory to find the TestData folder
diff --git a/mcp/CWM.RoslynNavigator/tests/Responses/PagingTests.cs b/mcp/CWM.RoslynNavigator/tests/Responses/PagingTests.cs
new file mode 100644
index 0000000..4c36423
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/Responses/PagingTests.cs
@@ -0,0 +1,71 @@
+using CWM.RoslynNavigator.Responses;
+
+namespace CWM.RoslynNavigator.Tests.Responses;
+
+public class PagingTests
+{
+ [Fact]
+ public void Apply_UnderLimit_ReportsCompleteSet()
+ {
+ var page = Paging.Apply([1, 2, 3], maxResults: 10);
+
+ Assert.Equal(3, page.Count);
+ Assert.Equal(3, page.TotalFound);
+ Assert.False(page.Truncated);
+ Assert.Equal(10, page.Limit);
+ }
+
+ [Fact]
+ public void Apply_ExactlyAtLimit_IsNotTruncated()
+ {
+ // The boundary that a naive "Count >= Limit" check gets wrong.
+ var page = Paging.Apply([1, 2, 3], maxResults: 3);
+
+ Assert.Equal(3, page.Count);
+ Assert.False(page.Truncated);
+ }
+
+ [Fact]
+ public void Apply_OverLimit_TruncatesAndKeepsTotal()
+ {
+ var page = Paging.Apply([1, 2, 3, 4, 5], maxResults: 2);
+
+ Assert.Equal([1, 2], page.Items);
+ Assert.Equal(2, page.Count);
+ Assert.Equal(5, page.TotalFound);
+ Assert.True(page.Truncated);
+ Assert.Equal(2, page.Limit);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Apply_NonPositiveLimit_ClampsToOne(int maxResults)
+ {
+ var page = Paging.Apply([1, 2, 3], maxResults);
+
+ Assert.Equal(1, page.Limit);
+ Assert.Single(page.Items);
+ Assert.True(page.Truncated);
+ }
+
+ [Fact]
+ public void Apply_EmptySource_IsNotTruncated()
+ {
+ var page = Paging.Apply([], maxResults: 5);
+
+ Assert.Empty(page.Items);
+ Assert.Equal(0, page.TotalFound);
+ Assert.False(page.Truncated);
+ }
+
+ [Fact]
+ public void Empty_ReportsNoResultsAtRequestedLimit()
+ {
+ var page = Paging.Empty(25);
+
+ Assert.Empty(page.Items);
+ Assert.False(page.Truncated);
+ Assert.Equal(25, page.Limit);
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/StackTraceParserTests.cs b/mcp/CWM.RoslynNavigator/tests/StackTraceParserTests.cs
new file mode 100644
index 0000000..6011e91
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/StackTraceParserTests.cs
@@ -0,0 +1,98 @@
+namespace CWM.RoslynNavigator.Tests;
+
+public class StackTraceParserTests
+{
+ [Theory]
+ // Plain method
+ [InlineData("MyApp.Services.OrderService.CreateOrderAsync(String customerId, CancellationToken ct)",
+ "MyApp.Services.OrderService.CreateOrderAsync")]
+ // Async state machine — the rewrite that makes naive parsing fail
+ [InlineData("MyApp.Services.OrderService+d__5.MoveNext()",
+ "MyApp.Services.OrderService.CreateOrderAsync")]
+ // Lambda hoisted into the closure class
+ [InlineData("MyApp.Services.OrderService.<>c.b__3_0()",
+ "MyApp.Services.OrderService.Configure")]
+ // Lambda hoisted into a display class (captures locals)
+ [InlineData("MyApp.Services.OrderService.<>c__DisplayClass7_0.b__1()",
+ "MyApp.Services.OrderService.Configure")]
+ // Local function resolves to its enclosing method
+ [InlineData("MyApp.Services.OrderService.g__Validate|9_0(Int32 id)",
+ "MyApp.Services.OrderService.Process")]
+ // Generic method arity
+ [InlineData("System.Linq.Enumerable.Select[TSource,TResult](IEnumerable`1 source)",
+ "System.Linq.Enumerable.Select")]
+ // Nested type separator
+ [InlineData("MyApp.Outer+Inner.Run()", "MyApp.Outer.Inner.Run")]
+ // Constructors anchor on the type
+ [InlineData("MyApp.Services.OrderService..ctor(IOrderRepository repo)",
+ "MyApp.Services.OrderService")]
+ [InlineData("MyApp.Services.OrderService..cctor()", "MyApp.Services.OrderService")]
+ public void NormalizeMethod_UndoesCompilerRewrites(string raw, string expected)
+ {
+ Assert.Equal(expected, StackTraceParser.NormalizeMethod(raw));
+ }
+
+ [Fact]
+ public void ParseFrames_ExtractsFileAndLineWhenPdbPresent()
+ {
+ const string trace = """
+ System.InvalidOperationException: Order 42 not found
+ at MyApp.Services.OrderService.CancelOrderAsync(Guid id) in C:\src\MyApp\OrderService.cs:line 31
+ at MyApp.Api.OrderEndpoints.Cancel(Guid id)
+ """;
+
+ var frames = StackTraceParser.ParseFrames(trace);
+
+ Assert.Equal(2, frames.Count);
+ Assert.Equal("MyApp.Services.OrderService.CancelOrderAsync", frames[0].QualifiedName);
+ Assert.Equal(@"C:\src\MyApp\OrderService.cs", frames[0].File);
+ Assert.Equal(31, frames[0].Line);
+
+ // No PDB info on the second frame
+ Assert.Null(frames[1].File);
+ Assert.Null(frames[1].Line);
+ }
+
+ [Fact]
+ public void ParseFrames_IgnoresRethrowSeparators()
+ {
+ const string trace = """
+ at MyApp.A.One()
+ --- End of stack trace from previous location ---
+ at MyApp.B.Two()
+ """;
+
+ var frames = StackTraceParser.ParseFrames(trace);
+
+ Assert.Equal(["MyApp.A.One", "MyApp.B.Two"], frames.Select(f => f.QualifiedName));
+ }
+
+ [Fact]
+ public void ParseHeader_ReadsExceptionTypeAndMessage()
+ {
+ const string trace = """
+ System.InvalidOperationException: Order 42 not found
+ at MyApp.Services.OrderService.CancelOrderAsync(Guid id)
+ """;
+
+ var header = StackTraceParser.ParseHeader(trace);
+
+ Assert.NotNull(header);
+ Assert.Equal("System.InvalidOperationException", header.ExceptionType);
+ Assert.Equal("Order 42 not found", header.Message);
+ }
+
+ [Fact]
+ public void ParseHeader_TraceWithoutHeader_ReturnsNull()
+ {
+ const string trace = " at MyApp.Services.OrderService.CancelOrderAsync(Guid id)";
+
+ Assert.Null(StackTraceParser.ParseHeader(trace));
+ }
+
+ [Fact]
+ public void ParseFrames_NonStackTraceInput_ReturnsNothing()
+ {
+ Assert.Empty(StackTraceParser.ParseFrames("this is not a stack trace"));
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/SymbolResolverTests.cs b/mcp/CWM.RoslynNavigator/tests/SymbolResolverTests.cs
new file mode 100644
index 0000000..2c7d575
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/SymbolResolverTests.cs
@@ -0,0 +1,120 @@
+using CWM.RoslynNavigator.Tests.Fixtures;
+
+namespace CWM.RoslynNavigator.Tests;
+
+public class SymbolResolverTests(TestSolutionFixture fixture) : IClassFixture
+{
+ [Theory]
+ [InlineData("GetByIdAsync", "GetByIdAsync")]
+ [InlineData("OrderService.GetOrderAsync", "OrderService.GetOrderAsync")]
+ [InlineData(" OrderService.GetOrderAsync ", "OrderService.GetOrderAsync")]
+ [InlineData("GetByIdAsync(Guid, CancellationToken)", "GetByIdAsync")]
+ [InlineData("IRepository.GetByIdAsync", "IRepository.GetByIdAsync")]
+ [InlineData("IRepository`1.GetByIdAsync", "IRepository.GetByIdAsync")]
+ [InlineData("Outer.Inner.Run(int x)", "Outer.Inner.Run")]
+ [InlineData("", "")]
+ public void NormalizeRequestedName_StripsNoiseButKeepsSegments(string input, string expected)
+ {
+ Assert.Equal(expected, SymbolResolver.NormalizeRequestedName(input));
+ }
+
+ [Theory]
+ [InlineData("GetByIdAsync", "GetByIdAsync")]
+ [InlineData("OrderService.GetOrderAsync", "GetOrderAsync")]
+ [InlineData("SampleApi.OrderService.GetOrderAsync", "GetOrderAsync")]
+ public void GetSimpleName_ReturnsFinalSegment(string input, string expected)
+ {
+ Assert.Equal(expected, SymbolResolver.GetSimpleName(input));
+ }
+
+ [Fact]
+ public async Task FindSymbolsByName_BareMemberName_MatchesEveryDeclaringType()
+ {
+ // GetByIdAsync is declared on both repository interfaces and all three implementations.
+ var symbols = await SymbolResolver.FindSymbolsByNameAsync(
+ fixture.WorkspaceManager, "GetByIdAsync", ct: TestContext.Current.CancellationToken);
+
+ Assert.True(symbols.Count > 1, $"expected multiple matches, got {symbols.Count}");
+ }
+
+ [Fact]
+ public async Task FindSymbolsByName_TypeQualified_NarrowsToOneDeclaringType()
+ {
+ var symbols = await SymbolResolver.FindSymbolsByNameAsync(
+ fixture.WorkspaceManager, "IOrderRepository.GetByIdAsync", ct: TestContext.Current.CancellationToken);
+
+ Assert.Single(symbols);
+ Assert.Equal("IOrderRepository", symbols[0].ContainingType.Name);
+ }
+
+ [Fact]
+ public async Task FindSymbolsByName_FullyQualified_Resolves()
+ {
+ var symbols = await SymbolResolver.FindSymbolsByNameAsync(
+ fixture.WorkspaceManager,
+ "SampleApi.OrderService.CreateOrderAsync",
+ ct: TestContext.Current.CancellationToken);
+
+ Assert.Single(symbols);
+ Assert.Equal("CreateOrderAsync", symbols[0].Name);
+ }
+
+ [Fact]
+ public async Task FindSymbolsByName_QualifiedWithParameterList_Resolves()
+ {
+ var symbols = await SymbolResolver.FindSymbolsByNameAsync(
+ fixture.WorkspaceManager,
+ "OrderService.GetOrderAsync(Guid, CancellationToken)",
+ ct: TestContext.Current.CancellationToken);
+
+ Assert.Single(symbols);
+ Assert.Equal("OrderService", symbols[0].ContainingType.Name);
+ }
+
+ [Fact]
+ public async Task FindSymbolsByName_QualifierNamesWrongType_ReturnsEmpty()
+ {
+ // ProductService has no CreateOrderAsync — a bare-name search would have found
+ // OrderService's and reported it under the wrong owner.
+ var symbols = await SymbolResolver.FindSymbolsByNameAsync(
+ fixture.WorkspaceManager,
+ "ProductService.CreateOrderAsync",
+ ct: TestContext.Current.CancellationToken);
+
+ Assert.Empty(symbols);
+ }
+
+ [Fact]
+ public async Task FindSymbolsByName_PartialSegment_DoesNotMatch()
+ {
+ // Suffix matching must align on segment boundaries: "derService" is not "OrderService".
+ var symbols = await SymbolResolver.FindSymbolsByNameAsync(
+ fixture.WorkspaceManager,
+ "derService.GetOrderAsync",
+ ct: TestContext.Current.CancellationToken);
+
+ Assert.Empty(symbols);
+ }
+
+ [Fact]
+ public async Task FindSymbolsByName_QualifiedTypeName_Resolves()
+ {
+ var symbols = await SymbolResolver.FindSymbolsByNameAsync(
+ fixture.WorkspaceManager, "SampleDomain.Order", ct: TestContext.Current.CancellationToken);
+
+ Assert.Single(symbols);
+ Assert.Equal("Order", symbols[0].Name);
+ }
+
+ [Fact]
+ public async Task ResolveSymbol_QualifiedName_ResolvesWithoutFileHint()
+ {
+ var symbol = await SymbolResolver.ResolveSymbolAsync(
+ fixture.WorkspaceManager,
+ "IProductRepository.GetByIdAsync",
+ ct: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(symbol);
+ Assert.Equal("IProductRepository", symbol.ContainingType.Name);
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/TestData/SampleSolution/SampleApi/OrderSummary.g.cs b/mcp/CWM.RoslynNavigator/tests/TestData/SampleSolution/SampleApi/OrderSummary.g.cs
new file mode 100644
index 0000000..8f65bfc
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/TestData/SampleSolution/SampleApi/OrderSummary.g.cs
@@ -0,0 +1,13 @@
+//
+using SampleDomain;
+
+namespace SampleApi;
+
+///
+/// Stands in for source-generator output. Both signals SourceClassifier keys on are
+/// present: the .g.cs suffix and the auto-generated header comment.
+///
+public sealed class GeneratedOrderSummary
+{
+ public static string Describe(Order order) => $"{order.Id}:{order.Status}";
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/TestData/SampleSolution/SampleInfrastructure/ReflectionBound.cs b/mcp/CWM.RoslynNavigator/tests/TestData/SampleSolution/SampleInfrastructure/ReflectionBound.cs
new file mode 100644
index 0000000..fb92920
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/TestData/SampleSolution/SampleInfrastructure/ReflectionBound.cs
@@ -0,0 +1,29 @@
+namespace SampleInfrastructure;
+
+///
+/// Never referenced by name — resolved at runtime from the string literal in
+/// . A reference search sees zero usages, so dead-code
+/// detection must downgrade it rather than call it removable.
+///
+internal sealed class LegacyPricingPlugin
+{
+ public decimal Apply(decimal amount) => amount * 0.9m;
+}
+
+///
+/// Genuinely unreferenced with no reflection signal anywhere — the control case that
+/// should still be reported at high confidence.
+///
+internal sealed class TrulyUnusedCalculator
+{
+ public decimal Apply(decimal amount) => amount;
+}
+
+internal static class PluginLoader
+{
+ private const string PluginTypeName = "SampleInfrastructure.LegacyPricingPlugin";
+
+ public static object? Load() => Type.GetType(PluginTypeName) is { } t
+ ? Activator.CreateInstance(t)
+ : null;
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/AnalyzeChangeImpactTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/AnalyzeChangeImpactTests.cs
new file mode 100644
index 0000000..35ac33f
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/AnalyzeChangeImpactTests.cs
@@ -0,0 +1,121 @@
+using System.Text.Json;
+using CWM.RoslynNavigator.Responses;
+using CWM.RoslynNavigator.Tests.Fixtures;
+using CWM.RoslynNavigator.Tools;
+
+namespace CWM.RoslynNavigator.Tests.Tools;
+
+public class AnalyzeChangeImpactTests(TestSolutionFixture fixture) : IClassFixture
+{
+ [Fact]
+ public async Task AnalyzeChangeImpact_Interface_CountsImplementationsThatMustChange()
+ {
+ var json = await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "IOrderRepository", ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ // InMemoryOrderRepository and CachedOrderRepository both implement it.
+ Assert.Equal(2, result.ImplementationsToUpdate);
+ Assert.Equal("high", result.Risk);
+ Assert.Contains("implementation", result.Rationale);
+ }
+
+ [Fact]
+ public async Task AnalyzeChangeImpact_CrossProjectSymbol_ReportsEveryAffectedProject()
+ {
+ var json = await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "SampleDomain.Order", ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.True(result.AffectedProjects > 1,
+ "Order is referenced from SampleApi and SampleInfrastructure as well as SampleDomain");
+ Assert.Equal(result.AffectedProjects, result.Projects.Count);
+ Assert.All(result.Projects, p => Assert.True(p.References > 0));
+ }
+
+ [Fact]
+ public async Task AnalyzeChangeImpact_ReferenceCountsAgreeAcrossGroupings()
+ {
+ var json = await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "SampleDomain.Order", maxResults: 500,
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(result.DirectReferences, result.Projects.Sum(p => p.References));
+ Assert.Equal(result.DirectReferences, result.Files.Sum(f => f.References));
+ }
+
+ [Fact]
+ public async Task AnalyzeChangeImpact_Method_WalksTransitiveCallers()
+ {
+ // GetByIdAsync <- OrderService.GetOrderAsync <- (nothing further)
+ var shallow = JsonSerializer.Deserialize(
+ await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "IOrderRepository.GetByIdAsync", depth: 1,
+ ct: TestContext.Current.CancellationToken))!;
+
+ Assert.NotEmpty(shallow.TransitiveCallers);
+ Assert.Contains(shallow.TransitiveCallers, c => c.EndsWith("OrderService.GetOrderAsync"));
+ }
+
+ [Fact]
+ public async Task AnalyzeChangeImpact_DeeperWalk_NeverReturnsFewerCallers()
+ {
+ var shallow = JsonSerializer.Deserialize(
+ await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "IOrderRepository.GetByIdAsync", depth: 1, maxResults: 500,
+ ct: TestContext.Current.CancellationToken))!;
+
+ var deep = JsonSerializer.Deserialize(
+ await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "IOrderRepository.GetByIdAsync", depth: 4, maxResults: 500,
+ ct: TestContext.Current.CancellationToken))!;
+
+ Assert.True(deep.TransitiveCallerCount >= shallow.TransitiveCallerCount);
+ }
+
+ [Fact]
+ public async Task AnalyzeChangeImpact_IsolatedInternalSymbol_IsLowRisk()
+ {
+ var json = await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "TrulyUnusedCalculator", ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal("low", result.Risk);
+ Assert.False(result.CrossesAssemblyBoundary);
+ Assert.Equal(0, result.DirectReferences);
+ Assert.Contains("reflection", result.Rationale);
+ }
+
+ [Fact]
+ public async Task AnalyzeChangeImpact_PublicSymbol_FlagsAssemblyBoundary()
+ {
+ var json = await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "SampleApi.OrderService", ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.True(result.CrossesAssemblyBoundary);
+ Assert.Equal("public", result.Accessibility);
+ }
+
+ [Fact]
+ public async Task AnalyzeChangeImpact_UnknownSymbol_ReturnsSymbolNotFound()
+ {
+ var json = await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "ZZZNoSuchSymbol", ct: TestContext.Current.CancellationToken);
+ var error = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(ErrorCodes.SymbolNotFound, error.Error);
+ }
+
+ [Fact]
+ public async Task AnalyzeChangeImpact_AmbiguousName_ReportsCandidates()
+ {
+ var json = await AnalyzeChangeImpactTool.ExecuteAsync(
+ fixture.WorkspaceManager, "GetByIdAsync", ct: TestContext.Current.CancellationToken);
+ var error = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(ErrorCodes.AmbiguousMatch, error.Error);
+ Assert.NotNull(error.Candidates);
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/FindCallersTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/FindCallersTests.cs
index c40b861..6f09389 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/FindCallersTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/FindCallersTests.cs
@@ -11,11 +11,27 @@ public class FindCallersTests(TestSolutionFixture fixture) : IClassFixture(json)!;
- // GetByIdAsync is called from OrderService and CachedOrderRepository
- Assert.True(result.Count > 0, "Expected callers of GetByIdAsync");
+ // IOrderRepository.GetByIdAsync is called from OrderService and CachedOrderRepository
+ Assert.True(result.Count > 0, "Expected callers of IOrderRepository.GetByIdAsync");
+ }
+
+ [Fact]
+ public async Task FindCallers_AmbiguousBareName_ReportsCandidatesInsteadOfGuessing()
+ {
+ // GetByIdAsync is declared on two interfaces and three implementations. Picking one
+ // silently would report the wrong call sites with full confidence.
+ var json = await FindCallersTool.ExecuteAsync(
+ fixture.WorkspaceManager, "GetByIdAsync", ct: TestContext.Current.CancellationToken);
+ var error = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(ErrorCodes.AmbiguousMatch, error.Error);
+ Assert.NotNull(error.Candidates);
+ Assert.True(error.Candidates.Count > 1);
+ Assert.Contains(error.Candidates, c => c.Qualified == "SampleDomain.IOrderRepository.GetByIdAsync");
}
[Fact]
@@ -32,14 +48,15 @@ public async Task FindCallers_WithClassName_DisambiguatesCorrectly()
}
[Fact]
- public async Task FindCallers_NonexistentMethod_ReturnsEmpty()
+ public async Task FindCallers_NonexistentMethod_ReturnsSymbolNotFound()
{
var json = await FindCallersTool.ExecuteAsync(
fixture.WorkspaceManager, "MethodThatDoesNotExist12345",
ct: TestContext.Current.CancellationToken);
- var result = JsonSerializer.Deserialize(json)!;
+ var error = JsonSerializer.Deserialize(json)!;
- Assert.Equal(0, result.Count);
+ // Distinct from a real method with zero callers, which returns an empty CallersResult.
+ Assert.Equal(ErrorCodes.SymbolNotFound, error.Error);
}
[Fact]
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/FindDeadCodeTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/FindDeadCodeTests.cs
index 745f7a6..f4459f8 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/FindDeadCodeTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/FindDeadCodeTests.cs
@@ -20,6 +20,46 @@ public async Task FindDeadCode_Solution_FindsUnusedTypes()
Assert.Contains(result.Symbols, s => s.Name == "UnusedHelper");
}
+ [Fact]
+ public async Task FindDeadCode_ReflectionBoundType_IsDowngradedNotDeleted()
+ {
+ // LegacyPricingPlugin is resolved via Type.GetType on a string literal, so it has
+ // zero references. Reporting it at high confidence would invite a breaking deletion.
+ var json = await FindDeadCodeTool.ExecuteAsync(
+ fixture.WorkspaceManager, scope: "solution", maxResults: 200,
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ var plugin = Assert.Single(result.Symbols, s => s.Name == "LegacyPricingPlugin");
+ Assert.Equal("low", plugin.Confidence);
+ Assert.Contains("string literal", plugin.Note);
+ }
+
+ [Fact]
+ public async Task FindDeadCode_UnreferencedTypeWithNoReflectionSignal_StaysHighConfidence()
+ {
+ var json = await FindDeadCodeTool.ExecuteAsync(
+ fixture.WorkspaceManager, scope: "solution", maxResults: 200,
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ var calculator = Assert.Single(result.Symbols, s => s.Name == "TrulyUnusedCalculator");
+ Assert.Equal("high", calculator.Confidence);
+ Assert.Null(calculator.Note);
+ }
+
+ [Fact]
+ public async Task FindDeadCode_SolutionUsingReflection_ReportsScanningDetected()
+ {
+ var json = await FindDeadCodeTool.ExecuteAsync(
+ fixture.WorkspaceManager, scope: "solution",
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ // PluginLoader calls Type.GetType and Activator.CreateInstance.
+ Assert.True(result.AssemblyScanningDetected);
+ }
+
[Fact]
public async Task FindDeadCode_ProjectScope_FiltersCorrectly()
{
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/FindOverridesTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/FindOverridesTests.cs
index af50436..d33f0dd 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/FindOverridesTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/FindOverridesTests.cs
@@ -20,14 +20,14 @@ public async Task FindOverrides_NonVirtualMethod_ReturnsEmpty()
}
[Fact]
- public async Task FindOverrides_NonexistentMethod_ReturnsEmpty()
+ public async Task FindOverrides_NonexistentMethod_ReturnsSymbolNotFound()
{
var json = await FindOverridesTool.ExecuteAsync(
fixture.WorkspaceManager, "NonExistentMethod12345",
ct: TestContext.Current.CancellationToken);
- var result = JsonSerializer.Deserialize(json)!;
+ var error = JsonSerializer.Deserialize(json)!;
- Assert.Equal(0, result.Count);
+ Assert.Equal(ErrorCodes.SymbolNotFound, error.Error);
}
[Fact]
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/FindReferencesTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/FindReferencesTests.cs
index fbb8bcd..63c9b43 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/FindReferencesTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/FindReferencesTests.cs
@@ -28,12 +28,25 @@ public async Task FindReferences_ClassUsedInSameFile_ReturnsReferences()
}
[Fact]
- public async Task FindReferences_NonexistentSymbol_ReturnsZero()
+ public async Task FindReferences_NonexistentSymbol_ReturnsSymbolNotFound()
{
var json = await FindReferencesTool.ExecuteAsync(fixture.WorkspaceManager, "ZZZNonExistentXXX", ct: TestContext.Current.CancellationToken);
+ var error = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(ErrorCodes.SymbolNotFound, error.Error);
+ }
+
+ [Fact]
+ public async Task FindReferences_UnreferencedSymbol_ReturnsEmptyResultNotAnError()
+ {
+ // The distinction that matters: a real symbol with no references is a valid answer
+ // ("this is dead"), while a misspelled one is not an answer at all.
+ var json = await FindReferencesTool.ExecuteAsync(
+ fixture.WorkspaceManager, "UnusedHelper", ct: TestContext.Current.CancellationToken);
var result = JsonSerializer.Deserialize(json)!;
- Assert.Equal(0, result.Count);
+ Assert.Equal(0, result.TotalFound);
+ Assert.Empty(result.References);
}
[Fact]
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/FindSymbolTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/FindSymbolTests.cs
index 797696c..d738b05 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/FindSymbolTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/FindSymbolTests.cs
@@ -57,6 +57,59 @@ public async Task FindSymbol_Enum_ReturnsEnumKind()
Assert.Equal("enum", result.Symbols[0].Kind);
}
+ [Fact]
+ public async Task FindSymbol_MaxResultsBelowTotal_FlagsTruncatedAndReportsLimit()
+ {
+ var json = await FindSymbolTool.ExecuteAsync(
+ fixture.WorkspaceManager, "GetByIdAsync", "method", maxResults: 1,
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.True(result.Truncated);
+ Assert.Equal(1, result.Limit);
+ Assert.Single(result.Symbols);
+ Assert.True(result.TotalFound > 1);
+ }
+
+ [Fact]
+ public async Task FindSymbol_CompleteResultSet_IsNotFlaggedTruncated()
+ {
+ var json = await FindSymbolTool.ExecuteAsync(
+ fixture.WorkspaceManager, "OrderStatus", "enum", maxResults: 50,
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.False(result.Truncated);
+ Assert.Equal(result.TotalFound, result.Count);
+ }
+
+ [Fact]
+ public async Task FindSymbol_TypeQualifiedMethod_NarrowsToOwningType()
+ {
+ var bare = JsonSerializer.Deserialize(
+ await FindSymbolTool.ExecuteAsync(fixture.WorkspaceManager, "GetByIdAsync", "method",
+ ct: TestContext.Current.CancellationToken))!;
+
+ var qualified = JsonSerializer.Deserialize(
+ await FindSymbolTool.ExecuteAsync(fixture.WorkspaceManager, "IOrderRepository.GetByIdAsync", "method",
+ ct: TestContext.Current.CancellationToken))!;
+
+ Assert.True(bare.TotalFound > 1);
+ Assert.Equal(1, qualified.TotalFound);
+ Assert.Equal("SampleDomain", qualified.Symbols[0].Namespace);
+ }
+
+ [Fact]
+ public async Task FindSymbol_FullyQualifiedType_Resolves()
+ {
+ var json = await FindSymbolTool.ExecuteAsync(
+ fixture.WorkspaceManager, "SampleDomain.Order", "class", ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.Single(result.Symbols);
+ Assert.Equal("Order", result.Symbols[0].Name);
+ }
+
[Fact]
public async Task FindSymbol_Record_ReturnsRecordKind()
{
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/GeneratedCodeFlagTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/GeneratedCodeFlagTests.cs
new file mode 100644
index 0000000..5733e01
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/GeneratedCodeFlagTests.cs
@@ -0,0 +1,63 @@
+using System.Text.Json;
+using CWM.RoslynNavigator.Responses;
+using CWM.RoslynNavigator.Tests.Fixtures;
+using CWM.RoslynNavigator.Tools;
+
+namespace CWM.RoslynNavigator.Tests.Tools;
+
+///
+/// Navigation results flag generated source so callers do not try to edit a file
+/// that the next build will overwrite.
+///
+public class GeneratedCodeFlagTests(TestSolutionFixture fixture) : IClassFixture
+{
+ [Fact]
+ public async Task FindSymbol_GeneratedType_IsFlagged()
+ {
+ var json = await FindSymbolTool.ExecuteAsync(
+ fixture.WorkspaceManager, "GeneratedOrderSummary", "class",
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ var symbol = Assert.Single(result.Symbols);
+ Assert.True(symbol.IsGenerated, "GeneratedOrderSummary lives in a .g.cs file");
+ }
+
+ [Fact]
+ public async Task FindSymbol_HandWrittenType_IsNotFlagged()
+ {
+ var json = await FindSymbolTool.ExecuteAsync(
+ fixture.WorkspaceManager, "OrderService", "class",
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ var symbol = Assert.Single(result.Symbols);
+ Assert.False(symbol.IsGenerated);
+ }
+
+ [Fact]
+ public async Task FindReferences_UsageInsideGeneratedFile_IsFlagged()
+ {
+ // Order.Status is read by GeneratedOrderSummary.Describe and by hand-written code,
+ // so this asserts the flag varies per reference rather than per query.
+ var json = await FindReferencesTool.ExecuteAsync(
+ fixture.WorkspaceManager, "SampleDomain.Order.Status", maxResults: 100,
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.Contains(result.References, r => r.IsGenerated && r.File.EndsWith(".g.cs"));
+ Assert.Contains(result.References, r => !r.IsGenerated);
+ }
+
+ [Fact]
+ public async Task FindCallers_HandWrittenCaller_IsNotFlagged()
+ {
+ var json = await FindCallersTool.ExecuteAsync(
+ fixture.WorkspaceManager, "IOrderRepository.GetByIdAsync",
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.NotEmpty(result.Callers);
+ Assert.All(result.Callers, c => Assert.False(c.IsGenerated));
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/GetDependencyGraphTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/GetDependencyGraphTests.cs
index 10e601b..7ae9e58 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/GetDependencyGraphTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/GetDependencyGraphTests.cs
@@ -33,14 +33,24 @@ public async Task GetDependencyGraph_DepthLimit_RespectsMaxDepth()
}
[Fact]
- public async Task GetDependencyGraph_NonexistentMethod_ReturnsEmpty()
+ public async Task GetDependencyGraph_NonexistentMethod_ReturnsSymbolNotFound()
{
var json = await GetDependencyGraphTool.ExecuteAsync(
fixture.WorkspaceManager, "NonExistentMethod12345",
ct: TestContext.Current.CancellationToken);
- var result = JsonSerializer.Deserialize(json)!;
+ var error = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(ErrorCodes.SymbolNotFound, error.Error);
+ }
+
+ [Fact]
+ public async Task GetDependencyGraph_TypeInsteadOfMethod_ReturnsWrongSymbolKind()
+ {
+ var json = await GetDependencyGraphTool.ExecuteAsync(
+ fixture.WorkspaceManager, "OrderService", ct: TestContext.Current.CancellationToken);
+ var error = JsonSerializer.Deserialize(json)!;
- Assert.Equal(0, result.TotalNodes);
+ Assert.Equal(ErrorCodes.WrongSymbolKind, error.Error);
}
[Fact]
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/GetFileOutlineTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/GetFileOutlineTests.cs
index 40f7ded..7045842 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/GetFileOutlineTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/GetFileOutlineTests.cs
@@ -55,12 +55,12 @@ public async Task GetFileOutline_MaxResults_CapsWithTotalFound()
}
[Fact]
- public async Task GetFileOutline_UnknownFile_ReturnsNotFoundStatus()
+ public async Task GetFileOutline_UnknownFile_ReturnsFileNotFound()
{
var json = await GetFileOutlineTool.ExecuteAsync(
fixture.WorkspaceManager, "NoSuchFile.cs", ct: TestContext.Current.CancellationToken);
- var result = JsonSerializer.Deserialize(json)!;
+ var error = JsonSerializer.Deserialize(json)!;
- Assert.Equal("NotFound", result.State);
+ Assert.Equal(ErrorCodes.FileNotFound, error.Error);
}
}
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/GetPublicApiTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/GetPublicApiTests.cs
index 855ad6c..fb974f5 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/GetPublicApiTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/GetPublicApiTests.cs
@@ -44,12 +44,22 @@ public async Task GetPublicApi_ExcludesPrivateMembers()
}
[Fact]
- public async Task GetPublicApi_NonexistentType_ReturnsNotFound()
+ public async Task GetPublicApi_NonexistentType_ReturnsSymbolNotFound()
{
var json = await GetPublicApiTool.ExecuteAsync(fixture.WorkspaceManager, "ZZZNonExistent", ct: TestContext.Current.CancellationToken);
- var result = JsonSerializer.Deserialize(json)!;
+ var error = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(ErrorCodes.SymbolNotFound, error.Error);
+ }
+
+ [Fact]
+ public async Task GetPublicApi_SymbolIsNotAType_ReturnsWrongSymbolKind()
+ {
+ var json = await GetPublicApiTool.ExecuteAsync(
+ fixture.WorkspaceManager, "OrderService.GetOrderAsync", ct: TestContext.Current.CancellationToken);
+ var error = JsonSerializer.Deserialize(json)!;
- Assert.Equal("not found", result.Type);
- Assert.Empty(result.Members);
+ Assert.Equal(ErrorCodes.WrongSymbolKind, error.Error);
+ Assert.Contains("method", error.Message);
}
}
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/GetSymbolDetailTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/GetSymbolDetailTests.cs
index 5446b99..7b4b472 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/GetSymbolDetailTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/GetSymbolDetailTests.cs
@@ -64,13 +64,13 @@ public async Task GetSymbolDetail_Interface_ReturnsXmlDoc()
}
[Fact]
- public async Task GetSymbolDetail_Nonexistent_ReturnsNotFound()
+ public async Task GetSymbolDetail_Nonexistent_ReturnsSymbolNotFound()
{
var json = await GetSymbolDetailTool.ExecuteAsync(
fixture.WorkspaceManager, "ZZZNonExistent12345",
ct: TestContext.Current.CancellationToken);
- var result = JsonSerializer.Deserialize(json)!;
+ var error = JsonSerializer.Deserialize(json)!;
- Assert.Equal("NotFound", result.State);
+ Assert.Equal(ErrorCodes.SymbolNotFound, error.Error);
}
}
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/GetSymbolSourceTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/GetSymbolSourceTests.cs
index a3ad64f..7805857 100644
--- a/mcp/CWM.RoslynNavigator/tests/Tools/GetSymbolSourceTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/GetSymbolSourceTests.cs
@@ -60,12 +60,12 @@ public async Task GetSymbolSource_MaxChars_TruncatesAndFlags()
}
[Fact]
- public async Task GetSymbolSource_UnknownSymbol_ReturnsNotFoundStatus()
+ public async Task GetSymbolSource_UnknownSymbol_ReturnsSymbolNotFound()
{
var json = await GetSymbolSourceTool.ExecuteAsync(
fixture.WorkspaceManager, "NoSuchSymbolXyz", ct: TestContext.Current.CancellationToken);
- var result = JsonSerializer.Deserialize(json)!;
+ var error = JsonSerializer.Deserialize(json)!;
- Assert.Equal("NotFound", result.State);
+ Assert.Equal(ErrorCodes.SymbolNotFound, error.Error);
}
}
diff --git a/mcp/CWM.RoslynNavigator/tests/Tools/ResolveStackTraceTests.cs b/mcp/CWM.RoslynNavigator/tests/Tools/ResolveStackTraceTests.cs
new file mode 100644
index 0000000..9a640db
--- /dev/null
+++ b/mcp/CWM.RoslynNavigator/tests/Tools/ResolveStackTraceTests.cs
@@ -0,0 +1,118 @@
+using System.Text.Json;
+using CWM.RoslynNavigator.Responses;
+using CWM.RoslynNavigator.Tests.Fixtures;
+using CWM.RoslynNavigator.Tools;
+
+namespace CWM.RoslynNavigator.Tests.Tools;
+
+public class ResolveStackTraceTests(TestSolutionFixture fixture) : IClassFixture
+{
+ ///
+ /// Mirrors what OrderService.CancelOrderAsync actually throws, framed the way the
+ /// runtime prints it: async state machine on top, framework frames interleaved.
+ ///
+ private const string RealisticTrace = """
+ System.InvalidOperationException: Order 42 not found
+ at SampleApi.OrderService+d__4.MoveNext()
+ at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+ at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
+ at SampleApi.OrderService.GetOrderAsync(Guid id, CancellationToken ct)
+ """;
+
+ [Fact]
+ public async Task ResolveStackTrace_ParsesHeader()
+ {
+ var json = await ResolveStackTraceTool.ExecuteAsync(
+ fixture.WorkspaceManager, RealisticTrace, ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal("System.InvalidOperationException", result.ExceptionType);
+ Assert.Equal("Order 42 not found", result.Message);
+ }
+
+ [Fact]
+ public async Task ResolveStackTrace_AsyncFrame_ResolvesToSourceMethod()
+ {
+ var json = await ResolveStackTraceTool.ExecuteAsync(
+ fixture.WorkspaceManager, RealisticTrace, ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ var frame = result.Frames[0];
+ Assert.True(frame.InSolution, "the async state machine frame must map back to source");
+ Assert.Equal("SampleApi.OrderService.CancelOrderAsync", frame.Method);
+ Assert.Equal("SampleApi/OrderService.cs", frame.File);
+ Assert.True(frame.DeclarationLine > 0);
+ }
+
+ [Fact]
+ public async Task ResolveStackTrace_FrameworkFrames_AreMarkedOutOfSolution()
+ {
+ var json = await ResolveStackTraceTool.ExecuteAsync(
+ fixture.WorkspaceManager, RealisticTrace, ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.All(
+ result.Frames.Where(f => f.Method.StartsWith("System.")),
+ f => Assert.False(f.InSolution));
+ }
+
+ [Fact]
+ public async Task ResolveStackTrace_PointsAtTopmostSolutionFrame()
+ {
+ var json = await ResolveStackTraceTool.ExecuteAsync(
+ fixture.WorkspaceManager, RealisticTrace, ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(0, result.FirstSolutionFrame);
+ Assert.Equal(2, result.SolutionFrames);
+ }
+
+ [Fact]
+ public async Task ResolveStackTrace_SolutionOnly_DropsFrameworkFrames()
+ {
+ var json = await ResolveStackTraceTool.ExecuteAsync(
+ fixture.WorkspaceManager, RealisticTrace, solutionOnly: true,
+ ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(2, result.Frames.Count);
+ Assert.All(result.Frames, f => Assert.True(f.InSolution));
+ }
+
+ [Fact]
+ public async Task ResolveStackTrace_TraceLineNumber_TakesPrecedenceOverDeclaration()
+ {
+ const string trace = """
+ at SampleApi.OrderService.CancelOrderAsync(Guid id) in /repo/SampleApi/OrderService.cs:line 33
+ """;
+
+ var json = await ResolveStackTraceTool.ExecuteAsync(
+ fixture.WorkspaceManager, trace, ct: TestContext.Current.CancellationToken);
+ var result = JsonSerializer.Deserialize(json)!;
+
+ var frame = result.Frames[0];
+ Assert.Equal(33, frame.Line);
+ Assert.NotEqual(frame.Line, frame.DeclarationLine);
+ Assert.Contains("order.Cancel()", frame.Snippet);
+ }
+
+ [Fact]
+ public async Task ResolveStackTrace_EmptyInput_ReturnsInvalidArgument()
+ {
+ var json = await ResolveStackTraceTool.ExecuteAsync(
+ fixture.WorkspaceManager, " ", ct: TestContext.Current.CancellationToken);
+ var error = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(ErrorCodes.InvalidArgument, error.Error);
+ }
+
+ [Fact]
+ public async Task ResolveStackTrace_NoFramesInInput_ReturnsInvalidArgument()
+ {
+ var json = await ResolveStackTraceTool.ExecuteAsync(
+ fixture.WorkspaceManager, "something went wrong", ct: TestContext.Current.CancellationToken);
+ var error = JsonSerializer.Deserialize(json)!;
+
+ Assert.Equal(ErrorCodes.InvalidArgument, error.Error);
+ }
+}
diff --git a/mcp/CWM.RoslynNavigator/tests/WorkspaceManagerTests.cs b/mcp/CWM.RoslynNavigator/tests/WorkspaceManagerTests.cs
index 9b40eeb..e87a6cd 100644
--- a/mcp/CWM.RoslynNavigator/tests/WorkspaceManagerTests.cs
+++ b/mcp/CWM.RoslynNavigator/tests/WorkspaceManagerTests.cs
@@ -1,4 +1,6 @@
using CWM.RoslynNavigator.Tests.Fixtures;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace CWM.RoslynNavigator.Tests;
@@ -57,4 +59,48 @@ public async Task EnsureReadyOrStatusAsync_ShouldReturnNull_WhenAlreadyReady()
Assert.Null(result);
}
+
+ [Fact]
+ public async Task LoadSolutionAsync_ReloadingSameSolution_DoesNotAccumulateStaleCompilations()
+ {
+ // A reload mints fresh ProjectIds. If the cache is not cleared, the old entries
+ // linger alongside the new ones and the count doubles.
+ TestSolutionFixture.RegisterMSBuild();
+ var ct = TestContext.Current.CancellationToken;
+
+ using var manager = new WorkspaceManager(
+ NullLoggerFactory.Instance.CreateLogger(),
+ TimeProvider.System);
+
+ await manager.LoadSolutionAsync(TestSolutionFixture.SampleSolutionPath, ct);
+ var afterFirstLoad = manager.CachedCompilationCount;
+
+ await manager.LoadSolutionAsync(TestSolutionFixture.SampleSolutionPath, ct);
+
+ Assert.Equal(3, afterFirstLoad);
+ Assert.Equal(afterFirstLoad, manager.CachedCompilationCount);
+ }
+
+ [Fact]
+ public async Task LoadSolutionAsync_ReloadingSameSolution_CachesOnlyLiveProjectIds()
+ {
+ TestSolutionFixture.RegisterMSBuild();
+ var ct = TestContext.Current.CancellationToken;
+
+ using var manager = new WorkspaceManager(
+ NullLoggerFactory.Instance.CreateLogger(),
+ TimeProvider.System);
+
+ await manager.LoadSolutionAsync(TestSolutionFixture.SampleSolutionPath, ct);
+ await manager.LoadSolutionAsync(TestSolutionFixture.SampleSolutionPath, ct);
+
+ var solution = manager.GetSolution()!;
+ foreach (var projectId in solution.ProjectIds)
+ {
+ Assert.NotNull(await manager.GetCompilationAsync(projectId, ct));
+ }
+
+ // Every cached entry belongs to the current solution — no orphans from the first load.
+ Assert.Equal(solution.ProjectIds.Count, manager.CachedCompilationCount);
+ }
}