Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions packages/core/src/Doctor/DoctorCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Evolve\Core\Doctor;

interface DoctorCheck
{
public function identifier(): string;

public function run(): DoctorFinding;
}
61 changes: 61 additions & 0 deletions packages/core/src/Doctor/DoctorFinding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Evolve\Core\Doctor;

use InvalidArgumentException;

final readonly class DoctorFinding
{
public function __construct(
private string $identifier,
private DoctorStatus $status,
private string $message,
private ?string $remediation = null,
) {
self::assertValidIdentifier($identifier);

if (trim($message) === '') {
throw new InvalidArgumentException('Doctor finding message must not be empty.');
}

if ($remediation !== null && trim($remediation) === '') {
throw new InvalidArgumentException('Doctor finding remediation must not be empty when provided.');
}
}

public static function assertValidIdentifier(string $identifier): void
{
if ($identifier === '') {
throw new InvalidArgumentException('Doctor finding identifier must not be empty.');
}

if (preg_match('/\A[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\z/', $identifier) !== 1) {
throw new InvalidArgumentException(sprintf(
'Doctor finding identifier "%s" must use dot-separated lowercase ASCII segments.',
$identifier,
));
}
}

public function identifier(): string
{
return $this->identifier;
}

public function status(): DoctorStatus
{
return $this->status;
}

public function message(): string
{
return $this->message;
}

public function remediation(): ?string
{
return $this->remediation;
}
}
66 changes: 66 additions & 0 deletions packages/core/src/Doctor/DoctorReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Evolve\Core\Doctor;

use InvalidArgumentException;

final readonly class DoctorReport
{
/** @var list<DoctorFinding> */
private array $findings;

/**
* @param iterable<mixed> $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<DoctorFinding>
*/
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;
}
}
70 changes: 70 additions & 0 deletions packages/core/src/Doctor/DoctorRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Evolve\Core\Doctor;

use InvalidArgumentException;
use LogicException;

final readonly class DoctorRunner
{
/** @var list<array{identifier: string, check: DoctorCheck}> */
private array $checks;

/**
* @param iterable<mixed> $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);
}
}
12 changes: 12 additions & 0 deletions packages/core/src/Doctor/DoctorStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Evolve\Core\Doctor;

enum DoctorStatus: string
{
case Pass = 'pass';
case Warning = 'warning';
case Fail = 'fail';
}
99 changes: 99 additions & 0 deletions packages/core/src/Doctor/Runtime/PhpExtensionCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Evolve\Core\Doctor\Runtime;

use Closure;
use Evolve\Core\Doctor\DoctorCheck;
use Evolve\Core\Doctor\DoctorFinding;
use Evolve\Core\Doctor\DoctorStatus;
use InvalidArgumentException;

final readonly class PhpExtensionCheck implements DoctorCheck
{
public const IDENTIFIER = 'runtime.php.extensions';

/** @var list<string> */
private array $requiredExtensions;

/**
* @param array<mixed> $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)),
);
}
}
Loading