diff --git a/README.md b/README.md index c105c79..7c70680 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,12 @@ 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 ranked by shared terms). Reuse-first authoring: don't write a step that already exists. +- `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. +- `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/src/CodeMap.Mcp/McpServer.cs b/src/CodeMap.Mcp/McpServer.cs index 3506c98..a5aa2b5 100644 --- a/src/CodeMap.Mcp/McpServer.cs +++ b/src/CodeMap.Mcp/McpServer.cs @@ -1,5 +1,8 @@ using System.Text.Json; +using System.Text.RegularExpressions; using TestAtlas.Core.Analysis; +using TestAtlas.Core.Binding; +using TestAtlas.Core.Model; using TestAtlas.Core.Storage; namespace TestAtlas.Mcp; @@ -126,6 +129,91 @@ 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 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 + { + 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("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), + + 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("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() @@ -232,6 +320,298 @@ 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) + // 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, + 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(); + + // Classify by distinct reusable steps: none / exact (reuse it) / ambiguous (a conflict to fix). + var status = matches.Count switch { 0 => "none", 1 => "exact", _ => "ambiguous" }; + + // 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 (matches.Count == 0) + { + 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 }); + } + + 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 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 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, + "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; + + /// 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 c9e5cc0..3b231f8 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; @@ -93,6 +94,151 @@ 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", "get_scenario", "get_step_definition", + "step_catalog", "list_tags", "project_dependencies", + }, 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() + { + // "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()); + } + + [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 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 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 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 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"}"""));