Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 35 additions & 47 deletions .claude/rules/php-library-code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,27 +44,45 @@ Verify every item before producing any PHP code. If any item fails, revise befor
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 body size ascending (number of lines between `{` and `}`).
Constants and enum cases, which have no body, are ordered by name length ascending. 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.
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 the **visible
width** of each call expression ascending. The body is not visible at the call site, so the
visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and
`->httpOnly()` come before parameterized `with*` builders for the same reason. When two
calls have equal width, order them alphabetically by method name.
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 width. 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
of name length. A `build()` that returns the built value, a `commit()` that finalizes a unit
of work, a `send()` that flushes a request, are terminal: the chain ends with them. The
ordering rule applies only to consecutive calls on the same receiver type; calls that
transition to a different type are not reorderable. The same applies in reverse to the
factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays
at its position.

**PHPUnit test classes** follow a dedicated sub-grouping inside the instance-methods group
that overrides the name-length-ascending rule:

1. **Lifecycle hooks** first, in PHPUnit execution order:
`setUpBeforeClass` → `setUp` → `tearDown` → `tearDownAfterClass`. Only those actually
defined appear; never introduce an empty hook to satisfy the rule.
2. **Test methods** (prefix `test`) next, ordered by name length ascending (alphabetical
tiebreak).
3. **Data providers** last, ordered by name length ascending (alphabetical tiebreak).

A method is a data provider if and only if its name appears as the string argument of a
`#[DataProvider('<name>')]` attribute or a `@dataProvider <name>` 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
Expand Down Expand Up @@ -225,10 +243,7 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali

### When required

- Every method of an interface, **including interfaces declared inside `src/Internal/`**.
Interfaces define contracts. The contract is documentation by definition, regardless of
namespace. The `Internal/` boundary applies to implementations, not to the contracts that
internal collaborators expose to each other.
- Every method of an interface.
- Every public method of a concrete class outside `src/Internal/`. Public classes are at the
public API boundary by definition. Consumers call every public method directly, and the
PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt.
Expand All @@ -244,10 +259,7 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali
interface. The interface carries the docblock.
- Anything inside `src/Internal/`. Internal types are implementation detail and must not carry
PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the
architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An
interface declared inside `src/Internal/` still defines a contract, and the contract is
documented per `### When required` regardless of namespace. The prohibition covers concrete
classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces.
architectural meaning of `Internal/`.
- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz`
naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in
`php-library-testing.md` describe the steps. PHPDoc documentation (summary plus
Expand All @@ -270,10 +282,7 @@ The PHPDoc prohibitions above take priority over the typed-array case. When PHPS

- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not
add PHPDoc.
- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via
`ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception:
they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved
through the PHPDoc, never through `ignoreErrors`.
- On anything inside **`src/Internal/`** → suppress via `ignoreErrors`. Do not add PHPDoc.
- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc.
- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary,
`@param` descriptions, and the typed-array information. The bare-tag form remains
Expand Down Expand Up @@ -338,8 +347,7 @@ public function __construct(public array $entries)
}
```

**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does
not extend to interfaces; see "Correct" below for an Internal/ interface):
**Prohibited.** PHPDoc on anything inside `src/Internal/`:

```php
namespace TinyBlocks\Http\Internal\Client;
Expand All @@ -353,26 +361,6 @@ final readonly class Url
}
```

**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every
method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they
are the contract:

```php
namespace TinyBlocks\Http\Internal\Client;

interface RequestResolver
{
/**
* Resolves the given URL against the configured base URL.
*
* @param string $url The path or absolute URL to resolve.
* @return string The absolute URL to dispatch.
* @throws MalformedPath If the URL violates RFC 3986.
*/
public function resolve(string $url): string;
}
```

**Correct.** Generic array type with summary and `@param` description:

```php
Expand Down
5 changes: 4 additions & 1 deletion .claude/rules/php-library-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ Verify every item before producing any test code. If any item fails, revise befo
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 data
5. No private or helper methods in test classes. The only non-test methods allowed are PHPUnit
lifecycle hooks (`setUp`, `setUpBeforeClass`, `tearDown`, `tearDownAfterClass`) and data
providers. Setup logic complex enough to extract belongs in a dedicated fixture class.
6. Test only the public API. Never assert on private state or `Internal/` classes directly.
7. Test the behavior that **raises** an exception, never the exception itself. Exception classes
Expand Down Expand Up @@ -69,6 +70,8 @@ Verify every item before producing any test code. If any item fails, revise befo
15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from
coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See
"Coverage and mutation discipline".
16. Member ordering in test classes follows `php-library-code-style.md` rule 6 (PHPUnit
test-class sub-grouping).

