From 9ce826bf351a6f67d9957dd0cc0c170dfa450eec Mon Sep 17 00:00:00 2001 From: Tymoteusz Motylewski Date: Mon, 27 Sep 2021 16:12:25 +0200 Subject: [PATCH] Make phpstan work without db connection - do not include/run Mage class - provide helper class for loading Magento and Modules XML configuration - introduce new configuration parameter "magentoRootPath" --- README.md | 27 +- composer.json | 7 +- extension.neon | 17 +- phpstan-bootstrap.php | 39 +- .../Magento/ModuleControllerAutoloader.php | 7 +- src/Config/MagentoCore.php | 560 ++++++++++++++++++ src/Type/Mage/GetResourceModel.php | 8 +- src/Type/Mage/MethodReturnTypeDetector.php | 18 +- 8 files changed, 655 insertions(+), 28 deletions(-) create mode 100644 src/Config/MagentoCore.php diff --git a/README.md b/README.md index 3046c52..8781232 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# PHPStan Extension for Magento 1 +# PHPStan Extension for Magento 1 / OpenMage MageLTS Extension for [PHPStan](https://github.com/phpstan/phpstan) to allow analysis of Magento 1 code. +It doesn't require db connection to run. -Currently it assumes Magento is installed in the `htdocs/` directory of the project root. Further work is needed in phpstan itself to allow more intellegence for extensions to be more customised whilst working with both phpstan/phpstan and phpstan/phpstan-shim. ## Usage @@ -12,32 +12,47 @@ Make sure it has ```neon includes: - - vendor/vianetz/phpstan-magento1/extension.neon + - vendor/macopedia/phpstan-magento1/extension.neon ``` Then run ```bash -composer require inviqa/phpstan-magento1 phpstan/phpstan +composer require --dev macopedia/phpstan-magento1 ``` ## Alternative Magento path By default this extension assumes the Magento directory is `%currentWorkingDirectory%/htdocs`. - +You can adapt the path by changing the `magentoRootPath` parameter in the phpstan.neon file. Add to the project's phpstan.neon: ```neon parameters: + magentoRootPath: %currentWorkingDirectory%/htdocs + +``` + +## Example configuration file for analysing Magento Module + +```neon +parameters: + magentoRootPath: %currentWorkingDirectory%/htdocs paths: - %currentWorkingDirectory%/path/to/magento/app/code/local autoload_files: - %currentWorkingDirectory%/path/to/magento/app/Mage.php + paths: + #lets start small with just our extensions + - %currentWorkingDirectory%/app/code/local/VendorName/ModuleName + excludePaths: + - */Vendor/ModuleName/SomePathToExclude/* + level: 0 ``` # Known Issues -## Data/SQL scripts can't be tested +## Data/SQL scripts can't be tested Since these scripts use a presumed $this variable due to being included from a setup class, work is needed to: diff --git a/composer.json b/composer.json index 283fc96..d69f4e9 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,14 @@ { - "name": "vianetz/phpstan-magento1", + "name": "macopedia/phpstan-magento1", "description": "Extension for PHPStan to allow analysis of Magento 1 code.", "type": "library", "require": { - "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan": "^0.12", "php": ">= 7.2" }, "replace": { - "inviqa/phpstan-magento1": "0.1.5" + "inviqa/phpstan-magento1": "0.1.5", + "vianetz/phpstan-magento1": "0.1.5" }, "autoload": { "psr-4": { diff --git a/extension.neon b/extension.neon index 54374de..5904fe6 100644 --- a/extension.neon +++ b/extension.neon @@ -1,10 +1,21 @@ +parametersSchema: + magentoRootPath: string() parameters: - excludes_analyse: + magentoRootPath: %currentWorkingDirectory%/htdocs + excludePaths: - */app/code/local/*/*/data/* - */app/code/local/*/*/sql/* bootstrapFiles: - - %currentWorkingDirectory%/htdocs/app/Mage.php - phpstan-bootstrap.php + scanFiles: + - %magentoRootPath%/app/Mage.php + - %magentoRootPath%/app/code/core/Mage/Core/functions.php + scanDirectories: + #for static reflection, so we don't have to call autoloader + - %magentoRootPath%/lib/ + - %magentoRootPath%/app/code/local/ + - %magentoRootPath%/app/code/community/ + - %magentoRootPath%/app/code/core/ typeAliases: Mage_Catalog_Model_Entity_Product_Collection: 'Mage_Catalog_Model_Resource_Product_Collection' callback: 'callable' @@ -37,4 +48,4 @@ services: - class: PHPStanMagento1\Type\Mage\Helper tags: - - phpstan.broker.dynamicStaticMethodReturnTypeExtension \ No newline at end of file + - phpstan.broker.dynamicStaticMethodReturnTypeExtension diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php index b6c6a62..3acf22a 100644 --- a/phpstan-bootstrap.php +++ b/phpstan-bootstrap.php @@ -3,15 +3,48 @@ use PHPStanMagento1\Autoload\Magento\ModuleControllerAutoloader; +if (!defined('DS')) { + define('DS', DIRECTORY_SEPARATOR); +} +if (!defined('PS')) { + define('PS', PATH_SEPARATOR); +} + +/** + * @var $container \PHPStan\DependencyInjection\MemoizingContainer + */ +$magentoRootPath = $container->getParameter('magentoRootPath'); +if (empty($magentoRootPath)) { + throw new \Exception('Please set "magentoRootPath" in your phpstan.neon.'); +} + +if (!defined('BP')) { + define('BP', $magentoRootPath); +} + +/** + * Set include path + */ +$paths = []; +$paths[] = BP . DS . 'app' . DS . 'code' . DS . 'local'; +$paths[] = BP . DS . 'app' . DS . 'code' . DS . 'community'; +$paths[] = BP . DS . 'app' . DS . 'code' . DS . 'core'; +$paths[] = BP . DS . 'lib'; + +$appPath = implode(PS, $paths); +set_include_path($appPath . PS . get_include_path()); +include_once "Mage/Core/functions.php"; + (new ModuleControllerAutoloader('local'))->register(); (new ModuleControllerAutoloader('core'))->register(); (new ModuleControllerAutoloader('community'))->register(); /** - * We replace the original Varien_Autoload autoloader with a custom one. + * Custom autoloader compatible with Varien_Autoload + * Autoloading is needed only for the PHPStanMagento1\Config\MagentoCore which inherits from some magento classes. + * PHPStan uses static analysis, so doesn't require autoloading. */ spl_autoload_register(static function($className) { - spl_autoload_unregister([Varien_Autoload::instance(), 'autoload']); $classFile = str_replace(' ', DIRECTORY_SEPARATOR, ucwords(str_replace('_', ' ', $className))); $classFile .= '.php'; @@ -22,5 +55,3 @@ } } }, true, true); - -Mage::app(); \ No newline at end of file diff --git a/src/Autoload/Magento/ModuleControllerAutoloader.php b/src/Autoload/Magento/ModuleControllerAutoloader.php index 6e522f5..637f334 100644 --- a/src/Autoload/Magento/ModuleControllerAutoloader.php +++ b/src/Autoload/Magento/ModuleControllerAutoloader.php @@ -17,12 +17,7 @@ final class ModuleControllerAutoloader public function __construct(string $codePool, $magentoRoot = null) { if (empty($magentoRoot)) { - $mageClass = new ReflectionClass(Mage::class); - if ($mageClass->getFileName() !== false) { - $magentoRoot = \dirname($mageClass->getFileName(), 2); - } else { - throw new \RuntimeException('Could not find path to Mage class'); - } + $magentoRoot = \dirname(BP, 2); } $this->codePool = $codePool; $this->magentoRoot = $magentoRoot; diff --git a/src/Config/MagentoCore.php b/src/Config/MagentoCore.php new file mode 100644 index 0000000..44f000f --- /dev/null +++ b/src/Config/MagentoCore.php @@ -0,0 +1,560 @@ +_prototype = new \Mage_Core_Model_Config_Base(); + parent::__construct($sourceData); + } + + /** + * workaround to skip calling getOptions(), so we don't have to override + * Mage_Core_Model_Config_Options too + * @return string + */ + protected function getEtcDir() + { + return BP . '/app/etc'; + } + + /** + * workaround to skip calling getOptions(), so we don't have to override + * Mage_Core_Model_Config_Options too + * @return string + */ + protected function getCodeDir() + { + return BP . '/app/code'; + } + + /** + * Retrieve class name by class group + * + * @param string $groupType currently supported model, block, helper + * @param string $classId slash separated class identifier, ex. group/class + * @param string $groupRootNode optional config path for group config + * @return string + */ + public function getGroupedClassName($groupType, $classId, $groupRootNode = null) + { + if (empty($groupRootNode)) { + $groupRootNode = 'global/'.$groupType.'s'; + } + + $classArr = explode('/', trim($classId)); + $group = $classArr[0]; + $class = !empty($classArr[1]) ? $classArr[1] : null; + + if (isset($this->_classNameCache[$groupRootNode][$group][$class])) { + return $this->_classNameCache[$groupRootNode][$group][$class]; + } + + $config = $this->_xml->global->{$groupType.'s'}->{$group}; + + // First - check maybe the entity class was rewritten + $className = ''; + if (isset($config->rewrite->$class)) { + $className = (string)$config->rewrite->$class; + } else { + /** + * Backwards compatibility for pre-MMDB extensions. + * In MMDB release resource nodes <..._mysql4> were renamed to <..._resource>. So is left + * to keep name of previously used nodes, that still may be used by non-updated extensions. + */ + if (isset($config->deprecatedNode)) { + $deprecatedNode = $config->deprecatedNode; + $configOld = $this->_xml->global->{$groupType.'s'}->$deprecatedNode; + if (isset($configOld->rewrite->$class)) { + $className = (string) $configOld->rewrite->$class; + } + } + } + + $className = trim($className); + + // Second - if entity is not rewritten then use class prefix to form class name + if (empty($className)) { + if (!empty($config)) { + $className = $this->getClassName($config); + } + if (empty($className)) { + $className = 'mage_'.$group.'_'.$groupType; + } + if (!empty($class)) { + $className .= '_'.$class; + } + $className = uc_words($className); + } + + $this->_classNameCache[$groupRootNode][$group][$class] = $className; + return $className; + } + + /** + * copied from Mage_Core_Model_Config_Element to avoid calling Mage:: + * + * @return string + */ + public function getClassName($config) + { + if ($config->class) { + $model = (string)$config->class; + } elseif ($config->model) { + $model = (string)$config->model; + } else { + return false; + } + return $this->getModelClassName($model); + } + + /** + * Retrieve block class name + * + * @param string $blockType + * @return string + */ + public function getBlockClassName($blockType) + { + if (strpos($blockType, '/')===false) { + return $blockType; + } + return $this->getGroupedClassName('block', $blockType); + } + + /** + * Retrieve helper class name + * + * @param string $helperName + * @return string + */ + public function getHelperClassName($helperName) + { + if (strpos($helperName, '/') === false) { + $helperName .= '/data'; + } + return $this->getGroupedClassName('helper', $helperName); + } + /** + * Retrieve module class name + * + * @param string $modelClass + * @return string + */ + public function getModelClassName($modelClass) + { + $modelClass = trim($modelClass); + if (strpos($modelClass, '/')===false) { + return $modelClass; + } + return $this->getGroupedClassName('model', $modelClass); + } + + /** + * Get factory class name for a resource + * + * @param string $modelClass + * @return string|false + */ + protected function _getResourceModelFactoryClassName($modelClass) + { + $classArray = explode('/', $modelClass); + if (count($classArray) != 2) { + return false; + } + + [$module, $model] = $classArray; + if (!isset($this->_xml->global->models->{$module})) { + return false; + } + + $moduleNode = $this->_xml->global->models->{$module}; + if (!empty($moduleNode->resourceModel)) { + $resourceModel = (string)$moduleNode->resourceModel; + } else { + return false; + } + + return $resourceModel . '/' . $model; + } + + /** + * Get a resource model class name + * + * @param string $modelClass + * @return string|false + */ + public function getResourceModelClassName($modelClass) + { + $factoryName = $this->_getResourceModelFactoryClassName($modelClass); + if ($factoryName) { + return $this->getModelClassName($factoryName); + } + return false; + } + + /** + * Load base system configuration (config.xml and local.xml files) + * + * @return $this + */ + public function loadBase() + { + $etcDir = $this->getEtcDir(); + + $files = glob($etcDir.DS.'*.xml'); + + $this->loadFile(current($files)); + while ($file = next($files)) { + $merge = clone $this->_prototype; + $merge->loadFile($file); + $this->extend($merge); + } + if (in_array($etcDir.DS.'local.xml', $files)) { + $this->_isLocalConfigLoaded = true; + } + return $this; + } + + /** + * Load modules configuration + * + * @return $this + */ + public function loadModules() + { + $this->_loadDeclaredModules(); + + $this->loadModulesConfiguration(array('config.xml'), $this); + + /** + * Prevent local.xml directives overwriting + */ + $mergeConfig = clone $this->_prototype; + $this->_isLocalConfigLoaded = $mergeConfig->loadFile($this->getEtcDir().DS.'local.xml'); + if ($this->_isLocalConfigLoaded) { + $this->extend($mergeConfig); + } + + $this->applyExtends(); + return $this; + } + + /** + * Iterate all active modules "etc" folders and combine data from + * specified xml file name to one object + * + * @param string $fileName + * @param null|\Mage_Core_Model_Config_Base $mergeToObject + * @param null $mergeModel + * @return \Mage_Core_Model_Config_Base + */ + public function loadModulesConfiguration($fileName, $mergeToObject = null, $mergeModel = null) + { + $disableLocalModules = false; + + if ($mergeToObject === null) { + $mergeToObject = clone $this->_prototype; + $mergeToObject->loadString(''); + } + if ($mergeModel === null) { + $mergeModel = clone $this->_prototype; + } + $modules = $this->getNode('modules')->children(); + foreach ($modules as $modName => $module) { + if ($module->is('active')) { + if ($disableLocalModules && ('local' === (string)$module->codePool)) { + continue; + } + if (!is_array($fileName)) { + $fileName = array($fileName); + } + + foreach ($fileName as $configFile) { + $configFile = $this->getModuleDir('etc', $modName).DS.$configFile; + if ($mergeModel->loadFile($configFile)) { + $this->_makeEventsLowerCase('global', $mergeModel); + $this->_makeEventsLowerCase('frontend', $mergeModel); + $this->_makeEventsLowerCase('admin', $mergeModel); + $this->_makeEventsLowerCase('adminhtml', $mergeModel); + + $mergeToObject->extend($mergeModel, true); + } + } + } + } + return $mergeToObject; + } + + /** + * Get module config node + * + * @param string $moduleName + * @return \Mage_Core_Model_Config_Element|\SimpleXMLElement + */ + public function getModuleConfig($moduleName = '') + { + $modules = $this->getNode('modules'); + if (''===$moduleName) { + return $modules; + } else { + return $modules->$moduleName; + } + } + + /** + * Get module directory by directory type + * + * @param string $type + * @param string $moduleName + * @return string + */ + public function getModuleDir($type, $moduleName) + { + $codePool = (string)$this->getModuleConfig($moduleName)->codePool; + $dir = $this->getCodeDir().DS.$codePool.DS.uc_words($moduleName, DS); + + switch ($type) { + case 'etc': + $dir .= DS.'etc'; + break; + + case 'controllers': + $dir .= DS.'controllers'; + break; + + case 'sql': + $dir .= DS.'sql'; + break; + case 'data': + $dir .= DS.'data'; + break; + + case 'locale': + $dir .= DS.'locale'; + break; + } + + $dir = str_replace('/', DS, $dir); + return $dir; + } + + /** + * Load declared modules configuration + * + * @param null $mergeConfig depricated + * @return $this|void + */ + protected function _loadDeclaredModules($mergeConfig = null) + { + $moduleFiles = $this->_getDeclaredModuleFiles(); + if (!$moduleFiles) { + return ; + } + + $unsortedConfig = new \Mage_Core_Model_Config_Base(); + $unsortedConfig->loadString(''); + $fileConfig = new \Mage_Core_Model_Config_Base(); + + // load modules declarations + foreach ($moduleFiles as $file) { + $fileConfig->loadFile($file); + $unsortedConfig->extend($fileConfig); + } + + $moduleDepends = array(); + foreach ($unsortedConfig->getNode('modules')->children() as $moduleName => $moduleNode) { + + $depends = array(); + if ($moduleNode->depends) { + foreach ($moduleNode->depends->children() as $depend) { + $depends[$depend->getName()] = true; + } + } + $moduleDepends[$moduleName] = array( + 'module' => $moduleName, + 'depends' => $depends, + 'active' => (string)$moduleNode->active === 'true', + ); + } + + // check and sort module dependence + $moduleDepends = $this->_sortModuleDepends($moduleDepends); + + // create sorted config + $sortedConfig = new \Mage_Core_Model_Config_Base(); + $sortedConfig->loadString(''); + + foreach ($unsortedConfig->getNode()->children() as $nodeName => $node) { + if ($nodeName !== 'modules') { + $sortedConfig->getNode()->appendChild($node); + } + } + + foreach ($moduleDepends as $moduleProp) { + $node = $unsortedConfig->getNode('modules/'.$moduleProp['module']); + $sortedConfig->getNode('modules')->appendChild($node); + } + + $this->extend($sortedConfig); + + return $this; + } + + /** + * Sort modules and check depends + * + * @param array $modules + * @return array + */ + protected function _sortModuleDepends($modules) + { + foreach ($modules as $moduleName => $moduleProps) { + $depends = $moduleProps['depends']; + foreach ($moduleProps['depends'] as $depend => $true) { + if ($moduleProps['active'] && ((!isset($modules[$depend])) || empty($modules[$depend]['active']))) { + throw new \Exception( + \sprintf('Module "%1$s" requires module "%2$s".', $moduleName, $depend) + ); + } + $depends = array_merge($depends, $modules[$depend]['depends']); + } + $modules[$moduleName]['depends'] = $depends; + } + $modules = array_values($modules); + + $size = count($modules) - 1; + for ($i = $size; $i >= 0; $i--) { + for ($j = $size; $i < $j; $j--) { + if (isset($modules[$i]['depends'][$modules[$j]['module']])) { + $value = $modules[$i]; + $modules[$i] = $modules[$j]; + $modules[$j] = $value; + } + } + } + + $definedModules = array(); + foreach ($modules as $moduleProp) { + foreach ($moduleProp['depends'] as $dependModule => $true) { + if (!isset($definedModules[$dependModule])) { + throw new \Exception( + \sprintf('Module "%1$s" cannot depend on "%2$s".', $moduleProp['module'], $dependModule) + ); + } + } + $definedModules[$moduleProp['module']] = true; + } + + return $modules; + } + + /** + * Retrive Declared Module file list + * + * @return array|false + */ + protected function _getDeclaredModuleFiles() + { + $etcDir = $this->getEtcDir(); + $moduleFiles = glob($etcDir . DS . 'modules' . DS . '*.xml'); + + if (!$moduleFiles) { + return false; + } + + $collectModuleFiles = array( + 'base' => array(), + 'mage' => array(), + 'custom' => array() + ); + + foreach ($moduleFiles as $v) { + $name = explode(DIRECTORY_SEPARATOR, $v); + $name = substr($name[count($name) - 1], 0, -4); + + if ($name === 'Mage_All') { + $collectModuleFiles['base'][] = $v; + } elseif (substr($name, 0, 5) === 'Mage_') { + $collectModuleFiles['mage'][] = $v; + } else { + $collectModuleFiles['custom'][] = $v; + } + } + + return array_merge( + $collectModuleFiles['base'], + $collectModuleFiles['mage'], + $collectModuleFiles['custom'] + ); + } + + /** + * Makes all events to lower-case + * + * @param string $area + * @param \Varien_Simplexml_Config $mergeModel + */ + protected function _makeEventsLowerCase($area, \Varien_Simplexml_Config $mergeModel) + { + $events = $mergeModel->getNode($area . "/" . \Mage_Core_Model_App_Area::PART_EVENTS); + if ($events !== false) { + $children = clone $events->children(); + /** @var \Mage_Core_Model_Config_Element $event */ + foreach ($children as $event) { + if ($this->_isNodeNameHasUpperCase($event)) { + $oldName = $event->getName(); + $newEventName = strtolower($oldName); + if (!isset($events->$newEventName)) { + /** @var \Mage_Core_Model_Config_Element $newNode */ + + $newNode = $events->addChild($newEventName); + $newNode->extend($event); + } + unset($events->$oldName); + } + } + } + } + + /** + * Checks is event name has upper-case letters + * + * @param \Mage_Core_Model_Config_Element $event + * @return bool + */ + protected function _isNodeNameHasUpperCase(\Mage_Core_Model_Config_Element $event) + { + return (strtolower($event->getName()) !== (string)$event->getName()); + } +} diff --git a/src/Type/Mage/GetResourceModel.php b/src/Type/Mage/GetResourceModel.php index c6eaffa..5c24a0f 100644 --- a/src/Type/Mage/GetResourceModel.php +++ b/src/Type/Mage/GetResourceModel.php @@ -7,11 +7,15 @@ final class GetResourceModel extends StaticMethodReturnTypeDetector { public function getMagentoClassName(string $identifier): string { - return $this->getMagentoConfig()->getResourceModelClassName($identifier); + $className = $this->getMagentoConfig()->getResourceModelClassName($identifier); + if ($className === false) { + throw new \PHPStan\Broker\ClassNotFoundException($identifier); + } + return $className; } protected static function getMethodName(): string { return 'getResourceModel'; } -} \ No newline at end of file +} diff --git a/src/Type/Mage/MethodReturnTypeDetector.php b/src/Type/Mage/MethodReturnTypeDetector.php index 1cec22b..691fd8d 100644 --- a/src/Type/Mage/MethodReturnTypeDetector.php +++ b/src/Type/Mage/MethodReturnTypeDetector.php @@ -10,6 +10,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStanMagento1\Config\MagentoCore; abstract class MethodReturnTypeDetector { @@ -36,7 +37,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method protected function getTypeFromExpr(MethodReflection $methodReflection, $methodCall, Scope $scope): Type { if (! isset($methodCall->args[0]) || ! $methodCall->args[0]->value instanceof String_) { - throw new ShouldNotHappenException(); + throw new ShouldNotHappenException("type:" . \get_class($methodCall->args[0]->value)); } $modelName = $methodCall->args[0]->value->value; @@ -45,8 +46,17 @@ protected function getTypeFromExpr(MethodReflection $methodReflection, $methodCa return new ObjectType($modelClassName); } - protected function getMagentoConfig(): \Mage_Core_Model_Config + /** + * Load Magento XML configuration + * + * @return MagentoCore + */ + protected function getMagentoConfig(): MagentoCore { - return \Mage::app()->getConfig(); + $config = new MagentoCore(); + $config->loadBase(); + $config->loadModules(); + + return $config; } -} \ No newline at end of file +}