From 4a2e9381b1c53d12531cde6ece48f70b1545d493 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 6 Aug 2026 20:45:11 -0300 Subject: [PATCH 1/5] chore: Remove the versioned Claude configuration. --- .claude/rules/php-library-architecture.md | 147 --- .claude/rules/php-library-code-style.md | 960 ------------------ .claude/rules/php-library-documentation.md | 203 ---- .claude/rules/php-library-github-workflows.md | 104 -- .claude/rules/php-library-modeling.md | 314 ------ .claude/rules/php-library-testing.md | 372 ------- .claude/rules/php-library-tooling.md | 138 --- .claude/settings.json | 232 ----- .claude/skills/commit-message/SKILL.md | 119 --- .claude/skills/tiny-blocks-consume/SKILL.md | 68 -- .../tiny-blocks-consume/references/catalog.md | 32 - .../scripts/refresh-catalog.py | 102 -- .claude/skills/tiny-blocks-create/SKILL.md | 158 --- .../assets/config/.editorconfig | 19 - .../assets/config/.gitattributes | 19 - .../assets/config/.gitignore | 28 - .../tiny-blocks-create/assets/config/Makefile | 74 -- .../assets/config/composer.json | 70 -- .../assets/config/infection.json.dist | 23 - .../assets/config/phpcs.xml | 7 - .../assets/config/phpstan.neon.dist | 6 - .../assets/config/phpunit.xml | 39 - .../assets/docs/SECURITY.md | 12 - .../github/ISSUE_TEMPLATE/bug_report.md | 29 - .../github/ISSUE_TEMPLATE/feature_request.md | 17 - .../assets/github/PULL_REQUEST_TEMPLATE.md | 16 - .../assets/github/workflows/ci.yml | 105 -- CLAUDE.md | 61 -- 28 files changed, 3474 deletions(-) delete mode 100644 .claude/rules/php-library-architecture.md delete mode 100644 .claude/rules/php-library-code-style.md delete mode 100644 .claude/rules/php-library-documentation.md delete mode 100644 .claude/rules/php-library-github-workflows.md delete mode 100644 .claude/rules/php-library-modeling.md delete mode 100644 .claude/rules/php-library-testing.md delete mode 100644 .claude/rules/php-library-tooling.md delete mode 100644 .claude/settings.json delete mode 100644 .claude/skills/commit-message/SKILL.md delete mode 100644 .claude/skills/tiny-blocks-consume/SKILL.md delete mode 100644 .claude/skills/tiny-blocks-consume/references/catalog.md delete mode 100644 .claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py delete mode 100644 .claude/skills/tiny-blocks-create/SKILL.md delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/.editorconfig delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/.gitattributes delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/.gitignore delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/Makefile delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/composer.json delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/infection.json.dist delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpcs.xml delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist delete mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpunit.xml delete mode 100644 .claude/skills/tiny-blocks-create/assets/docs/SECURITY.md delete mode 100644 .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml delete mode 100644 CLAUDE.md diff --git a/.claude/rules/php-library-architecture.md b/.claude/rules/php-library-architecture.md deleted file mode 100644 index 4be7fc3..0000000 --- a/.claude/rules/php-library-architecture.md +++ /dev/null @@ -1,147 +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. Vendor compatibility (driver) tests, verifying the - library against specific external libraries/frameworks, are optional and have no `src/` - counterpart. They exist only as tests, under `tests/Integration/Drivers//`, - grouped by vendor. Never a top-level `Drivers/` under `tests/`. -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 - ├── Drivers/ # only present when the library exposes vendor-specific drivers - │ └── / # tests against one specific third-party implementation - └── .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 c44dbc1..0000000 --- a/.claude/rules/php-library-code-style.md +++ /dev/null @@ -1,960 +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`. Four formatting rules outside `PSR-12` (single-line signatures within 120 -characters, no vertical alignment in parameter lists, vertical alignment of `=>` in multi-line -match arms and array literals, 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 static and instance methods (`DateTimeImmutable::createFromFormat`, - `DateTimeImmutable::createFromInterface`, `->setTimezone`, `->format`, and similar). Their - parameter names are an internal implementation detail, not a stable contract, exactly as - with native functions. - - 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, enum methods, and native class static and instance methods, but not native class - constructors (instantiation): those accept named arguments per rule 8. -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`) 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. See "Polymorphism and tell-don't-ask". -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`. **Exception:** a factory or constructor parameter that - wraps a single opaque scalar the value object exists to represent may keep `$value` when no - more specific meaning applies (for example, `Seconds::from(int $value)`). Where a more - specific meaning exists, prefer it (`$iso`, `$identifier`, `$isoDay`). -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). See "Duplication" for the resolution - under the inheritance and private-method constraints. -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, in `new self()` instantiation, and in static method calls - (`self::from(...)` → `ClassName::from(...)`). Constant access via `self::CONST_NAME` is the - only permitted `self::` form. `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, concise standard regex character classes (`\w`, `\d`, `\s`, and their negations) - over their explicit equivalents (`[A-Za-z0-9_]`, `[0-9]`), 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. -24. No method has more than three `return` statements. This bounds branching complexity and - coexists with rule 9 (no `else`): early-return guard clauses are fine, but a method that needs - more than three exit points is doing too much. Invariant violations `throw` a dedicated - exception rather than returning, so guards rarely add return points. When branching still - produces more than three returns, replace it with a `match` or map dispatch that resolves to a - single return, or extract a collaborator. When the branches turn on the runtime type of - polymorphic collaborator, the behavior belongs on that type instead. See "Return statements" - and "Polymorphism and tell-don't-ask". -25. The string concatenation operator (`.`) is never used, in any position. A string that - would be assembled by concatenation, whether it embeds a value or joins two or more - strings, is built with `sprintf` and a `$template` variable (rule 19) instead. This - covers value prefixes, value suffixes, inline fragments, and plain joins. See "Format - strings". - - **Exception:** a `const` string literal that contains no `sprintf` placeholder may - use `.` to split a message across lines when a single-line literal would exceed the - 120-character limit. In that case `sprintf` offers no benefit, since the `$template` - line would itself exceed the limit, and heredoc and nowdoc are not permitted in - constant expressions, so concatenation is the only way to honor the line length. - This exception is limited to placeholder-free constant literals. Runtime string - assembly, and any constant that interpolates a value, still uses `sprintf` with a - `$template`. -26. Behavior that varies by the concrete type of type the library owns is a polymorphic method - on that type, never an `instanceof`, `get_class`, or enum-case branch. A value or behavior an - enum case owns (a token, a flag about the case's nature, a derived value) lives on the enum as - a predicate or vocabulary method, called at the site instead of comparing the case. Behavior - that depends on a collaborator's state lives on that collaborator. See "Polymorphism and - tell-don't-ask". - -## 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`. -- A boolean method reads as a predicate, using an `is`/`has`/`can`/`was`/`should` prefix or a - third-person verb that reads as a yes/no question, such as `contains`, `matches`, `supports`, - `equals`, or `omits`. - -## 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, in `new self()` instantiation, - and in static method calls (`self::from(...)`). Constant access via `self::CONST_NAME` is - **permitted** and is the only allowed `self::` form. The prohibition covers the forms that - carry refactoring ambiguity when a method moves to a different class (type, instantiation, and - static-call forms): a `self::from()` call rebinds to the wrong class if the method moves, - exactly like `new self()`. 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 - -Everything exposed on the public API for consumption carries PHPDoc per these rules. - -- Every method of an interface, regardless of location. Interfaces are contracts, so they carry - PHPDoc per these rules even when declared inside `src/Internal/`. -- 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). -- Every abstract method on a public class or extension point outside `src/Internal/`. Abstract - methods are part of the public contract consumers implement or override, so each carries PHPDoc - exactly as an interface method does. -- A class-level summary docblock on every interface (including interfaces inside `src/Internal/`) - and on every public class or enum outside `src/Internal/`. The summary is a single line placed - directly above the declaration stating what the type is or does, following the same summary-line - rule as method docblocks. It is the class-level counterpart of the per-method PHPDoc. - -### 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. -- Concrete classes and collaborators inside `src/Internal/`. Internal implementation types are - detail, not contract, and carry no PHPDoc, class-level summary included. **Interfaces are the - exception**: an interface declared inside `src/Internal/` is still a contract and follows the - interface PHPDoc rules under "When required", including the class-level summary. See - `php-library-architecture.md` for the architectural meaning of `Internal/`. -- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyThenZzz` - 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 -`tests/`, zero PHPDoc is the rule, save for the generics carve-out below. Inside `src/Internal/`, -zero PHPDoc applies to concrete classes and collaborators, but interfaces carry PHPDoc per "When -required", and the generics carve-out below still applies to those concrete classes. PHPStan -errors that result from the missing annotations on the non-interface code route through -`ignoreErrors` (see below). - -**Generics carve-out.** The prohibitions above are waived for PHPDoc that exists *purely to -express generics* the native type system cannot: `@template`, `@extends`, `@implements`, and the -`@param`/`@return`/`@var` tags whose sole purpose is to carry a type parameter (for example -`Collection`, `iterable`, `Closure(TValue): bool`, `static`). These tags -are permitted wherever they are necessary for generic typing, including on **constructors**, on -**concrete classes and collaborators inside `src/Internal/`**, and as a **bare-tag block with no -summary line** (a summary would be the prohibited descriptive form). The waiver is strict: it -covers only the type-parameter information. Descriptive or redundant PHPDoc (summaries, prose -`@param`/`@return` descriptions, anything restating what the signature already says) stays -prohibited everywhere. When the only missing annotation is non-generic (a plain iterable value -type, a mixed-origin argument), the typed-array case below still applies and routes through -`ignoreErrors`, not PHPDoc. - -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 a concrete class or collaborator inside **`src/Internal/`** → suppress via `ignoreErrors`. - Do not add PHPDoc. An interface inside `src/Internal/` is the exception: it carries PHPDoc per - "When required", so the typed-array information goes in the docblock, not `ignoreErrors`. -- 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)); -} -``` - -The `.` operator is never used to assemble a string. Value prefixes, value suffixes, inline -fragments, and plain joins all go through `sprintf` with a `$template`. This holds even when -no value is interpolated, for example when joining a directory and a file name. - -The sole exception is a placeholder-free `const` string literal that would exceed 120 -characters on a single line: it may use `.` to split across lines, since `sprintf` would -not shorten the line and heredoc is unavailable in constant expressions. - -**Prohibited.** Concatenation to inject a value: - -```php -$candidate = is_int($value) ? '@' . $value : $value; -``` - -**Correct.** `$template` plus `sprintf`: - -```php -$template = '@%d'; -$candidate = is_int($value) ? sprintf($template, $value) : $value; -``` - -**Prohibited.** Concatenation to join strings: - -```php -$location = $directory . '/' . $file; -``` - -**Correct.** A single `$template` for the join: - -```php -$template = '%s/%s'; -$location = sprintf($template, $directory, $file); -``` - -## 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(uri: 'https://api.example.com', method: 'GET')) - ->withHeader('Accept', 'application/json') - ->getBody(); -``` - -**Correct.** No parentheses: - -```php -$body = new ServerRequest(uri: 'https://api.example.com', method: 'GET') - ->withHeader('Accept', 'application/json') - ->getBody(); -``` - -## Duplication - -When two or more places share logic, extract it into a collaborator (a value object, or a class -in `src/Internal/`), or move it onto a collaborator both call sites already depend on. The type -that owns the data owns the derived behavior. - -A shared base class is not available: inheritance between concrete classes is prohibited (see -"Inheritance and constructors"). A shared private helper is not available either: private methods -on public classes are prohibited (rule 13). Composition is therefore the only mechanism, and -leaving the duplication in place is never the resolution. - -**Prohibited.** The same derivation copied byte for byte into two types: - -```php -final readonly class Exam -{ - public function __construct(public int $score) {} - - public function grade(): Grade - { - return match (true) { - $this->score >= 90 => Grade::A, - $this->score >= 80 => Grade::B, - $this->score >= 70 => Grade::C, - default => Grade::F - }; - } -} - -final readonly class Assignment -{ - public function __construct(public int $score) {} - - public function grade(): Grade - { - return match (true) { - $this->score >= 90 => Grade::A, - $this->score >= 80 => Grade::B, - $this->score >= 70 => Grade::C, - default => Grade::F - }; - } -} -``` - -**Correct.** The derivation lives once on the collaborator both types hold, and each delegates: - -```php -final readonly class Score -{ - public function __construct(public int $value) {} - - public function toGrade(): Grade - { - return match (true) { - $this->value >= 90 => Grade::A, - $this->value >= 80 => Grade::B, - $this->value >= 70 => Grade::C, - default => Grade::F - }; - } -} - -final readonly class Exam -{ - public function __construct(public Score $score) {} - - public function grade(): Grade - { - return $this->score->toGrade(); - } -} - -final readonly class Assignment -{ - public function __construct(public Score $score) {} - - public function grade(): Grade - { - return $this->score->toGrade(); - } -} -``` - -## Polymorphism and tell-don't-ask - -This refines rules 9 and 24. A `match` on an enum, on a scalar, or on a value condition stays -correct. What is prohibited is branching on the runtime type of polymorphic collaborator the -library defines: when behavior differs across the concrete implementations of an interface the -library owns, that behavior is a method on the interface, resolved by the object itself, never an -`instanceof` or `get_class` chain at the call site. - -The opening sentence holds only for control flow. When a branch on an enum case yields a value or -behavior that belongs to the case itself, a token, a flag about the case's nature, or a derived -value, that value or behavior is a method on the enum: a predicate `isXxx()`, or a vocabulary -method that returns the value, called at the site instead of comparing the case. Comparing a case -(`$direction === Order::ASCENDING`, `match ($direction)`) stays correct for control flow whose -outcome is not a property of the case. This is the enum form of tell-don't-ask, and the companion -of the modeling rule that enums carry methods only when those methods hold vocabulary meaning (see -`php-library-modeling.md`, "Enums"): a case that drives a derived value is exactly that vocabulary. - -A consumer is outside this rule. A consumer matching on a sealed type the library exposes (for -example, translating a parsed tree into its own store) cannot add methods to the library's types, -so its `instanceof` is legitimate. The rule binds the library's own code. - -A type the library owns may `instanceof` its own internal types at construction or registration -time, to invoke behavior that exists only on the concrete type and that cannot be lifted onto a -public extension interface without breaking external implementers. The minimal public interface -outweighs the local, build-time type check. - -Tell-don't-ask. Behavior that depends on a collaborator's state belongs to the collaborator. Do -not read a collaborator's fields to recompute a result the collaborator should produce. Ask it for -the result, not for its parts. A getter exposes a value the caller needs as data, it is not a -license to reimplement the collaborator's logic at the call site. Tell-don't-ask binds the types -the library owns. Reading a value off a type the library does not own (a dependency's value object, -a PSR type) and computing with it is interop, not a violation: the library cannot add a method to a -type it does not control. The rule still binds the library's own types. - -**Prohibited.** Dispatching on the concrete type of interface the library owns: - -```php -return match (true) { - $discount instanceof Percentage => $amount->multiplyBy(factor: $discount->rate()), - $discount instanceof Fixed => $amount->subtract(other: $discount->amount()) -}; -``` - -**Correct.** The behavior is a method on the interface, resolved by the object: - -```php -return $discount->applyTo(amount: $amount); -``` - -**Prohibited.** Comparing an enum case to produce a value the case owns: - -```php -$token = match ($direction) { - Order::ASCENDING => '', - Order::DESCENDING => '-' -}; -``` - -**Correct.** A vocabulary method on the enum returns the value, called at the site: - -```php -enum Order: string -{ - case ASCENDING = 'asc'; - case DESCENDING = 'desc'; - - public function token(): string - { - return match ($this) { - self::ASCENDING => '', - self::DESCENDING => '-' - }; - } -} - -$token = $direction->token(); -``` - -**Prohibited.** Reading a collaborator's parts to recompute what it already owns: - -```php -$doubled = Money::of(amount: $price->amount() * 2, currency: $price->currency()); -``` - -**Correct.** Telling the collaborator to produce the result: - -```php -$doubled = $price->multiplyBy(factor: 2); -``` - -## Return statements - -A method has at most three `return` statements. The cap keeps methods small and their control -flow scannable, and it complements rule 9: early returns are the preferred alternative to `else`, -but they stop being a simplification once a method accumulates more than three exit points. -Invariant violations are signaled with a `throw`, not a `return`, so guard clauses usually do not -add to the count. - -**Prohibited.** Four return points: - -```php -public function classify(int $score): Grade -{ - if ($score >= 90) { - return Grade::A; - } - - if ($score >= 80) { - return Grade::B; - } - - if ($score >= 70) { - return Grade::C; - } - - return Grade::F; -} -``` - -**Correct.** Single return through `match`: - -```php -public function classify(int $score): Grade -{ - return match (true) { - $score >= 90 => Grade::A, - $score >= 80 => Grade::B, - $score >= 70 => Grade::C, - default => Grade::F - }; -} -``` - -## Formatting overrides - -Four formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12` -only). Apply them manually. - -### Single-line signatures within 120 characters - -A function or constructor signature stays on one line when the whole signature fits within the -120-character limit. Do not break the parameter list onto multiple lines unless the single-line -form would exceed 120 characters. The opening brace still goes on its own line (PSR-12). Break to -one parameter per line only when the signature genuinely overflows. - -**Prohibited.** Multiline signature that fits on one line: - -```php -private function __construct( - public ExternalReference $id, - public Money $amount, - public OrderContext $context -) { -} -``` - -**Correct.** Single line within 120 characters: - -```php -private function __construct(public ExternalReference $id, public Money $amount, public OrderContext $context) -{ -} -``` - -When the one-line form would exceed 120 characters, break to one parameter per line and apply the -no-vertical-alignment and no-trailing-comma rules below. - -### 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-documentation.md b/.claude/rules/php-library-documentation.md deleted file mode 100644 index a29de61..0000000 --- a/.claude/rules/php-library-documentation.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -description: Conventions for README and public-facing Markdown docs in PHP libraries. -paths: - - "README.md" - - "docs/**/*.md" ---- - -# Documentation - -Conventions for `README.md` and the public-facing Markdown a library ships. 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 **canonical bodies** of the non-README repository files (`SECURITY.md`, the issue templates, -the pull request template) are not duplicated here. They live as drop-in assets in the -`tiny-blocks-create` skill, the single source of truth for those files. This rule governs how -the README and any `docs/` Markdown are written. "Required repository files" below lists which -files must exist and points to the skill for their content. - -`CONTRIBUTING.md` 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. The repository contains the required non-README files listed in "Required repository files", - each matching its canonical asset in the `tiny-blocks-create` skill. - -## 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. The TOC mirrors the document structure -exactly. - -### Code examples - -Code examples fall into two categories. - -**Self-contained examples** include at least one of these: a `use` statement, a -`class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of executable code. -They open with `value; -``` - -The criteria are mechanical: a block meeting any self-contained condition gets the prologue. A -block meeting 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 -rule 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. - -> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8, -> "Domain Events". -``` - -Bibliographic citations follow `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` -The chapter and section fragments are optional when the title is precise enough. Multiple -citations stack as separate blockquote lines. - -### License and Contributing - -```markdown -## License - - is licensed under [MIT](LICENSE). -``` - -```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. - -## Required repository files - -In addition to the README, every library repository contains the files below. Their canonical -bodies are the drop-in assets in the `tiny-blocks-create` skill. This rule only asserts they -must exist and match those assets. - -- `SECURITY.md`: security policy (supported versions, private reporting via GitHub Security - Advisories). `` is substituted. -- `.github/ISSUE_TEMPLATE/bug_report.md`: bug report template (`labels: bug`). -- `.github/ISSUE_TEMPLATE/feature_request.md`: feature request template (`labels: enhancement`). -- `.github/PULL_REQUEST_TEMPLATE.md`: pull request template linking the centralized contributing - guidelines, with the standard checklist (`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 c30d364..0000000 --- a/.claude/rules/php-library-github-workflows.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -description: Structure, ordering, and pinning conventions 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. - -The **canonical `ci.yml` body** is not duplicated here. It lives as a drop-in asset in the -`tiny-blocks-create` skill (`assets/github/workflows/ci.yml`), the single source of truth. This -rule defines the conventions that asset satisfies and that any edit to a workflow must preserve. - -`ci.yml` is mandatory. Additional workflow files (security scanning, automated triage, scheduled -tasks, dependency updates) may exist and follow the general rules below. 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 - -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`. Absent keys are omitted. The relative order of the rest is preserved. -2. Properties inside a job follow the canonical order `name`, `needs`, `runs-on`, - `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule. -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`). 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, prefixed by the workflow's short purpose name - (`ci`, `codeql`, `auto-assign`): - - `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 }}`. -6. `permissions` is declared at the workflow root with the minimum scope every job needs. - Job-level `permissions` 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, immutable ref: a version tag at any granularity (major, minor, or patch) or a - commit SHA. Moving refs (branch names such as @main/@master, or @v with no version) are prohibited. Do not normalize - an explicit minor or patch pin down to its major, preserve the granularity the maintainer chose. -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 - -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`. Per rule 5 - for every workflow, with purpose `ci` and a `pull_request` trigger, `concurrency.group` is - `ci-${{ github.event.pull_request.number }}`. -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`. 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 the library needs (service - containers, fixture preparation, environment variables used during testing). The other three - jobs are identical across every library in the ecosystem. -8. `timeout-minutes` is 5 for `resolve-php-version` and 15 for `build`, `auto-review`, and - `tests`. `permissions` is `contents: read`. - -## ci.yml job sequence - -`ci.yml` gates every pull request with four jobs in this exact order. The first three are -identical across every library. Only `tests` may extend. - -- **Resolve PHP version.** Reads `.require.php` from `composer.json` and exposes the minor version - as the output `php-version`. A single step uses `jq` and a short regex to extract the value. -- **Build.** Sets up PHP using the resolved version, validates `composer.json`, installs with - `--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and - `composer.lock` as `vendor-artifact`. -- **Auto review.** Needs `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up - PHP, runs `composer review` (phpcs + phpstan). -- **Tests.** Needs `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up - PHP, runs `composer tests` (phpunit + infection). Library-specific test setup lives in this job - only. - -To extend the `tests` job (external services, env vars, fixtures), the additions go inside the -`tests` job exclusively. The skill asset includes an extended example with a MySQL service -container. diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md deleted file mode 100644 index a587b54..0000000 --- a/.claude/rules/php-library-modeling.md +++ /dev/null @@ -1,314 +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. A value or behavior a case owns lives on the enum as that method, called instead of a - `match` on the case at the site. See "Polymorphism and tell-don't-ask" in - `php-library-code-style.md`. -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. -17. A configuration-like value object whose fields are mostly optional exposes a no-argument - baseline factory (`default()`) plus fluent immutable `with*` copies, not a single factory - whose signature lists every field. See "Value objects". - -## 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. See "Duplication" in - `php-library-code-style.md` for how to resolve it without inheritance or private helpers. -- 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. - -**Scope.** The architectural-role banlist and the anemic-verb banlist apply to the **public -surface**: types at the `src/` root, types in public `/` folders, and public -exception and contract names. Inside `src/Internal/` (implementation detail by definition, where -the namespace is the boundary), a collaborator may carry a mechanical role or operation name that -describes its job (`Decoder`, `Encoder`, `Parser`, `Resolver`), since consumers never see or -manipulate it. The always-banned names (`Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`) -remain banned everywhere, `Internal/` included. - -## 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); -``` - -When a value object is configuration-like and most of its fields are optional with defaults, prefer -a baseline factory that takes no required arguments (`default()`, or `from()` with every parameter -defaulted) together with fluent immutable `with*` copies, over a single factory whose signature -carries every field. Each `with*` returns a new instance. Prefer the `with*` methods on the value -object itself over a separate mutable builder class: the value object is already immutable, so it -is its own builder. The smell is a factory signature that lists every field while most are -optional. - -**Prohibited.** A single factory whose signature carries every field, most of them optional: - -```php -MoneyFormat::from(scale: 4, symbol: '€', grouping: ','); -``` - -**Correct.** A baseline `default()` plus fluent `with*` copies that override only what differs: - -```php -MoneyFormat::default()->withScale(scale: 4)->withGrouping(grouping: ','); -``` - -## 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 - `OrderStatus::isFinal()` and `RoundingMode::apply()`. -- A value or behavior a case owns (a token, a flag, a derived value) is one of those vocabulary - methods, a predicate `isXxx()` or a method returning the value, called at the site instead of a - `match` comparing the case. This is the enum form of tell-don't-ask. See "Polymorphism and - tell-don't-ask" in `php-library-code-style.md`. - -## 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 30a329f..0000000 --- a/.claude/rules/php-library-testing.md +++ /dev/null @@ -1,372 +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. - One narrow, last-resort exception covers irreducible internal elements. See "White-box - coverage of irreducible internals". -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 using the `testXxxWhenYyyThenZzz` shape, never after the method - under test. `Xxx` names the subject or operation, `Yyy` the condition, `Zzz` the expected - outcome (for example, `testAddMoneyWhenSameCurrencyThenAmountsAreSummed`). The `When`/`Then` - structure is mandatory. The `@Given`/`@When`/`@Then`/`@And` annotation blocks describe the - steps within. A condition-free operation may collapse to `testXxxThenZzz` when there is no - meaningful precondition to name. -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. Named arguments are never used on PHPUnit assertions and expectations. Arguments are passed - positionally. The canonical rule and its full exclusion list live in - `php-library-code-style.md` rule 4. -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). - -## Generics in test PHPDoc - -The "zero PHPDoc anywhere inside `tests/`" rule (defined in `php-library-code-style.md`) has one -narrow exception: PHPDoc that exists *purely to express generics* the native type system cannot. -A test fixture that extends a generic public type carries the type argument with `@extends` (for -example `@extends Collection` on an `Invoices` fixture), and a generics-only `@var` may -pin a type parameter at an inference point where an imprecise result feeds a typed sink (for -example `/** @var Collection $shipments */` before passing a mapped collection to -`Shipments::createFrom(...)`). These tags carry only the type-parameter information, never a -summary or prose description. Every other form of PHPDoc (summaries, `@param`/`@return` -descriptions on test methods, fixtures, data providers, or anonymous classes) stays prohibited. -This is the same carve-out stated in `php-library-code-style.md` under "When prohibited", -restated here because it most often surfaces on collection fixtures and inference points in -`tests/`. - -## 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); -} -``` - -## 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 `HttpNetworkFailed` (`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 - -Checklist items 3, 4, 5, 10, and 11 govern setup blocks: one declaration per annotation, no -single-use intermediate variables, no private or helper methods, domain-named variables, and -domain-language annotations. The examples below illustrate the rules most often violated in -practice. Double naming (the `$spy`/`$mock` banlist and the class-name suffix nuance) is detailed -in "Test doubles" below. - -**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. -- The sole exception is an irreducible internal element (a non-functional memoization - cache, or the private constructor of a static-only surface) that cannot be reached - publicly without harming the design. It is covered or killed through a reflection-based - white-box test, never through suppression. See "White-box coverage of irreducible - internals". - -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. - -## White-box coverage of irreducible internals - -Rules 6 and 15 are near-absolute: tests exercise the public API, refactoring is the response -when a line or mutation resists coverage, and code is never hidden from coverage or mutation. -They yield in one narrow case: an *irreducible* internal element that cannot be reached -through the public API without either removing a legitimate non-functional optimization or -defeating a deliberate design. Two such elements recur: - -- **Memoization caches.** A purely non-functional cache (a resolved-mapping cache, a - shared-instance cache, a reflection-descriptor cache) whose removal leaves behavior - identical. The mutant that drops the cache is an equivalent mutant: no public observation - distinguishes the cached path from the recomputed one, so no public-API test can kill it. -- **Intentionally-uncallable members.** The private constructor of a static-only surface (a - class that exists solely to expose static factories and must never be instantiated). It is - never executed through any public path, so its line stays uncovered by construction. - -For these, and only these, a white-box test is permitted as a last resort: reflecting into -`Internal/` private state to assert that memoization holds, or reflection-invoking an -uncallable constructor so its line is covered. Such a test still follows the BDD structure -and `testXxxWhenYyyThenZzz` naming, and the repeated-invocation `@When` exception (checklist -item 3) already covers the memoization case. - -This exception covers code. It never hides it. `@codeCoverageIgnore`, coverage-excluding -configuration, and mutant suppression remain prohibited without exception. The irreducible -element is killed or covered honestly through reflection, not excluded from the metric. The -burden is on demonstrating irreducibility: if the line or mutation can be reached through the -public API, or if a proportionate refactor would expose it without harming the design, this -exception does not apply and the public-API test is required. White-box access is never a -convenience and never the first resort. diff --git a/.claude/rules/php-library-tooling.md b/.claude/rules/php-library-tooling.md deleted file mode 100644 index 8cf50d2..0000000 --- a/.claude/rules/php-library-tooling.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -description: Invariants for the canonical config files of PHP libraries in the tiny-blocks ecosystem. -paths: - - "composer.json" - - "phpcs.xml" - - "phpstan.neon" - - "phpstan.neon.dist" - - "phpunit.xml" - - "infection.json" - - "infection.json.dist" - - ".editorconfig" - - ".gitattributes" - - ".gitignore" - - "Makefile" ---- - -# Tooling - -Invariants that every config file in a tiny-blocks library must satisfy. The **canonical file -bodies** (full `composer.json`, `Makefile`, `phpunit.xml`, etc.) are not duplicated here. They -live as drop-in assets in the `tiny-blocks-create` skill, which is the single source of truth -for scaffolding a new library or restoring a file to its canonical shape. This rule defines the -invariants those files are checked against when editing an existing library. - -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 config file. If any item fails, -revise before outputting. - -1. The repository root contains all of: `composer.json`, `phpcs.xml`, `phpstan.neon.dist`, - `phpunit.xml`, `infection.json.dist`, `.editorconfig`, `.gitattributes`, `.gitignore`, - `Makefile`. (See "Config file naming" for which carry a `.dist` suffix and why.) -2. `composer.json` exposes exactly five scripts: `configure`, `configure-and-update`, `review`, - `test-file`, `tests`. No other public scripts. -3. `composer.json` fixed fields use the canonical values from the skill asset (`license`, `type`, - `minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`). The five universal - dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`, - `phpunit/phpunit`, `squizlabs/php_codesniffer`) are present. `require-dev` may add libraries - the tests need on top of those five. The asset's caret ranges are the canonical floor, and - the repo `composer.json` matches the asset. To bump, update the asset first, then the repo. -4. `composer.json` `description` is a single short sentence. Multi-sentence prose belongs in the - README Overview, not in Composer metadata. -5. `composer.json` includes a `keywords` array that contains `"tiny-blocks"`. Its position in - the array is not constrained. The remaining entries 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. Formatting rules outside - PSR-12 live in `php-library-code-style.md` under "Formatting overrides". -7. `phpunit.xml` sets all five `failOn*` flags to `true` (`failOnDeprecation`, `failOnNotice`, - `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`). -8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`. - Non-namespace root attributes are sorted alphabetically. The `xmlns:xsi` and - `xsi:noNamespaceSchemaLocation` declarations lead the attribute list and are not part of - the alphabetical run. -9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either is - prohibited. -10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`, - `end_of_line = lf` as the global default under `[*]`. YAML uses `indent_size = 2` and - Makefile uses `indent_style = tab` as per-extension overrides. -11. `.gitattributes` sets `* text=auto eol=lf` and lists every committed dev-only file under - `export-ignore`. The Packagist tarball contains only `src/`, `composer.json`, `README.md`, - `LICENSE`, and `SECURITY.md`. `.claude/` is listed under `export-ignore` (versioned on - GitHub for contributor parity, excluded from the published package), and `CLAUDE.md` (where - committed) is `export-ignore`d alongside it for the same reason. `.gitattributes` lists - only files that are actually committed: it never names a file the repository does not - contain (no `CONTRIBUTING.md`, which is centralized, and no phantom `.dist`/non-`.dist` - twin of a file that is committed under only one of those names). -12. `.gitignore` ignores the dependency and artifact paths, the local config overrides - (`/phpstan.neon`, `/infection.json`), and nothing tool caches the project does not produce. - The `.claude/` directory itself is **not** ignored (it is versioned on GitHub). Only - `/.claude/settings.local.json`, the per-clone settings override, is ignored. -13. `Makefile` wraps every PHP and Composer command in Docker using the canonical image - `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly. Targets that share - a name with a Composer script delegate to it. Additional non-Composer convenience targets - (`help`, `clean`, `show-*`) are permitted. -14. All test artifact paths use `reports/` (plural), consistent across `composer tests`, - `infection.json.dist`, `phpunit.xml`, and `Makefile`. `reports/` is listed under - `export-ignore` in `.gitattributes`. - -## Config file naming - -The committed config files split into two naming conventions on purpose. The split is documented -here so it reads as intentional, not accidental. - -- **Committed live, no `.dist`:** `phpcs.xml` and `phpunit.xml`. The ruleset (`PSR12` only) and - the test configuration are stable across the whole ecosystem and identical in every library. - There is no per-clone local-override story, so the live file is committed directly. -- **Committed as `.dist`:** `phpstan.neon.dist` and `infection.json.dist`. These are the two - tools a contributor may legitimately want to tune locally (a temporary `ignoreErrors` entry, a - narrower mutator set while iterating). The `.dist` baseline is committed. A contributor drops a - gitignored `phpstan.neon` or `infection.json` to override it, and the tool auto-resolves the - override over the `.dist` fallback. Those override names appear in `.gitignore`. - -Do not introduce a `.dist` twin for `phpcs.xml`/`phpunit.xml`, and do not commit a live -`phpstan.neon`/`infection.json` in place of the `.dist` baseline. - -## phpstan ignoreErrors - -`phpstan.neon.dist` runs at `level: max` on `src` and `tests`. `ignoreErrors` is permitted to -suppress legitimate false positives produced by `level: max` (third-party signatures carrying -`mixed`, PHP-FIG interfaces returning untyped arrays, trait unused-method warnings on shared -behavior, and the typed-array cases routed here by `php-library-code-style.md` instead of adding -PHPDoc). 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. - -```neon -ignoreErrors: - # Trait method intentionally unused by the consuming aggregate. Reflection wires it. - - identifier: trait.unused - path: src/Internal/EventualAggregateRootBehavior.php -``` - -## Infection mutator config - -`infection.json.dist` is configured with `"mutators": {"@default": true}`. That is the only -permitted form. No `ignore` lists, no `ignoreSourceCodeByRegex`, and no per-mutator overrides -are allowed. Every mutant the default profile produces must be killed by a test. When a mutant -escapes, the production code is refactored to make it testable rather than the configuration -relaxed. This aligns with `php-library-testing.md` rule 15 (no mutant suppression by any -mechanism) and with the MSI 100 thresholds in checklist item 9. - -## Composer scripts - -The five scripts and their purpose. Bodies live in the skill asset. - -- `composer configure` installs with `--optimize-autoloader` then normalizes. Run after cloning - or pulling. -- `composer configure-and-update` updates dependencies then normalizes. Run when intentionally - bumping dependencies. -- `composer review` runs `phpcs` then `phpstan`. Used by CI (`auto-review` job) and locally. -- `composer tests` runs `phpunit` then `infection`. Used by CI (`tests` job). -- `composer test-file ` runs a filtered subset without coverage. Local only. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 512042f..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/claude-code-settings.json", - "permissions": { - "defaultMode": "default", - "allow": [ - "Read", - "Glob", - "Grep", - - "Edit(./**)", - "Write(./**)", - - "Bash(make:*)", - "Bash(docker:*)", - - "Bash(rtk gain:*)", - "Bash(rtk discover:*)", - "Bash(rtk --version)", - - "Bash(rtk git status:*)", - "Bash(rtk git diff:*)", - "Bash(rtk git log:*)", - "Bash(rtk git show:*)", - "Bash(rtk ls:*)", - "Bash(rtk cat:*)", - "Bash(rtk grep:*)", - "Bash(rtk rg:*)", - "Bash(rtk head:*)", - "Bash(rtk tail:*)", - - "Bash(rtk docker:*)", - - "Bash(rg:*)", - "Bash(grep:*)", - "Bash(jq:*)", - "Bash(cat:*)", - "Bash(ls:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(wc:*)", - "Bash(sort:*)", - "Bash(uniq:*)", - "Bash(diff:*)", - "Bash(echo:*)", - "Bash(mkdir:*)", - "Bash(rmdir:*)", - "Bash(rm:*)", - - "Bash(composer install:*)", - "Bash(composer validate:*)", - "Bash(composer outdated:*)", - "Bash(composer show:*)", - - "Bash(git status:*)", - "Bash(git diff:*)", - "Bash(git log:*)", - "Bash(git show:*)", - "Bash(git blame:*)", - "Bash(git ls-files:*)", - "Bash(git grep:*)", - "Bash(git merge-base:*)", - "Bash(git rev-parse:*)", - "Bash(git describe:*)", - "Bash(git shortlog:*)", - "Bash(git reflog show:*)", - "Bash(git remote -v)", - "Bash(git remote get-url:*)", - "Bash(git stash list)", - "Bash(git stash show:*)", - "Bash(git worktree list)", - "Bash(git config --get:*)", - "Bash(git config --get-all:*)", - "Bash(git config --list)", - "Bash(git config --list:*)", - - "Bash(git branch)", - "Bash(git branch -a)", - "Bash(git branch -r)", - "Bash(git branch -v)", - "Bash(git branch -vv)", - "Bash(git branch --show-current)", - "Bash(git branch --list:*)", - "Bash(git branch --contains:*)", - "Bash(git branch --merged:*)", - "Bash(git branch --no-merged:*)", - - "Bash(git tag)", - "Bash(git tag -l)", - "Bash(git tag -l:*)", - "Bash(git tag -n)", - "Bash(git tag -n:*)", - "Bash(git tag --list)", - "Bash(git tag --list:*)", - "Bash(git tag --contains:*)", - "Bash(git tag --points-at:*)", - - "Bash(git rm:*)" - ], - "ask": [ - "Edit(./.claude/**)", - "Write(./.claude/**)", - - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git mv:*)", - - "Bash(composer require:*)", - "Bash(composer update:*)", - "Bash(composer remove:*)", - "Bash(composer normalize:*)", - - "Bash(curl:*)", - "Bash(wget:*)" - ], - "deny": [ - "Read(./.env)", - "Read(./.env.*)", - "Read(./**/.env)", - "Read(./**/.env.*)", - "Read(./secrets/**)", - "Read(./**/credentials*)", - "Read(./**/*.pem)", - "Read(./**/*.key)", - "Read(./**/id_rsa)", - "Read(./**/id_ed25519)", - "Read(~/.ssh/**)", - - "Edit(./.env)", - "Edit(./.env.*)", - "Edit(./**/.env)", - "Edit(./**/.env.*)", - "Edit(./secrets/**)", - "Edit(./.git/**)", - "Edit(~/.bashrc)", - "Edit(~/.zshrc)", - "Edit(~/.profile)", - "Edit(~/.ssh/**)", - - "Write(./.env)", - "Write(./.env.*)", - "Write(./**/.env)", - "Write(./**/.env.*)", - "Write(./secrets/**)", - "Write(./.git/**)", - "Write(~/.bashrc)", - "Write(~/.zshrc)", - "Write(~/.profile)", - "Write(~/.ssh/**)", - - "Bash(php:*)", - - "Bash(rm --no-preserve-root:*)", - "Bash(rm -rf /)", - "Bash(rm * /)", - "Bash(rm * ~)", - "Bash(rm * ~/)", - "Bash(rm * $HOME)", - "Bash(rm * $HOME/)", - - "Bash(git push:*)", - "Bash(git pull:*)", - "Bash(git fetch:*)", - "Bash(git checkout:*)", - "Bash(git switch:*)", - "Bash(git restore:*)", - "Bash(git reset:*)", - "Bash(git merge:*)", - "Bash(git rebase:*)", - "Bash(git revert:*)", - "Bash(git cherry-pick:*)", - "Bash(git apply:*)", - "Bash(git am:*)", - "Bash(git stash push:*)", - "Bash(git stash pop:*)", - "Bash(git stash apply:*)", - "Bash(git stash drop:*)", - "Bash(git stash clear)", - "Bash(git stash save:*)", - "Bash(git branch -d:*)", - "Bash(git branch -D:*)", - "Bash(git branch -m:*)", - "Bash(git branch -M:*)", - "Bash(git branch -c:*)", - "Bash(git branch -C:*)", - "Bash(git tag -a:*)", - "Bash(git tag -s:*)", - "Bash(git tag -d:*)", - "Bash(git tag -f:*)", - "Bash(git tag --delete:*)", - "Bash(git tag --force:*)", - "Bash(git remote add:*)", - "Bash(git remote remove:*)", - "Bash(git remote rm:*)", - "Bash(git remote rename:*)", - "Bash(git remote set-url:*)", - "Bash(git submodule:*)", - "Bash(git worktree add:*)", - "Bash(git worktree remove:*)", - "Bash(git worktree prune:*)", - "Bash(git filter-branch:*)", - "Bash(git filter-repo:*)", - "Bash(git replace:*)", - "Bash(git notes:*)", - "Bash(git clean:*)", - "Bash(git gc:*)", - "Bash(git prune:*)", - "Bash(git reflog delete:*)", - "Bash(git reflog expire:*)", - "Bash(git config --add:*)", - "Bash(git config --unset:*)", - "Bash(git config --unset-all:*)", - "Bash(git config --replace-all:*)", - "Bash(git config --global:*)", - - "Bash(eval:*)", - - "Bash(sudo:*)", - "Bash(mysql:*)", - "Bash(dropdb:*)", - "Bash(dd:*)", - "Bash(chmod:*)", - "Bash(chown:*)", - - "Bash(curl * | sh)", - "Bash(curl * | bash)", - "Bash(curl * | sudo:*)", - "Bash(wget * | sh)", - "Bash(wget * | bash)", - "Bash(wget * | sudo:*)" - ] - } -} diff --git a/.claude/skills/commit-message/SKILL.md b/.claude/skills/commit-message/SKILL.md deleted file mode 100644 index de37fcd..0000000 --- a/.claude/skills/commit-message/SKILL.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -name: commit-message -description: Generate a git commit message in the tiny-blocks Conventional Commits format (type-prefixed, imperative, capitalized, period-terminated, no scopes). Use this skill whenever the user asks you to write, draft, suggest, or fix a commit message, or whenever you are about to propose commit text for staged changes, even if they do not say the words "conventional commits". Commit messages are produced on request only and are never generated automatically as part of another task. ---- - -# Commit message - -Produce a single commit message in the tiny-blocks format. This skill formats the message only. -It never stages, commits, or runs any Git command. That happens only when the user explicitly -asks for it. - -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. Keep the 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. - -## Trailers - -Commit messages carry no trailers, regardless of any default to the contrary. Never append a -`Co-Authored-By` line or any other trailer. The message is the type-prefixed subject and, when -justified, a body. Nothing follows the body. - -## Allowed types - -- `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 - -**Example 1:** -Input: handled the case where a transaction has a zero amount -Output: `fix: Handle zero-amount transactions.` - -**Example 2:** -Input: added an endpoint to cancel an order -Output: `feat: Add order cancellation endpoint.` - -**Example 3:** -Input: pulled OrderStatus out into its own enum, no behavior change -Output: `refactor: Extract OrderStatus into its own enum.` - -Reject these shapes: - -- `Added order cancellation`: past tense, missing type, missing period. -- `feat: Adds order cancellation.`: 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, so split them. -- `feat(orders): Add cancellation.`: uses a scope, which is 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. - -When bullets are used, every bullet starts with a capital letter and ends with a period, with an -imperative present-tense verb, same as the subject line. - -### Body example with prose (preferred) - -``` -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. -``` - -### 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. -``` - -## Commit splitting - -Prefer one logical change per commit. Refactor commits never modify behavior. When a task needs -multiple types of change, produce multiple commits in order: `refactor` first, then `feat` or -`fix` on top. When the staged diff mixes types, say so and propose the split rather than forcing -one message over an incoherent change set. diff --git a/.claude/skills/tiny-blocks-consume/SKILL.md b/.claude/skills/tiny-blocks-consume/SKILL.md deleted file mode 100644 index c318df8..0000000 --- a/.claude/skills/tiny-blocks-consume/SKILL.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: tiny-blocks-consume -description: - Discover and reuse an existing tiny-blocks library as a dependency instead of writing or keeping hand-written code. Use this skill in two moments: before implementing a capability from scratch or adding a dependency from outside the ecosystem, and when reviewing or refactoring existing code, to catch where a tiny-blocks package now covers something already written by hand. It checks the catalog of published tiny-blocks packages for a candidate, adds the match with composer, and reads the installed library's own README and public API to use it correctly. Trigger on any request to implement, add, build, review, simplify, or refactor where an existing building block (collections, value objects, money, time, http, mapping, logging, identifiers, and similar) might apply. ---- - -# tiny-blocks consume - -Reuse the ecosystem before building anew. Inside any library, when a capability is needed, the -first move is to check whether a tiny-blocks package already provides it, adopt that package, and -use its documented API. This is the consuming counterpart of `tiny-blocks-create`. - -The source of truth for how to use a package is the package itself. After adding a dependency, its -README and public PHPDoc under `vendor/tiny-blocks//` are authoritative. This skill does not -copy any package API. It only points to the catalog for discovery and to the installed package for -usage. - -## When to use - -Use this before writing new code for a capability that is plausibly generic: collections, value -objects, money or currency, time, country codes, http primitives, object mapping, logging, -identifiers, encoding, environment variables, and similar. Also use it before reaching for any -dependency from outside the ecosystem. - -Use it also when reviewing or refactoring existing code. A package may have been published after -that code was written, so check whether hand-rolled logic can now be replaced by a tiny-blocks -package. The catalog is what surfaces newly published packages, so refresh it (see below) before -concluding that nothing applies. - -Do not use it for logic that is specific to the library being built and has no general building -block. In that case, write the code following the rules. - -## Consume steps - -1. Name the capability in one phrase, whether it is something you are about to write or something - the existing code already does by hand (for example, "type-safe ordered collection" or "ISO - currency with fraction digits"). -2. Check `references/catalog.md` for a tiny-blocks candidate. If nothing matches and the need is - generic, refresh the catalog (see below) and look again, since a newer package may exist. -3. If a candidate fits, add it with `composer require tiny-blocks/`. Packages from the - ecosystem are exempt from the freshness cooldown, and `composer require` prompts once before - adding. -4. Learn the API from the installed package, not from memory. Read - `vendor/tiny-blocks//README.md` and the public classes, interfaces, and enums under - `vendor/tiny-blocks//src/`. Their PHPDoc and the README examples are the contract. -5. Use the package following its documented API. Transitive dependencies are resolved by composer, - so depend on and use only the package that solves the capability directly. -6. If no candidate fits, only then write the code from scratch, or consider a dependency from - outside the ecosystem, subject to the freshness cooldown and the `composer require` prompt. - -## Catalog - -`references/catalog.md` is the committed index of published tiny-blocks packages, with a one-line -purpose for each. It exists for fast, offline discovery. It is generated from Packagist, not hand -maintained. Each entry points to a package whose full API lives in its own README and PHPDoc once -installed. - -## Refresh the catalog - -Run `python3 scripts/refresh-catalog.py` to rebuild `references/catalog.md` from the `tiny-blocks` -vendor on Packagist. The script uses only the Python standard library, with no curl or jq, pulls -the package list plus each description, skips abandoned packages, and rewrites the list. Refresh -when a new package shipped, or when the catalog looks stale and a needed capability is not listed. - -## Validate - -After adding a dependency and wiring it in, run `make review` and `make tests`. Both must be green -before the work is complete. A new dependency that breaks either gate is not done. diff --git a/.claude/skills/tiny-blocks-consume/references/catalog.md b/.claude/skills/tiny-blocks-consume/references/catalog.md deleted file mode 100644 index 778be52..0000000 --- a/.claude/skills/tiny-blocks-consume/references/catalog.md +++ /dev/null @@ -1,32 +0,0 @@ -# tiny-blocks catalog - -Index of published tiny-blocks packages and their one-line purpose. Generated from Packagist by -scripts/refresh-catalog.py, not hand-maintained. For the full API of a package, read its README -and public PHPDoc under vendor/tiny-blocks//. - -- `tiny-blocks/building-blocks`: Implements tactical DDD building blocks for PHP: entities, aggregate roots, domain - events, snapshots, and upcasters. -- `tiny-blocks/collection`: Models a type-safe, fluent collection API for PHP with eager and lazy pipelines over arrays, - iterators, and generators. -- `tiny-blocks/country`: Provides an ISO 3166-1 country value object for PHP, with Alpha-2, Alpha-3, numeric, and IANA - timezone resolution. -- `tiny-blocks/currency`: Models ISO-4217 currencies as a PHP enum, with per-currency fraction digit resolution. -- `tiny-blocks/docker-container`: Manages Docker containers programmatically for PHP, aimed at integration tests and - disposable infrastructure. -- `tiny-blocks/encoder`: Encoder and decoder for arbitrary data. -- `tiny-blocks/environment-variable`: Provides a type-safe environment variable reader for PHP, with strict integer and - boolean conversion. -- `tiny-blocks/http`: Implements PSR-7, PSR-15, PSR-17 and PSR-18 HTTP primitives for PHP, with a fluent response - builder, cookies, cache control, and a PSR-18 client facade. -- `tiny-blocks/immutable-object`: Provides immutable behavior for objects. -- `tiny-blocks/ksuid`: K-Sortable Unique Identifier. -- `tiny-blocks/logger`: Emits PSR-3 structured logs for PHP, with correlation tracking and configurable sensitive data - redaction. -- `tiny-blocks/mapper`: Maps PHP objects to and from arrays, JSON, and iterables through reflection and pluggable - strategies. -- `tiny-blocks/math`: Value Objects for handling arbitrary precision numbers. -- `tiny-blocks/outbox`: Write-side adapter for the Transactional Outbox pattern that persists domain events atomically - with aggregate state through Doctrine DBAL. -- `tiny-blocks/time`: Models time as immutable value objects for PHP: instants, durations, periods, timezones, and - time-of-day, all UTC-normalized. -- `tiny-blocks/value-object`: Defines the default behavior contract for PHP value objects with structural equality. diff --git a/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py b/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py deleted file mode 100644 index b6fa359..0000000 --- a/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -"""Rebuild references/catalog.md from the tiny-blocks vendor on Packagist. - -Usage: - python3 scripts/refresh-catalog.py - -Depends only on the Python standard library. No curl, no jq, no shell. -""" - -from __future__ import annotations - -import json -import sys -import textwrap -import urllib.error -import urllib.request -from pathlib import Path -from typing import List, Optional - -VENDOR = "tiny-blocks" -LIST_URL = f"https://packagist.org/packages/list.json?vendor={VENDOR}" -CATALOG_PATH = Path(__file__).resolve().parent.parent / "references" / "catalog.md" -LINE_WIDTH = 120 -REQUEST_TIMEOUT_SECONDS = 30 - -CATALOG_HEADER = """\ -# tiny-blocks catalog - -Index of published tiny-blocks packages and their one-line purpose. Generated from Packagist by -scripts/refresh-catalog.py, not hand-maintained. For the full API of a package, read its README -and public PHPDoc under vendor/tiny-blocks//. - -""" - - -def report(message: str) -> None: - print(message, file=sys.stderr) - - -def fetch_json(url: str) -> dict: - request = urllib.request.Request(url=url, headers={"User-Agent": "tiny-blocks-catalog"}) - - with urllib.request.urlopen(url=request, timeout=REQUEST_TIMEOUT_SECONDS) as response: - payload = json.load(fp=response) - return payload - - -def sanitize(description: str) -> str: - collapsed = " ".join(description.split()) - - for character in (";", "—", "–"): - collapsed = collapsed.replace(character, ",") - return collapsed - - -def catalog_line(name: str) -> Optional[str]: - try: - metadata = fetch_json(url=f"https://packagist.org/packages/{name}.json").get("package", {}) - except (urllib.error.URLError, json.JSONDecodeError): - report(message=f"Skipping {name}, metadata fetch failed.") - return None - - if metadata.get("abandoned"): - return None - - description = sanitize(description=metadata.get("description") or "") - - return textwrap.fill( - text=f"- `{name}`: {description}", - width=LINE_WIDTH, - subsequent_indent=" ", - break_long_words=False, - break_on_hyphens=False, - ) - - -def build_catalog() -> str: - names = sorted(fetch_json(url=LIST_URL).get("packageNames", [])) - lines: List[str] = [] - - for name in names: - line = catalog_line(name=name) - - if line is not None: - lines.append(line) - return CATALOG_HEADER + "\n".join(lines) + "\n" - - -def main() -> int: - try: - catalog = build_catalog() - except (urllib.error.URLError, json.JSONDecodeError) as error: - report(message=f"Failed to build the catalog: {error}") - return 1 - - CATALOG_PATH.write_text(data=catalog, encoding="utf-8") - print(f"Wrote {CATALOG_PATH}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.claude/skills/tiny-blocks-create/SKILL.md b/.claude/skills/tiny-blocks-create/SKILL.md deleted file mode 100644 index efcc62b..0000000 --- a/.claude/skills/tiny-blocks-create/SKILL.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -name: tiny-blocks-create -description: Scaffold a new PHP library for the tiny-blocks ecosystem, or restore a single canonical config/repository file (composer.json, phpcs.xml, phpunit.xml, phpstan.neon.dist, infection.json.dist, .editorconfig, .gitattributes, .gitignore, Makefile, the CI workflow, SECURITY.md, the issue templates, the PR template) to its standard shape. Use this skill whenever the user asks to create, bootstrap, set up, or initialize a new tiny-blocks library, to add the standard config/tooling files to a repository, or to fix/regenerate any of those files to match the ecosystem standard, even if they only mention one file by name. This skill owns the canonical bodies of those files. Do not hand-write them from memory. ---- - -# tiny-blocks library scaffolding - -This skill is the single source of truth for the boilerplate every tiny-blocks PHP library -shares: the config files, the CI workflow, and the repository templates. The canonical bodies -live in `assets/` as drop-in files. Copy them and substitute the placeholders rather than -regenerating them from memory. The assets already encode the ecosystem's decisions (PSR-12 only, -`level: max`, MSI 100, Docker-wrapped Makefile, the `.dist` naming split, the export-ignore set). - -The semantic conventions (how to name classes, how to structure `src/`, how to write tests) are -**not** in this skill. They live in `.claude/rules/`. This skill produces the skeleton. The rules -govern the code you then write into it. - -## When to use which mode - -- **Full scaffold**: the user is starting a new library. Create the directory skeleton and copy - every asset, substituting placeholders. -- **Single-file restore**: the user wants one file brought back to standard (for example, "fix - my Makefile" or "regenerate the CI workflow"). Copy only that asset. Do not touch the rest. - -## Asset map - -Copy each asset to the path on the right, relative to the repository root. - -| Asset (`assets/…`) | Destination | Placeholders | -|--------------------------------------------|---------------------------------------------|--------------| -| `config/composer.json` | `composer.json` | yes | -| `config/phpcs.xml` | `phpcs.xml` | no | -| `config/phpstan.neon.dist` | `phpstan.neon.dist` | no | -| `config/phpunit.xml` | `phpunit.xml` | no | -| `config/infection.json.dist` | `infection.json.dist` | no | -| `config/.editorconfig` | `.editorconfig` | no | -| `config/.gitattributes` | `.gitattributes` | no | -| `config/.gitignore` | `.gitignore` | no | -| `config/Makefile` | `Makefile` | no | -| `github/workflows/ci.yml` | `.github/workflows/ci.yml` | no | -| `github/ISSUE_TEMPLATE/bug_report.md` | `.github/ISSUE_TEMPLATE/bug_report.md` | no | -| `github/ISSUE_TEMPLATE/feature_request.md` | `.github/ISSUE_TEMPLATE/feature_request.md` | no | -| `github/PULL_REQUEST_TEMPLATE.md` | `.github/PULL_REQUEST_TEMPLATE.md` | no | -| `docs/SECURITY.md` | `SECURITY.md` | yes | - -## Placeholders - -Two assets carry placeholders. Substitute every occurrence. - -| Placeholder | Meaning | Example | -|---------------------------------------------------------|----------------------------------------------|---------------------------| -| `` | Repository name, kebab-case | `event-sourcing` | -| `` | PSR-4 namespace segment, PascalCase | `EventSourcing` | -| `` | `composer.json` `description` (one sentence) | n/a | -| ``, `` | `composer.json` `keywords` topic tokens | `psr-7`, `event-sourcing` | - -`` appears in `composer.json` (`name`, `homepage`, `support`) and in `SECURITY.md` -(advisory URL). `` appears only in `composer.json` (`autoload` / `autoload-dev` PSR-4 -prefixes). The first `keywords` entry is always `tiny-blocks`. The topic tokens follow. - -## Full scaffold steps - -1. Confirm ``, ``, the one-sentence description, and the keyword topics with - the user if not already known. -2. Create the directory skeleton: - ``` - src/ - tests/ - .github/workflows/ - .github/ISSUE_TEMPLATE/ - ``` -3. Copy every asset to its destination (table above), substituting placeholders. -4. Author the files this skill does **not** carry, following the rules: - - `README.md`: follow `php-library-documentation.md` (title, license badge, TOC, the fixed - section order, code-example rules). - - `LICENSE`: MIT, attributed to the author in `composer.json`. - - Initial `src/` and `tests/`: follow `php-library-architecture.md`, - `php-library-code-style.md`, `php-library-modeling.md`, and `php-library-testing.md`. -5. Validate (see below) before reporting the scaffold complete. - -## The .dist naming split - -The assets deliberately commit `phpcs.xml` and `phpunit.xml` as live files, but -`phpstan.neon.dist` and `infection.json.dist` with the `.dist` suffix. This is intentional and -documented in `php-library-tooling.md`: the ruleset and the test config are stable and committed -live. PHPStan and Infection are the two tools a contributor may tune locally, so a gitignored -`phpstan.neon` / `infection.json` overrides the committed `.dist` baseline. Do not add a `.dist` -twin for `phpcs.xml`/`phpunit.xml`, and do not commit a live `phpstan.neon`/`infection.json`. - -## Extending the CI tests job - -`ci.yml` is the minimal canonical workflow. Only the `tests` job may be extended, and only when -the library's tests need external services, environment variables, or fixture preparation. Add -them inside the `tests` job. Leave `resolve-php-version`, `build`, and `auto-review` untouched. -Example with a MySQL service container: - -```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 -``` - -## Pinned action versions - -The action versions pinned in `ci.yml` (`actions/checkout@v6`, `shivammathur/setup-php@v2`, -`actions/upload-artifact@v7`, `actions/download-artifact@v8`) may be outdated. Before adopting the -workflow, verify the current major version of each action and update the pin while preserving the -`@vN` prefix style, as required by `php-library-github-workflows.md` rule 8. - -## Validate - -After scaffolding (or restoring `composer.json`/the test config), run the toolchain through the -Makefile and confirm both pass before reporting done: - -- `make review`: phpcs (PSR-12) and phpstan (`level: max`) must pass clean. -- `make tests`: phpunit and infection must pass with MSI 100 / covered MSI 100. - -If `make` targets are missing, `make help` lists them. Do not claim the scaffold is complete on -the strength of file creation alone. The definition of done is a clean `review` and `tests`. diff --git a/.claude/skills/tiny-blocks-create/assets/config/.editorconfig b/.claude/skills/tiny-blocks-create/assets/config/.editorconfig deleted file mode 100644 index be5640e..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/.editorconfig +++ /dev/null @@ -1,19 +0,0 @@ -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 diff --git a/.claude/skills/tiny-blocks-create/assets/config/.gitattributes b/.claude/skills/tiny-blocks-create/assets/config/.gitattributes deleted file mode 100644 index 2bd9baa..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/.gitattributes +++ /dev/null @@ -1,19 +0,0 @@ -* text=auto eol=lf - -*.php text diff=php - -# Dev-only, excluded from the Packagist tarball -/.github export-ignore -/tests export-ignore -/.claude export-ignore -/CLAUDE.md export-ignore -/.editorconfig export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/phpcs.xml export-ignore -/phpunit.xml export-ignore -/phpstan.neon.dist export-ignore -/infection.json.dist export-ignore -/Makefile export-ignore -/reports export-ignore -/.phpunit.cache export-ignore diff --git a/.claude/skills/tiny-blocks-create/assets/config/.gitignore b/.claude/skills/tiny-blocks-create/assets/config/.gitignore deleted file mode 100644 index c8f4364..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -# PHP dependencies -/vendor/ -composer.lock - -# Local config overrides (committed baselines are the .dist files) -/phpstan.neon -/infection.json - -# Tooling cache -.phpunit.cache/ -.phpunit.result.cache - -# Coverage and reports -build/ -reports/ -coverage/ -infection.log - -# Editors and agents -.idea/ -.cursor/ -.vscode/ -/.claude/settings.local.json - -# OS -Thumbs.db -.DS_Store -Desktop.ini diff --git a/.claude/skills/tiny-blocks-create/assets/config/Makefile b/.claude/skills/tiny-blocks-create/assets/config/Makefile deleted file mode 100644 index 90ab50d..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/Makefile +++ /dev/null @@ -1,74 +0,0 @@ -PWD := $(CURDIR) -ARCH := $(shell uname -m) -PLATFORM := - -ifeq ($(ARCH),arm64) - PLATFORM := --platform=linux/amd64 -endif - -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 -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/.claude/skills/tiny-blocks-create/assets/config/composer.json b/.claude/skills/tiny-blocks-create/assets/config/composer.json deleted file mode 100644 index e10a520..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/composer.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "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.52", - "infection/infection": "^0.33", - "phpstan/phpstan": "^2.2", - "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" - ] - } -} diff --git a/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist b/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist deleted file mode 100644 index aab8c7e..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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" -} diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml b/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml deleted file mode 100644 index a52372c..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - Code style for the tiny-blocks library. - - src - tests - diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist b/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist deleted file mode 100644 index 0df69df..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist +++ /dev/null @@ -1,6 +0,0 @@ -parameters: - level: max - paths: - - src - - tests - reportUnmatchedIgnoredErrors: true diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml b/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml deleted file mode 100644 index 9cc6d13..0000000 --- a/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - src - - - - - - tests - - - - - - - - - - - - - - - - - diff --git a/.claude/skills/tiny-blocks-create/assets/docs/SECURITY.md b/.claude/skills/tiny-blocks-create/assets/docs/SECURITY.md deleted file mode 100644 index a892afe..0000000 --- a/.claude/skills/tiny-blocks-create/assets/docs/SECURITY.md +++ /dev/null @@ -1,12 +0,0 @@ -# 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. diff --git a/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md b/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 8ddd1db..0000000 --- a/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -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: diff --git a/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md b/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index b344d9e..0000000 --- a/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -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. diff --git a/.claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md b/.claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 7a2c836..0000000 --- a/.claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,16 +0,0 @@ -> 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/skills/tiny-blocks-create/assets/github/workflows/ci.yml b/.claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml deleted file mode 100644 index 728bb3b..0000000 --- a/.claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: CI - -on: - pull_request: - -concurrency: - group: ci-${{ 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 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d7efbcf..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,61 +0,0 @@ -# tiny-blocks PHP library - -A library in the [tiny-blocks](https://github.com/tiny-blocks) ecosystem: small, focused, -framework-agnostic PHP building blocks published to Packagist. Target runtime is **PHP 8.5**. - -This file is the index. The detailed conventions live in `.claude/rules/` (loaded automatically -when you touch matching files) and in three skills under `.claude/skills/`. Keep this file short: -when a convention needs explaining, it belongs in a rule or a skill, not here. - -## Validate - -Every PHP and Composer command runs inside Docker via the `Makefile` (image -`gustavofreze/php:8.5-alpine`). Never run PHP on the host directly. - -- `make review`: phpcs (PSR-12) + phpstan (`level: max`). Run before claiming code is clean. -- `make tests`: phpunit + infection. Mutation thresholds are `minMsi: 100` / `minCoveredMsi: 100`. -- `make test-file FILE=`: one filtered test file, no coverage. -- `make help`: discover all targets if any of the above is missing or has changed. - -Treat `make review` and `make tests` as the definition of done. Both gate every pull request in -CI. Passing them locally is the bar before any "complete" / "fixed" / "passing" claim. - -## Conventions (`.claude/rules/`) - -Path-scoped. Each loads only when you edit matching files. - -- `php-library-architecture.md`: folder layout, public API boundary, `Internal/` semantics (`src/`). -- `php-library-code-style.md`: semantic code rules, naming, PHPDoc, `self`/`static` (`src/`, `tests/`). -- `php-library-modeling.md`: value objects, exceptions, enums, complexity (`src/`). -- `php-library-testing.md`: BDD Given/When/Then, PHPUnit, fixtures, coverage discipline (`tests/`). -- `php-library-tooling.md`: invariants for `composer.json`, `phpcs.xml`, `phpunit.xml`, etc. -- `php-library-documentation.md`: README and `docs/` conventions. -- `php-library-github-workflows.md`: GitHub Actions conventions. - -## Skills (`.claude/skills/`) - -- `tiny-blocks-create`: scaffold a new library or restore a canonical config/repo file. Holds - the drop-in bodies of every config file, the CI workflow, and the issue/PR/security templates. -- `tiny-blocks-consume`: discover and reuse a published tiny-blocks package as a dependency - instead of writing the capability by hand. Checks the catalog, adds the match with Composer, - and uses the installed package's own README and public API. The consuming counterpart of - `tiny-blocks-create`. -- `commit-message`: generate a Conventional Commits message in the ecosystem's format. Invoke - when writing a commit. Commit messages are never generated automatically. - -## Global defaults - -- All identifiers, comments, documentation, and commit messages use American English. -- In prose and headings, do not use semicolons or em-dashes. This applies to PHPDoc descriptions - and to every Markdown file (README, docs). Use a period or a comma in place of a semicolon, and - a colon, a comma, or parentheses in place of an em-dash. Hyphens in compound words and - identifiers (`tiny-blocks`, `name-length`) are not affected, and semicolons that terminate PHP - statements in code are not affected. -- Prefer dependencies from the tiny-blocks ecosystem before reaching outside it. -- Do not install or update any dependency to a version published less than 7 days ago. Freshly - released versions can be yanked or compromised. Let them age past the cooldown first. Packages - from the tiny-blocks ecosystem (`tiny-blocks/*`) are exempt, they are first-party. When a - dependency bump is needed but the target version is too recent, report it and wait rather than - pinning the new version. -- Do not run any history-altering Git operation (branch, commit, push, merge, rebase, tag) unless - explicitly asked. From d9ac43893a44bd9bd68a8fe39dd39888d116a6a3 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 6 Aug 2026 20:45:11 -0300 Subject: [PATCH 2/5] build: Align tooling with the ecosystem assets and raise dependency floors. --- .gitattributes | 4 +-- .gitignore | 2 ++ composer.json | 8 +++-- phpcs.xml | 92 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/.gitattributes b/.gitattributes index 2bd9baa..f044953 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,11 +2,11 @@ *.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 -/CLAUDE.md export-ignore /.editorconfig export-ignore /.gitattributes export-ignore /.gitignore export-ignore diff --git a/.gitignore b/.gitignore index c8f4364..29546dd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ composer.lock # Tooling cache .phpunit.cache/ .phpunit.result.cache +__pycache__/ +*.pyc # Coverage and reports build/ diff --git a/composer.json b/composer.json index a5b6afa..5d9a5ce 100644 --- a/composer.json +++ b/composer.json @@ -35,11 +35,12 @@ }, "require-dev": { "ergebnis/composer-normalize": "^2.52", - "infection/infection": "^0.33", + "infection/infection": "^0.34", "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^13.1", + "phpunit/phpunit": "^13.2", + "slevomat/coding-standard": "^8.31", "squizlabs/php_codesniffer": "^4.0", - "tiny-blocks/time": "^2.1" + "tiny-blocks/time": "^2.5" }, "minimum-stability": "stable", "prefer-stable": true, @@ -55,6 +56,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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From fd5d913c5f725bb4a7865abc194b690ac9c73bb6 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 6 Aug 2026 20:45:11 -0300 Subject: [PATCH 3/5] refactor: Conform to the Slevomat ruleset and complexity limits. --- src/Internal/Deserialization/Hydrator.php | 11 +++-- .../Resolvers/PureEnumResolver.php | 2 +- src/Internal/Engine.php | 2 +- src/Internal/Json.php | 2 +- src/Internal/Mappings/Layout/ColumnNode.php | 2 +- .../Mappings/Layout/JsonColumnNode.php | 2 +- src/Internal/Mappings/Layout/LayoutCodec.php | 4 +- src/Internal/Mappings/SubtypeMapping.php | 2 +- src/Internal/Metadata/Properties.php | 42 ++++++++++++------- .../Encoders/PureEnumEncoder.php | 2 +- src/Internal/Serialization/ValueWriter.php | 7 +++- src/Mapper.php | 2 +- src/Subtype.php | 2 +- tests/Models/Weekday.php | 2 +- tests/Unit/HydrationTest.php | 4 +- 15 files changed, 54 insertions(+), 34 deletions(-) diff --git a/src/Internal/Deserialization/Hydrator.php b/src/Internal/Deserialization/Hydrator.php index b61f386..c2afd57 100644 --- a/src/Internal/Deserialization/Hydrator.php +++ b/src/Internal/Deserialization/Hydrator.php @@ -34,8 +34,15 @@ public function build(array $source, ClassDescriptor $descriptor): object $property->setValue($instance, $resolved); } + $this->guardUnknown(source: $source, descriptor: $descriptor, expectedKeys: $expectedKeys); + + return $instance; + } + + private function guardUnknown(array $source, ClassDescriptor $descriptor, array $expectedKeys): void + { if (!$this->rejectUnknownKeys) { - return $instance; + return; } foreach (array_keys($source) as $key) { @@ -47,7 +54,5 @@ public function build(array $source, ClassDescriptor $descriptor): object throw new UnexpectedKey(message: sprintf($template, (string)$key, $descriptor->type)); } - - return $instance; } } diff --git a/src/Internal/Deserialization/Resolvers/PureEnumResolver.php b/src/Internal/Deserialization/Resolvers/PureEnumResolver.php index 1cc5395..7baf68e8 100644 --- a/src/Internal/Deserialization/Resolvers/PureEnumResolver.php +++ b/src/Internal/Deserialization/Resolvers/PureEnumResolver.php @@ -4,10 +4,10 @@ namespace TinyBlocks\Mapper\Internal\Deserialization\Resolvers; -use UnitEnum; use TinyBlocks\Mapper\Exceptions\UnmappableSource; use TinyBlocks\Mapper\Internal\Metadata\ClassDescriptor; use TinyBlocks\Mapper\Internal\Metadata\Kind; +use UnitEnum; final readonly class PureEnumResolver implements Resolver { diff --git a/src/Internal/Engine.php b/src/Internal/Engine.php index 6ea619d..8e2173f 100644 --- a/src/Internal/Engine.php +++ b/src/Internal/Engine.php @@ -131,7 +131,7 @@ public function reflectionWrite(object $subject, ?Configuration $configuration): return $this->valueWriter->reflectionWrite( subject: $subject, descriptor: Descriptors::of(type: $subject::class), - configuration: $configuration ?? Configuration::default() + configuration: ($configuration ?? Configuration::default()) ); } } diff --git a/src/Internal/Json.php b/src/Internal/Json.php index d6d057b..42540ef 100644 --- a/src/Internal/Json.php +++ b/src/Internal/Json.php @@ -8,7 +8,7 @@ final class Json { - private const int ENCODE_FLAGS = JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION; + private const int ENCODE_FLAGS = (JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); private function __construct() { diff --git a/src/Internal/Mappings/Layout/ColumnNode.php b/src/Internal/Mappings/Layout/ColumnNode.php index 30aed34..3f1a1f9 100644 --- a/src/Internal/Mappings/Layout/ColumnNode.php +++ b/src/Internal/Mappings/Layout/ColumnNode.php @@ -14,7 +14,7 @@ public function __construct(private string $column) public function read(array $row): mixed { - return $row[$this->column] ?? null; + return ($row[$this->column] ?? null); } public function write(mixed $value, Context $context): array diff --git a/src/Internal/Mappings/Layout/JsonColumnNode.php b/src/Internal/Mappings/Layout/JsonColumnNode.php index 278419e..743388e 100644 --- a/src/Internal/Mappings/Layout/JsonColumnNode.php +++ b/src/Internal/Mappings/Layout/JsonColumnNode.php @@ -15,7 +15,7 @@ public function __construct(private string $column) public function read(array $row): mixed { - $raw = $row[$this->column] ?? null; + $raw = ($row[$this->column] ?? null); return is_string($raw) ? Json::decode(payload: $raw) : $raw; } diff --git a/src/Internal/Mappings/Layout/LayoutCodec.php b/src/Internal/Mappings/Layout/LayoutCodec.php index 4c85831..43448bc 100644 --- a/src/Internal/Mappings/Layout/LayoutCodec.php +++ b/src/Internal/Mappings/Layout/LayoutCodec.php @@ -5,13 +5,13 @@ namespace TinyBlocks\Mapper\Internal\Mappings\Layout; use ReflectionProperty; -use WeakMap; use TinyBlocks\Mapper\Internal\Context; use TinyBlocks\Mapper\Internal\Deserialization\ValueReader; use TinyBlocks\Mapper\Internal\Metadata\Descriptors; use TinyBlocks\Mapper\Internal\Metadata\ShapeAnalyzer; use TinyBlocks\Mapper\JsonColumn; use TinyBlocks\Mapper\NamingStrategy; +use WeakMap; final class LayoutCodec { @@ -76,7 +76,7 @@ private function resolve(array $path, NamingStrategy $naming, ReflectionProperty private function treeFor(string $type, NamingStrategy $naming): NestedNode { - $cached = $this->trees[$naming] ?? []; + $cached = ($this->trees[$naming] ?? []); if (!array_key_exists($type, $cached)) { $cached[$type] = $this->build(type: $type, naming: $naming, prefix: []); diff --git a/src/Internal/Mappings/SubtypeMapping.php b/src/Internal/Mappings/SubtypeMapping.php index a9363f3..1142d11 100644 --- a/src/Internal/Mappings/SubtypeMapping.php +++ b/src/Internal/Mappings/SubtypeMapping.php @@ -22,7 +22,7 @@ public function read(mixed $source, MappingContext $context): object { $engineContext = Context::cast(context: $context); $normalized = Source::normalize(source: $source); - $value = $normalized[$this->field] ?? null; + $value = ($normalized[$this->field] ?? null); $concrete = is_string($value) ? ($this->cases[$value] ?? null) : null; if (is_null($concrete)) { diff --git a/src/Internal/Metadata/Properties.php b/src/Internal/Metadata/Properties.php index cf4b8dc..7f80386 100644 --- a/src/Internal/Metadata/Properties.php +++ b/src/Internal/Metadata/Properties.php @@ -5,6 +5,7 @@ namespace TinyBlocks\Mapper\Internal\Metadata; use ReflectionClass; +use ReflectionProperty; use TinyBlocks\Mapper\Transient; final class Properties @@ -13,6 +14,30 @@ private function __construct() { } + private static function mergeFrom(array $collected, ReflectionClass $reflection): array + { + foreach ($reflection->getProperties() as $property) { + $name = $property->getName(); + + if (!Properties::isEligible(property: $property)) { + continue; + } + + if (array_key_exists($name, $collected)) { + continue; + } + + $collected[$name] = $property; + } + + return $collected; + } + + private static function isEligible(ReflectionProperty $property): bool + { + return !$property->isStatic() && $property->getAttributes(Transient::class) === []; + } + public static function collectDeclared(?ReflectionClass $reflection): array { if (is_null($reflection)) { @@ -23,22 +48,7 @@ public static function collectDeclared(?ReflectionClass $reflection): array $current = $reflection; while ($current !== false) { - foreach ($current->getProperties() as $property) { - if ($property->isStatic()) { - continue; - } - - if ($property->getAttributes(Transient::class) !== []) { - continue; - } - - if (array_key_exists($property->getName(), $collected)) { - continue; - } - - $collected[$property->getName()] = $property; - } - + $collected = Properties::mergeFrom(collected: $collected, reflection: $current); $current = $current->getParentClass(); } diff --git a/src/Internal/Serialization/Encoders/PureEnumEncoder.php b/src/Internal/Serialization/Encoders/PureEnumEncoder.php index c270fdf..12b4f11 100644 --- a/src/Internal/Serialization/Encoders/PureEnumEncoder.php +++ b/src/Internal/Serialization/Encoders/PureEnumEncoder.php @@ -4,9 +4,9 @@ namespace TinyBlocks\Mapper\Internal\Serialization\Encoders; -use UnitEnum; use TinyBlocks\Mapper\Internal\Metadata\ClassDescriptor; use TinyBlocks\Mapper\Internal\Metadata\Kind; +use UnitEnum; final readonly class PureEnumEncoder implements Encoder { diff --git a/src/Internal/Serialization/ValueWriter.php b/src/Internal/Serialization/ValueWriter.php index 25549d6..4ea20bd 100644 --- a/src/Internal/Serialization/ValueWriter.php +++ b/src/Internal/Serialization/ValueWriter.php @@ -57,6 +57,11 @@ public function serialize(object $subject, Configuration $configuration): mixed : $this->serializeObject(subject: $subject, descriptor: $descriptor, configuration: $configuration); } + private function skipsNull(mixed $value, Configuration $configuration): bool + { + return is_null($value) && $configuration->omitsNulls(); + } + private function writeObject(object $value, Configuration $configuration): mixed { $registered = $this->registry->find(type: $value::class); @@ -97,7 +102,7 @@ public function reflectionWrite(object $subject, ClassDescriptor $descriptor, Co $propertyValue = $property->getValue($subject); - if (is_null($propertyValue) && $configuration->omitsNulls()) { + if ($this->skipsNull(value: $propertyValue, configuration: $configuration)) { continue; } diff --git a/src/Mapper.php b/src/Mapper.php index 3844350..c02bbdd 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -47,7 +47,7 @@ public function toJson(object $source, ?Configuration $configuration = null): st public function toArray(object $source, ?Configuration $configuration = null): array { - $written = $this->engine->write(value: $source, configuration: $configuration ?? Configuration::default()); + $written = $this->engine->write(value: $source, configuration: ($configuration ?? Configuration::default())); return is_array($written) ? $written : [$written]; } diff --git a/src/Subtype.php b/src/Subtype.php index 0f207d9..df7153c 100644 --- a/src/Subtype.php +++ b/src/Subtype.php @@ -38,7 +38,7 @@ public static function by( ?NamingStrategy $naming = null, ?Closure $default = null ): Mapping { - $strategy = $naming ?? SnakeCase::create(); + $strategy = ($naming ?? SnakeCase::create()); $cases = []; foreach ($types as $type) { diff --git a/tests/Models/Weekday.php b/tests/Models/Weekday.php index 2e9cc67..b8da633 100644 --- a/tests/Models/Weekday.php +++ b/tests/Models/Weekday.php @@ -14,7 +14,7 @@ public static function fromName(string $name): Weekday { $cases = ['mon' => 'monday', 'monday' => 'monday', 'tue' => 'tuesday', 'tuesday' => 'tuesday']; - return new Weekday(name: $cases[$name] ?? $name); + return new Weekday(name: ($cases[$name] ?? $name)); } public function name(): string diff --git a/tests/Unit/HydrationTest.php b/tests/Unit/HydrationTest.php index 8a33b64..be96613 100644 --- a/tests/Unit/HydrationTest.php +++ b/tests/Unit/HydrationTest.php @@ -5,9 +5,11 @@ namespace Test\TinyBlocks\Mapper\Unit; use ArrayIterator; +use DateTime; use DateTimeImmutable; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\Mapper\Models\Amount; +use Test\TinyBlocks\Mapper\Models\Camera; use Test\TinyBlocks\Mapper\Models\Charge; use Test\TinyBlocks\Mapper\Models\Currency; use Test\TinyBlocks\Mapper\Models\DebitCard; @@ -18,7 +20,6 @@ use Test\TinyBlocks\Mapper\Models\PaymentMethod; use Test\TinyBlocks\Mapper\Models\Pix; use Test\TinyBlocks\Mapper\Models\Profile; -use Test\TinyBlocks\Mapper\Models\Camera; use Test\TinyBlocks\Mapper\Models\Refund; use Test\TinyBlocks\Mapper\Models\Refunds; use Test\TinyBlocks\Mapper\Models\Scalars; @@ -28,7 +29,6 @@ use Test\TinyBlocks\Mapper\Models\Variant; use Test\TinyBlocks\Mapper\Models\VersionedTag; use Test\TinyBlocks\Mapper\Models\Wallet; -use DateTime; use TinyBlocks\Mapper\Exceptions\UnexpectedKey; use TinyBlocks\Mapper\Exceptions\UnknownSubtype; use TinyBlocks\Mapper\Exceptions\UnmappableSource; From 6cdf67c46531b3e33bb3ac0dddaf8a6310d185be Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 6 Aug 2026 20:48:18 -0300 Subject: [PATCH 4/5] ci: Remove the CodeQL workflow. --- .github/workflows/codeql.yml | 39 ------------------------------------ 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index cb1cf3d..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.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 From 9f8946ba762f4f2734eba67deddcee47ac3189cc Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 6 Aug 2026 21:07:03 -0300 Subject: [PATCH 5/5] build: Restore the tiny-blocks/time floor to the resolvable version. tiny-blocks/time requires tiny-blocks/mapper from 2.2.0 onward, so raising the floor past 2.1 leaves Composer no version to pick while mapper is the root package. A fresh resolve without a lock file fails, which is what CI does. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5d9a5ce..5feaae4 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "phpunit/phpunit": "^13.2", "slevomat/coding-standard": "^8.31", "squizlabs/php_codesniffer": "^4.0", - "tiny-blocks/time": "^2.5" + "tiny-blocks/time": "^2.1" }, "minimum-stability": "stable", "prefer-stable": true,