## Structure: Given/When/Then (BDD)

Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"php": "^8.5"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.51",
"infection/infection": "^0.32",
"ergebnis/composer-normalize": "^2.52",
"infection/infection": "^0.33",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^13.1",
Comment thread
gustavofreze marked this conversation as resolved.
"squizlabs/php_codesniffer": "^4.0"
Expand Down
12 changes: 12 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,16 @@ parameters:
# Internal array-hash collaborator accepts arrays with arbitrary value types by design.
- identifier: missingType.iterableValue
path: src/Internal/ArrayHash.php

# Internal property extractor returns an untyped array by design; PHPDoc is prohibited in Internal/.
- identifier: missingType.iterableValue
path: src/Internal/ObjectProperties.php

# Private properties are read via reflection; PHPStan cannot trace reflection-based access.
- identifier: property.onlyWritten
path: tests/Models/PrivateMoney.php

# Private property is read via reflection; PHPStan cannot trace reflection-based access.
- identifier: property.onlyWritten
path: tests/Models/MixedVisibilityProfile.php
reportUnmatchedIgnoredErrors: true
2 changes: 1 addition & 1 deletion src/Internal/ArrayEquality.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public static function areEqual(array $left, array $right): bool

return array_all(
$left,
fn($element, $key) => StructuralEquality::areEqual(
fn(mixed $element, int|string $key): bool => StructuralEquality::areEqual(
left: $element,
right: $right[$key]
)
Expand Down
7 changes: 5 additions & 2 deletions src/Internal/ArrayHash.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ public static function hash(array $subject): string
$serialized = '[';

foreach ($subject as $key => $element) {
$serialized = sprintf('%s%s=%s;', $serialized, $key, StructuralHash::hash(subject: $element));
$template = '%s%s=%s;';
$serialized = sprintf($template, $serialized, $key, StructuralHash::hash(subject: $element));
}

return sprintf('%s]', $serialized);
$template = '%s]';

return sprintf($template, $serialized);
}
}
26 changes: 26 additions & 0 deletions src/Internal/ObjectProperties.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Vo\Internal;

use ReflectionObject;

final readonly class ObjectProperties
{
public static function extract(object $subject): array
{
$reflection = new ReflectionObject(object: $subject);
$properties = [];

Comment thread
gustavofreze marked this conversation as resolved.
foreach ($reflection->getProperties() as $property) {
if ($property->isStatic()) {
continue;
}

$properties[$property->getName()] = $property->getValue(object: $subject);
}

return $properties;
}
}
9 changes: 6 additions & 3 deletions src/Internal/ValueObjectEquality.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ public static function areEqual(ValueObject $left, ValueObject $right): bool
return false;
}

$rightProperties = get_object_vars($right);
$rightProperties = ObjectProperties::extract(subject: $right);

return array_all(
get_object_vars($left),
fn($element, $name) => StructuralEquality::areEqual(left: $element, right: $rightProperties[$name])
ObjectProperties::extract(subject: $left),
fn(mixed $element, string $name): bool => StructuralEquality::areEqual(
left: $element,
right: $rightProperties[$name]
)
);
}
}
5 changes: 3 additions & 2 deletions src/Internal/ValueObjectHash.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ public static function hash(ValueObject $subject): string
{
$serialized = $subject::class;

foreach (get_object_vars($subject) as $name => $element) {
$serialized = sprintf('%s|%s=%s', $serialized, $name, StructuralHash::hash(subject: $element));
foreach (ObjectProperties::extract(subject: $subject) as $name => $element) {
$template = '%s|%s=%s';
$serialized = sprintf($template, $serialized, $name, StructuralHash::hash(subject: $element));
}

return hash('xxh128', $serialized);
Expand Down
17 changes: 17 additions & 0 deletions tests/Models/MixedVisibilityProfile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Vo\Models;

use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;

final readonly class MixedVisibilityProfile implements ValueObject
{
use ValueObjectBehavior;

public function __construct(public string $name, private ?string $nickname)
{
}
}
17 changes: 17 additions & 0 deletions tests/Models/PrivateMoney.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Vo\Models;

use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;

final readonly class PrivateMoney implements ValueObject
{
use ValueObjectBehavior;

public function __construct(private int $amount, private Currency $currency)
{
}
}
20 changes: 20 additions & 0 deletions tests/Models/Rating.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Vo\Models;

use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;

final class Rating implements ValueObject
{
use ValueObjectBehavior;

private static int $ratingCount = 0;

public function __construct(public readonly int $score)
Comment thread
gustavofreze marked this conversation as resolved.
{
self::$ratingCount++;
}
}
Loading