diff --git a/.claude/skills/immutable-base/SKILL.md b/.claude/skills/immutable-base/SKILL.md new file mode 100644 index 0000000..07bf93f --- /dev/null +++ b/.claude/skills/immutable-base/SKILL.md @@ -0,0 +1,40 @@ +--- +name: immutable-base +description: "Apply this skill whenever working with reallifekip/immutable-base — creating or modifying DataTransferObject, ValueObject, or SingleValueObject classes; using attributes like ArrayOf, SkipOnNull, Defaults, InputKeyTo, OutputKeyTo, Strict, Spec; or calling toArray(), toJson(), with(), equals(), fromArray(), fromJson()." +license: MIT +metadata: + author: Zhang-mason +--- + +# immutable-base + +Strict immutable DTOs, VOs, and SVOs for PHP 8.4+ with construction-time type validation, deep path mutation, and automatic validation chaining. + +## Three Base Classes + +| Class | Use when | Construction | +|---|---|---| +| `DataTransferObject` | Carrying structured data between layers, no domain rules | `::fromArray()` | +| `ValueObject` | Multi-property domain concept with invariants | `::fromArray()` | +| `SingleValueObject` | Single scalar with domain validation (email, phone, money) | `::from()` | + +## Quick Reference + +- **DTO guide** → `rules/data-transfer-object.md` +- **VO / SVO guide** → `rules/value-object.md` +- **All attributes** → `rules/attributes.md` +- **Serialization & mutation** → `rules/serialization.md` + +## Key Rules (apply always) + +- Properties declared at **class level**, not constructor promotion +- Instantiate via **named constructors** (`::fromArray()` / `::from()`), never `new` +- All properties must be **public** (readonly enforced by `readonly class`) +- Use `#[ArrayOf(Class::class)]` for typed array properties — never raw `array` without it +- `DataTransferObject` — no `validate()` needed; `ValueObject` / `SingleValueObject` — override `validate(): bool` + +## How to Apply + +1. Identify which base class fits (DTO / VO / SVO) +2. Read the relevant rule file +3. Check `rules/attributes.md` for any needed modifiers diff --git a/.claude/skills/immutable-base/rules/attributes.md b/.claude/skills/immutable-base/rules/attributes.md new file mode 100644 index 0000000..f42072e --- /dev/null +++ b/.claude/skills/immutable-base/rules/attributes.md @@ -0,0 +1,252 @@ +# Attributes + +All attributes live in `ReallifeKip\ImmutableBase\Attributes\`. + +--- + +## #[ArrayOf] — typed array elements + +**Target:** property +**Applies to:** DTO, VO +**Requirement:** property type must be exactly `array` + +Declares that every element of an `array` property must be a specific type. Elements are auto-hydrated on construction. + +```php +use ReallifeKip\ImmutableBase\Attributes\ArrayOf; +use ReallifeKip\ImmutableBase\Enums\Native; + +readonly class OrderDto extends DataTransferObject +{ + #[ArrayOf(OrderItemDto::class)] // ImmutableBase subclass + public array $items; + + #[ArrayOf(Native::string)] // primitive: Native::string, ::int, ::float, ::bool + public array $tags; +} + +// Items can be arrays (auto-hydrated) or already-constructed instances: +OrderDto::fromArray([ + 'items' => [ + ['sku' => 'ABC', 'qty' => 2], // auto-hydrated to OrderItemDto + OrderItemDto::fromArray(['sku' => 'XYZ', 'qty' => 1]), // passthrough + ], + 'tags' => ['urgent', 'fragile'], +]); +``` + +--- + +## #[Defaults] — property fallback value + +**Target:** property +**Applies to:** DTO, VO +**Priority:** lowest (explicit input → `defaultValues()` → `#[Defaults]`) + +```php +use ReallifeKip\ImmutableBase\Attributes\Defaults; + +readonly class CreateUserDto extends DataTransferObject +{ + public string $name; + #[Defaults('user')] + public string $role; // 'user' when 'role' key absent from input + #[Defaults(false)] + public bool $isActive; +} +``` + +Note: `#[Defaults]` is ignored when the key is explicitly present with `null`. + +--- + +## #[SkipOnNull] — omit null values from serialization + +**Target:** class or property +**Applies to:** DTO, VO + +When applied at **class level**, all nullable properties are omitted from `toArray()` / `toJson()` output when their value is `null`. +When applied at **property level**, only that property is omitted. + +```php +use ReallifeKip\ImmutableBase\Attributes\SkipOnNull; +use ReallifeKip\ImmutableBase\Attributes\KeepOnNull; + +#[SkipOnNull] +readonly class UserDto extends DataTransferObject +{ + public string $name; + public ?string $nickname; // omitted when null + #[KeepOnNull] + public ?string $bio; // always included, even when null +} + +UserDto::fromArray(['name' => 'Alice', 'nickname' => null, 'bio' => null])->toArray(); +// ['name' => 'Alice', 'bio' => null] +``` + +--- + +## #[KeepOnNull] — force-keep null despite SkipOnNull + +**Target:** property +**Applies to:** DTO, VO +**Requires:** class-level `#[SkipOnNull]` to be meaningful + +Overrides class-level `#[SkipOnNull]` for a specific property, ensuring it always appears in `toArray()` / `toJson()` output even when `null`. + +--- + +## #[InputKeyTo] — remap input key case on construction + +**Target:** class or property +**Applies to:** DTO, VO + +Converts input array keys to the specified `KeyCase` before property matching. Useful when consuming snake_case APIs into camelCase properties. + +```php +use ReallifeKip\ImmutableBase\Attributes\InputKeyTo; +use ReallifeKip\ImmutableBase\Enums\KeyCase; + +#[InputKeyTo(KeyCase::Camel)] // class-level: all keys converted +readonly class UserDto extends DataTransferObject +{ + public string $firstName; + public string $lastName; +} + +// snake_case input works automatically: +UserDto::fromArray(['first_name' => 'Alice', 'last_name' => 'Smith']); +``` + +Property-level `#[InputKeyTo]` overrides the class-level setting for that specific property. + +### Available KeyCase values + +| Case | Example | +|---|---| +| `KeyCase::Snake` | `nick_name` | +| `KeyCase::Camel` | `nickName` | +| `KeyCase::Pascal` | `NickName` | +| `KeyCase::Kebab` | `nick-name` | +| `KeyCase::Macro` | `NICK_NAME` | +| `KeyCase::PascalSnake` | `Nick_Name` | +| `KeyCase::Train` | `Nick-Name` | +| `KeyCase::CamelKebab` | `nick-Name` | + +--- + +## #[OutputKeyTo] — remap output key case for serialization + +**Target:** class or property +**Applies to:** DTO, VO + +Converts property names to the specified `KeyCase` in `toArray()` / `toJson()` output. + +```php +use ReallifeKip\ImmutableBase\Attributes\OutputKeyTo; +use ReallifeKip\ImmutableBase\Enums\KeyCase; + +#[OutputKeyTo(KeyCase::Snake)] // class-level: all keys snake_case in output +readonly class UserDto extends DataTransferObject +{ + public string $firstName; + public string $lastName; +} + +UserDto::fromArray(['firstName' => 'Alice', 'lastName' => 'Smith'])->toArray(); +// ['first_name' => 'Alice', 'last_name' => 'Smith'] +``` + +--- + +## #[Strict] — reject unknown input keys + +**Target:** class +**Applies to:** DTO, VO + +Throws `StrictViolationException` if the input array contains keys not declared as properties. + +```php +use ReallifeKip\ImmutableBase\Attributes\Strict; + +#[Strict] +readonly class CreateUserDto extends DataTransferObject +{ + public string $name; + public string $email; +} + +CreateUserDto::fromArray(['name' => 'Alice', 'email' => 'a@b.com', 'extra' => 'x']); +// StrictViolationException: unknown keys ['extra'] +``` + +Global strict mode (without per-class attribute): `ImmutableBase::strict(true)`. + +--- + +## #[Lax] — opt out of global strict mode + +**Target:** class +**Applies to:** DTO, VO + +When `ImmutableBase::strict(true)` is enabled globally, `#[Lax]` exempts a specific class from strict key checking. + +```php +use ReallifeKip\ImmutableBase\Attributes\Lax; + +ImmutableBase::strict(true); + +#[Lax] +readonly class FlexibleDto extends DataTransferObject +{ + public string $name; + // accepts extra keys without throwing +} +``` + +--- + +## #[Spec] — domain validation message + +**Target:** class +**Applies to:** VO, SVO only (not DTO) + +Attaches a domain message to the `ValidationChainException` thrown when `validate()` returns `false`. Use as an error code, i18n key, or human-readable description. + +```php +use ReallifeKip\ImmutableBase\Attributes\Spec; + +#[Spec('email.invalid')] +readonly class Email extends SingleValueObject +{ + public string $value; + + public function validate(): bool + { + return filter_var($this->value, FILTER_VALIDATE_EMAIL) !== false; + } +} +``` + +--- + +## #[ValidateFromSelf] — child-first validation order + +**Target:** class +**Applies to:** VO, SVO + +By default, the validation chain runs **parent → child**. `#[ValidateFromSelf]` reverses it to **child → parent**, so the most specific rule is enforced first. + +```php +use ReallifeKip\ImmutableBase\Attributes\ValidateFromSelf; + +#[ValidateFromSelf] +readonly class PositiveMoney extends Money +{ + public function validate(): bool + { + return $this->amount > 0; // checked before Money::validate() + } +} +``` diff --git a/.claude/skills/immutable-base/rules/data-transfer-object.md b/.claude/skills/immutable-base/rules/data-transfer-object.md new file mode 100644 index 0000000..6434277 --- /dev/null +++ b/.claude/skills/immutable-base/rules/data-transfer-object.md @@ -0,0 +1,203 @@ +# DataTransferObject + +Carries structured data between layers with no domain validation. Construction-time type checking is automatic — if a value doesn't match the declared property type, an exception is thrown immediately. + +## Structure + +```php +validated()); + +// From explicit array +$dto = CreateUserDto::fromArray([ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'password' => 'secret', + 'role' => null, +]); + +// From JSON string +$dto = CreateUserDto::fromJson('{"name":"Alice","email":"alice@example.com","password":"secret"}'); +``` + +## Named Constructors (recommended for domain mapping) + +When the input source uses different key names or needs transformation, add a static factory: + +```php +readonly class UserResultDto extends DataTransferObject +{ + public int $id; + public string $name; + public string $email; + public string $role; + + public static function fromModel(User $user): self + { + return self::fromArray([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role->value, // Enum → scalar + ]); + } +} +``` + +## Nested DTOs + +Nested objects are hydrated automatically when the input is an associative array: + +```php +readonly class OrderDto extends DataTransferObject +{ + public string $reference; + public AddressDto $shippingAddress; // nested DTO — auto-hydrated +} + +// Both of these work: +OrderDto::fromArray([ + 'reference' => 'ORD-001', + 'shippingAddress' => ['street' => '123 Main St', 'city' => 'Taipei'], +]); + +OrderDto::fromArray([ + 'reference' => 'ORD-001', + 'shippingAddress' => AddressDto::fromArray(['street' => '123 Main St', 'city' => 'Taipei']), +]); +``` + +## Typed Array Properties + +Use `#[ArrayOf]` — see `rules/attributes.md` for full details. + +```php +use ReallifeKip\ImmutableBase\Attributes\ArrayOf; + +readonly class OrderDto extends DataTransferObject +{ + #[ArrayOf(OrderItemDto::class)] + public array $items; // OrderItemDto[] — each element auto-hydrated +} +``` + +## Default Values + +Two ways to provide defaults: + +```php +// Option A: #[Defaults] attribute on the property +use ReallifeKip\ImmutableBase\Attributes\Defaults; + +readonly class CreateUserDto extends DataTransferObject +{ + public string $name; + #[Defaults('user')] + public string $role; // defaults to 'user' when absent from input +} + +// Option B: override defaultValues() +readonly class CreateUserDto extends DataTransferObject +{ + public string $name; + public string $role; + + public static function defaultValues(): array + { + return ['role' => 'user']; + } +} +``` + +## Enum Properties + +Enum properties accept either the enum instance, its case name (string), or its backed value: + +```php +readonly class CreateUserDto extends DataTransferObject +{ + public string $name; + public UserRole $role; // BackedEnum +} + +// All three work: +CreateUserDto::fromArray(['name' => 'Alice', 'role' => UserRole::Admin]); +CreateUserDto::fromArray(['name' => 'Alice', 'role' => 'Admin']); // case name +CreateUserDto::fromArray(['name' => 'Alice', 'role' => 1]); // backed value +``` + +## Rules + +### Extend DataTransferObject, not ImmutableBase directly + +```php +// Bad +readonly class CreateUserDto extends ImmutableBase {} + +// Good +readonly class CreateUserDto extends DataTransferObject {} +``` + +### Class must be readonly + +```php +// Bad — mutable, reflection hydration still works but defeats immutability +class CreateUserDto extends DataTransferObject +{ + public string $name; +} + +// Good +readonly class CreateUserDto extends DataTransferObject +{ + public string $name; +} +``` + +### Properties at class level — no constructor promotion + +The constructor is `protected` and takes `array $data`. Promotion syntax is incompatible. + +```php +// Bad — will not work +readonly class CreateUserDto extends DataTransferObject +{ + public function __construct(public string $name) {} +} + +// Good +readonly class CreateUserDto extends DataTransferObject +{ + public string $name; +} +``` + +### Never instantiate with new + +```php +// Bad — constructor is protected +$dto = new CreateUserDto(['name' => 'Alice']); + +// Good +$dto = CreateUserDto::fromArray(['name' => 'Alice']); +``` + +### No business logic in DTOs + +DTOs carry data only. Transformations and decisions belong in Services or ValueObjects. diff --git a/.claude/skills/immutable-base/rules/serialization.md b/.claude/skills/immutable-base/rules/serialization.md new file mode 100644 index 0000000..5be2cc2 --- /dev/null +++ b/.claude/skills/immutable-base/rules/serialization.md @@ -0,0 +1,198 @@ +# Serialization & Mutation + +All methods are available on `DataTransferObject`, `ValueObject`, and `SingleValueObject` unless noted. + +--- + +## Construction + +### ::fromArray(array $data): static + +Hydrates from an associative array. Keys map to property names (or remapped names when `#[InputKeyTo]` is set). + +```php +$dto = CreateUserDto::fromArray(['name' => 'Alice', 'email' => 'alice@example.com']); +``` + +### ::fromJson(string $data): static + +Hydrates from a JSON object string. Rejects non-object JSON (arrays like `[1,2,3]`). + +```php +$dto = CreateUserDto::fromJson('{"name":"Alice","email":"alice@example.com"}'); +``` + +### ::from($value): static *(SVO only)* + +Single-value construction for `SingleValueObject`. + +```php +$email = Email::from('alice@example.com'); +``` + +--- + +## Serialization + +### toArray(KeyCase|bool $keyCase = false): array + +Converts to an associative array. Three output key formats: + +```php +$dto->toArray(); // property names as-is +$dto->toArray(true); // respect #[OutputKeyTo] definitions +$dto->toArray(KeyCase::Snake); // force all keys to snake_case +$dto->toArray(KeyCase::Camel); // force all keys to camelCase +``` + +Null handling: +- Properties with `#[SkipOnNull]` are omitted when `null` +- Properties with `#[KeepOnNull]` always appear, even with class-level `#[SkipOnNull]` + +SVOs serialize to their scalar `$value` when nested inside another object's `toArray()`. + +### toJson(KeyCase|bool $keyCase = false): string + +Delegates to `toArray()` then `json_encode()`. Same `$keyCase` options apply. + +```php +$dto->toJson(); // {"name":"Alice","email":"alice@example.com"} +$dto->toJson(KeyCase::Snake); // {"first_name":"Alice","last_name":"Smith"} +``` + +--- + +## Immutable Mutation + +### with(string|array|object $data, string $separator = '.'): static + +Returns a **new instance** with the specified properties replaced. The original is unchanged. + +```php +$original = CreateUserDto::fromArray(['name' => 'Alice', 'email' => 'alice@example.com']); + +$updated = $original->with(['name' => 'Bob']); +// $original->name === 'Alice' (unchanged) +// $updated->name === 'Bob' + +// From JSON string +$updated = $original->with('{"name":"Bob"}'); + +// From object +$updated = $original->with((object) ['name' => 'Bob']); +``` + +### Deep path mutation + +Use dot-notation to update nested properties: + +```php +readonly class OrderDto extends DataTransferObject +{ + public string $reference; + public AddressDto $address; +} + +$order = OrderDto::fromArray([ + 'reference' => 'ORD-001', + 'address' => ['street' => '123 Main St', 'city' => 'Taipei'], +]); + +$updated = $order->with(['address.city' => 'Kaohsiung']); +// $updated->address->city === 'Kaohsiung' + +// Bracket notation for arrays +$updated = $order->with(['items[0].qty' => 3]); + +// Custom separator +$updated = $order->with(['address/city' => 'Kaohsiung'], separator: '/'); +``` + +--- + +## Equality + +### equals(static $value): bool + +Deep structural equality check. Requires exact class match. + +```php +$a = CreateUserDto::fromArray(['name' => 'Alice', 'email' => 'a@b.com']); +$b = CreateUserDto::fromArray(['name' => 'Alice', 'email' => 'a@b.com']); +$c = CreateUserDto::fromArray(['name' => 'Bob', 'email' => 'b@b.com']); + +$a->equals($b); // true — same class, same values +$a->equals($c); // false — different name +``` + +For SVOs, compares the wrapped `$value` directly. + +Throws `InvalidCompareTargetException` if comparing different classes. + +--- + +## Global Configuration + +```php +// Enable strict mode globally (all classes reject unknown input keys) +ImmutableBase::strict(true); + +// Enable debug logging (logs redundant input keys to a file) +ImmutableBase::debug('/path/to/log/dir'); +ImmutableBase::debug(null); // disable + +// Pre-load reflection cache (call in bootstrap for production performance) +ImmutableBase::loadCache(); +``` + +--- + +## SVO-specific Ergonomics + +`SingleValueObject` adds scalar-like behaviour on top of the standard methods: + +```php +$email = Email::from('alice@example.com'); + +(string) $email; // "alice@example.com" — __toString +$email(); // "alice@example.com" — __invoke +json_encode($email); // "alice@example.com" — JsonSerializable (not {"value":...}) +$email->value; // "alice@example.com" — direct property +$email->toArray(); // ['value' => 'alice@example.com'] +$email->toJson(); // '"alice@example.com"' +``` + +--- + +## Exceptions Reference + +### Construction / Mutation (thrown during `::fromArray()`, `::fromJson()`, `::from()`, `->with()`) + +| Exception | Thrown when | +|---|---| +| `RequiredValueException` | Non-nullable property is absent or `null` in input | +| `InvalidValueException` | Value type does not match the declared property type | +| `InvalidEnumValueException` | Enum property receives a value matching no case | +| `InvalidJsonException` | `fromJson()` / `with()` receives malformed JSON | +| `ValidationChainException` | `validate()` returns `false` (VO / SVO only) | +| `StrictViolationException` | Input has keys not declared as properties (`#[Strict]` or global strict mode) | +| `InvalidArrayOfItemException` | An element of an `#[ArrayOf]` array cannot be resolved to the declared type | + +### Comparison + +| Exception | Thrown when | +|---|---| +| `InvalidCompareTargetException` | `equals()` called with a different class | + +### Class Definition (thrown on first instantiation when the class is defined incorrectly) + +| Exception | Cause | +|---|---| +| `InvalidArrayOfUsageException` | `#[ArrayOf]` placed on a non-`array` property | +| `InvalidArrayOfTargetException` | Invalid target type passed to `#[ArrayOf]` | +| `InvalidPropertyTypeException` | Forbidden property type declared | +| `InvalidVisibilityException` | Non-public property declared | +| `InvalidSpecException` | Empty or invalid `#[Spec]` value | +| `InvalidWithPathException` | Deep path in `with()` targets a non-traversable property | + +All exceptions extend `ImmutableBaseException`. RuntimeException subclasses cover construction/mutation; LogicException subclasses cover class definition errors. diff --git a/.claude/skills/immutable-base/rules/value-object.md b/.claude/skills/immutable-base/rules/value-object.md new file mode 100644 index 0000000..3699924 --- /dev/null +++ b/.claude/skills/immutable-base/rules/value-object.md @@ -0,0 +1,231 @@ +# ValueObject & SingleValueObject + +Value objects represent domain concepts with invariants. Unlike DTOs, they run `validate()` on construction and throw `ValidationChainException` if the object would be invalid. + +## Choosing the Right Class + +| | `ValueObject` | `SingleValueObject` | +|---|---|---| +| Properties | Multiple | Exactly one (`$value`) | +| Construction | `::fromArray()` | `::from($scalar)` | +| Use for | Multi-field domain concepts (Money, Address, DateRange) | Single scalar with rules (Email, Phone, Percentage) | +| `validate()` | Override in subclass | Override in subclass | + +--- + +## ValueObject (multi-property) + +```php +amount >= 0 + && in_array($this->currency, ['TWD', 'USD', 'EUR'], true); + } +} + +// Construction — same as DataTransferObject +$price = Money::fromArray(['amount' => 1000, 'currency' => 'TWD']); + +// Invalid → throws ValidationChainException immediately +$price = Money::fromArray(['amount' => -1, 'currency' => 'TWD']); +``` + +### Validation message with #[Spec] + +```php +use ReallifeKip\ImmutableBase\Attributes\Spec; + +#[Spec('amount must be non-negative and currency must be TWD, USD, or EUR')] +readonly class Money extends ValueObject +{ + public int $amount; + public string $currency; + + public function validate(): bool + { + return $this->amount >= 0 + && in_array($this->currency, ['TWD', 'USD', 'EUR'], true); + } +} +``` + +### Inheritance — validation chains up the hierarchy + +Each class in the chain runs its own `validate()`. All must pass. + +```php +readonly class PositiveMoney extends Money +{ + public function validate(): bool + { + return $this->amount > 0; // also runs Money::validate() from parent + } +} +``` + +To run child validation first (instead of parent-first), use `#[ValidateFromSelf]`: + +```php +use ReallifeKip\ImmutableBase\Attributes\ValidateFromSelf; + +#[ValidateFromSelf] +readonly class PositiveMoney extends Money +{ + public function validate(): bool + { + return $this->amount > 0; + } +} +``` + +--- + +## SingleValueObject (single scalar) + +Wraps exactly one scalar (`string|int|float|bool`) in `$value`. Construction via `::from()`. + +```php +value, FILTER_VALIDATE_EMAIL) !== false; + } +} + +$email = Email::from('alice@example.com'); // valid +$email = Email::from('not-an-email'); // throws ValidationChainException + +// Scalar ergonomics +echo $email; // "alice@example.com" (__toString) +echo $email(); // "alice@example.com" (__invoke) +json_encode($email); // "alice@example.com" (JsonSerializable) +$email->value; // "alice@example.com" (direct access) +``` + +### Spec message on SVO + +```php +use ReallifeKip\ImmutableBase\Attributes\Spec; + +#[Spec('Must be a valid email address')] +readonly class Email extends SingleValueObject +{ + public string $value; + + public function validate(): bool + { + return filter_var($this->value, FILTER_VALIDATE_EMAIL) !== false; + } +} +``` + +### SVO as a property of DTO/VO + +SVOs inside a DTO/VO accept either the SVO instance or its raw scalar: + +```php +readonly class CreateUserDto extends DataTransferObject +{ + public string $name; + public Email $email; // SVO property +} + +// Both work: +CreateUserDto::fromArray(['name' => 'Alice', 'email' => Email::from('alice@example.com')]); +CreateUserDto::fromArray(['name' => 'Alice', 'email' => 'alice@example.com']); // raw scalar +``` + +--- + +## Common SVO Examples + +```php +readonly class Percentage extends SingleValueObject +{ + public float $value; + + public function validate(): bool + { + return $this->value >= 0.0 && $this->value <= 100.0; + } +} + +readonly class PhoneNumber extends SingleValueObject +{ + public string $value; + + public function validate(): bool + { + return (bool) preg_match('/^\+?[0-9]{7,15}$/', $this->value); + } +} + +readonly class PositiveInt extends SingleValueObject +{ + public int $value; + + public function validate(): bool + { + return $this->value > 0; + } +} +``` + +--- + +## Rules + +### validate() must return bool — never throw inside it + +```php +// Bad — throwing inside validate() bypasses the ValidationChainException contract +public function validate(): bool +{ + if ($this->amount < 0) { + throw new \InvalidArgumentException('negative'); + } + return true; +} + +// Good +public function validate(): bool +{ + return $this->amount >= 0; +} +``` + +### SVO $value must be a scalar type + +```php +// Bad — SVO cannot hold complex types +readonly class OrderId extends SingleValueObject +{ + public array $value; // invalid — will throw at scan time +} + +// Good +readonly class OrderId extends SingleValueObject +{ + public int $value; +} +``` + +### Do not call validate() manually + +Construction via `::fromArray()` / `::from()` triggers validation automatically. diff --git a/bin/ib-skill b/bin/ib-skill new file mode 100755 index 0000000..101347a --- /dev/null +++ b/bin/ib-skill @@ -0,0 +1,136 @@ +#!/usr/bin/env php + install($skillSrc, $skillDest), + 'remove' => remove($skillDest), + default => help(), +}; + +// --------------------------------------------------------------------------- + +function findProjectRoot(): string +{ + // Walk up from vendor/reallifekip/immutable-base/bin/ to the project root + $candidates = [ + __DIR__ . '/../../../..', // when run from vendor/ + __DIR__ . '/../', // when run from the package root directly + getcwd(), + ]; + + foreach ($candidates as $path) { + $real = realpath($path); + if ($real && file_exists("$real/composer.json")) { + return $real; + } + } + + return getcwd(); +} + +function install(string $src, string $dest): void +{ + if (!is_dir($src)) { + fwrite(STDERR, "Error: skill source not found at $src\n"); + exit(1); + } + + if (is_dir($dest)) { + out("Skill already installed at $dest"); + out("Run 'vendor/bin/ib-skill remove' first to reinstall."); + exit(0); + } + + ensureDir(dirname($dest)); + copyDir($src, $dest); + + out("✓ immutable-base skill installed to .claude/skills/immutable-base/"); + out(" Claude Code will now use it automatically when working with"); + out(" DataTransferObject, ValueObject, or SingleValueObject."); +} + +function remove(string $dest): void +{ + if (!is_dir($dest)) { + out("Skill not installed at $dest — nothing to remove."); + exit(0); + } + + removeDir($dest); + out("✓ immutable-base skill removed from .claude/skills/immutable-base/"); +} + +function help(): void +{ + out("immutable-base Claude Code skill installer"); + out(""); + out("Usage:"); + out(" vendor/bin/ib-skill install Install skill into .claude/skills/immutable-base/"); + out(" vendor/bin/ib-skill remove Remove .claude/skills/immutable-base/"); +} + +function copyDir(string $src, string $dest): void +{ + ensureDir($dest); + + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($items as $item) { + $target = $dest . DIRECTORY_SEPARATOR . $items->getSubPathname(); + + if ($item->isDir()) { + ensureDir($target); + } else { + copy($item->getPathname(), $target); + } + } +} + +function removeDir(string $dir): void +{ + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($dir); +} + +function ensureDir(string $dir): void +{ + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } +} + +function out(string $line): void +{ + echo $line . PHP_EOL; +} diff --git a/composer.json b/composer.json index 163d3f2..28cefd6 100755 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ }, "bin": [ "bin/ib-cacher", - "bin/ib-writer" + "bin/ib-writer", + "bin/ib-skill" ] -} \ No newline at end of file +}