From 2f766c88f22a5fbcb8300051dcf641946fa63b8c Mon Sep 17 00:00:00 2001 From: Karzone Date: Wed, 29 Jul 2026 19:07:37 +0100 Subject: [PATCH 1/7] feat(mcp): add resolve_step and unbound_steps tools for spec-driven authoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_step: match a Gherkin phrase to existing step definition(s) via the real StepMatcher (regex/cucumber, keyword-agnostic) — exact/ambiguous/none, with near-match suggestions when nothing binds. Reuse-first: stops agents re-authoring steps that already exist. unbound_steps: scenario steps with no matching definition, each with its scenario/feature/location — the missing-glue worklist. Read-only, existing tools untouched. +6 tests (241 total, all green). --- README.md | 2 + src/CodeMap.Mcp/McpServer.cs | 137 ++++++++++++++++++++++++++ tests/CodeMap.Tests/McpServerTests.cs | 63 ++++++++++++ 3 files changed, 202 insertions(+) diff --git a/README.md b/README.md index c105c79..adcac41 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,8 @@ claude mcp add testatlas -- src/CodeMap.Mcp/bin/Release/net8.0/TestAtlas.Mcp.exe - `search_steps` — full-text search over step definitions (expression text + method + class name). - `search_scenarios` — full-text search over scenarios (feature + scenario name + step text + tags). - `list_endpoints` — the HTTP endpoints the suite calls, each with verb, route, and scenario blast radius (highest-reach first). +- `resolve_step` — resolve a Gherkin phrase to the existing step definition(s) that would bind it (regex/cucumber, keyword-agnostic). `exact` / `ambiguous` / `none` (+ near-match suggestions). Reuse-first authoring: don't write a step that already exists. +- `unbound_steps` — scenario steps that match no step definition — the glue an agent must implement, each with its scenario, feature, and file:line. > [!IMPORTANT] > MCP clients load servers **at session start**. If you register the server mid-session, restart diff --git a/src/CodeMap.Mcp/McpServer.cs b/src/CodeMap.Mcp/McpServer.cs index 3506c98..0842577 100644 --- a/src/CodeMap.Mcp/McpServer.cs +++ b/src/CodeMap.Mcp/McpServer.cs @@ -1,5 +1,7 @@ using System.Text.Json; using TestAtlas.Core.Analysis; +using TestAtlas.Core.Binding; +using TestAtlas.Core.Model; using TestAtlas.Core.Storage; namespace TestAtlas.Mcp; @@ -126,6 +128,31 @@ private string HandleToolCall(object? id, JsonElement root) new("list_endpoints", "The HTTP endpoints/operations the suite calls, each with verb, route (real path when known), and its scenario blast radius. Highest-reach first.", new { type = "object", properties = new { limit = new { type = "integer", description = "Max rows (default 50)." } } }, ListEndpoints), + + new("resolve_step", + "Resolve a Gherkin step phrase to the EXISTING step definition(s) that would bind it — the same way the runner does " + + "(regex/cucumber expression, keyword-agnostic). Use this BEFORE writing a new step so an agent reuses what already exists " + + "instead of authoring a duplicate. status is 'exact' (one binding — reuse it), 'ambiguous' (several match — a conflict to " + + "resolve), or 'none' (nothing binds; returns near-match suggestions to adapt). Each match returns the expression, the C# " + + "class/method, the method parameters, the argument values captured from the phrase, and file:line.", + new + { + type = "object", + properties = new + { + text = new { type = "string", description = "The step phrase to resolve, without the leading Given/When/Then keyword (e.g. \"the customer checks out\")." }, + keyword = new { type = "string", @enum = new[] { "given", "when", "then" }, description = "Optional; informational only — matching is keyword-agnostic, as in Reqnroll/SpecFlow." }, + }, + required = new[] { "text" }, + }, + ResolveStep), + + new("unbound_steps", + "Scenario steps that match NO step definition (unbound) — the glue an agent must implement before those scenarios can run. " + + "Each row: the step text, its keyword, the owning scenario + feature, and file:line. Use this to see exactly what step " + + "definitions are missing across the suite.", + new { type = "object", properties = new { limit = new { type = "integer", description = "Max rows (default 50)." } } }, + UnboundSteps), }; private string Stats() @@ -232,6 +259,116 @@ private string ListEndpoints(JsonElement args) return Serialize(new { total = _doc.Endpoints.Count, endpoints = rows }); } + private string ResolveStep(JsonElement args) + { + var text = Arg(args, "text"); + if (string.IsNullOrWhiteSpace(text)) + return Serialize(new { error = "resolve_step requires 'text' (the step phrase to resolve)." }); + + // Build + compile candidate bindings from the map's step definitions, tying each back to its + // StepDefinition id via the binding Reference. This reuses the exact matcher the indexer binds + // with, so "would this phrase bind?" matches runtime resolution (regex/cucumber, keyword-agnostic). + var compiled = new List(_doc.StepDefinitions.Count); + foreach (var sd in _doc.StepDefinitions) + { + var binding = new StepBinding( + ParseBindingKeyword(sd.Keyword), + sd.Expression, + sd.ExpressionKind == ExpressionKinds.CucumberExpression ? ExpressionKind.CucumberExpression : ExpressionKind.Regex, + Reference: sd.Id.ToString()); + var c = StepMatcher.Compile(binding); + if (c is not null) compiled.Add(c); + } + + var result = StepMatcher.Match(new ScenarioStepInput(ParseStepKeyword(Arg(args, "keyword")), text), compiled); + + var sdById = _doc.StepDefinitions.ToDictionary(s => s.Id); + var matches = result.Matches + .Select(m => int.TryParse(m.Binding.Reference, out var id) && sdById.TryGetValue(id, out var sd) ? (sd, m.Parameters) : default) + .Where(x => x.sd is not null) + .Select(x => new + { + expression = x.sd!.Expression, + expressionKind = x.sd.ExpressionKind, + keyword = x.sd.Keyword, + capturedArguments = x.Parameters, + methodParameters = x.sd.Parameters, + @class = _doc.Classes.FirstOrDefault(c => c.Id == x.sd.ClassId)?.Name, + method = _doc.Methods.FirstOrDefault(mm => mm.Id == x.sd.MethodId)?.Name, + location = $"{x.sd.FilePath}:{x.sd.LineStart}", + }) + .ToList(); + + var status = result.Confidence switch + { + MatchConfidence.Exact => "exact", + MatchConfidence.Ambiguous => "ambiguous", + _ => "none", + }; + + // No binding matched → offer near-matches (FTS over step text) so the agent adapts an existing + // step instead of authoring a duplicate. Empty when nothing is lexically close — then author anew. + object? suggestions = null; + if (result.Confidence == MatchConfidence.Unbound) + { + var ids = MapReader.SearchSteps(_dbPath, text!).ToHashSet(); + suggestions = _doc.StepDefinitions.Where(s => ids.Contains(s.Id)).Take(10) + .Select(s => new { expression = s.Expression, keyword = s.Keyword, location = $"{s.FilePath}:{s.LineStart}" }); + } + + return Serialize(new { status, text, matchCount = matches.Count, matches, suggestions }); + } + + private string UnboundSteps(JsonElement args) + { + var limit = LimitArg(args, 50); + var unboundIds = _doc.Edges + .Where(e => e.EdgeKind == EdgeKinds.Unbound && e.FromKind == RefKinds.ScenarioStep) + .Select(e => e.FromId).ToHashSet(); + + var scenarioById = _doc.Scenarios.ToDictionary(s => s.Id); + var featureById = _doc.Features.ToDictionary(f => f.Id); + + var all = _doc.ScenarioSteps.Where(s => unboundIds.Contains(s.Id)) + .OrderBy(s => s.FilePath, StringComparer.Ordinal).ThenBy(s => s.LineStart) + .ToList(); + + var rows = all.Take(limit).Select(st => + { + var sc = scenarioById.TryGetValue(st.ScenarioId, out var s) ? s : null; + var feature = sc is not null && featureById.TryGetValue(sc.FeatureId, out var f) ? f.Name : null; + return new + { + step = st.Text, + keyword = st.Keyword, + scenario = sc?.Name, + feature, + location = $"{st.FilePath}:{st.LineStart}", + }; + }); + + return Serialize(new { count = all.Count, truncated = all.Count > limit ? all.Count - limit : 0, steps = rows }); + } + + private static BindingKeyword ParseBindingKeyword(string? k) => (k ?? string.Empty).Trim().ToLowerInvariant() switch + { + "given" => BindingKeyword.Given, + "when" => BindingKeyword.When, + "then" => BindingKeyword.Then, + _ => BindingKeyword.StepDefinition, + }; + + private static StepKeyword ParseStepKeyword(string? k) => (k ?? string.Empty).Trim().ToLowerInvariant() switch + { + "when" => StepKeyword.When, + "then" => StepKeyword.Then, + _ => StepKeyword.Given, + }; + + private static int LimitArg(JsonElement args, int def) + => args.ValueKind == JsonValueKind.Object && args.TryGetProperty("limit", out var l) && l.ValueKind == JsonValueKind.Number + ? Math.Clamp(l.GetInt32(), 1, MaxRows) : def; + // ---- JSON-RPC plumbing ------------------------------------------------------------------------- private static string Result(object? id, object result) => Serialize(new { jsonrpc = "2.0", id, result }); diff --git a/tests/CodeMap.Tests/McpServerTests.cs b/tests/CodeMap.Tests/McpServerTests.cs index c9e5cc0..951e6a9 100644 --- a/tests/CodeMap.Tests/McpServerTests.cs +++ b/tests/CodeMap.Tests/McpServerTests.cs @@ -93,6 +93,69 @@ public void List_endpoints_tool_surfaces_the_operation_with_its_real_route() Assert.Equal("SupplierBff", op.GetProperty("targetApi").GetString()); } + [Fact] + public void New_authoring_tools_are_advertised() + { + var res = Call("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}"""); + var names = res.GetProperty("result").GetProperty("tools").EnumerateArray() + .Select(t => t.GetProperty("name").GetString()).ToHashSet(); + Assert.Superset(new HashSet { "resolve_step", "unbound_steps" }, names); + } + + [Fact] + public void Resolve_step_binds_a_phrase_to_the_existing_definition_cross_project() + { + // "the customer checks out" is only defined in the Reqnroll project (CheckoutSteps) — a + // SpecFlow feature's step still resolves to it. Exactly one binding → 'exact'. + var r = ToolCall("resolve_step", """{"text":"the customer checks out"}"""); + Assert.Equal("exact", r.GetProperty("status").GetString()); + Assert.Equal(1, r.GetProperty("matchCount").GetInt32()); + var m = r.GetProperty("matches")[0]; + Assert.Equal("the customer checks out", m.GetProperty("expression").GetString()); + Assert.Equal("CheckoutSteps", m.GetProperty("class").GetString()); + } + + [Fact] + public void Resolve_step_captures_the_argument_values_from_the_phrase() + { + // "a user named (.*)" binds "a user named Alice", capturing "Alice". + var r = ToolCall("resolve_step", """{"text":"a user named Alice"}"""); + Assert.Equal("exact", r.GetProperty("status").GetString()); + var captured = r.GetProperty("matches")[0].GetProperty("capturedArguments").EnumerateArray() + .Select(a => a.GetString()); + Assert.Contains("Alice", captured); + } + + [Fact] + public void Resolve_step_flags_an_ambiguous_phrase_matching_two_definitions() + { + // Both "the system is ready" and "the system is (.*)" match — a conflict to resolve. + var r = ToolCall("resolve_step", """{"text":"the system is ready"}"""); + Assert.Equal("ambiguous", r.GetProperty("status").GetString()); + Assert.Equal(2, r.GetProperty("matchCount").GetInt32()); + } + + [Fact] + public void Resolve_step_returns_none_when_nothing_binds() + { + // "pigs can fly" is the fixture's deliberately-unbound step — no definition matches. + var r = ToolCall("resolve_step", """{"text":"pigs can fly"}"""); + Assert.Equal("none", r.GetProperty("status").GetString()); + Assert.Equal(0, r.GetProperty("matchCount").GetInt32()); + } + + [Fact] + public void Unbound_steps_lists_the_deliberately_unbound_step() + { + var r = ToolCall("unbound_steps"); + Assert.True(r.GetProperty("count").GetInt32() >= 1); + var texts = r.GetProperty("steps").EnumerateArray().Select(s => s.GetProperty("step").GetString()); + Assert.Contains("pigs can fly", texts); + var pig = r.GetProperty("steps").EnumerateArray().Single(s => s.GetProperty("step").GetString() == "pigs can fly"); + Assert.Equal("Successful sign in", pig.GetProperty("scenario").GetString()); + Assert.Equal("Login", pig.GetProperty("feature").GetString()); + } + [Fact] public void A_notification_without_an_id_gets_no_response() => Assert.Null(_server.HandleLine("""{"jsonrpc":"2.0","method":"notifications/initialized"}""")); From e652977df90227439325d33d01fbb52e48d3c7c1 Mon Sep 17 00:00:00 2001 From: Karzone Date: Wed, 29 Jul 2026 19:14:10 +0100 Subject: [PATCH 2/7] fix(mcp): make resolve_step suggestions rank by shared terms; collapse multi-attribute duplicate - suggestions now OR the phrase's salient tokens and rank by shared-term count, so a near-miss that substitutes a word still surfaces the closest steps (previously AND-of-tokens returned nothing on any substitution). - a [Given]+[When]-on-one-method step now reads as one 'exact' step, not a false 'ambiguous'. +1 test (242 total, all green). --- src/CodeMap.Mcp/McpServer.cs | 41 ++++++++++++++++++--------- tests/CodeMap.Tests/McpServerTests.cs | 11 +++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/CodeMap.Mcp/McpServer.cs b/src/CodeMap.Mcp/McpServer.cs index 0842577..05002ac 100644 --- a/src/CodeMap.Mcp/McpServer.cs +++ b/src/CodeMap.Mcp/McpServer.cs @@ -133,7 +133,7 @@ private string HandleToolCall(object? id, JsonElement root) "Resolve a Gherkin step phrase to the EXISTING step definition(s) that would bind it — the same way the runner does " + "(regex/cucumber expression, keyword-agnostic). Use this BEFORE writing a new step so an agent reuses what already exists " + "instead of authoring a duplicate. status is 'exact' (one binding — reuse it), 'ambiguous' (several match — a conflict to " + - "resolve), or 'none' (nothing binds; returns near-match suggestions to adapt). Each match returns the expression, the C# " + + "resolve), or 'none' (nothing binds — returns existing step definitions ranked by shared terms, to adapt rather than duplicate). Each match returns the expression, the C# " + "class/method, the method parameters, the argument values captured from the phrase, and file:line.", new { @@ -286,6 +286,10 @@ private string ResolveStep(JsonElement args) var matches = result.Matches .Select(m => int.TryParse(m.Binding.Reference, out var id) && sdById.TryGetValue(id, out var sd) ? (sd, m.Parameters) : default) .Where(x => x.sd is not null) + // A method decorated with e.g. [Given]+[When] yields several bindings for the SAME reusable + // step at one location — collapse them so it reads as one step, not a false 'ambiguous'. + .GroupBy(x => (x.sd!.FilePath, x.sd.LineStart, x.sd.Expression)) + .Select(g => g.First()) .Select(x => new { expression = x.sd!.Expression, @@ -299,21 +303,24 @@ private string ResolveStep(JsonElement args) }) .ToList(); - var status = result.Confidence switch - { - MatchConfidence.Exact => "exact", - MatchConfidence.Ambiguous => "ambiguous", - _ => "none", - }; + // Classify by distinct reusable steps: none / exact (reuse it) / ambiguous (a conflict to fix). + var status = matches.Count switch { 0 => "none", 1 => "exact", _ => "ambiguous" }; - // No binding matched → offer near-matches (FTS over step text) so the agent adapts an existing - // step instead of authoring a duplicate. Empty when nothing is lexically close — then author anew. + // Nothing binds → rank existing step defs by how many of the phrase's salient tokens they share + // (an OR, not an all-tokens AND) so a near-miss that swaps a word still surfaces the closest + // steps to adapt — reuse-first. Empty only when nothing is lexically close; then author anew. object? suggestions = null; - if (result.Confidence == MatchConfidence.Unbound) + if (matches.Count == 0) { - var ids = MapReader.SearchSteps(_dbPath, text!).ToHashSet(); - suggestions = _doc.StepDefinitions.Where(s => ids.Contains(s.Id)).Take(10) - .Select(s => new { expression = s.Expression, keyword = s.Keyword, location = $"{s.FilePath}:{s.LineStart}" }); + var score = new Dictionary(); + foreach (var tok in SalientTokens(text!)) + foreach (var id in MapReader.SearchSteps(_dbPath, tok)) + score[(int)id] = score.TryGetValue((int)id, out var n) ? n + 1 : 1; + + suggestions = score.OrderByDescending(kv => kv.Value) + .Select(kv => sdById.TryGetValue(kv.Key, out var s) ? s : null) + .Where(s => s is not null).Take(10) + .Select(s => new { expression = s!.Expression, keyword = s.Keyword, location = $"{s.FilePath}:{s.LineStart}", sharedTerms = score[s.Id] }); } return Serialize(new { status, text, matchCount = matches.Count, matches, suggestions }); @@ -369,6 +376,14 @@ private static int LimitArg(JsonElement args, int def) => args.ValueKind == JsonValueKind.Object && args.TryGetProperty("limit", out var l) && l.ValueKind == JsonValueKind.Number ? Math.Clamp(l.GetInt32(), 1, MaxRows) : def; + /// Distinct alphanumeric tokens (length > 2) from a phrase — the terms worth OR-searching for near-matches. + private static IEnumerable SalientTokens(string text) + => (text ?? string.Empty) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Select(t => new string(t.Where(char.IsLetterOrDigit).ToArray())) + .Where(t => t.Length > 2) + .Distinct(StringComparer.OrdinalIgnoreCase); + // ---- JSON-RPC plumbing ------------------------------------------------------------------------- private static string Result(object? id, object result) => Serialize(new { jsonrpc = "2.0", id, result }); diff --git a/tests/CodeMap.Tests/McpServerTests.cs b/tests/CodeMap.Tests/McpServerTests.cs index 951e6a9..7f64b6c 100644 --- a/tests/CodeMap.Tests/McpServerTests.cs +++ b/tests/CodeMap.Tests/McpServerTests.cs @@ -144,6 +144,17 @@ public void Resolve_step_returns_none_when_nothing_binds() Assert.Equal(0, r.GetProperty("matchCount").GetInt32()); } + [Fact] + public void Resolve_step_suggests_the_closest_existing_step_for_a_near_miss() + { + // "the customer checks in" binds nothing, but shares "customer"/"checks" with the existing + // "the customer checks out" — so the ranked suggestions must surface it (reuse-first). + var r = ToolCall("resolve_step", """{"text":"the customer checks in"}"""); + Assert.Equal("none", r.GetProperty("status").GetString()); + var suggested = r.GetProperty("suggestions").EnumerateArray().Select(s => s.GetProperty("expression").GetString()); + Assert.Contains("the customer checks out", suggested); + } + [Fact] public void Unbound_steps_lists_the_deliberately_unbound_step() { From 2dbd79722e41fbbf5e54470590e58e1575464bee Mon Sep 17 00:00:00 2001 From: Karzone Date: Wed, 29 Jul 2026 19:24:16 +0100 Subject: [PATCH 3/7] feat(mcp): add get_scenario, get_step_definition, list_tags get_scenario: full scenario detail (feature, tags, kind, ordered steps) by name. get_step_definition: step-def detail + the scenarios that bind it, by expression. list_tags: tag taxonomy with per-tag scenario counts, most-used first. Read-only; existing tools untouched. +3 tests (245 total, all green). --- src/CodeMap.Mcp/McpServer.cs | 127 ++++++++++++++++++++++++++ tests/CodeMap.Tests/McpServerTests.cs | 34 +++++++ 2 files changed, 161 insertions(+) diff --git a/src/CodeMap.Mcp/McpServer.cs b/src/CodeMap.Mcp/McpServer.cs index 05002ac..7c663a1 100644 --- a/src/CodeMap.Mcp/McpServer.cs +++ b/src/CodeMap.Mcp/McpServer.cs @@ -153,6 +153,42 @@ private string HandleToolCall(object? id, JsonElement root) "definitions are missing across the suite.", new { type = "object", properties = new { limit = new { type = "integer", description = "Max rows (default 50)." } } }, UnboundSteps), + + new("get_scenario", + "Full detail of scenario(s) whose name contains the given text: feature, tags, kind, example-row count, file:line, " + + "and the ordered steps (keyword + text + doc-string/data-table flags). Use to read an existing scenario before writing a similar one.", + new + { + type = "object", + properties = new + { + name = new { type = "string", description = "Substring of the scenario name to match (case-insensitive)." }, + limit = new { type = "integer", description = "Max scenarios (default 10)." }, + }, + required = new[] { "name" }, + }, + GetScenario), + + new("get_step_definition", + "Full detail of step definition(s) whose expression contains the given text: keyword, expression kind, method parameters, " + + "C# class/method/signature, file:line, and the scenarios that currently use it (usage count). Use to inspect a step before reusing or changing it.", + new + { + type = "object", + properties = new + { + query = new { type = "string", description = "Substring of the step-definition expression to match (case-insensitive)." }, + limit = new { type = "integer", description = "Max definitions (default 20)." }, + }, + required = new[] { "query" }, + }, + GetStepDefinition), + + new("list_tags", + "The tag taxonomy across the suite — every scenario tag (e.g. @smoke, @regression, ticket ids) with the number of scenarios " + + "carrying it, most-used first. Use to tag new scenarios consistently with what already exists.", + new { type = "object", properties = new { limit = new { type = "integer", description = "Max tags (default 200)." } } }, + ListTags), }; private string Stats() @@ -357,6 +393,97 @@ private string UnboundSteps(JsonElement args) return Serialize(new { count = all.Count, truncated = all.Count > limit ? all.Count - limit : 0, steps = rows }); } + private string GetScenario(JsonElement args) + { + var name = Arg(args, "name"); + if (string.IsNullOrWhiteSpace(name)) return Serialize(new { error = "get_scenario requires 'name'." }); + var limit = LimitArg(args, 10); + + var featureById = _doc.Features.ToDictionary(f => f.Id); + var stepsByScenario = _doc.ScenarioSteps.GroupBy(s => s.ScenarioId) + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.Ordinal).ToList()); + + var matches = _doc.Scenarios + .Where(s => s.Name.Contains(name, StringComparison.OrdinalIgnoreCase)) + .OrderBy(s => s.FilePath, StringComparer.Ordinal).ThenBy(s => s.LineStart) + .Take(limit) + .Select(s => new + { + scenario = s.Name, + feature = featureById.TryGetValue(s.FeatureId, out var f) ? f.Name : null, + kind = s.Kind, + tags = string.IsNullOrEmpty(s.Tags) ? null : s.Tags, + exampleRowCount = s.ExampleRowCount, + location = $"{s.FilePath}:{s.LineStart}", + steps = (stepsByScenario.TryGetValue(s.Id, out var st) ? st : new List()) + .Select(x => new { keyword = x.Keyword, text = x.Text, hasDocString = x.HasDocString, hasDataTable = x.HasDataTable }), + }) + .ToList(); + + return Serialize(new { count = matches.Count, scenarios = matches }); + } + + private string GetStepDefinition(JsonElement args) + { + var query = Arg(args, "query"); + if (string.IsNullOrWhiteSpace(query)) return Serialize(new { error = "get_step_definition requires 'query'." }); + var limit = LimitArg(args, 20); + + // step_definition id -> the scenario names that bind it (via binds_to edges: scenario_step -> step_definition). + var scenarioByStep = _doc.ScenarioSteps.ToDictionary(s => s.Id, s => s.ScenarioId); + var scenarioNameById = _doc.Scenarios.ToDictionary(s => s.Id, s => s.Name); + var scenariosByDef = _doc.Edges + .Where(e => e.EdgeKind == EdgeKinds.BindsTo && e.ToKind == RefKinds.StepDefinition && e.ToId is not null) + .GroupBy(e => e.ToId!.Value) + .ToDictionary(g => g.Key, g => g + .Select(e => scenarioByStep.TryGetValue(e.FromId, out var sc) ? sc : (int?)null) + .Where(x => x is not null).Select(x => x!.Value).Distinct().ToList()); + + var matches = _doc.StepDefinitions + .Where(s => s.Expression.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(limit) + .Select(s => + { + var scenarioIds = scenariosByDef.TryGetValue(s.Id, out var l) ? l : new List(); + var method = _doc.Methods.FirstOrDefault(m => m.Id == s.MethodId); + return new + { + expression = s.Expression, + keyword = s.Keyword, + expressionKind = s.ExpressionKind, + methodParameters = s.Parameters, + @class = _doc.Classes.FirstOrDefault(c => c.Id == s.ClassId)?.Name, + method = method?.Name, + signature = method?.Signature, + location = $"{s.FilePath}:{s.LineStart}", + usageCount = scenarioIds.Count, + usedByScenarios = scenarioIds.Select(id => scenarioNameById.TryGetValue(id, out var n) ? n : null) + .Where(n => n is not null).Take(25), + }; + }) + .ToList(); + + return Serialize(new { count = matches.Count, stepDefinitions = matches }); + } + + private string ListTags(JsonElement args) + { + var limit = LimitArg(args, MaxRows); + // Scenario tags are own + inherited (the model already folds in feature tags), so counting + // scenarios per tag gives the true reach without double-counting the feature. + var counts = new Dictionary(StringComparer.Ordinal); + foreach (var s in _doc.Scenarios) + { + if (string.IsNullOrWhiteSpace(s.Tags)) continue; + foreach (var tag in s.Tags.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + counts[tag] = counts.TryGetValue(tag, out var n) ? n + 1 : 1; + } + + var rows = counts.OrderByDescending(kv => kv.Value).ThenBy(kv => kv.Key, StringComparer.Ordinal) + .Take(limit).Select(kv => new { tag = kv.Key, scenarios = kv.Value }); + return Serialize(new { total = counts.Count, tags = rows }); + } + private static BindingKeyword ParseBindingKeyword(string? k) => (k ?? string.Empty).Trim().ToLowerInvariant() switch { "given" => BindingKeyword.Given, diff --git a/tests/CodeMap.Tests/McpServerTests.cs b/tests/CodeMap.Tests/McpServerTests.cs index 7f64b6c..a3ec139 100644 --- a/tests/CodeMap.Tests/McpServerTests.cs +++ b/tests/CodeMap.Tests/McpServerTests.cs @@ -167,6 +167,40 @@ public void Unbound_steps_lists_the_deliberately_unbound_step() Assert.Equal("Login", pig.GetProperty("feature").GetString()); } + [Fact] + public void Get_scenario_returns_the_ordered_steps_and_feature() + { + var r = ToolCall("get_scenario", """{"name":"Successful sign in"}"""); + Assert.Equal(1, r.GetProperty("count").GetInt32()); + var sc = r.GetProperty("scenarios")[0]; + Assert.Equal("Login", sc.GetProperty("feature").GetString()); + var steps = sc.GetProperty("steps").EnumerateArray().Select(s => s.GetProperty("text").GetString()).ToList(); + Assert.Equal(4, steps.Count); // Given/When/Then/And + Assert.Contains("the dashboard is shown", steps); + Assert.Contains("pigs can fly", steps); + } + + [Fact] + public void Get_step_definition_returns_detail_and_the_scenarios_that_use_it() + { + var r = ToolCall("get_step_definition", """{"query":"dashboard"}"""); + Assert.Equal(1, r.GetProperty("count").GetInt32()); + var d = r.GetProperty("stepDefinitions")[0]; + Assert.Equal("the dashboard is shown", d.GetProperty("expression").GetString()); + Assert.Equal("LoginSteps", d.GetProperty("class").GetString()); + var users = d.GetProperty("usedByScenarios").EnumerateArray().Select(s => s.GetString()); + Assert.Contains("Successful sign in", users); + } + + [Fact] + public void List_tags_counts_the_smoke_tag() + { + var r = ToolCall("list_tags"); + var smoke = r.GetProperty("tags").EnumerateArray().SingleOrDefault(t => t.GetProperty("tag").GetString() == "@smoke"); + Assert.Equal(JsonValueKind.Object, smoke.ValueKind); + Assert.True(smoke.GetProperty("scenarios").GetInt32() >= 1); + } + [Fact] public void A_notification_without_an_id_gets_no_response() => Assert.Null(_server.HandleLine("""{"jsonrpc":"2.0","method":"notifications/initialized"}""")); From 7699df799dcf3768c9dbfa16b7d2711e67959ad9 Mon Sep 17 00:00:00 2001 From: Karzone Date: Wed, 29 Jul 2026 19:28:45 +0100 Subject: [PATCH 4/7] feat(mcp): add step_catalog, coverage_gaps, project_dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit step_catalog: reusable step vocabulary with placeholder/allowed-value extraction (cucumber {type}, regex (a|b) enums, free params) — compose scenarios from what exists. coverage_gaps: untested endpoints (0 scenario reach) + unused step definitions. project_dependencies: cross-project dependency graph (depends-on/depended-on-by), derived like the CLI map — answers 'what depends on ?' over MCP. Read-only; existing tools untouched. +3 tests (248 total, all green). --- src/CodeMap.Mcp/McpServer.cs | 173 ++++++++++++++++++++++++++ tests/CodeMap.Tests/McpServerTests.cs | 33 +++++ 2 files changed, 206 insertions(+) diff --git a/src/CodeMap.Mcp/McpServer.cs b/src/CodeMap.Mcp/McpServer.cs index 7c663a1..07208c7 100644 --- a/src/CodeMap.Mcp/McpServer.cs +++ b/src/CodeMap.Mcp/McpServer.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using TestAtlas.Core.Analysis; using TestAtlas.Core.Binding; using TestAtlas.Core.Model; @@ -189,6 +190,43 @@ private string HandleToolCall(object? id, JsonElement root) "carrying it, most-used first. Use to tag new scenarios consistently with what already exists.", new { type = "object", properties = new { limit = new { type = "integer", description = "Max tags (default 200)." } } }, ListTags), + + new("step_catalog", + "The reusable step vocabulary: step definitions with their placeholders and (best-effort) allowed values pulled from the " + + "expression — cucumber {int}/{string}/{word}, regex alternations like (Auto|Allianz) as enum values, other groups as free " + + "parameters. Use to compose new scenarios from steps and values that already exist. Optional keyword/query filters.", + new + { + type = "object", + properties = new + { + keyword = new { type = "string", @enum = new[] { "given", "when", "then", "stepdefinition" }, description = "Optional: only steps declared with this attribute keyword." }, + query = new { type = "string", description = "Optional: only steps whose expression contains this text." }, + limit = new { type = "integer", description = "Max steps (default 100)." }, + }, + }, + StepCatalog), + + new("coverage_gaps", + "Where the suite has holes: HTTP endpoints with zero scenario reach (untested), and step definitions that no scenario binds " + + "(dead glue). Use to decide what to automate next, or to prune. Counts are exact; lists are capped by 'limit'.", + new { type = "object", properties = new { limit = new { type = "integer", description = "Max rows per category (default 50)." } } }, + CoverageGaps), + + new("project_dependencies", + "The project dependency graph the suite implies: for each project, which projects it depends on and which depend on it, " + + "derived from cross-project binds_to/uses_type/inherits edges (edge counts as weight). Answers e.g. \"what depends on the " + + "Party project?\". Optional 'project' name filter.", + new + { + type = "object", + properties = new + { + project = new { type = "string", description = "Optional: substring of a project name to focus on (case-insensitive)." }, + limit = new { type = "integer", description = "Max projects (default 200)." }, + }, + }, + ProjectDependencies), }; private string Stats() @@ -484,6 +522,141 @@ private string ListTags(JsonElement args) return Serialize(new { total = counts.Count, tags = rows }); } + private string StepCatalog(JsonElement args) + { + var limit = LimitArg(args, 100); + var keyword = Arg(args, "keyword"); + var query = Arg(args, "query"); + + var q = _doc.StepDefinitions.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(keyword)) + q = q.Where(s => string.Equals(s.Keyword, keyword, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(query)) + q = q.Where(s => s.Expression.Contains(query, StringComparison.OrdinalIgnoreCase)); + + var all = q.ToList(); + var rows = all.Take(limit).Select(s => new + { + expression = s.Expression, + keyword = s.Keyword, + expressionKind = s.ExpressionKind, + methodParameters = s.Parameters, + placeholders = ExtractPlaceholders(s.Expression, s.ExpressionKind), + @class = _doc.Classes.FirstOrDefault(c => c.Id == s.ClassId)?.Name, + location = $"{s.FilePath}:{s.LineStart}", + }); + return Serialize(new { total = all.Count, count = Math.Min(all.Count, limit), steps = rows }); + } + + private string CoverageGaps(JsonElement args) + { + var limit = LimitArg(args, 50); + + var reach = ImpactAnalyzer.EndpointReachAll(_doc); + bool Untested(EndpointRow e) => !(reach.TryGetValue(e.Id, out var r) && r.ScenarioIds.Count > 0); + var untested = _doc.Endpoints.Where(Untested).OrderBy(e => e.Route, StringComparer.Ordinal).ToList(); + + var boundDefIds = _doc.Edges + .Where(e => e.EdgeKind == EdgeKinds.BindsTo && e.ToKind == RefKinds.StepDefinition && e.ToId is not null) + .Select(e => e.ToId!.Value).ToHashSet(); + var unused = _doc.StepDefinitions.Where(s => !boundDefIds.Contains(s.Id)).ToList(); + + return Serialize(new + { + untestedEndpointCount = untested.Count, + untestedEndpoints = untested.Take(limit).Select(e => new { verb = e.Verb, route = e.Path ?? e.Route }), + unusedStepDefinitionCount = unused.Count, + unusedStepDefinitions = unused.Take(limit).Select(s => new + { + expression = s.Expression, + keyword = s.Keyword, + @class = _doc.Classes.FirstOrDefault(c => c.Id == s.ClassId)?.Name, + location = $"{s.FilePath}:{s.LineStart}", + }), + }); + } + + private string ProjectDependencies(JsonElement args) + { + var filter = Arg(args, "project"); + var limit = LimitArg(args, MaxRows); + + // Resolve each edge endpoint to its owning project, then aggregate cross-project edges into a + // weighted project graph — the same derivation the CLI `map` (ProjectMapBuilder) uses. + var stepProj = _doc.ScenarioSteps.ToDictionary(s => s.Id, s => s.ProjectId); + var stepDefProj = _doc.StepDefinitions.ToDictionary(s => s.Id, s => s.ProjectId); + var methodProj = _doc.Methods.ToDictionary(m => m.Id, m => m.ProjectId); + var classProj = _doc.Classes.ToDictionary(c => c.Id, c => c.ProjectId); + int? ProjectOf(string kind, int? id) => id is not int i ? null : kind switch + { + RefKinds.ScenarioStep => stepProj.TryGetValue(i, out var p) ? p : null, + RefKinds.StepDefinition => stepDefProj.TryGetValue(i, out var p) ? p : null, + RefKinds.Method => methodProj.TryGetValue(i, out var p) ? p : null, + RefKinds.Class => classProj.TryGetValue(i, out var p) ? p : null, + RefKinds.Project => i, + _ => null, + }; + + var weight = new Dictionary<(int From, int To), int>(); + foreach (var e in _doc.Edges) + { + if (e.EdgeKind == EdgeKinds.Unbound) continue; + if (ProjectOf(e.FromKind, e.FromId) is not int a || ProjectOf(e.ToKind, e.ToId) is not int b || a == b) continue; + weight[(a, b)] = weight.TryGetValue((a, b), out var w) ? w + 1 : 1; + } + + var nameById = _doc.Projects.ToDictionary(p => p.Id, p => p.Name); + var projects = _doc.Projects.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(filter)) + projects = projects.Where(p => p.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)); + + var rows = projects.OrderBy(p => p.Name, StringComparer.Ordinal).Take(limit).Select(p => new + { + project = p.Name, + dependsOn = weight.Where(kv => kv.Key.From == p.Id).OrderByDescending(kv => kv.Value) + .Select(kv => new { project = nameById.TryGetValue(kv.Key.To, out var n) ? n : null, edges = kv.Value }), + dependedOnBy = weight.Where(kv => kv.Key.To == p.Id).OrderByDescending(kv => kv.Value) + .Select(kv => new { project = nameById.TryGetValue(kv.Key.From, out var n) ? n : null, edges = kv.Value }), + }).ToList(); + + return Serialize(new { count = rows.Count, projects = rows }); + } + + /// + /// Best-effort extraction of a step expression's parameters: cucumber {type} placeholders, + /// regex alternation groups (a|b|c) as enum values, and any other capture group as a free + /// parameter. Degrades to an empty list rather than throwing on anything it doesn't recognise. + /// + private static IReadOnlyList ExtractPlaceholders(string? expression, string expressionKind) + { + var list = new List(); + if (string.IsNullOrEmpty(expression)) return list; + + if (expressionKind == ExpressionKinds.CucumberExpression) + { + foreach (Match m in Regex.Matches(expression, @"\{([^}]*)\}")) + { + var type = m.Groups[1].Value; + list.Add(new { kind = "typed", type = string.IsNullOrEmpty(type) ? "any" : type }); + } + return list; + } + + // regex: scan non-nested capture groups. + foreach (Match m in Regex.Matches(expression, @"\(([^()]*)\)")) + { + var inner = m.Groups[1].Value; + if (inner.StartsWith("?:", StringComparison.Ordinal)) inner = inner.Substring(2); + if (inner.Length == 0) continue; + var alts = inner.Split('|'); + if (alts.Length > 1 && alts.All(a => a.Length > 0 && Regex.IsMatch(a, @"^[\w .\-]+$"))) + list.Add(new { kind = "enum", values = alts }); + else + list.Add(new { kind = "free", pattern = "(" + inner + ")" }); + } + return list; + } + private static BindingKeyword ParseBindingKeyword(string? k) => (k ?? string.Empty).Trim().ToLowerInvariant() switch { "given" => BindingKeyword.Given, diff --git a/tests/CodeMap.Tests/McpServerTests.cs b/tests/CodeMap.Tests/McpServerTests.cs index a3ec139..f3fa971 100644 --- a/tests/CodeMap.Tests/McpServerTests.cs +++ b/tests/CodeMap.Tests/McpServerTests.cs @@ -201,6 +201,39 @@ public void List_tags_counts_the_smoke_tag() Assert.True(smoke.GetProperty("scenarios").GetInt32() >= 1); } + [Fact] + public void Step_catalog_extracts_a_cucumber_typed_placeholder() + { + var r = ToolCall("step_catalog", """{"query":"cart"}"""); + var step = r.GetProperty("steps").EnumerateArray() + .Single(s => s.GetProperty("expression").GetString() == "a cart with {int} item(s)"); + var ph = step.GetProperty("placeholders").EnumerateArray().First(); + Assert.Equal("typed", ph.GetProperty("kind").GetString()); + Assert.Equal("int", ph.GetProperty("type").GetString()); + } + + [Fact] + public void Coverage_gaps_reports_consistent_counts_and_lists() + { + var r = ToolCall("coverage_gaps"); + // Counts are exact; the returned lists are capped but consistent when under the cap. + Assert.True(r.GetProperty("untestedEndpointCount").GetInt32() >= 0); + Assert.True(r.GetProperty("unusedStepDefinitionCount").GetInt32() >= 0); + Assert.True(r.GetProperty("untestedEndpoints").GetArrayLength() <= r.GetProperty("untestedEndpointCount").GetInt32()); + Assert.True(r.GetProperty("unusedStepDefinitions").GetArrayLength() <= r.GetProperty("unusedStepDefinitionCount").GetInt32()); + } + + [Fact] + public void Project_dependencies_shows_the_cross_project_edge() + { + // The SpecFlow feature's "the customer checks out" binds to the Reqnroll CheckoutSteps — + // so Fixture.SpecFlow depends on Fixture.Reqnroll. + var r = ToolCall("project_dependencies", """{"project":"SpecFlow"}"""); + var sf = r.GetProperty("projects").EnumerateArray().Single(p => p.GetProperty("project").GetString() == "Fixture.SpecFlow"); + var deps = sf.GetProperty("dependsOn").EnumerateArray().Select(d => d.GetProperty("project").GetString()); + Assert.Contains("Fixture.Reqnroll", deps); + } + [Fact] public void A_notification_without_an_id_gets_no_response() => Assert.Null(_server.HandleLine("""{"jsonrpc":"2.0","method":"notifications/initialized"}""")); From 95091f4a2bc286f8034a5b2bb17ca20121a9aac6 Mon Sep 17 00:00:00 2001 From: Karzone Date: Wed, 29 Jul 2026 19:30:52 +0100 Subject: [PATCH 5/7] docs+test: document all 8 authoring tools in README; assert full tool surface advertised --- README.md | 8 +++++++- tests/CodeMap.Tests/McpServerTests.cs | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index adcac41..5d03862 100644 --- a/README.md +++ b/README.md @@ -315,8 +315,14 @@ claude mcp add testatlas -- src/CodeMap.Mcp/bin/Release/net8.0/TestAtlas.Mcp.exe - `search_steps` — full-text search over step definitions (expression text + method + class name). - `search_scenarios` — full-text search over scenarios (feature + scenario name + step text + tags). - `list_endpoints` — the HTTP endpoints the suite calls, each with verb, route, and scenario blast radius (highest-reach first). -- `resolve_step` — resolve a Gherkin phrase to the existing step definition(s) that would bind it (regex/cucumber, keyword-agnostic). `exact` / `ambiguous` / `none` (+ near-match suggestions). Reuse-first authoring: don't write a step that already exists. +- `resolve_step` — resolve a Gherkin phrase to the existing step definition(s) that would bind it (regex/cucumber, keyword-agnostic). `exact` / `ambiguous` / `none` (+ near-match suggestions ranked by shared terms). Reuse-first authoring: don't write a step that already exists. - `unbound_steps` — scenario steps that match no step definition — the glue an agent must implement, each with its scenario, feature, and file:line. +- `get_scenario` — full detail of scenario(s) by name: feature, tags, kind, example-row count, and the ordered steps. +- `get_step_definition` — full detail of step definition(s) by expression: keyword, params, C# class/method/signature, and the scenarios that use it. +- `step_catalog` — the reusable step vocabulary with extracted placeholders and allowed values (cucumber `{type}`, regex `(a|b)` enums). Compose scenarios from what exists. +- `coverage_gaps` — untested endpoints (zero scenario reach) and unused step definitions (dead glue). +- `list_tags` — the tag taxonomy with per-tag scenario counts, most-used first — tag new scenarios consistently. +- `project_dependencies` — the implied project dependency graph (depends-on / depended-on-by), e.g. *"what depends on the Party project?"*. > [!IMPORTANT] > MCP clients load servers **at session start**. If you register the server mid-session, restart diff --git a/tests/CodeMap.Tests/McpServerTests.cs b/tests/CodeMap.Tests/McpServerTests.cs index f3fa971..91f8111 100644 --- a/tests/CodeMap.Tests/McpServerTests.cs +++ b/tests/CodeMap.Tests/McpServerTests.cs @@ -99,7 +99,11 @@ public void New_authoring_tools_are_advertised() var res = Call("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}"""); var names = res.GetProperty("result").GetProperty("tools").EnumerateArray() .Select(t => t.GetProperty("name").GetString()).ToHashSet(); - Assert.Superset(new HashSet { "resolve_step", "unbound_steps" }, names); + Assert.Superset(new HashSet + { + "resolve_step", "unbound_steps", "get_scenario", "get_step_definition", + "step_catalog", "coverage_gaps", "list_tags", "project_dependencies", + }, names); } [Fact] From e14cd7478d5db4ba0bc72d350396bbc8ad3be91f Mon Sep 17 00:00:00 2001 From: Karzone Date: Wed, 29 Jul 2026 19:35:06 +0100 Subject: [PATCH 6/7] harden: test step_catalog enum extraction; add coverage_gaps denominators - durable test for the regex-alternation -> enum values path (was eval-only). - coverage_gaps now returns totalEndpoints/totalStepDefinitions so '0 of 0' (no data indexed) is distinct from '0 of 50' (genuinely covered). +1 test (249). --- src/CodeMap.Mcp/McpServer.cs | 4 ++++ tests/CodeMap.Tests/McpServerTests.cs | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/CodeMap.Mcp/McpServer.cs b/src/CodeMap.Mcp/McpServer.cs index 07208c7..c777d1c 100644 --- a/src/CodeMap.Mcp/McpServer.cs +++ b/src/CodeMap.Mcp/McpServer.cs @@ -563,8 +563,12 @@ private string CoverageGaps(JsonElement args) return Serialize(new { + // Denominators so a clean zero is unambiguous: "0 of 0" means the map indexed no endpoints + // (e.g. a pre-v4 map), NOT that everything is covered — distinct from "0 of 50". + totalEndpoints = _doc.Endpoints.Count, untestedEndpointCount = untested.Count, untestedEndpoints = untested.Take(limit).Select(e => new { verb = e.Verb, route = e.Path ?? e.Route }), + totalStepDefinitions = _doc.StepDefinitions.Count, unusedStepDefinitionCount = unused.Count, unusedStepDefinitions = unused.Take(limit).Select(s => new { diff --git a/tests/CodeMap.Tests/McpServerTests.cs b/tests/CodeMap.Tests/McpServerTests.cs index 91f8111..6831098 100644 --- a/tests/CodeMap.Tests/McpServerTests.cs +++ b/tests/CodeMap.Tests/McpServerTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using TestAtlas.Core.Storage; using TestAtlas.Mcp; using Xunit; @@ -216,6 +217,29 @@ public void Step_catalog_extracts_a_cucumber_typed_placeholder() Assert.Equal("int", ph.GetProperty("type").GetString()); } + [Fact] + public void Step_catalog_extracts_regex_alternation_as_enum_values() + { + // Headline feature: a regex alternation surfaces as an enum of its allowed values. Driven + // through an injected map (the fixture has no alternation step) so it stays a durable check. + var doc = new MapDocument + { + StepDefinitions = new[] + { + new StepDefinitionRow(1, 1, 1, 1, "When", + "the user posts a damage with feed '(Auto|Allianz|AllianzVoe)'", "regex", null, "GpmSteps.cs", 10), + }, + }; + var server = new McpServer("unused.db", doc); + var res = JsonDocument.Parse(server.HandleLine( + """{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"step_catalog","arguments":{}}}""")!).RootElement; + var payload = JsonDocument.Parse(res.GetProperty("result").GetProperty("content")[0].GetProperty("text").GetString()!).RootElement; + var enumPh = payload.GetProperty("steps")[0].GetProperty("placeholders").EnumerateArray() + .Single(p => p.GetProperty("kind").GetString() == "enum"); + var values = enumPh.GetProperty("values").EnumerateArray().Select(v => v.GetString()).ToList(); + Assert.Equal(new[] { "Auto", "Allianz", "AllianzVoe" }, values); + } + [Fact] public void Coverage_gaps_reports_consistent_counts_and_lists() { From 633414ca4544fe8282179d4cf8076f8219c4f3df Mon Sep 17 00:00:00 2001 From: Karzone Date: Wed, 29 Jul 2026 19:46:44 +0100 Subject: [PATCH 7/7] =?UTF-8?q?refactor(mcp):=20drop=20unbound=5Fsteps=20a?= =?UTF-8?q?nd=20coverage=5Fgaps=20=E2=80=94=20SpecHygiene=20owns=20step=20?= =?UTF-8?q?hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both detected step-gap/hygiene (unbound scenario steps, unused step definitions), which SpecHygiene already covers authoritatively — avoid a second, map-staleness-prone source of truth. resolve_step retains the per-step authoring check; list_endpoints still exposes per-endpoint scenario reach. Net 6 new tools (11 total). 247 tests green. --- README.md | 2 - src/CodeMap.Mcp/McpServer.cs | 76 --------------------------- tests/CodeMap.Tests/McpServerTests.cs | 31 ++--------- 3 files changed, 4 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 5d03862..7c70680 100644 --- a/README.md +++ b/README.md @@ -316,11 +316,9 @@ claude mcp add testatlas -- src/CodeMap.Mcp/bin/Release/net8.0/TestAtlas.Mcp.exe - `search_scenarios` — full-text search over scenarios (feature + scenario name + step text + tags). - `list_endpoints` — the HTTP endpoints the suite calls, each with verb, route, and scenario blast radius (highest-reach first). - `resolve_step` — resolve a Gherkin phrase to the existing step definition(s) that would bind it (regex/cucumber, keyword-agnostic). `exact` / `ambiguous` / `none` (+ near-match suggestions ranked by shared terms). Reuse-first authoring: don't write a step that already exists. -- `unbound_steps` — scenario steps that match no step definition — the glue an agent must implement, each with its scenario, feature, and file:line. - `get_scenario` — full detail of scenario(s) by name: feature, tags, kind, example-row count, and the ordered steps. - `get_step_definition` — full detail of step definition(s) by expression: keyword, params, C# class/method/signature, and the scenarios that use it. - `step_catalog` — the reusable step vocabulary with extracted placeholders and allowed values (cucumber `{type}`, regex `(a|b)` enums). Compose scenarios from what exists. -- `coverage_gaps` — untested endpoints (zero scenario reach) and unused step definitions (dead glue). - `list_tags` — the tag taxonomy with per-tag scenario counts, most-used first — tag new scenarios consistently. - `project_dependencies` — the implied project dependency graph (depends-on / depended-on-by), e.g. *"what depends on the Party project?"*. diff --git a/src/CodeMap.Mcp/McpServer.cs b/src/CodeMap.Mcp/McpServer.cs index c777d1c..a5aa2b5 100644 --- a/src/CodeMap.Mcp/McpServer.cs +++ b/src/CodeMap.Mcp/McpServer.cs @@ -148,13 +148,6 @@ private string HandleToolCall(object? id, JsonElement root) }, ResolveStep), - new("unbound_steps", - "Scenario steps that match NO step definition (unbound) — the glue an agent must implement before those scenarios can run. " + - "Each row: the step text, its keyword, the owning scenario + feature, and file:line. Use this to see exactly what step " + - "definitions are missing across the suite.", - new { type = "object", properties = new { limit = new { type = "integer", description = "Max rows (default 50)." } } }, - UnboundSteps), - new("get_scenario", "Full detail of scenario(s) whose name contains the given text: feature, tags, kind, example-row count, file:line, " + "and the ordered steps (keyword + text + doc-string/data-table flags). Use to read an existing scenario before writing a similar one.", @@ -207,12 +200,6 @@ private string HandleToolCall(object? id, JsonElement root) }, StepCatalog), - new("coverage_gaps", - "Where the suite has holes: HTTP endpoints with zero scenario reach (untested), and step definitions that no scenario binds " + - "(dead glue). Use to decide what to automate next, or to prune. Counts are exact; lists are capped by 'limit'.", - new { type = "object", properties = new { limit = new { type = "integer", description = "Max rows per category (default 50)." } } }, - CoverageGaps), - new("project_dependencies", "The project dependency graph the suite implies: for each project, which projects it depends on and which depend on it, " + "derived from cross-project binds_to/uses_type/inherits edges (edge counts as weight). Answers e.g. \"what depends on the " + @@ -400,37 +387,6 @@ private string ResolveStep(JsonElement args) return Serialize(new { status, text, matchCount = matches.Count, matches, suggestions }); } - private string UnboundSteps(JsonElement args) - { - var limit = LimitArg(args, 50); - var unboundIds = _doc.Edges - .Where(e => e.EdgeKind == EdgeKinds.Unbound && e.FromKind == RefKinds.ScenarioStep) - .Select(e => e.FromId).ToHashSet(); - - var scenarioById = _doc.Scenarios.ToDictionary(s => s.Id); - var featureById = _doc.Features.ToDictionary(f => f.Id); - - var all = _doc.ScenarioSteps.Where(s => unboundIds.Contains(s.Id)) - .OrderBy(s => s.FilePath, StringComparer.Ordinal).ThenBy(s => s.LineStart) - .ToList(); - - var rows = all.Take(limit).Select(st => - { - var sc = scenarioById.TryGetValue(st.ScenarioId, out var s) ? s : null; - var feature = sc is not null && featureById.TryGetValue(sc.FeatureId, out var f) ? f.Name : null; - return new - { - step = st.Text, - keyword = st.Keyword, - scenario = sc?.Name, - feature, - location = $"{st.FilePath}:{st.LineStart}", - }; - }); - - return Serialize(new { count = all.Count, truncated = all.Count > limit ? all.Count - limit : 0, steps = rows }); - } - private string GetScenario(JsonElement args) { var name = Arg(args, "name"); @@ -548,38 +504,6 @@ private string StepCatalog(JsonElement args) return Serialize(new { total = all.Count, count = Math.Min(all.Count, limit), steps = rows }); } - private string CoverageGaps(JsonElement args) - { - var limit = LimitArg(args, 50); - - var reach = ImpactAnalyzer.EndpointReachAll(_doc); - bool Untested(EndpointRow e) => !(reach.TryGetValue(e.Id, out var r) && r.ScenarioIds.Count > 0); - var untested = _doc.Endpoints.Where(Untested).OrderBy(e => e.Route, StringComparer.Ordinal).ToList(); - - var boundDefIds = _doc.Edges - .Where(e => e.EdgeKind == EdgeKinds.BindsTo && e.ToKind == RefKinds.StepDefinition && e.ToId is not null) - .Select(e => e.ToId!.Value).ToHashSet(); - var unused = _doc.StepDefinitions.Where(s => !boundDefIds.Contains(s.Id)).ToList(); - - return Serialize(new - { - // Denominators so a clean zero is unambiguous: "0 of 0" means the map indexed no endpoints - // (e.g. a pre-v4 map), NOT that everything is covered — distinct from "0 of 50". - totalEndpoints = _doc.Endpoints.Count, - untestedEndpointCount = untested.Count, - untestedEndpoints = untested.Take(limit).Select(e => new { verb = e.Verb, route = e.Path ?? e.Route }), - totalStepDefinitions = _doc.StepDefinitions.Count, - unusedStepDefinitionCount = unused.Count, - unusedStepDefinitions = unused.Take(limit).Select(s => new - { - expression = s.Expression, - keyword = s.Keyword, - @class = _doc.Classes.FirstOrDefault(c => c.Id == s.ClassId)?.Name, - location = $"{s.FilePath}:{s.LineStart}", - }), - }); - } - private string ProjectDependencies(JsonElement args) { var filter = Arg(args, "project"); diff --git a/tests/CodeMap.Tests/McpServerTests.cs b/tests/CodeMap.Tests/McpServerTests.cs index 6831098..3b231f8 100644 --- a/tests/CodeMap.Tests/McpServerTests.cs +++ b/tests/CodeMap.Tests/McpServerTests.cs @@ -102,8 +102,8 @@ public void New_authoring_tools_are_advertised() .Select(t => t.GetProperty("name").GetString()).ToHashSet(); Assert.Superset(new HashSet { - "resolve_step", "unbound_steps", "get_scenario", "get_step_definition", - "step_catalog", "coverage_gaps", "list_tags", "project_dependencies", + "resolve_step", "get_scenario", "get_step_definition", + "step_catalog", "list_tags", "project_dependencies", }, names); } @@ -143,8 +143,8 @@ public void Resolve_step_flags_an_ambiguous_phrase_matching_two_definitions() [Fact] public void Resolve_step_returns_none_when_nothing_binds() { - // "pigs can fly" is the fixture's deliberately-unbound step — no definition matches. - var r = ToolCall("resolve_step", """{"text":"pigs can fly"}"""); + // "the astronaut plants a flag" is not defined anywhere — no definition matches. + var r = ToolCall("resolve_step", """{"text":"the astronaut plants a flag"}"""); Assert.Equal("none", r.GetProperty("status").GetString()); Assert.Equal(0, r.GetProperty("matchCount").GetInt32()); } @@ -160,18 +160,6 @@ public void Resolve_step_suggests_the_closest_existing_step_for_a_near_miss() Assert.Contains("the customer checks out", suggested); } - [Fact] - public void Unbound_steps_lists_the_deliberately_unbound_step() - { - var r = ToolCall("unbound_steps"); - Assert.True(r.GetProperty("count").GetInt32() >= 1); - var texts = r.GetProperty("steps").EnumerateArray().Select(s => s.GetProperty("step").GetString()); - Assert.Contains("pigs can fly", texts); - var pig = r.GetProperty("steps").EnumerateArray().Single(s => s.GetProperty("step").GetString() == "pigs can fly"); - Assert.Equal("Successful sign in", pig.GetProperty("scenario").GetString()); - Assert.Equal("Login", pig.GetProperty("feature").GetString()); - } - [Fact] public void Get_scenario_returns_the_ordered_steps_and_feature() { @@ -240,17 +228,6 @@ public void Step_catalog_extracts_regex_alternation_as_enum_values() Assert.Equal(new[] { "Auto", "Allianz", "AllianzVoe" }, values); } - [Fact] - public void Coverage_gaps_reports_consistent_counts_and_lists() - { - var r = ToolCall("coverage_gaps"); - // Counts are exact; the returned lists are capped but consistent when under the cap. - Assert.True(r.GetProperty("untestedEndpointCount").GetInt32() >= 0); - Assert.True(r.GetProperty("unusedStepDefinitionCount").GetInt32() >= 0); - Assert.True(r.GetProperty("untestedEndpoints").GetArrayLength() <= r.GetProperty("untestedEndpointCount").GetInt32()); - Assert.True(r.GetProperty("unusedStepDefinitions").GetArrayLength() <= r.GetProperty("unusedStepDefinitionCount").GetInt32()); - } - [Fact] public void Project_dependencies_shows_the_cross_project_edge() {