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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
37 changes: 35 additions & 2 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
# no dependency and no CI job. Its job is to stop whitespace-only diffs from
# burying real changes in review.
#
# It is a convenience, NOT a gate — nothing enforces it. Do not cite it as the
# reason a formatter is unnecessary.
# TWO C# rules below are gated at build time, both under [*.{cs,csx}]:
# file-scoped namespaces and using-directive placement. EnforceCodeStyleInBuild
# in Directory.Build.props runs the IDE analyzers during the build, and
# TreatWarningsAsErrors is already true there, so a `:warning` severity here is
# a BUILD ERROR. That applies to any rule written at `:warning` in this file,
# not only these two. Everything else here is a convenience — do not cite an
# unenforced entry as the reason a formatter is unnecessary.
root = true

[*]
Expand All @@ -22,6 +27,34 @@ trim_trailing_whitespace = false
# .NET
[*.{cs,csx}]
indent_size = 4
# Gated. Outside is the C# default, so a file from any template complies. Note
# that BOTH placements can shadow: a `using X;` inside a namespace resolves
# relative to that namespace and can bind to a sibling X, while one outside can
# be shadowed by a sibling namespace segment of the same simple name. The
# second is what renaming the FlockScope test namespace fixed. Fix a violation
# with `dotnet format style <project> --diagnostics IDE0065 IDE0161
# --severity warn --include <file>`.
csharp_using_directive_placement = outside_namespace:warning
csharp_style_namespace_declarations = file_scoped:warning

# #514: SeamSurfaceTests.cs and TableOwnerTests.cs each declare several
# namespaces in one file, one per fixture scenario. Their inside-namespace
# usings are load-bearing: 25 of them are RELATIVE aliases that resolve against
# the enclosing namespace (`using Fixtures = TableOwnerFixtures;`), so hoisting
# them would require fully qualifying every one. Only the placement rule is
# relaxed. IDE0161 needs no exemption here — it never reports on a compilation
# unit declaring more than one namespace.
[tests/Cluckwork.Application.Tests/Architecture/{SeamSurfaceTests,TableOwnerTests}.cs]
csharp_using_directive_placement = inside_namespace:silent

