diff --git a/AGENTS.md b/AGENTS.md index 75e5c76b1..d13509f17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,9 @@ dotnet test Cluckwork.sln # 2295 tests as of 2026-09; integrati - **A reference from one business module to another is declared in the module ledger (#514/#842).** `tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json` names nine owners by namespace — Access, Farm, FlockManagement, EggOperations, Commerce, GeneralInventory, Finance, Insights and Platform — and every cross-owner edge as a cell (`from`, `to`, `kind`, `reason`) listing the top-level types that realise it. `ModuleLedgerRealTreeTests` walks every `.cs` under `src/` and fails on an undeclared edge, a **stale row**, a namespace no owner claims, a parse error or a file count under the floor; an undeclared edge prints the JSON to add, so the fix is to read the code and extend the cell, never to widen a reason. Keyed by owner and top-level type, never by namespace or `file:line` (#632): `Customer.cs` living under `Domain/Sales/` is not an edge because both ends are Commerce. **Platform is the free hub** — `Cluckwork.Api.*`, `Infrastructure.*` except `Identity`, `Domain.Common`, `Domain.Auditing` and `Application.Common` may reference anything and be referenced by anything; narrowing that is Track B (#845–#847). `kind` (`W` writes through the other owner in one transaction, `R` reads or validates) is review-checked only. The ledger authorises no move: the design's status line still governs. → [`514-module-ledger.md`](docs/decisions/514-module-ledger.md) - **No persistence type crosses an Application seam (#514/#847).** A public interface under `Cluckwork.Application.Features.*` or `Cluckwork.Application.Common` may not reach `DbContext`, `DbSet<>`, `IQueryable`, any `Microsoft.EntityFrameworkCore` type, the bases `Entity`/`AggregateRoot` as a declared type, or any `Cluckwork.Infrastructure` type — through parameters, return types, generic arguments or the properties of a `Cluckwork.*` type it returns. `SeamSurfaceRealAssemblyTests` reflects over every such interface and fails closed if it finds fewer than 30; a second assertion pins that the Application assembly references no EF, Npgsql or Infrastructure assembly, which is what keeps the first from being vacuous. Return a DTO, a value object, a concrete aggregate or a `PagedResult`. → [`847-seam-surface-guard.md`](docs/decisions/847-seam-surface-guard.md) -- **Every adapter declares its module reach, and shrinking it is free (#514/#846).** `AdapterReachRealTreeTests` walks every method and declared constructor under the ledger's endpoint, CLI and job namespaces and its two seeder types, including private helpers and primary constructors, plus direct route handlers in the ledger-selected top-level programs. `Architecture/Data/module-ledger.json` records each non-empty set of module owners by enclosing type plus member name, or project, Program, mapping method and route for top-level handlers, including literal HTTP method lists and named handlers; a new owner fails with the crossing type, `file:line` and a generated JSON row. Platform is the free hub. This is a ceiling, not a multi-module ban: unused owners and deleted adapters stay green and appear in `Loosenable` for pruning. **Endpoints, including top-level Program route handlers, forbid `AppDbContext`, `DbContext`, `DbSet` and `IQueryable` parameters or service resolutions regardless of the ledger**; CLI verbs, jobs and seeders may hold those persistence types without counting them as reach. The walk reads parameter types, including typed lambda, local-function and anonymous-method parameters, and generic or `typeof` service resolutions from one API table, shares the module ledger's owner resolution, fails on parse or registry errors and enforces a 40-adapter floor. It over-approximates adapters and cannot bind every simple name or infer transitive reach; #843's adapter tiers layer on top later. → [`846-adapter-reach-ratchet.md`](docs/decisions/846-adapter-reach-ratchet.md) +- **Every adapter declares its module reach, and shrinking it is free (#514/#846).** `AdapterReachRealTreeTests` walks every method and declared constructor under the ledger's endpoint, CLI and job namespaces and its two seeder types, including private helpers and primary constructors, plus direct route handlers in the ledger-selected top-level programs. `Architecture/Data/module-ledger.json` records each non-empty set of module owners by enclosing type plus member name, or project, Program, mapping method and route for top-level handlers, including literal HTTP method lists and named handlers; a new owner fails with the crossing type, `file:line` and a generated JSON row. Platform is the free hub. This is a ceiling, not a multi-module ban: unused owners and deleted adapters stay green and appear in `Loosenable` for pruning. **Endpoints, including top-level Program route handlers, forbid `AppDbContext`, `DbContext`, `DbSet` and `IQueryable` parameters or service resolutions regardless of the ledger**; CLI verbs, jobs and seeders may hold those persistence types without counting them as reach. The walk reads parameter types, including typed lambda, local-function and anonymous-method parameters, and generic or `typeof` service resolutions from one API table, shares the module ledger's owner resolution, fails on parse or registry errors and enforces a 40-adapter floor. It over-approximates adapters and cannot bind every simple name or infer transitive reach; #843's adapter tiers layer on top. → [`846-adapter-reach-ratchet.md`](docs/decisions/846-adapter-reach-ratchet.md) + +- **A tier declares a privilege a module may not otherwise have, before the surface that needs it exists (#514/#843).** `adapterTiers` in `module-ledger.json` names a namespace, a closed-set `privilege` (`DirectRepository` today), a closed-set `surface` (`MapMcp` today, both read from the shared `AdapterTier.KnownSurfaces` map the scanner also walks), and a `reviewBy` issue so the exemption has an end date; `AdapterTierScanner` fails on a `[McpServerToolType]`-attributed type outside every declared tier namespace, on a mapped surface (`MapMcp`) with no tier row, and prints the JSON row to add. A tier row whose surface is unmapped and whose namespace holds no type is `Dormant` — green, informational, because the row is committed before the code exists: today's `Cluckwork.Api.Mcp` row for #806 is exactly this, and it goes stale (not red) the day #806 lands. A tier also unions into the reach ratchet's namespace roots and its persistence-forbidden set, so a tool class's repository parameters still need an `adapters` row and an `AppDbContext` parameter still fails. → [`843-adapter-tiers.md`](docs/decisions/843-adapter-tiers.md) - **The coupling matrix is generated from the ledger and its three walks (#848).** `CouplingMatrixRealTreeTests` validates the module-edge, EF-model and adapter reports before rendering `tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md`, then compares normalized line endings with the committed file. Regenerate with `CLUCKWORK_REGENERATE_MATRIX=1 dotnet test tests/Cluckwork.Application.Tests --filter FullyQualifiedName~CouplingMatrixRealTreeTests`; do not hand-edit the Markdown. Its observable-differences table has seven `W`/`R`/dash mismatches with §3.4. A separate table records its unobservable `E`, `Q` and Platform cells. `P` is the free Platform hub, and `A` counts adapter reaches. → [`848-generated-coupling-matrix.md`](docs/decisions/848-generated-coupling-matrix.md) diff --git a/docs/decisions/843-adapter-tiers.md b/docs/decisions/843-adapter-tiers.md new file mode 100644 index 000000000..08c404b04 --- /dev/null +++ b/docs/decisions/843-adapter-tiers.md @@ -0,0 +1,153 @@ +# Declare adapter tiers before the surfaces that need them exist (#843) + +> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md); +> this file records the rationale and the limits of the guard. + +**Status:** accepted +**Date:** 2026-09-15 + +## What happened + +No incident. This is epic #514, slice 2, Track A, stacked on #846's reach +ratchet. #806 is about to add MCP tool classes under `src/Cluckwork.Api/Mcp/` +that inject repositories directly — `docs/plans/770-mcp-server/01-design.md:112` +sanctions `WaterTools(McpCallContext call, IWaterUsageRepository water)`. #514's +own adapter rule says an adapter may call a module contract or an approved +workflow, not a repository. Both cannot hold at once. Waiting for #806 to merge +and then weakening the reach ratchet to fit it is the failure #407 spent five +review rounds on: a guard read as safety after it had already been loosened to +match the violation. This slice records the exemption on #514's own side, +named and dated, before the code that needs it exists. + +## The rule + +A tier declares a named privilege a module may not otherwise have, over a +namespace, tied to the surface that will map it and a review issue that gives +the exemption an end date. `adapterTiers` in +`tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json` is an +array of `{ namespace, privilege, surface, reason, reviewBy }` rows; today it +holds one, `Cluckwork.Api.Mcp` → `DirectRepository`, surfaced by `MapMcp`, +reviewed by `#806`. `AdapterTierScanner` walks every `.cs` under `src/` for a +type carrying `[McpServerToolType]` (simple, qualified, or `Attribute`-suffixed, +or reached through a `using X = ...;` alias — a file-level or namespace-block +alias in the same file, or a `global using` alias declared anywhere in the same +project) and every invocation of a declared surface call (`MapMcp` today). A tool type +outside every declared tier namespace is red. A mapped surface with no tier row +is red, and the failure prints the JSON row to add. A tier row whose surface is +never invoked and whose namespace holds no type is `Dormant` — informational, +never red, because the row is meant to be committed before the code exists. +`privilege` and `surface` are each a closed set, both read from +`AdapterTier.KnownSurfaces` beside `AdapterTier.DirectRepositoryPrivilege` — a +map from each supported surface to the privilege it grants, today just +`MapMcp` → `DirectRepository`. `AdapterTierScanner` walks invocations of the +same map's keys, so a row naming a surface the scanner does not walk is a +registry error rather than a silently inert exemption. `reviewBy` must match +`^#[0-9]+$`, so an exemption with no end date is a registry error, alongside a +blank or missing field and a duplicated `namespace` or `surface`. + +A tier is also an adapter root with a privilege. `AdapterReachScanner` unions +every `adapterTiers[].namespace` into its own namespace roots — so a tool +class's repository parameters are walked for module reach exactly like an +endpoint's — and into its persistence-forbidden set, because `DirectRepository` +authorizes a repository, not a direct `AppDbContext`. A tool class reaching an +undeclared module still needs an `adapters` row; one that takes `AppDbContext` +still fails. + +## Why not the obvious alternative + +Keying the tier on the folder (`src/Cluckwork.Api/Mcp/` existing) was rejected: +that folder does not exist yet, so a guard keyed on it would be red from the +moment this slice lands until #806 merges, training everyone to ignore it +exactly the way a chronically-red CI check does. `Dormant` is the alternative — +green today, informational, and it goes stale (not red) the day #806 adds a +type under the namespace or maps `/mcp`, which is the signal for that PR's +author to drop the real-tree Dormant assertion rather than route around it. + +Binding the tool-attribute detection to the actual +`ModelContextProtocol.Server.McpServerToolType` symbol was rejected for the +same reason the reach ratchet stays syntax-only: no MCP package reference +exists in `src/` yet, so a semantic check has nothing to bind against. A syntax +match on the attribute's last identifier, both bare and `Attribute`-suffixed, +is what a guard that must be committable today can do. + +## What this does NOT cover + +The walk is syntax-only, exactly like #846's. It matches on the attribute's +last identifier segment, so it cannot distinguish a real +`ModelContextProtocol.Server.McpServerToolType` from an unrelated type of the +same name; that is the same trade #846 makes for `AppDbContext`/`DbContext`. +The surface walk (`AdapterTier.KnownSurfaces`) matches on the invoked method +name only, receiver-independent — a call to an unrelated `MapMcp` method on a +different type would also count. Today's tree has none, and the ledger's tier +row is what a reviewer reads against the real one when #806 lands. The closed +privilege and surface sets each hold one value because MCP is the only tier +this slice adds; growing either needs the value added to +`AdapterTier`'s closed set and a stated reason, not a silent widening. This +slice changes no `src/` code, CI workflow, or package dependency, and it does +not ask #806–#809 to call contracts instead of repositories — that remains a +Track C question this ledger absorbs for now. + +The walk still parses every file with one fixed preprocessor symbol list +(`ModuleLedgerScanner.ParseOptions`), so a project-defined constant's `#if` +branch is never walked — an inactive branch is invisible to Roslyn's own +parse, not merely to this scanner. Nothing here reads an inactive branch; +instead, `ModuleLedgerScanner.UndeclaredDefineConstants` enumerates every +`*.csproj`, `*.props` and `*.targets` under the repo root (skipping `bin/`, +`obj/`, `node_modules/`, `.git/` and `web/`), collects each `` +value, and fails the walk-trust check the moment any of them defines a symbol +the fixed list does not carry. It does not resolve MSBuild imports — there is +no project graph to resolve against — so it over-approximates instead: it +reads every matching file textually, whatever file or `Condition` it sits in, +so a constant defined ANYWHERE under the repo root is red even if no project +actually imports that file. That keeps the fixed list honest without walking +inactive branches or resolving imports: the day a project needs `#if MCP`, +adding `MCP` to `DefineConstants` reds this check first, which is the prompt +to add `MCP` to `ParseOptions` too, before the guarded code can hide behind +it. `$(DefineConstants)` (the MSBuild reference to the inherited value) is +not itself a symbol and is skipped. + +## How it is enforced + +`tests/Cluckwork.Application.Tests/Architecture/AdapterTierTests.cs` exercises +the scanner on temporary trees: one test per registry error, `ToolTypeOutsideTier`, +`SurfaceWithoutTier`, a green `Dormant` row, a green in-tier type, both attribute +spellings, a file-local alias and a same-project `global using` alias resolving +to the tool attribute, an alias to an unrelated type not matching, a surface +call inside a lambda or local function, a parse error, a fixture `.csproj` +defining an undeclared preprocessor symbol, and one defining only +`$(DefineConstants);TRACE`. +`AdapterTierRealTreeTests` gates the real `src/` tree, confirms the `MapMcp` row +is declared, and pins it `Dormant` today — that last assertion is written to go +stale, not to stay true forever. `AdapterReachTests` adds two integration +cases: a tool class under a tier namespace with an undeclared repository +parameter is red, and one with `AppDbContext` is a persistence violation. + +Seven real-tree mutations were run against the built scanners and reverted with +`git checkout --` or by deleting the added file. Mutations touching only +`src/` ran with `--no-build`; mutations 2 and 3 edit the ledger's JSON content +file, which `--no-build` does not recopy into the test output directory, so +those two ran after a rebuild. Output files are local evidence under +`/tmp/514/mutations-843/`. + +| Mutation | Result | Output file | +|---|---|---| +| `[McpServerToolType] class Probe {}` under `Cluckwork.Api.Endpoints` | RED, `ToolTypeOutsideTier` at `Probe.cs:3` | `1-tooltype-outside-tier.txt` | +| Add `app.MapMcp("/mcp");` to `Program.cs`, empty `adapterTiers` | RED, `SurfaceWithoutTier`, prints the JSON row to add | `2-surface-without-tier.txt` | +| Remove `reviewBy` from the MCP row | RED, registry error: blank `reviewBy` | `3-remove-reviewby.txt` | +| `WaterTools(IWaterUsageRepository water)` under `Cluckwork.Api.Mcp` | RED, undeclared reach `WaterTools.ctor -> GeneralInventory` | `4-tier-namespace-undeclared-reach.txt` | +| Same class taking `AppDbContext` | RED, forbidden persistence type at `WaterTools.cs:3` | `5-tier-namespace-appdbcontext.txt` | +| codex-sol review of #880: a class outside `Cluckwork.Api.Mcp` carrying a locally-declared `McpServerToolTypeAttribute`, referenced only through a `using ToolMarker = ...;` alias | RED, `ToolTypeOutsideTier` at `AdapterTierAliasProbe.cs:7` | `6-alias-outside-tier.txt` | +| codex-sol review of #880: `MCP` added to `src/Cluckwork.Api/Cluckwork.Api.csproj` | RED, "the walk cannot be trusted: project src/Cluckwork.Api/Cluckwork.Api.csproj defines MCP, add it to ModuleLedgerScanner.ParseOptions" | `7-csproj-defines-mcp.txt` | + +Run the gate with: + +```sh +dotnet test tests/Cluckwork.Application.Tests \ + --filter FullyQualifiedName~AdapterTierRealTreeTests \ + --logger 'console;verbosity=detailed' +``` + +When #806 lands `/mcp` and its tool classes under `Cluckwork.Api.Mcp`: add each +tool class's repository reach as an `adapters` row exactly like an endpoint's, +and drop `AdapterTierRealTreeTests.McpTierRow_IsDormantToday` — its own failure +is the signal that the row it pins is no longer dormant. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ebca76e57..56ed717a4 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -29,6 +29,7 @@ Starting a new record: copy [`TEMPLATE.md`](TEMPLATE.md). | Decision | Rule lives in | |---|---| | [Adapter module reach may shrink without a ledger edit (#846)](846-adapter-reach-ratchet.md) | AGENTS · Application shape | +| [Declare adapter tiers before the surfaces that need them exist (#843)](843-adapter-tiers.md) | AGENTS · Application shape | | [Generate the coupling matrix from live evidence (#848)](848-generated-coupling-matrix.md) | AGENTS · Application shape | | [Table-owner completeness from the EF model (#845)](845-table-owners.md) | AGENTS · Data and correctness | | [Credential epoch revocation (#364)](364-credential-epoch-revocation.md) | AGENTS · Conventions | diff --git a/tests/Cluckwork.Application.Tests/Architecture/AdapterReachScanner.cs b/tests/Cluckwork.Application.Tests/Architecture/AdapterReachScanner.cs index e26205a09..1bdfd7dcb 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/AdapterReachScanner.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/AdapterReachScanner.cs @@ -80,13 +80,13 @@ public static AdapterReachReport Scan(string srcRoot, string ledgerPath) { var ns = NamespaceOf(type); var typeName = TypeName(type); - if (!ledger.AdapterRoots.Namespaces.Any(prefix => Under(ns, prefix)) + if (!ledger.AdapterNamespaces.Any(prefix => Under(ns, prefix)) && !ledger.AdapterRoots.Types.Contains(typeName, StringComparer.Ordinal)) { continue; } - var banPersistence = ledger.AdapterRoots.PersistenceForbiddenNamespaces.Any(prefix => Under(ns, prefix)); + var banPersistence = ledger.PersistenceForbiddenNamespaces.Any(prefix => Under(ns, prefix)); var adapters = type.Members.OfType() .Where(m => m is MethodDeclarationSyntax or ConstructorDeclarationSyntax) .Select(m => (Node: (SyntaxNode)m, diff --git a/tests/Cluckwork.Application.Tests/Architecture/AdapterReachTests.cs b/tests/Cluckwork.Application.Tests/Architecture/AdapterReachTests.cs index e052f12dc..40672443e 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/AdapterReachTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/AdapterReachTests.cs @@ -14,7 +14,7 @@ private void WriteSource(string relativePath, string content) File.WriteAllText(full, content); } - private string WriteLedger(string adapters = "") + private string WriteLedger(string adapters = "", string adapterTiers = "") { var path = Path.Combine(_tempRoot, "module-ledger.json"); File.WriteAllText(path, """ @@ -32,12 +32,15 @@ private string WriteLedger(string adapters = "") "persistenceForbiddenNamespaces": ["Cluckwork.Temp.Endpoints"] }, "adapters": [ - """ + adapters + "]\n}\n"); + """ + adapters + "],\n\"adapterTiers\": [" + adapterTiers + "]\n}\n"); return path; } - private AdapterReachReport Scan(string adapters = "") => - AdapterReachScanner.Scan(Path.Combine(_tempRoot, "src"), WriteLedger(adapters)); + private AdapterReachReport Scan(string adapters = "", string adapterTiers = "") => + AdapterReachScanner.Scan(Path.Combine(_tempRoot, "src"), WriteLedger(adapters, adapterTiers)); + + private static string Tier(string ns = "Cluckwork.Temp.Mcp") => + $$"""{ "namespace": "{{ns}}", "privilege": "DirectRepository", "surface": "MapMcp", "reason": "test", "reviewBy": "#1" }"""; private static string Row(string symbol = Symbol, string reaches = "\"Farm\"") => $$"""{ "symbol": "{{symbol}}", "reaches": [{{reaches}}] }"""; @@ -675,4 +678,27 @@ public class Endpoint { public void Run({{type}} value) { } } Assert.Equal(owner, reach.Owner); Assert.Equal(Symbol, reach.Symbol); } + + [Fact] + public void AdapterTierNamespace_UndeclaredRepositoryReachIsReported() + { + WriteSource("Tools.cs", """ + namespace Cluckwork.Temp.Mcp; + public class WaterTools { public void Run(Cluckwork.Temp.Farm.IAccountRepository accounts) { } } + """); + var failure = Assert.Single(AdapterReachScanner.Evaluate(Scan(adapterTiers: Tier()))); + Assert.Contains("Cluckwork.Temp.Mcp.WaterTools.Run -> Farm", failure); + Assert.Contains("Cluckwork.Temp.Farm.IAccountRepository", failure); + } + + [Fact] + public void AdapterTierNamespace_AppDbContextIsForbiddenPersistence() + { + WriteSource("Tools.cs", """ + namespace Cluckwork.Temp.Mcp; + public class WaterTools { public void Run(AppDbContext db) { } } + """); + var report = Scan(adapterTiers: Tier()); + Assert.Contains("forbidden persistence type AppDbContext", Assert.Single(report.PersistenceViolations)); + } } diff --git a/tests/Cluckwork.Application.Tests/Architecture/AdapterTierRealTreeTests.cs b/tests/Cluckwork.Application.Tests/Architecture/AdapterTierRealTreeTests.cs new file mode 100644 index 000000000..7439921ac --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Architecture/AdapterTierRealTreeTests.cs @@ -0,0 +1,39 @@ +namespace Cluckwork.Application.Tests.Architecture; + +using Cluckwork.Application.Tests.TenantBypass; + +public sealed class AdapterTierRealTreeTests +{ + private static string LedgerPath => Path.Combine(AppContext.BaseDirectory, "Architecture", "Data", "module-ledger.json"); + + private static AdapterTierReport Scan() => AdapterTierScanner.Scan( + Path.Combine(GuardScanner.FindRepoRoot(AppContext.BaseDirectory) + ?? throw new InvalidOperationException("repo root not found"), "src"), + LedgerPath); + + [Fact] + public void RealSourceTree_HasNoViolations() + { + var report = Scan(); + Assert.True(AdapterTierScanner.Evaluate(report).Count == 0, + "adapter tier guard failed:\n" + string.Join("\n", AdapterTierScanner.Evaluate(report))); + } + + [Fact] + public void McpTierRow_IsDeclared() + { + var ledger = ModuleLedger.Load(LedgerPath); + Assert.Contains(ledger.AdapterTiers, t => t.Namespace == "Cluckwork.Api.Mcp" && t.Surface == "MapMcp"); + } + + // /mcp is not mapped yet (#806 has not landed). This pins today's green: the + // row is committed but its namespace holds no tool type and MapMcp is not + // invoked. It goes stale the day #806 maps /mcp under this namespace — that + // author drops this assertion, not works around it. + [Fact] + public void McpTierRow_IsDormantToday() + { + var report = Scan(); + Assert.Contains(report.Dormant, t => t.Namespace == "Cluckwork.Api.Mcp"); + } +} diff --git a/tests/Cluckwork.Application.Tests/Architecture/AdapterTierScanner.cs b/tests/Cluckwork.Application.Tests/Architecture/AdapterTierScanner.cs new file mode 100644 index 000000000..d87532117 --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Architecture/AdapterTierScanner.cs @@ -0,0 +1,284 @@ +namespace Cluckwork.Application.Tests.Architecture; + +using System.Text.Encodings.Web; +using System.Text.Json; +using Cluckwork.Application.Tests.TenantBypass; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +public sealed record ToolType(string Name, string File, int Line); + +public sealed record SurfaceCallSite(string Surface, string File, int Line); + +public sealed record UndeclaredDefineConstant(string ProjectFile, string Symbol); + +public sealed record AdapterTierReport( + IReadOnlyList ToolTypeOutsideTier, + IReadOnlyList SurfaceWithoutTier, + IReadOnlyList Dormant, + IReadOnlyList ParseErrors, + IReadOnlyList RegistryErrors, + int ScannedFileCount, + int ExpectedFileCountFloor, + IReadOnlyList UndeclaredDefineConstants); + +public static class AdapterTierScanner +{ + internal const int RealTreeFileFloor = 400; + + private static readonly HashSet ToolAttributes = new(StringComparer.Ordinal) + { + "McpServerToolType", "McpServerToolTypeAttribute", + }; + + public static AdapterTierReport Scan(string srcRoot, string ledgerPath) + { + var srcFull = Path.GetFullPath(srcRoot); + var repoRoot = Path.GetDirectoryName(srcFull)!; + var ledger = ModuleLedger.Load(ledgerPath); + var errors = new List(ledger.RegistryErrors); + + var files = GuardScanner.EnumerateSourceFiles(srcFull); + var floor = GuardScanner.FindRepoRoot(AppContext.BaseDirectory) is { } realRoot + && srcFull == Path.Combine(realRoot, "src") ? RealTreeFileFloor : files.Count; + + var parseErrors = new List(); + var toolTypes = new List<(string Namespace, ToolType Type)>(); + var surfaceCalls = new List(); + + var parsed = new List<(string Relative, CompilationUnitSyntax Root, string ProjectNamespace)>(); + foreach (var file in files) + { + var relative = Relative(repoRoot, file); + var tree = CSharpSyntaxTree.ParseText(File.ReadAllText(file), ModuleLedgerScanner.ParseOptions, file); + var root = tree.GetCompilationUnitRoot(); + + foreach (var diagnostic in tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)) + { + var line = diagnostic.Location.GetLineSpan().StartLinePosition.Line + 1; + parseErrors.Add($"{relative}:{line}: {diagnostic.Id} {diagnostic.GetMessage()}"); + } + + parsed.Add((relative, root, ModuleLedgerScanner.ProjectRootNamespace(srcFull, file))); + } + + var globalAliases = CollectGlobalAliases(parsed); + + foreach (var (relative, root, projectNamespace) in parsed) + { + var localAliases = LocalAliasDirectives(root); + globalAliases.TryGetValue(projectNamespace, out var projectAliasMap); + + foreach (var type in root.DescendantNodes().OfType()) + { + if (!HasToolAttribute(type, localAliases, projectAliasMap)) + { + continue; + } + + var ns = ModuleLedgerScanner.NamespaceOf(type, projectNamespace); + var line = type.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + toolTypes.Add((ns, new ToolType(FullName(type, ns), relative, line))); + } + + foreach (var call in root.DescendantNodes().OfType()) + { + if (CalledName(call) is { } name && AdapterTier.KnownSurfaces.ContainsKey(name.Identifier.ValueText)) + { + var line = call.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + surfaceCalls.Add(new SurfaceCallSite(name.Identifier.ValueText, relative, line)); + } + } + } + + var outside = toolTypes + .Where(t => !ledger.AdapterTiers.Any(tier => Under(t.Namespace, tier.Namespace))) + .Select(t => t.Type) + .OrderBy(t => t.Name, StringComparer.Ordinal).ToList(); + var invokedSurfaces = surfaceCalls.Select(c => c.Surface).ToHashSet(StringComparer.Ordinal); + var tierSurfaces = ledger.AdapterTiers.Select(t => t.Surface).ToHashSet(StringComparer.Ordinal); + var withoutTier = surfaceCalls + .Where(c => !tierSurfaces.Contains(c.Surface)) + .OrderBy(c => c.File, StringComparer.Ordinal).ThenBy(c => c.Line).ToList(); + var dormant = ledger.AdapterTiers + .Where(tier => !invokedSurfaces.Contains(tier.Surface) + && !toolTypes.Any(t => Under(t.Namespace, tier.Namespace))) + .OrderBy(t => t.Namespace, StringComparer.Ordinal).ToList(); + + var undeclaredConstants = ModuleLedgerScanner.UndeclaredDefineConstants(repoRoot) + .Select(c => new UndeclaredDefineConstant(c.ProjectFile, c.Symbol)).ToList(); + + return new AdapterTierReport( + outside, withoutTier, dormant, parseErrors, errors, files.Count, floor, undeclaredConstants); + } + + public static IReadOnlyList Evaluate(AdapterTierReport report) + { + var failures = report.RegistryErrors.Select(e => $"adapter tier registry error: {e}").ToList(); + if (report.ParseErrors.Count > 0) + { + failures.Add("the walk cannot be trusted:\n" + string.Join("\n", report.ParseErrors)); + } + if (report.ScannedFileCount < report.ExpectedFileCountFloor) + { + failures.Add($"scanned {report.ScannedFileCount} files, expected at least {report.ExpectedFileCountFloor}"); + } + foreach (var constant in report.UndeclaredDefineConstants) + { + failures.Add($"the walk cannot be trusted: project {constant.ProjectFile} defines " + + $"{constant.Symbol} (a Condition attribute does not exempt it), add it to ModuleLedgerScanner.ParseOptions"); + } + foreach (var type in report.ToolTypeOutsideTier) + { + failures.Add($"tool type {type.Name} at {type.File}:{type.Line} is outside every adapterTiers " + + "namespace; add a tier row or move the type"); + } + foreach (var call in report.SurfaceWithoutTier) + { + failures.Add($"mapped surface {call.Surface} at {call.File}:{call.Line} has no adapterTiers row; " + + "review and add the JSON row:\n" + RenderRow(call.Surface)); + } + return failures; + } + + private static readonly JsonSerializerOptions RowOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + internal static string RenderRow(string surface) => JsonSerializer.Serialize(new + { + @namespace = "", + privilege = AdapterTier.KnownSurfaces[surface], + surface, + reason = "", + reviewBy = "<#issue>", + }, RowOptions); + + private sealed record AliasDirective(string Alias, string Target, BaseNamespaceDeclarationSyntax? Block); + + private static bool HasToolAttribute( + TypeDeclarationSyntax type, + IReadOnlyList localAliases, + IReadOnlyDictionary? projectAliases) => + type.AttributeLists.SelectMany(list => list.Attributes) + .Any(attr => IsToolAttributeName(LastIdentifier(attr.Name), type, localAliases, projectAliases)); + + // Bounded by the alias count so `using A = B; using B = A;` terminates. + private static bool IsToolAttributeName( + string identifier, + TypeDeclarationSyntax type, + IReadOnlyList localAliases, + IReadOnlyDictionary? projectAliases) + { + var current = identifier; + var remainingHops = localAliases.Count + (projectAliases?.Count ?? 0) + 1; + for (var hop = 0; hop < remainingHops; hop++) + { + if (ToolAttributes.Contains(current)) + { + return true; + } + + if (ResolveAliasOnce(current, type, localAliases, projectAliases) is not { } next) + { + return false; + } + + current = next; + } + + return false; + } + + private static string? ResolveAliasOnce( + string identifier, + TypeDeclarationSyntax type, + IReadOnlyList localAliases, + IReadOnlyDictionary? projectAliases) + { + foreach (var alias in localAliases) + { + if (alias.Alias == identifier && (alias.Block is null || type.Ancestors().Contains(alias.Block))) + { + return alias.Target; + } + } + + return projectAliases is not null && projectAliases.TryGetValue(identifier, out var globalTarget) + ? globalTarget + : null; + } + + private static IReadOnlyList LocalAliasDirectives(CompilationUnitSyntax root) => + root.DescendantNodes().OfType() + .Where(d => d.Alias is not null && d.GlobalKeyword == default) + .Select(d => (Directive: d, Target: AliasTargetLastIdentifier(d.NamespaceOrType))) + .Where(d => d.Target is not null) + .Select(d => new AliasDirective( + d.Directive.Alias!.Name.Identifier.ValueText, d.Target!, d.Directive.Parent as BaseNamespaceDeclarationSyntax)) + .ToList(); + + private static Dictionary> CollectGlobalAliases( + IEnumerable<(string Relative, CompilationUnitSyntax Root, string ProjectNamespace)> parsed) + { + var byProject = new Dictionary>(StringComparer.Ordinal); + foreach (var (_, root, project) in parsed) + { + foreach (var directive in root.DescendantNodes().OfType()) + { + if (directive.GlobalKeyword == default || directive.Alias is null) + { + continue; + } + + if (AliasTargetLastIdentifier(directive.NamespaceOrType) is not { } target) + { + continue; + } + + if (!byProject.TryGetValue(project, out var aliases)) + { + byProject[project] = aliases = new Dictionary(StringComparer.Ordinal); + } + + aliases[directive.Alias.Name.Identifier.ValueText] = target; + } + } + + return byProject; + } + + private static string? AliasTargetLastIdentifier(TypeSyntax target) => + target is NameSyntax name ? LastIdentifier(name) : null; + + private static string LastIdentifier(NameSyntax name) => name switch + { + QualifiedNameSyntax qualified => qualified.Right.Identifier.ValueText, + SimpleNameSyntax simple => simple.Identifier.ValueText, + AliasQualifiedNameSyntax alias => LastIdentifier(alias.Name), + _ => name.ToString(), + }; + + private static SimpleNameSyntax? CalledName(InvocationExpressionSyntax call) => call.Expression switch + { + MemberAccessExpressionSyntax member => member.Name, + MemberBindingExpressionSyntax binding => binding.Name, + SimpleNameSyntax simple => simple, + _ => null, + }; + + private static string FullName(TypeDeclarationSyntax type, string ns) + { + var typeNames = type.AncestorsAndSelf().OfType() + .Reverse().Select(t => t.Identifier.ValueText); + return ns + "." + string.Join(".", typeNames); + } + + private static bool Under(string name, string prefix) => + name == prefix || name.StartsWith(prefix + ".", StringComparison.Ordinal); + + private static string Relative(string root, string file) => Path.GetRelativePath(root, file).Replace('\\', '/'); +} diff --git a/tests/Cluckwork.Application.Tests/Architecture/AdapterTierTests.cs b/tests/Cluckwork.Application.Tests/Architecture/AdapterTierTests.cs new file mode 100644 index 000000000..9834eb922 --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Architecture/AdapterTierTests.cs @@ -0,0 +1,353 @@ +namespace Cluckwork.Application.Tests.Architecture; + +public sealed class AdapterTierTests : IDisposable +{ + private readonly string _tempRoot = Directory.CreateTempSubdirectory("adapter-tier-").FullName; + + public void Dispose() => Directory.Delete(_tempRoot, recursive: true); + + private void WriteSource(string relativePath, string content) + { + var full = Path.Combine(_tempRoot, "src", relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + } + + private string WriteLedger(string tiers = "") + { + var path = Path.Combine(_tempRoot, "module-ledger.json"); + File.WriteAllText(path, """ + { + "owners": { "Hub": { "kind": "platform", "namespaces": ["Cluckwork.Temp"] } }, + "edges": [], + "adapterTiers": [ + """ + tiers + "]\n}\n"); + return path; + } + + private AdapterTierReport Scan(string tiers = "") + { + Directory.CreateDirectory(Path.Combine(_tempRoot, "src")); + return AdapterTierScanner.Scan(Path.Combine(_tempRoot, "src"), WriteLedger(tiers)); + } + + private static string Tier(string ns = "Cluckwork.Temp.Mcp", string privilege = "DirectRepository", + string surface = "MapMcp", string reason = "test reason", string reviewBy = "#1") => + $$"""{ "namespace": "{{ns}}", "privilege": "{{privilege}}", "surface": "{{surface}}", "reason": "{{reason}}", "reviewBy": "{{reviewBy}}" }"""; + + private void WriteCsproj(string projectName, string defineConstants) + { + var full = Path.Combine(_tempRoot, "src", projectName, $"{projectName}.csproj"); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, $""" + + + {defineConstants} + + + """); + } + + private void WriteFile(string relativePath, string content) + { + var full = Path.Combine(_tempRoot, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + } + + [Fact] + public void CsprojDefinesUndeclaredConstant_IsParseTrustFailure() + { + WriteCsproj("Probe", "MCP"); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan())); + Assert.Contains("the walk cannot be trusted", failure); + Assert.Contains("MCP", failure); + } + + [Fact] + public void CsprojDefineConstantsPlaceholderPlusDeclaredSymbol_IsGreen() + { + WriteCsproj("Probe", "$(DefineConstants);TRACE"); + Assert.Empty(AdapterTierScanner.Evaluate(Scan())); + } + + [Fact] + public void DirectoryBuildPropsUnderSrc_DefinesUndeclaredConstant_IsParseTrustFailure() + { + WriteFile("src/Directory.Build.props", """ + + + MCP + + + """); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan())); + Assert.Contains("the walk cannot be trusted", failure); + Assert.Contains("MCP", failure); + } + + [Fact] + public void ImportedPropsFile_DefinesUndeclaredConstant_IsParseTrustFailure() + { + WriteFile("src/Shared/Shared.props", """ + + + MCP + + + """); + WriteFile("src/Probe/Probe.csproj", """ + + + + """); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan())); + Assert.Contains("the walk cannot be trusted", failure); + Assert.Contains("MCP", failure); + } + + [Fact] + public void ConditionedDefineConstants_IsParseTrustFailure() + { + WriteFile("src/Probe/Probe.csproj", """ + + + MCP + + + """); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan())); + Assert.Contains("Condition", failure); + Assert.Contains("MCP", failure); + } + + [Fact] + public void ToolTypeOutsideAnyTierNamespace_IsToolTypeOutsideTier() + { + WriteSource("Probe.cs", """ + namespace Cluckwork.Temp.Endpoints; + [McpServerToolType] + public sealed class Probe { } + """); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan(Tier()))); + Assert.Contains("Cluckwork.Temp.Endpoints.Probe", failure); + Assert.Contains("src/Probe.cs:2", failure); + } + + [Fact] + public void ToolTypeUnderTierNamespace_IsGreenAndNotDormant() + { + WriteSource("Tools.cs", """ + namespace Cluckwork.Temp.Mcp; + [McpServerToolType] + public sealed class WaterTools { } + """); + var report = Scan(Tier()); + Assert.Empty(AdapterTierScanner.Evaluate(report)); + Assert.Empty(report.Dormant); + } + + [Fact] + public void SurfaceCallWithNoTierRow_IsSurfaceWithoutTier() + { + WriteSource("Program.cs", "app.MapMcp(\"/mcp\");"); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan())); + Assert.Contains("MapMcp", failure); + Assert.Contains("src/Program.cs:1", failure); + Assert.Contains("\"surface\": \"MapMcp\"", failure); + } + + [Fact] + public void TierRowWithNoInvocationAndNoTypes_IsDormantAndGreen() + { + var report = Scan(Tier()); + Assert.Empty(AdapterTierScanner.Evaluate(report)); + var dormant = Assert.Single(report.Dormant); + Assert.Equal("Cluckwork.Temp.Mcp", dormant.Namespace); + } + + [Theory] + [InlineData("[McpServerToolTypeAttribute]")] + [InlineData("[ModelContextProtocol.Server.McpServerToolType]")] + public void QualifiedNameOrAttributeSuffix_IsDetected(string attribute) + { + WriteSource("Tools.cs", $$""" + namespace Cluckwork.Temp.Mcp; + {{attribute}} + public sealed class WaterTools { } + """); + var report = Scan(Tier()); + Assert.Empty(AdapterTierScanner.Evaluate(report)); + Assert.Empty(report.Dormant); + } + + [Fact] + public void FileLocalAliasToToolAttribute_OutsideTier_IsReported() + { + WriteSource("Probe.cs", """ + using ToolMarker = ModelContextProtocol.Server.McpServerToolTypeAttribute; + namespace Cluckwork.Temp.Endpoints; + [ToolMarker] + public sealed class Probe { } + """); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan(Tier()))); + Assert.Contains("Cluckwork.Temp.Endpoints.Probe", failure); + Assert.Contains("src/Probe.cs:3", failure); + } + + [Fact] + public void GlobalAliasFromAnotherFileInSameProject_IsDetected() + { + WriteSource("ProjA/Aliases.cs", """ + global using ToolMarker = ModelContextProtocol.Server.McpServerToolTypeAttribute; + namespace Cluckwork.Temp.ProjA; + """); + WriteSource("ProjA/Probe.cs", """ + namespace Cluckwork.Temp.Endpoints; + [ToolMarker] + public sealed class Probe { } + """); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan(Tier()))); + Assert.Contains("Cluckwork.Temp.Endpoints.Probe", failure); + Assert.Contains("src/ProjA/Probe.cs:2", failure); + } + + [Fact] + public void LocalAliasChainOfTwo_OutsideTier_IsReported() + { + WriteSource("Probe.cs", """ + using ActualMarker = ModelContextProtocol.Server.McpServerToolTypeAttribute; + using ToolMarker = ActualMarker; + namespace Cluckwork.Temp.Endpoints; + [ToolMarker] + public sealed class Probe { } + """); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan(Tier()))); + Assert.Contains("Cluckwork.Temp.Endpoints.Probe", failure); + Assert.Contains("src/Probe.cs:4", failure); + } + + [Fact] + public void GlobalThenLocalAliasChain_OutsideTier_IsReported() + { + WriteSource("ProjA/Aliases.cs", """ + global using GlobalMarker = ModelContextProtocol.Server.McpServerToolTypeAttribute; + namespace Cluckwork.Temp.ProjA; + """); + WriteSource("ProjA/Probe.cs", """ + using ToolMarker = GlobalMarker; + namespace Cluckwork.Temp.Endpoints; + [ToolMarker] + public sealed class Probe { } + """); + var failure = Assert.Single(AdapterTierScanner.Evaluate(Scan(Tier()))); + Assert.Contains("Cluckwork.Temp.Endpoints.Probe", failure); + Assert.Contains("src/ProjA/Probe.cs:3", failure); + } + + [Fact] + public void CyclicAlias_DoesNotHangAndMatchesNothing() + { + WriteSource("Probe.cs", """ + using A = B; + using B = A; + namespace Cluckwork.Temp.Endpoints; + [A] + public sealed class Probe { } + """); + Assert.Empty(Scan(Tier()).ToolTypeOutsideTier); + } + + [Fact] + public void AliasToUnrelatedType_DoesNotMatch() + { + WriteSource("Probe.cs", """ + using NotATool = System.ObsoleteAttribute; + namespace Cluckwork.Temp.Endpoints; + [NotATool] + public sealed class Probe { } + """); + Assert.Empty(Scan(Tier()).ToolTypeOutsideTier); + } + + [Fact] + public void SurfaceCallInsideLambdaOrLocalFunction_IsDetected() + { + WriteSource("Program.cs", """ + void Configure() { app.MapMcp("/mcp"); } + System.Action a = () => { app.MapMcp("/mcp"); }; + """); + var report = Scan(); + Assert.Equal(2, report.SurfaceWithoutTier.Count); + } + + [Fact] + public void ParseError_MakesTheWalkUntrusted() + { + WriteSource("Broken.cs", "namespace Cluckwork.Temp.Other; public class Broken {"); + Assert.Contains("the walk cannot be trusted", Assert.Single(AdapterTierScanner.Evaluate(Scan()))); + } + + [Fact] + public void BlankField_IsRegistryError() + { + var row = """{ "namespace": " ", "privilege": "DirectRepository", "surface": "MapMcp", "reason": "r", "reviewBy": "#1" }"""; + Assert.Contains(Scan(row).RegistryErrors, e => e.Contains("'namespace'", StringComparison.Ordinal)); + } + + [Fact] + public void MissingField_IsRegistryError() + { + var row = """{ "privilege": "DirectRepository", "surface": "MapMcp", "reason": "r", "reviewBy": "#1" }"""; + Assert.Contains(Scan(row).RegistryErrors, e => e.Contains("'namespace'", StringComparison.Ordinal)); + } + + [Fact] + public void UnknownPrivilege_IsRegistryError() + { + var row = Tier(privilege: "ReadOnlyRepository"); + Assert.Contains(Scan(row).RegistryErrors, + e => e.Contains("privilege", StringComparison.Ordinal) && e.Contains("ReadOnlyRepository", StringComparison.Ordinal)); + } + + [Fact] + public void PrivilegeDoesNotMatchSurfaceMapping_IsRegistryError() + { + var row = Tier(privilege: "ReadOnlyRepository"); + Assert.Contains(Scan(row).RegistryErrors, + e => e.Contains("privilege", StringComparison.Ordinal) + && e.Contains("MapMcp", StringComparison.Ordinal) + && e.Contains("DirectRepository", StringComparison.Ordinal)); + } + + [Fact] + public void UnknownSurface_IsRegistryError() + { + var row = Tier(surface: "MapMcpTypo"); + Assert.Contains(Scan(row).RegistryErrors, + e => e.Contains("surface", StringComparison.Ordinal) && e.Contains("MapMcpTypo", StringComparison.Ordinal)); + } + + [Fact] + public void ReviewByNotMatchingPattern_IsRegistryError() + { + var row = Tier(reviewBy: "806"); + Assert.Contains(Scan(row).RegistryErrors, e => e.Contains("reviewBy", StringComparison.Ordinal)); + } + + [Fact] + public void DuplicateNamespace_IsRegistryError() + { + var rows = Tier(surface: "MapMcp") + "," + Tier(surface: "MapOther"); + Assert.Contains(Scan(rows).RegistryErrors, + e => e.Contains("duplicate", StringComparison.Ordinal) && e.Contains("namespace", StringComparison.Ordinal)); + } + + [Fact] + public void DuplicateSurface_IsRegistryError() + { + var rows = Tier(ns: "Cluckwork.Temp.Mcp") + "," + Tier(ns: "Cluckwork.Temp.Other"); + Assert.Contains(Scan(rows).RegistryErrors, + e => e.Contains("duplicate", StringComparison.Ordinal) && e.Contains("surface", StringComparison.Ordinal)); + } +} diff --git a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json index 4caf93ff9..749f92d1c 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json +++ b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json @@ -1286,5 +1286,14 @@ "GeneralInventory" ] } + ], + "adapterTiers": [ + { + "namespace": "Cluckwork.Api.Mcp", + "privilege": "DirectRepository", + "surface": "MapMcp", + "reason": "MCP tool classes inject repositories by design (docs/plans/770-mcp-server/01-design.md:112); the contract-first shape is Track C (#514)", + "reviewBy": "#806" + } ] } diff --git a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedger.cs b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedger.cs index cbab42f69..1c970b9ab 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedger.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedger.cs @@ -3,6 +3,7 @@ namespace Cluckwork.Application.Tests.Architecture; // #842 — the committed module ledger: owners by namespace, one cell per cross-owner dependency. using System.Text.Json; +using System.Text.RegularExpressions; public sealed record OwnerDefinition( string Name, @@ -26,6 +27,20 @@ public sealed record AdapterRoots(IReadOnlyList Namespaces, IReadOnlyLis public sealed record AdapterClaim(string Symbol, IReadOnlyList Reaches); +public sealed record AdapterTier(string Namespace, string Privilege, string Surface, string Reason, string ReviewBy) +{ + public const string DirectRepositoryPrivilege = "DirectRepository"; + + // The closed set of surfaces a tier row may name, each mapped to the + // privilege it grants. AdapterTierScanner reads this same map, so a surface + // the scanner does not walk can never validate as a tier row's `surface`. + public static readonly IReadOnlyDictionary KnownSurfaces = + new Dictionary(StringComparer.Ordinal) + { + ["MapMcp"] = DirectRepositoryPrivilege, + }; +} + public sealed record ModuleLedger( IReadOnlyList Owners, IReadOnlyList Edges, @@ -37,6 +52,13 @@ public sealed record ModuleLedger( public AdapterRoots AdapterRoots { get; init; } = new([], []); public IReadOnlyList Adapters { get; init; } = []; + public IReadOnlyList AdapterTiers { get; init; } = []; + + public IEnumerable AdapterNamespaces => + AdapterRoots.Namespaces.Concat(AdapterTiers.Select(t => t.Namespace)); + + public IEnumerable PersistenceForbiddenNamespaces => + AdapterRoots.PersistenceForbiddenNamespaces.Concat(AdapterTiers.Select(t => t.Namespace)); public const string ModuleKind = "module"; public const string PlatformKind = "platform"; @@ -47,6 +69,8 @@ public sealed record ModuleLedger( AllowTrailingCommas = true, }; + private static readonly Regex ReviewByPattern = new("^#[0-9]+$", RegexOptions.Compiled); + public static ModuleLedger Load(string path) { if (!File.Exists(path)) @@ -153,10 +177,24 @@ public static ModuleLedger Load(string path) row.ValueKind == JsonValueKind.Object ? ReadStringArray(row, "reaches", label, errors) : [])); + var adapterTiers = ReadRows(root, "adapterTiers", errors, + (row, label) => ReadAdapterTier(row, label, errors)); + foreach (var duplicate in adapterTiers.GroupBy(t => t.Namespace, StringComparer.Ordinal) + .Where(g => g.Count() > 1 && !string.IsNullOrWhiteSpace(g.Key))) + { + errors.Add($"duplicate adapterTiers namespace '{duplicate.Key}'"); + } + foreach (var duplicate in adapterTiers.GroupBy(t => t.Surface, StringComparer.Ordinal) + .Where(g => g.Count() > 1 && !string.IsNullOrWhiteSpace(g.Key))) + { + errors.Add($"duplicate adapterTiers surface '{duplicate.Key}'"); + } + return new ModuleLedger(owners, edges, errors) { AdapterRoots = adapterRoots, Adapters = adapters, + AdapterTiers = adapterTiers, Tables = tables, ForeignKeys = foreignKeys, TableOwnerOverrides = overrides, @@ -194,6 +232,44 @@ private static string RequiredString(JsonElement row, string name, string label, return value ?? string.Empty; } + private static AdapterTier ReadAdapterTier(JsonElement row, string label, List errors) + { + var ns = RequiredString(row, "namespace", label, errors); + var privilege = RequiredString(row, "privilege", label, errors); + var surface = RequiredString(row, "surface", label, errors); + var reason = RequiredString(row, "reason", label, errors); + var reviewBy = RequiredString(row, "reviewBy", label, errors); + + var allowedPrivileges = AdapterTier.KnownSurfaces.Values.Distinct(StringComparer.Ordinal).ToList(); + if (!string.IsNullOrWhiteSpace(privilege) && !allowedPrivileges.Contains(privilege, StringComparer.Ordinal)) + { + errors.Add($"{label} has privilege '{privilege}', which is not in the closed set " + + $"{{{string.Join(", ", allowedPrivileges.Select(p => $"'{p}'"))}}}"); + } + + if (!string.IsNullOrWhiteSpace(surface) && !AdapterTier.KnownSurfaces.ContainsKey(surface)) + { + errors.Add($"{label} has surface '{surface}', which is not in the closed set " + + $"{{{string.Join(", ", AdapterTier.KnownSurfaces.Keys.Select(s => $"'{s}'"))}}} " + + "that AdapterTierScanner walks"); + } + + if (!string.IsNullOrWhiteSpace(privilege) && !string.IsNullOrWhiteSpace(surface) + && AdapterTier.KnownSurfaces.TryGetValue(surface, out var expectedPrivilege) + && privilege != expectedPrivilege) + { + errors.Add($"{label} has privilege '{privilege}', but surface '{surface}' grants " + + $"'{expectedPrivilege}' — a row's privilege must equal AdapterTier.KnownSurfaces['{surface}']"); + } + + if (!string.IsNullOrWhiteSpace(reviewBy) && !ReviewByPattern.IsMatch(reviewBy)) + { + errors.Add($"{label} has reviewBy '{reviewBy}', which must match ^#[0-9]+$ — an exemption needs an end date"); + } + + return new AdapterTier(ns, privilege, surface, reason, reviewBy); + } + private static OwnerDefinition ReadOwner(JsonProperty property, List errors) { var name = property.Name; diff --git a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs index 21a2df621..1bece4ab7 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs @@ -43,6 +43,62 @@ public static class ModuleLedgerScanner private const string Prefix = "Cluckwork."; + private static readonly string[] MsBuildFileSkipDirectories = + ["bin", "obj", "node_modules", ".git", "web"]; + + // A constant guards an `#if` branch the fixed ParseOptions never parse (#843). No project + // graph exists here, so every MSBuild file is read textually, Condition or not. + internal static IReadOnlyList<(string ProjectFile, string Symbol)> UndeclaredDefineConstants(string repoRoot) + { + var declared = new HashSet(ParseOptions.PreprocessorSymbolNames, StringComparer.Ordinal); + var undeclared = new List<(string, string)>(); + + foreach (var file in EnumerateMsBuildFiles(repoRoot)) + { + var document = System.Xml.Linq.XDocument.Load(file); + foreach (var element in document.Descendants("DefineConstants")) + { + foreach (var symbol in element.Value.Split( + ';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (symbol.StartsWith("$(", StringComparison.Ordinal) || declared.Contains(symbol)) + { + continue; + } + + undeclared.Add((Relative(repoRoot, file), symbol)); + } + } + } + + return undeclared; + } + + private static IReadOnlyList EnumerateMsBuildFiles(string repoRoot) + { + var files = new List(); + void Walk(string dir) + { + foreach (var sub in Directory.GetDirectories(dir)) + { + if (MsBuildFileSkipDirectories.Contains(Path.GetFileName(sub), StringComparer.Ordinal)) + { + continue; + } + + Walk(sub); + } + + foreach (var pattern in new[] { "*.csproj", "*.props", "*.targets" }) + { + files.AddRange(Directory.EnumerateFiles(dir, pattern, SearchOption.TopDirectoryOnly)); + } + } + + Walk(repoRoot); + return files.OrderBy(f => f, StringComparer.Ordinal).ToList(); + } + public static ModuleLedgerReport Scan(string srcRoot, string ledgerPath) { var srcFull = Path.GetFullPath(srcRoot);