Skip to content
Merged
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
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ It is deliberately **thin**: Microsoft's `almcp` already compiles, runs diagnost
### Key layers

- **Tools/** — MCP tool endpoints, annotated `[McpServerToolType]` / `[McpServerTool]`, auto-discovered via `WithToolsFromAssembly()`. Five native tools: `list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`, `analyze`. The proxied `al_*` tools are served by the dynamic list/call handlers in `McpHost`, not by classes here.
- **Services/**, all singletons registered in `McpHost`:
- **Services/**, singletons registered in `McpHost` unless noted:
- `BcToolsLocator` — the single runtime lookup. Finds the one directory holding both `Microsoft.Dynamics.Nav.*.dll` and `almcp` (they ship side by side). Probe order: `--devtools-path` → `BCDEVELOPMENTTOOLSPATH` → dotnet tool store → hard error naming every probe and the install command. The AL VS Code extension is no longer probed; the dotnet tool store is the primary channel on every OS. No auto-install by design. `AlMcpLaunch` describes how to start the child: native `almcp.exe` on Windows, `dotnet almcp.dll` on Linux/macOS (the nupkg ships no extension-less launcher).
- `WorkspaceStartupResolver` — discovers AL projects (mirrors `almcp`'s own `DiscoverProjectPaths`: downward scan for `app.json`, depth 4, standard exclusions) and composes the child `almcp`'s `--projects` / `--codeanalyzers` / `--rulesetpath` / `--packagecachepath` args. `almcp` in MCP mode never reads `.vscode/settings.json` and has no per-call analyzer, ruleset or package-cache parameter, so this bridge at launch is the only thing keeping `al_compile` and our fix tools in agreement (`ProjectLoader` reads the same `al.packageCachePath` for the in-process compilation).
- `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` — cache-first: when a valid cached version exists, `Task<string?> Ready` completes immediately with it and a background task checks NuGet for a newer version (for the next start). `internal Task BackgroundRefresh` is that background task, drained by `ProvisionAsync` and cancelled by `AlcopsAnalyzerProvisionerStartup.StopAsync`. On a cold cache or with a pinned version, the provisioner fetches from NuGet before completing. Internal HTTP timeouts surface as `TimeoutException` and fall back to cache; only the caller's cancellation propagates. Version ordering is SemVer 2 via `SemanticVersion`. The `.in-use` lock (`FileShare.None`) is a cross-process lock via `flock` on Unix, reliable on local file systems and advisory on network mounts such as NFS home directories. 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. On a cache hit it calls `ProjectSession.RefreshFromDiskAsync`: length+mtime gate, then ordinal content compare; changed files get `OnDocumentTextChanged`, new/deleted files are added/removed; untouched documents keep `DocumentId` and compilation state. Mutations go through `Workspace.OnDocument*` (like almcp's `ProjectWatcher`), never `TryApplyChanges` on a forked solution.
- `ToolResults` / `ToolErrors` — static helpers, not registered. `ToolResults.Ok` serializes a success payload; `ToolErrors` is the only place a tool error is built (see Tool patterns).
- `ProjectScope` — static helper, not registered. `Resolve` validates `projectPath` against the startup projects for `analyze` and `list_rules`; `RequireProjectFolder` checks that the fix tools got a folder that exists and holds `app.json`. A failure is an `Invalid` error.
- `GuardedFileWriter` — atomic, encoding-preserving `.al` writes; the only code that writes `.al` files. Used by `apply_fix` (`WriteIfUnchangedAsync`) and `apply_fix_all` (`WriteAllIfUnchangedAsync`). Its internal `Action<string,string>` constructor is a test seam for injecting move failures.
- **Models/** — record types for tool return values, serialized with `JsonDefaults.Options` (camelCase, not indented).

Expand All @@ -64,13 +66,14 @@ When passing analyzers to the child `almcp`, their sibling dependencies must tra

### Tool patterns

- All tool methods are `static async Task<string>`, receiving DI services as parameters.
- Tools return JSON-serialized results. Errors are caught and returned as `{ error, message }` JSON, not thrown.
- `list_rules` is read-only. `get_fixes`, `apply_fix` and `apply_fix_all` re-read changed `.al` files from disk before computing a fix (via `RefreshFromDiskAsync`); the write only proceeds if the file still equals the text the fix was computed from. `apply_fix` returns `{ error: "StaleFile" }` on a conflict; `apply_fix_all` writes the non-conflicting files and lists the rest in `conflicts`. There is no explicit reload after writing — the next `GetOrLoadProjectAsync` call picks the written file up via the same refresh.
- Writes go through `GuardedFileWriter`: read the file as bytes → decode with BOM detection → ordinal compare with the expected text → write preamble + new text to a sibling `<path>.<guid>.alcops.tmp` → `File.Move(temp, path, overwrite: true)`; the temp is deleted in `finally`. The encoding is detected at write time from that read, not stored on `TrackedDocument`. The BOM is sniffed explicitly (UTF-32 LE before UTF-16 LE) and every branch, BOM-less UTF-8 included, uses a strict (throwing) decoder: bytes invalid in the detected encoding (e.g. a Windows-1252 file, a stray Windows-1252 byte behind a UTF-8 BOM, a lone surrogate in UTF-16; the loader reads all of these with U+FFFD replacement, so the stale check alone would pass) are refused as `UnsupportedEncoding` instead of being re-encoded. Never decode through `StreamReader` with BOM detection: it swaps in its own lossy encoding when a BOM is present. `File.Move` replaces a symlink at the target with a regular file and drops Unix mode bits; both accepted, out of scope. **Never `File.Replace` and never a backup file**: almcp's `ProjectWatcher` (`*.al` filter, Renamed handler checks `EndsWith(".al")`) treats a move-over as an in-place update that keeps the `DocumentId`, while replace-with-backup makes it remove and re-add the document. Every `FileWriteConflict` has a `Kind` (serialized as `kind`): `StaleFile`, `UnsupportedEncoding`, `ReadFailed`, `WriteFailed`, `RolledBack`, `NotWritten`; `apply_fix` returns it as `error`. `apply_fix_all` is two-phase: stale/deleted/unreadable/invalidly-encoded files, and files whose fixed text the strict encoder cannot encode (also `UnsupportedEncoding`), drop out as conflicts first, then the rest are committed one by one; a commit failure (any exception; a cancellation is rethrown after the rollback) restores every file already committed in that call from the original bytes held in memory (same temp + move path) and reports the whole staged set in `conflicts` with `applied: false`. A rollback that itself fails, or a file modified after this call wrote it (rollback compares current bytes with what was written and skips the restore), is named in `message` and that file stays in `filesChanged`. `LoadProjectAsync` and `RefreshFromDiskAsync` sweep stray `*.alcops.tmp` files outside `.alpackages` (`ProjectLoader.SweepTempFiles`); the refresh only removes files older than 30 s because tool calls run concurrently and a write may be between temp-create and move.
- All tool methods are `static async Task<CallToolResult>`, receiving DI services as parameters. Success goes through `ToolResults.Ok(payload)` (`IsError` left unset); errors only through `ToolErrors`. No tool calls `JsonSerializer.Serialize` itself or builds an anonymous `error` object.
- Every error sets `IsError = true` and carries one `ToolError` envelope `{ error, message, reason?, candidates?, filePath?, diagnosticId?, detail? }` (optional fields omitted when null via per-property `JsonIgnore`; `JsonDefaults.Options` is not changed). `error` is one of `Invalid`, `NotFound`, `Ambiguous`, `Stale`, `Unavailable`, `Faulted`; `NotFound` reasons are the `FixNotFoundReason` names, `Unavailable` reasons the `UnavailableReason` constants. The README `## Errors` section is the public table. Every tool body ends in `catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { return ToolErrors.Faulted(ex); }`: a cancellation propagates, anything else becomes `Faulted` with `detail` = the exception's full type name.
- `get_fixes` returns `{ diagnosticId, filePath, line, column, fixes }` with a non-empty `fixes`; every no-match case is `NotFound` with a reason (`CodeFixRunner` returns `FixLookupResult` / `FixApplyResult` / `FixAllResult` with a `FixNotFoundReason`). Ruleset suppression is checked before analysis; pragma suppression is told apart from no diagnostic because `GetEffectiveDiagnostics` keeps pragma-suppressed diagnostics with `IsSuppressed = true`. Equivalence keys are compared through `KeyOf(action) => action.EquivalenceKey ?? ""` on both the advertising and the matching side, so every advertised key round-trips. `candidates` are de-duplicated by key, first occurrence wins.
- `list_rules` is read-only. `get_fixes`, `apply_fix` and `apply_fix_all` re-read changed `.al` files from disk before computing a fix (via `RefreshFromDiskAsync`); the write only proceeds if the file still equals the text the fix was computed from. `apply_fix` returns the error `Stale` on a conflict; `apply_fix_all` writes the non-conflicting files and lists the rest in `conflicts`. There is no explicit reload after writing — the next `GetOrLoadProjectAsync` call picks the written file up via the same refresh.
- Writes go through `GuardedFileWriter`: read the file as bytes → decode with BOM detection → ordinal compare with the expected text → write preamble + new text to a sibling `<path>.<guid>.alcops.tmp` → `File.Move(temp, path, overwrite: true)`; the temp is deleted in `finally`. The encoding is detected at write time from that read, not stored on `TrackedDocument`. The BOM is sniffed explicitly (UTF-32 LE before UTF-16 LE) and every branch, BOM-less UTF-8 included, uses a strict (throwing) decoder: bytes invalid in the detected encoding (e.g. a Windows-1252 file, a stray Windows-1252 byte behind a UTF-8 BOM, a lone surrogate in UTF-16; the loader reads all of these with U+FFFD replacement, so the stale check alone would pass) are refused as `UnsupportedEncoding` instead of being re-encoded. Never decode through `StreamReader` with BOM detection: it swaps in its own lossy encoding when a BOM is present. `File.Move` replaces a symlink at the target with a regular file and drops Unix mode bits; both accepted, out of scope. **Never `File.Replace` and never a backup file**: almcp's `ProjectWatcher` (`*.al` filter, Renamed handler checks `EndsWith(".al")`) treats a move-over as an in-place update that keeps the `DocumentId`, while replace-with-backup makes it remove and re-add the document. Every `FileWriteConflict` has a `Kind` (serialized as `kind`): `StaleFile`, `UnsupportedEncoding`, `ReadFailed`, `WriteFailed`, `RolledBack`, `NotWritten`; `apply_fix_all` reports it verbatim in `conflicts[].kind`, `apply_fix` maps `StaleFile` to the error `Stale` and every other kind to `Faulted` with `reason` = the kind (an exception thrown by the write itself is `Faulted` / `WriteFailed`). `apply_fix_all` is two-phase: stale/deleted/unreadable/invalidly-encoded files, and files whose fixed text the strict encoder cannot encode (also `UnsupportedEncoding`), drop out as conflicts first, then the rest are committed one by one; a commit failure (any exception; a cancellation is rethrown after the rollback) restores every file already committed in that call from the original bytes held in memory (same temp + move path) and reports the whole staged set in `conflicts` with `applied: false`. That rolled-back batch is a normal (non-`isError`) result with the failure in `message`, because the per-file outcome is in the body; only argument, lookup and single-file write failures use the error envelope. A rollback that itself fails, or a file modified after this call wrote it (rollback compares current bytes with what was written and skips the restore), is named in `message` and that file stays in `filesChanged`. `LoadProjectAsync` and `RefreshFromDiskAsync` sweep stray `*.alcops.tmp` files outside `.alpackages` (`ProjectLoader.SweepTempFiles`); the refresh only removes files older than 30 s because tool calls run concurrently and a write may be between temp-create and move.
- `al_compile` defaults to `onlyErrors: true` while nearly every ALCops rule is a warning — callers must pass `options.onlyErrors: false` (the flag lives inside almcp's `options` object; a top-level `onlyErrors` is ignored, and omitting `options` entirely also yields `onlyErrors: false` with no diagnostics cap). This is documented rather than patched, because `ForwardAsync` stays a generic passthrough.
- After `apply_fix` / `apply_fix_all`, verify with `analyze` (preferred) or `al_compile` (`options.onlyErrors: false`), not `al_getdiagnostics`. almcp's `ProjectWatcher` (`FileSystemWatcher`) re-reads changed `.al` files, and `al_compile` awaits `WaitForProcessingAsync` before compiling, so it normally picks up on-disk changes before the compile starts. The gate starts signalled and has no debounce, so on slow file systems or right after a large `apply_fix_all` a second `al_compile` may be needed if the watcher has not yet delivered the change notification. `al_getdiagnostics` returns cached compilation results without re-analyzing and will report stale diagnostics. `al_build` does not await the watcher at all.
- `analyze` is the one native tool that goes through `AlMcpProxy.ForwardAsync` (`al_compile` with nested `options.onlyErrors=false`, `enableCodeAnalysis=true`, `maxDiagnosticsPerCompilation=int.MaxValue`, never `codeAnalyzers`); it resolves the proxy via `IServiceProvider` because under `--no-proxy` the type is unregistered and the SDK would otherwise expose it as a tool argument; it returns `{error:"ProxyUnavailable"}` in that case.
- `analyze` is the one native tool that goes through `AlMcpProxy.ForwardAsync` (`al_compile` with nested `options.onlyErrors=false`, `enableCodeAnalysis=true`, `maxDiagnosticsPerCompilation=int.MaxValue`, never `codeAnalyzers`); it resolves the proxy via `IServiceProvider` because under `--no-proxy` the type is unregistered and the SDK would otherwise expose it as a tool argument; it returns `Unavailable` / `NoProxy` in that case, `AlmcpNotFound` when almcp is not installed, `AlmcpNotReady` when it failed to start, and `AlmcpCallFailed` (almcp text in `detail`) when the proxied call itself reports an error.
- `analyze` inherits the FileSystemWatcher staleness caveat after `apply_fix` / `apply_fix_all`.

## Conventions
Expand Down
Loading
Loading