# `dotnet ef migrations add` scaffolds a BLOCK-scoped namespace, so every future
# migration would fail IDE0161 without this, and the 19 existing ones do too.
# That, not #407's freeze, is why the exemption is here: it has to keep working
# for migrations nobody has written yet. Generated Designer and snapshot files
# carry <auto-generated /> and the analyzers skip them regardless. Migrations
# already place usings outside, so the placement rule needs no exemption.
[src/Cluckwork.Infrastructure/Persistence/Migrations/**]
csharp_style_namespace_declarations = block_scoped:silent

[*.{csproj,props,targets,sln}]
indent_size = 2
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ dotnet test Cluckwork.sln # 2887 tests as of 2026-09-21; integr
- **Validation:** FluentValidation validators (`*Validator`), one per command; endpoints call `ValidateAsync` and return `ValidationProblem`.
- **Endpoints:** minimal APIs grouped under `/api/v1/...` via `Map<Feature>Endpoints`; 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 <project> --diagnostics IDE0065 IDE0161 --severity warn --include <file>`; 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/Data/module-ledger.json` 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/`. Undeclared edges, stale rows, unowned namespaces, parse errors and file counts below the floor fail. An undeclared edge prints the JSON 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)
- **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)

Expand Down
5 changes: 5 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Runs the two .editorconfig warning-severity C# style rules
(file-scoped namespaces, using-directive placement) at build time.
Combined with TreatWarningsAsErrors above, a violation is a build
error, not just an IDE squiggle. -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>

<PropertyGroup>
Expand Down
102 changes: 102 additions & 0 deletions docs/decisions/985-csharp-style-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Gate two C# style rules at build time (#985)

**Rule in [`AGENTS.md`](../../AGENTS.md) → Conventions → Application shape.**

No incident. This is an **accepted-risk** record: the risk taken is that a future
`:warning` entry in `.editorconfig` breaks the build for everyone, and the rule is
load-bearing because that is also the mechanism doing the work.

## What was decided

`csharp_using_directive_placement = outside_namespace` and
`csharp_style_namespace_declarations = file_scoped`, both at `:warning`, with
`EnforceCodeStyleInBuild` added beside the existing `TreatWarningsAsErrors`. That
combination makes each a build error. 590 files were swept to comply.

## Why gate rather than leave it a preference

`TreatWarningsAsErrors` is already global, so "warning but not error" is not a
state this repo has. It would mean `WarningsNotAsErrors` for two ids plus a
warning line in a CI log nobody reads. An analyzer also cannot tell a new file
from an old one, so "enforce for new code only" is not expressible. The honest
options were gate-all or gate-none, and the branch passed through a silent
middle state (`2cd96251`) that enforced nothing at all.

Timing: #514's Track C adds nine module contracts across a dozen slices and
several authors, which is when an unenforced convention drifts. Rebase cost was
zero when this landed, because no other open PR touched a `.cs` file.

## Why outside rather than inside

The owner chose outside. Both placements have a shadowing failure mode, and the
first version of this change argued that outside was simply safer. **That was
wrong and the argument is recorded here so nobody repeats it.**

- A `using X;` *inside* a namespace resolves relative to that namespace, so it
can bind to a sibling `X` instead of the global one.
- A `using` *outside* resolves from the global root, so a sibling namespace
segment whose simple name matches a type can shadow that type.

The second is not theoretical in this repo. `FlockScope` was both
`Cluckwork.Infrastructure.Persistence.FlockScope` and the test namespace
`Cluckwork.Application.Tests.FlockScope`. Moving usings out of that namespace's
scope produced **13 errors across six files**, 11 `CS0118` and two `CS0234`,
reaching `CouplingMatrixRealTreeTests`, `TableOwnerRealModelTests` and the
tenant-bypass tests, not only the two files in the renamed namespace. The fix
was renaming the test namespace to `FlockScoping`, which is worth having anyway.

**So the operative rule is not "outside is safe". It is: never introduce a
namespace segment whose simple name is also a type name.**

## Blast radius, measured

`EnforceCodeStyleInBuild` does not enable two rules. It runs the IDE analyzers,
so every code-style diagnostic at warning severity or above becomes an error
under `TreatWarningsAsErrors`. Reflecting over every `DiagnosticAnalyzer` in the
SDK's `Microsoft.CodeAnalysis.CodeStyle.dll` and
`Microsoft.CodeAnalysis.CSharp.CodeStyle.dll` gives 121 distinct ids: 116 default
`Hidden`, four `Info`, one `Warning`, and that one only reports when IDE0005 is
raised, which it is not. The repo carries no `.globalconfig`, ruleset,
`AnalysisLevel`, `AnalysisMode` or `dotnet_diagnostic.*` entry. A probe file with
about 25 common style deviations produced compiler warnings only, no `IDE*`.

So the effective set is exactly {IDE0065, IDE0161} **by construction, not by
luck** — but the property's contract is open-ended, which is why the `AGENTS.md`
paragraph states that any future `:warning` entry is a gate. Build cost is about
one second on an eight-second full solution build.

## The two exemptions

`SeamSurfaceTests.cs` and `TableOwnerTests.cs` declare several namespaces per
file, one per fixture scenario. Only the *placement* rule is relaxed: 25 of their
usings are relative aliases resolving against the enclosing namespace
(`using Fixtures = TableOwnerFixtures;`), so hoisting them would mean fully
qualifying every one, a content change to two guard fixtures. IDE0161 needs no
exemption, because it never reports on a compilation unit declaring more than one
namespace — an earlier revision silenced it here anyway, which was dead
configuration.

The EF migrations tree keeps block-scoped namespaces because
`dotnet ef migrations add` **scaffolds** them. Verified by scaffolding one at this
head. The exemption therefore has to keep working for migrations nobody has
written yet, which is a stronger reason than #407's freeze; the freeze explains
why existing files are not rewritten, not why future generated ones pass.
Designer and snapshot files carry `<auto-generated />` and the analyzers skip
them regardless.

## How the sweep was made reviewable

`tools/style/move-usings-outside.py` is committed so the 590-file diff is
re-derivable in one command rather than read by hand. Review confirmed it
reproduces every file byte for byte from `main`. It is not the supported remedy
for new code; `dotnet format style --diagnostics IDE0065 IDE0161` is, and it
fixes both rules.

A green build was not treated as sufficient. The compiler catches a name that
became ambiguous, not one that silently rebinds to a different valid type, so
review compared Roslyn bindings across all nine projects' real compiler inputs
including implicit and generated sources: 2,770 using targets and 351,085
expression bindings matched, with no difference outside the intentional rename.
Four files that look like outliers by line-regex are not: their `using` lines sit
inside raw string literals that build synthetic source for the architecture
scanners to walk.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Starting a new record: copy [`TEMPLATE.md`](TEMPLATE.md).
| [Backend test coverage measurement, report only (#776)](776-backend-coverage.md) | AGENTS · Build / test / run |
| [Skip the web and image jobs on documentation-only pull requests (#782)](782-ci-job-gating.md) | AGENTS · CI security gates |
| [Adopt a UI component library, and which one: MUI (#674)](674-ui-component-library.md) | `web/README.md` · Stack · and `specs/technical/tech_spec.md` §8.1 |
| [Gate two C# style rules at build time (#985)](985-csharp-style-gate.md) | AGENTS · Conventions |
| [Cross-module references are declared in the module ledger (#514, #842)](514-module-ledger.md) | AGENTS · Conventions |
| [No persistence type crosses an Application seam (#514, #847)](847-seam-surface-guard.md) | AGENTS · Conventions |

Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/AuthPolicies.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace Cluckwork.Api;

using Cluckwork.Domain.Accounts;

namespace Cluckwork.Api;

// #103 — the role → capability map (spec §5.1/§5.3), replacing #73's binary
// Admin/other split. One policy per capability tier; endpoint groups pick a
// tier, never a raw role name (#84).
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/BootstrapAdminCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Infrastructure.Identity;
using Cluckwork.Infrastructure.Persistence;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;

// `bootstrap-admin --email <e>` (#283) — first-run admin provisioning. Same
// run-then-exit, fail-loud shape as seed/migrate/recover-admin: migrates the
// schema (idempotent, like every other one-shot verb), then creates the
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/CliDispatcher.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace Cluckwork.Api.Cli;

using Microsoft.AspNetCore.Builder;

namespace Cluckwork.Api.Cli;

// Routes args[0] to a one-off ICliCommand. Program.cs calls TryRunAsync right
// after Build(): a non-null result is the command's exit code (return it and
// never start the web host); null means no CLI verb matched, so this is a normal
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/ICliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace Cluckwork.Api.Cli;

using Microsoft.AspNetCore.Builder;

namespace Cluckwork.Api.Cli;

// A one-off, run-then-exit operator command on the API binary. Dispatched from
// Program.cs immediately after Build() and
// BEFORE the web host starts — Kestrel and the hosted services never run for
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/ListAccountsCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Infrastructure.Persistence;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;

// `list-accounts` (#531) — a read-only operator verb that prints every farm's
// code, name and active state. Moved forward from #533 deliberately: #532 makes
// the farm code mandatory at login, so the first upgraded deployment needs a
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/MigrateCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Infrastructure.Persistence;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Cluckwork.Api.Cli;

// `migrate` (#263) — applies EF migrations then EXITS: the pre-deploy-job
// entrypoint that lets a production deploy run schema DDL under a dedicated
// migrator/owner credential (a one-off job), with `Database:MigrateOnStartup=false`
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/ProvisionAccountCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Domain.Accounts;
using Cluckwork.Infrastructure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;

// Creates one farm, its canonical reference data, and its first Owner in one
// transaction. It deliberately does not migrate: production runs this through
// the least-privilege runtime database role after the migrate job has finished.
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/ReactivateAccountCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Infrastructure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;

// `reactivate-account --slug <s> [--reason <text>]` (#534) — brings a suspended
// farm back. Suspension deletes nothing, so reactivation restores the farm
// exactly — with one deliberate exception: sessions that predate the suspension
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/RecoverAdminCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Infrastructure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;

// `recover-admin --email <e> [--account <guid>] [--reason <t>]` (#265) — offline
// break-glass recovery for a locked-out account (a sole Owner with a lost
// password and no email/SMTP reset path would otherwise need direct DB surgery).
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/RenameAccountCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Domain.Accounts;
using Cluckwork.Infrastructure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;

// `rename-account --slug <current> --new-slug <new> [--reason <text>]` (#732) — changes a
// farm's code. The reason it exists: a database upgraded from before multi-farm tenancy
// gets `default-farm` from the AddAccountSlug migration, nothing asks at migration time,
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/SeedCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Infrastructure.Persistence;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Cluckwork.Api.Cli;

// `seed --profile <name> [--farm-code <slug>]` (#280) — a one-off command on the
// same binary, not a serving-process code path: it migrates the schema, runs the
// requested profile's seeder(s), then EXITS (Kestrel and the hosted services
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Cli/SuspendAccountCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
namespace Cluckwork.Api.Cli;

using Cluckwork.Infrastructure.Identity;
using Cluckwork.Infrastructure.Persistence;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;

// `suspend-account --slug <s> [--reason <text>]` (#534) — takes a farm offline.
// Enforcement is already live (#532): Account.IsActive is read by
// CredentialEpochMiddleware on EVERY authenticated request, so suspension bites
Expand Down
4 changes: 2 additions & 2 deletions src/Cluckwork.Api/Configuration/FarmBannerOptions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
namespace Cluckwork.Api.Configuration;

using Cluckwork.Domain.Media;
using Microsoft.Extensions.Options;

namespace Cluckwork.Api.Configuration;

// #179 — the OPERATIONAL cap on a farm-banner upload, tunable per deployment
// under the domain's hard ceiling (ImageSanitizer.MaxBannerByteLengthCeiling).
// Mirrors FarmLogoOptions exactly; kept separate because the banner (a wide,
Expand Down
Loading
Loading