diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..1085f87a5 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,67 @@ +# Copilot Instructions + +## Test/Lint/Analyze +After every code change, run `./project unit`, `./project sniffer`, and `./project analyzer`. + +## Architecture +This is a modular, domain-driven PHP platform with multiple bounded contexts that communicate via messaging. + +### Bounded Contexts +Every directory in `src/` except `Common` is a bounded context in the gaming platform domain. +`Common` contains shared libraries (Bus, EventStore, Timer, etc.). + +Architectural patterns vary per context but must stay consistent within a context. Do not change a context's +pattern unless explicitly asked to refactor. + +### UI Composition +The UI is composed using SSI (Server Side Includes) and Custom Elements. SSI renders server-side fragments from +different contexts into a page. Custom Elements encapsulate client-side behavior. Both serve as transclusion +boundaries between contexts. + +## Conventions + +### Wiring +All services are explicitly wired in `config/` YAML files. There is no autowiring. + +### Exceptions +The context/aggregate base exception type (e.g., `ChallengeException`) must extend `DomainException`. Use named +constructors on that base exception rather than individual exception classes: +```php +class ChallengeException extends DomainException +{ + public static function notFound(): self + { + return new self(new Violations(new Violation('challenge_not_found'))); + } + + public static function alreadyClosed(): self + { + return new self(new Violations(new Violation('challenge_already_closed'))); + } +} +``` +When an exception is explicitly caught to drive alternate control flow, use a dedicated exception class that is +`final` and extends the context base (e.g., `final class ChallengeNotFoundException extends ChallengeException`). +These control-flow exception classes must declare their own constructor, and that constructor may accept scalar +violation parameters to pass through, so the concrete type is always instantiated directly when needed. +Each named constructor should instantiate `Violations`/`ViolationParameter` directly, following the example above. +Every domain exception must populate `Violations` (and any parameters) so error translation is always available. + +### Cleanup +Remove unused code introduced by changes (classes, methods, config, assets) unless explicitly asked to keep it. + +### Tests +- Unit tests mirror the `src/` structure in `tests/unit/` +- Use PHPUnit attributes: `#[Test]` +- Test namespace: `Gaming\Tests\Unit\{Context}\...` +- For domain exceptions, assert via `DomainAssert::expectViolation` with the expected exception + class, identifier, and optional violation parameters as `['name' => value]` (omit the + parameters argument when empty). Use `expectException` only for non-domain exceptions. +- The closure passed to `DomainAssert::expectViolation` must contain only the single action that + triggers the exception. All setup (creating objects, calling prior methods) belongs before the call. + +### Code Style +- PSR-12 coding standard +- PHPStan level 8 +- `declare(strict_types=1)` in all PHP files +- Readonly properties and constructor promotion preferred diff --git a/src/Common/Domain/Test/DomainAssert.php b/src/Common/Domain/Test/DomainAssert.php new file mode 100644 index 000000000..9910b3b6b --- /dev/null +++ b/src/Common/Domain/Test/DomainAssert.php @@ -0,0 +1,41 @@ + $expectedException + * @param array $expectedParameters + */ + public static function expectViolation( + Closure $action, + string $expectedException, + string $expectedIdentifier, + array $expectedParameters = [] + ): void { + try { + $action(); + Assert::fail('Expected a domain exception.'); + } catch (DomainException $exception) { + Assert::assertInstanceOf($expectedException, $exception); + + $violation = $exception->violations->first(); + Assert::assertNotNull($violation); + Assert::assertSame($expectedIdentifier, $violation->identifier); + Assert::assertSame( + $expectedParameters, + array_combine( + array_column($violation->parameters, 'name'), + array_column($violation->parameters, 'value') + ) + ); + } + } +}