From 315622fd5c2fe5ededa9aa42d54a9b10d77c75fd Mon Sep 17 00:00:00 2001 From: Josiah King Date: Thu, 27 Aug 2026 17:13:43 +0100 Subject: [PATCH] Add Composer extension diagnostics --- packages/core/README.md | 55 ++- packages/core/bin/evolve | 7 + .../ComposerRequiredExtensionsCheck.php | 190 +++++++++++ .../Integration/Console/EvolveBinaryTest.php | 49 ++- .../ComposerRequiredExtensionsCheckTest.php | 323 ++++++++++++++++++ .../EvolvePhp2PackageSkeletonTest.php | 1 + 6 files changed, 604 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/Doctor/Project/ComposerRequiredExtensionsCheck.php create mode 100644 packages/core/tests/Unit/Doctor/Project/ComposerRequiredExtensionsCheckTest.php diff --git a/packages/core/README.md b/packages/core/README.md index 774b0a1..196099a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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 @@ -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 @@ -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: @@ -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. diff --git a/packages/core/bin/evolve b/packages/core/bin/evolve index 5dae0ea..d2b2da4 100755 --- a/packages/core/bin/evolve +++ b/packages/core/bin/evolve @@ -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; @@ -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), diff --git a/packages/core/src/Doctor/Project/ComposerRequiredExtensionsCheck.php b/packages/core/src/Doctor/Project/ComposerRequiredExtensionsCheck.php new file mode 100644 index 0000000..0265afc --- /dev/null +++ b/packages/core/src/Doctor/Project/ComposerRequiredExtensionsCheck.php @@ -0,0 +1,190 @@ +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- 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.', + ); + } +} diff --git a/packages/core/tests/Integration/Console/EvolveBinaryTest.php b/packages/core/tests/Integration/Console/EvolveBinaryTest.php index 2b6a162..a31f688 100644 --- a/packages/core/tests/Integration/Console/EvolveBinaryTest.php +++ b/packages/core/tests/Integration/Console/EvolveBinaryTest.php @@ -8,12 +8,42 @@ final class EvolveBinaryTest extends TestCase { - public function testDoctorCommandRunsDefaultPhpVersionCheck(): void + /** @var list */ + 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); } @@ -45,7 +75,7 @@ public function testNoCommandReturnsUsageError(): void /** * @param list $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]; @@ -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); @@ -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 diff --git a/packages/core/tests/Unit/Doctor/Project/ComposerRequiredExtensionsCheckTest.php b/packages/core/tests/Unit/Doctor/Project/ComposerRequiredExtensionsCheckTest.php new file mode 100644 index 0000000..7b2ca8c --- /dev/null +++ b/packages/core/tests/Unit/Doctor/Project/ComposerRequiredExtensionsCheckTest.php @@ -0,0 +1,323 @@ + */ + private array $temporaryDirectories = []; + + protected function tearDown(): void + { + foreach (array_reverse($this->temporaryDirectories) as $directory) { + $this->removeDirectory($directory); + } + + $this->temporaryDirectories = []; + } + + public function testIdentifierIsProjectComposerExtensions(): void + { + self::assertSame('project.composer.extensions', (new ComposerRequiredExtensionsCheck($this->missingManifestPath()))->identifier()); + } + + public function testEmptyConstructorPathRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new ComposerRequiredExtensionsCheck(''); + } + + public function testWhitespaceOnlyConstructorPathRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new ComposerRequiredExtensionsCheck(' '); + } + + public function testUriPathRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new ComposerRequiredExtensionsCheck('https://example.com/composer.json'); + } + + public function testMissingManifestFails(): void + { + $path = $this->missingManifestPath(); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame('project.composer.extensions', $finding->identifier()); + self::assertSame(sprintf('Composer project manifest is unavailable at %s.', $path), $finding->message()); + } + + public function testInvalidJsonFails(): void + { + $path = $this->writeComposerJson('{'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project manifest is not valid JSON at %s.', $path), $finding->message()); + } + + public function testJsonNullRootFails(): void + { + $path = $this->writeComposerJson('null'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project manifest must contain a JSON object at %s.', $path), $finding->message()); + } + + public function testJsonScalarRootFails(): void + { + $path = $this->writeComposerJson('"composer"'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project manifest must contain a JSON object at %s.', $path), $finding->message()); + } + + public function testJsonListRootFails(): void + { + $path = $this->writeComposerJson('[]'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project manifest must contain a JSON object at %s.', $path), $finding->message()); + } + + public function testMissingRequireSectionPassesWithNoExtensionRequirements(): void + { + $path = $this->writeComposerJson('{"name":"example/project"}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => false))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('Composer project declares no required PHP extensions.', $finding->message()); + } + + public function testEmptyRequireObjectPasses(): void + { + $path = $this->writeComposerJson('{"require":{}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => false))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('Composer project declares no required PHP extensions.', $finding->message()); + } + + public function testRequireListFails(): void + { + $path = $this->writeComposerJson('{"require":[]}'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project runtime requirements must be a JSON object at %s.', $path), $finding->message()); + } + + public function testUnrelatedRuntimePackagesIgnored(): void + { + $path = $this->writeComposerJson('{"require":{"php":"^8.4","psr/container":"^2.0"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => false))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('Composer project declares no required PHP extensions.', $finding->message()); + } + + public function testRequireDevExtensionRequirementsIgnored(): void + { + $path = $this->writeComposerJson('{"require-dev":{"ext-missing":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => false))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('Composer project declares no required PHP extensions.', $finding->message()); + } + + public function testOneRuntimeExtensionDeclarationIsDiscovered(): void + { + $path = $this->writeComposerJson('{"require":{"ext-json":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => true))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('All Composer-declared PHP extensions are loaded: json.', $finding->message()); + } + + public function testMultipleRuntimeExtensionsAreNormalizedAndSortedDeterministically(): void + { + $path = $this->writeComposerJson('{"require":{"ext-pdo":"*","ext-json":"*","ext-mbstring":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => true))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('All Composer-declared PHP extensions are loaded: json, mbstring, pdo.', $finding->message()); + } + + public function testUppercaseExtensionDeclarationNormalizesToLowercase(): void + { + $path = $this->writeComposerJson('{"require":{"EXT-JSON":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => true))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('All Composer-declared PHP extensions are loaded: json.', $finding->message()); + } + + public function testMalformedEmptyExtensionSuffixFails(): void + { + $path = $this->writeComposerJson('{"require":{"ext-":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project PHP extension requirement "ext-" is malformed at %s.', $path), $finding->message()); + } + + public function testMalformedExtensionNameFails(): void + { + $path = $this->writeComposerJson('{"require":{"ext-foo/bar":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame( + sprintf( + 'Composer project PHP extension requirement "ext-foo/bar" is malformed at %s.', + $path, + ), + $finding->message(), + ); + } + + public function testNonStringExtensionConstraintFails(): void + { + $path = $this->writeComposerJson('{"require":{"ext-json":null}}'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project PHP extension requirement "ext-json" must use a non-empty string constraint at %s.', $path), $finding->message()); + } + + public function testEmptyExtensionConstraintFails(): void + { + $path = $this->writeComposerJson('{"require":{"ext-json":""}}'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project PHP extension requirement "ext-json" must use a non-empty string constraint at %s.', $path), $finding->message()); + } + + public function testNormalizedDuplicateExtensionDeclarationsFail(): void + { + $path = $this->writeComposerJson('{"require":{"ext-JSON":"*","ext-json":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame(sprintf('Composer project PHP extension "json" is declared more than once after normalization at %s.', $path), $finding->message()); + } + + public function testAllLoadedPasses(): void + { + $path = $this->writeComposerJson('{"require":{"ext-json":"*","ext-mbstring":"^1"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => true))->run(); + + self::assertSame(DoctorStatus::Pass, $finding->status()); + self::assertSame('All Composer-declared PHP extensions are loaded: json, mbstring.', $finding->message()); + } + + public function testOneMissingFails(): void + { + $path = $this->writeComposerJson('{"require":{"ext-json":"*","ext-mbstring":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => $extension === 'json'))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame('Missing Composer-declared PHP extension: mbstring.', $finding->message()); + } + + public function testMultipleMissingFailWithDeterministicOrdering(): void + { + $path = $this->writeComposerJson('{"require":{"ext-pdo":"*","ext-json":"*","ext-mbstring":"*"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => $extension === 'json'))->run(); + + self::assertSame(DoctorStatus::Fail, $finding->status()); + self::assertSame('Missing Composer-declared PHP extensions: mbstring, pdo.', $finding->message()); + } + + public function testInjectedLookupClosureReceivesCanonicalNormalizedNamesInSortedOrder(): void + { + $received = []; + $path = $this->writeComposerJson('{"require":{"ext-pdo":"*","EXT-JSON":"*","ext-mbstring":"*"}}'); + + (new ComposerRequiredExtensionsCheck( + $path, + function (string $extension) use (&$received): bool { + $received[] = $extension; + + return true; + }, + ))->run(); + + self::assertSame(['json', 'mbstring', 'pdo'], $received); + } + + public function testRemediationIdentifiesMissingExtension(): void + { + $path = $this->writeComposerJson('{"require":{"ext-json":"*","ext-mbstring":">=1"}}'); + $finding = (new ComposerRequiredExtensionsCheck($path, static fn(string $extension): bool => $extension === 'json'))->run(); + + self::assertSame('Install or enable the missing PHP extension: mbstring.', $finding->remediation()); + } + + private function missingManifestPath(): string + { + return $this->makeTemporaryDirectory() . DIRECTORY_SEPARATOR . 'composer.json'; + } + + private function writeComposerJson(string $contents): string + { + $path = $this->missingManifestPath(); + + file_put_contents($path, $contents); + + return $path; + } + + private function makeTemporaryDirectory(): string + { + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evolve-composer-extensions-' . bin2hex(random_bytes(6)); + + mkdir($directory); + + $this->temporaryDirectories[] = $directory; + + return $directory; + } + + private function removeDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + + continue; + } + + unlink($file->getPathname()); + } + + rmdir($directory); + } +} diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index afaccc6..27630a3 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -795,6 +795,7 @@ private function acceptedPackageSourceInventories() 'Doctor/DoctorReport.php', 'Doctor/DoctorRunner.php', 'Doctor/DoctorStatus.php', + 'Doctor/Project/ComposerRequiredExtensionsCheck.php', 'Doctor/Runtime/PhpExtensionCheck.php', 'Doctor/Runtime/PhpVersionCheck.php', 'Exception/ActiveComponentConflict.php',