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.0 → net10.0,
+ /// .NETStandard,Version=v2.1 → netstandard2.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"));
+ }
+}