From d34e27154e49e9b70b64798aaafc6a5e18e5f06b Mon Sep 17 00:00:00 2001 From: DDEV User Date: Thu, 2 Oct 2025 19:18:28 -0400 Subject: [PATCH 1/6] Code standards. --- bin/polymer | 2 +- bin/polymer-robo-run.php | 46 +++++- composer.json | 13 +- src/Composer/Plugin.php | 2 +- src/Environment/AcquiaEnvironmentDetector.php | 4 +- src/Environment/DDEVEnvironment.php | 2 +- .../EnvironemntDetectorInterface.php | 4 +- src/Environment/EnvironmentDetectorBase.php | 2 +- .../PantheonEnvironmentDetector.php | 4 +- .../Template/GitHubWorkflows/ComposerDiff.php | 4 +- .../Commands/Artifact/BuildPrepareCommand.php | 4 +- .../Artifact/BuildSanitizeCommand.php | 4 +- src/Robo/Commands/Artifact/CompileCommand.php | 4 +- .../Artifact/ComposerInstallCommand.php | 4 +- src/Robo/Commands/Artifact/DeployCommand.php | 10 +- src/Robo/Commands/Docs/MkCommand.php | 6 +- src/Robo/Commands/Polymer/InstallCommand.php | 6 +- src/Robo/Commands/Source/BuildCopyCommand.php | 4 +- src/Robo/Commands/Source/FrontendCommand.php | 4 +- .../Commands/Template/TemplateCommand.php | 16 +- .../Validate/ComposerValidateCommand.php | 4 +- src/Robo/Common/ArrayManipulator.php | 2 +- src/Robo/Config/ConfigAwareTrait.php | 2 +- src/Robo/Config/ConfigManager.php | 6 +- src/Robo/Config/ConfigStack.php | 2 +- src/Robo/Config/ConfigStackInterface.php | 2 +- src/Robo/Config/PolymerConfig.php | 4 +- src/Robo/Config/YamlConfigProcessor.php | 4 +- src/Robo/ConsoleApplication.php | 2 +- .../Contract/ClassLoaderAwareInterface.php | 2 +- .../Contract/CommandInvokerAwareInterface.php | 4 +- src/Robo/Discovery/BuildRecipesDiscovery.php | 4 +- src/Robo/Discovery/CommandsDiscovery.php | 4 +- src/Robo/Discovery/CoreClassDiscovery.php | 68 +++++++++ src/Robo/Discovery/DiscoveryBase.php | 2 +- src/Robo/Discovery/DiscoveryInterface.php | 2 +- .../Discovery/ExtensionClassDiscovery.php | 65 ++++++++ src/Robo/Discovery/ExtensionDiscovery.php | 140 +++++++++++++++--- src/Robo/Discovery/Plugin/PluginBase.php | 4 +- src/Robo/Discovery/Plugin/PluginInterface.php | 2 +- .../Discovery/Plugin/PluginManagerBase.php | 6 +- .../Plugin/PluginManagerInterface.php | 2 +- src/Robo/Discovery/PushRecipesDiscovery.php | 4 +- src/Robo/Event/AlterConfigContextsEvent.php | 2 +- src/Robo/Event/CollectConfigContextsEvent.php | 2 +- .../ExtensionConfigPriorityOverrideEvent.php | 2 +- src/Robo/Event/PolymerEvents.php | 2 +- src/Robo/Event/PostInvokeCommandEvent.php | 2 +- src/Robo/Event/PreInvokeCommandEvent.php | 2 +- .../BadConfigurationValueException.php | 2 +- src/Robo/Exceptions/PolymerException.php | 2 +- src/Robo/Extension/ExtensionData.php | 2 +- src/Robo/Extension/PolymerExtensionBase.php | 2 +- .../Extension/PolymerExtensionInterface.php | 2 +- src/Robo/Polymer.php | 81 ++++++---- src/Robo/Services/ClassLoaderAwareTrait.php | 2 +- src/Robo/Services/CommandInfoAlterer.php | 4 +- src/Robo/Services/CommandInvoker.php | 10 +- .../Services/CommandInvokerAwareTrait.php | 2 +- src/Robo/Services/CommandInvokerInterface.php | 2 +- .../EventSubscriber/ConfigContextProvider.php | 6 +- .../EventSubscriber/ConfigInjector.php | 4 +- .../EventSubscriber/LoadConfiguration.php | 6 +- .../SetGlobalOptionsPostInvoke.php | 8 +- src/Robo/Services/TaskableServiceBase.php | 2 +- .../Services/TaskableServiceInterface.php | 4 +- src/Robo/Services/Template/Generator.php | 6 +- src/Robo/Tasks/Command.php | 2 +- src/Robo/Tasks/TaskBase.php | 14 +- src/Robo/Tasks/ToggleableSymfonyCommand.php | 6 +- .../GitHub/GitHubWorkflowTemplateBase.php | 4 +- src/Robo/Template/Template.php | 4 +- src/Robo/Template/TemplateInterface.php | 4 +- src/Robo/Template/TemplatePluginManager.php | 4 +- src/Robo/Template/Token.php | 2 +- src/Robo/Utility/CommandHelper.php | 2 +- 76 files changed, 492 insertions(+), 191 deletions(-) rename src/{Polymer => }/Plugin/Template/GitHubWorkflows/ComposerDiff.php (85%) create mode 100644 src/Robo/Discovery/CoreClassDiscovery.php create mode 100644 src/Robo/Discovery/ExtensionClassDiscovery.php diff --git a/bin/polymer b/bin/polymer index e9fdc3e..4bd1fca 100755 --- a/bin/polymer +++ b/bin/polymer @@ -1,4 +1,4 @@ #!/usr/bin/env php start(); @@ -24,12 +46,28 @@ } // Initialize configuration. -/** @var string $repoRoot */ -$repoRoot = find_repo_root(); +/** @var string|null $repoRoot */ +$repoRoot = null; +for ($i = 0; $i < 10; $i++) { + if (file_exists($cwd . '/.polymer')) { + $repoRoot = $cwd; + break; + } + $parent = dirname($cwd); + if ($parent === $cwd) { + // We have reached the root of the filesystem. + break; + } + $cwd = $parent; +} + +if (!$repoRoot) { + throw new \Exception("Could not find .polymer directory in this or any parent directory. cwd is $cwd; __DIR__ is " . __DIR__); +} // Execute command. -// @phpstan-ignore variable.undefined $polymer = new Polymer($repoRoot, $input, $output, $classLoader); +$polymer->boot(); $status_code = (int) $polymer->run($input, $output); // Stop timer. diff --git a/composer.json b/composer.json index 9e1e0c6..7602335 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,8 @@ }, "require": { "php": ">=8.1", + "oomphinc/composer-installers-extender": "*", + "mnsami/composer-custom-directory-installer": "*", "composer-plugin-api": "^2.0", "composer-runtime-api": "^2.0", "consolidation/robo": "^4 || ^5", @@ -22,19 +24,22 @@ "prefer-stable": true, "autoload": { "psr-4": { - "DigitalPolygon\\Polymer\\": "src/", + "DigitalPolygon\\Polymer\\Core\\": "src/", "DigitalPolygon\\PolymerTest\\": "tests/" } }, "config": { "sort-packages": true, "allow-plugins": { - "phpstan/extension-installer": true, - "phpro/grumphp-shim": true + "composer/installers": true, + "mnsami/composer-custom-directory-installer": true, + "oomphinc/composer-installers-extender": true, + "phpro/grumphp-shim": true, + "phpstan/extension-installer": true } }, "extra": { - "class": "DigitalPolygon\\Polymer\\Composer\\Plugin" + "class": "DigitalPolygon\\Polymer\\Core\\Composer\\Plugin" }, "bin": [ "bin/polymer" diff --git a/src/Composer/Plugin.php b/src/Composer/Plugin.php index 8e70ba9..394283c 100644 --- a/src/Composer/Plugin.php +++ b/src/Composer/Plugin.php @@ -1,6 +1,6 @@ relativeNamespace = trim($relativeNamespace, '\\'); + return $this; + } + + public function getClasses() + { + $classes = []; + $psr4Prefixes = $this->classLoader->getPrefixesPsr4(); + $relativeSearchNamespacePath = DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $this->relativeNamespace); + $namespaceInfo = [ + 'namespace' => 'DigitalPolygon\Polymer\Core', + 'path' => $this->polymerFilesRoot . '/core', + ]; + $extensionNamespacePrefix = $namespaceInfo['namespace'] . '\\'; + if (isset($psr4Prefixes[$extensionNamespacePrefix])) { + $directories = array_map(function ($directory) use ($relativeSearchNamespacePath) { + return $directory . $relativeSearchNamespacePath; + }, $psr4Prefixes[$extensionNamespacePrefix]); + $directories = array_filter($directories, 'is_dir'); + if ($directories) { + $fileIterator = $this->search($directories, $this->searchPattern); + foreach ($fileIterator as $file) { + $relativePath = DIRECTORY_SEPARATOR . $file->getRelativePathname(); + $relativePathNamespace = str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], trim($relativePath, DIRECTORY_SEPARATOR)); + $class = $extensionNamespacePrefix . $this->relativeNamespace . '\\' . $relativePathNamespace; + $classPath = $namespaceInfo['path'] . $relativeSearchNamespacePath . $relativePath; + $classes[$classPath] = $class; + } + } + } + return $classes; + } + + protected function search(array $directories, string $pattern): Finder + { + $finder = new Finder(); + $finder->files() + ->name($pattern) + ->in($directories); + + return $finder; + } + + public function getFile($class) + { + return $this->classLoader->findFile($class); + } +} diff --git a/src/Robo/Discovery/DiscoveryBase.php b/src/Robo/Discovery/DiscoveryBase.php index 7592057..7dc785a 100644 --- a/src/Robo/Discovery/DiscoveryBase.php +++ b/src/Robo/Discovery/DiscoveryBase.php @@ -1,6 +1,6 @@ classLoader->getPrefixesPsr4(); + $relativeSearchNamespacePath = DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $this->relativeNamespace); + foreach ($this->namespaceInfo as $namespaceInfo) { + $extensionNamespacePrefix = $namespaceInfo['namespace'] . '\\'; + if (isset($psr4Prefixes[$extensionNamespacePrefix])) { + $directories = array_map(function ($directory) use ($relativeSearchNamespacePath) { + return $directory . $relativeSearchNamespacePath; + }, $psr4Prefixes[$extensionNamespacePrefix]); + $directories = array_filter($directories, 'is_dir'); + if ($directories) { + $fileIterator = $this->search($directories, $this->searchPattern); + foreach ($fileIterator as $file) { + $relativePath = DIRECTORY_SEPARATOR . $file->getRelativePathname(); + $relativePathNamespace = str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], trim($relativePath, DIRECTORY_SEPARATOR)); + $class = $extensionNamespacePrefix . $this->relativeNamespace . '\\' . $relativePathNamespace; + $classPath = $namespaceInfo['path'] . $relativeSearchNamespacePath . $relativePath; + $classes[$classPath] = $class; + } + } + } + } + return $classes; + } + + /** + * {@inheritdoc} + */ + public function getFile($class) + { + return $this->classLoader->findFile($class); + } + + protected function search(array $directories, string $pattern): Finder + { + $finder = new Finder(); + $finder->files() + ->name($pattern) + ->in($directories); + + return $finder; + } +} diff --git a/src/Robo/Discovery/ExtensionDiscovery.php b/src/Robo/Discovery/ExtensionDiscovery.php index 0831c05..7d87e32 100644 --- a/src/Robo/Discovery/ExtensionDiscovery.php +++ b/src/Robo/Discovery/ExtensionDiscovery.php @@ -1,27 +1,101 @@ polymerFilesRoot . '/plugins', + ]; + + foreach ($pluginDirectories as $pluginDirectory) { + $recursiveIterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($pluginDirectory, \FilesystemIterator::SKIP_DOTS) + ); + foreach ($recursiveIterator as $dir) { + if ($dir->isDir()) { + $dirFiles = scandir($dir->getPathname()); + foreach ($dirFiles as $dirFile) { + if (str_ends_with($dirFile, '.poly_info.yml')) { + $extensionName = str_replace('.poly_info.yml', '', $dirFile); + $extensions[$extensionName] = $dir->getPathname(); + } + } + } + } + } + + return $extensions; + } + + /** + * Get enabled extensions. + * + * @return array + */ + protected function getEnabledExtensions(): array { - $this->extensionInfoDiscovery = new RelativeNamespaceDiscovery($classLoader); - $this->extensionHookDiscovery = new RelativeNamespaceDiscovery($classLoader); + $enabledExtensions = []; + $configFile = $this->polymerFilesRoot . '/config.yml'; + if (file_exists($configFile)) { + $config = Yaml::parseFile($configFile); + if (isset($config['enabled_extensions']) && is_array($config['enabled_extensions'])) { + $enabledExtensions = $config['enabled_extensions']; + } + } - $this->extensionInfoDiscovery->setRelativeNamespace('Polymer'); - $this->extensionInfoDiscovery->setSearchPattern('ExtensionInfo.php'); + if (!empty($enabledExtensions)) { + $allExtensions = $this->findExtensions(); + $enabledExtensions = array_diff_key($allExtensions, $enabledExtensions); + } + + return $enabledExtensions; + } - $this->extensionHookDiscovery->setRelativeNamespace('Polymer\Plugin\Hooks'); - $this->extensionHookDiscovery->setSearchPattern('*Hook.php'); + public function getExtensionNamespaceInfo(): array + { + $extensionNamespaces = []; + $extensions = $this->getEnabledExtensions(); + $namespacePrefix = 'DigitalPolygon\\Polymer\\'; + foreach ($extensions as $extensionId => $extensionDir) { + $namespace = $namespacePrefix . $extensionId; + $srcDir = $extensionDir . '/src'; + if (is_dir($srcDir)) { + $extensionNamespaces[$extensionId] = [ + 'namespace' => $namespace, + 'path' => $srcDir, + ]; + } + } + return $extensionNamespaces; + } + + public function registerExtensionNamespaces(): void + { + $namespaces = $this->getExtensionNamespaceInfo(); + foreach ($namespaces as $extensionId => $namespaceInfo) { + $this->classLoader->addPsr4($namespaceInfo['namespace'] . '\\', $namespaceInfo['path']); + } } /** @@ -30,25 +104,26 @@ public function __construct(ClassLoader $classLoader) public function getExtensions(): array { $extensions = []; - $classes = $this->extensionInfoDiscovery->getClasses(); + $namespaceInfos = $this->getExtensionNamespaceInfo(); + foreach ($namespaceInfos as $extensionId => $namespaceInfo) { + $namespaceInfos[$extensionId]['extension_class'] = $namespaceInfo['namespace'] . '\\' . 'ExtensionInfo'; + } - foreach ($classes as $class) { - $extensionReflection = new \ReflectionClass($class); + foreach ($namespaceInfos as $extensionId => $namespaceInfo) { + $extensionReflection = new \ReflectionClass($namespaceInfo['extension_class']); if ($extensionReflection->implementsInterface(PolymerExtensionInterface::class)) { /** @var PolymerExtensionInterface $extensionInstance */ $extensionInstance = $extensionReflection->newInstanceWithoutConstructor(); - $extensionName = $extensionInstance->getExtensionName(); $extensionFile = $extensionReflection->getFileName(); $serviceProvider = $extensionInstance->getInstantiatedServiceProvider(); $configFile = $extensionInstance->getDefaultConfigFile(); - $extensionRoot = dirname($extensionFile, 3); + $extensionRoot = realpath($namespaceInfo['path'] . '/../'); if (!$configFile) { // Since extensions live in the relative Polymer namespace, and // developers typically provide a src directory in the root of // their extension, we assume that 3 levels back from the // extension definition is the root of the extension. - $extensionRoot = realpath(dirname($extensionFile, 3)); $defaultConfigFile = $extensionRoot . '/config/default.yml'; if (file_exists($defaultConfigFile)) { $configFile = $defaultConfigFile; @@ -56,15 +131,15 @@ public function getExtensions(): array } if (!$serviceProvider) { // Service providers always live in the same namespace as the extension definition. - $serviceProviderClassName = static::camelCase($extensionName) . 'ServiceProvider'; + $serviceProviderClassName = static::camelCase($extensionId) . 'ServiceProvider'; $serviceProviderClass = $extensionReflection->getNamespaceName() . '\\' . $serviceProviderClassName; if (class_exists($serviceProviderClass)) { $serviceProvider = new $serviceProviderClass(); } } - $extensions[$extensionName] = new ExtensionData( + $extensions[$extensionId] = new ExtensionData( $extensionInstance, - $class, + $extensionInstance::class, $extensionFile, $extensionRoot, $configFile, @@ -77,11 +152,34 @@ public function getExtensions(): array } /** + * Get hook classes for all enabled extensions. + * * @return array */ public function getExtensionHooks(): array { - return $this->extensionHookDiscovery->getClasses(); + $extensionClassDiscovery = new ExtensionClassDiscovery( + $this->classLoader, + $this->getExtensionNamespaceInfo(), + 'Plugin\\Hooks' + ); + $extensionClassDiscovery->setSearchPattern('*Hook.php'); + return $extensionClassDiscovery->getClasses(); + } + + /** + * Get command classes for all enabled extensions. + * @return array + */ + public function getExtensionCommands(): array + { + $extensionClassDiscovery = new ExtensionClassDiscovery( + $this->classLoader, + $this->getExtensionNamespaceInfo(), + 'Plugin\\Commands' + ); + $extensionClassDiscovery->setSearchPattern('*Commands.php'); + return $extensionClassDiscovery->getClasses(); } /** diff --git a/src/Robo/Discovery/Plugin/PluginBase.php b/src/Robo/Discovery/Plugin/PluginBase.php index 2dc180e..93c8edf 100644 --- a/src/Robo/Discovery/Plugin/PluginBase.php +++ b/src/Robo/Discovery/Plugin/PluginBase.php @@ -1,8 +1,8 @@ */ protected array $extensions; + /** + * @var string The root directory where Polymer files are stored (core, extensions, boot configuration). + */ + protected string $polymerFilesRoot; + /** * Object constructor. * @@ -97,6 +104,11 @@ public function __construct( protected OutputInterface $output, protected ClassLoader $classLoader ) { + $this->polymerFilesRoot = $this->repoRoot . '/.polymer'; + } + + public function boot(): void + { $this ->setupBootContainer() ->createApplication() @@ -127,10 +139,10 @@ protected function discoverExtensions(): self { /** @var ExtensionDiscovery $extensionDiscovery */ $extensionDiscovery = $this->bootContainer->get('extensionDiscovery'); + $extensionDiscovery->registerExtensionNamespaces(); $this->extensions = $extensionDiscovery->getExtensions(); - $this->hooks = $extensionDiscovery->getExtensionHooks(); - $commandsDiscovery = new CommandsDiscovery(); - $this->commands = $commandsDiscovery->getDefinitions(); + $this->hooks = $this->getCoreHooks() + $extensionDiscovery->getExtensionHooks(); + $this->commands = $this->getCoreCommands() + $extensionDiscovery->getExtensionCommands(); return $this; } @@ -139,7 +151,9 @@ protected function setupBootContainer(): self { $this->bootContainer = new Container(); $this->bootContainer->addShared('extensionDiscovery', ExtensionDiscovery::class) - ->addArgument($this->classLoader); + ->addArgument($this->classLoader) + ->addArgument($this->repoRoot) + ->addArgument($this->polymerFilesRoot); return $this; } @@ -240,7 +254,6 @@ protected function configureRunner(): self $this->runner = new RoboRunner(); $this->runner->setClassLoader($this->classLoader); $this->runner->setContainer($this->getContainer()); - $this->runner->setRelativePluginNamespace('Polymer\Plugin'); $this->runner->setSelfUpdateRepository(self::REPOSITORY); return $this; @@ -265,8 +278,6 @@ public function run(InputInterface $input, OutputInterface $output): int // Compile the configuration. /** @var \Robo\Application $application */ $application = $this->getContainer()->get('application'); - /** @var ExtensionDiscovery $extensionDiscovery */ - $extensionDiscovery = $this->getContainer()->get('extensionDiscovery'); $mergedCommandsAndHooks = array_merge($this->commands, $this->hooks); return $this->runner->run($input, $output, $application, $mergedCommandsAndHooks); } @@ -282,4 +293,20 @@ protected function collectServiceProviders(): array } return array_filter($serviceProviders); } + + protected function getCoreHooks(): array + { + $coreClassDiscovery = new CoreClassDiscovery($this->classLoader, $this->polymerFilesRoot); + $coreClassDiscovery->setSearchPattern('*Hook.php'); + $coreClassDiscovery->setRelativeNamespace('Robo\\Hooks'); + return $coreClassDiscovery->getClasses(); + } + + protected function getCoreCommands(): array + { + $coreClassDiscovery = new CoreClassDiscovery($this->classLoader, $this->polymerFilesRoot); + $coreClassDiscovery->setSearchPattern('*Command.php'); + $coreClassDiscovery->setRelativeNamespace('Robo\\Commands'); + return $coreClassDiscovery->getClasses(); + } } diff --git a/src/Robo/Services/ClassLoaderAwareTrait.php b/src/Robo/Services/ClassLoaderAwareTrait.php index b250e02..53552da 100644 --- a/src/Robo/Services/ClassLoaderAwareTrait.php +++ b/src/Robo/Services/ClassLoaderAwareTrait.php @@ -1,6 +1,6 @@ Date: Fri, 3 Oct 2025 14:39:37 -0400 Subject: [PATCH 2/6] Fixes plugin error. --- config/deploy-exclude.txt | 3 + .../Commands/Template/TemplateCommand.php | 1 - src/Robo/Discovery/ClassDiscoveryTrait.php | 74 +++++++++++++++++++ src/Robo/Discovery/CoreClassDiscovery.php | 45 ++--------- .../Discovery/ExtensionClassDiscovery.php | 47 ++---------- src/Robo/Discovery/ExtensionDiscovery.php | 7 +- .../Discovery/Plugin/PluginManagerBase.php | 10 ++- src/Robo/Discovery/PluginClassDiscovery.php | 40 ++++++++++ src/Robo/Template/TemplatePluginManager.php | 2 +- 9 files changed, 143 insertions(+), 86 deletions(-) create mode 100644 src/Robo/Discovery/ClassDiscoveryTrait.php create mode 100644 src/Robo/Discovery/PluginClassDiscovery.php diff --git a/config/deploy-exclude.txt b/config/deploy-exclude.txt index f2f8423..c53bbf9 100644 --- a/config/deploy-exclude.txt +++ b/config/deploy-exclude.txt @@ -40,3 +40,6 @@ local.site.yml node_modules /vendor local.polymer.yml +/scratch +/.polymer/core +/.polymer/plugins/contrib diff --git a/src/Robo/Commands/Template/TemplateCommand.php b/src/Robo/Commands/Template/TemplateCommand.php index ea7ea1d..977966f 100644 --- a/src/Robo/Commands/Template/TemplateCommand.php +++ b/src/Robo/Commands/Template/TemplateCommand.php @@ -12,7 +12,6 @@ use Consolidation\AnnotatedCommand\CommandData; use Consolidation\AnnotatedCommand\Hooks\HookManager; use Consolidation\OutputFormatters\StructuredData\RowsOfFields; -use DigitalPolygon\Polymer\Core\Plugin\Template\GitHubWorkflows\ComposerDiff; use DigitalPolygon\Polymer\Core\Robo\Exceptions\PolymerException; use DigitalPolygon\Polymer\Core\Robo\Services\Template\Generator; use DigitalPolygon\Polymer\Core\Robo\Tasks\TaskBase; diff --git a/src/Robo/Discovery/ClassDiscoveryTrait.php b/src/Robo/Discovery/ClassDiscoveryTrait.php new file mode 100644 index 0000000..94cc779 --- /dev/null +++ b/src/Robo/Discovery/ClassDiscoveryTrait.php @@ -0,0 +1,74 @@ +classLoader->getPrefixesPsr4(); + + if (isset($prefixes[$namespacePrefix])) { + $relativeNamespacePath = $this->convertRelativeNamespaceToPath($relativeNamespace); + $candidateDirectories = array_map(function ($directory) use ($relativeNamespacePath) { + return realpath($directory . $relativeNamespacePath); + }, $prefixes[$namespacePrefix]); + $candidateDirectories = array_filter($candidateDirectories); + if (!empty($candidateDirectories)) { + foreach ($this->search($candidateDirectories, $pattern) as $file) { + $relativePath = DIRECTORY_SEPARATOR . $file->getRelativePathname(); + $relativePathNamespace = $this->convertPathToRelativeNamespace($relativePath); + $class = $namespacePrefix . $relativeNamespace . '\\' . $relativePathNamespace; + $classes[$file->getPathname()] = $class; + } + } + } + + return $classes; + } + + protected function convertRelativeNamespaceToPath(string $relativeNamespace): string + { + return DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, trim($relativeNamespace, '\\')); + } + + protected function convertPathToRelativeNamespace(string $path): string + { + return str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], trim($path, DIRECTORY_SEPARATOR)); + } + + protected static function search(array $directories, string $pattern): Finder + { + $finder = new Finder(); + $finder->files() + ->name($pattern) + ->in($directories); + + return $finder; + } + + /** + * {@inheritdoc} + */ + public function getFile($class) + { + return $this->classLoader->findFile($class); + } +} diff --git a/src/Robo/Discovery/CoreClassDiscovery.php b/src/Robo/Discovery/CoreClassDiscovery.php index c893be3..b6ca73d 100644 --- a/src/Robo/Discovery/CoreClassDiscovery.php +++ b/src/Robo/Discovery/CoreClassDiscovery.php @@ -8,6 +8,10 @@ class CoreClassDiscovery extends AbstractClassDiscovery { + use ClassDiscoveryTrait; + + public const CORE_NAMESPACE_PREFIX = 'DigitalPolygon\\Polymer\\Core\\'; + protected string $relativeNamespace; public function __construct( @@ -24,45 +28,6 @@ public function setRelativeNamespace(string $relativeNamespace): self public function getClasses() { - $classes = []; - $psr4Prefixes = $this->classLoader->getPrefixesPsr4(); - $relativeSearchNamespacePath = DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $this->relativeNamespace); - $namespaceInfo = [ - 'namespace' => 'DigitalPolygon\Polymer\Core', - 'path' => $this->polymerFilesRoot . '/core', - ]; - $extensionNamespacePrefix = $namespaceInfo['namespace'] . '\\'; - if (isset($psr4Prefixes[$extensionNamespacePrefix])) { - $directories = array_map(function ($directory) use ($relativeSearchNamespacePath) { - return $directory . $relativeSearchNamespacePath; - }, $psr4Prefixes[$extensionNamespacePrefix]); - $directories = array_filter($directories, 'is_dir'); - if ($directories) { - $fileIterator = $this->search($directories, $this->searchPattern); - foreach ($fileIterator as $file) { - $relativePath = DIRECTORY_SEPARATOR . $file->getRelativePathname(); - $relativePathNamespace = str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], trim($relativePath, DIRECTORY_SEPARATOR)); - $class = $extensionNamespacePrefix . $this->relativeNamespace . '\\' . $relativePathNamespace; - $classPath = $namespaceInfo['path'] . $relativeSearchNamespacePath . $relativePath; - $classes[$classPath] = $class; - } - } - } - return $classes; - } - - protected function search(array $directories, string $pattern): Finder - { - $finder = new Finder(); - $finder->files() - ->name($pattern) - ->in($directories); - - return $finder; - } - - public function getFile($class) - { - return $this->classLoader->findFile($class); + return $this->getNamespaceClasses(self::CORE_NAMESPACE_PREFIX, $this->relativeNamespace, $this->searchPattern); } } diff --git a/src/Robo/Discovery/ExtensionClassDiscovery.php b/src/Robo/Discovery/ExtensionClassDiscovery.php index 795d494..f503fba 100644 --- a/src/Robo/Discovery/ExtensionClassDiscovery.php +++ b/src/Robo/Discovery/ExtensionClassDiscovery.php @@ -8,11 +8,14 @@ class ExtensionClassDiscovery extends AbstractClassDiscovery { + use ClassDiscoveryTrait; + public function __construct( - protected ClassLoader $classLoader, - protected array $namespaceInfo, + ClassLoader $classLoader, + protected array $extensionNamespaceInfo, protected string $relativeNamespace, ) { + $this->classLoader = $classLoader; } /** @@ -21,45 +24,9 @@ public function __construct( public function getClasses() { $classes = []; - $psr4Prefixes = $this->classLoader->getPrefixesPsr4(); - $relativeSearchNamespacePath = DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $this->relativeNamespace); - foreach ($this->namespaceInfo as $namespaceInfo) { - $extensionNamespacePrefix = $namespaceInfo['namespace'] . '\\'; - if (isset($psr4Prefixes[$extensionNamespacePrefix])) { - $directories = array_map(function ($directory) use ($relativeSearchNamespacePath) { - return $directory . $relativeSearchNamespacePath; - }, $psr4Prefixes[$extensionNamespacePrefix]); - $directories = array_filter($directories, 'is_dir'); - if ($directories) { - $fileIterator = $this->search($directories, $this->searchPattern); - foreach ($fileIterator as $file) { - $relativePath = DIRECTORY_SEPARATOR . $file->getRelativePathname(); - $relativePathNamespace = str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], trim($relativePath, DIRECTORY_SEPARATOR)); - $class = $extensionNamespacePrefix . $this->relativeNamespace . '\\' . $relativePathNamespace; - $classPath = $namespaceInfo['path'] . $relativeSearchNamespacePath . $relativePath; - $classes[$classPath] = $class; - } - } - } + foreach ($this->extensionNamespaceInfo as $namespaceInfo) { + $classes = array_merge($classes, $this->getNamespaceClasses($namespaceInfo['namespace'] . '\\', $this->relativeNamespace, $this->searchPattern)); } return $classes; } - - /** - * {@inheritdoc} - */ - public function getFile($class) - { - return $this->classLoader->findFile($class); - } - - protected function search(array $directories, string $pattern): Finder - { - $finder = new Finder(); - $finder->files() - ->name($pattern) - ->in($directories); - - return $finder; - } } diff --git a/src/Robo/Discovery/ExtensionDiscovery.php b/src/Robo/Discovery/ExtensionDiscovery.php index 7d87e32..e193b5d 100644 --- a/src/Robo/Discovery/ExtensionDiscovery.php +++ b/src/Robo/Discovery/ExtensionDiscovery.php @@ -65,8 +65,13 @@ protected function getEnabledExtensions(): array } if (!empty($enabledExtensions)) { + $enabledExtensions = array_flip($enabledExtensions); $allExtensions = $this->findExtensions(); - $enabledExtensions = array_diff_key($allExtensions, $enabledExtensions); + foreach ($enabledExtensions as $extension => $delta) { + if (isset($allExtensions[$extension])) { + $enabledExtensions[$extension] = $allExtensions[$extension]; + } + } } return $enabledExtensions; diff --git a/src/Robo/Discovery/Plugin/PluginManagerBase.php b/src/Robo/Discovery/Plugin/PluginManagerBase.php index e886d8c..f4402dc 100644 --- a/src/Robo/Discovery/Plugin/PluginManagerBase.php +++ b/src/Robo/Discovery/Plugin/PluginManagerBase.php @@ -3,6 +3,7 @@ namespace DigitalPolygon\Polymer\Core\Robo\Discovery\Plugin; use DigitalPolygon\Polymer\Core\Robo\Contract\ClassLoaderAwareInterface; +use DigitalPolygon\Polymer\Core\Robo\Discovery\PluginClassDiscovery; use DigitalPolygon\Polymer\Core\Robo\Services\ClassLoaderAwareTrait; use League\Container\Argument\ResolvableArgument; use League\Container\Container; @@ -18,7 +19,7 @@ abstract class PluginManagerBase implements PluginManagerInterface, ContainerAwa use ContainerAwareTrait; use ClassLoaderAwareTrait; - protected RelativeNamespaceDiscovery $discovery; + protected PluginClassDiscovery $discovery; protected string $relativeNamespace; protected string $pluginInterface; @@ -36,8 +37,11 @@ public function configureDiscovery(): void $this->configureContainer(); - $this->discovery = new RelativeNamespaceDiscovery($this->classLoader); - $this->discovery->setRelativeNamespace($this->relativeNamespace); + $this->discovery = new PluginClassDiscovery( + $this->getContainer()->get('classLoader'), + $this->getContainer()->get('extensionDiscovery')->getExtensionNamespaceInfo(), + $this->relativeNamespace + ); $classes = $this->discovery->getClasses(); $pluginClasses = array_filter($classes, fn ($class) => is_subclass_of($class, $this->pluginInterface)); diff --git a/src/Robo/Discovery/PluginClassDiscovery.php b/src/Robo/Discovery/PluginClassDiscovery.php new file mode 100644 index 0000000..8817119 --- /dev/null +++ b/src/Robo/Discovery/PluginClassDiscovery.php @@ -0,0 +1,40 @@ +classLoader = $classLoader; + $this->pluginRelativeNamespace = 'Plugin\\' . trim($relativeNamespace, '\\'); + } + + /** + * {@inheritdoc} + */ + public function getClasses(): array + { + $classes = []; + $prefixes = [ + 'DigitalPolygon\\Polymer\\Core\\', + ]; + foreach ($this->extensionNamespaceInfo as $namespaceInfo) { + $prefixes[] = $namespaceInfo['namespace'] . '\\'; + } + foreach ($prefixes as $prefix) { + $classes = array_merge($classes, $this->getNamespaceClasses($prefix, $this->pluginRelativeNamespace, $this->searchPattern)); + } + return $classes; + } +} diff --git a/src/Robo/Template/TemplatePluginManager.php b/src/Robo/Template/TemplatePluginManager.php index 0cea639..e92ada3 100644 --- a/src/Robo/Template/TemplatePluginManager.php +++ b/src/Robo/Template/TemplatePluginManager.php @@ -8,7 +8,7 @@ class TemplatePluginManager extends PluginManagerBase { public function setDiscoveryData(): void { - $this->relativeNamespace = 'Polymer/Plugin/Template'; + $this->relativeNamespace = 'Template'; $this->pluginInterface = TemplateInterface::class; } From fb46a09b1149229b46ca208133c22ae4883ecb86 Mon Sep 17 00:00:00 2001 From: Les Peabody Date: Mon, 1 Jun 2026 21:40:42 -0400 Subject: [PATCH 3/6] Discover plugins regardless of install layout; add plugin enable/disable commands findExtensions() previously only located plugins installed as symlinks (path repositories); real directories were silently missed. A naive recursive marker search instead surfaced vendored copies of other plugins (e.g. polymer_drupal inside polymer_pantheon_drupal/vendor). Replace it with a fixed-depth glob for the .poly_info.yml marker so symlinked and real-directory plugins are both discovered without descending into a plugin's own vendor/ tree. Also: - Harden enabled-extension resolution: drop entries enabled in config but not installed on disk, and guard against a missing .polymer/plugins directory. - Add plugin:list / plugin:enable / plugin:disable commands to toggle enabled_extensions, building the AC-1 toggle on top of existing gating. - Fix a stale PolymerConfig namespace import that left the unit suite red after the package reorganization. Refs PWT-114. Co-Authored-By: Claude Opus 4.8 --- src/Robo/Commands/Polymer/PluginCommand.php | 115 ++++++++++++ src/Robo/Discovery/ExtensionDiscovery.php | 142 ++++++++++++--- .../unit/ConfigurationContextsTest.php | 2 +- tests/phpunit/unit/ExtensionDiscoveryTest.php | 168 ++++++++++++++++++ 4 files changed, 402 insertions(+), 25 deletions(-) create mode 100644 src/Robo/Commands/Polymer/PluginCommand.php create mode 100644 tests/phpunit/unit/ExtensionDiscoveryTest.php diff --git a/src/Robo/Commands/Polymer/PluginCommand.php b/src/Robo/Commands/Polymer/PluginCommand.php new file mode 100644 index 0000000..bf1fbf8 --- /dev/null +++ b/src/Robo/Commands/Polymer/PluginCommand.php @@ -0,0 +1,115 @@ +getExtensionDiscovery(); + $installed = $discovery->getInstalledExtensions(); + $enabled = $discovery->getEnabledExtensionNames(); + + $rows = []; + foreach ($installed as $name => $path) { + $rows[$name] = [ + $name, + in_array($name, $enabled, true) ? 'Enabled' : 'Disabled', + $path, + ]; + } + + // Surface plugins enabled in configuration but not installed on disk so + // the misconfiguration is visible rather than silently ignored. + foreach ($enabled as $name) { + if (!isset($installed[$name])) { + $rows[$name] = [$name, 'Enabled (not installed)', '-']; + } + } + + if (empty($rows)) { + $this->say('No Polymer plugins are installed.'); + return; + } + + ksort($rows); + $this->io()->table(['Plugin', 'Status', 'Path'], array_values($rows)); + } + + /** + * Enable a Polymer plugin. + * + * @throws PolymerException + */ + #[Command(name: 'plugin:enable')] + #[Argument(name: 'name', description: 'The plugin machine name, e.g. polymer_drupal.')] + #[Usage(name: 'polymer plugin:enable polymer_drupal', description: 'Enable the polymer_drupal plugin.')] + public function enablePlugin(string $name): void + { + $discovery = $this->getExtensionDiscovery(); + + if (!isset($discovery->getInstalledExtensions()[$name])) { + throw new PolymerException("Plugin '$name' is not installed. Run 'plugin:list' to see installed plugins."); + } + + if ($discovery->enableExtension($name)) { + $this->say("Enabled plugin '$name'."); + } else { + $this->say("Plugin '$name' is already enabled."); + } + } + + /** + * Disable a Polymer plugin. + * + * @throws PolymerException + */ + #[Command(name: 'plugin:disable')] + #[Argument(name: 'name', description: 'The plugin machine name, e.g. polymer_drupal.')] + #[Usage(name: 'polymer plugin:disable polymer_drupal', description: 'Disable the polymer_drupal plugin.')] + public function disablePlugin(string $name): void + { + $discovery = $this->getExtensionDiscovery(); + + if ($discovery->disableExtension($name)) { + $this->say("Disabled plugin '$name'."); + } else { + $this->say("Plugin '$name' is not enabled."); + } + } + + /** + * Resolve the extension discovery service from the container. + * + * @throws PolymerException + */ + protected function getExtensionDiscovery(): ExtensionDiscovery + { + $container = $this->getContainer(); + if (!$container->has('extensionDiscovery')) { + throw new PolymerException('The extension discovery service is not available.'); + } + $discovery = $container->get('extensionDiscovery'); + if (!$discovery instanceof ExtensionDiscovery) { + throw new PolymerException('Unexpected extension discovery service.'); + } + return $discovery; + } +} diff --git a/src/Robo/Discovery/ExtensionDiscovery.php b/src/Robo/Discovery/ExtensionDiscovery.php index e193b5d..57a6830 100644 --- a/src/Robo/Discovery/ExtensionDiscovery.php +++ b/src/Robo/Discovery/ExtensionDiscovery.php @@ -21,6 +21,76 @@ public function __construct( ) { } + /** + * Get all extensions installed on disk, regardless of enabled state. + * + * @return array + * Extension id keyed to its installed directory. + */ + public function getInstalledExtensions(): array + { + return $this->findExtensions(); + } + + /** + * Determine whether an extension is enabled in configuration. + */ + public function isExtensionEnabled(string $extensionName): bool + { + return in_array($extensionName, $this->getEnabledExtensionNames(), true); + } + + /** + * Enable an extension by adding it to enabled_extensions. + * + * @return bool + * TRUE if configuration changed, FALSE if it was already enabled. + */ + public function enableExtension(string $extensionName): bool + { + $enabled = $this->getEnabledExtensionNames(); + if (in_array($extensionName, $enabled, true)) { + return false; + } + $enabled[] = $extensionName; + $this->writeEnabledExtensions($enabled); + return true; + } + + /** + * Disable an extension by removing it from enabled_extensions. + * + * @return bool + * TRUE if configuration changed, FALSE if it was not enabled. + */ + public function disableExtension(string $extensionName): bool + { + $enabled = $this->getEnabledExtensionNames(); + $key = array_search($extensionName, $enabled, true); + if ($key === false) { + return false; + } + unset($enabled[$key]); + $this->writeEnabledExtensions(array_values($enabled)); + return true; + } + + /** + * Persist the enabled_extensions list back to config.yml. + * + * @param array $enabled + */ + protected function writeEnabledExtensions(array $enabled): void + { + $configFile = $this->polymerFilesRoot . '/config.yml'; + $config = []; + if (file_exists($configFile)) { + $config = Yaml::parseFile($configFile) ?: []; + } + $config['enabled_extensions'] = array_values($enabled); + file_put_contents($configFile, Yaml::dump($config, 4, 2)); + } + protected function findExtensions(): array { $extensions = []; @@ -28,19 +98,24 @@ protected function findExtensions(): array $this->polymerFilesRoot . '/plugins', ]; + // A plugin's .poly_info.yml marker lives at the plugin root, one or two + // levels below plugins/ (e.g. plugins// or plugins/contrib//). + // Globbing at these fixed depths discovers plugins whether Composer + // installed them as symlinks (path repositories) or as real directories + // (dist/committed), while never descending into a plugin's own vendor/ + // tree — which would otherwise surface vendored copies of other plugins. + $markerPatterns = [ + '/*/*.poly_info.yml', + '/*/*/*.poly_info.yml', + ]; foreach ($pluginDirectories as $pluginDirectory) { - $recursiveIterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($pluginDirectory, \FilesystemIterator::SKIP_DOTS) - ); - foreach ($recursiveIterator as $dir) { - if ($dir->isDir()) { - $dirFiles = scandir($dir->getPathname()); - foreach ($dirFiles as $dirFile) { - if (str_ends_with($dirFile, '.poly_info.yml')) { - $extensionName = str_replace('.poly_info.yml', '', $dirFile); - $extensions[$extensionName] = $dir->getPathname(); - } - } + if (!is_dir($pluginDirectory)) { + continue; + } + foreach ($markerPatterns as $pattern) { + foreach (glob($pluginDirectory . $pattern) ?: [] as $markerFile) { + $extensionName = str_replace('.poly_info.yml', '', basename($markerFile)); + $extensions[$extensionName] = dirname($markerFile); } } } @@ -49,28 +124,47 @@ protected function findExtensions(): array } /** - * Get enabled extensions. + * Get the names of extensions enabled in configuration. * - * @return array + * This reflects the raw `enabled_extensions` list and does not guarantee + * that the named extensions are actually installed on disk. + * + * @return array */ - protected function getEnabledExtensions(): array + public function getEnabledExtensionNames(): array { - $enabledExtensions = []; $configFile = $this->polymerFilesRoot . '/config.yml'; if (file_exists($configFile)) { $config = Yaml::parseFile($configFile); if (isset($config['enabled_extensions']) && is_array($config['enabled_extensions'])) { - $enabledExtensions = $config['enabled_extensions']; + return array_values($config['enabled_extensions']); } } - if (!empty($enabledExtensions)) { - $enabledExtensions = array_flip($enabledExtensions); - $allExtensions = $this->findExtensions(); - foreach ($enabledExtensions as $extension => $delta) { - if (isset($allExtensions[$extension])) { - $enabledExtensions[$extension] = $allExtensions[$extension]; - } + return []; + } + + /** + * Get enabled extensions that are installed on disk. + * + * Extensions enabled in configuration but not installed are dropped, so + * the result only ever contains extensions that can actually be loaded. + * + * @return array + * Extension id keyed to its installed directory. + */ + protected function getEnabledExtensions(): array + { + $enabledNames = $this->getEnabledExtensionNames(); + if (empty($enabledNames)) { + return []; + } + + $installed = $this->findExtensions(); + $enabledExtensions = []; + foreach ($enabledNames as $extensionName) { + if (isset($installed[$extensionName])) { + $enabledExtensions[$extensionName] = $installed[$extensionName]; } } diff --git a/tests/phpunit/unit/ConfigurationContextsTest.php b/tests/phpunit/unit/ConfigurationContextsTest.php index 725c016..ceccb6f 100644 --- a/tests/phpunit/unit/ConfigurationContextsTest.php +++ b/tests/phpunit/unit/ConfigurationContextsTest.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase; use Consolidation\Config\Config; -use DigitalPolygon\Polymer\Robo\Config\PolymerConfig; +use DigitalPolygon\Polymer\Core\Robo\Config\PolymerConfig; class ConfigurationContextsTest extends TestCase { diff --git a/tests/phpunit/unit/ExtensionDiscoveryTest.php b/tests/phpunit/unit/ExtensionDiscoveryTest.php new file mode 100644 index 0000000..b9e0d54 --- /dev/null +++ b/tests/phpunit/unit/ExtensionDiscoveryTest.php @@ -0,0 +1,168 @@ +repoRoot = sys_get_temp_dir() . '/polymer-ext-' . uniqid(); + $this->polymerRoot = $this->repoRoot . '/.polymer'; + + // Plugins installed both as symlinks (path repositories) and as a real + // directory (dist/committed), so discovery is covered for both layouts. + $this->installPlugin('foo'); + $this->installPlugin('bar'); + $this->installPlugin('baz', false); + + // Enable foo plus a "ghost" that is referenced but not installed. + $this->writeConfig(['foo', 'ghost']); + } + + protected function tearDown(): void + { + $this->deleteRecursive($this->repoRoot); + parent::tearDown(); + } + + protected function discovery(): ExtensionDiscovery + { + return new ExtensionDiscovery(new ClassLoader(), $this->repoRoot, $this->polymerRoot); + } + + protected function installPlugin(string $name, bool $asSymlink = true): void + { + if (!$asSymlink) { + // Real directory under plugins/custom (e.g. dist install or a + // committed plugin), not a symlink. + $dir = $this->polymerRoot . '/plugins/custom/' . $name; + mkdir($dir . '/src', 0777, true); + file_put_contents($dir . '/' . $name . '.poly_info.yml', "name: $name\n"); + return; + } + + // Mirror the path-repository layout: the plugin source lives elsewhere + // and is symlinked under .polymer/plugins/contrib. + $source = $this->repoRoot . '/sources/' . $name; + mkdir($source . '/src', 0777, true); + file_put_contents($source . '/' . $name . '.poly_info.yml', "name: $name\n"); + + $contrib = $this->polymerRoot . '/plugins/contrib'; + if (!is_dir($contrib)) { + mkdir($contrib, 0777, true); + } + symlink($source, $contrib . '/' . $name); + } + + /** + * @param array $enabled + */ + protected function writeConfig(array $enabled): void + { + file_put_contents($this->polymerRoot . '/config.yml', Yaml::dump(['enabled_extensions' => $enabled], 4, 2)); + } + + protected function deleteRecursive(string $path): void + { + // Unlink symlinks directly rather than descending into their targets. + if (is_link($path)) { + unlink($path); + return; + } + if (!file_exists($path)) { + return; + } + if (is_file($path)) { + unlink($path); + return; + } + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $this->deleteRecursive($path . '/' . $entry); + } + rmdir($path); + } + + public function testInstalledExtensionsReflectDiskRegardlessOfEnabledState(): void + { + $installed = $this->discovery()->getInstalledExtensions(); + // Symlinked (foo, bar) and real-directory (baz) plugins are all found. + $this->assertEqualsCanonicalizing(['foo', 'bar', 'baz'], array_keys($installed)); + } + + public function testRealDirectoryPluginCanBeEnabledAndRegistered(): void + { + $discovery = $this->discovery(); + $this->assertArrayHasKey('baz', $discovery->getInstalledExtensions()); + // Installed but disabled: not registerable yet. + $this->assertArrayNotHasKey('baz', $discovery->getExtensionNamespaceInfo()); + + $discovery->enableExtension('baz'); + $this->assertArrayHasKey('baz', $discovery->getExtensionNamespaceInfo()); + } + + public function testEnabledNamesReflectRawConfig(): void + { + // Includes the not-installed "ghost" since this is the raw config list. + $this->assertEqualsCanonicalizing(['foo', 'ghost'], $this->discovery()->getEnabledExtensionNames()); + } + + public function testOnlyEnabledInstalledExtensionsAreRegisterable(): void + { + // getExtensionNamespaceInfo() is the basis for namespace, command, and + // hook registration. Disabled (bar) and enabled-but-missing (ghost) + // must both be absent. + $namespaces = $this->discovery()->getExtensionNamespaceInfo(); + $this->assertSame(['foo'], array_keys($namespaces)); + $this->assertSame('DigitalPolygon\\Polymer\\foo', $namespaces['foo']['namespace']); + } + + public function testIsExtensionEnabled(): void + { + $discovery = $this->discovery(); + $this->assertTrue($discovery->isExtensionEnabled('foo')); + $this->assertFalse($discovery->isExtensionEnabled('bar')); + } + + public function testEnableExtensionIsIdempotentAndPersists(): void + { + $discovery = $this->discovery(); + + $this->assertTrue($discovery->enableExtension('bar'), 'Enabling a disabled plugin reports a change.'); + $this->assertFalse($discovery->enableExtension('bar'), 'Enabling an already-enabled plugin reports no change.'); + + // Persisted to config.yml and now registerable. + $config = Yaml::parseFile($this->polymerRoot . '/config.yml'); + $this->assertContains('bar', $config['enabled_extensions']); + $this->assertEqualsCanonicalizing(['foo', 'bar'], array_keys($discovery->getExtensionNamespaceInfo())); + } + + public function testDisableExtensionRemovesItFromRegistration(): void + { + $discovery = $this->discovery(); + + $this->assertTrue($discovery->disableExtension('foo')); + $this->assertFalse($discovery->disableExtension('foo'), 'Disabling a non-enabled plugin reports no change.'); + + $this->assertFalse($discovery->isExtensionEnabled('foo')); + $this->assertSame([], array_keys($discovery->getExtensionNamespaceInfo())); + } +} From e7c862bf8e0053ae0b9c92758748495db032bb26 Mon Sep 17 00:00:00 2001 From: Les Peabody Date: Mon, 1 Jun 2026 22:37:21 -0400 Subject: [PATCH 4/6] Address Gemini review feedback on PR #76 - ExtensionDiscovery: throw if writing enabled_extensions to config.yml fails (file_put_contents was unchecked, so a failed write still reported success). - ExtensionDiscovery: skip enabled extensions whose ExtensionInfo class is missing via class_exists() instead of crashing with a ReflectionException. - ExtensionDiscovery: drop an unused Drupal test-fixture import (stray IDE auto-import). - ClassDiscoveryTrait: strip only a trailing ".php" extension instead of str_replace, which corrupted any path containing ".php" mid-string. - Polymer: combine core + extension hooks/commands with array_merge() rather than the "+" union operator, which silently drops colliding numeric keys. - bin/polymer-robo-run.php: guard against getcwd() returning false, and remove the arbitrary 10-level cap on the upward .polymer search (filesystem-root termination already bounds the loop). Refs PWT-114. Co-Authored-By: Claude Opus 4.8 --- bin/polymer-robo-run.php | 6 +++++- src/Robo/Discovery/ClassDiscoveryTrait.php | 8 +++++++- src/Robo/Discovery/ExtensionDiscovery.php | 10 ++++++++-- src/Robo/Polymer.php | 4 ++-- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/bin/polymer-robo-run.php b/bin/polymer-robo-run.php index 5a95331..c0a8ed4 100644 --- a/bin/polymer-robo-run.php +++ b/bin/polymer-robo-run.php @@ -11,6 +11,9 @@ use Symfony\Component\Console\Output\ConsoleOutput; $cwd = isset($_SERVER['PWD']) && is_dir($_SERVER['PWD']) ? $_SERVER['PWD'] : getcwd(); +if ($cwd === false) { + throw new \RuntimeException("Could not determine the current working directory."); +} $autoloadFile = false; // Set up autoloader @@ -48,7 +51,8 @@ // Initialize configuration. /** @var string|null $repoRoot */ $repoRoot = null; -for ($i = 0; $i < 10; $i++) { +// Walk up the directory tree until we find .polymer or reach the filesystem root. +while (true) { if (file_exists($cwd . '/.polymer')) { $repoRoot = $cwd; break; diff --git a/src/Robo/Discovery/ClassDiscoveryTrait.php b/src/Robo/Discovery/ClassDiscoveryTrait.php index 94cc779..06d6811 100644 --- a/src/Robo/Discovery/ClassDiscoveryTrait.php +++ b/src/Robo/Discovery/ClassDiscoveryTrait.php @@ -51,7 +51,13 @@ protected function convertRelativeNamespaceToPath(string $relativeNamespace): st protected function convertPathToRelativeNamespace(string $path): string { - return str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], trim($path, DIRECTORY_SEPARATOR)); + $path = trim($path, DIRECTORY_SEPARATOR); + // Strip only a trailing .php extension; str_replace would also corrupt + // any directory name that happens to contain ".php" mid-string. + if (str_ends_with($path, '.php')) { + $path = substr($path, 0, -4); + } + return str_replace(DIRECTORY_SEPARATOR, '\\', $path); } protected static function search(array $directories, string $pattern): Finder diff --git a/src/Robo/Discovery/ExtensionDiscovery.php b/src/Robo/Discovery/ExtensionDiscovery.php index 57a6830..d09aa7d 100644 --- a/src/Robo/Discovery/ExtensionDiscovery.php +++ b/src/Robo/Discovery/ExtensionDiscovery.php @@ -4,7 +4,6 @@ use Composer\Autoload\ClassLoader; use DigitalPolygon\Polymer\Core\Robo\Extension\PolymerExtensionInterface; -use Drupal\Tests\Component\Annotation\Doctrine\Fixtures\Attribute\Relative; use Robo\ClassDiscovery\RelativeNamespaceDiscovery; use DigitalPolygon\Polymer\Core\Robo\Extension\ExtensionData; use Symfony\Component\Finder\Finder; @@ -88,7 +87,9 @@ protected function writeEnabledExtensions(array $enabled): void $config = Yaml::parseFile($configFile) ?: []; } $config['enabled_extensions'] = array_values($enabled); - file_put_contents($configFile, Yaml::dump($config, 4, 2)); + if (file_put_contents($configFile, Yaml::dump($config, 4, 2)) === false) { + throw new \RuntimeException("Failed to write Polymer configuration to $configFile"); + } } protected function findExtensions(): array @@ -209,6 +210,11 @@ public function getExtensions(): array } foreach ($namespaceInfos as $extensionId => $namespaceInfo) { + if (!class_exists($namespaceInfo['extension_class'])) { + // An enabled extension that ships no ExtensionInfo class is skipped + // rather than crashing the CLI with a ReflectionException. + continue; + } $extensionReflection = new \ReflectionClass($namespaceInfo['extension_class']); if ($extensionReflection->implementsInterface(PolymerExtensionInterface::class)) { /** @var PolymerExtensionInterface $extensionInstance */ diff --git a/src/Robo/Polymer.php b/src/Robo/Polymer.php index ff0b643..d6c230f 100644 --- a/src/Robo/Polymer.php +++ b/src/Robo/Polymer.php @@ -141,8 +141,8 @@ protected function discoverExtensions(): self $extensionDiscovery = $this->bootContainer->get('extensionDiscovery'); $extensionDiscovery->registerExtensionNamespaces(); $this->extensions = $extensionDiscovery->getExtensions(); - $this->hooks = $this->getCoreHooks() + $extensionDiscovery->getExtensionHooks(); - $this->commands = $this->getCoreCommands() + $extensionDiscovery->getExtensionCommands(); + $this->hooks = array_merge($this->getCoreHooks(), $extensionDiscovery->getExtensionHooks()); + $this->commands = array_merge($this->getCoreCommands(), $extensionDiscovery->getExtensionCommands()); return $this; } From f1eac2242bfbd8de900d63a05eb9690f9a07757b Mon Sep 17 00:00:00 2001 From: Les Peabody Date: Mon, 1 Jun 2026 22:40:25 -0400 Subject: [PATCH 5/6] Fix docs web-image build: install real setuptools before pip install Fresh builds of the web image failed at the `pip install ... git+https://gitlab.com/blacs30/mkdocs-edit-url.git` step with "module 'setuptools' has no attribute 'setup'". The DDEV Python 3.13 base image ships an empty `setuptools` namespace package with no RECORD file, so `pip install -U setuptools` fails with uninstall-no-record-file and legacy setup.py builds cannot import a usable setuptools. Install a real setuptools + wheel into /usr/local (earlier on sys.path) with --ignore-installed before the mkdocs install so it shadows the stub. Verified with a full clean build. Co-Authored-By: Claude Opus 4.8 --- .ddev/web-build/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.ddev/web-build/Dockerfile b/.ddev/web-build/Dockerfile index b411fe5..25886c1 100644 --- a/.ddev/web-build/Dockerfile +++ b/.ddev/web-build/Dockerfile @@ -1,6 +1,12 @@ RUN composer global config --no-plugins allow-plugins.ion-bazan/composer-diff true; \ composer global require ion-bazan/composer-diff --no-interaction; #RUN python -m pip install --upgrade pip +# The Python 3.13 base image ships an empty `setuptools` namespace package with no +# RECORD file, so `pip install -U setuptools` fails (uninstall-no-record-file) and +# legacy setup.py builds (e.g. the mkdocs-edit-url git package below) die with +# "module 'setuptools' has no attribute 'setup'". Install a real setuptools + wheel +# into /usr/local (earlier on sys.path) with --ignore-installed so it shadows the stub. +RUN pip install --break-system-packages --ignore-installed "setuptools<81" wheel RUN pip install --break-system-packages \ mkdocs==1.5.3 \ mkdocs-material \ From 1108dbeafaaa9d804326eae9556ac09b1b6515af Mon Sep 17 00:00:00 2001 From: Les Peabody Date: Mon, 1 Jun 2026 22:41:24 -0400 Subject: [PATCH 6/6] Add AGENTS.md guidance and CLAUDE.md pointer Document the package for Claude Code / agents (architecture, commands, conventions) and add an Issue tracking section pointing at the PWT Jira board. CLAUDE.md is a one-line pointer to AGENTS.md. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 112 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..018b17a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,111 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`digitalpolygon/polymer` is a **Composer plugin** that ships a Robo-based CLI (`bin/polymer`) for WebOps tooling — primarily artifact building/deployment for PHP/Drupal projects. It is intended to be extended by other packages (e.g. `polymer-drupal`, `polymer-pantheon-drupal`), not used as a standalone application. + +Package autoload (note the `Core` segment): `DigitalPolygon\Polymer\Core\` → `src/`. Tests autoload: `DigitalPolygon\PolymerTest\` → `tests/`. + +## Common commands + +All Composer scripts are defined in `composer.json`: + +| Command | What it does | +| --- | --- | +| `composer cs` | PHP_CodeSniffer against `phpcs.xml.dist` (PSR-12 over `src/` and `bin/`) | +| `composer lint` | Parallel `php -l` syntax check | +| `composer sa` | PHPStan level 6 against `src/` and `bin/` (config: `phpstan.neon`) | +| `composer validations` | Runs `lint`, `cs`, and `sa` in sequence — the CI gate | +| `composer nuke` / `composer nuke-install` | Wipe `vendor/` + lockfile (and reinstall) | + +Per the user's global rules: after modifying PHP, run it through **PHPStan** (`composer sa`) before declaring the change done. + +### Tests + +PHPUnit must be invoked **from `tests/phpunit/`** because `phpunit.xml.dist` references `unit/` relative to that directory: + +```bash +cd tests/phpunit && ../../vendor/bin/phpunit # full suite +cd tests/phpunit && ../../vendor/bin/phpunit --filter testMethodName # single test +cd tests/phpunit && ../../vendor/bin/phpunit-watcher watch # watch mode +``` + +CI matrix runs against PHP 8.1, 8.2, 8.3 (`.github/workflows/code_standards.yml`, `phpunit.yml`). + +### DDEV (development environment) + +Documentation work happens inside DDEV (Python/`mkdocs` toolchain is in the web container): + +- `ddev init` — full reset, `composer install`, build docs, open browser +- `ddev build-docs` — runs `polymer mk:docs` then `mkdocs build --clean`; view at `:444` +- `ddev dev-docs` — live `mkdocs serve` at `:445` (preferred while editing docs) + +When command-class docblocks/attributes change, regenerate the per-command `docs/commands/*.md` with `polymer mk:docs` before building the site. + +## Architecture + +### Bootstrap flow (`bin/polymer` → `Polymer::run()`) + +1. `bin/polymer` → `bin/polymer-robo-run.php` walks up from CWD looking for a `.polymer/` directory; this is the **repo root** (everything is anchored to it). +2. `Polymer::boot()` runs five phases in order: `setupBootContainer` → `createApplication` → `discoverExtensions` → `setupContainer` → `configureRunner`. +3. `RoboRunner::run()` is handed the merged `[commands + hooks]` array; Robo + Symfony Console take over. + +The two-container split (`bootContainer` then full `Container`) exists so extension discovery can run before the main DI container is finalized — extensions contribute service providers that need to register during container setup. + +### Extension model (the core extensibility surface) + +An external Composer package becomes a Polymer extension by: + +1. Having a directory under `/.polymer/plugins//` containing a `.poly_info.yml` marker file, **and** being listed in `/.polymer/config.yml` under `enabled_extensions`. +2. Exposing a `src/` directory; its PSR-4 namespace is **forced** to `DigitalPolygon\Polymer\\` (see `ExtensionDiscovery::registerExtensionNamespaces()` — the class loader is mutated at runtime). +3. Providing an `ExtensionInfo.php` implementing `PolymerExtensionInterface` at the root of that namespace. +4. Optionally providing a service provider next to `ExtensionInfo.php`, named `ServiceProvider`, implementing `League\Container\ServiceProvider\ServiceProviderInterface`. + +Within an extension, commands live in `Plugin\Commands\*Commands.php` and hooks in `Plugin\Hooks\*Hook.php`. Polymer Core's own commands follow the same `*Command.php` / `*Hook.php` suffix convention but are discovered separately via `CoreClassDiscovery` and the `Robo\Commands\` / `Robo\Hooks\` relative namespaces. + +The discovery suffix mismatch is intentional and load-bearing — **don't "normalize" it**: +- Core: files end in `Command.php` / `Hook.php` +- Extensions: files end in `Commands.php` / `Hook.php` + +### Configuration system (`src/Robo/Config/`) + +Config is **not** available during boot — it's compiled lazily on each command invocation. The flow: + +1. `LoadConfiguration` event subscriber fires at command start. +2. It dispatches `CollectConfigContextsEvent`, allowing extensions to contribute named contexts (each a YAML-derived array). +3. Then `AlterConfigContextsEvent`, allowing late mutation. +4. `PolymerConfig` (extends `Robo\Config`) layers contexts; higher contexts override lower keys. `${tokens}` are resolved against the merged result by `YamlConfigProcessor`. +5. The `process` context is always top-of-stack — runtime `$config->set()` writes go there. + +**Important quirk**: `commandInvoker->invokeCommand()` reloads config from scratch for the invoked command, so a `$this->getConfig()->set('foo', 'bar')` before invoking a sub-command will **not** propagate. Use `pinGlobalOption()` for parameter pass-through. See `docs/developing/command_invoker.md`. + +### Commands + +Built using `consolidation/annotated-command` with PHP attributes (`#[Command]`, `#[Argument]`, `#[Option]`, `#[Usage]`). All command classes extend `TaskBase` (`src/Robo/Tasks/TaskBase.php`). + +Commands prefixed `internal:` are hidden from `list` output by default — this is enforced by `CommandInfoAlterer` running *before* Robo's annotation processing, so `#[Help(hidden: false)]` cannot un-hide them (see `config/default.yml` comment). + +Notable namespaces under `src/Robo/Commands/`: +- `Artifact/` — `artifact:compile`, `artifact:deploy`, `artifact:build:prepare`, `artifact:build:sanitize`, `artifact:composer:install` (the deployment pipeline) +- `Source/` — `source:build:copy`, `source:build:frontend` +- `Docs/` — `mk:docs` (regenerates per-command markdown for the docs site) +- `Template/` — template generation via `TemplatePluginManager` +- `Validate/`, `Polymer/` + +### Composer plugin (`src/Composer/Plugin.php`) + +Implements `PluginInterface` + `EventSubscriberInterface`. On `post-install` / `post-update`, if `digitalpolygon/polymer` itself was just installed/updated **and** `/polymer/polymer.yml` does not yet exist, it shells out to `vendor/bin/polymer polymer:init` to scaffold project files. This is the only thing the Composer plugin does at runtime. + +## Conventions and gotchas + +- **Namespace prefix is `DigitalPolygon\Polymer\Core\` in this package** (the `Core` segment is easy to miss when copying code from extensions, which use `DigitalPolygon\Polymer\\`). +- `test_package/` and `test_drupal10/` are fixture projects used to exercise the Composer plugin / commands end-to-end; they are excluded from PHPStan and PHPCS. Don't lint or analyze them. +- `src/Robo/Common/ArrayManipulator.php` is excluded from PHPStan — it's vendored utility code. +- Default branch for PRs is `0.x`, not `main` (see CI workflow triggers and `mkdocs.yml: edit_uri`). +- GrumPHP runs `phpcs`, `phpstan`, and `phplint` on commit via `phpro/grumphp-shim` — same checks as `composer validations`. + +## Issue tracking + +Polymer development work is tracked in the **PWT** Jira project: https://digitalpolygon.atlassian.net/jira/software/projects/PWT/boards/96 — use the `acli` CLI (ADF format) for Jira interactions. The `composer.json` `support.issues` link points to GitHub issues, but PWT is the active board. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dc366f8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See @AGENTS.md.