Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ When passing analyzers to the child `almcp`, their sibling dependencies must tra
- Tools return JSON-serialized results. Errors are caught and returned as `{ error, message }` JSON, not thrown.
- `apply_fix` writes to disk and reloads the project session; `apply_fix_all` does the same across every occurrence of a rule (unless `dryRun`); the other two are read-only.
- `al_compile` defaults to `onlyErrors: true` while nearly every ALCops rule is a warning — callers must pass `onlyErrors: false`. This is documented rather than patched, because `ForwardAsync` stays a generic passthrough.
- After `apply_fix` / `apply_fix_all`, verify with `al_compile` (`onlyErrors: false`), not `al_getdiagnostics`. almcp's child has a `ProjectWatcher` (`FileSystemWatcher`) that re-reads changed `.al` files, and `al_compile` awaits `WaitForProcessingAsync` before compiling, so it picks up on-disk changes reliably. `al_getdiagnostics` returns cached compilation results without re-analyzing and will report stale diagnostics.

## Conventions

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ Pass `--no-proxy` to serve only the native tools. Use it when your agent already

> **`al_compile` defaults to `onlyErrors: true`.** Nearly every ALCops rule is a *warning*, so pass `onlyErrors: false` or you will see no cop diagnostics at all.

### Verifying a fix

After `apply_fix` or `apply_fix_all`, use `al_compile` with `onlyErrors: false` to confirm the diagnostic is gone. `al_compile` awaits almcp's internal file watcher, so it picks up the on-disk change reliably. Do **not** use `al_getdiagnostics` for this: it returns cached compilation results rather than re-analyzing, and will report stale (or empty) diagnostics. Only restarting the server gives a fully fresh almcp workspace.

