diff --git a/AGENTS.md b/AGENTS.md index 7092f6b8..e274ad54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,12 +16,11 @@ - `docs/render-pass-flow.md` - Rendering architecture overview, CommandBuffer vs RenderPass, push constants, binding order - `docs/subrenderers.md` - Composing multiple renderers within IRenderer (ordering members, IEnumerable injection) - `docs/ui.md` - Pixely.Ui retained UI: elements, sizing and layouts, views and view models, pointer and focus routing, text fields, styling -- `docs/architecture-concept.md` - MVP + CQS + Events: layer responsibilities, boundary contract vs. internal representation, per-genre decision framework -- `docs/architecture-library.md` - Pixely.Architecture API: command/query handlers, dispatcher, domain event stream/cursor, pump, post-dispatch hooks, registration extensions -- `docs/architecture-testing.md` - Pixely.Architecture.Testing: CqsConventions and ModelBoundary checks that enforce the architecture-concept.md boundary claims as unit tests - `docs/path-finding-grids.md` - Pixely.PathFinding.Grids: grid geometry, clearance-based agent footprints, connectivity and the corner rule, overlays, the admissible grid heuristic - `docs/development-packages.md` - Consuming packages from the public development feed - `docs/taskbar-icons.md` - Application-wide taskbar and Dock icons loaded from virtual content +- `docs/peach-architecture.md` - Peach architecture for games built on Pixely: project layout, Game/Frontend boundary, stages, systems, AI and scenario projects; shipped in the package under `docs/` and enforced by Pixely.Fitness +- `docs/fitness.md` - Pixely.Fitness: PeachArchitectureOptions, evaluating and asserting a FitnessReport from a game's tests, adding other rule sets ## Maintenance diff --git a/Pixely.slnx b/Pixely.slnx index ee4f3fde..62de6a08 100644 --- a/Pixely.slnx +++ b/Pixely.slnx @@ -6,8 +6,7 @@ - - + @@ -34,9 +33,7 @@ - - diff --git a/README.md b/README.md index 739c67fd..97dd7d53 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ made through AI pair-programming: botoddly contributes the implementation work, and stanoddly reviews, directs, and merges the changes through PRs. Earlier parts of the project were mostly written manually. +## Documentation + +`docs/` is shipped inside the NuGet package. In a project that references +Pixely, `dotnet msbuild -getProperty:PixelyDocsDirectory` prints where the +package version's copy lives. + ## License MIT. diff --git a/docs/architecture-concept.md b/docs/architecture-concept.md deleted file mode 100644 index 4a4b483f..00000000 --- a/docs/architecture-concept.md +++ /dev/null @@ -1,161 +0,0 @@ -# Game architecture: MVP + CQS + Events - -How to apply this architecture across genres. Most apparent mismatches come from -confusing the public contract with the internal representation. - -The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are -used as defined in RFC 2119. - -## Layers - -**Model–View–Presenter** separates simulation, rendering, and orchestration: - -- **Model** — game rules and state. MUST run without a screen. MAY depend on - math, pathfinding, DI, and framework lifecycle (tick, dispose); MUST NOT depend - on rendering. -- **View** — rendering, animation, input adapters. Touches GPU/pixels/screen - coordinates. MAY use command/query results handed to it, but MUST NOT invoke - a command or query, and MUST NOT subscribe to Model events. -- **Presenter** — the glue. Commands and queries MUST be invoked only by the - Presenter. It subscribes to Model events and View input, coordinates responses, - and feeds query results to the View. It MUST contain no game rules and no - rendering. - -**CQS at the Model boundary:** - -- **Command** — a requested mutation, usually user/AI intent ("this unit wants to - move there"). Each command type MUST have exactly one handler. A handler returns - `CommandResult.Success` when the command is accepted and its requested - postcondition holds. This includes an accepted idempotent no-op when the - postcondition already holds. It returns `CommandResult.FromError` for an expected - domain rejection and MUST NOT apply the requested state change in that case. - Invalid program state and infrastructure failures use exceptions. The result is - an acceptance outcome, not model data — reads are queries. -- **Query** — a requested read. MUST NOT have side effects. Its result MUST be a - **boundary data object (BDO)**: a behaviourless, recursively read-only data - contract whose type ends with `Bdo`. Even a scalar result is wrapped in a named - BDO so the Model boundary remains explicit. -- **Event** — notification of a discrete occurrence. MUST be raised by domain - objects and consumed by Presenters. - -BDO is Pixely-specific terminology for data in the Model boundary contract. It -is independent of which boundary operation carries the data and its direction. - -## Boundary contract vs. internal representation - -These are independent: - -- The **boundary contract** is commands / queries / events — the vocabulary the - outside world uses to talk to the Model. -- The **internal representation** is how the Model stores and steps state. It MAY - be data-oriented and MAY run a tight `Step(dt)` over packed arrays. - -The internals MUST NOT be forced through the command/query machinery. A command -handler MAY be a thin "record this intent" that a later simulation step acts on. -When a genre seems to break the architecture ("I can't dispatch a handler per -unit per frame"), the cause is usually pushing internals through the boundary. -Commands are intent at the edge; the simulation loop underneath is whatever is -fastest. - -## Decision framework - -Ask in order: - -1. **What are the player's discrete intents?** → **Commands** (move, build, buy, - cast, end-turn). Few types; friendly to queueing, replay, lockstep - determinism. -2. **What does the View read every frame?** → **Queries**, invoked by the - Presenter. Anything continuously changing (positions, resource counts, health - bars) is a query result the Presenter fetches each frame and hands to the - View. It MUST NOT be pushed per-frame as events, and the View MUST NOT invoke - the query itself. -3. **What discrete things must other systems react to?** → **Events** (died, - built, unlocked, came-under-attack, entered-vision). Bounded in volume by - construction. -4. **What advances simulation time?** → A non-command **`Step(dt)`** on the - Model, called by the game loop (the host), not by a Presenter — advancing - time is not glue. A Presenter is just one consumer of the Model; an AI actor - is another, symmetric to it. `Step(dt)` is the one core operation that is - neither command, query, nor event. Turn-based games hide it inside end-turn; - real-time games call it every frame. -5. **How should the Model store state?** → Whatever the simulation needs. The - boundary does not dictate this. - -## Rules - -- **Discrete vs. continuous state.** Discrete facts SHOULD live in the Model; - continuous per-frame floats SHOULD be View-side interpolation. When the - continuous value *is* simulation truth (an RTS unit's world position drives - collision and range checks), it MUST be Model state the Presenter queries and - the View reads, not View smoothing. -- **No event-per-frame.** High-frequency continuous change SHOULD NOT be - published as events; expose it as a query. Only discrete transitions - (`MovementStarted` / `MovementStopped`) SHOULD be published; in between, the - Presenter re-queries each frame and feeds the View the fresh snapshot. -- **BDOs are role-neutral boundary data.** A BDO MUST belong to at least one - command, query input, query output, or event data graph. It MAY be shared - between graphs when they use the same data contract. For example, a settings - query MAY return `SettingsBdo`, which a save-settings command MAY accept. Its - name SHOULD describe the represented data rather than one use of it. A BDO - MUST be a behaviourless record and MUST be recursively read-only to consumers. -- **Query results are temporary.** A BDO instance returned by a query is a - snapshot, valid only until the next `Step(dt)` or handled command. Consumers - MUST NOT cache it across frames and MUST NOT expect it to update in place — - the Model may be out-of-process (e.g. a server) in the future, so BDOs are - plain data, not live handles into Model memory. Hot-path queries MAY return - pooled or reused buffers that are only valid for the current frame. This - lifetime belongs to the query result; a BDO carried by a command or event MUST - remain valid for that message's lifetime. -- **Boundary mapping belongs to handlers.** Command and query handlers MUST own - mapping between BDOs and Model internals, but MAY delegate it to an internal - mapper. Domain objects MUST NOT depend on BDOs, and BDOs MUST NOT contain - conversion behaviour. Persistence mapping is a separate concern. -- **Commands aren't the simulation.** A `SimulationTickCommand` that mutates - thousands of entities SHOULD be `Step(dt)` instead. Commands are intent; - stepping is not. -- **Event stream.** For non-trivial event volume, a pull-based event stream - SHOULD be used: a ring buffer with per-consumer cursors lets multiple consumers - (render, audio, AI) drain at their own pace and tolerates bursts. Naive C# - events do not. -- **Caller-assigned identity.** A command that creates an entity SHOULD take the - new entity's identity as input (a client-generated id, e.g. a GUID) rather than - returning it. The handler stays intent-only, and the caller can reference the - entity in follow-up commands and queries without a round trip — which keeps - scripted, AI, and LLM-driven command sequences straightforward and preserves - replay / lockstep determinism. - -## Examples - -**Turn-based tactics.** Commands: move, attack, end-turn. Queries: movement -range, visibility. Events: unit moved/removed, turn started, became-visible. -`Step(dt)` is trivial — the sim advances on end-turn. Discrete tile positions in -the Model; View interpolates the visual slide. - -**Idler / incremental.** Discrete tick (per 100 ms / second) is the `Step(dt)`. -Few commands (buy, prestige). Resource counts change continuously → query them -for display, don't event them. Events only for milestones (unlock, prestige, -offline-progress applied). Works, but the ceremony is heavy for how simple an -idler is. - -**Local RTS.** Orders (`Move` / `Attack` / `Build`) are a canonical command fit -and lockstep-friendly. The Model runs a data-oriented `Step(dt)` over packed -arrays for movement/collision/targeting — the dispatcher never enters the hot -loop. Continuous positions are Model state, exposed by query; the Presenter wires -that result to the View each frame. Events are discrete transitions only. Two -adjustments versus turn-based: an explicit per-frame `Step(dt)`, and accepting -continuous position as Model truth. - -| Concern | Turn-based | Idler | Local RTS | -|-----------------------|------------|--------------|------------------------------| -| MVP separation | Fine | Fine | Fine | -| CQS for player intent | Fine | Fine (light) | Excellent fit (order queue) | -| Internal stepping | On-turn | Cheap ticks | Data-oriented `Step(dt)` | -| Event stream | Discrete | Discrete | Discrete transitions only | -| Continuous state | View-side | Query | Model state, read via query | -| Render-by-query | Some | Required | Required | - -The pattern generalizes: keep commands as intent at the boundary, let the Model's -internals be as data-oriented as the simulation needs, push only discrete events, -pull continuous state by query. Where a genre seems not to fit, check first -whether you're forcing internals through the boundary or treating -simulation-truth floats as View state. diff --git a/docs/architecture-library.md b/docs/architecture-library.md deleted file mode 100644 index dd69633f..00000000 --- a/docs/architecture-library.md +++ /dev/null @@ -1,182 +0,0 @@ -# Pixely.Architecture - -The CQS + domain-event infrastructure for a Model layer: command/query handler contracts, a command -dispatcher with command-dispatch hooks, and a pull-based domain-event stream. For the reasoning behind -the pattern see [architecture-concept.md](architecture-concept.md); for the tests that enforce it see -[architecture-testing.md](architecture-testing.md). - -## Commands and queries - -A command is a mutation request. Its handler returns `CommandResult.Success` when the command is accepted and -its requested postcondition holds, including an accepted idempotent no-op. It returns `CommandResult.FromError` -for an expected domain rejection and must not apply the requested state change in that case. Invalid program -state and infrastructure failures use exceptions. The result carries an integer code (0 = success) and a -localized error message; it converts to `bool` implicitly. A query is a side-effect-free read; its handler -returns a boundary data object (BDO). - -```csharp -public sealed record MoveCommand(UnitId Unit, TilePoint Destination); - -internal sealed class MoveCommandHandler : ICommandHandler -{ - private readonly UnitRegistry _units; - internal MoveCommandHandler(UnitRegistry units) => _units = units; - - public CommandResult Handle(MoveCommand command) { /* mutate */ return CommandResult.Success; } -} - -public sealed record MovementRangeQuery(UnitId Unit); - -public sealed record MovementRangeBdo(IReadOnlyList Tiles); - -internal sealed class MovementRangeQueryHandler : IQueryHandler -{ - public MovementRangeBdo Handle(MovementRangeQuery query) { /* compute */ } -} -``` - -Handlers are **internal** and constructor-injected; the command/query records are the public surface. -Register each as its closed interface so cross-assembly callers depend on the contract, not the handler: - -```csharp -services.AddSingleton, MoveCommandHandler>(); -services.AddSingleton, MovementRangeQueryHandler>(); -``` - -Queries are invoked directly — inject `IQueryHandler` where you need it and call `Handle`. -Commands go through the dispatcher. - -## Boundary data objects - -A BDO is a behaviourless, recursively read-only record in the Model boundary contract. Its name ends with -`Bdo`, and every query returns a named BDO even when it contains only one scalar value: - -```csharp -public sealed record UnitCountQuery(Faction Faction); -public sealed record UnitCountBdo(int Count); -``` - -BDOs are recursively read-only to consumers: expose get-only or `init` properties and read-only collection -interfaces or immutable collections, not public setters, mutable collections, or arrays. This is consumer-side -read-only access rather than a guarantee that the Model never reuses its backing storage. A BDO instance returned -by a query is a temporary snapshot: do not cache it across frames or expect it to update in place — re-query instead. - -A BDO must belong to at least one command, query input, query output, or event data graph. The same BDO may be -shared between graphs when they use the same data contract. For example, a settings query may return -`SettingsBdo`, which a save-settings command accepts. A BDO carried by a command or event must remain valid for -that message's lifetime. See [architecture-concept.md](architecture-concept.md) for the full boundary rules. - -Handlers return a `CommandResult`, never the entity they created. When a command creates something the -caller must reference afterward, the **caller supplies the identity** — a client-generated id passed into -the command — so it can use that id in follow-up commands and queries without the handler returning anything: - -```csharp -public sealed record SpawnUnitCommand(UnitId Unit, UnitDefinitionId Definition, TilePoint At); - -UnitId unit = UnitId.New(); // caller mints the id -_dispatcher.Dispatch(new SpawnUnitCommand(unit, definition, tile)); -_dispatcher.Dispatch(new MoveCommand(unit, destination)); // reference it immediately -``` - -## Dispatching commands - -`AddCommandDispatching()` registers `ICommandDispatcher`. Inject it and dispatch; it resolves the -handler for the closed command type: - -```csharp -services.AddCommandDispatching(); - -// in a caller (e.g. a Presenter): -_dispatcher.Dispatch(new MoveCommand(unit, destination)); -``` - -`Dispatch` is depth-gated. A handler may dispatch further commands; those share the same batch. When the -top-level command is handled, every `ICommandDispatchHook.OnBatchCompleted()` runs once — still inside the -dispatch call, before it returns — in registration order. Re-entrant commands do not re-trigger the hooks. - -```csharp -internal sealed class TurnTriggerHook : ICommandDispatchHook -{ - public void OnBatchCompleted() { /* run end-of-batch work */ } -} -services.AddSingleton(); -services.AddAlias(); -``` - -Hooks run in registration order, so a hook that **publishes** domain events must be registered before the -hook that drains them; otherwise its events wait for the next top-level dispatch. - -## Domain events - -Handlers raise discrete domain events through `IDomainEventPublisher`. Events derive from the -recipient-less `DomainMessage` base; recipient/routing fields are a game concern, added in a derived base. - -```csharp -public abstract record FactionDomainMessage(Faction Recipient) : DomainMessage; -public sealed record UnitMovedEvent(Faction Recipient, UnitId Unit) : FactionDomainMessage(Recipient); - -// in a handler: -_publisher.Publish(new UnitMovedEvent(faction, unit)); -``` - -`AddDomainEvents()` registers the stream as `IDomainEventPublisher` (write) and `IDomainEventStream` -(read), and registers `DomainEventCursor` as a transient. The stream is a ring buffer; each consumer reads -through its own cursor, so multiple consumers drain at independent paces and a slow consumer doesn't drop -events for the others. - -## Consuming events - -Two consumption models — pick by **cadence**, not preference: - -**Own a cursor, drain in your loop** — for consumers that react on their own clock (View/render, -audio, per-frame AI). This is the point of per-consumer cursors. - -```csharp -internal sealed class UnitSpritePresenter : IUpdatable -{ - private readonly DomainEventCursor _events; - internal UnitSpritePresenter(DomainEventCursor events) => _events = events; - - public void Update() - { - while (_events.TryRead(out DomainMessage? message)) - { - // recipient filter + per-type routing here - } - } -} -``` - -**`DomainEventDispatchHook` + `IDomainEventListener`** — for model-owned reactions that must run *within -the command batch* after every command, regardless of who triggered it (scenario triggers, objective checks, -AI that issues follow-up commands before control returns). The hook is an `ICommandDispatchHook` that drains -the buffered events after each batch and fans each to every listener. - -```csharp -internal sealed class DialogTrigger : IDomainEventListener -{ - public bool TryProcess(DomainMessage message) { /* react, maybe dispatch */ return true; } -} - -services.AddDomainEventDispatchHook(); // requires AddDomainEvents + AddCommandDispatching -services.AddSingleton(); -``` - -`AddDomainEventDispatchHook()` uses `ServiceRegistry` to auto-subscribe -activated singleton services that implement `IDomainEventListener`, similar to `Pixely.Events`. -Do not register listeners as `IDomainEventListener` aliases; register their concrete type. A -listener may depend on `ICommandDispatcher` and dispatch follow-up commands during processing. - -Do not push View consumers through the dispatch hook — that ties rendering reactions to the model's dispatch -path instead of the frame loop. Presenters, View sync, audio, and other consumers with their own cadence -should own a cursor instead. - -## Registration summary - -```csharp -services.AddDomainEvents(); // DomainEventStream aliases + transient DomainEventCursor -services.AddCommandDispatching(); // CommandDispatcher as ICommandDispatcher -services.AddDomainEventDispatchHook(); // DomainEventDispatchHook as ICommandDispatchHook (model-side reactions) -``` - -Then register the game's handlers (closed types), command-dispatch hooks, and event listeners. diff --git a/docs/architecture-testing.md b/docs/architecture-testing.md deleted file mode 100644 index a5c0bb46..00000000 --- a/docs/architecture-testing.md +++ /dev/null @@ -1,136 +0,0 @@ -# Architecture testing - -`Pixely.Architecture.Testing` turns the boundary claims in [architecture-concept.md](architecture-concept.md) -into reflection checks a game runs as ordinary unit tests. Commands, queries, handlers, and events are discovered -through the `Pixely.Architecture` contracts (`ICommandHandler<>`, `IQueryHandler<,>`, `DomainMessage`). BDOs are -discovered by their `Bdo` suffix when the BDO convention is enabled, and their placement is checked against the -command, query-input, query-output, and event data graphs. - -Both entry points are framework-agnostic: they return an `ArchitectureReport` (a `Violations` -list, `IsValid`, and a formatted `ToString()`), so you assert with whatever test framework you use. - -```csharp -ArchitectureReport report = CqsConventions.Check( - options => options.RequireBdoSuffix(), - typeof(GameModule).Assembly); -Assert.That(report.Violations, Is.Empty, report.ToString()); -``` - -## CqsConventions - -`CqsConventions.Check(params Assembly[])` enforces the per-type CQS conventions: - -- **Commands and query inputs are behaviourless records** — a record with no custom methods. -- **Handler naming** — types implementing `ICommandHandler<>` / `IQueryHandler<,>` end with - `CommandHandler` / `QueryHandler`. -- **Handlers are internal with no public constructors** — callers go through the dispatcher, and DI - constructs them via an internal constructor. -- **Command handlers don't depend on other command handlers** — shared behaviour belongs in a domain - service, not handler chaining. -- **Query results are recursively readonly** — see below. -- **BDO convention** — `RequireBdoSuffix()` enforces named boundary data records and their placement. - -`CommandDispatcher`, `DomainEventDispatchHook`, and similar infrastructure are not discovered as handlers -(they don't implement the handler interfaces), so they need no exclusion. - -### Readonly query results - -The result type (`TResult` of `IQueryHandler`) must be readonly from the consumer's -side, checked recursively. A type passes when either: - -- it is a **known-immutable type** — primitives, `enum`, `string`, `decimal`, `Guid`, - `DateTime`/`DateTimeOffset`/`TimeSpan`/`DateOnly`/`TimeOnly`, the read-only collection interfaces - (`IReadOnlyList<>`, `IReadOnlyCollection<>`, `IReadOnlyDictionary<,>`, `IReadOnlySet<>`), - `Immutable*`, and `ValueTuple` (element/argument types are still recursed); or -- **every member is non-externally-mutable and every member type is itself readonly**: - - properties are get-only, `init`, or have a **non-public** setter (a public `set` fails), - - fields are `readonly`, `const`, or **non-public**, - - arrays (`T[]`) fail — expose `IReadOnlyList` / `ImmutableArray` instead, - - compiler-generated record backing fields are ignored (judged via their property). - -Non-public setters are allowed so the Model can construct and fill result instances internally -(object initializers, mapping, deserialization) while consumers still cannot mutate them. This does -not make a result a live handle — a query result is a temporary snapshot, never cached across frames -(see [architecture-concept.md](architecture-concept.md)). - -### Boundary data objects - -Enable the BDO convention when checking the Model assemblies: - -```csharp -ArchitectureReport report = CqsConventions.Check( - options => options.RequireBdoSuffix(), - typeof(GameModule).Assembly); -``` - -The convention enforces: - -- every `TResult` of `IQueryHandler` is a named type ending with `Bdo`; scalar and collection - results therefore require a named wrapper, -- every type ending with `Bdo` is a behaviourless record and is recursively read-only to consumers, -- every `Bdo` type belongs to at least one command, query-input, query-output, or event data graph. - -BDOs may be shared between graphs when they represent the same data contract. BDOs nested within other BDOs are -also allowed. The temporary snapshot contract applies to an instance returned by a query, not to the BDO type in -every usage. - -## ModelBoundary - -`ModelBoundary.Check(assembly, configure)` checks the central claim — *the boundary contract is -commands / queries / events*: - -- **InternalsVisibleTo policy** — ordered rules allow or disallow friend assemblies. -- **Reachability** — every public type must be reachable from the CQS surface (commands, queries, - events, and declared surface seeds), or be handled by an outside-surface rule. - -All policy is caller-supplied through the options: - -```csharp -ArchitectureReport report = ModelBoundary.Check(typeof(GameModule).Assembly, options => options - .AllowInternalsTo("Game.Editor", "Required for editor integration.") - .DisallowInternalsTo( - new Regex(@"Game\.Tests(?:\..*)?"), - "Tests must exercise the public boundary.") - .DisallowInternalsTo(new Regex(@".*"), "No other assembly may access Model internals.") - .TreatAsSurface(type => type.Name.EndsWith("Module")) - .TreatAsSurface(type => typeof(IMarkerRoot).IsAssignableFrom(type)) - .AllowOutsideSurface(typeof(SomeIntentionalPublicType), "Required by the serializer.") - .DisallowOutsideSurface( - new Regex(@".*"), - "All other public types must be reachable from the boundary surface.")); -``` - -Rules are evaluated in declaration order. A rule handles and removes every matching candidate, so the first -matching rule decides each assembly or outside-surface type. Exact `string`/`Type` overloads use exact matching; -`Regex` overloads must match the candidate's entire name. Candidates left unmatched after all rules are allowed. -Put specific decisions before a final `.*` disallow rule when the policy should be closed by default. Every rule -requires a reason, which is included in diagnostics from disallow rules. - -Reachability seeds from handler `Handle` signatures (including internal handlers), so query result -types are reachable through the contract rather than only through incidental references. - -## Typical usage - -```csharp -[Test] -public void CqsConventions_AreHeld() -{ - ArchitectureReport report = CqsConventions.Check( - options => options.RequireBdoSuffix(), - typeof(GameModule).Assembly, typeof(EditorModule).Assembly); - Assert.That(report.Violations, Is.Empty, report.ToString()); -} - -[Test] -public void Model_ExposesOnlyItsCqsSurface() -{ - ArchitectureReport report = ModelBoundary.Check(typeof(GameModule).Assembly, options => options - .AllowInternalsTo("Game.Editor", "Required for editor integration.") - .DisallowInternalsTo(new Regex(@".*"), "No other assembly may access Model internals.") - .TreatAsSurface(type => type.Name.EndsWith("Module")) - .DisallowOutsideSurface( - new Regex(@".*"), - "Public types must be reachable from the boundary surface.")); - Assert.That(report.Violations, Is.Empty, report.ToString()); -} -``` diff --git a/docs/fitness.md b/docs/fitness.md new file mode 100644 index 00000000..8ec3c6a4 --- /dev/null +++ b/docs/fitness.md @@ -0,0 +1,48 @@ +# Fitness functions + +`Pixely.Fitness` checks a game's compiled assemblies against the rules in [peach-architecture.md](peach-architecture.md). Each rule is one function that returns the members breaking it. A game runs them from its own test project. + +## Options + +The document fixes the names of everything, so the prefix is enough: + +```csharp +PeachArchitectureOptions options = new PeachArchitectureOptions("Foo") +{ + ExtraGameNamespaces = [new ExtraNamespace("Foo.Game.Persistence", "Saving to disk is neither State nor a Mechanic")] +}; +``` + +- `ExtraGameNamespaces` lists namespaces the game adds to `Foo.Game` beyond the ones the document names, each with a justification. Rule 08 reports a type outside the documented and listed namespaces, a listed namespace without a justification, and a listed namespace that holds no types. +- `MechanicsHaveNoPublicConstructors`, `MechanicsTakeStateRootThroughConstructor` and `GameGrantsNoInternalAccess` are `init` properties that switch off hardened SHOULD rules. They default to true. + +From the prefix, rule 00 derives the rest and reports what it could not derive: + +- The assemblies, loaded by name: `Foo.Game`, `Foo.Frontend`, `Foo.Frontend.Rendering`, `Foo.Frontend.Audio`, `Foo.Ai`, `Foo.Scenario`, and `Foo.Executable` or `Foo`. The test project must reference the executable project so they sit in its output directory. The optional four are absent when the game has no such project; a missing `Game`, `Frontend` or executable stops the evaluation at rule 00. +- The state root: the one class in `Foo.Game.State` no other `State` type holds in a field. +- The two containers: every registrar in the production assemblies, a public static `Add*` extension method on `PixelyAppBuilder` or `ServiceCollection` in the project's root namespace, is invoked with default arguments on one `PixelyAppBuilder` and one `ServiceCollection`. A registrar that throws on defaults is a violation. +- The repository root: the directory above `src/Foo.Game/Foo.Game.csproj`, found by walking up from the test output directory. With it, rule 01 checks the `ProjectReference` items of each project and rule 00 compares the `src/Foo.*` directories against the loaded assemblies. Without it, e.g. when the tests run from a package, both checks are skipped. + +## Running + +`PeachArchitecture.Evaluate(options)` returns a `FitnessReport`. `IsFit` is true when no rule has violations; `ToString()` lists every failing rule with its members. `Results` holds one `FitnessResult` per rule, and the indexer looks one up by name, e.g. `report["17 StateHasNoPublicSetters"]`. + +The intended shape is one test asserting `report.IsFit` with `report.ToString()` as the message, plus one test case per `Results` name so the test explorer says which rule drifted: + +```csharp +private static readonly FitnessReport Report = PeachArchitecture.Evaluate(new PeachArchitectureOptions("Foo")); + +[Test] +public void Game_IsFit() => Assert.That(Report.IsFit, Is.True, Report.ToString()); + +[TestCaseSource(nameof(RuleNames))] +public void Rule(string name) => Assert.That(Report[name].Violations, Is.Empty, Report[name].ToString()); + +private static IEnumerable RuleNames() => Report.Results.Select(result => result.Name); +``` + +Evaluate once per fixture; the functions reflect over every production assembly. + +## Other rule sets + +`FitnessReport` and `FitnessResult` are not tied to the Peach rules. Any function that returns `IReadOnlyList` violations can be wrapped in a `FitnessResult` and collected into a `FitnessReport`, and `FitnessReport.Merge(reportA, reportB)` folds several reports into one assertion. `TypeGraph` holds the reflection helpers the Peach functions use, such as `DeclaredTypes`, `SignatureTypes` and `IsPublicSurface`, and is public for that purpose. diff --git a/docs/peach-architecture.md b/docs/peach-architecture.md new file mode 100644 index 00000000..ba509ada --- /dev/null +++ b/docs/peach-architecture.md @@ -0,0 +1,227 @@ +# Peach Architecture + +- Architecture for any genre, e.g. RTS, 4X, turn based, Vampire Survivors +- The framework is Pixely. What Pixely provides is listed below, this document adds only what a game + does with it +- MUST, SHOULD and MAY are RFC 2119. A rule stated for a project holds for every namespace in it +- `Foo` is a placeholder for the game's name, the rest of every name is literal. A game named + Ashfall has `Ashfall.Game` +- A participant is a side the rules treat as one. This document says participant, a game says its own + word, e.g. faction +- Each project MUST register what it offers through public extension methods, one per container it + registers into. A root method extends `PixelyAppBuilder`, a stage method extends + `ServiceCollection`, e.g. `AddGamePersistence` for root and `AddGame` for the stage. They are the + only way `Executable` registers an internal type. A registrar registers the same types whatever + its arguments; arguments configure values, not what exists +- A stage is what the player is in, a mission or a menu. Each has its own state root +- Not every game needs every part + - `Systems` exist when rules advance with time. A turn based game where nothing happens between + actions has none + - The `Ai` project exists when a non player participant acts like a player, by calling Mechanics. A + Survivors clone drives its enemies in `Systems` and has no `Ai` + +## What peach means here + +- `Game` is the stone. It is sealed, nothing outside it writes it, and a player never touches it + directly. Everything the player sees or hears is flesh +- There is exactly one boundary, not a stack of them. Unlike an onion nothing wraps `Frontend`, and + no call passes inward through a layer to reach State +- The flesh exists to put the stone in front of someone, and the stone is whole without it +- It is not a process or a network boundary. Everything is one process, a reader holds the live + reference and copies only what it keeps, nothing is serialized and there are no transport records + +## Projects + +```text +Game ──> framework only +Frontend ──> Game, Frontend.Rendering, Frontend.Audio +Frontend.Rendering ──> Game +Frontend.Audio ──> Game +Ai ──> Game +Scenario ──> Game +Executable ──> everything above +``` + +- The `Foo.` prefix is omitted. Every other name in this document is a namespace in one of these, + e.g. `Frontend.Forms` +- An arrow is what a project MUST reference. Nothing else MAY compile against it + +## What Pixely provides + +- The container and the stages, `IStageManager` builds one as a child of the root provider +- The frame loop, an updatable is a `Pixely.IUpdatable` and its `UpdateOrder` places it, lower first +- The log, `Pixely.Observations` is its storage, writer and reader. It has no subscribers, drops an + entry every reader has passed, and throws when a reader stops draining +- The user interface, `Pixely.Ui` is retained and `IUiViewModel.Changed` decides when a view syncs +- Content loading +- This document, shipped in the package under `docs/`. In a consuming project + `dotnet msbuild -getProperty:PixelyDocsDirectory` prints where + +## Foo.Game project + +- The game proper: what is true, the rules that change it and the record of what happened. It is not + a shared library, code that carries no rule does not belong here +- It MUST NOT know a frontend, a participant that is a player, or an output +- It MUST NOT push: no events, observers or callbacks, so it holds no delegate. A reader polls State + or drains the log + +### Foo.Game.Vocabulary namespace + +- Public primitives shared all over: enums, ids, read-only record structures, e.g. `UnitId`, + `ParticipantId`, `TileCoordinate`, `TilePoint` +- A type only one namespace names lives with it, not here +- An id is its own type + +### Foo.Game.State namespace + +- There MUST be one state root per stage. It is the one `State` class no other `State` type holds. + It SHOULD be handed to its readers through the constructor +- Storage MAY be ECS or not, depends on the game needs +- Mutation MUST be `internal`, so nothing outside `Game` writes State. `Mechanics` and `Systems` + write it. A public property MUST NOT have a setter, a public collection MUST be an + `IReadOnlyList`, an `IReadOnlyDictionary` or a `ReadOnlySpan` + - So no `InternalsVisibleTo` between production assemblies, and everything reachable from the + state root lives in `State` or `Vocabulary`, where those rules are checked +- A read MUST NOT return a transport record. The reader holds the live reference and copies only + what it keeps +- When a game hides information, what each participant perceives is State, kept per participant. A + reader bound to a participant MUST read through it, e.g. `ForParticipant(id)` on the state root +- Recomputing it is a Mechanic or a System, not part of the read +- State holds no logic, so it needs no tests. Logic is a rule that reaches beyond the object's own + fields, e.g. whether a unit is hidden depends on what it is doing and where. A read of the object's + own fields, e.g. `IsRipe => Growth >= GrowthTime`, is not + +### Foo.Game.Mechanics namespace + +- Basically game rules exposed via instance class methods +- Each Mechanic MUST have a `Mechanic` suffix +- A Mechanic is public when a project outside `Game` calls it, otherwise internal. Every other type + in `Mechanics` that no public Mechanic exposes, e.g. as an outcome, MUST be internal, otherwise a + helper that mutates State is callable from outside `Game` +- A Mechanic MAY call other Mechanics +- A Mechanic method SHOULD return an outcome, semantic, never a user facing message. It MAY return + what it created, e.g. an id +- A Mechanic MUST apply its effect during the call. No command records, no dispatcher. Work that + spans time is State the call writes, e.g. a construction job a System advances +- Validation MUST live here. `Frontend` MAY compute the same rule for a preview, the Mechanic's + answer is authoritative +- A Mechanic SHOULD take its full payload in one call. E.g. drafts and multi-step flows are `Frontend` + state that become one call when committed + +### Foo.Game.Systems namespace + +- Basically game rules triggered every tick, but MAY execute its job less often +- Systems MUST implement `Pixely.IUpdatable` and MUST be internal. A System MAY write State + directly and MAY use Mechanics + +### Foo.Game.Observations namespace + +- State says what is true, an entry says a transition that reads of State cannot derive + - The test for a new entry type: name what it carries that two reads of State one frame apart do not + - The same action may be either, and what State keeps decides it. An RTS walk advances every tick and + is in State, a turn based walk resolves in one call and is an entry +- A Mechanic or a System appends during the call or tick that caused it +- Entries MUST be past tense records of ids and value types, never live State. Every entry type MUST + have an `Entry` suffix, e.g. `UnitMovedEntry` +- One log per stage, carrying one entry type. The game picks its shape, a tagged value type appends + without allocating, a base class costs an allocation per append +- The game picks the maximum capacity. It is the stall detector, not a working size, so it sits far + past any legitimate burst +- When a game hides information, an entry names the participant that perceived it, a game with one + participant names none + - A writer appends one entry per participant that perceived the action, carrying only what that + participant perceived. Perception is a fact of the moment, a reader cannot reconstruct it later +- `Frontend` and an autonomous actor MAY read, each with its own reader +- A reader bound to one participant MUST ignore an entry naming another. That check SHOULD live in one + reader that filters, rather than be repeated in every consumer that drains the log + +## Foo.Frontend project + +- Directs and coordinates what player sees and hears +- Its state is selection, drafts, previews and what a form shows. Dropping it loses what the player + was doing, never game state. A tool the player selected decides what the next click means, and + that is Frontend's to decide +- Presentation infrastructure is root-scoped and survives stage changes: render contexts, phases, + render targets, presentation, atlas and sprite storage, the audio system, clips and groups. Only what + is bound to one stage's state belongs to the stage +- A form that outlives or replaces a stage, e.g. a save picker, is root-scoped. It MAY reach root + services and `IStageManager`, it MUST NOT hold a reference into a stage +- `Rendering` and `Audio` are output. Each presents the items `Frontend` gives it, e.g. a walk along a + path or a hit sound, owns timing inside them, reports upward, and MUST NOT mutate State or invoke + Mechanics. The item and report types are declared by the output project, `Frontend` constructs them +- Output state MUST be presentation only. Dropping it changes what is seen or heard, never game + state, never the meaning of input + +### Foo.Frontend root namespace + +- Owns input interpretation, selection, drafts, previews. The camera is `Rendering`'s, `Frontend` + decides where it points +- Hit testing MUST resolve against State, never against what `Rendering` drew +- Decides what `Rendering` and `Audio` present; they do not decide for themselves +- MAY read State and invoke Mechanics +- Owns playback: the queue of what one Mechanic call resolved into and what is showing now. It decides + what `Rendering` presents next, what a report means, and when input reopens +- SHOULD refuse input that targets what playback has not shown yet + +### Foo.Frontend.Forms namespace + +- `Pixely.Ui` based user interface, a `UiView` per form +- `Pixely.Ui` is retained. `Sync` runs when the ViewModel raises `Changed`, never per frame, so a form + MUST NOT read State or invoke Mechanics directly +- A ViewModel MAY hold nothing but Frontend state and never read State, e.g. a settings form +- A ViewModel that shows State SHOULD implement `Pixely.IUpdatable`, read State in `Update` and raise + `Changed` when what it shows differs. It MAY invoke Mechanics to update the State + +## Foo.Frontend.Rendering project + +- Owns the camera. `Frontend` sets it and reads it back to hit test +- MAY read State. What State says every frame, e.g. a position that advances every tick, is drawn + from State without `Frontend` handing it over as an item +- State is ahead of the screen: the Mechanic already put the unit at the end of its path. While + `Frontend` has given `Rendering` that walk to present, the unit is drawn from how far the walk has + got, and from State only once it is done +- Reports the markers inside an item, e.g. the step on frame 5 + +## Foo.Frontend.Audio project + +- Its item and report records name `Vocabulary` types + +## Autonomous actor projects + +- An autonomous actor MUST NOT be reachable from `Frontend` +- An autonomous actor reads State, MAY read the log, calls Mechanics, and decides nothing about + presentation. It MUST NOT own state that outlives a call, a plan spanning turns is State +- `Foo.Ai` is a non player participant that acts like a player +- `Foo.Scenario` is scripted, e.g. triggers that read the log and fire Mechanics. It MUST act after + the rules that can fire it and before any participant acts on the result + +## Foo.Executable project + +- Composition and the frame loop, nothing else +- Its assembly is named `Foo.Executable` or `Foo` +- Two containers. Root is the application: platform, window, presentation infrastructure, content, + root-scoped forms. A stage is a child container holding its state root, Mechanics, Systems, log, + `Ai` and the `Frontend` bound to that state. A stage MAY reach root, root MUST NOT reach a stage +- State MUST NOT be reset in place. Another run is another stage +- The frame is single threaded, set by Pixely, so nothing returns a `Task` +- One frame is three phases. Mutation: input driven Mechanics, `Ai`, `Systems`. Direction: the + `Frontend` root drains the log and advances playback. Presentation: output +- A phase is a band of `UpdateOrder`. Where an updatable sits inside one is composed per game, except + where a project states its own constraint, e.g. `Scenario` + +## Out of scope + +- Deliberate, not an omission to fill in. Each game decides these where it needs them +- Persistence. Where it lives is the game's call. The "no rule" test on `Game` does not exclude it, + a save schema is not a rule but it is bound to State tighter than to anything else +- A second frontend, e.g. an editor + +## TODO + +- Turn `Out of scope` into what a game's own document MUST answer: persistence, stage transitions, a + second frontend, the frame composition, its own Vocabulary +- Keep the reason behind a rule and add it back where it was cut. A rule with no reason gets + extrapolated wrongly on a case it does not cover +- Decide whether a Mechanic call from a form passes the playback gate the `Frontend` root owns +- Decide whether a bounded exception to what an entry carries is allowed, e.g. naming the tile a + unit stepped from when it steps into view, so the move can be animated diff --git a/packaging/Pixely/Pixely.Package.csproj b/packaging/Pixely/Pixely.Package.csproj index 0516c7ef..9d65b30f 100644 --- a/packaging/Pixely/Pixely.Package.csproj +++ b/packaging/Pixely/Pixely.Package.csproj @@ -39,8 +39,7 @@ - - + @@ -58,6 +57,7 @@ + $(InterceptorsNamespaces);Pixely.DependencyInjection.Generated + $(MSBuildThisFileDirectory)..\docs\ diff --git a/src/Pixely.Architecture.Testing/ArchitectureReport.cs b/src/Pixely.Architecture.Testing/ArchitectureReport.cs deleted file mode 100644 index bcda9bc5..00000000 --- a/src/Pixely.Architecture.Testing/ArchitectureReport.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Pixely.Architecture.Testing; - -/// -/// The outcome of an architecture check: the list of convention violations found, if any. -/// Framework-agnostic — assert on (or ) from any test framework. -/// -public sealed class ArchitectureReport -{ - public ArchitectureReport(IReadOnlyList violations) - { - Violations = violations; - } - - public IReadOnlyList Violations { get; } - - public bool IsValid => Violations.Count == 0; - - public override string ToString() - { - if (IsValid) - { - return "No architecture violations."; - } - - return $"{Violations.Count} architecture violation(s):" + Environment.NewLine - + string.Join(Environment.NewLine, Violations); - } -} diff --git a/src/Pixely.Architecture.Testing/BoundaryRule.cs b/src/Pixely.Architecture.Testing/BoundaryRule.cs deleted file mode 100644 index d5bd14e6..00000000 --- a/src/Pixely.Architecture.Testing/BoundaryRule.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace Pixely.Architecture.Testing; - -internal sealed record BoundaryRule(Func Matches, bool Allows, string Reason); - -internal static class BoundaryRuleEvaluator -{ - public static List Violations( - IEnumerable candidates, - IReadOnlyCollection> rules, - Func describeViolation) - { - List remaining = candidates.ToList(); - List violations = new(); - - foreach (BoundaryRule rule in rules) - { - T[] matches = remaining.Where(rule.Matches).ToArray(); - if (!rule.Allows) - { - violations.AddRange(matches.Select(candidate => describeViolation(candidate, rule.Reason))); - } - - foreach (T match in matches) - { - remaining.Remove(match); - } - } - - return violations; - } -} diff --git a/src/Pixely.Architecture.Testing/CqsConventions.cs b/src/Pixely.Architecture.Testing/CqsConventions.cs deleted file mode 100644 index d0b72439..00000000 --- a/src/Pixely.Architecture.Testing/CqsConventions.cs +++ /dev/null @@ -1,411 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using Pixely.Architecture.Events; - -namespace Pixely.Architecture.Testing; - -/// -/// Verifies the CQS conventions described in docs/architecture.md against one or more Model assemblies: -/// commands and queries are behaviourless data records, handlers are internal and constructor-injected, -/// and command handlers never depend on other command handlers. -/// -/// -/// Roles are discovered through the Pixely.Architecture contracts, not name suffixes: a command is the -/// TCommand of an , a query is the TQuery of an -/// , and handlers are the implementing types. -/// -public static class CqsConventions -{ - public static ArchitectureReport Check(params Assembly[] assemblies) - { - return Check(null, assemblies); - } - - public static ArchitectureReport Check(Action? configure, params Assembly[] assemblies) - { - if (assemblies.Length == 0) - { - throw new ArgumentException("At least one assembly must be supplied.", nameof(assemblies)); - } - - return CheckTypes(assemblies.SelectMany(assembly => assembly.GetTypes()), configure); - } - - internal static ArchitectureReport CheckTypes( - IEnumerable types, - Action? configure = null) - { - CqsConventionsOptions options = new(); - configure?.Invoke(options); - - Type[] allTypes = types - .Where(type => !type.IsDefined(typeof(CompilerGeneratedAttribute), false)) - .Distinct() - .ToArray(); - - Type[] concreteTypes = allTypes - .Where(type => type.IsClass && !type.IsAbstract) - .ToArray(); - - Type[] commandHandlers = concreteTypes - .Where(type => ImplementsOpenGeneric(type, typeof(ICommandHandler<>))) - .ToArray(); - - Type[] queryHandlers = concreteTypes - .Where(type => ImplementsOpenGeneric(type, typeof(IQueryHandler<,>))) - .ToArray(); - - Type[] commandTypes = commandHandlers - .SelectMany(handler => GenericArguments(handler, typeof(ICommandHandler<>))) - .Distinct() - .ToArray(); - - Type[] queryTypes = queryHandlers - .SelectMany(handler => QueryInterfaces(handler).Select(query => query.GetGenericArguments()[0])) - .Distinct() - .ToArray(); - - Type[] queryResultTypes = queryHandlers - .SelectMany(handler => QueryInterfaces(handler).Select(query => query.GetGenericArguments()[1])) - .Distinct() - .ToArray(); - - Type[] eventTypes = allTypes - .Where(type => type != typeof(DomainMessage) && typeof(DomainMessage).IsAssignableFrom(type)) - .ToArray(); - - List violations = new(); - - CheckDataRecords(commandTypes, "Command", violations); - CheckDataRecords(queryTypes, "Query", violations); - CheckHandlerNaming(commandHandlers, "CommandHandler", violations); - CheckHandlerNaming(queryHandlers, "QueryHandler", violations); - CheckHandlersAreInternal(commandHandlers.Concat(queryHandlers), violations); - CheckHandlersHaveNoPublicConstructors(commandHandlers.Concat(queryHandlers), violations); - CheckCommandHandlersDoNotDependOnHandlers(commandHandlers, violations); - CheckQueryResultsAreReadonly(queryResultTypes, violations); - CheckBdoConventions(allTypes, commandTypes, queryTypes, queryResultTypes, eventTypes, options, violations); - - return new ArchitectureReport(violations); - } - - private static void CheckDataRecords(Type[] types, string role, List violations) - { - foreach (Type type in types) - { - if (!IsRecord(type)) - { - violations.Add($"{role} {type.FullName} must be a record (the boundary contract is data, not behaviour)."); - } - - if (!HasNoCustomMethods(type)) - { - violations.Add($"{role} {type.FullName} must be a plain data record with no custom methods."); - } - } - } - - private static void CheckHandlerNaming(Type[] handlers, string suffix, List violations) - { - foreach (Type handler in handlers) - { - if (!handler.Name.EndsWith(suffix, StringComparison.Ordinal)) - { - violations.Add($"{handler.FullName} should end with '{suffix}'."); - } - } - } - - private static void CheckHandlersAreInternal(IEnumerable handlers, List violations) - { - foreach (Type handler in handlers) - { - if (handler.IsVisible) - { - violations.Add( - $"Handler {handler.FullName} must not be public — callers reach it through the dispatcher, not directly."); - } - } - } - - private static void CheckHandlersHaveNoPublicConstructors(IEnumerable handlers, List violations) - { - foreach (Type handler in handlers) - { - if (handler.GetConstructors(BindingFlags.Instance | BindingFlags.Public).Length > 0) - { - violations.Add($"Handler {handler.FullName} must have no public constructors (use internal for DI)."); - } - } - } - - private static void CheckCommandHandlersDoNotDependOnHandlers(Type[] commandHandlers, List violations) - { - foreach (Type commandHandler in commandHandlers) - { - ConstructorInfo[] constructors = commandHandler.GetConstructors( - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - - foreach (ConstructorInfo constructor in constructors) - { - foreach (ParameterInfo parameter in constructor.GetParameters()) - { - Type? dependency = FindHandlerDependency(parameter.ParameterType); - if (dependency != null) - { - violations.Add( - $"Command handler {commandHandler.FullName} depends on handler {dependency.FullName} " - + $"(parameter '{parameter.Name}'). Move shared behaviour into a domain service."); - } - } - } - } - } - - private static void CheckQueryResultsAreReadonly(Type[] resultTypes, List violations) - { - foreach (Type resultType in resultTypes) - { - if (!QueryResultRules.IsReadonly(resultType, out string reason)) - { - violations.Add($"Query result {resultType.FullName} must be readonly: {reason}"); - } - } - } - - private static void CheckBdoConventions( - Type[] allTypes, - Type[] commandTypes, - Type[] queryTypes, - Type[] resultTypes, - Type[] eventTypes, - CqsConventionsOptions options, - List violations) - { - if (!options.RequiresBdoSuffix) - { - return; - } - - foreach (Type resultType in resultTypes) - { - if (HasSuffix(resultType, "Bdo")) - { - continue; - } - - violations.Add( - $"Query result {resultType.FullName} must be a boundary data object ending with 'Bdo'."); - } - - Type[] bdoTypes = allTypes - .Where(type => HasSuffix(type, "Bdo")) - .ToArray(); - - CheckDataRecords(bdoTypes, "BDO", violations); - CheckBdosAreReadonly(bdoTypes.Except(resultTypes), violations); - - IEnumerable boundaryRoots = commandTypes.Concat(queryTypes).Concat(resultTypes).Concat(eventTypes); - HashSet boundaryGraph = DataGraph(boundaryRoots, allTypes); - foreach (Type bdoType in bdoTypes.Where(type => !boundaryGraph.Contains(type))) - { - violations.Add($"BDO {bdoType.FullName} must belong to a Model boundary graph."); - } - } - - private static void CheckBdosAreReadonly(IEnumerable bdoTypes, List violations) - { - foreach (Type bdoType in bdoTypes) - { - if (!QueryResultRules.IsReadonly(bdoType, out string reason)) - { - violations.Add($"BDO {bdoType.FullName} must be read-only to consumers: {reason}"); - } - } - } - - private static HashSet DataGraph(IEnumerable roots, IReadOnlyCollection allTypes) - { - HashSet availableTypes = allTypes.ToHashSet(); - HashSet graph = new(); - Queue toVisit = new(roots); - - while (toVisit.Count > 0) - { - Type current = toVisit.Dequeue(); - foreach (Type candidate in UnwrapDataType(current)) - { - Type graphType = candidate.IsGenericType && !candidate.IsGenericTypeDefinition - ? candidate.GetGenericTypeDefinition() - : candidate; - - if (!availableTypes.Contains(graphType) || !graph.Add(graphType)) - { - continue; - } - - foreach (PropertyInfo property in candidate.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (property.GetMethod != null) - { - toVisit.Enqueue(property.PropertyType); - } - } - - foreach (FieldInfo field in candidate.GetFields(BindingFlags.Public | BindingFlags.Instance)) - { - toVisit.Enqueue(field.FieldType); - } - } - } - - return graph; - } - - private static bool HasSuffix(Type type, string suffix) - { - string name = type.Name; - int genericAritySeparator = name.IndexOf('`'); - if (genericAritySeparator >= 0) - { - name = name[..genericAritySeparator]; - } - - return name.EndsWith(suffix, StringComparison.Ordinal); - } - - private static IEnumerable UnwrapDataType(Type type) - { - if (type.IsByRef || type.IsPointer || type.IsArray) - { - Type? elementType = type.GetElementType(); - if (elementType != null) - { - foreach (Type nested in UnwrapDataType(elementType)) - { - yield return nested; - } - } - - yield break; - } - - Type? nullableUnderlyingType = Nullable.GetUnderlyingType(type); - if (nullableUnderlyingType != null) - { - foreach (Type nested in UnwrapDataType(nullableUnderlyingType)) - { - yield return nested; - } - - yield break; - } - - yield return type; - - if (!type.IsGenericType) - { - yield break; - } - - foreach (Type genericArgument in type.GetGenericArguments()) - { - foreach (Type nested in UnwrapDataType(genericArgument)) - { - yield return nested; - } - } - } - - private static IEnumerable QueryInterfaces(Type handler) => - handler.GetInterfaces() - .Where(interfaceType => interfaceType.IsGenericType - && interfaceType.GetGenericTypeDefinition() == typeof(IQueryHandler<,>)); - - private static Type? FindHandlerDependency(Type type) - { - if (type.IsByRef || type.IsPointer || type.IsArray) - { - Type? elementType = type.GetElementType(); - return elementType == null ? null : FindHandlerDependency(elementType); - } - - Type? nullableUnderlyingType = Nullable.GetUnderlyingType(type); - if (nullableUnderlyingType != null) - { - return FindHandlerDependency(nullableUnderlyingType); - } - - if (IsCommandHandlerContract(type) || ImplementsOpenGeneric(type, typeof(ICommandHandler<>))) - { - return type; - } - - if (!type.IsGenericType) - { - return null; - } - - foreach (Type genericArgument in type.GetGenericArguments()) - { - Type? dependency = FindHandlerDependency(genericArgument); - if (dependency != null) - { - return dependency; - } - } - - return null; - } - - private static bool IsCommandHandlerContract(Type type) => - type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ICommandHandler<>); - - private static bool ImplementsOpenGeneric(Type type, Type openGeneric) => - type.GetInterfaces().Any(interfaceType => - interfaceType.IsGenericType && interfaceType.GetGenericTypeDefinition() == openGeneric); - - private static IEnumerable GenericArguments(Type type, Type openGeneric) => - type.GetInterfaces() - .Where(interfaceType => interfaceType.IsGenericType - && interfaceType.GetGenericTypeDefinition() == openGeneric) - .SelectMany(interfaceType => interfaceType.GetGenericArguments()); - - private static bool IsRecord(Type type) - { - if (type.IsEnum) - { - return false; - } - - // Both record classes and record structs carry a compiler-generated PrintMembers method. - MethodInfo? printMembers = type.GetMethod("PrintMembers", BindingFlags.Instance | BindingFlags.NonPublic); - return printMembers != null && printMembers.IsDefined(typeof(CompilerGeneratedAttribute), false); - } - - private static bool HasNoCustomMethods(Type type) - { - MethodInfo[] methods = type.GetMethods( - BindingFlags.Instance | BindingFlags.Static - | BindingFlags.Public | BindingFlags.NonPublic - | BindingFlags.DeclaredOnly); - - foreach (MethodInfo method in methods) - { - if (method.IsSpecialName || method.IsDefined(typeof(CompilerGeneratedAttribute), false)) - { - continue; - } - - // Methods the record machinery emits or that records legitimately override. - if (method.Name is "ToString" or "PrintMembers" or "GetHashCode" or "Equals" or "Deconstruct") - { - continue; - } - - return false; - } - - return true; - } -} diff --git a/src/Pixely.Architecture.Testing/CqsConventionsOptions.cs b/src/Pixely.Architecture.Testing/CqsConventionsOptions.cs deleted file mode 100644 index 11e36ed9..00000000 --- a/src/Pixely.Architecture.Testing/CqsConventionsOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Pixely.Architecture.Testing; - -/// -/// Caller-supplied policy for . -/// -public sealed class CqsConventionsOptions -{ - internal bool RequiresBdoSuffix { get; private set; } - - /// - /// Requires every query handler result type to be a boundary data object ending with Bdo, and verifies - /// that BDOs are behaviourless, read-only data records used in Model boundary graphs. - /// - public CqsConventionsOptions RequireBdoSuffix() - { - RequiresBdoSuffix = true; - return this; - } -} diff --git a/src/Pixely.Architecture.Testing/ModelBoundary.cs b/src/Pixely.Architecture.Testing/ModelBoundary.cs deleted file mode 100644 index 66c39e97..00000000 --- a/src/Pixely.Architecture.Testing/ModelBoundary.cs +++ /dev/null @@ -1,285 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using Pixely.Architecture.Events; - -namespace Pixely.Architecture.Testing; - -/// -/// Verifies the central claim of docs/architecture.md — "the boundary contract is commands / queries / -/// events" — against a Model assembly. Ordered caller-supplied rules decide whether assemblies may see Model -/// internals and whether public types may remain outside the CQS surface (commands, queries, events, and -/// caller-declared surface seeds). -/// -public static class ModelBoundary -{ - public static ArchitectureReport Check(Assembly assembly, Action? configure = null) - { - ModelBoundaryOptions options = new(); - configure?.Invoke(options); - - Type[] allTypes = assembly.GetTypes() - .Where(type => !type.IsDefined(typeof(CompilerGeneratedAttribute), false)) - .ToArray(); - Type[] publicTypes = assembly.GetExportedTypes(); - string[] internalsTargets = assembly.GetCustomAttributes() - .Select(attribute => new AssemblyName(attribute.AssemblyName).Name ?? attribute.AssemblyName) - .ToArray(); - - List violations = new(); - violations.AddRange(InternalsVisibleToViolations(internalsTargets, options.InternalsRules)); - violations.AddRange(ReachabilityViolations( - publicTypes, allTypes, type => type.Assembly == assembly, options)); - - return new ArchitectureReport(violations); - } - - internal static List InternalsVisibleToViolations( - IEnumerable actualTargets, IReadOnlyCollection> rules) - { - return BoundaryRuleEvaluator.Violations( - actualTargets, - rules, - (target, reason) => $"Model exposes internals to '{target}', which is disallowed: {reason}"); - } - - internal static List ReachabilityViolations( - IReadOnlyCollection publicTypes, - IReadOnlyCollection allTypes, - Func belongsToModel, - ModelBoundaryOptions options) - { - Type[] handlers = allTypes - .Where(type => type.IsClass && !type.IsAbstract) - .Where(type => ImplementsOpenGeneric(type, typeof(ICommandHandler<>)) - || ImplementsOpenGeneric(type, typeof(IQueryHandler<,>))) - .ToArray(); - - HashSet commandAndQueryTypes = handlers - .SelectMany(handler => GenericArguments(handler, typeof(ICommandHandler<>)) - .Concat(GenericArguments(handler, typeof(IQueryHandler<,>)).Take(1))) - .ToHashSet(); - - bool IsSurface(Type type) => - commandAndQueryTypes.Contains(type) - || typeof(DomainMessage).IsAssignableFrom(type) - || options.SurfaceSeeds.Any(predicate => predicate(type)); - - HashSet reachable = new(); - Queue toVisit = new(); - - foreach (Type seed in publicTypes.Where(IsSurface)) - { - AddReachableType(seed, belongsToModel, allTypes, reachable, toVisit); - } - - // A handler's Handle signature is the command/query contract: it pins down the request type and, - // for queries, the result type. Walk it even though handlers are internal and never seeds themselves. - foreach (Type handler in handlers) - { - foreach (MethodInfo handle in handler - .GetMethods(BindingFlags.Instance | BindingFlags.Public) - .Where(method => method.Name == "Handle")) - { - AddSignatureTypes(handle.ReturnType, belongsToModel, allTypes, reachable, toVisit); - foreach (ParameterInfo parameter in handle.GetParameters()) - { - AddSignatureTypes(parameter.ParameterType, belongsToModel, allTypes, reachable, toVisit); - } - } - } - - WalkReachableGraph(belongsToModel, allTypes, reachable, toVisit); - - Type[] outsideSurface = publicTypes - .Where(type => !reachable.Contains(type)) - .OrderBy(type => type.FullName ?? type.Name, StringComparer.Ordinal) - .ToArray(); - - return BoundaryRuleEvaluator.Violations( - outsideSurface, - options.OutsideSurfaceRules, - (type, reason) => $"Public type {type.FullName ?? type.Name} is not reachable from the CQS surface " - + $"(commands / queries / events), which is disallowed: {reason}"); - } - - private static void WalkReachableGraph( - Func belongsToModel, IReadOnlyCollection allTypes, - HashSet reachable, Queue toVisit) - { - const BindingFlags members = - BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; - - while (toVisit.Count > 0) - { - Type current = toVisit.Dequeue(); - - foreach (PropertyInfo property in current.GetProperties(members)) - { - if (property.GetMethod != null) - { - AddSignatureTypes(property.PropertyType, belongsToModel, allTypes, reachable, toVisit); - } - } - - foreach (EventInfo @event in current.GetEvents(members)) - { - if (@event.EventHandlerType == null) - { - continue; - } - - foreach (Type argumentType in GetEventArgumentTypes(@event.EventHandlerType)) - { - AddSignatureTypes(argumentType, belongsToModel, allTypes, reachable, toVisit); - } - } - - foreach (MethodInfo method in current.GetMethods(members)) - { - if (method.IsSpecialName) - { - continue; - } - - AddSignatureTypes(method.ReturnType, belongsToModel, allTypes, reachable, toVisit); - foreach (ParameterInfo parameter in method.GetParameters()) - { - AddSignatureTypes(parameter.ParameterType, belongsToModel, allTypes, reachable, toVisit); - } - } - } - } - - private static void AddReachableType( - Type type, Func belongsToModel, IReadOnlyCollection allTypes, - HashSet reachable, Queue toVisit) - { - if (type.IsGenericType && !type.IsGenericTypeDefinition) - { - AddReachableType(type.GetGenericTypeDefinition(), belongsToModel, allTypes, reachable, toVisit); - } - - if (!belongsToModel(type) || !reachable.Add(type)) - { - return; - } - - toVisit.Enqueue(type); - - if (type.IsNested && type.DeclaringType != null) - { - AddReachableType(type.DeclaringType, belongsToModel, allTypes, reachable, toVisit); - } - - if (type.BaseType != null) - { - AddReachableType(type.BaseType, belongsToModel, allTypes, reachable, toVisit); - } - - foreach (Type implementedInterface in type.GetInterfaces()) - { - AddReachableType(implementedInterface, belongsToModel, allTypes, reachable, toVisit); - } - - foreach (Type nestedType in type.GetNestedTypes(BindingFlags.Public)) - { - AddReachableType(nestedType, belongsToModel, allTypes, reachable, toVisit); - } - - if (type.IsAbstract) - { - foreach (Type derivedType in allTypes - .Where(candidate => candidate.IsPublic && !candidate.IsAbstract && candidate.IsSubclassOf(type))) - { - AddReachableType(derivedType, belongsToModel, allTypes, reachable, toVisit); - } - } - } - - private static void AddSignatureTypes( - Type type, Func belongsToModel, IReadOnlyCollection allTypes, - HashSet reachable, Queue toVisit) - { - foreach (Type unwrapped in UnwrapSignatureTypes(type, belongsToModel)) - { - AddReachableType(unwrapped, belongsToModel, allTypes, reachable, toVisit); - } - } - - private static IEnumerable UnwrapSignatureTypes(Type type, Func belongsToModel) - { - if (type.IsByRef || type.IsPointer || type.IsArray) - { - Type? elementType = type.GetElementType(); - if (elementType != null) - { - foreach (Type nested in UnwrapSignatureTypes(elementType, belongsToModel)) - { - yield return nested; - } - } - - yield break; - } - - if (type.IsGenericParameter) - { - yield break; - } - - Type? nullableUnderlyingType = Nullable.GetUnderlyingType(type); - if (nullableUnderlyingType != null) - { - foreach (Type nested in UnwrapSignatureTypes(nullableUnderlyingType, belongsToModel)) - { - yield return nested; - } - - yield break; - } - - if (belongsToModel(type)) - { - yield return type; - } - - if (!type.IsGenericType) - { - yield break; - } - - foreach (Type genericArgument in type.GetGenericArguments()) - { - foreach (Type nested in UnwrapSignatureTypes(genericArgument, belongsToModel)) - { - yield return nested; - } - } - } - - private static IEnumerable GetEventArgumentTypes(Type eventHandlerType) - { - if (eventHandlerType.IsGenericType) - { - return eventHandlerType.GetGenericArguments(); - } - - MethodInfo? invoke = eventHandlerType.GetMethod("Invoke", BindingFlags.Public | BindingFlags.Instance); - if (invoke == null) - { - return []; - } - - return invoke.GetParameters().Select(parameter => parameter.ParameterType).Append(invoke.ReturnType); - } - - private static bool ImplementsOpenGeneric(Type type, Type openGeneric) => - type.GetInterfaces().Any(interfaceType => - interfaceType.IsGenericType && interfaceType.GetGenericTypeDefinition() == openGeneric); - - private static IEnumerable GenericArguments(Type type, Type openGeneric) => - type.GetInterfaces() - .Where(interfaceType => interfaceType.IsGenericType - && interfaceType.GetGenericTypeDefinition() == openGeneric) - .SelectMany(interfaceType => interfaceType.GetGenericArguments()); -} diff --git a/src/Pixely.Architecture.Testing/ModelBoundaryOptions.cs b/src/Pixely.Architecture.Testing/ModelBoundaryOptions.cs deleted file mode 100644 index fbf51e3b..00000000 --- a/src/Pixely.Architecture.Testing/ModelBoundaryOptions.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Text.RegularExpressions; - -namespace Pixely.Architecture.Testing; - -/// -/// Caller-supplied policy for : ordered rules for internals access and public types -/// outside the boundary surface, plus extra types that count as part of the public CQS surface. -/// -public sealed class ModelBoundaryOptions -{ - private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); - - internal List> SurfaceSeeds { get; } = new(); - - internal List> InternalsRules { get; } = new(); - - internal List> OutsideSurfaceRules { get; } = new(); - - /// - /// Treats any type matching as an intentional part of the CQS surface - /// (e.g. DI modules, marker-interface roots) so it and what it references are considered reachable. - /// Commands, queries, and events are surface by default. - /// - public ModelBoundaryOptions TreatAsSurface(Func predicate) - { - SurfaceSeeds.Add(predicate); - return this; - } - - /// - /// Allows an exact assembly name in the Model's InternalsVisibleTo attributes. - /// - public ModelBoundaryOptions AllowInternalsTo(string assemblyName, string reason) - { - ArgumentException.ThrowIfNullOrWhiteSpace(assemblyName); - AddInternalsRule(candidate => string.Equals(candidate, assemblyName, StringComparison.Ordinal), true, reason); - return this; - } - - /// - /// Allows assembly names fully matched by in the Model's - /// InternalsVisibleTo attributes. - /// - public ModelBoundaryOptions AllowInternalsTo(Regex assemblyNamePattern, string reason) - { - AddInternalsRule(FullNameMatcher(assemblyNamePattern), true, reason); - return this; - } - - /// Disallows an exact assembly name in the Model's InternalsVisibleTo attributes. - public ModelBoundaryOptions DisallowInternalsTo(string assemblyName, string reason) - { - ArgumentException.ThrowIfNullOrWhiteSpace(assemblyName); - AddInternalsRule(candidate => string.Equals(candidate, assemblyName, StringComparison.Ordinal), false, reason); - return this; - } - - /// - /// Disallows assembly names fully matched by in the Model's - /// InternalsVisibleTo attributes. - /// - public ModelBoundaryOptions DisallowInternalsTo(Regex assemblyNamePattern, string reason) - { - AddInternalsRule(FullNameMatcher(assemblyNamePattern), false, reason); - return this; - } - - /// Allows an exact public type to remain outside the reachable boundary surface. - public ModelBoundaryOptions AllowOutsideSurface(Type type, string reason) - { - ArgumentNullException.ThrowIfNull(type); - AddOutsideSurfaceRule(candidate => candidate == type, true, reason); - return this; - } - - /// - /// Allows public types whose full names are fully matched by to remain - /// outside the reachable boundary surface. - /// - public ModelBoundaryOptions AllowOutsideSurface(Regex typeNamePattern, string reason) - { - Func matches = FullNameMatcher(typeNamePattern); - AddOutsideSurfaceRule(type => matches(type.FullName ?? type.Name), true, reason); - return this; - } - - /// Disallows an exact public type from remaining outside the reachable boundary surface. - public ModelBoundaryOptions DisallowOutsideSurface(Type type, string reason) - { - ArgumentNullException.ThrowIfNull(type); - AddOutsideSurfaceRule(candidate => candidate == type, false, reason); - return this; - } - - /// - /// Disallows public types whose full names are fully matched by from - /// remaining outside the reachable boundary surface. - /// - public ModelBoundaryOptions DisallowOutsideSurface(Regex typeNamePattern, string reason) - { - Func matches = FullNameMatcher(typeNamePattern); - AddOutsideSurfaceRule(type => matches(type.FullName ?? type.Name), false, reason); - return this; - } - - private void AddInternalsRule(Func matches, bool allows, string reason) - { - ArgumentException.ThrowIfNullOrWhiteSpace(reason); - InternalsRules.Add(new BoundaryRule(matches, allows, reason)); - } - - private void AddOutsideSurfaceRule(Func matches, bool allows, string reason) - { - ArgumentException.ThrowIfNullOrWhiteSpace(reason); - OutsideSurfaceRules.Add(new BoundaryRule(matches, allows, reason)); - } - - private static Func FullNameMatcher(Regex pattern) - { - ArgumentNullException.ThrowIfNull(pattern); - Regex boundedPattern = new(pattern.ToString(), pattern.Options, RegexTimeout); - - return candidate => - { - Match match = boundedPattern.Match(candidate); - return match.Success && match.Index == 0 && match.Length == candidate.Length; - }; - } -} diff --git a/src/Pixely.Architecture.Testing/Pixely.Architecture.Testing.csproj b/src/Pixely.Architecture.Testing/Pixely.Architecture.Testing.csproj deleted file mode 100644 index 302d1a60..00000000 --- a/src/Pixely.Architecture.Testing/Pixely.Architecture.Testing.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - 14 - enable - enable - - - - - - - - - - - diff --git a/src/Pixely.Architecture.Testing/QueryResultRules.cs b/src/Pixely.Architecture.Testing/QueryResultRules.cs deleted file mode 100644 index 5bac5df8..00000000 --- a/src/Pixely.Architecture.Testing/QueryResultRules.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Collections.Immutable; -using System.Reflection; -using System.Runtime.CompilerServices; - -namespace Pixely.Architecture.Testing; - -/// -/// Decides whether a query result type is readonly from the consumer's perspective: recursively, every readable -/// member is non-externally-mutable (get/init/readonly/const or non-public) and every member type is itself -/// readonly. Known-immutable scalar and collection types short-circuit the walk. -/// -internal static class QueryResultRules -{ - private static readonly HashSet KnownImmutableScalars = - [ - typeof(string), typeof(decimal), typeof(Guid), - typeof(DateTime), typeof(DateTimeOffset), typeof(TimeSpan), - typeof(DateOnly), typeof(TimeOnly), typeof(Uri), typeof(Version), - ]; - - private static readonly HashSet KnownImmutableGenerics = - [ - typeof(IEnumerable<>), typeof(IReadOnlyList<>), typeof(IReadOnlyCollection<>), - typeof(IReadOnlyDictionary<,>), typeof(IReadOnlySet<>), - typeof(ImmutableArray<>), typeof(ImmutableList<>), typeof(ImmutableHashSet<>), - typeof(ImmutableSortedSet<>), typeof(ImmutableDictionary<,>), typeof(ImmutableSortedDictionary<,>), - typeof(ImmutableQueue<>), typeof(ImmutableStack<>), - typeof(ValueTuple<>), typeof(ValueTuple<,>), typeof(ValueTuple<,,>), typeof(ValueTuple<,,,>), - typeof(ValueTuple<,,,,>), typeof(ValueTuple<,,,,,>), typeof(ValueTuple<,,,,,,>), typeof(ValueTuple<,,,,,,,>), - ]; - - public static bool IsReadonly(Type type, out string reason) => - IsReadonly(type, new HashSet(), out reason); - - private static bool IsReadonly(Type type, HashSet visiting, out string reason) - { - reason = string.Empty; - - Type? nullableUnderlying = Nullable.GetUnderlyingType(type); - if (nullableUnderlying != null) - { - type = nullableUnderlying; - } - - if (type.IsGenericParameter || type.IsPrimitive || type.IsEnum || KnownImmutableScalars.Contains(type)) - { - return true; - } - - if (type.IsArray) - { - reason = $"{type.Name} is an array; expose IReadOnlyList or ImmutableArray"; - return false; - } - - if (!visiting.Add(type)) - { - return true; - } - - try - { - if (type.IsGenericType) - { - foreach (Type argument in type.GetGenericArguments()) - { - if (!IsReadonly(argument, visiting, out reason)) - { - return false; - } - } - - if (KnownImmutableGenerics.Contains(type.GetGenericTypeDefinition())) - { - return true; - } - } - - foreach (PropertyInfo property in type.GetProperties( - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) - { - MethodInfo? setter = property.SetMethod; - if (setter != null && setter.IsPublic && !IsInitOnly(setter)) - { - reason = $"{type.Name}.{property.Name} has a public setter"; - return false; - } - - MethodInfo? getter = property.GetMethod; - if (getter != null && getter.IsPublic && !IsReadonly(property.PropertyType, visiting, out reason)) - { - reason = $"{type.Name}.{property.Name}: {reason}"; - return false; - } - } - - foreach (FieldInfo field in type.GetFields( - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (field.IsDefined(typeof(CompilerGeneratedAttribute), false)) - { - continue; - } - - if (field.IsPublic && !field.IsInitOnly && !field.IsLiteral) - { - reason = $"{type.Name}.{field.Name} is a public mutable field"; - return false; - } - - if (field.IsPublic && !IsReadonly(field.FieldType, visiting, out reason)) - { - reason = $"{type.Name}.{field.Name}: {reason}"; - return false; - } - } - - return true; - } - finally - { - visiting.Remove(type); - } - } - - private static bool IsInitOnly(MethodInfo setter) => - setter.ReturnParameter.GetRequiredCustomModifiers() - .Any(modifier => modifier.FullName == "System.Runtime.CompilerServices.IsExternalInit"); -} diff --git a/src/Pixely.Architecture/CommandDispatcher.cs b/src/Pixely.Architecture/CommandDispatcher.cs deleted file mode 100644 index 883dbce1..00000000 --- a/src/Pixely.Architecture/CommandDispatcher.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Pixely.DependencyInjection; - -namespace Pixely.Architecture; - -/// -/// Resolves the registered for each command and invokes it. After the -/// top-level command in a batch completes, every runs once, in registration -/// order — re-entrant commands dispatched by a handler share the same batch and do not re-trigger the hooks. -/// A hook that publishes domain events must be registered before any hook that drains them. -/// -public sealed class CommandDispatcher : ICommandDispatcher -{ - private readonly ServiceProvider _services; - private readonly ICommandDispatchHook[] _dispatchHooks; - private int _dispatchDepth; - - public CommandDispatcher(ServiceProvider services, IEnumerable dispatchHooks) - { - _services = services; - _dispatchHooks = dispatchHooks.ToArray(); - } - - public CommandResult Dispatch(TCommand command) - { - _dispatchDepth++; - try - { - ICommandHandler handler = _services.GetRequiredService>(); - CommandResult result = handler.Handle(command); - if (_dispatchDepth == 1) - { - foreach (ICommandDispatchHook hook in _dispatchHooks) - { - hook.OnBatchCompleted(); - } - } - - return result; - } - finally - { - _dispatchDepth--; - } - } -} diff --git a/src/Pixely.Architecture/CommandResult.cs b/src/Pixely.Architecture/CommandResult.cs deleted file mode 100644 index 8adfcaff..00000000 --- a/src/Pixely.Architecture/CommandResult.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Pixely.Architecture; - -public record struct CommandResult(int Code, string Message) -{ - public static readonly CommandResult Success = new(0, string.Empty); - - public static CommandResult FromError(string message) - { - return new CommandResult(1, message); - } - - public static CommandResult FromError(int code, string message) - { - return new CommandResult(code, message); - } - - public bool IsSuccess => Code == 0; - - public static implicit operator bool(CommandResult result) - { - return result.IsSuccess; - } -} diff --git a/src/Pixely.Architecture/Events/DomainEventCursor.cs b/src/Pixely.Architecture/Events/DomainEventCursor.cs deleted file mode 100644 index 69b891f9..00000000 --- a/src/Pixely.Architecture/Events/DomainEventCursor.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Pixely.Architecture.Events; - -public sealed class DomainEventCursor : IDisposable -{ - private readonly DomainEventStream _stream; - private bool _disposed; - - internal DomainEventCursor(DomainEventStream stream, long nextSequence) - { - _stream = stream; - NextSequence = nextSequence; - } - - internal long NextSequence { get; private set; } - - public bool TryRead([NotNullWhen(true)] out DomainMessage? domainMessage) - { - ObjectDisposedException.ThrowIf(_disposed, this); - return _stream.TryRead(this, out domainMessage); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _stream.RemoveCursor(this); - _disposed = true; - } - - internal void Advance() - { - NextSequence++; - } -} diff --git a/src/Pixely.Architecture/Events/DomainEventDispatchHook.cs b/src/Pixely.Architecture/Events/DomainEventDispatchHook.cs deleted file mode 100644 index 49821d71..00000000 --- a/src/Pixely.Architecture/Events/DomainEventDispatchHook.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Pixely.Architecture; -using Pixely.DependencyInjection; - -namespace Pixely.Architecture.Events; - -/// -/// A model-side command-dispatch hook that drains domain events after each top-level command batch, while the -/// originating call is still active, and dispatches -/// them to every registered . -/// -/// -/// Use this for model-owned reactions that must happen after every command regardless of who dispatched it, -/// such as scenario triggers, objective checks, or AI follow-up commands. Presenter, View, audio, and other -/// frame-loop consumers should usually create their own from -/// and drain it on their own cadence instead. -/// -public sealed class DomainEventDispatchHook : ICommandDispatchHook -{ - private readonly DomainEventCursor _events; - private readonly ServiceRegistry _listeners; - - public DomainEventDispatchHook(DomainEventCursor events, ServiceRegistry listeners) - { - _events = events; - _listeners = listeners; - } - - public void OnBatchCompleted() - { - while (_events.TryRead(out DomainMessage? message)) - { - foreach (IDomainEventListener listener in _listeners) - { - listener.TryProcess(message); - } - } - } -} diff --git a/src/Pixely.Architecture/Events/DomainEventStream.cs b/src/Pixely.Architecture/Events/DomainEventStream.cs deleted file mode 100644 index f613f362..00000000 --- a/src/Pixely.Architecture/Events/DomainEventStream.cs +++ /dev/null @@ -1,126 +0,0 @@ -namespace Pixely.Architecture.Events; - -public sealed class DomainEventStream : IDomainEventPublisher, IDomainEventStream -{ - private const int InitialCapacity = 16; - private const int MaximumRetainedEvents = 8192; - - private DomainMessage?[] _messages = new DomainMessage[InitialCapacity]; - private readonly List _cursors = new(); - private int _head; - private int _count; - private long _firstSequence; - private long _nextSequence; - - public void Publish(DomainMessage domainMessage) - { - if (_count == MaximumRetainedEvents) - { - throw new InvalidOperationException("Domain event stream retained too many events. A cursor may be stalled."); - } - - EnsureCapacity(_count + 1); - int messageIndex = PhysicalIndex(_count); - _messages[messageIndex] = domainMessage; - _count++; - _nextSequence++; - Compact(); - } - - public DomainEventCursor CreateCursor() - { - DomainEventCursor cursor = new DomainEventCursor(this, _nextSequence); - _cursors.Add(cursor); - return cursor; - } - - internal bool TryRead(DomainEventCursor cursor, out DomainMessage? domainMessage) - { - if (_count == 0) - { - domainMessage = null; - return false; - } - - if (cursor.NextSequence < _firstSequence) - { - throw new InvalidOperationException("Domain event cursor lagged behind the retained event buffer."); - } - - int offset = checked((int)(cursor.NextSequence - _firstSequence)); - if (offset >= _count) - { - domainMessage = null; - return false; - } - - domainMessage = _messages[PhysicalIndex(offset)]!; - cursor.Advance(); - Compact(); - return true; - } - - internal void RemoveCursor(DomainEventCursor cursor) - { - _cursors.Remove(cursor); - Compact(); - } - - private void Compact() - { - if (_count == 0) - { - return; - } - - long minimumNextSequence = _cursors.Count == 0 - ? _nextSequence - : _cursors.Min(cursor => cursor.NextSequence); - - int removeCount = checked((int)(minimumNextSequence - _firstSequence)); - if (removeCount > 0) - { - for (int i = 0; i < removeCount; i++) - { - _messages[PhysicalIndex(i)] = null; - } - - _head = PhysicalIndex(removeCount); - _count -= removeCount; - _firstSequence += removeCount; - } - } - - private void EnsureCapacity(int requiredCapacity) - { - if (requiredCapacity <= _messages.Length) - { - return; - } - - int newCapacity = _messages.Length * 2; - while (newCapacity < requiredCapacity) - { - newCapacity *= 2; - } - - if (newCapacity > MaximumRetainedEvents) - { - newCapacity = MaximumRetainedEvents; - } - - DomainMessage?[] newMessages = new DomainMessage[newCapacity]; - for (int i = 0; i < _count; i++) - { - newMessages[i] = _messages[PhysicalIndex(i)]; - } - - _messages = newMessages; - _head = 0; - } - - private int PhysicalIndex(int offset) - { - return (_head + offset) % _messages.Length; - } -} diff --git a/src/Pixely.Architecture/Events/DomainMessage.cs b/src/Pixely.Architecture/Events/DomainMessage.cs deleted file mode 100644 index 618c8f0f..00000000 --- a/src/Pixely.Architecture/Events/DomainMessage.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Pixely.Architecture.Events; - -public abstract record DomainMessage; diff --git a/src/Pixely.Architecture/Events/IDomainEventListener.cs b/src/Pixely.Architecture/Events/IDomainEventListener.cs deleted file mode 100644 index 54d49e3b..00000000 --- a/src/Pixely.Architecture/Events/IDomainEventListener.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Pixely.Architecture.Events; - -/// -/// Reacts to model-side domain events drained by the after a command batch. -/// Returns whether it consumed the message; the hook fans every message out to all listeners regardless of the -/// result. -/// -public interface IDomainEventListener -{ - bool TryProcess(DomainMessage message); -} diff --git a/src/Pixely.Architecture/Events/IDomainEventPublisher.cs b/src/Pixely.Architecture/Events/IDomainEventPublisher.cs deleted file mode 100644 index 55a7f15a..00000000 --- a/src/Pixely.Architecture/Events/IDomainEventPublisher.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Pixely.Architecture.Events; - -public interface IDomainEventPublisher -{ - void Publish(DomainMessage domainMessage); -} diff --git a/src/Pixely.Architecture/Events/IDomainEventStream.cs b/src/Pixely.Architecture/Events/IDomainEventStream.cs deleted file mode 100644 index 6226f795..00000000 --- a/src/Pixely.Architecture/Events/IDomainEventStream.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Pixely.Architecture.Events; - -public interface IDomainEventStream -{ - DomainEventCursor CreateCursor(); -} diff --git a/src/Pixely.Architecture/ICommandDispatchHook.cs b/src/Pixely.Architecture/ICommandDispatchHook.cs deleted file mode 100644 index e73e7fbe..00000000 --- a/src/Pixely.Architecture/ICommandDispatchHook.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Pixely.Architecture; - -/// -/// Runs once after a top-level command batch is handled — inside the dispatch call, before it returns — in -/// registration order. Re-entrant commands dispatched by a handler share the batch and do not trigger it again. -/// -public interface ICommandDispatchHook -{ - void OnBatchCompleted(); -} diff --git a/src/Pixely.Architecture/ICommandDispatcher.cs b/src/Pixely.Architecture/ICommandDispatcher.cs deleted file mode 100644 index 920e1ffc..00000000 --- a/src/Pixely.Architecture/ICommandDispatcher.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Pixely.Architecture; - -/// -/// Dispatches commands and returns their handlers' acceptance results. -/// -public interface ICommandDispatcher -{ - /// - CommandResult Dispatch(TCommand command); -} diff --git a/src/Pixely.Architecture/ICommandHandler.cs b/src/Pixely.Architecture/ICommandHandler.cs deleted file mode 100644 index a3157f50..00000000 --- a/src/Pixely.Architecture/ICommandHandler.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Pixely.Architecture; - -/// -/// Handles a requested mutation. -/// -/// The command type. -public interface ICommandHandler -{ - /// - /// Returns when the command is accepted and its requested postcondition - /// holds. Returns an error via for an expected domain rejection that - /// does not apply the requested state change. Invalid program state and infrastructure failures are reported - /// with exceptions. - /// - CommandResult Handle(TCommand command); -} diff --git a/src/Pixely.Architecture/IQueryHandler.cs b/src/Pixely.Architecture/IQueryHandler.cs deleted file mode 100644 index 7fb19a52..00000000 --- a/src/Pixely.Architecture/IQueryHandler.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Pixely.Architecture; - -public interface IQueryHandler -{ - TResult Handle(TQuery query); -} diff --git a/src/Pixely.Architecture/Pixely.Architecture.csproj b/src/Pixely.Architecture/Pixely.Architecture.csproj deleted file mode 100644 index 517306b1..00000000 --- a/src/Pixely.Architecture/Pixely.Architecture.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net10.0 - 14 - enable - enable - $(InterceptorsNamespaces);Pixely.DependencyInjection.Generated - - - - - - - - - - diff --git a/src/Pixely.Architecture/ServiceCollectionExtensions.cs b/src/Pixely.Architecture/ServiceCollectionExtensions.cs deleted file mode 100644 index 0d6a4f44..00000000 --- a/src/Pixely.Architecture/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Pixely.Architecture.Events; -using Pixely.DependencyInjection; - -namespace Pixely.Architecture; - -public static class ServiceCollectionExtensions -{ - /// - /// Registers the domain event stream as a singleton, aliased to and - /// . Command and query handlers are registered per-game as closed types. - /// - public static ServiceCollection AddDomainEvents(this ServiceCollection services) - { - services.AddSingleton(); - services.AddAlias(); - services.AddAlias(); - services.AddTransient(static sp => - sp.GetRequiredService().CreateCursor()); - return services; - } - - /// - /// Registers the as . The game registers its - /// implementations as closed types, and any - /// implementations it wants run after each command batch. - /// - public static ServiceCollection AddCommandDispatching(this ServiceCollection services) - { - services.AddSingleton(); - services.AddAlias(); - return services; - } - - /// - /// Registers the as an so buffered - /// domain events are drained to model-owned s after each command batch, - /// before the top-level dispatch call returns. Register it after any dispatch hook that publishes events. - /// Requires and . - /// - public static ServiceCollection AddDomainEventDispatchHook(this ServiceCollection services) - { - if (services.IsRegistered()) - { - return services; - } - - services.AddRegistry(); - services.AddSingleton(); - services.AddAlias(); - return services; - } -} diff --git a/src/Pixely.DependencyInjection/Pixely.DependencyInjection.csproj b/src/Pixely.DependencyInjection/Pixely.DependencyInjection.csproj index 5bb0b333..d63cac44 100644 --- a/src/Pixely.DependencyInjection/Pixely.DependencyInjection.csproj +++ b/src/Pixely.DependencyInjection/Pixely.DependencyInjection.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/src/Pixely.DependencyInjection/ServiceCollection.cs b/src/Pixely.DependencyInjection/ServiceCollection.cs index b5883052..3b4d7918 100644 --- a/src/Pixely.DependencyInjection/ServiceCollection.cs +++ b/src/Pixely.DependencyInjection/ServiceCollection.cs @@ -8,6 +8,8 @@ public class ServiceCollection private readonly ServiceProvider? _parent; private readonly HashSet _registeredTypeIds = new(); private readonly Dictionary> _serviceGroups = new(); + + internal IEnumerable Descriptors => _serviceGroups.Values.SelectMany(group => group); private readonly List> _onStartActions = new(); private readonly List _activatedCallbacks = new(); private readonly List _disposingCallbacks = new(); diff --git a/src/Pixely.Fitness/FitnessReport.cs b/src/Pixely.Fitness/FitnessReport.cs new file mode 100644 index 00000000..15958a86 --- /dev/null +++ b/src/Pixely.Fitness/FitnessReport.cs @@ -0,0 +1,41 @@ +namespace Pixely.Fitness; + +public sealed record FitnessResult(string Name, IReadOnlyList Violations) +{ + public bool IsFit => Violations.Count == 0; + + public override string ToString() + { + return IsFit ? Name : $"{Name}{Environment.NewLine}{string.Join(Environment.NewLine, Violations.Select(violation => " " + violation))}"; + } +} + +/// +/// Every fitness function's outcome, so one assertion reports the whole drift at once. Reports from +/// several rule sets merge into one. +/// +public sealed class FitnessReport +{ + public FitnessReport(IReadOnlyList results) + { + Results = results; + } + + public static FitnessReport Merge(params IEnumerable reports) + { + return new FitnessReport(reports.SelectMany(report => report.Results).ToArray()); + } + + public IReadOnlyList Results { get; } + + public bool IsFit => Results.All(result => result.IsFit); + + public IEnumerable Failures => Results.Where(result => !result.IsFit); + + public FitnessResult this[string name] => Results.Single(result => result.Name == name); + + public override string ToString() + { + return IsFit ? "fit" : string.Join(Environment.NewLine, Failures); + } +} diff --git a/src/Pixely.Fitness/PeachArchitecture.Frontend.cs b/src/Pixely.Fitness/PeachArchitecture.Frontend.cs new file mode 100644 index 00000000..6eac21f9 --- /dev/null +++ b/src/Pixely.Fitness/PeachArchitecture.Frontend.cs @@ -0,0 +1,102 @@ +using System.Reflection; +using Pixely.Ui; + +namespace Pixely.Fitness; + +public static partial class PeachArchitecture +{ + // Frontend and the actors show nothing but their registrars; Executable composes them through those alone. + private static IReadOnlyList DirectorAndActorPublicSurfaceIsTheRegistrar(PeachArchitectureOptions options) + { + return options.ActorAssemblies.Append(options.Frontend) + .SelectMany(assembly => TypeGraph.DeclaredTypes(assembly).Where(type => TypeGraph.IsPublicSurface(type) && !(TypeGraph.IsStatic(type) && type.Namespace == options.RootNamespaceOf(assembly)))) + .Select(type => type.FullName!) + .ToArray(); + } + + // 35. Forms own every UiView; output owns none. + private static IReadOnlyList FormsOwnEveryUiView(PeachArchitectureOptions options) + { + IEnumerable misplaced = TypeGraph.DeclaredTypes(options.Frontend) + .Where(type => typeof(IUiView).IsAssignableFrom(type) && !type.IsAbstract && type.Namespace != options.FormsNamespace) + .Select(type => type.FullName!); + IEnumerable output = options.OutputAssemblies.SelectMany(TypeGraph.DeclaredTypes) + .Where(type => typeof(IUiView).IsAssignableFrom(type)) + .Select(type => type.FullName!); + return misplaced.Concat(output).Order(StringComparer.Ordinal).ToArray(); + } + + // 36. A form syncs from its view model; it MUST NOT read State or invoke Mechanics directly. Reflection sees what a form names, not + // what a method body calls, so a form that reaches State through a service it names elsewhere is caught by that name. + private static IReadOnlyList FormsNameNoStateOrMechanics(PeachArchitectureOptions options) + { + string[] forbidden = [options.StateNamespace, options.MechanicsNamespace]; + return TypeGraph.DeclaredTypes(options.Frontend) + .Where(type => typeof(IUiView).IsAssignableFrom(type)) + .SelectMany(type => TypeGraph.SignatureTypes(type).Where(named => forbidden.Contains(named.Namespace)).Select(named => $"{type.FullName} -> {named.FullName}")) + .Distinct() + .Order(StringComparer.Ordinal) + .ToArray(); + } + + // 39. Output presents what Frontend gives it; it names nothing from Pixely.Input. + private static IReadOnlyList OutputReadsNoInput(PeachArchitectureOptions options) + { + return options.OutputAssemblies + .SelectMany(TypeGraph.DeclaredTypes) + .SelectMany(type => TypeGraph.SignatureTypes(type).Where(named => named.Namespace?.StartsWith("Pixely.Input", StringComparison.Ordinal) == true) + .Select(named => $"{type.FullName} -> {named.FullName}")) + .Distinct() + .Order(StringComparer.Ordinal) + .ToArray(); + } + + // 40. View models direct; output owns none. + private static IReadOnlyList OutputOwnsNoViewModels(PeachArchitectureOptions options) + { + return options.OutputAssemblies.SelectMany(TypeGraph.DeclaredTypes).Where(type => typeof(IUiViewModel).IsAssignableFrom(type)).Select(type => type.FullName!).ToArray(); + } + + // 41. What output shows: its registrar, constants, item and report shapes, a camera, an items collection. + private static IReadOnlyList OutputPublicSurfaceIsRegistrarItemsAndCamera(PeachArchitectureOptions options) + { + return options.OutputAssemblies + .SelectMany(assembly => TypeGraph.DeclaredTypes(assembly).Where(type => TypeGraph.IsPublicSurface(type) && !IsAllowed(type))) + .Select(type => type.FullName!) + .ToArray(); + + static bool IsAllowed(Type type) + { + return type.IsEnum || type.IsValueType || TypeGraph.IsRecord(type) || TypeGraph.IsStatic(type) + || type.Name.EndsWith("Camera", StringComparison.Ordinal) || type.Name.EndsWith("Items", StringComparison.Ordinal); + } + } + + // 41a. Audio's item and report records name Vocabulary types only. + private static IReadOnlyList AudioRecordsNameOnlyVocabulary(PeachArchitectureOptions options) + { + if (options.Audio == null) + { + return []; + } + + return TypeGraph.DeclaredTypes(options.Audio) + .Where(type => TypeGraph.IsPublicSurface(type) && (TypeGraph.IsRecord(type) || type.IsValueType && !type.IsEnum)) + .SelectMany(type => TypeGraph.PublicSignatureTypes(type) + .Where(named => named.Assembly == options.Game && named.Namespace != options.VocabularyNamespace) + .Select(named => $"{type.FullName} -> {named.FullName}")) + .Distinct() + .ToArray(); + } + + // 43. An actor owns no state that outlives a call: every field is readonly, and a collection it holds is a read-only one. + private static IReadOnlyList ActorsOwnNoState(PeachArchitectureOptions options) + { + return options.ActorAssemblies + .SelectMany(TypeGraph.DeclaredTypes) + .SelectMany(TypeGraph.DeclaredFields) + .Where(field => !field.IsStatic && (!field.IsInitOnly || TypeGraph.IsEnumerable(field.FieldType) && !IsReadOnlyCollection(field.FieldType))) + .Select(TypeGraph.Describe) + .ToArray(); + } +} diff --git a/src/Pixely.Fitness/PeachArchitecture.Game.cs b/src/Pixely.Fitness/PeachArchitecture.Game.cs new file mode 100644 index 00000000..bde7a224 --- /dev/null +++ b/src/Pixely.Fitness/PeachArchitecture.Game.cs @@ -0,0 +1,446 @@ +using System.Reflection; +using Pixely; +using Pixely.Observations; + +namespace Pixely.Fitness; + +public static partial class PeachArchitecture +{ + private static readonly Type[] PrimitiveIdTypes = [typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(short), typeof(ushort), typeof(string), typeof(Guid)]; + private static readonly Type[] ReadOnlyCollectionDefinitions = [typeof(IReadOnlyList<>), typeof(IReadOnlyDictionary<,>), typeof(ReadOnlySpan<>)]; + + // 10. Game never pushes: no events, no delegate fields, so a reader can only poll State or drain the log. + private static IReadOnlyList GameNeverPushes(PeachArchitectureOptions options) + { + List violations = new List(); + foreach (Type type in TypeGraph.DeclaredTypes(options.Game)) + { + violations.AddRange(type.GetEvents(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) + .Select(member => $"event {TypeGraph.Describe(member)}")); + violations.AddRange(TypeGraph.DeclaredFields(type).Where(field => typeof(Delegate).IsAssignableFrom(field.FieldType)).Select(field => $"delegate field {TypeGraph.Describe(field)}")); + } + + return violations; + } + + // 11. What Game shows is usable: no public member names an internal type. + private static IReadOnlyList PublicGameMembersExposeOnlyPublicTypes(PeachArchitectureOptions options) + { + return TypeGraph.DeclaredTypes(options.Game) + .Where(TypeGraph.IsPublicSurface) + .SelectMany(type => TypeGraph.PublicSignatureTypes(type).Where(named => !named.IsVisible && !named.IsGenericParameter).Select(named => $"{type.FullName}: {named.FullName}")) + .Distinct() + .Order(StringComparer.Ordinal) + .ToArray(); + } + + // 11a. A public type in Mechanics that no public Mechanic exposes, directly or through an outcome's members, MUST be internal. + private static IReadOnlyList UnexposedMechanicsTypesAreInternal(PeachArchitectureOptions options) + { + Type[] publicMechanics = PublicMechanics(options).ToArray(); + HashSet exposed = new HashSet(); + Queue pending = new Queue(publicMechanics.SelectMany(TypeGraph.PublicSignatureTypes)); + while (pending.TryDequeue(out Type? type)) + { + if (type.Namespace == options.MechanicsNamespace && exposed.Add(type)) + { + foreach (Type member in TypeGraph.PublicSignatureTypes(type).Concat(type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Select(property => property.PropertyType).SelectMany(TypeGraph.Expand))) + { + pending.Enqueue(member); + } + } + } + + return TypeGraph.TypesIn(options.Game, options.MechanicsNamespace) + .Where(type => TypeGraph.IsPublicSurface(type) && !publicMechanics.Contains(type) && !exposed.Contains(type)) + .Select(type => type.FullName!) + .ToArray(); + } + + // 12. Public types live in Vocabulary, State, Mechanics, Observations, the game's extras, and the root registrars. + private static IReadOnlyList GamePublicTypesLiveInTheirNamespaces(PeachArchitectureOptions options) + { + HashSet allowed = new[] { options.VocabularyNamespace, options.StateNamespace, options.MechanicsNamespace, options.ObservationsNamespace } + .Concat(options.ExtraGameNamespaceNames).ToHashSet(StringComparer.Ordinal); + return TypeGraph.DeclaredTypes(options.Game) + .Where(type => type.IsPublic && !(allowed.Contains(type.Namespace ?? string.Empty) || type.Namespace == options.GameNamespace && TypeGraph.IsStatic(type))) + .Select(type => type.FullName!) + .ToArray(); + } + + // 13. No command records, no dispatcher. + private static IReadOnlyList NoCommandsHandlersOrDispatchers(PeachArchitectureOptions options) + { + return TypeGraph.DeclaredTypes(options.Game) + .Where(type => type.Name.EndsWith("Command", StringComparison.Ordinal) || type.Name.EndsWith("Handler", StringComparison.Ordinal) || type.Name.EndsWith("Dispatcher", StringComparison.Ordinal)) + .Select(type => type.FullName!) + .ToArray(); + } + + // 14. Vocabulary is primitives: enums, value types, and static holders of them. + private static IReadOnlyList VocabularyHoldsOnlyPrimitives(PeachArchitectureOptions options) + { + return TypeGraph.TypesIn(options.Game, options.VocabularyNamespace) + .Where(type => !type.IsEnum && !type.IsValueType && !TypeGraph.IsStatic(type)) + .Select(type => type.FullName!) + .ToArray(); + } + + // 15. An id is its own type. + private static IReadOnlyList IdsAreTheirOwnTypes(PeachArchitectureOptions options) + { + List violations = new List(); + foreach (Type type in TypeGraph.DeclaredTypes(options.Game).Where(TypeGraph.IsPublicSurface)) + { + violations.AddRange(type.GetProperties(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly) + .Where(property => IsPrimitiveId(property.Name, property.PropertyType)).Select(TypeGraph.Describe)); + violations.AddRange(TypeGraph.DeclaredFields(type).Where(field => field.IsPublic && IsPrimitiveId(field.Name, field.FieldType)).Select(TypeGraph.Describe)); + violations.AddRange(TypeGraph.DeclaredMethods(type).Where(method => method.IsPublic).Concat(TypeGraph.Constructors(type).Where(constructor => constructor.IsPublic)) + .SelectMany(method => method.GetParameters().Where(parameter => IsPrimitiveId(parameter.Name!, parameter.ParameterType)).Select(parameter => $"{TypeGraph.Describe(method)}({parameter.Name})"))); + } + + return violations; + + static bool IsPrimitiveId(string name, Type type) + { + return name.EndsWith("Id", StringComparison.Ordinal) && PrimitiveIdTypes.Contains(Nullable.GetUnderlyingType(type) ?? type); + } + } + + // 16. One state root per stage: the stage registers the root and no other State type. + private static IReadOnlyList StageRegistersExactlyTheStateRoot(PeachArchitectureOptions options) + { + if (options.StateRoot == null) + { + return []; + } + + Type[] registered = StageRegistrations(options) + .SelectMany(registration => new[] { registration.ServiceType, registration.ConcreteType }) + .OfType() + .Where(type => type.Namespace == options.StateNamespace) + .Distinct() + .ToArray(); + List violations = registered.Where(type => type != options.StateRoot).Select(type => $"registered: {type.FullName}").ToList(); + if (!registered.Contains(options.StateRoot)) + { + violations.Add($"missing: {options.StateRoot.FullName}"); + } + + return violations; + } + + // 17. A public property MUST NOT have a setter. An init accessor is construction by another syntax, the shape of a payload record a + // caller builds, so it is not a setter here. + private static IReadOnlyList StateHasNoPublicSetters(PeachArchitectureOptions options) + { + return PublicStateProperties(options) + .Where(property => property.SetMethod?.IsPublic == true && !IsInitOnly(property)) + .Select(TypeGraph.Describe) + .ToArray(); + } + + // 18. A public collection is one of the three read-only shapes. + private static IReadOnlyList StateCollectionsAreReadOnly(PeachArchitectureOptions options) + { + return PublicStateProperties(options) + .Where(property => TypeGraph.IsEnumerable(property.PropertyType) && !IsReadOnlyCollection(property.PropertyType)) + .Select(TypeGraph.Describe) + .ToArray(); + } + + // 19. State holds no logic, so nothing in it, attributes included, names Mechanics, Systems or Observations. + private static IReadOnlyList StateNamesNoRules(PeachArchitectureOptions options) + { + string[] ruleNamespaces = [options.MechanicsNamespace, options.SystemsNamespace, options.ObservationsNamespace]; + return TypeGraph.TypesIn(options.Game, options.StateNamespace) + .SelectMany(type => TypeGraph.SignatureTypes(type).Where(named => ruleNamespaces.Contains(named.Namespace)).Select(named => $"{type.FullName} -> {named.FullName}")) + .Distinct() + .Order(StringComparer.Ordinal) + .ToArray(); + } + + // 20. A read returns the live thing, never a transport record. + private static IReadOnlyList StateReadsReturnNoTransportRecords(PeachArchitectureOptions options) + { + return TypeGraph.TypesIn(options.Game, options.StateNamespace) + .Where(TypeGraph.IsPublicSurface) + .SelectMany(type => TypeGraph.DeclaredMethods(type).Where(method => method.IsPublic && method.ReturnType != typeof(void))) + .Where(method => !IsStateReadType(options, method.ReturnType)) + .Select(TypeGraph.Describe) + .ToArray(); + } + + // 21. Everything reachable from the state root is State or Vocabulary, or a primitive, so the rules above see all of it. + private static IReadOnlyList StateGraphStaysInState(PeachArchitectureOptions options) + { + if (options.StateRoot == null) + { + return []; + } + + List violations = new List(); + HashSet visited = new HashSet(); + Visit(options.StateRoot); + return violations; + + void Visit(Type type) + { + Type? underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + { + Visit(underlying); + return; + } + + if (type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(TimeSpan) || type == typeof(Guid)) + { + return; + } + + if (typeof(Delegate).IsAssignableFrom(type) || type.IsPointer || type.IsByRef) + { + violations.Add(type.FullName!); + return; + } + + if (type.IsArray) + { + Visit(type.GetElementType()!); + return; + } + + // A framework container is storage, not state; what it holds is. + if (type.IsGenericType && type.Namespace?.StartsWith("System", StringComparison.Ordinal) == true) + { + foreach (Type argument in type.GetGenericArguments()) + { + Visit(argument); + } + + return; + } + + if (!visited.Add(type)) + { + return; + } + + if (type.Assembly != options.Game || type.Namespace != options.StateNamespace && type.Namespace != options.VocabularyNamespace) + { + violations.Add(type.FullName!); + return; + } + + for (Type? current = type; current != null && current != typeof(object); current = current.BaseType) + { + foreach (FieldInfo field in current.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + Visit(field.FieldType); + } + } + + if (type.IsAbstract || type.IsInterface) + { + foreach (Type implementation in TypeGraph.DeclaredTypes(options.Game).Where(candidate => candidate != type && !candidate.IsAbstract && type.IsAssignableFrom(candidate))) + { + Visit(implementation); + } + } + } + } + + // 22. Only Game creates State: a class it writes internally has no public constructor. Payload records are the caller's to build. + private static IReadOnlyList StateIsCreatedOnlyByGame(PeachArchitectureOptions options) + { + return TypeGraph.TypesIn(options.Game, options.StateNamespace) + .Where(type => TypeGraph.IsPublicSurface(type) && type.IsClass && !TypeGraph.IsStatic(type)) + .Where(type => HasInternalSetter(type) && TypeGraph.Constructors(type).Any(constructor => constructor.IsPublic)) + .Select(type => type.FullName!) + .ToArray(); + } + + // 23. Public classes in Mechanics are Mechanics; every other public type there is an outcome shape, an enum or a readonly record struct. + private static IReadOnlyList MechanicsPublicSurfaceIsMechanicsAndOutcomes(PeachArchitectureOptions options) + { + return TypeGraph.TypesIn(options.Game, options.MechanicsNamespace) + .Where(TypeGraph.IsPublicSurface) + .Where(type => type.IsClass ? !type.Name.EndsWith("Mechanic", StringComparison.Ordinal) : !type.IsEnum && !TypeGraph.IsReadOnlyRecordStruct(type)) + .Select(type => type.FullName!) + .ToArray(); + } + + // 24. The registrar is the only way in. + private static IReadOnlyList MechanicsHaveNoPublicConstructors(PeachArchitectureOptions options) + { + if (!options.MechanicsHaveNoPublicConstructors) + { + return []; + } + + return PublicMechanics(options).Where(type => TypeGraph.Constructors(type).Any(constructor => constructor.IsPublic)).Select(type => type.FullName!).ToArray(); + } + + // 25. The state root arrives through the constructor, never as a parameter, and no static rule takes ambient state. Ambient is what the + // root holds directly; an element handed to a helper is the unit of work, the same shape as Step(activity). + private static IReadOnlyList MechanicsTakeTheStateRootThroughConstructors(PeachArchitectureOptions options) + { + if (!options.MechanicsTakeStateRootThroughConstructor || options.StateRoot == null) + { + return []; + } + + // Live state: what the root holds and Game writes internally. A payload record with public init is a value a caller builds. + HashSet ambient = options.StateRoot.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Select(property => property.PropertyType) + .Where(type => type.Namespace == options.StateNamespace && HasInternalSetter(type)) + .Append(options.StateRoot) + .ToHashSet(); + Type[] mechanics = TypeGraph.TypesIn(options.Game, options.MechanicsNamespace).Where(type => type.IsClass && type.Name.EndsWith("Mechanic", StringComparison.Ordinal)).ToArray(); + List violations = mechanics + .Where(type => !TypeGraph.Constructors(type).Any(constructor => constructor.GetParameters().Any(parameter => parameter.ParameterType == options.StateRoot))) + .Select(type => $"no root in constructor: {type.FullName}") + .ToList(); + violations.AddRange(mechanics.SelectMany(TypeGraph.DeclaredMethods) + .Where(method => method.GetParameters().Any(parameter => parameter.ParameterType == options.StateRoot)) + .Select(method => $"root as parameter: {TypeGraph.Describe(method)}")); + violations.AddRange(TypeGraph.DeclaredTypes(options.Game) + .Where(type => type.Namespace == options.MechanicsNamespace || type.Namespace == options.SystemsNamespace) + .SelectMany(TypeGraph.DeclaredMethods) + .Where(method => method.IsStatic && !method.IsPrivate && method.GetParameters().Any(parameter => ambient.Contains(parameter.ParameterType))) + .Select(method => $"ambient state in static: {TypeGraph.Describe(method)}")); + return violations; + } + + // 26. An outcome is semantic, never a user facing message. + private static IReadOnlyList MechanicsReturnNoStrings(PeachArchitectureOptions options) + { + return PublicMechanics(options) + .SelectMany(TypeGraph.DeclaredMethods) + .Where(method => method.IsPublic && method.ReturnType == typeof(string)) + .Select(TypeGraph.Describe) + .ToArray(); + } + + // 27. Output and Executable name no Mechanic, so they cannot invoke one. + private static IReadOnlyList OnlyDirectorsAndActorsNameMechanics(PeachArchitectureOptions options) + { + HashSet mechanics = PublicMechanics(options).ToHashSet(); + return options.OutputAssemblies.Append(options.Executable) + .SelectMany(TypeGraph.DeclaredTypes) + .SelectMany(type => TypeGraph.SignatureTypes(type).Where(mechanics.Contains).Select(mechanic => $"{type.FullName} -> {mechanic.FullName}")) + .Distinct() + .Order(StringComparer.Ordinal) + .ToArray(); + } + + // 28 and 29. Systems are internal updatables. + private static IReadOnlyList SystemsAreInternalUpdatables(PeachArchitectureOptions options) + { + return TypeGraph.TypesIn(options.Game, options.SystemsNamespace) + .Where(type => TypeGraph.IsPublicSurface(type) || type.IsClass && !type.IsAbstract && !TypeGraph.IsStatic(type) && !typeof(IUpdatable).IsAssignableFrom(type)) + .Select(type => type.FullName!) + .ToArray(); + } + + // 32. An entry is a past tense record of ids and value types, named with the Entry suffix. + private static IReadOnlyList EntriesAreRecordsOfIdsAndValues(PeachArchitectureOptions options) + { + List violations = new List(); + foreach (Type type in TypeGraph.TypesIn(options.Game, options.ObservationsNamespace).Where(type => !type.IsEnum && !type.IsInterface && !TypeGraph.IsStatic(type))) + { + if (!type.Name.EndsWith("Entry", StringComparison.Ordinal) || !(type.IsValueType || TypeGraph.IsRecord(type))) + { + violations.Add($"not an entry: {type.FullName}"); + } + + violations.AddRange(TypeGraph.DeclaredFields(type).Where(field => !field.IsStatic) + .Select(field => Nullable.GetUnderlyingType(field.FieldType) ?? field.FieldType) + .Where(fieldType => !fieldType.IsValueType) + .Select(fieldType => $"{type.FullName} carries {fieldType.FullName}")); + } + + return violations; + } + + // 33. One log per stage, and only when there are entries to carry. + private static IReadOnlyList StageRegistersOneLogWhenEntriesExist(PeachArchitectureOptions options) + { + Type[] logs = StageRegistrations(options) + .Select(registration => registration.ServiceType) + .Where(type => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ObservationLog<>)) + .ToArray(); + bool hasEntries = TypeGraph.TypesIn(options.Game, options.ObservationsNamespace).Any(type => type.Name.EndsWith("Entry", StringComparison.Ordinal)); + List violations = new List(); + if (logs.Length != (hasEntries ? 1 : 0)) + { + violations.Add($"logs registered: {logs.Length}, entry types: {(hasEntries ? "present" : "none")}"); + } + + violations.AddRange(logs.Select(log => log.GetGenericArguments()[0]).Where(entry => entry.Namespace != options.ObservationsNamespace).Select(entry => $"entry outside Observations: {entry.FullName}")); + return violations; + } + + // 34. Game writes the log and never reads it; Frontend and the actors read, nobody else, and a holder keeps one reader. + private static IReadOnlyList OnlyDirectorsAndActorsHoldReaders(PeachArchitectureOptions options) + { + HashSet readers = options.ActorAssemblies.Append(options.Frontend).ToHashSet(); + IEnumerable outsiders = options.ProductionAssemblies + .Where(assembly => !readers.Contains(assembly)) + .SelectMany(TypeGraph.DeclaredTypes) + .SelectMany(type => TypeGraph.DeclaredFields(type).Where(field => IsReader(field.FieldType)).Select(TypeGraph.Describe)); + IEnumerable hoarders = readers + .SelectMany(TypeGraph.DeclaredTypes) + .Where(type => TypeGraph.DeclaredFields(type).Count(field => IsReader(field.FieldType)) > 1) + .Select(type => $"more than one reader: {type.FullName}"); + return outsiders.Concat(hoarders).ToArray(); + + static bool IsReader(Type type) + { + return type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(ObservationReader<>) || type.GetGenericTypeDefinition() == typeof(ParticipantObservationReader<,>)); + } + } + + private static IEnumerable PublicMechanics(PeachArchitectureOptions options) + { + return TypeGraph.TypesIn(options.Game, options.MechanicsNamespace).Where(type => type.IsPublic && type.IsClass && type.Name.EndsWith("Mechanic", StringComparison.Ordinal)); + } + + private static IEnumerable PublicStateProperties(PeachArchitectureOptions options) + { + return TypeGraph.TypesIn(options.Game, options.StateNamespace) + .Where(TypeGraph.IsPublicSurface) + .SelectMany(type => type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)); + } + + private static bool IsReadOnlyCollection(Type type) + { + return type.IsGenericType && ReadOnlyCollectionDefinitions.Contains(type.GetGenericTypeDefinition()); + } + + private static bool HasInternalSetter(Type type) + { + return type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly).Any(property => property.SetMethod is { IsPublic: false }); + } + + private static bool IsInitOnly(PropertyInfo property) + { + return property.SetMethod!.ReturnParameter.GetRequiredCustomModifiers().Any(modifier => modifier.FullName == "System.Runtime.CompilerServices.IsExternalInit"); + } + + private static bool IsStateReadType(PeachArchitectureOptions options, Type type) + { + Type inner = Nullable.GetUnderlyingType(type) ?? type; + if (inner.IsPrimitive || inner.IsEnum || inner == typeof(string) || inner == typeof(decimal) || inner == typeof(DateTime) || inner == typeof(TimeSpan) || inner == typeof(Guid)) + { + return true; + } + + if (IsReadOnlyCollection(inner)) + { + return inner.GetGenericArguments().All(argument => IsStateReadType(options, argument)); + } + + return inner.Assembly == options.Game && (inner.Namespace == options.StateNamespace || inner.Namespace == options.VocabularyNamespace); + } +} diff --git a/src/Pixely.Fitness/PeachArchitecture.Structure.cs b/src/Pixely.Fitness/PeachArchitecture.Structure.cs new file mode 100644 index 00000000..801a6471 --- /dev/null +++ b/src/Pixely.Fitness/PeachArchitecture.Structure.cs @@ -0,0 +1,234 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Pixely; +using Pixely.DependencyInjection; +using Pixely.RenderOrchestration; +using Pixely.Ui; + +namespace Pixely.Fitness; + +/// +/// The fitness functions of the Peach architecture document, one per rule, each returning the members +/// that break it. Numbers refer to the test plan. +/// +public static partial class PeachArchitecture +{ + private static readonly string[] FrameworkAssemblyPrefixes = ["Pixely", "System", "Microsoft", "netstandard", "mscorlib"]; + + public static FitnessReport Evaluate(PeachArchitectureOptions options) + { + FitnessResult resolution = new FitnessResult("00 GameResolvesFromPrefix", options.Resolved.Violations); + if (!options.Resolved.IsComplete) + { + return new FitnessReport([resolution]); + } + + return new FitnessReport( + [ + resolution, + Run("01 ProjectReferencesMatchTheGraph", static options => options.Resolved.RepositoryRoot == null ? [] : ProjectReferences.Violations(options.Resolved.RepositoryRoot, options)), + Run("02 AssemblyReferencesMatchTheGraph", AssemblyReferencesMatchTheGraph), + Run("03 NoInternalAccessBetweenProductionAssemblies", NoInternalAccessBetweenProductionAssemblies), + Run("05 RegistrarsAreTheOnlyRegistration", RegistrarsAreTheOnlyRegistration), + Run("06 ExecutableOnlyComposes", ExecutableOnlyComposes), + Run("07 RootDoesNotReachTheStage", RootDoesNotReachTheStage), + Run("08 TypesLiveInDocumentedNamespaces", TypesLiveInDocumentedNamespaces), + Run("09 NothingReturnsATask", NothingReturnsATask), + Run("10 GameNeverPushes", GameNeverPushes), + Run("11 PublicGameMembersExposeOnlyPublicTypes", PublicGameMembersExposeOnlyPublicTypes), + Run("11a UnexposedMechanicsTypesAreInternal", UnexposedMechanicsTypesAreInternal), + Run("12 GamePublicTypesLiveInTheirNamespaces", GamePublicTypesLiveInTheirNamespaces), + Run("13 NoCommandsHandlersOrDispatchers", NoCommandsHandlersOrDispatchers), + Run("14 VocabularyHoldsOnlyPrimitives", VocabularyHoldsOnlyPrimitives), + Run("15 IdsAreTheirOwnTypes", IdsAreTheirOwnTypes), + Run("16 StageRegistersExactlyTheStateRoot", StageRegistersExactlyTheStateRoot), + Run("17 StateHasNoPublicSetters", StateHasNoPublicSetters), + Run("18 StateCollectionsAreReadOnly", StateCollectionsAreReadOnly), + Run("19 StateNamesNoRules", StateNamesNoRules), + Run("20 StateReadsReturnNoTransportRecords", StateReadsReturnNoTransportRecords), + Run("21 StateGraphStaysInState", StateGraphStaysInState), + Run("22 StateIsCreatedOnlyByGame", StateIsCreatedOnlyByGame), + Run("23 MechanicsPublicSurfaceIsMechanicsAndOutcomes", MechanicsPublicSurfaceIsMechanicsAndOutcomes), + Run("24 MechanicsHaveNoPublicConstructors", MechanicsHaveNoPublicConstructors), + Run("25 MechanicsTakeTheStateRootThroughConstructors", MechanicsTakeTheStateRootThroughConstructors), + Run("26 MechanicsReturnNoStrings", MechanicsReturnNoStrings), + Run("27 OnlyDirectorsAndActorsNameMechanics", OnlyDirectorsAndActorsNameMechanics), + Run("29 SystemsAreInternalUpdatables", SystemsAreInternalUpdatables), + Run("32 EntriesAreRecordsOfIdsAndValues", EntriesAreRecordsOfIdsAndValues), + Run("33 StageRegistersOneLogWhenEntriesExist", StageRegistersOneLogWhenEntriesExist), + Run("34 OnlyDirectorsAndActorsHoldReaders", OnlyDirectorsAndActorsHoldReaders), + Run("35 FormsOwnEveryUiView", FormsOwnEveryUiView), + Run("36 FormsNameNoStateOrMechanics", FormsNameNoStateOrMechanics), + Run("38 DirectorAndActorPublicSurfaceIsTheRegistrar", DirectorAndActorPublicSurfaceIsTheRegistrar), + Run("39 OutputReadsNoInput", OutputReadsNoInput), + Run("40 OutputOwnsNoViewModels", OutputOwnsNoViewModels), + Run("41 OutputPublicSurfaceIsRegistrarItemsAndCamera", OutputPublicSurfaceIsRegistrarItemsAndCamera), + Run("41a AudioRecordsNameOnlyVocabulary", AudioRecordsNameOnlyVocabulary), + Run("43 ActorsOwnNoState", ActorsOwnNoState) + ]); + + FitnessResult Run(string name, Func> function) + { + return new FitnessResult(name, function(options)); + } + } + + // 2. The arrow graph, read from the compiled assemblies rather than the project files. Executable composes, so it may pull in any + // package; of the game's own assemblies it may reference only the production ones. + private static IReadOnlyList AssemblyReferencesMatchTheGraph(PeachArchitectureOptions options) + { + List violations = new List(); + HashSet productionNames = options.ProductionAssemblies.Select(assembly => assembly.GetName().Name!).ToHashSet(StringComparer.Ordinal); + violations.AddRange(options.Executable.GetReferencedAssemblies() + .Select(reference => reference.Name!) + .Where(name => name.StartsWith(options.GamePrefix + ".", StringComparison.Ordinal) && !productionNames.Contains(name)) + .Select(name => $"{options.Executable.GetName().Name} -> {name}")); + Check(options.Game); + foreach (Assembly output in options.OutputAssemblies.Concat(options.ActorAssemblies)) + { + Check(output, options.Game); + } + + Check(options.Frontend, new[] { options.Game, options.Rendering, options.Audio }.Where(assembly => assembly != null).ToArray()!); + return violations; + + void Check(Assembly assembly, params Assembly[] allowed) + { + HashSet allowedNames = allowed.Select(reference => reference.GetName().Name!).ToHashSet(StringComparer.Ordinal); + violations.AddRange(assembly.GetReferencedAssemblies() + .Select(reference => reference.Name!) + .Where(name => !allowedNames.Contains(name) && !FrameworkAssemblyPrefixes.Any(prefix => name == prefix || name.StartsWith(prefix + ".", StringComparison.Ordinal))) + .Select(name => $"{assembly.GetName().Name} -> {name}")); + } + } + + // 3. Mutation is internal, so an assembly that sees another's internals could write State. + private static IReadOnlyList NoInternalAccessBetweenProductionAssemblies(PeachArchitectureOptions options) + { + HashSet productionNames = options.ProductionAssemblies.Select(assembly => assembly.GetName().Name!).ToHashSet(StringComparer.Ordinal); + return options.ProductionAssemblies + .SelectMany(assembly => assembly.GetCustomAttributes().Select(attribute => (Owner: assembly, Friend: attribute.AssemblyName))) + .Where(grant => productionNames.Contains(grant.Friend) || options.GameGrantsNoInternalAccess && grant.Owner == options.Game) + .Select(grant => $"{grant.Owner.GetName().Name} -> {grant.Friend}") + .Order(StringComparer.Ordinal) + .ToArray(); + } + + // 5. The public static classes in a project's root namespace are its registrars: Add* extension methods on PixelyAppBuilder for root, + // on ServiceCollection for a stage. Nothing else registers. + private static IReadOnlyList RegistrarsAreTheOnlyRegistration(PeachArchitectureOptions options) + { + List violations = new List(); + foreach (Assembly assembly in options.ProductionAssemblies.Where(assembly => assembly != options.Executable)) + { + string rootNamespace = options.RootNamespaceOf(assembly); + foreach (Type type in TypeGraph.DeclaredTypes(assembly).Where(type => type.IsPublic && TypeGraph.IsStatic(type) && type.Namespace == rootNamespace)) + { + violations.AddRange(TypeGraph.DeclaredMethods(type).Where(method => method.IsPublic && !PeachGame.IsRegistrarMethod(method)).Select(TypeGraph.Describe)); + } + + violations.AddRange(TypeGraph.DeclaredTypes(assembly) + .Where(type => type.Namespace != rootNamespace) + .SelectMany(TypeGraph.DeclaredMethods) + .Where(method => method.IsPublic && method.GetParameters().Length > 0 && typeof(ServiceCollection).IsAssignableFrom(method.GetParameters()[0].ParameterType)) + .Select(TypeGraph.Describe)); + } + + return violations; + } + + // 6. Executable composes; it takes no part in the frame or the user interface itself. + private static IReadOnlyList ExecutableOnlyComposes(PeachArchitectureOptions options) + { + return TypeGraph.DeclaredTypes(options.Executable) + .Where(type => typeof(IUpdatable).IsAssignableFrom(type) || typeof(IUiView).IsAssignableFrom(type) || typeof(IUiViewModel).IsAssignableFrom(type) + || type.GetInterfaces().Any(implemented => implemented.IsGenericType && implemented.GetGenericTypeDefinition() == typeof(IRenderer<>))) + .Select(type => type.FullName!) + .ToArray(); + } + + // 7. Root MUST NOT hold a reference into a stage: nothing root registers keeps or is handed anything the stage registers. A root service + // that creates a stage type, e.g. persistence loading the state root, hands it over and keeps nothing. + private static IReadOnlyList RootDoesNotReachTheStage(PeachArchitectureOptions options) + { + HashSet stageTypes = StageRegistrations(options) + .SelectMany(registration => new[] { registration.ServiceType, registration.ConcreteType }) + .OfType() + .Where(type => !IsFrameworkAssembly(type.Assembly)) + .ToHashSet(); + return RootRegistrations(options) + .Select(registration => registration.ConcreteType) + .OfType() + .Where(type => !IsFrameworkAssembly(type.Assembly)) + .Distinct() + .SelectMany(type => HeldTypes(type).Where(stageTypes.Contains).Select(stageType => $"{type.FullName} -> {stageType.FullName}")) + .Distinct() + .Order(StringComparer.Ordinal) + .ToArray(); + + static IEnumerable HeldTypes(Type type) + { + return TypeGraph.DeclaredFields(type).Select(field => field.FieldType) + .Concat(TypeGraph.Constructors(type).SelectMany(constructor => constructor.GetParameters().Select(parameter => parameter.ParameterType))) + .SelectMany(TypeGraph.Expand); + } + } + + // 8. Every type sits in a namespace the document names, or one the game declared on top with a justification. A namespace is reported + // once, not once per type. + private static IReadOnlyList TypesLiveInDocumentedNamespaces(PeachArchitectureOptions options) + { + Dictionary> allowed = new Dictionary> + { + [options.Game] = new[] { options.GameNamespace, options.VocabularyNamespace, options.StateNamespace, options.MechanicsNamespace, options.SystemsNamespace, options.ObservationsNamespace } + .Concat(options.ExtraGameNamespaceNames).ToHashSet(StringComparer.Ordinal), + [options.Frontend] = [options.FrontendNamespace, options.FormsNamespace], + [options.Executable] = [options.ExecutableNamespace] + }; + foreach (Assembly assembly in options.OutputAssemblies.Concat(options.ActorAssemblies)) + { + allowed[assembly] = [options.RootNamespaceOf(assembly)]; + } + + List violations = allowed + .SelectMany(pair => TypeGraph.DeclaredTypes(pair.Key).Where(type => !type.IsNested && !pair.Value.Contains(type.Namespace ?? string.Empty))) + .Select(type => type.Namespace ?? "(global)") + .Distinct() + .Order(StringComparer.Ordinal) + .Select(ns => $"{ns}: not in the document; move its types or list the namespace in ExtraGameNamespaces with a strong justification") + .ToList(); + HashSet populated = TypeGraph.DeclaredTypes(options.Game).Select(type => type.Namespace ?? string.Empty).ToHashSet(StringComparer.Ordinal); + violations.AddRange(options.ExtraGameNamespaces.Where(extra => string.IsNullOrWhiteSpace(extra.Justification)).Select(extra => $"{extra.Namespace}: listed in ExtraGameNamespaces without a justification")); + violations.AddRange(options.ExtraGameNamespaces.Where(extra => !populated.Contains(extra.Namespace)).Select(extra => $"{extra.Namespace}: listed in ExtraGameNamespaces but holds no types")); + return violations; + } + + // 9. The frame is single threaded, so nothing hands work to another thread. + private static IReadOnlyList NothingReturnsATask(PeachArchitectureOptions options) + { + return options.ProductionAssemblies + .SelectMany(TypeGraph.DeclaredTypes) + .SelectMany(TypeGraph.DeclaredMethods) + .Where(method => TypeGraph.Expand(method.ReturnType).Any(type => type == typeof(Task) || type == typeof(ValueTask) + || type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(Task<>) || type.GetGenericTypeDefinition() == typeof(ValueTask<>)))) + .Select(TypeGraph.Describe) + .Order(StringComparer.Ordinal) + .ToArray(); + } + + private static bool IsFrameworkAssembly(Assembly assembly) + { + string name = assembly.GetName().Name!; + return FrameworkAssemblyPrefixes.Any(prefix => name == prefix || name.StartsWith(prefix + ".", StringComparison.Ordinal)); + } + + private static IReadOnlyList RootRegistrations(PeachArchitectureOptions options) + { + return options.Resolved.RootRegistrations; + } + + private static IReadOnlyList StageRegistrations(PeachArchitectureOptions options) + { + return options.Resolved.StageRegistrations; + } +} diff --git a/src/Pixely.Fitness/PeachArchitectureOptions.cs b/src/Pixely.Fitness/PeachArchitectureOptions.cs new file mode 100644 index 00000000..d8be1fe2 --- /dev/null +++ b/src/Pixely.Fitness/PeachArchitectureOptions.cs @@ -0,0 +1,92 @@ +using System.Reflection; + +namespace Pixely.Fitness; + +/// +/// A namespace a game adds to Foo.Game beyond the ones the document names, and why the document's +/// namespaces did not do. +/// +public sealed record ExtraNamespace(string Namespace, string Justification); + +/// +/// What a game tells the generic rules: its prefix, the namespaces it adds beyond the document's, and +/// which hardened SHOULDs it switches off. Everything else is derived from the prefix, see +/// . +/// +public sealed record PeachArchitectureOptions(string GamePrefix) +{ + private PeachGame? _game; + + public IReadOnlyList ExtraGameNamespaces { get; init; } = []; + + // Hardened SHOULDs a game may switch off. + public bool MechanicsHaveNoPublicConstructors { get; init; } = true; + public bool MechanicsTakeStateRootThroughConstructor { get; init; } = true; + public bool GameGrantsNoInternalAccess { get; init; } = true; + + internal string GameNamespace => $"{GamePrefix}.Game"; + internal string VocabularyNamespace => $"{GameNamespace}.Vocabulary"; + internal string StateNamespace => $"{GameNamespace}.State"; + internal string MechanicsNamespace => $"{GameNamespace}.Mechanics"; + internal string SystemsNamespace => $"{GameNamespace}.Systems"; + internal string ObservationsNamespace => $"{GameNamespace}.Observations"; + internal string FrontendNamespace => $"{GamePrefix}.Frontend"; + internal string FormsNamespace => $"{FrontendNamespace}.Forms"; + internal string RenderingNamespace => $"{FrontendNamespace}.Rendering"; + internal string AudioNamespace => $"{FrontendNamespace}.Audio"; + internal string AiNamespace => $"{GamePrefix}.Ai"; + internal string ScenarioNamespace => $"{GamePrefix}.Scenario"; + internal string ExecutableNamespace => $"{GamePrefix}.Executable"; + + internal IEnumerable ExtraGameNamespaceNames => ExtraGameNamespaces.Select(extra => extra.Namespace); + + internal PeachGame Resolved => _game ??= PeachGame.Resolve(this); + + internal Assembly Game => Resolved.Game!; + internal Assembly Frontend => Resolved.Frontend!; + internal Assembly? Rendering => Resolved.Rendering; + internal Assembly? Audio => Resolved.Audio; + internal Assembly? Ai => Resolved.Ai; + internal Assembly? Scenario => Resolved.Scenario; + internal Assembly Executable => Resolved.Executable!; + internal Type? StateRoot => Resolved.StateRoot; + + internal IEnumerable OutputAssemblies => new[] { Rendering, Audio }.Where(assembly => assembly != null)!; + internal IEnumerable ActorAssemblies => new[] { Ai, Scenario }.Where(assembly => assembly != null)!; + internal IEnumerable ProductionAssemblies => new[] { Game, Frontend, Rendering, Audio, Ai, Scenario, Executable }.Where(assembly => assembly != null)!; + + internal string RootNamespaceOf(Assembly assembly) + { + if (assembly == Game) + { + return GameNamespace; + } + + if (assembly == Frontend) + { + return FrontendNamespace; + } + + if (assembly == Rendering) + { + return RenderingNamespace; + } + + if (assembly == Audio) + { + return AudioNamespace; + } + + if (assembly == Ai) + { + return AiNamespace; + } + + if (assembly == Scenario) + { + return ScenarioNamespace; + } + + return ExecutableNamespace; + } +} diff --git a/src/Pixely.Fitness/PeachGame.cs b/src/Pixely.Fitness/PeachGame.cs new file mode 100644 index 00000000..372f33cd --- /dev/null +++ b/src/Pixely.Fitness/PeachGame.cs @@ -0,0 +1,171 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Pixely.App; +using Pixely.DependencyInjection; + +namespace Pixely.Fitness; + +/// +/// What the document lets the rules derive from the prefix alone: the assemblies by their fixed names, +/// the state root as the one State class nothing else in State holds, the two containers composed by +/// invoking every registrar with default arguments, and the repository root by the Game project file. +/// What could not be derived is a violation of rule 00, and a rule that needs the missing piece skips. +/// +internal sealed class PeachGame +{ + private PeachGame(PeachArchitectureOptions options) + { + List violations = new List(); + Game = Load(options.GameNamespace, required: true); + Frontend = Load(options.FrontendNamespace, required: true); + Rendering = Load(options.RenderingNamespace, required: false); + Audio = Load(options.AudioNamespace, required: false); + Ai = Load(options.AiNamespace, required: false); + Scenario = Load(options.ScenarioNamespace, required: false); + Executable = Load(options.ExecutableNamespace, required: false) ?? Load(options.GamePrefix, required: false); + if (Executable == null) + { + violations.Add($"assembly not found: {options.ExecutableNamespace} or {options.GamePrefix}"); + } + + RepositoryRoot = FindRepositoryRoot(options); + if (Game != null && Frontend != null && Executable != null) + { + StateRoot = FindStateRoot(options, violations); + RootRegistrations = Compose(options, typeof(PixelyAppBuilder), new PixelyAppBuilder(), violations); + StageRegistrations = Compose(options, typeof(ServiceCollection), new ServiceCollection(), violations); + if (RepositoryRoot != null) + { + violations.AddRange(ProjectInventory(options)); + } + } + + Violations = violations; + + Assembly? Load(string name, bool required) + { + try + { + return Assembly.Load(new AssemblyName(name)); + } + catch (FileNotFoundException) + { + if (required) + { + violations.Add($"assembly not found: {name}"); + } + + return null; + } + } + } + + internal Assembly? Game { get; } + internal Assembly? Frontend { get; } + internal Assembly? Rendering { get; } + internal Assembly? Audio { get; } + internal Assembly? Ai { get; } + internal Assembly? Scenario { get; } + internal Assembly? Executable { get; } + internal Type? StateRoot { get; } + internal string? RepositoryRoot { get; } + internal IReadOnlyList RootRegistrations { get; } = []; + internal IReadOnlyList StageRegistrations { get; } = []; + internal IReadOnlyList Violations { get; } + + internal bool IsComplete => Game != null && Frontend != null && Executable != null; + + internal static PeachGame Resolve(PeachArchitectureOptions options) + { + return new PeachGame(options); + } + + // The public static classes in a project's root namespace are its registrars: Add* extension methods on PixelyAppBuilder for root, on ServiceCollection for a stage. + internal static bool IsRegistrarMethod(MethodInfo method) + { + ParameterInfo[] parameters = method.GetParameters(); + return method.GetCustomAttribute() != null + && method.Name.StartsWith("Add", StringComparison.Ordinal) + && parameters.Length > 0 + && (parameters[0].ParameterType == typeof(PixelyAppBuilder) || parameters[0].ParameterType == typeof(ServiceCollection)); + } + + internal IEnumerable Registrars(PeachArchitectureOptions options, Type container) + { + return new[] { Game, Frontend, Rendering, Audio, Ai, Scenario }.OfType() + .SelectMany(assembly => TypeGraph.DeclaredTypes(assembly).Where(type => type.IsPublic && TypeGraph.IsStatic(type) && type.Namespace == options.RootNamespaceOf(assembly))) + .SelectMany(TypeGraph.DeclaredMethods) + .Where(method => method.IsPublic && IsRegistrarMethod(method) && method.GetParameters()[0].ParameterType == container) + .OrderBy(method => method.DeclaringType!.FullName, StringComparer.Ordinal) + .ThenBy(method => method.Name, StringComparer.Ordinal); + } + + // A registrar registers the same types whatever its arguments, so defaults compose the real set of registrations. + private IReadOnlyList Compose(PeachArchitectureOptions options, Type container, ServiceCollection target, List violations) + { + foreach (MethodInfo registrar in Registrars(options, container)) + { + object?[] arguments = registrar.GetParameters().Select(parameter => parameter.Position == 0 ? target : DefaultArgument(parameter)).ToArray(); + try + { + registrar.Invoke(null, arguments); + } + catch (TargetInvocationException exception) + { + violations.Add($"registrar rejected default arguments: {TypeGraph.Describe(registrar)}: {exception.InnerException?.Message ?? exception.Message}"); + } + } + + return ServiceCollectionProbe.Registrations(target); + + static object? DefaultArgument(ParameterInfo parameter) + { + if (parameter.HasDefaultValue && parameter.DefaultValue != null) + { + return parameter.DefaultValue; + } + + return parameter.ParameterType.IsValueType && Nullable.GetUnderlyingType(parameter.ParameterType) == null ? Activator.CreateInstance(parameter.ParameterType) : null; + } + } + + // The state root is the one State class no other State type holds. + private Type? FindStateRoot(PeachArchitectureOptions options, List violations) + { + Type[] stateTypes = TypeGraph.TypesIn(Game!, options.StateNamespace).ToArray(); + HashSet held = stateTypes.SelectMany(TypeGraph.DeclaredFields).Select(field => field.FieldType).SelectMany(TypeGraph.Expand).ToHashSet(); + Type[] roots = stateTypes.Where(type => type.IsClass && !TypeGraph.IsStatic(type) && !type.IsNested && !held.Contains(type)).ToArray(); + if (roots.Length == 1) + { + return roots[0]; + } + + violations.Add($"state roots in {options.StateNamespace}: {(roots.Length == 0 ? "none" : string.Join(", ", roots.Select(type => type.FullName)))}, expected one"); + return null; + } + + // Projects sit at src/Foo.Part/Foo.Part.csproj. Every Foo.* directory there is a documented part whose assembly loaded, and vice versa. + private IEnumerable ProjectInventory(PeachArchitectureOptions options) + { + string source = Path.Combine(RepositoryRoot!, "src"); + HashSet expected = new[] { Game, Frontend, Rendering, Audio, Ai, Scenario }.OfType().Select(assembly => assembly.GetName().Name!) + .Append(options.ExecutableNamespace) + .ToHashSet(StringComparer.Ordinal); + string[] actual = Directory.EnumerateDirectories(source, $"{options.GamePrefix}.*").Select(directory => Path.GetFileName(directory)).Order(StringComparer.Ordinal).ToArray(); + return actual.Where(name => !expected.Contains(name)).Select(name => $"project not in the document or not loaded: src/{name}") + .Concat(expected.Where(name => !actual.Contains(name)).Order(StringComparer.Ordinal).Select(name => $"project missing: src/{name}")); + } + + private static string? FindRepositoryRoot(PeachArchitectureOptions options) + { + for (DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); directory != null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "src", options.GameNamespace, $"{options.GameNamespace}.csproj"))) + { + return directory.FullName; + } + } + + return null; + } +} diff --git a/src/Pixely.Fitness/Pixely.Fitness.csproj b/src/Pixely.Fitness/Pixely.Fitness.csproj new file mode 100644 index 00000000..6a61fb61 --- /dev/null +++ b/src/Pixely.Fitness/Pixely.Fitness.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + 14 + enable + enable + + + + + + + + + + + diff --git a/src/Pixely.Fitness/ProjectReferences.cs b/src/Pixely.Fitness/ProjectReferences.cs new file mode 100644 index 00000000..cc1f38e0 --- /dev/null +++ b/src/Pixely.Fitness/ProjectReferences.cs @@ -0,0 +1,49 @@ +using System.Xml.Linq; + +namespace Pixely.Fitness; + +/// +/// 1. The project files carry exactly the arrows of the document. Projects sit at src/Foo.Part/Foo.Part.csproj. +/// +internal static class ProjectReferences +{ + internal static IReadOnlyList Violations(string repositoryRoot, PeachArchitectureOptions options) + { + string game = "Game"; + string? rendering = options.Rendering == null ? null : "Frontend.Rendering"; + string? audio = options.Audio == null ? null : "Frontend.Audio"; + string? ai = options.Ai == null ? null : "Ai"; + string? scenario = options.Scenario == null ? null : "Scenario"; + string[] outputs = new[] { rendering, audio }.OfType().ToArray(); + string[] actors = new[] { ai, scenario }.OfType().ToArray(); + List violations = new List(); + Check(game); + foreach (string part in outputs.Concat(actors)) + { + Check(part, game); + } + + Check("Frontend", outputs.Prepend(game).ToArray()); + Check("Executable", outputs.Concat(actors).Concat([game, "Frontend"]).ToArray()); + return violations; + + void Check(string part, params string[] expectedParts) + { + string path = Path.Combine(repositoryRoot, "src", $"{options.GamePrefix}.{part}", $"{options.GamePrefix}.{part}.csproj"); + string directory = Path.GetDirectoryName(path)!; + string[] actual = XDocument.Load(path).Descendants("ProjectReference") + .Select(reference => reference.Attribute("Include")?.Value ?? throw new InvalidOperationException($"ProjectReference in {path} has no Include attribute.")) + .Select(reference => Path.GetRelativePath(repositoryRoot, Path.GetFullPath(Path.Combine(directory, reference.Replace('\\', Path.DirectorySeparatorChar))))) + .Order(StringComparer.Ordinal) + .ToArray(); + string[] expected = expectedParts + .Select(expectedPart => Path.Combine("src", $"{options.GamePrefix}.{expectedPart}", $"{options.GamePrefix}.{expectedPart}.csproj")) + .Order(StringComparer.Ordinal) + .ToArray(); + if (!actual.SequenceEqual(expected, StringComparer.Ordinal)) + { + violations.Add($"{options.GamePrefix}.{part}: references [{string.Join(", ", actual)}], expected [{string.Join(", ", expected)}]"); + } + } + } +} diff --git a/src/Pixely.Fitness/ServiceCollectionProbe.cs b/src/Pixely.Fitness/ServiceCollectionProbe.cs new file mode 100644 index 00000000..9d4f3915 --- /dev/null +++ b/src/Pixely.Fitness/ServiceCollectionProbe.cs @@ -0,0 +1,14 @@ +using Pixely.DependencyInjection; + +namespace Pixely.Fitness; + +// What a registrar put into a collection, read without building it. +internal readonly record struct ServiceRegistration(Type ServiceType, Type? ConcreteType); + +internal static class ServiceCollectionProbe +{ + internal static IReadOnlyList Registrations(ServiceCollection services) + { + return services.Descriptors.Select(descriptor => new ServiceRegistration(descriptor.ServiceType, descriptor.ConcreteType)).ToArray(); + } +} diff --git a/src/Pixely.Fitness/TypeGraph.cs b/src/Pixely.Fitness/TypeGraph.cs new file mode 100644 index 00000000..48e8fb59 --- /dev/null +++ b/src/Pixely.Fitness/TypeGraph.cs @@ -0,0 +1,145 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Pixely.Fitness; + +public static class TypeGraph +{ + private const BindingFlags Declared = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + + /// + /// The types a game wrote: everything the compiler and the source generators emitted is skipped. + /// + public static IEnumerable DeclaredTypes(Assembly assembly) + { + return assembly.GetTypes().Where(type => !IsCompilerGenerated(type)); + } + + public static IEnumerable TypesIn(Assembly assembly, string ns) + { + return DeclaredTypes(assembly).Where(type => type.Namespace == ns); + } + + public static bool IsPublicSurface(Type type) + { + return type.IsVisible; + } + + // A record class gets a $ method, a record struct does not; both get PrintMembers(StringBuilder). + public static bool IsRecord(Type type) + { + return type.GetMethod("PrintMembers", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, [typeof(System.Text.StringBuilder)]) != null; + } + + public static bool IsStatic(Type type) + { + return type.IsAbstract && type.IsSealed; + } + + public static bool IsCompilerGenerated(Type type) + { + for (Type? current = type; current != null; current = current.DeclaringType) + { + if (current.Name.StartsWith('<') || current.GetCustomAttribute() != null) + { + return true; + } + } + + return false; + } + + public static IEnumerable DeclaredMethods(Type type) + { + return type.GetMethods(Declared).Where(method => method.GetCustomAttribute() == null); + } + + public static IEnumerable DeclaredFields(Type type) + { + return type.GetFields(Declared); + } + + // Auto-property accessors are compiler generated, so properties are listed on their own rather than through their accessors. + public static IEnumerable DeclaredProperties(Type type) + { + return type.GetProperties(Declared); + } + + public static IEnumerable Constructors(Type type) + { + return type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + } + + /// + /// Every type a type names: its base, interfaces, field types, constructor and method signatures, with generic arguments and + /// element types expanded. + /// + public static IEnumerable SignatureTypes(Type type) + { + IEnumerable direct = new[] { type.BaseType }.OfType() + .Concat(type.GetInterfaces()) + .Concat(DeclaredFields(type).Select(field => field.FieldType)) + .Concat(DeclaredProperties(type).Select(property => property.PropertyType)) + .Concat(Constructors(type).SelectMany(constructor => constructor.GetParameters().Select(parameter => parameter.ParameterType))) + .Concat(DeclaredMethods(type).SelectMany(method => method.GetParameters().Select(parameter => parameter.ParameterType).Append(method.ReturnType))) + .Concat(type.GetCustomAttributesData().Select(attribute => attribute.AttributeType)); + return direct.SelectMany(Expand).Distinct(); + } + + public static IEnumerable PublicSignatureTypes(Type type) + { + IEnumerable direct = Constructors(type).Where(constructor => constructor.IsPublic) + .SelectMany(constructor => constructor.GetParameters().Select(parameter => parameter.ParameterType)) + .Concat(DeclaredMethods(type).Where(method => method.IsPublic) + .SelectMany(method => method.GetParameters().Select(parameter => parameter.ParameterType).Append(method.ReturnType))) + .Concat(DeclaredFields(type).Where(field => field.IsPublic).Select(field => field.FieldType)) + .Concat(DeclaredProperties(type).Where(property => property.GetMethod?.IsPublic == true || property.SetMethod?.IsPublic == true).Select(property => property.PropertyType)); + return direct.SelectMany(Expand).Distinct(); + } + + public static IEnumerable Expand(Type type) + { + yield return type; + if (type.IsGenericParameter) + { + foreach (Type constraint in type.GetGenericParameterConstraints().SelectMany(Expand)) + { + yield return constraint; + } + + yield break; + } + + if (type.HasElementType) + { + foreach (Type element in Expand(type.GetElementType()!)) + { + yield return element; + } + } + + if (type.IsGenericType) + { + foreach (Type argument in type.GetGenericArguments().SelectMany(Expand)) + { + yield return argument; + } + } + } + + public static bool IsEnumerable(Type type) + { + return type != typeof(string) && (type.IsArray || typeof(System.Collections.IEnumerable).IsAssignableFrom(type) + || type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>) || type.GetGenericTypeDefinition() == typeof(Span<>))); + } + + public static bool IsReadOnlyRecordStruct(Type type) + { + return type.IsValueType && IsRecord(type) && type.GetCustomAttribute() != null; + } + + public static string Describe(MemberInfo member) + { + return member is Type type ? type.FullName! : $"{member.DeclaringType!.FullName}.{member.Name}"; + } +} diff --git a/tests/Pixely.Architecture.Testing.Tests/BoundaryFixtures.cs b/tests/Pixely.Architecture.Testing.Tests/BoundaryFixtures.cs deleted file mode 100644 index 3485fbfa..00000000 --- a/tests/Pixely.Architecture.Testing.Tests/BoundaryFixtures.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Pixely.Architecture; -using Pixely.Architecture.Events; - -namespace Pixely.Architecture.Testing.Tests.BoundaryFixtures; - -// A small Model surface used to exercise ModelBoundary reachability in isolation. - -// Command, public — part of the surface. -public record SpawnCommand(SpawnRequest Request); - -// Reachable only through SpawnCommand's property — proves the transitive walk works. -public record SpawnRequest(int Count); - -// Internal handler — not part of the public surface, discovered via its interface. -internal sealed class SpawnCommandHandler : ICommandHandler -{ - internal SpawnCommandHandler() - { - } - - public CommandResult Handle(SpawnCommand command) => CommandResult.Success; -} - -// Query whose result type is public and reachable only via the (internal) handler's Handle return type. -public record CountQuery(int Group); - -internal sealed class CountQueryHandler : IQueryHandler -{ - internal CountQueryHandler() - { - } - - public CountBdo Handle(CountQuery query) => new(query.Group); -} - -public record CountBdo(int Total); - -// Event, public — surface by virtue of deriving from DomainMessage. -public sealed record ThingSpawnedEvent(int Id) : DomainMessage; - -// Public type referenced by nothing on the surface — a leak. -public sealed class LeakedInternals -{ - public int Secret { get; set; } -} diff --git a/tests/Pixely.Architecture.Testing.Tests/CqsConventionsTests.cs b/tests/Pixely.Architecture.Testing.Tests/CqsConventionsTests.cs deleted file mode 100644 index 139b02ff..00000000 --- a/tests/Pixely.Architecture.Testing.Tests/CqsConventionsTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -namespace Pixely.Architecture.Testing.Tests; - -[TestFixture] -public sealed class CqsConventionsTests -{ - [Test] - public void CleanModel_HasNoViolations() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(MoveCommandHandler), typeof(UnitsInRangeQueryHandler), typeof(DomainService)]); - - Assert.That(report.IsValid, Is.True, report.ToString()); - } - - [Test] - public void RequireBdoSuffix_WithBdoGraph_HasNoViolations() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(UnitsInRangeQuery), typeof(UnitsInRangeBdo), typeof(UnitBdo), typeof(UnitsInRangeBdoQueryHandler)], - options => options.RequireBdoSuffix()); - - Assert.That(report.IsValid, Is.True, report.ToString()); - } - - [Test] - public void RequireBdoSuffix_WithGenericBdo_HasNoViolations() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(UnitsInRangeQuery), typeof(PageBdo<>), typeof(UnitBdo), typeof(PagedUnitsQueryHandler)], - options => options.RequireBdoSuffix()); - - Assert.That(report.IsValid, Is.True, report.ToString()); - } - - [Test] - public void RequireBdoSuffix_WithScalarResult_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(UnitsInRangeQueryHandler)], - options => options.RequireBdoSuffix()); - - Assert.That(report.Violations, Has.Exactly(1).Items); - Assert.That(report.Violations[0], Does.Contain("System.Int32").And.Contain("ending with 'Bdo'")); - } - - [Test] - public void RequireBdoSuffix_WithOrphanBdo_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(OrphanBdo)], - options => options.RequireBdoSuffix()); - - Assert.That(report.Violations, Has.Exactly(1).Items); - Assert.That(report.Violations[0], Does.Contain(nameof(OrphanBdo)).And.Contain("Model boundary graph")); - } - - [Test] - public void RequireBdoSuffix_WithBdoInCommandGraph_HasNoViolations() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(BdoCommandHandler), typeof(BdoCommand), typeof(UnitBdo)], - options => options.RequireBdoSuffix()); - - Assert.That(report.IsValid, Is.True, report.ToString()); - } - - [Test] - public void RequireBdoSuffix_WithBdoSharedByQueryOutputAndCommandInput_HasNoViolations() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(GetSettingsQueryHandler), typeof(GetSettingsQuery), typeof(SaveSettingsCommandHandler), typeof(SaveSettingsCommand), typeof(SettingsBdo)], - options => options.RequireBdoSuffix()); - - Assert.That(report.IsValid, Is.True, report.ToString()); - } - - [Test] - public void RequireBdoSuffix_WithBdoInQueryInputGraph_HasNoViolations() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(BdoInputQueryHandler), typeof(BdoInputQuery), typeof(UnitsInRangeBdo), typeof(UnitBdo)], - options => options.RequireBdoSuffix()); - - Assert.That(report.IsValid, Is.True, report.ToString()); - } - - [Test] - public void RequireBdoSuffix_WithBdoInEventGraph_HasNoViolations() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(BdoEvent), typeof(UnitBdo)], - options => options.RequireBdoSuffix()); - - Assert.That(report.IsValid, Is.True, report.ToString()); - } - - [Test] - public void RequireBdoSuffix_WithMutableOrphanBdo_ReportsReadOnlyAndOriginViolations() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(MutableBdo)], - options => options.RequireBdoSuffix()); - - Assert.That(report.Violations, Has.Exactly(3).Items); - Assert.That(report.Violations, Has.Some.Contains(nameof(MutableBdo)).And.Contains("custom methods")); - Assert.That(report.Violations, Has.Some.Contains(nameof(MutableBdo)).And.Contains("read-only to consumers")); - Assert.That(report.Violations, Has.Some.Contains(nameof(MutableBdo)).And.Contains("Model boundary graph")); - } - - [Test] - public void NonRecordCommandWithBehaviour_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes([typeof(BadCommandHandler)]); - - Assert.That(report.Violations, Has.Some.Contains(nameof(BadCommand)).And.Contains("record")); - Assert.That(report.Violations, Has.Some.Contains(nameof(BadCommand)).And.Contains("custom methods")); - } - - [Test] - public void PublicHandlerWithPublicConstructor_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes([typeof(PublicCommandHandler)]); - - Assert.That(report.Violations, Has.Some.Contains(nameof(PublicCommandHandler)).And.Contains("must not be public")); - Assert.That(report.Violations, Has.Some.Contains(nameof(PublicCommandHandler)).And.Contains("no public constructors")); - } - - [Test] - public void CommandHandlerDependingOnAnotherHandler_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes([typeof(ChainingCommandHandler)]); - - Assert.That(report.Violations, Has.Exactly(1).Items); - Assert.That(report.Violations[0], Does.Contain(nameof(ChainingCommandHandler)) - .And.Contain(nameof(MoveCommandHandler)).And.Contain("depends on handler")); - } - - [Test] - public void HandlerNotEndingWithExpectedSuffix_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes([typeof(OddlyNamedExecutor)]); - - Assert.That(report.Violations, Has.Exactly(1).Items); - Assert.That(report.Violations[0], Does.Contain(nameof(OddlyNamedExecutor)).And.Contain("CommandHandler")); - } - - [Test] - public void Check_WithNoAssemblies_Throws() - { - Assert.That(() => CqsConventions.Check(), Throws.ArgumentException); - } - - [Test] - public void Check_ScansAssembliesEndToEnd() - { - // The test assembly contains the deliberate violations above, so a real assembly scan must surface them. - ArchitectureReport report = CqsConventions.Check(typeof(CqsConventionsTests).Assembly); - - Assert.That(report.IsValid, Is.False); - Assert.That(report.Violations, Has.Some.Contains(nameof(PublicCommandHandler))); - } - - [Test] - public void Check_WithBdoConvention_ScansAssembliesEndToEnd() - { - ArchitectureReport report = CqsConventions.Check( - options => options.RequireBdoSuffix(), - typeof(CqsConventionsTests).Assembly); - - Assert.That(report.IsValid, Is.False); - Assert.That(report.Violations, Has.Some.Contains(nameof(OrphanBdo)).And.Contains("Model boundary graph")); - Assert.That(report.Violations, Has.None.Contains(nameof(UnitBdo))); - } -} diff --git a/tests/Pixely.Architecture.Testing.Tests/Fixtures.cs b/tests/Pixely.Architecture.Testing.Tests/Fixtures.cs deleted file mode 100644 index 7cb9145b..00000000 --- a/tests/Pixely.Architecture.Testing.Tests/Fixtures.cs +++ /dev/null @@ -1,167 +0,0 @@ -using Pixely.Architecture; - -namespace Pixely.Architecture.Testing.Tests; - -// A clean CQS slice: behaviourless record command/query, internal constructor-injected handlers. - -internal sealed class DomainService; - -internal record MoveCommand(int X, int Y); - -internal sealed class MoveCommandHandler : ICommandHandler -{ - internal MoveCommandHandler(DomainService service) - { - _ = service; - } - - public CommandResult Handle(MoveCommand command) => CommandResult.Success; -} - -internal record UnitsInRangeQuery(int Radius); - -internal sealed class UnitsInRangeQueryHandler : IQueryHandler -{ - internal UnitsInRangeQueryHandler() - { - } - - public int Handle(UnitsInRangeQuery query) => query.Radius; -} - -internal sealed record UnitBdo(int UnitId); - -internal sealed record UnitsInRangeBdo(IReadOnlyList Units); - -internal sealed record PageBdo(IReadOnlyList Items); - -internal sealed class UnitsInRangeBdoQueryHandler : IQueryHandler -{ - internal UnitsInRangeBdoQueryHandler() - { - } - - public UnitsInRangeBdo Handle(UnitsInRangeQuery query) => new([]); -} - -internal sealed class PagedUnitsQueryHandler : IQueryHandler> -{ - internal PagedUnitsQueryHandler() - { - } - - public PageBdo Handle(UnitsInRangeQuery query) => new([]); -} - -internal sealed record OrphanBdo(int Value); - -internal sealed record BdoCommand(UnitBdo Unit); - -internal sealed class BdoCommandHandler : ICommandHandler -{ - internal BdoCommandHandler() - { - } - - public CommandResult Handle(BdoCommand command) => CommandResult.Success; -} - -internal sealed record BdoInputQuery(UnitBdo Unit); - -internal sealed class BdoInputQueryHandler : IQueryHandler -{ - internal BdoInputQueryHandler() - { - } - - public UnitsInRangeBdo Handle(BdoInputQuery query) => new([]); -} - -internal sealed record BdoEvent(UnitBdo Unit) : Pixely.Architecture.Events.DomainMessage; - -internal sealed record SettingsBdo(int Volume); - -internal sealed record GetSettingsQuery; - -internal sealed class GetSettingsQueryHandler : IQueryHandler -{ - internal GetSettingsQueryHandler() - { - } - - public SettingsBdo Handle(GetSettingsQuery query) => new(100); -} - -internal sealed record SaveSettingsCommand(SettingsBdo Settings); - -internal sealed class SaveSettingsCommandHandler : ICommandHandler -{ - internal SaveSettingsCommandHandler() - { - } - - public CommandResult Handle(SaveSettingsCommand command) => CommandResult.Success; -} - -internal sealed record MutableBdo -{ - public int Value { get; set; } - - public int Increment() => Value + 1; -} - -// Deliberate violations, each isolated so a single rule fires. - -// Command is a class, not a record, and carries behaviour. -internal sealed class BadCommand -{ - public int Value { get; set; } - - public void Mutate() => Value++; -} - -internal sealed class BadCommandHandler : ICommandHandler -{ - internal BadCommandHandler() - { - } - - public CommandResult Handle(BadCommand command) => CommandResult.Success; -} - -// Handler is public and has a public constructor. -public record PublicCommand(int X); - -public sealed class PublicCommandHandler : ICommandHandler -{ - public PublicCommandHandler() - { - } - - public CommandResult Handle(PublicCommand command) => CommandResult.Success; -} - -// Command handler depends on another command handler. -internal record ChainingCommand(int X); - -internal sealed class ChainingCommandHandler : ICommandHandler -{ - internal ChainingCommandHandler(MoveCommandHandler other) - { - _ = other; - } - - public CommandResult Handle(ChainingCommand command) => CommandResult.Success; -} - -// Handler whose name does not end with the required suffix. -internal record OddlyNamedCommand(int X); - -internal sealed class OddlyNamedExecutor : ICommandHandler -{ - internal OddlyNamedExecutor() - { - } - - public CommandResult Handle(OddlyNamedCommand command) => CommandResult.Success; -} diff --git a/tests/Pixely.Architecture.Testing.Tests/ModelBoundaryTests.cs b/tests/Pixely.Architecture.Testing.Tests/ModelBoundaryTests.cs deleted file mode 100644 index 147eb30c..00000000 --- a/tests/Pixely.Architecture.Testing.Tests/ModelBoundaryTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Pixely.Architecture.Testing.Tests.BoundaryFixtures; -using System.Text.RegularExpressions; - -namespace Pixely.Architecture.Testing.Tests; - -[TestFixture] -public sealed class ModelBoundaryTests -{ - private const string BoundaryNamespace = "Pixely.Architecture.Testing.Tests.BoundaryFixtures"; - - private static Type[] BoundaryTypes() => - typeof(SpawnCommand).Assembly.GetTypes() - .Where(type => type.Namespace == BoundaryNamespace) - .ToArray(); - - private static List CheckReachability(ModelBoundaryOptions options) - { - Type[] allTypes = BoundaryTypes(); - Type[] publicTypes = allTypes.Where(type => type.IsPublic).ToArray(); - return ModelBoundary.ReachabilityViolations( - publicTypes, allTypes, type => type.Namespace == BoundaryNamespace, options); - } - - private static ModelBoundaryOptions DisallowAllOutsideSurface() - { - ModelBoundaryOptions options = new(); - options.DisallowOutsideSurface(new Regex(".*"), "Public types must belong to the boundary surface."); - return options; - } - - // --- Reachability --- - - [Test] - public void CommandQueryAndEventTransitiveTypes_AreReachable() - { - List violations = CheckReachability(DisallowAllOutsideSurface()); - - // SpawnRequest (via command property), CountBdo (via query handler return), and the event must - // not be reported. Only the genuine leak should remain. - Assert.That(violations, Has.None.Contains(nameof(SpawnRequest))); - Assert.That(violations, Has.None.Contains(nameof(CountBdo))); - Assert.That(violations, Has.None.Contains(nameof(ThingSpawnedEvent))); - } - - [Test] - public void PublicTypeNotReachableFromSurface_IsReportedAsLeak() - { - List violations = CheckReachability(DisallowAllOutsideSurface()); - - Assert.That(violations, Has.Exactly(1).Items); - Assert.That(violations[0], Does.Contain(nameof(LeakedInternals))); - } - - [Test] - public void AllowedOutsideSurfaceType_IsNotReportedAsLeak() - { - ModelBoundaryOptions options = new(); - options.AllowOutsideSurface(typeof(LeakedInternals), "Serializer entry point."); - options.DisallowOutsideSurface(new Regex(".*"), "Public types must belong to the boundary surface."); - - Assert.That(CheckReachability(options), Is.Empty); - } - - [Test] - public void DisallowedOutsideSurfaceType_ReportsRuleReason() - { - ModelBoundaryOptions options = new(); - options.DisallowOutsideSurface(typeof(LeakedInternals), "This type exposes implementation details."); - - List violations = CheckReachability(options); - - Assert.That(violations, Has.Exactly(1).Items); - Assert.That(violations[0], Does.Contain(nameof(LeakedInternals)) - .And.Contain("This type exposes implementation details.")); - } - - [Test] - public void TreatAsSurface_MakesAnOtherwiseLeakedTypeAndItsReferencesReachable() - { - ModelBoundaryOptions options = new(); - options.TreatAsSurface(type => type == typeof(LeakedInternals)); - options.DisallowOutsideSurface(new Regex(".*"), "Public types must belong to the boundary surface."); - - Assert.That(CheckReachability(options), Is.Empty); - } - - // --- InternalsVisibleTo --- - - [Test] - public void InternalsVisibleTo_SpecificDisallowRuleConsumesTargetBeforeCatchAll() - { - ModelBoundaryOptions options = new(); - options.AllowInternalsTo("Game.Editor", "Approved editor integration."); - options.DisallowInternalsTo("Game.Tests", "Tests must exercise the public boundary."); - options.DisallowInternalsTo(new Regex(".*"), "No other assembly may access internals."); - - List violations = ModelBoundary.InternalsVisibleToViolations( - ["Game.Editor", "Game.Tests"], options.InternalsRules); - - Assert.That(violations, Has.Exactly(1).Items); - Assert.That(violations[0], Does.Contain("Game.Tests").And.Contain("Tests must exercise the public boundary.")); - } - - [Test] - public void InternalsVisibleTo_AllowRuleConsumesTargetBeforeCatchAll() - { - ModelBoundaryOptions options = new(); - options.AllowInternalsTo("Game.Editor", "Approved editor integration."); - options.DisallowInternalsTo(new Regex(".*"), "No other assembly may access internals."); - - List violations = ModelBoundary.InternalsVisibleToViolations( - ["Game.Editor"], options.InternalsRules); - - Assert.That(violations, Is.Empty); - } - - [Test] - public void InternalsVisibleTo_RegexMustMatchEntireAssemblyName() - { - ModelBoundaryOptions options = new(); - options.DisallowInternalsTo(new Regex("Game\\.Tests"), "Tests must exercise the public boundary."); - - List violations = ModelBoundary.InternalsVisibleToViolations( - ["Prefix.Game.Tests.Suffix"], options.InternalsRules); - - Assert.That(violations, Is.Empty); - } - - [Test] - public void InternalsVisibleTo_UnmatchedTargetIsAllowed() - { - ModelBoundaryOptions options = new(); - - List violations = ModelBoundary.InternalsVisibleToViolations( - ["Game.Unmatched"], options.InternalsRules); - - Assert.That(violations, Is.Empty); - } -} diff --git a/tests/Pixely.Architecture.Testing.Tests/Pixely.Architecture.Testing.Tests.csproj b/tests/Pixely.Architecture.Testing.Tests/Pixely.Architecture.Testing.Tests.csproj deleted file mode 100644 index 23d99e93..00000000 --- a/tests/Pixely.Architecture.Testing.Tests/Pixely.Architecture.Testing.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - net10.0 - 14 - enable - enable - false - - - - - - - - - - - - - - - - - - - - diff --git a/tests/Pixely.Architecture.Testing.Tests/QueryResultFixtures.cs b/tests/Pixely.Architecture.Testing.Tests/QueryResultFixtures.cs deleted file mode 100644 index 152cc131..00000000 --- a/tests/Pixely.Architecture.Testing.Tests/QueryResultFixtures.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Collections.Immutable; -using Pixely.Architecture; - -namespace Pixely.Architecture.Testing.Tests.QueryResultFixtures; - -// Clean: result is a record exposing only init/get members over immutable and read-only collection types. -internal record RangeQuery(int Origin); - -internal sealed record GoodBdo(IReadOnlyList Tiles, ImmutableArray Names, int Count); - -internal sealed class GoodBdoQueryHandler : IQueryHandler -{ - internal GoodBdoQueryHandler() - { - } - - public GoodBdo Handle(RangeQuery query) => new([], [], 0); -} - -// Clean: a domain-entity-style result that stays mutable internally but is readonly to external consumers. -internal sealed class InternalSetterBdo -{ - public int Health { get; internal set; } -} - -internal record EntityQuery(int Id); - -internal sealed class InternalSetterQueryHandler : IQueryHandler -{ - internal InternalSetterQueryHandler() - { - } - - public InternalSetterBdo Handle(EntityQuery query) => new(); -} - -// Violation: public setter. -internal sealed class PublicSetterBdo -{ - public int Value { get; set; } -} - -internal record PublicSetterQuery(int X); - -internal sealed class PublicSetterQueryHandler : IQueryHandler -{ - internal PublicSetterQueryHandler() - { - } - - public PublicSetterBdo Handle(PublicSetterQuery query) => new(); -} - -// Violation: exposes a mutable List. -internal sealed record ListBdo(List Values); - -internal record ListQuery(int X); - -internal sealed class ListQueryHandler : IQueryHandler -{ - internal ListQueryHandler() - { - } - - public ListBdo Handle(ListQuery query) => new([]); -} - -// Violation: exposes an array. -internal sealed record ArrayBdo(int[] Values); - -internal record ArrayQuery(int X); - -internal sealed class ArrayQueryHandler : IQueryHandler -{ - internal ArrayQueryHandler() - { - } - - public ArrayBdo Handle(ArrayQuery query) => new([]); -} - -// Violation: recursive — a readonly wrapper around a mutable nested type. -internal sealed class MutableInner -{ - public int X { get; set; } -} - -internal sealed record NestedBdo(MutableInner Inner); - -internal record NestedQuery(int X); - -internal sealed class NestedQueryHandler : IQueryHandler -{ - internal NestedQueryHandler() - { - } - - public NestedBdo Handle(NestedQuery query) => new(new MutableInner()); -} diff --git a/tests/Pixely.Architecture.Testing.Tests/QueryResultImmutabilityTests.cs b/tests/Pixely.Architecture.Testing.Tests/QueryResultImmutabilityTests.cs deleted file mode 100644 index 971630c9..00000000 --- a/tests/Pixely.Architecture.Testing.Tests/QueryResultImmutabilityTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Pixely.Architecture.Testing.Tests.QueryResultFixtures; - -namespace Pixely.Architecture.Testing.Tests; - -[TestFixture] -public sealed class QueryResultImmutabilityTests -{ - [Test] - public void ReadonlyResults_IncludingInternalSetterAndReadOnlyCollections_AreClean() - { - ArchitectureReport report = CqsConventions.CheckTypes( - [typeof(GoodBdoQueryHandler), typeof(InternalSetterQueryHandler)]); - - Assert.That(report.IsValid, Is.True, report.ToString()); - } - - [Test] - public void PublicSetterResult_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes([typeof(PublicSetterQueryHandler)]); - - Assert.That(report.Violations, Has.Exactly(1).Items); - Assert.That(report.Violations[0], Does.Contain(nameof(PublicSetterBdo)).And.Contain("public setter")); - } - - [Test] - public void MutableCollectionResult_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes([typeof(ListQueryHandler)]); - - Assert.That(report.Violations, Has.Exactly(1).Items); - Assert.That(report.Violations[0], Does.Contain(nameof(ListBdo))); - } - - [Test] - public void ArrayResult_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes([typeof(ArrayQueryHandler)]); - - Assert.That(report.Violations, Has.Exactly(1).Items); - Assert.That(report.Violations[0], Does.Contain(nameof(ArrayBdo)).And.Contain("array")); - } - - [Test] - public void RecursivelyMutableResult_IsReported() - { - ArchitectureReport report = CqsConventions.CheckTypes([typeof(NestedQueryHandler)]); - - Assert.That(report.Violations, Has.Exactly(1).Items); - Assert.That(report.Violations[0], Does.Contain(nameof(MutableInner)).And.Contain("public setter")); - } -} diff --git a/tests/Pixely.Architecture.Tests/CommandDispatchingTests.cs b/tests/Pixely.Architecture.Tests/CommandDispatchingTests.cs deleted file mode 100644 index 38339c45..00000000 --- a/tests/Pixely.Architecture.Tests/CommandDispatchingTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -using Pixely.Architecture.Events; -using Pixely.DependencyInjection; - -namespace Pixely.Architecture.Tests; - -[TestFixture] -public sealed class CommandDispatchingTests -{ - // --- DomainEventDispatchHook (no DI) --- - - [Test] - public void DomainEventDispatchHook_FansEachDrainedMessageToEveryListener() - { - DomainEventStream stream = new(); - RecordingListener first = new(); - RecordingListener second = new(); - ServiceRegistry listeners = BuildListenerRegistry(first, second); - DomainEventDispatchHook dispatchHook = new(stream.CreateCursor(), listeners); - - stream.Publish(new TestMessage(1)); - stream.Publish(new TestMessage(2)); - dispatchHook.OnBatchCompleted(); - - Assert.That(first.Received, Is.EqualTo(new[] { 1, 2 })); - Assert.That(second.Received, Is.EqualTo(new[] { 1, 2 })); - } - - [Test] - public void DomainEventDispatchHook_OnlyDrainsNewMessagesOnEachBatch() - { - DomainEventStream stream = new(); - RecordingListener listener = new(); - ServiceRegistry listeners = BuildListenerRegistry(listener); - DomainEventDispatchHook dispatchHook = new(stream.CreateCursor(), listeners); - - stream.Publish(new TestMessage(1)); - dispatchHook.OnBatchCompleted(); - dispatchHook.OnBatchCompleted(); - - Assert.That(listener.Received, Is.EqualTo(new[] { 1 })); - } - - [Test] - public void DomainEventCursor_Dispose_RemovesCursorSoTheStreamCanCompact() - { - DomainEventStream stream = new(); - DomainEventCursor cursor = stream.CreateCursor(); - - // Fill the buffer to capacity without draining; the hook's undrained cursor pins every event. - for (int i = 0; i < 8192; i++) - { - stream.Publish(new TestMessage(i)); - } - - cursor.Dispose(); - - // With the cursor gone, compaction proceeds and publishing no longer hits the overflow guard. - Assert.That(() => stream.Publish(new TestMessage(8192)), Throws.Nothing); - } - - // --- CommandDispatcher depth gating (via DI) --- - - [Test] - public void Dispatch_RunsHandlerAndFiresHooksOncePerTopLevelBatch() - { - Recorder recorder = new(); - ServiceProvider provider = BuildModel(recorder); - - CommandResult result = provider.GetRequiredService().Dispatch(new OuterCommand()); - - Assert.That(result.IsSuccess, Is.True); - // Inner command runs inside the outer handler; both log, but the hook fires only once at depth 1. - Assert.That(recorder.Log, Is.EqualTo(new[] { "inner", "outer" })); - Assert.That(recorder.HookCalls, Is.EqualTo(1)); - } - - [Test] - public void Dispatch_DrainsPublishedEventsToListenersAfterTheBatch() - { - Recorder recorder = new(); - ServiceProvider provider = BuildModel(recorder); - - provider.GetRequiredService().Dispatch(new OuterCommand()); - - // OuterCommandHandler publishes event 42; the hook drains it to the listener once the batch ends. - Assert.That(provider.GetRequiredService().Received, Has.Member(42)); - } - - [Test] - public void Dispatch_DomainEventListenerMayDependOnDispatcherAndDispatchFollowUpCommand() - { - Recorder recorder = new(); - ServiceCollection services = new(); - services.AddSingleton(recorder); - services.AddDomainEvents(); - services.AddCommandDispatching(); - services.AddDomainEventDispatchHook(); - services.AddSingleton, PublishOnlyCommandHandler>(); - services.AddSingleton, FollowUpCommandHandler>(); - services.AddSingleton(); - ServiceProvider provider = services.BuildServiceProvider(); - - provider.GetRequiredService().Dispatch(new PublishOnlyCommand()); - - Assert.That(recorder.Log, Is.EqualTo(new[] { "follow-up" })); - } - - private static ServiceProvider BuildModel(Recorder recorder) - { - ServiceCollection services = new(); - services.AddSingleton(recorder); - services.AddDomainEvents(); - services.AddCommandDispatching(); - services.AddDomainEventDispatchHook(); - services.AddSingleton, OuterCommandHandler>(); - services.AddSingleton, InnerCommandHandler>(); - services.AddSingleton(); - services.AddAlias(); - services.AddSingleton(); - return services.BuildServiceProvider(); - } - - private static ServiceRegistry BuildListenerRegistry(params IDomainEventListener[] listeners) - { - ServiceCollection services = new(); - services.AddRegistry(); - foreach (IDomainEventListener listener in listeners) - { - services.AddSingleton(listener); - } - - ServiceProvider provider = services.BuildServiceProvider(); - return provider.GetRequiredService>(); - } -} - -internal sealed class Recorder -{ - public List Log { get; } = new(); - public int HookCalls { get; set; } -} - -internal sealed record OuterCommand; - -internal sealed record InnerCommand; - -internal sealed record PublishOnlyCommand; - -internal sealed record FollowUpCommand; - -internal sealed class OuterCommandHandler : ICommandHandler -{ - private readonly ICommandDispatcher _dispatcher; - private readonly IDomainEventPublisher _publisher; - private readonly Recorder _recorder; - - internal OuterCommandHandler(ICommandDispatcher dispatcher, IDomainEventPublisher publisher, Recorder recorder) - { - _dispatcher = dispatcher; - _publisher = publisher; - _recorder = recorder; - } - - public CommandResult Handle(OuterCommand command) - { - _dispatcher.Dispatch(new InnerCommand()); - _recorder.Log.Add("outer"); - _publisher.Publish(new TestMessage(42)); - return CommandResult.Success; - } -} - -internal sealed class InnerCommandHandler : ICommandHandler -{ - private readonly Recorder _recorder; - - internal InnerCommandHandler(Recorder recorder) - { - _recorder = recorder; - } - - public CommandResult Handle(InnerCommand command) - { - _recorder.Log.Add("inner"); - return CommandResult.Success; - } -} - -internal sealed class PublishOnlyCommandHandler : ICommandHandler -{ - private readonly IDomainEventPublisher _publisher; - - internal PublishOnlyCommandHandler(IDomainEventPublisher publisher) - { - _publisher = publisher; - } - - public CommandResult Handle(PublishOnlyCommand command) - { - _publisher.Publish(new TestMessage(7)); - return CommandResult.Success; - } -} - -internal sealed class FollowUpCommandHandler : ICommandHandler -{ - private readonly Recorder _recorder; - - internal FollowUpCommandHandler(Recorder recorder) - { - _recorder = recorder; - } - - public CommandResult Handle(FollowUpCommand command) - { - _recorder.Log.Add("follow-up"); - return CommandResult.Success; - } -} - -internal sealed class SpyHook : ICommandDispatchHook -{ - private readonly Recorder _recorder; - - internal SpyHook(Recorder recorder) - { - _recorder = recorder; - } - - public void OnBatchCompleted() => _recorder.HookCalls++; -} - -internal sealed class RecordingListener : IDomainEventListener -{ - public List Received { get; } = new(); - - public bool TryProcess(DomainMessage message) - { - if (message is TestMessage testMessage) - { - Received.Add(testMessage.Value); - } - - return true; - } -} - -internal sealed class CapturingListener : IDomainEventListener -{ - public List Received { get; } = new(); - - public bool TryProcess(DomainMessage message) - { - if (message is TestMessage testMessage) - { - Received.Add(testMessage.Value); - } - - return true; - } -} - -internal sealed class DispatchingListener : IDomainEventListener -{ - private readonly ICommandDispatcher _dispatcher; - - internal DispatchingListener(ICommandDispatcher dispatcher) - { - _dispatcher = dispatcher; - } - - public bool TryProcess(DomainMessage message) - { - if (message is not TestMessage) - { - return false; - } - - _dispatcher.Dispatch(new FollowUpCommand()); - return true; - } -} diff --git a/tests/Pixely.Architecture.Tests/DomainEventStreamTests.cs b/tests/Pixely.Architecture.Tests/DomainEventStreamTests.cs deleted file mode 100644 index 1119c3b8..00000000 --- a/tests/Pixely.Architecture.Tests/DomainEventStreamTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -using Pixely.Architecture.Events; - -namespace Pixely.Architecture.Tests; - -public sealed record TestMessage(int Value) : DomainMessage; - -[TestFixture] -public sealed class DomainEventStreamTests -{ - private const int MaximumRetainedEvents = 8192; - - [Test] - public void Cursor_ReadsPublishedMessagesInOrder() - { - DomainEventStream stream = new(); - DomainEventCursor cursor = stream.CreateCursor(); - - stream.Publish(new TestMessage(1)); - stream.Publish(new TestMessage(2)); - stream.Publish(new TestMessage(3)); - - Assert.That(Drain(cursor), Is.EqualTo(new[] { 1, 2, 3 })); - Assert.That(cursor.TryRead(out _), Is.False); - } - - [Test] - public void Cursor_OnlySeesMessagesPublishedAfterItsCreation() - { - DomainEventStream stream = new(); - stream.Publish(new TestMessage(1)); - - DomainEventCursor cursor = stream.CreateCursor(); - stream.Publish(new TestMessage(2)); - - Assert.That(Drain(cursor), Is.EqualTo(new[] { 2 })); - } - - [Test] - public void Cursors_DrainIndependentlyAtTheirOwnPace() - { - DomainEventStream stream = new(); - DomainEventCursor fast = stream.CreateCursor(); - DomainEventCursor slow = stream.CreateCursor(); - - stream.Publish(new TestMessage(1)); - stream.Publish(new TestMessage(2)); - - Assert.That(Drain(fast), Is.EqualTo(new[] { 1, 2 })); - - stream.Publish(new TestMessage(3)); - - // The slow cursor still sees everything from where it started. - Assert.That(Drain(slow), Is.EqualTo(new[] { 1, 2, 3 })); - Assert.That(Drain(fast), Is.EqualTo(new[] { 3 })); - } - - [Test] - public void Buffer_GrowsBeyondInitialCapacityPreservingOrder() - { - DomainEventStream stream = new(); - DomainEventCursor cursor = stream.CreateCursor(); - - // Far beyond the initial capacity of 16, without draining, forcing growth. - int[] expected = Enumerable.Range(0, 100).ToArray(); - foreach (int value in expected) - { - stream.Publish(new TestMessage(value)); - } - - Assert.That(Drain(cursor), Is.EqualTo(expected)); - } - - [Test] - public void Buffer_WrapsAroundWhenInterleavingPublishAndRead() - { - DomainEventStream stream = new(); - DomainEventCursor cursor = stream.CreateCursor(); - - // Interleaving advances _head past the modulo boundary repeatedly. - List read = new(); - for (int i = 0; i < 100; i++) - { - stream.Publish(new TestMessage(i)); - Assert.That(cursor.TryRead(out DomainMessage? message), Is.True); - read.Add(((TestMessage)message!).Value); - } - - Assert.That(read, Is.EqualTo(Enumerable.Range(0, 100))); - } - - [Test] - public void Compaction_ReleasesEventsOnceEveryCursorHasConsumedThem() - { - DomainEventStream stream = new(); - DomainEventCursor cursor = stream.CreateCursor(); - - // With a single cursor that keeps up, the buffer never overflows no - // matter how many events flow through it. - for (int i = 0; i < MaximumRetainedEvents * 3; i++) - { - stream.Publish(new TestMessage(i)); - cursor.TryRead(out _); - } - - Assert.Pass(); - } - - [Test] - public void Publish_ThrowsWhenAStalledCursorRetainsTooManyEvents() - { - DomainEventStream stream = new(); - // A cursor that never reads stalls compaction. - stream.CreateCursor(); - - for (int i = 0; i < MaximumRetainedEvents; i++) - { - stream.Publish(new TestMessage(i)); - } - - Assert.That(() => stream.Publish(new TestMessage(MaximumRetainedEvents)), - Throws.InvalidOperationException); - } - - [Test] - public void DisposingStalledCursor_FreesTheBufferForCompaction() - { - DomainEventStream stream = new(); - DomainEventCursor stalled = stream.CreateCursor(); - - for (int i = 0; i < MaximumRetainedEvents; i++) - { - stream.Publish(new TestMessage(i)); - } - - stalled.Dispose(); - - // With no cursors retaining the backlog, publishing succeeds again. - Assert.That(() => stream.Publish(new TestMessage(MaximumRetainedEvents)), - Throws.Nothing); - } - - [Test] - public void DisposedCursor_ThrowsOnRead() - { - DomainEventStream stream = new(); - DomainEventCursor cursor = stream.CreateCursor(); - cursor.Dispose(); - - Assert.That(() => cursor.TryRead(out _), Throws.TypeOf()); - } - - [Test] - public void Cursor_OnEmptyStreamReturnsFalse() - { - DomainEventStream stream = new(); - DomainEventCursor cursor = stream.CreateCursor(); - - Assert.That(cursor.TryRead(out DomainMessage? message), Is.False); - Assert.That(message, Is.Null); - } - - private static int[] Drain(DomainEventCursor cursor) - { - List values = new(); - while (cursor.TryRead(out DomainMessage? message)) - { - values.Add(((TestMessage)message!).Value); - } - - return values.ToArray(); - } -} diff --git a/tests/Pixely.Architecture.Tests/Pixely.Architecture.Tests.csproj b/tests/Pixely.Architecture.Tests/Pixely.Architecture.Tests.csproj deleted file mode 100644 index 83df9b73..00000000 --- a/tests/Pixely.Architecture.Tests/Pixely.Architecture.Tests.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - - net10.0 - 14 - enable - enable - false - $(InterceptorsNamespaces);Pixely.DependencyInjection.Generated - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/Pixely.Architecture.Tests/ServiceCollectionExtensionsTests.cs b/tests/Pixely.Architecture.Tests/ServiceCollectionExtensionsTests.cs deleted file mode 100644 index 8c18fc9d..00000000 --- a/tests/Pixely.Architecture.Tests/ServiceCollectionExtensionsTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Pixely.Architecture.Events; -using Pixely.DependencyInjection; - -namespace Pixely.Architecture.Tests; - -[TestFixture] -public sealed class ServiceCollectionExtensionsTests -{ - [Test] - public void AddDomainEvents_RegistersStreamAndAliasesToSameInstance() - { - ServiceCollection services = new(); - services.AddDomainEvents(); - ServiceProvider provider = services.BuildServiceProvider(); - - DomainEventStream stream = provider.GetRequiredService(); - IDomainEventPublisher publisher = provider.GetRequiredService(); - IDomainEventStream readSide = provider.GetRequiredService(); - - Assert.That(publisher, Is.SameAs(stream)); - Assert.That(readSide, Is.SameAs(stream)); - } - - [Test] - public void AddDomainEvents_PublishAndCursorFlowThroughResolvedServices() - { - ServiceCollection services = new(); - services.AddDomainEvents(); - ServiceProvider provider = services.BuildServiceProvider(); - - IDomainEventPublisher publisher = provider.GetRequiredService(); - DomainEventCursor cursor = provider.GetRequiredService(); - - publisher.Publish(new TestMessage(42)); - - Assert.That(cursor.TryRead(out DomainMessage? message), Is.True); - Assert.That(((TestMessage)message!).Value, Is.EqualTo(42)); - } - - [Test] - public void AddDomainEvents_RegistersCursorAsTransient() - { - ServiceCollection services = new(); - services.AddDomainEvents(); - ServiceProvider provider = services.BuildServiceProvider(); - - DomainEventCursor first = provider.GetRequiredService(); - DomainEventCursor second = provider.GetRequiredService(); - - Assert.That(second, Is.Not.SameAs(first)); - } -} diff --git a/tests/Pixely.Package.Tests/Consumers/ShaderConsumer/Program.cs b/tests/Pixely.Package.Tests/Consumers/ShaderConsumer/Program.cs index 3274c470..9815bc7c 100644 --- a/tests/Pixely.Package.Tests/Consumers/ShaderConsumer/Program.cs +++ b/tests/Pixely.Package.Tests/Consumers/ShaderConsumer/Program.cs @@ -6,8 +6,7 @@ [ "Pixely", "Pixely.PathFinding", - "Pixely.Architecture", - "Pixely.Architecture.Testing", + "Pixely.Fitness", "Pixely.Audio", "Pixely.Collections", "Pixely.Componentize", diff --git a/tests/Pixely.Package.Tests/PackageIntegrationTests.cs b/tests/Pixely.Package.Tests/PackageIntegrationTests.cs index 5fcea63c..1ab92578 100644 --- a/tests/Pixely.Package.Tests/PackageIntegrationTests.cs +++ b/tests/Pixely.Package.Tests/PackageIntegrationTests.cs @@ -17,8 +17,7 @@ public class PackageIntegrationTests private static readonly string[] RuntimeAssemblies = [ "Pixely.PathFinding", - "Pixely.Architecture.Testing", - "Pixely.Architecture", + "Pixely.Fitness", "Pixely.Audio", "Pixely.Collections", "Pixely.Componentize", @@ -134,6 +133,7 @@ public void PackageArchiveContainsAllCoordinatedAssetsAndMetadata() Assert.That(entries, Does.Contain("tools/net10.0/any/build/Pixely.SdlangCompiler.props")); Assert.That(entries, Does.Contain("tools/net10.0/any/build/Pixely.SdlangCompiler.targets")); Assert.That(entries, Does.Contain("THIRD-PARTY-NOTICES.md")); + Assert.That(entries, Does.Contain("docs/peach-architecture.md")); Assert.That(entries, Does.Not.Contain("lib/net10.0/Pixely.SdlangCompiler.dll")); Assert.That(entries, Does.Not.Contain("lib/net10.0/Pixely.DependencyInjection.Generator.dll")); Assert.That(entries.Any(entry => entry.StartsWith("tools/slang/", StringComparison.Ordinal)), Is.False);