diff --git a/packages/core/README.md b/packages/core/README.md index 78ac900..fc7ff25 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -91,3 +91,38 @@ Independent Composer installation guidance will be added when package publicatio ## Licence BSD-3-Clause. See `LICENSE.md`. +## Evolve Doctor Foundation + +Core includes the first runtime-neutral Evolve Doctor diagnostic foundation. +Doctor checks implement `Evolve\Core\Doctor\DoctorCheck` and produce immutable +`DoctorFinding` values with stable machine-readable identifiers, a +`DoctorStatus` of `pass`, `warning`, or `fail`, a human-readable message, and an +optional remediation hint. + +`DoctorRunner` accepts explicitly supplied checks, preserves their registration +order, rejects duplicate or malformed check identifiers, and verifies that each +returned finding matches the originating check identifier. `DoctorReport` +preserves findings in runner order and treats the report as successful when no +finding has a `fail` status. Warning findings remain diagnostic data and do not +make a report unsuccessful. + +Runtime checks currently provided by Core are limited to: + +- `Runtime\PhpVersionCheck`, which verifies that an explicitly supplied or + current PHP runtime version satisfies the minimum PHP version requirement of + `8.4.0`. +- `Runtime\PhpExtensionCheck`, which checks a caller-supplied ordered list of + required PHP extensions using an injectable lookup callback for deterministic + tests. + +Normal diagnostic problems, such as an unsupported PHP version or missing PHP +extension, are represented as `fail` findings. Malformed definitions, such as +duplicate check identifiers, invalid diagnostic identifiers, invalid extension +declarations, or malformed explicitly supplied PHP versions, fail fast with +standard exceptions. + +Current limitations: this foundation does not provide an `evolve doctor` CLI +command, `bin/evolve`, JSON output, Composer compatibility diagnosis, +environment inspection, route inspection, writable-path validation, Bridge +validation, persistent-worker certification, Evolve Audit integration, or +automatic remediation. diff --git a/packages/core/src/Doctor/DoctorCheck.php b/packages/core/src/Doctor/DoctorCheck.php new file mode 100644 index 0000000..6708a1b --- /dev/null +++ b/packages/core/src/Doctor/DoctorCheck.php @@ -0,0 +1,12 @@ +identifier; + } + + public function status(): DoctorStatus + { + return $this->status; + } + + public function message(): string + { + return $this->message; + } + + public function remediation(): ?string + { + return $this->remediation; + } +} diff --git a/packages/core/src/Doctor/DoctorReport.php b/packages/core/src/Doctor/DoctorReport.php new file mode 100644 index 0000000..289ed54 --- /dev/null +++ b/packages/core/src/Doctor/DoctorReport.php @@ -0,0 +1,66 @@ + */ + private array $findings; + + /** + * @param iterable $findings + */ + public function __construct(iterable $findings = []) + { + $normalizedFindings = []; + + foreach ($findings as $finding) { + if (! $finding instanceof DoctorFinding) { + throw new InvalidArgumentException('Doctor report findings must contain only DoctorFinding instances.'); + } + + $normalizedFindings[] = $finding; + } + + $this->findings = $normalizedFindings; + } + + /** + * @return list + */ + public function findings(): array + { + return $this->findings; + } + + public function successful(): bool + { + return ! $this->hasFailures(); + } + + public function hasWarnings(): bool + { + foreach ($this->findings as $finding) { + if ($finding->status() === DoctorStatus::Warning) { + return true; + } + } + + return false; + } + + public function hasFailures(): bool + { + foreach ($this->findings as $finding) { + if ($finding->status() === DoctorStatus::Fail) { + return true; + } + } + + return false; + } +} diff --git a/packages/core/src/Doctor/DoctorRunner.php b/packages/core/src/Doctor/DoctorRunner.php new file mode 100644 index 0000000..a9a311b --- /dev/null +++ b/packages/core/src/Doctor/DoctorRunner.php @@ -0,0 +1,70 @@ + */ + private array $checks; + + /** + * @param iterable $checks + */ + public function __construct(iterable $checks) + { + $normalizedChecks = []; + $seenIdentifiers = []; + + foreach ($checks as $check) { + if (! $check instanceof DoctorCheck) { + throw new InvalidArgumentException('Doctor runner checks must implement DoctorCheck.'); + } + + $identifier = $check->identifier(); + DoctorFinding::assertValidIdentifier($identifier); + + if (isset($seenIdentifiers[$identifier])) { + throw new InvalidArgumentException(sprintf( + 'Duplicate doctor check identifier "%s" registered.', + $identifier, + )); + } + + $seenIdentifiers[$identifier] = true; + $normalizedChecks[] = [ + 'identifier' => $identifier, + 'check' => $check, + ]; + } + + $this->checks = $normalizedChecks; + } + + public function run(): DoctorReport + { + $findings = []; + + foreach ($this->checks as $registeredCheck) { + $identifier = $registeredCheck['identifier']; + $check = $registeredCheck['check']; + $finding = $check->run(); + + if ($finding->identifier() !== $identifier) { + throw new LogicException(sprintf( + 'Doctor check "%s" returned finding "%s".', + $identifier, + $finding->identifier(), + )); + } + + $findings[] = $finding; + } + + return new DoctorReport($findings); + } +} diff --git a/packages/core/src/Doctor/DoctorStatus.php b/packages/core/src/Doctor/DoctorStatus.php new file mode 100644 index 0000000..b6656c7 --- /dev/null +++ b/packages/core/src/Doctor/DoctorStatus.php @@ -0,0 +1,12 @@ + */ + private array $requiredExtensions; + + /** + * @param array $requiredExtensions + * @param (Closure(string): bool)|null $extensionLoaded + */ + public function __construct( + array $requiredExtensions, + private ?Closure $extensionLoaded = null, + ) { + if (! array_is_list($requiredExtensions)) { + throw new InvalidArgumentException('Required PHP extensions must be provided as a list.'); + } + + $seenExtensions = []; + $normalizedExtensions = []; + + foreach ($requiredExtensions as $extension) { + if (! is_string($extension) || $extension === '') { + throw new InvalidArgumentException('Required PHP extension names must be non-empty strings.'); + } + + if (preg_match('/\A[A-Za-z0-9_.-]+\z/', $extension) !== 1) { + throw new InvalidArgumentException(sprintf('Required PHP extension name "%s" is malformed.', $extension)); + } + + $canonicalExtension = strtolower($extension); + + if (isset($seenExtensions[$canonicalExtension])) { + throw new InvalidArgumentException(sprintf('Duplicate required PHP extension "%s" declared.', $extension)); + } + + $seenExtensions[$canonicalExtension] = true; + $normalizedExtensions[] = $extension; + } + + $this->requiredExtensions = $normalizedExtensions; + } + + public function identifier(): string + { + return self::IDENTIFIER; + } + + public function run(): DoctorFinding + { + $missingExtensions = []; + $extensionLoaded = $this->extensionLoaded + ?? static fn(string $extension): bool => extension_loaded($extension); + + foreach ($this->requiredExtensions as $extension) { + if (! $extensionLoaded($extension)) { + $missingExtensions[] = $extension; + } + } + + if ($missingExtensions !== []) { + $missing = implode(', ', $missingExtensions); + + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Fail, + sprintf('Missing required PHP extension%s: %s.', count($missingExtensions) === 1 ? '' : 's', $missing), + sprintf('Install or enable the missing PHP extension%s: %s.', count($missingExtensions) === 1 ? '' : 's', $missing), + ); + } + + if ($this->requiredExtensions === []) { + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Pass, + 'No PHP extensions were required for this diagnostic check.', + ); + } + + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Pass, + sprintf('All required PHP extensions are loaded: %s.', implode(', ', $this->requiredExtensions)), + ); + } +} diff --git a/packages/core/src/Doctor/Runtime/PhpVersionCheck.php b/packages/core/src/Doctor/Runtime/PhpVersionCheck.php new file mode 100644 index 0000000..51f2b92 --- /dev/null +++ b/packages/core/src/Doctor/Runtime/PhpVersionCheck.php @@ -0,0 +1,61 @@ +assertValidVersion($currentVersion, 'current'); + $this->assertValidVersion($minimumVersion, 'minimum'); + } + + public function identifier(): string + { + return self::IDENTIFIER; + } + + public function run(): DoctorFinding + { + if (version_compare($this->currentVersion, $this->minimumVersion, '<')) { + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Fail, + sprintf( + 'PHP %s is below the minimum supported PHP version %s.', + $this->currentVersion, + $this->minimumVersion, + ), + sprintf('Upgrade PHP to version %s or higher.', $this->minimumVersion), + ); + } + + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Pass, + sprintf( + 'PHP %s satisfies the minimum supported PHP version %s.', + $this->currentVersion, + $this->minimumVersion, + ), + ); + } + + private function assertValidVersion(string $version, string $label): void + { + if (preg_match('/\A\d+\.\d+\.\d+(?:[A-Za-z0-9._+-]+)?\z/', $version) !== 1) { + throw new InvalidArgumentException(sprintf('The %s PHP version "%s" is malformed.', $label, $version)); + } + } +} diff --git a/packages/core/tests/Unit/Doctor/DoctorFindingTest.php b/packages/core/tests/Unit/Doctor/DoctorFindingTest.php new file mode 100644 index 0000000..8be42e4 --- /dev/null +++ b/packages/core/tests/Unit/Doctor/DoctorFindingTest.php @@ -0,0 +1,67 @@ +identifier()); + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('PHP version is supported.', $finding->message()); + self::assertSame('No remediation required.', $finding->remediation()); + } + + public function testNullableRemediationIsPreserved(): void + { + $finding = new DoctorFinding( + 'runtime.php.extensions', + DoctorStatus::Warning, + 'No extension requirements were declared.', + ); + + self::assertNull($finding->remediation()); + } + + public function testEmptyIdentifierIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new DoctorFinding('', DoctorStatus::Pass, 'PHP version is supported.'); + } + + public function testMalformedIdentifierIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new DoctorFinding('runtime php.version', DoctorStatus::Pass, 'PHP version is supported.'); + } + + public function testEmptyMessageIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new DoctorFinding('runtime.php.version', DoctorStatus::Pass, ''); + } + + public function testEmptyProvidedRemediationIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new DoctorFinding('runtime.php.version', DoctorStatus::Fail, 'PHP version is unsupported.', ''); + } +} diff --git a/packages/core/tests/Unit/Doctor/DoctorRunnerTest.php b/packages/core/tests/Unit/Doctor/DoctorRunnerTest.php new file mode 100644 index 0000000..ebe67a4 --- /dev/null +++ b/packages/core/tests/Unit/Doctor/DoctorRunnerTest.php @@ -0,0 +1,189 @@ +run(); + + self::assertSame([], $report->findings()); + self::assertTrue($report->successful()); + self::assertFalse($report->hasWarnings()); + self::assertFalse($report->hasFailures()); + } + + public function testOneCheckProducesOneFinding(): void + { + $report = (new DoctorRunner([ + $this->check('runtime.php.version', DoctorStatus::Pass), + ]))->run(); + + self::assertCount(1, $report->findings()); + self::assertSame('runtime.php.version', $report->findings()[0]->identifier()); + } + + public function testRegistrationOrderIsPreserved(): void + { + $report = (new DoctorRunner([ + $this->check('runtime.php.version', DoctorStatus::Pass), + $this->check('runtime.php.extensions', DoctorStatus::Pass), + $this->check('runtime.cache.writable', DoctorStatus::Pass), + ]))->run(); + + self::assertSame( + ['runtime.php.version', 'runtime.php.extensions', 'runtime.cache.writable'], + array_map(static fn(DoctorFinding $finding): string => $finding->identifier(), $report->findings()), + ); + } + + public function testDuplicateIdentifierIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new DoctorRunner([ + $this->check('runtime.php.version', DoctorStatus::Pass), + $this->check('runtime.php.version', DoctorStatus::Pass), + ]); + } + + public function testMalformedCheckIdentifierIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new DoctorRunner([ + $this->check('Runtime PHP Version', DoctorStatus::Pass), + ]); + } + + public function testPassOnlyReportIsSuccessful(): void + { + $report = (new DoctorRunner([ + $this->check('runtime.php.version', DoctorStatus::Pass), + $this->check('runtime.php.extensions', DoctorStatus::Pass), + ]))->run(); + + self::assertTrue($report->successful()); + self::assertFalse($report->hasWarnings()); + self::assertFalse($report->hasFailures()); + } + + public function testWarningDoesNotFailReport(): void + { + $report = (new DoctorRunner([ + $this->check('runtime.php.version', DoctorStatus::Warning), + ]))->run(); + + self::assertTrue($report->successful()); + self::assertTrue($report->hasWarnings()); + self::assertFalse($report->hasFailures()); + } + + public function testFailMakesReportUnsuccessful(): void + { + $report = (new DoctorRunner([ + $this->check('runtime.php.version', DoctorStatus::Fail), + ]))->run(); + + self::assertFalse($report->successful()); + self::assertFalse($report->hasWarnings()); + self::assertTrue($report->hasFailures()); + } + + public function testMixedStatusesPreserveOrder(): void + { + $report = (new DoctorRunner([ + $this->check('runtime.php.version', DoctorStatus::Pass), + $this->check('runtime.php.extensions', DoctorStatus::Warning), + $this->check('runtime.cache.writable', DoctorStatus::Fail), + ]))->run(); + + self::assertSame( + [DoctorStatus::Pass, DoctorStatus::Warning, DoctorStatus::Fail], + array_map(static fn(DoctorFinding $finding): DoctorStatus => $finding->status(), $report->findings()), + ); + } + + public function testChecksContinueAfterNormalFailFinding(): void + { + $ran = []; + $recordRun = static function (string $identifier) use (&$ran): void { + $ran[] = $identifier; + }; + + $report = (new DoctorRunner([ + $this->check('runtime.php.version', DoctorStatus::Fail, $recordRun), + $this->check('runtime.php.extensions', DoctorStatus::Pass, $recordRun), + ]))->run(); + + self::assertSame(['runtime.php.version', 'runtime.php.extensions'], $ran); + self::assertCount(2, $report->findings()); + } + + public function testReturnedFindingIdentifierMismatchIsRejected(): void + { + $this->expectException(LogicException::class); + + (new DoctorRunner([ + new class implements DoctorCheck { + public function identifier(): string + { + return 'runtime.php.version'; + } + + public function run(): DoctorFinding + { + return new DoctorFinding( + 'runtime.php.extensions', + DoctorStatus::Pass, + 'PHP extensions are loaded.', + ); + } + }, + ]))->run(); + } + + /** + * @param (Closure(string): void)|null $onRun + */ + private function check(string $identifier, DoctorStatus $status, ?Closure $onRun = null): DoctorCheck + { + return new class ($identifier, $status, $onRun) implements DoctorCheck { + public function __construct( + private readonly string $identifier, + private readonly DoctorStatus $status, + private readonly ?Closure $onRun, + ) {} + + public function identifier(): string + { + return $this->identifier; + } + + public function run(): DoctorFinding + { + if ($this->onRun !== null) { + ($this->onRun)($this->identifier); + } + + return new DoctorFinding( + $this->identifier, + $this->status, + sprintf('Diagnostic %s completed.', $this->identifier), + ); + } + }; + } +} diff --git a/packages/core/tests/Unit/Doctor/Runtime/PhpExtensionCheckTest.php b/packages/core/tests/Unit/Doctor/Runtime/PhpExtensionCheckTest.php new file mode 100644 index 0000000..df21b3c --- /dev/null +++ b/packages/core/tests/Unit/Doctor/Runtime/PhpExtensionCheckTest.php @@ -0,0 +1,112 @@ +identifier()); + } + + public function testEmptyRequirementPasses(): void + { + $finding = (new PhpExtensionCheck([], static fn(string $extension): bool => false))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + } + + public function testAllLoadedPasses(): void + { + $finding = (new PhpExtensionCheck( + ['json', 'mbstring'], + static fn(string $extension): bool => in_array($extension, ['json', 'mbstring'], true), + ))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + } + + public function testOneMissingFails(): void + { + $finding = (new PhpExtensionCheck( + ['json', 'mbstring'], + static fn(string $extension): bool => $extension === 'json', + ))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertStringContainsString('mbstring', $finding->message()); + self::assertStringContainsString('mbstring', (string) $finding->remediation()); + } + + public function testMultipleMissingFail(): void + { + $finding = (new PhpExtensionCheck( + ['json', 'mbstring', 'pdo'], + static fn(string $extension): bool => $extension === 'json', + ))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertStringContainsString('mbstring, pdo', $finding->message()); + } + + public function testMissingOrderIsDeterministic(): void + { + $finding = (new PhpExtensionCheck( + ['pdo', 'json', 'mbstring'], + static fn(string $extension): bool => $extension === 'json', + ))->run(); + + self::assertStringContainsString('pdo, mbstring', $finding->message()); + } + + public function testExplicitLookupCallbackReceivesNames(): void + { + $received = []; + + (new PhpExtensionCheck( + ['json', 'mbstring'], + function (string $extension) use (&$received): bool { + $received[] = $extension; + + return true; + }, + ))->run(); + + self::assertSame(['json', 'mbstring'], $received); + } + + public function testDuplicateRequirementsAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new PhpExtensionCheck(['json', 'json']); + } + + public function testDifferentlyCasedDuplicateRequirementsAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new PhpExtensionCheck(['json', 'JSON']); + } + + public function testEmptyExtensionNameIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new PhpExtensionCheck(['json', '']); + } + + public function testNonListInputIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new PhpExtensionCheck(['first' => 'json']); + } +} diff --git a/packages/core/tests/Unit/Doctor/Runtime/PhpVersionCheckTest.php b/packages/core/tests/Unit/Doctor/Runtime/PhpVersionCheckTest.php new file mode 100644 index 0000000..2942d66 --- /dev/null +++ b/packages/core/tests/Unit/Doctor/Runtime/PhpVersionCheckTest.php @@ -0,0 +1,71 @@ +identifier()); + } + + public function testPhp83FailsMinimumRuntimeCompatibility(): void + { + $finding = (new PhpVersionCheck('8.3.99'))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame('runtime.php.version', $finding->identifier()); + } + + public function testPhp84PassesMinimumRuntimeCompatibility(): void + { + $finding = (new PhpVersionCheck('8.4.0'))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + } + + public function testPhp85PassesMinimumRuntimeCompatibility(): void + { + $finding = (new PhpVersionCheck('8.5.0'))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + } + + public function testLaterVersionSatisfyingMinimumPasses(): void + { + $finding = (new PhpVersionCheck('9.0.0'))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + } + + public function testActualAndMinimumVersionsAppearInResult(): void + { + $finding = (new PhpVersionCheck('8.4.3', '8.4.0'))->run(); + + self::assertStringContainsString('8.4.3', $finding->message()); + self::assertStringContainsString('8.4.0', $finding->message()); + } + + public function testFailingResultProvidesRemediation(): void + { + $finding = (new PhpVersionCheck('8.3.99', '8.4.0'))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertNotNull($finding->remediation()); + self::assertStringContainsString('8.4.0', $finding->remediation()); + } + + public function testMalformedExplicitVersionIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new PhpVersionCheck('not-a-version'); + } +} diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index 9bc4bf1..ba1c33e 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -753,14 +753,21 @@ private function acceptedPackageSourceInventories() 'Console/Command.php', 'Console/CommandInput.php', 'Console/CommandOutput.php', - 'Console/CommandRegistry.php', - 'Console/CommandResult.php', - 'Console/CommandRunner.php', + 'Console/CommandRegistry.php', + 'Console/CommandResult.php', + 'Console/CommandRunner.php', 'Container/ExecutionScopeContainer.php', 'Container/ServiceContainer.php', 'Container/ServiceDefinition.php', 'Container/ServiceLifetime.php', - 'Container/ServiceRegistry.php', + 'Container/ServiceRegistry.php', + 'Doctor/DoctorCheck.php', + 'Doctor/DoctorFinding.php', + 'Doctor/DoctorReport.php', + 'Doctor/DoctorRunner.php', + 'Doctor/DoctorStatus.php', + 'Doctor/Runtime/PhpExtensionCheck.php', + 'Doctor/Runtime/PhpVersionCheck.php', 'Exception/ActiveComponentConflict.php', 'Exception/AmbiguousCapabilityProvider.php', 'Exception/CommandNotFound.php', @@ -788,7 +795,7 @@ private function acceptedPackageSourceInventories() 'Exception/ServiceNotFound.php', 'Exception/ServiceRegistryFrozen.php', 'Exception/ServiceResolutionFailed.php', - 'Execution/ExecutionContext.php', + 'Execution/ExecutionContext.php', 'Execution/ExecutionIdentifier.php', 'Execution/ExecutionKind.php', 'Execution/ExecutionOrchestrator.php',