diff --git a/docs/schemas/becoming-close.json b/docs/schemas/becoming-close.json index 1a390b13..bd4f6d60 100644 --- a/docs/schemas/becoming-close.json +++ b/docs/schemas/becoming-close.json @@ -13,7 +13,8 @@ "not": { "anyOf": [ { "required": ["error"] }, - { "required": ["message"] } + { "required": ["message"] }, + { "required": ["origin"] } ] } }, @@ -42,6 +43,11 @@ "message": { "type": "string", "description": "Exception message when the chain ended in failure." + }, + "origin": { + "type": "string", + "enum": ["input", "runtime"], + "description": "Provenance of a semantic validation failure: 'input' when the first metamorphosis (incoming input) failed validation, 'runtime' when a later metamorphosis failed. Absent for non-semantic failures." } }, "additionalProperties": false diff --git a/src/Becoming.php b/src/Becoming.php index 8c361fdb..27b89b17 100644 --- a/src/Becoming.php +++ b/src/Becoming.php @@ -4,6 +4,10 @@ namespace Be\Framework; +use Be\Framework\Exception\InputSemanticVariableException; +use Be\Framework\Exception\RuntimeSemanticVariableException; +use Be\Framework\Exception\SemanticVariableException; +use Be\Framework\SemanticLog\Context\BecomingCloseContext; use Be\Framework\SemanticLog\Logger; use Be\Framework\SemanticLog\LoggerInterface; use Be\Framework\SemanticVariable\SemanticValidator; @@ -51,15 +55,34 @@ public function __invoke(object $input): object { $chainId = $this->logger->openChain($input); $current = $input; + $isFirst = true; try { // Being reveals its becoming, then becomes it while ($nextForm = $this->being->willBe($current)) { $current = $this->being->metamorphose($current, $nextForm); + $isFirst = false; } } catch (Throwable $e) { + // Refine semantic validation failures by their position in the chain: + // the first metamorphosis validates incoming input (input error), + // any later one validates already-validated state (runtime error). + // Callers receive the refined subtype, while the chain-close log keeps + // the original error class (consistent with the inner span) and carries + // the input/runtime distinction as `origin`. + $loggedException = $e; + $origin = null; + if ($e instanceof SemanticVariableException) { + $origin = $isFirst + ? BecomingCloseContext::ORIGIN_INPUT + : BecomingCloseContext::ORIGIN_RUNTIME; + $e = $isFirst + ? new InputSemanticVariableException($e->getErrors(), $e) + : new RuntimeSemanticVariableException($e->getErrors(), $e); + } + try { - $this->logger->closeChain(null, $chainId, $e); + $this->logger->closeChain(null, $chainId, $loggedException, $origin); } catch (Throwable) { // A failure inside error logging must not mask the original // metamorphosis exception. Swallow the logging error and diff --git a/src/Exception/InputSemanticVariableException.php b/src/Exception/InputSemanticVariableException.php new file mode 100644 index 00000000..cb3a16b2 --- /dev/null +++ b/src/Exception/InputSemanticVariableException.php @@ -0,0 +1,18 @@ + $exception->getMessage(), @@ -31,7 +40,7 @@ public function __construct( ? $errorMessages[0] : 'Multiple semantic validation errors: ' . implode(', ', $errorMessages); - parent::__construct($message); + parent::__construct($message, 0, $previous); } /** diff --git a/src/SemanticLog/Context/BecomingCloseContext.php b/src/SemanticLog/Context/BecomingCloseContext.php index f762808f..53a2e500 100644 --- a/src/SemanticLog/Context/BecomingCloseContext.php +++ b/src/SemanticLog/Context/BecomingCloseContext.php @@ -25,17 +25,25 @@ final class BecomingCloseContext extends AbstractContext implements JsonSerializ public const string EXIT_ERROR = 'error'; + public const string ORIGIN_INPUT = 'input'; + + public const string ORIGIN_RUNTIME = 'runtime'; + /** * @param string $exit Exit status for the chain (self::EXIT_SUCCESS|self::EXIT_ERROR). * @param class-string|null $final FQCN of the terminal being, or null if the chain failed. * @param string|null $error FQCN of the thrown exception, or null on success. * @param string|null $message Exception message, or null on success. + * @param string|null $origin Provenance of a semantic validation failure + * (self::ORIGIN_INPUT|self::ORIGIN_RUNTIME), or null when + * the failure is not a semantic validation error. */ public function __construct( public readonly string $exit, public readonly string|null $final = null, public readonly string|null $error = null, public readonly string|null $message = null, + public readonly string|null $origin = null, ) { } @@ -52,6 +60,10 @@ public function jsonSerialize(): array if ($this->error !== null) { $payload['error'] = $this->error; $payload['message'] = $this->message ?? ''; + + if ($this->origin !== null) { + $payload['origin'] = $this->origin; + } } return $payload; diff --git a/src/SemanticLog/Logger.php b/src/SemanticLog/Logger.php index 196ed38d..f1a16cf9 100644 --- a/src/SemanticLog/Logger.php +++ b/src/SemanticLog/Logger.php @@ -81,7 +81,7 @@ public function openChain(object $input): string * `becoming-close.json`, so we refuse to emit one. */ #[Override] - public function closeChain(object|null $final, string $openId, Throwable|null $exception = null): void + public function closeChain(object|null $final, string $openId, Throwable|null $exception = null, string|null $origin = null): void { if ($openId === '') { return; @@ -92,6 +92,7 @@ public function closeChain(object|null $final, string $openId, Throwable|null $e exit: BecomingCloseContext::EXIT_ERROR, error: $exception::class, message: $exception->getMessage(), + origin: $origin, ), $openId); return; diff --git a/src/SemanticLog/LoggerInterface.php b/src/SemanticLog/LoggerInterface.php index 24f9bfcf..b196d73f 100644 --- a/src/SemanticLog/LoggerInterface.php +++ b/src/SemanticLog/LoggerInterface.php @@ -37,8 +37,10 @@ public function openChain(object $input): string; * @param object|null $final Terminal being reached on success; null if the chain failed * @param string $openId Open ID from the corresponding openChain call * @param Throwable|null $exception Exception that ended the chain, or null on success + * @param string|null $origin Provenance of a semantic validation failure ('input'|'runtime'), + * or null when the failure is not a semantic validation error */ - public function closeChain(object|null $final, string $openId, Throwable|null $exception = null): void; + public function closeChain(object|null $final, string $openId, Throwable|null $exception = null, string|null $origin = null): void; /** * Log transformation start diff --git a/tests/BecomingTest.php b/tests/BecomingTest.php index d5164593..ceedb26f 100644 --- a/tests/BecomingTest.php +++ b/tests/BecomingTest.php @@ -8,12 +8,17 @@ use Be\Framework\Attribute\Validate; use Be\Framework\Exception\BeMatchException; use Be\Framework\Exception\ConflictingParameterAttributes; +use Be\Framework\Exception\InputSemanticVariableException; use Be\Framework\Exception\MissingParameterAttribute; +use Be\Framework\Exception\RuntimeSemanticVariableException; use Be\Framework\Exception\SemanticVariableException; use Be\Framework\Exception\UnbecomingException; +use Be\Framework\SemanticLog\Context\BecomingCloseContext; +use Be\Framework\SemanticLog\Logger; use Be\Framework\SemanticVariable\Errors; use Be\Framework\SemanticVariable\SemanticValidator; use InvalidArgumentException; +use Koriym\SemanticLogger\SemanticLogger; use PHPUnit\Framework\TestCase; use Ray\Di\AbstractModule; use Ray\Di\Di\Inject; @@ -22,7 +27,9 @@ use Ray\InputQuery\Attribute\Input; use RuntimeException; +use function assert; use function filter_var; +use function is_array; use const FILTER_VALIDATE_EMAIL; @@ -183,6 +190,132 @@ public function testSemanticValidationFailure(): void ($this->becoming)($input); } + public function testFirstMetamorphosisFailsWithInputException(): void + { + // Validation failing on the first metamorphosis is an input error + $input = new BecomingTestSemanticInvalid('invalid-email'); + + try { + ($this->becoming)($input); + $this->fail('Expected InputSemanticVariableException'); + } catch (InputSemanticVariableException $e) { + // The refined input subtype is still a SemanticVariableException + $this->assertInstanceOf(SemanticVariableException::class, $e); + $this->assertTrue($e->getErrors()->hasErrors()); + + // The original base exception is preserved as the cause so the real + // validation site stays reachable. Pin it on the Becoming rewrap path + // (not just in the direct-construction unit tests). + $previous = $e->getPrevious(); + $this->assertInstanceOf(SemanticVariableException::class, $previous); + $this->assertNotInstanceOf(InputSemanticVariableException::class, $previous); + $this->assertSame($e->getErrors(), $previous->getErrors()); + } + } + + public function testLaterMetamorphosisFailsWithRuntimeException(): void + { + // The first transformation succeeds; the second fails validation, + // which is a runtime error rather than an input error. + $input = new BecomingTestRuntimeStart('seed'); + + try { + ($this->becoming)($input); + $this->fail('Expected RuntimeSemanticVariableException'); + } catch (RuntimeSemanticVariableException $e) { + $this->assertInstanceOf(SemanticVariableException::class, $e); + $this->assertNotInstanceOf(InputSemanticVariableException::class, $e); + $this->assertTrue($e->getErrors()->hasErrors()); + + // Cause chain preserved through the rewrap, same Errors carried over. + $previous = $e->getPrevious(); + $this->assertInstanceOf(SemanticVariableException::class, $previous); + $this->assertNotInstanceOf(RuntimeSemanticVariableException::class, $previous); + $this->assertSame($e->getErrors(), $previous->getErrors()); + } + } + + public function testBranchingFirstStepFailsWithInputException(): void + { + // The first metamorphosis through the array/branching path (performTypeMatching) + // must classify a validation failure as an input error, just like the linear path. + $input = new BecomingTestSemanticFailureInput('invalid-email'); + + try { + ($this->becoming)($input); + $this->fail('Expected InputSemanticVariableException'); + } catch (InputSemanticVariableException $e) { + $this->assertNotInstanceOf(RuntimeSemanticVariableException::class, $e); + $this->assertTrue($e->getErrors()->hasErrors()); + } + } + + public function testChainCloseLogRecordsBaseErrorAndInputOrigin(): void + { + // The chain-close log records the ORIGINAL base exception class (consistent + // with the inner being_error_close span) and carries the input/runtime + // distinction via `origin` — not by swapping the class name. Use a real + // SemanticLogger so the emitted becoming_close payload can be inspected. + $semanticLogger = new SemanticLogger(); + $becoming = $this->becomingWithLogger($semanticLogger); + + try { + $becoming(new BecomingTestSemanticInvalid('invalid-email')); + $this->fail('Expected InputSemanticVariableException'); + } catch (InputSemanticVariableException) { + // expected + } + + $context = $this->becomingCloseContext($semanticLogger); + $this->assertSame('error', $context['exit']); + $this->assertSame(SemanticVariableException::class, $context['error']); + $this->assertSame(BecomingCloseContext::ORIGIN_INPUT, $context['origin']); + } + + public function testChainCloseLogRecordsRuntimeOrigin(): void + { + // A validation failure on a later metamorphosis is logged with origin=runtime. + $semanticLogger = new SemanticLogger(); + $becoming = $this->becomingWithLogger($semanticLogger); + + try { + $becoming(new BecomingTestRuntimeStart('seed')); + $this->fail('Expected RuntimeSemanticVariableException'); + } catch (RuntimeSemanticVariableException) { + // expected + } + + $context = $this->becomingCloseContext($semanticLogger); + $this->assertSame('error', $context['exit']); + $this->assertSame(SemanticVariableException::class, $context['error']); + $this->assertSame(BecomingCloseContext::ORIGIN_RUNTIME, $context['origin']); + } + + private function becomingWithLogger(SemanticLogger $semanticLogger): Becoming + { + $injector = new Injector(new BecomingTestModule()); + $semanticValidator = new SemanticValidator('MyVendor\\MyApp\\SemanticVariables'); + $becomingArguments = new BecomingArguments($injector, $semanticValidator); + $logger = new Logger($semanticLogger, $becomingArguments); + + return new Becoming($injector, 'MyVendor\\MyApp', $logger, $becomingArguments); + } + + /** @return array */ + private function becomingCloseContext(SemanticLogger $semanticLogger): array + { + $logData = $semanticLogger->toArray(); + assert(is_array($logData['open']) && is_array($logData['open'][0]) && is_array($logData['open'][0]['close'])); + $closeData = $logData['open'][0]['close']; + assert(is_array($closeData) && is_array($closeData['context'])); + + // A real assertion (not assert()) so the shape check still holds when + // zend.assertions is disabled. + $this->assertSame('becoming_close', $closeData['type']); + + return $closeData['context']; + } + public function testTypeMatchingFailureWithFallback(): void { // This test covers lines 106-107 in Being.php performTypeMatching @@ -614,6 +747,42 @@ public function __construct( } } +// Runtime semantic validation test fixtures: a two-step chain where the first +// transformation passes validation and the second produces invalid data. +#[Be(BecomingTestRuntimeMiddle::class)] +final class BecomingTestRuntimeStart +{ + public function __construct( + public readonly string $value, // no Value semantic class - first step passes + ) { + } +} + +#[Be(BecomingTestRuntimeFailingTarget::class)] +final class BecomingTestRuntimeMiddle +{ + public readonly string $email; + + public function __construct( + #[Input] + string $value, + ) { + // Carry the seed forward as the email for the next step. The seed ('seed') + // is not a valid email, so the SECOND metamorphosis fails semantic validation. + $this->email = $value; + } +} + +final class BecomingTestRuntimeFailingTarget +{ + public function __construct( + #[Input] + #[Validate('Email')] + public readonly string $email, // fails semantic validation on the second step + ) { + } +} + // UnbecomingException test fixtures #[Be([BecomingTestRejectionTarget::class, BecomingTestRejectionFallback::class])] final class BecomingTestRejectionInput diff --git a/tests/Exception/InputSemanticVariableExceptionTest.php b/tests/Exception/InputSemanticVariableExceptionTest.php new file mode 100644 index 00000000..c41b19be --- /dev/null +++ b/tests/Exception/InputSemanticVariableExceptionTest.php @@ -0,0 +1,39 @@ +assertInstanceOf(SemanticVariableException::class, $exception); + } + + public function testGetErrorsAndMessage(): void + { + $errors = new Errors([new DomainException('Invalid email format')]); + $exception = new InputSemanticVariableException($errors); + + $this->assertSame('Invalid email format', $exception->getMessage()); + $this->assertSame($errors, $exception->getErrors()); + } + + public function testPreviousIsChained(): void + { + $errors = new Errors([new DomainException('Invalid input')]); + $previous = new SemanticVariableException($errors); + + $exception = new InputSemanticVariableException($errors, $previous); + + $this->assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Exception/RuntimeSemanticVariableExceptionTest.php b/tests/Exception/RuntimeSemanticVariableExceptionTest.php new file mode 100644 index 00000000..86dd476b --- /dev/null +++ b/tests/Exception/RuntimeSemanticVariableExceptionTest.php @@ -0,0 +1,39 @@ +assertInstanceOf(SemanticVariableException::class, $exception); + } + + public function testGetErrorsAndMessage(): void + { + $errors = new Errors([new DomainException('Invalid email format')]); + $exception = new RuntimeSemanticVariableException($errors); + + $this->assertSame('Invalid email format', $exception->getMessage()); + $this->assertSame($errors, $exception->getErrors()); + } + + public function testPreviousIsChained(): void + { + $errors = new Errors([new DomainException('Internal inconsistency')]); + $previous = new SemanticVariableException($errors); + + $exception = new RuntimeSemanticVariableException($errors, $previous); + + $this->assertSame($previous, $exception->getPrevious()); + } +}