From bb340ed311aef862fe80d6acfccc4fa873b78dc0 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sun, 10 May 2026 01:24:04 +0200 Subject: [PATCH 01/14] fix: redirect dependency commands into docker Run dependency-affecting Composer commands in the configured Docker Compose service before host Composer begins solving or installing. This preserves container platform requirements for install, update, require, remove, and reinstall while keeping existing script redirection behavior. --- README.md | 12 +- src/DockerComposeCommandBuilder.php | 133 ++++++++++++++ src/DockerComposerPlugin.php | 163 ++++++++++++++++++ .../DockerComposerIntegrationTest.php | 25 ++- tests/Unit/DockerComposerPluginTest.php | 139 +++++++++++++++ 5 files changed, 467 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 66223ba..7687b9e 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,11 @@ Set `DOCKER_COMPOSER_DISABLE=1` to bypass Docker redirection temporarily. This plugin redirects Composer scripts, including lifecycle scripts such as `post-install-cmd` and custom scripts run through `composer run-script`. -It does not transparently replace whole Composer commands such as -`composer install` with `docker compose exec php composer install`. Composer's -plugin command events do not provide a clean way to run a child command, skip the -host command, and return the child exit code without relying on fragile internals. +It also redirects dependency commands before host execution so platform +requirements are resolved from inside the configured service: + +- `composer install` +- `composer update` +- `composer require` +- `composer remove` +- `composer reinstall` diff --git a/src/DockerComposeCommandBuilder.php b/src/DockerComposeCommandBuilder.php index 4d42a83..2b854cb 100644 --- a/src/DockerComposeCommandBuilder.php +++ b/src/DockerComposeCommandBuilder.php @@ -16,6 +16,7 @@ use Composer\Script\Event as ScriptEvent; use Composer\Util\ProcessExecutor; use InvalidArgumentException; +use Symfony\Component\Console\Input\InputInterface; /** * Builds Docker Compose commands for redirected Composer scripts. @@ -97,6 +98,50 @@ public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $ev return array_merge($command, $this->composerRunScriptCommand($event)); } + /** + * Builds the Docker Compose Composer command execution command. + * + * @param DockerComposerConfig $config + * The Docker Composer configuration that provides service options. + * + * @param string $commandName + * The Composer command name to replay inside Docker Compose. + * + * @param InputInterface $input + * The original console input whose raw arguments are replayed. + * + * @param bool $interactive + * Whether the Docker command should keep TTY interaction enabled. + * + * @return list + * Returns command arguments for `docker compose exec` or `run`. + */ + public function buildComposerCommand(DockerComposerConfig $config, string $commandName, InputInterface $input, bool $interactive): array + { + $command = $this->composeBase($config); + $command[] = $config->getMode(); + + if ($config->getMode() === DockerComposerConfig::MODE_RUN) { + $command[] = '--rm'; + } + + if (! $interactive) { + $command[] = '-T'; + } + + if ($config->getWorkdir() !== null) { + $command[] = '--workdir'; + $command[] = $config->getWorkdir(); + } + + $command[] = '--env'; + $command[] = 'DOCKER_COMPOSER_INSIDE=1'; + $command[] = $config->getService(); + $command[] = 'composer'; + + return array_merge($command, $this->getCommandArguments($input, $commandName)); + } + /** * Builds the common Docker Compose command prefix. * @@ -160,6 +205,94 @@ private function composerRunScriptCommand(ScriptEvent $event): array return $command; } + /** + * Gets raw Composer command arguments with host working directory removed. + * + * @param InputInterface $input + * The input that may expose raw console tokens. + * + * @param string $commandName + * The Composer command name to replace the first argument. + * + * @return list + * Returns Composer arguments including the command name. + */ + private function getCommandArguments(InputInterface $input, string $commandName): array + { + if (! method_exists($input, 'getRawTokens')) { + throw new InvalidArgumentException('Composer command input must expose raw tokens.'); + } + + /** @var list $tokens */ + $tokens = $input->getRawTokens(); + $firstArgument = $input->getFirstArgument(); + $commandTokenReplaced = false; + + foreach ($tokens as $index => $token) { + if (! $commandTokenReplaced && $token === $firstArgument) { + $tokens[$index] = $commandName; + $commandTokenReplaced = true; + } + } + + if (! $commandTokenReplaced) { + $tokens[] = $commandName; + } + + return $this->stripWorkingDirectoryTokens($tokens); + } + + /** + * Removes host-only Composer working directory options from arguments. + * + * @param list $tokens + * The raw command arguments. + * + * @return list + * Returns __tokens__ without `--working-dir` or `-d`. + */ + private function stripWorkingDirectoryTokens(array $tokens): array + { + $stripped = []; + $afterOptions = false; + $skipNext = false; + + foreach ($tokens as $token) { + if ($skipNext) { + $skipNext = false; + + continue; + } + + if ($afterOptions) { + $stripped[] = $token; + + continue; + } + + if ($token === '--') { + $afterOptions = true; + $stripped[] = $token; + + continue; + } + + if ($token === '--working-dir' || $token === '-d') { + $skipNext = true; + + continue; + } + + if (str_starts_with($token, '--working-dir=') || preg_match('/^-d.+/', $token) === 1) { + continue; + } + + $stripped[] = $token; + } + + return $stripped; + } + /** * Converts a Composer script argument to a command string. * diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php index 88de511..b805d86 100644 --- a/src/DockerComposerPlugin.php +++ b/src/DockerComposerPlugin.php @@ -20,6 +20,8 @@ use Composer\EventDispatcher\EventSubscriberInterface; use Composer\EventDispatcher\ScriptExecutionException; use Composer\IO\IOInterface; +use Composer\Plugin\PreCommandRunEvent; +use Composer\Plugin\PluginEvents; use Composer\Plugin\PluginInterface; use Composer\Script\Event as ScriptEvent; use Composer\Util\ProcessExecutor; @@ -30,6 +32,19 @@ */ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface { + /** + * Lists Composer commands redirected before host execution. + * + * @var list + */ + private const REDIRECTED_COMMANDS = [ + 'install', + 'update', + 'require', + 'remove', + 'reinstall', + ]; + /** * Stores Composer IO for plugin messages. */ @@ -121,6 +136,7 @@ public function activate(Composer $composer, IOInterface $io) $this->writeUnknownConfigWarning(); $this->writeDuplicateServiceMappingWarnings($io); + $this->registerCommandListener($composer); $this->registerScriptListeners($composer); } @@ -229,6 +245,69 @@ public function onScript(ScriptEvent $event): void $event->stopPropagation(); } + /** + * Redirects selected Composer commands into Docker Compose. + * + * @param PreCommandRunEvent $event + * The Composer command event to inspect and possibly redirect. + * + * @return void + * Returns nothing. + * + * @throws ScriptExecutionException + * Thrown after Docker execution to stop host Composer command handling. + */ + public function onCommand(PreCommandRunEvent $event): void + { + $commandName = $event->getCommand(); + if (! in_array($commandName, self::REDIRECTED_COMMANDS, true)) { + return; + } + + $io = $this->io; + if ($io === null || $this->isDisabledByEnvironment() || $this->containerDetector->isInsideContainer()) { + return; + } + + $config = $this->config; + if ($config === null) { + return; + } + + $this->writeDuplicateServiceMappingWarnings($io); + + if ($config->isExcluded($commandName)) { + return; + } + + if (! $config->isConfiguredForScript($commandName)) { + $this->writeMissingConfigWarning($io, $commandName); + + return; + } + + $commandConfig = $config->forScript($commandName); + + $this->writeCommandRedirectNotice($commandName, $commandConfig); + $this->runComposerCommandInDocker($event, $commandConfig); + + throw new ScriptExecutionException('', 0); + } + + /** + * Registers the listener for dependency Composer commands. + * + * @param Composer $composer + * The Composer instance whose commands should be watched. + * + * @return void + * Returns nothing. + */ + private function registerCommandListener(Composer $composer): void + { + $composer->getEventDispatcher()->addListener(PluginEvents::PRE_COMMAND_RUN, [$this, 'onCommand'], PHP_INT_MAX); + } + /** * Registers listeners for configured Composer scripts. * @@ -369,6 +448,31 @@ private function writeRedirectNotice(ScriptEvent $event, DockerComposerConfig $c )); } + /** + * Writes the command redirection notice. + * + * @param string $commandName + * The Composer command being redirected. + * + * @param DockerComposerConfig $config + * The configuration that provides the target service. + * + * @return void + * Returns nothing. + */ + private function writeCommandRedirectNotice(string $commandName, DockerComposerConfig $config): void + { + if ($this->io === null) { + return; + } + + $this->io->writeError(sprintf( + 'docker-composer: Running composer %s in Docker Compose service %s.', + OutputFormatter::escape($commandName), + OutputFormatter::escape($config->getService()), + )); + } + /** * Runs a Composer script inside Docker Compose. * @@ -408,6 +512,46 @@ private function runInDocker(ScriptEvent $event, DockerComposerConfig $config): } } + /** + * Runs a Composer command inside Docker Compose. + * + * @param PreCommandRunEvent $event + * The Composer command event being executed. + * + * @param DockerComposerConfig $config + * The Docker Composer configuration used to build commands. + * + * @return void + * Returns nothing. + * + * @throws ScriptExecutionException + * Thrown when Docker Compose startup or command execution fails. + */ + private function runComposerCommandInDocker(PreCommandRunEvent $event, DockerComposerConfig $config): void + { + $runner = $this->getProcessRunnerForCommand(); + + if ($config->getMode() === DockerComposerConfig::MODE_EXEC) { + $startupKey = $this->getExecServiceStartupKey($config); + if (! isset($this->startedExecServices[$startupKey])) { + $this->ensureExecServiceStarted($runner, $config); + $this->startedExecServices[$startupKey] = true; + } + } + + $isInteractive = $event->getInput()->isInteractive() && $runner->supportsTty(); + $command = $this->commandBuilder->buildComposerCommand( + $config, + $event->getCommand(), + $event->getInput(), + $isInteractive, + ); + $exitCode = $runner->run($command, $isInteractive); + if ($exitCode !== 0) { + $this->throwScriptExecutionException($runner, $exitCode, $config->getMode(), $command); + } + } + /** * Ensures the configured service can receive `docker compose exec`. * @@ -489,6 +633,25 @@ private function getProcessRunner(ScriptEvent $event): ProcessRunner return $this->processRunner; } + /** + * Gets the process runner for Docker commands. + * + * @return ProcessRunner + * Returns the configured or lazily created process runner. + */ + private function getProcessRunnerForCommand(): ProcessRunner + { + if ($this->processRunner === null) { + if ($this->io === null) { + throw new ScriptExecutionException('Docker Composer plugin was not activated.', 1); + } + + $this->processRunner = new ComposerProcessRunner($this->io); + } + + return $this->processRunner; + } + /** * Builds a cache key for exec-mode service startup. * diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index 3fcee3b..fbc1c34 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -46,6 +46,21 @@ public function testExecModeRedirectsCustomAndLifecycleScriptsWithAutoUp(): void self::assertSame('1', trim((string) file_get_contents($projectDirectory . '/lifecycle.txt'))); } + public function testInstallCommandRedirectsWhenPluginIsAlreadyInstalled(): void + { + $projectDirectory = $this->createProject([ + 'service' => 'php', + 'mode' => 'exec', + 'compose-files' => 'docker-compose.yaml', + 'workdir' => '/usr/src/app', + ]); + $this->installProject($projectDirectory); + + $result = $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory); + + self::assertStringContainsString('Running composer install in Docker Compose service php.', $result['stderr']); + } + public function testServiceMappingOverrideRedirectsToConfiguredService(): void { $projectDirectory = $this->createProject([ @@ -236,8 +251,10 @@ private function installProject(string $projectDirectory): void /** * @param list $command * @param array $environment + * + * @return array{stdout: string, stderr: string, exit-code: int} */ - private function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): void + private function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array { $descriptorSpec = [ 1 => ['pipe', 'w'], @@ -268,6 +285,12 @@ private function runCommand(array $command, string $workingDirectory, array $env $stderr, )); } + + return [ + 'stdout' => $stdout, + 'stderr' => $stderr, + 'exit-code' => $exitCode, + ]; } private function removeDirectory(string $directory): void diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php index 65bf5ae..fa017c8 100644 --- a/tests/Unit/DockerComposerPluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -10,6 +10,8 @@ use Composer\EventDispatcher\Event; use Composer\EventDispatcher\ScriptExecutionException; +use Composer\Plugin\PluginEvents; +use Composer\Plugin\PreCommandRunEvent; use Composer\Script\Event as ScriptEvent; use empaphy\docker_composer\ComposerProcessRunner; use empaphy\docker_composer\DockerComposeCommandBuilder; @@ -23,6 +25,7 @@ use Tests\Unit\Mocks\MockContainerDetector; use Tests\Unit\Mocks\MockOutputCapturingProcessRunner; use Tests\Unit\Mocks\MockProcessRunner; +use Symfony\Component\Console\Input\ArgvInput; #[CoversClass(DockerComposerPlugin::class)] #[CoversClass(ComposerProcessRunner::class)] @@ -558,6 +561,131 @@ public function testMissingServiceWarnsOnceAndFallsThrough(): void self::assertSame(1, substr_count($io->getOutput(), 'no default service and no service-mapping override for "test"')); } + public function testRedirectsInstallCommandBeforeHostExecution(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => [ + 'service' => 'php', + 'compose-files' => ['docker-compose.yaml'], + 'project-directory' => '.', + 'workdir' => '/usr/src/app', + ], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $input = new ArgvInput(['composer', '--no-interaction', 'install', '--no-dev', '--working-dir', 'app', '--prefer-dist']); + $input->setInteractive(false); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + $plugin->activate($composer, $io); + $exception = $this->assertCommandExecutionStops($plugin, $event); + + self::assertSame(0, $exception->getCode()); + self::assertSame([ + [ + 'docker', + 'compose', + '--file', + 'docker-compose.yaml', + '--project-directory', + '.', + 'up', + '-d', + 'php', + ], + [ + 'docker', + 'compose', + '--file', + 'docker-compose.yaml', + '--project-directory', + '.', + 'exec', + '-T', + '--workdir', + '/usr/src/app', + '--env', + 'DOCKER_COMPOSER_INSIDE=1', + 'php', + 'composer', + '--no-interaction', + 'install', + '--no-dev', + '--prefer-dist', + ], + ], $runner->commands); + self::assertStringContainsString('Running composer install in Docker Compose service php.', $io->getOutput()); + } + + public function testRedirectsDependencyCommandsBeforeHostExecution(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => [ + 'service' => 'php', + 'mode' => 'run', + ], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + + $plugin->activate($composer, $io); + + foreach (['update', 'require', 'remove', 'reinstall'] as $commandName) { + $input = new ArgvInput(['composer', $commandName, 'vendor/package']); + $input->setInteractive(false); + $this->assertCommandExecutionStops($plugin, new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, $commandName)); + } + + self::assertSame([ + ['run', 'composer', 'update', 'vendor/package'], + ['run', 'composer', 'require', 'vendor/package'], + ['run', 'composer', 'remove', 'vendor/package'], + ['run', 'composer', 'reinstall', 'vendor/package'], + ], array_map( + static fn(array $command): array => [$command[2], $command[8], $command[9], $command[10]], + $runner->commands, + )); + } + + public function testExcludedCommandFallsThrough(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => [ + 'service' => 'php', + 'exclude' => ['install'], + ], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $input = new ArgvInput(['composer', 'install']); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + $plugin->activate($composer, $io); + $plugin->onCommand($event); + + self::assertSame([], $runner->commands); + } + + public function testCommandDockerFailurePreservesExitCode(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $runner = new MockProcessRunner([0, 7], 'install failed'); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $input = new ArgvInput(['composer', 'install']); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + $plugin->activate($composer, $io); + + $exception = $this->assertCommandExecutionStops($plugin, $event); + + self::assertSame(7, $exception->getCode()); + self::assertStringContainsString('Docker Compose exec command failed with exit code 7.', $exception->getMessage()); + self::assertStringContainsString("'composer' 'install'", $exception->getMessage()); + self::assertStringContainsString('Error Output: install failed', $exception->getMessage()); + } + public function testEmptyAndInvalidScriptNamesAreIgnoredDuringActivation(): void { [$composer, $io] = $this->createComposer( @@ -807,4 +935,15 @@ private function assertScriptExecutionFails(DockerComposerPlugin $plugin, Script self::fail('Expected Docker script execution to fail.'); } + + private function assertCommandExecutionStops(DockerComposerPlugin $plugin, PreCommandRunEvent $event): ScriptExecutionException + { + try { + $plugin->onCommand($event); + } catch (ScriptExecutionException $exception) { + return $exception; + } + + self::fail('Expected Docker command execution to stop host command.'); + } } From d973e8bb8a697106a1f3d357c314f65099d8ab28 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sun, 10 May 2026 01:43:27 +0200 Subject: [PATCH 02/14] fix: support older console command inputs Recover raw Composer command tokens from legacy Symfony Console input storage when getRawTokens is unavailable. This keeps dependency command redirection compatible with older Composer and Symfony versions used by CI while preserving argument replay semantics. --- src/DockerComposeCommandBuilder.php | 87 +++++++++++++++++-- .../Unit/DockerComposeCommandBuilderTest.php | 67 ++++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) diff --git a/src/DockerComposeCommandBuilder.php b/src/DockerComposeCommandBuilder.php index 2b854cb..5da1f24 100644 --- a/src/DockerComposeCommandBuilder.php +++ b/src/DockerComposeCommandBuilder.php @@ -219,12 +219,7 @@ private function composerRunScriptCommand(ScriptEvent $event): array */ private function getCommandArguments(InputInterface $input, string $commandName): array { - if (! method_exists($input, 'getRawTokens')) { - throw new InvalidArgumentException('Composer command input must expose raw tokens.'); - } - - /** @var list $tokens */ - $tokens = $input->getRawTokens(); + $tokens = $this->getRawInputTokens($input); $firstArgument = $input->getFirstArgument(); $commandTokenReplaced = false; @@ -242,6 +237,86 @@ private function getCommandArguments(InputInterface $input, string $commandName) return $this->stripWorkingDirectoryTokens($tokens); } + /** + * Gets raw input tokens across Symfony Console versions. + * + * @param InputInterface $input + * The console input that carries Composer command arguments. + * + * @return list + * Returns raw tokens without the Composer executable. + * + * @throws InvalidArgumentException + * Thrown when raw tokens cannot be recovered safely. + */ + private function getRawInputTokens(InputInterface $input): array + { + if (method_exists($input, 'getRawTokens')) { + /** @var list $tokens */ + $tokens = $input->getRawTokens(); + + return $tokens; + } + + $tokens = $this->getRawInputTokensFromProperty($input); + if ($tokens !== null) { + return $tokens; + } + + $argv = $_SERVER['argv'] ?? null; + if (! is_array($argv) || $argv === []) { + throw new InvalidArgumentException('Composer command input must expose raw tokens.'); + } + + $tokens = []; + foreach (array_slice($argv, 1) as $token) { + if (! is_string($token)) { + throw new InvalidArgumentException('Composer command input must expose raw tokens.'); + } + + $tokens[] = $token; + } + + return $tokens; + } + + /** + * Gets raw input tokens from legacy Symfony Console internals. + * + * @param InputInterface $input + * The console input that may store raw tokens privately. + * + * @return list|null + * Returns raw tokens, or `null` when no compatible property exists. + * + * @throws InvalidArgumentException + * Thrown when the legacy tokens property has an unexpected shape. + */ + private function getRawInputTokensFromProperty(InputInterface $input): ?array + { + $reflection = new \ReflectionObject($input); + if (! $reflection->hasProperty('tokens')) { + return null; + } + + $property = $reflection->getProperty('tokens'); + $rawTokens = $property->getValue($input); + if (! is_array($rawTokens) || ! array_is_list($rawTokens)) { + throw new InvalidArgumentException('Composer command input must expose raw tokens.'); + } + + $tokens = []; + foreach ($rawTokens as $token) { + if (! is_string($token)) { + throw new InvalidArgumentException('Composer command input must expose raw tokens.'); + } + + $tokens[] = $token; + } + + return $tokens; + } + /** * Removes host-only Composer working directory options from arguments. * diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php index f45ef81..b1c23cb 100644 --- a/tests/Unit/DockerComposeCommandBuilderTest.php +++ b/tests/Unit/DockerComposeCommandBuilderTest.php @@ -14,6 +14,7 @@ use empaphy\docker_composer\DockerComposerConfig; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; +use Symfony\Component\Console\Input\ArrayInput; use Tests\TestCase; #[CoversClass(DockerComposeCommandBuilder::class)] @@ -62,4 +63,70 @@ public function testCommandBuilderRejectsNonScalarArguments(): void $method->invoke(new DockerComposeCommandBuilder(), []); } + + public function testCommandBuilderUsesLegacyInputTokensProperty(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new LegacyTokenInput([ + '--no-interaction', + 'install', + '--working-dir=app', + '--prefer-dist', + ]); + + $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); + + self::assertSame([ + 'composer', + '--no-interaction', + 'install', + '--prefer-dist', + ], array_slice($command, -4)); + } +} + +/** + * Provides Symfony Console 7.0-style raw token storage. + */ +final class LegacyTokenInput extends ArrayInput +{ + /** + * Stores raw input tokens. + * + * @var list + */ + private array $tokens; + + /** + * Creates a legacy token input. + * + * @param list $tokens + * The raw input tokens without the Composer executable. + */ + public function __construct(array $tokens) + { + $this->tokens = $tokens; + + parent::__construct($tokens); + } + + /** + * Returns the first command-like argument. + * + * @return string|null + * Returns the first token that is not an option. + */ + public function getFirstArgument(): ?string + { + foreach ($this->tokens as $token) { + if ($token !== '' && $token[0] !== '-') { + return $token; + } + } + + return null; + } } From 4a189f1d83aea5e8042143bd9ce266e93d06bcb6 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sun, 10 May 2026 01:47:24 +0200 Subject: [PATCH 03/14] test: make command failure assertion portable Avoid assuming Unix shell quoting in the Docker command failure assertion. Windows CI escapes command output differently, so the test now verifies the command terms without depending on quote style. --- tests/Unit/DockerComposerPluginTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php index fa017c8..e11e023 100644 --- a/tests/Unit/DockerComposerPluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -682,7 +682,8 @@ public function testCommandDockerFailurePreservesExitCode(): void self::assertSame(7, $exception->getCode()); self::assertStringContainsString('Docker Compose exec command failed with exit code 7.', $exception->getMessage()); - self::assertStringContainsString("'composer' 'install'", $exception->getMessage()); + self::assertStringContainsString('composer', $exception->getMessage()); + self::assertStringContainsString('install', $exception->getMessage()); self::assertStringContainsString('Error Output: install failed', $exception->getMessage()); } From 3579f89fc9ce21ecfab6564ff832522fdf881eb3 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sun, 10 May 2026 03:06:14 +0200 Subject: [PATCH 04/14] test: raise docker command coverage Add unit coverage for Composer command argument replay fallbacks, working-dir stripping, command fallthrough guards, and service-mapping command redirects. This protects the Docker dependency-command redirect behavior and raises the reported line coverage on the affected files. --- .../Unit/DockerComposeCommandBuilderTest.php | 200 +++++++++++++++++- tests/Unit/DockerComposerPluginTest.php | 107 ++++++++++ 2 files changed, 306 insertions(+), 1 deletion(-) diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php index b1c23cb..a2ae94e 100644 --- a/tests/Unit/DockerComposeCommandBuilderTest.php +++ b/tests/Unit/DockerComposeCommandBuilderTest.php @@ -86,6 +86,145 @@ public function testCommandBuilderUsesLegacyInputTokensProperty(): void '--prefer-dist', ], array_slice($command, -4)); } + + public function testCommandBuilderStripsWorkingDirectoryTokenForms(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new LegacyTokenInput([ + '--working-dir', + '/host/a', + '-d', + '/host/b', + '-d/host/c', + '--working-dir=/host/d', + 'install', + '--', + '-d', + 'vendor/package', + ], 'install'); + + $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); + + self::assertSame([ + 'composer', + 'install', + '--', + '-d', + 'vendor/package', + ], array_slice($command, -5)); + } + + public function testCommandBuilderBuildsInteractiveRunCommand(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => [ + 'service' => 'php', + 'mode' => 'run', + ], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new LegacyTokenInput(['update'], 'update'); + + $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'update', $input, true); + + self::assertSame([ + 'docker', + 'compose', + 'run', + '--rm', + '--env', + 'DOCKER_COMPOSER_INSIDE=1', + 'php', + 'composer', + 'update', + ], $command); + } + + public function testCommandBuilderUsesServerArgvFallback(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new ArrayInput(['install']); + $previousServer = $_SERVER; + + try { + $_SERVER['argv'] = ['composer', '--no-interaction', '--no-dev']; + + $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); + } finally { + $_SERVER = $previousServer; + } + + self::assertSame([ + 'composer', + '--no-interaction', + '--no-dev', + 'install', + ], array_slice($command, -4)); + } + + public function testCommandBuilderRejectsMissingRawTokens(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new ArrayInput(['install']); + $previousServer = $_SERVER; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Composer command input must expose raw tokens.'); + + try { + $_SERVER = array_diff_key($_SERVER, ['argv' => true]); + + (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); + } finally { + $_SERVER = $previousServer; + } + } + + public function testCommandBuilderRejectsInvalidServerArgvTokens(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new ArrayInput(['install']); + $previousServer = $_SERVER; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Composer command input must expose raw tokens.'); + + try { + $_SERVER['argv'] = ['composer', []]; + + (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); + } finally { + $_SERVER = $previousServer; + } + } + + public function testCommandBuilderRejectsInvalidLegacyInputTokensProperty(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new InvalidLegacyTokenInput(['install', []]); + + self::assertSame(['install', []], $input->getTokensForAssertion()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Composer command input must expose raw tokens.'); + + (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); + } } /** @@ -100,15 +239,24 @@ final class LegacyTokenInput extends ArrayInput */ private array $tokens; + /** + * Stores the command-like first argument. + */ + private ?string $firstArgument; + /** * Creates a legacy token input. * * @param list $tokens * The raw input tokens without the Composer executable. + * + * @param string|null $firstArgument + * The command-like first argument to return. */ - public function __construct(array $tokens) + public function __construct(array $tokens, ?string $firstArgument = null) { $this->tokens = $tokens; + $this->firstArgument = $firstArgument; parent::__construct($tokens); } @@ -121,6 +269,10 @@ public function __construct(array $tokens) */ public function getFirstArgument(): ?string { + if ($this->firstArgument !== null) { + return $this->firstArgument; + } + foreach ($this->tokens as $token) { if ($token !== '' && $token[0] !== '-') { return $token; @@ -130,3 +282,49 @@ public function getFirstArgument(): ?string return null; } } + +/** + * Provides malformed Symfony Console 7.0-style raw token storage. + */ +final class InvalidLegacyTokenInput extends ArrayInput +{ + /** + * Stores malformed raw input tokens. + */ + private mixed $tokens; + + /** + * Creates an invalid legacy token input. + * + * @param mixed $tokens + * The malformed raw token payload. + */ + public function __construct(mixed $tokens) + { + $this->tokens = $tokens; + + parent::__construct(['install']); + } + + /** + * Returns the malformed raw token payload. + * + * @return mixed + * Returns the malformed raw token payload. + */ + public function getTokensForAssertion(): mixed + { + return $this->tokens; + } + + /** + * Returns the first command-like argument. + * + * @return string + * Returns `install`. + */ + public function getFirstArgument(): string + { + return 'install'; + } +} diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php index e11e023..d64affa 100644 --- a/tests/Unit/DockerComposerPluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -666,6 +666,113 @@ public function testExcludedCommandFallsThrough(): void self::assertSame([], $runner->commands); } + public function testUnredirectedCommandFallsThrough(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $input = new ArgvInput(['composer', 'validate']); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'validate'); + + $plugin->activate($composer, $io); + $plugin->onCommand($event); + + self::assertSame([], $runner->commands); + } + + public function testCommandFallsThroughWithoutActivation(): void + { + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $input = new ArgvInput(['composer', 'install']); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + $plugin->onCommand($event); + + self::assertSame([], $runner->commands); + } + + public function testContainerCommandFallsThrough(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(true)); + $input = new ArgvInput(['composer', 'install']); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + $plugin->activate($composer, $io); + $plugin->onCommand($event); + + self::assertSame([], $runner->commands); + } + + #[BackupGlobals(true)] + public function testDisableEnvironmentVariableMakesCommandFallThrough(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $input = new ArgvInput(['composer', 'install']); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + putenv('DOCKER_COMPOSER_DISABLE=1'); + try { + $plugin->activate($composer, $io); + $plugin->onCommand($event); + } finally { + putenv('DOCKER_COMPOSER_DISABLE'); + } + + self::assertSame([], $runner->commands); + } + + public function testMissingServiceCommandWarnsAndFallsThrough(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => [], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $input = new ArgvInput(['composer', 'install']); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + $plugin->activate($composer, $io); + $plugin->onCommand($event); + + self::assertSame([], $runner->commands); + self::assertStringContainsString('no default service and no service-mapping override for "install"', $io->getOutput()); + } + + public function testCommandServiceMappingOverrideChangesTargetService(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => [ + 'service' => 'php', + 'service-mapping' => [ + 'php-tools' => 'install', + ], + ], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $input = new ArgvInput(['composer', 'install']); + $input->setInteractive(false); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + $plugin->activate($composer, $io); + $this->assertCommandExecutionStops($plugin, $event); + + self::assertSame('php-tools', $runner->commands[0][4]); + self::assertSame('php-tools', $runner->commands[1][6]); + self::assertStringContainsString('Running composer install in Docker Compose service php-tools.', $io->getOutput()); + } + public function testCommandDockerFailurePreservesExitCode(): void { [$composer, $io] = $this->createComposer([], [ From 72fec4362181dc8a96ebedc87e0b82cbb77bf1d0 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sun, 10 May 2026 03:17:52 +0200 Subject: [PATCH 05/14] test: reach full docker command coverage Add targeted coverage for malformed legacy command tokens, command fallthrough without configuration, and lazy command runner creation. This brings the affected Docker command redirect files to 100% line coverage and removes an unreachable notice guard now proven by onCommand control flow. --- src/DockerComposerPlugin.php | 13 +++--- .../Unit/DockerComposeCommandBuilderTest.php | 16 +++++++ tests/Unit/DockerComposerPluginTest.php | 42 +++++++++++++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php index b805d86..93ecf50 100644 --- a/src/DockerComposerPlugin.php +++ b/src/DockerComposerPlugin.php @@ -288,7 +288,7 @@ public function onCommand(PreCommandRunEvent $event): void $commandConfig = $config->forScript($commandName); - $this->writeCommandRedirectNotice($commandName, $commandConfig); + $this->writeCommandRedirectNotice($io, $commandName, $commandConfig); $this->runComposerCommandInDocker($event, $commandConfig); throw new ScriptExecutionException('', 0); @@ -451,6 +451,9 @@ private function writeRedirectNotice(ScriptEvent $event, DockerComposerConfig $c /** * Writes the command redirection notice. * + * @param IOInterface $io + * The Composer IO that receives the notice. + * * @param string $commandName * The Composer command being redirected. * @@ -460,13 +463,9 @@ private function writeRedirectNotice(ScriptEvent $event, DockerComposerConfig $c * @return void * Returns nothing. */ - private function writeCommandRedirectNotice(string $commandName, DockerComposerConfig $config): void + private function writeCommandRedirectNotice(IOInterface $io, string $commandName, DockerComposerConfig $config): void { - if ($this->io === null) { - return; - } - - $this->io->writeError(sprintf( + $io->writeError(sprintf( 'docker-composer: Running composer %s in Docker Compose service %s.', OutputFormatter::escape($commandName), OutputFormatter::escape($config->getService()), diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php index a2ae94e..342d735 100644 --- a/tests/Unit/DockerComposeCommandBuilderTest.php +++ b/tests/Unit/DockerComposeCommandBuilderTest.php @@ -225,6 +225,22 @@ public function testCommandBuilderRejectsInvalidLegacyInputTokensProperty(): voi (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); } + + public function testCommandBuilderRejectsMalformedLegacyInputTokensProperty(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new InvalidLegacyTokenInput(['command' => 'install']); + + self::assertSame(['command' => 'install'], $input->getTokensForAssertion()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Composer command input must expose raw tokens.'); + + (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); + } } /** diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php index d64affa..5086cf9 100644 --- a/tests/Unit/DockerComposerPluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -773,6 +773,48 @@ public function testCommandServiceMappingOverrideChangesTargetService(): void self::assertStringContainsString('Running composer install in Docker Compose service php-tools.', $io->getOutput()); } + public function testCommandFallsThroughWhenIoExistsWithoutConfiguration(): void + { + [, $io] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $ioProperty = new \ReflectionProperty(DockerComposerPlugin::class, 'io'); + $input = new ArgvInput(['composer', 'install']); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install'); + + $ioProperty->setValue($plugin, $io); + $plugin->onCommand($event); + + self::assertSame([], $runner->commands); + } + + public function testCommandProcessRunnerRequiresActivationContext(): void + { + $plugin = new DockerComposerPlugin(null, new MockContainerDetector(false)); + $method = new \ReflectionMethod(DockerComposerPlugin::class, 'getProcessRunnerForCommand'); + + $this->expectException(ScriptExecutionException::class); + $this->expectExceptionMessage('Docker Composer plugin was not activated.'); + + $method->invoke($plugin); + } + + public function testCommandProcessRunnerCreatesDefaultRunnerFromStoredIo(): void + { + [, $io] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $plugin = new DockerComposerPlugin(null, new MockContainerDetector(false)); + $ioProperty = new \ReflectionProperty(DockerComposerPlugin::class, 'io'); + $method = new \ReflectionMethod(DockerComposerPlugin::class, 'getProcessRunnerForCommand'); + + $ioProperty->setValue($plugin, $io); + + self::assertInstanceOf(ComposerProcessRunner::class, $method->invoke($plugin)); + } + public function testCommandDockerFailurePreservesExitCode(): void { [$composer, $io] = $this->createComposer([], [ From d1eff4fc0147f547ec516c973ecbfa7b40e8d69c Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sun, 10 May 2026 10:05:36 +0200 Subject: [PATCH 06/14] docs: tweak `AGENTS.md` --- AGENTS.md | 43 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b258cc9..39c4b8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ ## General Instructions +In all interactions and comments be extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent. In commit messages use conventional commits and provide justification of the changes in the body. -In all interactions and plans be extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent. ## Plan Mode -Make plans extremely concise — sacrifice grammar for the sake of concision. Conciseness alone does not justify omitting information or intent. +Make plans extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent. At the end of each plan, give me a list of unresolved questions to answer, if any. ## Tests @@ -13,25 +13,17 @@ At the end of every task, execute these commands to ensure the quality of the co - `composer stan` - `composer test` -## PHPStan -Prefer clear, performant code over reshaping code only to satisfy PHPStan. -Treat PHPStan findings as likely real; fix root causes first. -If PHPStan cannot model valid runtime behavior, use the narrowest fix: - 1. add explicit control flow or `assert()` when it improves clarity; - 2. otherwise add a targeted `@phpstan-ignore ` on the exact line. -Do not add broad suppressions, baselines, or unclear type workarounds. - ## Coding Style -All PHP code must adhere to PER Coding Syle 3.0, which also includes PSR-1: Basic Coding Standard. +All PHP code must adhere to PER Coding Style, which includes PSR-1: Basic Coding Standard. Files should _either_ declare symbols _or_ cause side-effects but not both. ## PHPDoc -Add descriptive PHPDoc comments to all Structural Elements in PHP code under `src/`. Include descriptive `@param` and `@return` tags for all argument and return types, and `@var` tags for all parameters. +Add descriptive PHPDoc comments to all Structural Elements in PHP code under `src/`. For functions and methods include the return type, and the `@param` and `@return` tags for every argument. When writing PHPDocs, observe this format: ```php -class +class Foo { /** * Constants don't need a `@var` doctag, since their type is implied. @@ -53,32 +45,39 @@ class private Baz baz(): /** - * The first line should be a short description with no Markdown. + * The first line should be a short description, no Markdown allowed here. * - * Here you can add as many lines and all the Markdown you want. + * Here, provide a high-level explanation of what the function does and what + * the use cases are. Aim for a line length of 80 characters or fewer. + * + * You can use as many lines and all the Markdown you want. * - reference arguments by making the text __bold__ - * - reference scalar types and literals with backticks, so `string` and `"foo"` + * - reference scalar types and literals with backticks, so `string` and + * `"foo"` * - reference non-scalar types as {@see Foo} * - import referenced classes, do not use FQN * + * If appropriate, e.g. if not completely clear from the description + * signature alone, provide some examples: + * * // 4 spaces indent Code blocks. - * foo(1, 2, 'three'); + * foo($foo, $bar, 'foo'); // returns $foo + * foo($foo, $bar, 'baz'); // returns $bar * - * Keep a blank line before the first doctag: + * Keep a blank line before the first doc tag: * * @template TFoo of Foo * @template TBar of Bar * * @param TFoo $one - * Place the description of a doctag on the next line, indented by 2 + * Place the description of a doc tag on the next line, indented by 2 * spaces. * * @param TBar $two - * Doctags with a description should be surrounded by a blank line. + * Doc tags with a description should be surrounded by a blank line. * * @param string $three - * `@param` doctags should have their type and argument name surrounded - * by 2 spaces + * Only `@param` doc tags have their type and name surrounded by 2 spaces. * * @return ($three is "foo" ? TFoo : TBar) * Returns __one__ if __three__ is `"foo"`, __two__ otherwise. From 9850d41256f22be810e56a860c0ab6fb5dcd7107 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sun, 10 May 2026 10:21:35 +0200 Subject: [PATCH 07/14] test: split command builder input helpers Move legacy input test helpers into dedicated mock files so the command builder test declares only its test case. Keeps behavior unchanged while addressing PR feedback about mixed file responsibilities. --- .../Unit/DockerComposeCommandBuilderTest.php | 104 +----------------- tests/Unit/Mocks/InvalidLegacyTokenInput.php | 53 +++++++++ tests/Unit/Mocks/LegacyTokenInput.php | 63 +++++++++++ 3 files changed, 118 insertions(+), 102 deletions(-) create mode 100644 tests/Unit/Mocks/InvalidLegacyTokenInput.php create mode 100644 tests/Unit/Mocks/LegacyTokenInput.php diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php index 342d735..254af56 100644 --- a/tests/Unit/DockerComposeCommandBuilderTest.php +++ b/tests/Unit/DockerComposeCommandBuilderTest.php @@ -16,6 +16,8 @@ use PHPUnit\Framework\Attributes\UsesClass; use Symfony\Component\Console\Input\ArrayInput; use Tests\TestCase; +use Tests\Unit\Mocks\InvalidLegacyTokenInput; +use Tests\Unit\Mocks\LegacyTokenInput; #[CoversClass(DockerComposeCommandBuilder::class)] #[UsesClass(DockerComposerConfig::class)] @@ -242,105 +244,3 @@ public function testCommandBuilderRejectsMalformedLegacyInputTokensProperty(): v (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); } } - -/** - * Provides Symfony Console 7.0-style raw token storage. - */ -final class LegacyTokenInput extends ArrayInput -{ - /** - * Stores raw input tokens. - * - * @var list - */ - private array $tokens; - - /** - * Stores the command-like first argument. - */ - private ?string $firstArgument; - - /** - * Creates a legacy token input. - * - * @param list $tokens - * The raw input tokens without the Composer executable. - * - * @param string|null $firstArgument - * The command-like first argument to return. - */ - public function __construct(array $tokens, ?string $firstArgument = null) - { - $this->tokens = $tokens; - $this->firstArgument = $firstArgument; - - parent::__construct($tokens); - } - - /** - * Returns the first command-like argument. - * - * @return string|null - * Returns the first token that is not an option. - */ - public function getFirstArgument(): ?string - { - if ($this->firstArgument !== null) { - return $this->firstArgument; - } - - foreach ($this->tokens as $token) { - if ($token !== '' && $token[0] !== '-') { - return $token; - } - } - - return null; - } -} - -/** - * Provides malformed Symfony Console 7.0-style raw token storage. - */ -final class InvalidLegacyTokenInput extends ArrayInput -{ - /** - * Stores malformed raw input tokens. - */ - private mixed $tokens; - - /** - * Creates an invalid legacy token input. - * - * @param mixed $tokens - * The malformed raw token payload. - */ - public function __construct(mixed $tokens) - { - $this->tokens = $tokens; - - parent::__construct(['install']); - } - - /** - * Returns the malformed raw token payload. - * - * @return mixed - * Returns the malformed raw token payload. - */ - public function getTokensForAssertion(): mixed - { - return $this->tokens; - } - - /** - * Returns the first command-like argument. - * - * @return string - * Returns `install`. - */ - public function getFirstArgument(): string - { - return 'install'; - } -} diff --git a/tests/Unit/Mocks/InvalidLegacyTokenInput.php b/tests/Unit/Mocks/InvalidLegacyTokenInput.php new file mode 100644 index 0000000..af2bfbd --- /dev/null +++ b/tests/Unit/Mocks/InvalidLegacyTokenInput.php @@ -0,0 +1,53 @@ +tokens = $tokens; + + parent::__construct(['install']); + } + + /** + * Returns the malformed raw token payload. + * + * @return mixed + * Returns the malformed raw token payload. + */ + public function getTokensForAssertion(): mixed + { + return $this->tokens; + } + + /** + * Returns the first command-like argument. + * + * @return string + * Returns `install`. + */ + public function getFirstArgument(): string + { + return 'install'; + } +} diff --git a/tests/Unit/Mocks/LegacyTokenInput.php b/tests/Unit/Mocks/LegacyTokenInput.php new file mode 100644 index 0000000..e56620f --- /dev/null +++ b/tests/Unit/Mocks/LegacyTokenInput.php @@ -0,0 +1,63 @@ + + */ + private array $tokens; + + /** + * Stores the command-like first argument. + */ + private ?string $firstArgument; + + /** + * Creates a legacy token input. + * + * @param list $tokens + * The raw input tokens without the Composer executable. + * + * @param string|null $firstArgument + * The command-like first argument to return. + */ + public function __construct(array $tokens, ?string $firstArgument = null) + { + $this->tokens = $tokens; + $this->firstArgument = $firstArgument; + + parent::__construct($tokens); + } + + /** + * Returns the first command-like argument. + * + * @return string|null + * Returns the first token that is not an option. + */ + public function getFirstArgument(): ?string + { + if ($this->firstArgument !== null) { + return $this->firstArgument; + } + + foreach ($this->tokens as $token) { + if ($token !== '' && $token[0] !== '-') { + return $token; + } + } + + return null; + } +} From 9178f122141fc873e2b88d9435ef5d171883e117 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sun, 10 May 2026 11:47:55 +0200 Subject: [PATCH 08/14] fix: guard command event registration Only register the dependency command listener when the Composer pre-command event API is available. Also clarifies missing-service warnings for Composer commands so output no longer describes them as scripts. --- src/DockerComposerPlugin.php | 23 ++++++++++++++++------- tests/Unit/DockerComposerPluginTest.php | 3 +++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php index 93ecf50..f85cb93 100644 --- a/src/DockerComposerPlugin.php +++ b/src/DockerComposerPlugin.php @@ -281,7 +281,7 @@ public function onCommand(PreCommandRunEvent $event): void } if (! $config->isConfiguredForScript($commandName)) { - $this->writeMissingConfigWarning($io, $commandName); + $this->writeMissingConfigWarning($io, $commandName, 'command'); return; } @@ -305,7 +305,12 @@ public function onCommand(PreCommandRunEvent $event): void */ private function registerCommandListener(Composer $composer): void { - $composer->getEventDispatcher()->addListener(PluginEvents::PRE_COMMAND_RUN, [$this, 'onCommand'], PHP_INT_MAX); + if (class_exists(PreCommandRunEvent::class) && defined(PluginEvents::class . '::PRE_COMMAND_RUN')) { + $eventName = constant(PluginEvents::class . '::PRE_COMMAND_RUN'); + assert(is_string($eventName)); + + $composer->getEventDispatcher()->addListener($eventName, [$this, 'onCommand'], PHP_INT_MAX); + } } /** @@ -408,21 +413,25 @@ private function writeDuplicateServiceMappingWarnings(IOInterface $io): void * @param IOInterface $io * The Composer IO that receives the warning. * - * @param string $scriptName - * The Composer script name without a configured service. + * @param string $entryName + * The Composer script or command name without a configured service. + * + * @param string $entryType + * The Composer entry type being allowed to run on the host. * * @return void * Returns nothing. */ - private function writeMissingConfigWarning(IOInterface $io, string $scriptName): void + private function writeMissingConfigWarning(IOInterface $io, string $entryName, string $entryType = 'script'): void { if ($this->missingConfigWarningWritten) { return; } $io->writeError(sprintf( - 'docker-composer: no default service and no service-mapping override for "%s"; running Composer script on the host.', - OutputFormatter::escape($scriptName), + 'docker-composer: no default service and no service-mapping override for "%s"; running Composer %s on the host.', + OutputFormatter::escape($entryName), + $entryType, )); $this->missingConfigWarningWritten = true; } diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php index 5086cf9..93b1b64 100644 --- a/tests/Unit/DockerComposerPluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -559,6 +559,7 @@ public function testMissingServiceWarnsOnceAndFallsThrough(): void self::assertFalse($secondEvent->isPropagationStopped()); self::assertSame([], $runner->commands); self::assertSame(1, substr_count($io->getOutput(), 'no default service and no service-mapping override for "test"')); + self::assertStringContainsString('running Composer script on the host', $io->getOutput()); } public function testRedirectsInstallCommandBeforeHostExecution(): void @@ -747,6 +748,8 @@ public function testMissingServiceCommandWarnsAndFallsThrough(): void self::assertSame([], $runner->commands); self::assertStringContainsString('no default service and no service-mapping override for "install"', $io->getOutput()); + self::assertStringContainsString('running Composer command on the host', $io->getOutput()); + self::assertStringNotContainsString('running Composer script on the host', $io->getOutput()); } public function testCommandServiceMappingOverrideChangesTargetService(): void From 8d91930eb29311e5fd082da4cf1b0e2e22d7bcf1 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Mon, 11 May 2026 16:56:42 +0200 Subject: [PATCH 09/14] ci: improve coverage test output --- .github/workflows/unit-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 1778e00..8dbbbb3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -41,6 +41,7 @@ jobs: vendor/bin/phpunit \ --testsuite 'Unit' \ --log-junit 'var/test-results/${{ inputs.os }}-php${{ matrix.php-version }}.junit.xml' \ + --coverage-text \ --coverage-cobertura 'var/coverage/${{ inputs.os }}-php${{ matrix.php-version }}/coverage.cobertura.xml' - if: success() || failure() From 9ce7850fce25bd1041e56b4e64a1fe54caf7e7d2 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Mon, 11 May 2026 17:26:19 +0200 Subject: [PATCH 10/14] ci: enable assertions --- .github/actions/setup/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index b5b2872..f5b59af 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -21,6 +21,7 @@ runs: php-version: ${{ inputs.php-version }} tools: composer:${{ inputs.composer-version }} coverage: xdebug + ini-values: zend.assertions=1 - name: Validate Composer Config shell: bash From 350d16dff59cb99391fabf08bf963a718d4f23f6 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Tue, 12 May 2026 01:46:45 +0200 Subject: [PATCH 11/14] test: cover raw token command inputs Add a Symfony-style raw token input double and unit coverage for Composer command replay through getRawTokens(). This addresses PR feedback and protects command token replacement plus host working-directory stripping for current Symfony Console inputs. --- .../Unit/DockerComposeCommandBuilderTest.php | 24 ++++++ tests/Unit/Mocks/RawTokenInput.php | 85 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tests/Unit/Mocks/RawTokenInput.php diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php index 254af56..e2f7ade 100644 --- a/tests/Unit/DockerComposeCommandBuilderTest.php +++ b/tests/Unit/DockerComposeCommandBuilderTest.php @@ -18,6 +18,7 @@ use Tests\TestCase; use Tests\Unit\Mocks\InvalidLegacyTokenInput; use Tests\Unit\Mocks\LegacyTokenInput; +use Tests\Unit\Mocks\RawTokenInput; #[CoversClass(DockerComposeCommandBuilder::class)] #[UsesClass(DockerComposerConfig::class)] @@ -89,6 +90,29 @@ public function testCommandBuilderUsesLegacyInputTokensProperty(): void ], array_slice($command, -4)); } + public function testCommandBuilderUsesRawTokensMethod(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $input = new RawTokenInput([ + '--no-interaction', + 'i', + '--working-dir=/host/app', + '--prefer-dist', + ], 'i'); + + $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false); + + self::assertSame([ + 'composer', + '--no-interaction', + 'install', + '--prefer-dist', + ], array_slice($command, -4)); + } + public function testCommandBuilderStripsWorkingDirectoryTokenForms(): void { [$composer] = $this->createComposer([], [ diff --git a/tests/Unit/Mocks/RawTokenInput.php b/tests/Unit/Mocks/RawTokenInput.php new file mode 100644 index 0000000..08b0469 --- /dev/null +++ b/tests/Unit/Mocks/RawTokenInput.php @@ -0,0 +1,85 @@ + + */ + private array $tokens; + + /** + * Stores the command-like first argument. + */ + private ?string $firstArgument; + + /** + * Creates a raw token input. + * + * @param list $tokens + * The raw input tokens without the Composer executable. + * + * @param string|null $firstArgument + * The command-like first argument to return. + */ + public function __construct(array $tokens, ?string $firstArgument = null) + { + $this->tokens = $tokens; + $this->firstArgument = $firstArgument; + + parent::__construct($tokens); + } + + /** + * Returns the first command-like argument. + * + * @return string|null + * Returns the configured first argument. + */ + public function getFirstArgument(): ?string + { + return $this->firstArgument; + } + + /** + * Returns unparsed raw tokens. + * + * @param bool $strip + * Whether to return only tokens after the command-like first argument. + * + * @return list + * Returns raw input tokens. + */ + public function getRawTokens(bool $strip = false): array + { + if (! $strip) { + return $this->tokens; + } + + $tokens = []; + $keep = false; + foreach ($this->tokens as $token) { + if (! $keep && $token === $this->firstArgument) { + $keep = true; + + continue; + } + + if ($keep) { + $tokens[] = $token; + } + } + + return $tokens; + } +} From 0f6975d0cde7f7d1b31947d2284438be05037811 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Tue, 12 May 2026 18:38:35 +0200 Subject: [PATCH 12/14] fix: fix composer scripts and add descriptions --- composer.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5a20c3d..34b38b9 100644 --- a/composer.json +++ b/composer.json @@ -51,11 +51,19 @@ "scripts": { "style-check": "XDEBUG_MODE=off php-cs-fixer check", "style-fix": "XDEBUG_MODE=off php-cs-fixer fix", - "stan": "XDEBUG_MODE=off phpstan --memory-limit=1G", + "stan": "XDEBUG_MODE=off phpstan analyse --memory-limit=1G", "test": "XDEBUG_MODE=coverage phpunit --coverage-text", "test-integration": "XDEBUG_MODE=off phpunit --testsuite Integration", "test-unit": "XDEBUG_MODE=coverage phpunit --testsuite Unit --coverage-text" }, + "scripts-descriptions": { + "style-check": "Check coding style using `php-cs-fixer check [options] [--] [...]`", + "style-fix": "Fix coding style using `php-cs-fixer fix [options] [--] [...]`", + "stan": "Perform static analysis using `phpstan analyse [options] [--] [...]`", + "test": "Run all test suites using `phpunit --coverage-text [options] [ ...]`", + "test-integration": "Run Integration test suite using `phpunit --testsuite Integration [options] [ ...]`", + "test-unit": "Run Unit test suite using `phpunit --testsuite Unit --coverage-text [options] [ ...]`" + }, "extra": { "class": "empaphy\\docker_composer\\DockerComposerPlugin", "branch-alias": { From cd263a1746c753b9230f439a638c98f3d435ba51 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Tue, 12 May 2026 18:46:30 +0200 Subject: [PATCH 13/14] ide: update `.idea` config --- .idea/docker-composer.iml | 2 -- .idea/php.xml | 2 -- .idea/runConfigurations/Unit_Tests.xml | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.idea/docker-composer.iml b/.idea/docker-composer.iml index 1f5144e..73307ed 100644 --- a/.idea/docker-composer.iml +++ b/.idea/docker-composer.iml @@ -5,12 +5,10 @@ - - diff --git a/.idea/php.xml b/.idea/php.xml index 167a688..621e190 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -28,13 +28,11 @@ - - diff --git a/.idea/runConfigurations/Unit_Tests.xml b/.idea/runConfigurations/Unit_Tests.xml index 066d3fc..d066454 100644 --- a/.idea/runConfigurations/Unit_Tests.xml +++ b/.idea/runConfigurations/Unit_Tests.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file From ac0389206742f35c7bf72025f2638a62bec32930 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Tue, 12 May 2026 20:05:56 +0200 Subject: [PATCH 14/14] tests: remove comments from test code --- .../DockerComposerIntegrationTest.php | 16 +++++------- tests/Unit/Mocks/InvalidLegacyTokenInput.php | 24 ----------------- tests/Unit/Mocks/LegacyTokenInput.php | 20 -------------- .../MockOutputCapturingProcessRunner.php | 4 ++- tests/Unit/Mocks/MockProcessExecutor.php | 26 +++++++++++-------- tests/Unit/Mocks/MockProcessRunner.php | 19 ++++++++++---- tests/Unit/Mocks/RawTokenInput.php | 26 ------------------- 7 files changed, 39 insertions(+), 96 deletions(-) diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index fbc1c34..59b5ef1 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -1,6 +1,8 @@ */ + /** + * @var list + */ private array $projectDirectories = []; protected function tearDown(): void @@ -121,8 +125,8 @@ public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void } /** - * @param array $dockerComposerConfig - * @param list>|null $repositories + * @param array $dockerComposerConfig + * @param list>|null $repositories */ private function createProject(array $dockerComposerConfig, ?array $repositories = null, string $requireVersion = '*'): string { @@ -222,13 +226,7 @@ private function getComposerImage(): string } /** - * Gets a Composer require command for the active integration Composer version. - * - * @param string $package - * The package constraint to require. - * * @return list - * Returns a Composer require command compatible with the active version. */ protected function getRequireCommand(string $package): array { diff --git a/tests/Unit/Mocks/InvalidLegacyTokenInput.php b/tests/Unit/Mocks/InvalidLegacyTokenInput.php index af2bfbd..6d37105 100644 --- a/tests/Unit/Mocks/InvalidLegacyTokenInput.php +++ b/tests/Unit/Mocks/InvalidLegacyTokenInput.php @@ -6,22 +6,10 @@ use Symfony\Component\Console\Input\ArrayInput; -/** - * Provides malformed Symfony Console 7.0-style raw token storage. - */ final class InvalidLegacyTokenInput extends ArrayInput { - /** - * Stores malformed raw input tokens. - */ private mixed $tokens; - /** - * Creates an invalid legacy token input. - * - * @param mixed $tokens - * The malformed raw token payload. - */ public function __construct(mixed $tokens) { $this->tokens = $tokens; @@ -29,23 +17,11 @@ public function __construct(mixed $tokens) parent::__construct(['install']); } - /** - * Returns the malformed raw token payload. - * - * @return mixed - * Returns the malformed raw token payload. - */ public function getTokensForAssertion(): mixed { return $this->tokens; } - /** - * Returns the first command-like argument. - * - * @return string - * Returns `install`. - */ public function getFirstArgument(): string { return 'install'; diff --git a/tests/Unit/Mocks/LegacyTokenInput.php b/tests/Unit/Mocks/LegacyTokenInput.php index e56620f..ac6e8d6 100644 --- a/tests/Unit/Mocks/LegacyTokenInput.php +++ b/tests/Unit/Mocks/LegacyTokenInput.php @@ -6,31 +6,17 @@ use Symfony\Component\Console\Input\ArrayInput; -/** - * Provides Symfony Console 7.0-style raw token storage. - */ final class LegacyTokenInput extends ArrayInput { /** - * Stores raw input tokens. - * * @var list */ private array $tokens; - /** - * Stores the command-like first argument. - */ private ?string $firstArgument; /** - * Creates a legacy token input. - * * @param list $tokens - * The raw input tokens without the Composer executable. - * - * @param string|null $firstArgument - * The command-like first argument to return. */ public function __construct(array $tokens, ?string $firstArgument = null) { @@ -40,12 +26,6 @@ public function __construct(array $tokens, ?string $firstArgument = null) parent::__construct($tokens); } - /** - * Returns the first command-like argument. - * - * @return string|null - * Returns the first token that is not an option. - */ public function getFirstArgument(): ?string { if ($this->firstArgument !== null) { diff --git a/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php b/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php index 554d978..86788b8 100644 --- a/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php +++ b/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php @@ -10,7 +10,9 @@ final class MockOutputCapturingProcessRunner extends MockProcessRunner implements OutputCapturingProcessRunner { - /** @var list */ + /** + * @var list + */ private array $outputs; /** diff --git a/tests/Unit/Mocks/MockProcessExecutor.php b/tests/Unit/Mocks/MockProcessExecutor.php index 2005a67..dd2104a 100644 --- a/tests/Unit/Mocks/MockProcessExecutor.php +++ b/tests/Unit/Mocks/MockProcessExecutor.php @@ -8,40 +8,44 @@ final class MockProcessExecutor extends ProcessExecutor { - /** @var list */ + /** + * @var list> + */ public array $commands = []; - /** @var list */ + /** + * @var list> + */ public array $ttyCommands = []; /** * @noinspection PhpMissingParentConstructorInspection */ public function __construct( - private int $executeExitCode, - private int $ttyExitCode, - private string $testErrorOutput, - private string $testOutput = '', + private readonly int $executeExitCode, + private readonly int $ttyExitCode, + private readonly string $testErrorOutput, + private readonly string $testOutput = '', ) {} /** - * @param mixed $command - * @param mixed $output + * @param string|non-empty-list $command + * @param mixed $output */ public function execute($command, &$output = null, ?string $cwd = null): int { - $this->commands[] = (string) $command; + $this->commands[] = $command; $output = $this->testOutput; return $this->executeExitCode; } /** - * @param mixed $command + * @param string|non-empty-list $command */ public function executeTty($command, ?string $cwd = null): int { - $this->ttyCommands[] = (string) $command; + $this->ttyCommands[] = $command; return $this->ttyExitCode; } diff --git a/tests/Unit/Mocks/MockProcessRunner.php b/tests/Unit/Mocks/MockProcessRunner.php index 3072785..4e491bf 100644 --- a/tests/Unit/Mocks/MockProcessRunner.php +++ b/tests/Unit/Mocks/MockProcessRunner.php @@ -10,13 +10,19 @@ class MockProcessRunner implements ProcessRunner { - /** @var list> */ + /** + * @var list> + */ public array $commands = []; - /** @var list */ + /** + * @var list + */ public array $tty = []; - /** @var list */ + /** + * @var list + */ private array $exitCodes; /** @@ -24,12 +30,15 @@ class MockProcessRunner implements ProcessRunner */ public function __construct( array $exitCodes = [0], - private string $errorOutput = '', - private bool $supportsTty = false, + private readonly string $errorOutput = '', + private readonly bool $supportsTty = false, ) { $this->exitCodes = $exitCodes; } + /** + * @param list $command + */ public function run(array $command, bool $tty = false): int { $this->commands[] = $command; diff --git a/tests/Unit/Mocks/RawTokenInput.php b/tests/Unit/Mocks/RawTokenInput.php index 08b0469..1065fbf 100644 --- a/tests/Unit/Mocks/RawTokenInput.php +++ b/tests/Unit/Mocks/RawTokenInput.php @@ -6,31 +6,17 @@ use Symfony\Component\Console\Input\ArrayInput; -/** - * Provides Symfony Console raw token access. - */ final class RawTokenInput extends ArrayInput { /** - * Stores raw input tokens. - * * @var list */ private array $tokens; - /** - * Stores the command-like first argument. - */ private ?string $firstArgument; /** - * Creates a raw token input. - * * @param list $tokens - * The raw input tokens without the Composer executable. - * - * @param string|null $firstArgument - * The command-like first argument to return. */ public function __construct(array $tokens, ?string $firstArgument = null) { @@ -40,25 +26,13 @@ public function __construct(array $tokens, ?string $firstArgument = null) parent::__construct($tokens); } - /** - * Returns the first command-like argument. - * - * @return string|null - * Returns the configured first argument. - */ public function getFirstArgument(): ?string { return $this->firstArgument; } /** - * Returns unparsed raw tokens. - * - * @param bool $strip - * Whether to return only tokens after the command-like first argument. - * * @return list - * Returns raw input tokens. */ public function getRawTokens(bool $strip = false): array {