diff --git a/AGENTS.md b/AGENTS.md index a9e072c..99ef6b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ A plain `PackageReference` is impossible: as of 17.0 the `Microsoft.Dynamics.Bus - `src` sets `false`, so the proprietary DLLs never enter the build output and therefore never enter the published package. This is the redistribution guard. - `tests` sets `true` on purpose: the CI compatibility matrix hot-swaps those three DLLs in the prebuilt test binary to run the same tests against every supported SDK version. -At runtime `BcToolsLocator` finds the DLLs in the user's own toolchain. Nothing is downloaded at runtime. +At runtime `BcToolsLocator` finds the DLLs in the user's own toolchain. The DevTools themselves are never downloaded at runtime; only ALCops' own analyzers are provisioned from NuGet (see below). ## Architecture @@ -48,13 +48,16 @@ It is deliberately **thin**: Microsoft's `almcp` already compiles, runs diagnost - `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). - `AlMcpProxy` — child process lifecycle plus generic tool forwarding over a single long-lived MCP client that reconnects on session expiry. `ForwardAsync` is a passthrough with **no per-tool argument rewriting**; configuration is conveyed at launch instead. - `ProjectAnalyzerResolver` — reads `al.codeAnalyzers` and the ruleset (`.vscode/settings.json`, `.AL-Go/settings.json`, convention-named files) and builds an `AnalyzerSet`. Nothing is built in. + - `AlcopsAnalyzerProvisioner` — downloads ALCops' own analyzers from NuGet, matched to the installed DevTools TFM, and caches them under `~/.alcops/analyzers/`. `Task Ready` completes with the provisioned folder or `null`. Configured via `--alcops-analyzers` / `ALCOPS_ANALYZERS` / `ALCOPS_ANALYZERS_CACHE`. - `ExternalAnalyzerLoader` — loads analyzer DLLs through `AnalyzerAssemblyLoadContext`, which resolves shared types by simple name from the default context. That type sharing is what makes `typeof(DiagnosticAnalyzer).IsAssignableFrom` work, and therefore what makes in-process code fixes possible at all. - `ProjectSessionManager` / `ProjectLoader` — caches AL project workspaces keyed by path; `GetOrLoadProjectAsync` is the entry point tools use. - **Models/** — record types for tool return values, serialized with `JsonDefaults.Options` (camelCase, not indented). -### Analyzers are never bundled +### Analyzers are never bundled; ALCops' own analyzers are provisioned at runtime -Shipping pinned cop DLLs beside whatever `Nav.CodeAnalysis` the user installed is what caused `AD0001` / `MissingMethodException` (issue #10). Analyzers come solely from the project's own config. `ALCops.Analyzers` is referenced by the **test project only**, so the fixtures have real cops with real code fixes to exercise; it must never move back to `src`. +Shipping pinned cop DLLs beside whatever `Nav.CodeAnalysis` the user installed is what caused `AD0001` / `MissingMethodException` (issue #10). Microsoft cops and third-party analyzers come solely from the project's own config and the DevTools directory. `ALCops.Analyzers` is referenced by the **test project only**, so the fixtures have real cops with real code fixes to exercise; it must never move back to `src`. + +ALCops' own analyzers are provisioned by `AlcopsAnalyzerProvisioner` at every startup: it detects the DevTools TFM, downloads the latest stable `ALCops.Analyzers` NuGet package (or uses a pinned/prerelease version per `--alcops-analyzers`), extracts the matching `lib//` folder, and caches the DLLs under `~/.alcops/analyzers///`. `ExternalAnalyzerLoader.ResolveDllPath` probes the provisioned folder first for `${analyzerFolder}ALCops.*.dll` specs. The DevTools themselves are never downloaded at runtime. When passing analyzers to the child `almcp`, their sibling dependencies must travel with them (`ALCops.Common.dll`, `Microsoft.Dynamics.Nav.Analyzers.Common.dll`): `almcp` resolves analyzer dependencies only among the paths it was given and does not probe the analyzer's directory. A missing one turns every rule in that assembly into an `AD0001` instead of a diagnostic. diff --git a/README.md b/README.md index 2277160..a932c2b 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,28 @@ Pass `--no-proxy` to serve only the native tools. Use it when your agent already ## Analyzers -**Analyzers are not bundled.** The server loads exactly what your project configures via `al.codeAnalyzers` in `.vscode/settings.json` (AL-Go's `rulesetFile` and the `custom.ruleset.json` / `app.ruleset.json` conventions are honored too). That includes ALCops' cops, BC's standard cops (`${CodeCop}`, `${UICop}`, `${PerTenantExtensionCop}`, `${AppSourceCop}`), and any third-party analyzer. +**Microsoft cops and third-party analyzers are never bundled.** The server loads exactly what your project configures via `al.codeAnalyzers` in `.vscode/settings.json` — BC's standard cops (`${CodeCop}`, `${UICop}`, `${PerTenantExtensionCop}`, `${AppSourceCop}`) and any third-party analyzer resolve from the DevTools directory. AL-Go's `rulesetFile` and the `custom.ruleset.json` / `app.ruleset.json` conventions are honored too. -This is deliberate: bundling pinned cop DLLs beside whatever `Nav.CodeAnalysis` you have installed is what produced `AD0001` / `MissingMethodException` failures ([#10](https://github.com/ALCops/mcp-server/issues/10)). Resolving both from your own toolchain makes that mismatch impossible. +This is deliberate: bundling pinned cop DLLs beside whatever `Nav.CodeAnalysis` you have installed is what produced `AD0001` / `MissingMethodException` failures ([#10](https://github.com/ALCops/mcp-server/issues/10)). + +### ALCops analyzer provisioning + +ALCops' own analyzers (`${analyzerFolder}ALCops.*.dll`) are provisioned automatically at every startup. The server detects the installed DevTools' target framework (e.g. `net10.0`), downloads the latest stable [ALCops.Analyzers](https://www.nuget.org/packages/ALCops.Analyzers) NuGet package, extracts the matching `lib//` folder, and caches the DLLs under `~/.alcops/analyzers///`. On subsequent starts a newer stable version is picked up automatically; older cached versions are left in place. + +Configure with `--alcops-analyzers` or the `ALCOPS_ANALYZERS` environment variable: + +| Value | Behaviour | +|-------|-----------| +| `latest` (default) | Download the latest stable release. | +| `prerelease` | Download the highest version including prereleases. | +| `` (e.g. `1.2.0`) | Pin to a specific version (no index lookup). | +| `off` | Disable provisioning entirely. | + +Set `ALCOPS_ANALYZERS_CACHE` to override the default cache directory (`~/.alcops/analyzers`). + +When offline, the newest previously cached version for the target TFM is used with a warning. When no cache exists, the server starts without ALCops analyzers and logs a message with manual provisioning instructions. + +The recommended `al.codeAnalyzers` configuration: ```json { @@ -86,7 +105,7 @@ The DevTools DLLs and `almcp` live in the same directory in both delivery channe 3. dotnet tool store (`~/.dotnet/tools/.store/…`) — highest version wins 4. AL Language VS Code extension `bin/` — highest version wins -If none match, the server exits with the install command rather than starting up degraded. Nothing is downloaded at runtime. +If none match, the server exits with the install command rather than starting up degraded. The DevTools themselves are never downloaded at runtime — only ALCops' own analyzers are provisioned from NuGet (see [Analyzers](#analyzers) above). ## CLI Options @@ -95,6 +114,7 @@ If none match, the server exits with the install command rather than starting up | `--devtools-path ` | Use this BC DevTools directory instead of probing. | | `--projects [;]` | Work on these projects instead of scanning down from the working directory for `app.json`. A directory that is not itself a project is scanned for projects beneath it; entries with none are ignored with a warning. | | `--no-proxy` | Serve only the native tools; do not start `almcp`. | +| `--alcops-analyzers ` | `latest` (default), `prerelease`, a pinned version, or `off`. See [Analyzers](#analyzers). | Arguments `almcp` understands — `--codeanalyzers`, `--rulesetpath`, `--settingspath`, `--enablecodeanalysis`, `--enableexternalrulesets`, `--locale`, `--noauth`, `--nolog` and friends — are forwarded to the child process and override anything discovered from your project. diff --git a/src/ALCops.Mcp/McpHost.cs b/src/ALCops.Mcp/McpHost.cs index a9f552d..00ea079 100644 --- a/src/ALCops.Mcp/McpHost.cs +++ b/src/ALCops.Mcp/McpHost.cs @@ -35,15 +35,44 @@ public static async Task RunAsync(string[] args, BcToolsLocator toolsLocator, Pr builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + { + var provisioner = sp.GetRequiredService(); + Func provisionedFolder = () => + provisioner.Ready is { IsCompletedSuccessfully: true, Result: not null } + ? provisioner.Ready.Result + : null; + return new ExternalAnalyzerLoader( + sp.GetRequiredService(), + provisionedFolder); + }); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + new ProjectAnalyzerResolver( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + // ALCops analyzer provisioner: downloads ALCops' own analyzers from NuGet, matched to the + // installed DevTools TFM. Runs under --no-proxy too — the native fix tools need them. + var analyzersOption = AlcopsAnalyzersOption.Parse( + proxyOptions.AlcopsAnalyzers + ?? Environment.GetEnvironmentVariable("ALCOPS_ANALYZERS")); + builder.Services.AddSingleton(sp => + new AlcopsAnalyzerProvisioner( + sp.GetRequiredService(), + analyzersOption, + null, + null, + sp.GetRequiredService>())); + builder.Services.AddHostedService(); // Registered even with --no-proxy: list_rules falls back to the discovered project too. builder.Services.AddSingleton(sp => new WorkspaceStartupResolver( sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>(), proxyOptions.Projects)); diff --git a/src/ALCops.Mcp/README.md b/src/ALCops.Mcp/README.md index 06956e1..db34ed7 100644 --- a/src/ALCops.Mcp/README.md +++ b/src/ALCops.Mcp/README.md @@ -35,7 +35,9 @@ Add to your `.mcp.json` (Claude Code) or `claude_desktop_config.json` (Claude De ## Analyzers -Analyzers are **not bundled**. The server loads exactly what your project configures via `al.codeAnalyzers` in `.vscode/settings.json` — ALCops' cops, BC's standard cops (`${CodeCop}`, `${UICop}`, `${PerTenantExtensionCop}`, `${AppSourceCop}`), or any third-party analyzer. AL-Go's `rulesetFile` and the `custom.ruleset.json` / `app.ruleset.json` conventions are honored too, and the same configuration is handed to the child `almcp` so `al_compile` and `get_fixes` agree about which rules run and which are suppressed. +Microsoft cops and third-party analyzers are **not bundled** — the server loads exactly what your project configures via `al.codeAnalyzers` in `.vscode/settings.json`. AL-Go's `rulesetFile` and the `custom.ruleset.json` / `app.ruleset.json` conventions are honored too, and the same configuration is handed to the child `almcp` so `al_compile` and `get_fixes` agree about which rules run and which are suppressed. + +ALCops' own analyzers are provisioned automatically at every startup: the server detects the installed DevTools target framework, downloads the matching `ALCops.Analyzers` NuGet package, and caches it under `~/.alcops/analyzers/`. Configure with `--alcops-analyzers` (`latest` | `prerelease` | `` | `off`) or the `ALCOPS_ANALYZERS` environment variable. Browse the ALCops rules reference at [alcops.dev/docs/analyzers](https://alcops.dev/docs/analyzers/). diff --git a/src/ALCops.Mcp/Services/AlMcpProxy.cs b/src/ALCops.Mcp/Services/AlMcpProxy.cs index 1ecb2d0..728b9ab 100644 --- a/src/ALCops.Mcp/Services/AlMcpProxy.cs +++ b/src/ALCops.Mcp/Services/AlMcpProxy.cs @@ -93,10 +93,12 @@ public async Task StartAsync(CancellationToken cancellationToken) private async Task StartCoreAsync(CancellationToken cancellationToken) { + var resolverArgs = await _workspaceResolver.BuildAlMcpArgsAsync(_passthroughArgs); + for (var attempt = 1; ; attempt++) { _port = FindFreePort(); - LaunchChild(); + LaunchChild(["--port", _port.ToString(), .. resolverArgs]); try { @@ -122,9 +124,8 @@ private async Task StartCoreAsync(CancellationToken cancellationToken) _logger.LogInformation("Discovered {Count} tools from almcp", _cachedTools.Count); } - private void LaunchChild() + private void LaunchChild(string[] args) { - var args = BuildChildArgs(); _logger.LogInformation("Starting almcp on port {Port}: {Path} {Args}", _port, _almcpPath, string.Join(' ', args)); // Both streams must be captured. In HTTP mode almcp writes its banner, "Port: N" and the @@ -352,13 +353,6 @@ private async Task DisposeClientAsync(McpClient client) Content = [new TextContentBlock { Text = message }], }; - private string[] BuildChildArgs() - { - // The workspace resolver supplies --projects/--codeanalyzers/--rulesetpath from the project's - // own config; anything the user passed through on our CLI overrides it. - return ["--port", _port.ToString(), .. _workspaceResolver.BuildAlMcpArgs(_passthroughArgs)]; - } - private static int FindFreePort() { using var listener = new TcpListener(IPAddress.Loopback, 0); diff --git a/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisioner.cs b/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisioner.cs new file mode 100644 index 0000000..478e075 --- /dev/null +++ b/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisioner.cs @@ -0,0 +1,339 @@ +using System.IO.Compression; +using System.Text.Json; +using ALCops.Mcp; +using Microsoft.Extensions.Logging; + +namespace ALCops.Mcp.Services; + +internal enum AlcopsAnalyzersMode { Latest, Prerelease, Pinned, Off } + +internal sealed record AlcopsAnalyzersOption(AlcopsAnalyzersMode Mode, string? PinnedVersion = null) +{ + public static readonly AlcopsAnalyzersOption Latest = new(AlcopsAnalyzersMode.Latest); + public static readonly AlcopsAnalyzersOption Prerelease = new(AlcopsAnalyzersMode.Prerelease); + public static readonly AlcopsAnalyzersOption Off = new(AlcopsAnalyzersMode.Off); + + public static AlcopsAnalyzersOption Parse(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Equals("latest", StringComparison.OrdinalIgnoreCase)) + return Latest; + if (value.Equals("prerelease", StringComparison.OrdinalIgnoreCase)) + return Prerelease; + if (value.Equals("off", StringComparison.OrdinalIgnoreCase)) + return Off; + return new AlcopsAnalyzersOption(AlcopsAnalyzersMode.Pinned, value); + } +} + +internal sealed class AlcopsAnalyzerProvisioner +{ + private const string PackageId = "alcops.analyzers"; + private static readonly Uri IndexUri = new($"https://api.nuget.org/v3-flatcontainer/{PackageId}/index.json"); + + private readonly BcToolsLocator _toolsLocator; + private readonly AlcopsAnalyzersOption _option; + private readonly HttpClient _httpClient; + private readonly string _cacheRoot; + private readonly ILogger _logger; + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Ready => _ready.Task; + + public AlcopsAnalyzerProvisioner( + BcToolsLocator toolsLocator, + AlcopsAnalyzersOption option, + HttpMessageHandler? httpHandler, + string? cacheRoot, + ILogger logger) + { + _toolsLocator = toolsLocator; + _option = option; + _httpClient = httpHandler is not null ? new HttpClient(httpHandler) : new HttpClient(); + _cacheRoot = cacheRoot + ?? Environment.GetEnvironmentVariable("ALCOPS_ANALYZERS_CACHE") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".alcops", "analyzers"); + _logger = logger; + } + + public async Task ProvisionAsync(CancellationToken ct) + { + string? result = null; + try + { + result = await ProvisionCoreAsync(ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) + { + _logger.LogWarning(ex, "ALCops analyzer provisioning failed"); + } + + _ready.TrySetResult(result); + } + + private async Task ProvisionCoreAsync(CancellationToken ct) + { + if (_option.Mode == AlcopsAnalyzersMode.Off) + { + _logger.LogInformation("ALCops analyzer provisioning disabled (--alcops-analyzers off)"); + return null; + } + + string tfm; + try + { + tfm = TargetFrameworkMoniker.DetectDevToolsTfm(_toolsLocator.ToolsDirectory); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not detect DevTools target framework"); + return null; + } + + _logger.LogInformation("DevTools target framework: {Tfm}", tfm); + + string? version; + try + { + version = await ResolveVersionAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Could not reach NuGet to resolve ALCops analyzer version"); + return FallbackToCacheOrWarn(tfm); + } + + if (version is null) + { + _logger.LogWarning("No suitable ALCops analyzer version found on NuGet"); + return FallbackToCacheOrWarn(tfm); + } + + _logger.LogInformation("ALCops analyzers: resolved version {Version}", version); + + var cacheDir = Path.Combine(_cacheRoot, tfm, version); + if (IsCacheValid(cacheDir)) + { + _logger.LogInformation("ALCops analyzers: v{Version} ({Tfm}) from {Dir}", version, tfm, cacheDir); + return cacheDir; + } + + try + { + return await DownloadAndExtractAsync(version, tfm, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to download ALCops.Analyzers {Version}", version); + return FallbackToCacheOrWarn(tfm); + } + } + + private async Task ResolveVersionAsync(CancellationToken ct) + { + if (_option.Mode == AlcopsAnalyzersMode.Pinned) + return _option.PinnedVersion; + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + + var response = await _httpClient.GetAsync(IndexUri, cts.Token); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(cts.Token); + + var (latest, prerelease) = NuGetVersions.Parse(json); + return _option.Mode == AlcopsAnalyzersMode.Prerelease ? prerelease : latest; + } + + private async Task DownloadAndExtractAsync(string version, string tfm, CancellationToken ct) + { + var nupkgUrl = $"https://api.nuget.org/v3-flatcontainer/{PackageId}/{version}/{PackageId}.{version}.nupkg"; + _logger.LogInformation("Downloading {Url}", nupkgUrl); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(60)); + + using var response = await _httpClient.GetAsync(nupkgUrl, HttpCompletionOption.ResponseHeadersRead, cts.Token); + response.EnsureSuccessStatusCode(); + + var tempFile = Path.Combine(Path.GetTempPath(), $"alcops-{version}-{Guid.NewGuid():N}.nupkg"); + try + { + await using (var fs = File.Create(tempFile)) + await using (var content = await response.Content.ReadAsStreamAsync(cts.Token)) + await content.CopyToAsync(fs, cts.Token); + + return ExtractPackage(tempFile, version, tfm, nupkgUrl); + } + finally + { + try { File.Delete(tempFile); } catch { } + } + } + + private string ExtractPackage(string nupkgPath, string version, string tfm, string sourceUrl) + { + using var zip = ZipFile.OpenRead(nupkgPath); + + var libFolders = zip.Entries + .Select(e => e.FullName) + .Where(n => n.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) + && n.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + && n.Split('/').Length >= 3) + .Select(n => n.Split('/')[1]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var bestTfm = TargetFrameworkMoniker.FindBestLibFolder(libFolders, tfm) + ?? throw new InvalidOperationException( + $"ALCops.Analyzers {version} has no compatible TFM for {tfm}. Available: {string.Join(", ", libFolders)}"); + + if (!bestTfm.Equals(tfm, StringComparison.OrdinalIgnoreCase)) + _logger.LogWarning("ALCops analyzers: no {Requested} in package; using {Actual} fallback", tfm, bestTfm); + + var targetDir = Path.Combine(_cacheRoot, tfm, version); + var tempDir = $"{targetDir}.tmp-{Guid.NewGuid():N}"; + Directory.CreateDirectory(tempDir); + + try + { + var prefix = $"lib/{bestTfm}/"; + var files = new List(); + + foreach (var entry in zip.Entries) + { + if (entry.FullName.Length <= prefix.Length) + continue; + if (!entry.FullName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + if (!entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + continue; + + var fileName = Path.GetFileName(entry.FullName); + entry.ExtractToFile(Path.Combine(tempDir, fileName), overwrite: true); + files.Add(fileName); + } + + if (files.Count == 0) + throw new InvalidOperationException($"No DLLs in lib/{bestTfm}/ of ALCops.Analyzers {version}"); + + var manifest = new + { + alcopsVersion = version, + requestedTfm = tfm, + targetFramework = bestTfm, + downloadedAt = DateTime.UtcNow.ToString("o"), + files = files.Order().ToArray(), + source = sourceUrl + }; + + File.WriteAllText( + Path.Combine(tempDir, ".alcops-manifest.json"), + JsonSerializer.Serialize(manifest, JsonDefaults.Options)); + + Directory.CreateDirectory(Path.GetDirectoryName(targetDir)!); + + try + { + Directory.Move(tempDir, targetDir); + } + catch (IOException) when (Directory.Exists(targetDir)) + { + try { Directory.Delete(tempDir, recursive: true); } catch { } + } + + _logger.LogInformation("ALCops analyzers: v{Version} ({Tfm}) provisioned to {Dir}", version, bestTfm, targetDir); + return targetDir; + } + catch + { + try { Directory.Delete(tempDir, recursive: true); } catch { } + throw; + } + } + + private string? FallbackToCacheOrWarn(string tfm) + { + var cached = FindNewestCachedVersion(tfm); + if (cached is not null) + { + _logger.LogWarning("ALCops analyzers: using cached version from {Dir}", cached); + return cached; + } + + _logger.LogWarning( + "ALCops analyzers unavailable. To provision manually: " + + "--alcops-analyzers (when the NuGet index is reachable), or: " + + "npx @alcops/core download --detect-using {ToolsDir} --output ", + _toolsLocator.ToolsDirectory); + return null; + } + + private string? FindNewestCachedVersion(string tfm) + { + var tfmDir = Path.Combine(_cacheRoot, tfm); + if (!Directory.Exists(tfmDir)) + return null; + + string? best = null; + Version? bestVersion = null; + + try + { + foreach (var dir in Directory.EnumerateDirectories(tfmDir)) + { + var name = Path.GetFileName(dir); + if (name.Contains(".tmp-", StringComparison.Ordinal)) + continue; + if (!IsCacheValid(dir)) + continue; + + var dashIndex = name.IndexOf('-'); + var basePart = dashIndex >= 0 ? name[..dashIndex] : name; + if (!Version.TryParse(basePart, out var v)) + continue; + + if (bestVersion is null || v > bestVersion) + { + best = dir; + bestVersion = v; + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + return best; + } + + internal static bool IsCacheValid(string cacheDir) + { + var manifestPath = Path.Combine(cacheDir, ".alcops-manifest.json"); + if (!File.Exists(manifestPath)) + return false; + + try + { + var json = File.ReadAllText(manifestPath); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array) + return false; + + foreach (var file in files.EnumerateArray()) + { + var fileName = file.GetString(); + if (fileName is null || !File.Exists(Path.Combine(cacheDir, fileName))) + return false; + } + + return true; + } + catch + { + return false; + } + } +} diff --git a/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisionerStartup.cs b/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisionerStartup.cs new file mode 100644 index 0000000..6c0d6e5 --- /dev/null +++ b/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisionerStartup.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ALCops.Mcp.Services; + +internal sealed class AlcopsAnalyzerProvisionerStartup : IHostedService +{ + private readonly AlcopsAnalyzerProvisioner _provisioner; + private readonly ILogger _logger; + private readonly CancellationTokenSource _cts = new(); + private Task _startup = Task.CompletedTask; + + public AlcopsAnalyzerProvisionerStartup( + AlcopsAnalyzerProvisioner provisioner, + ILogger logger) + { + _provisioner = provisioner; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _startup = Task.Run(() => RunStartupAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + private async Task RunStartupAsync(CancellationToken cancellationToken) + { + try + { + await _provisioner.ProvisionAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception ex) + { + _logger.LogWarning(ex, "ALCops analyzer provisioning failed"); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync(); + await _startup; + _cts.Dispose(); + } +} diff --git a/src/ALCops.Mcp/Services/ExternalAnalyzerLoader.cs b/src/ALCops.Mcp/Services/ExternalAnalyzerLoader.cs index 1efece2..5d80326 100644 --- a/src/ALCops.Mcp/Services/ExternalAnalyzerLoader.cs +++ b/src/ALCops.Mcp/Services/ExternalAnalyzerLoader.cs @@ -9,12 +9,17 @@ namespace ALCops.Mcp.Services; public sealed class ExternalAnalyzerLoader { private readonly BcToolsLocator _toolsLocator; + private readonly Func? _provisionedFolder; private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); private int _assemblyResolveRegistered; public ExternalAnalyzerLoader(BcToolsLocator toolsLocator) + : this(toolsLocator, null) { } + + internal ExternalAnalyzerLoader(BcToolsLocator toolsLocator, Func? provisionedFolder) { _toolsLocator = toolsLocator; + _provisionedFolder = provisionedFolder; } public LoadedAnalyzerAssembly? ResolveAndLoad(AnalyzerSpec spec, string projectPath) @@ -39,13 +44,29 @@ public ExternalAnalyzerLoader(BcToolsLocator toolsLocator) switch (spec.Kind) { case AnalyzerSpecKind.WellKnownBcCop: + { + var candidate = Path.Combine(_toolsLocator.AnalyzerFolder, spec.GetDllFileName()); + if (File.Exists(candidate)) + return candidate; + + var localPath = Path.Combine(projectPath, ".vscode", "analyzers", spec.GetDllFileName()); + return File.Exists(localPath) ? localPath : null; + } + case AnalyzerSpecKind.AnalyzerFolderRelative: { + var provisioned = _provisionedFolder?.Invoke(); + if (provisioned is not null) + { + var provCandidate = Path.Combine(provisioned, spec.GetDllFileName()); + if (File.Exists(provCandidate)) + return provCandidate; + } + var candidate = Path.Combine(_toolsLocator.AnalyzerFolder, spec.GetDllFileName()); if (File.Exists(candidate)) return candidate; - // Fallback: project-local .vscode/analyzers/ var localPath = Path.Combine(projectPath, ".vscode", "analyzers", spec.GetDllFileName()); return File.Exists(localPath) ? localPath : null; } @@ -152,11 +173,20 @@ private void EnsureAssemblyResolveRegistered() return; string[] searchPaths = [_toolsLocator.AnalyzerFolder, _toolsLocator.ToolsDirectory, AppContext.BaseDirectory]; + var provisionedFunc = _provisionedFolder; AppDomain.CurrentDomain.AssemblyResolve += (_, args) => { var dllName = new AssemblyName(args.Name).Name + ".dll"; + var prov = provisionedFunc?.Invoke(); + if (prov is not null) + { + var provCandidate = Path.Combine(prov, dllName); + if (File.Exists(provCandidate)) + return Assembly.LoadFrom(provCandidate); + } + foreach (var dir in searchPaths) { var candidate = Path.Combine(dir, dllName); diff --git a/src/ALCops.Mcp/Services/NuGetVersions.cs b/src/ALCops.Mcp/Services/NuGetVersions.cs new file mode 100644 index 0000000..c77b334 --- /dev/null +++ b/src/ALCops.Mcp/Services/NuGetVersions.cs @@ -0,0 +1,71 @@ +using System.Text.Json; + +namespace ALCops.Mcp.Services; + +/// +/// Parses NuGet's flat-container index.json version list. +/// +internal static class NuGetVersions +{ + public static (string? Latest, string? Prerelease) Parse(string json) + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("versions", out var versions) || + versions.ValueKind != JsonValueKind.Array) + return (null, null); + + string? latest = null; + string? prerelease = null; + + Version? latestVersion = null; + (Version? Base, string Raw)? prereleaseVersion = null; + + foreach (var element in versions.EnumerateArray()) + { + var raw = element.GetString(); + if (string.IsNullOrEmpty(raw)) + continue; + + var parsed = ParseVersion(raw); + if (parsed is null) + continue; + + var (baseVersion, isStable) = parsed.Value; + + if (isStable && (latestVersion is null || CompareVersions(baseVersion, latestVersion) > 0)) + { + latest = raw; + latestVersion = baseVersion; + } + + if (prereleaseVersion is null || CompareVersions(baseVersion, prereleaseVersion.Value.Base!) > 0 || + (CompareVersions(baseVersion, prereleaseVersion.Value.Base!) == 0 && + StringComparer.OrdinalIgnoreCase.Compare(raw, prereleaseVersion.Value.Raw) > 0)) + { + prerelease = raw; + prereleaseVersion = (baseVersion, raw); + } + } + + return (latest, prerelease); + } + + private static (Version Base, bool IsStable)? ParseVersion(string raw) + { + var dashIndex = raw.IndexOf('-'); + var isStable = dashIndex < 0; + var basePart = isStable ? raw : raw[..dashIndex]; + + return Version.TryParse(basePart, out var v) + ? (v, isStable) + : null; + } + + private static int CompareVersions(Version? a, Version? b) + { + if (a is null && b is null) return 0; + if (a is null) return -1; + if (b is null) return 1; + return a.CompareTo(b); + } +} diff --git a/src/ALCops.Mcp/Services/ProjectAnalyzerResolver.cs b/src/ALCops.Mcp/Services/ProjectAnalyzerResolver.cs index c785dd4..d2649d4 100644 --- a/src/ALCops.Mcp/Services/ProjectAnalyzerResolver.cs +++ b/src/ALCops.Mcp/Services/ProjectAnalyzerResolver.cs @@ -6,6 +6,7 @@ public sealed class ProjectAnalyzerResolver { private readonly ExternalAnalyzerLoader _loader; private readonly RulesetLoader _rulesetLoader; + private readonly Task? _provisionerReady; public ProjectAnalyzerResolver(ExternalAnalyzerLoader loader, RulesetLoader rulesetLoader) { @@ -13,11 +14,20 @@ public ProjectAnalyzerResolver(ExternalAnalyzerLoader loader, RulesetLoader rule _rulesetLoader = rulesetLoader; } + internal ProjectAnalyzerResolver(ExternalAnalyzerLoader loader, RulesetLoader rulesetLoader, AlcopsAnalyzerProvisioner provisioner) + : this(loader, rulesetLoader) + { + _provisionerReady = provisioner.Ready; + } + public async Task ResolveAsync( string projectPath, IReadOnlyList? analyzerSpecs = null, CancellationToken ct = default) { + if (_provisionerReady is not null) + await _provisionerReady; + var specs = analyzerSpecs ?? ReadAnalyzerSpecsFromSettings(projectPath); var loaded = new List(); diff --git a/src/ALCops.Mcp/Services/ProxyOptions.cs b/src/ALCops.Mcp/Services/ProxyOptions.cs index b89c6b5..407519f 100644 --- a/src/ALCops.Mcp/Services/ProxyOptions.cs +++ b/src/ALCops.Mcp/Services/ProxyOptions.cs @@ -7,6 +7,7 @@ public sealed record ProxyOptions( bool ProxyDisabled, string? DevToolsPath, string[]? Projects, + string? AlcopsAnalyzers, string[] PassthroughArgs) { // almcp's arg parsing (ALMcpOptions.ParseArguments) has three arities, and treating them all @@ -31,6 +32,7 @@ public static ProxyOptions Parse(string[] args) bool disabled = false; string? devToolsPath = null; string[]? projects = null; + string? alcopsAnalyzers = null; var passthroughArgs = new List(); for (int i = 0; i < args.Length; i++) @@ -50,6 +52,10 @@ public static ProxyOptions Parse(string[] args) case "--projects" when i + 1 < args.Length: projects = args[++i].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); continue; + + case "--alcops-analyzers" when i + 1 < args.Length: + alcopsAnalyzers = args[++i]; + continue; } if (BooleanFlags.Contains(flag)) @@ -69,6 +75,6 @@ public static ProxyOptions Parse(string[] args) } } - return new ProxyOptions(disabled, devToolsPath, projects, [.. passthroughArgs]); + return new ProxyOptions(disabled, devToolsPath, projects, alcopsAnalyzers, [.. passthroughArgs]); } } diff --git a/src/ALCops.Mcp/Services/TargetFrameworkMoniker.cs b/src/ALCops.Mcp/Services/TargetFrameworkMoniker.cs new file mode 100644 index 0000000..4f32392 --- /dev/null +++ b/src/ALCops.Mcp/Services/TargetFrameworkMoniker.cs @@ -0,0 +1,150 @@ +using System.Reflection; +using System.Runtime.Versioning; + +namespace ALCops.Mcp.Services; + +/// +/// TFM utilities for matching NuGet lib/ folders to the installed DevTools runtime. +/// +internal static class TargetFrameworkMoniker +{ + /// + /// Converts a long framework name to its short form: + /// .NETCoreApp,Version=v10.0net10.0, + /// .NETStandard,Version=v2.1netstandard2.1. + /// + public static string? ShortName(string? frameworkName) + { + if (string.IsNullOrEmpty(frameworkName)) + return null; + + const string corePrefix = ".NETCoreApp,Version=v"; + const string standardPrefix = ".NETStandard,Version=v"; + + if (frameworkName.StartsWith(corePrefix, StringComparison.OrdinalIgnoreCase)) + return "net" + frameworkName[corePrefix.Length..]; + + if (frameworkName.StartsWith(standardPrefix, StringComparison.OrdinalIgnoreCase)) + return "netstandard" + frameworkName[standardPrefix.Length..]; + + return null; + } + + /// + /// Detects the TFM of the installed BC DevTools from the loaded Nav.CodeAnalysis assembly, + /// falling back to the TFM segment of the tools directory path. + /// + public static string DetectDevToolsTfm(string toolsDirectory) + { + try + { + var attr = typeof(Microsoft.Dynamics.Nav.CodeAnalysis.Compilation) + .Assembly + .GetCustomAttribute(); + + if (attr is not null) + { + var short_ = ShortName(attr.FrameworkName); + if (short_ is not null) + return short_; + } + } + catch + { + // Non-critical: fall through to path-based detection. + } + + // Fallback: extract from the tools directory path (e.g. .../tools/net10.0/any/). + var segments = toolsDirectory.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + for (var i = segments.Length - 1; i >= 0; i--) + { + if (segments[i].StartsWith("net", StringComparison.OrdinalIgnoreCase) && + segments[i].Contains('.')) + return segments[i]; + } + + return "net10.0"; + } + + /// + /// Picks the best lib/ folder from a NuGet package for the given target TFM. + /// Ports npm @alcops/core's findMatchingTfmFolder exactly: + /// exact → for netN.0 targets try net{N-1}.0 down to net6.0 → + /// netstandard2.1; for netstandardX.Y the lowest higher-or-equal minor. + /// + public static string? FindBestLibFolder(IEnumerable available, string target) + { + var set = new HashSet(available, StringComparer.OrdinalIgnoreCase); + + if (set.Count == 0) + return null; + + // Exact match. + if (set.Contains(target)) + return target; + + // For netN.0 targets: walk downward from N-1 to 6, then fall back to netstandard2.1. + if (TryParseNetVersion(target, out var major)) + { + for (var n = major - 1; n >= 6; n--) + { + var candidate = $"net{n}.0"; + if (set.Contains(candidate)) + return candidate; + } + + if (set.Contains("netstandard2.1")) + return "netstandard2.1"; + + return null; + } + + // For netstandardX.Y targets: lowest higher-or-equal minor wins. + if (TryParseNetstandardMinor(target, out var targetMinor)) + { + string? best = null; + int bestMinor = int.MaxValue; + + foreach (var folder in set) + { + if (TryParseNetstandardMinor(folder, out var minor) && minor >= targetMinor && minor < bestMinor) + { + best = folder; + bestMinor = minor; + } + } + + return best; + } + + return null; + } + + private static bool TryParseNetVersion(string tfm, out int major) + { + major = 0; + if (!tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase) || + tfm.StartsWith("netstandard", StringComparison.OrdinalIgnoreCase) || + tfm.StartsWith("netcoreapp", StringComparison.OrdinalIgnoreCase)) + return false; + + var rest = tfm[3..]; + var dotIndex = rest.IndexOf('.'); + var versionPart = dotIndex >= 0 ? rest[..dotIndex] : rest; + return int.TryParse(versionPart, out major) && major >= 5; + } + + private static bool TryParseNetstandardMinor(string tfm, out int minor) + { + minor = 0; + if (!tfm.StartsWith("netstandard", StringComparison.OrdinalIgnoreCase)) + return false; + + var rest = tfm["netstandard".Length..]; + var dotIndex = rest.IndexOf('.'); + if (dotIndex < 0) + return false; + + return int.TryParse(rest[(dotIndex + 1)..], out minor); + } +} diff --git a/src/ALCops.Mcp/Services/WorkspaceStartupResolver.cs b/src/ALCops.Mcp/Services/WorkspaceStartupResolver.cs index 50c470a..82381ac 100644 --- a/src/ALCops.Mcp/Services/WorkspaceStartupResolver.cs +++ b/src/ALCops.Mcp/Services/WorkspaceStartupResolver.cs @@ -38,32 +38,68 @@ public sealed class WorkspaceStartupResolver private readonly ProjectAnalyzerResolver _analyzerResolver; private readonly ExternalAnalyzerLoader _loader; + private readonly AlcopsAnalyzerProvisioner? _provisioner; private readonly ILogger _logger; private readonly string[]? _explicitProjects; - private readonly Lazy _config; + private readonly Lazy _projectConfig; public WorkspaceStartupResolver( ProjectAnalyzerResolver analyzerResolver, ExternalAnalyzerLoader loader, ILogger logger, string[]? explicitProjects = null) + : this(analyzerResolver, loader, null, logger, explicitProjects) { } + + internal WorkspaceStartupResolver( + ProjectAnalyzerResolver analyzerResolver, + ExternalAnalyzerLoader loader, + AlcopsAnalyzerProvisioner? provisioner, + ILogger logger, + string[]? explicitProjects = null) { _analyzerResolver = analyzerResolver; _loader = loader; + _provisioner = provisioner; _logger = logger; _explicitProjects = explicitProjects; - _config = new Lazy(Resolve); + _projectConfig = new Lazy(DiscoverProjectsOnly); } - public WorkspaceStartupConfig Config => _config.Value; + /// + /// Sync: project directories only. PrimaryProject is cheap; analyzer/ruleset fields are + /// empty. Use when the full config is needed. + /// + public WorkspaceStartupConfig Config => _projectConfig.Value; + + /// + /// Async: awaits the provisioner (if present) then resolves the full config including analyzers, + /// rulesets, and package cache paths. + /// + public async Task GetConfigAsync() + { + if (_provisioner is not null) + { + var folder = await _provisioner.Ready; + if (folder is not null) + _logger.LogInformation("ALCops analyzers provisioned at {Folder}", folder); + } + + return ResolveFullConfig(); + } /// /// Composes the child almcp argument list, merged with any passthrough args the user gave /// us. User-supplied flags always win — we only fill in what they left unset. /// - public string[] BuildAlMcpArgs(IReadOnlyList userArgs) + public string[] BuildAlMcpArgs(IReadOnlyList userArgs) => + ComposeAlMcpArgs(ResolveFullConfig(), userArgs); + + /// Async variant that awaits the provisioner before resolving the full config. + public async Task BuildAlMcpArgsAsync(IReadOnlyList userArgs) => + ComposeAlMcpArgs(await GetConfigAsync(), userArgs); + + private static string[] ComposeAlMcpArgs(WorkspaceStartupConfig config, IReadOnlyList userArgs) { - var config = Config; var userFlags = userArgs .Where(a => a.StartsWith("--", StringComparison.Ordinal)) .Select(a => a.ToLowerInvariant()) @@ -93,8 +129,6 @@ void AddIfUnset(string flag, string value) if (config.RulesetPath is not null) AddIfUnset("--rulesetpath", config.RulesetPath); - // Passed as written: almcp resolves relative entries against each project, exactly as the - // AL extension does, so a shared "../.alpackages" stays correct for every project. if (config.PackageCachePaths is { Count: > 0 }) AddIfUnset("--packagecachepath", string.Join(';', config.PackageCachePaths)); @@ -102,7 +136,7 @@ void AddIfUnset(string flag, string value) return [.. args]; } - private WorkspaceStartupConfig Resolve() + private WorkspaceStartupConfig DiscoverProjectsOnly() { var projects = _explicitProjects is { Length: > 0 } ? ResolveExplicitProjects(_explicitProjects) @@ -110,20 +144,26 @@ private WorkspaceStartupConfig Resolve() if (projects.Count == 0) { - // Not fatal: almcp prints its own "use the al_addproject tool" hint, and every native - // tool takes projectPath per call. A silently wrong cwd guess is the only bad failure - // mode here, which is exactly why all of this is logged. _logger.LogWarning( "No AL projects found under {Cwd} (scanned {Depth} levels for app.json). " + "Pass --projects [;] to set them explicitly.", Directory.GetCurrentDirectory(), MaxScanDepth); - return new WorkspaceStartupConfig([], [], null); } + else + { + _logger.LogInformation("Discovered {Count} AL project(s): {Projects}", + projects.Count, string.Join(", ", projects)); + } + + return new WorkspaceStartupConfig(projects, [], null); + } - _logger.LogInformation("Discovered {Count} AL project(s): {Projects}", - projects.Count, string.Join(", ", projects)); + private WorkspaceStartupConfig ResolveFullConfig() + { + var projects = Config.ProjectDirectories; + if (projects.Count == 0) + return Config; - // The first project's configuration drives the child almcp — it has one global analyzer set. var primary = projects[0]; if (projects.Count > 1) _logger.LogInformation("Using analyzer/ruleset configuration from {Project}", primary); @@ -148,10 +188,6 @@ private WorkspaceStartupConfig Resolve() _logger.LogInformation("Analyzer {Spec} -> {Path}", rawSpec, resolved); AddDistinct(analyzerPaths, Path.GetFullPath(resolved)); - // almcp turns each --codeanalyzers entry into an AnalyzerFileReference and resolves - // dependencies only among the paths it was given — it does not probe the analyzer's own - // directory. Anything left out surfaces as AD0001 "analyzer threw FileNotFoundException" - // instead of the rule's diagnostics, so the dependencies have to travel with it. foreach (var dependency in SiblingDependencies(resolved)) { _logger.LogInformation(" dependency of {Spec}: {Path}", rawSpec, dependency); @@ -167,8 +203,6 @@ private WorkspaceStartupConfig Resolve() var rulesetPath = _analyzerResolver.GetConfiguredRulesetPath(primary); _logger.LogInformation("Ruleset: {Path}", rulesetPath ?? "(none)"); - // Like analyzers and the ruleset, almcp in MCP mode never reads this from settings.json - // itself; unbridged, it would look in .alpackages while the project keeps its symbols elsewhere. var packageCachePaths = ProjectAnalyzerResolver.GetConfiguredPackageCachePaths(primary); _logger.LogInformation("Package cache: {Paths}", packageCachePaths is null ? ".alpackages (default)" : string.Join("; ", packageCachePaths)); diff --git a/tests/ALCops.Mcp.Tests/AlcopsAnalyzerProvisionerTests.cs b/tests/ALCops.Mcp.Tests/AlcopsAnalyzerProvisionerTests.cs new file mode 100644 index 0000000..8ae6760 --- /dev/null +++ b/tests/ALCops.Mcp.Tests/AlcopsAnalyzerProvisionerTests.cs @@ -0,0 +1,196 @@ +using System.IO.Compression; +using System.Net; +using ALCops.Mcp.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace ALCops.Mcp.Tests; + +public class AlcopsAnalyzerProvisionerTests : IDisposable +{ + private readonly string _cacheRoot = Path.Combine( + Path.GetTempPath(), $"alcops-provisioner-test-{Guid.NewGuid():N}"); + + private static readonly string Tfm = + TargetFrameworkMoniker.DetectDevToolsTfm(TestAnalyzers.ToolsLocator.ToolsDirectory); + + private const string IndexUrl = "https://api.nuget.org/v3-flatcontainer/alcops.analyzers/index.json"; + + private static readonly string IndexJson = + """{"versions":["0.4.1--no-branch-.1","1.0.0","1.1.0","1.2.0","1.3.0-preview.1"]}"""; + + public void Dispose() + { + if (Directory.Exists(_cacheRoot)) + Directory.Delete(_cacheRoot, recursive: true); + } + + private static string NupkgUrl(string v) => + $"https://api.nuget.org/v3-flatcontainer/alcops.analyzers/{v}/alcops.analyzers.{v}.nupkg"; + + private static byte[] BuildFakeNupkg(params (string tfm, string fileName)[] entries) + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (tfm, fileName) in entries) + { + var entry = zip.CreateEntry($"lib/{tfm}/{fileName}"); + using var stream = entry.Open(); + stream.Write([0x4D, 0x5A]); + } + } + + return ms.ToArray(); + } + + private AlcopsAnalyzerProvisioner Create(AlcopsAnalyzersOption option, FakeHandler handler) => + new(TestAnalyzers.ToolsLocator, option, handler, _cacheRoot, + NullLogger.Instance); + + [Fact] + public async Task Provision_ExtractsCorrectTfm_And_WritesManifest() + { + var handler = new FakeHandler(); + handler.Respond(IndexUrl, IndexJson); + handler.Respond(NupkgUrl("1.2.0"), + BuildFakeNupkg(($"{Tfm}", "ALCops.Fake.dll"), ("net8.0", "ALCops.Fake.dll"))); + + var provisioner = Create(AlcopsAnalyzersOption.Latest, handler); + await provisioner.ProvisionAsync(CancellationToken.None); + var result = await provisioner.Ready; + + Assert.NotNull(result); + Assert.True(File.Exists(Path.Combine(result, "ALCops.Fake.dll"))); + Assert.True(File.Exists(Path.Combine(result, ".alcops-manifest.json"))); + + var manifest = System.Text.Json.JsonDocument.Parse( + File.ReadAllText(Path.Combine(result, ".alcops-manifest.json"))); + Assert.Equal("1.2.0", manifest.RootElement.GetProperty("alcopsVersion").GetString()); + Assert.Equal(Tfm, manifest.RootElement.GetProperty("requestedTfm").GetString()); + } + + [Fact] + public async Task Provision_SecondRun_HitsCache_OnlyIndexRequested() + { + var handler1 = new FakeHandler(); + handler1.Respond(IndexUrl, IndexJson); + handler1.Respond(NupkgUrl("1.2.0"), + BuildFakeNupkg(($"{Tfm}", "ALCops.Fake.dll"))); + + var p1 = Create(AlcopsAnalyzersOption.Latest, handler1); + await p1.ProvisionAsync(CancellationToken.None); + Assert.NotNull(await p1.Ready); + + var handler2 = new FakeHandler(); + handler2.Respond(IndexUrl, IndexJson); + + var p2 = Create(AlcopsAnalyzersOption.Latest, handler2); + await p2.ProvisionAsync(CancellationToken.None); + var result = await p2.Ready; + + Assert.NotNull(result); + Assert.Single(handler2.RequestUrls); + Assert.Equal(IndexUrl, handler2.RequestUrls[0]); + } + + [Fact] + public async Task Provision_NetworkFailure_WarmCache_ReturnsCached() + { + var handler1 = new FakeHandler(); + handler1.Respond(IndexUrl, IndexJson); + handler1.Respond(NupkgUrl("1.2.0"), + BuildFakeNupkg(($"{Tfm}", "ALCops.Fake.dll"))); + + var p1 = Create(AlcopsAnalyzersOption.Latest, handler1); + await p1.ProvisionAsync(CancellationToken.None); + var firstResult = await p1.Ready; + Assert.NotNull(firstResult); + + var handler2 = new FakeHandler { ThrowOnRequest = true }; + var p2 = Create(AlcopsAnalyzersOption.Latest, handler2); + await p2.ProvisionAsync(CancellationToken.None); + var result = await p2.Ready; + + Assert.NotNull(result); + Assert.Equal(firstResult, result); + } + + [Fact] + public async Task Provision_NetworkFailure_ColdCache_ReturnsNull() + { + var handler = new FakeHandler { ThrowOnRequest = true }; + var provisioner = Create(AlcopsAnalyzersOption.Latest, handler); + await provisioner.ProvisionAsync(CancellationToken.None); + var result = await provisioner.Ready; + + Assert.Null(result); + } + + [Fact] + public async Task Provision_Off_ZeroRequests() + { + var handler = new FakeHandler(); + var provisioner = Create(AlcopsAnalyzersOption.Off, handler); + await provisioner.ProvisionAsync(CancellationToken.None); + var result = await provisioner.Ready; + + Assert.Null(result); + Assert.Empty(handler.RequestUrls); + } + + [Fact] + public async Task Provision_Pinned_NoIndexRequest() + { + var handler = new FakeHandler(); + handler.Respond(NupkgUrl("1.1.0"), + BuildFakeNupkg(($"{Tfm}", "ALCops.Fake.dll"))); + + var provisioner = Create( + AlcopsAnalyzersOption.Parse("1.1.0"), handler); + await provisioner.ProvisionAsync(CancellationToken.None); + var result = await provisioner.Ready; + + Assert.NotNull(result); + Assert.DoesNotContain(IndexUrl, handler.RequestUrls); + Assert.Contains(NupkgUrl("1.1.0"), handler.RequestUrls); + } + + internal sealed class FakeHandler : HttpMessageHandler + { + private readonly Dictionary> _responses = new(StringComparer.OrdinalIgnoreCase); + public List RequestUrls { get; } = []; + public bool ThrowOnRequest { get; set; } + + public void Respond(string url, string json) + { + _responses[url] = () => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + + public void Respond(string url, byte[] content) + { + _responses[url] = () => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(content) + }; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + RequestUrls.Add(url); + + if (ThrowOnRequest) + throw new HttpRequestException("Network unavailable (test)"); + + if (_responses.TryGetValue(url, out var factory)) + return Task.FromResult(factory()); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } +} diff --git a/tests/ALCops.Mcp.Tests/ExternalAnalyzerLoaderTests.cs b/tests/ALCops.Mcp.Tests/ExternalAnalyzerLoaderTests.cs new file mode 100644 index 0000000..46a1b2b --- /dev/null +++ b/tests/ALCops.Mcp.Tests/ExternalAnalyzerLoaderTests.cs @@ -0,0 +1,97 @@ +using ALCops.Mcp.Services; +using Xunit; + +namespace ALCops.Mcp.Tests; + +public class ExternalAnalyzerLoaderTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), $"alcops-loader-test-{Guid.NewGuid():N}"); + + public void Dispose() + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + + private string CreateDir(params string[] segments) + { + var path = Path.Combine([_root, .. segments]); + Directory.CreateDirectory(path); + return path; + } + + private static void CreateStubDll(string dir, string name) + { + File.WriteAllBytes(Path.Combine(dir, name), [0x4D, 0x5A]); + } + + [Fact] + public void ProvisionedFolder_WinsOverToolsFolder_ForAnalyzerFolderSpec() + { + var tools = CreateDir("tools"); + var provisioned = CreateDir("provisioned"); + + CreateStubDll(tools, "ALCops.LinterCop.dll"); + CreateStubDll(provisioned, "ALCops.LinterCop.dll"); + + var locator = new BcToolsLocator(tools); + var loader = new ExternalAnalyzerLoader(locator, () => provisioned); + + var spec = AnalyzerSpec.Parse("${analyzerFolder}ALCops.LinterCop.dll"); + var result = loader.ResolveDllPath(spec, _root); + + Assert.Equal(Path.Combine(provisioned, "ALCops.LinterCop.dll"), result); + } + + [Fact] + public void ProvisionedFolder_FallsThrough_WhenFileAbsent() + { + var tools = CreateDir("tools"); + var provisioned = CreateDir("provisioned"); + + CreateStubDll(tools, "ALCops.LinterCop.dll"); + // provisioned folder has nothing + + var locator = new BcToolsLocator(tools); + var loader = new ExternalAnalyzerLoader(locator, () => provisioned); + + var spec = AnalyzerSpec.Parse("${analyzerFolder}ALCops.LinterCop.dll"); + var result = loader.ResolveDllPath(spec, _root); + + Assert.Equal(Path.Combine(tools, "ALCops.LinterCop.dll"), result); + } + + [Fact] + public void ProvisionedFolder_NotUsed_ForWellKnownBcCop() + { + var tools = CreateDir("tools"); + var provisioned = CreateDir("provisioned"); + + CreateStubDll(tools, "Microsoft.Dynamics.Nav.CodeCop.dll"); + CreateStubDll(provisioned, "Microsoft.Dynamics.Nav.CodeCop.dll"); + + var locator = new BcToolsLocator(tools); + var loader = new ExternalAnalyzerLoader(locator, () => provisioned); + + var spec = AnalyzerSpec.Parse("${CodeCop}"); + var result = loader.ResolveDllPath(spec, _root); + + Assert.Equal(Path.Combine(tools, "Microsoft.Dynamics.Nav.CodeCop.dll"), result); + } + + [Fact] + public void NullProvisioner_FallsToToolsFolder() + { + var tools = CreateDir("tools"); + CreateStubDll(tools, "ALCops.LinterCop.dll"); + + var locator = new BcToolsLocator(tools); + var loader = new ExternalAnalyzerLoader(locator); + + var spec = AnalyzerSpec.Parse("${analyzerFolder}ALCops.LinterCop.dll"); + var result = loader.ResolveDllPath(spec, _root); + + Assert.Equal(Path.Combine(tools, "ALCops.LinterCop.dll"), result); + } +} diff --git a/tests/ALCops.Mcp.Tests/NuGetVersionsTests.cs b/tests/ALCops.Mcp.Tests/NuGetVersionsTests.cs new file mode 100644 index 0000000..db3336d --- /dev/null +++ b/tests/ALCops.Mcp.Tests/NuGetVersionsTests.cs @@ -0,0 +1,73 @@ +using ALCops.Mcp.Services; +using Xunit; + +namespace ALCops.Mcp.Tests; + +public class NuGetVersionsTests +{ + [Fact] + public void Parse_FindsLatestStable_And_HighestOverall() + { + var json = """{"versions":["0.4.1--no-branch-.1","1.0.0","1.1.0","1.2.0","1.3.0-preview.1"]}"""; + + var (latest, prerelease) = NuGetVersions.Parse(json); + + Assert.Equal("1.2.0", latest); + Assert.Equal("1.3.0-preview.1", prerelease); + } + + [Fact] + public void Parse_StableIsHighestOverall_WhenNoPrerelease() + { + var json = """{"versions":["1.0.0","1.1.0","1.2.0"]}"""; + + var (latest, prerelease) = NuGetVersions.Parse(json); + + Assert.Equal("1.2.0", latest); + Assert.Equal("1.2.0", prerelease); + } + + [Fact] + public void Parse_MalformedEntries_AreSkipped() + { + var json = """{"versions":["not-a-version","also broken","1.0.0"]}"""; + + var (latest, prerelease) = NuGetVersions.Parse(json); + + Assert.Equal("1.0.0", latest); + Assert.Equal("1.0.0", prerelease); + } + + [Fact] + public void Parse_EmptyVersionsList_ReturnsNulls() + { + var json = """{"versions":[]}"""; + + var (latest, prerelease) = NuGetVersions.Parse(json); + + Assert.Null(latest); + Assert.Null(prerelease); + } + + [Fact] + public void Parse_OnlyPrereleases_LatestIsNull() + { + var json = """{"versions":["1.0.0-alpha","2.0.0-beta"]}"""; + + var (latest, prerelease) = NuGetVersions.Parse(json); + + Assert.Null(latest); + Assert.Equal("2.0.0-beta", prerelease); + } + + [Fact] + public void Parse_MissingVersionsProperty_ReturnsNulls() + { + var json = """{"other":"data"}"""; + + var (latest, prerelease) = NuGetVersions.Parse(json); + + Assert.Null(latest); + Assert.Null(prerelease); + } +} diff --git a/tests/ALCops.Mcp.Tests/ProxyOptionsTests.cs b/tests/ALCops.Mcp.Tests/ProxyOptionsTests.cs index a6ea998..5f48504 100644 --- a/tests/ALCops.Mcp.Tests/ProxyOptionsTests.cs +++ b/tests/ALCops.Mcp.Tests/ProxyOptionsTests.cs @@ -48,11 +48,21 @@ public void Parse_ValueFlagWithMissingValue_IsDroppedRatherThanForwardedBare() public void Parse_OurOwnFlags_AreConsumedNotForwarded() { var options = ProxyOptions.Parse( - ["--no-proxy", "--devtools-path", @"C:\tools", "--projects", @"C:\a;C:\b", "--nolog"]); + ["--no-proxy", "--devtools-path", @"C:\tools", "--projects", @"C:\a;C:\b", "--alcops-analyzers", "off", "--nolog"]); Assert.True(options.ProxyDisabled); Assert.Equal(@"C:\tools", options.DevToolsPath); Assert.Equal([@"C:\a", @"C:\b"], options.Projects ?? []); + Assert.Equal("off", options.AlcopsAnalyzers); + Assert.Equal(["--nolog"], options.PassthroughArgs); + } + + [Fact] + public void Parse_AlcopsAnalyzers_IsConsumedNotForwarded() + { + var options = ProxyOptions.Parse(["--alcops-analyzers", "1.2.0", "--nolog"]); + + Assert.Equal("1.2.0", options.AlcopsAnalyzers); Assert.Equal(["--nolog"], options.PassthroughArgs); } @@ -70,6 +80,7 @@ public void Parse_NoArgs_LeavesProxyEnabledWithNoOverrides() Assert.False(options.ProxyDisabled); Assert.Null(options.DevToolsPath); Assert.Null(options.Projects); + Assert.Null(options.AlcopsAnalyzers); Assert.Empty(options.PassthroughArgs); } } diff --git a/tests/ALCops.Mcp.Tests/TargetFrameworkMonikerTests.cs b/tests/ALCops.Mcp.Tests/TargetFrameworkMonikerTests.cs new file mode 100644 index 0000000..c70f943 --- /dev/null +++ b/tests/ALCops.Mcp.Tests/TargetFrameworkMonikerTests.cs @@ -0,0 +1,83 @@ +using ALCops.Mcp.Services; +using Xunit; + +namespace ALCops.Mcp.Tests; + +public class TargetFrameworkMonikerTests +{ + [Theory] + [InlineData(".NETCoreApp,Version=v10.0", "net10.0")] + [InlineData(".NETCoreApp,Version=v8.0", "net8.0")] + [InlineData(".NETCoreApp,Version=v6.0", "net6.0")] + [InlineData(".NETStandard,Version=v2.1", "netstandard2.1")] + [InlineData(".NETStandard,Version=v2.0", "netstandard2.0")] + public void ShortName_ConvertsLongFrameworkName(string input, string expected) + { + Assert.Equal(expected, TargetFrameworkMoniker.ShortName(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("UnknownFramework,Version=v1.0")] + public void ShortName_ReturnsNull_ForUnrecognisedInput(string? input) + { + Assert.Null(TargetFrameworkMoniker.ShortName(input)); + } + + [Fact] + public void FindBestLibFolder_ExactMatch() + { + Assert.Equal("net10.0", TargetFrameworkMoniker.FindBestLibFolder(["net10.0", "net8.0"], "net10.0")); + } + + [Fact] + public void FindBestLibFolder_Net10FallsBackToNet8() + { + Assert.Equal("net8.0", TargetFrameworkMoniker.FindBestLibFolder(["net8.0"], "net10.0")); + } + + [Fact] + public void FindBestLibFolder_Net10PicksHighestAvailableFallback() + { + Assert.Equal("net9.0", TargetFrameworkMoniker.FindBestLibFolder(["net8.0", "net9.0"], "net10.0")); + } + + [Fact] + public void FindBestLibFolder_FallsToNetstandardWhenNoNetMatch() + { + Assert.Equal("netstandard2.1", TargetFrameworkMoniker.FindBestLibFolder(["netstandard2.1"], "net10.0")); + } + + [Fact] + public void FindBestLibFolder_NetstandardTarget_LowestHigherOrEqualMinorWins() + { + Assert.Equal("netstandard2.1", + TargetFrameworkMoniker.FindBestLibFolder(["netstandard2.0", "netstandard2.1"], "netstandard2.1")); + } + + [Fact] + public void FindBestLibFolder_NetstandardTarget_HigherMinorAccepted() + { + Assert.Equal("netstandard2.1", + TargetFrameworkMoniker.FindBestLibFolder(["netstandard2.1"], "netstandard2.0")); + } + + [Fact] + public void FindBestLibFolder_ReturnsNull_WhenNothingCompatible() + { + Assert.Null(TargetFrameworkMoniker.FindBestLibFolder(["net5.0"], "net10.0")); + } + + [Fact] + public void FindBestLibFolder_ReturnsNull_WhenEmpty() + { + Assert.Null(TargetFrameworkMoniker.FindBestLibFolder([], "net10.0")); + } + + [Fact] + public void FindBestLibFolder_CaseInsensitive() + { + Assert.NotNull(TargetFrameworkMoniker.FindBestLibFolder(["Net10.0"], "net10.0")); + } +}