diff --git a/CHANGELOG.md b/CHANGELOG.md index e0bd6c9..1357d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Added Phase 4.7 repository cutover with root-native Composer, PHPUnit, PHPStan, PHP-CS-Fixer, Deptrac, release-validation, package split validation, supply-chain checks and developer-experience commands; removed the tracked workspace directory and EvolvePHP 1 runtime files from the 2.x working tree while preserving master and Git history. The PHP 8.4/8.5 root quality matrix now runs from the repository root; the protected `Workspace quality (PHP 8.4)` and `Workspace quality (PHP 8.5)` context names are retained for governance compatibility. +### Audit foundation + +- Added Phase 7.1 Evolve Audit foundation inside `evolvephp/dev-tools`, including public pre-beta experimental `AuditSeverity`, `AuditFinding`, `AuditReport`, `AuditInspector`, `AuditRunner` and `ComposerProjectInspector` APIs for read-only root `composer.json` inspection, deterministic direct dependency/framework evidence, raw root PHP constraint preservation, Composer platform PHP review evidence, malformed Composer evidence reporting and tests proving target autoload files, Composer scripts and target PHP files are not executed or modified. Audit remains development-time tooling, not Doctor, and does not add an `evolvephp/audit` package, Composer solving, lockfile/transitive analysis, JSON output, CLI commands, remediation, Bridge adapters, remote clients or compatibility claims from raw Composer constraints. + ### Component foundation - Added Phase 5.7 Testing SDK and independent component acceptance coverage inside `evolvephp/testing`, including public pre-beta experimental `ComponentDefinitionFixture` and `ComponentEntryPointFixture` closure-backed fixtures for the accepted component contracts, no direct PHPUnit dependency in Testing source, no Module/Plugin replacement semantics, no hidden registries or lifecycle orchestration, and independent integration coverage for explicit `ModuleDefinition`, `ComposerPluginDiscovery`, app-controlled enablement, Core `ComponentBootstrapper`, real graph ordering, restricted registration, service-registry freeze, `ApplicationKernel` boot, component boot, ready and reverse shutdown. Create-project tooling, generators, Doctor, route inspection, environment inspection and broader developer tooling remain Phase 6 work. diff --git a/packages/README.md b/packages/README.md index 111ba40..4ff0894 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,7 +2,7 @@ `packages/` contains the initial EvolvePHP 2 modular-monorepo package set. -The packages define Composer package identities, namespace ownership, dependency direction, lifecycle, configuration, service-container, execution-scope, runtime-neutral execution orchestration, generic Core instrumentation, minimal Core console APIs, PSR HTTP middleware and routing, response/error and health handling, explicit response emission, component identity, descriptors, graph declarations and resolution, restricted registration, lifecycle entry points, explicit component bootstrap, Composer plugin discovery, testing fixtures, development-time generators and command-output recording. Complete runtime implementation is not yet present, and the packages are not yet published. +The packages define Composer package identities, namespace ownership, dependency direction, lifecycle, configuration, service-container, execution-scope, runtime-neutral execution orchestration, generic Core instrumentation, minimal Core console APIs, PSR HTTP middleware and routing, response/error and health handling, explicit response emission, component identity, descriptors, graph declarations and resolution, restricted registration, lifecycle entry points, explicit component bootstrap, Composer plugin discovery, testing fixtures, development-time generators, read-only project audit evidence and command-output recording. Complete runtime implementation is not yet present, and the packages are not yet published. All package manifests require PHP `^8.4`. @@ -12,7 +12,7 @@ All package manifests require PHP `^8.4`. | --- | --- | --- | | `evolvephp/contracts` | `Evolve\Contracts\` | Foundational public-contract boundary, including application lifecycle, configuration, reset-participant and exception contracts, experimental shared identity vocabulary, graph declaration vocabulary, `ServiceDefinitionRegistrar`, `ComponentEntryPoint` / `ComponentBootContext` lifecycle contracts and `ComponentDefinition`. | | `evolvephp/core` | `Evolve\Core\` | Core orchestration boundary, including the minimal application lifecycle kernel, array-backed configuration, PSR-11-readable service container, explicit execution scopes, runtime-neutral execution outcomes, generic execution-lifecycle observation hooks, runtime-neutral command foundation, `ComponentGraphResolver`, `ResolvedComponentGraph`, consumer-scoped `CapabilityProviderSelection`, restricted registration, component lifecycle coordination and explicit `ComponentBootstrapper`. | -| `evolvephp/dev-tools` | `Evolve\DevTools\` | Development-only generator boundary with public experimental `module:new` and `plugin:new` command adapters for application-owned module and plugin starter files. | +| `evolvephp/dev-tools` | `Evolve\DevTools\` | Development-only tooling boundary with public experimental `module:new` and `plugin:new` command adapters plus read-only Audit APIs for root Composer evidence. | | `evolvephp/http` | `Evolve\Http\` | HTTP boundary with PSR HTTP interoperability, `MiddlewarePipeline`, route definitions and matching, routed handler dispatch, typed routing failures, `HttpKernel` integration with Core execution orchestration, response/error and health foundations and explicit response-emitter boundary; runtime adapters remain deferred. | | `evolvephp/module` | `Evolve\Module\` | Module SDK boundary with the public experimental `ModuleDescriptor`, EvolvePHP-major compatibility validation, `graphDeclaration()` projection, `Module` entry point extending `ComponentEntryPoint` and explicit `ModuleDefinition`; discovery remains deferred. | | `evolvephp/plugin` | `Evolve\Plugin\` | Plugin SDK boundary with the public experimental `PluginDescriptor`, EvolvePHP-major compatibility validation, `graphDeclaration()` projection, `Plugin` entry point extending `ComponentEntryPoint`, explicit `PluginDefinition` and `ComposerPluginDiscovery` for packaged plugin metadata. | @@ -27,7 +27,7 @@ The package graph follows an inward dependency principle: - `contracts` is the innermost package. - `core`, `module` and `plugin` depend inward on `contracts`. - `http` depends inward on `contracts` and `core`. -- `dev-tools` may depend on `contracts`, `core`, `module` and `plugin` for development-time generators. +- `dev-tools` may depend on `contracts`, `core`, `module` and `plugin` for development-time generators and audit tooling. - `testing` may depend on the five production packages for development support. There is no production dependency on Testing. @@ -86,6 +86,8 @@ Testing provides concrete SDK surfaces for component tests. `ComponentDefinition DevTools provides public experimental `ModuleNewCommand` and `PluginNewCommand`. The commands accept an explicit project root and one ASCII StudlyCase name token, generate application-owned module or plugin starter files with deterministic `app/...` component identifiers, refuse invalid usage and existing targets, and do not run Composer, run Git, edit Composer manifests, discover components or auto-enable generated definitions. +DevTools also provides the public experimental Audit foundation. `AuditRunner` composes explicitly supplied inspectors in registration order and validates an explicit existing target root. `ComposerProjectInspector` reads only the target root `composer.json` as data, reports direct runtime/development dependencies, direct Laravel/Symfony/CakePHP/Yii/EvolvePHP package evidence, raw root PHP constraint evidence and Composer platform PHP review evidence, while avoiding framework boot, Composer execution, target autoloading, shell commands, `.env` loading, lockfile/transitive graph analysis, target writes and compatibility claims from raw Composer constraints. + Testing also provides `Evolve\Testing\Console\RecordingCommandOutput`, a public experimental in-memory `CommandOutput` implementation for command tests. It records normal and error lines in order without replacing PHPUnit assertions or becoming a general CLI testing framework. EvolvePHP 2 does not yet provide full module/plugin runtime managers, descriptor serialization, component versions, dependency version ranges, Composer semantic-version constraint evaluation, runtime CLI adapters beyond the explicit skeleton shell, Symfony Console integration, broad Doctor checks, generator discovery, automatic component enablement, Bridge, Insight, Observe, telemetry storage/export, persistent-worker concurrency guarantees or production-ready framework runtime behavior. DevTools does not add automatic discovery, application bootstrapping, route generation, Composer mutation or package publication. Testing does not add a general testing framework. diff --git a/packages/dev-tools/README.md b/packages/dev-tools/README.md index 4909738..d0b5adf 100644 --- a/packages/dev-tools/README.md +++ b/packages/dev-tools/README.md @@ -16,6 +16,37 @@ PHP `^8.4` `evolvephp/contracts`, `evolvephp/core`, `evolvephp/module`, `evolvephp/plugin` +## Audit Foundation + +The package provides a public experimental Audit foundation under +`Evolve\DevTools\Audit`. + +`AuditRunner` accepts explicitly supplied inspectors and an explicit target +project root. It performs no automatic discovery. `ComposerProjectInspector` +inspects only the target root `composer.json`; it does not scan parent +directories, nested manifests, lock files or transitive dependency graphs. + +Audit treats the target project as data. It does not include target PHP files, +include the target `vendor/autoload.php`, bootstrap Laravel, Symfony, CakePHP, +Yii, EvolvePHP or custom application code, run Composer, run scripts, invoke +shell commands, load `.env`, write caches or modify target files. + +The first inspector reports structured findings for root Composer evidence: +direct runtime and development dependencies, direct framework package evidence +for Laravel, Symfony, CakePHP, Yii and EvolvePHP, the raw root `require.php` +constraint when present, missing PHP constraint evidence, malformed Composer +evidence and `config.platform.php` review evidence. + +Raw Composer constraints are preserved exactly as declared. Audit does not solve +Composer SemVer constraints and does not claim PHP, package or framework +compatibility from raw constraints. A Composer platform override is reported as +compatibility-review evidence only; it is not proof of the actual runtime PHP +version. + +Current limitations: Audit has no CLI command, JSON output, remediation, +lockfile analysis, vulnerability lookup, PHP source analysis, framework +bootstrap analysis, route discovery, migration planning or Bridge integration. + ## Generator Commands The package provides two public experimental command adapters: diff --git a/packages/dev-tools/src/Audit/AuditFinding.php b/packages/dev-tools/src/Audit/AuditFinding.php new file mode 100644 index 0000000..83baf8a --- /dev/null +++ b/packages/dev-tools/src/Audit/AuditFinding.php @@ -0,0 +1,79 @@ + $evidence + */ + public function __construct( + private string $identifier, + private AuditSeverity $severity, + private string $message, + private array $evidence, + ) { + if (preg_match(self::IDENTIFIER_PATTERN, $identifier) !== 1) { + throw new InvalidArgumentException('Audit finding identifier is invalid.'); + } + + if (trim($message) === '') { + throw new InvalidArgumentException('Audit finding message must be non-empty.'); + } + + if (! self::isPlainData($evidence)) { + throw new InvalidArgumentException('Audit finding evidence must contain only plain data.'); + } + } + + public function identifier(): string + { + return $this->identifier; + } + + public function severity(): AuditSeverity + { + return $this->severity; + } + + public function message(): string + { + return $this->message; + } + + /** + * @return array + */ + public function evidence(): array + { + return $this->evidence; + } + + private static function isPlainData(mixed $value): bool + { + if ($value === null || is_scalar($value)) { + return true; + } + + if (! is_array($value)) { + return false; + } + + foreach ($value as $nestedValue) { + if (! self::isPlainData($nestedValue)) { + return false; + } + } + + return true; + } +} diff --git a/packages/dev-tools/src/Audit/AuditInspector.php b/packages/dev-tools/src/Audit/AuditInspector.php new file mode 100644 index 0000000..4d70f99 --- /dev/null +++ b/packages/dev-tools/src/Audit/AuditInspector.php @@ -0,0 +1,13 @@ + + */ +final readonly class AuditReport implements Countable, IteratorAggregate +{ + /** + * @var list + */ + private array $findings; + + /** + * @param iterable $findings + */ + public function __construct(iterable $findings) + { + $orderedFindings = []; + + foreach ($findings as $finding) { + if (! $finding instanceof AuditFinding) { + throw new InvalidArgumentException('Audit reports may contain only audit findings.'); + } + + $orderedFindings[] = $finding; + } + + $this->findings = $orderedFindings; + } + + /** + * @return list + */ + public function findings(): array + { + return $this->findings; + } + + public function count(): int + { + return count($this->findings); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->findings); + } +} diff --git a/packages/dev-tools/src/Audit/AuditRunner.php b/packages/dev-tools/src/Audit/AuditRunner.php new file mode 100644 index 0000000..c87c55a --- /dev/null +++ b/packages/dev-tools/src/Audit/AuditRunner.php @@ -0,0 +1,65 @@ + + */ + private array $inspectors; + + /** + * @param iterable $inspectors + */ + public function __construct(iterable $inspectors) + { + $orderedInspectors = []; + + foreach ($inspectors as $inspector) { + if (! $inspector instanceof AuditInspector) { + throw new InvalidArgumentException('Audit runner accepts only audit inspectors.'); + } + + $orderedInspectors[] = $inspector; + } + + $this->inspectors = $orderedInspectors; + } + + public function inspect(string $projectRoot): AuditReport + { + $realProjectRoot = realpath($projectRoot); + + if ($realProjectRoot === false || ! is_dir($realProjectRoot)) { + throw new InvalidArgumentException('Audit target root must be an existing directory.'); + } + + $findings = []; + + foreach ($this->inspectors as $inspector) { + $inspectorFindings = $inspector->inspect($realProjectRoot); + + if (! is_iterable($inspectorFindings)) { + throw new InvalidArgumentException('Audit inspectors must return iterable findings.'); + } + + foreach ($inspectorFindings as $finding) { + if (! $finding instanceof AuditFinding) { + throw new InvalidArgumentException('Audit inspectors must return audit findings.'); + } + + $findings[] = $finding; + } + } + + return new AuditReport($findings); + } +} diff --git a/packages/dev-tools/src/Audit/AuditSeverity.php b/packages/dev-tools/src/Audit/AuditSeverity.php new file mode 100644 index 0000000..3b6499a --- /dev/null +++ b/packages/dev-tools/src/Audit/AuditSeverity.php @@ -0,0 +1,15 @@ + + */ + public function inspect(string $projectRoot): array + { + $composerPath = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'composer.json'; + + if (! is_file($composerPath) || ! is_readable($composerPath)) { + return [ + new AuditFinding('composer_json.unavailable', AuditSeverity::Risk, 'composer.json is missing or unreadable.', [ + 'path' => 'composer.json', + 'reason' => 'missing or unreadable', + ]), + ]; + } + + $content = file_get_contents($composerPath); + + if ($content === false) { + return [ + new AuditFinding('composer_json.unavailable', AuditSeverity::Risk, 'composer.json is missing or unreadable.', [ + 'path' => 'composer.json', + 'reason' => 'missing or unreadable', + ]), + ]; + } + + try { + $root = json_decode($content, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + return [ + new AuditFinding('composer_json.invalid_json', AuditSeverity::Risk, 'composer.json is not valid JSON.', [ + 'path' => 'composer.json', + 'error' => $exception->getMessage(), + ]), + ]; + } + + if (! $root instanceof \stdClass) { + return [ + new AuditFinding('composer_json.root_not_object', AuditSeverity::Risk, 'composer.json root must be a JSON object.', [ + 'path' => 'composer.json', + 'actual' => $this->typeOf($root), + ]), + ]; + } + + $require = $this->dependencyMap($root, 'require'); + $requireDev = $this->dependencyMap($root, 'require-dev'); + $findings = [ + new AuditFinding('composer_json.present', AuditSeverity::Info, 'Root composer.json was found.', [ + 'path' => 'composer.json', + ]), + ]; + + foreach ($require['malformed'] ?? [] as $malformedFinding) { + $findings[] = $malformedFinding; + } + + foreach ($requireDev['malformed'] ?? [] as $malformedFinding) { + $findings[] = $malformedFinding; + } + + $phpConstraintFinding = $this->phpConstraintFinding($require['php']); + + if ($phpConstraintFinding !== null) { + $findings[] = $phpConstraintFinding; + } + + $findings[] = $this->runtimeDependenciesFinding($require); + $findings[] = $this->developmentDependenciesFinding($requireDev); + $findings[] = $this->frameworksFinding($require, $requireDev); + + $platformFinding = $this->platformPhpFinding($root); + + if ($platformFinding !== null) { + $findings[] = $platformFinding; + } + + return $findings; + } + + /** + * @return array{state: 'absent'|'valid'|'partial'|'unknown', dependencies: array, packages: array, php: array{state: 'absent'|'valid'|'malformed'|'unknown', constraint?: string}, malformed?: list} + */ + private function dependencyMap(\stdClass $root, string $field): array + { + if (! property_exists($root, $field)) { + return ['state' => 'absent', 'dependencies' => [], 'packages' => [], 'php' => ['state' => 'absent']]; + } + + $value = $root->{$field}; + + if (! $value instanceof \stdClass) { + return [ + 'state' => 'unknown', + 'dependencies' => [], + 'packages' => [], + 'php' => ['state' => $field === 'require' ? 'unknown' : 'absent'], + 'malformed' => [new AuditFinding($this->malformedDependencyIdentifier($field), AuditSeverity::Risk, $field . ' must be a JSON object.', [ + 'field' => $field, + 'actual' => $this->typeOf($value), + ])], + ]; + } + + $dependencies = []; + $packages = []; + $malformed = []; + $php = ['state' => 'absent']; + $entries = get_object_vars($value); + + ksort($entries, SORT_STRING); + + foreach ($entries as $package => $constraint) { + if (! is_string($constraint)) { + $packages[$package] = [ + 'constraint' => null, + 'constraint_state' => 'malformed', + ]; + + if ($field === 'require' && $package === 'php') { + $php = ['state' => 'malformed']; + $malformed[] = new AuditFinding('composer.require_php.malformed', AuditSeverity::Risk, 'Root require.php must be a non-empty string when present.', [ + 'field' => 'require.php', + 'actual' => is_array($constraint) ? 'array' : $this->typeOf($constraint), + ]); + + continue; + } + + $malformed[] = new AuditFinding($this->malformedDependencyIdentifier($field), AuditSeverity::Risk, $field . ' must contain string package constraints.', [ + 'field' => $field, + 'actual' => 'object with non-string package constraints', + ]); + + continue; + } + + if ($field === 'require' && $package === 'php' && $constraint === '') { + $packages[$package] = [ + 'constraint' => null, + 'constraint_state' => 'malformed', + ]; + $php = ['state' => 'malformed']; + $malformed[] = new AuditFinding('composer.require_php.malformed', AuditSeverity::Risk, 'Root require.php must be a non-empty string when present.', [ + 'field' => 'require.php', + 'actual' => 'empty string', + ]); + + continue; + } + + if ($field === 'require' && $package === 'php') { + $php = ['state' => 'valid', 'constraint' => $constraint]; + } + + $dependencies[$package] = $constraint; + $packages[$package] = [ + 'constraint' => $constraint, + 'constraint_state' => 'valid', + ]; + } + + if ($malformed === []) { + return ['state' => 'valid', 'dependencies' => $dependencies, 'packages' => $packages, 'php' => $php]; + } + + return ['state' => 'partial', 'dependencies' => $dependencies, 'packages' => $packages, 'php' => $php, 'malformed' => $malformed]; + } + + /** + * @param array{state: 'absent'|'valid'|'malformed'|'unknown', constraint?: string} $php + */ + private function phpConstraintFinding(array $php): ?AuditFinding + { + if ($php['state'] === 'unknown' || $php['state'] === 'malformed') { + return null; + } + + if ($php['state'] === 'absent') { + return new AuditFinding('composer.php_constraint', AuditSeverity::Info, 'No direct root PHP constraint was found.', [ + 'present' => false, + 'constraint' => null, + 'claim' => 'no direct root PHP constraint found', + ]); + } + + return new AuditFinding('composer.php_constraint', AuditSeverity::Info, 'Raw root PHP constraint was found.', [ + 'present' => true, + 'constraint' => $php['constraint'], + 'claim' => 'raw composer constraint only', + ]); + } + + /** + * @param array{state: 'absent'|'valid'|'partial'|'unknown', dependencies: array} $section + */ + private function runtimeDependenciesFinding(array $section): AuditFinding + { + return $this->dependenciesFinding('composer.dependencies.runtime', 'runtime', $section); + } + + /** + * @param array{state: 'absent'|'valid'|'partial'|'unknown', dependencies: array} $section + */ + private function developmentDependenciesFinding(array $section): AuditFinding + { + return $this->dependenciesFinding('composer.dependencies.development', 'development', $section); + } + + /** + * @param array{state: 'absent'|'valid'|'partial'|'unknown', dependencies: array} $section + */ + private function dependenciesFinding(string $identifier, string $scope, array $section): AuditFinding + { + $complete = $section['state'] === 'absent' || $section['state'] === 'valid'; + + return new AuditFinding($identifier, $complete ? AuditSeverity::Info : AuditSeverity::Warning, 'Direct ' . $scope . ' dependency evidence was inspected.', [ + 'scope' => $scope, + 'state' => $section['state'], + 'complete' => $complete, + 'dependencies' => $section['state'] === 'unknown' ? null : $this->dependencyEvidence($section['dependencies']), + 'sort' => 'package name ascending', + ]); + } + + /** + * @param array{state: 'absent'|'valid'|'partial'|'unknown', packages: array} $runtime + * @param array{state: 'absent'|'valid'|'partial'|'unknown', packages: array} $development + */ + private function frameworksFinding(array $runtime, array $development): AuditFinding + { + $frameworks = []; + + foreach ([ + 'runtime' => $runtime['packages'], + 'development' => $development['packages'], + ] as $scope => $packages) { + foreach ($packages as $package => $evidence) { + $family = $this->frameworkFamily($package); + + if ($family === null) { + continue; + } + + $framework = [ + 'family' => $family, + 'package' => $package, + 'scope' => $scope, + 'constraint' => $evidence['constraint'], + ]; + + if ($evidence['constraint_state'] !== 'valid') { + $framework['constraint_state'] = $evidence['constraint_state']; + } + + $frameworks[] = $framework; + } + } + + usort( + $frameworks, + static fn(array $left, array $right): int => [$left['family'], $left['package'], $left['scope']] + <=> [$right['family'], $right['package'], $right['scope']], + ); + + $complete = ($runtime['state'] === 'absent' || $runtime['state'] === 'valid') + && ($development['state'] === 'absent' || $development['state'] === 'valid'); + $stateEvidence = [ + 'runtime' => $runtime['state'], + 'development' => $development['state'], + ]; + + if ($frameworks === [] && $complete) { + return new AuditFinding('composer.frameworks', AuditSeverity::Info, 'No direct supported framework package evidence was found.', [ + 'dependency_state' => $stateEvidence, + 'complete' => true, + 'frameworks' => [], + 'claim' => 'no direct supported framework package evidence found', + ]); + } + + if ($frameworks === []) { + return new AuditFinding('composer.frameworks', AuditSeverity::Warning, 'Framework package evidence is incomplete.', [ + 'dependency_state' => $stateEvidence, + 'complete' => false, + 'frameworks' => [], + 'claim' => 'framework evidence incomplete because dependency evidence is partial or unknown', + ]); + } + + return new AuditFinding('composer.frameworks', AuditSeverity::Warning, 'Direct framework package evidence was found.', [ + 'dependency_state' => $stateEvidence, + 'complete' => $complete, + 'frameworks' => $frameworks, + 'claim' => 'direct composer package evidence only', + ]); + } + + private function platformPhpFinding(\stdClass $root): ?AuditFinding + { + if (! property_exists($root, 'config')) { + return null; + } + + $config = $root->config; + + if (! $config instanceof \stdClass || ! property_exists($config, 'platform')) { + return null; + } + + $platform = $config->platform; + + if (! $platform instanceof \stdClass || ! property_exists($platform, 'php')) { + return null; + } + + $php = $platform->php; + + if (! is_string($php) || $php === '') { + return new AuditFinding('composer.config_platform_php.malformed', AuditSeverity::Risk, 'Composer config.platform.php must be a non-empty string when present.', [ + 'field' => 'config.platform.php', + 'actual' => $php === '' ? 'empty string' : $this->typeOf($php), + ]); + } + + return new AuditFinding('composer.platform_php', AuditSeverity::Warning, 'Composer platform PHP override requires compatibility review.', [ + 'present' => true, + 'value' => $php, + 'claim' => 'Composer platform emulation is not proof of the actual runtime PHP version', + ]); + } + + /** + * @param array $dependencies + * @return list + */ + private function dependencyEvidence(array $dependencies): array + { + unset($dependencies['php']); + ksort($dependencies, SORT_STRING); + + $evidence = []; + + foreach ($dependencies as $package => $constraint) { + $evidence[] = ['name' => $package, 'constraint' => $constraint]; + } + + return $evidence; + } + + private function frameworkFamily(string $package): ?string + { + return match (true) { + $package === 'laravel/framework' => 'laravel', + $package === 'symfony/framework-bundle', $package === 'symfony/symfony' => 'symfony', + $package === 'cakephp/cakephp' => 'cakephp', + $package === 'yiisoft/yii2' => 'yii', + str_starts_with($package, 'evolvephp/') => 'evolvephp', + default => null, + }; + } + + private function malformedDependencyIdentifier(string $field): string + { + return match ($field) { + 'require' => 'composer.require.malformed', + 'require-dev' => 'composer.require_dev.malformed', + default => throw new \InvalidArgumentException('Unknown dependency field.'), + }; + } + + private function typeOf(mixed $value): string + { + if (is_array($value)) { + return array_is_list($value) ? 'list' : 'array'; + } + + return match (get_debug_type($value)) { + 'int' => 'integer', + 'bool' => 'boolean', + default => get_debug_type($value), + }; + } +} diff --git a/packages/dev-tools/tests/Unit/Audit/AuditRunnerTest.php b/packages/dev-tools/tests/Unit/Audit/AuditRunnerTest.php new file mode 100644 index 0000000..7638946 --- /dev/null +++ b/packages/dev-tools/tests/Unit/Audit/AuditRunnerTest.php @@ -0,0 +1,235 @@ +projectRoot = $this->createProjectRoot(); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectRoot); + } + + public function testSeverityValuesAreExact(): void + { + $reflection = new \ReflectionEnum(AuditSeverity::class); + $values = array_map( + static fn(\ReflectionEnumBackedCase $case): int|string => $case->getBackingValue(), + $reflection->getCases(), + ); + + self::assertSame(['info', 'warning', 'risk'], $values); + } + + public function testFindingIdentifierValidation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Audit finding identifier is invalid.'); + + new AuditFinding('Invalid Identifier', AuditSeverity::Info, 'Observed evidence.', []); + } + + public function testFindingMessageValidation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Audit finding message must be non-empty.'); + + new AuditFinding('composer.php', AuditSeverity::Info, ' ', []); + } + + public function testEvidenceRemainsPlainData(): void + { + $finding = new AuditFinding('composer.php', AuditSeverity::Info, 'Observed evidence.', [ + 'php' => '^8.4', + 'present' => true, + 'nested' => ['value' => null], + ]); + + self::assertSame(['php' => '^8.4', 'present' => true, 'nested' => ['value' => null]], $finding->evidence()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Audit finding evidence must contain only plain data.'); + + new AuditFinding('composer.object', AuditSeverity::Risk, 'Object evidence.', ['object' => new \stdClass()]); + } + + public function testReportRejectsNonFindings(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Audit reports may contain only audit findings.'); + + $report = new AuditReport([new AuditFinding('valid', AuditSeverity::Info, 'Valid.', []), 'not-a-finding']); + + self::assertCount(0, $report); + } + + public function testExplicitInspectorListAndOrderArePreserved(): void + { + $runner = new AuditRunner([ + new FixedInspector(new AuditFinding('first', AuditSeverity::Info, 'First.', [])), + new FixedInspector(new AuditFinding('second', AuditSeverity::Warning, 'Second.', [])), + ]); + + self::assertSame(['first', 'second'], $this->findingIds($runner->inspect($this->projectRoot))); + } + + public function testFindingOrderInsideInspectorOutputIsPreserved(): void + { + $runner = new AuditRunner([ + new FixedInspector( + new AuditFinding('first', AuditSeverity::Info, 'First.', []), + new AuditFinding('second', AuditSeverity::Warning, 'Second.', []), + ), + ]); + + self::assertSame(['first', 'second'], $this->findingIds($runner->inspect($this->projectRoot))); + } + + public function testInvalidTargetRootIsRejectedBeforeInspection(): void + { + $runner = new AuditRunner([new ExplodingInspector()]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Audit target root must be an existing directory.'); + + $runner->inspect($this->projectRoot . DIRECTORY_SEPARATOR . 'missing'); + } + + public function testMalformedInspectorCollectionIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Audit runner accepts only audit inspectors.'); + + $runner = new AuditRunner([new FixedInspector(), 'not-an-inspector']); + + self::assertCount(0, $runner->inspect($this->projectRoot)); + } + + public function testInvalidInspectorOutputIsRejected(): void + { + $runner = new AuditRunner([new InvalidOutputInspector()]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Audit inspectors must return iterable findings.'); + + $runner->inspect($this->projectRoot); + } + + public function testIterableInspectorOutputContainingNonFindingIsRejected(): void + { + $runner = new AuditRunner([new InvalidIterableOutputInspector()]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Audit inspectors must return audit findings.'); + + $runner->inspect($this->projectRoot); + } + + /** + * @return list + */ + private function findingIds(AuditReport $report): array + { + return array_map( + static fn(AuditFinding $finding): string => $finding->identifier(), + $report->findings(), + ); + } + + private function createProjectRoot(): string + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evolvephp-audit-runner-test-' . bin2hex(random_bytes(8)); + + self::assertTrue(mkdir($path, 0777, true)); + + return realpath($path) ?: $path; + } + + private function removeDirectory(string $path): void + { + $real = realpath($path); + + if ($real === false) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $entry) { + $entryPath = $entry->getPathname(); + + if ($entry->isDir() && ! $entry->isLink()) { + rmdir($entryPath); + continue; + } + + unlink($entryPath); + } + + rmdir($real); + } +} + +final readonly class FixedInspector implements AuditInspector +{ + /** + * @var list + */ + private array $findings; + + public function __construct(AuditFinding ...$findings) + { + $this->findings = $findings; + } + + public function inspect(string $projectRoot): mixed + { + return $this->findings; + } +} + +final readonly class ExplodingInspector implements AuditInspector +{ + public function inspect(string $projectRoot): mixed + { + throw new \RuntimeException('Inspector should not run.'); + } +} + +final readonly class InvalidOutputInspector implements AuditInspector +{ + public function inspect(string $projectRoot): mixed + { + return 'not-iterable'; + } +} + +final readonly class InvalidIterableOutputInspector implements AuditInspector +{ + public function inspect(string $projectRoot): mixed + { + return [ + new AuditFinding('valid', AuditSeverity::Info, 'Valid.', []), + 'not-a-finding', + ]; + } +} diff --git a/packages/dev-tools/tests/Unit/Audit/Project/ComposerProjectInspectorTest.php b/packages/dev-tools/tests/Unit/Audit/Project/ComposerProjectInspectorTest.php new file mode 100644 index 0000000..425f4cc --- /dev/null +++ b/packages/dev-tools/tests/Unit/Audit/Project/ComposerProjectInspectorTest.php @@ -0,0 +1,584 @@ +projectRoot = $this->createProjectRoot(); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectRoot); + } + + public function testValidRootManifestReportsDirectEvidence(): void + { + $this->writeComposerJson([ + 'require' => [ + 'vendor/zeta' => '^2.0', + 'php' => '^8.1 || ^8.2', + 'vendor/alpha' => '^1.0', + ], + 'require-dev' => [ + 'phpunit/phpunit' => '^13.0', + 'mockery/mockery' => '^2.0', + ], + ]); + + $findings = $this->inspect(); + + $this->assertFinding($findings, 'composer_json.present', AuditSeverity::Info, [ + 'path' => 'composer.json', + ]); + $this->assertFinding($findings, 'composer.php_constraint', AuditSeverity::Info, [ + 'present' => true, + 'constraint' => '^8.1 || ^8.2', + 'claim' => 'raw composer constraint only', + ]); + $this->assertFinding($findings, 'composer.dependencies.runtime', AuditSeverity::Info, [ + 'scope' => 'runtime', + 'state' => 'valid', + 'complete' => true, + 'dependencies' => [ + ['name' => 'vendor/alpha', 'constraint' => '^1.0'], + ['name' => 'vendor/zeta', 'constraint' => '^2.0'], + ], + 'sort' => 'package name ascending', + ]); + $this->assertFinding($findings, 'composer.dependencies.development', AuditSeverity::Info, [ + 'scope' => 'development', + 'state' => 'valid', + 'complete' => true, + 'dependencies' => [ + ['name' => 'mockery/mockery', 'constraint' => '^2.0'], + ['name' => 'phpunit/phpunit', 'constraint' => '^13.0'], + ], + 'sort' => 'package name ascending', + ]); + } + + public function testMissingPhpConstraintReportsNeutralEvidence(): void + { + $this->writeComposerJson(['require' => ['vendor/package' => '^1.0']]); + + $this->assertFinding($this->inspect(), 'composer.php_constraint', AuditSeverity::Info, [ + 'present' => false, + 'constraint' => null, + 'claim' => 'no direct root PHP constraint found', + ]); + } + + public function testFrameworkEvidenceIsDetectedFromDirectPackages(): void + { + $this->writeComposerJson([ + 'require' => [ + 'symfony/framework-bundle' => '^7.0', + 'laravel/framework' => '^11.0', + 'evolvephp/core' => '^2.0', + 'cakephp/cakephp' => '^5.0', + 'yiisoft/yii2' => '^2.0', + 'vendor/package' => '^1.0', + ], + 'require-dev' => [ + 'symfony/symfony' => '^7.0', + 'evolvephp/dev-tools' => '^2.0', + ], + ]); + + $this->assertFinding($this->inspect(), 'composer.frameworks', AuditSeverity::Warning, [ + 'dependency_state' => [ + 'runtime' => 'valid', + 'development' => 'valid', + ], + 'complete' => true, + 'frameworks' => [ + ['family' => 'cakephp', 'package' => 'cakephp/cakephp', 'scope' => 'runtime', 'constraint' => '^5.0'], + ['family' => 'evolvephp', 'package' => 'evolvephp/core', 'scope' => 'runtime', 'constraint' => '^2.0'], + ['family' => 'evolvephp', 'package' => 'evolvephp/dev-tools', 'scope' => 'development', 'constraint' => '^2.0'], + ['family' => 'laravel', 'package' => 'laravel/framework', 'scope' => 'runtime', 'constraint' => '^11.0'], + ['family' => 'symfony', 'package' => 'symfony/framework-bundle', 'scope' => 'runtime', 'constraint' => '^7.0'], + ['family' => 'symfony', 'package' => 'symfony/symfony', 'scope' => 'development', 'constraint' => '^7.0'], + ['family' => 'yii', 'package' => 'yiisoft/yii2', 'scope' => 'runtime', 'constraint' => '^2.0'], + ], + 'claim' => 'direct composer package evidence only', + ]); + } + + public function testNoFrameworkEvidenceDoesNotClaimCompatibility(): void + { + $this->writeComposerJson(['require' => ['vendor/package' => '^1.0']]); + + $this->assertFinding($this->inspect(), 'composer.frameworks', AuditSeverity::Info, [ + 'dependency_state' => [ + 'runtime' => 'valid', + 'development' => 'absent', + ], + 'complete' => true, + 'frameworks' => [], + 'claim' => 'no direct supported framework package evidence found', + ]); + } + + public function testPlatformOverrideIsCompatibilityReviewEvidenceOnly(): void + { + $this->writeComposerJson([ + 'require' => ['php' => '^8.4'], + 'config' => ['platform' => ['php' => '8.1.99']], + ]); + + $this->assertFinding($this->inspect(), 'composer.platform_php', AuditSeverity::Warning, [ + 'present' => true, + 'value' => '8.1.99', + 'claim' => 'Composer platform emulation is not proof of the actual runtime PHP version', + ]); + } + + public function testMissingManifestReportsRiskFinding(): void + { + $this->assertFinding($this->inspect(), 'composer_json.unavailable', AuditSeverity::Risk, [ + 'path' => 'composer.json', + 'reason' => 'missing or unreadable', + ]); + } + + public function testInvalidJsonReportsRiskFinding(): void + { + file_put_contents($this->path('composer.json'), '{"require":'); + + $finding = $this->finding($this->inspect(), 'composer_json.invalid_json'); + + self::assertSame(AuditSeverity::Risk, $finding->severity()); + self::assertSame('composer.json is not valid JSON.', $finding->message()); + self::assertSame('composer.json', $finding->evidence()['path']); + self::assertArrayHasKey('error', $finding->evidence()); + } + + public function testNonObjectJsonRootReportsRiskFinding(): void + { + file_put_contents($this->path('composer.json'), '[]'); + + $this->assertFinding($this->inspect(), 'composer_json.root_not_object', AuditSeverity::Risk, [ + 'path' => 'composer.json', + 'actual' => 'list', + ]); + } + + public function testMalformedRequireReportsRiskFinding(): void + { + $this->writeComposerJson(['require' => 'not-an-object']); + + $this->assertFinding($this->inspect(), 'composer.require.malformed', AuditSeverity::Risk, [ + 'field' => 'require', + 'actual' => 'string', + ]); + } + + public function testMalformedRequireDoesNotProduceKnownEmptyRuntimeDependencyEvidence(): void + { + $this->writeComposerJson(['require' => 'not-an-object']); + + $this->assertFinding($this->inspect(), 'composer.dependencies.runtime', AuditSeverity::Warning, [ + 'scope' => 'runtime', + 'state' => 'unknown', + 'complete' => false, + 'dependencies' => null, + 'sort' => 'package name ascending', + ]); + } + + public function testMalformedRequireDoesNotProduceCompleteNoFrameworkEvidence(): void + { + $this->writeComposerJson(['require' => 'not-an-object']); + + $this->assertFinding($this->inspect(), 'composer.frameworks', AuditSeverity::Warning, [ + 'dependency_state' => [ + 'runtime' => 'unknown', + 'development' => 'absent', + ], + 'complete' => false, + 'frameworks' => [], + 'claim' => 'framework evidence incomplete because dependency evidence is partial or unknown', + ]); + } + + public function testMalformedRequireDoesNotClaimPhpConstraintIsAbsent(): void + { + $this->writeComposerJson(['require' => 'not-an-object']); + + self::assertSame([ + 'composer_json.present', + 'composer.require.malformed', + 'composer.dependencies.runtime', + 'composer.dependencies.development', + 'composer.frameworks', + ], $this->findingIdentifiers($this->inspect())); + } + + public function testMalformedRequireDevReportsRiskFinding(): void + { + $this->writeComposerJson(['require-dev' => ['valid/package' => '^1.0', 'bad/package' => false]]); + + $this->assertFinding($this->inspect(), 'composer.require_dev.malformed', AuditSeverity::Risk, [ + 'field' => 'require-dev', + 'actual' => 'object with non-string package constraints', + ]); + } + + public function testMalformedRequireDevDoesNotProduceKnownEmptyDevelopmentDependencyEvidence(): void + { + $this->writeComposerJson(['require-dev' => 'not-an-object']); + + $this->assertFinding($this->inspect(), 'composer.dependencies.development', AuditSeverity::Warning, [ + 'scope' => 'development', + 'state' => 'unknown', + 'complete' => false, + 'dependencies' => null, + 'sort' => 'package name ascending', + ]); + } + + public function testPartiallyMalformedRequireReportsValidDependenciesAsIncomplete(): void + { + $this->writeComposerJson([ + 'require' => [ + 'vendor/valid' => '^1.0', + 'vendor/malformed' => false, + ], + ]); + + $this->assertFinding($this->inspect(), 'composer.dependencies.runtime', AuditSeverity::Warning, [ + 'scope' => 'runtime', + 'state' => 'partial', + 'complete' => false, + 'dependencies' => [ + ['name' => 'vendor/valid', 'constraint' => '^1.0'], + ], + 'sort' => 'package name ascending', + ]); + } + + public function testMalformedFrameworkConstraintPreservesPackageNameEvidence(): void + { + $this->writeComposerJson([ + 'require' => [ + 'laravel/framework' => false, + 'vendor/valid' => '^1.0', + ], + ]); + + $this->assertFinding($this->inspect(), 'composer.frameworks', AuditSeverity::Warning, [ + 'dependency_state' => [ + 'runtime' => 'partial', + 'development' => 'absent', + ], + 'complete' => false, + 'frameworks' => [ + [ + 'family' => 'laravel', + 'package' => 'laravel/framework', + 'scope' => 'runtime', + 'constraint' => null, + 'constraint_state' => 'malformed', + ], + ], + 'claim' => 'direct composer package evidence only', + ]); + } + + public function testMalformedPhpRequirementReportsRiskFinding(): void + { + $this->writeComposerJson(['require' => ['php' => []]]); + + $this->assertFinding($this->inspect(), 'composer.require_php.malformed', AuditSeverity::Risk, [ + 'field' => 'require.php', + 'actual' => 'array', + ]); + } + + public function testMalformedPhpRequirementDoesNotClaimPhpConstraintIsAbsent(): void + { + $this->writeComposerJson(['require' => ['php' => [], 'vendor/package' => '^1.0']]); + + self::assertSame([ + 'composer_json.present', + 'composer.require_php.malformed', + 'composer.dependencies.runtime', + 'composer.dependencies.development', + 'composer.frameworks', + ], $this->findingIdentifiers($this->inspect())); + } + + public function testMalformedPlatformOverrideReportsRiskFinding(): void + { + $this->writeComposerJson(['config' => ['platform' => ['php' => 80400]]]); + + $this->assertFinding($this->inspect(), 'composer.config_platform_php.malformed', AuditSeverity::Risk, [ + 'field' => 'config.platform.php', + 'actual' => 'integer', + ]); + } + + public function testTargetAutoloadComposerScriptsAndPhpFilesAreNotExecuted(): void + { + mkdir($this->path('vendor'), 0777, true); + mkdir($this->path('bootstrap'), 0777, true); + + file_put_contents($this->path('vendor/autoload.php'), "path('bootstrap/trap.php'), "writeComposerJson([ + 'autoload' => ['files' => ['bootstrap/trap.php']], + 'scripts' => ['post-install-cmd' => ['@php -r "file_put_contents(\'script-marker\', \'ran\');"']], + ]); + + $before = $this->targetInventory(); + $this->inspect(); + $after = $this->targetInventory(); + + self::assertFileDoesNotExist($this->path('autoload-marker')); + self::assertFileDoesNotExist($this->path('autoload-files-marker')); + self::assertFileDoesNotExist($this->path('script-marker')); + self::assertSame($before, $after); + } + + public function testEquivalentPropertyOrderingYieldsEquivalentOutput(): void + { + $firstRoot = $this->createProjectRoot(); + $secondRoot = $this->createProjectRoot(); + + try { + file_put_contents($firstRoot . DIRECTORY_SEPARATOR . 'composer.json', json_encode([ + 'require' => ['vendor/b' => '^2.0', 'php' => '^8.4', 'vendor/a' => '^1.0'], + 'require-dev' => ['tool/b' => '^2.0', 'tool/a' => '^1.0'], + ], JSON_THROW_ON_ERROR)); + file_put_contents($secondRoot . DIRECTORY_SEPARATOR . 'composer.json', json_encode([ + 'require-dev' => ['tool/a' => '^1.0', 'tool/b' => '^2.0'], + 'require' => ['vendor/a' => '^1.0', 'vendor/b' => '^2.0', 'php' => '^8.4'], + ], JSON_THROW_ON_ERROR)); + + $inspector = new ComposerProjectInspector(); + + self::assertSame( + $this->serializeFindings($inspector->inspect($firstRoot)), + $this->serializeFindings($inspector->inspect($secondRoot)), + ); + } finally { + $this->removeDirectory($firstRoot); + $this->removeDirectory($secondRoot); + } + } + + public function testEquivalentMalformedRequireOrderingYieldsEquivalentOutput(): void + { + $firstRoot = $this->createProjectRoot(); + $secondRoot = $this->createProjectRoot(); + + try { + file_put_contents($firstRoot . DIRECTORY_SEPARATOR . 'composer.json', json_encode([ + 'require' => [ + 'vendor/valid' => '^1.0', + 'php' => [], + 'vendor/malformed' => false, + ], + ], JSON_THROW_ON_ERROR)); + file_put_contents($secondRoot . DIRECTORY_SEPARATOR . 'composer.json', json_encode([ + 'require' => [ + 'vendor/malformed' => false, + 'php' => [], + 'vendor/valid' => '^1.0', + ], + ], JSON_THROW_ON_ERROR)); + + $inspector = new ComposerProjectInspector(); + + self::assertSame( + $this->serializeFindings($inspector->inspect($firstRoot)), + $this->serializeFindings($inspector->inspect($secondRoot)), + ); + } finally { + $this->removeDirectory($firstRoot); + $this->removeDirectory($secondRoot); + } + } + + public function testEquivalentPartiallyMalformedFrameworkOrderingYieldsEquivalentOutput(): void + { + $firstRoot = $this->createProjectRoot(); + $secondRoot = $this->createProjectRoot(); + + try { + file_put_contents($firstRoot . DIRECTORY_SEPARATOR . 'composer.json', json_encode([ + 'require' => [ + 'vendor/valid' => '^1.0', + 'laravel/framework' => false, + 'php' => '^8.4', + ], + ], JSON_THROW_ON_ERROR)); + file_put_contents($secondRoot . DIRECTORY_SEPARATOR . 'composer.json', json_encode([ + 'require' => [ + 'php' => '^8.4', + 'laravel/framework' => false, + 'vendor/valid' => '^1.0', + ], + ], JSON_THROW_ON_ERROR)); + + $inspector = new ComposerProjectInspector(); + + self::assertSame( + $this->serializeFindings($inspector->inspect($firstRoot)), + $this->serializeFindings($inspector->inspect($secondRoot)), + ); + } finally { + $this->removeDirectory($firstRoot); + $this->removeDirectory($secondRoot); + } + } + + /** + * @param array $manifest + */ + private function writeComposerJson(array $manifest): void + { + file_put_contents($this->path('composer.json'), json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + } + + /** + * @return list + */ + private function inspect(): array + { + return (new ComposerProjectInspector())->inspect($this->projectRoot); + } + + /** + * @param list $findings + * @param array $evidence + */ + private function assertFinding(array $findings, string $identifier, AuditSeverity $severity, array $evidence): void + { + $finding = $this->finding($findings, $identifier); + + self::assertSame($severity, $finding->severity()); + self::assertSame($evidence, $finding->evidence()); + } + + /** + * @param list $findings + */ + private function finding(array $findings, string $identifier): AuditFinding + { + foreach ($findings as $finding) { + if ($finding->identifier() === $identifier) { + return $finding; + } + } + + self::fail('Missing finding: ' . $identifier); + } + + /** + * @param list $findings + * @return list + */ + private function findingIdentifiers(array $findings): array + { + return array_map( + static fn(AuditFinding $finding): string => $finding->identifier(), + $findings, + ); + } + + /** + * @param list $findings + * @return list}> + */ + private function serializeFindings(array $findings): array + { + return array_map( + static fn(AuditFinding $finding): array => [ + 'identifier' => $finding->identifier(), + 'severity' => $finding->severity()->value, + 'message' => $finding->message(), + 'evidence' => $finding->evidence(), + ], + $findings, + ); + } + + /** + * @return array + */ + private function targetInventory(): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->projectRoot, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($this->projectRoot) + 1)); + $files[$relativePath] = [ + 'size' => $file->getSize(), + 'hash' => hash_file('sha256', $file->getPathname()), + ]; + } + } + + ksort($files); + + return $files; + } + + private function path(string $relativePath): string + { + return $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + } + + private function createProjectRoot(): string + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evolvephp-composer-audit-test-' . bin2hex(random_bytes(8)); + + self::assertTrue(mkdir($path, 0777, true)); + + return realpath($path) ?: $path; + } + + private function removeDirectory(string $path): void + { + $real = realpath($path); + + if ($real === false) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $entry) { + $entryPath = $entry->getPathname(); + + if ($entry->isDir() && ! $entry->isLink()) { + rmdir($entryPath); + continue; + } + + unlink($entryPath); + } + + rmdir($real); + } +} diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index 3ddc975..95f7b33 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -884,6 +884,12 @@ private function acceptedPackageSourceInventories() 'Lifecycle/ApplicationState.php', ), 'packages/dev-tools/src' => array( + 'Audit/AuditFinding.php', + 'Audit/AuditInspector.php', + 'Audit/AuditReport.php', + 'Audit/AuditRunner.php', + 'Audit/AuditSeverity.php', + 'Audit/Project/ComposerProjectInspector.php', 'Console/ComponentScaffoldGenerator.php', 'Console/ModuleNewCommand.php', 'Console/PluginNewCommand.php',