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
55 changes: 38 additions & 17 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,18 +114,25 @@ Runtime checks currently provided by Core are limited to:
- `Runtime\PhpExtensionCheck`, which checks a caller-supplied ordered list of
required PHP extensions using an injectable lookup callback for deterministic
tests.
- `Project\ComposerRequiredExtensionsCheck`, which reads one local
`composer.json` path, inspects only top-level `require` keys beginning with
`ext-`, normalizes extension names to lowercase, and checks whether those
extensions are loaded. It intentionally ignores `require-dev` and does not
evaluate extension version constraints.

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.
extension, are represented as `fail` findings. Project Composer evidence
problems, such as a missing, unreadable, malformed, or structurally invalid
project manifest, are also represented as `fail` findings. Malformed
definitions, such as duplicate check identifiers, invalid diagnostic
identifiers, invalid caller-supplied extension declarations, or malformed
explicitly supplied PHP versions, fail fast with standard exceptions.

Current limitations: this foundation does not provide JSON output, arbitrary
Composer dependency graph compatibility analysis, a Composer semver solver,
extension version-constraint evaluation, environment inspection, route
inspection, writable-path validation, Bridge validation, persistent-worker
certification, Evolve Audit integration, or automatic remediation.
## Evolve Doctor Console Adapter

Core provides `Evolve\Core\Doctor\Console\DoctorCommand`, a runtime-neutral
Expand Down Expand Up @@ -160,7 +167,8 @@ failures.

Current Doctor command adapter limitations: this layer does not provide argv
parsing, TTY support, ANSI formatting, prompts, command help UI, JSON Doctor
output, Composer compatibility checks, project discovery, route inspection,
output, arbitrary Composer dependency graph compatibility analysis, a Composer
semver solver, extension version-constraint evaluation, route inspection,
environment inspection, writable-path inspection, create-project, or generators.
## Evolve CLI Entrypoint

Expand All @@ -173,7 +181,16 @@ vendor/bin/evolve doctor
The package-owned `bin/evolve` executable is a thin composition root around the
existing Core console abstractions. It wires `CliApplication` to `CommandRunner`,
registers the existing `DoctorCommand`, and configures the default shell Doctor
runner with the PHP version check.
runner with:

1. PHP runtime version diagnosis.
2. Current-project Composer runtime extension discovery from `composer.json`
`require` declarations whose package names begin with `ext-`.

The project manifest is resolved from the process current working directory.
Only top-level `require` is inspected; `require-dev` is intentionally ignored.
Extension names are normalized to lowercase before lookup, and this diagnosis
checks only whether each declared extension is loaded.

Current shell behavior:

Expand All @@ -184,11 +201,15 @@ Current shell behavior:
- exit `2` means CLI usage failed, such as a missing command or unsupported
Doctor argument.
- PASS, WARNING, and FAIL rendering remains owned by `DoctorCommand`.
- Missing or malformed current-project Composer evidence is a Doctor diagnostic
failure, writes diagnostics to stdout, and exits `1`.
- Shell usage errors remain stderr output and exit `2`.
- Caller-configured PHP extension checks remain available programmatically but
are not auto-discovered by the shell entrypoint yet.
are not automatically added by the shell entrypoint.

Current limitations: there is no option parser, `--help` or help framework,
JSON output, command listing or completion, Composer or project inspection,
automatic required-extension discovery, route inspection, environment
inspection, writable-path inspection, create-project support, generators,
Bridge or Audit integration, or interactive, TTY, or ANSI behavior.
JSON output, command listing or completion, arbitrary Composer dependency graph
compatibility analysis, Composer semver solving, extension version-constraint
evaluation, route inspection, environment inspection, writable-path inspection,
create-project support, generators, Bridge or Audit integration, or
interactive, TTY, or ANSI behavior.
7 changes: 7 additions & 0 deletions packages/core/bin/evolve
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use Evolve\Core\Console\Runtime\StreamCommandOutput;
use Evolve\Core\Container\ServiceRegistry;
use Evolve\Core\Doctor\Console\DoctorCommand;
use Evolve\Core\Doctor\DoctorRunner as EvolveDoctorRunner;
use Evolve\Core\Doctor\Project\ComposerRequiredExtensionsCheck;
use Evolve\Core\Doctor\Runtime\PhpVersionCheck;
use Evolve\Core\Execution\ExecutionOrchestrator;

