diff --git a/.github/scripts/lockfix-apply.sh b/.github/scripts/lockfix-apply.sh index 7979bd1c1..a1656e944 100755 --- a/.github/scripts/lockfix-apply.sh +++ b/.github/scripts/lockfix-apply.sh @@ -5,7 +5,7 @@ # PR checkout under `pr/`, and commits+pushes iff ONLY lock files changed. set -euo pipefail -# The classifier prints the allowlist; keep the 9 paths here in lockstep with +# The classifier prints the allowlist; keep the 10 paths here in lockstep with # LOCK_FILES in lockfix.mjs (the classifier is the enforcing check). LOCKS=( "src/Cluckwork.Domain/packages.lock.json" @@ -13,6 +13,7 @@ LOCKS=( "src/Cluckwork.Infrastructure/packages.lock.json" "src/Cluckwork.Api/packages.lock.json" "src/Cluckwork.AppHost/packages.lock.json" + "src/Cluckwork.Analyzers/packages.lock.json" "tests/Cluckwork.Domain.Tests/packages.lock.json" "tests/Cluckwork.Application.Tests/packages.lock.json" "tests/Cluckwork.Api.IntegrationTests/packages.lock.json" diff --git a/.github/scripts/lockfix.mjs b/.github/scripts/lockfix.mjs index b90e40a2e..cf59a494d 100644 --- a/.github/scripts/lockfix.mjs +++ b/.github/scripts/lockfix.mjs @@ -30,6 +30,7 @@ export const LOCK_FILES = Object.freeze([ "src/Cluckwork.Infrastructure/packages.lock.json", "src/Cluckwork.Api/packages.lock.json", "src/Cluckwork.AppHost/packages.lock.json", + "src/Cluckwork.Analyzers/packages.lock.json", "tests/Cluckwork.Domain.Tests/packages.lock.json", "tests/Cluckwork.Application.Tests/packages.lock.json", "tests/Cluckwork.Api.IntegrationTests/packages.lock.json", diff --git a/.github/scripts/lockfix.test.mjs b/.github/scripts/lockfix.test.mjs index 356fd0c6f..2481a0269 100644 --- a/.github/scripts/lockfix.test.mjs +++ b/.github/scripts/lockfix.test.mjs @@ -14,8 +14,8 @@ import { changedPaths, classify, LOCK_FILES } from "./lockfix.mjs"; // the stream is NUL-terminated per record. const z = (...records) => records.map((r) => r + "\0").join(""); -test("the allowlist is exactly the 9 solution lock files", () => { - assert.equal(LOCK_FILES.length, 9); +test("the allowlist is exactly the 10 solution lock files", () => { + assert.equal(LOCK_FILES.length, 10); assert.ok(LOCK_FILES.every((p) => p.endsWith("/packages.lock.json"))); }); @@ -30,7 +30,7 @@ test("the allowlist covers every project in Cluckwork.sln", () => { const projects = [...sln.matchAll(/Project\("[^"]+"\) = "[^"]+", "([^"]+\.csproj)"/g)] .map((m) => m[1].replaceAll("\\", "/").replace(/[^/]+\.csproj$/, "packages.lock.json")) .sort(); - assert.ok(projects.length >= 9, `parsed only ${projects.length} projects from the sln`); + assert.ok(projects.length >= 10, `parsed only ${projects.length} projects from the sln`); assert.deepEqual([...LOCK_FILES].sort(), projects); }); diff --git a/.github/workflows/dependabot-lockfix.yml b/.github/workflows/dependabot-lockfix.yml index b28623eb4..975a75671 100644 --- a/.github/workflows/dependabot-lockfix.yml +++ b/.github/workflows/dependabot-lockfix.yml @@ -80,7 +80,7 @@ jobs: name: lockfiles if-no-files-found: error retention-days: 1 - # These 9 paths (every project in Cluckwork.sln; the two AppHost ones + # These 10 paths (every project in Cluckwork.sln; the two AppHost ones # were missing from the hand-written #203 list) span both src/ and tests/, so the artifact's # least-common-ancestor is the repo root: each file is preserved with # its full repo-relative path. lockfix-apply.sh relies on this — it @@ -91,6 +91,7 @@ jobs: src/Cluckwork.Infrastructure/packages.lock.json src/Cluckwork.Api/packages.lock.json src/Cluckwork.AppHost/packages.lock.json + src/Cluckwork.Analyzers/packages.lock.json tests/Cluckwork.Domain.Tests/packages.lock.json tests/Cluckwork.Application.Tests/packages.lock.json tests/Cluckwork.Api.IntegrationTests/packages.lock.json diff --git a/AGENTS.md b/AGENTS.md index 162d2634c..033ee004b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ src/ Cluckwork.Infrastructure EF Core, Identity/JWT, repositories, seeding, jobs Cluckwork.Api minimal-API endpoints, middleware, Program.cs Cluckwork.AppHost .NET Aspire local orchestration — dev only, never a deploy path + Cluckwork.Analyzers module-edge analyzer (CW1000-CW1003): build and editor feedback, never shipped web/ React/Vite SPA (see web/README.md) deploy/ docker-compose (.yml prod, .dev.yml dev DB), .env.example specs/ product + technical specs, wireframes diff --git a/Cluckwork.sln b/Cluckwork.sln index 6879b425f..cabd52877 100644 --- a/Cluckwork.sln +++ b/Cluckwork.sln @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Api", "src/Cluckw EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.AppHost", "src/Cluckwork.AppHost/Cluckwork.AppHost.csproj", "{10000001-0000-0000-0000-000000000008}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Analyzers", "src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj", "{10000001-0000-0000-0000-00000000000A}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Domain.Tests", "tests/Cluckwork.Domain.Tests/Cluckwork.Domain.Tests.csproj", "{10000001-0000-0000-0000-000000000005}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Application.Tests", "tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj", "{10000001-0000-0000-0000-000000000006}" @@ -47,6 +49,10 @@ Global {10000001-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU {10000001-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU {10000001-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU + {10000001-0000-0000-0000-00000000000A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10000001-0000-0000-0000-00000000000A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10000001-0000-0000-0000-00000000000A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10000001-0000-0000-0000-00000000000A}.Release|Any CPU.Build.0 = Release|Any CPU {10000001-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {10000001-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU {10000001-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Directory.Packages.props b/Directory.Packages.props index 8850cd493..ea08eb4b7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,7 +24,13 @@ - + + + + + diff --git a/docs/decisions/514-module-ledger.md b/docs/decisions/514-module-ledger.md index bbf5b45c8..0f4dad28a 100644 --- a/docs/decisions/514-module-ledger.md +++ b/docs/decisions/514-module-ledger.md @@ -7,6 +7,7 @@ **Status:** accepted **Date:** 2026-09-14 **Mechanism note (2026-10-04, #859):** the ledger rows now live in the `RealModuleLedger.*.cs` files in `tests/Cluckwork.Application.Tests/Architecture`, not in a JSON file. The rule and its checks are unchanged. +**Mechanism note (2026-10-05, #859):** the owner and edge rows moved again, to assembly attributes in `src/Cluckwork.Domain/Common/Architecture`, so the module-edge analyzer can read them; the tests read them by reflection. See [the module-edge analyzer](859-typed-rule-registries.md#the-module-edge-analyzer). ## What happened @@ -27,13 +28,13 @@ seeds default egg grades). A list maintained by recall goes stale; a walk does n ## The rule Every reference from one business module's namespaces to another's is declared in -`tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Edges.cs`, as a cell +`src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs`, as a cell (`From`, `To`, `Kind`, `Reason`) that lists the fully-qualified top-level types realising it. `ModuleLedgerRealTreeTests` walks every `.cs` under `src/` with Roslyn and fails on an undeclared edge, a stale row (a listed type that no longer references the other owner), a namespace no owner claims, a parse error, a compile error in a module-owned file, or a file count below the floor. Add a cross-module dependency and the build tells you which cell to extend -and prints the C# row to paste; remove one and the build tells you which row to delete. +and prints the row to paste; remove one and the build tells you which row to delete. Break the guard by widening a cell's reason instead of reading the code, and the ledger stops being a document anyone reads. diff --git a/docs/decisions/859-typed-rule-registries.md b/docs/decisions/859-typed-rule-registries.md index a095805ba..eeda34420 100644 --- a/docs/decisions/859-typed-rule-registries.md +++ b/docs/decisions/859-typed-rule-registries.md @@ -48,7 +48,8 @@ that message unreachable. - The remaining compatibility exception, `UserRoleAssignmentRepository.ListByNameByUserAsync` with `deleteWhen: "#859"`, moves byte-identical. The last slice removes it; see [The last compatibility exception](#the-last-compatibility-exception). -- No analyzer, no assembly split and no policy change. Known ledger gaps (`FarmModule` reading +- No analyzer, no assembly split and no policy change. The analyzer came later; see + [The module-edge analyzer](#the-module-edge-analyzer). Known ledger gaps (`FarmModule` reading `DiscountCeiling`, Access reaching Commerce through `IIdentityProvider`, seven import-scope rows) stay as they are. - During S1 only, malformed JSON can produce slightly different registry errors, because `Validate` sees @@ -184,3 +185,65 @@ and marks the old row stale with both hashes. Two rows sharing a hash are a regi The hash proves only that a reviewer saw these tokens. It does not see inputs outside the member, such as a class-level constant, a helper the member calls or a wrapper in another file. Forwarding wrappers keep their own rows, each with its own hash. + +## The module-edge analyzer + +On 2026-10-05 the owner added a Roslyn analyzer for compile-time and editor feedback on module edges and +namespace ownership only. Every architecture test stays as it was and remains the CI authority; the tests +never call the analyzer. The spike that preceded this (base `17e37869`) and its review measured the design +below. No incident. + +**One copy of the rows.** The analyzer cannot read the test project, so the owner and edge rows moved from +`RealModuleLedger.Owners.cs` and `RealModuleLedger.Edges.cs` to assembly attributes in +`src/Cluckwork.Domain/Common/Architecture`: `ModuleOwners.cs` (whole owner rows, contract, seam and +implementation lists included) and `ModuleEdges.cs`. Domain is the one assembly every module compilation +references. The analyzer reads the attributes from source while compiling Domain and from metadata +elsewhere, so an edited row takes effect in the editor without rebuilding the analyzer. +`RealModuleLedger.Owners` and `Edges` read the same attributes by reflection, so every test keeps its logic +and assertions and only its data source moved. A parity test compared the reflected rows with the C# rows, +in order, by `System.Text.Json` serialisation, and passed before the old files were deleted; a +one-character reason edit turned it red. It ran locally, not in CI. The adapter, table, tier and +compatibility rows stay in `RealModuleLedger.*.cs` because no analyzer reads them. + +Rejected shapes: a data file passed as `AdditionalFiles` brings back the file format this record removed and +needs a parser on both sides; a C# file compiled into both the analyzer and the tests bakes the rows into +the analyzer, so an editor shows stale diagnostics until it reloads the analyzer. + +**What it reports.** `src/Cluckwork.Analyzers` ports `ModuleLedgerScanner`'s attribution and #1071's +semantic walk: CW1001 an undeclared edge, with the row to paste; CW1002 a stale symbol; CW1003 an unowned +namespace; CW1000 a compilation that reads no map. A symbol that exists in no assembly is reported only by +`Cluckwork.Api`, the compilation that references every module. The registry validation, the global-using +rule and the file floor stay test-only. The spike's suppression barriers and its measurement toggle were +not shipped: the tests are the authority, so silencing a CW id loses only the early warning. + +**Roslyn.** A compiler refuses an analyzer built against a newer Roslyn than itself (CS9057). The central +`Microsoft.CodeAnalysis.CSharp` pin dropped from 5.9.0 to 5.0.0, the compiler in SDK 10.0.1xx; nothing in +the tests needed 5.9. `Microsoft.CodeAnalysis.CSharp.Analyzer.Testing` asks for Workspaces 1.0.1, a .NET +Framework build, so `Microsoft.CodeAnalysis.CSharp.Workspaces` is pinned to 5.0.0 beside it. + +| Check | Result | +|---|---| +| Parity on the real tree | the analyzer's realised edges equal `ModuleLedgerRealTreeTests`' `LiveEdges`: 73 and 73, `diff` identical | +| SDK 10.0.112, compiler `5.0.0-2.26422.108` | clean build green; Finance mutation red with CW1001 | +| SDK 10.0.401, compiler `5.9.0-1.26423.113` | same | +| Docker `build` stage, pinned SDK 10.0.401 | clean exit 0; Finance mutation exit 1 with CW1001 | +| `dotnet format analyzers Cluckwork.sln --verify-no-changes --diagnostics CW1001 CW1002 CW1003 --severity error` | clean exit 0; mutated exit 2 with CW1001. Pointed at a single project it skips referenced projects and reports nothing | +| Full rebuild, `dotnet build src/Cluckwork.Api --no-incremental`, 6 alternating pairs after one warm-up pair | median 3.87 s with, 3.79 s without (+0.08 s, 2 %); analyzer CPU about 1.6 s per build across the four compilations, mostly in parallel | +| `Cluckwork.Domain.dll`, Release | 125,952 to 156,160 bytes, the rows' strings | + +| Mutation | `dotnet build src/Cluckwork.Api` | Existing test, built with `-p:RunAnalyzers=false` | +|---|---|---| +| Finance `CreateExpenseHandler` gets `typeof(Domain.Sales.DiscountCeiling)` | CW1001 Finance -> Commerce, with the row to paste | `ModuleLedgerRealTreeTests`: undeclared cross-owner edge | +| `EggGradeFloorPolicy` removed from its EggOperations -> Farm row while in use | CW1001, naming the row to extend | same test: undeclared edge | +| `UpdateEggGradeHandler`, which does not reference Farm, added to that row | CW1002 from Application | same test: stale ledger row | +| `GoneHandler`, which exists nowhere, added to that row | CW1002 from Api | same test: stale ledger row | +| New type in `Cluckwork.Application.Features.Nowhere` | CW1003 | same test: unowned namespace | + +The analyzer's own tests run the analyzer over a two-project fixture whose Domain compiles the real +`ModuleMapAttributes.cs`: undeclared edge, declared edge, member-access-only reference, stale row, unowned +namespace, and an unused `using` plus `using static` that stays clean. Each has a mutation of the analyzer +that turns it red. + +What this does not cover: live squiggles in a GUI IDE were not observed; CW1002 is a compilation-end +diagnostic, so an IDE shows it only with full-solution analysis on. A new module project must add the +analyzer's `ProjectReference` itself. diff --git a/src/AGENTS.md b/src/AGENTS.md index c245466ef..e153625dc 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -15,7 +15,8 @@ record you must read before changing the rule. Persistence edits also read - **Endpoints:** minimal APIs grouped under `/api/v1/...` via `MapEndpoints`; writes require auth + an `Idempotency-Key` (middleware). - **Nullable enabled**, no unused usings — both are build-breaking. - **`.editorconfig` at `:warning` is a build error, and two C# style rules are gated that way (#985).** `EnforceCodeStyleInBuild` in `Directory.Build.props` runs the IDE analyzers during the build, and `TreatWarningsAsErrors` is already true there, so **any** rule written at `:warning` in `.editorconfig` fails the build repo-wide the moment it is committed — adding one is a decision, not a preference. Two are gated today: `csharp_using_directive_placement = outside_namespace` (IDE0065) and `csharp_style_namespace_declarations = file_scoped` (IDE0161). Fix a violation with `dotnet format style --diagnostics IDE0065 IDE0161 --severity warn --include `; do not hand-move headers. Measured, so you can rely on it rather than re-deriving it: those two are the ONLY rules this property activates at warning severity, because 116 of the SDK's 121 code-style diagnostics default to `Hidden`, four to `Info`, and the repo carries no `.globalconfig`, ruleset, `AnalysisLevel`, `AnalysisMode` or `dotnet_diagnostic.*` entry. Two exemptions, each for a reason that is not "this file is awkward": `SeamSurfaceTests.cs` and `TableOwnerTests.cs` keep inside-namespace usings because 25 of them are relative aliases resolving against the enclosing namespace, and the EF migrations tree keeps block-scoped namespaces because `dotnet ef migrations add` scaffolds them, so the exemption has to keep working for migrations nobody has written yet. **Both placements can shadow**: a using inside a namespace resolves relative to it and can bind to a sibling, and a using outside can be shadowed by a sibling namespace segment of the same simple name. The second is real here — a test namespace named `FlockScope` shadowed the production `FlockScope` class and produced 13 errors across six files — so never introduce a namespace segment whose simple name is also a type. → [`985-csharp-style-gate.md`](../docs/decisions/985-csharp-style-gate.md) -- **Declare cross-module references in the module ledger (#514/#842).** `tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.*.cs` names nine namespace owners: Access, Farm, FlockManagement, EggOperations, Commerce, GeneralInventory, Finance, Insights and Platform. Each cross-owner cell (`From`, `To`, `Kind`, `Reason`) lists the top-level types realising it. `ModuleLedgerRealTreeTests` walks every `.cs` under `src/` and binds module-owned files: an edge is a type a node binds to, or the type declaring the member it binds to, so member access counts and an unused `using` does not (#1071). Undeclared edges, stale rows, unowned namespaces, parse errors, compile errors in module-owned files and file counts below the floor fail. An undeclared edge prints the C# row to add; read the code and extend the cell, never widen a reason. Key by owner and top-level type, never namespace or `file:line` (#632). `Customer.cs` 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 this is Track B (#845–#847). Only review checks `Kind`: `W` writes through the other owner in one transaction; `R` reads or validates. The ledger authorises no move; the design's status line still governs. → [`514-module-ledger.md`](../docs/decisions/514-module-ledger.md) +- **Declare cross-module references in the module ledger (#514/#842).** `src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs` names nine namespace owners: Access, Farm, FlockManagement, EggOperations, Commerce, GeneralInventory, Finance, Insights and Platform. Each cross-owner cell in `ModuleEdges.cs` (`From`, `To`, `Kind`, `Reason`) lists the top-level types realising it. Both are assembly attributes, which the tests read through `RealModuleLedger.Owners` and `Edges` (#859). `ModuleLedgerRealTreeTests` walks every `.cs` under `src/` and binds module-owned files: an edge is a type a node binds to, or the type declaring the member it binds to, so member access counts and an unused `using` does not (#1071). Undeclared edges, stale rows, unowned namespaces, parse errors, compile errors in module-owned files and file counts below the floor fail. An undeclared edge prints the `[assembly: ModuleEdge]` row to add; read the code and extend the cell, never widen a reason. Key by owner and top-level type, never namespace or `file:line` (#632). `Customer.cs` 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 this is Track B (#845–#847). Only review checks `Kind`: `W` writes through the other owner in one transaction; `R` reads or validates. The ledger authorises no move; the design's status line still governs. → [`514-module-ledger.md`](../docs/decisions/514-module-ledger.md) +- **The module-edge analyzer reports the ledger's three source failures while you edit and build; the tests stay the authority (#859).** `src/Cluckwork.Analyzers` runs in Domain, Application, Infrastructure and Api through an analyzer-only `ProjectReference`, and reads the rows from Domain's attributes, so an edited row applies without rebuilding it. It ports `ModuleLedgerScanner`'s semantic walk (#1071): CW1001 is an undeclared edge, at the reference, with the row to paste; CW1002 is a stale symbol; CW1003 is an unowned namespace; CW1000 means the compilation read no map. CW1002 is a compilation-end diagnostic, so an IDE shows it only with full-solution analysis, and a symbol that exists in no assembly is reported only by the Api compilation. It is developer feedback. `ModuleLedgerRealTreeTests` remains the CI authority and never calls the analyzer, and nothing stops `#pragma`, `NoWarn` or `.editorconfig` from silencing a CW id, so fix the row instead. Registry validation, the global-using rule and the file floor stay test-only. The central `Microsoft.CodeAnalysis.CSharp` pin is the analyzer's Roslyn and stays at the oldest compiler in use, 5.0.0 for SDK 10.0.1xx: a newer pin fails every build on that band with CS9057. A new module project adds the same reference. → [`859-typed-rule-registries.md`](../docs/decisions/859-typed-rule-registries.md#the-module-edge-analyzer) - **No persistence type crosses an Application seam (#514/#847).** Public interfaces under `Cluckwork.Application.Features.*` or `Cluckwork.Application.Common` may not reach `DbContext`, `DbSet<>`, `IQueryable`, any `Microsoft.EntityFrameworkCore` type, the bases `Entity`/`AggregateRoot` as declared types, or any `Cluckwork.Infrastructure` type. This covers parameters, return types, generic arguments and properties of returned `Cluckwork.*` types. `SeamSurfaceRealAssemblyTests` reflects over every such interface and fails closed below 30. A second assertion forbids Application assembly references to EF, Npgsql or Infrastructure, keeping the first from being vacuous. Return a DTO, value object, concrete aggregate or `PagedResult`. → [`847-seam-surface-guard.md`](../docs/decisions/847-seam-surface-guard.md) - **Every adapter declares its module reach, and shrinking it is required (#514/#846/#1073).** `AdapterReachRealTreeTests` walks every method and declared constructor in the ledger's endpoint, CLI and job namespaces and its two seeder types, including private helpers and primary constructors. It also walks direct route handlers in ledger-selected top-level programs. `RealModuleLedger.Adapters` records each non-empty owner set by enclosing type and member name. Top-level handlers use project, Program, mapping method and route, including literal HTTP method lists and named handlers. A new owner fails with the crossing type, `file:line` and a generated C# row. Platform is the free hub. This is not a multi-module ban, but each row must equal live reach: an unused owner, or a row whose adapter reaches nothing or no longer exists, fails and names what to remove. An `[AsParameters]` parameter fails because the walk reads no property types. **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 these 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. It shares the module ledger's owner resolution, fails on parse or registry errors and enforces a 40-adapter floor. It over-approximates adapters, cannot bind every simple name or infer transitive reach, and gains #843's adapter tiers on top. → [`846-adapter-reach-ratchet.md`](../docs/decisions/846-adapter-reach-ratchet.md) @@ -24,7 +25,7 @@ record you must read before changing the rule. Persistence edits also read - **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) -- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface in the module's own Application namespace, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. The module's `Contract` in `RealModuleLedger.Owners` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations, Commerce and Insights are contracted today. `IInsightsModule` inherits `IReportQueries` and `IExportQueries`; keep both off Insights' list, because listing either lets any adapter or peer inject it past the ledger guards and skip the facade. → [`849-module-contract.md`](../docs/decisions/849-module-contract.md) **Peer modules reach a contracted module only through its contract or `seam` (#1023).** `PeerContractRealTreeTests` runs the adapter walk over every module's own namespaces and fails when a member takes another contracted module's type listed in neither its `Contract` nor its `Seam`. Adapters never get the seam. Farm's seam is `IAccountRepository`, `Account` and `UserRoleAssignment`; add to it only on purpose. An owner's `Types` claims a type that sits in a Platform namespace, so the check stops treating it as free hub. Every `contract`, `seam` and `types` entry names a top-level, non-generic type, and a claimed type declares no nested types, because the walk drops generic arity. Like the adapter check, it reads parameter types and service resolutions, never return types or method bodies. → [`1023-peer-contract-guard.md`](../docs/decisions/1023-peer-contract-guard.md) **Farm's contract stops at settings, logo and banner (#851), plus the cross-farm `IFarmDirectory` (#858).** `IFarmDirectory` ignores the tenant filter so the operator verbs and the lock sweep can list farms and resolve a farm code; `FarmDirectoryCallerTests` fails when a file outside `Api/Cli`, `Infrastructure/Jobs` and the directory's declaration, implementation and registration names it or `AccountRepository`, because a request path that reached it could show one farm another's. Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. That method is the only caller of the seam's cross-farm `IAccountRepository.FindBySlugAsync`; `FindBySlugCallerTests` fails when any other member references it (#1053). `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](../docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. A peer that injects `IFlockRepository` again fails `PeerContractRealTreeTests` (#1023). → [`852-flock-contract.md`](../docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](../docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](../docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a planner over the locked lots that changes nothing, so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. The port refuses to run without a transaction, and a reservation refuses to plan or draw once the transaction that locked it is no longer current (`EggStockTransactionTests`). Commerce never sees `EggLot`: `PeerContractRealTreeTests` fails when a Commerce type takes an Egg Operations type outside its contract, though it reads only parameter types and service resolutions, not return types or method bodies. `ConfirmSaleAtomicityTests` fails when the confirm runs outside its transaction or saves a draw before the end, each on its own. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](../docs/decisions/854-commerce-contract.md) +- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface in the module's own Application namespace, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. The module's `Contract` in `ModuleOwners.cs` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations, Commerce and Insights are contracted today. `IInsightsModule` inherits `IReportQueries` and `IExportQueries`; keep both off Insights' list, because listing either lets any adapter or peer inject it past the ledger guards and skip the facade. → [`849-module-contract.md`](../docs/decisions/849-module-contract.md) **Peer modules reach a contracted module only through its contract or `seam` (#1023).** `PeerContractRealTreeTests` runs the adapter walk over every module's own namespaces and fails when a member takes another contracted module's type listed in neither its `Contract` nor its `Seam`. Adapters never get the seam. Farm's seam is `IAccountRepository`, `Account` and `UserRoleAssignment`; add to it only on purpose. An owner's `Types` claims a type that sits in a Platform namespace, so the check stops treating it as free hub. Every `contract`, `seam` and `types` entry names a top-level, non-generic type, and a claimed type declares no nested types, because the walk drops generic arity. Like the adapter check, it reads parameter types and service resolutions, never return types or method bodies. → [`1023-peer-contract-guard.md`](../docs/decisions/1023-peer-contract-guard.md) **Farm's contract stops at settings, logo and banner (#851), plus the cross-farm `IFarmDirectory` (#858).** `IFarmDirectory` ignores the tenant filter so the operator verbs and the lock sweep can list farms and resolve a farm code; `FarmDirectoryCallerTests` fails when a file outside `Api/Cli`, `Infrastructure/Jobs` and the directory's declaration, implementation and registration names it or `AccountRepository`, because a request path that reached it could show one farm another's. Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. That method is the only caller of the seam's cross-farm `IAccountRepository.FindBySlugAsync`; `FindBySlugCallerTests` fails when any other member references it (#1053). `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](../docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. A peer that injects `IFlockRepository` again fails `PeerContractRealTreeTests` (#1023). → [`852-flock-contract.md`](../docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](../docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](../docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a planner over the locked lots that changes nothing, so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. The port refuses to run without a transaction, and a reservation refuses to plan or draw once the transaction that locked it is no longer current (`EggStockTransactionTests`). Commerce never sees `EggLot`: `PeerContractRealTreeTests` fails when a Commerce type takes an Egg Operations type outside its contract, though it reads only parameter types and service resolutions, not return types or method bodies. `ConfirmSaleAtomicityTests` fails when the confirm runs outside its transaction or saves a draw before the end, each on its own. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](../docs/decisions/854-commerce-contract.md) - **Access has an aggregate-free contract and owns its Common identity ports (#857/#1023).** Its 29 ledger entries separate request/profile operations, authorization lookup, epoch verification, purge, operations, lifecycle and fixture reads. `IIdentityProvider` and `IStepUpGrantService` are Access-owned `types`, outside the contract; a peer or adapter taking either fails the ownership walk. Fixture reads use `IAccessSeedLookup`, registered outside Production, so demo does not construct IdentityProvider or Redis. `ICurrentUser` and `CurrentUserContext` remain one scoped instance; the context and `SimulationOptions` belong to Persistence. Assignment reads return null only for zero rows or any null-flock row; failures propagate, and otherwise every assigned flock remains in the set. Lifecycle verbs must resolve without UserManager, Identity or Redis. Simulation's flock assignment goes through `IAccessFixture`, also registered outside Production: it skips only the interactive handler's step-up and Worker-role checks, and refuses another farm's user, flock or tenant (#858). Both seeders read the Owner-role prerequisite through `IAccessSeedLookup` (#858); `AccessModule` names a user's flock assignments through `IFlockLookup.GetDisplayNamesAsync` (#859). Session reshaping needs a separate design. → [`857-access-contract.md`](../docs/decisions/857-access-contract.md) - **Register every read of a contracted module's tables from outside it (#514/#850).** `CompatibilityExceptionRealTreeTests` compiles `src/Cluckwork.Infrastructure` from source, and the projects referencing it with errors tolerated. It finds every member that obtains a `DbSet`, maps `T` through the EF model to the tables a query touches (its own, owned values mapped apart and derived types), and takes each table's owner from the table-owner walk, so a `TableOwnerOverrides` row outranks the entity's namespace. A read of a contracted module's table is allowed for the module's own types, a type its ledger edge's `Symbols` names, a type listed in the module's `Implementations`, and a `DbSet` property whose whole body is `Set()`. Every other read needs a `RealModuleLedger.CompatibilityExceptions` row, keyed by namespace, type and member, with `Reaches`, the `Tables` read, a ledger `Owner`, a `Reason` and `DeleteWhen`, the slice issue that deletes it. `DeleteWhen` is `#`, never a date. A new read, a table the row does not name, a `DbSet` of a type parameter and an unbound candidate name fail; a row or table no longer read fails as stale; an Infrastructure compile error fails closed. SQL text is not read. The next contract slice lists its implementations and registers its module's other readers. → [`850-compatibility-exceptions.md`](../docs/decisions/850-compatibility-exceptions.md) - **Seeders and operator verbs compose modules through contracts and fixture ports (#514/#858).** Seeder-only reads and writes go through `IFixture` (Access: `IAccessSeedLookup`) in the module's Application namespace, listed on its ledger `contract` (its implementation goes on `implementations` only when it sits outside the module's namespaces; Access's `Infrastructure.Identity` classes must not), and registered only inside `AddCluckworkPersistence`'s `!IsProduction()` block; members return ids, counts, records or a `Result`, never entities or queries, and purges join the caller's transaction and never commit. `FixturePortRegistrationTests` runs the real API per process role and environment and fails if Production resolves any `I*Fixture`; name a new port that way or add it beside `IAccessSeedLookup`. Cross-farm operator reads use Farm's `IFarmDirectory`, named only where `FarmDirectoryCallerTests` allows and never behind a forwarder. Register services in the owner's file under `Cluckwork.Api/Hosting/Modules/` and list a new module once in `AddCluckworkModules`. `RealModuleLedger.CompatibilityExceptions` is empty since #859. Order the guards cannot see is kept, not enforced: the first two `ICurrencyBoundRowSource` registrations and demo cleanup's flocks-last delete. → [`858-platform-composition.md`](../docs/decisions/858-platform-composition.md) diff --git a/src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj b/src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj new file mode 100644 index 000000000..70575dc34 --- /dev/null +++ b/src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj @@ -0,0 +1,18 @@ + + + + + netstandard2.0 + latest + true + true + + $(NoWarn);RS2008 + + + + + + + diff --git a/src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs b/src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs new file mode 100644 index 000000000..5b4ad591d --- /dev/null +++ b/src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs @@ -0,0 +1,284 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Cluckwork.Analyzers; + +// #859: ModuleLedgerScanner's undeclared-edge, stale-row and unowned-namespace rules (#842, semantic since #1071), +// reported by the compiler while editing and building. ModuleLedgerRealTreeTests stays the CI authority; this +// analyzer only reports earlier, so nothing here guards against suppressing it. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ModuleEdgeAnalyzer : DiagnosticAnalyzer +{ + // The compilation that references every other module assembly, so it alone can tell a row naming a type + // that exists nowhere. + private const string RootAssembly = "Cluckwork.Api"; + + internal static readonly DiagnosticDescriptor MapMissing = new( + "CW1000", "Module map unavailable", + "{0} runs the module-edge analyzer but reads no [assembly: ModuleOwner] rows from Cluckwork.Domain", + "Architecture", DiagnosticSeverity.Error, isEnabledByDefault: true, customTags: WellKnownDiagnosticTags.CompilationEnd); + + internal static readonly DiagnosticDescriptor Undeclared = new( + "CW1001", "Undeclared cross-module edge", + "undeclared cross-owner edge {0} -> {1} from {2} (references {3}); {4}", + "Architecture", DiagnosticSeverity.Error, isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor Stale = new( + "CW1002", "Stale module edge", + "stale module edge {0} -> {1} :: {2}; {3}", + "Architecture", DiagnosticSeverity.Error, isEnabledByDefault: true, customTags: WellKnownDiagnosticTags.CompilationEnd); + + internal static readonly DiagnosticDescriptor Unowned = new( + "CW1003", "Unowned namespace", + "unowned namespace '{0}'; every namespace in src/ must be claimed by exactly one owner in src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs", + "Architecture", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [MapMissing, Undeclared, Stale, Unowned]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + // ModuleLedgerScanner reads every src/*.cs file, generated header or not; Walk skips generator output. + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.RegisterCompilationStartAction(Start); + } + + private static void Start(CompilationStartAnalysisContext start) + { + var compilation = start.Compilation; + var assembly = compilation.AssemblyName ?? ""; + if (ModuleMap.Read(compilation) is not { } map) + { + start.RegisterCompilationEndAction(end => end.ReportDiagnostic(Diagnostic.Create(MapMissing, Location.None, assembly))); + return; + } + + var realised = new ConcurrentDictionary<(string From, string To, string Symbol), byte>(); + var declaredHere = new ConcurrentDictionary(StringComparer.Ordinal); + + start.RegisterSemanticModelAction(model => Walk(model, map, assembly, realised, declaredHere)); + start.RegisterCompilationEndAction(end => + { + foreach (var (from, to, symbols) in map.Edges) + { + if (symbols.IsEmpty && assembly == RootAssembly) + { + end.ReportDiagnostic(Diagnostic.Create(Stale, Location.None, from, to, "", + "the cell lists no symbols, so it excuses nothing and can never go stale; delete it")); + } + + foreach (var symbol in symbols) + { + var stale = declaredHere.ContainsKey(symbol) + ? !realised.ContainsKey((from, to, symbol)) + : assembly == RootAssembly && !ExistsAnywhere(end.Compilation, symbol); + if (stale) + { + end.ReportDiagnostic(Diagnostic.Create(Stale, Location.None, from, to, symbol, + $"no reference in src/ realises this edge; delete the symbol from its ModuleEdge row in {ModuleMap.EdgesFile}, or restore the dependency it was written for")); + } + } + } + }); + } + + private static bool ExistsAnywhere(Compilation compilation, string symbol) + { + // File-scoped symbols are checked only by the compilation holding their file. + if (symbol.StartsWith(":", StringComparison.Ordinal) || symbol.IndexOf('@') >= 0) + { + return true; + } + + var lt = symbol.IndexOf('<'); + var metadataName = lt < 0 ? symbol : $"{symbol.Substring(0, lt)}`{symbol.Length - lt - 1}"; + return compilation.GetTypeByMetadataName(metadataName) is not null; + } + + private static void Walk( + SemanticModelAnalysisContext context, + ModuleMap map, + string assembly, + ConcurrentDictionary<(string, string, string), byte> realised, + ConcurrentDictionary declaredHere) + { + var model = context.SemanticModel; + var path = model.SyntaxTree.FilePath.Replace('\\', '/'); + // Generator output has a relative hint path, or sits under obj/; ModuleLedgerScanner never reads either. + var rooted = path.StartsWith("/", StringComparison.Ordinal) || (path.Length > 2 && path[1] == ':'); + if (!rooted || path.Contains("/obj/")) + { + return; + } + + var root = (CompilationUnitSyntax)model.SyntaxTree.GetRoot(context.CancellationToken); + var attributions = Attribute(context, root, path, assembly, map); + foreach (var attribution in attributions) + { + declaredHere.TryAdd(attribution.Symbol, 0); + } + + if (!attributions.Any(a => a.Owner is { } owner && !map.IsPlatform(owner))) + { + return; + } + + var reported = new HashSet<(string, string, string)>(); + foreach (var node in root.DescendantNodes(n => n is not UsingDirectiveSyntax)) + { + var enclosing = node.Ancestors().LastOrDefault(IsTypeDeclaration); + var from = attributions.FirstOrDefault(a => a.Scope == enclosing) ?? attributions[0]; + if (from.Owner is null || map.IsPlatform(from.Owner)) + { + continue; + } + + foreach (var type in BoundSymbols(model, node).SelectMany(ReferencedTypes)) + { + var outermost = type.OriginalDefinition; + while (outermost.ContainingType is { } parent) + { + outermost = parent; + } + + if (outermost.TypeKind == TypeKind.Error || outermost.ContainingNamespace.IsGlobalNamespace + || map.Resolve($"{outermost.ContainingNamespace.ToDisplayString()}.{outermost.Name}", declared: false) is not { } to + || to.Owner == from.Owner || map.IsPlatform(to.Owner)) + { + continue; + } + + var key = (from.Owner, to.Owner, from.Symbol); + realised.TryAdd(key, 0); + if (!map.IsDeclared(from.Owner, to.Owner, from.Symbol) && reported.Add(key)) + { + context.ReportDiagnostic(Diagnostic.Create(Undeclared, node.GetLocation(), + from.Owner, to.Owner, from.Symbol, to.Namespace, map.Fix(from.Owner, to.Owner, from.Symbol))); + } + } + } + } + + // ModuleLedgerScanner.ScanFile's attribution: each top-level type is charged to its namespace owner, or to the + // owner claiming it (#1023); a file without types is charged as a whole. + private static List Attribute( + SemanticModelAnalysisContext context, CompilationUnitSyntax root, string path, string assembly, ModuleMap map) + { + var attributions = new List(); + foreach (var type in root.DescendantNodes().Where(IsTypeDeclaration).Where(n => !n.Ancestors().Any(IsTypeDeclaration))) + { + var declared = NamespaceOf(type, assembly); + var owner = map.Resolve(declared, declared: true)?.Owner; + if (owner is null) + { + context.ReportDiagnostic(Diagnostic.Create(Unowned, IdentifierLocation(type), declared)); + } + + var symbol = IsFileLocal(type) ? $"{declared}.{Identifier(type)}@{Relative(path)}" : $"{declared}.{Identifier(type)}"; + attributions.Add(new Attribution(type, symbol, map.Claimant(symbol) ?? owner)); + } + + if (attributions.Count == 0) + { + var declaration = root.DescendantNodes().OfType().FirstOrDefault(); + var fileNamespace = declaration?.Name.ToString() ?? assembly; + var owner = map.Resolve(fileNamespace, declared: true)?.Owner; + if (owner is null) + { + context.ReportDiagnostic(Diagnostic.Create(Unowned, + declaration?.Name.GetLocation() ?? Location.Create(root.SyntaxTree, default), fileNamespace)); + } + + attributions.Add(new Attribution(null, $":{Relative(path)}", owner)); + } + + return attributions; + } + + // GetSymbolInfo names what the source spells; the compiler also calls conversion operators, collection-initializer + // Adds and the foreach enumerator pattern on its own. + private static IEnumerable BoundSymbols(SemanticModel model, SyntaxNode node) => node switch + { + ExpressionSyntax expression => + [ + model.GetSymbolInfo(expression).Symbol, + model.GetConversion(expression).MethodSymbol, + expression.Parent.IsKind(SyntaxKind.CollectionInitializerExpression) + ? model.GetCollectionInitializerSymbolInfo(expression).Symbol + : null, + ], + CommonForEachStatementSyntax loop when model.GetForEachStatementInfo(loop) is var info => + [info.GetEnumeratorMethod, info.MoveNextMethod, info.CurrentProperty, info.DisposeMethod, info.ElementConversion.MethodSymbol], + _ => [], + }; + + private static IEnumerable ReferencedTypes(ISymbol? symbol) => symbol switch + { + ITypeSymbol type => TypesIn(type), + IMethodSymbol or IPropertySymbol or IFieldSymbol or IEventSymbol => TypesIn(symbol.ContainingType), + _ => [], + }; + + private static IEnumerable TypesIn(ITypeSymbol type) => type switch + { + INamedTypeSymbol named => named.TypeArguments.SelectMany(TypesIn).Prepend(named), + IArrayTypeSymbol array => TypesIn(array.ElementType), + IPointerTypeSymbol pointer => TypesIn(pointer.PointedAtType), + _ => [], + }; + + private sealed class Attribution(SyntaxNode? scope, string symbol, string? owner) + { + public SyntaxNode? Scope { get; } = scope; + + public string Symbol { get; } = symbol; + + public string? Owner { get; } = owner; + } + + private static bool IsTypeDeclaration(SyntaxNode node) => node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax; + + private static Location IdentifierLocation(SyntaxNode node) => node switch + { + BaseTypeDeclarationSyntax type => type.Identifier.GetLocation(), + DelegateDeclarationSyntax d => d.Identifier.GetLocation(), + _ => node.GetLocation(), + }; + + private static string Identifier(SyntaxNode node) => node switch + { + TypeDeclarationSyntax type => GenericIdentifier(type.Identifier.ValueText, type.TypeParameterList), + BaseTypeDeclarationSyntax type => type.Identifier.ValueText, + DelegateDeclarationSyntax d => GenericIdentifier(d.Identifier.ValueText, d.TypeParameterList), + _ => throw new InvalidOperationException($"{node.Kind()} is not a type declaration."), + }; + + // Arity only: type parameter names are not part of a generic type's identity. + private static string GenericIdentifier(string identifier, TypeParameterListSyntax? parameters) => + parameters is null ? identifier : $"{identifier}<{new string(',', parameters.Parameters.Count - 1)}>"; + + private static bool IsFileLocal(SyntaxNode node) => node switch + { + BaseTypeDeclarationSyntax type => type.Modifiers.Any(SyntaxKind.FileKeyword), + DelegateDeclarationSyntax d => d.Modifiers.Any(SyntaxKind.FileKeyword), + _ => false, + }; + + private static string NamespaceOf(SyntaxNode node, string assembly) + { + var parts = node.Ancestors().OfType().Select(n => n.Name.ToString()).Reverse().ToList(); + return parts.Count == 0 ? assembly : string.Join(".", parts); + } + + // ponytail: repo-relative by the last "/src/" segment, as ModuleLedgerScanner prints it; a checkout path without + // one keeps the absolute path, which only file-scoped symbols would notice. + private static string Relative(string path) + { + var cut = path.LastIndexOf("/src/", StringComparison.Ordinal); + return cut < 0 ? path : path.Substring(cut + 1); + } +} diff --git a/src/Cluckwork.Analyzers/ModuleMap.cs b/src/Cluckwork.Analyzers/ModuleMap.cs new file mode 100644 index 000000000..8663fe594 --- /dev/null +++ b/src/Cluckwork.Analyzers/ModuleMap.cs @@ -0,0 +1,107 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Cluckwork.Analyzers; + +// The [assembly: ModuleOwner] and [assembly: ModuleEdge] rows of Cluckwork.Domain, read from source when compiling +// Domain and from metadata everywhere else, so an edited row takes effect without rebuilding the analyzer. +internal sealed class ModuleMap +{ + internal const string EdgesFile = "src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs"; + + private const string Namespace = "Cluckwork.Domain.Common.Architecture."; + + // ModuleLedgerScanner.BuildNamespaceIndex: a namespace claim covers its subtree; an exact namespace and a + // claimed type (#1023) cover their own name only. + private readonly Dictionary _claims = new(StringComparer.Ordinal); + private readonly HashSet _platform = new(StringComparer.Ordinal); + private readonly HashSet<(string From, string To, string Symbol)> _declared = []; + + internal ImmutableArray<(string From, string To, ImmutableArray Symbols)> Edges { get; private set; } + + internal static ModuleMap? Read(Compilation compilation) + { + var domain = compilation.Assembly.Name == "Cluckwork.Domain" + ? compilation.Assembly + : compilation.SourceModule.ReferencedAssemblySymbols.FirstOrDefault(a => a.Name == "Cluckwork.Domain"); + if (domain is null) + { + return null; + } + + var map = new ModuleMap(); + var edges = ImmutableArray.CreateBuilder<(string, string, ImmutableArray)>(); + foreach (var attribute in domain.GetAttributes()) + { + var args = attribute.ConstructorArguments; + switch (attribute.AttributeClass?.ToDisplayString()) + { + case Namespace + "ModuleOwnerAttribute": + var owner = (string)args[0].Value!; + if ((string?)args[1].Value == "platform") + { + map._platform.Add(owner); + } + + foreach (var named in attribute.NamedArguments) + { + if (named.Key is "Namespaces" or "ExactNamespaces" or "Types") + { + foreach (var claimed in named.Value.Values) + { + map._claims[(string)claimed.Value!] = (owner, named.Key == "Namespaces"); + } + } + } + + break; + case Namespace + "ModuleEdgeAttribute": + var (from, to) = ((string)args[0].Value!, (string)args[1].Value!); + var symbols = args[4].Values.Select(v => (string)v.Value!).ToImmutableArray(); + edges.Add((from, to, symbols)); + foreach (var symbol in symbols) + { + map._declared.Add((from, to, symbol)); + } + + break; + } + } + + map.Edges = edges.ToImmutable(); + return map._claims.Count == 0 ? null : map; + } + + internal bool IsPlatform(string owner) => _platform.Contains(owner); + + internal bool IsDeclared(string from, string to, string symbol) => _declared.Contains((from, to, symbol)); + + internal string? Claimant(string symbol) => _claims.TryGetValue(symbol, out var claim) ? claim.Owner : null; + + // ModuleLedgerScanner.Resolve: the longest claimed prefix. Resolving a declared namespace, an exact claim covers + // only its own name. + internal (string Owner, string Namespace)? Resolve(string dotted, bool declared) + { + var probe = dotted; + while (true) + { + if (_claims.TryGetValue(probe, out var claim) && (claim.Subtree || !declared || probe == dotted)) + { + return (claim.Owner, probe); + } + + var cut = probe.LastIndexOf('.'); + if (cut < 0) + { + return null; + } + + probe = probe.Substring(0, cut); + } + } + + internal string Fix(string from, string to, string symbol) => + Edges.Any(cell => cell.From == from && cell.To == to) + ? $"add \"{symbol}\" to the ModuleEdge(\"{from}\", \"{to}\", ...) row in {EdgesFile}, and extend its reason" + : $"add this row to {EdgesFile}, with a reason naming the port or type it calls: [assembly: ModuleEdge(\"{from}\", \"{to}\", \"R\", \"\", \"{symbol}\")]"; +} diff --git a/src/Cluckwork.Analyzers/packages.lock.json b/src/Cluckwork.Analyzers/packages.lock.json new file mode 100644 index 000000000..9b0a600f1 --- /dev/null +++ b/src/Cluckwork.Analyzers/packages.lock.json @@ -0,0 +1,120 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Buffers": "4.6.0", + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.6.0", + "System.Numerics.Vectors": "4.6.0", + "System.Reflection.Metadata": "9.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.1.0", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "System.Buffers": "4.6.0", + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.6.0", + "System.Numerics.Vectors": "4.6.0", + "System.Reflection.Metadata": "9.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.1.0", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "lN6tZi7Q46zFzAbRYXTIvfXcyvQQgxnY7Xm6C6xQ9784dEL1amjM6S6Iw4ZpsvesAKnRVsM4scrDQaDqSClkjA==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "OEkbBQoklHngJ8UD8ez2AERSk2g+/qpAaSWWCBFbpH727HxDq5ydVkuncBaKcKfwRqXGWx64dS6G1SUScMsitg==", + "dependencies": { + "System.Buffers": "4.6.0", + "System.Numerics.Vectors": "4.6.0", + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "t+SoieZsRuEyiw/J+qXUbolyO219tKQQI0+2/YI+Qv7YdGValA6WiuokrNKqjrTNsy5ABWU11bdKOzUdheteXg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", + "dependencies": { + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.5.5" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.0", + "contentHash": "5o/HZxx6RVqYlhKSq8/zronDkALJZUT2Vz0hx43f0gwe8mwlM0y2nYlqdBwLMzr262Bwvpikeb/yEwkAa5PADg==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Cluckwork.Api/Cluckwork.Api.csproj b/src/Cluckwork.Api/Cluckwork.Api.csproj index d1dadccc5..026ad0841 100644 --- a/src/Cluckwork.Api/Cluckwork.Api.csproj +++ b/src/Cluckwork.Api/Cluckwork.Api.csproj @@ -34,6 +34,8 @@ + + diff --git a/src/Cluckwork.Api/Dockerfile b/src/Cluckwork.Api/Dockerfile index a5702e38a..f7ce517b9 100644 --- a/src/Cluckwork.Api/Dockerfile +++ b/src/Cluckwork.Api/Dockerfile @@ -31,7 +31,8 @@ WORKDIR /src # Management), so both must precede restore — without the second the csproj # files reference packages with no version and the restore fails (NU1010). # Only the SERVING projects are restored here (not the test projects), -# so only their four lock files are needed. Copying just these inputs (not the +# so only their four lock files are needed, plus the module-edge analyzer +# they all reference (#859). Copying just these inputs (not the # full source tree) keeps the restore layer cached across source-only changes. COPY Directory.Build.props Directory.Packages.props ./ COPY Cluckwork.sln ./ @@ -39,6 +40,7 @@ COPY src/Cluckwork.Domain/Cluckwork.Domain.csproj src/Cluckwork.Domain/packages. COPY src/Cluckwork.Application/Cluckwork.Application.csproj src/Cluckwork.Application/packages.lock.json src/Cluckwork.Application/ COPY src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj src/Cluckwork.Infrastructure/packages.lock.json src/Cluckwork.Infrastructure/ COPY src/Cluckwork.Api/Cluckwork.Api.csproj src/Cluckwork.Api/packages.lock.json src/Cluckwork.Api/ +COPY src/Cluckwork.Analyzers/Cluckwork.Analyzers.csproj src/Cluckwork.Analyzers/packages.lock.json src/Cluckwork.Analyzers/ RUN dotnet restore src/Cluckwork.Api/Cluckwork.Api.csproj --locked-mode COPY . . RUN dotnet publish src/Cluckwork.Api/Cluckwork.Api.csproj -c Release -o /out --no-restore diff --git a/src/Cluckwork.Api/packages.lock.json b/src/Cluckwork.Api/packages.lock.json index 5c4c855b6..7be0ec0a6 100644 --- a/src/Cluckwork.Api/packages.lock.json +++ b/src/Cluckwork.Api/packages.lock.json @@ -146,19 +146,6 @@ "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, - "Microsoft.CodeAnalysis.CSharp.Workspaces": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, "Microsoft.CodeAnalysis.Workspaces.Common": { "type": "Transitive", "resolved": "5.0.0", @@ -444,7 +431,7 @@ }, "Microsoft.CodeAnalysis.CSharp": { "type": "CentralTransitive", - "requested": "[5.9.0, )", + "requested": "[5.0.0, )", "resolved": "5.0.0", "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { @@ -452,6 +439,20 @@ "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "CentralTransitive", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, "Microsoft.EntityFrameworkCore": { "type": "CentralTransitive", "requested": "[10.*, )", diff --git a/src/Cluckwork.Application/Cluckwork.Application.csproj b/src/Cluckwork.Application/Cluckwork.Application.csproj index b8dcec579..4b82ed4bc 100644 --- a/src/Cluckwork.Application/Cluckwork.Application.csproj +++ b/src/Cluckwork.Application/Cluckwork.Application.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/Cluckwork.Application/Features/Accounts/IFarmModule.cs b/src/Cluckwork.Application/Features/Accounts/IFarmModule.cs index ce1b2a7b1..83c9f177c 100644 --- a/src/Cluckwork.Application/Features/Accounts/IFarmModule.cs +++ b/src/Cluckwork.Application/Features/Accounts/IFarmModule.cs @@ -8,7 +8,7 @@ namespace Cluckwork.Application.Features.Accounts; // #851: the Farm module's contract. Adapters reach Farm only through the types -// RealModuleLedger.Owners lists in the Farm row's Contract. Every member reads or +// ModuleOwners.cs lists in the Farm row's Contract. Every member reads or // writes the current tenant's farm, so it runs only after TenantContext is // resolved and never establishes identity: sign-in resolves its farm code // through IIdentityProvider instead. diff --git a/src/Cluckwork.Application/Features/Eggs/IEggOperationsModule.cs b/src/Cluckwork.Application/Features/Eggs/IEggOperationsModule.cs index 56e5789df..38a643abf 100644 --- a/src/Cluckwork.Application/Features/Eggs/IEggOperationsModule.cs +++ b/src/Cluckwork.Application/Features/Eggs/IEggOperationsModule.cs @@ -12,7 +12,7 @@ namespace Cluckwork.Application.Features.Eggs; // #853: the Egg Operations contract for adapters. Adapters reach Egg Operations -// only through the types RealModuleLedger.Owners lists in +// only through the types ModuleOwners.cs lists in // the EggOperations row's Contract; peer modules use the narrower IEggGradeLookup // and IDailyEntryLookup ports. public interface IEggOperationsModule diff --git a/src/Cluckwork.Application/Features/Expenses/IFinanceModule.cs b/src/Cluckwork.Application/Features/Expenses/IFinanceModule.cs index 78de09c0f..07af9a848 100644 --- a/src/Cluckwork.Application/Features/Expenses/IFinanceModule.cs +++ b/src/Cluckwork.Application/Features/Expenses/IFinanceModule.cs @@ -8,7 +8,7 @@ namespace Cluckwork.Application.Features.Expenses; // #849: the Finance module's contract. Adapters reach Finance only through the -// types RealModuleLedger.Owners lists in the Finance row's Contract. +// types ModuleOwners.cs lists in the Finance row's Contract. public interface IFinanceModule { // The EntityType Finance writes on an expense's audit rows; provenance reads key by it. diff --git a/src/Cluckwork.Application/Features/Flocks/IFlockModule.cs b/src/Cluckwork.Application/Features/Flocks/IFlockModule.cs index 0e1bcce81..d88ab5231 100644 --- a/src/Cluckwork.Application/Features/Flocks/IFlockModule.cs +++ b/src/Cluckwork.Application/Features/Flocks/IFlockModule.cs @@ -7,7 +7,7 @@ namespace Cluckwork.Application.Features.Flocks; // #852: the Flock Management contract for adapters. Adapters reach Flock -// Management only through the types RealModuleLedger.Owners lists in +// Management only through the types ModuleOwners.cs lists in // the FlockManagement row's Contract; peer modules use the narrower IFlockLookup // and IMortalityLedger ports. public interface IFlockModule diff --git a/src/Cluckwork.Application/Features/Inventory/IInventoryModule.cs b/src/Cluckwork.Application/Features/Inventory/IInventoryModule.cs index 3e23fc0af..987f146a7 100644 --- a/src/Cluckwork.Application/Features/Inventory/IInventoryModule.cs +++ b/src/Cluckwork.Application/Features/Inventory/IInventoryModule.cs @@ -11,7 +11,7 @@ namespace Cluckwork.Application.Features.Inventory; // #855: the General Inventory contract. Adapters reach General Inventory only -// through the types RealModuleLedger.Owners lists in +// through the types ModuleOwners.cs lists in // the GeneralInventory row's Contract. public interface IInventoryModule { diff --git a/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs b/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs index 84809c4a1..7eb61d73f 100644 --- a/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs +++ b/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs @@ -18,7 +18,7 @@ namespace Cluckwork.Application.Features.Sales; // #854: the Commerce contract for adapters. Adapters reach Commerce only -// through the types RealModuleLedger.Owners lists in the Commerce row's Contract; +// through the types ModuleOwners.cs lists in the Commerce row's Contract; // peer modules use the narrower IEggUnitConversionLookup port. public interface ICommerceModule { diff --git a/src/Cluckwork.Domain/Cluckwork.Domain.csproj b/src/Cluckwork.Domain/Cluckwork.Domain.csproj index 61718a1f8..fb7aaeeac 100644 --- a/src/Cluckwork.Domain/Cluckwork.Domain.csproj +++ b/src/Cluckwork.Domain/Cluckwork.Domain.csproj @@ -1,3 +1,8 @@ + + + + + diff --git a/src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs b/src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs new file mode 100644 index 000000000..134bae899 --- /dev/null +++ b/src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs @@ -0,0 +1,141 @@ +using Cluckwork.Domain.Common.Architecture; + +[assembly: ModuleEdge( + "Access", "Commerce", "W", + "W because AccountProvisioner inserts EggUnitConversion.Defaults for a new farm inside its provisioning transaction; the rest of the cell reads. Identity carries the per-user stepper-unit preference: ApplicationUser stores Domain.Catalog.EggUnit, IIdentityProvider, the Identity port Access claims, takes it in SetStepperUnitAsync, IdentityProvider reads and writes it, SetStepperUnitHandler resolves it through Commerce's IEggUnitConversionLookup port (#854) and rejects inactive conversions, and SetStepperUnitValidator validates only the enum name. Nothing prevents a stored preference from pointing at a conversion later deactivated through UpdateEggUnitConversionHandler. Design 3.4 shows Access -> Commerce as none; this is live coupling the target design has still to remove.", + "Cluckwork.Application.Common.IIdentityProvider", + "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitHandler", + "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitValidator", + "Cluckwork.Infrastructure.Identity.AccountProvisioner", + "Cluckwork.Infrastructure.Identity.ApplicationUser", + "Cluckwork.Infrastructure.Identity.IdentityProvider")] +[assembly: ModuleEdge( + "Access", "EggOperations", "W", + "W because AccountProvisioner inserts a new farm's default grades, Domain.Eggs.EggGrade.Defaults(accountId, SeedDefaults.FarmId), inside its provisioning transaction, so farm provisioning writes Egg Operations' table and depends on its grade shape. Design 3.4 shows Access -> Egg Ops as none; this is live coupling the target design has still to remove.", + "Cluckwork.Infrastructure.Identity.AccountProvisioner")] +[assembly: ModuleEdge( + "Access", "Farm", "W", + "W because AccountProvisioner inserts the new Account inside its provisioning transaction and AccountRenameService calls Account.Rename and saves; the rest of the cell reads. Identity is where a farm is created, renamed, suspended and recovered. AccountProvisioner, AccountRenameService, AccountSuspensionService, AdminRecoveryService, FirstRunAdminService and FirstRunStatusService load Domain.Accounts.Account or inject Farm's IAccountRepository, and IdentityProvider takes that repository by fully qualified name for the account-scoped login (#532). CredentialEpochVerifier reads Account.IsActive in the same fresh per-request query as the user's credential epoch, so a suspended farm's credentials stop working at once (#364, #579, #857). The user validators read Domain.Accounts.Roles for the assignable role set, and IUserRoleAssignmentRepository returns Domain.Accounts.UserRoleAssignment rows, which AccessModule reads to list a user's flock assignments. IAccessLookup returns Domain.Accounts.EffectiveAccountRole, and AccessLookup resolves it with Roles.ResolveEffective in its private effective-role routine (#612, #857). Design 3.4 row Access -> Farm = R. AccessFixture (#858) creates the simulation fixture's UserRoleAssignment through the scoped context, and AccessSeedLookup checks the fixture's Owner role by Roles.Owner.", + "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler", + "Cluckwork.Application.Features.Users.AssignFlock.UnassignFlockHandler", + "Cluckwork.Application.Features.Users.ChangeUserRole.ChangeUserRoleValidator", + "Cluckwork.Application.Features.Users.CreateUser.CreateUserValidator", + "Cluckwork.Application.Features.Users.IAccessLookup", + "Cluckwork.Application.Features.Users.IUserRoleAssignmentRepository", + "Cluckwork.Infrastructure.Identity.AccessFixture", + "Cluckwork.Infrastructure.Identity.AccessLookup", + "Cluckwork.Infrastructure.Identity.AccessModule", + "Cluckwork.Infrastructure.Identity.AccessSeedLookup", + "Cluckwork.Infrastructure.Identity.AccountProvisioner", + "Cluckwork.Infrastructure.Identity.AccountRenameService", + "Cluckwork.Infrastructure.Identity.AccountSuspensionService", + "Cluckwork.Infrastructure.Identity.AdminRecoveryService", + "Cluckwork.Infrastructure.Identity.CredentialEpochVerifier", + "Cluckwork.Infrastructure.Identity.FirstRunAdminService", + "Cluckwork.Infrastructure.Identity.FirstRunStatusService", + "Cluckwork.Infrastructure.Identity.IdentityProvider")] +[assembly: ModuleEdge( + "Access", "FlockManagement", "R", + "AssignFlockHandler injects Flock Management's IFlockLookup port to prove the flock exists before narrowing a worker to it and to name it in the audit row (#103, spec 5.2/5.3). Design 3.4 row Access -> Flock = R. AccessFixture (#858) reads the same port for the simulation fixture's assignment and audit row. AccessModule names a user's flock assignments through IFlockLookup.GetDisplayNamesAsync, one bounded read whose filters decide which names the caller sees (#613, #859).", + "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler", + "Cluckwork.Infrastructure.Identity.AccessFixture", + "Cluckwork.Infrastructure.Identity.AccessModule")] +[assembly: ModuleEdge( + "Commerce", "Access", "R", + "ConfirmSaleHandler reads through Access's IAccessLookup port (GetEffectiveRoleAsync and GetAssignedFlocksAsync, #857) inside the confirm transaction, so a plain Worker's committed flock assignments and the discount ceiling bound to their effective role are read at the moment of confirmation rather than from a request-start snapshot (#727). Design 3.4 shows Commerce -> Access as none; this is live coupling the target design has still to remove.", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler")] +[assembly: ModuleEdge( + "Commerce", "EggOperations", "W", + "FIFO egg stock. ConfirmSaleHandler and VoidSaleHandler lock, draw from and return egg lots through Egg Operations' IEggStock port, which adds the lot changes and Sale or Void movements to the confirm and void transactions and never saves (#854); Commerce sees plans and lot ids, never Domain.Eggs.EggLot. Products, order lines and confirm refusals read grades through IEggGradeLookup in CreateProductHandler, UpdateProductHandler, AddOrderItemHandler and ConfirmSaleHandler. Design 3.4 row Commerce -> Egg Ops = W.", + "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", + "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", + "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemHandler", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", + "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleHandler")] +[assembly: ModuleEdge( + "Commerce", "Farm", "R", + "Orders and products snapshot the farm's currency through Farm's IAccountRepository (CreateSalesOrderHandler, CreateProductHandler, UpdateProductHandler), with the sales-order path holding the FOR SHARE lock on the account row (#162) so an order cannot land in a denomination the farm is mid-flight out of; ConfirmSaleHandler also reads the farm's discount ceiling and worker sale-allocation policy off Account. Design 3.4 row Commerce -> Farm = R.", + "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", + "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", + "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderHandler")] +[assembly: ModuleEdge( + "EggOperations", "Farm", "R", + "CreateEggGradeHandler attaches a new grade to Domain.Accounts.SeedDefaults.FarmId, the single-farm stand-in for the farm the grade belongs to; EggGradeFloorPolicy resolves the caller's effective role through Domain.Accounts.Roles to decide whether a grade's low-stock floor may move (#911, Owner-only per #729). Design 3.4 row Egg Ops -> Farm = R.", + "Cluckwork.Application.Features.EggGrades.CreateEggGrade.CreateEggGradeHandler", + "Cluckwork.Application.Features.EggGrades.EggGradeFloorPolicy")] +[assembly: ModuleEdge( + "EggOperations", "FlockManagement", "W", + "Daily entry is the mortality writer: SubmitDailyEntryHandler, AdjustDailyEntryHandler and VoidDailyEntryHandler append bird-movement rows through Flock Management's IMortalityLedger port, which adds the row to the caller's unit of work and never saves, so it commits with the entry itself (#54, #69, #852). Every daily-entry handler reads the flock through the IFlockLookup port to check CanRecordProductionOn for the entry's date. Design 3.4 row Egg Ops -> Flock = W.", + "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryHandler", + "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.RecordDailyEntryHandler", + "Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry.SubmitDailyEntryHandler", + "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryHandler")] +[assembly: ModuleEdge( + "Farm", "Commerce", "R", + "Farm settings own three Commerce-shaped values. Domain.Accounts.Account holds Domain.Catalog.EggUnit as the farm's default stepper unit and exposes Domain.Sales.DiscountCeiling, while UpdateFarmSettingsHandler resolves the stepper unit through Commerce's IEggUnitConversionLookup port (#854) and UpdateFarmSettingsValidator parses the ceiling with DiscountCeiling.TryParsePercent (#727). The Farm contract's FarmSettingsDetails, declared beside IFarmModule, carries the same Domain.Catalog.EggUnit stepper unit out to its callers (#851), and FarmModule fills its discount ceiling from DiscountCeiling.Percent. Design 3.4 shows Farm -> Commerce as none; this is live coupling the target design has still to remove.", + "Cluckwork.Application.Features.Accounts.FarmModule", + "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", + "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsHandler", + "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsValidator", + "Cluckwork.Domain.Accounts.Account")] +[assembly: ModuleEdge( + "Finance", "Farm", "R", + "CreateExpenseHandler injects Farm's IAccountRepository for the lock-aware currency snapshot an expense binds to (#162, FOR SHARE on the account row), and it and CreateExpenseCategoryHandler attach their rows to Domain.Accounts.SeedDefaults.FarmId, whose active categories FinanceModule lists. Design 3.4 classes this cell W, but the code reads Farm state under a shared row lock and mutates nothing there, the same shape 3.4 classes R on Commerce -> Farm and Inventory -> Farm; the ledger records what the code does and notes the disagreement here so #848 regenerates the matrix from this reading.", + "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseHandler", + "Cluckwork.Application.Features.Expenses.CreateExpenseCategory.CreateExpenseCategoryHandler", + "Cluckwork.Application.Features.Expenses.FinanceModule")] +[assembly: ModuleEdge( + "Finance", "FlockManagement", "R", + "CreateExpenseHandler and AdjustExpenseHandler inject Flock Management's IFlockLookup port to validate the optional flock an expense is attributed to. Design 3.4 row Finance -> Flock = R.", + "Cluckwork.Application.Features.Expenses.AdjustExpense.AdjustExpenseHandler", + "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseHandler")] +[assembly: ModuleEdge( + "FlockManagement", "Farm", "R", + "CreateFlockHandler places a new flock on Domain.Accounts.SeedDefaults.FarmId and SeedDefaults.HouseId, the single-farm stand-ins. Design 3.4 row Flock -> Farm = R.", + "Cluckwork.Application.Features.Flocks.CreateFlock.CreateFlockHandler")] +[assembly: ModuleEdge( + "GeneralInventory", "EggOperations", "R", + "RecordFeedUsageHandler and RecordWaterUsageHandler inject Egg Operations' IDailyEntryLookup port by fully qualified name and call FindIdForFlockScopedWriteAsync (#853), so a feed or water record carries the day's daily-entry provenance. Design 3.4 shows Inventory -> Egg Ops as none; this is live coupling the target design has still to remove.", + "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageHandler", + "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageHandler")] +[assembly: ModuleEdge( + "GeneralInventory", "Farm", "R", + "CreateInventoryItemHandler, RecordPurchaseHandler and UpdateInventoryItemHandler inject Farm's IAccountRepository for the farm currency a priced item or a purchase snapshots, under the same FOR SHARE account-row lock as the sales path (#162), and CreateInventoryItemHandler attaches the item to Domain.Accounts.SeedDefaults.FarmId. Design 3.4 row Inventory -> Farm = R.", + "Cluckwork.Application.Features.Inventory.CreateInventoryItem.CreateInventoryItemHandler", + "Cluckwork.Application.Features.Inventory.RecordPurchase.RecordPurchaseHandler", + "Cluckwork.Application.Features.Inventory.UpdateInventoryItem.UpdateInventoryItemHandler")] +[assembly: ModuleEdge( + "GeneralInventory", "FlockManagement", "R", + "RecordFeedUsageHandler, RecordWaterUsageHandler and UpdateWaterUsageHandler inject Flock Management's IFlockLookup port to prove the flock being fed or watered exists and is eligible for the usage date. Design 3.4 row Inventory -> Flock = R.", + "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageHandler", + "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageHandler", + "Cluckwork.Application.Features.Inventory.UpdateWaterUsage.UpdateWaterUsageHandler")] +[assembly: ModuleEdge( + "Insights", "Commerce", "R", + "ReportQueries reads confirmed SalesOrders and Payments for sales and profit totals; ExportQueries streams Customers, SalesOrders, SalesOrderItems, SalesOrderAllocations and Payments. Reads compose in C# and never mutate Commerce rows.", + "Cluckwork.Infrastructure.Insights.ExportQueries", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "EggOperations", "R", + "ReportQueries reads DailyEntries, DailyEntryGrades and EggGrades for production and grading totals; ExportQueries streams those records, EggLots and EggInventoryMovements with their existing filters and ordering.", + "Cluckwork.Infrastructure.Insights.ExportQueries", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "Farm", "R", + "ReportQueries.AccountCurrencyAsync reads the current Account currency for report DTOs. The account query remains tenant-filtered and AsNoTracking.", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "Finance", "R", + "ReportQueries aggregates Expenses for expense and profit totals; ExportQueries streams ExpenseCategories and Expenses as typed CSV rows. Neither query writes Finance data.", + "Cluckwork.Infrastructure.Insights.ExportQueries", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "FlockManagement", "R", + "ExportQueries streams Flock and BirdMovement records; ReportQueries reads flock lifecycle and bird movements to calculate hen-day exposure. These are read-only owner-table queries composed in C#.", + "Cluckwork.Infrastructure.Insights.ExportQueries", + "Cluckwork.Infrastructure.Insights.ReportQueries")] +[assembly: ModuleEdge( + "Insights", "GeneralInventory", "R", + "ExportQueries streams InventoryItems, InventoryLots, InventoryMovements and FeedUsages as typed CSV rows. No inventory mutation or cross-owner SQL join is introduced.", + "Cluckwork.Infrastructure.Insights.ExportQueries")] diff --git a/src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs b/src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs new file mode 100644 index 000000000..9858c09dc --- /dev/null +++ b/src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs @@ -0,0 +1,46 @@ +namespace Cluckwork.Domain.Common.Architecture; + +// The module map (#514, #842, #859): one owner row per module and one edge cell per cross-owner dependency, +// declared in ModuleOwners.cs and ModuleEdges.cs. Domain is the one assembly every module compilation +// references, so the module-edge analyzer reads these rows from source or metadata, and the architecture tests +// read them by reflection through RealModuleLedger. Owner names stay strings (#859). + +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class ModuleOwnerAttribute(string name, string kind) : Attribute +{ + public string Name { get; } = name; + + public string Kind { get; } = kind; + + public string[] Namespaces { get; set; } = []; + + public string[] ExactNamespaces { get; set; } = []; + + // #849: when non-empty, adapters may reach this owner only through these types. + public string[] Contract { get; set; } = []; + + // #850: types outside the module's namespaces trusted to read its tables. + public string[] Implementations { get; set; } = []; + + // #1023: non-contract types peer modules may still reach. Adapters may not, and the contract walk skips + // them, because a seam can carry an aggregate on purpose (#851's account seam). + public string[] Seam { get; set; } = []; + + // #1023: types this owner claims inside a Platform namespace. + public string[] Types { get; set; } = []; +} + +// Symbols are the top-level from-side types realising the edge. +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class ModuleEdgeAttribute(string from, string to, string kind, string reason, params string[] symbols) : Attribute +{ + public string From { get; } = from; + + public string To { get; } = to; + + public string Kind { get; } = kind; + + public string Reason { get; } = reason; + + public string[] Symbols { get; } = symbols; +} diff --git a/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs b/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs new file mode 100644 index 000000000..ebf68b9f5 --- /dev/null +++ b/src/Cluckwork.Domain/Common/Architecture/ModuleOwners.cs @@ -0,0 +1,293 @@ +using Cluckwork.Domain.Common.Architecture; + +[assembly: ModuleOwner("Access", "module", + Namespaces = [ + "Cluckwork.Application.Features.Users", + "Cluckwork.Infrastructure.Identity", + ], + Contract = [ + "Cluckwork.Application.Features.Users.IAccessModule", + "Cluckwork.Application.Features.Users.IAccessLookup", + "Cluckwork.Application.Features.Users.ICredentialEpochVerifier", + "Cluckwork.Application.Features.Users.IRefreshTokenPurge", + "Cluckwork.Application.Features.Users.IAccessOperations", + "Cluckwork.Application.Features.Users.IAccessAccountLifecycle", + "Cluckwork.Application.Features.Users.IAccessSeedLookup", + "Cluckwork.Application.Features.Users.IAccessFixture", + "Cluckwork.Application.Features.Users.CredentialVerdict", + "Cluckwork.Application.Features.Users.UserFlockAssignment", + "Cluckwork.Application.Features.Users.AccessActor", + "Cluckwork.Application.Features.Users.AccessUserSummary", + "Cluckwork.Application.Features.Users.FirstRunAdminOutcome", + "Cluckwork.Application.Features.Users.AdminRecoveryResult", + "Cluckwork.Application.Features.Users.AccountProvisionOutcome", + "Cluckwork.Application.Features.Users.AccountLifecycleOutcome", + "Cluckwork.Application.Features.Users.AccountRenameOutcome", + "Cluckwork.Application.Features.Users.CreateUser.CreateUserCommand", + "Cluckwork.Application.Features.Users.UpdateUser.UpdateUserCommand", + "Cluckwork.Application.Features.Users.SetUserPassword.SetUserPasswordCommand", + "Cluckwork.Application.Features.Users.ChangeUserRole.ChangeUserRoleCommand", + "Cluckwork.Application.Features.Users.ChangeUserEmail.ChangeUserEmailCommand", + "Cluckwork.Application.Features.Users.DisableUser.DisableUserCommand", + "Cluckwork.Application.Features.Users.EnableUser.EnableUserCommand", + "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockCommand", + "Cluckwork.Application.Features.Users.AssignFlock.UnassignFlockCommand", + "Cluckwork.Application.Features.Users.SetLanguage.SetLanguageCommand", + "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitCommand", + "Cluckwork.Application.Features.Users.ChangeOwnPassword.ChangeOwnPasswordCommand", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.UserRoleAssignmentRepository", + ], + Types = [ + "Cluckwork.Application.Common.IIdentityProvider", + "Cluckwork.Application.Common.IStepUpGrantService", + ])] +[assembly: ModuleOwner("Farm", "module", + Namespaces = [ + "Cluckwork.Domain.Accounts", + "Cluckwork.Domain.Media", + "Cluckwork.Application.Features.Accounts", + ], + Contract = [ + "Cluckwork.Application.Features.Accounts.IFarmModule", + "Cluckwork.Application.Features.Accounts.IFarmFixture", + "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", + "Cluckwork.Application.Features.Accounts.FarmBrandingHashes", + "Cluckwork.Application.Features.Accounts.FarmLogoMetadata", + "Cluckwork.Application.Features.Accounts.FarmLogoContent", + "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsCommand", + "Cluckwork.Application.Features.Accounts.IFarmDirectory", + "Cluckwork.Application.Features.Accounts.FarmTimeZone", + "Cluckwork.Application.Features.Accounts.FarmListing", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.AccountRepository", + "Cluckwork.Infrastructure.Repositories.FarmFixture", + "Cluckwork.Infrastructure.Repositories.FarmLogoRepository", + ], + Seam = [ + "Cluckwork.Application.Features.Accounts.IAccountRepository", + "Cluckwork.Domain.Accounts.Account", + "Cluckwork.Domain.Accounts.UserRoleAssignment", + ])] +[assembly: ModuleOwner("FlockManagement", "module", + Namespaces = [ + "Cluckwork.Domain.Flocks", + "Cluckwork.Application.Features.Flocks", + ], + Contract = [ + "Cluckwork.Application.Features.Flocks.IFlockModule", + "Cluckwork.Application.Features.Flocks.IFlockFixture", + "Cluckwork.Application.Features.Flocks.FlockFixtureCounts", + "Cluckwork.Application.Features.Flocks.IFlockLookup", + "Cluckwork.Application.Features.Flocks.IMortalityLedger", + "Cluckwork.Application.Features.Flocks.FlockDetails", + "Cluckwork.Application.Features.Flocks.FlockReference", + "Cluckwork.Application.Features.Flocks.FlockNameResolution", + "Cluckwork.Application.Features.Flocks.BirdMovementDetails", + "Cluckwork.Application.Features.Flocks.FlockEligibility", + "Cluckwork.Application.Features.Flocks.CreateFlock.CreateFlockCommand", + "Cluckwork.Application.Features.Flocks.UpdateFlock.UpdateFlockCommand", + "Cluckwork.Application.Features.Flocks.RecordBirdMovement.RecordBirdMovementCommand", + "Cluckwork.Domain.Flocks.FlockStatus", + "Cluckwork.Domain.Flocks.BirdMovementType", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.BirdMovementRepository", + "Cluckwork.Infrastructure.Repositories.FlockFixture", + "Cluckwork.Infrastructure.Repositories.FlockRepository", + ])] +[assembly: ModuleOwner("EggOperations", "module", + Namespaces = [ + "Cluckwork.Domain.Eggs", + "Cluckwork.Application.Features.DailyEntries", + "Cluckwork.Application.Features.EggGrades", + "Cluckwork.Application.Features.EggLots", + "Cluckwork.Application.Features.Eggs", + ], + Contract = [ + "Cluckwork.Application.Features.Eggs.IEggOperationsModule", + "Cluckwork.Application.Features.Eggs.IEggOperationsFixture", + "Cluckwork.Application.Features.Eggs.EggOperationsFixtureCounts", + "Cluckwork.Application.Features.EggGrades.IEggGradeLookup", + "Cluckwork.Application.Features.EggLots.IEggStock", + "Cluckwork.Application.Features.EggLots.IEggStockReservation", + "Cluckwork.Application.Features.EggLots.SaleDemandLine", + "Cluckwork.Application.Features.EggLots.PlannedEggLotDraw", + "Cluckwork.Application.Features.EggLots.SaleAllocationPlan", + "Cluckwork.Application.Features.DailyEntries.IDailyEntryLookup", + "Cluckwork.Application.Features.Eggs.DailyEntryDetails", + "Cluckwork.Application.Features.Eggs.EggGradeDetails", + "Cluckwork.Application.Features.Eggs.EggLotDetails", + "Cluckwork.Application.Features.Eggs.EggLotMovementDetails", + "Cluckwork.Application.Features.EggLots.StockByGrade", + "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.DailyEntryLockPass", + "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.LockedDailyEntry", + "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.RefusedDailyEntryLock", + "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.RecordDailyEntryCommand", + "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.GradeQuantityDto", + "Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry.SubmitDailyEntryResponse", + "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryCommand", + "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryResponse", + "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryCommand", + "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryResponse", + "Cluckwork.Application.Features.EggGrades.CreateEggGrade.CreateEggGradeCommand", + "Cluckwork.Application.Features.EggGrades.UpdateEggGrade.UpdateEggGradeCommand", + "Cluckwork.Application.Features.EggLots.RecordEggLotMovement.RecordEggLotMovementCommand", + "Cluckwork.Application.Features.EggLots.RecordEggLotMovement.RecordEggLotMovementResult", + "Cluckwork.Domain.Eggs.DailyEntryStatus", + "Cluckwork.Domain.Eggs.DailyEntryKind", + "Cluckwork.Domain.Eggs.EggGradeType", + "Cluckwork.Domain.Eggs.EggMovementType", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.EggOperationsFixture", + "Cluckwork.Infrastructure.Repositories.DailyEntryRepository", + "Cluckwork.Infrastructure.Repositories.EggGradeRepository", + "Cluckwork.Infrastructure.Repositories.EggInventoryMovementRepository", + "Cluckwork.Infrastructure.Repositories.EggLotRepository", + ])] +[assembly: ModuleOwner("Commerce", "module", + Namespaces = [ + "Cluckwork.Domain.Catalog", + "Cluckwork.Domain.Sales", + "Cluckwork.Application.Features.Catalog", + "Cluckwork.Application.Features.Customers", + "Cluckwork.Application.Features.Sales", + ], + Contract = [ + "Cluckwork.Application.Features.Sales.ICommerceModule", + "Cluckwork.Application.Features.Sales.ICommerceFixture", + "Cluckwork.Application.Features.Sales.CommerceFixtureCounts", + "Cluckwork.Application.Features.Catalog.IEggUnitConversionLookup", + "Cluckwork.Application.Features.Sales.ProductDetails", + "Cluckwork.Application.Features.Catalog.EggUnitConversionDetails", + "Cluckwork.Application.Features.Sales.CustomerDetails", + "Cluckwork.Application.Features.Customers.CustomerReference", + "Cluckwork.Application.Features.Sales.SalesOrderDetails", + "Cluckwork.Application.Features.Sales.SalesOrderItemDetails", + "Cluckwork.Application.Features.Sales.SalesOrderListItem", + "Cluckwork.Application.Features.Sales.SalesOrderListFilter", + "Cluckwork.Application.Features.Sales.SettlementScope", + "Cluckwork.Application.Features.Sales.PaymentDetails", + "Cluckwork.Application.Features.Sales.CustomerBalance", + "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductCommand", + "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductCommand", + "Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion.UpdateEggUnitConversionCommand", + "Cluckwork.Application.Features.Customers.CreateCustomer.CreateCustomerCommand", + "Cluckwork.Application.Features.Customers.UpdateCustomer.UpdateCustomerCommand", + "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderCommand", + "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemCommand", + "Cluckwork.Application.Features.Sales.UpdateOrderItem.UpdateOrderItemCommand", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleCommand", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleResponse", + "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleCommand", + "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleResponse", + "Cluckwork.Application.Features.Sales.RecordPayment.RecordPaymentCommand", + "Cluckwork.Application.Features.Sales.VoidPayment.VoidPaymentCommand", + "Cluckwork.Domain.Catalog.ProductType", + "Cluckwork.Domain.Catalog.ProductUnit", + "Cluckwork.Domain.Catalog.EggUnit", + "Cluckwork.Domain.Sales.SalesOrderStatus", + "Cluckwork.Domain.Sales.DiscountReasonCode", + "Cluckwork.Domain.Sales.ListPriceBasis", + "Cluckwork.Domain.Sales.PaymentMethod", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.CustomerRepository", + "Cluckwork.Infrastructure.Repositories.CommerceFixture", + "Cluckwork.Infrastructure.Repositories.EggUnitConversionRepository", + "Cluckwork.Infrastructure.Repositories.PaymentRepository", + "Cluckwork.Infrastructure.Repositories.ProductRepository", + "Cluckwork.Infrastructure.Repositories.SalesOrderAllocationRepository", + "Cluckwork.Infrastructure.Repositories.SalesOrderRepository", + ])] +[assembly: ModuleOwner("GeneralInventory", "module", + Namespaces = [ + "Cluckwork.Domain.Inventory", + "Cluckwork.Application.Features.Inventory", + ], + Contract = [ + "Cluckwork.Application.Features.Inventory.IInventoryModule", + "Cluckwork.Application.Features.Inventory.IInventoryFixture", + "Cluckwork.Application.Features.Inventory.InventoryFixtureCounts", + "Cluckwork.Application.Features.Inventory.InventoryItemDetails", + "Cluckwork.Application.Features.Inventory.InventoryLotDetails", + "Cluckwork.Application.Features.Inventory.InventoryMovementDetails", + "Cluckwork.Application.Features.Inventory.FeedUsageDetails", + "Cluckwork.Application.Features.Inventory.WaterUsageDetails", + "Cluckwork.Application.Features.Inventory.CreateInventoryItem.CreateInventoryItemCommand", + "Cluckwork.Application.Features.Inventory.UpdateInventoryItem.UpdateInventoryItemCommand", + "Cluckwork.Application.Features.Inventory.RecordPurchase.RecordPurchaseCommand", + "Cluckwork.Application.Features.Inventory.RecordAdjustment.RecordAdjustmentCommand", + "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageCommand", + "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageResponse", + "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageCommand", + "Cluckwork.Application.Features.Inventory.UpdateWaterUsage.UpdateWaterUsageCommand", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.FeedUsageRepository", + "Cluckwork.Infrastructure.Repositories.InventoryFixture", + "Cluckwork.Infrastructure.Repositories.InventoryItemRepository", + "Cluckwork.Infrastructure.Repositories.InventoryLotRepository", + "Cluckwork.Infrastructure.Repositories.InventoryMovementRepository", + "Cluckwork.Infrastructure.Repositories.WaterUsageRepository", + ])] +[assembly: ModuleOwner("Finance", "module", + Namespaces = [ + "Cluckwork.Domain.Expenses", + "Cluckwork.Application.Features.Expenses", + ], + Contract = [ + "Cluckwork.Application.Features.Expenses.IFinanceModule", + "Cluckwork.Application.Features.Expenses.IFinanceFixture", + "Cluckwork.Application.Features.Expenses.FinanceFixtureCounts", + "Cluckwork.Application.Features.Expenses.ExpenseCategoryDetails", + "Cluckwork.Application.Features.Expenses.ExpenseDetails", + "Cluckwork.Application.Features.Expenses.ExpenseListPage", + "Cluckwork.Application.Features.Expenses.CreateExpenseCategory.CreateExpenseCategoryCommand", + "Cluckwork.Application.Features.Expenses.UpdateExpenseCategory.UpdateExpenseCategoryCommand", + "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseCommand", + "Cluckwork.Application.Features.Expenses.AdjustExpense.AdjustExpenseCommand", + ], + Implementations = [ + "Cluckwork.Infrastructure.Repositories.ExpenseCategoryRepository", + "Cluckwork.Infrastructure.Repositories.ExpenseRepository", + "Cluckwork.Infrastructure.Repositories.FinanceFixture", + ])] +[assembly: ModuleOwner("Insights", "module", + Namespaces = [ + "Cluckwork.Application.Features.Audit", + "Cluckwork.Application.Features.Reports", + "Cluckwork.Application.Features.Export", + "Cluckwork.Application.Features.Insights", + "Cluckwork.Infrastructure.Insights", + ], + Contract = [ + "Cluckwork.Application.Features.Insights.IInsightsModule", + "Cluckwork.Application.Features.Insights.AuditEventRead", + "Cluckwork.Application.Features.Audit.EntityProvenance", + "Cluckwork.Application.Features.Export.ExportDataset", + "Cluckwork.Application.Features.Reports.ProductionReport", + "Cluckwork.Application.Features.Reports.ProductionDay", + "Cluckwork.Application.Features.Reports.GradeTotal", + "Cluckwork.Application.Features.Reports.SalesSummary", + "Cluckwork.Application.Features.Reports.ExpenseSummary", + "Cluckwork.Application.Features.Reports.ExpenseCategoryTotal", + "Cluckwork.Application.Features.Reports.ProfitReport", + ])] +[assembly: ModuleOwner("Platform", "platform", + Namespaces = [ + "Cluckwork.Domain.Common", + "Cluckwork.Domain.Auditing", + "Cluckwork.Application.Common", + "Cluckwork.Infrastructure", + "Cluckwork.Api", + "Cluckwork.AppHost", + "Cluckwork.Analyzers", + ], + ExactNamespaces = [ + "Cluckwork.Domain", + "Cluckwork.Application", + ])] diff --git a/src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj b/src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj index 5454005d4..c34721789 100644 --- a/src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj +++ b/src/Cluckwork.Infrastructure/Cluckwork.Infrastructure.csproj @@ -25,6 +25,8 @@ + + diff --git a/src/Cluckwork.Infrastructure/packages.lock.json b/src/Cluckwork.Infrastructure/packages.lock.json index a7dd3e9c8..1e382447e 100644 --- a/src/Cluckwork.Infrastructure/packages.lock.json +++ b/src/Cluckwork.Infrastructure/packages.lock.json @@ -112,19 +112,6 @@ "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, - "Microsoft.CodeAnalysis.CSharp.Workspaces": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, "Microsoft.CodeAnalysis.Workspaces.Common": { "type": "Transitive", "resolved": "5.0.0", @@ -307,7 +294,7 @@ }, "Microsoft.CodeAnalysis.CSharp": { "type": "CentralTransitive", - "requested": "[5.9.0, )", + "requested": "[5.0.0, )", "resolved": "5.0.0", "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { @@ -315,6 +302,20 @@ "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "CentralTransitive", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, "Riok.Mapperly": { "type": "CentralTransitive", "requested": "[4.*, )", diff --git a/tests/Cluckwork.Api.IntegrationTests/packages.lock.json b/tests/Cluckwork.Api.IntegrationTests/packages.lock.json index 591a15c18..3366b0828 100644 --- a/tests/Cluckwork.Api.IntegrationTests/packages.lock.json +++ b/tests/Cluckwork.Api.IntegrationTests/packages.lock.json @@ -26,12 +26,12 @@ }, "Microsoft.CodeAnalysis.CSharp": { "type": "Direct", - "requested": "[5.9.0, )", - "resolved": "5.9.0", - "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", - "Microsoft.CodeAnalysis.Common": "[5.9.0]" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, "Microsoft.NET.Test.Sdk": { @@ -153,15 +153,15 @@ }, "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", - "resolved": "5.9.0-1.26328.17", - "contentHash": "HP9NNk8ZjOSI2hgOyXnQg+kv7/X837Vr2nAlXiGAtqtYnYKjRRa1UmQFr8KFs5ynGYKqfbb8zB9APoWjiAGdMg==" + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", - "resolved": "5.9.0", - "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, "Microsoft.CodeCoverage": { diff --git a/tests/Cluckwork.Application.Tests/Architecture/ModuleEdgeAnalyzerTests.cs b/tests/Cluckwork.Application.Tests/Architecture/ModuleEdgeAnalyzerTests.cs new file mode 100644 index 000000000..7c4d27432 --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Architecture/ModuleEdgeAnalyzerTests.cs @@ -0,0 +1,114 @@ +using Cluckwork.Analyzers; +using Cluckwork.Application.Tests.TenantBypass; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; + +namespace Cluckwork.Application.Tests.Architecture; + +// #859: the analyzer over a two-project fixture. The fixture's Cluckwork.Domain compiles the real +// ModuleMapAttributes.cs with a map of Red, Blue and Platform; the code under test is a second project referencing it. +public sealed class ModuleEdgeAnalyzerTests +{ + private const string BlueSource = """ + namespace Cluckwork.Domain.Blue; + public sealed class B { public string Name => ""; } + """; + + private const string HubSource = """ + namespace Cluckwork.Domain.Common; + public sealed class Hub { public Cluckwork.Domain.Blue.B Blue { get; } = new(); } + """; + + [Fact] + public Task UndeclaredEdge_IsCW1001() => + Fixture(edges: "", """ + namespace Cluckwork.Application.Red; + public sealed class R { public object Go() => {|CW1001:new Cluckwork.Domain.Blue.B()|}; } + """).RunAsync(); + + [Fact] + public Task DeclaredEdge_IsClean() => + Fixture(Edge("Cluckwork.Application.Red.R"), """ + namespace Cluckwork.Application.Red; + public sealed class R { public object Go() => new Cluckwork.Domain.Blue.B(); } + """).RunAsync(); + + // The type is never named; binding Name still charges its declaring type (#1071). + [Fact] + public Task MemberAccessOnlyReference_IsAnEdge() => + Fixture(edges: "", """ + namespace Cluckwork.Application.Red; + public sealed class R { public string Go(Cluckwork.Domain.Common.Hub hub) => {|CW1001:hub.Blue.Name|}; } + """).RunAsync(); + + [Fact] + public Task StaleRow_IsCW1002() + { + var test = Fixture(Edge("Cluckwork.Application.Red.R"), """ + namespace Cluckwork.Application.Red; + public sealed class R; + """); + test.ExpectedDiagnostics.Add(new DiagnosticResult("CW1002", DiagnosticSeverity.Error).WithArguments( + "Red", "Blue", "Cluckwork.Application.Red.R", + "no reference in src/ realises this edge; delete the symbol from its ModuleEdge row in src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs, or restore the dependency it was written for")); + return test.RunAsync(); + } + + [Fact] + public Task UnownedNamespace_IsCW1003() => + Fixture(edges: "", """ + namespace Cluckwork.Application.Green; + public sealed class {|CW1003:G|}; + """).RunAsync(); + + [Fact] + public Task UsingWithNoUse_IsClean() => + Fixture(edges: "", """ + using Cluckwork.Domain.Blue; + using static Cluckwork.Domain.Blue.B; + namespace Cluckwork.Application.Red; + public sealed class R; + """).RunAsync(); + + private static string Edge(string symbol) => + $"[assembly: ModuleEdge(\"Red\", \"Blue\", \"R\", \"Red reads Blue.\", \"{symbol}\")]"; + + private static CSharpAnalyzerTest Fixture(string edges, string source) + { + var attributes = File.ReadAllText(Path.Combine( + GuardScanner.FindRepoRoot(AppContext.BaseDirectory) ?? throw new InvalidOperationException("repo root not found"), + "src/Cluckwork.Domain/Common/Architecture/ModuleMapAttributes.cs")); + var map = $""" + using Cluckwork.Domain.Common.Architecture; + [assembly: ModuleOwner("Red", "module", Namespaces = ["Cluckwork.Application.Red"])] + [assembly: ModuleOwner("Blue", "module", Namespaces = ["Cluckwork.Domain.Blue"])] + [assembly: ModuleOwner("Platform", "platform", Namespaces = ["Cluckwork.Domain.Common"], ExactNamespaces = ["Cluckwork.Domain", "Cluckwork.Application"])] + {edges} + """; + + var domain = new ProjectState("Cluckwork.Domain", LanguageNames.CSharp, "/domain/", "cs"); + domain.Sources.Add(("/domain/ModuleMapAttributes.cs", "using System;\n" + attributes)); + domain.Sources.Add(("/domain/ModuleMap.cs", map)); + domain.Sources.Add(("/domain/B.cs", BlueSource)); + domain.Sources.Add(("/domain/Hub.cs", HubSource)); + + var test = new CSharpAnalyzerTest + { + // The running runtime's assemblies, so the test never downloads a reference pack. + ReferenceAssemblies = new ReferenceAssemblies("net10.0"), + TestCode = source, + }; + test.TestState.AdditionalProjects.Add("Cluckwork.Domain", domain); + test.TestState.AdditionalProjectReferences.Add("Cluckwork.Domain"); + test.SolutionTransforms.Add((solution, _) => solution.Projects.Aggregate(solution, (s, project) => + s.AddMetadataReferences(project.Id, RuntimeReferences.Value))); + return test; + } + + private static readonly Lazy RuntimeReferences = new(() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) + .Where(path => Path.GetFileName(path) is "System.Private.CoreLib.dll" or "System.Runtime.dll") + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToArray()); +} diff --git a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs index 3cc98d486..a43acd256 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerScanner.cs @@ -256,7 +256,7 @@ public static IReadOnlyList Evaluate(ModuleLedgerReport report) failures.Add( $"undeclared cross-owner edge {edge.From} -> {edge.To} from {edge.Symbol} " + $"(references {string.Join(", ", edge.ReferencedNamespaces)}) at {edge.File}:{edge.Line} — " + - $"add this row to RealModuleLedger.Edges, with a reason naming the port or type it calls:\n{RenderEdges([edge])}"); + $"add this row to src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs, with a reason naming the port or type it calls:\n{RenderEdges([edge])}"); } foreach (var row in report.StaleSymbols) @@ -609,8 +609,8 @@ private static string RenderEdges(IReadOnlyList edges) => string .GroupBy(e => (e.From, e.To)) .OrderBy(g => g.Key.From, StringComparer.Ordinal) .ThenBy(g => g.Key.To, StringComparer.Ordinal) - .Select(cell => $"new({RealModuleLedger.Quote(cell.Key.From)}, {RealModuleLedger.Quote(cell.Key.To)}, \"R\", \"\", " + - $"{RealModuleLedger.List(cell.Select(e => e.Symbol).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal))}),")); + .Select(cell => $"[assembly: ModuleEdge({RealModuleLedger.Quote(cell.Key.From)}, {RealModuleLedger.Quote(cell.Key.To)}, \"R\", \"\", " + + $"{string.Join(", ", cell.Select(e => e.Symbol).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(RealModuleLedger.Quote))})]")); private static bool IsTypeDeclaration(SyntaxNode node) => node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax; diff --git a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerTests.cs b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerTests.cs index 587068781..e7fb1cf95 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/ModuleLedgerTests.cs @@ -62,8 +62,8 @@ public class R { public string Go() => B.Name; } Assert.Contains("Red -> Blue", failure!); Assert.Contains("Cluckwork.Temp.Red.R", failure!); Assert.Contains("Cluckwork.Temp.Blue", failure!); - Assert.Contains("add this row to RealModuleLedger.Edges", failure!); - Assert.EndsWith("\nnew(\"Red\", \"Blue\", \"R\", \"\", [\"Cluckwork.Temp.Red.R\"]),", failure!); + Assert.Contains("add this row to src/Cluckwork.Domain/Common/Architecture/ModuleEdges.cs", failure!); + Assert.EndsWith("\n[assembly: ModuleEdge(\"Red\", \"Blue\", \"R\", \"\", \"Cluckwork.Temp.Red.R\")]", failure!); } [Fact] diff --git a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Edges.cs b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Edges.cs deleted file mode 100644 index 0b4cc34c4..000000000 --- a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Edges.cs +++ /dev/null @@ -1,159 +0,0 @@ -namespace Cluckwork.Application.Tests.Architecture; - -internal static partial class RealModuleLedger -{ - internal static readonly EdgeCell[] Edges = - [ - new("Access", "Commerce", "W", - "W because AccountProvisioner inserts EggUnitConversion.Defaults for a new farm inside its provisioning transaction; the rest of the cell reads. Identity carries the per-user stepper-unit preference: ApplicationUser stores Domain.Catalog.EggUnit, IIdentityProvider, the Identity port Access claims, takes it in SetStepperUnitAsync, IdentityProvider reads and writes it, SetStepperUnitHandler resolves it through Commerce's IEggUnitConversionLookup port (#854) and rejects inactive conversions, and SetStepperUnitValidator validates only the enum name. Nothing prevents a stored preference from pointing at a conversion later deactivated through UpdateEggUnitConversionHandler. Design 3.4 shows Access -> Commerce as none; this is live coupling the target design has still to remove.", - [ - "Cluckwork.Application.Common.IIdentityProvider", - "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitHandler", - "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitValidator", - "Cluckwork.Infrastructure.Identity.AccountProvisioner", - "Cluckwork.Infrastructure.Identity.ApplicationUser", - "Cluckwork.Infrastructure.Identity.IdentityProvider", - ]), - new("Access", "EggOperations", "W", - "W because AccountProvisioner inserts a new farm's default grades, Domain.Eggs.EggGrade.Defaults(accountId, SeedDefaults.FarmId), inside its provisioning transaction, so farm provisioning writes Egg Operations' table and depends on its grade shape. Design 3.4 shows Access -> Egg Ops as none; this is live coupling the target design has still to remove.", - ["Cluckwork.Infrastructure.Identity.AccountProvisioner"]), - new("Access", "Farm", "W", - "W because AccountProvisioner inserts the new Account inside its provisioning transaction and AccountRenameService calls Account.Rename and saves; the rest of the cell reads. Identity is where a farm is created, renamed, suspended and recovered. AccountProvisioner, AccountRenameService, AccountSuspensionService, AdminRecoveryService, FirstRunAdminService and FirstRunStatusService load Domain.Accounts.Account or inject Farm's IAccountRepository, and IdentityProvider takes that repository by fully qualified name for the account-scoped login (#532). CredentialEpochVerifier reads Account.IsActive in the same fresh per-request query as the user's credential epoch, so a suspended farm's credentials stop working at once (#364, #579, #857). The user validators read Domain.Accounts.Roles for the assignable role set, and IUserRoleAssignmentRepository returns Domain.Accounts.UserRoleAssignment rows, which AccessModule reads to list a user's flock assignments. IAccessLookup returns Domain.Accounts.EffectiveAccountRole, and AccessLookup resolves it with Roles.ResolveEffective in its private effective-role routine (#612, #857). Design 3.4 row Access -> Farm = R. AccessFixture (#858) creates the simulation fixture's UserRoleAssignment through the scoped context, and AccessSeedLookup checks the fixture's Owner role by Roles.Owner.", - [ - "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler", - "Cluckwork.Application.Features.Users.AssignFlock.UnassignFlockHandler", - "Cluckwork.Application.Features.Users.ChangeUserRole.ChangeUserRoleValidator", - "Cluckwork.Application.Features.Users.CreateUser.CreateUserValidator", - "Cluckwork.Application.Features.Users.IAccessLookup", - "Cluckwork.Application.Features.Users.IUserRoleAssignmentRepository", - "Cluckwork.Infrastructure.Identity.AccessFixture", - "Cluckwork.Infrastructure.Identity.AccessLookup", - "Cluckwork.Infrastructure.Identity.AccessModule", - "Cluckwork.Infrastructure.Identity.AccessSeedLookup", - "Cluckwork.Infrastructure.Identity.AccountProvisioner", - "Cluckwork.Infrastructure.Identity.AccountRenameService", - "Cluckwork.Infrastructure.Identity.AccountSuspensionService", - "Cluckwork.Infrastructure.Identity.AdminRecoveryService", - "Cluckwork.Infrastructure.Identity.CredentialEpochVerifier", - "Cluckwork.Infrastructure.Identity.FirstRunAdminService", - "Cluckwork.Infrastructure.Identity.FirstRunStatusService", - "Cluckwork.Infrastructure.Identity.IdentityProvider", - ]), - new("Access", "FlockManagement", "R", - "AssignFlockHandler injects Flock Management's IFlockLookup port to prove the flock exists before narrowing a worker to it and to name it in the audit row (#103, spec 5.2/5.3). Design 3.4 row Access -> Flock = R. AccessFixture (#858) reads the same port for the simulation fixture's assignment and audit row. AccessModule names a user's flock assignments through IFlockLookup.GetDisplayNamesAsync, one bounded read whose filters decide which names the caller sees (#613, #859).", - [ - "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler", - "Cluckwork.Infrastructure.Identity.AccessFixture", - "Cluckwork.Infrastructure.Identity.AccessModule", - ]), - new("Commerce", "Access", "R", - "ConfirmSaleHandler reads through Access's IAccessLookup port (GetEffectiveRoleAsync and GetAssignedFlocksAsync, #857) inside the confirm transaction, so a plain Worker's committed flock assignments and the discount ceiling bound to their effective role are read at the moment of confirmation rather than from a request-start snapshot (#727). Design 3.4 shows Commerce -> Access as none; this is live coupling the target design has still to remove.", - ["Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler"]), - new("Commerce", "EggOperations", "W", - "FIFO egg stock. ConfirmSaleHandler and VoidSaleHandler lock, draw from and return egg lots through Egg Operations' IEggStock port, which adds the lot changes and Sale or Void movements to the confirm and void transactions and never saves (#854); Commerce sees plans and lot ids, never Domain.Eggs.EggLot. Products, order lines and confirm refusals read grades through IEggGradeLookup in CreateProductHandler, UpdateProductHandler, AddOrderItemHandler and ConfirmSaleHandler. Design 3.4 row Commerce -> Egg Ops = W.", - [ - "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", - "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", - "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemHandler", - "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", - "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleHandler", - ]), - new("Commerce", "Farm", "R", - "Orders and products snapshot the farm's currency through Farm's IAccountRepository (CreateSalesOrderHandler, CreateProductHandler, UpdateProductHandler), with the sales-order path holding the FOR SHARE lock on the account row (#162) so an order cannot land in a denomination the farm is mid-flight out of; ConfirmSaleHandler also reads the farm's discount ceiling and worker sale-allocation policy off Account. Design 3.4 row Commerce -> Farm = R.", - [ - "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", - "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", - "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", - "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderHandler", - ]), - new("EggOperations", "Farm", "R", - "CreateEggGradeHandler attaches a new grade to Domain.Accounts.SeedDefaults.FarmId, the single-farm stand-in for the farm the grade belongs to; EggGradeFloorPolicy resolves the caller's effective role through Domain.Accounts.Roles to decide whether a grade's low-stock floor may move (#911, Owner-only per #729). Design 3.4 row Egg Ops -> Farm = R.", - [ - "Cluckwork.Application.Features.EggGrades.CreateEggGrade.CreateEggGradeHandler", - "Cluckwork.Application.Features.EggGrades.EggGradeFloorPolicy", - ]), - new("EggOperations", "FlockManagement", "W", - "Daily entry is the mortality writer: SubmitDailyEntryHandler, AdjustDailyEntryHandler and VoidDailyEntryHandler append bird-movement rows through Flock Management's IMortalityLedger port, which adds the row to the caller's unit of work and never saves, so it commits with the entry itself (#54, #69, #852). Every daily-entry handler reads the flock through the IFlockLookup port to check CanRecordProductionOn for the entry's date. Design 3.4 row Egg Ops -> Flock = W.", - [ - "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryHandler", - "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.RecordDailyEntryHandler", - "Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry.SubmitDailyEntryHandler", - "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryHandler", - ]), - new("Farm", "Commerce", "R", - "Farm settings own three Commerce-shaped values. Domain.Accounts.Account holds Domain.Catalog.EggUnit as the farm's default stepper unit and exposes Domain.Sales.DiscountCeiling, while UpdateFarmSettingsHandler resolves the stepper unit through Commerce's IEggUnitConversionLookup port (#854) and UpdateFarmSettingsValidator parses the ceiling with DiscountCeiling.TryParsePercent (#727). The Farm contract's FarmSettingsDetails, declared beside IFarmModule, carries the same Domain.Catalog.EggUnit stepper unit out to its callers (#851), and FarmModule fills its discount ceiling from DiscountCeiling.Percent. Design 3.4 shows Farm -> Commerce as none; this is live coupling the target design has still to remove.", - [ - "Cluckwork.Application.Features.Accounts.FarmModule", - "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", - "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsHandler", - "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsValidator", - "Cluckwork.Domain.Accounts.Account", - ]), - new("Finance", "Farm", "R", - "CreateExpenseHandler injects Farm's IAccountRepository for the lock-aware currency snapshot an expense binds to (#162, FOR SHARE on the account row), and it and CreateExpenseCategoryHandler attach their rows to Domain.Accounts.SeedDefaults.FarmId, whose active categories FinanceModule lists. Design 3.4 classes this cell W, but the code reads Farm state under a shared row lock and mutates nothing there, the same shape 3.4 classes R on Commerce -> Farm and Inventory -> Farm; the ledger records what the code does and notes the disagreement here so #848 regenerates the matrix from this reading.", - [ - "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseHandler", - "Cluckwork.Application.Features.Expenses.CreateExpenseCategory.CreateExpenseCategoryHandler", - "Cluckwork.Application.Features.Expenses.FinanceModule", - ]), - new("Finance", "FlockManagement", "R", - "CreateExpenseHandler and AdjustExpenseHandler inject Flock Management's IFlockLookup port to validate the optional flock an expense is attributed to. Design 3.4 row Finance -> Flock = R.", - [ - "Cluckwork.Application.Features.Expenses.AdjustExpense.AdjustExpenseHandler", - "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseHandler", - ]), - new("FlockManagement", "Farm", "R", - "CreateFlockHandler places a new flock on Domain.Accounts.SeedDefaults.FarmId and SeedDefaults.HouseId, the single-farm stand-ins. Design 3.4 row Flock -> Farm = R.", - ["Cluckwork.Application.Features.Flocks.CreateFlock.CreateFlockHandler"]), - new("GeneralInventory", "EggOperations", "R", - "RecordFeedUsageHandler and RecordWaterUsageHandler inject Egg Operations' IDailyEntryLookup port by fully qualified name and call FindIdForFlockScopedWriteAsync (#853), so a feed or water record carries the day's daily-entry provenance. Design 3.4 shows Inventory -> Egg Ops as none; this is live coupling the target design has still to remove.", - [ - "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageHandler", - "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageHandler", - ]), - new("GeneralInventory", "Farm", "R", - "CreateInventoryItemHandler, RecordPurchaseHandler and UpdateInventoryItemHandler inject Farm's IAccountRepository for the farm currency a priced item or a purchase snapshots, under the same FOR SHARE account-row lock as the sales path (#162), and CreateInventoryItemHandler attaches the item to Domain.Accounts.SeedDefaults.FarmId. Design 3.4 row Inventory -> Farm = R.", - [ - "Cluckwork.Application.Features.Inventory.CreateInventoryItem.CreateInventoryItemHandler", - "Cluckwork.Application.Features.Inventory.RecordPurchase.RecordPurchaseHandler", - "Cluckwork.Application.Features.Inventory.UpdateInventoryItem.UpdateInventoryItemHandler", - ]), - new("GeneralInventory", "FlockManagement", "R", - "RecordFeedUsageHandler, RecordWaterUsageHandler and UpdateWaterUsageHandler inject Flock Management's IFlockLookup port to prove the flock being fed or watered exists and is eligible for the usage date. Design 3.4 row Inventory -> Flock = R.", - [ - "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageHandler", - "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageHandler", - "Cluckwork.Application.Features.Inventory.UpdateWaterUsage.UpdateWaterUsageHandler", - ]), - new("Insights", "Commerce", "R", - "ReportQueries reads confirmed SalesOrders and Payments for sales and profit totals; ExportQueries streams Customers, SalesOrders, SalesOrderItems, SalesOrderAllocations and Payments. Reads compose in C# and never mutate Commerce rows.", - [ - "Cluckwork.Infrastructure.Insights.ExportQueries", - "Cluckwork.Infrastructure.Insights.ReportQueries", - ]), - new("Insights", "EggOperations", "R", - "ReportQueries reads DailyEntries, DailyEntryGrades and EggGrades for production and grading totals; ExportQueries streams those records, EggLots and EggInventoryMovements with their existing filters and ordering.", - [ - "Cluckwork.Infrastructure.Insights.ExportQueries", - "Cluckwork.Infrastructure.Insights.ReportQueries", - ]), - new("Insights", "Farm", "R", - "ReportQueries.AccountCurrencyAsync reads the current Account currency for report DTOs. The account query remains tenant-filtered and AsNoTracking.", - ["Cluckwork.Infrastructure.Insights.ReportQueries"]), - new("Insights", "Finance", "R", - "ReportQueries aggregates Expenses for expense and profit totals; ExportQueries streams ExpenseCategories and Expenses as typed CSV rows. Neither query writes Finance data.", - [ - "Cluckwork.Infrastructure.Insights.ExportQueries", - "Cluckwork.Infrastructure.Insights.ReportQueries", - ]), - new("Insights", "FlockManagement", "R", - "ExportQueries streams Flock and BirdMovement records; ReportQueries reads flock lifecycle and bird movements to calculate hen-day exposure. These are read-only owner-table queries composed in C#.", - [ - "Cluckwork.Infrastructure.Insights.ExportQueries", - "Cluckwork.Infrastructure.Insights.ReportQueries", - ]), - new("Insights", "GeneralInventory", "R", - "ExportQueries streams InventoryItems, InventoryLots, InventoryMovements and FeedUsages as typed CSV rows. No inventory mutation or cross-owner SQL join is introduced.", - ["Cluckwork.Infrastructure.Insights.ExportQueries"]), - ]; -} diff --git a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Owners.cs b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Owners.cs deleted file mode 100644 index 6470c8cb7..000000000 --- a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Owners.cs +++ /dev/null @@ -1,302 +0,0 @@ -namespace Cluckwork.Application.Tests.Architecture; - -internal static partial class RealModuleLedger -{ - internal static readonly OwnerDefinition[] Owners = - [ - new("Access", "module", [ - "Cluckwork.Application.Features.Users", - "Cluckwork.Infrastructure.Identity", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Users.IAccessModule", - "Cluckwork.Application.Features.Users.IAccessLookup", - "Cluckwork.Application.Features.Users.ICredentialEpochVerifier", - "Cluckwork.Application.Features.Users.IRefreshTokenPurge", - "Cluckwork.Application.Features.Users.IAccessOperations", - "Cluckwork.Application.Features.Users.IAccessAccountLifecycle", - "Cluckwork.Application.Features.Users.IAccessSeedLookup", - "Cluckwork.Application.Features.Users.IAccessFixture", - "Cluckwork.Application.Features.Users.CredentialVerdict", - "Cluckwork.Application.Features.Users.UserFlockAssignment", - "Cluckwork.Application.Features.Users.AccessActor", - "Cluckwork.Application.Features.Users.AccessUserSummary", - "Cluckwork.Application.Features.Users.FirstRunAdminOutcome", - "Cluckwork.Application.Features.Users.AdminRecoveryResult", - "Cluckwork.Application.Features.Users.AccountProvisionOutcome", - "Cluckwork.Application.Features.Users.AccountLifecycleOutcome", - "Cluckwork.Application.Features.Users.AccountRenameOutcome", - "Cluckwork.Application.Features.Users.CreateUser.CreateUserCommand", - "Cluckwork.Application.Features.Users.UpdateUser.UpdateUserCommand", - "Cluckwork.Application.Features.Users.SetUserPassword.SetUserPasswordCommand", - "Cluckwork.Application.Features.Users.ChangeUserRole.ChangeUserRoleCommand", - "Cluckwork.Application.Features.Users.ChangeUserEmail.ChangeUserEmailCommand", - "Cluckwork.Application.Features.Users.DisableUser.DisableUserCommand", - "Cluckwork.Application.Features.Users.EnableUser.EnableUserCommand", - "Cluckwork.Application.Features.Users.AssignFlock.AssignFlockCommand", - "Cluckwork.Application.Features.Users.AssignFlock.UnassignFlockCommand", - "Cluckwork.Application.Features.Users.SetLanguage.SetLanguageCommand", - "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitCommand", - "Cluckwork.Application.Features.Users.ChangeOwnPassword.ChangeOwnPasswordCommand", - ], - Implementations = ["Cluckwork.Infrastructure.Repositories.UserRoleAssignmentRepository"], - Types = [ - "Cluckwork.Application.Common.IIdentityProvider", - "Cluckwork.Application.Common.IStepUpGrantService", - ], - }, - new("Farm", "module", [ - "Cluckwork.Domain.Accounts", - "Cluckwork.Domain.Media", - "Cluckwork.Application.Features.Accounts", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Accounts.IFarmModule", - "Cluckwork.Application.Features.Accounts.IFarmFixture", - "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", - "Cluckwork.Application.Features.Accounts.FarmBrandingHashes", - "Cluckwork.Application.Features.Accounts.FarmLogoMetadata", - "Cluckwork.Application.Features.Accounts.FarmLogoContent", - "Cluckwork.Application.Features.Accounts.UpdateFarmSettings.UpdateFarmSettingsCommand", - "Cluckwork.Application.Features.Accounts.IFarmDirectory", - "Cluckwork.Application.Features.Accounts.FarmTimeZone", - "Cluckwork.Application.Features.Accounts.FarmListing", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.AccountRepository", - "Cluckwork.Infrastructure.Repositories.FarmFixture", - "Cluckwork.Infrastructure.Repositories.FarmLogoRepository", - ], - Seam = [ - "Cluckwork.Application.Features.Accounts.IAccountRepository", - "Cluckwork.Domain.Accounts.Account", - "Cluckwork.Domain.Accounts.UserRoleAssignment", - ], - }, - new("FlockManagement", "module", [ - "Cluckwork.Domain.Flocks", - "Cluckwork.Application.Features.Flocks", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Flocks.IFlockModule", - "Cluckwork.Application.Features.Flocks.IFlockFixture", - "Cluckwork.Application.Features.Flocks.FlockFixtureCounts", - "Cluckwork.Application.Features.Flocks.IFlockLookup", - "Cluckwork.Application.Features.Flocks.IMortalityLedger", - "Cluckwork.Application.Features.Flocks.FlockDetails", - "Cluckwork.Application.Features.Flocks.FlockReference", - "Cluckwork.Application.Features.Flocks.FlockNameResolution", - "Cluckwork.Application.Features.Flocks.BirdMovementDetails", - "Cluckwork.Application.Features.Flocks.FlockEligibility", - "Cluckwork.Application.Features.Flocks.CreateFlock.CreateFlockCommand", - "Cluckwork.Application.Features.Flocks.UpdateFlock.UpdateFlockCommand", - "Cluckwork.Application.Features.Flocks.RecordBirdMovement.RecordBirdMovementCommand", - "Cluckwork.Domain.Flocks.FlockStatus", - "Cluckwork.Domain.Flocks.BirdMovementType", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.BirdMovementRepository", - "Cluckwork.Infrastructure.Repositories.FlockFixture", - "Cluckwork.Infrastructure.Repositories.FlockRepository", - ], - }, - new("EggOperations", "module", [ - "Cluckwork.Domain.Eggs", - "Cluckwork.Application.Features.DailyEntries", - "Cluckwork.Application.Features.EggGrades", - "Cluckwork.Application.Features.EggLots", - "Cluckwork.Application.Features.Eggs", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Eggs.IEggOperationsModule", - "Cluckwork.Application.Features.Eggs.IEggOperationsFixture", - "Cluckwork.Application.Features.Eggs.EggOperationsFixtureCounts", - "Cluckwork.Application.Features.EggGrades.IEggGradeLookup", - "Cluckwork.Application.Features.EggLots.IEggStock", - "Cluckwork.Application.Features.EggLots.IEggStockReservation", - "Cluckwork.Application.Features.EggLots.SaleDemandLine", - "Cluckwork.Application.Features.EggLots.PlannedEggLotDraw", - "Cluckwork.Application.Features.EggLots.SaleAllocationPlan", - "Cluckwork.Application.Features.DailyEntries.IDailyEntryLookup", - "Cluckwork.Application.Features.Eggs.DailyEntryDetails", - "Cluckwork.Application.Features.Eggs.EggGradeDetails", - "Cluckwork.Application.Features.Eggs.EggLotDetails", - "Cluckwork.Application.Features.Eggs.EggLotMovementDetails", - "Cluckwork.Application.Features.EggLots.StockByGrade", - "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.DailyEntryLockPass", - "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.LockedDailyEntry", - "Cluckwork.Application.Features.DailyEntries.LockDueDailyEntries.RefusedDailyEntryLock", - "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.RecordDailyEntryCommand", - "Cluckwork.Application.Features.DailyEntries.RecordDailyEntry.GradeQuantityDto", - "Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry.SubmitDailyEntryResponse", - "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryCommand", - "Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry.AdjustDailyEntryResponse", - "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryCommand", - "Cluckwork.Application.Features.DailyEntries.VoidDailyEntry.VoidDailyEntryResponse", - "Cluckwork.Application.Features.EggGrades.CreateEggGrade.CreateEggGradeCommand", - "Cluckwork.Application.Features.EggGrades.UpdateEggGrade.UpdateEggGradeCommand", - "Cluckwork.Application.Features.EggLots.RecordEggLotMovement.RecordEggLotMovementCommand", - "Cluckwork.Application.Features.EggLots.RecordEggLotMovement.RecordEggLotMovementResult", - "Cluckwork.Domain.Eggs.DailyEntryStatus", - "Cluckwork.Domain.Eggs.DailyEntryKind", - "Cluckwork.Domain.Eggs.EggGradeType", - "Cluckwork.Domain.Eggs.EggMovementType", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.EggOperationsFixture", - "Cluckwork.Infrastructure.Repositories.DailyEntryRepository", - "Cluckwork.Infrastructure.Repositories.EggGradeRepository", - "Cluckwork.Infrastructure.Repositories.EggInventoryMovementRepository", - "Cluckwork.Infrastructure.Repositories.EggLotRepository", - ], - }, - new("Commerce", "module", [ - "Cluckwork.Domain.Catalog", - "Cluckwork.Domain.Sales", - "Cluckwork.Application.Features.Catalog", - "Cluckwork.Application.Features.Customers", - "Cluckwork.Application.Features.Sales", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Sales.ICommerceModule", - "Cluckwork.Application.Features.Sales.ICommerceFixture", - "Cluckwork.Application.Features.Sales.CommerceFixtureCounts", - "Cluckwork.Application.Features.Catalog.IEggUnitConversionLookup", - "Cluckwork.Application.Features.Sales.ProductDetails", - "Cluckwork.Application.Features.Catalog.EggUnitConversionDetails", - "Cluckwork.Application.Features.Sales.CustomerDetails", - "Cluckwork.Application.Features.Customers.CustomerReference", - "Cluckwork.Application.Features.Sales.SalesOrderDetails", - "Cluckwork.Application.Features.Sales.SalesOrderItemDetails", - "Cluckwork.Application.Features.Sales.SalesOrderListItem", - "Cluckwork.Application.Features.Sales.SalesOrderListFilter", - "Cluckwork.Application.Features.Sales.SettlementScope", - "Cluckwork.Application.Features.Sales.PaymentDetails", - "Cluckwork.Application.Features.Sales.CustomerBalance", - "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductCommand", - "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductCommand", - "Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion.UpdateEggUnitConversionCommand", - "Cluckwork.Application.Features.Customers.CreateCustomer.CreateCustomerCommand", - "Cluckwork.Application.Features.Customers.UpdateCustomer.UpdateCustomerCommand", - "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderCommand", - "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemCommand", - "Cluckwork.Application.Features.Sales.UpdateOrderItem.UpdateOrderItemCommand", - "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleCommand", - "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleResponse", - "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleCommand", - "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleResponse", - "Cluckwork.Application.Features.Sales.RecordPayment.RecordPaymentCommand", - "Cluckwork.Application.Features.Sales.VoidPayment.VoidPaymentCommand", - "Cluckwork.Domain.Catalog.ProductType", - "Cluckwork.Domain.Catalog.ProductUnit", - "Cluckwork.Domain.Catalog.EggUnit", - "Cluckwork.Domain.Sales.SalesOrderStatus", - "Cluckwork.Domain.Sales.DiscountReasonCode", - "Cluckwork.Domain.Sales.ListPriceBasis", - "Cluckwork.Domain.Sales.PaymentMethod", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.CustomerRepository", - "Cluckwork.Infrastructure.Repositories.CommerceFixture", - "Cluckwork.Infrastructure.Repositories.EggUnitConversionRepository", - "Cluckwork.Infrastructure.Repositories.PaymentRepository", - "Cluckwork.Infrastructure.Repositories.ProductRepository", - "Cluckwork.Infrastructure.Repositories.SalesOrderAllocationRepository", - "Cluckwork.Infrastructure.Repositories.SalesOrderRepository", - ], - }, - new("GeneralInventory", "module", [ - "Cluckwork.Domain.Inventory", - "Cluckwork.Application.Features.Inventory", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Inventory.IInventoryModule", - "Cluckwork.Application.Features.Inventory.IInventoryFixture", - "Cluckwork.Application.Features.Inventory.InventoryFixtureCounts", - "Cluckwork.Application.Features.Inventory.InventoryItemDetails", - "Cluckwork.Application.Features.Inventory.InventoryLotDetails", - "Cluckwork.Application.Features.Inventory.InventoryMovementDetails", - "Cluckwork.Application.Features.Inventory.FeedUsageDetails", - "Cluckwork.Application.Features.Inventory.WaterUsageDetails", - "Cluckwork.Application.Features.Inventory.CreateInventoryItem.CreateInventoryItemCommand", - "Cluckwork.Application.Features.Inventory.UpdateInventoryItem.UpdateInventoryItemCommand", - "Cluckwork.Application.Features.Inventory.RecordPurchase.RecordPurchaseCommand", - "Cluckwork.Application.Features.Inventory.RecordAdjustment.RecordAdjustmentCommand", - "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageCommand", - "Cluckwork.Application.Features.Inventory.RecordFeedUsage.RecordFeedUsageResponse", - "Cluckwork.Application.Features.Inventory.RecordWaterUsage.RecordWaterUsageCommand", - "Cluckwork.Application.Features.Inventory.UpdateWaterUsage.UpdateWaterUsageCommand", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.FeedUsageRepository", - "Cluckwork.Infrastructure.Repositories.InventoryFixture", - "Cluckwork.Infrastructure.Repositories.InventoryItemRepository", - "Cluckwork.Infrastructure.Repositories.InventoryLotRepository", - "Cluckwork.Infrastructure.Repositories.InventoryMovementRepository", - "Cluckwork.Infrastructure.Repositories.WaterUsageRepository", - ], - }, - new("Finance", "module", [ - "Cluckwork.Domain.Expenses", - "Cluckwork.Application.Features.Expenses", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Expenses.IFinanceModule", - "Cluckwork.Application.Features.Expenses.IFinanceFixture", - "Cluckwork.Application.Features.Expenses.FinanceFixtureCounts", - "Cluckwork.Application.Features.Expenses.ExpenseCategoryDetails", - "Cluckwork.Application.Features.Expenses.ExpenseDetails", - "Cluckwork.Application.Features.Expenses.ExpenseListPage", - "Cluckwork.Application.Features.Expenses.CreateExpenseCategory.CreateExpenseCategoryCommand", - "Cluckwork.Application.Features.Expenses.UpdateExpenseCategory.UpdateExpenseCategoryCommand", - "Cluckwork.Application.Features.Expenses.CreateExpense.CreateExpenseCommand", - "Cluckwork.Application.Features.Expenses.AdjustExpense.AdjustExpenseCommand", - ], - Implementations = [ - "Cluckwork.Infrastructure.Repositories.ExpenseCategoryRepository", - "Cluckwork.Infrastructure.Repositories.ExpenseRepository", - "Cluckwork.Infrastructure.Repositories.FinanceFixture", - ], - }, - new("Insights", "module", [ - "Cluckwork.Application.Features.Audit", - "Cluckwork.Application.Features.Reports", - "Cluckwork.Application.Features.Export", - "Cluckwork.Application.Features.Insights", - "Cluckwork.Infrastructure.Insights", - ], []) - { - Contract = [ - "Cluckwork.Application.Features.Insights.IInsightsModule", - "Cluckwork.Application.Features.Insights.AuditEventRead", - "Cluckwork.Application.Features.Audit.EntityProvenance", - "Cluckwork.Application.Features.Export.ExportDataset", - "Cluckwork.Application.Features.Reports.ProductionReport", - "Cluckwork.Application.Features.Reports.ProductionDay", - "Cluckwork.Application.Features.Reports.GradeTotal", - "Cluckwork.Application.Features.Reports.SalesSummary", - "Cluckwork.Application.Features.Reports.ExpenseSummary", - "Cluckwork.Application.Features.Reports.ExpenseCategoryTotal", - "Cluckwork.Application.Features.Reports.ProfitReport", - ], - }, - new("Platform", "platform", [ - "Cluckwork.Domain.Common", - "Cluckwork.Domain.Auditing", - "Cluckwork.Application.Common", - "Cluckwork.Infrastructure", - "Cluckwork.Api", - "Cluckwork.AppHost", - ], [ - "Cluckwork.Domain", - "Cluckwork.Application", - ]), - ]; -} diff --git a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.cs b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.cs index 09022401c..4ecbb445d 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.cs @@ -1,10 +1,27 @@ +using System.Reflection; +using Cluckwork.Domain.Common.Architecture; using Microsoft.CodeAnalysis.CSharp; namespace Cluckwork.Application.Tests.Architecture; -// The one module ledger every real-tree test reads (#859). Its rows are the RealModuleLedger.*.cs files. +// The one module ledger every real-tree test reads (#859). Its rows are the RealModuleLedger.*.cs files, plus the +// owner and edge rows in src/Cluckwork.Domain/Common/Architecture, which the module-edge analyzer reads too. internal static partial class RealModuleLedger { + internal static readonly OwnerDefinition[] Owners = [.. typeof(ModuleOwnerAttribute).Assembly + .GetCustomAttributes() + .Select(o => new OwnerDefinition(o.Name, o.Kind, o.Namespaces, o.ExactNamespaces) + { + Contract = o.Contract, + Implementations = o.Implementations, + Seam = o.Seam, + Types = o.Types, + })]; + + internal static readonly EdgeCell[] Edges = [.. typeof(ModuleEdgeAttribute).Assembly + .GetCustomAttributes() + .Select(e => new EdgeCell(e.From, e.To, e.Kind, e.Reason, e.Symbols))]; + // Lazy, because the order of static field initializers across partial files is not defined; Build runs on // first access, after every row array is set. private static readonly Lazy Built = new(Build); diff --git a/tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj b/tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj index 785ea2bf1..19a7318ee 100644 --- a/tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj +++ b/tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj @@ -7,6 +7,8 @@ + + @@ -17,6 +19,7 @@ +