From cc70eafaebf6bd5a6305cb92136eaeed963738c9 Mon Sep 17 00:00:00 2001 From: Josiah King Date: Fri, 28 Aug 2026 10:02:55 +0100 Subject: [PATCH] Add environment and writable path diagnostics --- packages/core/README.md | 23 ++ .../Project/EnvironmentVariablesCheck.php | 131 +++++++++ .../src/Doctor/Project/WritablePathsCheck.php | 128 +++++++++ .../Project/EnvironmentVariablesCheckTest.php | 264 ++++++++++++++++++ .../Doctor/Project/WritablePathsCheckTest.php | 205 ++++++++++++++ .../EvolvePhp2PackageSkeletonTest.php | 2 + 6 files changed, 753 insertions(+) create mode 100644 packages/core/src/Doctor/Project/EnvironmentVariablesCheck.php create mode 100644 packages/core/src/Doctor/Project/WritablePathsCheck.php create mode 100644 packages/core/tests/Unit/Doctor/Project/EnvironmentVariablesCheckTest.php create mode 100644 packages/core/tests/Unit/Doctor/Project/WritablePathsCheckTest.php diff --git a/packages/core/README.md b/packages/core/README.md index 196099a..ddf8eda 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -213,3 +213,26 @@ 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. +## Doctor project diagnostics + +Core includes caller-configured project diagnostic primitives in addition to the +package-owned shell Doctor checks. + +`Project\EnvironmentVariablesCheck` checks an explicitly supplied ordered list +of required environment-variable names for presence only. Empty-string values +count as present, values are never exposed in messages or remediation, and the +check does not load dotenv files, parse `.env.example`, or define which +variables an application requires. + +`Project\WritablePathsCheck` checks an explicitly supplied ordered list of local +filesystem paths with writability inspection. It does not create paths, chmod +files, change permissions, perform automatic remediation, or establish default +storage/cache conventions. + +These checks are programmatic Doctor primitives and are not automatically wired +into the package-owned shell Doctor. The shell Doctor remains limited to: + +1. PHP runtime version +2. current-project Composer-declared extension presence + +Route inspection remains deferred. diff --git a/packages/core/src/Doctor/Project/EnvironmentVariablesCheck.php b/packages/core/src/Doctor/Project/EnvironmentVariablesCheck.php new file mode 100644 index 0000000..5f5f80e --- /dev/null +++ b/packages/core/src/Doctor/Project/EnvironmentVariablesCheck.php @@ -0,0 +1,131 @@ + + */ + private array $requiredVariables; + + /** + * @param array $requiredVariables + * @param (Closure(string): (string|false))|null $environmentLookup + */ + public function __construct( + array $requiredVariables, + private ?Closure $environmentLookup = null, + ) { + $this->requiredVariables = self::validateRequiredVariables($requiredVariables); + } + + public function identifier(): string + { + return self::IDENTIFIER; + } + + public function run(): DoctorFinding + { + if ($this->requiredVariables === []) { + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Pass, + 'No environment variables were required for this diagnostic check.', + ); + } + + $lookup = $this->environmentLookup ?? static fn(string $name): string|false => getenv($name); + $missingVariables = []; + + foreach ($this->requiredVariables as $variableName) { + if ($lookup($variableName) === false) { + $missingVariables[] = $variableName; + } + } + + if ($missingVariables === []) { + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Pass, + sprintf( + 'All required environment variables are present: %s.', + implode(', ', $this->requiredVariables), + ), + ); + } + + $missingVariableList = implode(', ', $missingVariables); + + if (count($missingVariables) === 1) { + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Fail, + sprintf('Missing required environment variable: %s.', $missingVariableList), + sprintf('Define the missing environment variable before running the application: %s.', $missingVariableList), + ); + } + + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Fail, + sprintf('Missing required environment variables: %s.', $missingVariableList), + sprintf('Define the missing environment variables before running the application: %s.', $missingVariableList), + ); + } + + /** + * @param array $requiredVariables + * @return list + */ + private static function validateRequiredVariables(array $requiredVariables): array + { + if (! array_is_list($requiredVariables)) { + throw new InvalidArgumentException('Required environment variables must be provided as a list.'); + } + + $seenVariableNames = []; + $validatedVariables = []; + + foreach ($requiredVariables as $variableName) { + if (! is_string($variableName)) { + throw new InvalidArgumentException('Required environment variable names must be strings.'); + } + + if ($variableName === '') { + throw new InvalidArgumentException('Required environment variable names must be non-empty strings.'); + } + + if (str_contains($variableName, '=')) { + throw new InvalidArgumentException('Required environment variable names must not contain equals signs.'); + } + + if (str_contains($variableName, "\0")) { + throw new InvalidArgumentException('Required environment variable names must not contain ASCII NUL bytes.'); + } + + if (preg_match('/[\s\x00-\x1F\x7F]/', $variableName) === 1) { + throw new InvalidArgumentException('Required environment variable names must not contain whitespace or control characters.'); + } + + if (isset($seenVariableNames[$variableName])) { + throw new InvalidArgumentException('Required environment variable names must not contain exact duplicates.'); + } + + $seenVariableNames[$variableName] = true; + $validatedVariables[] = $variableName; + } + + return $validatedVariables; + } +} diff --git a/packages/core/src/Doctor/Project/WritablePathsCheck.php b/packages/core/src/Doctor/Project/WritablePathsCheck.php new file mode 100644 index 0000000..e5af8e0 --- /dev/null +++ b/packages/core/src/Doctor/Project/WritablePathsCheck.php @@ -0,0 +1,128 @@ + + */ + private array $requiredPaths; + + /** + * @param array $requiredPaths + * @param (Closure(string): bool)|null $isWritable + */ + public function __construct( + array $requiredPaths, + private ?Closure $isWritable = null, + ) { + $this->requiredPaths = self::validateRequiredPaths($requiredPaths); + } + + public function identifier(): string + { + return self::IDENTIFIER; + } + + public function run(): DoctorFinding + { + if ($this->requiredPaths === []) { + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Pass, + 'No writable paths were required for this diagnostic check.', + ); + } + + $isWritable = $this->isWritable ?? static fn(string $path): bool => is_writable($path); + $nonWritablePaths = []; + + foreach ($this->requiredPaths as $path) { + if (! $isWritable($path)) { + $nonWritablePaths[] = $path; + } + } + + if ($nonWritablePaths === []) { + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Pass, + sprintf('All required paths are writable: %s.', implode(', ', $this->requiredPaths)), + ); + } + + $nonWritablePathList = implode(', ', $nonWritablePaths); + + if (count($nonWritablePaths) === 1) { + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Fail, + sprintf('Required path is not writable: %s.', $nonWritablePathList), + sprintf('Ensure the required path is writable by the PHP process: %s.', $nonWritablePathList), + ); + } + + return new DoctorFinding( + self::IDENTIFIER, + DoctorStatus::Fail, + sprintf('Required paths are not writable: %s.', $nonWritablePathList), + sprintf('Ensure the required paths are writable by the PHP process: %s.', $nonWritablePathList), + ); + } + + /** + * @param array $requiredPaths + * @return list + */ + private static function validateRequiredPaths(array $requiredPaths): array + { + if (! array_is_list($requiredPaths)) { + throw new InvalidArgumentException('Required writable paths must be provided as a list.'); + } + + $seenPaths = []; + $validatedPaths = []; + + foreach ($requiredPaths as $path) { + if (! is_string($path)) { + throw new InvalidArgumentException('Required writable paths must be strings.'); + } + + if ($path === '') { + throw new InvalidArgumentException('Required writable paths must be non-empty strings.'); + } + + if (preg_match('/\S/', $path) !== 1) { + throw new InvalidArgumentException('Required writable paths must contain at least one non-whitespace character.'); + } + + if (str_contains($path, "\0")) { + throw new InvalidArgumentException('Required writable paths must not contain ASCII NUL bytes.'); + } + + if (str_contains($path, '://')) { + throw new InvalidArgumentException('Required writable paths must be local filesystem paths, not URIs or stream wrappers.'); + } + + if (isset($seenPaths[$path])) { + throw new InvalidArgumentException('Required writable paths must not contain exact duplicates.'); + } + + $seenPaths[$path] = true; + $validatedPaths[] = $path; + } + + return $validatedPaths; + } +} diff --git a/packages/core/tests/Unit/Doctor/Project/EnvironmentVariablesCheckTest.php b/packages/core/tests/Unit/Doctor/Project/EnvironmentVariablesCheckTest.php new file mode 100644 index 0000000..c751a01 --- /dev/null +++ b/packages/core/tests/Unit/Doctor/Project/EnvironmentVariablesCheckTest.php @@ -0,0 +1,264 @@ +identifier()); + } + + public function test_empty_requirement_list_passes(): void + { + $finding = (new EnvironmentVariablesCheck([]))->run(); + + self::assertFinding( + $finding, + DoctorStatus::Pass, + 'No environment variables were required for this diagnostic check.', + ); + } + + public function test_all_variables_present_passes(): void + { + $finding = (new EnvironmentVariablesCheck( + ['APP_ENV', 'APP_KEY'], + static fn(string $name): string|false => match ($name) { + 'APP_ENV' => 'production', + 'APP_KEY' => 'base64:key', + default => false, + }, + ))->run(); + + self::assertFinding( + $finding, + DoctorStatus::Pass, + 'All required environment variables are present: APP_ENV, APP_KEY.', + ); + } + + public function test_empty_string_environment_value_counts_as_present(): void + { + $finding = (new EnvironmentVariablesCheck( + ['OPTIONAL_VALUE'], + static fn(string $name) => '', + ))->run(); + + self::assertSame(DoctorStatus::Pass, self::statusOf($finding)); + } + + public function test_string_zero_environment_value_counts_as_present(): void + { + $finding = (new EnvironmentVariablesCheck( + ['FEATURE_ENABLED'], + static fn(string $name) => '0', + ))->run(); + + self::assertSame(DoctorStatus::Pass, self::statusOf($finding)); + } + + public function test_one_missing_variable_fails(): void + { + $finding = (new EnvironmentVariablesCheck( + ['APP_ENV'], + static fn(string $name) => false, + ))->run(); + + self::assertFinding( + $finding, + DoctorStatus::Fail, + 'Missing required environment variable: APP_ENV.', + 'Define the missing environment variable before running the application: APP_ENV.', + ); + } + + public function test_multiple_missing_variables_preserve_supplied_order(): void + { + $finding = (new EnvironmentVariablesCheck( + ['DATABASE_URL', 'APP_ENV', 'APP_KEY'], + static fn(string $name): string|false => $name === 'APP_ENV' ? 'local' : false, + ))->run(); + + self::assertFinding( + $finding, + DoctorStatus::Fail, + 'Missing required environment variables: DATABASE_URL, APP_KEY.', + 'Define the missing environment variables before running the application: DATABASE_URL, APP_KEY.', + ); + } + + public function test_remediation_contains_missing_names(): void + { + $finding = (new EnvironmentVariablesCheck( + ['DATABASE_URL'], + static fn(string $name) => false, + ))->run(); + + self::assertStringContainsString('DATABASE_URL', self::remediationOf($finding) ?? ''); + } + + public function test_environment_values_are_never_exposed_in_message(): void + { + $finding = (new EnvironmentVariablesCheck( + ['APP_ENV', 'DATABASE_URL'], + static fn(string $name): string|false => match ($name) { + 'APP_ENV' => 'super-secret-value', + 'DATABASE_URL' => false, + default => false, + }, + ))->run(); + + self::assertStringNotContainsString('super-secret-value', self::messageOf($finding)); + } + + public function test_environment_values_are_never_exposed_in_remediation(): void + { + $finding = (new EnvironmentVariablesCheck( + ['APP_ENV', 'DATABASE_URL'], + static fn(string $name): string|false => match ($name) { + 'APP_ENV' => 'super-secret-value', + 'DATABASE_URL' => false, + default => false, + }, + ))->run(); + + self::assertStringNotContainsString('super-secret-value', self::remediationOf($finding) ?? ''); + } + + public function test_injected_lookup_receives_names_in_supplied_order(): void + { + $seen = []; + + (new EnvironmentVariablesCheck( + ['APP_ENV', 'APP_KEY', 'DATABASE_URL'], + static function (string $name) use (&$seen) { + $seen[] = $name; + + return 'present'; + }, + ))->run(); + + self::assertSame(['APP_ENV', 'APP_KEY', 'DATABASE_URL'], $seen); + } + + public function test_exact_duplicate_variable_declarations_are_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnvironmentVariablesCheck(['APP_ENV', 'APP_ENV']); + } + + public function test_case_different_variable_names_are_allowed_as_distinct(): void + { + $check = new EnvironmentVariablesCheck( + ['APP_ENV', 'app_env'], + static fn(string $name) => 'present', + ); + + self::assertSame(DoctorStatus::Pass, self::statusOf($check->run())); + } + + public function test_empty_name_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnvironmentVariablesCheck(['']); + } + + public function test_whitespace_only_name_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnvironmentVariablesCheck([' ']); + } + + public function test_name_containing_whitespace_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnvironmentVariablesCheck(['APP ENV']); + } + + public function test_name_containing_equals_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnvironmentVariablesCheck(['APP_ENV=production']); + } + + public function test_name_containing_nul_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnvironmentVariablesCheck(["APP\0ENV"]); + } + + public function test_non_string_entry_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnvironmentVariablesCheck(['APP_ENV', 123]); + } + + public function test_non_list_input_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnvironmentVariablesCheck(['first' => 'APP_ENV']); + } + + public function test_lookup_throwable_propagates(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('lookup failed'); + + (new EnvironmentVariablesCheck( + ['APP_ENV'], + static fn(string $name) => throw new RuntimeException('lookup failed'), + ))->run(); + } + + private static function assertFinding( + DoctorFinding $finding, + DoctorStatus $status, + string $message, + ?string $remediation = null, + ): void { + self::assertSame(EnvironmentVariablesCheck::IDENTIFIER, self::identifierOf($finding)); + self::assertSame($status, self::statusOf($finding)); + self::assertSame($message, self::messageOf($finding)); + self::assertSame($remediation, self::remediationOf($finding)); + } + + private static function identifierOf(DoctorFinding $finding): string + { + return $finding->identifier(); + } + + private static function statusOf(DoctorFinding $finding): DoctorStatus + { + return $finding->status(); + } + + private static function messageOf(DoctorFinding $finding): string + { + return $finding->message(); + } + + private static function remediationOf(DoctorFinding $finding): ?string + { + return $finding->remediation(); + } +} diff --git a/packages/core/tests/Unit/Doctor/Project/WritablePathsCheckTest.php b/packages/core/tests/Unit/Doctor/Project/WritablePathsCheckTest.php new file mode 100644 index 0000000..0b09097 --- /dev/null +++ b/packages/core/tests/Unit/Doctor/Project/WritablePathsCheckTest.php @@ -0,0 +1,205 @@ +identifier()); + } + + public function test_empty_requirement_list_passes(): void + { + $finding = (new WritablePathsCheck([]))->run(); + + self::assertFinding( + $finding, + DoctorStatus::Pass, + 'No writable paths were required for this diagnostic check.', + ); + } + + public function test_all_paths_writable_passes(): void + { + $finding = (new WritablePathsCheck( + ['storage/cache', 'storage/logs'], + static fn(string $path): bool => true, + ))->run(); + + self::assertFinding( + $finding, + DoctorStatus::Pass, + 'All required paths are writable: storage/cache, storage/logs.', + ); + } + + public function test_one_non_writable_path_fails(): void + { + $finding = (new WritablePathsCheck( + ['storage/cache'], + static fn(string $path): bool => false, + ))->run(); + + self::assertFinding( + $finding, + DoctorStatus::Fail, + 'Required path is not writable: storage/cache.', + 'Ensure the required path is writable by the PHP process: storage/cache.', + ); + } + + public function test_multiple_non_writable_paths_preserve_supplied_order(): void + { + $finding = (new WritablePathsCheck( + ['storage/cache', 'storage/logs', 'var/tmp'], + static fn(string $path): bool => $path === 'storage/logs', + ))->run(); + + self::assertFinding( + $finding, + DoctorStatus::Fail, + 'Required paths are not writable: storage/cache, var/tmp.', + 'Ensure the required paths are writable by the PHP process: storage/cache, var/tmp.', + ); + } + + public function test_remediation_contains_affected_paths(): void + { + $finding = (new WritablePathsCheck( + ['storage/cache'], + static fn(string $path): bool => false, + ))->run(); + + self::assertStringContainsString('storage/cache', self::remediationOf($finding) ?? ''); + } + + public function test_injected_lookup_receives_paths_in_supplied_order(): void + { + $seen = []; + + (new WritablePathsCheck( + ['storage/cache', 'storage/logs', 'var/tmp'], + static function (string $path) use (&$seen): bool { + $seen[] = $path; + + return true; + }, + ))->run(); + + self::assertSame(['storage/cache', 'storage/logs', 'var/tmp'], $seen); + } + + public function test_nonexistent_default_style_lookup_failure_is_represented_as_fail(): void + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evolvephp-doctor-missing-' . bin2hex(random_bytes(8)); + + $finding = (new WritablePathsCheck([$path]))->run(); + + self::assertSame(DoctorStatus::Fail, self::statusOf($finding)); + self::assertStringContainsString($path, self::messageOf($finding)); + } + + public function test_exact_duplicate_path_declarations_are_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new WritablePathsCheck(['storage/cache', 'storage/cache']); + } + + public function test_empty_path_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new WritablePathsCheck(['']); + } + + public function test_whitespace_only_path_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new WritablePathsCheck([' ']); + } + + public function test_path_containing_nul_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new WritablePathsCheck(["storage\0cache"]); + } + + public function test_uri_or_stream_wrapper_path_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new WritablePathsCheck(['php://memory']); + } + + public function test_non_string_entry_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new WritablePathsCheck(['storage/cache', 123]); + } + + public function test_non_list_input_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new WritablePathsCheck(['cache' => 'storage/cache']); + } + + public function test_callback_throwable_propagates(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('writability failed'); + + (new WritablePathsCheck( + ['storage/cache'], + static fn(string $path): bool => throw new RuntimeException('writability failed'), + ))->run(); + } + + private static function assertFinding( + DoctorFinding $finding, + DoctorStatus $status, + string $message, + ?string $remediation = null, + ): void { + self::assertSame(WritablePathsCheck::IDENTIFIER, self::identifierOf($finding)); + self::assertSame($status, self::statusOf($finding)); + self::assertSame($message, self::messageOf($finding)); + self::assertSame($remediation, self::remediationOf($finding)); + } + + private static function identifierOf(DoctorFinding $finding): string + { + return $finding->identifier(); + } + + private static function statusOf(DoctorFinding $finding): DoctorStatus + { + return $finding->status(); + } + + private static function messageOf(DoctorFinding $finding): string + { + return $finding->message(); + } + + private static function remediationOf(DoctorFinding $finding): ?string + { + return $finding->remediation(); + } +} diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index 27630a3..053ccf5 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -796,6 +796,8 @@ private function acceptedPackageSourceInventories() 'Doctor/DoctorRunner.php', 'Doctor/DoctorStatus.php', 'Doctor/Project/ComposerRequiredExtensionsCheck.php', + 'Doctor/Project/EnvironmentVariablesCheck.php', + 'Doctor/Project/WritablePathsCheck.php', 'Doctor/Runtime/PhpExtensionCheck.php', 'Doctor/Runtime/PhpVersionCheck.php', 'Exception/ActiveComponentConflict.php',