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: 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 diff --git a/CLAUDE.md b/CLAUDE.md index 26d9981..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 @@ -92,14 +95,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 @@ -133,9 +137,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. diff --git a/README.ja.md b/README.ja.md index f28bfd0..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 を注入) @@ -179,11 +179,11 @@ flowchart LR cd demos/hello-world && composer install && vendor/bin/phpunit ``` -各デモには正常系統合テスト、Semantic検証単体テスト、Reasonレイヤーロジックテスト、そして該当する場合はPotential冪等性テストが含まれます。 +各デモには正常系テストとSemantic検証単体テストが含まれます。上級デモにはさらにReasonレイヤーロジックテスト、該当する場合はPotential冪等性テストが含まれます。 ## 要件 -- PHP 8.2+ +- PHP 8.3+ - [Ray.Di](https://ray-di.github.io/)(依存性注入) ## 背景 diff --git a/README.md b/README.md index f15eaa9..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. @@ -179,11 +179,11 @@ 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 -- PHP 8.2+ +- PHP 8.3+ - [Ray.Di](https://ray-di.github.io/) (dependency injection) ## Background diff --git a/demos/blog-publishing/composer.json b/demos/blog-publishing/composer.json index a9c6b1a..2e82c1c 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", + "description": "Be Framework Blog Publishing Demo - Multi-Reason Being orchestrating several Reason services", + "license": "MIT", "type": "project", "autoload": { "psr-4": { @@ -19,7 +20,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/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/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)]); } } diff --git a/demos/contact-form/composer.json b/demos/contact-form/composer.json index d4f56ff..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": { @@ -19,7 +20,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..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": { @@ -19,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 'hello?name=World'", 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(''); + } } 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/composer.json b/demos/insurance-claim/composer.json index a75b2a4..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": { @@ -18,7 +20,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/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/composer.json b/demos/loan-application/composer.json index b1ecc44..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": { @@ -18,7 +20,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/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/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 7983ccc..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(); @@ -267,11 +276,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 +333,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 diff --git a/demos/medical-triage/composer.json b/demos/medical-triage/composer.json index 1a5f370..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": { @@ -18,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", diff --git a/demos/order-processing/composer.json b/demos/order-processing/composer.json index 6900217..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": { @@ -18,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", 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) 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/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); + } +} 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'); + } +} diff --git a/demos/user-registration/composer.json b/demos/user-registration/composer.json index 6c56de4..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": { @@ -20,7 +21,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/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); } } 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, ) {