diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index a561aa6..0000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,16 +0,0 @@ -# CLAUDE.md - -This is a PHP library in the tiny-blocks ecosystem. Detailed rules live in `.claude/rules/`. -Each file is scoped via its `paths` frontmatter. Read the relevant file before producing or -editing content under its scope. - -## Rule files - -- `php-library-architecture.md` — folder structure, public API boundary, `Internal/` semantics. -- `php-library-code-style.md` — semantic code rules for `.php` files in `src/` and `tests/`. -- `php-library-commits.md` — Conventional Commits format. Applied only when generating commit messages. -- `php-library-documentation.md` — README and Markdown documentation standards. -- `php-library-github-workflows.md` — CI workflow structure and action pinning. -- `php-library-modeling.md` — nomenclature, value objects, exceptions, enums, complexity. -- `php-library-testing.md` — BDD Given/When/Then, PHPUnit conventions, coverage discipline. -- `php-library-tooling.md` — canonical config files (`composer.json`, `phpcs.xml`, etc). diff --git a/.claude/rules/php-library-architecture.md b/.claude/rules/php-library-architecture.md deleted file mode 100644 index 7e4be10..0000000 --- a/.claude/rules/php-library-architecture.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -description: Folder structure, public API boundary, and Internal/ semantics for PHP libraries. -paths: - - "src/**/*.php" ---- - -# Architecture - -Covers the physical layout of the library. Folder structure, the boundary between public API and -implementation detail, and where each type of class lives. Semantic rules (value objects, -exceptions, enums, complexity, nomenclature) live in `php-library-modeling.md`. Code style lives -in `php-library-code-style.md`. - -## Pre-output checklist - -Verify every item before producing or relocating any file. If any item fails, revise before -outputting. - -1. None of the following folder names exist in `src/`: `Models/`, `Entities/`, `ValueObjects/`, - `Enums/`, `Domain/`. They carry no semantic content and conflate technical role with domain - meaning. -2. The `src/` root contains only interfaces, extension points, public enums, thin orchestration - classes, and primary implementations or façades. Substantial logic (algorithms, state machines, - I/O) lives in `src/Internal/`, never at the root. -3. `src/Internal/` is implementation detail and not part of the public API. Breaking changes - inside `src/Internal/` are not semver-breaking. -4. Consumers must not reference, extend, or depend on any type inside `src/Internal/`. The - namespace itself is the boundary. -5. Public exception classes live in `src/Exceptions/`. -6. Internal exception classes live in `src/Internal/Exceptions/`. -7. Public enums live at the `src/` root or inside a public `/` folder. Enums used - only by internals live in `src/Internal/`. -8. Public interfaces live at the `src/` root or inside a public `/` folder. -9. A `/` folder at the `src/` root groups related public types under a shared - concept. Each group has its own namespace and is part of the public API. -10. `/` is optional. Use it only when the library exposes several coherent groups of - types (for example, aggregates and events) rather than a flat set of types around a single - concept. -11. Test fixtures representing domain concepts live in `tests/Models/`. Test doubles for system - boundaries live at the root of `tests/Unit/` or `tests/Integration/`. No dedicated `Mocks/` - or `Doubles/` subdirectory exists. `tests/Drivers//` is permitted when the library - exposes a port exercised against multiple third-party implementations (PSR adapters, - framework integrations). Each `/` subdir holds tests against one specific - implementation. -12. The `tests/Integration/` folder exists only when the library interacts with external - infrastructure (filesystem, database, network). Otherwise, the folder is absent. - -## Folder structure - -Canonical layout for a PHP library in the tiny-blocks ecosystem. - -``` -src/ -├── .php # public contract at root -├── .php # main implementation or extension point at root -├── .php # public enum at root -├── / # public folder grouping related public types under a shared concept -│ ├── .php -│ └── ... -├── Internal/ # implementation details, not part of the public API -│ ├── .php -│ └── Exceptions/ # internal exception classes -└── Exceptions/ # public exception classes - -tests/ -├── Models/ # domain fixtures reused across tests -├── Unit/ # unit tests targeting the public API -│ ├── .php # test doubles at root of Unit/ -│ └── .php -└── Integration/ # only present when the library interacts with infrastructure - └── .php # test doubles at root of Integration/ when needed -``` - -Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. They -carry no semantic content and describe technical role instead of domain meaning. - -## Public API boundary - -The `src/` root is the contract. Everything at the root, plus everything inside public -`/` folders and the public `Exceptions/` folder, is what consumers depend on. Changes -to these types follow semver rules. - -`src/Internal/` is implementation detail. The namespace itself signals the boundary. Consumers -must not depend on any type inside `src/Internal/`. Breaking changes inside `src/Internal/` are -not semver-breaking for the library. - -### What lives at the public boundary - -- Interfaces that define contracts for consumers. -- Extension points designed to be subclassed or composed by consumers. -- Public enums and value objects consumers manipulate directly. -- Thin orchestration classes that wire collaborators together without containing substantial logic. -- Public exception classes consumers may catch. - -### What lives in `src/Internal/` - -- Algorithms, state machines, and complex transformations. -- Adapters for I/O (filesystem, network, database). -- Collaborators that exist purely to break a public class into testable units. -- Implementation details that may change between minor or patch releases. -- Internal exception classes raised by collaborators. - -## Reference examples - -### Small library with flat root - -``` -src/ -├── Timezone.php # public value object -├── Timezones.php # public collection -├── Clock.php # public interface -└── Internal/ - ├── SystemClock.php # default Clock implementation - └── Exceptions/ - └── InvalidTimezone.php -``` - -Everything lives at the root or inside `Internal/`. No `/` folders. Suitable when -the library exposes a small, cohesive set of types around a single concept. - -### Library with public concept groups - -``` -src/ -├── ValueObject.php # public extension point at root -├── Aggregate/ # public namespace grouping aggregate types -│ ├── AggregateRoot.php -│ ├── EventualAggregateRoot.php -│ └── ModelVersion.php -├── Event/ # public namespace grouping event types -│ ├── EventRecord.php -│ ├── EventRecords.php -│ └── SequenceNumber.php -├── Internal/ -│ ├── DefaultModelVersionResolver.php -│ └── Exceptions/ -│ └── InvalidSequenceNumber.php -└── Exceptions/ - └── EventRecordingFailure.php -``` - -`Aggregate/` and `Event/` are public folders at the root, each grouping a coherent set of public -types under one shared concept. Consumers import directly, for example -`TinyBlocks\\Aggregate\AggregateRoot`. Suitable when the library exposes several distinct -concept areas, each with its own set of related types. diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md deleted file mode 100644 index 997b294..0000000 --- a/.claude/rules/php-library-code-style.md +++ /dev/null @@ -1,604 +0,0 @@ ---- -description: Semantic code rules for all PHP files in libraries. -paths: - - "src/**/*.php" - - "tests/**/*.php" ---- - -# Code style - -Semantic rules for all PHP files in libraries. Formatting rules covered by `PSR-12` are enforced -by `phpcs.xml`. Two formatting rules outside `PSR-12` (no vertical alignment, no trailing comma in -multi-line lists) are documented at the end of this file under "Formatting overrides". Complexity -rules live in `php-library-modeling.md`. Folder structure, public API boundary, and the semantics -of `Internal/` live in `php-library-architecture.md`. - -## Pre-output checklist - -Verify every item before producing any PHP code. If any item fails, revise before outputting. - -1. `declare(strict_types=1)` is present. -2. All parameters, return types, and properties have explicit types. -3. Constructor property promotion is used. -4. Named arguments are used at call sites for own code, tests, and third-party library methods - (for example, tiny-blocks). Never use named arguments on: - - Native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, - `iterator_to_array`, `sprintf`, `implode`, and similar). - - Native PHP enum methods (`from`, `tryFrom`, `cases`). - - PHPUnit assertions and expectations (`assertEquals`, `assertSame`, `assertTrue`, - `expectException`, and similar). - - Interfaces from PHP-FIG PSR standards (PSR-7 `withHeader`, PSR-18 `sendRequest`, etc.). - The PSR contract does not include parameter names. Implementations may rename parameters. - - Calls that include variadic spread (`...$args`). PHP rejects positional argument unpacking - after named arguments. When the caller passes through a `...$variadic`, all arguments are - positional. New own-code APIs should prefer a typed collection parameter over a variadic - so named-argument call sites remain possible. - - Native PHP **class constructors** (`parent::__construct` calls to `\Exception`, - `\RuntimeException`, `\InvalidArgumentException`, `\LogicException`, and similar) are not - in the list above. They accept named arguments, and rule 8 requires using them whenever - the positional call would pass an argument whose value equals the parameter's default. - Example: `parent::__construct(message: sprintf(...), previous: $previous)` instead of - `parent::__construct(sprintf(...), 0, $previous)`. The exclusion above covers native - functions and enum methods, not native class instantiation. -5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default, - with documented exceptions for extension points and for parents that are not `readonly`. -6. Members are ordered constants first, then constructor, then static methods, then instance - methods. Within each group, order by **member name length ascending** (count the name only, - without parentheses, arguments, or return type). Constants, enum cases, and methods share - the same name-length-ascending rule, applied within their respective groups. This mirrors - the rule that governs constructor parameters and named arguments (rule 7). When two names - have equal length, order them alphabetically. This ordering may be overridden only when the - alternative carries explicit documentation value: grouping by domain class with section - markers (HTTP status codes by 1xx/2xx/3xx/etc), mirroring the order of an implemented - interface, or similar evident structure. The override must be obvious at first reading. - - **At call sites** (chained method calls in production code, tests, or documentation - examples), consecutive method invocations on the same receiver are ordered by **method name - length ascending**, the same rule that governs member declarations. Boolean toggles such as - `->secure()` and `->httpOnly()` come before parameterized `with*` builders because their - names are shorter, not because the expression is narrower. When two method names have equal - length, order them alphabetically. - - **Terminal methods that change the receiver type** stay at the end of the chain regardless - of name length. A `build()` that returns the built value, a `commit()` that finalizes a unit - of work, a `send()` that flushes a request, are terminal: the chain ends with them. The - ordering rule applies only to consecutive calls on the same receiver type; calls that - transition to a different type are not reorderable. The same applies in reverse to the - factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays - at its position. - - **PHPUnit test classes** follow a dedicated sub-grouping inside the instance-methods group - that overrides the name-length-ascending rule: - - 1. **Lifecycle hooks** first, in PHPUnit execution order: - `setUpBeforeClass` → `setUp` → `tearDown` → `tearDownAfterClass`. Only those actually - defined appear; never introduce an empty hook to satisfy the rule. - 2. **Test methods** (prefix `test`) next, ordered by name length ascending (alphabetical - tiebreak). - 3. **Data providers** last, ordered by name length ascending (alphabetical tiebreak). - - A method is a data provider if and only if its name appears as the string argument of a - `#[DataProvider('')]` attribute or a `@dataProvider ` docblock annotation on a - test method in the same class. The naming convention (`*DataProvider`) is informational - only; the reference is the authoritative signal. A method named `*DataProvider` that no - test references is dead code under rule 17, not a data provider. -7. Constructor parameters are ordered by parameter name length ascending (count the name only, - without `$` or type), except when parameters have an implicit semantic order (for example, - `$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default - values go last, regardless of name length. The same rule applies to named arguments at call - sites. Example order: `$id` (2), `$value` (5), `$status` (6), `$precision` (9). -8. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. - Example with `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`. The call - `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` becomes - `$collection->toArray()`. Only pass the argument when the value differs from the default. -9. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. -10. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of - `$acc`. -11. No generic identifiers exist. Use domain-specific names instead. Examples are `$data` to - `$payload`, `$value` to `$totalAmount`, `$item` to `$element`, `$info` to `$currencyDetails`, - `$result` to `$conversionOutcome`. -12. No raw arrays exist where a typed collection or value object is available. When data is - `Collectible`, use the `tiny-blocks/collection` fluent API (`Collection`, `Collectible`). Use - `createLazyFrom` when elements are consumed once. Raw arrays are acceptable only for primitive - configuration data, variadic pass-through, and interop at system boundaries. See "Collection - usage" for the full rule and example. -13. No private methods exist except for private constructors in factory patterns, methods inside - `src/Internal/` (implementation detail by definition, where the namespace is the abstraction - boundary), and `setUp` or `tearDown` overrides in PHPUnit test classes. Outside these cases, - inline trivial logic at the call site or extract it to a collaborator or value object. -14. No logic is duplicated across two or more places (DRY). -15. No abstraction exists without real duplication or isolation need (KISS). -16. No inline comments exist in `src/` or `tests/`, except `# TODO: ` when implementation - is unknown, uncertain, or intentionally deferred. Code is the documentation. Block comments - (`/* */`) never appear outside docblocks (`/** */`). The `#` style for inline PHP comments - applies only to code examples inside Markdown files (see `php-library-documentation.md`). -17. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. -18. Never create public methods, constants, or classes in `src/` solely to serve tests. If - production code does not need it, it does not exist. -19. Format strings with placeholders (`%s`, `%d`, `%f`, etc.) are assigned to a `$template` - variable before being passed to `sprintf`. The variable assignment and the `sprintf` call live - on separate statements. See "Format strings" for examples. -20. All class references use `use` imports at the top of the file. Fully qualified names inline are - prohibited. -21. Return types and `new` calls use the explicit class name. `self` is prohibited as a type, - as a return type, and in `new self()` instantiation. Constant access via `self::CONST_NAME` - is permitted. `static` is permitted only inside extension-point classes (declared `class` - without `final readonly`) and inside traits, where late static binding lets subclasses or - consuming classes instantiate the correct concrete type. In every other context, use the - class name. -22. Always use the most current and clean syntax available in the target PHP version. Prefer - `match` over `switch`, first-class callables over `Closure::fromCallable()`, readonly promotion - over manual assignment, enum methods over external switch or if chains, named arguments over - positional ambiguity (except where excluded by rule 4), `Collection::map` over foreach - accumulation, and **unparenthesized constructor chaining** (PHP 8.4+): - `new Foo()->bar()` instead of `(new Foo())->bar()`. The parentheses around the `new` - expression are no longer required and add visual noise. -23. All identifiers, comments, and documentation use American English. See "American English" for - the spelling list. - -## Naming - -- Internal code (variables, methods, classes) uses `camelCase`. -- Constants and enum-backed values when representing codes use `SCREAMING_SNAKE_CASE`. -- Names describe what in domain terms, not how technically. `$monthlyRevenue` instead of - `$calculatedValue`. Generic technical verbs are avoided. See `php-library-modeling.md` for the - full banlist of generic and anemic names. -- Booleans use predicate form. Examples are `isActive`, `hasPermission`, `wasProcessed`. -- Collections are always plural. Examples are `$orders`, `$lines`. -- Methods returning `bool` use prefixes `is`, `has`, `can`, `was`, `should`. - -## Class self-references - -Type declarations, return types, and `new` calls inside a class use the explicit class name. -The class name is unambiguous, survives refactors that move the method to a different class, -and reads identically inside the class body and at the call site. - -- `self` is prohibited everywhere as a type, as a return type, and in `new self()` - instantiation. Constant access via `self::CONST_NAME` is **permitted**. The prohibition - covers the forms that carry refactoring ambiguity when a method moves to a different class - (the type-or-instantiation forms). Constant access does not have that ambiguity because the - constant is declared in the same class body. -- `static` is permitted only inside extension-point classes (declared `class` without - `final readonly`) and inside traits, where late static binding is required for subclasses or - consuming classes to instantiate the correct concrete type. -- In every other context (the default `final readonly class`, factory methods, return types), - use the class name. - -**Prohibited.** `self` as return type and `new self()` inside a final class: - -```php -final readonly class UserAgent -{ - public static function from(string $product): self - { - return new self(product: $product); - } -} -``` - -**Correct.** Explicit class name in a final class: - -```php -final readonly class UserAgent -{ - public static function from(string $product): UserAgent - { - return new UserAgent(product: $product); - } -} -``` - -**Correct.** `static` permitted in an extension-point class: - -```php -class Collection -{ - public static function createFrom(iterable $elements): static - { - return new static(elements: $elements); - } -} -``` - -## Inheritance and constructors - -- All classes are `final readonly` by default. -- Use `class` (without `final` or `readonly`) only when the class is designed as an extension point - for consumers, for example `Collection` or `ValueObject`. -- Use `final class` without `readonly` only when the parent class is not readonly, for example - when extending a third-party abstract class. -- Use `final class` without `readonly` is also permitted for `src/Internal/` collaborators that - carry intrinsically mutable state (resource handles, counters, cursors) where the mutation is - central to the class's responsibility (`Stream` closing a resource, `Cursor` advancing a - position). The class must remain confined to `src/Internal/`. -- Use `final class` without `readonly` for classes that consist exclusively of `static` methods - (no instance properties, no instance methods, only static factories or utilities). Pair it - with `private function __construct() {}` to prevent instantiation. `readonly` is meaningless - without instance state, and the private constructor signals that the class is a static - surface, not a value type. -- Inheritance between concrete classes is prohibited. Every concrete class is `final`. -- Polymorphism uses interfaces plus composition, never extension of concrete types. -- The only allowed `extends` is against framework or SPL base classes that the language requires. - Examples are `RuntimeException`, `LogicException`, `PHPUnit\Framework\TestCase`. -- Constructors of `final` classes are `private` when paired with named factory methods, `public` - otherwise. `protected` constructors are prohibited because no subclasses exist to call them. - -## Comparisons - -1. Null checks use `is_null($variable)`, never `$variable === null`. -2. Empty string checks on typed `string` parameters use `$variable === ''`. Avoid `empty()` on - typed strings because `empty('0')` returns `true`. -3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`) use - `empty($variable)`. - -## American English - -All identifiers, enum values, comments, and error codes use American English spelling. Examples -are `canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not -`initialise`), `behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not -`labelled`), `fulfill` (not `fulfil`), `color` (not `colour`). - -## PHPDoc - -### When required - -- Every method of an interface. -- Every public method of a concrete class outside `src/Internal/`. Public classes are at the - public API boundary by definition. Consumers call every public method directly, and the - PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt. - The only exception is a public method whose contract is already documented on an implemented - interface (the interface carries the docblock). - -### When prohibited - -- Constructors. The constructor signature with property promotion is self-documenting. Parameter - types are already explicit in the signature. -- Private and protected methods. -- Public methods of concrete classes whose contract is already documented on an implemented - interface. The interface carries the docblock. -- Anything inside `src/Internal/`. Internal types are implementation detail and must not carry - PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the - architectural meaning of `Internal/`. -- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz` - naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in - `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus - `@param`/`@return` descriptions) is prohibited on test methods, data providers, fixtures, - setUp/tearDown overrides, and anonymous classes inside tests. The BDD annotations are not - PHPDoc documentation in the sense of this section and remain required per the testing rule. -- Single-line PHPDocs with only a tag (`/** @param ... */`, `/** @return ... */`, - `/** @throws ... */`). PHPDoc always opens with a summary line. Bare-tag docblocks are - prohibited regardless of how few tags they carry. - -The prohibitions above apply to **every form of PHPDoc** in the prohibited scope: -method-level docblocks, property-level docblocks, inline `@var` annotations on local variables, -and PHPDoc blocks placed above anonymous functions or closures inside method bodies. Inside -`src/Internal/` and `tests/`, zero PHPDoc is the rule with no exception. PHPStan errors that -result from the missing annotations route through `ignoreErrors` (see below). - -The PHPDoc prohibitions above take priority over the typed-array case. When PHPStan at -`level: max` flags a missing iterable value type (`missingType.iterableValue`, -`argument.type`, `return.type`): - -- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not - add PHPDoc. -- On anything inside **`src/Internal/`** → suppress via `ignoreErrors`. Do not add PHPDoc. -- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc. -- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary, - `@param` descriptions, and the typed-array information. The bare-tag form remains - prohibited. This is the normal case where PHPDoc is permitted by "When required" above. - -The summary requirement and the bare-tag prohibition are never waived. Use `ignoreErrors` only -when the context (constructor, `src/Internal/`, `tests/`) makes PHPDoc impossible. Every public -method of a public concrete class carries PHPDoc per "When required", whether the method -has typed-array parameters. - -### Style - -- Summary on the first line, in domain terms. **Mandatory.** PHPDoc without a summary line is - prohibited, even when it carries a single `@param` or `@return`. -- Optional detailed body in `

