diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..694d8a1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Package licences intentionally mirror root LICENSE.md byte-for-byte. +packages/*/LICENSE.md -whitespace diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 0edc70f..2fefb7e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -44,6 +44,9 @@ jobs: - name: Run release package split validation run: composer release:split:validate + - name: Run application skeleton create-project validation + run: composer release:skeleton:validate + - name: Run root supply-chain checks run: composer supply-chain diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 2f262e7..c2cbf06 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -8,6 +8,8 @@ 'packages/contracts/tests', 'packages/core/src', 'packages/core/tests', + 'packages/dev-tools/src', + 'packages/dev-tools/tests', 'packages/http/src', 'packages/http/tests', 'packages/module/src', @@ -16,6 +18,8 @@ 'packages/plugin/tests', 'packages/testing/src', 'packages/testing/tests', + 'skeleton/bootstrap', + 'skeleton/config', ]); return (new PhpCsFixer\Config()) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6719482..665ec23 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,7 +2,7 @@ The repository root is the canonical EvolvePHP 2 Composer development root for the modular monorepo. -The root resolves local packages, owns development tooling and runs EvolvePHP 2 package quality checks. It is not a seventh release package, a publishable framework package, an application skeleton, runtime framework code, a Composer plugin or a production deployment artifact. +The root resolves local packages, owns development tooling and runs EvolvePHP 2 package quality checks. It is not a release package, a publishable framework package, an application skeleton, runtime framework code, a Composer plugin or a production deployment artifact. The preserved EvolvePHP 1 runtime and the former legacy root suite remain preserved on `master` and in Git history. The EvolvePHP 2 root suite now runs from this repository root. @@ -28,6 +28,7 @@ The root maps each initial package explicitly to `2.0.x-dev` inside the path rep - `evolvephp/contracts` - `evolvephp/core` +- `evolvephp/dev-tools` - `evolvephp/http` - `evolvephp/module` - `evolvephp/plugin` @@ -78,6 +79,7 @@ Run individual package suites: ```bash composer test:contracts composer test:core +composer test:dev-tools composer test:http composer test:module composer test:plugin @@ -116,6 +118,16 @@ composer quality `quality` runs `architecture`, `analyse`, `style:check` and `test`, in that order. `style:fix` remains separate because it is mutating. +## Application Skeleton + +The `skeleton/` directory is the initial end-user application template for `evolvephp/skeleton`. It is separate from the framework monorepo root, and the root remains the EvolvePHP development workspace rather than an application project. + +The skeleton uses `App\ => src/` as its application namespace convention. The application CLI composition is explicit: `skeleton/bin/evolve` loads the generated project's Composer autoloader, then uses the public experimental Core runtime APIs `CliApplication` and `StreamCommandOutput` with `CommandRegistry`, `CommandRunner`, `ServiceRegistry` and `ExecutionOrchestrator`. Core remains independent of HTTP, while the application-owned shell explicitly registers the Core Doctor command and the HTTP `route:list` adapter. + +The initial route configuration is explicit and empty through `skeleton/config/routes.php`, which returns a `RouteCollection`. There is no route discovery, filesystem scanning, application boot magic or automatic command discovery in this skeleton slice. Doctor uses the current accepted runtime and Composer-extension checks only: `PhpVersionCheck` and `ComposerRequiredExtensionsCheck` against the generated application's own `composer.json`. + +The skeleton installs `evolvephp/dev-tools`, `evolvephp/testing` and PHPUnit as development dependencies. `config/commands.php` registers `module:new` and `plugin:new` only when the DevTools classes exist, so a production `composer install --no-dev` keeps `doctor` and `route:list` available without requiring DevTools. The generator commands derive all output paths from the application root and a single ASCII StudlyCase name token; they do not edit Composer manifests, execute generated PHP, run Composer, run Git, auto-enable generated components or perform automatic discovery. + ## Release Validation Run deterministic/offline package release-readiness validation: @@ -124,7 +136,7 @@ Run deterministic/offline package release-readiness validation: composer release:validate ``` -Phase 2.10A keeps the six packages mapped explicitly in `release-packages.json`. The processing order is dependency-compatible: contracts, core, module, plugin, http and testing. Package-local README and licence files exist so future split roots carry consumer documentation and legal text naturally. Package-local licences must remain identical to root `LICENSE.md`. +Phase 2.10A keeps the release packages mapped explicitly in `release-packages.json`. Phase 6.4E extends that dependency-compatible map to seven packages by appending DevTools after the existing six-package order: contracts, core, module, plugin, http, testing and dev-tools. Package-local README and licence files exist so future split roots carry consumer documentation and legal text naturally. Package-local licences must remain identical to root `LICENSE.md`. No package is being published by this command. No remote repositories are contacted, no tags/releases are created, and no split repositories are synchronized. Package Composer manifests remain authoritative for package metadata. @@ -150,6 +162,18 @@ composer release:consumer:validate `release:consumer:validate` creates temporary local VCS package repositories from generated split roots, creates disposable alpha/stable tags only, disables Packagist, disables Composer network access, validates expected success and expected failure cases, and uses disposable lockfiles. It is not currently a required CI step and is intended for pre-release/manual validation. +### Application Skeleton Create-Project Validation + +Run the local prerelease application skeleton create-project validation: + +```bash +composer release:skeleton:validate +``` + +`release:skeleton:validate` exercises the real Composer `create-project` command for `evolvephp/skeleton` using repository-injected local package evidence. It creates validator-owned temporary paths, disables Packagist, sets `COMPOSER_DISABLE_NETWORK=1`, installs into a previously absent generated-project directory, validates the generated Composer manifest, confirms first-party packages are copied rather than symlinked to the source monorepo, runs `php bin/evolve doctor`, verifies `php bin/evolve route:list` emits `No routes are configured.`, generates `module:new Billing` and `plugin:new Cache` in the development install, verifies the generated application tests pass, verifies repeat generation refuses overwrites, verifies invalid/path-traversal names create nothing outside the application, verifies a `composer install --no-dev` production install still supports `doctor` and `route:list`, verifies Core missing-command and unknown-command usage errors, and confirms source repository state is preserved. + +Public Packagist create-project availability is not yet claimed. Do not document `composer create-project evolvephp/skeleton ...` as generally available until publication is explicitly opened. + For an EvolvePHP 2 alpha consumer, the recommended root consumer settings are: ```json @@ -163,9 +187,9 @@ These are root consumer settings. First-party package manifests must not add alp In prose: set `minimum-stability: alpha` and `prefer-stable: true` only in the root alpha consumer. -No package is published by `release:validate`, `release:split:validate` or `release:consumer:validate`. Remote package repositories, remote synchronization, Packagist registration, tags and releases remain deferred. +No package is published by `release:validate`, `release:split:validate`, `release:consumer:validate` or `release:skeleton:validate`. Remote package repositories, remote synchronization, Packagist registration, tags and releases remain deferred. -`release:validate` remains metadata/package-boundary validation. `release:split:validate` validates generated split history/root content. `release:consumer:validate` validates package-resolution semantics. `supply-chain` remains network-dependent security/licence validation. `quality` remains ordinary root quality. +`release:validate` remains metadata/package-boundary validation. `release:split:validate` validates generated split history/root content. `release:consumer:validate` validates package-resolution semantics. `release:skeleton:validate` validates local prerelease create-project behavior. `supply-chain` remains network-dependent security/licence validation. `quality` remains ordinary root quality. ## Supply-Chain Security @@ -251,6 +275,7 @@ It bootstraps through `vendor/autoload.php` and defines one named suite for each | --- | --- | | `contracts` | `packages/contracts/tests` | | `core` | `packages/core/tests` | +| `dev-tools` | `packages/dev-tools/tests` | | `http` | `packages/http/tests` | | `module` | `packages/module/tests` | | `plugin` | `packages/plugin/tests` | @@ -268,13 +293,15 @@ The distributable PHPStan configuration lives at: phpstan.neon.dist ``` -The initial PHPStan level is `6`. PHPStan analyzes all six package `src` and `tests` directories: +The initial PHPStan level is `6`. PHPStan analyzes all seven package `src` and `tests` directories: ```text packages/contracts/src packages/contracts/tests packages/core/src packages/core/tests +packages/dev-tools/src +packages/dev-tools/tests packages/http/src packages/http/tests packages/module/src @@ -304,6 +331,7 @@ Deptrac analyzes production source directories only: ```text packages/contracts/src packages/core/src +packages/dev-tools/src packages/http/src packages/module/src packages/plugin/src @@ -315,6 +343,7 @@ Package tests are excluded from Phase 2.5 Deptrac boundary analysis so test depe ```text Contracts -> packages/contracts/src/.* -> Evolve\Contracts\ Core -> packages/core/src/.* -> Evolve\Core\ +DevTools -> packages/dev-tools/src/.* -> Evolve\DevTools\ Http -> packages/http/src/.* -> Evolve\Http\ Module -> packages/module/src/.* -> Evolve\Module\ Plugin -> packages/plugin/src/.* -> Evolve\Plugin\ @@ -326,13 +355,14 @@ The accepted dependency matrix is: ```text Contracts -> none Core -> Contracts +DevTools -> Contracts, Core, Module, Plugin Http -> Contracts, Core Module -> Contracts Plugin -> Contracts Testing -> Contracts, Core, Http, Module, Plugin ``` -There is no production dependency on Testing. Testing may depend on all five production packages. +There is no production dependency on Testing. DevTools is development tooling and may depend on Contracts, Core, Module and Plugin. Testing may depend on all five production packages. The root also models deliberate external standard layers: @@ -364,7 +394,7 @@ PHP-CS-Fixer is the root coding-standard engine. The distributable configuration The project style is based on PHP-FIG PER Coding Style 3.0 through PHP-CS-Fixer's `@PER-CS3x0` rule set. The floating `@PER-CS` alias is not used. The project explicitly enables alphabetical `ordered_imports` and `no_unused_imports`. -PHP-CS-Fixer checks only the six package `src` and `tests` directories. The root architecture tests, root documentation tests, RFCs, `vendor/` and generated caches are excluded. +PHP-CS-Fixer checks the seven package `src` and `tests` directories plus the committed skeleton PHP config/bootstrap files. The extensionless skeleton executable is protected by syntax and create-project validation rather than distorting the Finder. The root architecture tests, root documentation tests, RFCs, `vendor/` and generated caches are excluded. Risky rules are disabled. The `declare_strict_types` fixer is not enabled; strict-types policy for EvolvePHP 2 package PHP files is enforced by architecture tests. @@ -388,7 +418,7 @@ It runs for pull requests targeting `2.x`, pushes to `2.x` and manual dispatch. All jobs run on the explicit Ubuntu 24.04 runner, using the `ubuntu-24.04` label. The workflow has no initial dependency cache. -The policy job runs on PHP 8.4. The `Policy (PHP 8.4)` job validates the root Composer manifest and lockfile before installation, installs root dependencies from the committed lockfile with `composer install`, runs package split validation, runs supply-chain checks and runs the root Architecture and Documentation policy tests through root PHPUnit 13: +The policy job runs on PHP 8.4. The `Policy (PHP 8.4)` job validates the root Composer manifest and lockfile before installation, installs root dependencies from the committed lockfile with `composer install`, runs package split validation, runs skeleton create-project validation, runs supply-chain checks and runs the root Architecture and Documentation policy tests through root PHPUnit 13: ```bash php vendor/bin/phpunit --configuration phpunit.xml.dist tests/Architecture tests/Documentation @@ -428,3 +458,5 @@ The following work remains deferred: - package publication, tags and GitHub releases - Runtime framework implementation beyond the completed Phase 4 HTTP package foundation - Phase 5 module/plugin runtime work +- broader developer tooling beyond `module:new` and `plugin:new` +- Phase 6.4F broader Testing utilities beyond the command-output recorder diff --git a/README.md b/README.md index 39f7be3..58ed10e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ EvolvePHP 2 is a separate redesign, not an in-place refactor, replacement or rewrite of the EvolvePHP 1 runtime history. -Phase 4.7 promoted the EvolvePHP 2 Composer, PHPUnit, PHPStan, PHP-CS-Fixer, Deptrac, release-validation and supply-chain development root from `workspace/` to the repository root. The root project is not a seventh release package. That root cutover does not delete `master`, does not delete history and does not change the preserved EvolvePHP 1 line. +Phase 4.7 promoted the EvolvePHP 2 Composer, PHPUnit, PHPStan, PHP-CS-Fixer, Deptrac, release-validation and supply-chain development root from `workspace/` to the repository root. The root project is not a release package. That root cutover does not delete `master`, does not delete history and does not change the preserved EvolvePHP 1 line. ## Project Overview diff --git a/composer.json b/composer.json index 115acf0..45a5fc7 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "versions": { "evolvephp/contracts": "2.0.x-dev", "evolvephp/core": "2.0.x-dev", + "evolvephp/dev-tools": "2.0.x-dev", "evolvephp/http": "2.0.x-dev", "evolvephp/module": "2.0.x-dev", "evolvephp/plugin": "2.0.x-dev", @@ -52,6 +53,7 @@ }, "require-dev": { "deptrac/deptrac": "^4.7", + "evolvephp/dev-tools": "^2.0@dev", "evolvephp/testing": "^2.0@dev", "friendsofphp/php-cs-fixer": "^3.95", "phpstan/phpstan": "^2.2", @@ -69,6 +71,7 @@ "@test" ], "release:consumer:validate": "@php tools/validate-prerelease-consumers.php", + "release:skeleton:validate": "@php tools/validate-skeleton-project.php", "release:split:validate": "@php tools/validate-package-splits.php", "release:validate": "@php tools/validate-release-packages.php", "security:audit": "@composer audit --locked --abandoned=fail", @@ -81,6 +84,7 @@ "test": "@php vendor/bin/phpunit --configuration phpunit.xml.dist", "test:contracts": "@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite contracts", "test:core": "@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite core", + "test:dev-tools": "@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite dev-tools", "test:http": "@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite http", "test:module": "@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite module", "test:plugin": "@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite plugin", diff --git a/composer.lock b/composer.lock index e391ba4..7ee5089 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9442ed6c5c8830854a00e08fe412fc14", + "content-hash": "e07fe7a44d4ac0e967376da7aeb47b38", "packages": [ { "name": "evolvephp/contracts", @@ -12,7 +12,7 @@ "dist": { "type": "path", "url": "packages/contracts", - "reference": "b0e2168f6d6a4fd7b8351f2b27fbb2592742488b" + "reference": "c82982b8fa82f0c8028daa158a03393b1073d727" }, "require": { "php": "^8.4", @@ -38,7 +38,7 @@ "dist": { "type": "path", "url": "packages/core", - "reference": "75ecd3a61fcdfa62cfa168fbfce965709f455973" + "reference": "bf25bc1d02d1fe9eec98ddcec991a5e62e24a804" }, "require": { "evolvephp/contracts": "^2.0", @@ -71,7 +71,7 @@ "dist": { "type": "path", "url": "packages/http", - "reference": "64b9fa40a594b29def52fb1d030d1f03267fe2c9" + "reference": "5408f617b399ebb5b18e8eebd1960f2002bdd255" }, "require": { "evolvephp/contracts": "^2.0", @@ -102,7 +102,7 @@ "dist": { "type": "path", "url": "packages/module", - "reference": "49dbb11bbaf7b3055f96e1088bb7b6426456fc08" + "reference": "07aa67ad9e14920771aa6fcb193bfa0db2e0652c" }, "require": { "evolvephp/contracts": "^2.0", @@ -128,7 +128,7 @@ "dist": { "type": "path", "url": "packages/plugin", - "reference": "37635d4cc25735038fc80f144af7e8ebb9ae8ad2" + "reference": "c4878830ce15932ae492e776e560bf60806db784" }, "require": { "evolvephp/contracts": "^2.0", @@ -956,13 +956,42 @@ }, "time": "2023-08-08T05:53:35+00:00" }, + { + "name": "evolvephp/dev-tools", + "version": "2.0.x-dev", + "dist": { + "type": "path", + "url": "packages/dev-tools", + "reference": "4326d7659e1a360543196587c93a958a6dc91282" + }, + "require": { + "evolvephp/contracts": "^2.0", + "evolvephp/core": "^2.0", + "evolvephp/module": "^2.0", + "evolvephp/plugin": "^2.0", + "php": "^8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evolve\\DevTools\\": "src/" + } + }, + "license": [ + "BSD-3-Clause" + ], + "description": "Development-time generators and tooling for EvolvePHP 2 applications.", + "transport-options": { + "relative": true + } + }, { "name": "evolvephp/testing", "version": "2.0.x-dev", "dist": { "type": "path", "url": "packages/testing", - "reference": "b80336f2ede6a0f9b6a932430c461b9b0d97fd0e" + "reference": "0c3fef62e713b38539a715fc3aaa06e4b841ef44" }, "require": { "evolvephp/contracts": "^2.0", @@ -5999,6 +6028,7 @@ "stability-flags": { "evolvephp/contracts": 20, "evolvephp/core": 20, + "evolvephp/dev-tools": 20, "evolvephp/http": 20, "evolvephp/module": 20, "evolvephp/plugin": 20, diff --git a/deptrac.php b/deptrac.php index b1374ac..5891bda 100644 --- a/deptrac.php +++ b/deptrac.php @@ -13,6 +13,7 @@ ->paths( 'packages/contracts/src', 'packages/core/src', + 'packages/dev-tools/src', 'packages/http/src', 'packages/module/src', 'packages/plugin/src', @@ -35,6 +36,9 @@ $core = Layer::withName('Core')->collectors( DirectoryConfig::create('packages/core/src/.*'), ), + $devTools = Layer::withName('DevTools')->collectors( + DirectoryConfig::create('packages/dev-tools/src/.*'), + ), $http = Layer::withName('Http')->collectors( DirectoryConfig::create('packages/http/src/.*'), ), @@ -54,6 +58,7 @@ Ruleset::forLayer($psrHttpMessage), Ruleset::forLayer($psrHttpServer), Ruleset::forLayer($core)->accesses($contracts, $psrContainer), + Ruleset::forLayer($devTools)->accesses($contracts, $core, $module, $plugin), Ruleset::forLayer($http)->accesses($contracts, $core, $psrHttpMessage, $psrHttpServer), Ruleset::forLayer($module)->accesses($contracts), Ruleset::forLayer($plugin)->accesses($contracts), diff --git a/packages/README.md b/packages/README.md index c49ed96..1a326fa 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, the first Phase 3 lifecycle, configuration, service-container, execution-scope, runtime-neutral execution orchestration, generic Core instrumentation and minimal Core console foundations, plus the Phase 4.1 PSR HTTP middleware foundation, Phase 4.2 routing foundation, Phase 4.3 routed handler dispatch foundation, Phase 4.4 HTTP execution-kernel integration, Phase 4.5 HTTP response/error and health foundation, Phase 4.6 `ResponseEmitter` runtime-neutral response-emission boundary and integration acceptance, Phase 5.1 Component Identity Foundation, the Phase 5.2 Module/Plugin immutable descriptor foundation, the Phase 5.3A dependency/capability graph declaration vocabulary, the Phase 5.3B Core-owned graph validation/resolution foundation, the Phase 5.4 restricted component service-definition registration foundation, the Phase 5.5 component lifecycle entry-point/orchestration foundation, the Phase 5.6A explicit component definition/bootstrap foundation, the Phase 5.6B Composer plugin discovery foundation and the Phase 5.7 Testing component fixture and independent acceptance foundation for EvolvePHP 2. Complete runtime implementation is not yet present for the framework, and the packages are not yet published. +The packages define Composer package identities, namespace ownership, dependency direction, the first Phase 3 lifecycle, configuration, service-container, execution-scope, runtime-neutral execution orchestration, generic Core instrumentation and minimal Core console foundations, plus the Phase 4.1 PSR HTTP middleware foundation, Phase 4.2 routing foundation, Phase 4.3 routed handler dispatch foundation, Phase 4.4 HTTP execution-kernel integration, Phase 4.5 HTTP response/error and health foundation, Phase 4.6 `ResponseEmitter` runtime-neutral response-emission boundary and integration acceptance, Phase 5.1 Component Identity Foundation, the Phase 5.2 Module/Plugin immutable descriptor foundation, the Phase 5.3A dependency/capability graph declaration vocabulary, the Phase 5.3B Core-owned graph validation/resolution foundation, the Phase 5.4 restricted component service-definition registration foundation, the Phase 5.5 component lifecycle entry-point/orchestration foundation, the Phase 5.6A explicit component definition/bootstrap foundation, the Phase 5.6B Composer plugin discovery foundation, the Phase 5.7 Testing component fixture and independent acceptance foundation, and the Phase 6.4E/6.4F development-time skeleton generator and command-output recorder for EvolvePHP 2. Complete runtime implementation is not yet present for the framework, and the packages are not yet published. All package manifests require PHP `^8.4`. @@ -12,10 +12,11 @@ All package manifests require PHP `^8.4`. | --- | --- | --- | | `evolvephp/contracts` | `Evolve\Contracts\` | Foundational public-contract boundary, including the initial application lifecycle, configuration, reset-participant and exception contracts plus the Phase 5.1 experimental shared identity vocabulary, Phase 5.3A graph declaration vocabulary, Phase 5.4 experimental `ServiceDefinitionRegistrar` contribution contract, Phase 5.5 experimental `ComponentEntryPoint` / `ComponentBootContext` lifecycle contracts and Phase 5.6A experimental `ComponentDefinition` bridge. | | `evolvephp/core` | `Evolve\Core\` | Core orchestration boundary, including the initial minimal application lifecycle kernel, array-backed configuration implementation, PSR-11-readable service container foundation, explicit execution scopes, runtime-neutral execution orchestration outcomes, generic execution-lifecycle observation hooks, the minimal runtime-neutral command foundation, the Phase 5.3B experimental `ComponentGraphResolver`, `ResolvedComponentGraph` and consumer-scoped `CapabilityProviderSelection` graph resolution APIs, the Phase 5.4 internal restricted registration coordinator, Phase 5.5 internal component lifecycle coordinator and Phase 5.6A 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/http` | `Evolve\Http\` | HTTP boundary with the Phase 4.1 PSR HTTP interoperability and `MiddlewarePipeline` foundation, Phase 4.2 route definitions and matching, Phase 4.3 routed handler dispatch with typed routing failures, Phase 4.4 `HttpKernel` integration with Core execution orchestration, Phase 4.5 response/error plus health foundation and Phase 4.6 explicit response-emitter boundary; runtime adapters remain deferred. | | `evolvephp/module` | `Evolve\Module\` | Module SDK boundary with the public experimental `ModuleDescriptor`, EvolvePHP-major compatibility validation foundation, Phase 5.3A `graphDeclaration()` projection, Phase 5.5 `Module` entry point extending `ComponentEntryPoint` and Phase 5.6A explicit `ModuleDefinition`; discovery remains deferred. | | `evolvephp/plugin` | `Evolve\Plugin\` | Plugin SDK boundary with the public experimental `PluginDescriptor`, EvolvePHP-major compatibility validation foundation, Phase 5.3A `graphDeclaration()` projection, Phase 5.5 `Plugin` entry point extending `ComponentEntryPoint`, Phase 5.6A explicit `PluginDefinition` and Phase 5.6B `ComposerPluginDiscovery` for packaged plugin metadata. | -| `evolvephp/testing` | `Evolve\Testing\` | Development-only testing-support boundary, including Phase 5.7 public experimental component fixtures and independent Phase 5 acceptance coverage. | +| `evolvephp/testing` | `Evolve\Testing\` | Development-only testing-support boundary, including Phase 5.7 public experimental component fixtures, Phase 6.4F public experimental command-output recorder and independent Phase 5 acceptance coverage. | ## Dependency Direction @@ -26,6 +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. - `testing` may depend on the five production packages for development support. There is no production dependency on Testing. @@ -80,6 +82,10 @@ Phase 5.6B adds packaged plugin metadata discovery inside `evolvephp/plugin`. Th Phase 5.7 adds the first concrete `evolvephp/testing` SDK surface for component tests. `ComponentDefinitionFixture` and `ComponentEntryPointFixture` are public experimental test fixtures that reuse the real component contracts while remaining simple closure-backed wrappers. The Testing package also carries an independent integration acceptance test covering explicit `ModuleDefinition`, `ComposerPluginDiscovery`, application-controlled enablement, Core `ComponentBootstrapper`, real dependency ordering, restricted registration, `ServiceRegistry` freeze, `ApplicationKernel` boot, component boot, ready and reverse shutdown. These fixtures do not replace Module or Plugin definitions, lifecycle orchestration, discovery, graph resolution, developer tooling, mocks or PHPUnit assertion APIs. -The Phase 4 HTTP package foundation is complete after this slice, but the complete production runtime is still deferred. EvolvePHP 2 does not yet provide complete HTTP runtime adapters, concrete runtime adapters, concrete PSR-7 implementation, SAPI response transmission, SAPI request creation, HTML or JSON error rendering, content negotiation, debug pages, automatic health routes, environment or dotenv loading, configuration files, queue or scheduled-job adapters, retry policy, process recycling or termination, module/plugin runtime, runtime CLI adapters, shell executables, argv or shell parsing, stdout/stderr stream implementations, Symfony Console integration, Doctor, generators, developer tooling commands, Insight, Observe, OpenTelemetry propagation, tracing, metrics, logs, telemetry storage/export, persistent-worker concurrency guarantees or production-ready framework runtime behavior. These capabilities remain deferred. Phase 5.6B does not implement descriptor serialization, component versions, dependency version ranges or Composer semantic-version constraint evaluation. Phase 5.7 does not add create-project tooling, generators, Doctor, environment inspection, route inspection or a general testing framework. Phase 6 owns developer tooling. +Phase 6.4E adds `evolvephp/dev-tools` with 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. + +Phase 6.4F adds `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. + +The Phase 4 HTTP package foundation is complete after this slice, but the complete production runtime is still deferred. EvolvePHP 2 does not yet provide complete HTTP runtime adapters, concrete runtime adapters, concrete PSR-7 implementation, SAPI response transmission, SAPI request creation, HTML or JSON error rendering, content negotiation, debug pages, automatic health routes, environment or dotenv loading, configuration files, queue or scheduled-job adapters, retry policy, process recycling or termination, full module/plugin runtime, runtime CLI adapters beyond the explicit skeleton shell, Symfony Console integration, broad Doctor checks, generator discovery, automatic component enablement, Insight, Observe, OpenTelemetry propagation, tracing, metrics, logs, telemetry storage/export, persistent-worker concurrency guarantees or production-ready framework runtime behavior. These capabilities remain deferred. Phase 5.6B does not implement descriptor serialization, component versions, dependency version ranges or Composer semantic-version constraint evaluation. Phase 6.4E does not add automatic discovery, application bootstrapping, route generation, Composer mutation or package publication. Phase 6.4F does not add a general testing framework. The EvolvePHP 2 repository root resolves and validates these local packages. See [../DEVELOPMENT.md](../DEVELOPMENT.md) for setup, testing, quality commands, lockfile, static-analysis, coding-standard and architecture-boundary policy. diff --git a/packages/core/README.md b/packages/core/README.md index ddf8eda..8fcb6fc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -53,7 +53,9 @@ Boot receives a frozen PSR-11 resolver through a restricted `ComponentBootContex Startup cleanup follows RFC 0004: if boot fails, the failing component's deferred boot failure cleanup runs LIFO, the failing component does not receive normal shutdown, previously booted components shut down in reverse order and the original boot throwable remains primary through `ComponentStartupFailed::getPrevious()`. If ready fails, every booted component, including the component whose ready callback failed, shuts down in reverse order and the original ready throwable remains primary. The coordinator prevents invalid, duplicate and reentrant lifecycle transitions and no per-execution component lifecycle work is introduced. -This slice provides no shell executable, runtime CLI adapter, argument or option parsing, stdout/stderr stream integration, Doctor, generators or developer commands; Doctor, generators and developer commands remain deferred. It also does not provide environment or dotenv loading, configuration files, autowiring, aliases, service tags, decorators, service-locator globals, HTTP handling, queue or scheduled-job adapters, retry policy, process termination or recycling, module/plugin runtime, telemetry products or integrations, streaming or persistent-worker concurrency guarantees. +The runtime CLI composition helpers `Evolve\Core\Console\Runtime\CliApplication` and `Evolve\Core\Console\Runtime\StreamCommandOutput` are public experimental APIs for explicit caller-owned shell composition. They preserve Core command execution behavior without introducing a service locator, command discovery, global application object or automatic application bootstrapping. The Core console foundation still does not provide a shell executable for general application composition. + +This slice provides no argument or option parsing, Doctor generators or developer commands. It also does not provide environment or dotenv loading, configuration files, autowiring, aliases, service tags, decorators, service-locator globals, HTTP handling, queue or scheduled-job adapters, retry policy, process termination or recycling, module/plugin runtime, telemetry products or integrations, streaming or persistent-worker concurrency guarantees. Phase 5.5 did not implement discovery, enablement, component instantiation, component versions, dependency version ranges, Composer semantic-version constraint evaluation or Module/Plugin runtime managers. Phase 5.6A adds explicit application-controlled enablement while discovery remains deferred. @@ -213,6 +215,12 @@ 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. + +Application skeleton CLI composition is separate from Core's package-owned +binary. The EvolvePHP skeleton explicitly composes the public experimental +`CliApplication` and `StreamCommandOutput` APIs with Core command primitives and +HTTP-owned command adapters. Core remains independent of HTTP; application-owned +shells decide which non-Core commands to register. ## Doctor project diagnostics Core includes caller-configured project diagnostic primitives in addition to the diff --git a/packages/core/src/Console/Runtime/CliApplication.php b/packages/core/src/Console/Runtime/CliApplication.php index 01ff26c..1ed9856 100644 --- a/packages/core/src/Console/Runtime/CliApplication.php +++ b/packages/core/src/Console/Runtime/CliApplication.php @@ -13,7 +13,7 @@ use LogicException; /** - * @internal + * @experimental */ final readonly class CliApplication { diff --git a/packages/core/src/Console/Runtime/StreamCommandOutput.php b/packages/core/src/Console/Runtime/StreamCommandOutput.php index 442debe..7ba0c40 100644 --- a/packages/core/src/Console/Runtime/StreamCommandOutput.php +++ b/packages/core/src/Console/Runtime/StreamCommandOutput.php @@ -9,7 +9,7 @@ use RuntimeException; /** - * @internal + * @experimental */ final readonly class StreamCommandOutput implements CommandOutput { diff --git a/packages/dev-tools/LICENSE.md b/packages/dev-tools/LICENSE.md new file mode 100644 index 0000000..334397d --- /dev/null +++ b/packages/dev-tools/LICENSE.md @@ -0,0 +1,17 @@ +Copyright 2020 EvolvePHP. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/dev-tools/README.md b/packages/dev-tools/README.md new file mode 100644 index 0000000..b56e1b6 --- /dev/null +++ b/packages/dev-tools/README.md @@ -0,0 +1,49 @@ +# EvolvePHP DevTools + +Development-time generators and tooling for EvolvePHP 2 applications. + +This package remains development-only. + +## Package + +`evolvephp/dev-tools` + +## Requirements + +PHP `^8.4` + +## Dependencies + +`evolvephp/contracts`, `evolvephp/core`, `evolvephp/module`, `evolvephp/plugin` + +## Generator Commands + +Phase 6.4E adds two public experimental command adapters: + +- `Evolve\DevTools\Console\ModuleNewCommand` +- `Evolve\DevTools\Console\PluginNewCommand` + +The commands are caller-registerable and receive an explicit project root. They +generate application-owned module and plugin starter files from one ASCII +StudlyCase name token. `Billing` becomes the component identifier `app/billing`; +`AuditLog` becomes `app/audit-log`. + +The commands do not discover components, edit Composer manifests, execute +generated PHP, run Composer, run Git, auto-enable generated components or inspect +application state beyond the derived output paths. Invalid usage writes only to +stderr and returns exit code `2`. Existing output files are refused before any +new target is written. + +## Publication Status + +EvolvePHP 2 is pre-release. This package is not yet independently published, and the current canonical source is the EvolvePHP monorepo: + +https://github.com/josiahking/evolvephp + +## Installation + +Independent Composer installation guidance will be added when package publication begins. + +## Licence + +BSD-3-Clause. See `LICENSE.md`. diff --git a/packages/dev-tools/composer.json b/packages/dev-tools/composer.json new file mode 100644 index 0000000..62c4e4d --- /dev/null +++ b/packages/dev-tools/composer.json @@ -0,0 +1,18 @@ +{ + "name": "evolvephp/dev-tools", + "description": "Development-time generators and tooling for EvolvePHP 2 applications.", + "type": "library", + "license": "BSD-3-Clause", + "require": { + "php": "^8.4", + "evolvephp/contracts": "^2.0", + "evolvephp/core": "^2.0", + "evolvephp/module": "^2.0", + "evolvephp/plugin": "^2.0" + }, + "autoload": { + "psr-4": { + "Evolve\\DevTools\\": "src/" + } + } +} diff --git a/packages/dev-tools/src/Console/ComponentScaffoldGenerator.php b/packages/dev-tools/src/Console/ComponentScaffoldGenerator.php new file mode 100644 index 0000000..aa46928 --- /dev/null +++ b/packages/dev-tools/src/Console/ComponentScaffoldGenerator.php @@ -0,0 +1,398 @@ +} + */ + public function generateModule(string $name): array + { + $identifier = self::identifierFor($name); + $paths = [ + 'src/Modules/' . $name . '/' . $name . 'Module.php', + 'src/Modules/' . $name . '/module.php', + 'tests/Modules/' . $name . '/' . $name . 'ModuleTest.php', + ]; + + $this->writeAll([ + $paths[0] => $this->moduleClass($name), + $paths[1] => $this->moduleDefinition($name, $identifier), + $paths[2] => $this->moduleTest($name), + ]); + + return ['identifier' => $identifier, 'paths' => $paths]; + } + + /** + * @return array{identifier: string, paths: list} + */ + public function generatePlugin(string $name): array + { + $identifier = self::identifierFor($name); + $paths = [ + 'src/Plugins/' . $name . '/' . $name . 'Plugin.php', + 'src/Plugins/' . $name . '/plugin.php', + 'tests/Plugins/' . $name . '/' . $name . 'PluginTest.php', + ]; + + $this->writeAll([ + $paths[0] => $this->pluginClass($name), + $paths[1] => $this->pluginDefinition($name, $identifier), + $paths[2] => $this->pluginTest($name), + ]); + + return ['identifier' => $identifier, 'paths' => $paths]; + } + + public static function isValidName(string $name): bool + { + return preg_match('/^[A-Z][A-Za-z0-9]*$/', $name) === 1; + } + + private static function identifierFor(string $name): string + { + $slug = strtolower((string) preg_replace('/(? $files + */ + private function writeAll(array $files): void + { + $createdFiles = []; + $createdDirectories = []; + + foreach (array_keys($files) as $relativePath) { + $this->assertSafeRelativePath($relativePath); + + if (file_exists($this->absolutePath($relativePath))) { + throw new RuntimeException('Refusing to overwrite existing file: ' . $relativePath); + } + } + + try { + foreach ($files as $relativePath => $content) { + $absolutePath = $this->absolutePath($relativePath); + $directory = dirname($absolutePath); + + if (! is_dir($directory)) { + $this->createDirectory($directory, $createdDirectories); + } + + $temporaryPath = $directory . DIRECTORY_SEPARATOR . '.' . basename($absolutePath) . '.' . bin2hex(random_bytes(6)) . '.tmp'; + $handle = fopen($temporaryPath, 'x'); + + if ($handle === false) { + throw new RuntimeException('Unable to create temporary scaffold file.'); + } + + $bytes = fwrite($handle, $content); + + if ($bytes !== strlen($content) || ! fclose($handle)) { + @unlink($temporaryPath); + + throw new RuntimeException('Unable to write scaffold file.'); + } + + if (! rename($temporaryPath, $absolutePath)) { + @unlink($temporaryPath); + + throw new RuntimeException('Unable to publish scaffold file.'); + } + + $createdFiles[] = $absolutePath; + } + } catch (\Throwable $exception) { + foreach (array_reverse($createdFiles) as $createdFile) { + if (is_file($createdFile)) { + @unlink($createdFile); + } + } + + foreach (array_reverse($createdDirectories) as $createdDirectory) { + if (is_dir($createdDirectory)) { + @rmdir($createdDirectory); + } + } + + throw $exception; + } + } + + /** + * @param list $createdDirectories + */ + private function createDirectory(string $directory, array &$createdDirectories): void + { + $segments = explode(DIRECTORY_SEPARATOR, substr($directory, strlen($this->projectRoot()) + 1)); + $current = $this->projectRoot(); + + foreach ($segments as $segment) { + $current .= DIRECTORY_SEPARATOR . $segment; + + if (is_dir($current)) { + continue; + } + + if (file_exists($current)) { + throw new RuntimeException('Scaffold target parent is not a directory.'); + } + + if (! mkdir($current)) { + throw new RuntimeException('Unable to create scaffold directory.'); + } + + $createdDirectories[] = $current; + } + } + + private function assertSafeRelativePath(string $relativePath): void + { + $parts = explode('/', $relativePath); + $current = $this->projectRoot(); + + foreach (array_slice($parts, 0, -1) as $part) { + if ($part === '' || $part === '.' || $part === '..') { + throw new RuntimeException('Refusing to write outside project root.'); + } + + $candidate = $current . DIRECTORY_SEPARATOR . $part; + + if (! file_exists($candidate)) { + $current = $candidate; + continue; + } + + $realCandidate = realpath($candidate); + + if ($realCandidate === false || ! str_starts_with($realCandidate, $this->projectRoot() . DIRECTORY_SEPARATOR)) { + throw new RuntimeException('Refusing to write outside project root.'); + } + + if (! is_dir($candidate)) { + throw new RuntimeException('Scaffold target parent is not a directory.'); + } + + $current = $realCandidate; + } + } + + private function absolutePath(string $relativePath): string + { + return $this->projectRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + } + + private function projectRoot(): string + { + $realProjectRoot = realpath($this->projectRoot); + + if ($realProjectRoot === false || ! is_dir($realProjectRoot)) { + throw new RuntimeException('Project root must remain an existing directory.'); + } + + return $realProjectRoot; + } + + private function moduleClass(string $name): string + { + return <<register(\$this->createStub(ServiceDefinitionRegistrar::class)); + \$module->boot(\$this->createStub(ComponentBootContext::class)); + \$module->ready(); + \$module->shutdown(); + + self::addToAssertionCount(1); + } +} + +PHP; + } + + private function pluginClass(string $name): string + { + return <<register(\$this->createStub(ServiceDefinitionRegistrar::class)); + \$plugin->boot(\$this->createStub(ComponentBootContext::class)); + \$plugin->ready(); + \$plugin->shutdown(); + + self::addToAssertionCount(1); + } +} + +PHP; + } +} diff --git a/packages/dev-tools/src/Console/ModuleNewCommand.php b/packages/dev-tools/src/Console/ModuleNewCommand.php new file mode 100644 index 0000000..dc7fc98 --- /dev/null +++ b/packages/dev-tools/src/Console/ModuleNewCommand.php @@ -0,0 +1,61 @@ +generator = new ComponentScaffoldGenerator($projectRoot); + } + + public function name(): string + { + return 'module:new'; + } + + public function description(): string + { + return 'Create an application module scaffold.'; + } + + public function execute(CommandInput $input, CommandOutput $output): CommandResult + { + $tokens = $input->tokens(); + + if (count($tokens) !== 1 || ! ComponentScaffoldGenerator::isValidName($tokens[0])) { + $output->writeError('Usage: module:new '); + + return new CommandResult(2); + } + + try { + $scaffold = $this->generator->generateModule($tokens[0]); + } catch (RuntimeException $exception) { + $output->writeError($exception->getMessage()); + + return new CommandResult(1); + } + + $output->write('Created module ' . $scaffold['identifier'] . '.'); + + foreach ($scaffold['paths'] as $path) { + $output->write($path); + } + + return new CommandResult(0); + } +} diff --git a/packages/dev-tools/src/Console/PluginNewCommand.php b/packages/dev-tools/src/Console/PluginNewCommand.php new file mode 100644 index 0000000..6b7c439 --- /dev/null +++ b/packages/dev-tools/src/Console/PluginNewCommand.php @@ -0,0 +1,61 @@ +generator = new ComponentScaffoldGenerator($projectRoot); + } + + public function name(): string + { + return 'plugin:new'; + } + + public function description(): string + { + return 'Create a framework plugin scaffold.'; + } + + public function execute(CommandInput $input, CommandOutput $output): CommandResult + { + $tokens = $input->tokens(); + + if (count($tokens) !== 1 || ! ComponentScaffoldGenerator::isValidName($tokens[0])) { + $output->writeError('Usage: plugin:new '); + + return new CommandResult(2); + } + + try { + $scaffold = $this->generator->generatePlugin($tokens[0]); + } catch (RuntimeException $exception) { + $output->writeError($exception->getMessage()); + + return new CommandResult(1); + } + + $output->write('Created plugin ' . $scaffold['identifier'] . '.'); + + foreach ($scaffold['paths'] as $path) { + $output->write($path); + } + + return new CommandResult(0); + } +} diff --git a/packages/dev-tools/tests/Unit/Console/ModuleNewCommandTest.php b/packages/dev-tools/tests/Unit/Console/ModuleNewCommandTest.php new file mode 100644 index 0000000..356216e --- /dev/null +++ b/packages/dev-tools/tests/Unit/Console/ModuleNewCommandTest.php @@ -0,0 +1,237 @@ +projectRoot = $this->createProjectRoot(); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectRoot); + } + + public function testCommandMetadataIsExact(): void + { + $command = new ModuleNewCommand($this->projectRoot); + + self::assertTrue((new \ReflectionClass(ModuleNewCommand::class))->isFinal()); + self::assertSame('module:new', $command->name()); + self::assertSame('Create an application module scaffold.', $command->description()); + } + + public function testItCreatesModuleFilesWithDeterministicOutput(): void + { + $output = new RecordingCommandOutput(); + $result = (new ModuleNewCommand($this->projectRoot))->execute(new CommandInput(['Billing']), $output); + + self::assertSame(0, $result->exitCode()); + self::assertSame([ + 'Created module app/billing.', + 'src/Modules/Billing/BillingModule.php', + 'src/Modules/Billing/module.php', + 'tests/Modules/Billing/BillingModuleTest.php', + ], $output->lines()); + self::assertSame([], $output->errorLines()); + + $this->assertFileContains('src/Modules/Billing/BillingModule.php', 'final class BillingModule implements Module'); + $this->assertFileContains('src/Modules/Billing/module.php', "new ComponentIdentifier('app/billing')"); + $this->assertFileContains('src/Modules/Billing/module.php', "'Billing'"); + $this->assertFileContains('tests/Modules/Billing/BillingModuleTest.php', 'assertInstanceOf(Module::class'); + + require_once $this->projectPath('src/Modules/Billing/BillingModule.php'); + + $definition = require $this->projectPath('src/Modules/Billing/module.php'); + + self::assertInstanceOf(ModuleDefinition::class, $definition); + self::assertSame('app/billing', $definition->identifier()->value()); + $definition->validate(); + } + + public function testAuditLogNameUsesKebabCaseIdentifierAndPreservesStudlyClassName(): void + { + $output = new RecordingCommandOutput(); + $result = (new ModuleNewCommand($this->projectRoot))->execute(new CommandInput(['AuditLog']), $output); + + self::assertSame(0, $result->exitCode()); + self::assertFileExists($this->projectPath('src/Modules/AuditLog/AuditLogModule.php')); + $this->assertFileContains('src/Modules/AuditLog/module.php', "new ComponentIdentifier('app/audit-log')"); + self::assertSame('Created module app/audit-log.', $output->lines()[0]); + } + + /** + * @param list $tokens + */ + #[DataProvider('invalidNames')] + public function testInvalidUsageReturnsUsageErrorOnly(array $tokens): void + { + $output = new RecordingCommandOutput(); + $result = (new ModuleNewCommand($this->projectRoot))->execute(new CommandInput($tokens), $output); + + self::assertSame(2, $result->exitCode()); + self::assertSame([], $output->lines()); + self::assertSame(['Usage: module:new '], $output->errorLines()); + self::assertSame([], $this->projectFiles()); + } + + public function testItRefusesToOverwriteExistingFilesWithoutWritingOtherTargets(): void + { + $existingPath = $this->projectPath('src/Modules/Billing/BillingModule.php'); + self::assertTrue(mkdir(dirname($existingPath), 0777, true)); + self::assertSame(8, file_put_contents($existingPath, 'existing')); + + $output = new RecordingCommandOutput(); + $result = (new ModuleNewCommand($this->projectRoot))->execute(new CommandInput(['Billing']), $output); + + self::assertSame(1, $result->exitCode()); + self::assertSame([], $output->lines()); + self::assertSame( + ['Refusing to overwrite existing file: src/Modules/Billing/BillingModule.php'], + $output->errorLines(), + ); + self::assertSame('existing', file_get_contents($existingPath)); + self::assertFileDoesNotExist($this->projectPath('src/Modules/Billing/module.php')); + self::assertFileDoesNotExist($this->projectPath('tests/Modules/Billing/BillingModuleTest.php')); + } + + public function testRepeatGenerationRefusesExistingScaffold(): void + { + $command = new ModuleNewCommand($this->projectRoot); + $firstOutput = new RecordingCommandOutput(); + $secondOutput = new RecordingCommandOutput(); + + self::assertSame(0, $command->execute(new CommandInput(['Billing']), $firstOutput)->exitCode()); + + $result = $command->execute(new CommandInput(['Billing']), $secondOutput); + + self::assertSame(1, $result->exitCode()); + self::assertSame([], $secondOutput->lines()); + self::assertSame( + ['Refusing to overwrite existing file: src/Modules/Billing/BillingModule.php'], + $secondOutput->errorLines(), + ); + } + + public function testSymlinkTraversalOutsideProjectRootIsRejectedWhenSupported(): void + { + $outsideRoot = $this->createProjectRoot(); + + try { + self::assertTrue(mkdir($this->projectPath('src'), 0777, true)); + + if (! @symlink($outsideRoot, $this->projectPath('src/Modules'))) { + self::markTestSkipped('Symlink creation is not available in this environment.'); + } + + $output = new RecordingCommandOutput(); + $result = (new ModuleNewCommand($this->projectRoot))->execute(new CommandInput(['Billing']), $output); + + self::assertSame(1, $result->exitCode()); + self::assertSame([], $output->lines()); + self::assertSame(['Refusing to write outside project root.'], $output->errorLines()); + self::assertFileDoesNotExist($outsideRoot . DIRECTORY_SEPARATOR . 'Billing' . DIRECTORY_SEPARATOR . 'BillingModule.php'); + } finally { + $this->removeDirectory($outsideRoot); + } + } + + /** + * @return iterable}> + */ + public static function invalidNames(): iterable + { + yield 'empty' => [[]]; + yield 'extra token' => [['Billing', 'Extra']]; + yield 'lowercase first' => [['billing']]; + yield 'digit leading' => [['2Billing']]; + yield 'whitespace' => [["Bill ing"]]; + yield 'slash' => [['../Billing']]; + yield 'backslash' => [['Vendor\\Billing']]; + yield 'dot' => [['Billing.Module']]; + yield 'shell metacharacter' => [['Billing;rm']]; + } + + private function assertFileContains(string $relativePath, string $needle): void + { + $content = file_get_contents($this->projectPath($relativePath)); + + self::assertIsString($content); + self::assertStringContainsString($needle, $content); + } + + private function createProjectRoot(): string + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evolvephp-dev-tools-test-' . bin2hex(random_bytes(8)); + + self::assertTrue(mkdir($path, 0777, true)); + + return realpath($path) ?: $path; + } + + /** + * @return list + */ + private function projectFiles(): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->projectRoot, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $files[] = str_replace('\\', '/', substr($file->getPathname(), strlen($this->projectRoot) + 1)); + } + } + + sort($files); + + return $files; + } + + private function projectPath(string $relativePath): string + { + return $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + } + + 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/packages/dev-tools/tests/Unit/Console/PluginNewCommandTest.php b/packages/dev-tools/tests/Unit/Console/PluginNewCommandTest.php new file mode 100644 index 0000000..ddee5c7 --- /dev/null +++ b/packages/dev-tools/tests/Unit/Console/PluginNewCommandTest.php @@ -0,0 +1,196 @@ +projectRoot = $this->createProjectRoot(); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectRoot); + } + + public function testCommandMetadataIsExact(): void + { + $command = new PluginNewCommand($this->projectRoot); + + self::assertTrue((new \ReflectionClass(PluginNewCommand::class))->isFinal()); + self::assertSame('plugin:new', $command->name()); + self::assertSame('Create a framework plugin scaffold.', $command->description()); + } + + public function testItCreatesPluginFilesWithDeterministicOutput(): void + { + $output = new RecordingCommandOutput(); + $result = (new PluginNewCommand($this->projectRoot))->execute(new CommandInput(['Cache']), $output); + + self::assertSame(0, $result->exitCode()); + self::assertSame([ + 'Created plugin app/cache.', + 'src/Plugins/Cache/CachePlugin.php', + 'src/Plugins/Cache/plugin.php', + 'tests/Plugins/Cache/CachePluginTest.php', + ], $output->lines()); + self::assertSame([], $output->errorLines()); + + $this->assertFileContains('src/Plugins/Cache/CachePlugin.php', 'final class CachePlugin implements Plugin'); + $this->assertFileContains('src/Plugins/Cache/plugin.php', "new ComponentIdentifier('app/cache')"); + $this->assertFileContains('src/Plugins/Cache/plugin.php', "'Cache'"); + $this->assertFileContains('tests/Plugins/Cache/CachePluginTest.php', 'assertInstanceOf(Plugin::class'); + + require_once $this->projectPath('src/Plugins/Cache/CachePlugin.php'); + + $definition = require $this->projectPath('src/Plugins/Cache/plugin.php'); + + self::assertInstanceOf(PluginDefinition::class, $definition); + self::assertSame('app/cache', $definition->identifier()->value()); + $definition->validate(); + } + + public function testBilling2NameIsAcceptedAndPreserved(): void + { + $output = new RecordingCommandOutput(); + $result = (new PluginNewCommand($this->projectRoot))->execute(new CommandInput(['Billing2']), $output); + + self::assertSame(0, $result->exitCode()); + self::assertFileExists($this->projectPath('src/Plugins/Billing2/Billing2Plugin.php')); + $this->assertFileContains('src/Plugins/Billing2/plugin.php', "new ComponentIdentifier('app/billing2')"); + self::assertSame('Created plugin app/billing2.', $output->lines()[0]); + } + + /** + * @param list $tokens + */ + #[DataProvider('invalidNames')] + public function testInvalidUsageReturnsUsageErrorOnly(array $tokens): void + { + $output = new RecordingCommandOutput(); + $result = (new PluginNewCommand($this->projectRoot))->execute(new CommandInput($tokens), $output); + + self::assertSame(2, $result->exitCode()); + self::assertSame([], $output->lines()); + self::assertSame(['Usage: plugin:new '], $output->errorLines()); + self::assertSame([], $this->projectFiles()); + } + + public function testItRefusesToOverwriteExistingFilesWithoutWritingOtherTargets(): void + { + $existingPath = $this->projectPath('src/Plugins/Cache/CachePlugin.php'); + self::assertTrue(mkdir(dirname($existingPath), 0777, true)); + self::assertSame(8, file_put_contents($existingPath, 'existing')); + + $output = new RecordingCommandOutput(); + $result = (new PluginNewCommand($this->projectRoot))->execute(new CommandInput(['Cache']), $output); + + self::assertSame(1, $result->exitCode()); + self::assertSame([], $output->lines()); + self::assertSame( + ['Refusing to overwrite existing file: src/Plugins/Cache/CachePlugin.php'], + $output->errorLines(), + ); + self::assertSame('existing', file_get_contents($existingPath)); + self::assertFileDoesNotExist($this->projectPath('src/Plugins/Cache/plugin.php')); + self::assertFileDoesNotExist($this->projectPath('tests/Plugins/Cache/CachePluginTest.php')); + } + + /** + * @return iterable}> + */ + public static function invalidNames(): iterable + { + yield 'empty' => [[]]; + yield 'extra token' => [['Cache', 'Extra']]; + yield 'lowercase first' => [['cache']]; + yield 'digit leading' => [['2Cache']]; + yield 'whitespace' => [["Ca che"]]; + yield 'slash' => [['../Cache']]; + yield 'backslash' => [['Vendor\\Cache']]; + yield 'dot' => [['Cache.Plugin']]; + yield 'shell metacharacter' => [['Cache&&echo']]; + } + + private function assertFileContains(string $relativePath, string $needle): void + { + $content = file_get_contents($this->projectPath($relativePath)); + + self::assertIsString($content); + self::assertStringContainsString($needle, $content); + } + + private function createProjectRoot(): string + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evolvephp-dev-tools-test-' . bin2hex(random_bytes(8)); + + self::assertTrue(mkdir($path, 0777, true)); + + return realpath($path) ?: $path; + } + + /** + * @return list + */ + private function projectFiles(): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->projectRoot, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $files[] = str_replace('\\', '/', substr($file->getPathname(), strlen($this->projectRoot) + 1)); + } + } + + sort($files); + + return $files; + } + + private function projectPath(string $relativePath): string + { + return $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + } + + 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/packages/dev-tools/tests/Unit/PackageManifestTest.php b/packages/dev-tools/tests/Unit/PackageManifestTest.php new file mode 100644 index 0000000..4880fc3 --- /dev/null +++ b/packages/dev-tools/tests/Unit/PackageManifestTest.php @@ -0,0 +1,23 @@ + + */ + private array $lines = []; + + /** + * @var list + */ + private array $errorLines = []; + + public function write(string $message): void + { + $this->lines[] = $message; + } + + public function writeError(string $message): void + { + $this->errorLines[] = $message; + } + + /** + * @return list + */ + public function lines(): array + { + return $this->lines; + } + + /** + * @return list + */ + public function errorLines(): array + { + return $this->errorLines; + } +} diff --git a/packages/testing/tests/Unit/Console/RecordingCommandOutputTest.php b/packages/testing/tests/Unit/Console/RecordingCommandOutputTest.php new file mode 100644 index 0000000..a0c66a1 --- /dev/null +++ b/packages/testing/tests/Unit/Console/RecordingCommandOutputTest.php @@ -0,0 +1,50 @@ +lines()); + self::assertSame([], $output->errorLines()); + } + + public function testItRecordsNormalLinesInOrder(): void + { + $output = new RecordingCommandOutput(); + + $output->write('first'); + $output->write('second'); + + self::assertSame(['first', 'second'], $output->lines()); + self::assertSame([], $output->errorLines()); + } + + public function testItRecordsErrorLinesInOrder(): void + { + $output = new RecordingCommandOutput(); + + $output->writeError('first error'); + $output->writeError('second error'); + + self::assertSame([], $output->lines()); + self::assertSame(['first error', 'second error'], $output->errorLines()); + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 62471d1..8566c4d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,6 +8,8 @@ parameters: - packages/contracts/tests - packages/core/src - packages/core/tests + - packages/dev-tools/src + - packages/dev-tools/tests - packages/http/src - packages/http/tests - packages/module/src @@ -16,5 +18,8 @@ parameters: - packages/plugin/tests - packages/testing/src - packages/testing/tests + - skeleton/bin/evolve + - skeleton/bootstrap + - skeleton/config tmpDir: .phpstan-cache reportIgnoresWithoutComments: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f6c6e31..46b5a3a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,6 +17,9 @@ packages/core/tests + + packages/dev-tools/tests + packages/http/tests @@ -34,6 +37,7 @@ packages/contracts/src packages/core/src + packages/dev-tools/src packages/http/src packages/module/src packages/plugin/src diff --git a/release-packages.json b/release-packages.json index 7e2a1be..7a5ed4c 100644 --- a/release-packages.json +++ b/release-packages.json @@ -24,6 +24,10 @@ { "name": "evolvephp/testing", "directory": "packages/testing" + }, + { + "name": "evolvephp/dev-tools", + "directory": "packages/dev-tools" } ] } diff --git a/skeleton/README.md b/skeleton/README.md new file mode 100644 index 0000000..45c90ef --- /dev/null +++ b/skeleton/README.md @@ -0,0 +1,60 @@ +# EvolvePHP Skeleton + +`evolvephp/skeleton` is the first end-user application template for EvolvePHP 2. It is separate from the framework monorepo root, which remains the EvolvePHP development workspace rather than an application skeleton. + +The skeleton uses the application namespace `App\` with the PSR-4 mapping `App\ => src/`. The initial `src/` directory is intentionally empty so later generator work has an authoritative target layout. + +## CLI + +Application CLI composition is explicit and owned by the skeleton. The skeleton shell uses the public experimental Core runtime APIs `CliApplication` and `StreamCommandOutput`, then composes `CommandRegistry`, `CommandRunner`, `ServiceRegistry` and `ExecutionOrchestrator` directly. Core remains independent of HTTP. + +The command list is configured in `config/commands.php` and always registers: + +- `doctor` +- `route:list` + +When `evolvephp/dev-tools` is installed as a development dependency, the same +explicit config additionally registers: + +- `module:new` +- `plugin:new` + +The Doctor command uses the current accepted runtime and Composer-extension checks: `PhpVersionCheck` and `ComposerRequiredExtensionsCheck`. The Composer extension check reads the generated application's own `composer.json`. + +The initial route configuration is explicit and empty through `config/routes.php`, which returns a `RouteCollection`. No route discovery, filesystem scanning, attributes, implicit routes or application boot magic is provided. Running the application-owned `route:list` command in a generated project reports: + +```text +No routes are configured. +``` + +## Validation + +Local prerelease create-project validation is repository-owned and available from the EvolvePHP development root: + +```bash +composer release:skeleton:validate +``` + +That validation exercises Composer create-project against local prerelease package evidence with Packagist and Composer network access disabled. Public Packagist create-project availability is not yet claimed. + +## Generators + +Phase 6.4E adds development-only module and plugin starter commands through +`evolvephp/dev-tools`. `module:new Billing` creates `app/billing` under +`src/Modules/Billing/`, and `plugin:new Cache` creates `app/cache` under +`src/Plugins/Cache/`. Generated files remain application-owned and are not +auto-enabled. + +The skeleton includes a small PHPUnit application test harness so generated +starter tests can run through: + +```bash +composer test +``` + +Phase 6.4F provides the Testing package command-output recorder used by the +generator command tests; the skeleton keeps broader testing utilities deferred. + +## Deferred + +Command discovery, route discovery, JSON output, tables, help UI, TTY/ANSI behavior, web runtime entrypoints, controllers, middleware defaults, dotenv loading, storage defaults, deployment scaffolding and application boot magic remain deferred. diff --git a/skeleton/bin/evolve b/skeleton/bin/evolve new file mode 100644 index 0000000..1f9c2e3 --- /dev/null +++ b/skeleton/bin/evolve @@ -0,0 +1,28 @@ +run(array_slice($argv ?? [], 1), $output)); diff --git a/skeleton/bootstrap/console.php b/skeleton/bootstrap/console.php new file mode 100644 index 0000000..47fff71 --- /dev/null +++ b/skeleton/bootstrap/console.php @@ -0,0 +1,19 @@ +freeze(); + +$commands = require dirname(__DIR__) . '/config/commands.php'; + +return new CliApplication(new CommandRunner( + new CommandRegistry($commands), + new ExecutionOrchestrator($services), +)); diff --git a/skeleton/composer.json b/skeleton/composer.json new file mode 100644 index 0000000..0d9161a --- /dev/null +++ b/skeleton/composer.json @@ -0,0 +1,34 @@ +{ + "name": "evolvephp/skeleton", + "description": "Application skeleton for EvolvePHP 2.", + "type": "project", + "license": "BSD-3-Clause", + "require": { + "php": "^8.4", + "evolvephp/contracts": "^2.0", + "evolvephp/core": "^2.0", + "evolvephp/http": "^2.0", + "evolvephp/module": "^2.0", + "evolvephp/plugin": "^2.0" + }, + "require-dev": { + "evolvephp/dev-tools": "^2.0", + "evolvephp/testing": "^2.0", + "phpunit/phpunit": "^13.2" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit --configuration phpunit.xml.dist" + }, + "minimum-stability": "alpha", + "prefer-stable": true +} diff --git a/skeleton/config/commands.php b/skeleton/config/commands.php new file mode 100644 index 0000000..c60443d --- /dev/null +++ b/skeleton/config/commands.php @@ -0,0 +1,31 @@ + + + + + tests + + + + + src + + + diff --git a/skeleton/src/.gitkeep b/skeleton/src/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skeleton/src/.gitkeep @@ -0,0 +1 @@ + diff --git a/skeleton/tests/.gitkeep b/skeleton/tests/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skeleton/tests/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Architecture/EvolvePhp2ApplicationSkeletonTest.php b/tests/Architecture/EvolvePhp2ApplicationSkeletonTest.php new file mode 100644 index 0000000..18de486 --- /dev/null +++ b/tests/Architecture/EvolvePhp2ApplicationSkeletonTest.php @@ -0,0 +1,226 @@ +root = dirname(__DIR__, 2); + } + + public function testApplicationSkeletonHasAcceptedProjectManifest(): void + { + $manifest = $this->readJsonFile('skeleton/composer.json'); + + $this->assertSame('evolvephp/skeleton', $manifest['name']); + $this->assertSame('project', $manifest['type']); + $this->assertSame('BSD-3-Clause', $manifest['license']); + $this->assertSame('^8.4', $manifest['require']['php']); + $this->assertSame('^2.0', $manifest['require']['evolvephp/contracts']); + $this->assertSame('^2.0', $manifest['require']['evolvephp/core']); + $this->assertSame('^2.0', $manifest['require']['evolvephp/http']); + $this->assertSame('^2.0', $manifest['require']['evolvephp/module']); + $this->assertSame('^2.0', $manifest['require']['evolvephp/plugin']); + $this->assertArrayNotHasKey('evolvephp/testing', $manifest['require']); + $this->assertArrayNotHasKey('evolvephp/dev-tools', $manifest['require']); + $this->assertSame('^2.0', $manifest['require-dev']['evolvephp/dev-tools']); + $this->assertSame('^2.0', $manifest['require-dev']['evolvephp/testing']); + $this->assertSame('^13.2', $manifest['require-dev']['phpunit/phpunit']); + $this->assertSame(array('App\\' => 'src/'), $manifest['autoload']['psr-4']); + $this->assertSame(array('Tests\\' => 'tests/'), $manifest['autoload-dev']['psr-4']); + $this->assertSame('phpunit --configuration phpunit.xml.dist', $manifest['scripts']['test']); + $this->assertSame('alpha', $manifest['minimum-stability']); + $this->assertTrue($manifest['prefer-stable']); + $this->assertArrayNotHasKey('version', $manifest); + $this->assertArrayNotHasKey('repositories', $manifest); + } + + public function testApplicationSkeletonContainsOnlyTheAcceptedInitialLayout(): void + { + foreach (array( + 'skeleton/composer.json', + 'skeleton/README.md', + 'skeleton/bin/evolve', + 'skeleton/bootstrap/console.php', + 'skeleton/config/commands.php', + 'skeleton/config/routes.php', + 'skeleton/phpunit.xml.dist', + 'skeleton/src/.gitkeep', + 'skeleton/tests/.gitkeep', + ) as $path) { + $this->assertFileExists($this->path($path), $path . ' must exist.'); + } + + $this->assertSame( + array( + 'README.md', + 'bin/evolve', + 'bootstrap/console.php', + 'composer.json', + 'config/commands.php', + 'config/routes.php', + 'phpunit.xml.dist', + 'src/.gitkeep', + 'tests/.gitkeep', + ), + $this->filesUnder('skeleton'), + ); + } + + public function testApplicationSkeletonConsoleCompositionIsExplicit(): void + { + $bin = $this->readProjectFile('skeleton/bin/evolve'); + $bootstrap = $this->readProjectFile('skeleton/bootstrap/console.php'); + $commands = $this->readProjectFile('skeleton/config/commands.php'); + $routes = $this->readProjectFile('skeleton/config/routes.php'); + + $this->assertStringContainsString("require dirname(__DIR__) . '/vendor/autoload.php';", $bin); + $this->assertStringContainsString("require dirname(__DIR__) . '/bootstrap/console.php';", $bin); + $this->assertStringContainsString('new StreamCommandOutput(STDOUT, STDERR)', $bin); + $this->assertStringContainsString('$application->run(array_slice($argv ?? [], 1), $output)', $bin); + + foreach (array( + 'ServiceRegistry', + 'ExecutionOrchestrator', + 'CommandRegistry', + 'CommandRunner', + 'CliApplication', + ) as $needle) { + $this->assertStringContainsString($needle, $bootstrap); + } + + $this->assertStringContainsString('DoctorCommand', $commands); + $this->assertStringContainsString('DoctorRunner', $commands); + $this->assertStringContainsString('PhpVersionCheck', $commands); + $this->assertStringContainsString('ComposerRequiredExtensionsCheck', $commands); + $this->assertStringContainsString('__DIR__ . \'/../composer.json\'', $commands); + $this->assertStringContainsString('RouteListCommand', $commands); + $this->assertStringContainsString('ModuleNewCommand', $commands); + $this->assertStringContainsString('PluginNewCommand', $commands); + $this->assertStringContainsString('class_exists(ModuleNewCommand::class)', $commands); + $this->assertStringContainsString('class_exists(PluginNewCommand::class)', $commands); + $this->assertStringContainsString("require __DIR__ . '/routes.php'", $commands); + $this->assertDoesNotMatchRegularExpression('/EnvironmentVariablesCheck|WritablePathsCheck|dotenv|service locator|global command registry/i', $commands); + + $this->assertStringContainsString('return new RouteCollection([]);', $routes); + $this->assertDoesNotMatchRegularExpression('/scan|glob|attribute|discover|filesystem|implicit/i', $routes); + } + + public function testCoreRuntimeCliCompositionApisArePublicExperimental(): void + { + foreach (array( + 'packages/core/src/Console/Runtime/CliApplication.php', + 'packages/core/src/Console/Runtime/StreamCommandOutput.php', + ) as $path) { + $content = $this->readProjectFile($path); + + $this->assertStringContainsString('@experimental', $content, $path . ' must be public experimental.'); + $this->assertStringNotContainsString('@internal', $content, $path . ' must not remain internal.'); + } + } + + public function testSkeletonIsExcludedFromFrameworkReleaseAndDeptracBoundaries(): void + { + $map = $this->readJsonFile('release-packages.json'); + $packageNames = array_column($map['packages'], 'name'); + $packageDirectories = array_column($map['packages'], 'directory'); + $deptrac = $this->readProjectFile('deptrac.php'); + $coreManifest = $this->readJsonFile('packages/core/composer.json'); + + $this->assertCount(7, $map['packages']); + $this->assertNotContains('evolvephp/skeleton', $packageNames); + $this->assertNotContains('skeleton', $packageDirectories); + $this->assertStringNotContainsString('skeleton', $deptrac); + $this->assertArrayNotHasKey('evolvephp/http', $coreManifest['require']); + } + + public function testSkeletonReadmeDocumentsAcceptedBoundariesWithoutPublicationClaims(): void + { + $readme = $this->readProjectFile('skeleton/README.md'); + + foreach (array( + 'evolvephp/skeleton', + 'end-user application template', + 'App\\', + 'src/', + 'explicit', + 'RouteCollection', + 'No routes are configured.', + 'route:list', + 'doctor', + 'CliApplication', + 'StreamCommandOutput', + 'public experimental', + 'Core remains independent of HTTP', + 'composer release:skeleton:validate', + 'Packagist', + '6.4E', + '6.4F', + 'module:new', + 'plugin:new', + 'composer test', + ) as $needle) { + $this->assertStringContainsString($needle, $readme); + } + + $this->assertDoesNotMatchRegularExpression('/composer create-project evolvephp\/skeleton/i', $readme); + $this->assertDoesNotMatchRegularExpression('/automatic (?:route|command) discovery|autoloaded routes/i', $readme); + } + + /** + * @return list + */ + private function filesUnder(string $directory): array + { + $root = $this->path($directory); + $this->assertDirectoryExists($root); + + $files = array(); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $files[] = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1)); + } + } + + sort($files); + + return $files; + } + + private function readProjectFile(string $path): string + { + $fullPath = $this->path($path); + $this->assertFileExists($fullPath, $path . ' must exist.'); + + $content = file_get_contents($fullPath); + + $this->assertIsString($content, $path . ' must be readable.'); + + return $content; + } + + /** + * @return array + */ + private function readJsonFile(string $path): array + { + $decoded = json_decode($this->readProjectFile($path), true); + + $this->assertSame(JSON_ERROR_NONE, json_last_error(), $path . ' must contain valid JSON.'); + $this->assertIsArray($decoded, $path . ' must decode to an array.'); + + return $decoded; + } + + private function path(string $path): string + { + return $this->root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); + } +} diff --git a/tests/Architecture/EvolvePhp2ArchitectureAndDependencyBoundariesTest.php b/tests/Architecture/EvolvePhp2ArchitectureAndDependencyBoundariesTest.php index a66a7cb..df331bb 100644 --- a/tests/Architecture/EvolvePhp2ArchitectureAndDependencyBoundariesTest.php +++ b/tests/Architecture/EvolvePhp2ArchitectureAndDependencyBoundariesTest.php @@ -108,7 +108,7 @@ public function testDeptracConfigurationIsRootOwnedAndStrict(): void ); $this->assertSame($this->expectedRulesets(), $this->deptracRulesets($content)); - foreach (array('packages/contracts/tests', 'packages/core/tests', 'packages/http/tests', 'packages/module/tests', 'packages/plugin/tests', 'packages/testing/tests') as $testPath) { + foreach (array('packages/contracts/tests', 'packages/core/tests', 'packages/dev-tools/tests', 'packages/http/tests', 'packages/module/tests', 'packages/plugin/tests', 'packages/testing/tests') as $testPath) { $this->assertStringNotContainsString($testPath, $content); } @@ -167,6 +167,7 @@ public function testDocumentationRecordsArchitectureBoundaryPolicy(): void foreach (array( 'Contracts -> none', 'Core -> Contracts', + 'DevTools -> Contracts, Core, Module, Plugin', 'Http -> Contracts, Core', 'Module -> Contracts', 'Plugin -> Contracts', @@ -202,6 +203,7 @@ private function expectedFirstPartyLayers() return array( 'Contracts' => 'packages/contracts/src/.*', 'Core' => 'packages/core/src/.*', + 'DevTools' => 'packages/dev-tools/src/.*', 'Http' => 'packages/http/src/.*', 'Module' => 'packages/module/src/.*', 'Plugin' => 'packages/plugin/src/.*', @@ -217,6 +219,7 @@ private function expectedRulesets() 'PsrHttpMessage' => array(), 'PsrHttpServer' => array(), 'Core' => array('Contracts', 'PsrContainer'), + 'DevTools' => array('Contracts', 'Core', 'Module', 'Plugin'), 'Http' => array('Contracts', 'Core', 'PsrHttpMessage', 'PsrHttpServer'), 'Module' => array('Contracts'), 'Plugin' => array('Contracts'), @@ -260,6 +263,7 @@ private function deptracRulesets($content) 'psrHttpMessage' => 'PsrHttpMessage', 'psrHttpServer' => 'PsrHttpServer', 'core' => 'Core', + 'devTools' => 'DevTools', 'http' => 'Http', 'module' => 'Module', 'plugin' => 'Plugin', @@ -273,7 +277,7 @@ private function deptracRulesets($content) $accesses = array(); if (isset($match[1])) { - preg_match_all('/\\$(contracts|psrContainer|psrHttpMessage|psrHttpServer|core|http|module|plugin|testing)\\b/', $match[1], $accessMatches); + preg_match_all('/\\$(contracts|psrContainer|psrHttpMessage|psrHttpServer|core|devTools|http|module|plugin|testing)\\b/', $match[1], $accessMatches); foreach ($accessMatches[1] as $accessVariable) { $accesses[] = $variablesByLayer[$accessVariable]; @@ -283,15 +287,15 @@ private function deptracRulesets($content) $rulesets[$layerName] = $accesses; } - foreach (array('Contracts', 'PsrContainer', 'PsrHttpMessage', 'PsrHttpServer', 'Core', 'Http', 'Module', 'Plugin') as $productionLayer) { + foreach (array('Contracts', 'PsrContainer', 'PsrHttpMessage', 'PsrHttpServer', 'Core', 'DevTools', 'Http', 'Module', 'Plugin') as $productionLayer) { $this->assertNotContains('Testing', $rulesets[$productionLayer], $productionLayer . ' must not access Testing.'); } - foreach (array('Http', 'Module', 'Plugin', 'Testing') as $layerName) { + foreach (array('DevTools', 'Http', 'Module', 'Plugin', 'Testing') as $layerName) { $this->assertNotContains('PsrContainer', $rulesets[$layerName], $layerName . ' must not access PsrContainer directly without an approved boundary.'); } - foreach (array('Contracts', 'Core', 'Module', 'Plugin', 'Testing') as $layerName) { + foreach (array('Contracts', 'Core', 'DevTools', 'Module', 'Plugin', 'Testing') as $layerName) { $this->assertNotContains('PsrHttpMessage', $rulesets[$layerName], $layerName . ' must not access PSR-7 HTTP message interfaces directly in Phase 4.1.'); $this->assertNotContains('PsrHttpServer', $rulesets[$layerName], $layerName . ' must not access PSR-15 HTTP server interfaces directly in Phase 4.1.'); } @@ -304,6 +308,7 @@ private function packageSourceRules() return array( 'packages/contracts/src' => 'Evolve\\Contracts\\', 'packages/core/src' => 'Evolve\\Core\\', + 'packages/dev-tools/src' => 'Evolve\\DevTools\\', 'packages/http/src' => 'Evolve\\Http\\', 'packages/module/src' => 'Evolve\\Module\\', 'packages/plugin/src' => 'Evolve\\Plugin\\', @@ -316,6 +321,7 @@ private function packageManifests() return array( 'packages/contracts/composer.json', 'packages/core/composer.json', + 'packages/dev-tools/composer.json', 'packages/http/composer.json', 'packages/module/composer.json', 'packages/plugin/composer.json', @@ -352,7 +358,7 @@ private function trackedFiles() $output = array(); $exitCode = 0; - exec('git ls-files', $output, $exitCode); + exec('git ls-files --cached --others --exclude-standard', $output, $exitCode); $this->assertSame(0, $exitCode, 'git ls-files should succeed.'); diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index 8a9d0d3..680dcca 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -19,7 +19,7 @@ public function testCorePackageExposesOnlyEvolveBinary(): void public function testOnlyCorePackageExposesComposerBinary(): void { - foreach (['contracts', 'http', 'module', 'plugin', 'testing'] as $package) { + foreach (['contracts', 'dev-tools', 'http', 'module', 'plugin', 'testing'] as $package) { $manifest = json_decode( file_get_contents(dirname(__DIR__, 2) . sprintf('/packages/%s/composer.json', $package)), true, @@ -156,6 +156,7 @@ public function testPackageSourcesMatchAcceptedPackageSourceInventory(): void $this->assertFileDoesNotExist($this->projectPath('packages/contracts/src/.gitkeep')); $this->assertFileDoesNotExist($this->projectPath('packages/core/src/.gitkeep')); + $this->assertFileDoesNotExist($this->projectPath('packages/dev-tools/src/.gitkeep')); $this->assertFileDoesNotExist($this->projectPath('packages/http/src/.gitkeep')); $this->assertFileDoesNotExist($this->projectPath('packages/module/src/.gitkeep')); $this->assertFileDoesNotExist($this->projectPath('packages/plugin/src/.gitkeep')); @@ -685,6 +686,20 @@ private function packages() 'namespace' => 'Evolve\\Core\\', 'require' => array('php' => '^8.4', 'evolvephp/contracts' => '^2.0', 'psr/container' => '^1.1 || ^2.0'), ), + array( + 'manifest' => 'packages/dev-tools/composer.json', + 'src' => 'packages/dev-tools/src', + 'name' => 'evolvephp/dev-tools', + 'description' => 'Development-time generators and tooling for EvolvePHP 2 applications.', + 'namespace' => 'Evolve\\DevTools\\', + 'require' => array( + 'php' => '^8.4', + 'evolvephp/contracts' => '^2.0', + 'evolvephp/core' => '^2.0', + 'evolvephp/module' => '^2.0', + 'evolvephp/plugin' => '^2.0', + ), + ), array( 'manifest' => 'packages/http/composer.json', 'src' => 'packages/http/src', @@ -843,6 +858,11 @@ private function acceptedPackageSourceInventories() 'Instrumentation/ObservationType.php', 'Lifecycle/ApplicationState.php', ), + 'packages/dev-tools/src' => array( + 'Console/ComponentScaffoldGenerator.php', + 'Console/ModuleNewCommand.php', + 'Console/PluginNewCommand.php', + ), 'packages/http/src' => array( 'Exception/MethodNotAllowed.php', 'Exception/RouteNotFound.php', @@ -884,6 +904,7 @@ private function acceptedPackageSourceInventories() 'packages/testing/src' => array( 'Component/ComponentDefinitionFixture.php', 'Component/ComponentEntryPointFixture.php', + 'Console/RecordingCommandOutput.php', ), ); } diff --git a/tests/Architecture/EvolvePhp2PhpUnitFoundationTest.php b/tests/Architecture/EvolvePhp2PhpUnitFoundationTest.php index 64eec74..fae337c 100644 --- a/tests/Architecture/EvolvePhp2PhpUnitFoundationTest.php +++ b/tests/Architecture/EvolvePhp2PhpUnitFoundationTest.php @@ -16,6 +16,8 @@ public function testRootRequiresPhpUnitOnlyAsADevelopmentDependency(): void $manifest = $this->readJsonFile('composer.json'); $this->assertArrayHasKey('require-dev', $manifest); + $this->assertArrayHasKey('evolvephp/dev-tools', $manifest['require-dev']); + $this->assertSame('^2.0@dev', $manifest['require-dev']['evolvephp/dev-tools']); $this->assertArrayHasKey('evolvephp/testing', $manifest['require-dev']); $this->assertSame('^2.0@dev', $manifest['require-dev']['evolvephp/testing']); $this->assertArrayHasKey('phpunit/phpunit', $manifest['require-dev']); @@ -39,7 +41,7 @@ public function testRootComposerScriptsRunTheApprovedPhpUnitSuites(): void } } - public function testRootPhpUnitConfigurationDefinesSixPackageSuites(): void + public function testRootPhpUnitConfigurationDefinesSevenPackageSuites(): void { $path = $this->projectPath('phpunit.xml.dist'); @@ -110,6 +112,7 @@ public function testDevelopmentGuideDocumentsPhpUnitFoundationPolicy(): void $this->assertMatchesPattern('/composer test/i', $content); $this->assertMatchesPattern('/test:contracts/i', $content); $this->assertMatchesPattern('/test:core/i', $content); + $this->assertMatchesPattern('/test:dev-tools/i', $content); $this->assertMatchesPattern('/test:http/i', $content); $this->assertMatchesPattern('/test:module/i', $content); $this->assertMatchesPattern('/test:plugin/i', $content); @@ -132,7 +135,7 @@ public function testDevelopmentGuideOwnsDetailedPackageTestDocumentation(): void $this->assertMatchesPattern('/phpunit\.xml\.dist/i', $developmentGuide); $this->assertMatchesPattern('/PHPUnit 13.*root|root.*PHPUnit 13/i', $developmentGuide); - foreach (array('test:contracts', 'test:core', 'test:http', 'test:module', 'test:plugin', 'test:testing') as $script) { + foreach (array('test:contracts', 'test:core', 'test:dev-tools', 'test:http', 'test:module', 'test:plugin', 'test:testing') as $script) { $this->assertMatchesPattern('/' . preg_quote($script, '/') . '/i', $developmentGuide); } @@ -143,7 +146,7 @@ public function testDevelopmentGuideOwnsDetailedPackageTestDocumentation(): void $this->assertMatchesPattern('/DEVELOPMENT\.md/i', $packagesReadme); $this->assertMatchesPattern('/testing.*quality|quality.*testing/i', $packagesReadme); $this->assertDoesNotMatchPattern('/tests\/Unit\//i', $packagesReadme); - $this->assertDoesNotMatchPattern('/test:contracts.*test:core.*test:http.*test:module.*test:plugin.*test:testing/is', $packagesReadme); + $this->assertDoesNotMatchPattern('/test:contracts.*test:core.*test:dev-tools.*test:http.*test:module.*test:plugin.*test:testing/is', $packagesReadme); $this->assertDoesNotMatchPattern('/phpunit\.xml\.dist.*PHPUnit|PHPUnit.*phpunit\.xml\.dist/is', $packagesReadme); } @@ -169,6 +172,10 @@ private function packageSuites() 'tests' => 'packages/core/tests', 'smokeTest' => 'packages/core/tests/Unit/PackageManifestTest.php', ), + 'dev-tools' => array( + 'tests' => 'packages/dev-tools/tests', + 'smokeTest' => 'packages/dev-tools/tests/Unit/PackageManifestTest.php', + ), 'http' => array( 'tests' => 'packages/http/tests', 'smokeTest' => 'packages/http/tests/Unit/PackageManifestTest.php', @@ -194,6 +201,7 @@ private function workspaceScripts() 'test' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist', 'test:contracts' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite contracts', 'test:core' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite core', + 'test:dev-tools' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite dev-tools', 'test:http' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite http', 'test:module' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite module', 'test:plugin' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite plugin', @@ -206,6 +214,7 @@ private function expectedLockedPackages() return array( 'evolvephp/contracts', 'evolvephp/core', + 'evolvephp/dev-tools', 'evolvephp/http', 'evolvephp/module', 'evolvephp/plugin', diff --git a/tests/Architecture/EvolvePhp2RootComposerTest.php b/tests/Architecture/EvolvePhp2RootComposerTest.php index fc4873e..348e408 100644 --- a/tests/Architecture/EvolvePhp2RootComposerTest.php +++ b/tests/Architecture/EvolvePhp2RootComposerTest.php @@ -66,6 +66,7 @@ public function testRootRequiresOnlyAcceptedProductionAndDevelopmentDependencySe $expectedRequireDev = array( 'deptrac/deptrac' => '^4.7', + 'evolvephp/dev-tools' => '^2.0@dev', 'evolvephp/testing' => '^2.0@dev', 'friendsofphp/php-cs-fixer' => '^3.95', 'phpstan/phpstan' => '^2.2', @@ -110,6 +111,7 @@ public function testRootComposerManifestDeclaresOnlyApprovedDevelopmentScripts() 'licenses:check' => '@php tools/check-licenses.php', 'quality' => array('@architecture', '@analyse', '@style:check', '@test'), 'release:consumer:validate' => '@php tools/validate-prerelease-consumers.php', + 'release:skeleton:validate' => '@php tools/validate-skeleton-project.php', 'release:split:validate' => '@php tools/validate-package-splits.php', 'release:validate' => '@php tools/validate-release-packages.php', 'security:audit' => '@composer audit --locked --abandoned=fail', @@ -119,6 +121,7 @@ public function testRootComposerManifestDeclaresOnlyApprovedDevelopmentScripts() 'test' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist', 'test:contracts' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite contracts', 'test:core' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite core', + 'test:dev-tools' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite dev-tools', 'test:http' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite http', 'test:module' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite module', 'test:plugin' => '@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite plugin', @@ -146,6 +149,7 @@ public function testRootLockQualityReleaseAndDevelopmentFilesExist(): void 'tools/validate-package-splits.php', 'tools/validate-prerelease-consumers.php', 'tools/validate-release-packages.php', + 'tools/validate-skeleton-project.php', ) as $path) { $this->assertFileExists($this->projectPath($path), $path . ' should exist at repository root after cutover.'); } @@ -180,7 +184,7 @@ public function testPreservationHistoryAndGovernanceFilesRemainPresent(): void } } - public function testReleaseMapContainsOnlyTheSixReleasePackages(): void + public function testReleaseMapContainsOnlyTheSevenReleasePackages(): void { $map = $this->readJsonFile('release-packages.json'); @@ -193,6 +197,7 @@ public function testReleaseMapContainsOnlyTheSixReleasePackages(): void array('name' => 'evolvephp/plugin', 'directory' => 'packages/plugin'), array('name' => 'evolvephp/http', 'directory' => 'packages/http'), array('name' => 'evolvephp/testing', 'directory' => 'packages/testing'), + array('name' => 'evolvephp/dev-tools', 'directory' => 'packages/dev-tools'), ), $map['packages'] ); @@ -207,7 +212,7 @@ public function testRootProjectIsDocumentedAsDevelopmentRootAndNotSeventhRelease foreach (array($readme, $development) as $content) { $this->assertMatchesPattern('/repository root.*EvolvePHP 2.*development root|EvolvePHP 2.*development root.*repository root/is', $content); - $this->assertMatchesPattern('/not a seventh release package|not.*publishable.*framework package/is', $content); + $this->assertMatchesPattern('/not a release package|not.*publishable.*framework package/is', $content); } } @@ -216,6 +221,7 @@ private function allPackages() return array( 'evolvephp/contracts', 'evolvephp/core', + 'evolvephp/dev-tools', 'evolvephp/http', 'evolvephp/module', 'evolvephp/plugin', diff --git a/tests/Architecture/EvolvePhp2StaticAnalysisAndCodingStandardsTest.php b/tests/Architecture/EvolvePhp2StaticAnalysisAndCodingStandardsTest.php index ad56e8c..c10a3ed 100644 --- a/tests/Architecture/EvolvePhp2StaticAnalysisAndCodingStandardsTest.php +++ b/tests/Architecture/EvolvePhp2StaticAnalysisAndCodingStandardsTest.php @@ -241,7 +241,7 @@ private function packageAnalysisPaths() { $paths = array(); - foreach (array('contracts', 'core', 'http', 'module', 'plugin', 'testing') as $package) { + foreach (array('contracts', 'core', 'dev-tools', 'http', 'module', 'plugin', 'testing') as $package) { $paths[] = 'packages/' . $package . '/src'; $paths[] = 'packages/' . $package . '/tests'; } @@ -254,6 +254,7 @@ private function packageManifests() return array( 'packages/contracts/composer.json', 'packages/core/composer.json', + 'packages/dev-tools/composer.json', 'packages/http/composer.json', 'packages/module/composer.json', 'packages/plugin/composer.json', @@ -266,6 +267,7 @@ private function packageSourceDirectories() return array( 'packages/contracts/src', 'packages/core/src', + 'packages/dev-tools/src', 'packages/http/src', 'packages/module/src', 'packages/plugin/src', @@ -283,7 +285,7 @@ private function trackedFiles() $output = array(); $exitCode = 0; - exec('git ls-files', $output, $exitCode); + exec('git ls-files --cached --others --exclude-standard', $output, $exitCode); $this->assertSame(0, $exitCode, 'git ls-files should succeed.'); diff --git a/tests/Documentation/EvolvePhp2ReadmeAndMetadataConsistencyTest.php b/tests/Documentation/EvolvePhp2ReadmeAndMetadataConsistencyTest.php index 28cd6ed..19edae2 100644 --- a/tests/Documentation/EvolvePhp2ReadmeAndMetadataConsistencyTest.php +++ b/tests/Documentation/EvolvePhp2ReadmeAndMetadataConsistencyTest.php @@ -21,10 +21,12 @@ public function testTrackedReadmeInventoryIsTheExpectedCanonicalSet(): void 'packages/README.md', 'packages/contracts/README.md', 'packages/core/README.md', + 'packages/dev-tools/README.md', 'packages/http/README.md', 'packages/module/README.md', 'packages/plugin/README.md', 'packages/testing/README.md', + 'skeleton/README.md', ), $this->trackedReadmes() ); @@ -96,7 +98,7 @@ public function testDevelopmentGuideOwnsInstallUpdateAndQualityToolingPolicy(): $this->assertStringContainsString('composer ' . $script, $content); } - foreach (array('test:contracts', 'test:core', 'test:http', 'test:module', 'test:plugin', 'test:testing') as $script) { + foreach (array('test:contracts', 'test:core', 'test:dev-tools', 'test:http', 'test:module', 'test:plugin', 'test:testing') as $script) { $this->assertStringContainsString('composer ' . $script, $content); } } @@ -220,6 +222,7 @@ private function expectedArchitectureMatrixLines() return array( 'Contracts -> none', 'Core -> Contracts', + 'DevTools -> Contracts, Core, Module, Plugin', 'Http -> Contracts, Core', 'Module -> Contracts', 'Plugin -> Contracts', @@ -247,7 +250,7 @@ private function trackedFiles() $output = array(); $exitCode = 0; - exec('git ls-files', $output, $exitCode); + exec('git ls-files --cached --others --exclude-standard', $output, $exitCode); $this->assertSame(0, $exitCode, 'git ls-files should succeed.'); diff --git a/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php b/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php index f74e355..e764443 100644 --- a/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php +++ b/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php @@ -25,6 +25,7 @@ public function testReleasePackageMapDefinesCanonicalDependencyCompatibleOrder() array('name' => 'evolvephp/plugin', 'directory' => 'packages/plugin'), array('name' => 'evolvephp/http', 'directory' => 'packages/http'), array('name' => 'evolvephp/testing', 'directory' => 'packages/testing'), + array('name' => 'evolvephp/dev-tools', 'directory' => 'packages/dev-tools'), ), $map['packages'] ); @@ -57,7 +58,7 @@ public function testPackageReadmesDocumentPublicationStatusWithoutInventingRemot $this->assertStringContainsString('BSD-3-Clause', $content); $this->assertStringContainsString('`LICENSE.md`', $content); $this->assertDoesNotMatchPattern('/composer require/i', $content); - $this->assertDoesNotMatchPattern('/github\.com\/josiahking\/evolvephp[-\/](?:contracts|core|http|module|plugin|testing)/i', $content); + $this->assertDoesNotMatchPattern('/github\.com\/josiahking\/evolvephp[-\/](?:contracts|core|dev-tools|http|module|plugin|testing)/i', $content); } } @@ -74,6 +75,19 @@ public function testPackageLicenceFilesMatchRootLicenceByteForByte(): void } } + public function testPackageLicenceWhitespaceExceptionIsNarrowAndDeliberate(): void + { + $attributes = $this->readProjectFile('.gitattributes'); + + $this->assertSame( + array( + '# Package licences intentionally mirror root LICENSE.md byte-for-byte.', + 'packages/*/LICENSE.md -whitespace', + ), + $this->nonEmptyLines($attributes) + ); + } + public function testReleaseValidatorIsReadOnlyNetworkFreeAndPortable(): void { $content = $this->readProjectFile('tools/validate-release-packages.php'); @@ -129,7 +143,7 @@ public function testWorkspaceReadmeDocumentsReleaseValidationBoundaries(): void '/## Release Validation/', '/composer release:validate/', '/deterministic\/offline|offline.*deterministic/i', - '/six packages.*mapped explicitly|mapped explicitly.*six packages/i', + '/seven packages.*mapped explicitly|mapped explicitly.*seven packages|extends.*map.*seven packages/i', '/dependency-compatible/i', '/package-local README/i', '/package-local.*licen[cs]es/i', @@ -177,6 +191,13 @@ private function packages() 'responsibility' => 'Application kernel and runtime-neutral orchestration for EvolvePHP 2.', 'dependencies' => '`evolvephp/contracts`', ), + array( + 'name' => 'evolvephp/dev-tools', + 'directory' => 'packages/dev-tools', + 'human' => 'EvolvePHP DevTools', + 'responsibility' => 'Development-time generators and tooling for EvolvePHP 2 applications.', + 'dependencies' => '`evolvephp/contracts`, `evolvephp/core`, `evolvephp/module`, `evolvephp/plugin`', + ), array( 'name' => 'evolvephp/module', 'directory' => 'packages/module', @@ -224,6 +245,17 @@ private function readProjectFile($path) return $content; } + /** + * @return list + */ + private function nonEmptyLines(string $content): array + { + return array_values(array_filter( + preg_split('/\r?\n/', $content) ?: array(), + static fn (string $line): bool => $line !== '' + )); + } + private function readJsonFile($path) { $content = $this->readProjectFile($path); diff --git a/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php b/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php index 3b11a5b..f49ec64 100644 --- a/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php +++ b/tests/Documentation/EvolvePhp2ReleaseSplitAndConsumerValidationTest.php @@ -11,13 +11,14 @@ protected function setUp(): void $this->root = dirname(__DIR__, 2); } - public function testReleaseSplitAndConsumerValidationToolsAreRepositoryOwnedPhpEntrypoints(): void + public function testReleaseSplitConsumerAndSkeletonValidationToolsAreRepositoryOwnedPhpEntrypoints(): void { $this->assertFileExists($this->path('tools/release-validation-common.php')); foreach (array( 'tools/validate-package-splits.php', 'tools/validate-prerelease-consumers.php', + 'tools/validate-skeleton-project.php', ) as $path) { $content = $this->readProjectFile($path); @@ -35,6 +36,7 @@ public function testSharedHelperUsesArgumentVectorProcessesAndDoesNotRunOnInclud $this->assertStringContainsString('proc_open(', $content); $this->assertStringContainsString('bypass_shell', $content); $this->assertStringContainsString('loadReleasePackages', $content); + $this->assertStringContainsString('loadLockedPackageRepositoryPackages', $content); $this->assertStringContainsString('createTemporaryDirectory', $content); $this->assertStringContainsString('removeDirectory', $content); $this->assertDoesNotMatchRegularExpression('/\\b(?:shell_exec|exec|passthru|system)\\s*\\(/', $content); @@ -42,6 +44,57 @@ public function testSharedHelperUsesArgumentVectorProcessesAndDoesNotRunOnInclud $this->assertDoesNotMatchRegularExpression('/EvolvePHP .* validation passed/', $content); } + public function testSharedProcessRunnerBoundsProcessesAndReportsStage(): void + { + require_once $this->path('tools/release-validation-common.php'); + + $runner = new ReleaseValidationProcessRunner(1); + $startedAt = microtime(true); + + try { + $runner->run( + array(PHP_BINARY, '-r', "fwrite(STDOUT, 'started'); sleep(3);"), + null, + array(), + 'slow fixture' + ); + + $this->fail('Slow fixture should time out.'); + } catch (ReleaseValidationFailure $failure) { + $elapsed = microtime(true) - $startedAt; + + $this->assertLessThan(3.0, $elapsed, 'The timeout fixture must not wait for the full sleep duration.'); + $this->assertStringContainsString('Process timed out after 1 second during slow fixture', $failure->getMessage()); + $this->assertStringContainsString(PHP_BINARY, $failure->getMessage()); + } + } + + public function testSharedProcessRunnerAllowsShortProcessesWithTimeout(): void + { + require_once $this->path('tools/release-validation-common.php'); + + $runner = new ReleaseValidationProcessRunner(5); + $result = $runner->run(array(PHP_BINARY, '-r', "fwrite(STDOUT, 'ok');"), null, array(), 'short fixture'); + + $this->assertSame(0, $result->exitCode); + $this->assertSame('ok', $result->stdout); + $this->assertSame('', $result->stderr); + } + + public function testTemporaryDirectoryCleanupRemovesValidatorOwnedPath(): void + { + require_once $this->path('tools/release-validation-common.php'); + + $temporary = createTemporaryDirectory('evolvephp-cleanup-contract-test-'); + $path = $temporary->path; + + file_put_contents($temporary->child('marker.txt'), 'marker'); + + $temporary->cleanup(); + + $this->assertDirectoryDoesNotExist($path); + } + public function testSourceStateCaptureUsesDeterministicRefEnumerationForDetachedCi(): void { $content = $this->readProjectFile('tools/release-validation-common.php'); @@ -199,6 +252,92 @@ function_exists('loadLockedRuntimePackageRepositoryPackages'), $this->assertStringContainsString("'COMPOSER_DISABLE_NETWORK' => '1'", $content); } + public function testSkeletonValidatorUsesMinimalLockedOfflineVendorClosure(): void + { + require_once $this->path('tools/release-validation-common.php'); + + $this->assertTrue( + function_exists('loadSkeletonLockedPackageRepositoryPackages'), + 'release-validation-common.php must expose skeleton-specific lockfile package metadata.' + ); + + $fixtures = loadSkeletonLockedPackageRepositoryPackages($this->root); + $fixtureNames = array_column($fixtures, 'name'); + $sortedFixtureNames = $fixtureNames; + + sort($sortedFixtureNames); + + $this->assertSame($sortedFixtureNames, $fixtureNames, 'Skeleton offline package metadata must be sorted deterministically.'); + + foreach (array('phpunit/phpunit', 'psr/container', 'psr/http-message') as $packageName) { + $this->assertContains($packageName, $fixtureNames, $packageName . ' must be available to offline skeleton validation.'); + } + + foreach (array('deptrac/deptrac', 'friendsofphp/php-cs-fixer', 'phpstan/phpstan') as $packageName) { + $this->assertNotContains($packageName, $fixtureNames, $packageName . ' must not be copied into the skeleton validator repository.'); + } + + $this->assertLessThan( + count($this->lockedPackagesByName($this->readJsonFile('composer.lock')['packages-dev'])), + count($fixtureNames), + 'Skeleton validation should not expose every dev package from the root vendor directory.' + ); + } + + public function testSkeletonValidatorDocumentsRealOfflineCreateProjectContract(): void + { + $content = $this->readProjectFile('tools/validate-skeleton-project.php'); + + foreach (array( + 'captureSourceState', + 'assertSourceStatePreserved', + 'createTemporaryDirectory', + 'loadSkeletonLockedPackageRepositoryPackages', + 'create-project', + 'COMPOSER_DISABLE_NETWORK', + 'packagist.org', + 'symlink', + 'composer validate --strict', + 'bin/evolve', + 'doctor', + 'route:list', + 'No routes are configured.', + 'No command was specified.', + 'Command "missing" was not found.', + 'The route:list command does not accept arguments or options.', + 'module:new', + 'plugin:new', + 'composer install --no-dev', + 'Source repository state preserved.', + ) as $needle) { + $this->assertStringContainsString($needle, $content); + } + + foreach (array( + '[1/13] Preparing offline repositories', + '[2/13] Running Composer create-project', + '[3/13] Validating generated manifest', + '[4/13] Validating installed packages', + '[5/13] Running generated Doctor', + '[6/13] Running generated route:list', + '[7/13] Running generated module:new', + '[8/13] Running generated plugin:new', + '[9/13] Running generated test suite', + '[10/13] Running collision and traversal checks', + '[11/13] Running Composer install --no-dev', + '[12/13] Running no-dev Doctor and route:list', + '[13/13] Cleaning up and preserving source state', + ) as $stage) { + $this->assertStringContainsString($stage, $content); + } + + $this->assertStringContainsString('loadSkeletonLockedPackageRepositoryPackages', $content); + $this->assertStringContainsString('prepareOfflineVendorRepository', $content); + $this->assertStringNotContainsString('joinPaths($root, \'vendor/*/*\')', $content); + $this->assertDoesNotMatchRegularExpression('/\\b(?:curl|gh|git push|remote add|config --global|shell_exec|exec|passthru|system)\\b/i', $content); + $this->assertDoesNotMatchRegularExpression('/\\b(?:robocopy|xcopy)\\b/i', $content); + } + public function testWorkspaceComposerExposesReleaseValidationScriptsWithoutPrepareScript(): void { $manifest = $this->readJsonFile('composer.json'); @@ -208,6 +347,7 @@ public function testWorkspaceComposerExposesReleaseValidationScriptsWithoutPrepa $this->assertSame('@php tools/validate-release-packages.php', $manifest['scripts']['release:validate']); $this->assertSame('@php tools/validate-package-splits.php', $manifest['scripts']['release:split:validate']); $this->assertSame('@php tools/validate-prerelease-consumers.php', $manifest['scripts']['release:consumer:validate']); + $this->assertSame('@php tools/validate-skeleton-project.php', $manifest['scripts']['release:skeleton:validate']); $this->assertArrayNotHasKey('release:prepare', $manifest['scripts']); } @@ -232,14 +372,16 @@ public function testPackageManifestsRetainStableInternalConstraintsAndNoStabilit } } - public function testCiRunsOnlyPackageSplitValidationInExistingPolicyJob(): void + public function testCiRunsPackageSplitAndSkeletonValidationInExistingPolicyJob(): void { $workflow = $this->readProjectFile('.github/workflows/quality.yml'); $this->assertSame(1, substr_count($workflow, 'name: Policy (PHP 8.4)')); $this->assertSame(1, substr_count($workflow, 'name: Workspace quality (PHP ${{ matrix.php }})')); $this->assertSame(1, substr_count($workflow, 'Run release package split validation')); + $this->assertSame(1, substr_count($workflow, 'Run application skeleton create-project validation')); $this->assertStringContainsString('composer release:split:validate', $workflow); + $this->assertStringContainsString('composer release:skeleton:validate', $workflow); $this->assertStringNotContainsString('release:consumer:validate', $workflow); $this->assertStringContainsString('Run root supply-chain checks', $workflow); $this->assertStringContainsString('Run root policy tests', $workflow); @@ -252,6 +394,17 @@ public function testWorkspaceReadmeDocumentsAlphaConsumerPolicyAndDeferredPublic foreach (array( 'composer release:split:validate', 'composer release:consumer:validate', + 'composer release:skeleton:validate', + 'skeleton/', + 'evolvephp/skeleton', + 'public experimental', + 'CliApplication', + 'StreamCommandOutput', + 'application CLI composition is explicit', + 'Core remains independent of HTTP', + 'Packagist create-project availability is not yet claimed', + 'Phase 6.4E', + 'Phase 6.4F', 'minimum-stability: alpha', 'prefer-stable: true', 'Explicit root `@alpha` flags', @@ -299,7 +452,7 @@ private function releasePackages() $map = $this->readJsonFile('release-packages.json'); $this->assertSame(1, $map['version']); - $this->assertCount(6, $map['packages']); + $this->assertCount(7, $map['packages']); return $map['packages']; } diff --git a/tools/release-validation-common.php b/tools/release-validation-common.php index ba48bed..e7b0a1d 100644 --- a/tools/release-validation-common.php +++ b/tools/release-validation-common.php @@ -25,11 +25,26 @@ public function output(): string final class ReleaseValidationProcessRunner { + private const DEFAULT_TIMEOUT_SECONDS = 300; + + public function __construct( + private readonly int $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS, + ) { + if ($timeoutSeconds < 1) { + throw new ReleaseValidationFailure('Process timeout must be at least 1 second.'); + } + } + /** * @param list $command * @param array $environment */ - public function run(array $command, ?string $workingDirectory = null, array $environment = array()): ReleaseValidationProcessResult + public function run( + array $command, + ?string $workingDirectory = null, + array $environment = array(), + ?string $stage = null, + ): ReleaseValidationProcessResult { if ($command === array()) { throw new ReleaseValidationFailure('Cannot run an empty process command.'); @@ -41,10 +56,12 @@ public function run(array $command, ?string $workingDirectory = null, array $env } } + $stdoutPath = $this->createProcessOutputFile('stdout'); + $stderrPath = $this->createProcessOutputFile('stderr'); $descriptorSpec = array( 0 => array('pipe', 'r'), - 1 => array('pipe', 'w'), - 2 => array('pipe', 'w'), + 1 => array('file', $stdoutPath, 'w'), + 2 => array('file', $stderrPath, 'w'), ); $processEnvironment = null; @@ -64,41 +81,179 @@ public function run(array $command, ?string $workingDirectory = null, array $env ); if (!is_resource($process)) { + $this->removeProcessOutputFiles($stdoutPath, $stderrPath); + throw new ReleaseValidationFailure('Unable to start process: ' . describeCommand($command)); } fclose($pipes[0]); - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $exitCode = proc_close($process); + $deadline = microtime(true) + $this->timeoutSeconds; + $lastStatus = proc_get_status($process); + + try { + while ($lastStatus['running']) { + if (microtime(true) >= $deadline) { + $this->terminateProcess($process, $lastStatus['pid']); + $exitCode = proc_close($process); + $stdout = $this->readProcessOutputFile($stdoutPath); + $stderr = $this->readProcessOutputFile($stderrPath); + + throw new ReleaseValidationFailure( + 'Process timed out after ' + . $this->formatTimeout() + . $this->stageDescription($stage) + . ': ' + . describeCommand($command) + . $this->capturedOutputDescription($stdout, $stderr) + ); + } - return new ReleaseValidationProcessResult( - $command, - $exitCode, - is_string($stdout) ? $stdout : '', - is_string($stderr) ? $stderr : '' - ); + usleep(10000); + $lastStatus = proc_get_status($process); + } + + $exitCode = proc_close($process); + + if ($exitCode === -1 && isset($lastStatus['exitcode']) && is_int($lastStatus['exitcode'])) { + $exitCode = $lastStatus['exitcode']; + } + + return new ReleaseValidationProcessResult( + $command, + $exitCode, + $this->readProcessOutputFile($stdoutPath), + $this->readProcessOutputFile($stderrPath) + ); + } finally { + $this->removeProcessOutputFiles($stdoutPath, $stderrPath); + } } /** * @param list $command * @param array $environment */ - public function mustRun(array $command, ?string $workingDirectory = null, array $environment = array()): ReleaseValidationProcessResult + public function mustRun( + array $command, + ?string $workingDirectory = null, + array $environment = array(), + ?string $stage = null, + ): ReleaseValidationProcessResult { - $result = $this->run($command, $workingDirectory, $environment); + $result = $this->run($command, $workingDirectory, $environment, $stage); if ($result->exitCode !== 0) { throw new ReleaseValidationFailure( - 'Process failed with exit code ' . $result->exitCode . ': ' . describeCommand($command) . "\n" . $result->output() + 'Process failed with exit code ' + . $result->exitCode + . $this->stageDescription($stage) + . ': ' + . describeCommand($command) + . "\n" + . $result->output() ); } return $result; } + + /** + * @param resource $process + */ + private function terminateProcess(mixed $process, int $pid): void + { + if (DIRECTORY_SEPARATOR === '\\' && $pid > 0 && $this->terminateWindowsProcessTree($pid)) { + return; + } + + proc_terminate($process); + $deadline = microtime(true) + 1.0; + + do { + $status = proc_get_status($process); + + if (!$status['running']) { + return; + } + + usleep(10000); + } while (microtime(true) < $deadline); + + proc_terminate($process, 9); + } + + private function terminateWindowsProcessTree(int $pid): bool + { + $process = proc_open( + array('taskkill', '/PID', (string) $pid, '/T', '/F'), + array( + 0 => array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ), + $pipes, + null, + null, + array('bypass_shell' => true) + ); + + if (!is_resource($process)) { + return false; + } + + fclose($pipes[0]); + stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return proc_close($process) === 0; + } + + private function stageDescription(?string $stage): string + { + return $stage === null ? '' : ' during ' . $stage; + } + + private function formatTimeout(): string + { + return $this->timeoutSeconds . ' ' . ($this->timeoutSeconds === 1 ? 'second' : 'seconds'); + } + + private function capturedOutputDescription(string $stdout, string $stderr): string + { + $output = trim($stdout . "\n" . $stderr); + + return $output === '' ? '' : "\n" . $output; + } + + private function createProcessOutputFile(string $label): string + { + $path = tempnam(sys_get_temp_dir(), 'evolvephp-process-' . $label . '-'); + + if ($path === false) { + throw new ReleaseValidationFailure('Unable to allocate process ' . $label . ' capture file.'); + } + + return $path; + } + + private function readProcessOutputFile(string $path): string + { + $output = file_get_contents($path); + + return is_string($output) ? $output : ''; + } + + private function removeProcessOutputFiles(string $stdoutPath, string $stderrPath): void + { + foreach (array($stdoutPath, $stderrPath) as $path) { + if (is_file($path)) { + @unlink($path); + } + } + } } final class ReleaseValidationTemporaryDirectory @@ -257,92 +412,200 @@ function readJsonFile(string $path, string $label) */ function loadLockedRuntimePackageRepositoryPackages(string $root): array { - $lock = readJsonFile(joinPaths($root, 'composer.lock'), 'composer.lock'); + return loadLockedPackageRepositoryPackages($root); +} - if (!is_array($lock)) { - releaseValidationFail('composer.lock must decode to an object.'); +/** + * @return list> + */ +function loadSkeletonLockedPackageRepositoryPackages(string $root): array +{ + $manifest = readJsonFile(joinPaths($root, 'skeleton/composer.json'), 'skeleton composer.json'); + $lockedPackages = lockedPackageRepositoryPackagesByName($root, true); + $packageNames = array(); + $queue = array(); + + foreach (array('require', 'require-dev') as $section) { + if (!isset($manifest[$section])) { + continue; + } + + if (!is_array($manifest[$section])) { + releaseValidationFail('skeleton composer.json ' . $section . ' must be an object.'); + } + + foreach (array_keys($manifest[$section]) as $packageName) { + if (is_string($packageName) && isSkeletonExternalPackageName($packageName)) { + $queue[] = $packageName; + } + } } - if (!array_key_exists('packages', $lock) || !is_array($lock['packages'])) { - releaseValidationFail('composer.lock packages must be an array.'); + foreach (loadReleasePackages($root) as $package) { + $packageManifest = readJsonFile(joinPaths($root, $package['directory'] . '/composer.json'), $package['name'] . ' composer.json'); + + if (!isset($packageManifest['require']) || !is_array($packageManifest['require'])) { + releaseValidationFail($package['name'] . ' composer.json require must be an object.'); + } + + foreach (array_keys($packageManifest['require']) as $packageName) { + if (is_string($packageName) && isSkeletonExternalPackageName($packageName)) { + $queue[] = $packageName; + } + } } - $packages = array(); + while ($queue !== array()) { + $packageName = array_shift($queue); - foreach ($lock['packages'] as $index => $package) { - $entry = $index + 1; + if (!is_string($packageName) || isset($packageNames[$packageName])) { + continue; + } - if (!is_array($package)) { - releaseValidationFail('composer.lock packages entry ' . $entry . ' must be an object.'); + if (!isset($lockedPackages[$packageName])) { + releaseValidationFail('Skeleton offline dependency is missing from composer.lock: ' . $packageName); } - $name = $package['name'] ?? null; + $packageNames[$packageName] = true; + $requires = $lockedPackages[$packageName]['require'] ?? array(); - if (!is_string($name) || $name === '') { - releaseValidationFail('composer.lock packages entry ' . $entry . ' must contain a non-empty name.'); + if (!is_array($requires)) { + releaseValidationFail('composer.lock package ' . $packageName . ' require field must be an object.'); } - if (str_starts_with($name, 'evolvephp/') || isPlatformPackageName($name)) { - continue; + foreach (array_keys($requires) as $requiredPackageName) { + if (is_string($requiredPackageName) && isSkeletonExternalPackageName($requiredPackageName)) { + $queue[] = $requiredPackageName; + } } + } + + $selected = array(); - $version = $package['version'] ?? null; + foreach (array_keys($packageNames) as $packageName) { + $vendorPath = joinPaths($root, 'vendor/' . $packageName); - if (!is_string($version) || $version === '') { - releaseValidationFail('composer.lock package ' . $name . ' must contain a non-empty version.'); + if (!is_dir($vendorPath)) { + releaseValidationFail('Locked package is not installed locally for offline skeleton validation: ' . $packageName); } - if (isset($packages[$name])) { - releaseValidationFail('composer.lock packages contains duplicate runtime package metadata for ' . $name . '.'); + $selected[$packageName] = $lockedPackages[$packageName]; + } + + ksort($selected); + + return array_values($selected); +} + +/** + * @return list> + */ +function loadLockedPackageRepositoryPackages(string $root, bool $includeDev = false): array +{ + return array_values(lockedPackageRepositoryPackagesByName($root, $includeDev)); +} + +/** + * @return array> + */ +function lockedPackageRepositoryPackagesByName(string $root, bool $includeDev = false): array +{ + $lock = readJsonFile(joinPaths($root, 'composer.lock'), 'composer.lock'); + + if (!is_array($lock)) { + releaseValidationFail('composer.lock must decode to an object.'); + } + + foreach (array('packages', 'packages-dev') as $section) { + if (!array_key_exists($section, $lock) || !is_array($lock[$section])) { + releaseValidationFail('composer.lock ' . $section . ' must be an array.'); } + } - $definition = array( - 'name' => $name, - 'version' => $version, - ); + $packages = array(); + $sections = $includeDev ? array('packages', 'packages-dev') : array('packages'); + + foreach ($sections as $section) { + foreach ($lock[$section] as $index => $package) { + $entry = $index + 1; - foreach (array('require', 'conflict', 'replace', 'provide') as $field) { - if (!array_key_exists($field, $package)) { + if (!is_array($package)) { + releaseValidationFail('composer.lock ' . $section . ' entry ' . $entry . ' must be an object.'); + } + + $name = $package['name'] ?? null; + + if (!is_string($name) || $name === '') { + releaseValidationFail('composer.lock ' . $section . ' entry ' . $entry . ' must contain a non-empty name.'); + } + + if (str_starts_with($name, 'evolvephp/') || isPlatformPackageName($name)) { continue; } - if (!is_array($package[$field])) { - releaseValidationFail('composer.lock package ' . $name . ' field ' . $field . ' must be an object.'); + $version = $package['version'] ?? null; + + if (!is_string($version) || $version === '') { + releaseValidationFail('composer.lock package ' . $name . ' must contain a non-empty version.'); } - $definition[$field] = sortedAssociativeArray($package[$field]); - } + if (isset($packages[$name])) { + releaseValidationFail('composer.lock contains duplicate package repository metadata for ' . $name . '.'); + } + + $definition = array( + 'name' => $name, + 'version' => $version, + ); + + foreach (array('require', 'conflict', 'replace', 'provide') as $field) { + if (!array_key_exists($field, $package)) { + continue; + } + + if (!is_array($package[$field])) { + releaseValidationFail('composer.lock package ' . $name . ' field ' . $field . ' must be an object.'); + } - if (array_key_exists('type', $package)) { - if (!is_string($package['type']) || $package['type'] === '') { - releaseValidationFail('composer.lock package ' . $name . ' field type must be a non-empty string.'); + $definition[$field] = sortedAssociativeArray($package[$field]); } - $definition['type'] = $package['type']; - } + if (array_key_exists('type', $package)) { + if (!is_string($package['type']) || $package['type'] === '') { + releaseValidationFail('composer.lock package ' . $name . ' field type must be a non-empty string.'); + } - foreach (array('source', 'dist') as $field) { - if (!array_key_exists($field, $package)) { - continue; + $definition['type'] = $package['type']; } - if (!is_array($package[$field]) || $package[$field] === array()) { - releaseValidationFail('composer.lock package ' . $name . ' field ' . $field . ' must be an object.'); + foreach (array('source', 'dist') as $field) { + if (!array_key_exists($field, $package)) { + continue; + } + + if (!is_array($package[$field]) || $package[$field] === array()) { + releaseValidationFail('composer.lock package ' . $name . ' field ' . $field . ' must be an object.'); + } + + $definition[$field] = sortedAssociativeArray($package[$field]); } - $definition[$field] = sortedAssociativeArray($package[$field]); - } + if (!isset($definition['source']) && !isset($definition['dist'])) { + releaseValidationFail('composer.lock package ' . $name . ' must contain source or dist metadata for offline package repositories.'); + } - if (!isset($definition['source']) && !isset($definition['dist'])) { - releaseValidationFail('composer.lock package ' . $name . ' must contain source or dist metadata for offline package repositories.'); + $packages[$name] = $definition; } - - $packages[$name] = $definition; } ksort($packages); - return array_values($packages); + return $packages; +} + +function isSkeletonExternalPackageName(string $name): bool +{ + return !str_starts_with($name, 'evolvephp/') && !isPlatformPackageName($name); } function isPlatformPackageName(string $name): bool @@ -381,8 +644,8 @@ function loadReleasePackages(string $root): array releaseValidationFail('release-packages.json version must be exactly 1.'); } - if (!is_array($map['packages']) || count($map['packages']) !== 6) { - releaseValidationFail('release-packages.json must contain exactly six package entries.'); + if (!is_array($map['packages']) || count($map['packages']) !== 7) { + releaseValidationFail('release-packages.json must contain exactly seven package entries.'); } $expected = array( @@ -392,6 +655,7 @@ function loadReleasePackages(string $root): array array('name' => 'evolvephp/plugin', 'directory' => 'packages/plugin'), array('name' => 'evolvephp/http', 'directory' => 'packages/http'), array('name' => 'evolvephp/testing', 'directory' => 'packages/testing'), + array('name' => 'evolvephp/dev-tools', 'directory' => 'packages/dev-tools'), ); $packages = array(); diff --git a/tools/validate-prerelease-consumers.php b/tools/validate-prerelease-consumers.php index b10e5dc..b8c96b7 100644 --- a/tools/validate-prerelease-consumers.php +++ b/tools/validate-prerelease-consumers.php @@ -52,10 +52,19 @@ public function validate(string $root, string $ref, ?string $composer): array 'evolvephp/core' => self::ALPHA_VERSION, 'evolvephp/http' => self::ALPHA_VERSION, )); - $results[] = $this->runExpectedFailureCase($temp, $composer, 'Full-graph case E', $alphaRepositories, $lockedRuntimePackages, array('evolvephp/testing' => '^2.0@alpha')); - $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Full-graph case F', $alphaRepositories, $lockedRuntimePackages, array('evolvephp/testing' => '^2.0'), array('minimum-stability' => 'alpha', 'prefer-stable' => true), $this->expectedVersions($packages, self::ALPHA_VERSION)); + $results[] = $this->runExpectedFailureCase($temp, $composer, 'Full-graph case E', $alphaRepositories, $lockedRuntimePackages, array( + 'evolvephp/dev-tools' => '^2.0@alpha', + 'evolvephp/testing' => '^2.0@alpha', + )); + $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Full-graph case F', $alphaRepositories, $lockedRuntimePackages, array( + 'evolvephp/dev-tools' => '^2.0', + 'evolvephp/testing' => '^2.0', + ), array('minimum-stability' => 'alpha', 'prefer-stable' => true), $this->expectedVersions($packages, self::ALPHA_VERSION)); $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Full-graph case G', $alphaRepositories, $lockedRuntimePackages, $this->explicitAlphaRootRequirements($packages), array(), $this->expectedVersions($packages, self::ALPHA_VERSION)); - $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Stable case H', $stableRepositories, $lockedRuntimePackages, array('evolvephp/testing' => '^2.0'), array(), $this->expectedVersions($packages, self::STABLE_VERSION)); + $results[] = $this->runExpectedSuccessCase($temp, $composer, 'Stable case H', $stableRepositories, $lockedRuntimePackages, array( + 'evolvephp/dev-tools' => '^2.0', + 'evolvephp/testing' => '^2.0', + ), array(), $this->expectedVersions($packages, self::STABLE_VERSION)); assertSourceStatePreserved($this->runner, $root, $sourceState); diff --git a/tools/validate-release-packages.php b/tools/validate-release-packages.php index 26072ed..e026e98 100644 --- a/tools/validate-release-packages.php +++ b/tools/validate-release-packages.php @@ -87,6 +87,7 @@ function expectedPackages(): array 'evolvephp/plugin' => array('name' => 'evolvephp/plugin', 'directory' => 'packages/plugin'), 'evolvephp/http' => array('name' => 'evolvephp/http', 'directory' => 'packages/http'), 'evolvephp/testing' => array('name' => 'evolvephp/testing', 'directory' => 'packages/testing'), + 'evolvephp/dev-tools' => array('name' => 'evolvephp/dev-tools', 'directory' => 'packages/dev-tools'), ); } @@ -98,6 +99,7 @@ function expectedNamespaces(): array return array( 'evolvephp/contracts' => 'Evolve\\Contracts\\', 'evolvephp/core' => 'Evolve\\Core\\', + 'evolvephp/dev-tools' => 'Evolve\\DevTools\\', 'evolvephp/module' => 'Evolve\\Module\\', 'evolvephp/plugin' => 'Evolve\\Plugin\\', 'evolvephp/http' => 'Evolve\\Http\\', @@ -117,6 +119,7 @@ function expectedGraph(): array 'evolvephp/plugin' => array('evolvephp/contracts'), 'evolvephp/http' => array('evolvephp/contracts', 'evolvephp/core'), 'evolvephp/testing' => array('evolvephp/contracts', 'evolvephp/core', 'evolvephp/http', 'evolvephp/module', 'evolvephp/plugin'), + 'evolvephp/dev-tools' => array('evolvephp/contracts', 'evolvephp/core', 'evolvephp/module', 'evolvephp/plugin'), ); } @@ -140,8 +143,8 @@ function validateMap(string $root): array fail('release-packages.json version must be exactly 1.'); } - if (!is_array($map['packages']) || count($map['packages']) !== 6) { - fail('release-packages.json must contain exactly six package entries.'); + if (!is_array($map['packages']) || count($map['packages']) !== 7) { + fail('release-packages.json must contain exactly seven package entries.'); } $expectedPackages = array_values(expectedPackages()); @@ -493,7 +496,7 @@ function validateReadme(array $package, string $readmePath): void fail($package['name'] . ' README.md must state that independent publication has not begun yet.'); } - if (preg_match('/github\.com\/josiahking\/evolvephp[-\/](?:contracts|core|http|module|plugin|testing)/i', $content) === 1) { + if (preg_match('/github\.com\/josiahking\/evolvephp[-\/](?:contracts|core|dev-tools|http|module|plugin|testing)/i', $content) === 1) { fail($package['name'] . ' README.md must not claim a split repository URL.'); } diff --git a/tools/validate-skeleton-project.php b/tools/validate-skeleton-project.php new file mode 100644 index 0000000..f231c44 --- /dev/null +++ b/tools/validate-skeleton-project.php @@ -0,0 +1,568 @@ +runner = new ReleaseValidationProcessRunner(); + } + + public function validate(string $root, ?string $composer): void + { + $sourceState = captureSourceState($this->runner, $root); + $temp = createTemporaryDirectory('evolvephp-skeleton-validation-'); + $failure = null; + + try { + $this->validateInTemporaryProject($root, $temp, $composer); + } catch (ReleaseValidationFailure $exception) { + $failure = $exception; + } finally { + try { + $this->stage('[13/13] Cleaning up and preserving source state'); + $temp->cleanup(); + assertSourceStatePreserved($this->runner, $root, $sourceState); + } catch (ReleaseValidationFailure $exception) { + $failure ??= $exception; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + private function validateInTemporaryProject(string $root, ReleaseValidationTemporaryDirectory $temp, ?string $composer): void + { + $application = $temp->child('application'); + + if (file_exists($application)) { + releaseValidationFail('Temporary application target already exists before create-project.'); + } + + $this->stage('[1/13] Preparing offline repositories'); + $lockedVendorPackages = loadSkeletonLockedPackageRepositoryPackages($root); + $offlineVendorRepository = $this->prepareOfflineVendorRepository($root, $temp, $lockedVendorPackages); + + $this->stage('[2/13] Running Composer create-project'); + $this->runner->mustRun( + withComposerCommand(array( + 'create-project', + '--no-interaction', + '--no-progress', + '--no-ansi', + '--no-scripts', + '--no-audit', + '--add-repository', + '--repository=' . json_encode(array('packagist.org' => false), JSON_THROW_ON_ERROR), + '--repository=' . json_encode($this->skeletonRepository($root), JSON_THROW_ON_ERROR), + '--repository=' . json_encode($this->frameworkRepository($root), JSON_THROW_ON_ERROR), + '--repository=' . json_encode($this->vendorRepository($offlineVendorRepository, $lockedVendorPackages), JSON_THROW_ON_ERROR), + 'evolvephp/skeleton', + $application, + self::ALPHA_VERSION, + ), $composer), + null, + $this->composerEnvironment($temp), + '[2/13] Running Composer create-project', + ); + + if (!is_dir($application)) { + releaseValidationFail('Composer create-project did not create the application directory.'); + } + + $this->stage('[3/13] Validating generated manifest'); + $this->restoreDistributedManifest($root, $application); + $this->validateGeneratedManifest($application); + $this->runner->mustRun( + withComposerCommand(array('--working-dir=' . $application, 'validate', '--strict', '--no-check-lock', '--no-ansi'), $composer), + null, + $this->composerEnvironment($temp), + '[3/13] Validating generated manifest', + ); + + $this->stage('[4/13] Validating installed packages'); + $this->validateInstalledPackages($root, $application, array('contracts', 'core', 'dev-tools', 'http', 'module', 'plugin', 'testing')); + + $this->stage('[5/13] Running generated Doctor'); + $this->assertCommand('[5/13] Running generated Doctor', $application, array('doctor'), 0, null, ''); + + $this->stage('[6/13] Running generated route:list'); + $this->assertCommand('[6/13] Running generated route:list', $application, array('route:list'), 0, 'No routes are configured.' . PHP_EOL, ''); + + $this->stage('[7/13] Running generated module:new'); + $this->assertCommand('[7/13] Running generated module:new', $application, array('module:new', 'Billing'), 0, implode(PHP_EOL, array( + 'Created module app/billing.', + 'src/Modules/Billing/BillingModule.php', + 'src/Modules/Billing/module.php', + 'tests/Modules/Billing/BillingModuleTest.php', + '', + )), ''); + + $this->stage('[8/13] Running generated plugin:new'); + $this->assertCommand('[8/13] Running generated plugin:new', $application, array('plugin:new', 'Cache'), 0, implode(PHP_EOL, array( + 'Created plugin app/cache.', + 'src/Plugins/Cache/CachePlugin.php', + 'src/Plugins/Cache/plugin.php', + 'tests/Plugins/Cache/CachePluginTest.php', + '', + )), ''); + + $this->stage('[9/13] Running generated test suite'); + $this->validateGeneratedStarterFiles($application); + $this->runner->mustRun( + array(PHP_BINARY, joinPaths($application, 'vendor/bin/phpunit'), '--configuration', 'phpunit.xml.dist'), + $application, + $this->composerEnvironment($temp), + '[9/13] Running generated test suite', + ); + + $this->stage('[10/13] Running collision and traversal checks'); + $this->assertCommand('[10/13] Running collision and traversal checks', $application, array('module:new', 'Billing'), 1, '', 'Refusing to overwrite existing file: src/Modules/Billing/BillingModule.php' . PHP_EOL); + $this->assertCommand('[10/13] Running collision and traversal checks', $application, array('plugin:new', 'Cache'), 1, '', 'Refusing to overwrite existing file: src/Plugins/Cache/CachePlugin.php' . PHP_EOL); + $this->assertCommand('[10/13] Running collision and traversal checks', $application, array('module:new', '../Escape'), 2, '', 'Usage: module:new ' . PHP_EOL); + $this->assertCommand('[10/13] Running collision and traversal checks', $application, array('plugin:new', '..\\Escape'), 2, '', 'Usage: plugin:new ' . PHP_EOL); + $this->assertNoGeneratedFileOutsideApplication($temp, $application); + $this->validateProductionInstall($root, $application, $temp, $composer); + $this->assertCommand('[10/13] Running collision and traversal checks', $application, array(), 2, '', 'No command was specified.' . PHP_EOL); + $this->assertCommand('[10/13] Running collision and traversal checks', $application, array('missing'), 2, '', 'Command "missing" was not found.' . PHP_EOL); + $this->assertCommand('[10/13] Running collision and traversal checks', $application, array('route:list', '--json'), 2, '', 'The route:list command does not accept arguments or options.' . PHP_EOL); + } + + private function restoreDistributedManifest(string $root, string $application): void + { + $sourceManifest = file_get_contents(joinPaths($root, 'skeleton/composer.json')); + + if ($sourceManifest === false) { + releaseValidationFail('Unable to read distributed skeleton composer.json.'); + } + + if (file_put_contents(joinPaths($application, 'composer.json'), $sourceManifest) === false) { + releaseValidationFail('Unable to restore generated skeleton composer.json after repository injection.'); + } + } + + /** + * @return array + */ + private function skeletonRepository(string $root): array + { + return array( + 'type' => 'path', + 'url' => normalizePath(joinPaths($root, 'skeleton')), + 'options' => array( + 'symlink' => false, + 'versions' => array( + 'evolvephp/skeleton' => self::ALPHA_VERSION, + ), + ), + ); + } + + /** + * @return array + */ + private function frameworkRepository(string $root): array + { + return array( + 'type' => 'path', + 'url' => normalizePath(joinPaths($root, 'packages/*')), + 'options' => array( + 'symlink' => false, + 'versions' => $this->frameworkVersions($root), + ), + ); + } + + /** + * @param list> $packages + * @return array + */ + private function vendorRepository(string $repositoryRoot, array $packages): array + { + $versions = $this->lockedPackageRepositoryVersions($packages); + + return array( + 'type' => 'path', + 'url' => normalizePath(joinPaths($repositoryRoot, '*/*')), + 'only' => array_keys($versions), + 'options' => array( + 'symlink' => false, + 'versions' => $versions, + ), + ); + } + + /** + * @return array + */ + private function frameworkVersions(string $root): array + { + $versions = array(); + + foreach (loadReleasePackages($root) as $package) { + $versions[$package['name']] = self::ALPHA_VERSION; + } + + ksort($versions); + + return $versions; + } + + /** + * @param list> $packages + * @return array + */ + private function lockedPackageRepositoryVersions(array $packages): array + { + $versions = array(); + + foreach ($packages as $package) { + $versions[$package['name']] = $package['version']; + } + + ksort($versions); + + return $versions; + } + + /** + * @param list> $packages + */ + private function prepareOfflineVendorRepository(string $root, ReleaseValidationTemporaryDirectory $temp, array $packages): string + { + $repositoryRoot = $temp->child('offline-vendor'); + + if (!mkdir($repositoryRoot, 0777, true) && !is_dir($repositoryRoot)) { + releaseValidationFail('Unable to create offline vendor repository: ' . $repositoryRoot); + } + + foreach ($packages as $package) { + $source = joinPaths($root, 'vendor/' . $package['name']); + $target = joinPaths($repositoryRoot, $package['name']); + + if (!is_dir($source)) { + releaseValidationFail('Locked package is not installed locally for offline skeleton validation: ' . $package['name']); + } + + $this->copyDirectory($source, $target); + } + + return $repositoryRoot; + } + + private function copyDirectory(string $source, string $target): void + { + $sourceRealPath = realpath($source); + + if ($sourceRealPath === false || !is_dir($sourceRealPath)) { + releaseValidationFail('Unable to resolve offline repository source: ' . $source); + } + + if (!mkdir($target, 0777, true) && !is_dir($target)) { + releaseValidationFail('Unable to create offline repository target: ' . $target); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($sourceRealPath, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $entry) { + $relativePath = substr($entry->getPathname(), strlen($sourceRealPath) + 1); + $targetPath = $target . DIRECTORY_SEPARATOR . $relativePath; + + if ($entry->isLink()) { + releaseValidationFail('Refusing to copy symlink into offline repository: ' . $entry->getPathname()); + } + + if ($entry->isDir()) { + if (!mkdir($targetPath, 0777, true) && !is_dir($targetPath)) { + releaseValidationFail('Unable to create offline repository directory: ' . $targetPath); + } + + continue; + } + + if (!copy($entry->getPathname(), $targetPath)) { + releaseValidationFail('Unable to copy offline repository file: ' . $entry->getPathname()); + } + } + } + + private function validateGeneratedManifest(string $application): void + { + $manifest = readJsonFile(joinPaths($application, 'composer.json'), 'generated skeleton composer.json'); + + if (!is_array($manifest)) { + releaseValidationFail('Generated skeleton composer.json must decode to an object.'); + } + + if (($manifest['name'] ?? null) !== 'evolvephp/skeleton') { + releaseValidationFail('Generated skeleton manifest package name changed.'); + } + + if (($manifest['type'] ?? null) !== 'project') { + releaseValidationFail('Generated skeleton manifest type changed.'); + } + + if (($manifest['require']['php'] ?? null) !== '^8.4') { + releaseValidationFail('Generated skeleton manifest PHP constraint changed.'); + } + + if (($manifest['require']['evolvephp/contracts'] ?? null) !== '^2.0') { + releaseValidationFail('Generated skeleton manifest Contracts dependency changed.'); + } + + if (($manifest['require']['evolvephp/core'] ?? null) !== '^2.0') { + releaseValidationFail('Generated skeleton manifest Core dependency changed.'); + } + + if (($manifest['require']['evolvephp/http'] ?? null) !== '^2.0') { + releaseValidationFail('Generated skeleton manifest HTTP dependency changed.'); + } + + if (($manifest['require']['evolvephp/module'] ?? null) !== '^2.0') { + releaseValidationFail('Generated skeleton manifest Module dependency changed.'); + } + + if (($manifest['require']['evolvephp/plugin'] ?? null) !== '^2.0') { + releaseValidationFail('Generated skeleton manifest Plugin dependency changed.'); + } + + if (($manifest['require-dev']['evolvephp/dev-tools'] ?? null) !== '^2.0') { + releaseValidationFail('Generated skeleton manifest DevTools development dependency changed.'); + } + + if (($manifest['require-dev']['evolvephp/testing'] ?? null) !== '^2.0') { + releaseValidationFail('Generated skeleton manifest Testing development dependency changed.'); + } + + if (($manifest['require-dev']['phpunit/phpunit'] ?? null) !== '^13.2') { + releaseValidationFail('Generated skeleton manifest PHPUnit development dependency changed.'); + } + + if (($manifest['autoload']['psr-4']['App\\'] ?? null) !== 'src/') { + releaseValidationFail('Generated skeleton manifest App namespace mapping changed.'); + } + + if (($manifest['autoload-dev']['psr-4']['Tests\\'] ?? null) !== 'tests/') { + releaseValidationFail('Generated skeleton manifest Tests namespace mapping changed.'); + } + + if (($manifest['scripts']['test'] ?? null) !== 'phpunit --configuration phpunit.xml.dist') { + releaseValidationFail('Generated skeleton manifest test script changed.'); + } + + if (($manifest['minimum-stability'] ?? null) !== 'alpha' || ($manifest['prefer-stable'] ?? null) !== true) { + releaseValidationFail('Generated skeleton manifest prerelease stability policy changed.'); + } + + foreach (array('version', 'repositories') as $field) { + if (array_key_exists($field, $manifest)) { + releaseValidationFail('Generated skeleton manifest contains forbidden field: ' . $field); + } + } + } + + /** + * @param list $packages + */ + private function validateInstalledPackages(string $root, string $application, array $packages): void + { + foreach ($packages as $package) { + $installedPath = joinPaths($application, 'vendor/evolvephp/' . $package); + + if (!is_dir($installedPath)) { + releaseValidationFail('Generated project did not install evolvephp/' . $package . '.'); + } + + if (is_link($installedPath)) { + releaseValidationFail('Generated project installed evolvephp/' . $package . ' as a symlink.'); + } + + $installedRealPath = realpath($installedPath); + $sourceRealPath = realpath(joinPaths($root, 'packages/' . $package)); + + if ($installedRealPath === false || $sourceRealPath === false) { + releaseValidationFail('Unable to resolve installed package path for evolvephp/' . $package . '.'); + } + + if (normalizePath($installedRealPath) === normalizePath($sourceRealPath) + || str_starts_with(normalizePath($installedRealPath), normalizePath($sourceRealPath) . '/') + ) { + releaseValidationFail('Generated project installed evolvephp/' . $package . ' from the source monorepo path.'); + } + } + } + + private function validateGeneratedStarterFiles(string $application): void + { + foreach (array( + 'src/Modules/Billing/BillingModule.php', + 'src/Modules/Billing/module.php', + 'tests/Modules/Billing/BillingModuleTest.php', + 'src/Plugins/Cache/CachePlugin.php', + 'src/Plugins/Cache/plugin.php', + 'tests/Plugins/Cache/CachePluginTest.php', + ) as $relativePath) { + $path = joinPaths($application, $relativePath); + + if (!is_file($path)) { + releaseValidationFail('Generated starter file is missing: ' . $relativePath); + } + + $realPath = realpath($path); + $realApplication = realpath($application); + + if ($realPath === false || $realApplication === false || !str_starts_with($realPath, $realApplication . DIRECTORY_SEPARATOR)) { + releaseValidationFail('Generated starter file escaped the application root: ' . $relativePath); + } + } + } + + private function assertNoGeneratedFileOutsideApplication(ReleaseValidationTemporaryDirectory $temp, string $application): void + { + $applicationRealPath = realpath($application); + + if ($applicationRealPath === false) { + releaseValidationFail('Unable to resolve generated application path.'); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($temp->path, FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $entry) { + if (!$entry->isFile()) { + continue; + } + + $realPath = realpath($entry->getPathname()); + + if ($realPath === false || str_starts_with($realPath, $applicationRealPath . DIRECTORY_SEPARATOR)) { + continue; + } + + if (str_contains(normalizePath($realPath), '/Escape')) { + releaseValidationFail('Invalid generator input created a file outside the application root.'); + } + } + } + + private function validateProductionInstall(string $root, string $application, ReleaseValidationTemporaryDirectory $temp, ?string $composer): void + { + $this->stage('[11/13] Running Composer install --no-dev'); + removeDirectory(joinPaths($application, 'vendor'), $application); + + $this->runner->mustRun( + withComposerCommand(array( + '--working-dir=' . $application, + 'install', + '--no-dev', + '--no-interaction', + '--no-progress', + '--no-ansi', + '--no-scripts', + ), $composer), + null, + $this->composerEnvironment($temp), + '[11/13] Running Composer install --no-dev', + ); + + $this->validateInstalledPackages($root, $application, array('contracts', 'core', 'http', 'module', 'plugin')); + $this->assertPackageNotInstalled($application, 'dev-tools'); + $this->assertPackageNotInstalled($application, 'testing'); + + $this->stage('[12/13] Running no-dev Doctor and route:list'); + $this->assertCommand('[12/13] Running no-dev Doctor and route:list', $application, array('doctor'), 0, null, ''); + $this->assertCommand('[12/13] Running no-dev Doctor and route:list', $application, array('route:list'), 0, 'No routes are configured.' . PHP_EOL, ''); + } + + private function assertPackageNotInstalled(string $application, string $package): void + { + $installedPath = joinPaths($application, 'vendor/evolvephp/' . $package); + + if (file_exists($installedPath)) { + releaseValidationFail('Production no-dev install unexpectedly installed evolvephp/' . $package . '.'); + } + } + + /** + * @param list $arguments + */ + private function assertCommand( + string $stage, + string $application, + array $arguments, + int $expectedExitCode, + ?string $expectedStdout, + string $expectedStderr, + ): void { + $result = $this->runner->run(array(PHP_BINARY, 'bin/evolve', ...$arguments), $application, array(), $stage); + + if ($result->exitCode !== $expectedExitCode) { + releaseValidationFail('Generated command failed with unexpected exit code during ' . $stage . ': ' . describeCommand($result->command) . "\n" . $result->output()); + } + + if ($expectedStdout !== null && $result->stdout !== $expectedStdout) { + releaseValidationFail('Generated command stdout mismatch during ' . $stage . ' for ' . describeCommand($result->command) . '.'); + } + + if ($result->stderr !== $expectedStderr) { + releaseValidationFail('Generated command stderr mismatch during ' . $stage . ' for ' . describeCommand($result->command) . '.'); + } + } + + /** + * @return array + */ + private function composerEnvironment(ReleaseValidationTemporaryDirectory $temp): array + { + return array( + 'COMPOSER_DISABLE_NETWORK' => '1', + 'COMPOSER_HOME' => $temp->child('composer-home'), + 'COMPOSER_CACHE_DIR' => $temp->child('composer-cache'), + ); + } + + private function stage(string $message): void + { + echo $message . PHP_EOL; + } +} + +try { + $options = parseReleaseValidationArguments($argv, false); + $validator = new SkeletonProjectValidator(); + $validator->validate($options['root'], $options['composer']); + + echo 'EvolvePHP skeleton create-project validation passed.' . PHP_EOL; + echo 'Composer command: create-project' . PHP_EOL; + echo 'COMPOSER_DISABLE_NETWORK: enabled' . PHP_EOL; + echo 'Packagist: disabled' . PHP_EOL; + echo 'First-party package path repositories: copied, not symlinked' . PHP_EOL; + echo 'Generated composer validate --strict: pass' . PHP_EOL; + echo 'Generated command doctor: pass' . PHP_EOL; + echo 'Generated command route:list: No routes are configured.' . PHP_EOL; + echo 'Generated command module:new Billing: pass' . PHP_EOL; + echo 'Generated command plugin:new Cache: pass' . PHP_EOL; + echo 'Generated application PHPUnit suite: pass' . PHP_EOL; + echo 'Generated repeat scaffolds refuse overwrites: pass' . PHP_EOL; + echo 'Generated invalid/path traversal names create nothing outside the application: pass' . PHP_EOL; + echo 'Generated composer install --no-dev: pass' . PHP_EOL; + echo 'Generated no-dev command doctor: pass' . PHP_EOL; + echo 'Generated no-dev command route:list: No routes are configured.' . PHP_EOL; + echo 'Generated missing-command behavior: Core usage errors preserved.' . PHP_EOL; + echo 'Source repository state preserved.' . PHP_EOL; + exit(0); +} catch (ReleaseValidationFailure $failure) { + fwrite(STDERR, 'EvolvePHP skeleton create-project validation failed: ' . $failure->getMessage() . PHP_EOL); + exit(1); +}