From 57844f6e0a8f89d8095e010f3b933896d60de881 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:26:23 +0900 Subject: [PATCH 01/19] Restore fresh composer install with explicit vimeo/psalm constraint be-framework/psalm-plugin 1.x-dev requires vimeo/psalm ^7.0@dev, but stability flags in a dependency's constraints are ignored for transitive resolution; only the root package can allow non-stable versions. Since Psalm 7 has no stable release yet, every fresh 'composer install' (and therefore CI) has been failing with an unresolvable dependency since psalm-plugin adopted the ^7.0@dev constraint upstream on 2026-06-10. Declare vimeo/psalm ^7.0@beta in each demo's require-dev so the root package permits the beta line (currently resolves to 7.0.0-beta19). Verified: all eight demos install from scratch and pass their test suites (178 tests) against the freshly resolved dependency set. --- demos/blog-publishing/composer.json | 3 ++- demos/contact-form/composer.json | 3 ++- demos/hello-world/composer.json | 3 ++- demos/insurance-claim/composer.json | 3 ++- demos/loan-application/composer.json | 3 ++- demos/medical-triage/composer.json | 3 ++- demos/order-processing/composer.json | 3 ++- demos/user-registration/composer.json | 3 ++- 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/demos/blog-publishing/composer.json b/demos/blog-publishing/composer.json index a9c6b1a..5f25f08 100644 --- a/demos/blog-publishing/composer.json +++ b/demos/blog-publishing/composer.json @@ -19,7 +19,8 @@ }, "require-dev": { "be-framework/psalm-plugin": "1.x-dev", - "phpunit/phpunit": "^12" + "phpunit/phpunit": "^12", + "vimeo/psalm": "^7.0@beta" }, "scripts": { "psalm": "psalm --show-info=false", diff --git a/demos/contact-form/composer.json b/demos/contact-form/composer.json index d4f56ff..078f23a 100644 --- a/demos/contact-form/composer.json +++ b/demos/contact-form/composer.json @@ -19,7 +19,8 @@ }, "require-dev": { "be-framework/psalm-plugin": "1.x-dev", - "phpunit/phpunit": "^12" + "phpunit/phpunit": "^12", + "vimeo/psalm": "^7.0@beta" }, "scripts": { "psalm": "psalm --show-info=false", diff --git a/demos/hello-world/composer.json b/demos/hello-world/composer.json index f0b1870..ec48e22 100644 --- a/demos/hello-world/composer.json +++ b/demos/hello-world/composer.json @@ -19,7 +19,8 @@ }, "require-dev": { "be-framework/psalm-plugin": "1.x-dev", - "phpunit/phpunit": "^12" + "phpunit/phpunit": "^12", + "vimeo/psalm": "^7.0@beta" }, "scripts": { "dev": "php bin/be.php 'hello?name=World'", diff --git a/demos/insurance-claim/composer.json b/demos/insurance-claim/composer.json index a75b2a4..12aebd0 100644 --- a/demos/insurance-claim/composer.json +++ b/demos/insurance-claim/composer.json @@ -18,7 +18,8 @@ }, "require-dev": { "be-framework/psalm-plugin": "1.x-dev", - "phpunit/phpunit": "^12" + "phpunit/phpunit": "^12", + "vimeo/psalm": "^7.0@beta" }, "scripts": { "psalm": "psalm --show-info=false", diff --git a/demos/loan-application/composer.json b/demos/loan-application/composer.json index b1ecc44..7f18bc5 100644 --- a/demos/loan-application/composer.json +++ b/demos/loan-application/composer.json @@ -18,7 +18,8 @@ }, "require-dev": { "be-framework/psalm-plugin": "1.x-dev", - "phpunit/phpunit": "^12" + "phpunit/phpunit": "^12", + "vimeo/psalm": "^7.0@beta" }, "scripts": { "psalm": "psalm --show-info=false", diff --git a/demos/medical-triage/composer.json b/demos/medical-triage/composer.json index 1a5f370..9ded20d 100644 --- a/demos/medical-triage/composer.json +++ b/demos/medical-triage/composer.json @@ -18,7 +18,8 @@ }, "require-dev": { "be-framework/psalm-plugin": "1.x-dev", - "phpunit/phpunit": "^12" + "phpunit/phpunit": "^12", + "vimeo/psalm": "^7.0@beta" }, "scripts": { "dev": "php bin/be.php", diff --git a/demos/order-processing/composer.json b/demos/order-processing/composer.json index 6900217..3da6c77 100644 --- a/demos/order-processing/composer.json +++ b/demos/order-processing/composer.json @@ -18,7 +18,8 @@ }, "require-dev": { "be-framework/psalm-plugin": "1.x-dev", - "phpunit/phpunit": "^12" + "phpunit/phpunit": "^12", + "vimeo/psalm": "^7.0@beta" }, "scripts": { "dev": "php bin/be.php", diff --git a/demos/user-registration/composer.json b/demos/user-registration/composer.json index 6c56de4..7d15127 100644 --- a/demos/user-registration/composer.json +++ b/demos/user-registration/composer.json @@ -20,7 +20,8 @@ }, "require-dev": { "be-framework/psalm-plugin": "1.x-dev", - "phpunit/phpunit": "^12" + "phpunit/phpunit": "^12", + "vimeo/psalm": "^7.0@beta" }, "scripts": { "dev": "php bin/be.php", From d38332b1ca69cbfa3ad2e806e371aa60e549f88d Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:26:29 +0900 Subject: [PATCH 02/19] Run tests weekly and on manual dispatch All demos depend on dev branches (be-framework/be 0.x-dev, be-framework/psalm-plugin 1.x-dev), so upstream changes can break a fresh install without any push to this repository. The psalm-plugin constraint change on 2026-06-10 went unnoticed for over a month because CI only ran on push and pull_request. A weekly scheduled run surfaces upstream breakage promptly, and workflow_dispatch allows checking on demand. --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 19626da..932e245 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,9 @@ on: branches: [1.x] pull_request: branches: [1.x] + schedule: + - cron: '0 3 * * 1' + workflow_dispatch: jobs: tests: From f2ff75218c338e5e55c2d4d81eeca38b18d9a97c Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:26:29 +0900 Subject: [PATCH 03/19] Ignore generated semantic-dev logs The composer dev/profile scripts write timestamped var/log/semantic-dev-*.json files on every run. Only the curated .json logs are meant to be committed; ignore the generated ones so they stop cluttering git status. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f2cc474..62f4220 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor/ .phpunit.cache/ composer.lock +demos/*/var/log/semantic-dev-*.json From 71a1523192e552c80de16d84929f96b688e43d28 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:26:34 +0900 Subject: [PATCH 04/19] Fix stale test-running instructions in CLAUDE.md Section 6 told agents to run 'composer test' at the repository root and './demos/vendor/bin/phpunit', but there is no root composer.json and no shared demos/vendor directory - each demo is a standalone Composer project. An agent following the contract literally would conclude the test suite is broken. Document the real per-demo workflow, matching README.md and the CI matrix. --- CLAUDE.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26d9981..5fe53bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,9 +133,15 @@ a real demo (replace `Template` with the actual demo name). ## 6. Running tests +Each demo is a standalone Composer project — there is no root `composer.json`. +Install dependencies and run PHPUnit inside the demo directory: + ```bash -composer test # all demos -./demos/vendor/bin/phpunit demos/medical-triage/tests/ # one demo +# one demo +cd demos/medical-triage && composer install && ./vendor/bin/phpunit + +# all demos +for d in demos/*/; do (cd "$d" && composer install -q && ./vendor/bin/phpunit); done ``` If you modify a demo, always run its tests before concluding the task. From f54408a069e6f41c32bae6416e060883a039872b Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:26:34 +0900 Subject: [PATCH 05/19] Align documented PHP requirement with composer constraint Every demo requires php ^8.3 and CI tests 8.3-8.5, but both READMEs still advertised PHP 8.2+. --- README.ja.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.ja.md b/README.ja.md index f28bfd0..dbd5cf1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -183,7 +183,7 @@ cd demos/hello-world && composer install && vendor/bin/phpunit ## 要件 -- PHP 8.2+ +- PHP 8.3+ - [Ray.Di](https://ray-di.github.io/)(依存性注入) ## 背景 diff --git a/README.md b/README.md index f15eaa9..c68e2a7 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ Every demo ships with happy-path integration tests, Semantic validation unit tes ## Requirements -- PHP 8.2+ +- PHP 8.3+ - [Ray.Di](https://ray-di.github.io/) (dependency injection) ## Background From 03d5763233d2f001208ed44f7c3a8d5d575ce136 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:29:48 +0900 Subject: [PATCH 06/19] Complete composer.json metadata for all demos Four demos (insurance-claim, loan-application, medical-triage, order-processing) had no description, and none of the eight declared a license, so 'composer validate --strict' failed. Add the missing descriptions in the established style and declare MIT (matching the repository license) everywhere. All eight demos now pass strict validation. --- demos/blog-publishing/composer.json | 1 + demos/contact-form/composer.json | 1 + demos/hello-world/composer.json | 1 + demos/insurance-claim/composer.json | 2 ++ demos/loan-application/composer.json | 2 ++ demos/medical-triage/composer.json | 2 ++ demos/order-processing/composer.json | 2 ++ demos/user-registration/composer.json | 1 + 8 files changed, 12 insertions(+) diff --git a/demos/blog-publishing/composer.json b/demos/blog-publishing/composer.json index 5f25f08..75cd515 100644 --- a/demos/blog-publishing/composer.json +++ b/demos/blog-publishing/composer.json @@ -1,6 +1,7 @@ { "name": "be-framework/demo-blog-publishing", "description": "Be Framework Blog Publishing Demo - Moment without Potential (pure data Moment) and mini-diamond merge", + "license": "MIT", "type": "project", "autoload": { "psr-4": { diff --git a/demos/contact-form/composer.json b/demos/contact-form/composer.json index 078f23a..64a0984 100644 --- a/demos/contact-form/composer.json +++ b/demos/contact-form/composer.json @@ -1,6 +1,7 @@ { "name": "be-framework/demo-contact-form", "description": "Be Framework Contact Form Demo - Semantic validation and Being transformation", + "license": "MIT", "type": "project", "autoload": { "psr-4": { diff --git a/demos/hello-world/composer.json b/demos/hello-world/composer.json index ec48e22..07080fa 100644 --- a/demos/hello-world/composer.json +++ b/demos/hello-world/composer.json @@ -1,6 +1,7 @@ { "name": "be-framework/demo-hello-world", "description": "Be Framework Hello World Demo - The simplest transformation", + "license": "MIT", "type": "project", "autoload": { "psr-4": { diff --git a/demos/insurance-claim/composer.json b/demos/insurance-claim/composer.json index 12aebd0..a3a7d6a 100644 --- a/demos/insurance-claim/composer.json +++ b/demos/insurance-claim/composer.json @@ -1,5 +1,7 @@ { "name": "be-framework/demo-insurance-claim", + "description": "Be Framework Insurance Claim Demo - Complex convergence with shared Moments across branching Finals", + "license": "MIT", "type": "project", "autoload": { "psr-4": { diff --git a/demos/loan-application/composer.json b/demos/loan-application/composer.json index 7f18bc5..d5c5adb 100644 --- a/demos/loan-application/composer.json +++ b/demos/loan-application/composer.json @@ -1,5 +1,7 @@ { "name": "be-framework/demo-loan-application", + "description": "Be Framework Loan Application Demo - Cascade diamond with staged Moment realization", + "license": "MIT", "type": "project", "autoload": { "psr-4": { diff --git a/demos/medical-triage/composer.json b/demos/medical-triage/composer.json index 9ded20d..967a712 100644 --- a/demos/medical-triage/composer.json +++ b/demos/medical-triage/composer.json @@ -1,5 +1,7 @@ { "name": "be-framework/demo-medical-triage", + "description": "Be Framework Medical Triage Demo - Branching to one of three Finals by $being type matching", + "license": "MIT", "type": "project", "autoload": { "psr-4": { diff --git a/demos/order-processing/composer.json b/demos/order-processing/composer.json index 3da6c77..feca444 100644 --- a/demos/order-processing/composer.json +++ b/demos/order-processing/composer.json @@ -1,5 +1,7 @@ { "name": "be-framework/demo-order-processing", + "description": "Be Framework Order Processing Demo - Diamond metamorphosis with three converging Moments", + "license": "MIT", "type": "project", "autoload": { "psr-4": { diff --git a/demos/user-registration/composer.json b/demos/user-registration/composer.json index 7d15127..1aded4b 100644 --- a/demos/user-registration/composer.json +++ b/demos/user-registration/composer.json @@ -1,6 +1,7 @@ { "name": "be-framework/demo-user-registration", "description": "Be Framework User Registration Demo - Being chain (sequential) pattern", + "license": "MIT", "type": "project", "autoload": { "psr-4": { From 954489412d7ff9e3c41cd2a96cdc412540b8e602 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:34:05 +0900 Subject: [PATCH 07/19] Test the Semantic classes the framework actually resolves in blog-publishing SemanticValidator maps constructor parameter names to PascalCase class names, so ArticleInput's $title and $tags resolve to Semantic\Title and Semantic\Tags. The Semantic tests, however, targeted ArticleTitle and Tag - duplicate orphan classes that nothing in the transformation chain ever invokes. The classes doing the real validation had no direct coverage. Point the tests at Title and Tags, add the missing empty-tags case, and delete the two orphans. --- .../src/Semantic/ArticleTitle.php | 30 ----------------- demos/blog-publishing/src/Semantic/Tag.php | 28 ---------------- .../tests/BlogPublishingTest.php | 33 +++++++++++-------- 3 files changed, 20 insertions(+), 71 deletions(-) delete mode 100644 demos/blog-publishing/src/Semantic/ArticleTitle.php delete mode 100644 demos/blog-publishing/src/Semantic/Tag.php diff --git a/demos/blog-publishing/src/Semantic/ArticleTitle.php b/demos/blog-publishing/src/Semantic/ArticleTitle.php deleted file mode 100644 index c3771e5..0000000 --- a/demos/blog-publishing/src/Semantic/ArticleTitle.php +++ /dev/null @@ -1,30 +0,0 @@ - 200) { - throw new InvalidTitleException(); - } - } -} diff --git a/demos/blog-publishing/src/Semantic/Tag.php b/demos/blog-publishing/src/Semantic/Tag.php deleted file mode 100644 index 77e1fe3..0000000 --- a/demos/blog-publishing/src/Semantic/Tag.php +++ /dev/null @@ -1,28 +0,0 @@ - 30) { - throw new InvalidTagException(); - } - } -} diff --git a/demos/blog-publishing/tests/BlogPublishingTest.php b/demos/blog-publishing/tests/BlogPublishingTest.php index a09b1be..7cc5abf 100644 --- a/demos/blog-publishing/tests/BlogPublishingTest.php +++ b/demos/blog-publishing/tests/BlogPublishingTest.php @@ -11,10 +11,10 @@ use Be\Pattern\BlogPublishing\Final\ArticlePublished; use Be\Pattern\BlogPublishing\Input\ArticleInput; use Be\Pattern\BlogPublishing\Module\AppModule; -use Be\Pattern\BlogPublishing\Semantic\ArticleTitle; use Be\Pattern\BlogPublishing\Semantic\AuthorId; use Be\Pattern\BlogPublishing\Semantic\MarkdownBody; -use Be\Pattern\BlogPublishing\Semantic\Tag; +use Be\Pattern\BlogPublishing\Semantic\Tags; +use Be\Pattern\BlogPublishing\Semantic\Title; use Be\Framework\Becoming; use PHPUnit\Framework\TestCase; use Ray\Di\Injector; @@ -102,9 +102,9 @@ public function testExcerptIsTruncated(): void // Semantic Validation Tests // ────────────────────────────────────────────── - public function testValidArticleTitle(): void + public function testValidTitle(): void { - $semantic = new ArticleTitle(); + $semantic = new Title(); $semantic->validate('Understanding the BE Framework'); $this->addToAssertionCount(1); } @@ -112,14 +112,14 @@ public function testValidArticleTitle(): void public function testEmptyTitleThrowsException(): void { $this->expectException(InvalidTitleException::class); - $semantic = new ArticleTitle(); + $semantic = new Title(); $semantic->validate(''); } public function testTitleTooLongThrowsException(): void { $this->expectException(InvalidTitleException::class); - $semantic = new ArticleTitle(); + $semantic = new Title(); $semantic->validate(str_repeat('a', 201)); } @@ -158,24 +158,31 @@ public function testInvalidAuthorIdThrowsException(): void $semantic->validate('not-a-valid-uuid'); } - public function testValidTag(): void + public function testValidTags(): void { - $semantic = new Tag(); - $semantic->validate('philosophy'); + $semantic = new Tags(); + $semantic->validate(['philosophy', 'framework', 'php']); $this->addToAssertionCount(1); } + public function testEmptyTagsThrowsException(): void + { + $this->expectException(InvalidTagException::class); + $semantic = new Tags(); + $semantic->validate([]); + } + public function testInvalidTagFormatThrowsException(): void { $this->expectException(InvalidTagException::class); - $semantic = new Tag(); - $semantic->validate('INVALID_TAG'); + $semantic = new Tags(); + $semantic->validate(['INVALID_TAG']); } public function testTagTooLongThrowsException(): void { $this->expectException(InvalidTagException::class); - $semantic = new Tag(); - $semantic->validate(str_repeat('a', 31)); + $semantic = new Tags(); + $semantic->validate([str_repeat('a', 31)]); } } From 8d002061eedf32bc0b9d33391037f7bea54fb19b Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:35:15 +0900 Subject: [PATCH 08/19] Test the idempotency guard of order-processing Potentials All three Potential classes implement an already-realized guard in be() - the property README advertises as 'Potential idempotency tests' - but no test ever called be() twice, so the guarantee was implemented yet unverified. Cover realization and repeated be() for InventoryReservation, PaymentCapture, and ShippingDispatch using counting closures. --- .../Potential/InventoryReservationTest.php | 36 ++++++++++++++ .../Moment/Potential/PaymentCaptureTest.php | 49 +++++++++++++++++++ .../Moment/Potential/ShippingDispatchTest.php | 36 ++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 demos/order-processing/tests/Moment/Potential/InventoryReservationTest.php create mode 100644 demos/order-processing/tests/Moment/Potential/PaymentCaptureTest.php create mode 100644 demos/order-processing/tests/Moment/Potential/ShippingDispatchTest.php diff --git a/demos/order-processing/tests/Moment/Potential/InventoryReservationTest.php b/demos/order-processing/tests/Moment/Potential/InventoryReservationTest.php new file mode 100644 index 0000000..ad2aaa9 --- /dev/null +++ b/demos/order-processing/tests/Moment/Potential/InventoryReservationTest.php @@ -0,0 +1,36 @@ +be(); + + $this->assertSame(1, $confirmed); + } + + public function testBeIsIdempotent(): void + { + $confirmed = 0; + $reservation = new InventoryReservation('RSV-TEST-001', function () use (&$confirmed): void { + $confirmed++; + }); + + $reservation->be(); + $reservation->be(); + + $this->assertSame(1, $confirmed); + } +} diff --git a/demos/order-processing/tests/Moment/Potential/PaymentCaptureTest.php b/demos/order-processing/tests/Moment/Potential/PaymentCaptureTest.php new file mode 100644 index 0000000..c197af5 --- /dev/null +++ b/demos/order-processing/tests/Moment/Potential/PaymentCaptureTest.php @@ -0,0 +1,49 @@ + 'TXN-TEST-001'); + + $this->assertNull($capture->getTransactionId()); + } + + public function testBeRealizesThePotential(): void + { + $captured = 0; + $capture = new PaymentCapture('AUTH-TEST', 10000, function () use (&$captured): string { + $captured++; + + return 'TXN-TEST-001'; + }); + + $capture->be(); + + $this->assertSame(1, $captured); + $this->assertSame('TXN-TEST-001', $capture->getTransactionId()); + } + + public function testBeIsIdempotent(): void + { + $captured = 0; + $capture = new PaymentCapture('AUTH-TEST', 10000, function () use (&$captured): string { + $captured++; + + return 'TXN-TEST-' . $captured; + }); + + $capture->be(); + $capture->be(); + + $this->assertSame(1, $captured); + $this->assertSame('TXN-TEST-1', $capture->getTransactionId()); + } +} diff --git a/demos/order-processing/tests/Moment/Potential/ShippingDispatchTest.php b/demos/order-processing/tests/Moment/Potential/ShippingDispatchTest.php new file mode 100644 index 0000000..704292f --- /dev/null +++ b/demos/order-processing/tests/Moment/Potential/ShippingDispatchTest.php @@ -0,0 +1,36 @@ +be(); + + $this->assertSame(1, $dispatched); + } + + public function testBeIsIdempotent(): void + { + $dispatched = 0; + $dispatch = new ShippingDispatch('TRK-TEST-001', 800, function () use (&$dispatched): void { + $dispatched++; + }); + + $dispatch->be(); + $dispatch->be(); + + $this->assertSame(1, $dispatched); + } +} From c38901f4a9780417d156a50b9c54a8b5b5616a98 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:36:06 +0900 Subject: [PATCH 09/19] Cover hello-world's Semantic layer and rejection path hello-world was the only demo with no Semantic test at all: the Name validator and its InvalidNameException were never exercised, and no test showed what happens when validation fails during metamorphosis. Add a whitespace-only-name rejection test through the real Becoming pipeline plus direct Name validator cases, mirroring medical-triage's style. --- demos/hello-world/tests/HelloTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/demos/hello-world/tests/HelloTest.php b/demos/hello-world/tests/HelloTest.php index 9f8dc8d..8f0bc2e 100644 --- a/demos/hello-world/tests/HelloTest.php +++ b/demos/hello-world/tests/HelloTest.php @@ -4,10 +4,13 @@ namespace Be\Pattern\Hello\Tests; +use Be\Pattern\Hello\Exception\InvalidNameException; use Be\Pattern\Hello\Final\Hello; use Be\Pattern\Hello\Input\HelloInput; use Be\Pattern\Hello\Module\AppModule; +use Be\Pattern\Hello\Semantic\Name; use Be\Framework\Becoming; +use Be\Framework\Exception\SemanticVariableException; use PHPUnit\Framework\TestCase; use Ray\Di\Injector; @@ -41,4 +44,25 @@ public function testHelloWithDifferentName(): void $this->assertSame('Hello Be Framework', $final->greeting); } + + public function testBecomingFlowRejectsEmptyName(): void + { + $this->expectException(SemanticVariableException::class); + + ($this->becoming)(new HelloInput(name: ' ')); + } + + public function testValidName(): void + { + $semantic = new Name(); + $semantic->validate('World'); + $this->addToAssertionCount(1); + } + + public function testEmptyNameThrowsException(): void + { + $this->expectException(InvalidNameException::class); + $semantic = new Name(); + $semantic->validate(''); + } } From 61da8c0fa3d879fea3c5f3fbf0090094a64a3cc3 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:38:27 +0900 Subject: [PATCH 10/19] Order constructor parameters per the Input-then-Inject invariant CLAUDE.md invariant 4 requires #[Input] parameters before #[Inject] parameters, never interleaved. LoanApproved (Inject, Inject, Input, Input, Inject) and MetadataResolved (Inject, Input, Input, Inject) both violated the contract they are meant to exemplify. Reorder the parameters and switch the hand-built LoanApproved test constructions to named arguments so the call sites no longer depend on parameter order. --- .../src/Moment/MetadataResolved.php | 2 +- .../src/Final/LoanApproved.php | 4 ++-- .../tests/LoanApplicationTest.php | 20 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/demos/blog-publishing/src/Moment/MetadataResolved.php b/demos/blog-publishing/src/Moment/MetadataResolved.php index c6a9546..f36b796 100644 --- a/demos/blog-publishing/src/Moment/MetadataResolved.php +++ b/demos/blog-publishing/src/Moment/MetadataResolved.php @@ -26,9 +26,9 @@ public string $slug; public function __construct( - #[Inject] SlugGenerated $slugGenerated, #[Input] public string $authorId, #[Input] public array $tags, + #[Inject] SlugGenerated $slugGenerated, #[Inject] AuthorResolverInterface $resolver, ) { $this->slug = $slugGenerated->slug; diff --git a/demos/loan-application/src/Final/LoanApproved.php b/demos/loan-application/src/Final/LoanApproved.php index 6f01a99..a9f6a4d 100644 --- a/demos/loan-application/src/Final/LoanApproved.php +++ b/demos/loan-application/src/Final/LoanApproved.php @@ -27,10 +27,10 @@ public string $approvedAt; public function __construct( - #[Inject] public CollateralValued $collateral, - #[Inject] public InsurancePrepared $insurance, #[Input] public string $eligibilityId, #[Input] public int $requestedAmount, + #[Inject] public CollateralValued $collateral, + #[Inject] public InsurancePrepared $insurance, #[Inject] LoanPolicy $policy, ) { // Stage 2 convergence: realize all Stage 2 Moments diff --git a/demos/loan-application/tests/LoanApplicationTest.php b/demos/loan-application/tests/LoanApplicationTest.php index 7983ccc..bf7bf80 100644 --- a/demos/loan-application/tests/LoanApplicationTest.php +++ b/demos/loan-application/tests/LoanApplicationTest.php @@ -267,11 +267,11 @@ public function testStage2ConvergenceToFinal(): void $loanPolicy = new LoanPolicy(); $final = new LoanApproved( - $collateral, - $insurance, - 'ELIG-20260101-abc12345', - 50000000, - $loanPolicy, + eligibilityId: 'ELIG-20260101-abc12345', + requestedAmount: 50000000, + collateral: $collateral, + insurance: $insurance, + policy: $loanPolicy, ); $this->assertStringStartsWith('LOAN-', $final->loanId); @@ -324,11 +324,11 @@ public function testFullCascadeMetamorphosis(): void // Stage 2 Convergence = Final $loanPolicy = new LoanPolicy(); $final = new LoanApproved( - $collateral, - $insurance, - $eligibility->eligibilityId, - 50000000, - $loanPolicy, + eligibilityId: $eligibility->eligibilityId, + requestedAmount: 50000000, + collateral: $collateral, + insurance: $insurance, + policy: $loanPolicy, ); // Verify final state From abedcddd6049c665deda893b75c7ab956e433504 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:39:31 +0900 Subject: [PATCH 11/19] Remove dead false-check in PasswordHasher and fix blog-publishing description password_hash() has returned string unconditionally since PHP 8.0 (failure raises ValueError), so the false-check throwing a generic RuntimeException was unreachable - and generic exceptions are against house style anyway. blog-publishing's composer description advertised 'Moment without Potential and mini-diamond merge', which describes classes that are not wired into the live chain; the demo's actual documented pattern is the Multi-Reason Being. --- demos/blog-publishing/composer.json | 2 +- demos/user-registration/src/Reason/PasswordHasher.php | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/demos/blog-publishing/composer.json b/demos/blog-publishing/composer.json index 75cd515..2e82c1c 100644 --- a/demos/blog-publishing/composer.json +++ b/demos/blog-publishing/composer.json @@ -1,6 +1,6 @@ { "name": "be-framework/demo-blog-publishing", - "description": "Be Framework Blog Publishing Demo - Moment without Potential (pure data Moment) and mini-diamond merge", + "description": "Be Framework Blog Publishing Demo - Multi-Reason Being orchestrating several Reason services", "license": "MIT", "type": "project", "autoload": { diff --git a/demos/user-registration/src/Reason/PasswordHasher.php b/demos/user-registration/src/Reason/PasswordHasher.php index 508a444..69e2f0c 100644 --- a/demos/user-registration/src/Reason/PasswordHasher.php +++ b/demos/user-registration/src/Reason/PasswordHasher.php @@ -13,12 +13,6 @@ final class PasswordHasher { public function hash(string $password): string { - $hash = password_hash($password, PASSWORD_BCRYPT); - - if ($hash === false) { - throw new \RuntimeException('Password hashing failed'); - } - - return $hash; + return password_hash($password, PASSWORD_BCRYPT); } } From 78b62d7013ff78c08f7e19ea4e2a075928aa7e8a Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:40:03 +0900 Subject: [PATCH 12/19] State the actual test coverage shape in both READMEs The blanket claim that every demo ships Reason layer tests was untrue - only the four advanced demos have them. Describe the real distribution: happy-path plus Semantic tests everywhere, Reason and Potential idempotency tests in the advanced demos. --- README.ja.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.ja.md b/README.ja.md index dbd5cf1..be6940f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -179,7 +179,7 @@ flowchart LR cd demos/hello-world && composer install && vendor/bin/phpunit ``` -各デモには正常系統合テスト、Semantic検証単体テスト、Reasonレイヤーロジックテスト、そして該当する場合はPotential冪等性テストが含まれます。 +各デモには正常系テストとSemantic検証単体テストが含まれます。上級デモにはさらにReasonレイヤーロジックテスト、該当する場合はPotential冪等性テストが含まれます。 ## 要件 diff --git a/README.md b/README.md index c68e2a7..701c697 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ See [Semantic Logging](https://be-framework.github.io/manuals/1.0/en/10-semantic cd demos/hello-world && composer install && vendor/bin/phpunit ``` -Every demo ships with happy-path integration tests, Semantic validation unit tests, Reason layer logic tests, and (where applicable) Potential idempotency tests. +Every demo ships with happy-path tests and Semantic validation unit tests; the advanced demos add Reason layer logic tests and (where applicable) Potential idempotency tests. ## Requirements From a252c67890f5c4efd5044b727897700e6413b748 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:41:22 +0900 Subject: [PATCH 13/19] Sharpen the Reason interface rule to match what the demos do The invariant listed 'clock, randomness' as external I/O boundaries requiring an interface, yet the demos consistently inject in-process generators concretely (UserIdGenerator, WelcomeTokenGenerator, ReceiptGenerator, PublishTimestamper - all use date()/random_bytes(), none has an interface) while reserving interfaces for Reasons that model external systems. An agent applying the written rule would 'fix' half the catalog. Document the actually-observed boundary: external systems need interfaces, in-process policies/calculators/generators do not. Continues the direction of e9e9c35. --- CLAUDE.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5fe53bd..d389b3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,14 +92,15 @@ will not run under Ray.Di or will break framework expectations. 7. **Semantic validators**: one class per concept, one `#[Validate]` method, throw a domain exception from `src/Exception/`. Link to schema.org in the docblock when a standard term exists (`@link https://schema.org/…`). -8. **Reason services**: Reasons sitting at an **external I/O boundary** - (HTTP, DB, payment gateway, third-party API, filesystem, clock, randomness) +8. **Reason services**: Reasons that model an **external system** + (HTTP, DB, payment gateway, credit bureau, third-party API, filesystem) MUST define an `…Interface`; consumers depend on the interface and Ray.Di binds the implementation — so tests can swap in a Fake. Reasons that are - **pure in-process policies or calculators** (rule-book classes with no - I/O) MAY be injected as concrete classes; no interface is required. - Examples: `PaymentGatewayInterface` and `CreditBureauInterface` (boundary); - `IncomePolicy` and `JTASProtocol` (pure policy). + **in-process policies, calculators, or generators** (rule-book classes, + ID/token/timestamp generators with no out-of-process dependency) MAY be + injected as concrete classes; no interface is required. + Examples: `PaymentGatewayInterface` and `CreditBureauInterface` (external + system); `IncomePolicy`, `JTASProtocol`, and `UserIdGenerator` (in-process). 9. **Namespaces**: follow the existing per-demo pattern `Be\Pattern\\\…`. Never invent a new root namespace. 10. **No side effects in Beings.** A Being transforms data; external I/O From fd61c2cbe38c422a9720ac4dba84c52056c78ab7 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:45:48 +0900 Subject: [PATCH 14/19] Fix CardExpiry month-boundary bugs and cover it with tests createFromFormat('m/y') fills the unspecified day and time from 'now', which broke the check in two directions: - On the 29th-31st, an expiry in a shorter month overflowed into the next month (06/26 read on July 31 becomes 2026-07-01), so an expired card passed validation. - A card expiring in the current month carried the current clock time, so on the last day of the month it compared as already expired. Use '!Y-m' (day and time reset) and compare first-of-month to first-of-month, the same semantics CardValidator already implements correctly. Add the missing CardExpiryTest covering format errors, expired cards, and the current-month boundary. --- .../src/Semantic/CardExpiry.php | 9 ++-- .../tests/Semantic/CardExpiryTest.php | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 demos/order-processing/tests/Semantic/CardExpiryTest.php diff --git a/demos/order-processing/src/Semantic/CardExpiry.php b/demos/order-processing/src/Semantic/CardExpiry.php index 01f3cf1..47c68f5 100644 --- a/demos/order-processing/src/Semantic/CardExpiry.php +++ b/demos/order-processing/src/Semantic/CardExpiry.php @@ -18,15 +18,14 @@ final class CardExpiry public function validate(string $cardExpiry): void { // Format: MM/YY - if (!preg_match('/^(0[1-9]|1[0-2])\/\d{2}$/', $cardExpiry)) { + if (!preg_match('/^(0[1-9]|1[0-2])\/(\d{2})$/', $cardExpiry, $matches)) { throw new InvalidCardExpiryException(); } - // Check if not expired - $expiryDate = \DateTime::createFromFormat('m/y', $cardExpiry); - $expiryDate->modify('last day of this month'); + // Valid through the last day of the expiry month: compare month to month + $expiry = \DateTimeImmutable::createFromFormat('!Y-m', sprintf('20%s-%s', $matches[2], $matches[1])); - if ($expiryDate < new \DateTime()) { + if ($expiry < new \DateTimeImmutable('first day of this month 00:00:00')) { throw new InvalidCardExpiryException(); } } diff --git a/demos/order-processing/tests/Semantic/CardExpiryTest.php b/demos/order-processing/tests/Semantic/CardExpiryTest.php new file mode 100644 index 0000000..c1dad12 --- /dev/null +++ b/demos/order-processing/tests/Semantic/CardExpiryTest.php @@ -0,0 +1,50 @@ +semantic = new CardExpiry(); + } + + public function testValidFutureExpiry(): void + { + $this->semantic->validate('12/49'); + $this->addToAssertionCount(1); + } + + public function testCurrentMonthIsStillValid(): void + { + // A card expiring this month is valid through the last day of the month + $this->semantic->validate(date('m/y')); + $this->addToAssertionCount(1); + } + + public function testExpiredCardThrowsException(): void + { + $this->expectException(InvalidCardExpiryException::class); + $this->semantic->validate('01/20'); + } + + public function testInvalidMonthThrowsException(): void + { + $this->expectException(InvalidCardExpiryException::class); + $this->semantic->validate('13/49'); + } + + public function testInvalidFormatThrowsException(): void + { + $this->expectException(InvalidCardExpiryException::class); + $this->semantic->validate('12-49'); + } +} From 76d1ad12ba4579801a22317f498e37e5e9b9e136 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:46:31 +0900 Subject: [PATCH 15/19] Replace the fictitious #[Moment] attribute with the real #[Inject] PHILOSOPHY.md and the After_BeFramework comparison showed OrderConfirmed taking its Moments via a #[Moment] attribute that exists nowhere in the framework - copying the example verbatim fatals with an unknown attribute class. The real OrderConfirmed uses #[Inject]; the docs now match it. --- demos/order-processing/docs/PHILOSOPHY.md | 6 +++--- .../docs/comparison/After_BeFramework.php | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/demos/order-processing/docs/PHILOSOPHY.md b/demos/order-processing/docs/PHILOSOPHY.md index c5b8041..f315133 100644 --- a/demos/order-processing/docs/PHILOSOPHY.md +++ b/demos/order-processing/docs/PHILOSOPHY.md @@ -66,9 +66,9 @@ A Moment is complete within its pipeline but incomplete in the larger context. J ```php final readonly class OrderConfirmed { public function __construct( - #[Moment] InventoryReserved $inventory, - #[Moment] PaymentCompleted $payment, - #[Moment] ShippingArranged $shipping, + #[Inject] InventoryReserved $inventory, + #[Inject] PaymentCompleted $payment, + #[Inject] ShippingArranged $shipping, ) { ... } } ``` diff --git a/demos/order-processing/docs/comparison/After_BeFramework.php b/demos/order-processing/docs/comparison/After_BeFramework.php index a935f00..eefd5cd 100644 --- a/demos/order-processing/docs/comparison/After_BeFramework.php +++ b/demos/order-processing/docs/comparison/After_BeFramework.php @@ -16,6 +16,7 @@ // ═══════════════════════════════════════════════════════════════════════════ use Be\Framework\Attribute\Be; +use Ray\Di\Di\Inject; #[Be([Final\OrderConfirmed::class])] final readonly class Input\OrderInput @@ -164,9 +165,9 @@ public function __construct( public string $status; public function __construct( - #[Moment] public Moment\InventoryReserved $inventory, - #[Moment] public Moment\PaymentCompleted $payment, - #[Moment] public Moment\ShippingArranged $shipping, + #[Inject] public Moment\InventoryReserved $inventory, + #[Inject] public Moment\PaymentCompleted $payment, + #[Inject] public Moment\ShippingArranged $shipping, ) { $this->orderId = 'ORD-' . date('Ymd') . '-' . bin2hex(random_bytes(4)); $this->status = 'confirmed'; @@ -231,7 +232,7 @@ public function __construct( // - Adding a 4th pipeline (e.g., FraudChecked Moment): // - Create Being\Fraud\* classes // - Create Moment\FraudCleared class -// - Add #[Moment] to OrderConfirmed constructor +// - Add an #[Inject] Moment parameter to OrderConfirmed // - Done. No changes to existing code. // // LINES OF CODE: ~150 (spread across focused classes) From 95517c69f13cd2b04c3fc03d8f3a0e1a9163d728 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:47:26 +0900 Subject: [PATCH 16/19] Drop the false $being claim from the insurance-claim catalog entry The Complex Convergence description said each Input resolves to one of the two Finals 'by $being type matching' - wording copied from the medical-triage entry. insurance-claim has no $being discriminator anywhere; only medical-triage implements that mechanism. Describe what the #[Be] attribute actually declares instead. --- README.ja.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.ja.md b/README.ja.md index be6940f..7b906da 100644 --- a/README.ja.md +++ b/README.ja.md @@ -152,7 +152,7 @@ flowchart LR M3([Moment]) -.-> F1 & F2 ``` -保険請求処理デモ。`ClaimInput` と `PolicyInput` はどちらも `#[Be([ClaimSettled, ClaimEscalated])]` を宣言しており、`$being` の型マッチングによって各 Input がちょうど1つの Final に解決されます。`DamageValued`、`AdjustmentReviewed`、`FraudCleared` などの Moment は両方の Final に注入されるため、どちらの分岐を辿っても同じ自己完結ロジックが共有されます。 +保険請求処理デモ。`ClaimInput` と `PolicyInput` はどちらも `#[Be([ClaimSettled, ClaimEscalated])]` を宣言しており、2つの Final を可能な帰結として名指しします。`DamageValued`、`AdjustmentReviewed`、`FraudCleared` などの Moment は両方の Final に注入されるため、どちらの分岐を辿っても同じ自己完結ロジックが共有されます。 > 具体: `ClaimInput` + `PolicyInput` → `ClaimSettled` または `ClaimEscalated`(各 Final に共有 Moment を注入) diff --git a/README.md b/README.md index 701c697..4886f18 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ flowchart LR M3([Moment]) -.-> F1 & F2 ``` -Insurance claim processing. `ClaimInput` and `PolicyInput` both declare `#[Be([ClaimSettled, ClaimEscalated])]`, so each Input resolves to exactly one of the two Finals by `$being` type matching. Moments such as `DamageValued`, `AdjustmentReviewed` and `FraudCleared` are injected into both Finals, so the same self-completion logic is shared regardless of which branch is taken. +Insurance claim processing. `ClaimInput` and `PolicyInput` both declare `#[Be([ClaimSettled, ClaimEscalated])]`, naming the two Finals as possible outcomes. Moments such as `DamageValued`, `AdjustmentReviewed` and `FraudCleared` are injected into both Finals, so the same self-completion logic is shared regardless of which branch is taken. > Concrete: `ClaimInput` + `PolicyInput` → `ClaimSettled` | `ClaimEscalated`, with shared Moments injected into each Final. From 64a294cade464f2d182654f2b8ab00fca70af460 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:48:50 +0900 Subject: [PATCH 17/19] Name amount Semantics after the parameters they validate; fix DTI fallback SemanticValidator resolves a validator class from the constructor parameter name, so ClaimInput's $estimatedAmount looks for EstimatedAmount and LoanInput's $requestedAmount looks for RequestedAmount. The classes were named ClaimAmount and LoanAmount after the business concept instead, so neither could ever be resolved. Rename them to match their parameters (their validate() signatures already did). Also make IncomePolicy's assessment fallback null-based: with '$this->lastDti ?: 0.25' a legitimately computed DTI of 0.0 (huge income, minimal loan) was silently replaced by the 0.25 fallback. Track the not-yet-computed state as null and use ?? instead, with a regression test. --- demos/insurance-claim/README.md | 2 +- .../{ClaimAmount.php => EstimatedAmount.php} | 4 ++-- .../tests/InsuranceClaimTest.php | 8 ++++---- demos/loan-application/README.md | 2 +- .../src/Reason/IncomePolicy.php | 8 ++++---- .../{LoanAmount.php => RequestedAmount.php} | 4 ++-- .../tests/LoanApplicationTest.php | 17 +++++++++++++---- 7 files changed, 27 insertions(+), 18 deletions(-) rename demos/insurance-claim/src/Semantic/{ClaimAmount.php => EstimatedAmount.php} (91%) rename demos/loan-application/src/Semantic/{LoanAmount.php => RequestedAmount.php} (93%) diff --git a/demos/insurance-claim/README.md b/demos/insurance-claim/README.md index 7ce96d4..0469950 100644 --- a/demos/insurance-claim/README.md +++ b/demos/insurance-claim/README.md @@ -73,7 +73,7 @@ the order-processing demo. | **Moment** | `DamageValued`, `AdjustmentReviewed`, `FraudCleared`, `PaymentDispatched`, `EscalationQueued` | | **Potential** | `DamageValuation`, `PaymentExecution` | | **Final** | `ClaimSettled`, `ClaimEscalated` | -| **Semantic** | `PolicyNumber`, `IncidentDate`, `IncidentType`, `ClaimAmount`, `CoverageType`, `ClaimantId` | +| **Semantic** | `PolicyNumber`, `IncidentDate`, `IncidentType`, `EstimatedAmount`, `CoverageType`, `ClaimantId` | | **Reason** | `ClaimRegistrar`, `PolicyRegistry`, `CoverageValidator`, `DamageAppraiser`, `AdjusterAllocator`, `FraudDetector`, `PaymentProcessor`, `SettlementPolicy` | | **Exception** | `InvalidPolicyNumberException`, `InvalidIncidentDateException`, `InvalidIncidentTypeException`, `InvalidClaimAmountException`, `InvalidCoverageTypeException`, `InvalidClaimantIdException` | diff --git a/demos/insurance-claim/src/Semantic/ClaimAmount.php b/demos/insurance-claim/src/Semantic/EstimatedAmount.php similarity index 91% rename from demos/insurance-claim/src/Semantic/ClaimAmount.php rename to demos/insurance-claim/src/Semantic/EstimatedAmount.php index 3c9b41b..136eab0 100644 --- a/demos/insurance-claim/src/Semantic/ClaimAmount.php +++ b/demos/insurance-claim/src/Semantic/EstimatedAmount.php @@ -8,11 +8,11 @@ use Be\Pattern\InsuranceClaim\Exception\InvalidClaimAmountException; /** - * Claim Amount + * Estimated Amount * * Must be a positive integer, maximum 100,000,000. */ -final class ClaimAmount +final class EstimatedAmount { #[Validate] public function validate(int $estimatedAmount): void diff --git a/demos/insurance-claim/tests/InsuranceClaimTest.php b/demos/insurance-claim/tests/InsuranceClaimTest.php index 58c62b7..146a4c1 100644 --- a/demos/insurance-claim/tests/InsuranceClaimTest.php +++ b/demos/insurance-claim/tests/InsuranceClaimTest.php @@ -32,7 +32,7 @@ use Be\Pattern\InsuranceClaim\Reason\PolicyRegistry; use Be\Pattern\InsuranceClaim\Reason\PolicyRegistryInterface; use Be\Pattern\InsuranceClaim\Reason\SettlementPolicy; -use Be\Pattern\InsuranceClaim\Semantic\ClaimAmount; +use Be\Pattern\InsuranceClaim\Semantic\EstimatedAmount; use Be\Pattern\InsuranceClaim\Semantic\ClaimantId; use Be\Pattern\InsuranceClaim\Semantic\CoverageType; use Be\Pattern\InsuranceClaim\Semantic\IncidentDate; @@ -99,7 +99,7 @@ public function testInvalidIncidentType(): void public function testValidClaimAmount(): void { - $semantic = new ClaimAmount(); + $semantic = new EstimatedAmount(); $semantic->validate(500_000); $this->addToAssertionCount(1); } @@ -107,13 +107,13 @@ public function testValidClaimAmount(): void public function testInvalidClaimAmountZero(): void { $this->expectException(InvalidClaimAmountException::class); - (new ClaimAmount())->validate(0); + (new EstimatedAmount())->validate(0); } public function testInvalidClaimAmountExceedsMax(): void { $this->expectException(InvalidClaimAmountException::class); - (new ClaimAmount())->validate(100_000_001); + (new EstimatedAmount())->validate(100_000_001); } public function testValidCoverageType(): void diff --git a/demos/loan-application/README.md b/demos/loan-application/README.md index 4431375..4ce6fcf 100644 --- a/demos/loan-application/README.md +++ b/demos/loan-application/README.md @@ -71,7 +71,7 @@ All Potential classes (`CreditInquiry`, `CollateralRegistration`, `InsuranceCont | **Moment** | Parts with potential | `CreditApproved`, `IncomeApproved`, `CollateralValued`, `InsurancePrepared` | | **Potential** | Deferred side effects | `CreditInquiry`, `CollateralRegistration`, `InsuranceContract` | | **Final** | Terminal convergence | `LoanApproved` | -| **Semantic** | Input validation | `ApplicantId`, `AnnualIncome`, `EmploymentYears`, `LoanAmount`, `PropertyAddress` | +| **Semantic** | Input validation | `ApplicantId`, `AnnualIncome`, `EmploymentYears`, `RequestedAmount`, `PropertyAddress` | | **Reason** | Domain logic & gateways | `IdentityVerifier`, `CreditBureau`, `IncomePolicy`, `PropertyAppraisal`, `InsuranceQuoter`, `LoanPolicy` | | **Exception** | Domain exceptions with i18n | 5 exception classes with en/ja messages | diff --git a/demos/loan-application/src/Reason/IncomePolicy.php b/demos/loan-application/src/Reason/IncomePolicy.php index c81613b..cc27e73 100644 --- a/demos/loan-application/src/Reason/IncomePolicy.php +++ b/demos/loan-application/src/Reason/IncomePolicy.php @@ -12,8 +12,8 @@ */ final class IncomePolicy { - private float $lastDti = 0.0; - private string $lastStability = ''; + private ?float $lastDti = null; + private ?string $lastStability = null; /** * Calculate debt-to-income ratio @@ -53,8 +53,8 @@ public function assessStability(int $employmentYears): string public function getAssessmentResult(): array { return [ - 'dti' => $this->lastDti ?: 0.25, - 'stability' => $this->lastStability ?: 'good', + 'dti' => $this->lastDti ?? 0.25, + 'stability' => $this->lastStability ?? 'good', ]; } } diff --git a/demos/loan-application/src/Semantic/LoanAmount.php b/demos/loan-application/src/Semantic/RequestedAmount.php similarity index 93% rename from demos/loan-application/src/Semantic/LoanAmount.php rename to demos/loan-application/src/Semantic/RequestedAmount.php index 1c90726..f632606 100644 --- a/demos/loan-application/src/Semantic/LoanAmount.php +++ b/demos/loan-application/src/Semantic/RequestedAmount.php @@ -8,11 +8,11 @@ use Be\Framework\Attribute\Validate; /** - * Loan Amount + * Requested Amount * * @link https://schema.org/loanAmount */ -final class LoanAmount +final class RequestedAmount { #[Validate] public function validate(int $requestedAmount): void diff --git a/demos/loan-application/tests/LoanApplicationTest.php b/demos/loan-application/tests/LoanApplicationTest.php index bf7bf80..398048b 100644 --- a/demos/loan-application/tests/LoanApplicationTest.php +++ b/demos/loan-application/tests/LoanApplicationTest.php @@ -29,7 +29,7 @@ use Be\Pattern\LoanApplication\Reason\PropertyAppraisal; use Be\Pattern\LoanApplication\Semantic\AnnualIncome; use Be\Pattern\LoanApplication\Semantic\ApplicantId; -use Be\Pattern\LoanApplication\Semantic\LoanAmount; +use Be\Pattern\LoanApplication\Semantic\RequestedAmount; use Be\Pattern\LoanApplication\Semantic\PropertyAddress; use Be\Pattern\LoanApplication\Semantic\EmploymentYears; use Be\Pattern\LoanApplication\Exception\InvalidPropertyAddressException; @@ -70,7 +70,7 @@ public function testAnnualIncomeZeroThrows(): void public function testLoanAmountWithinRange(): void { - $validator = new LoanAmount(); + $validator = new RequestedAmount(); $validator->validate(50000000); $this->addToAssertionCount(1); } @@ -78,14 +78,14 @@ public function testLoanAmountWithinRange(): void public function testLoanAmountTooLow(): void { $this->expectException(InvalidLoanAmountException::class); - $validator = new LoanAmount(); + $validator = new RequestedAmount(); $validator->validate(100); } public function testLoanAmountTooHigh(): void { $this->expectException(InvalidLoanAmountException::class); - $validator = new LoanAmount(); + $validator = new RequestedAmount(); $validator->validate(999999999); } @@ -129,6 +129,15 @@ public function testIncomePolicyDti(): void $this->assertLessThan(1.0, $dti); } + public function testIncomePolicyPreservesZeroDti(): void + { + // Huge income + minimal loan legitimately rounds to a DTI of 0.0; + // the fallback must not overwrite a computed value + $policy = new IncomePolicy(); + $policy->calculateDti(700000000, 1000000); + $this->assertSame(0.0, $policy->getAssessmentResult()['dti']); + } + public function testIncomePolicyStability(): void { $policy = new IncomePolicy(); From f4bef40b4f3f1fa3ba181697896e58b8cadfc93e Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:49:14 +0900 Subject: [PATCH 18/19] Note the qualifier-attribute convention in MomentTemplate The template showed #[Input] for a Moment's scalar fields while citing InventoryReserved as canonical - but that class (like every real Moment in the catalog) binds its scalars with per-demo qualifier attributes (#[ProductId], #[Quantity], ...) from AppModule, not #[Input]. Add the distinction so a copied template doesn't teach the one pattern its cited example avoids. --- docs/templates/MomentTemplate.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/templates/MomentTemplate.php b/docs/templates/MomentTemplate.php index b9e7a0c..adcb7e5 100644 --- a/docs/templates/MomentTemplate.php +++ b/docs/templates/MomentTemplate.php @@ -30,6 +30,10 @@ public object $potential; public function __construct( + // NOTE: the canonical example binds its scalar fields with per-demo + // qualifier attributes (#[ProductId], #[Quantity], ... bound in + // AppModule) rather than #[Input]. Use #[Input] only when the value + // flows from the predecessor state's properties. #[Input] public string $someField, // TODO: replace #[Inject] /* TODO: SomeReasonInterface */ object $reason, ) { From 9f47a6eb30b5dc5a30816d377e6d5267b5dc4e59 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 18 Jul 2026 02:58:57 +0900 Subject: [PATCH 19/19] Document the pure-data Moment variant in invariant 5 Invariant 5 stated all Moments implement MomentInterface, but five Moments across the catalog (FraudCleared, AdjustmentReviewed, EscalationQueued, IncomeApproved, MetadataResolved) are deliberate pure-data Moments - parts of a whole with no Potential and no be() - each self-documented in its docblock. CodeRabbit flagged MetadataResolved against the written rule, which confirms the rule as stated misleads reviewers and agents. Document the variant instead of 'fixing' five intentional classes. --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d389b3d..033d7a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,10 @@ will not run under Ray.Di or will break framework expectations. 5. **Moments**: implement `MomentInterface`; create the Potential object in the constructor; commit it in `be()`. Do NOT call `be()` from the constructor. The owning Final calls `be()` on its Moments during self- - completion. + completion. Exception: a **pure-data Moment** (a part of the whole that + carries no Potential and performs no side effect) MAY omit + `MomentInterface` and `be()` entirely — state this explicitly in the + class docblock (see `demos/insurance-claim/src/Moment/FraudCleared.php`). 6. **Finals that converge Moments**: take each Moment via `#[Inject]` and call `$this->foo->be()` in the constructor body, then derive the actualized fields. See `demos/order-processing/src/Final/OrderConfirmed.php` for the