Expand Down Expand Up @@ -44,10 +45,16 @@ require $autoload();
$services = new ServiceRegistry();
$services->freeze();

$workingDirectory = getcwd();
$composerJsonPath = is_string($workingDirectory)
? $workingDirectory . DIRECTORY_SEPARATOR . 'composer.json'
: 'composer.json';

$application = new CliApplication(new CommandRunner(
new CommandRegistry([
new DoctorCommand(new EvolveDoctorRunner([
new PhpVersionCheck(),
new ComposerRequiredExtensionsCheck($composerJsonPath),
])),
]),
new ExecutionOrchestrator($services),
Expand Down
190 changes: 190 additions & 0 deletions packages/core/src/Doctor/Project/ComposerRequiredExtensionsCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php

declare(strict_types=1);

namespace Evolve\Core\Doctor\Project;

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

final readonly class ComposerRequiredExtensionsCheck implements DoctorCheck
{
public const IDENTIFIER = 'project.composer.extensions';

/**
* @param (Closure(string): bool)|null $extensionLoaded
*/
public function __construct(
private string $composerJsonPath,
private ?Closure $extensionLoaded = null,
) {
if (trim($composerJsonPath) === '') {
throw new InvalidArgumentException('Composer project manifest path must not be empty.');
}

if (str_contains($composerJsonPath, '://')) {
throw new InvalidArgumentException('Composer project manifest path must be a local filesystem path.');
}
}

public function identifier(): string
{
return self::IDENTIFIER;
}

public function run(): DoctorFinding
{
if (! is_file($this->composerJsonPath) || ! is_readable($this->composerJsonPath)) {
return $this->manifestUnavailableFinding();
}

$contents = @file_get_contents($this->composerJsonPath);

if ($contents === false) {
return $this->manifestUnavailableFinding();
}

try {
$manifest = json_decode($contents, false, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Fail,
sprintf('Composer project manifest is not valid JSON at %s.', $this->composerJsonPath),
'Fix the JSON syntax in the project composer.json manifest.',
);
}

if (! $manifest instanceof stdClass) {
return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Fail,
sprintf('Composer project manifest must contain a JSON object at %s.', $this->composerJsonPath),
'Ensure the project composer.json manifest root is a JSON object.',
);
}

if (! property_exists($manifest, 'require')) {
return $this->noRequiredExtensionsFinding();
}

if (! $manifest->require instanceof stdClass) {
return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Fail,
sprintf('Composer project runtime requirements must be a JSON object at %s.', $this->composerJsonPath),
'Ensure the project composer.json require section is a JSON object.',
);
}

$requiredExtensions = [];
$seenExtensions = [];

$runtimeRequirements = get_object_vars($manifest->require);

foreach ($runtimeRequirements as $packageName => $constraint) {
if (! str_starts_with(strtolower($packageName), 'ext-')) {
continue;
}

$extensionName = substr($packageName, 4);

if ($extensionName === '' || preg_match('/\A[A-Za-z0-9_.-]+\z/', $extensionName) !== 1) {
return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Fail,
sprintf('Composer project PHP extension requirement "%s" is malformed at %s.', $packageName, $this->composerJsonPath),
'Declare PHP extension requirements as ext-<name> using letters, digits, underscore, dot, or dash.',
);
}

if (! is_string($constraint) || trim($constraint) === '') {
return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Fail,
sprintf('Composer project PHP extension requirement "%s" must use a non-empty string constraint at %s.', $packageName, $this->composerJsonPath),
'Declare Composer PHP extension constraints as non-empty strings.',
);
}

$canonicalExtensionName = strtolower($extensionName);

if (isset($seenExtensions[$canonicalExtensionName])) {
return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Fail,
sprintf('Composer project PHP extension "%s" is declared more than once after normalization at %s.', $canonicalExtensionName, $this->composerJsonPath),
'Remove duplicate Composer PHP extension requirements after case normalization.',
);
}

$seenExtensions[$canonicalExtensionName] = true;
$requiredExtensions[] = $canonicalExtensionName;
}

if ($requiredExtensions === []) {
return $this->noRequiredExtensionsFinding();
}

sort($requiredExtensions);

