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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/scripts/lockfix-apply.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
# 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"
"src/Cluckwork.Application/packages.lock.json"
"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"
Expand Down
1 change: 1 addition & 0 deletions .github/scripts/lockfix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions .github/scripts/lockfix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
});

Expand All @@ -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);
});

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/dependabot-lockfix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Cluckwork.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.*" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.*" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.*" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
<!-- Also the Roslyn Cluckwork.Analyzers compiles against (#859). A compiler loads an analyzer
built against a Roslyn no newer than itself, and 5.0.0 ships with SDK 10.0.1xx, the oldest
band in use; raising this fails every build on that band with CS9057. -->
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
<!-- The analyzer test library asks for Workspaces >= 1.0.1, a .NET Framework build; pin it to the Roslyn above. -->
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.*" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.*" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.*" />
Expand Down
5 changes: 3 additions & 2 deletions docs/decisions/514-module-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
65 changes: 64 additions & 1 deletion docs/decisions/859-typed-rule-registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Loading
Loading