## 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.
Expand Down
3 changes: 2 additions & 1 deletion src/ALCops.Mcp/Tools/ApplyFixAllTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public sealed class ApplyFixAllTool
[McpServerTool(Name = "apply_fix_all", ReadOnly = false, Destructive = false),
Description("Apply a code fix to every occurrence of a diagnostic rule across a project (or a single file). " +
"Runs analysis once, then fixes all matches for that rule ID in one pass — like VS Code's 'Fix all in workspace'. " +
"Writes changed files directly to disk unless dryRun is true. Use get_fixes first to discover equivalenceKey options.")]
"Writes changed files directly to disk unless dryRun is true. Use get_fixes first to discover equivalenceKey options. " +
"Verify with al_compile (onlyErrors: false).")]
public static async Task<string> ApplyFixAll(
ProjectSessionManager sessionManager,
CodeFixRunner codeFixRunner,
Expand Down
2 changes: 1 addition & 1 deletion src/ALCops.Mcp/Tools/ApplyFixTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace ALCops.Mcp.Tools;
public sealed class ApplyFixTool
{
[McpServerTool(Name = "apply_fix", ReadOnly = false, Destructive = false),
Description("Apply a code fix to resolve a diagnostic. Writes the fixed content directly to the file on disk and returns a summary of what changed.")]
Description("Apply a code fix to resolve a diagnostic. Writes the fixed content directly to the file on disk and returns a summary of what changed. Verify with al_compile (onlyErrors: false).")]
public static async Task<string> ApplyFix(
ProjectSessionManager sessionManager,
CodeFixRunner codeFixRunner,
Expand Down
4 changes: 2 additions & 2 deletions tests/ALCops.Mcp.Tests/AlMcpProxyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ public sealed class AlMcpFixture : IAsyncLifetime
/// A proxy over a fresh copy of the MinimalProject fixture, not yet started. The lifecycle tests
/// drive start/stop themselves, so they cannot share this fixture's already-running child.
/// </summary>
public static AlMcpProxy CreateProxy(CapturingLogger logger, out string projectDir)
public static AlMcpProxy CreateProxy(CapturingLogger logger, out string projectDir, string fixtureName = "MinimalProject")
{
projectDir = TestAnalyzers.CopyFixtureWithAnalyzers("MinimalProject", "alcops-proxy-test");
projectDir = TestAnalyzers.CopyFixtureWithAnalyzers(fixtureName, "alcops-proxy-test");

var loader = new ExternalAnalyzerLoader(Locator!);
var resolver = new WorkspaceStartupResolver(
Expand Down
123 changes: 123 additions & 0 deletions tests/ALCops.Mcp.Tests/ApplyFixThenCompileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System.Text.Json;
using ALCops.Mcp.Services;
using ALCops.Mcp.Tools;
using ModelContextProtocol.Protocol;
using Xunit;
using Xunit.Abstractions;

namespace ALCops.Mcp.Tests;

/// <summary>
/// End-to-end: apply a native code fix, then ask almcp to recompile and verify the fixed
/// diagnostic is gone. Regression guard for the "stale diagnostics after apply_fix" report
/// (PR #20 known issue). <c>al_getdiagnostics</c> was observed to return zero diagnostics on
/// this fixture because it reads the existing compilation without draining almcp's file watcher.
/// Therefore <c>al_compile</c> with <c>onlyErrors: false</c> is the only verification path
/// this suite covers.
/// </summary>
[Collection(ApplyFixAlMcpFixture.CollectionName)]
public sealed class ApplyFixThenCompileTests(ApplyFixAlMcpFixture fixture, ITestOutputHelper output) : IDisposable
{
private CancellationTokenSource Cts { get; } = new(TimeSpan.FromSeconds(90));

public void Dispose() => Cts.Dispose();

private static IDictionary<string, JsonElement> Args(object value) =>
JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(JsonSerializer.Serialize(value))!;

private static string ConcatTextContent(CallToolResult result) =>
string.Join('\n', result.Content.OfType<TextContentBlock>().Select(b => b.Text));

[AlMcpFact]
public async Task ApplyFix_ThenAlCompile_NoLongerReportsFixedDiagnostic()
{
var proxy = fixture.Proxy;
var projectDir = fixture.ProjectDir;
var filePath = Path.Combine(projectDir, "MyPage.al");

// 1. Compile — LC0020 must be present before the fix
var before = await proxy.ForwardAsync("al_compile", Args(new { onlyErrors = false }), Cts.Token);
var beforeText = ConcatTextContent(before);
output.WriteLine("=== al_compile BEFORE fix ===");
output.WriteLine(beforeText);
foreach (var logLine in fixture.Logger.Lines)
output.WriteLine($" [log] {logLine}");

Assert.True(beforeText.Contains("LC0020"),
$"Expected LC0020 in al_compile output before applying the fix.\n{beforeText}");

// 2. Apply the fix (same path as ApplyFixToolTests.ApplyFix_WritesModifiedContentToDisk)
using var sessionManager = new ProjectSessionManager(new ProjectLoader());
var codeFixRunner = new CodeFixRunner();
var loader = new ExternalAnalyzerLoader(TestAnalyzers.ToolsLocator);
var analyzerResolver = new ProjectAnalyzerResolver(loader, new RulesetLoader());

var session = await sessionManager.GetOrLoadProjectAsync(projectDir, Cts.Token);
var analyzerSet = await analyzerResolver.ResolveAsync(projectDir, null, Cts.Token);

const int line = 11, column = 17;
var fixes = await codeFixRunner.GetFixesAsync(session, filePath, "LC0020", line, column, analyzerSet, Cts.Token);
Assert.True(fixes.Count > 0, "Expected a fixable LC0020 at line 11, column 17.");

var applyResult = await ApplyFixTool.ApplyFix(
sessionManager, codeFixRunner, analyzerResolver,
projectDir, filePath, "LC0020", line, column,
fixes[0].EquivalenceKey, analyzers: null, Cts.Token);

Assert.Contains("\"applied\":true", applyResult);

// 3. Compile again — LC0020 must be gone
fixture.Logger.Lines.Clear();
var after = await proxy.ForwardAsync("al_compile", Args(new { onlyErrors = false }), Cts.Token);
var afterText = ConcatTextContent(after);
output.WriteLine("=== al_compile AFTER fix ===");
output.WriteLine(afterText);
foreach (var logLine2 in fixture.Logger.Lines)
output.WriteLine($" [log] {logLine2}");

Assert.False(afterText.Contains("LC0020"),
$"LC0020 still reported after applying the fix — stale diagnostics.\n{afterText}");
}
}

/// <summary>
/// Dedicated fixture for apply-fix-then-compile tests: uses the ApplyFixProject fixture
/// (which has LC0020 at MyPage.al line 11 col 17) and its own almcp child.
/// </summary>
public sealed class ApplyFixAlMcpFixture : IAsyncLifetime
{
public const string CollectionName = "almcp-applyfix";

public AlMcpProxy Proxy { get; private set; } = null!;
public CapturingLogger Logger { get; } = new();
public string ProjectDir { get; private set; } = string.Empty;

public static bool IsAvailable => AlMcpFixture.IsAvailable;

public async Task InitializeAsync()
{
if (!IsAvailable)
return;

Proxy = AlMcpFixture.CreateProxy(Logger, out var projectDir, "ApplyFixProject");
ProjectDir = projectDir;

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
await Proxy.StartAsync(cts.Token);
}

public async Task DisposeAsync()
{
if (Proxy is not null)
await Proxy.DisposeAsync();

if (Directory.Exists(ProjectDir))
{
try { Directory.Delete(ProjectDir, recursive: true); }
catch (IOException) { }
}
}
}

[CollectionDefinition(ApplyFixAlMcpFixture.CollectionName)]
public sealed class ApplyFixAlMcpCollection : ICollectionFixture<ApplyFixAlMcpFixture>;
Loading