` paragraphs below the summary. -- Tags use the form `@param Type $name Description.`, `@return Type Description.`, - `@throws ExceptionClass If .`. -- Document `@throws` for every exception the method may raise. -- HTML tags allowed inside descriptions are `

` for paragraphs, `

  • ` for lists, - `` for inline code, `` and `` for emphasis. - -### Summary patterns - -The summary line is not a creative intent statement. It is a template selected by the method's -name prefix. Apply the matching template. Only methods with no matching prefix require a -free-form one-line summary in domain terms. - -| Method shape | Template | -|-------------------------------------------------------------------------|--------------------------------------------------------------------------------| -| Static factory (`create`, `from`, `fromX`, `with*` when static) | `Creates a {ClassName} from {input}.` or `Builds a {ClassName} with {fields}.` | -| `with*` instance method | `Returns a copy of the {ClassName} with the {field} replaced.` | -| Getter (no prefix, returns a property: `code()`, `body()`, `headers()`) | `Returns the {field}.` | -| Predicate (`is*`, `has*`, `can*`, `was*`, `should*`) | `Tells whether {condition}.` | -| Converter (`toArray`, `toString`, `asX`) | `Returns the {ClassName} as {target shape}.` | -| `apply*`, `merge*`, `add*`, and other side-effect-free operations | One-line summary in domain terms describing the operation. | - -The patterns are mandatory when applicable. They make summary lines mechanical: substitute -`{ClassName}` and `{field}` and the summary is complete. No per-method intent decision is -required. Volume is never a reason to skip the summary. Many methods just mean applying the -template many times. - -### Cross-references - -- `{@see ClassName}` for links to other types in the codebase. -- `@see Author, Title (Publisher, Year), Chapter X.` for bibliographical references. - -### Examples - -**Prohibited.** Single-line bare-tag PHPDoc, no summary: - -```php -/** @param array|null $body */ -public static function with(Code $code, ?array $body = null): Response -``` - -**Prohibited.** PHPDoc on a constructor: - -```php -/** @param array $entries */ -public function __construct(public array $entries) -{ -} -``` - -**Prohibited.** PHPDoc on anything inside `src/Internal/`: - -```php -namespace TinyBlocks\Http\Internal\Client; - -final readonly class Url -{ - /** @param array|null $query */ - public static function compose(string $path, ?array $query, string $baseUrl): string - { - } -} -``` - -**Correct.** Generic array type with summary and `@param` description: - -```php -/** - * Builds a synthesized response from a status code and an optional body. - * - * @param array|null $body The response body as an associative array. - * @return Response The synthesized response instance. - */ -public static function with(Code $code, ?array $body = null): Response -``` - -**Correct.** Interface with rich description, paragraphs, cross-references, and bibliography: - -```php -/** - * Money tied to a specific currency. - * - *

    Operations between different currencies raise CurrencyMismatch. Arithmetic - * preserves the currency.

    - * - *

    Sibling of {@see Quantity}, not a parent. Money carries currency semantics.

    - * - * @see Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), Chapter 5. - */ -interface Money -{ - /** - * Adds the given amount. - * - * @param Money $other The amount to add. - * @return Money A new instance with the summed amount. - * @throws CurrencyMismatch If $other has a different currency. - */ - public function add(Money $other): Money; -} -``` - -**Correct.** Concrete class with a short summary and direct tags: - -```php -/** - * IANA timezone identifier (e.g. America/Sao_Paulo). - */ -final readonly class Timezone -{ - /** - * Creates a Timezone from a valid IANA identifier. - * - * @param string $identifier The IANA timezone identifier. - * @return Timezone The created instance. - * @throws InvalidTimezone If the identifier is not a valid IANA timezone. - */ - public static function from(string $identifier): Timezone - { - # ... - } -} -``` - -## Dependencies - -When the library needs an external dependency, prefer packages from the `tiny-blocks` ecosystem -(https://github.com/tiny-blocks) whenever a suitable option exists. Reach for outside packages -only when the ecosystem has no equivalent that fits the use case. - -## Collection usage - -When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array -functions such as `array_map`, `array_filter`, `iterator_to_array`, or `foreach` plus accumulation. -The same applies to `filter()`, `reduce()`, `each()`, and every other `Collectible` operation. -Chain them fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*` -function. - -**Prohibited.** `array_map` plus `iterator_to_array` on a `Collectible`: - -```php -$names = array_map( - static fn(Element $element): string => $element->name(), - iterator_to_array($collection) -); -``` - -**Correct.** Fluent chain with `map()` plus `toArray()`: - -```php -$names = $collection - ->map(transformations: static fn(Element $element): string => $element->name()) - ->toArray(keyPreservation: KeyPreservation::DISCARD); -``` - -## Format strings - -When building a message with placeholders, assign the format string to a `$template` variable -first. Pass it to `sprintf` on a separate statement. The format and the data are visually -separated, and the template line stays scannable. - -**Prohibited.** Format string inline with the call: - -```php -if ($value < 0 || $value > 16) { - throw new PrecisionOutOfRange( - message: sprintf('Precision must be between 0 and 16, got %d.', $value) - ); -} -``` - -**Correct.** Format string in a `$template` variable: - -```php -if ($value < 0 || $value > 16) { - $template = 'Precision must be between 0 and 16, got %d.'; - - throw new PrecisionOutOfRange(message: sprintf($template, $value)); -} -``` - -## Constructor chaining - -PHP 8.4 allows chained method calls directly on a `new` expression without wrapping it in -parentheses. The parentheses are no longer required and only add visual noise. Apply this -everywhere a `new` is followed by a method call. - -**Prohibited.** Parentheses around the `new` expression: - -```php -$body = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) - ->withHeader('Accept', 'application/json') - ->getBody(); -``` - -**Correct.** No parentheses: - -```php -$body = new ServerRequest(method: 'GET', uri: 'https://api.example.com') - ->withHeader('Accept', 'application/json') - ->getBody(); -``` - -## Formatting overrides - -Three formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12` -only). Apply them manually. - -### No vertical alignment in parameter lists - -Use a single space between the type and the variable name in parameter lists (constructors, -function signatures, closures). Never pad with extra spaces to align columns. This rule applies -only to parameter lists, not to other contexts that use `=>` alignment (see "Vertical alignment -of `=>`" below). - -**Prohibited.** Vertical alignment of types: - -```php -public function __construct( - public OrderId $id, - public Money $total, - public Customer $customer, - public Precision $precision -) {} -``` - -**Correct.** Single space between type and variable: - -```php -public function __construct( - public OrderId $id, - public Money $total, - public Customer $customer, - public Precision $precision -) {} -``` - -### Vertical alignment of `=>` in match arms and array literals - -Multi-line `match` expressions and multi-line array literals with `=>` align the `=>` column -across all arms or entries by padding shorter left-hand sides with spaces. Single-line cases -(one-arm match, single-line array) keep the standard PSR-12 single-space form. - -**Prohibited.** Unaligned `=>` in match: - -```php -return match ($this) { - self::MAX_AGE => sprintf($template, $this->value, $value), - default => $this->value -}; -``` - -**Correct.** Aligned `=>` in match: - -```php -return match ($this) { - self::MAX_AGE => sprintf($template, $this->value, $value), - default => $this->value -}; -``` - -**Prohibited.** Unaligned `=>` in array literal: - -```php -return [ - 'name' => 'Gustavo', - 'role' => 'developer', - 'company' => 'Anthropic' -]; -``` - -**Correct.** Aligned `=>` in array literal: - -```php -return [ - 'name' => 'Gustavo', - 'role' => 'developer', - 'company' => 'Anthropic' -]; -``` - -### No trailing comma in multi-line lists - -Never place a trailing comma after the last element of any multi-line list. Applies to parameter -lists, argument lists, array literals, match arms, and every other comma-separated multi-line -structure. PHP accepts trailing commas in these positions, but this ecosystem prohibits them for -visual consistency. - -**Prohibited.** Trailing comma after the last argument: - -```php -new Precision( - value: 2, - rounding: RoundingMode::HALF_UP, -); -``` - -**Correct.** No trailing comma: - -```php -new Precision( - value: 2, - rounding: RoundingMode::HALF_UP -); -``` diff --git a/.claude/rules/php-library-commits.md b/.claude/rules/php-library-commits.md deleted file mode 100644 index feefcf5..0000000 --- a/.claude/rules/php-library-commits.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -description: Conventional Commits format. Applied on request when generating commit messages. ---- - -# Commits - -Applied only when generating commit messages, never automatically. All commit messages are -written in English. - -## Format - -`: ` - -The description starts with a capital letter, uses imperative present tense ("Add", "Fix", -"Change", not "Added", "Adds", or "Adding"), and ends with a period. Subject under 300 -characters. If it does not fit, split the change into multiple commits or move detail into the -body. - -Scopes are prohibited. `feat(orders): ...` is wrong. The type stands alone. - -## Allowed types - -Each entry below is a bullet that starts with a capital letter and ends with a period. This is -the canonical example of bullet punctuation enforced everywhere in this document. - -- `ci` for CI configuration changes. -- `fix` for a bug fix. -- `feat` for a user-facing feature. -- `docs` for documentation only. -- `test` for adding or correcting tests. -- `chore` for maintenance with no production code change. -- `build` for build or dependency changes. -- `revert` for reverting a previous commit. -- `refactor` for a code change that neither fixes a bug nor adds a feature. - -`style` is not used. Formatting is enforced by the linter and never appears as a standalone -commit. - -## Subject examples - -Good: - -- `fix: Handle zero-amount transactions.` -- `feat: Add order cancellation endpoint.` -- `refactor: Extract OrderStatus into its own enum.` - -Bad: - -- `Added order cancellation` is past tense, missing type, missing period. -- `feat: Adds order cancellation.` is third-person singular instead of imperative. -- `feat: added order cancellation.` starts lowercase and is past tense. -- `feat: Add cancellation, and fix billing rounding.` bundles two changes. Split. -- `feat(orders): Add cancellation.` uses a scope. Prohibited. - -## Body - -The body is **optional and rarely needed**. Single-purpose commits never have a body. Add a body -ONLY when the reason cannot be inferred from the diff (a non-obvious trade-off, a workaround for -an external bug, a decision worth recording). - -Separate the body from the subject with a blank line. Wrap at 72 characters per line. Explain -why, not what. The diff already shows what. - -## Prose vs. bullets in the body - -**Default to prose.** One or two paragraphs fits almost every commit that has a body at all. - -**Use bullets only when ALL of these are true:** - -1. The commit covers 3 or more independent changes that genuinely belong in the same commit. -2. The list cannot be expressed as continuous prose without becoming disconnected sentences. -3. Each item is independently meaningful (no sub-bullets, no continuation across bullets). - -A two-item bullet list is the wrong shape. Use prose. - -## Bullet formatting (when used) - -Every bullet starts with a capital letter and ends with a period. Imperative verb in present -tense, same as the subject line. Without exception. - -Wrong (do NOT generate): - -- `add the OrderCancelling port` lowercase, missing period. -- `Add the OrderCancelling port` capital but missing period. -- `Adds the OrderCancelling port.` third-person singular instead of imperative. - -## Body example with bullets - -``` -feat: Add order cancellation flow. - -- Add the OrderCancelling inbound port and OrderCancellingHandler. -- Add the CancelOrder command and its validator. -- Cover the cancellation path in the integration test suite. -``` - -## Body example with prose (preferred for most commits) - -``` -fix: Handle zero-amount transactions. - -The payment gateway rejects zero-amount charges with a generic 400 instead -of a documented error code, so the adapter short-circuits before the HTTP -call and raises ZeroAmountNotAllowed directly. -``` - -## Commit splitting - -Prefer one logical change per commit. Refactor commits never modify behavior. When a task -requires multiple types of change, produce multiple commits in order: `refactor` first, then -`feat` or `fix` on top. diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md deleted file mode 100644 index b7e0da4..0000000 --- a/.claude/rules/php-library-documentation.md +++ /dev/null @@ -1,313 +0,0 @@ ---- -description: Standards for README and other public-facing Markdown docs in PHP libraries. -paths: - - "**/*.md" ---- - -# Documentation - -Standards for `README.md` and other public-facing Markdown files in the repository. PHPDoc rules -for `.php` files live in `php-library-code-style.md`. American English applies everywhere (see -the American English section in `php-library-code-style.md`). - -The `CONTRIBUTING.md` file is centralized at -`https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md`. Each library's README and -pull request template link to that location. No local `CONTRIBUTING.md` is created per library. - -## Pre-output checklist - -Verify every item before producing any Markdown documentation. If any item fails, revise before -outputting. - -1. README title is `# ` with spaces between words (`# Building Blocks`, not - `# BuildingBlocks`). -2. License badge is the only badge. No build, coverage, Packagist, or version badges. -3. Header is followed by an anchor-linked table of contents. -4. Table of contents uses `*` for top-level (H2) entries, `+` indented by 4 spaces for - second-level (H3) entries, and `-` indented by 8 spaces for third-level (H4) entries. Every - heading from the document appears in the TOC, except FAQ entries: the FAQ is represented by - a single `* [FAQ](#faq)` line regardless of how many questions it contains. -5. Sections appear in the canonical order: Overview, Installation, How to use, FAQ (optional), - License, Contributing. -6. FAQ exists only when there are genuine points of confusion or unusual design decisions. Skip - it entirely when not needed. -7. **Self-contained code examples** are blocks that include any of: a `use` statement, a - `class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of - executable code. Self-contained blocks open with `?` with zero-padded numbering - (`### 01.`, `### 02.`). -12. FAQ bibliographic citations use the format - `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` -13. License and Contributing sections each follow the canonical one-line template. -14. Repository includes `SECURITY.md`, `.github/ISSUE_TEMPLATE/bug_report.md`, - `.github/ISSUE_TEMPLATE/feature_request.md`, and `.github/PULL_REQUEST_TEMPLATE.md`, each - matching the canonical template in "Other documentation files". - -## README - -### Structure - -The README follows a fixed section order: - -1. **Overview**. One or more paragraphs explaining the problem the library solves and its design - philosophy. Cross-references to related `tiny-blocks` libraries belong here. -2. **Installation**. Composer command in a code block, with no surrounding prose unless strictly - necessary. -3. **How to use**. Runnable examples covering the primary use cases. Each subsection demonstrates - one capability with a heading and a self-contained code block. -4. **FAQ** (optional). Numbered questions that address real points of confusion or unusual design - decisions. -5. **License**. One-line link to the `LICENSE` file. -6. **Contributing**. One-line link to the centralized `CONTRIBUTING.md` in - `tiny-blocks/tiny-blocks`. - -### Header and license badge - -The first line is `# ` followed by a blank line and the license badge: - -```markdown -# Outbox - -[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks//blob/main/LICENSE) -``` - -Replace `` with the library's repository name. The badge is the only badge in the document. - -### Table of contents - -The table of contents is anchor-linked. Top-level (H2) entries use `*`. Second-level (H3) -entries use `+` indented by 4 spaces. Third-level (H4) entries use `-` indented by 8 spaces. -Every heading from the document appears, with one exception: the FAQ is represented by a single -`* [FAQ](#faq)` line. Its questions never appear as TOC sub-entries, regardless of how many -exist. - -```markdown -* [Overview](#overview) -* [Installation](#installation) -* [How to use](#how-to-use) - + [Subtopic A](#subtopic-a) - + [Subtopic B](#subtopic-b) -* [FAQ](#faq) -* [License](#license) -* [Contributing](#contributing) -``` - -Use the third level whenever the document has H4 headings, regardless of whether they form a -two-axis split. The TOC mirrors the document structure exactly. - -```markdown -* [How to use](#how-to-use) - + [Entity](#entity) - - [Single-field identity](#single-field-identity) - - [Compound identity](#compound-identity) - + [Aggregate](#aggregate) -``` - -### Code examples - -Code examples fall into two categories. - -**Self-contained examples** include at least one of: - -- A `use` statement. -- A `class`, `enum`, `interface`, `trait`, or `function` declaration. -- More than 3 lines of executable code. - -They open with `push(records: $order->recordedEvents()); -``` - -**Inline fragment examples** have all of: - -- At most 3 lines of executable code. -- No `use` statements. -- No type declarations. - -Fragments may omit the prologue. - -```php -Code::OK->value; -``` - -The criteria are mechanical: a block that meets any self-contained condition gets the prologue. A block that meets every fragment condition may omit it. There is no middle ground. - -The `#` convention for inline comments applies only to code examples inside Markdown files. PHP -files under `src/` and `tests/` have no inline comments at all, except `# TODO: ` (see -item 16 in `php-library-code-style.md`). - -### FAQ - -FAQ entries are numbered with zero-padded prefixes and end with a question mark: - -```markdown -### 01. Why is DomainEvent close to a marker interface? - -A domain event is a fact about something that happened in the domain. The contract carries only -`revision()` so the library can route schema migrations through upcasters. Everything else -(aggregate identity, sequence number, aggregate type, occurrence timestamp) is envelope metadata -that belongs to `EventRecord`. - -> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8, -> "Domain Events". -``` - -Bibliographic citations follow the format -`> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` The chapter and section -fragments are optional when the title is precise enough on its own. Multiple citations can be -stacked as separate blockquote lines. - -### License and Contributing - -The License section is a single line: - -```markdown -## License - - is licensed under [MIT](LICENSE). -``` - -The Contributing section is a single line pointing to the centralized guideline: - -```markdown -## Contributing - -Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to -contribute to the project. -``` - -## Structured data - -Tables are preferred to prose for any structured information: constructor parameter lists, -builder method catalogs, default value tables, complexity tables, and configuration matrices. -Column layout is chosen per case. No fixed column set is mandated. - -## Other documentation files - -Every library repository includes the following files in addition to the README. Each follows -the canonical template below. - -### SECURITY.md - -```markdown -# Security Policy - -## Supported versions - -Only the latest release receives security updates. - -## Reporting a vulnerability - -Report security vulnerabilities privately via -[GitHub Security Advisories](https://github.com/tiny-blocks//security/advisories/new). - -Please do not disclose the vulnerability publicly until it has been addressed. -``` - -Replace `` with the repository name. - -### .github/ISSUE_TEMPLATE/bug_report.md - -```markdown ---- -name: Bug report -about: Report a bug to help improve the library -labels: bug ---- - -## Description - -A clear and concise description of the bug. - -## Steps to reproduce - -1. -2. -3. - -## Expected behavior - -What should happen. - -## Actual behavior - -What actually happens. - -## Environment - -- PHP version: -- Library version: -- OS: -``` - -### .github/ISSUE_TEMPLATE/feature_request.md - -```markdown ---- -name: Feature request -about: Suggest a feature for the library -labels: enhancement ---- - -## Problem - -What problem does this feature solve? - -## Proposed solution - -How should the feature work? - -## Alternatives considered - -Other approaches considered. -``` - -### .github/PULL_REQUEST_TEMPLATE.md - -```markdown -> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md). - -## Summary - -What this pull request does. - -## Related issue - -Closes #... - -## Checklist - -- [ ] Tests added or updated. -- [ ] Documentation updated when applicable. -- [ ] `composer review` passes. -- [ ] `composer tests` passes. -``` diff --git a/.claude/rules/php-library-github-workflows.md b/.claude/rules/php-library-github-workflows.md deleted file mode 100644 index 396c40a..0000000 --- a/.claude/rules/php-library-github-workflows.md +++ /dev/null @@ -1,287 +0,0 @@ ---- -description: Structure, ordering, and pinning rules for GitHub Actions workflows in PHP libraries. -paths: - - ".github/workflows/**/*.yml" - - ".github/workflows/**/*.yaml" ---- - -# Workflows - -Conventions for GitHub Actions workflows in PHP libraries. CD does not apply. Libraries publish -to Packagist via tags and never deploy. - -`.github/workflows/ci.yml` is mandatory and follows the canonical structure defined in the -"ci.yml" section below. Additional workflow files (security scanning, automated triage, -scheduled tasks, dependency updates, etc.) may exist and follow the general rules in this file. -Their trigger, job structure, and steps are chosen by their purpose. - -The Composer scripts invoked by `ci.yml` (`composer review`, `composer tests`) are defined in -`php-library-tooling.md`. - -## Pre-output checklist - -Verify every item before producing or editing any workflow YAML. If any item fails, revise -before outputting. - -### Rules for every workflow - -These rules apply to `ci.yml` and to every additional workflow in `.github/workflows/`. - -1. Keys at the workflow root follow the canonical order `name`, `on`, `concurrency`, - `permissions`, `jobs`. Keys absent in a given workflow are simply omitted. The relative order - of the remaining keys is preserved. -2. Properties inside a job follow the canonical order `name`, `needs`, `runs-on`, - `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule as above. -3. Inside any block (`env`, `outputs`, `with`, `permissions`), entries are ordered by key length - ascending. -4. The workflow `name`, every job `name`, and every step `name` are mandatory and use sentence - case (`Resolve PHP version`, not `RESOLVE_PHP_VERSION` or `resolve_php_version`). Step names - start with a verb. Job keys describe the job's purpose. Generic keys (`run`, `job`, `do`) are - discouraged in favor of descriptive identifiers (`auto-assign`, `analyze`, `notify`). -5. `concurrency` is set at the workflow root with `cancel-in-progress: true` and a `group` - expression scoped by the workflow's trigger: - - `pull_request`: `-${{ github.event.pull_request.number }}`. - - `issues`, or `issues` combined with `pull_request`: - `-${{ github.event.issue.number || github.event.pull_request.number }}`. - - `push`, `schedule`, or both: `-${{ github.ref }}`. - - `` is the workflow's short name (`ci`, `codeql`, `auto-assign`). -6. `permissions` is declared at the workflow root with the minimum scope every job needs. - Job-level `permissions` blocks are allowed only when a specific job needs a narrower scope - than the root, never broader. -7. Every job sets `timeout-minutes`. Defaults: 5 for trivial steps (single API call, lightweight - script), 15 for jobs with PHP setup or test runs, 30 for analysis-heavy jobs (CodeQL, - security scanning). Adjust based on observed runtime when prior runs exist. -8. Every action is pinned to a fixed major version tag written explicitly. Examples are - `actions/checkout@v6` and `shivammathur/setup-php@v2`. Never use `@latest`, `@main`, a branch - name, or a commit SHA. When the existing pin is an explicit minor or patch, derive the major - version while **preserving the prefix style** of the original tag: `@v2.1.0` → `@v2`, - `@2.1.0` → `@2`. The action's tag convention is reflected in the existing pin. Web lookup is - required only when the existing pin is missing, ambiguous, or pointing to a non-version - reference. Example versions cited in this file may be outdated and are not a license to skip - the lookup when it is required. -9. Inline shell logic longer than 3 lines is extracted to a script in `scripts/ci/`. -10. All text (workflow name, job names, step names, comments) uses American English with correct - spelling and punctuation. Sentences and descriptions end with a period. - -### Rules specific to ci.yml - -These rules apply only to `.github/workflows/ci.yml`. Additional workflows are not bound by them. - -1. File path is `.github/workflows/ci.yml`. The workflow `name` field is exactly `CI`. -2. Trigger is `pull_request` only. No `push`, no branch filter, no `workflow_dispatch`. -3. Jobs run in the fixed sequence `resolve-php-version`, `build`, `auto-review`, `tests`. Each - downstream job lists its upstream jobs in `needs`. -4. PHP version is never hardcoded. The `resolve-php-version` job reads `.require.php` from - `composer.json` at runtime and exposes the minor version (for example, `8.5`) as the job - output `php-version`. Downstream jobs reference - `${{ needs.resolve-php-version.outputs.php-version }}` when setting up PHP. -5. The `auto-review` job runs `composer review`. The `tests` job runs `composer tests`. Both - scripts are defined in `composer.json` per `php-library-tooling.md`. No other command is - invoked in either job. -6. The `build` job uploads `vendor/` and `composer.lock` as a single artifact named - `vendor-artifact`. The `auto-review` and `tests` jobs download that artifact instead of - running `composer install` again. -7. The `tests` job is the only job that may extend with extra setup required by the library, - such as service containers, fixture preparation, or environment variables used during - testing. The other three jobs are identical across every library in the ecosystem. -8. `concurrency.group` is `pr-${{ github.event.pull_request.number }}`. `timeout-minutes` is 5 - for `resolve-php-version` and 15 for `build`, `auto-review`, and `tests`. `permissions` is - `contents: read`. - -## ci.yml - -`ci.yml` is the mandatory workflow that gates every pull request. It contains four jobs in the -exact order below. The first three jobs are identical across every library. Only `tests` may -extend with extra setup required by the library. - -### Resolve PHP version - -Reads `.require.php` from `composer.json` and exposes the minor version (for example, `8.5`) as the -output `php-version`. A single step uses `jq` and a short regex to extract the value. Downstream jobs -consume the output to configure their PHP setup. - -### Build - -Sets up PHP using the resolved version, validates `composer.json`, installs dependencies with -`--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and -`composer.lock` as the artifact `vendor-artifact`. - -### Auto review - -Depends on `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up PHP, and runs -`composer review`. The `review` script in `composer.json` aggregates lint, static analysis, and style -checks for the library. - -### Tests - -Depends on `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up PHP, and runs -`composer tests`. Any setup required by the library's tests (service containers, fixture preparation, -environment variables used during testing) lives in this job only. - -## Reference shape - -The YAML below is the canonical minimal form. Every library starts from this exact shape and extends -only the `tests` job when its tests require extra setup. Action versions cited here may be outdated. -Look up the current major version of every action via web search before adopting this shape verbatim. - -### Minimal workflow - -```yaml -name: CI - -on: - pull_request: - -concurrency: - group: pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - resolve-php-version: - name: Resolve PHP version - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - php-version: ${{ steps.config.outputs.php-version }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Resolve PHP version from composer.json - id: config - run: | - version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) - echo "php-version=$version" >> "$GITHUB_OUTPUT" - - build: - name: Build - needs: resolve-php-version - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - tools: composer:2 - php-version: ${{ needs.resolve-php-version.outputs.php-version }} - - - name: Validate composer.json - run: composer validate --no-interaction - - - name: Install dependencies - run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction - - - name: Upload vendor and composer.lock as artifact - uses: actions/upload-artifact@v7 - with: - name: vendor-artifact - path: | - vendor - composer.lock - - auto-review: - name: Auto review - needs: [resolve-php-version, build] - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - tools: composer:2 - php-version: ${{ needs.resolve-php-version.outputs.php-version }} - - - name: Download vendor artifact from build - uses: actions/download-artifact@v8 - with: - name: vendor-artifact - path: . - - - name: Run review - run: composer review - - tests: - name: Tests - needs: [resolve-php-version, auto-review] - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - tools: composer:2 - php-version: ${{ needs.resolve-php-version.outputs.php-version }} - - - name: Download vendor artifact from build - uses: actions/download-artifact@v8 - with: - name: vendor-artifact - path: . - - - name: Run tests - run: composer tests -``` - -### Extending the tests job - -When the library's tests need external services, env vars, or fixture preparation, the additions live -inside the `tests` job only. The example below shows the same `tests` job extended with a MySQL service -container and the env vars consumed by the test suite. - -```yaml -tests: - name: Tests - needs: [resolve-php-version, auto-review] - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - DB_HOST: 127.0.0.1 - DB_NAME: library_test - DB_PORT: '3306' - DB_USER: library - DB_PASSWORD: library - services: - mysql: - image: mysql:8 - ports: - - 3306:3306 - env: - MYSQL_DATABASE: library_test - MYSQL_ROOT_PASSWORD: library - options: >- - --health-cmd="mysqladmin ping" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - tools: composer:2 - php-version: ${{ needs.resolve-php-version.outputs.php-version }} - - - name: Download vendor artifact from build - uses: actions/download-artifact@v8 - with: - name: vendor-artifact - path: . - - - name: Run tests - run: composer tests -``` diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md deleted file mode 100644 index 127413c..0000000 --- a/.claude/rules/php-library-modeling.md +++ /dev/null @@ -1,276 +0,0 @@ ---- -description: Semantic modeling rules for PHP libraries (nomenclature, value objects, exceptions, enums, extension points, complexity). -paths: - - "src/**/*.php" ---- - -# Modeling - -Library modeling rules. How to model the concepts the library exposes. Folder structure and -public API boundary live in `php-library-architecture.md`. Code style lives in -`php-library-code-style.md`. Tooling lives in `php-library-tooling.md`. - -## Pre-output checklist - -Verify every item before producing any PHP code that defines a model, an exception, or an -algorithm. If any item fails, revise before outputting. - -1. Each model has a single, clear responsibility. Apply DDD, SOLID, DRY, and KISS where they - sharpen the design, not as dogma. -2. Concept names. Every class, property, method, and exception name reflects the concept the - library represents, not a technical role. -3. No always-banned names. Never use `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity` as - class suffix, prefix, or method name. Never use `Exception` as a class suffix. Exception: - names that correspond to externally standardized identifiers (HTTP status text from RFC - documents, PSR interface names being mirrored, etc.) are permitted. The standard reference - is the meaning carrier. -4. No anemic verbs as the primary operation name (`ensure`, `validate`, `check`, `verify`, - `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, `transform`, `parse`) unless - the verb is the library's reason to exist. -5. Architectural role names (`Manager`, `Handler`, `Processor`, `Service`, and their verb forms - `process`, `handle`, `execute`) are allowed only when the class IS that role for consumers - integrating with the library. -6. Value objects are immutable. No setters. Operations return new instances. -7. Value objects compare by value, never by reference. No identity field. -8. Value objects validate invariants in the constructor and throw a dedicated exception on - invalid input. -9. Value objects with multiple creation paths use static factory methods (`from`, `of`, `zero`) - with a private constructor. -10. Every failure throws a dedicated exception class named after the invariant it guards. Never - `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`, or any other - generic native exception directly. -11. Dedicated exception classes extend the appropriate native PHP exception (`DomainException`, - `InvalidArgumentException`, `OverflowException`, etc.). -12. Exceptions are pure. No transport-specific fields (HTTP status in `code`, formatted message - for end-user display). They signal invariant violations only, never control flow. -13. Enums are PHP backed enums. They include methods only when those methods carry vocabulary - meaning. -14. Extension points use `class` instead of `final readonly class`. They expose a private - constructor with static factory methods as the only creation path. Internal state is - injected via the constructor. -15. Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) - or worse needs explicit justification. -16. Prefer lazy or streaming evaluation over materializing intermediate results. Memory usage - is bounded and proportional to the output, not to the sum of intermediate stages. - -## Modeling principles - -Apply the following principles where they sharpen the design. Treat them as guides, not as dogma. - -- Single responsibility. Each model represents one concept, has one reason to change, and - exposes operations that belong to that concept. -- DDD ubiquitous language. Names, types, and operations match the vocabulary the library's - domain uses. Code and conversation share the same terms. -- SOLID. Interfaces define narrow contracts. Composition is preferred to inheritance. - Substitutability holds at every interface boundary. -- DRY. No duplicated logic across two or more places. -- KISS. No abstraction without real duplication or isolation need. - -## Nomenclature - -- Every class, property, method, and exception name reflects the concept the library represents. - A math library uses `Precision` and `RoundingMode`. A money library uses `Currency` and - `Amount`. A collection library uses `Collectible` and `Order`. -- Name classes after what they represent, not after what they do technically. Use `Money`, - `Color`, `Pipeline`, not `MoneyCalculator`, `ColorHelper`, `PipelineProcessor`. -- Name methods after the operation in the library's vocabulary. Use `add()`, `convertTo()`, - `splitAt()`, not `compute()`, `process()`, `handle()`. - -### Always banned - -These names carry zero semantic content. Never use them anywhere as class suffix, prefix, or -method name. - -- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. -- `Exception` as a class suffix (e.g., `FooException`). Use the invariant name when extending a - native exception (e.g., `PrecisionOutOfRange`, not `InvalidPrecisionException`). - -### Externally standardized names (exception to the banlist) - -Names that correspond to externally standardized identifiers are exempt from the banlist. The -standard reference is the meaning carrier. Renaming weakens it. Examples: - -- HTTP status text from RFC documents (`unprocessableEntity` from RFC 4918, `noContent`). -- PSR interface names being mirrored as test doubles (`ClientException` mirroring - `Psr\Http\Client\ClientExceptionInterface`). -- Unicode category names, locale identifiers, MIME type tokens, and similar registered names. - -This exception applies only when the external standard is the actual source of the name. It -does not authorize using `Data` or `Entity` as generic suffixes when no external reference is -involved. - -### Anemic verbs - -These verbs hide what is actually happening behind a generic action. Banned unless the verb IS -the operation that constitutes the library's reason to exist (e.g., a JSON parser may have -`parse()`, a hashing library may have `compute()`). - -- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, - `compute`, `transform`, `parse`. - -When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`. -`Email::parse()` is fine in a parser library but suspicious elsewhere. Use `Email::from()` -instead. - -### Architectural roles - -These names describe a role the library offers as a building block. Acceptable when the class IS -that role (e.g., `EventHandler` in an events library, `CacheManager` in a cache library, -`Upcaster` in an event-sourcing library). Not acceptable on domain objects inside the library -(value objects, enums, contract interfaces). - -- `Manager`, `Handler`, `Processor`, `Service`. -- Verb forms: `process`, `handle`, `execute`. - -The test. If the consumer instantiates or extends this class to integrate with the library, the -role name is legitimate. If the class models a concept the consumer manipulates (a money amount, -a country code, a color), the role name is wrong. - -## Value objects - -- Are immutable. No setters. No mutation after construction. Operations return new instances. -- Compare by value, not by reference. -- Validate invariants in the constructor and throw a dedicated exception on invalid input. -- Have no identity field. -- Use static factory methods (`from`, `of`, `zero`) with a private constructor when multiple - creation paths exist. The factory name communicates the semantic intent. - -**Prohibited.** Public constructor with multiple creation paths. Semantics are unclear at the -call site: - -```php -final readonly class Money -{ - public function __construct(public int $amount, public Currency $currency) {} -} - -new Money(amount: 1000, currency: Currency::BRL); -new Money(amount: 0, currency: Currency::USD); -``` - -**Correct.** Private constructor with named factory methods. Each factory name communicates -intent: - -```php -final readonly class Money -{ - private function __construct(public int $amount, public Currency $currency) {} - - public static function of(int $amount, Currency $currency): Money - { - return new Money(amount: $amount, currency: $currency); - } - - public static function zero(Currency $currency): Money - { - return new Money(amount: 0, currency: $currency); - } -} - -Money::of(amount: 1000, currency: Currency::BRL); -Money::zero(currency: Currency::USD); -``` - -## Exceptions - -- Every failure throws a dedicated exception class named after the invariant it guards. Never - `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`, - `throw new RuntimeException(...)`, or any other generic native exception directly. If the - invariant is worth throwing for, it is worth a named class. -- Dedicated exception classes extend the appropriate native PHP exception (`DomainException`, - `InvalidArgumentException`, `OverflowException`, etc.). The native class is the parent, never - the thing that is thrown. Consumers that catch the broad standard types continue to work. - Consumers that need precise handling can catch the specific classes. -- Exceptions are pure. No transport-specific fields (`code` populated with HTTP status, - formatted `message` meant for end-user display). Formatting to any transport happens at the - consumer's boundary, not inside the library. -- Exceptions signal invariant violations only, not control flow. -- Name the class after the invariant violated, never after the technical type. Use - `PrecisionOutOfRange`, not `InvalidPrecisionException`. Use `CurrencyMismatch`, not - `BadCurrencyException`. Use `ContainerWaitTimeout`, not `TimeoutException`. -- A descriptive `message` argument is allowed and encouraged when it carries debugging context - (the violating value, the boundary crossed, the state the library was in). The class name - identifies the invariant. The message describes the specific violation for stack traces and - test assertions. Keep messages short, factual, and in American English. - -**Prohibited.** Throwing a native exception directly: - -```php -if ($value < 0) { - throw new InvalidArgumentException('Precision cannot be negative.'); -} -``` - -**Correct.** Dedicated class, no message (class name is sufficient): - -```php -final class PrecisionOutOfRange extends InvalidArgumentException -{ -} - -if ($value < 0) { - throw new PrecisionOutOfRange(); -} -``` - -**Correct.** Dedicated class with debugging context in the message: - -```php -if ($value < 0 || $value > 16) { - $template = 'Precision must be between 0 and 16, got %d.'; - - throw new PrecisionOutOfRange(message: sprintf($template, $value)); -} -``` - -## Enums - -- Are PHP backed enums. -- Include methods only when those methods carry vocabulary meaning. Examples are - `Order::ASCENDING_KEY` and `RoundingMode::apply()`. - -## Extension points - -- A class designed to be extended by consumers (e.g., `Collection`, `ValueObject`) uses `class` - instead of `final readonly class`. All other classes use `final readonly class`. See - "Inheritance and constructors" in `php-library-code-style.md`. -- Extension point classes use a private constructor with static factory methods (`createFrom`, - `createFromEmpty`) as the only creation path. -- Internal state is injected via the constructor and stored in a `private readonly` property. - -## Time and space complexity - -- Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) or - worse needs explicit justification at the point of definition. -- Prefer lazy or streaming evaluation over materializing intermediate results. In pipeline-style - libraries, fuse stages so a single pass suffices over the input. -- Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. -- Never re-iterate the same source. When a sequence is consumed once, use lazy creation - primitives (`createLazyFrom`) instead of materializing. - -**Prohibited.** Eager pipeline that materializes between stages: - -```php -$paidTotals = array_map( - static fn(Order $order): float => $order->total(), - array_filter( - $orders->toArray(), - static fn(Order $order): bool => $order->isPaid() - ) -); -``` - -Each stage allocates a full intermediate array. Memory grows with the input size, even when only -the final scalar matters. - -**Correct.** Fused pipeline that runs in a single pass: - -```php -$paidTotals = $orders - ->filter(predicates: static fn(Order $order): bool => $order->isPaid()) - ->map(transformations: static fn(Order $order): float => $order->total()) - ->toArray(keyPreservation: KeyPreservation::DISCARD); -``` - -Operations stack on the same iterator. No intermediate array is built. Memory stays bounded by -the final output. diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md deleted file mode 100644 index 81fdfb8..0000000 --- a/.claude/rules/php-library-testing.md +++ /dev/null @@ -1,328 +0,0 @@ ---- -description: BDD Given/When/Then structure, PHPUnit conventions, fixture rules, and coverage discipline. -paths: - - "tests/**/*.php" ---- - -# Testing - -PHPUnit conventions for tests in PHP libraries. Covers BDD structure, fixture rules, and coverage -discipline. Code style applies to test files as well. See `php-library-code-style.md`. Folder -structure for `tests/` lives in `php-library-architecture.md`. Canonical thresholds (MSI 100, -covered MSI 100) live in `php-library-tooling.md`. - -## Pre-output checklist - -Verify every item before producing any test code. If any item fails, revise before outputting. - -1. Each test contains exactly one `@When` block. Two actions require two tests. -2. Use `@And` for complementary preconditions or actions within the same scenario, avoiding - consecutive `@Given` or `@When` tags. -3. Each `@Given` or `@And` block contains exactly one annotation line followed by one expression - or assignment. Never place multiple variable declarations or object constructions under a - single annotation. **Exception for data-provider tests.** When the test method binds its - inputs through a `#[DataProvider]` attribute (or the equivalent `@dataProvider` annotation), - the `@Given` block may declare the input shape in prose form, without an expression below - it. The values are bound by PHPUnit before the test body runs, so the prose annotation - replaces the assignment that would otherwise sit under the `@Given`. - - `@When` blocks follow the same one-expression rule by default: the block represents the - single action under test. **Exception for repeated-invocation tests** (idempotence, caching, - memoization). When the purpose of the test is asserting that the same operation produces the - same outcome across N invocations, the `@When` block may contain N consecutive identical - invocations, each captured in a numbered variable (`$first`, `$second`, ...), and the - annotation reads `@When invoked twice` (or thrice, etc.) to make the composite-action - semantic explicit. Two unrelated actions still require two tests. -4. No intermediate variables used only once. Chain method calls when the intermediate state is - not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of - `$money = Money::of(...)` followed by `$money->add(...)`). -5. No private or helper methods in test classes. The only non-test methods allowed are PHPUnit - lifecycle hooks (`setUp`, `setUpBeforeClass`, `tearDown`, `tearDownAfterClass`) and data - providers. Setup logic complex enough to extract belongs in a dedicated fixture class. -6. Test only the public API. Never assert on private state or `Internal/` classes directly. -7. Test the behavior that **raises** an exception, never the exception itself. Exception classes - represent invariant violations and are value objects, not the subject of behavior tests. A - test constructs the conditions, invokes the public method that is supposed to fail, and - asserts the expected exception class is raised (plus its accessor values when they carry - information relevant to the failure). Constructing an exception directly - (`new HttpRequestInvalid(...)`) and asserting on its accessors is **prohibited**: the - exception's structure is exercised through the call path that produces it. If a method does - not exist whose call path produces the exception, the exception is dead code and should be - removed. -8. Never mock internal collaborators. Use real objects. Test doubles are used only at system - boundaries (filesystem, clock, network) when the library interacts with external resources. -9. Name tests after behavior, not method names. -10. Use domain-specific names in variables and properties. Never `$spy`, `$mock`, `$stub`, - `$fake`, `$dummy` as variable or property names. Use the domain concept the object - represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like - `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what matters. -11. Annotations use domain language. Write `/** @Given a collection of amounts */`, not - `/** @Given a mocked collection in test state */`. -12. Never use the `/** @test */` annotation. Test methods are discovered by the `test` prefix in - the method name. -13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, - `expectException`, etc.). Pass arguments positionally. -14. Never include conditional logic inside tests. Each `@Then` block expresses one logical - concept. The only allowed `try`/`catch` is when the assertion target is a property of the - caught exception that cannot be expressed via `expectException*` methods (notably - `getPrevious()` for chain inspection). The catch block contains only assertions against the - caught exception, no branching. -15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from - coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See - "Coverage and mutation discipline". -16. Member ordering in test classes follows `php-library-code-style.md` rule 6 (PHPUnit - test-class sub-grouping). - -## Structure: Given/When/Then (BDD) - -Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments -without exception. - -### Happy path example - -```php -public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void -{ - /** @Given two money instances in the same currency */ - $ten = Money::of(amount: 1000, currency: Currency::BRL); - - /** @And another money instance with the same currency */ - $five = Money::of(amount: 500, currency: Currency::BRL); - - /** @When adding them together */ - $total = $ten->add(other: $five); - - /** @Then the result contains the sum of both amounts */ - self::assertEquals(1500, $total->amount()); -} -``` - -### Exception example - -When testing that an exception is thrown, place `@Then` (`expectException`) before `@When`. -PHPUnit requires this ordering. - -```php -public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void -{ - /** @Given two money instances in different currencies */ - $brl = Money::of(amount: 1000, currency: Currency::BRL); - - /** @And another money instance with a different currency */ - $usd = Money::of(amount: 500, currency: Currency::USD); - - /** @Then an exception indicating currency mismatch should be thrown */ - $this->expectException(CurrencyMismatch::class); - - /** @When trying to add money with different currencies */ - $brl->add(other: $usd); -} -``` - -Use `@And` for complementary preconditions or actions within the same scenario, avoiding -consecutive `@Given` or `@When` tags. - -## Testing exceptions - -Exception classes are value objects describing an invariant violation. They are not the subject -of behavior tests. A test verifies that a public method, under specific conditions, raises a -specific exception. Constructing the exception directly and asserting on its accessors is -prohibited. The exception's structure is exercised through the call path that produces it. - -**Prohibited.** Testing the exception as a value object: - -```php -public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void -{ - /** @Given a URL */ - $url = 'https://api.example.com'; - - /** @And an HTTP method */ - $method = Method::GET; - - /** @And a reason */ - $reason = 'Connection refused.'; - - /** @When the exception is constructed */ - $exception = HttpNetworkFailed::from(url: $url, method: $method, reason: $reason); - - /** @Then it exposes the URL */ - self::assertSame($url, $exception->url()); -} -``` - -The test constructs the exception in isolation and asserts on its accessors. No production code -is exercised. The same coverage is achieved (and made meaningful) by the test below, which -drives the path that raises the exception. - -**Correct.** Testing the behavior that raises the exception: - -```php -public function testSendRequestWhenTransportCannotReachServerThenThrowsHttpNetworkFailed(): void -{ - /** @Given an HTTP client backed by a transport that always raises a network error */ - $http = Http::usingTransport(transport: new ThrowingClient()); - - /** @And a target request to that transport */ - $request = Request::create(url: 'https://api.example.com', method: Method::GET); - - /** @Then a network failure exception describing the unreachable target is raised */ - $this->expectException(HttpNetworkFailed::class); - - /** @When the request is sent */ - $http->send(request: $request); -} -``` - -When the accessor values on the raised exception are part of the assertion, `expectException` -alone is not enough (it asserts only the class). Use a `try`/`catch` block as permitted by -rule 14. The catch block contains only assertions against the caught exception, no branching. - -```php -public function testSendRequestWhenTargetUnreachableThenExceptionCarriesUrlAndMethod(): void -{ - /** @Given an HTTP client backed by a transport that always raises a network error */ - $http = Http::usingTransport(transport: new ThrowingClient()); - - /** @And a target request to that transport */ - $request = Request::create(url: 'https://api.example.com', method: Method::GET); - - try { - /** @When the request is sent */ - $http->send(request: $request); - } catch (HttpNetworkFailed $failure) { - /** @Then the exception exposes the target URL and method */ - self::assertSame('https://api.example.com', $failure->url()); - self::assertSame(Method::GET, $failure->method()); - } -} -``` - -If a method does not exist whose call path produces the exception, the exception itself is dead -code. Remove it instead of writing a behavior test against a constructor. - -**The `try`/`catch` form is reserved for assertions that PHPUnit's `expectException*` family -does not cover.** Message, code, and class are covered by PHPUnit (`expectException`, -`expectExceptionMessage`, `expectExceptionMessageMatches`, `expectExceptionCode`): use those -methods, not `try`/`catch`. The only case that warrants `try`/`catch` is inspecting accessors -that PHPUnit cannot reach — notably `getPrevious()` for chain inspection, or domain-specific -accessors on a `TransportFailure` (`url()`, `method()`, `reason()`). - -**Prohibited.** `try`/`catch` to assert message: - -```php -try { - $http->send(request: $request); - self::fail('NoMoreResponses was expected.'); -} catch (NoMoreResponses $exception) { - self::assertStringContainsString('queue exhausted', $exception->getMessage()); -} -``` - -**Correct.** PHPUnit's `expectExceptionMessage`: - -```php -$this->expectException(NoMoreResponses::class); -$this->expectExceptionMessage('queue exhausted'); - -$http->send(request: $request); -``` - -## Test setup and fixtures - -- Each `@Given` or `@And` block contains exactly one annotation followed by one expression or - assignment. Never place multiple declarations under a single annotation. The exception for - data-provider tests applies here as well (see rule 3). -- No intermediate variables used only once. Chain method calls when the intermediate state is - not referenced elsewhere. -- No private or helper methods in test classes. The only non-test methods allowed are data - providers. Setup logic complex enough to extract belongs in a dedicated fixture class, not in - a private method on the test class. -- Domain terms in variables and properties. Never use technical testing jargon (`$spy`, `$mock`, - `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object - represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like - `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what - matters. -- Annotations use domain language. Write `/** @Given a collection of amounts */`, not - `/** @Given a mocked collection in test state */`. The annotation describes the domain - scenario, not the technical setup. - -**Prohibited.** Multiple declarations under a single annotation: - -```php -/** @And two money instances in different currencies */ -$usd = Money::of(amount: 500, currency: Currency::USD); -$eur = Money::of(amount: 300, currency: Currency::EUR); -``` - -**Correct.** One annotation per declaration: - -```php -/** @And a money instance in USD */ -$usd = Money::of(amount: 500, currency: Currency::USD); - -/** @And a money instance in EUR */ -$eur = Money::of(amount: 300, currency: Currency::EUR); -``` - -**Also prohibited.** Setup multi-statement grouped under a single annotation because "the -statements build one coherent concept": - -```php -/** @Given transport seeded with two responses */ -$first = Response::with(code: Code::OK); -$second = Response::with(code: Code::CREATED); -$transport = InMemoryTransport::with(responses: [$first, $second]); -``` - -Three statements, one annotation. The fact that the three lines together build a single -setup concept is **not** a license to share one annotation. Each declaration takes its own -`@And` block. The same applies under `@When` when the test prepares the input alongside the -action: the input preparation goes back to `@And` under `@Given`, and `@When` contains only -the action under test. - -**Correct.** Each statement keeps its own annotation: - -```php -/** @Given a first queued response */ -$first = Response::with(code: Code::OK); - -/** @And a second queued response */ -$second = Response::with(code: Code::CREATED); - -/** @And transport with both responses */ -$transport = InMemoryTransport::with(responses: [$first, $second]); -``` - -## Test doubles - -Conventions for naming and locating test doubles (mocks, spies, stubs, fakes, dummies). - -### Naming - -- Variables and properties never carry the technical role in their name. Never `$spy`, `$mock`, - `$stub`, `$fake`, `$dummy`. Use the domain concept the object represents (`$gateway`, - `$clock`, `$repository`, `$client`). -- Class names may carry the technical role as suffix when the class IS a test double - (`ClientMock`, `GatewaySpy`, `ClockFake`). The suffix signals that the file is a collaborator - built for tests, not a production type. - -### Location - -- Test doubles live at the root of `tests/Unit/`. When integration tests exist, doubles used - there live at the root of `tests/Integration/`. -- No dedicated `Mocks/` or `Doubles/` subdirectory exists. -- Domain fixtures that represent real domain concepts live in `tests/Models/`. See - `php-library-architecture.md` for the canonical `tests/` folder layout. - -## Coverage and mutation discipline - -- Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from coverage. -- Never suppress mutants via `infection.json.dist` or any other mechanism. -- If a line or mutation cannot be covered or killed, the design is wrong. Refactor the - production code to make it testable. Never work around the tool. - -Canonical thresholds (MSI 100, covered MSI 100) live in `php-library-tooling.md`. They are -enforced by `infection.json.dist`. Achieving MSI 100 implies effective full coverage of `src/` -because every mutation must be killed by an assertion. This file covers only the behavioral -rules that complement those thresholds. diff --git a/.claude/rules/php-library-tooling.md b/.claude/rules/php-library-tooling.md deleted file mode 100644 index 3b55111..0000000 --- a/.claude/rules/php-library-tooling.md +++ /dev/null @@ -1,464 +0,0 @@ ---- -description: Canonical config files for PHP libraries in the tiny-blocks ecosystem. -paths: - - "composer.json" - - "phpcs.xml" - - "phpstan.neon.dist" - - "phpunit.xml" - - "infection.json.dist" - - ".editorconfig" - - ".gitattributes" - - ".gitignore" - - "Makefile" ---- - -# Tooling - -Canonical configuration files for a PHP library in the tiny-blocks ecosystem. Each file has a -fixed shape. Deviations require justification. Folder structure lives in -`php-library-architecture.md`. Code style lives in `php-library-code-style.md`. - -## Pre-output checklist - -Verify every item before creating, editing, or relocating any of the files below. If any item -fails, revise before outputting. - -1. The library repository contains all the following files at its root: `composer.json`, - `phpcs.xml`, `phpstan.neon.dist`, `phpunit.xml`, `infection.json.dist`, `.editorconfig`, - `.gitattributes`, `.gitignore`, `Makefile`. -2. `composer.json` exposes exactly five scripts: `configure`, `configure-and-update`, `review`, - `test-file`, `tests`. No other public scripts are defined. -3. `composer.json` fixed fields use the canonical values defined in the "composer.json" section - (`license`, `type`, `minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`). -4. `composer.json` `description` is a single short sentence describing what the library does. - Multi-sentence or multi-paragraph descriptions belong in the README Overview, not in Composer - metadata. -5. `composer.json` includes a `keywords` array. The first keyword is always `"tiny-blocks"`. - Additional keywords are topic tokens derived from the library's purpose (`psr-7`, - `http-client`, `event-sourcing`, etc.). -6. `phpcs.xml` references only the `PSR12` ruleset. No additional sniffs are added. -7. `phpunit.xml` sets all five `failOn*` flags to `true`: `failOnDeprecation`, `failOnNotice`, - `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`. -8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`. -9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either value is - prohibited. -10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`, and - `end_of_line = lf` for PHP files. YAML uses `indent_size = 2`. Makefile uses `indent_style = tab`. -11. `.gitattributes` sets `* text=auto eol=lf` and lists every dev-only file under `export-ignore`. - The Packagist tarball contains only `src/`, `composer.json`, `README.md`, and `LICENSE`. - `.claude/` is listed under `export-ignore` (versioned on GitHub for contributor parity, - excluded from the published package). -12. `.gitignore` follows the canonical content in the ".gitignore" section. `.claude/` is **not** - listed (it is versioned on GitHub). -13. `Makefile` wraps every PHP and Composer command in a Docker container using the canonical - image `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly. -14. All test artifact paths use `reports/` (plural). The directory is consistent across - `composer tests`, `infection.json.dist`, `phpunit.xml`, and `Makefile`. -15. The `reports/` directory is listed under `export-ignore` in `.gitattributes`. - -## composer.json - -Fixed fields, identical in every library: `license`, `type`, `minimum-stability`, `prefer-stable`, -`require.php`, `authors`, `config.allow-plugins`, `config.sort-packages`, `scripts`, and the five -universal dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`, -`phpunit/phpunit`, `squizlabs/php_codesniffer`). - -Per-library fields, vary by library: `name`, `description`, `keywords`, `homepage`, `support`, -`autoload`, `autoload-dev`. The `require-dev` section may add libraries needed by tests (for -example, HTTP client implementations in a PSR-7 library) on top of the five universal tools. - -```json -{ - "name": "tiny-blocks/", - "description": "", - "license": "MIT", - "type": "library", - "keywords": [ - "tiny-blocks", - "", - "" - ], - "authors": [ - { - "name": "Gustavo Freze de Araujo Santos", - "homepage": "https://github.com/gustavofreze" - } - ], - "homepage": "https://github.com/tiny-blocks/", - "support": { - "issues": "https://github.com/tiny-blocks//issues", - "source": "https://github.com/tiny-blocks/" - }, - "require": { - "php": "^8.5" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.51", - "infection/infection": "^0.32", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^13.1", - "squizlabs/php_codesniffer": "^4.0" - }, - "minimum-stability": "stable", - "prefer-stable": true, - "autoload": { - "psr-4": { - "TinyBlocks\\\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Test\\TinyBlocks\\\\": "tests/" - } - }, - "config": { - "allow-plugins": { - "ergebnis/composer-normalize": true, - "infection/extension-installer": true - }, - "sort-packages": true - }, - "scripts": { - "configure": [ - "@composer install --optimize-autoloader", - "@composer normalize" - ], - "configure-and-update": [ - "@composer update --optimize-autoloader", - "@composer normalize" - ], - "review": [ - "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", - "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" - ], - "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", - "tests": [ - "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" - ] - } -} -``` - -Script usage: - -- `composer configure` runs `composer install --optimize-autoloader` followed by `composer normalize`. - Use this after cloning the repository or pulling new changes. -- `composer configure-and-update` runs `composer update --optimize-autoloader` followed by - `composer normalize`. Use this when intentionally updating dependencies. -- `composer review` runs `phpcs` and `phpstan` in sequence. Used by CI and local validation. -- `composer tests` runs `phpunit` followed by `infection`. Used by CI. -- `composer test-file ` runs a filtered subset of tests without coverage. Local - development only. - -## phpcs.xml - -References only the `PSR12` ruleset. Additional formatting rules (vertical alignment, trailing -comma, etc.) live in `php-library-code-style.md` under "Formatting overrides". - -```xml - - - Code style for the tiny-blocks library. - - src - tests - -``` - -## phpstan.neon.dist - -Static analysis configuration. Runs at the highest level on both `src/` and `tests/`. Invoked -by the `review` Composer script. - -```neon -parameters: - level: max - paths: - - src - - tests - reportUnmatchedIgnoredErrors: true -``` - -`ignoreErrors` is permitted to suppress legitimate false positives produced by `level: max` -(third-party type signatures with `mixed`, PHP-FIG interfaces returning untyped arrays, trait -unused-method warnings on shared behavior, etc.). Each entry follows these rules: - -- A short comment above the entry justifies its existence. -- Prefer scoping via `identifier:` plus `path:` over raw `#...#` message patterns. -- `reportUnmatchedIgnoredErrors: true` is mandatory. Obsolete entries fail the build, forcing - cleanup. - -Example with `ignoreErrors`: - -```neon -parameters: - level: max - paths: - - src - - tests - ignoreErrors: - # Trait method intentionally unused by the consuming aggregate; reflection wires it. - - identifier: trait.unused - path: src/Internal/EventualAggregateRootBehavior.php - - # json_encode signature carries `mixed` for backward compatibility at level max. - - identifier: argument.type - path: src/Internal/Serialization/JsonEncoder.php - reportUnmatchedIgnoredErrors: true -``` - -## phpunit.xml - -Strict configuration. All `failOn*` flags are `true`. `executionOrder="random"` forces tests to be -independent of one another. Coverage and JUnit reports go under `reports/`. - -```xml - - - - - - src - - - - - - tests - - - - - - - - - - - - - - - - - -``` - -Root attributes are sorted alphabetically. - -## infection.json.dist - -Mutation testing configuration. `minMsi` and `minCoveredMsi` are both `100`. Mutants that escape -make the build fail. - -```json -{ - "logs": { - "text": "reports/infection/logs/infection-text.log", - "summary": "reports/infection/logs/infection-summary.log" - }, - "tmpDir": "reports/infection/", - "minMsi": 100, - "timeout": 30, - "source": { - "directories": [ - "src" - ] - }, - "phpUnit": { - "configDir": "", - "customPath": "./vendor/bin/phpunit" - }, - "mutators": { - "@default": true - }, - "minCoveredMsi": 100, - "testFramework": "phpunit" -} -``` - -## .editorconfig - -Whitespace and line ending rules applied by editor integrations. - -```ini -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_size = 4 -indent_style = space -max_line_length = 120 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{yml,yaml}] -indent_size = 2 - -[Makefile] -indent_style = tab - -[*.md] -trim_trailing_whitespace = false -``` - -## .gitattributes - -Normalizes line endings to LF and excludes every dev-only file from the Packagist tarball. The -published package contains only `src/`, `composer.json`, `README.md`, and `LICENSE`. - -``` -* text=auto eol=lf - -*.php text diff=php - -# Dev-only, excluded from the Packagist tarball -/.github export-ignore -/tests export-ignore -/.claude export-ignore -/.editorconfig export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/phpunit.xml export-ignore -/phpunit.xml.dist export-ignore -/phpstan.neon export-ignore -/phpstan.neon.dist export-ignore -/phpcs.xml export-ignore -/phpcs.xml.dist export-ignore -/infection.json export-ignore -/infection.json.dist export-ignore -/Makefile export-ignore -/CONTRIBUTING.md export-ignore -/CHANGES.md export-ignore -/reports export-ignore -/.phpunit.cache export-ignore -``` - -## .gitignore - -Keeps the repository working tree clean of artifacts that should never be committed. Entries -are grouped from most fundamental (PHP dependencies) to least critical (OS files). The -`.claude/` directory is **not** listed here. It is versioned on GitHub so other contributors -share the same rules, and it is excluded from the published Packagist tarball through -`export-ignore` in `.gitattributes` (see above). - -``` -# PHP dependencies -/vendor/ -composer.lock - -# Tooling cache -.phpcs-cache -.phpunit.cache/ -.php-cs-fixer.cache -.phpunit.result.cache - -# Coverage and reports -build/ -reports/ -coverage/ -infection.log - -# Editors and agents -.idea/ -.cursor/ -.vscode/ - -# OS -Thumbs.db -.DS_Store -Desktop.ini -``` - -## Makefile - -Thin wrapper over Composer scripts. Every PHP and Composer command runs inside a Docker container -using the canonical image `gustavofreze/php:8.5-alpine`. Targets that match a Composer script -delegate to it directly, avoiding duplication. - -```makefile -PWD := $(CURDIR) -ARCH := $(shell uname -m) -PLATFORM := - -ifeq ($(ARCH),arm64) - PLATFORM := --platform=linux/amd64 -endif - -DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine - -RESET := \033[0m -GREEN := \033[0;32m -YELLOW := \033[0;33m - -.DEFAULT_GOAL := help - -.PHONY: configure -configure: ## Configure development environment - @${DOCKER_RUN} composer configure - -.PHONY: configure-and-update -configure-and-update: ## Configure development environment and update dependencies - @${DOCKER_RUN} composer configure-and-update - -.PHONY: tests -tests: ## Run unit and mutation tests with coverage - @${DOCKER_RUN} composer tests - -.PHONY: test-file -test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) - @${DOCKER_RUN} composer test-file ${FILE} - -.PHONY: review -review: ## Run lint and static analysis - @${DOCKER_RUN} composer review - -.PHONY: show-reports -show-reports: ## Open coverage and mutation reports in the browser - @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html - -.PHONY: show-outdated -show-outdated: ## Show outdated direct dependencies - @${DOCKER_RUN} composer outdated --direct - -.PHONY: clean -clean: ## Remove dependencies and generated artifacts - @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf reports vendor .phpunit.cache *.lock - -.PHONY: help -help: ## Display this help message - @echo "Usage: make [target]" - @echo "" - @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" - @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' - @echo "" - @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" - @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' - @echo "" - @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" - @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' - @echo "" - @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" - @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' - @echo "" - @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" - @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' -``` diff --git a/.gitattributes b/.gitattributes index eedb473..f044953 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,23 +2,18 @@ *.php text diff=php +# Keep Claude tooling scripts out of GitHub's language statistics + # Dev-only, excluded from the Packagist tarball /.github export-ignore /tests export-ignore -/.claude export-ignore /.editorconfig export-ignore /.gitattributes export-ignore /.gitignore export-ignore +/phpcs.xml export-ignore /phpunit.xml export-ignore -/phpunit.xml.dist export-ignore -/phpstan.neon export-ignore /phpstan.neon.dist export-ignore -/phpcs.xml export-ignore -/phpcs.xml.dist export-ignore -/infection.json export-ignore /infection.json.dist export-ignore /Makefile export-ignore -/CONTRIBUTING.md export-ignore -/CHANGES.md export-ignore /reports export-ignore /.phpunit.cache export-ignore diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index c33581d..e87e331 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -18,7 +18,7 @@ permissions: jobs: auto-assign: - name: Auto assign issues and pull requests + name: Auto assign runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 7d404f2..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Security checks - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - schedule: - - cron: "0 0 * * *" - -concurrency: - group: codeql-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - security-events: write - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - language: ["actions"] - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore index 6107765..29546dd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,15 @@ /vendor/ composer.lock +# Local config overrides (committed baselines are the .dist files) +/phpstan.neon +/infection.json + # Tooling cache -.phpcs-cache .phpunit.cache/ -.php-cs-fixer.cache .phpunit.result.cache +__pycache__/ +*.pyc # Coverage and reports build/ @@ -18,6 +22,7 @@ infection.log .idea/ .cursor/ .vscode/ +/.claude/settings.local.json # OS Thumbs.db diff --git a/Makefile b/Makefile index 4f0e85d..90ab50d 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,9 @@ ifeq ($(ARCH),arm64) PLATFORM := --platform=linux/amd64 endif -DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine +TTY := $(shell [ -t 0 ] && echo -it) + +DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine RESET := \033[0m GREEN := \033[0;32m diff --git a/composer.json b/composer.json index 0176d26..6e671f5 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,10 @@ }, "require-dev": { "ergebnis/composer-normalize": "^2.52", - "infection/infection": "^0.33", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^13.1", + "infection/infection": "^0.34", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^13.2", + "slevomat/coding-standard": "^8.31", "squizlabs/php_codesniffer": "^4.0" }, "minimum-stability": "stable", @@ -47,6 +48,7 @@ }, "config": { "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, "ergebnis/composer-normalize": true, "infection/extension-installer": true }, diff --git a/phpcs.xml b/phpcs.xml index a52372c..96c803e 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,7 +1,97 @@ Code style for the tiny-blocks library. - + src tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +