From e5acdd7f4690e321d7d15e8f0d00f90be97aca42 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 08:33:24 -0500 Subject: [PATCH 01/15] adding new commands Assisted-by: Claude Code --- src/Command/ComposeCommand.php | 58 ++++++ src/Command/DownCommand.php | 41 ++++ src/Command/InstallCommand.php | 42 ++++ src/Command/NewCommand.php | 290 ++++++++++++++++++++++++++++ src/Command/Stub/PublishCommand.php | 19 +- src/Command/UpCommand.php | 41 ++++ src/Commands.php | 8 + 7 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 src/Command/ComposeCommand.php create mode 100644 src/Command/DownCommand.php create mode 100644 src/Command/InstallCommand.php create mode 100644 src/Command/NewCommand.php create mode 100644 src/Command/UpCommand.php diff --git a/src/Command/ComposeCommand.php b/src/Command/ComposeCommand.php new file mode 100644 index 0000000..331b232 --- /dev/null +++ b/src/Command/ComposeCommand.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Command\Command; +use Crest\Console\Input; +use Crest\Process\Runner; +use Crest\Process\ShellRunner; + +/** + * The base for the commands that control the project containers. + * + * They are thin, but they are not aliases. crest wrote docker-compose.yml, so + * crest owns the `app` service name. docker compose finds the file when it + * goes up from the working directory, as crest finds crest.php. Thus no path + * is recorded. + * + * They read no project configuration, because they run before the project + * has a vendor/ directory. + */ +abstract class ComposeCommand extends Command +{ + private readonly Runner $runner; + + /** + * The default lets the kernel's `new $class()` work. A test gives a fake + * runner, and checks the exact argv without docker. + */ + public function __construct(?Runner $runner = null) + { + $this->runner = $runner ?? new ShellRunner(); + } + + /** + * Runs `docker compose` with the arguments, in --directory if the user + * gave one. + * + * @param list $arguments + */ + protected function compose(Input $input, array $arguments): int + { + return $this->runner->run( + ['docker', 'compose', ...$arguments], + $input->optionStringOrNull('directory') + ); + } +} diff --git a/src/Command/DownCommand.php b/src/Command/DownCommand.php new file mode 100644 index 0000000..f49ec5c --- /dev/null +++ b/src/Command/DownCommand.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +/** + * Stops the project containers and removes them. + */ +final class DownCommand extends ComposeCommand +{ + public function define(): Definition + { + return Definition::for('down', 'Stop and remove the project containers') + ->option('volumes', 'Remove the named volumes too'); + } + + public function handle(Input $input, Output $output): int + { + $arguments = ['down']; + + if (true === $input->option('volumes')) { + $arguments[] = '--volumes'; + } + + return $this->compose($input, $arguments); + } +} diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php new file mode 100644 index 0000000..442f6f7 --- /dev/null +++ b/src/Command/InstallCommand.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +/** + * Runs composer install in the project container. On the host, use + * `composer install` directly. This command exists because crest knows the + * container. + */ +final class InstallCommand extends ComposeCommand +{ + /** + * The service in docker-compose.yml. crest wrote that file + * (project-compose.stub), so the name belongs to crest. + */ + private const SERVICE = 'app'; + + public function define(): Definition + { + return Definition::for('install', 'Install composer dependencies in the project container'); + } + + public function handle(Input $input, Output $output): int + { + return $this->compose($input, ['exec', self::SERVICE, 'composer', 'install']); + } +} diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php new file mode 100644 index 0000000..c329db7 --- /dev/null +++ b/src/Command/NewCommand.php @@ -0,0 +1,290 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Command\Command; +use Crest\Console\Exceptions\Exception; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; +use Crest\Generator\ArtifactWriter; +use Crest\Generator\ClassName; +use Crest\Generator\Stub; +use Crest\Paths; +use Crest\Project\Flavor; +use FilesystemIterator; + +use function file_exists; +use function getcwd; +use function is_dir; +use function preg_match; +use function rtrim; +use function sprintf; +use function str_replace; +use function strtolower; +use function version_compare; + +/** + * Creates a new ADR project from stubs. + * + * This command runs where no project exists yet: no crest.php, no + * composer.json, no vendor/. Thus it extends the console Command, not + * ProjectCommand. It gets all values from its arguments, and it writes + * crest.php. It does not read it. + * + * It runs nothing: no composer, no docker, no network. Thus it cannot stop + * halfway. Either it writes the tree, or an error tells why it did not. + */ +final class NewCommand extends Command +{ + /** + * Where the actions go, relative to the project root. The seed action and + * the front controller use it. + */ + private const ACTION_PATH = 'src/Action'; + + /** + * Stub name => path in the new project. The seed action is not here: it + * uses the action stub with its own placeholders. + */ + private const FILES = [ + 'project-composer' => 'composer.json', + 'project-config' => 'crest.php', + 'project-env' => '.env', + 'project-gitignore' => '.gitignore', + 'project-htrouter' => '.htrouter.php', + 'project-readme' => 'README.md', + 'project-compose' => 'docker-compose.yml', + 'project-dockerfile' => 'resources/docker/Dockerfile', + 'project-index' => 'public/index.php', + 'project-front' => 'src/AppFront.php', + ]; + + /** + * A project name: letters, digits, '-' and '_', with a letter or digit + * first. It is a directory name, not a path. It is also the docker + * container prefix, which must start with a letter or digit. + */ + private const NAME = '/^[A-Za-z0-9][A-Za-z0-9_-]*$/'; + + /** + * Variant => the composer requirement and its constraint. v5 needs 5.18, + * because Phalcon\ADR first ships in cphalcon 5.18.0; an older extension + * installs and then fails on every request. v6 has @RC because + * phalcon/phalcon has no stable 6.0 release yet. Composer still selects a + * stable release when one exists. + */ + private const PHALCON = [ + 'v5' => ['ext-phalcon', '^5.18'], + 'v6' => ['phalcon/phalcon', '^6.0@RC'], + ]; + + /** + * major.minor only. The Dockerfile base image `php:-cli` has no + * patch tags, and composer.json uses the same value. + */ + private const PHP = '/^\d+\.\d+$/'; + + /** + * The oldest PHP that runs the generated code. It uses readonly promoted + * properties, which need PHP 8.1. + */ + private const PHP_FLOOR = '8.1'; + + public function define(): Definition + { + return Definition::for('new', 'Create an ADR project') + ->argument('name', true, 'Project directory, e.g. my-app') + ->option('namespace=s', 'Root namespace for the generated code', 'App') + ->option('php=s', 'PHP version the project targets, major.minor', '8.4') + ->option('phalcon=s', 'Phalcon: v5 (extension) or v6 (package)', 'v5') + ->option('force', 'Write into a directory that is not empty'); + } + + public function handle(Input $input, Output $output): int + { + $name = $this->name($input->argumentString('name')); + $namespace = ClassName::namespace($input->optionString('namespace')); + $php = $this->php($input->optionString('php')); + $variant = strtolower($input->optionString('phalcon')); + + if (false === isset(self::PHALCON[$variant])) { + throw new Exception( + sprintf("unknown Phalcon version '%s'; expected v5 or v6", $variant) + ); + } + + [$package, $constraint] = self::PHALCON[$variant]; + + $parent = $this->parent($input); + $target = $parent . '/' . $name; + $force = true === $input->option('force'); + + $this->guard($target, $force); + + // Overrides come from the directory that the project goes into. A + // team that publishes the project stubs there gets its own + // conventions in each project that it creates there. + $writer = new ArtifactWriter(new Stub(Paths::stubs(), $parent), Flavor::ADR->value); + + $replacements = [ + 'actionNamespace' => $namespace . '\\Action', + 'actionPath' => self::ACTION_PATH, + 'jsonNamespace' => str_replace('\\', '\\\\', $namespace), + 'namespace' => $namespace, + 'phalconConstraint' => $constraint, + 'phalconPackage' => $package, + 'phalconVariant' => $variant, + 'phpVersion' => $php, + 'project' => $name, + // The prefix of each line of the extension install in the + // Dockerfile: active for v5, commented out for v6. + 'v5' => 'v5' === $variant ? '' : '# ', + ]; + + foreach (self::FILES as $stub => $path) { + $writer->render($target . '/' . $path, $stub, $replacements, $force); + } + + // The seed action uses the usual action stub. Convention cannot name + // it, because Convention asks the router, and there is no vendor/ yet. + $writer->render( + $target . '/' . self::ACTION_PATH . '/Get.php', + 'action', + [ + 'attributes' => '', + 'class' => 'Get', + 'namespace' => $namespace . '\\Action', + 'params' => '', + ], + $force + ); + + $this->report( + $output, + '' === $input->optionString('directory') ? $name : $target + ); + + return 0; + } + + /** + * Refuses a target that would mix the new project into other files, + * unless --force says that this is the intent. A missing or empty + * directory is always correct. + */ + private function guard(string $target, bool $force): void + { + if (true === file_exists($target) && false === is_dir($target)) { + throw new Exception(sprintf('%s exists and is not a directory', $target)); + } + + if (true === $force || false === is_dir($target)) { + return; + } + + // FilesystemIterator skips . and .., so valid() is true only when the + // directory contains something. + if (true === (new FilesystemIterator($target))->valid()) { + throw new Exception( + sprintf('%s exists and is not empty; pass --force to write into it', $target) + ); + } + } + + /** + * A name, not a path: `crest new ../elsewhere` must not write outside the + * directory it runs in. + */ + private function name(string $name): string + { + if (0 === preg_match(self::NAME, $name)) { + throw new Exception( + sprintf( + "'%s' is not a usable project name; expected letters, digits, '-' and '_', " + . 'starting with a letter or digit', + $name + ) + ); + } + + return $name; + } + + /** + * The directory that the project goes into. + * + * --directory is the global project-root option. This command has no + * project yet, so for it the option names where the project goes. + * + * An empty value reads as absent, as optionString() reads every other + * option. Otherwise `--directory="$DIR"` with an unset variable puts the + * project in the filesystem root. + */ + private function parent(Input $input): string + { + $directory = $input->optionString('directory'); + + return rtrim('' === $directory ? (string) getcwd() : $directory, '/'); + } + + /** + * major.minor, and not older than the generated code needs. + */ + private function php(string $version): string + { + if (0 === preg_match(self::PHP, $version)) { + throw new Exception( + sprintf("'%s' is not a PHP version; expected major.minor, e.g. 8.4", $version) + ); + } + + if (true === version_compare($version, self::PHP_FLOOR, '<')) { + throw new Exception( + sprintf( + 'PHP %s is too old; the generated code needs %s or later', + $version, + self::PHP_FLOOR + ) + ); + } + + return $version; + } + + /** + * Both ways to run the project, always. The generated files are the same + * on every host. Only this text names the two ways, so nothing here + * examines the environment. + */ + private function report(Output $output, string $shown): void + { + $output->success(sprintf('Created %s/', $shown)); + $output->line(); + $output->line('Nothing runs it yet. With docker:'); + $output->line(); + $output->line(sprintf(' cd %s', $shown)); + $output->line(' crest up'); + $output->line(' crest install'); + $output->line(); + $output->line('Or with PHP and composer on the host:'); + $output->line(); + $output->line(sprintf(' cd %s', $shown)); + $output->line(' composer install'); + // Until `crest serve` exists, the host way names the server directly. + $output->line(' php -S localhost:8080 -t public .htrouter.php'); + $output->line(); + $output->line('Then GET / answers from src/Action/Get.php'); + } +} diff --git a/src/Command/Stub/PublishCommand.php b/src/Command/Stub/PublishCommand.php index 2078412..eab92a3 100644 --- a/src/Command/Stub/PublishCommand.php +++ b/src/Command/Stub/PublishCommand.php @@ -28,6 +28,7 @@ use function is_file; use function preg_match; use function sprintf; +use function str_starts_with; /** * Copies packaged stubs into the project so they can be edited. @@ -47,6 +48,14 @@ final class PublishCommand extends ProjectCommand */ private const NAME = '/^[A-Za-z0-9_-]+$/'; + /** + * The prefix of the stubs that `crest new` renders. They only have an + * effect in the directory `new` runs from, not in a project. Thus a + * publish with no name leaves them out. A publish by name still copies + * them. + */ + private const PROJECT_STUBS = 'project-'; + public function define(): Definition { return Definition::for('stub:publish', 'Copy packaged stubs into the project for editing') @@ -110,9 +119,17 @@ private function sources(string $flavor, string $name): array return [$single]; } + $found = []; + // glob() sorts alphabetically unless told not to, so the listing is // stable without a sort of its own. - $found = glob(Stub::packagedDirectory(Paths::stubs(), $flavor) . '/*.stub') ?: []; + foreach (glob(Stub::packagedDirectory(Paths::stubs(), $flavor) . '/*.stub') ?: [] as $path) { + if (true === str_starts_with(basename($path), self::PROJECT_STUBS)) { + continue; + } + + $found[] = $path; + } if ([] === $found) { throw new Exception(sprintf("no stubs are packaged for the '%s' flavor", $flavor)); diff --git a/src/Command/UpCommand.php b/src/Command/UpCommand.php new file mode 100644 index 0000000..bcdbb10 --- /dev/null +++ b/src/Command/UpCommand.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command; + +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Console\Parsing\Definition; + +/** + * Starts the project containers in the background. + */ +final class UpCommand extends ComposeCommand +{ + public function define(): Definition + { + return Definition::for('up', 'Start the project containers') + ->option('build', 'Rebuild the images first'); + } + + public function handle(Input $input, Output $output): int + { + $arguments = ['up', '-d']; + + if (true === $input->option('build')) { + $arguments[] = '--build'; + } + + return $this->compose($input, $arguments); + } +} diff --git a/src/Commands.php b/src/Commands.php index e0b209c..d801364 100644 --- a/src/Commands.php +++ b/src/Commands.php @@ -16,15 +16,19 @@ use Crest\Command\AboutCommand; use Crest\Command\Config\ShowCommand as ConfigShowCommand; use Crest\Command\Container\ListCommand as ContainerListCommand; +use Crest\Command\DownCommand; use Crest\Command\Event\ListCommand as EventListCommand; +use Crest\Command\InstallCommand; use Crest\Command\ListCommand; use Crest\Command\Make\ActionCommand; use Crest\Command\Make\CommandCommand; use Crest\Command\Make\MiddlewareCommand; use Crest\Command\Make\ProviderCommand; use Crest\Command\Make\ResponderCommand; +use Crest\Command\NewCommand; use Crest\Command\Route\ListCommand as RouteListCommand; use Crest\Command\Stub\PublishCommand as StubPublishCommand; +use Crest\Command\UpCommand; use Crest\Console\Registry; /** @@ -58,15 +62,19 @@ public static function registry(): Registry ->add('about', AboutCommand::class, 'info', 'i') ->add('config:show', ConfigShowCommand::class) ->add('container:list', ContainerListCommand::class) + ->add('down', DownCommand::class) ->add('event:list', EventListCommand::class) + ->add('install', InstallCommand::class) ->add('list', ListCommand::class, 'commands', 'enumerate') ->add('make:action', ActionCommand::class) ->add('make:command', CommandCommand::class) ->add('make:middleware', MiddlewareCommand::class) ->add('make:provider', ProviderCommand::class) ->add('make:responder', ResponderCommand::class) + ->add('new', NewCommand::class) ->add('route:list', RouteListCommand::class) ->add('stub:publish', StubPublishCommand::class) + ->add('up', UpCommand::class) ->withDiscovery(self::KEY); } } From 49e520d7de7e87ab37b84462357a3f545b641245 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 08:34:36 -0500 Subject: [PATCH 02/15] adding shell process Assisted-by: Claude Code --- src/Process/Runner.php | 35 ++++++++++++ src/Process/ShellRunner.php | 103 ++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/Process/Runner.php create mode 100644 src/Process/ShellRunner.php diff --git a/src/Process/Runner.php b/src/Process/Runner.php new file mode 100644 index 0000000..45352c8 --- /dev/null +++ b/src/Process/Runner.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Process; + +/** + * Runs an external program. + * + * This is the seam for the commands that run programs (`up`, `down`, + * `install`). Tests replace it with a fake and check the exact argv, so they + * need no docker. + */ +interface Runner +{ + /** + * Runs the program and returns its exit status. The output of the program + * goes directly to the terminal, not through crest's Output. + * + * @param non-empty-list $command The program, then its + * arguments. No shell. + * @param string|null $directory Where the program runs; null + * for the working directory. + */ + public function run(array $command, ?string $directory = null): int; +} diff --git a/src/Process/ShellRunner.php b/src/Process/ShellRunner.php new file mode 100644 index 0000000..8aaa1ee --- /dev/null +++ b/src/Process/ShellRunner.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Process; + +use Crest\Console\Exceptions\Exception; + +use function explode; +use function getenv; +use function is_dir; +use function is_executable; +use function is_file; +use function proc_close; +use function proc_open; +use function sprintf; +use function str_contains; + +use const PATH_SEPARATOR; + +/** + * Runs a program as a child process that shares crest's terminal. + * + * The child gets crest's own stdin, stdout and stderr: an empty descriptor + * list does that. Thus `docker compose exec` sees a terminal, and composer + * shows its progress. crest captures nothing. + * + * Two checks come first, because proc_open() does neither. A missing program + * gives no clear error. A missing working directory is ignored, and the child + * runs in crest's own directory. + * + * The command is an argv list, not a shell string. Nothing in it is + * interpreted. + */ +final class ShellRunner implements Runner +{ + public function run(array $command, ?string $directory = null): int + { + if (false === $this->exists($command[0])) { + throw new Exception( + sprintf("'%s' was not found; install it or add it to the PATH", $command[0]) + ); + } + + if (null !== $directory && false === is_dir($directory)) { + throw new Exception(sprintf('%s is not a directory', $directory)); + } + + return proc_close($this->start($command, $directory)); + } + + /** + * Whether the program can run. A path must name an executable file. A + * bare name must be an executable file in a directory on the PATH. + */ + private function exists(string $program): bool + { + if (true === str_contains($program, '/')) { + return true === is_file($program) && true === is_executable($program); + } + + foreach (explode(PATH_SEPARATOR, (string) getenv('PATH')) as $directory) { + $candidate = $directory . '/' . $program; + + if (true === is_file($candidate) && true === is_executable($candidate)) { + return true; + } + } + + return false; + } + + /** + * Starts the program with crest's own stdin, stdout and stderr. + * + * proc_open() returns false only when it cannot create the process. run() + * checks the program and the directory first, so the guard is a last + * defense that the suite cannot reach. + * + * @param non-empty-list $command + * + * @return resource + */ + private function start(array $command, ?string $directory) + { + $process = proc_open($command, [], $pipes, $directory); + + if (false === $process) { + throw new Exception(sprintf('could not start %s', $command[0])); + } + + return $process; + } +} From 1e26aa38ec6d3cdcde8cdb39b7ad492d28dce34d Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 08:35:22 -0500 Subject: [PATCH 03/15] link to the namespace for boot Assisted-by: Claude Code --- src/Generator/ClassName.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/Generator/ClassName.php b/src/Generator/ClassName.php index 4e4e220..48c052d 100644 --- a/src/Generator/ClassName.php +++ b/src/Generator/ClassName.php @@ -15,9 +15,11 @@ use Crest\Console\Exceptions\Exception; +use function explode; use function preg_match; use function sprintf; use function str_ends_with; +use function trim; /** * Turns the name a user typed into the class name a generator writes. @@ -47,6 +49,29 @@ final class ClassName */ private const PATTERN = '/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*$/'; + /** + * A namespace: one name, or more names that backslashes join. Each name + * follows the identifier rule above. Leading and trailing backslashes are + * removed, because `\App` and `App\` both mean App. + */ + public static function namespace(string $namespace): string + { + $trimmed = trim($namespace, '\\'); + + foreach (explode('\\', $trimmed) as $segment) { + if (0 === preg_match(self::PATTERN, $segment)) { + throw new Exception( + sprintf( + "'%s' is not a usable namespace; expected something like 'App' or 'Acme\\Shop'", + $namespace + ) + ); + } + } + + return $trimmed; + } + public static function suffixed(string $name, string $suffix): string { if (0 === preg_match(self::PATTERN, $name)) { From 8f1a1cdbf8ea104ecfd1f3038ef8d2d71277c820 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 08:35:37 -0500 Subject: [PATCH 04/15] adding tests Assisted-by: Claude Code --- tests/Support/Process/FakeRunner.php | 38 ++ tests/Support/RunsACommandDirectly.php | 44 ++ tests/Unit/Command/DownCommandTest.php | 58 ++ tests/Unit/Command/GeneratedProjectTest.php | 242 +++++++ tests/Unit/Command/InstallCommandTest.php | 52 ++ tests/Unit/Command/NewCommandTest.php | 592 ++++++++++++++++++ .../Unit/Command/Stub/PublishCommandTest.php | 37 +- tests/Unit/Command/UpCommandTest.php | 113 ++++ tests/Unit/CommandsTest.php | 4 + tests/Unit/Generator/ClassNameTest.php | 36 ++ tests/Unit/Generator/ProjectStubsTest.php | 180 ++++++ tests/Unit/Generator/StubContractsTest.php | 7 + tests/Unit/Process/ShellRunnerTest.php | 127 ++++ 13 files changed, 1529 insertions(+), 1 deletion(-) create mode 100644 tests/Support/Process/FakeRunner.php create mode 100644 tests/Support/RunsACommandDirectly.php create mode 100644 tests/Unit/Command/DownCommandTest.php create mode 100644 tests/Unit/Command/GeneratedProjectTest.php create mode 100644 tests/Unit/Command/InstallCommandTest.php create mode 100644 tests/Unit/Command/NewCommandTest.php create mode 100644 tests/Unit/Command/UpCommandTest.php create mode 100644 tests/Unit/Generator/ProjectStubsTest.php create mode 100644 tests/Unit/Process/ShellRunnerTest.php diff --git a/tests/Support/Process/FakeRunner.php b/tests/Support/Process/FakeRunner.php new file mode 100644 index 0000000..640dbb4 --- /dev/null +++ b/tests/Support/Process/FakeRunner.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Support\Process; + +use Crest\Process\Runner; + +/** + * Records each command and directory, and returns a set exit status. Runs + * nothing. + */ +final class FakeRunner implements Runner +{ + /** @var list, string|null}> */ + public array $calls = []; + + public function __construct( + private readonly int $status = 0, + ) { + } + + public function run(array $command, ?string $directory = null): int + { + $this->calls[] = [$command, $directory]; + + return $this->status; + } +} diff --git a/tests/Support/RunsACommandDirectly.php b/tests/Support/RunsACommandDirectly.php new file mode 100644 index 0000000..bef9330 --- /dev/null +++ b/tests/Support/RunsACommandDirectly.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Support; + +use Crest\Console\Command\Command; +use Crest\Console\Input; +use Crest\Console\Kernel; +use Crest\Console\Output; + +/** + * Runs one command without the kernel. The kernel creates commands with + * `new $class()`, so a test cannot give them a fake collaborator. This trait + * binds argv the same way as the kernel does, with the global options. + * + * @mixin \PHPUnit\Framework\TestCase + */ +trait RunsACommandDirectly +{ + use CapturesOutput; + + /** + * @param list $tokens + */ + protected function handleDirectly(Command $command, array $tokens): int + { + $definition = $command->define(); + + return $command->handle( + new Input($definition->getName(), $definition->merge(Kernel::globals())->bind($tokens)), + new Output($this->stdout, $this->stderr, false) + ); + } +} diff --git a/tests/Unit/Command/DownCommandTest.php b/tests/Unit/Command/DownCommandTest.php new file mode 100644 index 0000000..214da2f --- /dev/null +++ b/tests/Unit/Command/DownCommandTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command; + +use Crest\Command\DownCommand; +use Crest\Tests\Support\Process\FakeRunner; +use Crest\Tests\Support\RunsACommandDirectly; +use PHPUnit\Framework\TestCase; + +final class DownCommandTest extends TestCase +{ + use RunsACommandDirectly; + + protected function setUp(): void + { + $this->captureStreams(); + } + + protected function tearDown(): void + { + $this->closeStreams(); + } + + public function testDefinitionNamesItselfDown(): void + { + $this->assertSame('down', (new DownCommand())->define()->getName()); + } + + public function testTheContainersStopAndAreRemoved(): void + { + $runner = new FakeRunner(); + + $status = $this->handleDirectly(new DownCommand($runner), []); + + $this->assertSame(0, $status); + $this->assertSame([[['docker', 'compose', 'down'], null]], $runner->calls); + } + + public function testVolumesAreRemovedToo(): void + { + $runner = new FakeRunner(); + + $this->handleDirectly(new DownCommand($runner), ['--volumes']); + + $this->assertSame([[['docker', 'compose', 'down', '--volumes'], null]], $runner->calls); + } +} diff --git a/tests/Unit/Command/GeneratedProjectTest.php b/tests/Unit/Command/GeneratedProjectTest.php new file mode 100644 index 0000000..4852fcc --- /dev/null +++ b/tests/Unit/Command/GeneratedProjectTest.php @@ -0,0 +1,242 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command; + +use Closure; +use Crest\Command\Make\ActionCommand; +use Crest\Command\NewCommand; +use Crest\Console\Input; +use Crest\Console\Kernel; +use Crest\Console\Output; +use Crest\Console\PackageVersion; +use Crest\Project\Bootstrap; +use Crest\Project\Config; +use Crest\Project\Flavor; +use Crest\Tests\Support\GeneratesInAScratchProject; +use ParseError; +use Phalcon\ADR\Front\AbstractHttpFront; +use Phalcon\Container\Container; +use Phalcon\Contracts\ADR\Application; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\Http\ResponseInterface; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +use function class_exists; +use function extension_loaded; +use function file_get_contents; +use function interface_exists; +use function is_file; +use function preg_match_all; +use function spl_autoload_register; +use function spl_autoload_unregister; +use function sprintf; +use function str_replace; +use function str_starts_with; +use function strlen; +use function substr; +use function token_get_all; + +use const TOKEN_PARSE; + +/** + * The generated project must run, not only exist. + * + * `new` runs nothing, so no other test proves the wiring: crest.php names a + * front controller that crest can boot, and the front sends GET / to the seed + * action. Boot alone does not prove the second part: + * AbstractHttpFront::boot() does not call getApplication(), and + * getApplication() sets the base namespace and the action directory. Thus + * the last test sends GET / through the generated application in this + * process. There is no docker and no HTTP. The full generate, install and + * request run is a release check. + * + * The generated classes are declared in this process. Thus they use a + * namespace that no other test uses. + */ +final class GeneratedProjectTest extends TestCase +{ + use GeneratesInAScratchProject; + + private const ROOT_NAMESPACE = 'Scaffolded'; + + private ?Closure $autoloader = null; + + private string $project = ''; + + /** @var array */ + private array $server = []; + + protected function setUp(): void + { + $this->startScratchProject('generated-project'); + $this->server = $_SERVER; + + if ( + false === PackageVersion::isInstalled('phalcon/phalcon') + && false === extension_loaded('phalcon') + ) { + $this->markTestSkipped('running the generated project needs Phalcon present'); + } + + $this->runProjectCommand('new', NewCommand::class, ['shop', '--namespace', self::ROOT_NAMESPACE]); + + $this->project = $this->root . '/shop'; + + // The project has no vendor/ yet. Load its classes as its own + // composer.json does: the root namespace maps to src/. + $source = $this->project . '/src/'; + + $this->autoloader = static function (string $class) use ($source): void { + $prefix = self::ROOT_NAMESPACE . '\\'; + + if (false === str_starts_with($class, $prefix)) { + return; + } + + $file = $source . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; + + if (true === is_file($file)) { + require $file; + } + }; + + spl_autoload_register($this->autoloader); + } + + protected function tearDown(): void + { + if (null !== $this->autoloader) { + spl_autoload_unregister($this->autoloader); + } + + $_SERVER = $this->server; + + $this->endScratchProject(); + } + + public function testCrestBootsTheGeneratedFrontController(): void + { + $this->assertInstanceOf( + Container::class, + Bootstrap::container(Config::discover($this->project)) + ); + } + + public function testEveryGeneratedPhpFileParsesAndItsImportsResolve(): void + { + $files = ['crest.php', '.htrouter.php', 'public/index.php', 'src/AppFront.php', 'src/Action/Get.php']; + + foreach ($files as $path) { + $code = (string) file_get_contents($this->project . '/' . $path); + + try { + $this->assertNotEmpty(token_get_all($code, TOKEN_PARSE)); + } catch (ParseError $error) { + $this->fail(sprintf('%s does not parse: %s', $path, $error->getMessage())); + } + + preg_match_all('/^use\s+(?!function\s|const\s)([\w\\\\]+)/m', $code, $matches); + + foreach ($matches[1] as $import) { + $this->assertTrue( + class_exists($import) || interface_exists($import), + sprintf('%s imports %s, which does not exist', $path, $import) + ); + } + } + } + + public function testTheGeneratedApplicationAnswersGetSlash(): void + { + $response = $this->get('/'); + + // The content is the message: on failure, the error responder puts + // the reason there. + $this->assertSame(200, $response->getStatusCode(), $response->getContent()); + } + + public function testTheGeneratedApplicationFindsActionsInItsDirectory(): void + { + // GET / names a class directly under the base namespace, so the router + // does not read the action directory for it. A deeper route does: the + // router goes into src/Action/Hello only when that directory exists. + $command = new ActionCommand(); + + $command->handle( + new Input( + 'make:action', + $command->define() + ->merge(Kernel::globals()) + ->bind(['GET', '/hello', '--directory', $this->project]) + ), + new Output($this->stdout, $this->stderr, false) + ); + + $response = $this->get('/hello'); + + $this->assertSame(200, $response->getStatusCode(), $response->getContent()); + } + + public function testTheGeneratedConfigurationIsReadByCrest(): void + { + $config = Config::discover($this->project); + + $this->assertSame(Flavor::ADR, $config->flavor()); + $this->assertSame(self::ROOT_NAMESPACE, $config->namespace()); + $this->assertSame(self::ROOT_NAMESPACE . '\\AppFront', $config->bootstrap()); + $this->assertSame(self::ROOT_NAMESPACE . '\\Action', $config->namespaceFor('action')); + $this->assertSame($this->project . '/src/Action', $config->path('action')); + } + + /** + * Sends a GET request through the generated application, as + * public/index.php does, but in this process and with no emitter. + */ + private function get(string $uri): ResponseInterface + { + // The class that crest.php names, as Bootstrap reads it. A literal + // class name here would make PHPStan look for a class that exists only + // at run time. + $class = Config::discover($this->project)->bootstrap(); + + if (null === $class) { + $this->fail('crest.php names no front controller'); + } + + $front = new $class($this->project); + + if (false === $front instanceof AbstractHttpFront) { + $this->fail(sprintf('%s is not a front controller', $class)); + } + + $container = $front->boot(); + $application = (new ReflectionMethod($front, 'getApplication'))->invoke($front, $container); + + if (false === $application instanceof Application) { + $this->fail('getApplication() did not return an ADR application'); + } + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = $uri; + + $request = $container->get(AttributeRequest::class); + + if (false === $request instanceof AttributeRequest) { + $this->fail('the container did not supply an AttributeRequest'); + } + + return $application->handle($request); + } +} diff --git a/tests/Unit/Command/InstallCommandTest.php b/tests/Unit/Command/InstallCommandTest.php new file mode 100644 index 0000000..1010107 --- /dev/null +++ b/tests/Unit/Command/InstallCommandTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command; + +use Crest\Command\InstallCommand; +use Crest\Tests\Support\Process\FakeRunner; +use Crest\Tests\Support\RunsACommandDirectly; +use PHPUnit\Framework\TestCase; + +final class InstallCommandTest extends TestCase +{ + use RunsACommandDirectly; + + protected function setUp(): void + { + $this->captureStreams(); + } + + protected function tearDown(): void + { + $this->closeStreams(); + } + + public function testComposerInstallRunsInTheAppService(): void + { + $runner = new FakeRunner(); + + $status = $this->handleDirectly(new InstallCommand($runner), []); + + $this->assertSame(0, $status); + $this->assertSame( + [[['docker', 'compose', 'exec', 'app', 'composer', 'install'], null]], + $runner->calls + ); + } + + public function testDefinitionNamesItselfInstall(): void + { + $this->assertSame('install', (new InstallCommand())->define()->getName()); + } +} diff --git a/tests/Unit/Command/NewCommandTest.php b/tests/Unit/Command/NewCommandTest.php new file mode 100644 index 0000000..626f9a7 --- /dev/null +++ b/tests/Unit/Command/NewCommandTest.php @@ -0,0 +1,592 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command; + +use Crest\Command\NewCommand; +use Crest\Commands; +use Crest\Console\Kernel; +use Crest\Console\Registry; +use Crest\Generator\Stub; +use Crest\Paths; +use Crest\Tests\Support\GeneratesInAScratchProject; +use PHPUnit\Framework\TestCase; + +use function dirname; +use function file_get_contents; +use function file_put_contents; +use function json_decode; +use function mkdir; + +use const JSON_THROW_ON_ERROR; +use const PHP_EOL; + +final class NewCommandTest extends TestCase +{ + use GeneratesInAScratchProject; + + protected function setUp(): void + { + $this->startScratchProject('new'); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testANamespacedRootReachesEveryFile(): void + { + $status = $this->runCommand(['my-app', '--namespace', 'Acme\\Shop']); + + $this->assertSame(0, $status); + $this->assertSame(['psr-4' => ['Acme\\Shop\\' => 'src/']], $this->composer()['autoload']); + $this->assertStringContainsString("'namespace' => 'Acme\\Shop',", $this->read('crest.php')); + $this->assertStringContainsString( + "'bootstrap' => Acme\\Shop\\AppFront::class,", + $this->read('crest.php') + ); + $this->assertStringContainsString("use Acme\\Shop\\AppFront;\n", $this->read('public/index.php')); + $this->assertStringContainsString("namespace Acme\\Shop;\n", $this->read('src/AppFront.php')); + $this->assertStringContainsString( + "->setBaseNamespace('Acme\\Shop\\Action')", + $this->read('src/AppFront.php') + ); + $this->assertStringContainsString( + "namespace Acme\\Shop\\Action;\n", + $this->read('src/Action/Get.php') + ); + } + + public function testAnEmptyDirectoryOptionMeansTheWorkingDirectory(): void + { + // `--directory="$DIR"` with an unset variable. As with every other + // option, empty reads as absent. Not: the project in the filesystem + // root. + $status = $this->runInWorkingDirectory(['my-app', '--directory=']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/my-app/composer.json'); + $this->assertStringStartsWith('Created my-app/' . PHP_EOL, $this->readStdout()); + } + + public function testAnEmptyExistingDirectoryIsUsed(): void + { + mkdir($this->root . '/my-app'); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/my-app/composer.json'); + } + + public function testANonEmptyDirectoryIsRefused(): void + { + mkdir($this->root . '/my-app'); + file_put_contents($this->root . '/my-app/notes.txt', 'mine'); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + $this->root . '/my-app exists and is not empty; pass --force to write into it', + $this->readStderr() + ); + $this->assertFileDoesNotExist($this->root . '/my-app/composer.json'); + } + + public function testAnUnknownPhalconVersionIsRejected(): void + { + $status = $this->runCommand(['my-app', '--phalcon', 'v7']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "unknown Phalcon version 'v7'; expected v5 or v6", + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/my-app'); + } + + public function testAnUnusableNamespaceIsRejected(): void + { + $status = $this->runCommand(['my-app', '--namespace', 'my-app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'my-app' is not a usable namespace; expected something like 'App' or 'Acme\\Shop'", + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/my-app'); + } + + public function testAPhpVersionBelowTheFloorIsRejected(): void + { + $status = $this->runCommand(['my-app', '--php', '8.0']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + 'PHP 8.0 is too old; the generated code needs 8.1 or later', + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist($this->root . '/my-app'); + } + + public function testAPhpVersionWithAPatchIsRejected(): void + { + $status = $this->runCommand(['my-app', '--php', '8.4.1']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'8.4.1' is not a PHP version; expected major.minor, e.g. 8.4", + $this->readStderr() + ); + } + + public function testAProjectNameMustStartWithALetterOrDigit(): void + { + $status = $this->runCommand(['_app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'_app' is not a usable project name; expected letters, digits, '-' and '_', " + . 'starting with a letter or digit', + $this->readStderr() + ); + } + + public function testAProjectNameThatIsAPathIsRejected(): void + { + $status = $this->runCommand(['../elsewhere']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'../elsewhere' is not a usable project name", + $this->readStderr() + ); + $this->assertDirectoryDoesNotExist(dirname($this->root) . '/elsewhere'); + } + + public function testAPublishedProjectStubInTheParentDirectoryIsUsed(): void + { + $override = Stub::overridePath($this->root, 'adr', 'project-readme'); + + mkdir(dirname($override), 0o775, true); + file_put_contents($override, "custom {{ project }}\n"); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(0, $status); + $this->assertSame("custom my-app\n", $this->read('README.md')); + } + + public function testATargetThatIsAFileIsRefused(): void + { + file_put_contents($this->root . '/my-app', 'not a directory'); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + $this->root . '/my-app exists and is not a directory', + $this->readStderr() + ); + } + + public function testComposerJsonRequiresTheExtensionByDefault(): void + { + $this->runCommand(['my-app']); + + $this->assertSame( + [ + 'type' => 'project', + 'require' => ['php' => '>=8.4', 'ext-phalcon' => '^5.18'], + 'autoload' => ['psr-4' => ['App\\' => 'src/']], + 'config' => ['sort-packages' => true], + ], + $this->composer() + ); + } + + public function testComposerJsonRequiresThePackageForV6(): void + { + $this->runCommand(['my-app', '--phalcon', 'v6']); + + $this->assertSame( + ['php' => '>=8.4', 'phalcon/phalcon' => '^6.0@RC'], + $this->composer()['require'] + ); + } + + public function testDefinitionNamesItselfNew(): void + { + $this->assertSame('new', (new NewCommand())->define()->getName()); + } + + public function testEveryProjectFileIsWritten(): void + { + $status = $this->runCommand(['my-app']); + + $this->assertSame(0, $status); + + foreach ( + [ + 'composer.json', + 'crest.php', + '.env', + '.gitignore', + '.htrouter.php', + 'README.md', + 'docker-compose.yml', + 'resources/docker/Dockerfile', + 'public/index.php', + 'src/AppFront.php', + 'src/Action/Get.php', + ] as $path + ) { + $this->assertFileExists($this->root . '/my-app/' . $path); + } + } + + public function testForceOverwritesAPreviouslyGeneratedProject(): void + { + $this->runCommand(['my-app']); + file_put_contents($this->root . '/my-app/composer.json', 'stale'); + + $status = $this->runCommand(['my-app', '--force']); + + $this->assertSame(0, $status); + $this->assertSame('project', $this->composer()['type']); + } + + public function testForceWritesIntoANonEmptyDirectory(): void + { + mkdir($this->root . '/my-app'); + file_put_contents($this->root . '/my-app/notes.txt', 'mine'); + + $status = $this->runCommand(['my-app', '--force']); + + $this->assertSame(0, $status); + $this->assertSame('mine', $this->read('notes.txt')); + $this->assertFileExists($this->root . '/my-app/composer.json'); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + public function testSurroundingBackslashesAreDropped(): void + { + $this->runCommand(['my-app', '--namespace', '\\Acme\\']); + + $this->assertSame(['psr-4' => ['Acme\\' => 'src/']], $this->composer()['autoload']); + } + + public function testTheClosingOutputShowsBothWaysToRunIt(): void + { + $this->runCommand(['my-app']); + + $target = $this->root . '/my-app'; + + $this->assertSame( + 'Created ' . $target . '/' . PHP_EOL + . PHP_EOL + . 'Nothing runs it yet. With docker:' . PHP_EOL + . PHP_EOL + . ' cd ' . $target . PHP_EOL + . ' crest up' . PHP_EOL + . ' crest install' . PHP_EOL + . PHP_EOL + . 'Or with PHP and composer on the host:' . PHP_EOL + . PHP_EOL + . ' cd ' . $target . PHP_EOL + . ' composer install' . PHP_EOL + . ' php -S localhost:8080 -t public .htrouter.php' . PHP_EOL + . PHP_EOL + . 'Then GET / answers from src/Action/Get.php' . PHP_EOL, + $this->readStdout() + ); + } + + public function testTheDirectoryOptionNamesWhereTheProjectGoes(): void + { + // Not the working directory, and with a trailing slash: the project + // must land in nested/, and the report must not show a double slash. + $status = $this->runInWorkingDirectory(['my-app', '--directory', $this->root . '/nested/']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/nested/my-app/composer.json'); + $this->assertStringStartsWith( + 'Created ' . $this->root . '/nested/my-app/' . PHP_EOL, + $this->readStdout() + ); + } + + public function testTheDockerfileCommentsTheExtensionOutForV6(): void + { + $this->runCommand(['my-app', '--phalcon', 'v6']); + + $dockerfile = $this->read('resources/docker/Dockerfile'); + + $this->assertStringContainsString("# Phalcon v6.\n", $dockerfile); + $this->assertStringContainsString("\n# pie install --no-interaction phalcon/cphalcon:^5.18\n", $dockerfile); + $this->assertStringNotContainsString("\npie install", $dockerfile); + } + + public function testTheDockerfileInstallsTheExtensionForV5(): void + { + $this->runCommand(['my-app']); + + $dockerfile = $this->read('resources/docker/Dockerfile'); + + $this->assertStringContainsString("# Phalcon v5.\n", $dockerfile); + $this->assertStringContainsString("\npie install --no-interaction phalcon/cphalcon:^5.18\n", $dockerfile); + $this->assertStringContainsString( + 'CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", ".htrouter.php"]', + $dockerfile + ); + } + + public function testTheDockerFilesCarryTheProjectName(): void + { + $this->runCommand(['my-app']); + + $this->assertStringContainsString("PROJECT_PREFIX=my-app\n", $this->read('.env')); + $this->assertStringContainsString( + 'container_name: ${PROJECT_PREFIX:-my-app}-app', + $this->read('docker-compose.yml') + ); + $this->assertStringStartsWith("# my-app\n", $this->read('README.md')); + } + + public function testTheFrontControllerIsRendered(): void + { + // Asserted whole: this is generated code nobody reviews. + $this->runCommand(['my-app']); + + $this->assertSame( + "setBaseNamespace('App\\Action')\n" + . " ->setActionDirectory(\$this->projectRoot . '/src/Action');\n" + . " }\n" + . "}\n", + $this->read('src/AppFront.php') + ); + } + + public function testTheGeneratedConfigNamesTheFrontController(): void + { + $this->runCommand(['my-app']); + + // The docblock comes before declare(): PSR-12 puts the file docblock + // first in the header. + $this->assertSame( + " 'adr',\n" + . " 'namespace' => 'App',\n" + . " 'bootstrap' => App\\AppFront::class,\n" + . "];\n", + $this->read('crest.php') + ); + } + + public function testTheGitignoreKeepsVendorOut(): void + { + $this->runCommand(['my-app']); + + $this->assertSame("/vendor/\n", $this->read('.gitignore')); + } + + public function testThePhalconVersionIsCaseInsensitive(): void + { + $this->runCommand(['my-app', '--phalcon', 'V6']); + + $this->assertSame( + ['php' => '>=8.4', 'phalcon/phalcon' => '^6.0@RC'], + $this->composer()['require'] + ); + } + + public function testThePhpFloorItselfIsAccepted(): void + { + $status = $this->runCommand(['my-app', '--php', '8.1']); + + $this->assertSame(0, $status); + } + + public function testThePhpVersionReachesComposerAndTheDockerfile(): void + { + $this->runCommand(['my-app', '--php', '8.3']); + + $this->assertSame(['php' => '>=8.3', 'ext-phalcon' => '^5.18'], $this->composer()['require']); + $this->assertStringContainsString( + "ARG PHP_VERSION=8.3\n", + $this->read('resources/docker/Dockerfile') + ); + } + + public function testTheRouterScriptIsRendered(): void + { + $this->runCommand(['my-app']); + + $this->assertSame( + "read('.htrouter.php') + ); + } + + public function testTheSeedActionIsTheRootAction(): void + { + // The packaged action stub, as make:action would render GET /. + $this->runCommand(['my-app']); + + $expected = (new Stub(Paths::stubs()))->render( + 'adr', + 'action', + [ + 'attributes' => '', + 'class' => 'Get', + 'namespace' => 'App\\Action', + 'params' => '', + ] + ); + + $this->assertSame($expected, $this->read('src/Action/Get.php')); + $this->assertStringContainsString('final class Get implements Action', $expected); + } + + public function testTheWebEntryPointIsRendered(): void + { + $this->runCommand(['my-app']); + + $this->assertSame( + "run());\n", + $this->read('public/index.php') + ); + } + + public function testWithoutDirectoryTheProjectLandsInTheWorkingDirectory(): void + { + // startScratchProject() moved the working directory into the scratch + // root, as a user would cd into the directory they want. + $status = $this->runInWorkingDirectory(['my-app']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->root . '/my-app/composer.json'); + $this->assertStringStartsWith('Created my-app/' . PHP_EOL, $this->readStdout()); + $this->assertStringContainsString(' cd my-app' . PHP_EOL, $this->readStdout()); + } + + /** + * @return array + */ + private function composer(): array + { + /** @var array $decoded */ + $decoded = json_decode($this->read('composer.json'), true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } + + private function read(string $path): string + { + return (string) file_get_contents($this->root . '/my-app/' . $path); + } + + /** + * @param list $arguments + */ + private function runCommand(array $arguments): int + { + return $this->runProjectCommand('new', NewCommand::class, $arguments); + } + + /** + * Runs `new` with no --directory added, the way a user types it. + * + * @param list $arguments + */ + private function runInWorkingDirectory(array $arguments): int + { + $kernel = new Kernel( + Commands::NAME, + (new Registry())->add('new', NewCommand::class), + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false + ); + + return $kernel->handle(['crest', 'new', ...$arguments]); + } +} diff --git a/tests/Unit/Command/Stub/PublishCommandTest.php b/tests/Unit/Command/Stub/PublishCommandTest.php index ee6f109..085c9d7 100644 --- a/tests/Unit/Command/Stub/PublishCommandTest.php +++ b/tests/Unit/Command/Stub/PublishCommandTest.php @@ -23,6 +23,7 @@ use function file_get_contents; use function file_put_contents; use function glob; +use function str_starts_with; final class PublishCommandTest extends TestCase { @@ -72,6 +73,14 @@ public function testAnAlreadyPublishedStubIsSkippedNotOverwritten(): void ); } + public function testAProjectStubMayBePublishedByName(): void + { + $status = $this->runCommand(['project-front']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root, 'adr', 'project-front')); + } + public function testAPublishedStubIsAByteForByteCopy(): void { $this->runCommand(['action']); @@ -169,6 +178,23 @@ public function testForceOverwritesAPublishedStub(): void ); } + public function testProjectStubsAreNotPublishedInBulk(): void + { + // They only have an effect in the directory `crest new` runs from. + // In a project they do nothing. + $projectStubs = glob(Paths::stubs() . '/adr/project-*.stub') ?: []; + + $this->assertNotEmpty($projectStubs); + + $this->runCommand([]); + + foreach ($projectStubs as $path) { + $this->assertFileDoesNotExist( + Stub::overridePath($this->root, 'adr', basename($path, '.stub')) + ); + } + } + public function testPublishedPathsAreReported(): void { $this->runCommand(['action']); @@ -196,6 +222,9 @@ public function testPublishingContinuesPastAStubTheProjectAlreadyHas(): void } /** + * The stubs a publish with no name copies: every packaged ADR stub except + * the project stubs. + * * @return list */ private function packagedStubs(): array @@ -203,7 +232,13 @@ private function packagedStubs(): array $names = []; foreach (glob(Paths::stubs() . '/adr/*.stub') ?: [] as $path) { - $names[] = basename($path, '.stub'); + $name = basename($path, '.stub'); + + if (true === str_starts_with($name, 'project-')) { + continue; + } + + $names[] = $name; } return $names; diff --git a/tests/Unit/Command/UpCommandTest.php b/tests/Unit/Command/UpCommandTest.php new file mode 100644 index 0000000..af0fd34 --- /dev/null +++ b/tests/Unit/Command/UpCommandTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command; + +use Crest\Command\UpCommand; +use Crest\Commands; +use Crest\Console\Kernel; +use Crest\Console\Registry; +use Crest\Tests\Support\Process\FakeRunner; +use Crest\Tests\Support\RunsACommandDirectly; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function getenv; +use function putenv; + +use const PHP_EOL; + +final class UpCommandTest extends TestCase +{ + use RunsACommandDirectly; + use ScratchDirectory; + + private string|false $savedPath = false; + + protected function setUp(): void + { + $this->captureStreams(); + $this->makeScratchDirectory('up', 'bin'); + $this->savedPath = getenv('PATH'); + } + + protected function tearDown(): void + { + putenv(false === $this->savedPath ? 'PATH' : 'PATH=' . $this->savedPath); + + $this->closeStreams(); + $this->removeScratchDirectory(); + } + + public function testAMissingDockerIsReportedAsACrestError(): void + { + // The real runner, through the real kernel, with no docker on the + // PATH. The user sees one crest line, not a PHP warning. + putenv('PATH=' . $this->root . '/bin'); + + $kernel = new Kernel( + Commands::NAME, + (new Registry())->add('up', UpCommand::class), + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false + ); + + $status = $kernel->handle(['crest', 'up']); + + $this->assertSame(1, $status); + $this->assertSame( + "crest: 'docker' was not found; install it or add it to the PATH" . PHP_EOL, + $this->readStderr() + ); + } + + public function testBuildRebuildsTheImagesFirst(): void + { + $runner = new FakeRunner(); + + $this->handleDirectly(new UpCommand($runner), ['--build']); + + $this->assertSame([[['docker', 'compose', 'up', '-d', '--build'], null]], $runner->calls); + } + + public function testDefinitionNamesItselfUp(): void + { + $this->assertSame('up', (new UpCommand())->define()->getName()); + } + + public function testTheContainersStartDetached(): void + { + $runner = new FakeRunner(); + + $status = $this->handleDirectly(new UpCommand($runner), []); + + $this->assertSame(0, $status); + $this->assertSame([[['docker', 'compose', 'up', '-d'], null]], $runner->calls); + } + + public function testTheDirectoryOptionIsWhereComposeRuns(): void + { + $runner = new FakeRunner(); + + $this->handleDirectly(new UpCommand($runner), ['--directory', $this->root]); + + $this->assertSame([[['docker', 'compose', 'up', '-d'], $this->root]], $runner->calls); + } + + public function testTheExitStatusOfComposeIsReturned(): void + { + $this->assertSame(5, $this->handleDirectly(new UpCommand(new FakeRunner(5)), [])); + } +} diff --git a/tests/Unit/CommandsTest.php b/tests/Unit/CommandsTest.php index fd587a4..66ce51e 100644 --- a/tests/Unit/CommandsTest.php +++ b/tests/Unit/CommandsTest.php @@ -28,15 +28,19 @@ public function testAliasesAreNotListedAsCommands(): void 'about', 'config:show', 'container:list', + 'down', 'event:list', + 'install', 'list', 'make:action', 'make:command', 'make:middleware', 'make:provider', 'make:responder', + 'new', 'route:list', 'stub:publish', + 'up', ], array_keys(Commands::registry()->all()) ); diff --git a/tests/Unit/Generator/ClassNameTest.php b/tests/Unit/Generator/ClassNameTest.php index e14dfff..37b57fc 100644 --- a/tests/Unit/Generator/ClassNameTest.php +++ b/tests/Unit/Generator/ClassNameTest.php @@ -57,6 +57,34 @@ public function testANamespacedNameIsRejected(): void ClassName::suffixed('Admin/Album', 'Responder'); } + public function testANamespaceLosesItsSurroundingBackslashes(): void + { + $this->assertSame('Acme\\Shop', ClassName::namespace('\\Acme\\Shop\\')); + } + + public function testANamespaceMayHaveSeveralSegments(): void + { + $this->assertSame('Acme\\Shop', ClassName::namespace('Acme\\Shop')); + } + + public function testANamespaceWithAHyphenIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage( + "'my-app' is not a usable namespace; expected something like 'App' or 'Acme\\Shop'" + ); + + ClassName::namespace('my-app'); + } + + public function testANamespaceWithAnEmptySegmentIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'Acme\\\\Shop' is not a usable namespace"); + + ClassName::namespace('Acme\\\\Shop'); + } + public function testANameWithASpaceIsRejected(): void { $this->expectException(Exception::class); @@ -73,6 +101,14 @@ public function testAnEmptyNameIsRejected(): void ClassName::suffixed('', 'Responder'); } + public function testAnEmptyNamespaceIsRejected(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'' is not a usable namespace"); + + ClassName::namespace(''); + } + public function testANonLatinNameIsAccepted(): void { // PHP's own identifier rule allows the high-byte range, so a class named diff --git a/tests/Unit/Generator/ProjectStubsTest.php b/tests/Unit/Generator/ProjectStubsTest.php new file mode 100644 index 0000000..b4b8833 --- /dev/null +++ b/tests/Unit/Generator/ProjectStubsTest.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Generator; + +use Crest\Console\PackageVersion; +use Crest\Generator\Stub; +use Crest\Paths; +use ParseError; +use PHPUnit\Framework\TestCase; + +use function basename; +use function class_exists; +use function extension_loaded; +use function glob; +use function interface_exists; +use function json_decode; +use function preg_match_all; +use function sprintf; +use function str_contains; +use function token_get_all; + +use const JSON_THROW_ON_ERROR; +use const TOKEN_PARSE; + +/** + * The project-* stubs render a whole application for `crest new`, not one + * artifact class. StubContractsTest skips them, and this test holds their + * contract. + */ +final class ProjectStubsTest extends TestCase +{ + private const FLAVOR = 'adr'; + + /** + * Every placeholder that the project stubs use. NewCommand supplies + * exactly these keys. + */ + private const REPLACEMENTS = [ + 'actionNamespace' => 'App\\Action', + 'actionPath' => 'src/Action', + 'jsonNamespace' => 'App', + 'namespace' => 'App', + 'phalconConstraint' => '^5', + 'phalconPackage' => 'ext-phalcon', + 'phalconVariant' => 'v5', + 'phpVersion' => '8.4', + 'project' => 'my-app', + 'v5' => '', + ]; + + /** + * @return iterable + */ + public static function phpStubs(): iterable + { + foreach (['project-config', 'project-front', 'project-htrouter', 'project-index'] as $name) { + yield $name => [$name]; + } + } + + /** + * @return iterable + */ + public static function projectStubs(): iterable + { + foreach (self::packagedNames() as $name) { + yield $name => [$name]; + } + } + + /** + * @dataProvider projectStubs + */ + public function testNoPlaceholderIsLeftUnrendered(string $name): void + { + $this->assertFalse( + str_contains($this->render($name), '{{'), + sprintf("stub '%s' left a placeholder unrendered", $name) + ); + } + + /** + * @dataProvider phpStubs + */ + public function testPhpStubsRenderToParseablePhp(string $name): void + { + try { + // TOKEN_PARSE makes this a syntax check, not only a tokenizer run. + $this->assertNotEmpty(token_get_all($this->render($name), TOKEN_PARSE)); + } catch (ParseError $error) { + $this->fail( + sprintf("stub '%s' does not render to valid PHP: %s", $name, $error->getMessage()) + ); + } + } + + public function testTheComposerStubRendersToValidJson(): void + { + $this->assertSame( + [ + 'type' => 'project', + 'require' => ['php' => '>=8.4', 'ext-phalcon' => '^5'], + 'autoload' => ['psr-4' => ['App\\' => 'src/']], + 'config' => ['sort-packages' => true], + ], + json_decode($this->render('project-composer'), true, 512, JSON_THROW_ON_ERROR) + ); + } + + public function testTheFrontControllerImportsResolve(): void + { + if ( + false === PackageVersion::isInstalled('phalcon/phalcon') + && false === extension_loaded('phalcon') + ) { + $this->markTestSkipped('resolving the front controller imports needs Phalcon present'); + } + + preg_match_all('/^use\s+([\w\\\\]+)/m', $this->render('project-front'), $matches); + + $this->assertNotEmpty($matches[1]); + + foreach ($matches[1] as $import) { + $this->assertTrue( + class_exists($import) || interface_exists($import), + sprintf('project-front imports %s, which does not exist', $import) + ); + } + } + + public function testTheProjectStubsArePackaged(): void + { + $this->assertSame( + [ + 'project-compose', + 'project-composer', + 'project-config', + 'project-dockerfile', + 'project-env', + 'project-front', + 'project-gitignore', + 'project-htrouter', + 'project-index', + 'project-readme', + ], + self::packagedNames() + ); + } + + /** + * @return list + */ + private static function packagedNames(): array + { + $names = []; + $found = glob(Stub::packagedDirectory(Paths::stubs(), self::FLAVOR) . '/project-*.stub'); + + foreach ($found ?: [] as $file) { + $names[] = basename($file, '.stub'); + } + + return $names; + } + + private function render(string $name): string + { + return (new Stub(Paths::stubs()))->render(self::FLAVOR, $name, self::REPLACEMENTS); + } +} diff --git a/tests/Unit/Generator/StubContractsTest.php b/tests/Unit/Generator/StubContractsTest.php index f4dbab0..7254437 100644 --- a/tests/Unit/Generator/StubContractsTest.php +++ b/tests/Unit/Generator/StubContractsTest.php @@ -40,6 +40,7 @@ use function preg_match_all; use function sprintf; use function str_contains; +use function str_starts_with; use function token_get_all; use function trait_exists; @@ -115,6 +116,12 @@ public static function packagedStubs(): iterable foreach (glob($directory . '/*.stub') ?: [] as $file) { $name = basename($file, '.stub'); + // The project stubs render a whole application, not one artifact + // class. ProjectStubsTest holds their contract. + if (true === str_starts_with($name, 'project-')) { + continue; + } + yield $name => [$name]; } } diff --git a/tests/Unit/Process/ShellRunnerTest.php b/tests/Unit/Process/ShellRunnerTest.php new file mode 100644 index 0000000..fe9e4f1 --- /dev/null +++ b/tests/Unit/Process/ShellRunnerTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Process; + +use Crest\Console\Exceptions\Exception; +use Crest\Process\ShellRunner; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function chmod; +use function file_put_contents; +use function getenv; +use function putenv; + +use const PHP_BINARY; + +final class ShellRunnerTest extends TestCase +{ + use ScratchDirectory; + + private string|false $savedPath = false; + + protected function setUp(): void + { + $this->makeScratchDirectory('shell-runner', 'bin', 'work'); + $this->savedPath = getenv('PATH'); + } + + protected function tearDown(): void + { + putenv(false === $this->savedPath ? 'PATH' : 'PATH=' . $this->savedPath); + + $this->removeScratchDirectory(); + } + + public function testABareNameIsFoundOnThePath(): void + { + $this->executable('bin/hello', "#!/bin/sh\nexit 4\n"); + putenv('PATH=' . $this->root . '/bin'); + + $this->assertSame(4, (new ShellRunner())->run(['hello'])); + } + + public function testAMissingDirectoryIsReportedNotIgnored(): void + { + // proc_open() ignores a working directory that does not exist, and + // the child runs in crest's own directory. For `down`, that is the + // wrong project. + $this->expectException(Exception::class); + $this->expectExceptionMessage($this->root . '/missing is not a directory'); + + (new ShellRunner())->run([PHP_BINARY, '-r', 'exit(0);'], $this->root . '/missing'); + } + + public function testAMissingProgramIsReportedByName(): void + { + putenv('PATH=' . $this->root . '/bin'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("'docker' was not found; install it or add it to the PATH"); + + (new ShellRunner())->run(['docker', 'compose', 'up', '-d']); + } + + public function testANonExecutableFileIsNotRun(): void + { + file_put_contents($this->root . '/bin/plain', "#!/bin/sh\nexit 0\n"); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("'" . $this->root . "/bin/plain' was not found"); + + (new ShellRunner())->run([$this->root . '/bin/plain']); + } + + public function testANonExecutableFileOnThePathIsNotFound(): void + { + file_put_contents($this->root . '/bin/plain', "#!/bin/sh\nexit 0\n"); + putenv('PATH=' . $this->root . '/bin'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("'plain' was not found"); + + (new ShellRunner())->run(['plain']); + } + + public function testAnUnsetPathFindsNothing(): void + { + putenv('PATH'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("'sh' was not found"); + + (new ShellRunner())->run(['sh']); + } + + public function testTheExitStatusIsReturned(): void + { + $this->assertSame(3, (new ShellRunner())->run([PHP_BINARY, '-r', 'exit(3);'])); + } + + public function testTheProgramRunsInTheGivenDirectory(): void + { + (new ShellRunner())->run( + [PHP_BINARY, '-r', 'file_put_contents("marker", "here");'], + $this->root . '/work' + ); + + $this->assertFileExists($this->root . '/work/marker'); + } + + private function executable(string $path, string $contents): void + { + file_put_contents($this->root . '/' . $path, $contents); + chmod($this->root . '/' . $path, 0o755); + } +} From 95fa62282454a8e2a3668a193facee444f245244 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 08:35:54 -0500 Subject: [PATCH 05/15] adding resources/stubs Assisted-by: Claude Code --- resources/infection.json5 | 22 ++++++-- resources/stubs/adr/project-compose.stub | 16 ++++++ resources/stubs/adr/project-composer.stub | 15 ++++++ resources/stubs/adr/project-config.stub | 14 +++++ resources/stubs/adr/project-dockerfile.stub | 57 +++++++++++++++++++++ resources/stubs/adr/project-env.stub | 8 +++ resources/stubs/adr/project-front.stub | 27 ++++++++++ resources/stubs/adr/project-gitignore.stub | 1 + resources/stubs/adr/project-htrouter.stub | 17 ++++++ resources/stubs/adr/project-index.stub | 11 ++++ resources/stubs/adr/project-readme.stub | 31 +++++++++++ 11 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 resources/stubs/adr/project-compose.stub create mode 100644 resources/stubs/adr/project-composer.stub create mode 100644 resources/stubs/adr/project-config.stub create mode 100644 resources/stubs/adr/project-dockerfile.stub create mode 100644 resources/stubs/adr/project-env.stub create mode 100644 resources/stubs/adr/project-front.stub create mode 100644 resources/stubs/adr/project-gitignore.stub create mode 100644 resources/stubs/adr/project-htrouter.stub create mode 100644 resources/stubs/adr/project-index.stub create mode 100644 resources/stubs/adr/project-readme.stub diff --git a/resources/infection.json5 b/resources/infection.json5 index 804fc15..e7d43a0 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -37,6 +37,10 @@ // getcwd() returns false only when the working directory is // unreadable, which the suite cannot produce. "Crest\\Project\\Config::discover", + // Same as Config::discover: getcwd() returns false only when + // the working directory cannot be read, which the suite + // cannot cause. + "Crest\\Command\\NewCommand::parent", // Environment branch. See the note on Identical below. "Crest\\Command\\AboutCommand::phalcon" ] @@ -108,7 +112,9 @@ // NO_COLOR and the tty check both yield "undecorated" against // the php://memory streams the suite uses, so the two branches // cannot be told apart without a real terminal. - "Crest\\Console\\Output::detectDecoration" + "Crest\\Console\\Output::detectDecoration", + // The guard that the suite cannot reach. See Throw_. + "Crest\\Process\\ShellRunner::start" ] }, "FunctionCallRemoval": { @@ -126,14 +132,24 @@ "Crest\\Generator\\ArtifactWriter::write", // Widening the slice by one includes the '--' token itself, // which is never '--trace', '--help' or '-h'. - "Crest\\Console\\Kernel::beforeLiteral" + "Crest\\Console\\Kernel::beforeLiteral", + // The guard that the suite cannot reach. See Throw_. + "Crest\\Process\\ShellRunner::start" ] }, "NotIdentical": { "ignore": ["Crest\\Console\\Output::detectDecoration"] }, "Throw_": { - "ignore": ["Crest\\ADR\\PhalconRouterResolver::router"] + "ignore": [ + "Crest\\ADR\\PhalconRouterResolver::router", + // proc_open() returns false only when it cannot create the + // process. ShellRunner::run() checks the program and the + // directory first, so the suite cannot make it fail. The same + // reason covers the FalseValue and IncrementInteger entries + // for this method. + "Crest\\Process\\ShellRunner::start" + ] }, "UnwrapArrayValues": { // The routes are keyed by path so they can be sorted; table() diff --git a/resources/stubs/adr/project-compose.stub b/resources/stubs/adr/project-compose.stub new file mode 100644 index 0000000..35d7b33 --- /dev/null +++ b/resources/stubs/adr/project-compose.stub @@ -0,0 +1,16 @@ +services: + app: + build: + context: . + dockerfile: resources/docker/Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-1000} + container_name: ${PROJECT_PREFIX:-{{ project }}}-app + hostname: ${PROJECT_PREFIX:-{{ project }}}-app + working_dir: /srv + ports: + - "${APP_PORT:-8080}:8080" + volumes: + - .:/srv + env_file: .env diff --git a/resources/stubs/adr/project-composer.stub b/resources/stubs/adr/project-composer.stub new file mode 100644 index 0000000..c07e11b --- /dev/null +++ b/resources/stubs/adr/project-composer.stub @@ -0,0 +1,15 @@ +{ + "type": "project", + "require": { + "php": ">={{ phpVersion }}", + "{{ phalconPackage }}": "{{ phalconConstraint }}" + }, + "autoload": { + "psr-4": { + "{{ jsonNamespace }}\\": "src/" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/resources/stubs/adr/project-config.stub b/resources/stubs/adr/project-config.stub new file mode 100644 index 0000000..0a96bd9 --- /dev/null +++ b/resources/stubs/adr/project-config.stub @@ -0,0 +1,14 @@ + 'adr', + 'namespace' => '{{ namespace }}', + 'bootstrap' => {{ namespace }}\AppFront::class, +]; diff --git a/resources/stubs/adr/project-dockerfile.stub b/resources/stubs/adr/project-dockerfile.stub new file mode 100644 index 0000000..bd63a18 --- /dev/null +++ b/resources/stubs/adr/project-dockerfile.stub @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1 +ARG PHP_VERSION={{ phpVersion }} + +FROM php:${PHP_VERSION}-cli + +ARG UID=1000 +ARG GID=1000 +ARG USER=app +ARG GROUP=app + +# System packages and a user with the host UID and GID. Files that the +# container writes through the bind mount then belong to you. +RUN <setBaseNamespace('{{ actionNamespace }}') + ->setActionDirectory($this->projectRoot . '/{{ actionPath }}'); + } +} diff --git a/resources/stubs/adr/project-gitignore.stub b/resources/stubs/adr/project-gitignore.stub new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/resources/stubs/adr/project-gitignore.stub @@ -0,0 +1 @@ +/vendor/ diff --git a/resources/stubs/adr/project-htrouter.stub b/resources/stubs/adr/project-htrouter.stub new file mode 100644 index 0000000..c98bbb5 --- /dev/null +++ b/resources/stubs/adr/project-htrouter.stub @@ -0,0 +1,17 @@ +run()); diff --git a/resources/stubs/adr/project-readme.stub b/resources/stubs/adr/project-readme.stub new file mode 100644 index 0000000..2341eea --- /dev/null +++ b/resources/stubs/adr/project-readme.stub @@ -0,0 +1,31 @@ +# {{ project }} + +A Phalcon ADR application. + +## Run it + +With docker: + + crest up + crest install + +With PHP and composer on the host: + + composer install + php -S localhost:8080 -t public .htrouter.php + +Then open http://localhost:8080/. + +## Layout + +| Path | Contents | +|---|---| +| `public/index.php` | The web entry point | +| `src/AppFront.php` | The front controller: container, providers, application | +| `src/Action/` | One class for each route. `GET /` is `src/Action/Get.php` | +| `crest.php` | The crest configuration | + +## Next steps + + crest make:action GET /hello + crest route:list From 22a0166f1fab8670de09c03e8f96f43c039e73c7 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 08:36:10 -0500 Subject: [PATCH 06/15] updating docs Assisted-by: Claude Code --- CHANGELOG.md | 5 +++++ README.md | 6 +++++- docs/index.md | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bafe2f6..22108ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added `Crest\Command\ProjectCommand::writer()`, assembling the stub writer once instead of repeating the same three-argument construction in five `make:*` commands. [#5](https://github.com/phalcon/crest/issues/5) - Added `methodFor()` to `Crest\ADR\ActionResolver`, so the HTTP method an Action answers is asked of the framework rather than derived from the class name. [#1](https://github.com/phalcon/crest/issues/1) - Added `Crest\Console\Registry::descriptions()` and `Crest\Console\Output::commandTable()`, so a bare `crest` and `crest list` render the command listing through one path instead of two copies kept in agreement by hand. +- Added `new`, creating an ADR project from stubs: front controller, web entry point, `crest.php`, an action for `GET /`, `composer.json` and docker files. It runs nothing - no composer, no docker, no network. `--namespace`, `--php` and `--phalcon` set the root namespace, the PHP version and the Phalcon variant. [#8](https://github.com/phalcon/crest/issues/8) +- Added `up`, `down` and `install`, running `docker compose up -d`, `docker compose down` and `composer install` in the `app` container of a project that `new` created. `--directory` names the project. [#8](https://github.com/phalcon/crest/issues/8) +- Added `Crest\Process\Runner`, the seam through which commands run external programs, with `ShellRunner` as the default. A missing program or working directory is reported as a crest error. [#8](https://github.com/phalcon/crest/issues/8) +- Added `Crest\Generator\ClassName::namespace()`, validating a namespace with the same identifier rule as a class name. [#8](https://github.com/phalcon/crest/issues/8) ### Changed @@ -40,6 +44,7 @@ All notable changes are documented here. The format is based on [Keep a Changelo - `route:list` and `make:action` now accept an `ActionResolver`, defaulted so the kernel still constructs them with no arguments. This is what lets a test prove the routing answers come from the framework rather than from crest. [#5](https://github.com/phalcon/crest/issues/5) - `event:list` now reads every listener in a single `getListenerMap()` call instead of one call per event type. [#1](https://github.com/phalcon/crest/issues/1) - `phalcon/talon` moved from `^0.8` to `^0.9`. +- `stub:publish` with no name leaves out the `project-*` stubs that `new` renders. They only have an effect where `new` runs; publish one by name. [#8](https://github.com/phalcon/crest/issues/8) ### Fixed diff --git a/README.md b/README.md index 9e589d1..1bcc459 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,14 @@ Command line application for Phalcon - generators, introspection and project too vendor/bin/crest list available commands vendor/bin/crest about environment and version report vendor/bin/crest make:action GET /company/all + vendor/bin/crest new my-app create an ADR project ## Global options | Option | Purpose | |---|---| | `--config=` | explicit path to `crest.php` | -| `--directory=` | project root override | +| `--directory=` | project root override; for `new`, the directory the project is created in | | `--trace` | full exception trace | | `--help`, `-h` | usage for the current command | | `--quiet`, `-q` | suppress non-essential output | @@ -93,6 +94,9 @@ filesystem and keeps working on a project that does not currently run. Copy a stub into `resources/stubs//` in your project and crest uses yours instead of the packaged one. +The `project-*` stubs that `new` renders are published by name only. See +[docs/index.md](docs/index.md#creating-a-project). + ## Development docker compose up -d diff --git a/docs/index.md b/docs/index.md index 6610a97..cd46327 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,19 +14,56 @@ for the same listing from the tool itself. | `about` (`info`, `i`) | environment and version report | | `config:show` | the project configuration crest resolved, and where each value came from | | `container:list` | services registered in the project container | +| `down` | stop and remove the project containers | | `event:list` | listeners attached to the project events manager | +| `install` | install composer dependencies in the project container | | `list` (`commands`, `enumerate`) | the available commands | | `make:action` | create an ADR action for a route | | `make:command` | create a crest command | | `make:middleware` | create an ADR middleware | | `make:provider` | create a service provider | | `make:responder` | create an ADR responder | +| `new` | create an ADR project | | `route:list` | every route the application answers | | `stub:publish` | copy packaged stubs into the project for editing | +| `up` | start the project containers | Only the `adr` flavor has generators. A `cli` or `mvc` project can still run `about`, `config:show` and `list`. +## Creating a project + +`new` writes an ADR project that runs: + + crest new my-app + +| Option | Purpose | +|---|---| +| `--namespace=` | root namespace for the generated code; defaults to `App` | +| `--php=` | PHP version for `composer.json` and the Dockerfile; defaults to `8.4`, and must be 8.1 or later | +| `--phalcon=v5\|v6` | `v5` requires the C extension, 5.18 or later; `v6` the `phalcon/phalcon` package; defaults to `v5` | +| `--force` | write into a directory that is not empty | + +The project goes into the working directory, or into `--directory` if you give +one. `new` runs nothing: no composer, no docker, no network. It prints the +next steps: + + cd my-app + crest up docker compose up -d + crest install composer install in the app container + +`crest down` stops and removes the containers. `up --build` rebuilds the image +first, and `down --volumes` also removes the named volumes. + +The files come from the `project-*` stubs. To change them, publish them by +name in the directory that you run `new` from, then edit the copies: + + crest stub:publish project-front + +`stub:publish` reads the project configuration, so that directory needs a +`crest.php`. ` Date: Fri, 25 Sep 2026 08:41:15 -0500 Subject: [PATCH 07/15] phpcs --- tests/Unit/Command/UpCommandTest.php | 2 +- tests/Unit/Generator/ProjectStubsTest.php | 30 +++++++++++------------ tests/Unit/Process/ShellRunnerTest.php | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/Unit/Command/UpCommandTest.php b/tests/Unit/Command/UpCommandTest.php index af0fd34..1e25452 100644 --- a/tests/Unit/Command/UpCommandTest.php +++ b/tests/Unit/Command/UpCommandTest.php @@ -32,7 +32,7 @@ final class UpCommandTest extends TestCase use RunsACommandDirectly; use ScratchDirectory; - private string|false $savedPath = false; + private false | string $savedPath = false; protected function setUp(): void { diff --git a/tests/Unit/Generator/ProjectStubsTest.php b/tests/Unit/Generator/ProjectStubsTest.php index b4b8833..be1d3cc 100644 --- a/tests/Unit/Generator/ProjectStubsTest.php +++ b/tests/Unit/Generator/ProjectStubsTest.php @@ -79,6 +79,21 @@ public static function projectStubs(): iterable } } + /** + * @return list + */ + private static function packagedNames(): array + { + $names = []; + $found = glob(Stub::packagedDirectory(Paths::stubs(), self::FLAVOR) . '/project-*.stub'); + + foreach ($found ?: [] as $file) { + $names[] = basename($file, '.stub'); + } + + return $names; + } + /** * @dataProvider projectStubs */ @@ -158,21 +173,6 @@ public function testTheProjectStubsArePackaged(): void ); } - /** - * @return list - */ - private static function packagedNames(): array - { - $names = []; - $found = glob(Stub::packagedDirectory(Paths::stubs(), self::FLAVOR) . '/project-*.stub'); - - foreach ($found ?: [] as $file) { - $names[] = basename($file, '.stub'); - } - - return $names; - } - private function render(string $name): string { return (new Stub(Paths::stubs()))->render(self::FLAVOR, $name, self::REPLACEMENTS); diff --git a/tests/Unit/Process/ShellRunnerTest.php b/tests/Unit/Process/ShellRunnerTest.php index fe9e4f1..cf0b026 100644 --- a/tests/Unit/Process/ShellRunnerTest.php +++ b/tests/Unit/Process/ShellRunnerTest.php @@ -29,7 +29,7 @@ final class ShellRunnerTest extends TestCase { use ScratchDirectory; - private string|false $savedPath = false; + private false | string $savedPath = false; protected function setUp(): void { From 3a1e3e73894d0da546099664423a3c75c119a1cc Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 08:51:57 -0500 Subject: [PATCH 08/15] fixing tests --- src/Command/Stub/PublishCommand.php | 7 ++-- .../Unit/Command/Stub/PublishCommandTest.php | 34 +++++++++++++++++++ tests/Unit/Generator/ProjectStubsTest.php | 5 +++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/Command/Stub/PublishCommand.php b/src/Command/Stub/PublishCommand.php index eab92a3..1bedac5 100644 --- a/src/Command/Stub/PublishCommand.php +++ b/src/Command/Stub/PublishCommand.php @@ -27,6 +27,7 @@ use function glob; use function is_file; use function preg_match; +use function sort; use function sprintf; use function str_starts_with; @@ -121,8 +122,6 @@ private function sources(string $flavor, string $name): array $found = []; - // glob() sorts alphabetically unless told not to, so the listing is - // stable without a sort of its own. foreach (glob(Stub::packagedDirectory(Paths::stubs(), $flavor) . '/*.stub') ?: [] as $path) { if (true === str_starts_with(basename($path), self::PROJECT_STUBS)) { continue; @@ -131,6 +130,10 @@ private function sources(string $flavor, string $name): array $found[] = $path; } + // glob() sorts with the collation of the locale, so its order changes + // from machine to machine. sort() gives byte order everywhere. + sort($found); + if ([] === $found) { throw new Exception(sprintf("no stubs are packaged for the '%s' flavor", $flavor)); } diff --git a/tests/Unit/Command/Stub/PublishCommandTest.php b/tests/Unit/Command/Stub/PublishCommandTest.php index 085c9d7..f240227 100644 --- a/tests/Unit/Command/Stub/PublishCommandTest.php +++ b/tests/Unit/Command/Stub/PublishCommandTest.php @@ -23,8 +23,13 @@ use function file_get_contents; use function file_put_contents; use function glob; +use function preg_match_all; +use function setlocale; +use function sort; use function str_starts_with; +use const LC_COLLATE; + final class PublishCommandTest extends TestCase { use GeneratesInAScratchProject; @@ -221,6 +226,35 @@ public function testPublishingContinuesPastAStubTheProjectAlreadyHas(): void } } + public function testStubsArePublishedInByteOrderOnEveryMachine(): void + { + // glob() sorts with the collation of the locale. Under en_US.UTF-8, + // `action.stub` comes before `action-view.stub`; in byte order it is + // the other way round. The listing must be the same on every machine. + $previous = (string) setlocale(LC_COLLATE, '0'); + + if (false === setlocale(LC_COLLATE, 'en_US.UTF-8', 'en_US.utf8')) { + $this->markTestSkipped('the en_US.UTF-8 locale is not installed'); + } + + try { + $this->runCommand([]); + } finally { + setlocale(LC_COLLATE, $previous); + } + + // Whole file names: without `.stub`, `action` sorts before + // `action-view` in byte order too, and the test would prove nothing. + preg_match_all('#/([a-z-]+\.stub)$#m', $this->readStdout(), $matches); + + $published = $matches[1]; + $sorted = $published; + sort($sorted); + + $this->assertNotEmpty($published); + $this->assertSame($sorted, $published); + } + /** * The stubs a publish with no name copies: every packaged ADR stub except * the project stubs. diff --git a/tests/Unit/Generator/ProjectStubsTest.php b/tests/Unit/Generator/ProjectStubsTest.php index be1d3cc..fa2d04e 100644 --- a/tests/Unit/Generator/ProjectStubsTest.php +++ b/tests/Unit/Generator/ProjectStubsTest.php @@ -26,6 +26,7 @@ use function interface_exists; use function json_decode; use function preg_match_all; +use function sort; use function sprintf; use function str_contains; use function token_get_all; @@ -91,6 +92,10 @@ private static function packagedNames(): array $names[] = basename($file, '.stub'); } + // glob() sorts with the collation of the locale, so its order changes + // from machine to machine. sort() gives byte order everywhere. + sort($names); + return $names; } From cb408083bf9a6b28e95b6a09ed7a2b6b213ca158 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 09:19:19 -0500 Subject: [PATCH 09/15] dedup and minor refactoring --- src/Command/Make/CommandCommand.php | 36 ++-- src/Command/Make/MiddlewareCommand.php | 29 +-- src/Command/Make/NamedArtifactCommand.php | 79 +++++++++ src/Command/Make/ProviderCommand.php | 29 +-- src/Command/Make/ResponderCommand.php | 33 +--- .../Support/NamedArtifactCommandTestCase.php | 165 ++++++++++++++++++ .../Unit/Command/Make/CommandCommandTest.php | 128 +++----------- .../Command/Make/MiddlewareCommandTest.php | 120 +------------ .../Unit/Command/Make/ProviderCommandTest.php | 122 +------------ .../Command/Make/ResponderCommandTest.php | 122 +------------ 10 files changed, 311 insertions(+), 552 deletions(-) create mode 100644 src/Command/Make/NamedArtifactCommand.php create mode 100644 tests/Support/NamedArtifactCommandTestCase.php diff --git a/src/Command/Make/CommandCommand.php b/src/Command/Make/CommandCommand.php index c30319a..4661fb3 100644 --- a/src/Command/Make/CommandCommand.php +++ b/src/Command/Make/CommandCommand.php @@ -13,11 +13,10 @@ namespace Crest\Command\Make; -use Crest\Command\ProjectCommand; use Crest\Commands; -use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; +use Crest\Generator\Placement; use function sprintf; use function str_replace; @@ -41,11 +40,11 @@ * `migration:run` from a class name, so the generated definition is a starting * point either way. */ -final class CommandCommand extends ProjectCommand +final class CommandCommand extends NamedArtifactCommand { - private const KEY = 'command'; + protected const KEY = 'command'; - private const SUFFIX = 'Command'; + protected const SUFFIX = 'Command'; public function define(): Definition { @@ -54,26 +53,8 @@ public function define(): Definition ->option('force', 'Overwrite an existing command'); } - public function handle(Input $input, Output $output): int + protected function guidance(Placement $placement, Output $output): void { - $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); - $name = $this->registryName($placement->class); - - $writer = $this->writer($config); - - $writer->render( - $placement->file, - self::KEY, - [ - 'namespace' => $placement->namespace, - 'class' => $placement->class, - 'command' => $name, - ], - true === $input->option('force') - ); - - $output->success(sprintf('Created %s', $placement->file)); $output->line('Nothing lists it yet. Declare it in the package composer.json:'); $output->line(); $output->line(' "extra": {'); @@ -82,15 +63,18 @@ public function handle(Input $input, Output $output): int $output->line( sprintf( ' "%s": "%s"', - $name, + $this->registryName($placement->class), str_replace('\\', '\\\\', $placement->namespace . '\\' . $placement->class) ) ); $output->line(' }'); $output->line(' }'); $output->line(' }'); + } - return 0; + protected function replacements(Placement $placement): array + { + return ['command' => $this->registryName($placement->class)]; } /** diff --git a/src/Command/Make/MiddlewareCommand.php b/src/Command/Make/MiddlewareCommand.php index f33df9a..ef0ca41 100644 --- a/src/Command/Make/MiddlewareCommand.php +++ b/src/Command/Make/MiddlewareCommand.php @@ -13,10 +13,9 @@ namespace Crest\Command\Make; -use Crest\Command\ProjectCommand; -use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; +use Crest\Generator\Placement; use function sprintf; @@ -30,11 +29,11 @@ * the registration instead: the file is crest's to write, the wiring is the * developer's to place. */ -final class MiddlewareCommand extends ProjectCommand +final class MiddlewareCommand extends NamedArtifactCommand { - private const KEY = 'middleware'; + protected const KEY = 'middleware'; - private const SUFFIX = 'Middleware'; + protected const SUFFIX = 'Middleware'; public function define(): Definition { @@ -43,24 +42,8 @@ public function define(): Definition ->option('force', 'Overwrite an existing middleware'); } - public function handle(Input $input, Output $output): int + protected function guidance(Placement $placement, Output $output): void { - $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); - - $writer = $this->writer($config); - - $writer->render( - $placement->file, - self::KEY, - [ - 'namespace' => $placement->namespace, - 'class' => $placement->class, - ], - true === $input->option('force') - ); - - $output->success(sprintf('Created %s', $placement->file)); $output->line('Nothing runs it yet. Add it to the router\'s middleware map:'); $output->line(); $output->line( @@ -75,7 +58,5 @@ public function handle(Input $input, Output $output): int "The key is a namespace suffix under the base namespace: '' guards every " . "action, '\\Album' only the actions beneath it." ); - - return 0; } } diff --git a/src/Command/Make/NamedArtifactCommand.php b/src/Command/Make/NamedArtifactCommand.php new file mode 100644 index 0000000..5b80aba --- /dev/null +++ b/src/Command/Make/NamedArtifactCommand.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Command\Make; + +use Crest\Command\ProjectCommand; +use Crest\Console\Input; +use Crest\Console\Output; +use Crest\Generator\Placement; + +use function sprintf; + +/** + * Base for a make:* command that writes one class from a user-given name. + * + * A subclass sets KEY and SUFFIX, and its definition must declare a `name` + * argument and a `--force` option: this class reads both. KEY selects the + * configured path, the namespace and the stub. + * + * A subclass that must tell the developer how to wire the class overrides + * guidance(). A subclass whose stub needs more values overrides replacements(). + */ +abstract class NamedArtifactCommand extends ProjectCommand +{ + /** @var string */ + protected const KEY = ''; + + /** @var string */ + protected const SUFFIX = ''; + + public function handle(Input $input, Output $output): int + { + $config = $this->config($input); + $placement = $this->placement($config, $input->argumentString('name'), static::KEY, static::SUFFIX); + + $this->writer($config)->render( + $placement->file, + static::KEY, + [ + 'namespace' => $placement->namespace, + 'class' => $placement->class, + ] + $this->replacements($placement), + true === $input->option('force') + ); + + $output->success(sprintf('Created %s', $placement->file)); + $this->guidance($placement, $output); + + return 0; + } + + /** + * Prints what the developer must do to make the class run. The default + * prints nothing. + */ + protected function guidance(Placement $placement, Output $output): void + { + } + + /** + * Stub values in addition to `namespace` and `class`. + * + * @return array + */ + protected function replacements(Placement $placement): array + { + return []; + } +} diff --git a/src/Command/Make/ProviderCommand.php b/src/Command/Make/ProviderCommand.php index e7aa471..cfac152 100644 --- a/src/Command/Make/ProviderCommand.php +++ b/src/Command/Make/ProviderCommand.php @@ -13,10 +13,9 @@ namespace Crest\Command\Make; -use Crest\Command\ProjectCommand; -use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; +use Crest\Generator\Placement; use function sprintf; @@ -33,11 +32,11 @@ * instead, including the parent:: line - omitting that one is a silent failure * that takes the ADR services down with it. */ -final class ProviderCommand extends ProjectCommand +final class ProviderCommand extends NamedArtifactCommand { - private const KEY = 'provider'; + protected const KEY = 'provider'; - private const SUFFIX = 'Provider'; + protected const SUFFIX = 'Provider'; public function define(): Definition { @@ -46,24 +45,8 @@ public function define(): Definition ->option('force', 'Overwrite an existing provider'); } - public function handle(Input $input, Output $output): int + protected function guidance(Placement $placement, Output $output): void { - $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); - - $writer = $this->writer($config); - - $writer->render( - $placement->file, - self::KEY, - [ - 'namespace' => $placement->namespace, - 'class' => $placement->class, - ], - true === $input->option('force') - ); - - $output->success(sprintf('Created %s', $placement->file)); $output->line('Nothing registers it yet. Call it from your front controller:'); $output->line(); $output->line(' protected function registerProviders(Container $container): void'); @@ -80,7 +63,5 @@ public function handle(Input $input, Output $output): int $output->line(' }'); $output->line(); $output->line('Keep the parent call: it is what registers the ADR services.'); - - return 0; } } diff --git a/src/Command/Make/ResponderCommand.php b/src/Command/Make/ResponderCommand.php index 91140a4..8c2ae3b 100644 --- a/src/Command/Make/ResponderCommand.php +++ b/src/Command/Make/ResponderCommand.php @@ -13,13 +13,8 @@ namespace Crest\Command\Make; -use Crest\Command\ProjectCommand; -use Crest\Console\Input; -use Crest\Console\Output; use Crest\Console\Parsing\Definition; -use function sprintf; - /** * Generates an ADR Responder - the one layer that speaks HTTP, turning a domain * payload into a response. @@ -32,11 +27,11 @@ * Boots nothing - it reads config and writes a file, so it keeps working on a * project that does not currently run. */ -final class ResponderCommand extends ProjectCommand +final class ResponderCommand extends NamedArtifactCommand { - private const KEY = 'responder'; + protected const KEY = 'responder'; - private const SUFFIX = 'Responder'; + protected const SUFFIX = 'Responder'; public function define(): Definition { @@ -47,26 +42,4 @@ public function define(): Definition // is never read. ->option('force', 'Overwrite an existing responder'); } - - public function handle(Input $input, Output $output): int - { - $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), self::KEY, self::SUFFIX); - - $writer = $this->writer($config); - - $writer->render( - $placement->file, - self::KEY, - [ - 'namespace' => $placement->namespace, - 'class' => $placement->class, - ], - true === $input->option('force') - ); - - $output->success(sprintf('Created %s', $placement->file)); - - return 0; - } } diff --git a/tests/Support/NamedArtifactCommandTestCase.php b/tests/Support/NamedArtifactCommandTestCase.php new file mode 100644 index 0000000..0e93acc --- /dev/null +++ b/tests/Support/NamedArtifactCommandTestCase.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Support; + +use Crest\Console\Command\Command; +use PHPUnit\Framework\TestCase; + +use function file_get_contents; +use function file_put_contents; +use function str_replace; + +/** + * The tests that every Crest\Command\Make\NamedArtifactCommand must pass. + * + * A subclass sets the constants for its command and keeps only the tests that + * are specific to that command. PHPUnit reports each test with the name of the + * subclass, so a failure still identifies the command. + */ +abstract class NamedArtifactCommandTestCase extends TestCase +{ + use GeneratesInAScratchProject; + + /** @var class-string */ + protected const COMMAND = Command::class; + + /** @var string */ + protected const DECLARATION = ''; + + /** @var string */ + protected const DIRECTORY = ''; + + /** @var string */ + protected const NAME = ''; + + /** @var string */ + protected const SUFFIX = ''; + + protected function setUp(): void + { + $this->startScratchProject(str_replace(':', '-', static::NAME), static::DIRECTORY); + } + + protected function tearDown(): void + { + $this->endScratchProject(); + } + + public function testAnUnusableNameIsReported(): void + { + $status = $this->runCommand(['Admin/Sample']); + + $this->assertSame(1, $status); + $this->assertStringContainsString( + "'Admin/Sample' is not a usable class name", + $this->readStderr() + ); + } + + public function testCreatedPathIsReported(): void + { + $this->runCommand(['Sample']); + + $this->assertStringContainsString( + 'Created ' . $this->file('Sample' . static::SUFFIX), + $this->readStdout() + ); + } + + public function testDefinitionNamesItself(): void + { + $class = static::COMMAND; + + $this->assertSame(static::NAME, (new $class())->define()->getName()); + } + + public function testForceOverwritesAnExistingFile(): void + { + $file = $this->file('Sample' . static::SUFFIX); + + $this->runCommand(['Sample']); + file_put_contents($file, 'stale'); + + $status = $this->runCommand(['Sample', '--force']); + + $this->assertSame(0, $status); + $this->assertStringNotContainsString('stale', (string) file_get_contents($file)); + } + + public function testNameArgumentIsRequired(): void + { + $status = $this->runCommand([]); + + $this->assertSame(1, $status); + $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); + } + + public function testRefusesToOverwriteWithoutForce(): void + { + $this->runCommand(['Sample']); + + $status = $this->runCommand(['Sample']); + + $this->assertSame(1, $status); + $this->assertStringContainsString('already exists', $this->readStderr()); + } + + public function testTheDirectoryIsCreatedWhenItIsAbsent(): void + { + // The configured path is only a default. A project that never had this + // artifact does not have the directory. + $this->safeDeleteDirectory($this->root . '/' . static::DIRECTORY); + + $status = $this->runCommand(['Sample']); + + $this->assertSame(0, $status); + $this->assertFileExists($this->file('Sample' . static::SUFFIX)); + } + + public function testTheImportIsAliasedSoTheNameCanNeverCollide(): void + { + // The name that is only the suffix is the pathological case: the class + // gets the same name as the contract or base class that the stub + // imports. Without the alias, the generated file does not compile. + $status = $this->runCommand([static::SUFFIX]); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + static::DECLARATION, + (string) file_get_contents($this->file(static::SUFFIX)) + ); + } + + public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void + { + $status = $this->runCommand(['Sample' . static::SUFFIX]); + + $this->assertSame(0, $status); + $this->assertFileExists($this->file('Sample' . static::SUFFIX)); + $this->assertFileDoesNotExist($this->file('Sample' . static::SUFFIX . static::SUFFIX)); + } + + /** + * @param list $arguments + */ + protected function runCommand(array $arguments): int + { + return $this->runProjectCommand(static::NAME, static::COMMAND, $arguments); + } + + private function file(string $class): string + { + return $this->root . '/' . static::DIRECTORY . '/' . $class . '.php'; + } +} diff --git a/tests/Unit/Command/Make/CommandCommandTest.php b/tests/Unit/Command/Make/CommandCommandTest.php index 26dd685..f3e4b9b 100644 --- a/tests/Unit/Command/Make/CommandCommandTest.php +++ b/tests/Unit/Command/Make/CommandCommandTest.php @@ -15,110 +15,23 @@ use Crest\Command\Make\CommandCommand; use Crest\Commands; -use Crest\Tests\Support\GeneratesInAScratchProject; -use PHPUnit\Framework\TestCase; +use Crest\Tests\Support\NamedArtifactCommandTestCase; use function file_get_contents; -use function file_put_contents; use const PHP_EOL; -final class CommandCommandTest extends TestCase +final class CommandCommandTest extends NamedArtifactCommandTestCase { - use GeneratesInAScratchProject; + protected const COMMAND = CommandCommand::class; - protected function setUp(): void - { - $this->startScratchProject('make-command', 'src/Command'); - } - - protected function tearDown(): void - { - $this->endScratchProject(); - } - - public function testAnUnusableNameIsReported(): void - { - $status = $this->runCommand(['Admin/Greet']); - - $this->assertSame(1, $status); - $this->assertStringContainsString( - "'Admin/Greet' is not a usable class name", - $this->readStderr() - ); - } - - public function testCreatedPathIsReported(): void - { - $this->runCommand(['Greet']); - - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Command/GreetCommand.php', - $this->readStdout() - ); - } - - public function testDefinitionNamesItselfMakeCommand(): void - { - $this->assertSame('make:command', (new CommandCommand())->define()->getName()); - } - - public function testForceOverwritesAnExistingCommand(): void - { - $this->runCommand(['Greet']); - file_put_contents($this->root . '/src/Command/GreetCommand.php', 'stale'); - - $status = $this->runCommand(['Greet', '--force']); - - $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', - (string) file_get_contents($this->root . '/src/Command/GreetCommand.php') - ); - } - - public function testNameArgumentIsRequired(): void - { - $status = $this->runCommand([]); - - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); - } - - public function testRefusesToOverwriteWithoutForce(): void - { - $this->runCommand(['Greet']); + protected const DECLARATION = 'final class Command extends CrestCommand'; - $status = $this->runCommand(['Greet']); + protected const DIRECTORY = 'src/Command'; - $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); - } + protected const NAME = 'make:command'; - public function testTheBaseClassIsAliasedSoTheNameCanNeverCollide(): void - { - // `make:command Command` is the pathological case: without the alias the - // stub would emit `final class Command extends Command`, which does not - // compile. The registry name falls back to the whole class rather than - // the empty string stripping the suffix would leave. - $status = $this->runCommand(['Command']); - - $contents = (string) file_get_contents($this->root . '/src/Command/Command.php'); - - $this->assertSame(0, $status); - $this->assertStringContainsString('final class Command extends CrestCommand', $contents); - $this->assertStringContainsString("Definition::for('command',", $contents); - } - - public function testTheCommandDirectoryIsCreatedWhenItIsAbsent(): void - { - $this->safeDeleteDirectory($this->root . '/src/Command'); - - $status = $this->runCommand(['Greet']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Command/GreetCommand.php'); - } + protected const SUFFIX = 'Command'; public function testTheExtraBlockIsPrintedWithEscapedBackslashes(): void { @@ -141,16 +54,27 @@ public function testTheExtraBlockIsPrintedWithEscapedBackslashes(): void $this->assertSame($expected, $this->readStdout()); } - public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void + public function testTheRegistryNameFallsBackToTheWholeClass(): void { - $status = $this->runCommand(['GreetCommand']); + // The registry name falls back to the whole class rather than the empty + // string stripping the suffix would leave. + $status = $this->runCommand(['Command']); - $contents = (string) file_get_contents($this->root . '/src/Command/GreetCommand.php'); + $contents = (string) file_get_contents($this->root . '/src/Command/Command.php'); $this->assertSame(0, $status); - $this->assertFileDoesNotExist($this->root . '/src/Command/GreetCommandCommand.php'); + $this->assertStringContainsString("Definition::for('command',", $contents); + } + + public function testTheSuffixDoesNotLeakIntoTheRegistryName(): void + { // The registry name is derived from the class minus its suffix, so // spelling the suffix out must not leak into it as 'greetcommand'. + $status = $this->runCommand(['GreetCommand']); + + $contents = (string) file_get_contents($this->root . '/src/Command/GreetCommand.php'); + + $this->assertSame(0, $status); $this->assertStringContainsString("Definition::for('greet',", $contents); } @@ -193,12 +117,4 @@ public function testTheWholeCommandIsRendered(): void (string) file_get_contents($this->root . '/src/Command/GreetCommand.php') ); } - - /** - * @param list $arguments - */ - private function runCommand(array $arguments): int - { - return $this->runProjectCommand('make:command', CommandCommand::class, $arguments); - } } diff --git a/tests/Unit/Command/Make/MiddlewareCommandTest.php b/tests/Unit/Command/Make/MiddlewareCommandTest.php index 8452833..ff39ae3 100644 --- a/tests/Unit/Command/Make/MiddlewareCommandTest.php +++ b/tests/Unit/Command/Make/MiddlewareCommandTest.php @@ -14,112 +14,23 @@ namespace Crest\Tests\Unit\Command\Make; use Crest\Command\Make\MiddlewareCommand; -use Crest\Tests\Support\GeneratesInAScratchProject; -use PHPUnit\Framework\TestCase; +use Crest\Tests\Support\NamedArtifactCommandTestCase; use function file_get_contents; -use function file_put_contents; use const PHP_EOL; -final class MiddlewareCommandTest extends TestCase +final class MiddlewareCommandTest extends NamedArtifactCommandTestCase { - use GeneratesInAScratchProject; + protected const COMMAND = MiddlewareCommand::class; - protected function setUp(): void - { - $this->startScratchProject('make-middleware', 'src/Middleware'); - } - - protected function tearDown(): void - { - $this->endScratchProject(); - } - - public function testAnUnusableNameIsReported(): void - { - $status = $this->runCommand(['Admin/Auth']); - - $this->assertSame(1, $status); - $this->assertStringContainsString( - "'Admin/Auth' is not a usable class name", - $this->readStderr() - ); - } - - public function testCreatedPathIsReported(): void - { - $this->runCommand(['Auth']); - - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Middleware/AuthMiddleware.php', - $this->readStdout() - ); - } - - public function testDefinitionNamesItselfMakeMiddleware(): void - { - $this->assertSame('make:middleware', (new MiddlewareCommand())->define()->getName()); - } - - public function testForceOverwritesAnExistingMiddleware(): void - { - $this->runCommand(['Auth']); - file_put_contents($this->root . '/src/Middleware/AuthMiddleware.php', 'stale'); - - $status = $this->runCommand(['Auth', '--force']); - - $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', - (string) file_get_contents($this->root . '/src/Middleware/AuthMiddleware.php') - ); - } - - public function testNameArgumentIsRequired(): void - { - $status = $this->runCommand([]); - - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); - } + protected const DECLARATION = 'final class Middleware implements MiddlewareContract'; - public function testRefusesToOverwriteWithoutForce(): void - { - $this->runCommand(['Auth']); + protected const DIRECTORY = 'src/Middleware'; - $status = $this->runCommand(['Auth']); + protected const NAME = 'make:middleware'; - $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); - } - - public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void - { - // `make:middleware Middleware` is the pathological case: the suffix is - // already there, so the class is named Middleware - and without the - // alias the stub would emit `implements Middleware` beside - // `use ...\Middleware;`, which does not compile. - $status = $this->runCommand(['Middleware']); - - $contents = (string) file_get_contents($this->root . '/src/Middleware/Middleware.php'); - - $this->assertSame(0, $status); - $this->assertStringContainsString( - 'final class Middleware implements MiddlewareContract', - $contents - ); - } - - public function testTheMiddlewareDirectoryIsCreatedWhenItIsAbsent(): void - { - $this->safeDeleteDirectory($this->root . '/src/Middleware'); - - $status = $this->runCommand(['Auth']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Middleware/AuthMiddleware.php'); - } + protected const SUFFIX = 'Middleware'; public function testTheRegistrationSnippetIsPrintedWithTheFullClassName(): void { @@ -142,15 +53,6 @@ public function testTheRegistrationSnippetIsPrintedWithTheFullClassName(): void $this->assertSame($expected, $this->readStdout()); } - public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void - { - $status = $this->runCommand(['AuthMiddleware']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Middleware/AuthMiddleware.php'); - $this->assertFileDoesNotExist($this->root . '/src/Middleware/AuthMiddlewareMiddleware.php'); - } - public function testTheWholeMiddlewareIsRendered(): void { // Asserted whole rather than by substring: this is generated code nobody @@ -183,12 +85,4 @@ public function testTheWholeMiddlewareIsRendered(): void (string) file_get_contents($this->root . '/src/Middleware/AuthMiddleware.php') ); } - - /** - * @param list $arguments - */ - private function runCommand(array $arguments): int - { - return $this->runProjectCommand('make:middleware', MiddlewareCommand::class, $arguments); - } } diff --git a/tests/Unit/Command/Make/ProviderCommandTest.php b/tests/Unit/Command/Make/ProviderCommandTest.php index 2c3cc28..3c82ddb 100644 --- a/tests/Unit/Command/Make/ProviderCommandTest.php +++ b/tests/Unit/Command/Make/ProviderCommandTest.php @@ -14,113 +14,24 @@ namespace Crest\Tests\Unit\Command\Make; use Crest\Command\Make\ProviderCommand; -use Crest\Tests\Support\GeneratesInAScratchProject; -use PHPUnit\Framework\TestCase; +use Crest\Tests\Support\NamedArtifactCommandTestCase; use function file_get_contents; -use function file_put_contents; use const PHP_EOL; -final class ProviderCommandTest extends TestCase +final class ProviderCommandTest extends NamedArtifactCommandTestCase { - use GeneratesInAScratchProject; + protected const COMMAND = ProviderCommand::class; - protected function setUp(): void - { - $this->startScratchProject('make-provider', 'src/Provider'); - } - - protected function tearDown(): void - { - $this->endScratchProject(); - } - - public function testAnUnusableNameIsReported(): void - { - $status = $this->runCommand(['Admin/Cache']); - - $this->assertSame(1, $status); - $this->assertStringContainsString( - "'Admin/Cache' is not a usable class name", - $this->readStderr() - ); - } - - public function testCreatedPathIsReported(): void - { - $this->runCommand(['Cache']); - - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Provider/CacheProvider.php', - $this->readStdout() - ); - } - - public function testDefinitionNamesItselfMakeProvider(): void - { - $this->assertSame('make:provider', (new ProviderCommand())->define()->getName()); - } - - public function testForceOverwritesAnExistingProvider(): void - { - $this->runCommand(['Cache']); - file_put_contents($this->root . '/src/Provider/CacheProvider.php', 'stale'); - - $status = $this->runCommand(['Cache', '--force']); - - $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', - (string) file_get_contents($this->root . '/src/Provider/CacheProvider.php') - ); - } - - public function testNameArgumentIsRequired(): void - { - $status = $this->runCommand([]); - - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); - } + // Collection is left unaliased: no artifact suffix can produce that name. + protected const DECLARATION = 'final class Provider implements ProviderContract'; - public function testRefusesToOverwriteWithoutForce(): void - { - $this->runCommand(['Cache']); + protected const DIRECTORY = 'src/Provider'; - $status = $this->runCommand(['Cache']); + protected const NAME = 'make:provider'; - $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); - } - - public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void - { - // `make:provider Provider` is the pathological case: the suffix is - // already there, so the class is named Provider - and without the alias - // the stub would emit `implements Provider` beside `use ...\Provider;`, - // which does not compile. Collection is left unaliased: no artifact - // suffix can produce that name. - $status = $this->runCommand(['Provider']); - - $contents = (string) file_get_contents($this->root . '/src/Provider/Provider.php'); - - $this->assertSame(0, $status); - $this->assertStringContainsString( - 'final class Provider implements ProviderContract', - $contents - ); - } - - public function testTheProviderDirectoryIsCreatedWhenItIsAbsent(): void - { - $this->safeDeleteDirectory($this->root . '/src/Provider'); - - $status = $this->runCommand(['Cache']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Provider/CacheProvider.php'); - } + protected const SUFFIX = 'Provider'; public function testTheRegistrationSnippetIsPrintedWithTheParentCall(): void { @@ -144,15 +55,6 @@ public function testTheRegistrationSnippetIsPrintedWithTheParentCall(): void $this->assertSame($expected, $this->readStdout()); } - public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void - { - $status = $this->runCommand(['CacheProvider']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Provider/CacheProvider.php'); - $this->assertFileDoesNotExist($this->root . '/src/Provider/CacheProviderProvider.php'); - } - public function testTheWholeProviderIsRendered(): void { // Asserted whole rather than by substring: this is generated code nobody @@ -187,12 +89,4 @@ public function testTheWholeProviderIsRendered(): void (string) file_get_contents($this->root . '/src/Provider/CacheProvider.php') ); } - - /** - * @param list $arguments - */ - private function runCommand(array $arguments): int - { - return $this->runProjectCommand('make:provider', ProviderCommand::class, $arguments); - } } diff --git a/tests/Unit/Command/Make/ResponderCommandTest.php b/tests/Unit/Command/Make/ResponderCommandTest.php index 1c86835..7cc8d92 100644 --- a/tests/Unit/Command/Make/ResponderCommandTest.php +++ b/tests/Unit/Command/Make/ResponderCommandTest.php @@ -14,121 +14,21 @@ namespace Crest\Tests\Unit\Command\Make; use Crest\Command\Make\ResponderCommand; -use Crest\Tests\Support\GeneratesInAScratchProject; -use PHPUnit\Framework\TestCase; +use Crest\Tests\Support\NamedArtifactCommandTestCase; use function file_get_contents; -use function file_put_contents; -final class ResponderCommandTest extends TestCase +final class ResponderCommandTest extends NamedArtifactCommandTestCase { - use GeneratesInAScratchProject; + protected const COMMAND = ResponderCommand::class; - protected function setUp(): void - { - $this->startScratchProject('make-responder', 'src/Responder'); - } - - protected function tearDown(): void - { - $this->endScratchProject(); - } - - public function testAnUnusableNameIsReported(): void - { - $status = $this->runCommand(['Admin/Album']); - - $this->assertSame(1, $status); - $this->assertStringContainsString( - "'Admin/Album' is not a usable class name", - $this->readStderr() - ); - } - - public function testCreatedPathIsReported(): void - { - $this->runCommand(['Album']); - - $this->assertStringContainsString( - 'Created ' . $this->root . '/src/Responder/AlbumResponder.php', - $this->readStdout() - ); - } - - public function testDefinitionNamesItselfMakeResponder(): void - { - $this->assertSame('make:responder', (new ResponderCommand())->define()->getName()); - } - - public function testForceOverwritesAnExistingResponder(): void - { - $this->runCommand(['Album']); - file_put_contents($this->root . '/src/Responder/AlbumResponder.php', 'stale'); - - $status = $this->runCommand(['Album', '--force']); + protected const DECLARATION = 'final class Responder implements ResponderContract'; - $this->assertSame(0, $status); - $this->assertStringNotContainsString( - 'stale', - (string) file_get_contents($this->root . '/src/Responder/AlbumResponder.php') - ); - } + protected const DIRECTORY = 'src/Responder'; - public function testNameArgumentIsRequired(): void - { - $status = $this->runCommand([]); + protected const NAME = 'make:responder'; - $this->assertSame(1, $status); - $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); - } - - public function testRefusesToOverwriteWithoutForce(): void - { - $this->runCommand(['Album']); - - $status = $this->runCommand(['Album']); - - $this->assertSame(1, $status); - $this->assertStringContainsString('already exists', $this->readStderr()); - } - - public function testTheContractIsAliasedSoTheNameCanNeverCollide(): void - { - // `make:responder Responder` is the pathological case: the suffix is - // already there, so the class is named Responder - and without the alias - // the stub would emit `implements Responder` beside - // `use ...\Responder;`, which does not compile. - $status = $this->runCommand(['Responder']); - - $contents = (string) file_get_contents($this->root . '/src/Responder/Responder.php'); - - $this->assertSame(0, $status); - $this->assertStringContainsString( - 'final class Responder implements ResponderContract', - $contents - ); - } - - public function testTheResponderDirectoryIsCreatedWhenItIsAbsent(): void - { - // A project that has never had a responder has no src/Responder, and the - // default path is only a default - nothing guarantees it exists. - $this->safeDeleteDirectory($this->root . '/src/Responder'); - - $status = $this->runCommand(['Album']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Responder/AlbumResponder.php'); - } - - public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void - { - $status = $this->runCommand(['AlbumResponder']); - - $this->assertSame(0, $status); - $this->assertFileExists($this->root . '/src/Responder/AlbumResponder.php'); - $this->assertFileDoesNotExist($this->root . '/src/Responder/AlbumResponderResponder.php'); - } + protected const SUFFIX = 'Responder'; public function testTheWholeResponderIsRendered(): void { @@ -167,12 +67,4 @@ public function testTheWholeResponderIsRendered(): void (string) file_get_contents($this->root . '/src/Responder/AlbumResponder.php') ); } - - /** - * @param list $arguments - */ - private function runCommand(array $arguments): int - { - return $this->runProjectCommand('make:responder', ResponderCommand::class, $arguments); - } } From 0d472004fae6755e6ad7576f25a2b3835bafd141 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 10:14:27 -0500 Subject: [PATCH 10/15] optimizations --- CHANGELOG.md | 1 + docs/index.md | 2 +- resources/stubs/adr/project-compose.stub | 2 +- resources/stubs/adr/project-dockerfile.stub | 10 +- src/Command/ComposeCommand.php | 4 +- src/Command/InstallCommand.php | 5 +- src/Command/Make/CommandCommand.php | 16 +++- src/Command/Make/MiddlewareCommand.php | 14 ++- src/Command/Make/NamedArtifactCommand.php | 27 +++--- src/Command/Make/ProviderCommand.php | 14 ++- src/Command/Make/ResponderCommand.php | 14 ++- src/Command/NewCommand.php | 37 +++++--- src/Command/Stub/PublishCommand.php | 13 +-- src/Generator/Stub.php | 7 ++ tests/Support/GeneratesInAScratchProject.php | 17 +--- .../Support/NamedArtifactCommandTestCase.php | 82 +++++++++------- tests/Support/RunsThroughTheKernel.php | 50 ++++++++++ .../Unit/Command/Make/CommandCommandTest.php | 35 +++++-- .../Command/Make/MiddlewareCommandTest.php | 35 +++++-- .../Unit/Command/Make/ProviderCommandTest.php | 37 +++++--- .../Command/Make/ResponderCommandTest.php | 35 +++++-- tests/Unit/Command/NewCommandTest.php | 93 ++++++++++++++++--- .../Unit/Command/Stub/PublishCommandTest.php | 4 +- tests/Unit/Command/UpCommandTest.php | 27 +++--- tests/Unit/Generator/ProjectStubsTest.php | 3 +- tests/Unit/Generator/StubContractsTest.php | 2 +- 26 files changed, 405 insertions(+), 181 deletions(-) create mode 100644 tests/Support/RunsThroughTheKernel.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 22108ce..e7cf85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added `up`, `down` and `install`, running `docker compose up -d`, `docker compose down` and `composer install` in the `app` container of a project that `new` created. `--directory` names the project. [#8](https://github.com/phalcon/crest/issues/8) - Added `Crest\Process\Runner`, the seam through which commands run external programs, with `ShellRunner` as the default. A missing program or working directory is reported as a crest error. [#8](https://github.com/phalcon/crest/issues/8) - Added `Crest\Generator\ClassName::namespace()`, validating a namespace with the same identifier rule as a class name. [#8](https://github.com/phalcon/crest/issues/8) +- Added `Crest\Command\Make\NamedArtifactCommand`, the base of `make:command`, `make:middleware`, `make:provider` and `make:responder`. The four commands repeated the same `handle()`; each now gives only its key, its suffix and the instructions it prints after the file is written. ### Changed diff --git a/docs/index.md b/docs/index.md index cd46327..94d3d03 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,7 +42,7 @@ Only the `adr` flavor has generators. A `cli` or `mvc` project can still run | `--namespace=` | root namespace for the generated code; defaults to `App` | | `--php=` | PHP version for `composer.json` and the Dockerfile; defaults to `8.4`, and must be 8.1 or later | | `--phalcon=v5\|v6` | `v5` requires the C extension, 5.18 or later; `v6` the `phalcon/phalcon` package; defaults to `v5` | -| `--force` | write into a directory that is not empty | +| `--force` | write into a directory that is not empty, and overwrite files with the same names | The project goes into the working directory, or into `--directory` if you give one. `new` runs nothing: no composer, no docker, no network. It prints the diff --git a/resources/stubs/adr/project-compose.stub b/resources/stubs/adr/project-compose.stub index 35d7b33..ebe29d3 100644 --- a/resources/stubs/adr/project-compose.stub +++ b/resources/stubs/adr/project-compose.stub @@ -1,5 +1,5 @@ services: - app: + {{ service }}: build: context: . dockerfile: resources/docker/Dockerfile diff --git a/resources/stubs/adr/project-dockerfile.stub b/resources/stubs/adr/project-dockerfile.stub index bd63a18..7c512c2 100644 --- a/resources/stubs/adr/project-dockerfile.stub +++ b/resources/stubs/adr/project-dockerfile.stub @@ -9,11 +9,12 @@ ARG USER=app ARG GROUP=app # System packages and a user with the host UID and GID. Files that the -# container writes through the bind mount then belong to you. +# container writes through the bind mount then belong to you. -o accepts an ID +# that the image already uses, for example GID 20 on macOS. RUN < $arguments */ @@ -52,7 +52,7 @@ protected function compose(Input $input, array $arguments): int { return $this->runner->run( ['docker', 'compose', ...$arguments], - $input->optionStringOrNull('directory') + $input->optionStringOrNull('directory') ?: null ); } } diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 442f6f7..8459eb9 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -26,9 +26,10 @@ final class InstallCommand extends ComposeCommand { /** * The service in docker-compose.yml. crest wrote that file - * (project-compose.stub), so the name belongs to crest. + * (project-compose.stub), so the name belongs to crest. NewCommand writes + * this value into the stub. */ - private const SERVICE = 'app'; + public const SERVICE = 'app'; public function define(): Definition { diff --git a/src/Command/Make/CommandCommand.php b/src/Command/Make/CommandCommand.php index 4661fb3..ef9a23a 100644 --- a/src/Command/Make/CommandCommand.php +++ b/src/Command/Make/CommandCommand.php @@ -42,10 +42,6 @@ */ final class CommandCommand extends NamedArtifactCommand { - protected const KEY = 'command'; - - protected const SUFFIX = 'Command'; - public function define(): Definition { return Definition::for('make:command', 'Create a crest command') @@ -72,11 +68,21 @@ protected function guidance(Placement $placement, Output $output): void $output->line(' }'); } + protected function key(): string + { + return 'command'; + } + protected function replacements(Placement $placement): array { return ['command' => $this->registryName($placement->class)]; } + protected function suffix(): string + { + return 'Command'; + } + /** * The name the registry answers to. Falls back to the whole class when * stripping the suffix leaves nothing, so `make:command Command` still @@ -84,6 +90,6 @@ protected function replacements(Placement $placement): array */ private function registryName(string $class): string { - return strtolower(substr($class, 0, -strlen(self::SUFFIX))) ?: strtolower($class); + return strtolower(substr($class, 0, -strlen($this->suffix()))) ?: strtolower($class); } } diff --git a/src/Command/Make/MiddlewareCommand.php b/src/Command/Make/MiddlewareCommand.php index ef0ca41..a67999a 100644 --- a/src/Command/Make/MiddlewareCommand.php +++ b/src/Command/Make/MiddlewareCommand.php @@ -31,10 +31,6 @@ */ final class MiddlewareCommand extends NamedArtifactCommand { - protected const KEY = 'middleware'; - - protected const SUFFIX = 'Middleware'; - public function define(): Definition { return Definition::for('make:middleware', 'Create an ADR middleware') @@ -59,4 +55,14 @@ protected function guidance(Placement $placement, Output $output): void . "action, '\\Album' only the actions beneath it." ); } + + protected function key(): string + { + return 'middleware'; + } + + protected function suffix(): string + { + return 'Middleware'; + } } diff --git a/src/Command/Make/NamedArtifactCommand.php b/src/Command/Make/NamedArtifactCommand.php index 5b80aba..fae16ff 100644 --- a/src/Command/Make/NamedArtifactCommand.php +++ b/src/Command/Make/NamedArtifactCommand.php @@ -23,29 +23,23 @@ /** * Base for a make:* command that writes one class from a user-given name. * - * A subclass sets KEY and SUFFIX, and its definition must declare a `name` - * argument and a `--force` option: this class reads both. KEY selects the - * configured path, the namespace and the stub. + * A subclass gives key() and suffix(), and its definition must declare a + * `name` argument and a `--force` option: this class reads both. * * A subclass that must tell the developer how to wire the class overrides * guidance(). A subclass whose stub needs more values overrides replacements(). */ abstract class NamedArtifactCommand extends ProjectCommand { - /** @var string */ - protected const KEY = ''; - - /** @var string */ - protected const SUFFIX = ''; - public function handle(Input $input, Output $output): int { + $key = $this->key(); $config = $this->config($input); - $placement = $this->placement($config, $input->argumentString('name'), static::KEY, static::SUFFIX); + $placement = $this->placement($config, $input->argumentString('name'), $key, $this->suffix()); $this->writer($config)->render( $placement->file, - static::KEY, + $key, [ 'namespace' => $placement->namespace, 'class' => $placement->class, @@ -67,6 +61,12 @@ protected function guidance(Placement $placement, Output $output): void { } + /** + * The configuration key. It selects the configured path, the namespace and + * the stub. + */ + abstract protected function key(): string; + /** * Stub values in addition to `namespace` and `class`. * @@ -76,4 +76,9 @@ protected function replacements(Placement $placement): array { return []; } + + /** + * The suffix of the class name, for example `Middleware`. + */ + abstract protected function suffix(): string; } diff --git a/src/Command/Make/ProviderCommand.php b/src/Command/Make/ProviderCommand.php index cfac152..9c30b6a 100644 --- a/src/Command/Make/ProviderCommand.php +++ b/src/Command/Make/ProviderCommand.php @@ -34,10 +34,6 @@ */ final class ProviderCommand extends NamedArtifactCommand { - protected const KEY = 'provider'; - - protected const SUFFIX = 'Provider'; - public function define(): Definition { return Definition::for('make:provider', 'Create a service provider') @@ -64,4 +60,14 @@ protected function guidance(Placement $placement, Output $output): void $output->line(); $output->line('Keep the parent call: it is what registers the ADR services.'); } + + protected function key(): string + { + return 'provider'; + } + + protected function suffix(): string + { + return 'Provider'; + } } diff --git a/src/Command/Make/ResponderCommand.php b/src/Command/Make/ResponderCommand.php index 8c2ae3b..245fcdc 100644 --- a/src/Command/Make/ResponderCommand.php +++ b/src/Command/Make/ResponderCommand.php @@ -29,10 +29,6 @@ */ final class ResponderCommand extends NamedArtifactCommand { - protected const KEY = 'responder'; - - protected const SUFFIX = 'Responder'; - public function define(): Definition { return Definition::for('make:responder', 'Create an ADR responder') @@ -42,4 +38,14 @@ public function define(): Definition // is never read. ->option('force', 'Overwrite an existing responder'); } + + protected function key(): string + { + return 'responder'; + } + + protected function suffix(): string + { + return 'Responder'; + } } diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php index c329db7..e6d250d 100644 --- a/src/Command/NewCommand.php +++ b/src/Command/NewCommand.php @@ -25,12 +25,14 @@ use Crest\Project\Flavor; use FilesystemIterator; +use function escapeshellarg; use function file_exists; use function getcwd; use function is_dir; use function preg_match; use function rtrim; use function sprintf; +use function str_contains; use function str_replace; use function strtolower; use function version_compare; @@ -43,8 +45,9 @@ * ProjectCommand. It gets all values from its arguments, and it writes * crest.php. It does not read it. * - * It runs nothing: no composer, no docker, no network. Thus it cannot stop - * halfway. Either it writes the tree, or an error tells why it did not. + * It runs nothing: no composer, no docker, no network. It checks all its + * input before it writes the first file. Only a write error, for example a + * full disk, can stop it after it has written some of the files. */ final class NewCommand extends Command { @@ -59,16 +62,16 @@ final class NewCommand extends Command * uses the action stub with its own placeholders. */ private const FILES = [ - 'project-composer' => 'composer.json', - 'project-config' => 'crest.php', - 'project-env' => '.env', - 'project-gitignore' => '.gitignore', - 'project-htrouter' => '.htrouter.php', - 'project-readme' => 'README.md', - 'project-compose' => 'docker-compose.yml', - 'project-dockerfile' => 'resources/docker/Dockerfile', - 'project-index' => 'public/index.php', - 'project-front' => 'src/AppFront.php', + Stub::PROJECT_PREFIX . 'composer' => 'composer.json', + Stub::PROJECT_PREFIX . 'config' => 'crest.php', + Stub::PROJECT_PREFIX . 'env' => '.env', + Stub::PROJECT_PREFIX . 'gitignore' => '.gitignore', + Stub::PROJECT_PREFIX . 'htrouter' => '.htrouter.php', + Stub::PROJECT_PREFIX . 'readme' => 'README.md', + Stub::PROJECT_PREFIX . 'compose' => 'docker-compose.yml', + Stub::PROJECT_PREFIX . 'dockerfile' => 'resources/docker/Dockerfile', + Stub::PROJECT_PREFIX . 'index' => 'public/index.php', + Stub::PROJECT_PREFIX . 'front' => 'src/AppFront.php', ]; /** @@ -109,7 +112,7 @@ public function define(): Definition ->option('namespace=s', 'Root namespace for the generated code', 'App') ->option('php=s', 'PHP version the project targets, major.minor', '8.4') ->option('phalcon=s', 'Phalcon: v5 (extension) or v6 (package)', 'v5') - ->option('force', 'Write into a directory that is not empty'); + ->option('force', 'Write into a directory that is not empty, and overwrite files with the same names'); } public function handle(Input $input, Output $output): int @@ -148,6 +151,7 @@ public function handle(Input $input, Output $output): int 'phalconVariant' => $variant, 'phpVersion' => $php, 'project' => $name, + 'service' => InstallCommand::SERVICE, // The prefix of each line of the extension install in the // Dockerfile: active for v5, commented out for v6. 'v5' => 'v5' === $variant ? '' : '# ', @@ -270,17 +274,20 @@ private function php(string $version): string */ private function report(Output $output, string $shown): void { + // Quoted when the path has a space, so that the line works when pasted. + $cd = sprintf(' cd %s', true === str_contains($shown, ' ') ? escapeshellarg($shown) : $shown); + $output->success(sprintf('Created %s/', $shown)); $output->line(); $output->line('Nothing runs it yet. With docker:'); $output->line(); - $output->line(sprintf(' cd %s', $shown)); + $output->line($cd); $output->line(' crest up'); $output->line(' crest install'); $output->line(); $output->line('Or with PHP and composer on the host:'); $output->line(); - $output->line(sprintf(' cd %s', $shown)); + $output->line($cd); $output->line(' composer install'); // Until `crest serve` exists, the host way names the server directly. $output->line(' php -S localhost:8080 -t public .htrouter.php'); diff --git a/src/Command/Stub/PublishCommand.php b/src/Command/Stub/PublishCommand.php index 1bedac5..93faa2e 100644 --- a/src/Command/Stub/PublishCommand.php +++ b/src/Command/Stub/PublishCommand.php @@ -49,14 +49,6 @@ final class PublishCommand extends ProjectCommand */ private const NAME = '/^[A-Za-z0-9_-]+$/'; - /** - * The prefix of the stubs that `crest new` renders. They only have an - * effect in the directory `new` runs from, not in a project. Thus a - * publish with no name leaves them out. A publish by name still copies - * them. - */ - private const PROJECT_STUBS = 'project-'; - public function define(): Definition { return Definition::for('stub:publish', 'Copy packaged stubs into the project for editing') @@ -122,8 +114,11 @@ private function sources(string $flavor, string $name): array $found = []; + // The project stubs only have an effect in the directory `new` runs + // from, not in a project. Thus a publish with no name leaves them out. + // A publish by name still copies them. foreach (glob(Stub::packagedDirectory(Paths::stubs(), $flavor) . '/*.stub') ?: [] as $path) { - if (true === str_starts_with(basename($path), self::PROJECT_STUBS)) { + if (true === str_starts_with(basename($path), Stub::PROJECT_PREFIX)) { continue; } diff --git a/src/Generator/Stub.php b/src/Generator/Stub.php index 8e0e48b..4bac842 100644 --- a/src/Generator/Stub.php +++ b/src/Generator/Stub.php @@ -31,6 +31,13 @@ */ final class Stub { + /** + * The name prefix of the stubs that `crest new` renders into a new + * project. They are not artifact stubs, so stub:publish with no name + * leaves them out. + */ + public const PROJECT_PREFIX = 'project-'; + /** * Where a project keeps the stubs it has taken over, relative to its root. * diff --git a/tests/Support/GeneratesInAScratchProject.php b/tests/Support/GeneratesInAScratchProject.php index ca448d8..daf85e8 100644 --- a/tests/Support/GeneratesInAScratchProject.php +++ b/tests/Support/GeneratesInAScratchProject.php @@ -13,10 +13,7 @@ namespace Crest\Tests\Support; -use Crest\Commands; use Crest\Console\Command\Command; -use Crest\Console\Kernel; -use Crest\Console\Registry; use function chdir; use function getcwd; @@ -33,7 +30,7 @@ */ trait GeneratesInAScratchProject { - use CapturesOutput; + use RunsThroughTheKernel; use ScratchDirectory; private string $previousCwd = ''; @@ -52,17 +49,7 @@ protected function endScratchProject(): void */ protected function runProjectCommand(string $name, string $class, array $arguments): int { - $registry = (new Registry())->add($name, $class); - $kernel = new Kernel( - Commands::NAME, - $registry, - Commands::PACKAGE, - $this->stdout, - $this->stderr, - false - ); - - return $kernel->handle(['crest', $name, ...$arguments, '--directory', $this->root]); + return $this->runThroughKernel($name, $class, [...$arguments, '--directory', $this->root]); } protected function startScratchProject(string $prefix, string ...$subdirectories): void diff --git a/tests/Support/NamedArtifactCommandTestCase.php b/tests/Support/NamedArtifactCommandTestCase.php index 0e93acc..d1a5ba4 100644 --- a/tests/Support/NamedArtifactCommandTestCase.php +++ b/tests/Support/NamedArtifactCommandTestCase.php @@ -23,32 +23,18 @@ /** * The tests that every Crest\Command\Make\NamedArtifactCommand must pass. * - * A subclass sets the constants for its command and keeps only the tests that - * are specific to that command. PHPUnit reports each test with the name of the - * subclass, so a failure still identifies the command. + * A subclass gives the values for its command through the abstract methods + * and keeps only the tests that are specific to that command. PHPUnit reports + * each test with the name of the subclass, so a failure still identifies the + * command. */ abstract class NamedArtifactCommandTestCase extends TestCase { use GeneratesInAScratchProject; - /** @var class-string */ - protected const COMMAND = Command::class; - - /** @var string */ - protected const DECLARATION = ''; - - /** @var string */ - protected const DIRECTORY = ''; - - /** @var string */ - protected const NAME = ''; - - /** @var string */ - protected const SUFFIX = ''; - protected function setUp(): void { - $this->startScratchProject(str_replace(':', '-', static::NAME), static::DIRECTORY); + $this->startScratchProject(str_replace(':', '-', $this->commandName()), $this->directory()); } protected function tearDown(): void @@ -72,21 +58,21 @@ public function testCreatedPathIsReported(): void $this->runCommand(['Sample']); $this->assertStringContainsString( - 'Created ' . $this->file('Sample' . static::SUFFIX), + 'Created ' . $this->file('Sample' . $this->suffix()), $this->readStdout() ); } public function testDefinitionNamesItself(): void { - $class = static::COMMAND; + $class = $this->command(); - $this->assertSame(static::NAME, (new $class())->define()->getName()); + $this->assertSame($this->commandName(), (new $class())->define()->getName()); } public function testForceOverwritesAnExistingFile(): void { - $file = $this->file('Sample' . static::SUFFIX); + $file = $this->file('Sample' . $this->suffix()); $this->runCommand(['Sample']); file_put_contents($file, 'stale'); @@ -119,12 +105,12 @@ public function testTheDirectoryIsCreatedWhenItIsAbsent(): void { // The configured path is only a default. A project that never had this // artifact does not have the directory. - $this->safeDeleteDirectory($this->root . '/' . static::DIRECTORY); + $this->safeDeleteDirectory($this->root . '/' . $this->directory()); $status = $this->runCommand(['Sample']); $this->assertSame(0, $status); - $this->assertFileExists($this->file('Sample' . static::SUFFIX)); + $this->assertFileExists($this->file('Sample' . $this->suffix())); } public function testTheImportIsAliasedSoTheNameCanNeverCollide(): void @@ -132,34 +118,64 @@ public function testTheImportIsAliasedSoTheNameCanNeverCollide(): void // The name that is only the suffix is the pathological case: the class // gets the same name as the contract or base class that the stub // imports. Without the alias, the generated file does not compile. - $status = $this->runCommand([static::SUFFIX]); + $status = $this->runCommand([$this->suffix()]); $this->assertSame(0, $status); $this->assertStringContainsString( - static::DECLARATION, - (string) file_get_contents($this->file(static::SUFFIX)) + $this->declaration(), + (string) file_get_contents($this->file($this->suffix())) ); } public function testTheSuffixIsNotDoubledWhenTheUserSuppliesIt(): void { - $status = $this->runCommand(['Sample' . static::SUFFIX]); + $status = $this->runCommand(['Sample' . $this->suffix()]); $this->assertSame(0, $status); - $this->assertFileExists($this->file('Sample' . static::SUFFIX)); - $this->assertFileDoesNotExist($this->file('Sample' . static::SUFFIX . static::SUFFIX)); + $this->assertFileExists($this->file('Sample' . $this->suffix())); + $this->assertFileDoesNotExist($this->file('Sample' . $this->suffix() . $this->suffix())); } + /** + * The command under test. + * + * @return class-string + */ + abstract protected function command(): string; + + /** + * The name that the command registers, for example `make:middleware`. + * Not name(): PHPUnit's TestCase declares that method final. + */ + abstract protected function commandName(): string; + + /** + * The class declaration that the stub writes when the name is only the + * suffix, for example `final class Middleware implements + * MiddlewareContract`. + */ + abstract protected function declaration(): string; + + /** + * Where the command writes, relative to the project root. + */ + abstract protected function directory(): string; + /** * @param list $arguments */ protected function runCommand(array $arguments): int { - return $this->runProjectCommand(static::NAME, static::COMMAND, $arguments); + return $this->runProjectCommand($this->commandName(), $this->command(), $arguments); } + /** + * The suffix of the class name, for example `Middleware`. + */ + abstract protected function suffix(): string; + private function file(string $class): string { - return $this->root . '/' . static::DIRECTORY . '/' . $class . '.php'; + return $this->root . '/' . $this->directory() . '/' . $class . '.php'; } } diff --git a/tests/Support/RunsThroughTheKernel.php b/tests/Support/RunsThroughTheKernel.php new file mode 100644 index 0000000..d06c299 --- /dev/null +++ b/tests/Support/RunsThroughTheKernel.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Support; + +use Crest\Commands; +use Crest\Console\Command\Command; +use Crest\Console\Kernel; +use Crest\Console\Registry; + +/** + * Runs one command through the real kernel, with the captured streams. + * + * The kernel creates the command with `new $class()`. A test that must give + * the command a fake collaborator uses RunsACommandDirectly instead. + * + * @mixin \PHPUnit\Framework\TestCase + */ +trait RunsThroughTheKernel +{ + use CapturesOutput; + + /** + * @param class-string $class + * @param list $tokens The arguments and options, as a user types them + */ + protected function runThroughKernel(string $name, string $class, array $tokens): int + { + $kernel = new Kernel( + Commands::NAME, + (new Registry())->add($name, $class), + Commands::PACKAGE, + $this->stdout, + $this->stderr, + false + ); + + return $kernel->handle(['crest', $name, ...$tokens]); + } +} diff --git a/tests/Unit/Command/Make/CommandCommandTest.php b/tests/Unit/Command/Make/CommandCommandTest.php index f3e4b9b..84bd89d 100644 --- a/tests/Unit/Command/Make/CommandCommandTest.php +++ b/tests/Unit/Command/Make/CommandCommandTest.php @@ -23,16 +23,6 @@ final class CommandCommandTest extends NamedArtifactCommandTestCase { - protected const COMMAND = CommandCommand::class; - - protected const DECLARATION = 'final class Command extends CrestCommand'; - - protected const DIRECTORY = 'src/Command'; - - protected const NAME = 'make:command'; - - protected const SUFFIX = 'Command'; - public function testTheExtraBlockIsPrintedWithEscapedBackslashes(): void { // The registry has no other way in, so the block is the deliverable. @@ -117,4 +107,29 @@ public function testTheWholeCommandIsRendered(): void (string) file_get_contents($this->root . '/src/Command/GreetCommand.php') ); } + + protected function command(): string + { + return CommandCommand::class; + } + + protected function commandName(): string + { + return 'make:command'; + } + + protected function declaration(): string + { + return 'final class Command extends CrestCommand'; + } + + protected function directory(): string + { + return 'src/Command'; + } + + protected function suffix(): string + { + return 'Command'; + } } diff --git a/tests/Unit/Command/Make/MiddlewareCommandTest.php b/tests/Unit/Command/Make/MiddlewareCommandTest.php index ff39ae3..a322990 100644 --- a/tests/Unit/Command/Make/MiddlewareCommandTest.php +++ b/tests/Unit/Command/Make/MiddlewareCommandTest.php @@ -22,16 +22,6 @@ final class MiddlewareCommandTest extends NamedArtifactCommandTestCase { - protected const COMMAND = MiddlewareCommand::class; - - protected const DECLARATION = 'final class Middleware implements MiddlewareContract'; - - protected const DIRECTORY = 'src/Middleware'; - - protected const NAME = 'make:middleware'; - - protected const SUFFIX = 'Middleware'; - public function testTheRegistrationSnippetIsPrintedWithTheFullClassName(): void { // The generated class is inert until the router names it, and crest will @@ -85,4 +75,29 @@ public function testTheWholeMiddlewareIsRendered(): void (string) file_get_contents($this->root . '/src/Middleware/AuthMiddleware.php') ); } + + protected function command(): string + { + return MiddlewareCommand::class; + } + + protected function commandName(): string + { + return 'make:middleware'; + } + + protected function declaration(): string + { + return 'final class Middleware implements MiddlewareContract'; + } + + protected function directory(): string + { + return 'src/Middleware'; + } + + protected function suffix(): string + { + return 'Middleware'; + } } diff --git a/tests/Unit/Command/Make/ProviderCommandTest.php b/tests/Unit/Command/Make/ProviderCommandTest.php index 3c82ddb..35f2035 100644 --- a/tests/Unit/Command/Make/ProviderCommandTest.php +++ b/tests/Unit/Command/Make/ProviderCommandTest.php @@ -22,17 +22,6 @@ final class ProviderCommandTest extends NamedArtifactCommandTestCase { - protected const COMMAND = ProviderCommand::class; - - // Collection is left unaliased: no artifact suffix can produce that name. - protected const DECLARATION = 'final class Provider implements ProviderContract'; - - protected const DIRECTORY = 'src/Provider'; - - protected const NAME = 'make:provider'; - - protected const SUFFIX = 'Provider'; - public function testTheRegistrationSnippetIsPrintedWithTheParentCall(): void { // The whole hint is the deliverable, asserted as one block: the blank @@ -89,4 +78,30 @@ public function testTheWholeProviderIsRendered(): void (string) file_get_contents($this->root . '/src/Provider/CacheProvider.php') ); } + + protected function command(): string + { + return ProviderCommand::class; + } + + protected function commandName(): string + { + return 'make:provider'; + } + + protected function declaration(): string + { + // Collection is left unaliased: no artifact suffix can produce that name. + return 'final class Provider implements ProviderContract'; + } + + protected function directory(): string + { + return 'src/Provider'; + } + + protected function suffix(): string + { + return 'Provider'; + } } diff --git a/tests/Unit/Command/Make/ResponderCommandTest.php b/tests/Unit/Command/Make/ResponderCommandTest.php index 7cc8d92..6a6f331 100644 --- a/tests/Unit/Command/Make/ResponderCommandTest.php +++ b/tests/Unit/Command/Make/ResponderCommandTest.php @@ -20,16 +20,6 @@ final class ResponderCommandTest extends NamedArtifactCommandTestCase { - protected const COMMAND = ResponderCommand::class; - - protected const DECLARATION = 'final class Responder implements ResponderContract'; - - protected const DIRECTORY = 'src/Responder'; - - protected const NAME = 'make:responder'; - - protected const SUFFIX = 'Responder'; - public function testTheWholeResponderIsRendered(): void { // Asserted whole rather than by substring: this is generated code nobody @@ -67,4 +57,29 @@ public function testTheWholeResponderIsRendered(): void (string) file_get_contents($this->root . '/src/Responder/AlbumResponder.php') ); } + + protected function command(): string + { + return ResponderCommand::class; + } + + protected function commandName(): string + { + return 'make:responder'; + } + + protected function declaration(): string + { + return 'final class Responder implements ResponderContract'; + } + + protected function directory(): string + { + return 'src/Responder'; + } + + protected function suffix(): string + { + return 'Responder'; + } } diff --git a/tests/Unit/Command/NewCommandTest.php b/tests/Unit/Command/NewCommandTest.php index 626f9a7..2aaac57 100644 --- a/tests/Unit/Command/NewCommandTest.php +++ b/tests/Unit/Command/NewCommandTest.php @@ -14,19 +14,22 @@ namespace Crest\Tests\Unit\Command; use Crest\Command\NewCommand; -use Crest\Commands; -use Crest\Console\Kernel; -use Crest\Console\Registry; use Crest\Generator\Stub; use Crest\Paths; use Crest\Tests\Support\GeneratesInAScratchProject; +use FilesystemIterator; use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; use function dirname; use function file_get_contents; use function file_put_contents; use function json_decode; use function mkdir; +use function preg_match; +use function sprintf; use const JSON_THROW_ON_ERROR; use const PHP_EOL; @@ -45,6 +48,15 @@ protected function tearDown(): void $this->endScratchProject(); } + /** + * @return iterable + */ + public static function variants(): iterable + { + yield 'v5' => ['v5']; + yield 'v6' => ['v6']; + } + public function testANamespacedRootReachesEveryFile(): void { $status = $this->runCommand(['my-app', '--namespace', 'Acme\\Shop']); @@ -288,6 +300,36 @@ public function testNameArgumentIsRequired(): void $this->assertStringContainsString("missing required argument 'name'", $this->readStderr()); } + /** + * @dataProvider variants + */ + public function testNoPlaceholderIsLeftInAnyWrittenFile(string $variant): void + { + // ProjectStubsTest renders the stubs with its own values. This test + // uses the values that NewCommand supplies, so a key that NewCommand + // does not supply fails here. + $this->runCommand(['my-app', '--phalcon', $variant]); + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->root . '/my-app', FilesystemIterator::SKIP_DOTS) + ); + + $scanned = 0; + + /** @var SplFileInfo $file */ + foreach ($files as $file) { + $scanned++; + + $this->assertStringNotContainsString( + '{{', + (string) file_get_contents($file->getPathname()), + sprintf('%s still has a placeholder', $file->getPathname()) + ); + } + + $this->assertGreaterThan(0, $scanned); + } + public function testSurroundingBackslashesAreDropped(): void { $this->runCommand(['my-app', '--namespace', '\\Acme\\']); @@ -335,6 +377,21 @@ public function testTheDirectoryOptionNamesWhereTheProjectGoes(): void ); } + public function testTheDockerfileAcceptsIdsThatTheImageAlreadyUses(): void + { + // GID 20 is the main group on macOS, and the Debian image already has + // it. Without -o, groupadd fails and the build stops. + $this->runCommand(['my-app']); + + $dockerfile = $this->read('resources/docker/Dockerfile'); + + $this->assertStringContainsString('groupadd -o -g "${GID}" "${GROUP}"', $dockerfile); + $this->assertStringContainsString( + 'useradd -l -m -o -u "${UID}" -g "${GID}" -s /bin/bash "${USER}"', + $dockerfile + ); + } + public function testTheDockerfileCommentsTheExtensionOutForV6(): void { $this->runCommand(['my-app', '--phalcon', 'v6']); @@ -360,6 +417,25 @@ public function testTheDockerfileInstallsTheExtensionForV5(): void ); } + public function testTheDockerfileInstallsTheExtensionVersionThatComposerRequires(): void + { + // The constraint is in two places: NewCommand writes it into + // composer.json, and the Dockerfile stub has its own copy. They must + // agree. + $this->runCommand(['my-app']); + + preg_match( + '#^pie install --no-interaction phalcon/cphalcon:(\S+)$#m', + $this->read('resources/docker/Dockerfile'), + $matches + ); + + $this->assertSame( + ['php' => '>=8.4', 'ext-phalcon' => $matches[1] ?? ''], + $this->composer()['require'] + ); + } + public function testTheDockerFilesCarryTheProjectName(): void { $this->runCommand(['my-app']); @@ -578,15 +654,6 @@ private function runCommand(array $arguments): int */ private function runInWorkingDirectory(array $arguments): int { - $kernel = new Kernel( - Commands::NAME, - (new Registry())->add('new', NewCommand::class), - Commands::PACKAGE, - $this->stdout, - $this->stderr, - false - ); - - return $kernel->handle(['crest', 'new', ...$arguments]); + return $this->runThroughKernel('new', NewCommand::class, $arguments); } } diff --git a/tests/Unit/Command/Stub/PublishCommandTest.php b/tests/Unit/Command/Stub/PublishCommandTest.php index f240227..de80ca5 100644 --- a/tests/Unit/Command/Stub/PublishCommandTest.php +++ b/tests/Unit/Command/Stub/PublishCommandTest.php @@ -187,7 +187,7 @@ public function testProjectStubsAreNotPublishedInBulk(): void { // They only have an effect in the directory `crest new` runs from. // In a project they do nothing. - $projectStubs = glob(Paths::stubs() . '/adr/project-*.stub') ?: []; + $projectStubs = glob(Paths::stubs() . '/adr/' . Stub::PROJECT_PREFIX . '*.stub') ?: []; $this->assertNotEmpty($projectStubs); @@ -268,7 +268,7 @@ private function packagedStubs(): array foreach (glob(Paths::stubs() . '/adr/*.stub') ?: [] as $path) { $name = basename($path, '.stub'); - if (true === str_starts_with($name, 'project-')) { + if (true === str_starts_with($name, Stub::PROJECT_PREFIX)) { continue; } diff --git a/tests/Unit/Command/UpCommandTest.php b/tests/Unit/Command/UpCommandTest.php index 1e25452..eabbd6f 100644 --- a/tests/Unit/Command/UpCommandTest.php +++ b/tests/Unit/Command/UpCommandTest.php @@ -14,11 +14,9 @@ namespace Crest\Tests\Unit\Command; use Crest\Command\UpCommand; -use Crest\Commands; -use Crest\Console\Kernel; -use Crest\Console\Registry; use Crest\Tests\Support\Process\FakeRunner; use Crest\Tests\Support\RunsACommandDirectly; +use Crest\Tests\Support\RunsThroughTheKernel; use Crest\Tests\Support\ScratchDirectory; use PHPUnit\Framework\TestCase; @@ -30,6 +28,7 @@ final class UpCommandTest extends TestCase { use RunsACommandDirectly; + use RunsThroughTheKernel; use ScratchDirectory; private false | string $savedPath = false; @@ -55,16 +54,7 @@ public function testAMissingDockerIsReportedAsACrestError(): void // PATH. The user sees one crest line, not a PHP warning. putenv('PATH=' . $this->root . '/bin'); - $kernel = new Kernel( - Commands::NAME, - (new Registry())->add('up', UpCommand::class), - Commands::PACKAGE, - $this->stdout, - $this->stderr, - false - ); - - $status = $kernel->handle(['crest', 'up']); + $status = $this->runThroughKernel('up', UpCommand::class, []); $this->assertSame(1, $status); $this->assertSame( @@ -73,6 +63,17 @@ public function testAMissingDockerIsReportedAsACrestError(): void ); } + public function testAnEmptyDirectoryOptionMeansTheWorkingDirectory(): void + { + // `--directory="$DIR"` with an unset variable. As for `new`, empty + // reads as absent, not as a directory named ''. + $runner = new FakeRunner(); + + $this->handleDirectly(new UpCommand($runner), ['--directory=']); + + $this->assertSame([[['docker', 'compose', 'up', '-d'], null]], $runner->calls); + } + public function testBuildRebuildsTheImagesFirst(): void { $runner = new FakeRunner(); diff --git a/tests/Unit/Generator/ProjectStubsTest.php b/tests/Unit/Generator/ProjectStubsTest.php index fa2d04e..9a8ded1 100644 --- a/tests/Unit/Generator/ProjectStubsTest.php +++ b/tests/Unit/Generator/ProjectStubsTest.php @@ -57,6 +57,7 @@ final class ProjectStubsTest extends TestCase 'phalconVariant' => 'v5', 'phpVersion' => '8.4', 'project' => 'my-app', + 'service' => 'app', 'v5' => '', ]; @@ -86,7 +87,7 @@ public static function projectStubs(): iterable private static function packagedNames(): array { $names = []; - $found = glob(Stub::packagedDirectory(Paths::stubs(), self::FLAVOR) . '/project-*.stub'); + $found = glob(Stub::packagedDirectory(Paths::stubs(), self::FLAVOR) . '/' . Stub::PROJECT_PREFIX . '*.stub'); foreach ($found ?: [] as $file) { $names[] = basename($file, '.stub'); diff --git a/tests/Unit/Generator/StubContractsTest.php b/tests/Unit/Generator/StubContractsTest.php index 7254437..ed407f2 100644 --- a/tests/Unit/Generator/StubContractsTest.php +++ b/tests/Unit/Generator/StubContractsTest.php @@ -118,7 +118,7 @@ public static function packagedStubs(): iterable // The project stubs render a whole application, not one artifact // class. ProjectStubsTest holds their contract. - if (true === str_starts_with($name, 'project-')) { + if (true === str_starts_with($name, Stub::PROJECT_PREFIX)) { continue; } From 7e77ab5b1486130eaef93db77549f55d163b5b89 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 11:24:44 -0500 Subject: [PATCH 11/15] fixing some coupling Assisted-by: Claude Code --- src/Command/Make/CommandCommand.php | 12 ++-- src/Command/Make/MiddlewareCommand.php | 12 ++-- src/Command/Make/NamedArtifactCommand.php | 29 +++++++- src/Command/Make/ProviderCommand.php | 12 ++-- src/Command/Make/ResponderCommand.php | 16 ++--- src/Command/NewCommand.php | 85 ++++++++++++++++------- src/Command/Stub/PublishCommand.php | 28 +++++--- src/Generator/Stub.php | 17 ++++- 8 files changed, 148 insertions(+), 63 deletions(-) diff --git a/src/Command/Make/CommandCommand.php b/src/Command/Make/CommandCommand.php index ef9a23a..132fac6 100644 --- a/src/Command/Make/CommandCommand.php +++ b/src/Command/Make/CommandCommand.php @@ -15,7 +15,6 @@ use Crest\Commands; use Crest\Console\Output; -use Crest\Console\Parsing\Definition; use Crest\Generator\Placement; use function sprintf; @@ -42,11 +41,14 @@ */ final class CommandCommand extends NamedArtifactCommand { - public function define(): Definition + protected function description(): string { - return Definition::for('make:command', 'Create a crest command') - ->argument('name', true, 'Command name, e.g. Greet') - ->option('force', 'Overwrite an existing command'); + return 'Create a crest command'; + } + + protected function example(): string + { + return 'Greet'; } protected function guidance(Placement $placement, Output $output): void diff --git a/src/Command/Make/MiddlewareCommand.php b/src/Command/Make/MiddlewareCommand.php index a67999a..b4c12ba 100644 --- a/src/Command/Make/MiddlewareCommand.php +++ b/src/Command/Make/MiddlewareCommand.php @@ -14,7 +14,6 @@ namespace Crest\Command\Make; use Crest\Console\Output; -use Crest\Console\Parsing\Definition; use Crest\Generator\Placement; use function sprintf; @@ -31,11 +30,14 @@ */ final class MiddlewareCommand extends NamedArtifactCommand { - public function define(): Definition + protected function description(): string { - return Definition::for('make:middleware', 'Create an ADR middleware') - ->argument('name', true, 'Middleware name, e.g. Auth') - ->option('force', 'Overwrite an existing middleware'); + return 'Create an ADR middleware'; + } + + protected function example(): string + { + return 'Auth'; } protected function guidance(Placement $placement, Output $output): void diff --git a/src/Command/Make/NamedArtifactCommand.php b/src/Command/Make/NamedArtifactCommand.php index fae16ff..808d609 100644 --- a/src/Command/Make/NamedArtifactCommand.php +++ b/src/Command/Make/NamedArtifactCommand.php @@ -16,21 +16,36 @@ use Crest\Command\ProjectCommand; use Crest\Console\Input; use Crest\Console\Output; +use Crest\Console\Parsing\Definition; use Crest\Generator\Placement; use function sprintf; +use function ucfirst; /** * Base for a make:* command that writes one class from a user-given name. * - * A subclass gives key() and suffix(), and its definition must declare a - * `name` argument and a `--force` option: this class reads both. + * A subclass gives key(), suffix(), description() and example(). This class + * declares the `name` argument and the `--force` option, because it reads + * both. * * A subclass that must tell the developer how to wire the class overrides * guidance(). A subclass whose stub needs more values overrides replacements(). */ abstract class NamedArtifactCommand extends ProjectCommand { + public function define(): Definition + { + $key = $this->key(); + + return Definition::for('make:' . $key, $this->description()) + ->argument('name', true, sprintf('%s name, e.g. %s', ucfirst($key), $this->example())) + // No declared default: resolveOptions() supplies false for a flag + // without consulting one, so passing it would state something that + // is never read. + ->option('force', sprintf('Overwrite an existing %s', $key)); + } + public function handle(Input $input, Output $output): int { $key = $this->key(); @@ -53,6 +68,16 @@ public function handle(Input $input, Output $output): int return 0; } + /** + * The command description, for example `Create an ADR middleware`. + */ + abstract protected function description(): string; + + /** + * An example name for the help text, for example `Auth`. + */ + abstract protected function example(): string; + /** * Prints what the developer must do to make the class run. The default * prints nothing. diff --git a/src/Command/Make/ProviderCommand.php b/src/Command/Make/ProviderCommand.php index 9c30b6a..ef29cc2 100644 --- a/src/Command/Make/ProviderCommand.php +++ b/src/Command/Make/ProviderCommand.php @@ -14,7 +14,6 @@ namespace Crest\Command\Make; use Crest\Console\Output; -use Crest\Console\Parsing\Definition; use Crest\Generator\Placement; use function sprintf; @@ -34,11 +33,14 @@ */ final class ProviderCommand extends NamedArtifactCommand { - public function define(): Definition + protected function description(): string { - return Definition::for('make:provider', 'Create a service provider') - ->argument('name', true, 'Provider name, e.g. Cache') - ->option('force', 'Overwrite an existing provider'); + return 'Create a service provider'; + } + + protected function example(): string + { + return 'Cache'; } protected function guidance(Placement $placement, Output $output): void diff --git a/src/Command/Make/ResponderCommand.php b/src/Command/Make/ResponderCommand.php index 245fcdc..a5916ee 100644 --- a/src/Command/Make/ResponderCommand.php +++ b/src/Command/Make/ResponderCommand.php @@ -13,8 +13,6 @@ namespace Crest\Command\Make; -use Crest\Console\Parsing\Definition; - /** * Generates an ADR Responder - the one layer that speaks HTTP, turning a domain * payload into a response. @@ -29,14 +27,14 @@ */ final class ResponderCommand extends NamedArtifactCommand { - public function define(): Definition + protected function description(): string + { + return 'Create an ADR responder'; + } + + protected function example(): string { - return Definition::for('make:responder', 'Create an ADR responder') - ->argument('name', true, 'Responder name, e.g. Album') - // No declared default: resolveOptions() supplies false for a flag - // without consulting one, so passing it would state something that - // is never read. - ->option('force', 'Overwrite an existing responder'); + return 'Album'; } protected function key(): string diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php index e6d250d..7aa6d46 100644 --- a/src/Command/NewCommand.php +++ b/src/Command/NewCommand.php @@ -57,6 +57,13 @@ final class NewCommand extends Command */ private const ACTION_PATH = 'src/Action'; + /** + * The crest that the new project requires. This crest creates the project, + * and the project crest (vendor/bin/crest) runs the project commands, + * because they need the project autoloader and its Phalcon. + */ + private const CREST = 'dev-master'; + /** * Stub name => path in the new project. The seed action is not here: it * uses the action stub with its own placeholders. @@ -105,6 +112,35 @@ final class NewCommand extends Command */ private const PHP_FLOOR = '8.1'; + /** + * The class of the seed action, which answers GET /. This is a copy of the + * framework routing rule, because there is no vendor/ to ask yet. + * GeneratedProjectTest sends GET / through the generated application to + * make sure that the copy is correct. + */ + private const SEED = 'Get'; + + /** + * The directory that the project goes into. + * + * --directory is the global project-root option. This command has no + * project yet, so for it the option names where the project goes. + * + * The project stub overrides are also read from here. stub:publish calls + * this method, so that it writes the overrides where this command reads + * them. + * + * An empty value reads as absent, as optionString() reads every other + * option. Otherwise `--directory="$DIR"` with an unset variable puts the + * project in the filesystem root. + */ + public static function parent(Input $input): string + { + $directory = $input->optionString('directory'); + + return rtrim('' === $directory ? (string) getcwd() : $directory, '/'); + } + public function define(): Definition { return Definition::for('new', 'Create an ADR project') @@ -130,7 +166,7 @@ public function handle(Input $input, Output $output): int [$package, $constraint] = self::PHALCON[$variant]; - $parent = $this->parent($input); + $parent = self::parent($input); $target = $parent . '/' . $name; $force = true === $input->option('force'); @@ -139,11 +175,13 @@ public function handle(Input $input, Output $output): int // Overrides come from the directory that the project goes into. A // team that publishes the project stubs there gets its own // conventions in each project that it creates there. - $writer = new ArtifactWriter(new Stub(Paths::stubs(), $parent), Flavor::ADR->value); + $stub = new Stub(Paths::stubs(), $parent); + $flavor = Flavor::ADR->value; $replacements = [ 'actionNamespace' => $namespace . '\\Action', 'actionPath' => self::ACTION_PATH, + 'crestConstraint' => self::CREST, 'jsonNamespace' => str_replace('\\', '\\\\', $namespace), 'namespace' => $namespace, 'phalconConstraint' => $constraint, @@ -151,30 +189,40 @@ public function handle(Input $input, Output $output): int 'phalconVariant' => $variant, 'phpVersion' => $php, 'project' => $name, + 'seed' => self::SEED, 'service' => InstallCommand::SERVICE, // The prefix of each line of the extension install in the // Dockerfile: active for v5, commented out for v6. 'v5' => 'v5' === $variant ? '' : '# ', ]; - foreach (self::FILES as $stub => $path) { - $writer->render($target . '/' . $path, $stub, $replacements, $force); + // All files render before the first write. A published stub that does + // not render then stops the command before it writes a file. + $files = []; + + foreach (self::FILES as $stubName => $path) { + $files[$path] = $stub->render($flavor, $stubName, $replacements); } // The seed action uses the usual action stub. Convention cannot name // it, because Convention asks the router, and there is no vendor/ yet. - $writer->render( - $target . '/' . self::ACTION_PATH . '/Get.php', + $files[self::ACTION_PATH . '/' . self::SEED . '.php'] = $stub->render( + $flavor, 'action', [ 'attributes' => '', - 'class' => 'Get', + 'class' => self::SEED, 'namespace' => $namespace . '\\Action', 'params' => '', - ], - $force + ] ); + // guard() has refused a directory that is not empty, unless --force + // is given. Thus a file that exists here can be overwritten. + foreach ($files as $path => $contents) { + ArtifactWriter::write($target . '/' . $path, $contents); + } + $this->report( $output, '' === $input->optionString('directory') ? $name : $target @@ -226,23 +274,6 @@ private function name(string $name): string return $name; } - /** - * The directory that the project goes into. - * - * --directory is the global project-root option. This command has no - * project yet, so for it the option names where the project goes. - * - * An empty value reads as absent, as optionString() reads every other - * option. Otherwise `--directory="$DIR"` with an unset variable puts the - * project in the filesystem root. - */ - private function parent(Input $input): string - { - $directory = $input->optionString('directory'); - - return rtrim('' === $directory ? (string) getcwd() : $directory, '/'); - } - /** * major.minor, and not older than the generated code needs. */ @@ -292,6 +323,6 @@ private function report(Output $output, string $shown): void // Until `crest serve` exists, the host way names the server directly. $output->line(' php -S localhost:8080 -t public .htrouter.php'); $output->line(); - $output->line('Then GET / answers from src/Action/Get.php'); + $output->line(sprintf('Then GET / answers from %s/%s.php', self::ACTION_PATH, self::SEED)); } } diff --git a/src/Command/Stub/PublishCommand.php b/src/Command/Stub/PublishCommand.php index 93faa2e..8e76fe9 100644 --- a/src/Command/Stub/PublishCommand.php +++ b/src/Command/Stub/PublishCommand.php @@ -13,6 +13,7 @@ namespace Crest\Command\Stub; +use Crest\Command\NewCommand; use Crest\Command\ProjectCommand; use Crest\Console\Exceptions\Exception; use Crest\Console\Input; @@ -21,6 +22,7 @@ use Crest\Generator\ArtifactWriter; use Crest\Generator\Stub; use Crest\Paths; +use Crest\Project\Flavor; use function basename; use function file_get_contents; @@ -58,14 +60,22 @@ public function define(): Definition public function handle(Input $input, Output $output): int { - $config = $this->config($input); - - $flavor = $config->flavor()->value; - $name = $input->argumentString('name'); - $force = true === $input->option('force'); + $name = $input->argumentString('name'); + $force = true === $input->option('force'); + + // A project stub has an effect only where `new` reads it. That + // directory is not a project, and `new` creates only ADR projects. + if (true === str_starts_with($name, Stub::PROJECT_PREFIX)) { + $root = NewCommand::parent($input); + $flavor = Flavor::ADR->value; + } else { + $config = $this->config($input); + $root = $config->root(); + $flavor = $config->flavor()->value; + } foreach ($this->sources($flavor, $name) as $source) { - $target = Stub::overridePath($config->root(), $flavor, basename($source, '.stub')); + $target = Stub::overridePath($root, $flavor, basename($source, '.stub')); if (true === is_file($target) && false === $force) { $output->line( @@ -114,9 +124,9 @@ private function sources(string $flavor, string $name): array $found = []; - // The project stubs only have an effect in the directory `new` runs - // from, not in a project. Thus a publish with no name leaves them out. - // A publish by name still copies them. + // The project stubs have an effect only in the directory that the + // project goes into, not in a project. Thus a publish with no name + // leaves them out. A publish by name still copies them. foreach (glob(Stub::packagedDirectory(Paths::stubs(), $flavor) . '/*.stub') ?: [] as $path) { if (true === str_starts_with(basename($path), Stub::PROJECT_PREFIX)) { continue; diff --git a/src/Generator/Stub.php b/src/Generator/Stub.php index 4bac842..f15cb3d 100644 --- a/src/Generator/Stub.php +++ b/src/Generator/Stub.php @@ -17,6 +17,7 @@ use function file_get_contents; use function is_file; +use function preg_match; use function rtrim; use function sprintf; use function str_replace; @@ -48,6 +49,11 @@ final class Stub */ private const OVERRIDE_DIRECTORY = 'resources/stubs'; + /** + * A placeholder: a name between `{{ ` and ` }}`. + */ + private const PLACEHOLDER = '/\{\{ \w+ \}\}/'; + private string $packagedRoot; private ?string $projectRoot; @@ -94,16 +100,25 @@ public static function packagedPath(string $packagedRoot, string $flavor, string } /** + * Fails when a placeholder has no value. A published copy can keep a + * placeholder that crest no longer sends, and the raw placeholder must not + * go into the generated file. + * * @param array $replacements */ public function render(string $flavor, string $name, array $replacements): string { - $template = (string) file_get_contents($this->resolve($flavor, $name)); + $path = $this->resolve($flavor, $name); + $template = (string) file_get_contents($path); foreach ($replacements as $key => $value) { $template = str_replace('{{ ' . $key . ' }}', $value, $template); } + if (1 === preg_match(self::PLACEHOLDER, $template, $matches)) { + throw new Exception(sprintf('%s has no value for %s', $path, $matches[0])); + } + return $template; } From 1dd0b620a9dc4711d1911b56cde3ebe7f39a1bbd Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 11:24:57 -0500 Subject: [PATCH 12/15] adjusting tests Assisted-by: Claude Code --- .../Command/Make/NamedArtifactCommandTest.php | 79 +++++++++++++++++++ tests/Unit/Command/NewCommandTest.php | 52 +++++++++++- .../Unit/Command/Stub/PublishCommandTest.php | 62 ++++++++++++++- tests/Unit/Generator/ProjectStubsTest.php | 11 ++- tests/Unit/Generator/StubTest.php | 23 +++--- 5 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 tests/Unit/Command/Make/NamedArtifactCommandTest.php diff --git a/tests/Unit/Command/Make/NamedArtifactCommandTest.php b/tests/Unit/Command/Make/NamedArtifactCommandTest.php new file mode 100644 index 0000000..d5efc91 --- /dev/null +++ b/tests/Unit/Command/Make/NamedArtifactCommandTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit\Command\Make; + +use Crest\Command\Make\NamedArtifactCommand; +use Crest\Console\Parsing\Definition; +use PHPUnit\Framework\TestCase; + +final class NamedArtifactCommandTest extends TestCase +{ + public function testTheBaseDeclaresTheForceOption(): void + { + $option = $this->definition()->findOption('force'); + + $this->assertNotNull($option); + $this->assertSame('Overwrite an existing widget', $option->description); + } + + public function testTheBaseDeclaresTheNameArgument(): void + { + $arguments = $this->definition()->getArguments(); + + $this->assertCount(1, $arguments); + $this->assertSame('name', $arguments[0]->name); + $this->assertTrue($arguments[0]->required); + $this->assertSame('Widget name, e.g. Blue', $arguments[0]->description); + } + + public function testTheCommandNameComesFromTheKey(): void + { + $definition = $this->definition(); + + $this->assertSame('make:widget', $definition->getName()); + $this->assertSame('Create a widget', $definition->getDescription()); + } + + /** + * A generator that gives only what the base cannot know. handle() reads + * the `name` argument and the --force option, so the base must declare + * them. + */ + private function definition(): Definition + { + $command = new class () extends NamedArtifactCommand { + protected function description(): string + { + return 'Create a widget'; + } + + protected function example(): string + { + return 'Blue'; + } + + protected function key(): string + { + return 'widget'; + } + + protected function suffix(): string + { + return 'Widget'; + } + }; + + return $command->define(); + } +} diff --git a/tests/Unit/Command/NewCommandTest.php b/tests/Unit/Command/NewCommandTest.php index 2aaac57..7aea690 100644 --- a/tests/Unit/Command/NewCommandTest.php +++ b/tests/Unit/Command/NewCommandTest.php @@ -14,6 +14,7 @@ namespace Crest\Tests\Unit\Command; use Crest\Command\NewCommand; +use Crest\Command\Stub\PublishCommand; use Crest\Generator\Stub; use Crest\Paths; use Crest\Tests\Support\GeneratesInAScratchProject; @@ -188,6 +189,31 @@ public function testAProjectNameThatIsAPathIsRejected(): void $this->assertDirectoryDoesNotExist(dirname($this->root) . '/elsewhere'); } + public function testAProjectStubFromStubPublishIsUsed(): void + { + // stub:publish must write where `new` reads, with the same + // --directory. That directory is not a project: it has no crest.php + // and no composer.json. + $directory = $this->root . '/work'; + + mkdir($directory); + + $published = $this->runThroughKernel( + 'stub:publish', + PublishCommand::class, + ['project-readme', '--directory', $directory] + ); + + $this->assertSame(0, $published); + + file_put_contents(Stub::overridePath($directory, 'adr', 'project-readme'), "custom {{ project }}\n"); + + $status = $this->runThroughKernel('new', NewCommand::class, ['my-app', '--directory', $directory]); + + $this->assertSame(0, $status); + $this->assertSame("custom my-app\n", (string) file_get_contents($directory . '/my-app/README.md')); + } + public function testAPublishedProjectStubInTheParentDirectoryIsUsed(): void { $override = Stub::overridePath($this->root, 'adr', 'project-readme'); @@ -201,6 +227,22 @@ public function testAPublishedProjectStubInTheParentDirectoryIsUsed(): void $this->assertSame("custom my-app\n", $this->read('README.md')); } + public function testAPublishedStubWithAnUnknownPlaceholderStopsBeforeAnyWrite(): void + { + // project-front renders last. Without the render pass before the first + // write, the files before it are already on disk when it fails. + $override = Stub::overridePath($this->root, 'adr', 'project-front'); + + mkdir(dirname($override), 0o775, true); + file_put_contents($override, "{{ old }}\n"); + + $status = $this->runCommand(['my-app']); + + $this->assertSame(1, $status); + $this->assertStringContainsString($override . ' has no value for {{ old }}', $this->readStderr()); + $this->assertDirectoryDoesNotExist($this->root . '/my-app'); + } + public function testATargetThatIsAFileIsRefused(): void { file_put_contents($this->root . '/my-app', 'not a directory'); @@ -220,10 +262,11 @@ public function testComposerJsonRequiresTheExtensionByDefault(): void $this->assertSame( [ - 'type' => 'project', - 'require' => ['php' => '>=8.4', 'ext-phalcon' => '^5.18'], - 'autoload' => ['psr-4' => ['App\\' => 'src/']], - 'config' => ['sort-packages' => true], + 'type' => 'project', + 'require' => ['php' => '>=8.4', 'ext-phalcon' => '^5.18'], + 'require-dev' => ['phalcon/crest' => 'dev-master'], + 'autoload' => ['psr-4' => ['App\\' => 'src/']], + 'config' => ['sort-packages' => true], ], $this->composer() ); @@ -505,6 +548,7 @@ public function testTheGeneratedConfigNamesTheFrontController(): void . " 'flavor' => 'adr',\n" . " 'namespace' => 'App',\n" . " 'bootstrap' => App\\AppFront::class,\n" + . " 'paths' => ['action' => 'src/Action'],\n" . "];\n", $this->read('crest.php') ); diff --git a/tests/Unit/Command/Stub/PublishCommandTest.php b/tests/Unit/Command/Stub/PublishCommandTest.php index de80ca5..a3091c0 100644 --- a/tests/Unit/Command/Stub/PublishCommandTest.php +++ b/tests/Unit/Command/Stub/PublishCommandTest.php @@ -20,13 +20,16 @@ use PHPUnit\Framework\TestCase; use function basename; +use function chdir; use function file_get_contents; use function file_put_contents; use function glob; +use function mkdir; use function preg_match_all; use function setlocale; use function sort; use function str_starts_with; +use function unlink; use const LC_COLLATE; @@ -78,6 +81,50 @@ public function testAnAlreadyPublishedStubIsSkippedNotOverwritten(): void ); } + public function testAProjectStubGoesIntoTheDirectoryOptionAndNotAParentProject(): void + { + // `new --directory work` reads the overrides from work/. A crest.php + // above it must not take the stub somewhere else. + file_put_contents($this->root . '/crest.php', "root . '/work'); + + $status = $this->runThroughKernel( + 'stub:publish', + PublishCommand::class, + ['project-front', '--directory', $this->root . '/work'] + ); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root . '/work', 'adr', 'project-front')); + $this->assertFileDoesNotExist(Stub::overridePath($this->root, 'adr', 'project-front')); + } + + public function testAProjectStubGoesIntoTheWorkingDirectoryAndNotAParentProject(): void + { + // `new` with no --directory reads the overrides from the working + // directory. endScratchProject() restores the working directory. + file_put_contents($this->root . '/crest.php', "root . '/work'); + chdir($this->root . '/work'); + + $status = $this->runThroughKernel('stub:publish', PublishCommand::class, ['project-front']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root . '/work', 'adr', 'project-front')); + $this->assertFileDoesNotExist(Stub::overridePath($this->root, 'adr', 'project-front')); + } + + public function testAProjectStubIgnoresTheConfiguredFlavor(): void + { + // `new` creates only ADR projects, so it reads only ADR overrides. + file_put_contents($this->root . '/crest.php', " 'mvc'];\n"); + + $status = $this->runCommand(['project-front']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root, 'adr', 'project-front')); + } + public function testAProjectStubMayBePublishedByName(): void { $status = $this->runCommand(['project-front']); @@ -86,6 +133,17 @@ public function testAProjectStubMayBePublishedByName(): void $this->assertFileExists(Stub::overridePath($this->root, 'adr', 'project-front')); } + public function testAProjectStubNeedsNoProjectConfiguration(): void + { + // The directory that `new` runs in is not a project. + unlink($this->root . '/composer.json'); + + $status = $this->runCommand(['project-front']); + + $this->assertSame(0, $status); + $this->assertFileExists(Stub::overridePath($this->root, 'adr', 'project-front')); + } + public function testAPublishedStubIsAByteForByteCopy(): void { $this->runCommand(['action']); @@ -185,8 +243,8 @@ public function testForceOverwritesAPublishedStub(): void public function testProjectStubsAreNotPublishedInBulk(): void { - // They only have an effect in the directory `crest new` runs from. - // In a project they do nothing. + // They have an effect only in the directory that `crest new` puts the + // project into. In a project they do nothing. $projectStubs = glob(Paths::stubs() . '/adr/' . Stub::PROJECT_PREFIX . '*.stub') ?: []; $this->assertNotEmpty($projectStubs); diff --git a/tests/Unit/Generator/ProjectStubsTest.php b/tests/Unit/Generator/ProjectStubsTest.php index 9a8ded1..86ddc4d 100644 --- a/tests/Unit/Generator/ProjectStubsTest.php +++ b/tests/Unit/Generator/ProjectStubsTest.php @@ -50,6 +50,7 @@ final class ProjectStubsTest extends TestCase private const REPLACEMENTS = [ 'actionNamespace' => 'App\\Action', 'actionPath' => 'src/Action', + 'crestConstraint' => '^1.0', 'jsonNamespace' => 'App', 'namespace' => 'App', 'phalconConstraint' => '^5', @@ -57,6 +58,7 @@ final class ProjectStubsTest extends TestCase 'phalconVariant' => 'v5', 'phpVersion' => '8.4', 'project' => 'my-app', + 'seed' => 'Get', 'service' => 'app', 'v5' => '', ]; @@ -130,10 +132,11 @@ public function testTheComposerStubRendersToValidJson(): void { $this->assertSame( [ - 'type' => 'project', - 'require' => ['php' => '>=8.4', 'ext-phalcon' => '^5'], - 'autoload' => ['psr-4' => ['App\\' => 'src/']], - 'config' => ['sort-packages' => true], + 'type' => 'project', + 'require' => ['php' => '>=8.4', 'ext-phalcon' => '^5'], + 'require-dev' => ['phalcon/crest' => '^1.0'], + 'autoload' => ['psr-4' => ['App\\' => 'src/']], + 'config' => ['sort-packages' => true], ], json_decode($this->render('project-composer'), true, 512, JSON_THROW_ON_ERROR) ); diff --git a/tests/Unit/Generator/StubTest.php b/tests/Unit/Generator/StubTest.php index 3254ed1..bc8a7ce 100644 --- a/tests/Unit/Generator/StubTest.php +++ b/tests/Unit/Generator/StubTest.php @@ -35,6 +35,20 @@ protected function tearDown(): void $this->removeScratchDirectory(); } + public function testAPlaceholderWithNoValueThrows(): void + { + // A published copy can keep a placeholder that crest no longer sends. + // Without this, the raw placeholder goes into the generated file. + file_put_contents($this->root . '/packaged/adr/action.stub', '{{ a }}|{{ b }}'); + + $stub = new Stub($this->root . '/packaged'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage($this->root . '/packaged/adr/action.stub has no value for {{ b }}'); + + $stub->render('adr', 'action', ['a' => 'X']); + } + public function testPackagedRootIsAlsoStrippedOfATrailingSlash(): void { file_put_contents($this->root . '/packaged/adr/action.stub', 'packaged'); @@ -134,13 +148,4 @@ public function testUnknownStubThrows(): void $stub->resolve('adr', 'nope'); } - - public function testUnreplacedPlaceholdersAreLeftAlone(): void - { - file_put_contents($this->root . '/packaged/adr/action.stub', '{{ a }}|{{ b }}'); - - $stub = new Stub($this->root . '/packaged'); - - $this->assertSame('X|{{ b }}', $stub->render('adr', 'action', ['a' => 'X'])); - } } From ea8cd33cab91aa11e4c684dfde5de8fb7fe00b74 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 11:25:10 -0500 Subject: [PATCH 13/15] fixing stubs and enabling v5 --- resources/octocov.yml | 4 ++-- resources/stubs/adr/project-composer.stub | 3 +++ resources/stubs/adr/project-config.stub | 1 + resources/stubs/adr/project-readme.stub | 14 +++++++++++--- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/resources/octocov.yml b/resources/octocov.yml index 53a6bd7..6ef9d1c 100644 --- a/resources/octocov.yml +++ b/resources/octocov.yml @@ -24,8 +24,8 @@ # installed" fallback (2 lines) # - Registry::discover, the "Composer\InstalledVersions is missing" return # (1 line) -# The ext-phalcon branch starts being covered once the v5 matrix cell is added -# (blocked on ext-phalcon 5.18). +# The ext-phalcon branch stays uncovered: the unit-tests job runs the v5 +# variant, but the coverage job runs v6 only. # # The gate is deliberately set well below the measured value while the suite is # still growing, so ordinary work is not blocked by the floor. Ratchet it up as diff --git a/resources/stubs/adr/project-composer.stub b/resources/stubs/adr/project-composer.stub index c07e11b..5ef8ac7 100644 --- a/resources/stubs/adr/project-composer.stub +++ b/resources/stubs/adr/project-composer.stub @@ -4,6 +4,9 @@ "php": ">={{ phpVersion }}", "{{ phalconPackage }}": "{{ phalconConstraint }}" }, + "require-dev": { + "phalcon/crest": "{{ crestConstraint }}" + }, "autoload": { "psr-4": { "{{ jsonNamespace }}\\": "src/" diff --git a/resources/stubs/adr/project-config.stub b/resources/stubs/adr/project-config.stub index 0a96bd9..143f7a4 100644 --- a/resources/stubs/adr/project-config.stub +++ b/resources/stubs/adr/project-config.stub @@ -11,4 +11,5 @@ return [ 'flavor' => 'adr', 'namespace' => '{{ namespace }}', 'bootstrap' => {{ namespace }}\AppFront::class, + 'paths' => ['action' => '{{ actionPath }}'], ]; diff --git a/resources/stubs/adr/project-readme.stub b/resources/stubs/adr/project-readme.stub index 2341eea..305af7b 100644 --- a/resources/stubs/adr/project-readme.stub +++ b/resources/stubs/adr/project-readme.stub @@ -22,10 +22,18 @@ Then open http://localhost:8080/. |---|---| | `public/index.php` | The web entry point | | `src/AppFront.php` | The front controller: container, providers, application | -| `src/Action/` | One class for each route. `GET /` is `src/Action/Get.php` | +| `{{ actionPath }}/` | One class for each route. `GET /` is `{{ actionPath }}/{{ seed }}.php` | | `crest.php` | The crest configuration | ## Next steps - crest make:action GET /hello - crest route:list +Use the crest in `vendor/` for these commands. It uses the autoloader and +the Phalcon of this project. On the host: + + vendor/bin/crest make:action GET /hello + vendor/bin/crest route:list + +With docker: + + docker compose exec {{ service }} vendor/bin/crest make:action GET /hello + docker compose exec {{ service }} vendor/bin/crest route:list From 514543c00fbd7415bdb1d457281a22b6cafd42c2 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Fri, 25 Sep 2026 11:25:30 -0500 Subject: [PATCH 14/15] enabling v5 in CI and doc fixes Assisted-by: Claude Code --- .github/workflows/main.yml | 24 +++++++++++++++++++----- CHANGELOG.md | 7 ++++--- README.md | 18 ++++++++++++++++-- docs/index.md | 28 ++++++++++++++++++++++++---- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 250e8ae..d3a1cbc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,11 +21,10 @@ env: LANGUAGE: en_US.UTF-8 LC_ALL: en_US.UTF-8 - # PHP extensions required by Composer and the suite. No Phalcon extension: - # this workflow runs the v6 variant, where Phalcon arrives as the - # phalcon/phalcon composer dev dependency. When ext-phalcon 5.18 ships, the - # v5 variant is added by extending the matrix and installing the extension - # through setup-php's `extensions` input. + # PHP extensions required by Composer and the suite. The Phalcon extension is + # not here: the unit-tests job installs it for the v5 variant only. The v6 + # variant, and the other jobs, get Phalcon from the phalcon/phalcon composer + # dev dependency. EXTENSIONS: mbstring, zip permissions: { } @@ -100,6 +99,9 @@ jobs: - '8.3' - '8.4' - '8.5' + phalcon: + - 'v5' + - 'v6' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -117,6 +119,18 @@ jobs: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # v5 only, with the same PIE install as resources/docker/Dockerfile. + # phalcon/phalcon stays in vendor/, as in the v5 dev container. The + # extension classes load first, so the package does not replace them. + - name: "Install the Phalcon extension" + if: matrix.phalcon == 'v5' + env: + PIE_VERSION: '1.5.0' + run: | + curl -fsSL "https://github.com/php/pie/releases/download/${PIE_VERSION}/pie.phar" -o "${RUNNER_TEMP}/pie.phar" + php "${RUNNER_TEMP}/pie.phar" install --no-interaction 'phalcon/cphalcon:^5.18' + php -m | grep -i phalcon + # No --ignore-platform-reqs: composer.json pins config.platform.php to # 8.1.0, so the lock resolves against the floor and installs cleanly on # every matrix cell. diff --git a/CHANGELOG.md b/CHANGELOG.md index e7cf85d..3c4d50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,11 +26,11 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added `Crest\Command\ProjectCommand::writer()`, assembling the stub writer once instead of repeating the same three-argument construction in five `make:*` commands. [#5](https://github.com/phalcon/crest/issues/5) - Added `methodFor()` to `Crest\ADR\ActionResolver`, so the HTTP method an Action answers is asked of the framework rather than derived from the class name. [#1](https://github.com/phalcon/crest/issues/1) - Added `Crest\Console\Registry::descriptions()` and `Crest\Console\Output::commandTable()`, so a bare `crest` and `crest list` render the command listing through one path instead of two copies kept in agreement by hand. -- Added `new`, creating an ADR project from stubs: front controller, web entry point, `crest.php`, an action for `GET /`, `composer.json` and docker files. It runs nothing - no composer, no docker, no network. `--namespace`, `--php` and `--phalcon` set the root namespace, the PHP version and the Phalcon variant. [#8](https://github.com/phalcon/crest/issues/8) +- Added `new`, creating an ADR project from stubs: front controller, web entry point, `crest.php`, an action for `GET /`, `composer.json` and docker files. It runs nothing - no composer, no docker, no network. `--namespace`, `--php` and `--phalcon` set the root namespace, the PHP version and the Phalcon variant. The project requires `phalcon/crest` as a dev dependency: the commands that work on the project need its autoloader and its Phalcon, so they run as `vendor/bin/crest`, not with the crest that created the project. The generated `crest.php` states `paths.action`, so a later crest default does not move the actions. `new` renders every file before it writes the first one, so a published stub that does not render leaves no files. [#8](https://github.com/phalcon/crest/issues/8) - Added `up`, `down` and `install`, running `docker compose up -d`, `docker compose down` and `composer install` in the `app` container of a project that `new` created. `--directory` names the project. [#8](https://github.com/phalcon/crest/issues/8) - Added `Crest\Process\Runner`, the seam through which commands run external programs, with `ShellRunner` as the default. A missing program or working directory is reported as a crest error. [#8](https://github.com/phalcon/crest/issues/8) - Added `Crest\Generator\ClassName::namespace()`, validating a namespace with the same identifier rule as a class name. [#8](https://github.com/phalcon/crest/issues/8) -- Added `Crest\Command\Make\NamedArtifactCommand`, the base of `make:command`, `make:middleware`, `make:provider` and `make:responder`. The four commands repeated the same `handle()`; each now gives only its key, its suffix and the instructions it prints after the file is written. +- Added `Crest\Command\Make\NamedArtifactCommand`, the base of `make:command`, `make:middleware`, `make:provider` and `make:responder`. The four commands repeated the same `handle()` and `define()`; each now gives only its key, its suffix, its description, an example name and the instructions it prints after the file is written. The base declares the `name` argument and the `--force` option, because `handle()` reads both, so a new generator cannot leave them out. ### Changed @@ -45,7 +45,8 @@ All notable changes are documented here. The format is based on [Keep a Changelo - `route:list` and `make:action` now accept an `ActionResolver`, defaulted so the kernel still constructs them with no arguments. This is what lets a test prove the routing answers come from the framework rather than from crest. [#5](https://github.com/phalcon/crest/issues/5) - `event:list` now reads every listener in a single `getListenerMap()` call instead of one call per event type. [#1](https://github.com/phalcon/crest/issues/1) - `phalcon/talon` moved from `^0.8` to `^0.9`. -- `stub:publish` with no name leaves out the `project-*` stubs that `new` renders. They only have an effect where `new` runs; publish one by name. [#8](https://github.com/phalcon/crest/issues/8) +- `stub:publish` with no name leaves out the `project-*` stubs that `new` renders. They have an effect only in the directory that `new` puts the project into; publish one by name. A `project-*` name goes where `new` reads it - the working directory, or `--directory` - with the `adr` flavor, and needs no `crest.php`. [#8](https://github.com/phalcon/crest/issues/8) +- Rendering a stub now fails when a placeholder has no value, and the error names the stub file. A published copy that kept a placeholder crest no longer sends put the raw `{{ name }}` into the generated file. [#8](https://github.com/phalcon/crest/issues/8) ### Fixed diff --git a/README.md b/README.md index 1bcc459..cba3115 100644 --- a/README.md +++ b/README.md @@ -20,19 +20,33 @@ Command line application for Phalcon - generators, introspection and project too ## Requirements - PHP `^8.1` -- Phalcon, either the `ext-phalcon` C extension (`^5`) or the `phalcon/phalcon` PHP +- Phalcon, either the `ext-phalcon` C extension (`^5.18`) or the `phalcon/phalcon` PHP implementation (`^6`) - crest itself needs neither to run ## Install +In a project: + composer require --dev phalcon/crest +To create projects, also install crest globally. The global composer `vendor/bin` +directory must be in your `PATH`: + + composer global require phalcon/crest + ## Usage +In a project, use the crest in `vendor/`. The commands that work on the project need +the project autoloader and its Phalcon: + vendor/bin/crest list available commands vendor/bin/crest about environment and version report vendor/bin/crest make:action GET /company/all - vendor/bin/crest new my-app create an ADR project + +To create a project, use the global crest. The new project requires `phalcon/crest`, so +after `composer install` it has its own `vendor/bin/crest`: + + crest new my-app create an ADR project ## Global options diff --git a/docs/index.md b/docs/index.md index 94d3d03..0e70eb1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,14 +55,34 @@ next steps: `crest down` stops and removes the containers. `up --build` rebuilds the image first, and `down --volumes` also removes the named volumes. +`new`, `up`, `down` and `install` run before the project has a `vendor/`. Run +them with a crest outside the project, for example one that you install with +`composer global require phalcon/crest`. + +The generated project requires `phalcon/crest` as a dev dependency. The +commands that work on the project, for example `make:action` and +`route:list`, need the autoloader and the Phalcon of the project. After +`crest install` or `composer install`, run them with the crest in `vendor/`: + + vendor/bin/crest make:action GET /hello + docker compose exec app vendor/bin/crest make:action GET /hello + The files come from the `project-*` stubs. To change them, publish them by -name in the directory that you run `new` from, then edit the copies: +name in the directory that the project goes into (the working directory, or +`--directory`), then edit the copies: crest stub:publish project-front -`stub:publish` reads the project configuration, so that directory needs a -`crest.php`. ` Date: Fri, 25 Sep 2026 12:00:13 -0500 Subject: [PATCH 15/15] infection adn CI cleanup Assisted-by: Claude Code --- .github/workflows/main.yml | 24 +- composer.json | 1 - composer.lock | 954 +----------------------------------- resources/infection.json5 | 12 + src/Process/ShellRunner.php | 2 +- 5 files changed, 49 insertions(+), 944 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d3a1cbc..50e8e7f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,6 +27,10 @@ env: # dev dependency. EXTENSIONS: mbstring, zip + # The cphalcon constraint for the v5 variant. 5.18 is the first release with + # Phalcon\ADR. + PHALCON_CONSTRAINT: "^5.18" + permissions: { } concurrency: @@ -63,6 +67,9 @@ jobs: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: "Validate composer" + run: composer validate --no-check-all --no-check-publish + # No --ignore-platform-reqs: composer.json pins config.platform.php to # 8.1.0, so the lock resolves against the floor and installs cleanly on # every matrix cell. @@ -77,8 +84,11 @@ jobs: - name: "PHPStan" run: composer analyze + - name: "PHP CS Fixer (dry-run)" + run: composer cs-fixer + unit-tests: - name: "Unit tests" + name: "Unit tests (PHP ${{ matrix.php }}, Phalcon ${{ matrix.variant }})" permissions: contents: read @@ -99,7 +109,8 @@ jobs: - '8.3' - '8.4' - '8.5' - phalcon: + variant: + # v5 = cphalcon C extension; v6 = phalcon/phalcon composer package - 'v5' - 'v6' @@ -120,15 +131,17 @@ jobs: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} # v5 only, with the same PIE install as resources/docker/Dockerfile. + # PIE writes to the extension directory, so it needs sudo. sudo resets + # PATH, so "$(which php)" names the PHP that setup-php installed. # phalcon/phalcon stays in vendor/, as in the v5 dev container. The # extension classes load first, so the package does not replace them. - name: "Install the Phalcon extension" - if: matrix.phalcon == 'v5' + if: matrix.variant == 'v5' env: PIE_VERSION: '1.5.0' run: | curl -fsSL "https://github.com/php/pie/releases/download/${PIE_VERSION}/pie.phar" -o "${RUNNER_TEMP}/pie.phar" - php "${RUNNER_TEMP}/pie.phar" install --no-interaction 'phalcon/cphalcon:^5.18' + sudo "$(which php)" "${RUNNER_TEMP}/pie.phar" install --no-interaction "phalcon/cphalcon:${PHALCON_CONSTRAINT}" php -m | grep -i phalcon # No --ignore-platform-reqs: composer.json pins config.platform.php to @@ -191,7 +204,10 @@ jobs: - name: "Tests with coverage" run: composer test-coverage + # Push only: a pull request from a fork gets no SONAR_TOKEN. SonarQube + # runs before octocov, so a failed octocov gate cannot skip the upload. - name: "SonarQube Scan" + if: github.event_name == 'push' uses: SonarSource/sonarqube-scan-action@ba9859eae8dd6bd29e412f25ddbbef3d032000f4 # v8.2.2 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/composer.json b/composer.json index d39dd05..f5f28df 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,6 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3", - "infection/infection": "^0.29", "pds/composer-script-names": "^1", "pds/skeleton": "^1", "phalcon/phalcon": "v6.0.x-dev", diff --git a/composer.lock b/composer.lock index dc00cb3..e3b270c 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": "7efce124bb3f06a2344761735f8ab716", + "content-hash": "9749fe461704aa68db6fc261f94c41e2", "packages": [], "packages-dev": [ { @@ -71,94 +71,6 @@ ], "time": "2022-12-23T10:58:28+00:00" }, - { - "name": "colinodell/json5", - "version": "v3.0.0", - "source": { - "type": "git", - "url": "https://github.com/colinodell/json5.git", - "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/colinodell/json5/zipball/5724d21bc5c910c2560af1b8915f0cc0163579c8", - "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^8.0" - }, - "require-dev": { - "mikehaertl/php-shellcommand": "^1.7.0", - "phpstan/phpstan": "^1.10.57", - "scrutinizer/ocular": "^1.9", - "squizlabs/php_codesniffer": "^3.8.1", - "symfony/finder": "^6.0|^7.0", - "symfony/phpunit-bridge": "^7.0.3" - }, - "bin": [ - "bin/json5" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "files": [ - "src/global.php" - ], - "psr-4": { - "ColinODell\\Json5\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Colin O'Dell", - "email": "colinodell@gmail.com", - "homepage": "https://www.colinodell.com", - "role": "Developer" - } - ], - "description": "UTF-8 compatible JSON5 parser for PHP", - "homepage": "https://github.com/colinodell/json5", - "keywords": [ - "JSON5", - "json", - "json5_decode", - "json_decode" - ], - "support": { - "issues": "https://github.com/colinodell/json5/issues", - "source": "https://github.com/colinodell/json5/tree/v3.0.0" - }, - "funding": [ - { - "url": "https://www.colinodell.com/sponsor", - "type": "custom" - }, - { - "url": "https://www.paypal.me/colinpodell/10.00", - "type": "custom" - }, - { - "url": "https://github.com/colinodell", - "type": "github" - }, - { - "url": "https://www.patreon.com/colinodell", - "type": "patreon" - } - ], - "time": "2024-02-09T13:06:12+00:00" - }, { "name": "composer/pcre", "version": "3.4.0", @@ -237,16 +149,16 @@ }, { "name": "composer/semver", - "version": "3.4.4", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", "shasum": "" }, "require": { @@ -288,7 +200,7 @@ "homepage": "http://robbast.nl" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", @@ -298,7 +210,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "source": "https://github.com/composer/semver/tree/3.5.0" }, "funding": [ { @@ -310,7 +222,7 @@ "type": "github" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-09-24T14:38:51+00:00" }, { "name": "composer/xdebug-handler", @@ -755,430 +667,6 @@ ], "time": "2026-09-22T09:54:55+00:00" }, - { - "name": "infection/abstract-testframework-adapter", - "version": "0.5.0", - "source": { - "type": "git", - "url": "https://github.com/infection/abstract-testframework-adapter.git", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/abstract-testframework-adapter/zipball/18925e20d15d1a5995bb85c9dc09e8751e1e069b", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^2.17", - "phpunit/phpunit": "^9.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\AbstractTestFramework\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Abstract Test Framework Adapter for Infection", - "support": { - "issues": "https://github.com/infection/abstract-testframework-adapter/issues", - "source": "https://github.com/infection/abstract-testframework-adapter/tree/0.5.0" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-08-17T18:49:12+00:00" - }, - { - "name": "infection/extension-installer", - "version": "0.1.2", - "source": { - "type": "git", - "url": "https://github.com/infection/extension-installer.git", - "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/extension-installer/zipball/9b351d2910b9a23ab4815542e93d541e0ca0cdcf", - "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.1 || ^2.0" - }, - "require-dev": { - "composer/composer": "^1.9 || ^2.0", - "friendsofphp/php-cs-fixer": "^2.18, <2.19", - "infection/infection": "^0.15.2", - "php-coveralls/php-coveralls": "^2.4", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.10", - "phpstan/phpstan-phpunit": "^0.12.6", - "phpstan/phpstan-strict-rules": "^0.12.2", - "phpstan/phpstan-webmozart-assert": "^0.12.2", - "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^4.8" - }, - "type": "composer-plugin", - "extra": { - "class": "Infection\\ExtensionInstaller\\Plugin" - }, - "autoload": { - "psr-4": { - "Infection\\ExtensionInstaller\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Infection Extension Installer", - "support": { - "issues": "https://github.com/infection/extension-installer/issues", - "source": "https://github.com/infection/extension-installer/tree/0.1.2" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-10-20T22:08:34+00:00" - }, - { - "name": "infection/include-interceptor", - "version": "0.2.5", - "source": { - "type": "git", - "url": "https://github.com/infection/include-interceptor.git", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/include-interceptor/zipball/0cc76d95a79d9832d74e74492b0a30139904bdf7", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7", - "shasum": "" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.16", - "infection/infection": "^0.15.0", - "phan/phan": "^2.4 || ^3", - "php-coveralls/php-coveralls": "^2.2", - "phpstan/phpstan": "^0.12.8", - "phpunit/phpunit": "^8.5", - "vimeo/psalm": "^3.8" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\StreamWrapper\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Stream Wrapper: Include Interceptor. Allows to replace included (autoloaded) file with another one.", - "support": { - "issues": "https://github.com/infection/include-interceptor/issues", - "source": "https://github.com/infection/include-interceptor/tree/0.2.5" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2021-08-09T10:03:57+00:00" - }, - { - "name": "infection/infection", - "version": "0.29.9", - "source": { - "type": "git", - "url": "https://github.com/infection/infection.git", - "reference": "beac2ca971b37dd7feb92fe2d3e705c175b2360b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/beac2ca971b37dd7feb92fe2d3e705c175b2360b", - "reference": "beac2ca971b37dd7feb92fe2d3e705c175b2360b", - "shasum": "" - }, - "require": { - "colinodell/json5": "^2.2 || ^3.0", - "composer-runtime-api": "^2.0", - "composer/xdebug-handler": "^2.0 || ^3.0", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "fidry/cpu-core-counter": "^0.4.0 || ^0.5.0 || ^1.0", - "infection/abstract-testframework-adapter": "^0.5.0", - "infection/extension-installer": "^0.1.0", - "infection/include-interceptor": "^0.2.5", - "infection/mutator": "^0.4", - "justinrainbow/json-schema": "^5.3", - "nikic/php-parser": "^5.3", - "ondram/ci-detector": "^4.1.0", - "php": "^8.1", - "sanmai/later": "^0.1.1", - "sanmai/pipeline": "^5.1 || ^6", - "sebastian/diff": "^3.0.2 || ^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "thecodingmachine/safe": "dev-master as 2.5.0", - "webmozart/assert": "^1.11" - }, - "conflict": { - "antecedent/patchwork": "<2.1.25", - "dg/bypass-finals": "<1.4.1", - "phpunit/php-code-coverage": ">9,<9.1.4 || >9.2.17,<9.2.21" - }, - "require-dev": { - "ext-simplexml": "*", - "fidry/makefile": "^1.0", - "helmich/phpunit-json-assert": "^3.0", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.10.15", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpstan/phpstan-strict-rules": "^1.1.0", - "phpstan/phpstan-webmozart-assert": "^1.0.2", - "phpunit/phpunit": "^10.5", - "rector/rector": "^1.0", - "sidz/phpstan-rules": "^0.4", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0", - "thecodingmachine/phpstan-safe-rule": "^1.2.0" - }, - "bin": [ - "bin/infection" - ], - "type": "library", - "autoload": { - "psr-4": { - "Infection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com", - "homepage": "https://twitter.com/maks_rafalko" - }, - { - "name": "Oleg Zhulnev", - "homepage": "https://github.com/sidz" - }, - { - "name": "Gert de Pagter", - "homepage": "https://github.com/BackEndTea" - }, - { - "name": "Théo FIDRY", - "email": "theo.fidry@gmail.com", - "homepage": "https://twitter.com/tfidry" - }, - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com", - "homepage": "https://www.alexeykopytko.com" - }, - { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" - } - ], - "description": "Infection is a Mutation Testing framework for PHP. The mutation adequacy score can be used to measure the effectiveness of a test set in terms of its ability to detect faults.", - "keywords": [ - "coverage", - "mutant", - "mutation framework", - "mutation testing", - "testing", - "unit testing" - ], - "support": { - "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.29.9" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2024-12-08T22:23:44+00:00" - }, - { - "name": "infection/mutator", - "version": "0.4.1", - "source": { - "type": "git", - "url": "https://github.com/infection/mutator.git", - "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/infection/mutator/zipball/3c976d721b02b32f851ee4e15d553ef1e9186d1d", - "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^10" - }, - "type": "library", - "autoload": { - "psr-4": { - "Infection\\Mutator\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Maks Rafalko", - "email": "maks.rafalko@gmail.com" - } - ], - "description": "Mutator interface to implement custom mutators (mutation operators) for Infection", - "support": { - "issues": "https://github.com/infection/mutator/issues", - "source": "https://github.com/infection/mutator/tree/0.4.1" - }, - "funding": [ - { - "url": "https://github.com/infection", - "type": "github" - }, - { - "url": "https://opencollective.com/infection", - "type": "open_collective" - } - ], - "time": "2025-04-29T08:19:52+00:00" - }, - { - "name": "justinrainbow/json-schema", - "version": "5.3.4", - "source": { - "type": "git", - "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", - "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" - }, - "bin": [ - "bin/validate-json" - ], - "type": "library", - "autoload": { - "psr-4": { - "JsonSchema\\": "src/JsonSchema/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" - } - ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", - "keywords": [ - "json", - "schema" - ], - "support": { - "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.4" - }, - "time": "2026-05-04T18:54:58+00:00" - }, { "name": "masterminds/html5", "version": "2.11.0", @@ -1486,84 +974,6 @@ }, "time": "2026-09-13T18:51:52+00:00" }, - { - "name": "ondram/ci-detector", - "version": "4.2.0", - "source": { - "type": "git", - "url": "https://github.com/OndraM/ci-detector.git", - "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", - "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.13.2", - "lmc/coding-standard": "^3.0.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.2.0", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpunit/phpunit": "^9.6.13" - }, - "type": "library", - "autoload": { - "psr-4": { - "OndraM\\CiDetector\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ondřej Machulda", - "email": "ondrej.machulda@gmail.com" - } - ], - "description": "Detect continuous integration environment and provide unified access to properties of current build", - "keywords": [ - "CircleCI", - "Codeship", - "Wercker", - "adapter", - "appveyor", - "aws", - "aws codebuild", - "azure", - "azure devops", - "azure pipelines", - "bamboo", - "bitbucket", - "buddy", - "ci-info", - "codebuild", - "continuous integration", - "continuousphp", - "devops", - "drone", - "github", - "gitlab", - "interface", - "jenkins", - "pipelines", - "sourcehut", - "teamcity", - "travis" - ], - "support": { - "issues": "https://github.com/OndraM/ci-detector/issues", - "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" - }, - "time": "2024-03-12T13:22:30+00:00" - }, { "name": "pds/composer-script-names", "version": "1.0.0", @@ -1771,12 +1181,12 @@ "source": { "type": "git", "url": "https://github.com/phalcon/phalcon.git", - "reference": "e353b8a926cd022f06ac0f115bd5ddc1489ce311" + "reference": "76cdd1807b68210a0ecb574218af90114eb087f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phalcon/phalcon/zipball/e353b8a926cd022f06ac0f115bd5ddc1489ce311", - "reference": "e353b8a926cd022f06ac0f115bd5ddc1489ce311", + "url": "https://api.github.com/repos/phalcon/phalcon/zipball/76cdd1807b68210a0ecb574218af90114eb087f0", + "reference": "76cdd1807b68210a0ecb574218af90114eb087f0", "shasum": "" }, "require": { @@ -1849,7 +1259,7 @@ "type": "open_collective" } ], - "time": "2026-09-22T20:11:19+00:00" + "time": "2026-09-24T21:56:14+00:00" }, { "name": "phalcon/talon", @@ -2131,11 +1541,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.15", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7", - "reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -2191,7 +1601,7 @@ "type": "github" } ], - "time": "2026-09-23T12:23:07+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3286,135 +2696,6 @@ ], "time": "2024-06-11T12:45:25+00:00" }, - { - "name": "sanmai/later", - "version": "0.1.5", - "source": { - "type": "git", - "url": "https://github.com/sanmai/later.git", - "reference": "cf5164557d19930295892094996f049ea12ba14d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sanmai/later/zipball/cf5164557d19930295892094996f049ea12ba14d", - "reference": "cf5164557d19930295892094996f049ea12ba14d", - "shasum": "" - }, - "require": { - "php": ">=7.4" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^3.35.1", - "infection/infection": ">=0.27.6", - "phan/phan": ">=2", - "php-coveralls/php-coveralls": "^2.0", - "phpstan/phpstan": ">=1.4.5", - "phpunit/phpunit": ">=9.5 <10", - "vimeo/psalm": ">=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "0.1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Later\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com" - } - ], - "description": "Later: deferred wrapper object", - "support": { - "issues": "https://github.com/sanmai/later/issues", - "source": "https://github.com/sanmai/later/tree/0.1.5" - }, - "funding": [ - { - "url": "https://github.com/sanmai", - "type": "github" - } - ], - "time": "2024-12-06T02:36:26+00:00" - }, - { - "name": "sanmai/pipeline", - "version": "6.12", - "source": { - "type": "git", - "url": "https://github.com/sanmai/pipeline.git", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sanmai/pipeline/zipball/ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^3.17", - "infection/infection": ">=0.10.5", - "league/pipeline": "^0.3 || ^1.0", - "phan/phan": ">=1.1", - "php-coveralls/php-coveralls": "^2.4.1", - "phpstan/phpstan": ">=0.10", - "phpunit/phpunit": ">=9.4", - "vimeo/psalm": ">=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "v6.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Pipeline\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Alexey Kopytko", - "email": "alexey@kopytko.com" - } - ], - "description": "General-purpose collections pipeline", - "support": { - "issues": "https://github.com/sanmai/pipeline/issues", - "source": "https://github.com/sanmai/pipeline/tree/6.12" - }, - "funding": [ - { - "url": "https://github.com/sanmai", - "type": "github" - } - ], - "time": "2024-10-17T02:22:57+00:00" - }, { "name": "sebastian/cli-parser", "version": "2.0.1", @@ -6459,151 +5740,6 @@ ], "time": "2026-09-11T09:03:57+00:00" }, - { - "name": "thecodingmachine/safe", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "e527bb2d39d1dc81de6e23cdb6b18be0bd7dd99b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/e527bb2d39d1dc81de6e23cdb6b18be0bd7dd99b", - "reference": "e527bb2d39d1dc81de6e23cdb6b18be0bd7dd99b", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/phpstan": "^2", - "phpunit/phpunit": "^10", - "squizlabs/php_codesniffer": "^3.2" - }, - "default-branch": true, - "type": "library", - "autoload": { - "files": [ - "lib/special_cases.php", - "generated/apache.php", - "generated/apcu.php", - "generated/array.php", - "generated/bzip2.php", - "generated/calendar.php", - "generated/classobj.php", - "generated/com.php", - "generated/cubrid.php", - "generated/curl.php", - "generated/datetime.php", - "generated/dir.php", - "generated/eio.php", - "generated/errorfunc.php", - "generated/exec.php", - "generated/fileinfo.php", - "generated/filesystem.php", - "generated/filter.php", - "generated/fpm.php", - "generated/ftp.php", - "generated/funchand.php", - "generated/gettext.php", - "generated/gmp.php", - "generated/gnupg.php", - "generated/hash.php", - "generated/ibase.php", - "generated/ibmDb2.php", - "generated/iconv.php", - "generated/image.php", - "generated/imap.php", - "generated/info.php", - "generated/inotify.php", - "generated/json.php", - "generated/ldap.php", - "generated/libxml.php", - "generated/litespeed.php", - "generated/lzf.php", - "generated/mailparse.php", - "generated/mbstring.php", - "generated/misc.php", - "generated/mysql.php", - "generated/mysqli.php", - "generated/network.php", - "generated/oci8.php", - "generated/opcache.php", - "generated/openssl.php", - "generated/outcontrol.php", - "generated/pcntl.php", - "generated/pcre.php", - "generated/pgsql.php", - "generated/posix.php", - "generated/ps.php", - "generated/pspell.php", - "generated/readline.php", - "generated/rnp.php", - "generated/rpminfo.php", - "generated/rrd.php", - "generated/sem.php", - "generated/session.php", - "generated/shmop.php", - "generated/sockets.php", - "generated/sodium.php", - "generated/solr.php", - "generated/spl.php", - "generated/sqlsrv.php", - "generated/ssdeep.php", - "generated/ssh2.php", - "generated/stream.php", - "generated/strings.php", - "generated/swoole.php", - "generated/uodbc.php", - "generated/uopz.php", - "generated/url.php", - "generated/var.php", - "generated/xdiff.php", - "generated/xml.php", - "generated/xmlrpc.php", - "generated/yaml.php", - "generated/yaz.php", - "generated/zip.php", - "generated/zlib.php" - ], - "classmap": [ - "lib/DateTime.php", - "lib/DateTimeImmutable.php", - "lib/Exceptions/", - "generated/Exceptions/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "support": { - "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/master" - }, - "funding": [ - { - "url": "https://github.com/OskarStark", - "type": "github" - }, - { - "url": "https://github.com/shish", - "type": "github" - }, - { - "url": "https://github.com/silasjoisten", - "type": "github" - }, - { - "url": "https://github.com/staabm", - "type": "github" - } - ], - "time": "2026-08-17T23:42:47+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -6653,64 +5789,6 @@ } ], "time": "2025-11-17T20:03:58+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.12.1", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^7.2 || ^8.0" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" - }, - "time": "2025-10-29T15:56:20+00:00" } ], "aliases": [], diff --git a/resources/infection.json5 b/resources/infection.json5 index e7d43a0..e5be8d9 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -140,6 +140,18 @@ "NotIdentical": { "ignore": ["Crest\\Console\\Output::detectDecoration"] }, + "ProtectedVisibility": { + // The default hooks run only for the commands that do not override + // them, so only the tests of those commands cover these lines, and + // they cannot see an override. The full suite kills both mutants: + // with a private hook, handle() calls the default and not the + // override, and the make:command, make:middleware and + // make:provider tests fail. + "ignore": [ + "Crest\\Command\\Make\\NamedArtifactCommand::guidance", + "Crest\\Command\\Make\\NamedArtifactCommand::replacements" + ] + }, "Throw_": { "ignore": [ "Crest\\ADR\\PhalconRouterResolver::router", diff --git a/src/Process/ShellRunner.php b/src/Process/ShellRunner.php index 8aaa1ee..1f3770a 100644 --- a/src/Process/ShellRunner.php +++ b/src/Process/ShellRunner.php @@ -95,7 +95,7 @@ private function start(array $command, ?string $directory) $process = proc_open($command, [], $pipes, $directory); if (false === $process) { - throw new Exception(sprintf('could not start %s', $command[0])); + throw new Exception(sprintf('could not start %s', $command[0])); // @codeCoverageIgnore } return $process;