diff --git a/AGENTS.md b/AGENTS.md index f58a13b..069cb7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,11 +38,11 @@ It is deliberately **thin**: Microsoft's `almcp` already compiles, runs diagnost 1. `Program.cs` calls `BcToolsLocator.ResolveAndRegister()` to find the tools directory and register an `AssemblyLoadContext` resolver. This **must** happen before any BC types are JIT-compiled — the DLLs are not in the output directory, so nothing can resolve them before this runs. 2. `McpHost.RunAsync()` is marked `[NoInlining]` to enforce that ordering, then builds the host, registers DI services, and starts the MCP stdio transport. -3. `AlMcpProxyStartup` (an `IHostedService`) launches `almcp` as a child process on a free localhost port and caches its tool list — **on a background task**, so the stdio server and the four native tools are up immediately no matter how long the child takes. `AlMcpProxy.Ready` is the signal everything else waits on: `tools/list` gives it 10s and otherwise answers with the native tools plus a one-shot `notifications/tools/list_changed`; an `al_*` call that arrives early parks on `Ready` instead of failing. +3. `AlMcpProxyStartup` (an `IHostedService`) launches `almcp` as a child process on a free localhost port and caches its tool list — **on a background task**, so the stdio server and the five native tools are up immediately no matter how long the child takes. `AlMcpProxy.Ready` is the signal everything else waits on: `tools/list` gives it 10s and otherwise answers with the native tools plus a one-shot `notifications/tools/list_changed`; an `al_*` call that arrives early parks on `Ready` instead of failing. ### Key layers -- **Tools/** — MCP tool endpoints, annotated `[McpServerToolType]` / `[McpServerTool]`, auto-discovered via `WithToolsFromAssembly()`. Four native tools: `list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`. The proxied `al_*` tools are served by the dynamic list/call handlers in `McpHost`, not by classes here. +- **Tools/** — MCP tool endpoints, annotated `[McpServerToolType]` / `[McpServerTool]`, auto-discovered via `WithToolsFromAssembly()`. Five native tools: `list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`, `analyze`. The proxied `al_*` tools are served by the dynamic list/call handlers in `McpHost`, not by classes here. - **Services/**, all singletons registered in `McpHost`: - `BcToolsLocator` — the single runtime lookup. Finds the one directory holding both `Microsoft.Dynamics.Nav.*.dll` and `almcp` (they ship side by side). Probe order: `--devtools-path` → `BCDEVELOPMENTTOOLSPATH` → dotnet tool store → hard error naming every probe and the install command. The AL VS Code extension is no longer probed; the dotnet tool store is the primary channel on every OS. No auto-install by design. `AlMcpLaunch` describes how to start the child: native `almcp.exe` on Windows, `dotnet almcp.dll` on Linux/macOS (the nupkg ships no extension-less launcher). - `WorkspaceStartupResolver` — discovers AL projects (mirrors `almcp`'s own `DiscoverProjectPaths`: downward scan for `app.json`, depth 4, standard exclusions) and composes the child `almcp`'s `--projects` / `--codeanalyzers` / `--rulesetpath` / `--packagecachepath` args. `almcp` in MCP mode never reads `.vscode/settings.json` and has no per-call analyzer, ruleset or package-cache parameter, so this bridge at launch is the only thing keeping `al_compile` and our fix tools in agreement (`ProjectLoader` reads the same `al.packageCachePath` for the in-process compilation). @@ -66,8 +66,10 @@ When passing analyzers to the child `almcp`, their sibling dependencies must tra - All tool methods are `static async Task`, receiving DI services as parameters. - Tools return JSON-serialized results. Errors are caught and returned as `{ error, message }` JSON, not thrown. - `apply_fix` writes to disk and reloads the project session; `apply_fix_all` does the same across every occurrence of a rule (unless `dryRun`); the other two are read-only. -- `al_compile` defaults to `onlyErrors: true` while nearly every ALCops rule is a warning — callers must pass `onlyErrors: false`. This is documented rather than patched, because `ForwardAsync` stays a generic passthrough. -- After `apply_fix` / `apply_fix_all`, verify with `al_compile` (`onlyErrors: false`), not `al_getdiagnostics`. almcp's `ProjectWatcher` (`FileSystemWatcher`) re-reads changed `.al` files, and `al_compile` awaits `WaitForProcessingAsync` before compiling, so it normally picks up on-disk changes before the compile starts. The gate starts signalled and has no debounce, so on slow file systems or right after a large `apply_fix_all` a second `al_compile` may be needed if the watcher has not yet delivered the change notification. `al_getdiagnostics` returns cached compilation results without re-analyzing and will report stale diagnostics. `al_build` does not await the watcher at all. +- `al_compile` defaults to `onlyErrors: true` while nearly every ALCops rule is a warning — callers must pass `options.onlyErrors: false` (the flag lives inside almcp's `options` object; a top-level `onlyErrors` is ignored, and omitting `options` entirely also yields `onlyErrors: false` with no diagnostics cap). This is documented rather than patched, because `ForwardAsync` stays a generic passthrough. +- After `apply_fix` / `apply_fix_all`, verify with `analyze` (preferred) or `al_compile` (`options.onlyErrors: false`), not `al_getdiagnostics`. almcp's `ProjectWatcher` (`FileSystemWatcher`) re-reads changed `.al` files, and `al_compile` awaits `WaitForProcessingAsync` before compiling, so it normally picks up on-disk changes before the compile starts. The gate starts signalled and has no debounce, so on slow file systems or right after a large `apply_fix_all` a second `al_compile` may be needed if the watcher has not yet delivered the change notification. `al_getdiagnostics` returns cached compilation results without re-analyzing and will report stale diagnostics. `al_build` does not await the watcher at all. +- `analyze` is the one native tool that goes through `AlMcpProxy.ForwardAsync` (`al_compile` with nested `options.onlyErrors=false`, `enableCodeAnalysis=true`, `maxDiagnosticsPerCompilation=int.MaxValue`, never `codeAnalyzers`); it resolves the proxy via `IServiceProvider` because under `--no-proxy` the type is unregistered and the SDK would otherwise expose it as a tool argument; it returns `{error:"ProxyUnavailable"}` in that case. +- `analyze` inherits the FileSystemWatcher staleness caveat after `apply_fix` / `apply_fix_all`. ## Conventions diff --git a/README.md b/README.md index c1e8657..568a065 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Add to your `.mcp.json` (Claude Code) or `claude_desktop_config.json` (Claude De | Linux | `dotnet almcp.dll` | The nupkg has no extension-less launcher; the server falls back to the dotnet host automatically. | | macOS | `dotnet almcp.dll` | Same as Linux. | -The native tools (`list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`) work on every OS regardless of `almcp` availability. +The native tools (`list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`) work on every OS regardless of `almcp` availability. `analyze` wraps `al_compile` and therefore needs `almcp`. ## Tools @@ -55,6 +55,7 @@ The native tools (`list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`) work | `get_fixes` | Get available code fixes for a specific diagnostic at a location. | | `apply_fix` | Apply a code fix to resolve a diagnostic. Writes the fixed content directly to the file on disk. | | `apply_fix_all` | Apply a code fix to every occurrence of a diagnostic rule across a project or a single file (like VS Code's "Fix all in workspace"). Writes to disk unless `dryRun` is set. | +| `analyze` | Compile with all configured analyzers and return structured cop + compiler diagnostics (analyzer, hasFix, filters, summary). Wraps `al_compile` with `onlyErrors: false`; needs `almcp`. | ### Proxied from Microsoft's `almcp` @@ -64,11 +65,11 @@ The native tools (`list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`) work Pass `--no-proxy` to serve only the native tools. Use it when your agent already registers Microsoft's `almcp` itself, so the `al_*` tools don't show up twice. -> **`al_compile` defaults to `onlyErrors: true`.** Nearly every ALCops rule is a *warning*, so pass `onlyErrors: false` or you will see no cop diagnostics at all. +> **`al_compile` defaults to `onlyErrors: true`.** Nearly every ALCops rule is a *warning*, so pass `options: { onlyErrors: false }` or you will see no cop diagnostics at all. The flag lives inside the `options` object; a top-level `onlyErrors` is ignored. The native `analyze` tool does this for you and adds filtering, sorting and `hasFix` metadata. ### Verifying a fix -After `apply_fix` or `apply_fix_all`, use `al_compile` with `onlyErrors: false` to confirm the diagnostic is gone. `al_compile` awaits almcp's internal file watcher, which normally sees the write before the compile starts. The watcher gate is not debounced, so on slow file systems or right after a large `apply_fix_all` a second `al_compile` may be needed. Do **not** use `al_getdiagnostics` for this: it returns cached compilation results rather than re-analyzing, and will report stale (or empty) diagnostics. `al_build` does not await the watcher. Only restarting the server gives a fully fresh almcp workspace. +After `apply_fix` or `apply_fix_all`, call `analyze` (preferred) or `al_compile` with `options.onlyErrors: false` to confirm the diagnostic is gone. Both await almcp's internal file watcher, which normally sees the write before the compile starts. The watcher gate is not debounced, so on slow file systems or right after a large `apply_fix_all` a second call may be needed. Do **not** use `al_getdiagnostics` for this: it returns cached compilation results rather than re-analyzing, and will report stale (or empty) diagnostics. `al_build` does not await the watcher. Only restarting the server gives a fully fresh almcp workspace. ## Analyzers diff --git a/src/ALCops.Mcp/Models/AnalyzeResult.cs b/src/ALCops.Mcp/Models/AnalyzeResult.cs new file mode 100644 index 0000000..8deb836 --- /dev/null +++ b/src/ALCops.Mcp/Models/AnalyzeResult.cs @@ -0,0 +1,24 @@ +namespace ALCops.Mcp.Models; + +public record AnalyzeDiagnostic( + string? FilePath, + int? Line, + int? Column, + string Id, + string Severity, + string Message, + string Analyzer, + bool HasFix); + +public record AnalyzeSummary( + IReadOnlyDictionary BySeverity, + IReadOnlyDictionary ByAnalyzer); + +public record AnalyzeResult( + string Project, + int Count, + int TotalCount, + bool Truncated, + AnalyzeSummary Summary, + IReadOnlyList Diagnostics, + IReadOnlyList? Warnings); diff --git a/src/ALCops.Mcp/README.md b/src/ALCops.Mcp/README.md index 586969c..d2af9e7 100644 --- a/src/ALCops.Mcp/README.md +++ b/src/ALCops.Mcp/README.md @@ -27,11 +27,11 @@ Add to your `.mcp.json` (Claude Code) or `claude_desktop_config.json` (Claude De ## Tools -**Native:** `list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all` — code fixes and rule discovery, which Microsoft's `almcp` does not provide. +**Native:** `list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`, `analyze` — code fixes, rule discovery, and structured diagnostics with filtering. `analyze` wraps `al_compile` (needs `almcp`); the others work without it. **Proxied from `almcp`:** `al_compile`, `al_build`, `al_getdiagnostics`, `al_downloadsymbols`, `al_symbolsearch`, `al_publish`, `al_run_tests` and the rest of the `al_*` set. Pass `--no-proxy` to suppress these when your agent already registers `almcp` itself. -> **`al_compile` defaults to `onlyErrors: true`.** Nearly every ALCops rule is a *warning*, so pass `onlyErrors: false` or you will see no cop diagnostics at all. +> **`al_compile` defaults to `onlyErrors: true`.** Nearly every ALCops rule is a *warning*, so pass `options: { onlyErrors: false }` (the flag lives inside the `options` object; a top-level `onlyErrors` is ignored) or you will see no cop diagnostics at all. The native `analyze` tool does this for you. ## Analyzers diff --git a/src/ALCops.Mcp/Services/CompileDiagnosticsParser.cs b/src/ALCops.Mcp/Services/CompileDiagnosticsParser.cs new file mode 100644 index 0000000..b9150c2 --- /dev/null +++ b/src/ALCops.Mcp/Services/CompileDiagnosticsParser.cs @@ -0,0 +1,303 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using ALCops.Mcp.Models; +using ModelContextProtocol.Protocol; + +namespace ALCops.Mcp.Services; + +public sealed record RawCompileDiagnostic(string Severity, string Code, string? Location, string Description); + +public readonly record struct ParsedLocation(string? Path, int? Line, int? Column); + +public sealed record AnalyzeFilter( + string? FilePath, + string? FolderPath, + string? ProjectPath, + bool IncludeUnlocated, + IReadOnlySet? Severities, + IReadOnlySet? Analyzers, + IReadOnlySet? RuleIds); + +public static partial class CompileDiagnosticsParser +{ + public static (IReadOnlyList Diagnostics, string? Message, bool Succeeded) Parse(CallToolResult result) + { + if (result.StructuredContent is JsonNode structured) + return Parse(structured.ToJsonString()); + + foreach (var block in result.Content.OfType()) + { + var text = block.Text?.Trim(); + if (text is not null && text.StartsWith('{')) + { + try + { + return Parse(text); + } + catch (JsonException) { } + } + } + + var preview = string.Join('\n', result.Content.OfType().Select(b => b.Text)); + if (preview.Length > 200) preview = preview[..200]; + throw new InvalidOperationException($"almcp returned no JSON payload for al_compile: {preview}"); + } + + public static (IReadOnlyList Diagnostics, string? Message, bool Succeeded) Parse(string json) + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var dto = JsonSerializer.Deserialize(json, options); + + var diagnostics = new List(); + if (dto?.Diagnostics is not null) + { + foreach (var d in dto.Diagnostics) + { + if (string.IsNullOrEmpty(d.Code)) + continue; + + var severity = ResolveSeverity(d.Severity); + diagnostics.Add(new RawCompileDiagnostic(severity, d.Code!, d.Location, d.Description ?? "")); + } + } + + var message = string.IsNullOrWhiteSpace(dto?.Message) ? null : dto.Message; + var succeeded = dto?.Succeeded ?? true; + + return (diagnostics, message, succeeded); + } + + public static ParsedLocation ParseLocation(string? location) + { + if (string.IsNullOrWhiteSpace(location)) + return default; + + var match = LocationRegex().Match(location); + if (!match.Success) + return default; + + var path = match.Groups["path"].Value; + if (path.Length == 0) + return default; + + if (!int.TryParse(match.Groups["line"].ValueSpan, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var line)) + return default; + + if (!int.TryParse(match.Groups["col"].ValueSpan, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var column)) + return default; + + return new ParsedLocation(path, line, column); + } + + [GeneratedRegex(@"^(?!None\b)[A-Za-z]+\((?.*)@(?\d+):(?\d+)\)$", RegexOptions.CultureInvariant)] + private static partial Regex LocationRegex(); + + public static IReadOnlyList ExtractWarnings(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + return []; + + var lines = message.Split(["\r\n", "\n"], StringSplitOptions.None); + var result = new List(); + foreach (var rawLine in lines) + { + var trimmed = rawLine.Trim(); + if (trimmed.Length == 0) + continue; + + if (trimmed.StartsWith("[Warning]", StringComparison.Ordinal)) + { + var rest = trimmed["[Warning]".Length..].TrimStart(); + if (rest.Length > 0) + result.Add(rest); + } + else + { + result.Add(trimmed); + } + } + + return result; + } + + public static bool IsCompilerDiagnostic(string code) => CompilerDiagnosticRegex().IsMatch(code); + + [GeneratedRegex(@"^AL\d{4}$", RegexOptions.CultureInvariant)] + private static partial Regex CompilerDiagnosticRegex(); + + public static IReadOnlyList Enrich( + IReadOnlyList raw, + Func copNameOf, + Func hasFixOf, + Func normalizePath) + { + var result = new List(raw.Count); + foreach (var d in raw) + { + var loc = ParseLocation(d.Location); + var filePath = loc.Path is not null ? normalizePath(loc.Path) : null; + + string analyzer; + bool hasFix; + if (IsCompilerDiagnostic(d.Code)) + { + analyzer = "Compiler"; + hasFix = false; + } + else + { + analyzer = copNameOf(d.Code); + hasFix = hasFixOf(d.Code); + } + + result.Add(new AnalyzeDiagnostic(filePath, loc.Line, loc.Column, d.Code, d.Severity, d.Description, analyzer, hasFix)); + } + + return result; + } + + public static (IReadOnlyList Filtered, int DroppedUnlocated) Filter(IReadOnlyList all, AnalyzeFilter filter) + { + var result = new List(); + var droppedUnlocated = 0; + + foreach (var d in all) + { + if (d.FilePath is null) + { + if (!filter.IncludeUnlocated) + { + droppedUnlocated++; + continue; + } + } + else + { + if (filter.FilePath is not null && + !d.FilePath.Equals(filter.FilePath, StringComparison.OrdinalIgnoreCase)) + continue; + + if (filter.FolderPath is not null && + !d.FilePath.StartsWith(filter.FolderPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + continue; + + if (filter.ProjectPath is not null && + !d.FilePath.StartsWith(filter.ProjectPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + continue; + } + + if (filter.Severities is not null && !filter.Severities.Contains(d.Severity)) + continue; + + if (filter.Analyzers is not null && !filter.Analyzers.Contains(d.Analyzer)) + continue; + + if (filter.RuleIds is not null && !filter.RuleIds.Contains(d.Id)) + continue; + + result.Add(d); + } + + return (result, droppedUnlocated); + } + + public static string? FindContainingProject(string path, IReadOnlyList projectDirectories) + { + string? best = null; + var bestLen = -1; + + foreach (var project in projectDirectories) + { + if (project.Length <= bestLen) + continue; + + if (path.Equals(project, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(project + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + best = project; + bestLen = project.Length; + } + } + + return best; + } + + public static IReadOnlyList Sort(IEnumerable diagnostics) => + diagnostics + .OrderBy(d => d.FilePath ?? "", StringComparer.OrdinalIgnoreCase) + .ThenBy(d => d.Line ?? 0) + .ThenBy(d => d.Column ?? 0) + .ThenBy(d => d.Id, StringComparer.Ordinal) + .ToList(); + + public static IReadOnlyList BuildWarnings(bool succeeded, int rawCount, int filteredCount, int droppedUnlocated) + { + var warnings = new List(); + + if (!succeeded) + warnings.Add($"al_compile reported succeeded=false: {rawCount} diagnostics workspace-wide, {filteredCount} after filtering."); + + if (droppedUnlocated > 0) + warnings.Add($"{droppedUnlocated} diagnostic(s) without a file location were excluded by the scope filter; call analyze without scope arguments to see them."); + + return warnings; + } + + public static AnalyzeResult Build(string project, IReadOnlyList filteredSorted, int limit, IReadOnlyList warnings) + { + var totalCount = filteredSorted.Count; + var taken = totalCount <= limit ? filteredSorted : filteredSorted.Take(limit).ToList(); + var count = taken.Count; + var truncated = totalCount > count; + + var bySeverity = new Dictionary(StringComparer.OrdinalIgnoreCase); + var byAnalyzer = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var d in filteredSorted) + { + bySeverity[d.Severity] = bySeverity.GetValueOrDefault(d.Severity) + 1; + byAnalyzer[d.Analyzer] = byAnalyzer.GetValueOrDefault(d.Analyzer) + 1; + } + + var summary = new AnalyzeSummary(bySeverity, byAnalyzer); + var warningsList = warnings.Count == 0 ? null : warnings; + + return new AnalyzeResult(project, count, totalCount, truncated, summary, taken, warningsList); + } + + private static string ResolveSeverity(JsonElement severity) + { + if (severity.ValueKind == JsonValueKind.String) + return severity.GetString()!; + + if (severity.ValueKind == JsonValueKind.Number && severity.TryGetInt32(out var num)) + { + return num switch + { + 0 => "Hidden", + 1 => "Info", + 2 => "Warning", + 3 => "Error", + _ => "Unknown", + }; + } + + return "Unknown"; + } + + private sealed class CompileResultDto + { + public bool? Succeeded { get; set; } + public List? Diagnostics { get; set; } + public string? Message { get; set; } + } + + private sealed class DiagnosticDto + { + public JsonElement Severity { get; set; } + public string? Code { get; set; } + public string? Location { get; set; } + public string? Description { get; set; } + } +} diff --git a/src/ALCops.Mcp/Tools/AnalyzeTool.cs b/src/ALCops.Mcp/Tools/AnalyzeTool.cs new file mode 100644 index 0000000..e55a687 --- /dev/null +++ b/src/ALCops.Mcp/Tools/AnalyzeTool.cs @@ -0,0 +1,138 @@ +using System.ComponentModel; +using System.Text.Json; +using ALCops.Mcp.Models; +using ALCops.Mcp.Services; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ALCops.Mcp.Tools; + +[McpServerToolType] +public sealed class AnalyzeTool +{ + public const int DefaultLimit = 500; + + [McpServerTool(Name = "analyze", ReadOnly = true), + Description("Compile the AL workspace with all configured analyzers and return cop + compiler diagnostics as structured JSON. Wraps Microsoft's al_compile (onlyErrors=false, enableCodeAnalysis=true, no diagnostic cap) using the analyzers and ruleset the server passed to almcp at startup, then enriches each diagnostic with the owning analyzer ('CodeCop', 'ALCops.LinterCop', ..., or 'Compiler' for AL#### errors) and whether a native code fix exists (hasFix). Prefer this over al_compile or al_getdiagnostics whenever you want cop diagnostics: al_compile hides warnings unless you remember onlyErrors=false, and al_getdiagnostics never runs analyzers. Scope with filePath, folderPath or projectPath (combined with AND); filter with severities, analyzers, ruleIds; cap with limit (default 500). totalCount, truncated and summary always describe the full filtered set. Results are sorted by filePath, line, column. Scoping: without any scope argument, results are limited to the startup project. With filePath or folderPath and no projectPath, the file/folder is the only scope and analyzer/hasFix come from the analyzer configuration of the project that contains it (falling back to the startup project). The 'project' field names that project. Next steps: for a diagnostic with hasFix=true call get_fixes (then apply_fix) at its filePath/line/column/id, or apply_fix_all for every occurrence of one rule. After apply_fix / apply_fix_all, call analyze again to verify; almcp's file watcher normally sees the write first, but on slow file systems or right after a large apply_fix_all a second call may be needed before the fixed diagnostic disappears.")] + public static async Task Analyze( + IServiceProvider services, + ProjectAnalyzerResolver analyzerResolver, + WorkspaceStartupResolver workspaceResolver, + [Description("Optional: absolute path to a single .al file. Only diagnostics in that file are returned.")] string? filePath = null, + [Description("Optional: absolute folder path. Only diagnostics in files under it (recursively) are returned.")] string? folderPath = null, + [Description("Optional: absolute path to the AL project folder (contains app.json). Scopes results to that project and selects whose analyzer set is used for 'analyzer'/'hasFix'. Defaults to the project discovered at startup.")] string? projectPath = null, + [Description("Optional: keep only these severities, e.g. [\"Error\",\"Warning\"]. Case-insensitive. Values: Error, Warning, Info, Hidden.")] string[]? severities = null, + [Description("Optional: keep only diagnostics from these analyzers, by the cop name list_rules reports (e.g. \"CodeCop\", \"ALCops.LinterCop\") or \"Compiler\" for AL#### compiler diagnostics. Case-insensitive.")] string[]? analyzers = null, + [Description("Optional: keep only these rule IDs, e.g. [\"LC0020\",\"AL0432\"]. Case-insensitive.")] string[]? ruleIds = null, + [Description("Maximum diagnostics returned after filtering (default 500). totalCount and summary always cover the full filtered set.")] int limit = DefaultLimit, + CancellationToken cancellationToken = default) + { + try + { + var proxy = services.GetService(typeof(AlMcpProxy)) as AlMcpProxy; + if (proxy is null) + return Error("ProxyUnavailable", + "analyze wraps the proxied al_compile, but this server runs with --no-proxy. " + + "Restart without --no-proxy to use analyze."); + + if (!proxy.IsAvailable || !await proxy.Ready.WaitAsync(cancellationToken)) + return Error("ProxyUnavailable", + "MS AL MCP Server (almcp) is not available (not found in the DevTools directory, or it failed to start). " + + "See the server log on stderr."); + + var callerPassedProjectPath = projectPath is not null; + var callerPassedFileOrFolder = filePath is not null || folderPath is not null; + var callerPassedAnyScope = callerPassedProjectPath || callerPassedFileOrFolder; + + var normalizedFilePath = filePath is not null ? Path.GetFullPath(filePath) : null; + var normalizedFolderPath = folderPath is not null ? Path.TrimEndingDirectorySeparator(Path.GetFullPath(folderPath)) : null; + + var scopePath = normalizedFilePath ?? normalizedFolderPath; + string? enrichmentProject; + if (callerPassedProjectPath) + { + enrichmentProject = Path.TrimEndingDirectorySeparator(Path.GetFullPath(projectPath!)); + + var knownProjects = workspaceResolver.Config.ProjectDirectories + .Select(p => Path.TrimEndingDirectorySeparator(Path.GetFullPath(p))).ToList(); + if (!knownProjects.Contains(enrichmentProject, StringComparer.OrdinalIgnoreCase)) + return Error("UnknownProject", + $"'{projectPath}' is not one of the AL projects this server was started with: " + + $"{string.Join(", ", knownProjects)}. Pass one of those, or restart the server with --projects."); + } + else if (scopePath is not null) + { + enrichmentProject = CompileDiagnosticsParser.FindContainingProject( + scopePath, workspaceResolver.Config.ProjectDirectories) + ?? workspaceResolver.Config.PrimaryProject; + } + else + { + enrichmentProject = workspaceResolver.Config.PrimaryProject; + } + + if (enrichmentProject is null) + return Error("NoProject", + "No AL project available. Pass projectPath, or start the server from a folder " + + "containing app.json (or use --projects)."); + + // Project filter applies when projectPath is explicit or when no file/folder scope was given. + var projectScopeFilter = callerPassedFileOrFolder && !callerPassedProjectPath ? null : enrichmentProject; + + if (limit <= 0) + return Error("InvalidLimit", "limit must be a positive integer."); + + HashSet? severitySet = severities is { Length: > 0 } + ? new HashSet(severities, StringComparer.OrdinalIgnoreCase) : null; + HashSet? analyzerSet = analyzers is { Length: > 0 } + ? new HashSet(analyzers, StringComparer.OrdinalIgnoreCase) : null; + HashSet? ruleIdSet = ruleIds is { Length: > 0 } + ? new HashSet(ruleIds, StringComparer.OrdinalIgnoreCase) : null; + + var resolvedAnalyzers = await analyzerResolver.ResolveAsync(enrichmentProject, null, cancellationToken); + var warnings = new List(resolvedAnalyzers.Warnings); + + var args = new Dictionary + { + ["options"] = JsonSerializer.SerializeToElement(new + { + onlyErrors = false, + enableCodeAnalysis = true, + maxDiagnosticsPerCompilation = int.MaxValue + }, JsonDefaults.Options) + }; + + var result = await proxy.ForwardAsync("al_compile", args, cancellationToken); + + if (result.IsError == true) + { + var errorMessage = string.Join('\n', result.Content.OfType().Select(b => b.Text)); + return Error("ProxyCallFailed", + "The proxied al_compile call failed (almcp may have exited, or its session was lost): " + errorMessage); + } + + var (raw, message, succeeded) = CompileDiagnosticsParser.Parse(result); + warnings.AddRange(CompileDiagnosticsParser.ExtractWarnings(message)); + + var enriched = CompileDiagnosticsParser.Enrich(raw, resolvedAnalyzers.GetCopName, resolvedAnalyzers.HasCodeFix, Path.GetFullPath); + var includeUnlocated = !callerPassedAnyScope; + var filter = new AnalyzeFilter(normalizedFilePath, normalizedFolderPath, projectScopeFilter, includeUnlocated, severitySet, analyzerSet, ruleIdSet); + var (filtered, droppedUnlocated) = CompileDiagnosticsParser.Filter(enriched, filter); + var sorted = CompileDiagnosticsParser.Sort(filtered); + + var compileWarnings = CompileDiagnosticsParser.BuildWarnings(succeeded, raw.Count, filtered.Count, droppedUnlocated); + warnings.InsertRange(0, compileWarnings); + + var analyzeResult = CompileDiagnosticsParser.Build(enrichmentProject, sorted, limit, warnings); + + return JsonSerializer.Serialize(analyzeResult, JsonDefaults.Options); + } + catch (Exception ex) + { + return Error(ex.GetType().Name, ex.Message); + } + } + + private static string Error(string code, string message) => + JsonSerializer.Serialize(new { error = code, message }, JsonDefaults.Options); +} diff --git a/tests/ALCops.Mcp.Tests/AlMcpProxyTests.cs b/tests/ALCops.Mcp.Tests/AlMcpProxyTests.cs index 92b86b1..c85076f 100644 --- a/tests/ALCops.Mcp.Tests/AlMcpProxyTests.cs +++ b/tests/ALCops.Mcp.Tests/AlMcpProxyTests.cs @@ -190,6 +190,22 @@ public static AlMcpProxy CreateProxy(CapturingLogger logger, out string projectD return new AlMcpProxy(Locator!, resolver, logger); } + /// + /// A proxy over multiple project directories, not yet started. For tests that need diagnostics + /// from more than one AL project in the same almcp workspace. + /// + public static AlMcpProxy CreateProxy(CapturingLogger logger, IReadOnlyList projectDirs) + { + var (analyzerResolver, loader) = TestAnalyzers.CreateAnalyzerResolver(Locator!); + var resolver = new WorkspaceStartupResolver( + analyzerResolver, + loader, + NullLogger.Instance, + [.. projectDirs]); + + return new AlMcpProxy(Locator!, resolver, logger); + } + public async Task InitializeAsync() { if (!IsAvailable) diff --git a/tests/ALCops.Mcp.Tests/AnalyzeToolTests.cs b/tests/ALCops.Mcp.Tests/AnalyzeToolTests.cs new file mode 100644 index 0000000..556d83b --- /dev/null +++ b/tests/ALCops.Mcp.Tests/AnalyzeToolTests.cs @@ -0,0 +1,367 @@ +using System.Text.Json; +using ALCops.Mcp.Models; +using ALCops.Mcp.Services; +using ALCops.Mcp.Tools; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Server; +using Xunit; +using Xunit.Abstractions; + +namespace ALCops.Mcp.Tests; + +public sealed class AnalyzeToolTests +{ + [Fact] + public async Task ProxyUnavailable_WhenNoProxy() + { + var services = new ServiceCollection().BuildServiceProvider(); + var (analyzerResolver, _) = TestAnalyzers.CreateAnalyzerResolver(); + var resolver = new WorkspaceStartupResolver( + analyzerResolver, + new ExternalAnalyzerLoader(TestAnalyzers.ToolsLocator), + NullLogger.Instance, + ["C:\\dummy"]); + + var json = await AnalyzeTool.Analyze(services, analyzerResolver, resolver); + var doc = JsonDocument.Parse(json); + + Assert.Equal("ProxyUnavailable", doc.RootElement.GetProperty("error").GetString()); + Assert.Contains("--no-proxy", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task ProxyUnavailable_WhenAlmcpNotFound() + { + var toolsDir = Path.Combine(Path.GetTempPath(), $"alcops-noalmcp-analyze-{Guid.NewGuid():N}"); + Directory.CreateDirectory(toolsDir); + File.WriteAllText(Path.Combine(toolsDir, "Microsoft.Dynamics.Nav.CodeAnalysis.dll"), "stub"); + + try + { + var locator = new BcToolsLocator(toolsDir); + Assert.False(locator.HasAlMcp); + + var (analyzerResolver, loader) = TestAnalyzers.CreateAnalyzerResolver(locator); + var resolver = new WorkspaceStartupResolver( + analyzerResolver, + loader, + NullLogger.Instance, + []); + + var proxy = new AlMcpProxy(locator, resolver, new CapturingLogger()); + + var sc = new ServiceCollection(); + sc.AddSingleton(proxy); + var sp = sc.BuildServiceProvider(); + + var json = await AnalyzeTool.Analyze(sp, analyzerResolver, resolver); + var doc = JsonDocument.Parse(json); + + Assert.Equal("ProxyUnavailable", doc.RootElement.GetProperty("error").GetString()); + Assert.Contains("not available", doc.RootElement.GetProperty("message").GetString()); + } + finally + { + TestAnalyzers.TryDeleteDirectory(toolsDir); + } + } + + [Fact] + public void Schema_ExposesUserParameters_HidesServicesAndCancellationToken() + { + var method = typeof(AnalyzeTool).GetMethod(nameof(AnalyzeTool.Analyze))!; + var tool = McpServerTool.Create(method); + + var schema = tool.ProtocolTool.InputSchema; + var schemaJson = JsonSerializer.Serialize(schema); + using var doc = JsonDocument.Parse(schemaJson); + var properties = doc.RootElement.GetProperty("properties"); + var paramNames = properties.EnumerateObject().Select(p => p.Name).ToHashSet(); + + Assert.DoesNotContain("services", paramNames); + Assert.DoesNotContain("cancellationToken", paramNames); + + string[] expected = ["filePath", "folderPath", "projectPath", "severities", "analyzers", "ruleIds", "limit"]; + foreach (var name in expected) + Assert.Contains(name, paramNames); + } +} + +[Collection(AnalyzeAlMcpFixture.CollectionName)] +public sealed class AnalyzeToolIntegrationTests(AnalyzeAlMcpFixture fixture, ITestOutputHelper output) : IDisposable +{ + private CancellationTokenSource Cts { get; } = new(TimeSpan.FromSeconds(90)); + + public void Dispose() => Cts.Dispose(); + + private async Task RunAnalyze( + string? projectPath = null, + string? filePath = null, + string? folderPath = null, + string[]? severities = null, + string[]? analyzers = null, + string[]? ruleIds = null, + int limit = AnalyzeTool.DefaultLimit) + { + var json = await AnalyzeTool.Analyze( + fixture.ServiceProvider, + fixture.AnalyzerResolver, + fixture.WorkspaceResolver, + filePath, folderPath, projectPath, + severities, analyzers, ruleIds, limit, Cts.Token); + + output.WriteLine(json); + + var doc = JsonDocument.Parse(json); + Assert.False(doc.RootElement.TryGetProperty("error", out var err), + $"Unexpected error: {err}"); + + return JsonSerializer.Deserialize(json, JsonDefaults.Options)!; + } + + [AlMcpFact] + public async Task ProjectScoped_ReturnsOnlyApplyFixProjectDiagnostics() + { + var result = await RunAnalyze(projectPath: fixture.ProjA); + + Assert.NotEmpty(result.Diagnostics); + Assert.All(result.Diagnostics, d => + { + Assert.NotNull(d.FilePath); + Assert.True( + d.FilePath.StartsWith(fixture.ProjA + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase), + $"Expected path under {fixture.ProjA}, got {d.FilePath}"); + }); + + Assert.True(result.Summary.ByAnalyzer.Count >= 1); + Assert.Equal(result.Count, result.TotalCount); + Assert.False(result.Truncated); + + Assert.DoesNotContain(result.Diagnostics, d => + d.FilePath is not null && d.FilePath.StartsWith(fixture.ProjB + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + } + + [AlMcpFact] + public async Task DefaultScope_ReturnsDiagnosticsFromPrimaryProject() + { + var result = await RunAnalyze(); + + Assert.NotEmpty(result.Diagnostics); + var located = result.Diagnostics.Where(d => d.FilePath is not null).ToList(); + Assert.NotEmpty(located); + Assert.All(located, d => + Assert.True( + d.FilePath!.StartsWith(fixture.ProjA + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase), + $"Expected path under primary project {fixture.ProjA}, got {d.FilePath}")); + Assert.Equal(fixture.ProjA, result.Project, ignoreCase: true); + + Assert.DoesNotContain(result.Diagnostics, d => + d.FilePath is not null && d.FilePath.StartsWith(fixture.ProjB + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + } + + [AlMcpFact] + public async Task ExplicitProjB_ReturnsDiagnosticsFromProjBOnly() + { + var result = await RunAnalyze(projectPath: fixture.ProjB); + + Assert.NotEmpty(result.Diagnostics); + Assert.All(result.Diagnostics, d => + { + Assert.NotNull(d.FilePath); + Assert.True( + d.FilePath.StartsWith(fixture.ProjB + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase), + $"Expected path under {fixture.ProjB}, got {d.FilePath}"); + }); + } + + [AlMcpFact] + public async Task FilterByRuleIdAndSeverityAndProject_ReturnsLC0020Hits() + { + var result = await RunAnalyze( + projectPath: fixture.ProjB, + severities: ["warning"], + ruleIds: ["LC0020"]); + + Assert.All(result.Diagnostics, d => + { + Assert.Equal("LC0020", d.Id); + Assert.Equal("Warning", d.Severity); + Assert.True( + d.FilePath!.StartsWith(fixture.ProjB + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase), + $"Expected path under {fixture.ProjB}, got {d.FilePath}"); + }); + + var fileNames = result.Diagnostics.Select(d => Path.GetFileName(d.FilePath!)).ToList(); + Assert.Contains("PageA.al", fileNames); + Assert.Contains("PageB.al", fileNames); + + for (var i = 1; i < result.Diagnostics.Count; i++) + { + var prev = result.Diagnostics[i - 1]; + var curr = result.Diagnostics[i]; + Assert.True( + string.Compare(prev.FilePath, curr.FilePath, StringComparison.OrdinalIgnoreCase) <= 0, + $"Not sorted: {prev.FilePath} > {curr.FilePath}"); + } + } + + [AlMcpFact] + public async Task Limit_Truncates() + { + var result = await RunAnalyze(limit: 1); + + Assert.Equal(1, result.Count); + Assert.True(result.TotalCount > 1); + Assert.True(result.Truncated); + } + + [AlMcpFact] + public async Task AnalyzersFilter_Compiler() + { + var result = await RunAnalyze(analyzers: ["Compiler"]); + + Assert.All(result.Diagnostics, d => + Assert.Matches("^AL\\d{4}$", d.Id)); + } + + [AlMcpFact] + public async Task FilePathInProjB_NoProjectPath_ReturnsProjBDiagnosticsOnly() + { + var pageA = Path.Combine(fixture.ProjB, "PageA.al"); + var result = await RunAnalyze(filePath: pageA); + + Assert.NotEmpty(result.Diagnostics); + Assert.All(result.Diagnostics, d => + { + Assert.NotNull(d.FilePath); + Assert.Equal(pageA, d.FilePath, ignoreCase: true); + }); + Assert.Contains(result.Diagnostics, d => d.Analyzer == "ALCops.LinterCop"); + Assert.Equal(fixture.ProjB, result.Project, ignoreCase: true); + } + + [AlMcpFact] + public async Task FolderPathProjB_NoProjectPath_ReturnsProjBDiagnostics() + { + var result = await RunAnalyze(folderPath: fixture.ProjB); + + Assert.NotEmpty(result.Diagnostics); + Assert.All(result.Diagnostics, d => + { + Assert.NotNull(d.FilePath); + Assert.True( + d.FilePath.StartsWith(fixture.ProjB + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase), + $"Expected path under {fixture.ProjB}, got {d.FilePath}"); + }); + + var fileNames = result.Diagnostics.Select(d => Path.GetFileName(d.FilePath!)).Distinct().ToList(); + Assert.Contains("PageA.al", fileNames); + Assert.Contains("PageB.al", fileNames); + Assert.Equal(fixture.ProjB, result.Project, ignoreCase: true); + + Assert.DoesNotContain(result.Diagnostics, d => + d.FilePath is not null && d.FilePath.StartsWith(fixture.ProjA + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + } + + [AlMcpFact] + public async Task InvalidLimit_ReturnsError() + { + var json = await AnalyzeTool.Analyze( + fixture.ServiceProvider, + fixture.AnalyzerResolver, + fixture.WorkspaceResolver, + limit: 0, + cancellationToken: Cts.Token); + + var doc = JsonDocument.Parse(json); + Assert.Equal("InvalidLimit", doc.RootElement.GetProperty("error").GetString()); + } + + [AlMcpFact] + public async Task UnknownProject_ReturnsError() + { + var bogus = Path.Combine(Path.GetDirectoryName(fixture.ProjA)!, "NonExistent"); + var json = await AnalyzeTool.Analyze( + fixture.ServiceProvider, + fixture.AnalyzerResolver, + fixture.WorkspaceResolver, + projectPath: bogus, + cancellationToken: Cts.Token); + + output.WriteLine(json); + var doc = JsonDocument.Parse(json); + Assert.Equal("UnknownProject", doc.RootElement.GetProperty("error").GetString()); + + var message = doc.RootElement.GetProperty("message").GetString()!; + Assert.Contains(fixture.ProjA, message, StringComparison.OrdinalIgnoreCase); + Assert.Contains(fixture.ProjB, message, StringComparison.OrdinalIgnoreCase); + } + +} + +public sealed class AnalyzeAlMcpFixture : IAsyncLifetime +{ + public const string CollectionName = "almcp-analyze"; + + public AlMcpProxy Proxy { get; private set; } = null!; + public CapturingLogger Logger { get; } = new(); + public string ProjA { get; private set; } = string.Empty; + public string ProjB { get; private set; } = string.Empty; + public ProjectAnalyzerResolver AnalyzerResolver { get; private set; } = null!; + public WorkspaceStartupResolver WorkspaceResolver { get; private set; } = null!; + public IServiceProvider ServiceProvider { get; private set; } = null!; + + private string _tempRoot = string.Empty; + + public static bool IsAvailable => AlMcpFixture.IsAvailable; + + public async Task InitializeAsync() + { + if (!IsAvailable) + return; + + _tempRoot = Path.Combine(Path.GetTempPath(), $"alcops-analyze-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempRoot); + + ProjA = CopyFixtureInto("ApplyFixProject", "ProjA"); + ProjB = CopyFixtureInto("FixAllProject", "ProjB"); + + var (analyzerResolver, loader) = TestAnalyzers.CreateAnalyzerResolver(); + AnalyzerResolver = analyzerResolver; + + WorkspaceResolver = new WorkspaceStartupResolver( + analyzerResolver, + loader, + NullLogger.Instance, + [ProjA, ProjB]); + + Proxy = AlMcpFixture.CreateProxy(Logger, [ProjA, ProjB]); + + var sc = new ServiceCollection(); + sc.AddSingleton(Proxy); + ServiceProvider = sc.BuildServiceProvider(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + await Proxy.StartAsync(cts.Token); + } + + private string CopyFixtureInto(string fixtureName, string subDir) + { + var dest = Path.Combine(_tempRoot, subDir); + TestAnalyzers.CopyDirectory(TestAnalyzers.GetFixturePath(fixtureName), dest); + TestAnalyzers.WriteAnalyzerSettings(dest); + return dest; + } + + public async Task DisposeAsync() + { + if (Proxy is not null) + await Proxy.DisposeAsync(); + + TestAnalyzers.TryDeleteDirectory(_tempRoot); + } +} + +[CollectionDefinition(AnalyzeAlMcpFixture.CollectionName)] +public sealed class AnalyzeAlMcpCollection : ICollectionFixture; diff --git a/tests/ALCops.Mcp.Tests/ApplyFixThenCompileTests.cs b/tests/ALCops.Mcp.Tests/ApplyFixThenCompileTests.cs index 7410893..82125c3 100644 --- a/tests/ALCops.Mcp.Tests/ApplyFixThenCompileTests.cs +++ b/tests/ALCops.Mcp.Tests/ApplyFixThenCompileTests.cs @@ -12,8 +12,10 @@ namespace ALCops.Mcp.Tests; /// diagnostic is gone. Regression guard for the "stale diagnostics after apply_fix" report /// (PR #20 known issue). al_getdiagnostics was observed to return zero diagnostics on /// this fixture because it reads the existing compilation without draining almcp's file watcher. -/// Therefore al_compile with onlyErrors: false is the only verification path -/// this suite covers. +/// Therefore al_compile with options.onlyErrors: false is the only verification path +/// this suite covers. The flag lives inside almcp's options object; a top-level +/// onlyErrors is ignored (it only worked before because an absent options also +/// defaults to onlyErrors: false). /// [Collection(ApplyFixAlMcpFixture.CollectionName)] public sealed class ApplyFixThenCompileTests(ApplyFixAlMcpFixture fixture, ITestOutputHelper output) : IDisposable @@ -36,7 +38,7 @@ public async Task ApplyFix_ThenAlCompile_NoLongerReportsFixedDiagnostic() var filePath = Path.Combine(projectDir, "MyPage.al"); // 1. Compile — LC0020 must be present before the fix - var before = await proxy.ForwardAsync("al_compile", Args(new { onlyErrors = false }), Cts.Token); + var before = await proxy.ForwardAsync("al_compile", Args(new { options = new { onlyErrors = false } }), Cts.Token); var beforeText = ConcatTextContent(before); output.WriteLine("=== al_compile BEFORE fix ==="); output.WriteLine(beforeText); @@ -67,7 +69,7 @@ public async Task ApplyFix_ThenAlCompile_NoLongerReportsFixedDiagnostic() // 3. Compile again — LC0020 must be gone fixture.Logger.Lines.Clear(); - var after = await proxy.ForwardAsync("al_compile", Args(new { onlyErrors = false }), Cts.Token); + var after = await proxy.ForwardAsync("al_compile", Args(new { options = new { onlyErrors = false } }), Cts.Token); var afterText = ConcatTextContent(after); output.WriteLine("=== al_compile AFTER fix ==="); output.WriteLine(afterText); diff --git a/tests/ALCops.Mcp.Tests/CompileDiagnosticsParserTests.cs b/tests/ALCops.Mcp.Tests/CompileDiagnosticsParserTests.cs new file mode 100644 index 0000000..d77fbb4 --- /dev/null +++ b/tests/ALCops.Mcp.Tests/CompileDiagnosticsParserTests.cs @@ -0,0 +1,646 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ALCops.Mcp.Models; +using ALCops.Mcp.Services; +using ModelContextProtocol.Protocol; +using Xunit; + +namespace ALCops.Mcp.Tests; + +public sealed class CompileDiagnosticsParserTests +{ + // --- ParseLocation --- + + [Fact] + public void ParseLocation_WindowsPath() + { + var loc = CompileDiagnosticsParser.ParseLocation(@"SourceFile(C:\repo\App\src\File.al@42:15)"); + Assert.Equal(@"C:\repo\App\src\File.al", loc.Path); + Assert.Equal(42, loc.Line); + Assert.Equal(15, loc.Column); + } + + [Fact] + public void ParseLocation_LinuxPath() + { + var loc = CompileDiagnosticsParser.ParseLocation("SourceFile(/home/u/ws/App/src/File.al@3:1)"); + Assert.Equal("/home/u/ws/App/src/File.al", loc.Path); + Assert.Equal(3, loc.Line); + Assert.Equal(1, loc.Column); + } + + [Fact] + public void ParseLocation_PathWithParenthesesAndAt() + { + var loc = CompileDiagnosticsParser.ParseLocation(@"SourceFile(C:\ws\My (Test) App\a@b\File.al@7:9)"); + Assert.Equal(@"C:\ws\My (Test) App\a@b\File.al", loc.Path); + Assert.Equal(7, loc.Line); + Assert.Equal(9, loc.Column); + } + + [Theory] + [InlineData("None")] + [InlineData("")] + [InlineData(null)] + [InlineData("None()")] + public void ParseLocation_NullOrNoneOrEmpty_ReturnsDefault(string? location) + { + var loc = CompileDiagnosticsParser.ParseLocation(location); + Assert.Null(loc.Path); + Assert.Null(loc.Line); + Assert.Null(loc.Column); + } + + [Fact] + public void ParseLocation_BadInt_ReturnsDefault() + { + var loc = CompileDiagnosticsParser.ParseLocation(@"SourceFile(C:\x.al@abc:1)"); + Assert.Null(loc.Path); + } + + [Fact] + public void ParseLocation_XMLFile_Parsed() + { + var loc = CompileDiagnosticsParser.ParseLocation(@"XMLFile(C:\ws\App\Translations\App.g.xlf@1:1)"); + Assert.Equal(@"C:\ws\App\Translations\App.g.xlf", loc.Path); + Assert.Equal(1, loc.Line); + Assert.Equal(1, loc.Column); + } + + // --- Parse(string) --- + + [Fact] + public void Parse_CamelCase_Payload() + { + var json = """ + { + "succeeded": false, + "diagnostics": [ + { "severity": "Error", "code": "AL0001", "location": "SourceFile(C:\\x.al@1:1)", "description": "Syntax error" }, + { "severity": "Warning", "code": "LC0020", "location": "SourceFile(C:\\x.al@5:3)", "description": "Redundant property" } + ], + "message": "[Warning] Some warning" + } + """; + + var (diags, message, succeeded) = CompileDiagnosticsParser.Parse(json); + Assert.False(succeeded); + Assert.Equal(2, diags.Count); + Assert.Equal("AL0001", diags[0].Code); + Assert.Equal("Error", diags[0].Severity); + Assert.Equal("LC0020", diags[1].Code); + Assert.Equal("Warning", diags[1].Severity); + Assert.Equal("[Warning] Some warning", message); + } + + [Fact] + public void Parse_PascalCase_Payload() + { + var json = """ + { + "Succeeded": false, + "Diagnostics": [ + { "Severity": "Error", "Code": "AL0001", "Location": "SourceFile(C:\\x.al@1:1)", "Description": "Syntax error" }, + { "Severity": "Warning", "Code": "LC0020", "Location": "SourceFile(C:\\x.al@5:3)", "Description": "Redundant property" } + ], + "Message": "[Warning] Some warning" + } + """; + + var (diags, _, _) = CompileDiagnosticsParser.Parse(json); + Assert.Equal(2, diags.Count); + Assert.Equal("AL0001", diags[0].Code); + Assert.Equal("Error", diags[0].Severity); + } + + [Fact] + public void Parse_NumericSeverities() + { + var json = """ + { + "diagnostics": [ + { "severity": 3, "code": "AL0001", "description": "err" }, + { "severity": 2, "code": "LC0020", "description": "warn" } + ] + } + """; + + var (diags, _, succeeded) = CompileDiagnosticsParser.Parse(json); + Assert.True(succeeded); + Assert.Equal("Error", diags[0].Severity); + Assert.Equal("Warning", diags[1].Severity); + } + + [Fact] + public void Parse_EmptyDiagnostics() + { + var json = """{"succeeded":true,"diagnostics":[]}"""; + var (diags, message, succeeded) = CompileDiagnosticsParser.Parse(json); + Assert.True(succeeded); + Assert.Empty(diags); + Assert.Null(message); + } + + [Fact] + public void Parse_SkipsMissingCode_IgnoresUnknownProperties() + { + var json = """ + { + "diagnostics": [ + { "severity": "Error", "code": null, "description": "skip me" }, + { "severity": "Error", "code": "", "description": "skip too" }, + { "severity": "Warning", "code": "LC0001", "description": "keep", "unknownProp": 42 } + ], + "unknownTop": true + } + """; + + var (diags, _, _) = CompileDiagnosticsParser.Parse(json); + Assert.Single(diags); + Assert.Equal("LC0001", diags[0].Code); + } + + // --- Parse(CallToolResult) --- + + [Fact] + public void Parse_CallToolResult_StructuredContentWins() + { + var structuredJson = """{"succeeded":true,"diagnostics":[{"severity":"Warning","code":"LC0020","description":"From structured"}]}"""; + var textJson = """{"succeeded":true,"diagnostics":[{"severity":"Error","code":"AL0001","description":"From text"}]}"""; + + var result = new CallToolResult + { + StructuredContent = JsonNode.Parse(structuredJson), + Content = [new TextContentBlock { Text = textJson }], + }; + + var (diags, _, _) = CompileDiagnosticsParser.Parse(result); + Assert.Single(diags); + Assert.Equal("LC0020", diags[0].Code); + } + + [Fact] + public void Parse_CallToolResult_NonJsonTextThenJsonText() + { + var result = new CallToolResult + { + Content = [ + new TextContentBlock { Text = "Compiled." }, + new TextContentBlock { Text = """{"succeeded":true,"diagnostics":[{"severity":"Warning","code":"LC0020","description":"ok"}]}""" } + ], + }; + + var (diags, _, _) = CompileDiagnosticsParser.Parse(result); + Assert.Single(diags); + Assert.Equal("LC0020", diags[0].Code); + } + + [Fact] + public void Parse_CallToolResult_NoJson_Throws() + { + var result = new CallToolResult + { + Content = [new TextContentBlock { Text = "Compiled successfully." }], + }; + + var ex = Assert.Throws(() => CompileDiagnosticsParser.Parse(result)); + Assert.Contains("almcp returned no JSON payload", ex.Message); + } + + // --- ExtractWarnings --- + + [Fact] + public void ExtractWarnings_MultipleLines() + { + var warnings = CompileDiagnosticsParser.ExtractWarnings("[Warning] A\n[Warning] B\n"); + Assert.Equal(2, warnings.Count); + Assert.Equal("A", warnings[0]); + Assert.Equal("B", warnings[1]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ExtractWarnings_NullOrWhitespace_Empty(string? input) + { + Assert.Empty(CompileDiagnosticsParser.ExtractWarnings(input)); + } + + [Fact] + public void ExtractWarnings_UnprefixedMessage_KeptVerbatim() + { + var warnings = CompileDiagnosticsParser.ExtractWarnings("Something unexpected happened"); + Assert.Single(warnings); + Assert.Equal("Something unexpected happened", warnings[0]); + } + + // --- IsCompilerDiagnostic --- + + [Theory] + [InlineData("AL0432", true)] + [InlineData("AL0001", true)] + [InlineData("AL432", false)] + [InlineData("ALC0001", false)] + [InlineData("LC0020", false)] + [InlineData("al0432", false)] + public void IsCompilerDiagnostic_Cases(string code, bool expected) + { + Assert.Equal(expected, CompileDiagnosticsParser.IsCompilerDiagnostic(code)); + } + + // --- Enrich --- + + [Fact] + public void Enrich_CompilerDiagnostic_OverridesCopNameAndHasFix() + { + var raw = new List + { + new("Error", "AL0432", @"SourceFile(C:\x.al@1:1)", "Some error"), + }; + + var enriched = CompileDiagnosticsParser.Enrich( + raw, + _ => "Unknown", + _ => true, + p => p); + + Assert.Single(enriched); + Assert.Equal("Compiler", enriched[0].Analyzer); + Assert.False(enriched[0].HasFix); + } + + [Fact] + public void Enrich_CopDiagnostic_PropagatesCopNameAndHasFix() + { + var raw = new List + { + new("Warning", "LC0020", @"SourceFile(C:\x.al@5:3)", "Redundant property"), + }; + + var enriched = CompileDiagnosticsParser.Enrich( + raw, + _ => "ALCops.LinterCop", + _ => true, + p => p); + + Assert.Single(enriched); + Assert.Equal("ALCops.LinterCop", enriched[0].Analyzer); + Assert.True(enriched[0].HasFix); + } + + [Fact] + public void Enrich_NormalizePath_CalledOnlyWhenLocated() + { + var normalizeCount = 0; + var raw = new List + { + new("Warning", "LC0020", @"SourceFile(C:\x.al@5:3)", "Located"), + new("Error", "AL0001", "None", "Unlocated"), + }; + + CompileDiagnosticsParser.Enrich( + raw, + _ => "Unknown", + _ => false, + p => { normalizeCount++; return p; }); + + Assert.Equal(1, normalizeCount); + } + + // --- Filter --- + + [Fact] + public void Filter_FilePathCaseInsensitive() + { + var diags = new List + { + new(@"C:\ws\App\MyPage.al", 1, 1, "LC0020", "Warning", "msg", "Cop", false), + new(@"C:\ws\App\Other.al", 2, 1, "LC0020", "Warning", "msg", "Cop", false), + }; + + var (filtered, _) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter( + @"c:\WS\app\MYPAGE.AL", null, null, false, null, null, null)); + + Assert.Single(filtered); + Assert.Equal(@"C:\ws\App\MyPage.al", filtered[0].FilePath); + } + + [Fact] + public void Filter_FolderPath_PrefixMatchWithSeparator() + { + var sep = Path.DirectorySeparatorChar; + var inside = $"C:{sep}ws{sep}App{sep}src{sep}X.al"; + var sibling = $"C:{sep}ws{sep}AppSource{sep}X.al"; + var exact = $"C:{sep}ws{sep}App"; + + var diags = new List + { + new(inside, 1, 1, "LC0020", "Warning", "msg", "Cop", false), + new(sibling, 2, 1, "LC0020", "Warning", "msg", "Cop", false), + new(exact, 3, 1, "LC0020", "Warning", "msg", "Cop", false), + }; + + var (filtered, _) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter( + null, $"C:{sep}ws{sep}App", null, false, null, null, null)); + + Assert.Single(filtered); + Assert.Equal(inside, filtered[0].FilePath); + } + + [Fact] + public void Filter_ProjectPathAndFilePath_AND() + { + var sep = Path.DirectorySeparatorChar; + var match = $"C:{sep}ws{sep}App{sep}MyPage.al"; + var other = $"C:{sep}ws{sep}App{sep}Other.al"; + + var diags = new List + { + new(match, 1, 1, "LC0020", "Warning", "msg", "Cop", false), + new(other, 2, 1, "LC0020", "Warning", "msg", "Cop", false), + }; + + var (filtered, _) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter( + match, null, $"C:{sep}ws{sep}App", false, null, null, null)); + + Assert.Single(filtered); + Assert.Equal(match, filtered[0].FilePath); + } + + [Fact] + public void Filter_UnlocatedDiagnostics_KeptWhenIncludeUnlocated() + { + var diags = new List + { + new(null, null, null, "AL0001", "Error", "Unlocated", "Compiler", false), + }; + + var (kept, droppedKept) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter(null, null, null, true, null, null, null)); + Assert.Single(kept); + Assert.Equal(0, droppedKept); + } + + [Fact] + public void Filter_UnlocatedDiagnostics_DroppedWhenNotIncludeUnlocated() + { + var diags = new List + { + new(null, null, null, "AL0001", "Error", "Unlocated", "Compiler", false), + }; + + var (dropped, droppedCount) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter(null, null, null, false, null, null, null)); + Assert.Empty(dropped); + Assert.Equal(1, droppedCount); + } + + [Fact] + public void Filter_UnlocatedDiagnostics_DroppedWithScope() + { + var diags = new List + { + new(null, null, null, "AL0001", "Error", "Unlocated", "Compiler", false), + }; + + var (filtered, droppedCount) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter( + null, null, "C:\\ws\\App", false, null, null, null)); + Assert.Empty(filtered); + Assert.Equal(1, droppedCount); + } + + [Fact] + public void Filter_Severities_CaseInsensitive() + { + var diags = new List + { + new("a.al", 1, 1, "LC0020", "Warning", "msg", "Cop", false), + new("b.al", 1, 1, "AL0001", "Error", "msg", "Compiler", false), + }; + + var (filtered, _) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter( + null, null, null, false, + new HashSet(["warning"], StringComparer.OrdinalIgnoreCase), + null, null)); + + Assert.Single(filtered); + Assert.Equal("Warning", filtered[0].Severity); + } + + [Fact] + public void Filter_Analyzers_CaseInsensitive() + { + var diags = new List + { + new("a.al", 1, 1, "AL0432", "Error", "msg", "Compiler", false), + new("b.al", 1, 1, "LC0020", "Warning", "msg", "ALCops.LinterCop", true), + new("c.al", 1, 1, "AA0001", "Warning", "msg", "CodeCop", false), + }; + + var (filtered, _) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter( + null, null, null, false, null, + new HashSet(["compiler", "alcops.lintercop"], StringComparer.OrdinalIgnoreCase), + null)); + + Assert.Equal(2, filtered.Count); + } + + [Fact] + public void Filter_RuleIds_CaseInsensitive() + { + var diags = new List + { + new("a.al", 1, 1, "LC0020", "Warning", "msg", "Cop", false), + new("b.al", 1, 1, "AL0001", "Error", "msg", "Compiler", false), + }; + + var (filtered, _) = CompileDiagnosticsParser.Filter(diags, new AnalyzeFilter( + null, null, null, false, null, null, + new HashSet(["lc0020"], StringComparer.OrdinalIgnoreCase))); + + Assert.Single(filtered); + Assert.Equal("LC0020", filtered[0].Id); + } + + // --- Sort/Build --- + + [Fact] + public void Sort_OrdersByFilePathLinColumnId() + { + var diags = new List + { + new("c.al", 2, 1, "LC0020", "Warning", "msg", "Cop", false), + new(null, null, null, "AL0001", "Error", "msg", "Compiler", false), + new("a.al", 1, 1, "LC0020", "Warning", "msg", "Cop", false), + new("B.al", 1, 1, "LC0020", "Warning", "msg", "Cop", false), + new("a.al", 1, 1, "AL0001", "Error", "msg", "Compiler", false), + }; + + var sorted = CompileDiagnosticsParser.Sort(diags); + + Assert.Null(sorted[0].FilePath); + Assert.Equal("a.al", sorted[1].FilePath); + Assert.Equal("AL0001", sorted[1].Id); + Assert.Equal("a.al", sorted[2].FilePath); + Assert.Equal("LC0020", sorted[2].Id); + Assert.Equal("B.al", sorted[3].FilePath); + Assert.Equal("c.al", sorted[4].FilePath); + } + + [Fact] + public void Build_TruncatesAndSummarizes() + { + var diags = Enumerable.Range(1, 7).Select(i => + new AnalyzeDiagnostic($"file{i}.al", i, 1, + i <= 3 ? "LC0020" : "AL0001", + i <= 3 ? "Warning" : "Error", + $"msg{i}", + i <= 3 ? "ALCops.LinterCop" : "Compiler", + i <= 3)).ToList(); + + var result = CompileDiagnosticsParser.Build("C:\\ws\\App", diags, 5, []); + + Assert.Equal("C:\\ws\\App", result.Project); + Assert.Equal(5, result.Count); + Assert.Equal(7, result.TotalCount); + Assert.True(result.Truncated); + Assert.Equal(3, result.Summary.BySeverity["Warning"]); + Assert.Equal(4, result.Summary.BySeverity["Error"]); + Assert.Equal(3, result.Summary.ByAnalyzer["ALCops.LinterCop"]); + Assert.Equal(4, result.Summary.ByAnalyzer["Compiler"]); + Assert.Null(result.Warnings); + } + + [Fact] + public void Build_NoTruncation() + { + var diags = Enumerable.Range(1, 3).Select(i => + new AnalyzeDiagnostic($"file{i}.al", i, 1, "LC0020", "Warning", $"msg{i}", "Cop", false)).ToList(); + + var result = CompileDiagnosticsParser.Build("C:\\ws\\App", diags, 500, ["A warning"]); + + Assert.Equal(3, result.Count); + Assert.Equal(3, result.TotalCount); + Assert.False(result.Truncated); + Assert.NotNull(result.Warnings); + Assert.Single(result.Warnings); + } + + // --- Serialized key order --- + + [Fact] + public void AnalyzeResult_SerializedKeyOrder() + { + var result = new AnalyzeResult( + "C:\\ws\\App", 1, 1, false, + new AnalyzeSummary( + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Warning"] = 1 }, + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Cop"] = 1 }), + [new AnalyzeDiagnostic("a.al", 1, 1, "LC0020", "Warning", "msg", "Cop", false)], + null); + + var json = JsonSerializer.Serialize(result, JsonDefaults.Options); + using var doc = JsonDocument.Parse(json); + + var topKeys = doc.RootElement.EnumerateObject().Select(p => p.Name).ToList(); + Assert.Equal(["project", "count", "totalCount", "truncated", "summary", "diagnostics", "warnings"], topKeys); + + var diagKeys = doc.RootElement.GetProperty("diagnostics")[0].EnumerateObject().Select(p => p.Name).ToList(); + Assert.Equal(["filePath", "line", "column", "id", "severity", "message", "analyzer", "hasFix"], diagKeys); + } + + // --- FindContainingProject --- + + [Fact] + public void FindContainingProject_FileInsideProject_ReturnsProject() + { + var sep = Path.DirectorySeparatorChar; + var projects = new List { $"C:{sep}ws{sep}App" }; + var file = $"C:{sep}ws{sep}App{sep}src{sep}X.al"; + + Assert.Equal($"C:{sep}ws{sep}App", CompileDiagnosticsParser.FindContainingProject(file, projects)); + } + + [Fact] + public void FindContainingProject_ExactMatch_ReturnsProject() + { + var sep = Path.DirectorySeparatorChar; + var projects = new List { $"C:{sep}ws{sep}App" }; + var path = $"C:{sep}ws{sep}App"; + + Assert.Equal($"C:{sep}ws{sep}App", CompileDiagnosticsParser.FindContainingProject(path, projects)); + } + + [Fact] + public void FindContainingProject_SiblingFolder_ReturnsNull() + { + var sep = Path.DirectorySeparatorChar; + var projects = new List { $"C:{sep}ws{sep}App" }; + var file = $"C:{sep}ws{sep}AppSource{sep}X.al"; + + Assert.Null(CompileDiagnosticsParser.FindContainingProject(file, projects)); + } + + [Fact] + public void FindContainingProject_LongestPrefixWins() + { + var sep = Path.DirectorySeparatorChar; + var projects = new List + { + $"C:{sep}ws", + $"C:{sep}ws{sep}App" + }; + var file = $"C:{sep}ws{sep}App{sep}src{sep}X.al"; + + Assert.Equal($"C:{sep}ws{sep}App", CompileDiagnosticsParser.FindContainingProject(file, projects)); + } + + [Fact] + public void FindContainingProject_NoMatch_ReturnsNull() + { + var sep = Path.DirectorySeparatorChar; + var projects = new List { $"C:{sep}ws{sep}App" }; + var file = $"D:{sep}other{sep}X.al"; + + Assert.Null(CompileDiagnosticsParser.FindContainingProject(file, projects)); + } + + // --- BuildWarnings --- + + [Fact] + public void BuildWarnings_SucceededFalse_OnlySucceededWarning() + { + var warnings = CompileDiagnosticsParser.BuildWarnings(succeeded: false, rawCount: 7, filteredCount: 3, droppedUnlocated: 0); + + Assert.Single(warnings); + Assert.Contains("succeeded=false", warnings[0]); + Assert.Contains("7 diagnostics workspace-wide", warnings[0]); + Assert.Contains("3 after filtering", warnings[0]); + } + + [Fact] + public void BuildWarnings_SucceededTrue_DroppedUnlocated_OnlyDroppedWarning() + { + var warnings = CompileDiagnosticsParser.BuildWarnings(succeeded: true, rawCount: 5, filteredCount: 4, droppedUnlocated: 1); + + Assert.Single(warnings); + Assert.StartsWith("1 diagnostic(s) without a file location", warnings[0]); + } + + [Fact] + public void BuildWarnings_SucceededFalse_DroppedUnlocated_BothWarningsInOrder() + { + var warnings = CompileDiagnosticsParser.BuildWarnings(succeeded: false, rawCount: 10, filteredCount: 6, droppedUnlocated: 2); + + Assert.Equal(2, warnings.Count); + Assert.Contains("succeeded=false", warnings[0]); + Assert.Contains("2 diagnostic(s) without a file location", warnings[1]); + } + + [Fact] + public void BuildWarnings_SucceededTrue_NoneDropped_Empty() + { + var warnings = CompileDiagnosticsParser.BuildWarnings(succeeded: true, rawCount: 5, filteredCount: 5, droppedUnlocated: 0); + + Assert.Empty(warnings); + } +}