$missingExtensions = [];
$extensionLoaded = $this->extensionLoaded
?? static fn(string $extension): bool => extension_loaded($extension);

foreach ($requiredExtensions as $extension) {
if (! $extensionLoaded($extension)) {
$missingExtensions[] = $extension;
}
}

if ($missingExtensions !== []) {
$missing = implode(', ', $missingExtensions);

return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Fail,
sprintf(
'Missing Composer-declared PHP extension%s: %s.',
count($missingExtensions) === 1 ? '' : 's',
$missing,
),
sprintf(
'Install or enable the missing PHP extension%s: %s.',
count($missingExtensions) === 1 ? '' : 's',
$missing,
),
);
}

return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Pass,
sprintf('All Composer-declared PHP extensions are loaded: %s.', implode(', ', $requiredExtensions)),
);
}

private function manifestUnavailableFinding(): DoctorFinding
{
return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Fail,
sprintf('Composer project manifest is unavailable at %s.', $this->composerJsonPath),
'Create a readable composer.json in the current project directory.',
);
}

private function noRequiredExtensionsFinding(): DoctorFinding
{
return new DoctorFinding(
self::IDENTIFIER,
DoctorStatus::Pass,
'Composer project declares no required PHP extensions.',
);
}
}
49 changes: 45 additions & 4 deletions packages/core/tests/Integration/Console/EvolveBinaryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,42 @@

final class EvolveBinaryTest extends TestCase
{
public function testDoctorCommandRunsDefaultPhpVersionCheck(): void
/** @var list<string> */
private array $temporaryDirectories = [];

protected function tearDown(): void
{
foreach (array_reverse($this->temporaryDirectories) as $directory) {
if (is_dir($directory)) {
rmdir($directory);
}
}

$this->temporaryDirectories = [];
}

public function testDoctorCommandRunsDefaultPhpVersionAndProjectComposerExtensionChecks(): void
{
$result = $this->runEvolve(['doctor']);
$result = $this->runEvolve(['doctor'], dirname(__DIR__, 5));

self::assertSame(0, $result->exitCode);
self::assertStringContainsString('[PASS] runtime.php.version:', $result->stdout);
self::assertStringContainsString('[PASS] project.composer.extensions:', $result->stdout);
$phpVersionPosition = strpos($result->stdout, '[PASS] runtime.php.version:');
$composerExtensionsPosition = strpos($result->stdout, '[PASS] project.composer.extensions:');
self::assertNotFalse($phpVersionPosition);
self::assertNotFalse($composerExtensionsPosition);
self::assertLessThan($composerExtensionsPosition, $phpVersionPosition);
self::assertSame('', $result->stderr);
}

public function testDoctorCommandReportsMissingProjectComposerManifestAsDiagnosticFailure(): void
{
$result = $this->runEvolve(['doctor'], $this->makeTemporaryDirectory());

self::assertSame(1, $result->exitCode);
self::assertStringContainsString('[PASS] runtime.php.version:', $result->stdout);
self::assertStringContainsString('[FAIL] project.composer.extensions:', $result->stdout);
self::assertSame('', $result->stderr);
}

Expand Down Expand Up @@ -45,7 +75,7 @@ public function testNoCommandReturnsUsageError(): void
/**
* @param list<string> $arguments
*/
private function runEvolve(array $arguments): BinaryResult
private function runEvolve(array $arguments, ?string $workingDirectory = null): BinaryResult
{
$binary = dirname(__DIR__, 3) . '/bin/evolve';
$command = [PHP_BINARY, $binary, ...$arguments];
Expand All @@ -55,7 +85,7 @@ private function runEvolve(array $arguments): BinaryResult
2 => ['pipe', 'w'],
];

$process = proc_open($command, $descriptorSpec, $pipes);
$process = proc_open($command, $descriptorSpec, $pipes, $workingDirectory);

self::assertIsResource($process);

Expand All @@ -71,6 +101,17 @@ private function runEvolve(array $arguments): BinaryResult
$stderr,
);
}

private function makeTemporaryDirectory(): string
{
$directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evolve-binary-' . bin2hex(random_bytes(6));

mkdir($directory);

$this->temporaryDirectories[] = $directory;

return $directory;
}
}

final readonly class BinaryResult
Expand Down
Loading