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
23 changes: 23 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
131 changes: 131 additions & 0 deletions packages/core/src/Doctor/Project/EnvironmentVariablesCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?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;

final readonly class EnvironmentVariablesCheck implements DoctorCheck
{
public const IDENTIFIER = 'project.environment.variables';

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

/**
* @param array<mixed> $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<mixed> $requiredVariables
* @return list<string>
*/
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;
}
}
128 changes: 128 additions & 0 deletions packages/core/src/Doctor/Project/WritablePathsCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?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;

final readonly class WritablePathsCheck implements DoctorCheck
{
public const IDENTIFIER = 'project.paths.writable';

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

/**
* @param array<mixed> $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<mixed> $requiredPaths
* @return list<string>
*/
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;
}
}
Loading