diff --git a/config/quality-tools.yaml b/config/quality-tools.yaml deleted file mode 100644 index c519561..0000000 --- a/config/quality-tools.yaml +++ /dev/null @@ -1,138 +0,0 @@ -# Quality Tools Configuration -# This file configures all quality analysis tools for your TYPO3 project -# -# Configuration hierarchy (highest priority first): -# 1. Command-line overrides (--config, --path) -# 2. Project-specific configuration (quality-tools.yaml) -# 3. Global user configuration (~/.quality-tools.yaml) -# 4. Package defaults (this file) -# -# Environment variables can be used with ${VAR} or ${VAR:-default} syntax - -quality-tools: - # Project settings - project: - # Project name - used in reports and logs - name: "${PROJECT_NAME:-TYPO3 Project}" - - # Target PHP version for code analysis and modernization - php_version: "8.3" - - # Target TYPO3 version for compatibility checks - typo3_version: "13.4" - - # Scan paths configuration - paths: - # Directories to analyze - relative to project root - scan: - - "packages/" # Custom extensions and site packages - - "config/system/" # System configuration files - - # Directories to exclude from analysis - exclude: - - "var/" # Runtime cache and logs - - "vendor/" # Third-party packages - - "node_modules/" # Frontend dependencies - - ".git/" # Git repository files - - ".build/" # Build artifacts - - "public/" # Web-accessible files only - - # Tool-specific settings - tools: - # Rector - PHP modernization and refactoring - rector: - enabled: true - - # TYPO3 version compatibility level - level: "typo3-13" - - # PHP version override (uses project.php_version if not specified) - # php_version: "8.3" - - # Preview changes without applying them - # dry_run: true - - # Fractor - TypoScript modernization - fractor: - enabled: true - - # Indentation spaces for TypoScript files - indentation: 2 - - # Files to skip during processing - # skip_files: - # - "Configuration/TypoScript/Legacy/" - - # PHPStan - Static analysis - phpstan: - enabled: true - - # Analysis strictness level (0-9, higher = stricter) - level: 6 - - # Memory limit for large projects - memory_limit: "1G" - - # Custom paths override (uses paths.scan if not specified) - # paths: - # - "packages/custom-extension/" - - # PHP CS Fixer - Code style fixing - php-cs-fixer: - enabled: true - - # Code style preset - preset: "typo3" - - # Enable caching for better performance - # cache: true - - # TypoScript Lint - TypoScript validation - typoscript-lint: - enabled: true - - # Expected indentation for TypoScript files - indentation: 2 - - # Patterns to ignore during linting - # ignore_patterns: - # - "**/Tests/**" - - # Output and reporting configuration - output: - # Verbosity level: quiet, normal, verbose, debug - verbosity: "normal" - - # Enable colored output (auto-detected for terminals) - colors: true - - # Show progress bars for long-running operations - progress: true - - # Performance optimization settings - performance: - # Run tools in parallel when possible - parallel: true - - # Maximum number of concurrent processes - max_processes: 4 - - # Enable result caching to speed up repeated runs - cache_enabled: true - -# Example environment-specific overrides: -# -# For development environments, you might want more verbose output: -# quality-tools: -# output: -# verbosity: "verbose" -# performance: -# parallel: false # Easier debugging -# -# For CI environments, you might want to disable progress bars: -# quality-tools: -# output: -# progress: false -# colors: false -# performance: -# max_processes: 2 # CI resource limits diff --git a/config/services.yaml b/config/services.yaml index e6c49ed..0494b77 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -27,6 +27,9 @@ services: public: true exclude: - '../src/Configuration/ValidationResult.php' + - '../src/Configuration/ConfigurationHierarchy.php' + - '../src/Configuration/ConfigurationDiscovery.php' + - '../src/Configuration/EnhancedConfiguration.php' # Console Commands Cpsit\QualityTools\Console\Command\: @@ -72,6 +75,13 @@ services: $filesystemService: '@Cpsit\QualityTools\Service\FilesystemService' public: true + Cpsit\QualityTools\Configuration\HierarchicalConfigurationLoader: + arguments: + $validator: '@Cpsit\QualityTools\Configuration\ConfigurationValidator' + $securityService: '@Cpsit\QualityTools\Service\SecurityService' + $filesystemService: '@Cpsit\QualityTools\Service\FilesystemService' + public: true + Cpsit\QualityTools\Configuration\ConfigurationValidator: public: true diff --git a/docs/plan/feature/015-configuration-overwrites.md b/docs/plan/feature/015-configuration-overwrites.md index bcef49f..b0288ab 100644 --- a/docs/plan/feature/015-configuration-overwrites.md +++ b/docs/plan/feature/015-configuration-overwrites.md @@ -1,6 +1,6 @@ # Feature 015: Configuration Overwrites -- **Status:** Not Started +- **Status:** Completed - **Estimated Time:** 6–8 hours - **Layer:** MVP - **Dependencies:** 010-unified-yaml-configuration-system (Not Started) @@ -28,40 +28,40 @@ Projects need the ability to customize quality tool configurations for their spe ## Tasks -- [ ] Configuration Precedence System - - [ ] Design configuration hierarchy and precedence rules - - [ ] Implement configuration file discovery mechanism - - [ ] Create configuration merging algorithm - - [ ] Add configuration source tracking and debugging -- [ ] Configuration File Support - - [ ] Support phpcs.xml in the project root - - [ ] Support tool configs in package root directories - - [ ] Support tool configs in config/ subdirectory - - [ ] Support quality-tools.yaml in various locations -- [ ] Override Mechanisms - - [ ] Implement deep configuration merging - - [ ] Add configuration validation after merging - - [ ] Create override conflict detection - - [ ] Add configuration inheritance documentation +- [x] Configuration Precedence System + - [x] Design configuration hierarchy and precedence rules + - [x] Implement configuration file discovery mechanism + - [x] Create configuration merging algorithm + - [x] Add configuration source tracking and debugging +- [x] Configuration File Support + - [x] Support phpcs.xml in the project root + - [x] Support tool configs in package root directories + - [x] Support tool configs in config/ subdirectory + - [x] Support .quality-tools.yaml in various locations +- [x] Override Mechanisms + - [x] Implement deep configuration merging + - [x] Add configuration validation after merging + - [x] Create override conflict detection + - [x] Add configuration inheritance documentation ## Success Criteria -- [ ] Clear, documented configuration precedence order -- [ ] Projects can override any configuration setting -- [ ] Configuration merging works predictably -- [ ] Debugging tools show which config files are active -- [ ] Backward compatibility maintained for existing projects +- [x] Clear, documented configuration precedence order +- [x] Projects can override any configuration setting +- [x] Configuration merging works predictably +- [x] Debugging tools show which config files are active +- [x] Backward compatibility maintained for existing projects ## Technical Requirements ### Configuration File Locations (in precedence order) 1. Command line arguments (the highest priority) -2. `quality-tools.yaml` in project root -3. `quality-tools.yaml` in config/ directory +2. `.quality-tools.yaml` in project root +3. `.quality-tools.yaml` in config/ directory 4. Tool-specific config in project root (e.g., `phpcs.xml`) 5. Tool-specific config in an arbitrary directory (e.g., /`config/phpcs.xml`, /`phpcs.xml`, /`config/phpcs.xml`) -6. `quality-tools.yaml` in package root +6. `.quality-tools.yaml` in package root 7. Package defaults (the lowest priority) ### Configuration Merging Strategy @@ -70,7 +70,7 @@ Projects need the ability to customize quality tool configurations for their spe - Objects: Deep merge with override - Scalars: Override completely - Special handling for path arrays (relative path resolution) -- a custom config file for a tool overrides the default config file and all other configs for that tool in configuration YAML files. (config file set as command argument or in `quality-tools.yaml`) +- a custom config file for a tool overrides the default config file and all other configs for that tool in configuration YAML files. (config file set as command argument or in `.quality-tools.yaml`) ## Implementation Plan @@ -93,7 +93,7 @@ Projects need the ability to customize quality tool configurations for their spe Extends unified YAML configuration from Feature 010: ```yaml -# Example: project-root/quality-tools.yaml +# Example: project-root/.quality-tools.yaml quality-tools: # Override specific tool settings tools: @@ -119,14 +119,14 @@ quality-tools: ``` project-root/ -├── quality-tools.yaml # Project-level overrides +├── .quality-tools.yaml # Project-level overrides ├── phpcs.xml # Legacy PHP CS Fixer config ├── config/ -│ ├── quality-tools.yaml # Config directory overrides +│ ├── .quality-tools.yaml # Config directory overrides │ └── rector.php # Legacy Rector config └── packages/ └── custom-package/ - └── quality-tools.yaml # Package-specific overrides + └── .quality-tools.yaml # Package-specific overrides ``` ## Backward Compatibility diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 99a5f65..a09a917 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -127,31 +127,236 @@ vendor/bin/qt --quiet [command] ## Configuration Files -### Current Status +### Hierarchical Configuration System -The current implementation uses environment variables for configuration. Configuration files are planned for future releases. +The tool implements a hierarchical configuration override system that allows projects to customize quality tool configurations at multiple levels. This provides powerful configuration management while maintaining simplicity for basic use cases. -### Planned Configuration +### Configuration Precedence Hierarchy -Future versions will support configuration files: +Configuration sources are applied in the following order (highest to lowest priority): + +1. **Command Line Arguments** - Highest priority +2. **Project Root Configuration** - `quality-tools.yaml` in project root +3. **Config Directory Configuration** - `quality-tools.yaml` in `config/` directory +4. **Tool-Specific Project Configuration** - Tool config files in the project root +5. **Tool-Specific Config Directory** - Tool config files in `config/` directory +6. **Package Configuration** - `quality-tools.yaml` in package directories +7. **Global User Configuration** - `~/.quality-tools.yaml` in user's home directory +8. **Package Defaults** - Lowest priority + +### YAML Configuration Files + +The system looks for YAML configuration files in the following locations: + +#### Global User Configuration +- `~/.quality-tools.yaml` (user's home directory) + +#### Project Root +- `quality-tools.yaml` +- `.quality-tools.yaml` +- `quality-tools.yml` + +#### Config Directory +- `config/quality-tools.yaml` +- `config/.quality-tools.yaml` +- `config/quality-tools.yml` + +#### Package Directories +- `packages/*/quality-tools.yaml` +- `packages/*/.quality-tools.yaml` + +### Tool-Specific Configuration Files + +Tool-specific configuration files take precedence over unified YAML configuration for their respective tools: + +#### Project Root +- `rector.php` (Rector) +- `phpstan.neon`, `phpstan.neon.dist` (PHPStan) +- `.php-cs-fixer.dist.php`, `.php-cs-fixer.php` (PHP CS Fixer) +- `typoscript-lint.yml` (TypoScript Lint) +- `fractor.php` (Fractor) + +#### Config Directory +- `config/rector.php` +- `config/phpstan.neon` +- `config/.php-cs-fixer.dist.php` +- `config/.php-cs-fixer.php` +- `config/typoscript-lint.yml` + +### Configuration Usage Examples + +#### Global User Configuration + +Create a global default configuration that applies to all your projects: + +```yaml +# ~/.quality-tools.yaml +quality-tools: + project: + php_version: "8.4" + typo3_version: "13.4" + + tools: + phpstan: + enabled: true + level: 6 + memory_limit: "2G" + + paths: + exclude: + - "var/" + - "vendor/" + - ".git/" +``` + +#### Basic Project Configuration + +Override global defaults for a specific project: + +```yaml +# project-root/quality-tools.yaml +quality-tools: + project: + name: "my-project" + php_version: "8.4" # Override global default + + tools: + rector: + level: "typo3-13" + enabled: true + + phpstan: + level: 8 # Higher than global default + + paths: + scan: + - "packages/" + - "src/" + exclude: + - "var/" + - "public/" +``` + +#### Config Directory Overrides + +Environment-specific or deployment-specific overrides: + +```yaml +# project-root/config/quality-tools.yaml +quality-tools: + tools: + phpstan: + level: 5 # Lower level for development + + php-cs-fixer: + preset: "custom" + + paths: + exclude: + - "legacy/" # Additional exclusion +``` + +#### Tool-Specific Configuration + +When you need complex tool-specific configuration that goes beyond the unified YAML format: + +```php +withPaths(['src/', 'packages/']) + ->withPhpVersion(PhpVersion::PHP_84) + ->withRules([ + // Complex Rector-specific configuration + ]); +``` + +### Environment Variable Interpolation + +Configuration files support environment variable interpolation with default values: ```yaml -# .qt-config.yml (planned) -project: - root: /path/to/project - auto_detect: true - -output: - verbosity: normal - debug: false - -tools: - rector: - enabled: true - config: config/rector.php - phpstan: - enabled: true - level: 6 +quality-tools: + project: + name: "${PROJECT_NAME:-default-project}" + php_version: "${PHP_VERSION:-8.4}" + + tools: + phpstan: + memory_limit: "${PHPSTAN_MEMORY:-2G}" + level: ${PHPSTAN_LEVEL:-6} + + paths: + scan: + - "${PROJECT_SRC_DIR:-src/}" + - "packages/" +``` + +### Configuration Merging Strategies + +The system uses different merging strategies based on the type of configuration data: + +#### Arrays +- **Indexed Arrays**: Merge and remove duplicates +- **Path Arrays**: Special handling for relative paths and deduplication +- **Associative Arrays**: Deep merge with override + +#### Objects +- **Deep Merge**: Recursively merge object properties +- **Override Protection**: Lower priority values are preserved unless explicitly overridden + +#### Scalar Values +- **Complete Override**: Higher priority sources completely replace lower priority values + +#### Special Cases +- **Tool Config Files**: When present, completely override unified configuration for that tool +- **Path Resolution**: Relative paths are resolved relative to their source file location + +### Configuration Management Commands + +#### Configuration Show Command + +Display current configuration and sources: + +```bash +# Show current configuration +vendor/bin/qt config:show + +# Show configuration with sources +vendor/bin/qt config:show --with-sources + +# Show configuration for specific tool +vendor/bin/qt config:show --tool=phpstan + +# Show configuration debug information +vendor/bin/qt config:show --debug +``` + +#### Configuration Validate Command + +Validate configuration files: + +```bash +# Validate configuration +vendor/bin/qt config:validate + +# Validate specific configuration file +vendor/bin/qt config:validate config/quality-tools.yaml + +# Show validation warnings +vendor/bin/qt config:validate --warnings +``` + +#### Configuration Initialize Command + +Create initial configuration files: + +```bash +# Create initial configuration file +vendor/bin/qt config:init + +# Force overwrite existing configuration +vendor/bin/qt config:init --force ``` ## Runtime Configuration @@ -286,21 +491,56 @@ Project root confirmed: /path/to/project CPSIT Quality Tools 1.0.0-dev ``` +## Configuration Troubleshooting + +### Common Issues + +1. **Configuration Not Found**: Check file paths and permissions +2. **Unexpected Values**: Review precedence hierarchy and debug configuration +3. **Tool Not Working**: Verify tool-specific configuration syntax +4. **Environment Variables**: Check variable names and default values + +### Debug Commands + +```bash +# Show all configuration sources +vendor/bin/qt config:show --debug + +# Validate configuration files +vendor/bin/qt config:validate --warnings + +# Show which source provides specific values +vendor/bin/qt config:show --with-sources +``` + +### Getting Help + +1. Use `--help` flag with any command for detailed usage information +2. Use `--debug` flag to see detailed configuration loading information +3. Check file permissions and syntax if configuration isn't loading +4. Refer to tool-specific documentation for advanced configuration options + ## Best Practices -### Development Setup +### Project Setup + +1. **Global Defaults**: Set common preferences in `~/.quality-tools.yaml` +2. **Project Customization**: Override only what you need in project root configuration +3. **Environment Specific**: Use config directory for deployment-specific settings +4. **Tool Complexity**: Use tool-specific files only when you need complex configuration -1. **Use project-relative paths**: Avoid absolute paths when possible -2. **Environment isolation**: Use different configurations for development/staging/production -3. **Version control**: Don't commit environment-specific configuration -4. **Documentation**: Document any required environment variables +### Configuration Organization -### Production Considerations +1. **Start Simple**: Begin with project root configuration, add complexity as needed +2. **Minimize Overrides**: Only override settings that differ from sensible defaults +3. **Document Changes**: Comment why specific overrides were made +4. **Version Control**: Commit configuration files, exclude environment-specific secrets -1. **Security**: Be cautious with paths in shared environments -2. **Performance**: Disable debug mode in production -3. **Monitoring**: Use quiet mode for automated scripts -4. **Validation**: Always validate paths exist and are accessible +### Performance Considerations + +1. **Avoid Deep Nesting**: Keep package directory structures reasonable +2. **Tool-Specific Files**: Use only when unified YAML isn't sufficient +3. **Environment Variables**: Use sparingly to avoid complexity ### Team Collaboration @@ -308,3 +548,21 @@ CPSIT Quality Tools 1.0.0-dev 2. **Default behavior**: Rely on automatic detection when possible 3. **Flexibility**: Provide override options for different development setups 4. **Testing**: Test configuration on different environments + +### Migration Guide + +#### From Simple to Hierarchical Configuration + +1. **Assess Current Setup**: Identify existing configuration files +2. **Plan Hierarchy**: Decide which settings belong at which level +3. **Create Global Config**: Move common settings to `~/.quality-tools.yaml` +4. **Test Thoroughly**: Verify that tools behave as expected +5. **Clean Up**: Remove redundant configuration files + +#### Best Migration Strategy + +1. Start with global configuration for common defaults +2. Keep project-specific overrides minimal +3. Use config directory only for environment differences +4. Migrate tool-specific configs only when needed +5. Test each step to ensure tools work correctly diff --git a/docs/user-guide/configuration/reference.md b/docs/user-guide/configuration/reference.md index 11602bb..627c8b6 100644 --- a/docs/user-guide/configuration/reference.md +++ b/docs/user-guide/configuration/reference.md @@ -12,12 +12,18 @@ The configuration loader searches for configuration files in the following order ## Configuration Hierarchy -Configuration is merged in the following order (later values override earlier ones): - -1. **Package defaults** (built-in defaults) -2. **Global user configuration** (`~/.quality-tools.yaml`) -3. **Project-specific configuration** (project root) -4. **Command-line overrides** (--config, --path, etc.) +Configuration sources are applied in the following order (highest to lowest priority): + +1. **Command Line Arguments** - Highest priority +2. **Project Root Configuration** - `quality-tools.yaml` in project root +3. **Config Directory Configuration** - `quality-tools.yaml` in `config/` directory +4. **Tool-Specific Project Configuration** - Tool config files in the project root +5. **Tool-Specific Config Directory** - Tool config files in `config/` directory +6. **Package Configuration** - `quality-tools.yaml` in package directories +7. **Global User Configuration** - `~/.quality-tools.yaml` in user's home directory +8. **Package Defaults** - Lowest priority + +This hierarchical system allows for flexible configuration management while maintaining clear precedence rules. Higher priority sources override lower priority sources for the same configuration options. ## Complete Configuration Schema diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 5cde955..392eecf 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -45,17 +45,17 @@ CPSIT Quality Tools provides a unified command-line interface for running variou 2. [Getting Started](getting-started.md) - Basic usage and first steps 3. [Dynamic Resource Optimization](optimization.md) - How automatic optimization works 4. [Project Detection](project-detection.md) - How the tool finds TYPO3 projects -5. [Configuration](configuration.md) - Environment variables and customization options +5. [Configuration](configuration.md) - Configuration system with hierarchical override support 6. [Troubleshooting](troubleshooting.md) - Common issues and solutions ## Configuration System -The unified YAML configuration system provides comprehensive configuration management: +The hierarchical configuration system provides powerful yet simple configuration management: -7. [YAML Configuration Guide](configuration/yaml-configuration.md) - Complete guide to the unified YAML configuration system -8. [Project Templates](configuration/templates.md) - Pre-configured setups for different TYPO3 project types -9. [Environment Variables](configuration/environment-variables.md) - Using environment variables in configurations -10. [Configuration Reference](configuration/reference.md) - Complete reference for all configuration options +7. [Configuration Reference](configuration/reference.md) - Complete reference for all configuration options and hierarchy +8. [YAML Configuration Guide](configuration/yaml-configuration.md) - Complete guide to the unified YAML configuration system +9. [Project Templates](configuration/templates.md) - Pre-configured setups for different TYPO3 project types +10. [Environment Variables](configuration/environment-variables.md) - Using environment variables in configurations 11. [Migration Guide](configuration/migration.md) - Migrating from tool-specific configurations 12. [Configuration Troubleshooting](configuration/troubleshooting.md) - Diagnosing configuration issues diff --git a/src/Configuration/ConfigurationDiscovery.php b/src/Configuration/ConfigurationDiscovery.php new file mode 100644 index 0000000..1c24fda --- /dev/null +++ b/src/Configuration/ConfigurationDiscovery.php @@ -0,0 +1,288 @@ + Array of file paths mapped to error messages + */ + public function getConfigurationErrors(): array + { + return $this->configurationErrors; + } + + /** + * Clear stored configuration errors. + */ + public function clearConfigurationErrors(): void + { + $this->configurationErrors = []; + } + + /** + * Discover all configuration sources with their metadata. + */ + public function discoverConfigurations(): array + { + $configurations = []; + + // 1. Package defaults (always available) + $configurations[] = $this->createConfigurationSource( + 'package_defaults', + null, + Configuration::createDefault()->toArray(), + ); + + // 2. Global user configuration + $globalConfig = $this->discoverGlobalConfiguration(); + if ($globalConfig !== null) { + $configurations[] = $globalConfig; + } + + // 3. Project-level configurations + $projectConfigurations = $this->discoverProjectConfigurations(); + $configurations = array_merge($configurations, $projectConfigurations); + + // Sort by precedence (the highest priority first) + usort($configurations, fn (array $a, array $b): int => $this->hierarchy->getPrecedenceLevel($a['source']) <=> + $this->hierarchy->getPrecedenceLevel($b['source'])); + + return $configurations; + } + + /** + * Discover global user configuration. + */ + private function discoverGlobalConfiguration(): ?array + { + $homeDir = $this->getHomeDirectory(); + if ($homeDir === null) { + return null; + } + + $globalConfigPath = $homeDir . '/.quality-tools.yaml'; + if (!file_exists($globalConfigPath)) { + return null; + } + + try { + $data = $this->loadYamlFile($globalConfigPath); + + return $this->createConfigurationSource( + 'global', + $globalConfigPath, + $data, + ); + } catch (ConfigurationLoadException) { + // Skip invalid global configuration + return null; + } + } + + /** + * Discover all project-level configurations. + */ + private function discoverProjectConfigurations(): array + { + $configurations = []; + $existingFiles = $this->hierarchy->getExistingConfigurationFiles(); + + foreach ($existingFiles as $level => $files) { + foreach ($files as $fileInfo) { + try { + $data = $this->loadConfigurationFile($fileInfo); + + $configurations[] = $this->createConfigurationSource( + $level, + $fileInfo['path'], + $data, + $fileInfo['type'], + $fileInfo['tool'], + ); + } catch (ConfigurationLoadException $e) { + // Store failed configuration for potential error reporting at the command level + $this->configurationErrors[$fileInfo['path']] = $e->getMessage(); + continue; + } + } + } + + return $configurations; + } + + /** + * Load configuration data based on a file type. + */ + private function loadConfigurationFile(array $fileInfo): array + { + return match ($fileInfo['type']) { + 'yaml' => $this->loadYamlFile($fileInfo['path']), + 'php' => $this->loadPhpFile($fileInfo['path']), + 'neon' => $this->loadNeonFile($fileInfo['path']), + default => throw new ConfigurationLoadException("Unsupported configuration file type: {$fileInfo['type']}", $fileInfo['path']) + }; + } + + /** + * Load the PHP configuration file (for tools like Rector). + */ + private function loadPhpFile(string $path): array + { + // PHP configuration files are tool-specific and don't follow our YAML schema + // We just mark them as existing and let the tool handle them + return [ + 'tool_config_file' => $path, + 'custom_config' => true, + ]; + } + + /** + * Load the Neon configuration file (for PHPStan). + */ + private function loadNeonFile(string $path): array + { + // Neon configuration files are tool-specific + // We just mark them as existing and let PHPStan handle them + return [ + 'tool_config_file' => $path, + 'custom_config' => true, + ]; + } + + /** + * Create a configuration source array with metadata. + */ + private function createConfigurationSource( + string $source, + ?string $filePath, + array $data, + string $fileType = 'yaml', + ?string $tool = null, + ): array { + return [ + 'source' => $source, + 'file_path' => $filePath, + 'file_type' => $fileType, + 'tool' => $tool, + 'data' => $data, + 'precedence' => $this->hierarchy->getPrecedenceLevel($source), + 'timestamp' => $filePath !== null && file_exists($filePath) ? filemtime($filePath) : time(), + ]; + } + + /** + * Get home directory for global configuration. + */ + private function getHomeDirectory(): ?string + { + $homeDir = getenv('HOME') ?: ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE'] ?? ''); + + return !empty($homeDir) ? $homeDir : null; + } + + /** + * Check if a configuration file exists for a specific tool. + */ + public function hasToolConfiguration(string $tool): bool + { + $existingFiles = $this->hierarchy->getExistingConfigurationFiles(); + + foreach ($existingFiles as $files) { + foreach ($files as $fileInfo) { + if ($fileInfo['tool'] === $tool) { + return true; + } + } + } + + return false; + } + + /** + * Get the path to a tool's configuration file. + */ + public function getToolConfigurationPath(string $tool): ?string + { + $existingFiles = $this->hierarchy->getExistingConfigurationFiles(); + + // Look for tool-specific configs in order of precedence + foreach (ConfigurationHierarchy::PRECEDENCE_LEVELS as $level) { + if (!isset($existingFiles[$level])) { + continue; + } + + foreach ($existingFiles[$level] as $fileInfo) { + if ($fileInfo['tool'] === $tool) { + return $fileInfo['path']; + } + } + } + + return null; + } + + /** + * Get all configuration files that would affect a specific tool. + */ + public function getToolAffectingConfigurations(string $tool): array + { + $configurations = $this->discoverConfigurations(); + $affecting = []; + + foreach ($configurations as $config) { + // Include general configurations and tool-specific configurations + if ($config['tool'] === null || $config['tool'] === $tool) { + $affecting[] = $config; + } + } + + return $affecting; + } + + /** + * Get debug information about configuration discovery. + */ + public function getDiscoveryDebugInfo(): array + { + $configurations = $this->discoverConfigurations(); + + return [ + 'total_configurations_found' => \count($configurations), + 'configurations' => $configurations, + 'hierarchy_debug' => $this->hierarchy->getDebugInfo(), + 'global_config_path' => $this->getHomeDirectory() ? $this->getHomeDirectory() . '/.quality-tools.yaml' : null, + 'existing_files_by_level' => $this->hierarchy->getExistingConfigurationFiles(), + ]; + } +} diff --git a/src/Configuration/ConfigurationHierarchy.php b/src/Configuration/ConfigurationHierarchy.php new file mode 100644 index 0000000..1f280d5 --- /dev/null +++ b/src/Configuration/ConfigurationHierarchy.php @@ -0,0 +1,275 @@ + [ + 'quality-tools.yaml', + '.quality-tools.yaml', + 'quality-tools.yml', + ], + 'config_dir' => [ + 'config/quality-tools.yaml', + 'config/.quality-tools.yaml', + 'config/quality-tools.yml', + ], + 'tool_specific' => [ + 'rector.php', + 'phpstan.neon', + 'phpstan.neon.dist', + '.php-cs-fixer.dist.php', + '.php-cs-fixer.php', + 'typoscript-lint.yml', + ], + 'tool_config_dir' => [ + 'config/rector.php', + 'config/phpstan.neon', + 'config/.php-cs-fixer.dist.php', + 'config/.php-cs-fixer.php', + 'config/typoscript-lint.yml', + ], + 'package_config' => [ + 'packages/*/quality-tools.yaml', + 'packages/*/.quality-tools.yaml', + ], + ]; + + /** + * Tool-specific configuration file mappings. + */ + public const array TOOL_CONFIG_FILES = [ + 'rector' => ['rector.php'], + 'phpstan' => ['phpstan.neon', 'phpstan.neon.dist'], + 'php-cs-fixer' => ['.php-cs-fixer.dist.php', '.php-cs-fixer.php'], + 'typoscript-lint' => ['typoscript-lint.yml'], + 'fractor' => ['fractor.php'], + ]; + + /** + * Configuration merging strategies for different data types. + */ + public const array MERGE_STRATEGIES = [ + 'arrays' => 'merge_unique', // Arrays: merge and deduplicate + 'objects' => 'deep_merge', // Objects: deep merge with override + 'scalars' => 'override', // Scalars: override completely + 'paths' => 'resolve_relative', // Special handling for path arrays + ]; + + /** + * Special configuration keys that require custom handling. + */ + public const array SPECIAL_KEYS = [ + 'paths' => 'path_resolution', + 'exclude' => 'path_resolution', + 'scan' => 'path_resolution', + 'config_file' => 'tool_config_override', + ]; + + public function __construct( + private string $projectRoot, + private ?string $packageRoot = null, + ) { + } + + /** + * Get all potential configuration file paths in precedence order. + */ + public function getConfigurationFilePaths(): array + { + $paths = []; + + // Project root configurations + foreach (self::FILE_PATTERNS['project_root'] as $pattern) { + $paths['project_root'][] = $this->projectRoot . '/' . $pattern; + } + + // Config directory configurations + foreach (self::FILE_PATTERNS['config_dir'] as $pattern) { + $paths['config_dir'][] = $this->projectRoot . '/' . $pattern; + } + + // Tool-specific configurations + foreach (self::FILE_PATTERNS['tool_specific'] as $pattern) { + $paths['tool_specific'][] = $this->projectRoot . '/' . $pattern; + } + + // Tool configs in config directory + foreach (self::FILE_PATTERNS['tool_config_dir'] as $pattern) { + $paths['tool_config_dir'][] = $this->projectRoot . '/' . $pattern; + } + + // Package configurations (if package root is different) + if ($this->packageRoot !== null && $this->packageRoot !== $this->projectRoot) { + foreach (self::FILE_PATTERNS['package_config'] as $pattern) { + $expandedPaths = glob($this->packageRoot . '/' . $pattern); + if ($expandedPaths !== false) { + $paths['package_config'] = array_merge($paths['package_config'] ?? [], $expandedPaths); + } + } + } + + return $paths; + } + + /** + * Get existing configuration files in precedence order. + */ + public function getExistingConfigurationFiles(): array + { + $allPaths = $this->getConfigurationFilePaths(); + $existingFiles = []; + + foreach (self::PRECEDENCE_LEVELS as $level) { + if ($level === 'command_line' || $level === 'package_defaults') { + continue; // These are handled separately + } + + if (!isset($allPaths[$level])) { + continue; + } + + foreach ($allPaths[$level] as $filePath) { + if (file_exists($filePath)) { + $existingFiles[$level][] = [ + 'path' => $filePath, + 'type' => $this->getFileType($filePath), + 'tool' => $this->getToolForConfigFile($filePath), + ]; + } + } + } + + return $existingFiles; + } + + /** + * Determine the file type (yaml, php, neon, etc.). + */ + private function getFileType(string $filePath): string + { + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + return match ($extension) { + 'yaml', 'yml' => 'yaml', + 'php' => 'php', + 'neon' => 'neon', + default => 'unknown' + }; + } + + /** + * Determine which tool a configuration file belongs to. + */ + private function getToolForConfigFile(string $filePath): ?string + { + $fileName = basename($filePath); + + foreach (self::TOOL_CONFIG_FILES as $tool => $patterns) { + foreach ($patterns as $pattern) { + if ($fileName === $pattern || fnmatch($pattern, $fileName)) { + return $tool; + } + } + } + + // Check if it's a general quality-tools config + if (str_contains($fileName, 'quality-tools')) { + return null; // General config, not tool-specific + } + + return null; + } + + /** + * Check if a tool-specific config file overrides unified configuration. + */ + public function hasToolConfigOverride(string $tool): bool + { + $existingFiles = $this->getExistingConfigurationFiles(); + + foreach (['tool_specific', 'tool_config_dir'] as $level) { + if (isset($existingFiles[$level])) { + foreach ($existingFiles[$level] as $fileInfo) { + if ($fileInfo['tool'] === $tool) { + return true; + } + } + } + } + + return false; + } + + /** + * Get the precedence level for a configuration source. + */ + public function getPrecedenceLevel(string $source): int + { + $index = array_search($source, self::PRECEDENCE_LEVELS, true); + + return $index !== false ? $index : \count(self::PRECEDENCE_LEVELS); + } + + /** + * Check if one configuration source has higher precedence than another. + */ + public function hasHigherPrecedence(string $source1, string $source2): bool + { + return $this->getPrecedenceLevel($source1) < $this->getPrecedenceLevel($source2); + } + + /** + * Get debug information about the configuration hierarchy. + */ + public function getDebugInfo(): array + { + return [ + 'project_root' => $this->projectRoot, + 'package_root' => $this->packageRoot, + 'precedence_levels' => self::PRECEDENCE_LEVELS, + 'all_potential_files' => $this->getConfigurationFilePaths(), + 'existing_files' => $this->getExistingConfigurationFiles(), + 'tool_overrides' => $this->getToolOverrideStatus(), + ]; + } + + /** + * Get tool override status for debugging. + */ + private function getToolOverrideStatus(): array + { + $status = []; + + foreach (array_keys(self::TOOL_CONFIG_FILES) as $tool) { + $status[$tool] = $this->hasToolConfigOverride($tool); + } + + return $status; + } +} diff --git a/src/Configuration/ConfigurationMerger.php b/src/Configuration/ConfigurationMerger.php new file mode 100644 index 0000000..2c22c7c --- /dev/null +++ b/src/Configuration/ConfigurationMerger.php @@ -0,0 +1,330 @@ +sourceMap = []; + $this->conflicts = []; + + if (empty($configurations)) { + return [ + 'data' => [], + 'source_map' => [], + 'conflicts' => [], + 'merge_summary' => $this->getMergeSummary([]), + ]; + } + + // Sort configurations by precedence (highest to lowest priority) + // Lower precedence numbers = higher priority (override later configurations) + usort($configurations, fn (array $a, array $b): int => $b['precedence'] <=> $a['precedence']); + + // Start with the lowest priority configuration + $mergedData = []; + + foreach ($configurations as $config) { + $this->mergeConfiguration($mergedData, $this->sourceMap, $config); + } + + return [ + 'data' => $mergedData, + 'source_map' => $this->sourceMap, + 'conflicts' => $this->conflicts, + 'merge_summary' => $this->getMergeSummary($configurations), + ]; + } + + /** + * Merge a single configuration into the merged data. + */ + private function mergeConfiguration(array &$mergedData, array &$sourceMap, array $config): void + { + $configData = $config['data'] ?? []; + $source = $config['source'] ?? 'unknown'; + + $this->deepMerge($mergedData, $sourceMap, $configData, $source, []); + } + + /** + * Deep merge algorithm with source tracking and conflict detection. + */ + private function deepMerge( + array &$target, + array &$targetSourceMap, + array $source, + string $sourceName, + array $keyPath, + ): void { + foreach ($source as $key => $value) { + $currentKeyPath = array_merge($keyPath, [$key]); + $keyPathStr = implode('.', $currentKeyPath); + + if (!\array_key_exists($key, $target)) { + // New key, just add it + $target[$key] = $value; + $targetSourceMap[$keyPathStr] = $sourceName; + + // If this is an array, recursively populate source map for all nested keys + if (\is_array($value)) { + $this->populateSourceMapForNewArray($targetSourceMap, $value, $sourceName, $currentKeyPath); + } + } elseif (\is_array($value) && \is_array($target[$key])) { + // Both are arrays, determine merge strategy + $mergeStrategy = $this->getMergeStrategy($currentKeyPath); + + switch ($mergeStrategy) { + case 'replace': + $this->recordConflict($keyPathStr, $target[$key], $value, $targetSourceMap[$keyPathStr] ?? 'unknown', $sourceName); + $target[$key] = $value; + $targetSourceMap[$keyPathStr] = $sourceName; + break; + + case 'merge_unique': + if ($this->isIndexedArray($target[$key]) && $this->isIndexedArray($value)) { + // Merge indexed arrays and remove duplicates + $merged = array_merge($target[$key], $value); + $target[$key] = array_values(array_unique($merged)); + $targetSourceMap[$keyPathStr] = $sourceName; + } else { + // Deep merge associative arrays + $this->deepMerge($target[$key], $targetSourceMap, $value, $sourceName, $currentKeyPath); + } + break; + + case 'deep_merge': + $this->deepMerge($target[$key], $targetSourceMap, $value, $sourceName, $currentKeyPath); + break; + + case 'path_resolution': + // For path resolution, handle indexed arrays specially + if ($this->isIndexedArray($target[$key]) && $this->isIndexedArray($value)) { + // Merge path arrays and remove duplicates + $merged = array_merge($target[$key], $value); + $target[$key] = array_values(array_unique($merged)); + $targetSourceMap[$keyPathStr] = $sourceName; + } else { + // Deep merge associative arrays + $this->deepMerge($target[$key], $targetSourceMap, $value, $sourceName, $currentKeyPath); + } + break; + + case 'tool_config_override': + // Tool config files completely override unified configuration + if (isset($value['custom_config']) && $value['custom_config'] === true) { + $this->recordConflict($keyPathStr, $target[$key], $value, $targetSourceMap[$keyPathStr] ?? 'unknown', $sourceName); + $target[$key] = $value; + $targetSourceMap[$keyPathStr] = $sourceName; + } else { + $this->deepMerge($target[$key], $targetSourceMap, $value, $sourceName, $currentKeyPath); + } + break; + } + } else { + // Different types or scalar values, override + $this->recordConflict($keyPathStr, $target[$key], $value, $targetSourceMap[$keyPathStr] ?? 'unknown', $sourceName); + $target[$key] = $value; + $targetSourceMap[$keyPathStr] = $sourceName; + } + } + } + + /** + * Determine the merge strategy for a specific key path. + */ + private function getMergeStrategy(array $keyPath): string + { + $fullPath = implode('.', $keyPath); + $lastKey = end($keyPath); + + // Check for special keys + foreach (ConfigurationHierarchy::SPECIAL_KEYS as $specialKey => $strategy) { + if ($lastKey === $specialKey || str_contains($fullPath, $specialKey)) { + return $strategy; + } + } + + // Default strategies based on context + if (\in_array($lastKey, ['scan', 'exclude'], true)) { + return 'merge_unique'; + } + + if ($lastKey === 'paths') { + return 'deep_merge'; + } + + if (\in_array($lastKey, ['tools', 'project', 'output', 'performance'], true)) { + return 'deep_merge'; + } + + // For simple lists, replace rather than merge + if ($lastKey === 'list') { + return 'replace'; + } + + // Default to deep merge for objects + return 'deep_merge'; + } + + /** + * Check if array is indexed (not associative). + */ + private function isIndexedArray(array $array): bool + { + return array_is_list($array); + } + + /** + * Recursively populate source map for all nested keys in a new array. + */ + private function populateSourceMapForNewArray(array &$sourceMap, array $array, string $sourceName, array $basePath): void + { + foreach ($array as $key => $value) { + $currentPath = array_merge($basePath, [$key]); + $keyPathStr = implode('.', $currentPath); + + $sourceMap[$keyPathStr] = $sourceName; + + if (\is_array($value) && !$this->isIndexedArray($value)) { + // Recursively process associative arrays, but not indexed arrays + $this->populateSourceMapForNewArray($sourceMap, $value, $sourceName, $currentPath); + } + } + } + + /** + * Record a configuration conflict for debugging. + */ + private function recordConflict( + string $keyPath, + mixed $existingValue, + mixed $newValue, + string $existingSource, + string $newSource, + ): void { + $this->conflicts[] = [ + 'key_path' => $keyPath, + 'existing_value' => $existingValue, + 'new_value' => $newValue, + 'existing_source' => $existingSource, + 'new_source' => $newSource, + 'resolution' => 'override', + 'winner' => $newSource, + ]; + } + + /** + * Get merge summary with statistics. + */ + private function getMergeSummary(array $configurations): array + { + $summary = [ + 'total_configurations' => \count($configurations), + 'configurations_by_source' => [], + 'total_conflicts' => \count($this->conflicts), + 'conflicts_by_key' => [], + ]; + + foreach ($configurations as $config) { + $source = $config['source'] ?? 'unknown'; + $summary['configurations_by_source'][$source] = [ + 'file_path' => $config['file_path'] ?? null, + 'file_type' => $config['file_type'] ?? 'unknown', + 'tool' => $config['tool'] ?? null, + 'precedence' => $config['precedence'] ?? 999, + ]; + } + + foreach ($this->conflicts as $conflict) { + $keyPath = $conflict['key_path']; + if (!isset($summary['conflicts_by_key'][$keyPath])) { + $summary['conflicts_by_key'][$keyPath] = 0; + } + ++$summary['conflicts_by_key'][$keyPath]; + } + + return $summary; + } + + /** + * Get all recorded conflicts. + */ + public function getConflicts(): array + { + return $this->conflicts; + } + + /** + * Check if there were any conflicts during merging. + */ + public function hasConflicts(): bool + { + return !empty($this->conflicts); + } + + /** + * Get conflicts for a specific key path. + */ + public function getConflictsForKey(string $keyPath): array + { + return array_values(array_filter($this->conflicts, fn (array $conflict): bool => $conflict['key_path'] === $keyPath)); + } + + /** + * Merge two individual configuration arrays (utility method). + */ + public static function mergeTwo(array $base, array $override): array + { + $merger = new self(); + + $result = $merger->mergeConfigurations([ + [ + 'source' => 'base', + 'precedence' => 1, + 'data' => $base, + ], + [ + 'source' => 'override', + 'precedence' => 0, + 'data' => $override, + ], + ]); + + return $result['data']; + } + + /** + * Create a merger with debug output for testing. + */ + public static function createDebugMerger(ConfigurationHierarchy $hierarchy): self + { + return new self(); + } + + /** + * Get the source map for debugging which source provided which values. + */ + public function getSourceMap(): array + { + return $this->sourceMap; + } +} diff --git a/src/Configuration/EnhancedConfiguration.php b/src/Configuration/EnhancedConfiguration.php new file mode 100644 index 0000000..93efe15 --- /dev/null +++ b/src/Configuration/EnhancedConfiguration.php @@ -0,0 +1,452 @@ +validator !== null && !empty($this->data)) { + $this->validator->validate($this->data); + } + + if ($this->projectRoot !== null) { + $this->setProjectRoot($this->projectRoot); + } + } + + public function setProjectRoot(string $projectRoot): void + { + $this->actualProjectRoot = $projectRoot; // Reset path scanner + } + + public function getProjectRoot(): ?string + { + return $this->actualProjectRoot ?? null; + } + + public function toArray(): array + { + return $this->data; + } + + public function getProjectPhpVersion(): string + { + $qualityTools = $this->data['quality-tools'] ?? []; + $projectConfig = $qualityTools['project'] ?? []; + + return $projectConfig['php_version'] ?? '8.3'; + } + + public function getProjectTypo3Version(): string + { + $qualityTools = $this->data['quality-tools'] ?? []; + $projectConfig = $qualityTools['project'] ?? []; + + return $projectConfig['typo3_version'] ?? '13.4'; + } + + public function getProjectName(): ?string + { + $qualityTools = $this->data['quality-tools'] ?? []; + $projectConfig = $qualityTools['project'] ?? []; + + return $projectConfig['name'] ?? null; + } + + public function getScanPaths(): array + { + $qualityTools = $this->data['quality-tools'] ?? []; + $pathsConfig = $qualityTools['paths'] ?? []; + + return $pathsConfig['scan'] ?? ['packages/', 'config/system/']; + } + + public function getExcludePaths(): array + { + $qualityTools = $this->data['quality-tools'] ?? []; + $pathsConfig = $qualityTools['paths'] ?? []; + + return $pathsConfig['exclude'] ?? ['var/', 'vendor/', 'public/', '_assets/', 'fileadmin/', 'typo3/', 'Tests/', 'tests/', 'typo3conf/']; + } + + public function getToolPaths(string $tool): array + { + $qualityTools = $this->data['quality-tools'] ?? []; + $toolsConfig = $qualityTools['tools'] ?? []; + + return $toolsConfig[$tool]['paths'] ?? []; + } + + public function isToolEnabled(string $tool): bool + { + $qualityTools = $this->data['quality-tools'] ?? []; + $toolsConfig = $qualityTools['tools'] ?? []; + + return $toolsConfig[$tool]['enabled'] ?? true; + } + + public function getToolConfig(string $tool): array + { + $qualityTools = $this->data['quality-tools'] ?? []; + $toolsConfig = $qualityTools['tools'] ?? []; + $config = $toolsConfig[$tool] ?? []; + + // Apply tool-specific defaults for backward compatibility + return match ($tool) { + 'phpstan' => $this->getPhpStanConfig($config), + 'rector' => $this->getRectorConfig($config), + 'fractor' => $this->getFractorConfig($config), + 'php-cs-fixer' => $this->getPhpCsFixerConfig($config), + default => $config, + }; + } + + private function getPhpStanConfig(array $config = []): array + { + return array_merge([ + 'enabled' => true, + 'level' => 6, + 'memory_limit' => '1G', + ], $config); + } + + private function getRectorConfig(array $config = []): array + { + return array_merge([ + 'enabled' => true, + 'level' => 'typo3-13', + 'php_version' => $this->getProjectPhpVersion(), + ], $config); + } + + private function getFractorConfig(array $config = []): array + { + return array_merge([ + 'enabled' => true, + 'php_version' => $this->getProjectPhpVersion(), + ], $config); + } + + private function getPhpCsFixerConfig(array $config = []): array + { + return array_merge([ + 'enabled' => true, + 'preset' => 'typo3', + ], $config); + } + + /** + * Get the source that provided a specific configuration value. + */ + public function getConfigurationSource(string $keyPath): ?string + { + return $this->sourceMap[$keyPath] ?? null; + } + + /** + * Get all configuration sources with their metadata. + */ + public function getConfigurationSources(): array + { + $sources = []; + + foreach ($this->sourceMap as $keyPath => $source) { + if (!isset($sources[$source])) { + $sources[$source] = [ + 'source' => $source, + 'keys' => [], + ]; + } + $sources[$source]['keys'][] = $keyPath; + } + + return $sources; + } + + /** + * Get configuration conflicts that occurred during merging. + */ + public function getConfigurationConflicts(): array + { + return $this->conflicts; + } + + /** + * Check if there were any configuration conflicts. + */ + public function hasConfigurationConflicts(): bool + { + return !empty($this->conflicts); + } + + /** + * Get conflicts for a specific configuration key. + */ + public function getConflictsForKey(string $keyPath): array + { + return array_filter( + $this->conflicts, + fn (array $conflict): bool => $conflict['key_path'] === $keyPath, + ); + } + + /** + * Get merge summary with statistics. + */ + public function getMergeSummary(): array + { + return $this->mergeSummary; + } + + /** + * Check if a tool uses a custom configuration file. + */ + public function usesCustomConfigFile(string $tool): bool + { + $toolConfig = $this->getToolConfig($tool); + + return isset($toolConfig['use_custom_config']) && $toolConfig['use_custom_config'] === true; + } + + /** + * Get the path to a tool's custom configuration file. + */ + public function getCustomConfigFilePath(string $tool): ?string + { + $toolConfig = $this->getToolConfig($tool); + + return $toolConfig['config_file'] ?? null; + } + + /** + * Get configuration with source attribution for debugging. + */ + public function getConfigurationWithSources(): array + { + $config = $this->toArray(); + $withSources = []; + + $this->addSourceAttributionRecursive($config, $withSources, []); + + return $withSources; + } + + /** + * Add source attribution recursively to configuration data. + */ + private function addSourceAttributionRecursive( + array $data, + array &$target, + array $keyPath, + ): void { + foreach ($data as $key => $value) { + $currentKeyPath = array_merge($keyPath, [$key]); + $keyPathStr = implode('.', $currentKeyPath); + + if (\is_array($value)) { + $target[$key] = []; + $this->addSourceAttributionRecursive($value, $target[$key], $currentKeyPath); + } else { + $target[$key] = [ + 'value' => $value, + 'source' => $this->getConfigurationSource($keyPathStr), + ]; + } + } + } + + /** + * Get tool-specific configuration with override handling. + */ + public function getToolConfigurationResolved(string $tool): array + { + // If tool uses custom config file, don't merge with unified config + if ($this->usesCustomConfigFile($tool)) { + $customConfigPath = $this->getCustomConfigFilePath($tool); + + return [ + 'use_custom_config' => true, + 'config_file' => $customConfigPath, + 'unified_config_ignored' => true, + ]; + } + + // Return normal tool configuration + $qualityTools = $this->data['quality-tools'] ?? []; + $toolsConfig = $qualityTools['tools'] ?? []; + + return $toolsConfig[$tool] ?? []; + } + + /** + * Get hierarchy information if available. + */ + public function getHierarchyInfo(): ?array + { + return $this->hierarchy?->getDebugInfo(); + } + + /** + * Get discovery information if available. + */ + public function getDiscoveryInfo(): ?array + { + return $this->discovery?->getDiscoveryDebugInfo(); + } + + /** + * Check if hierarchical configuration is active. + */ + public function isHierarchicalConfiguration(): bool + { + return $this->hierarchy !== null && $this->discovery !== null; + } + + /** + * Get all tools that have custom configuration files. + */ + public function getToolsWithCustomConfigs(): array + { + $tools = []; + + foreach (['rector', 'phpstan', 'php-cs-fixer', 'fractor', 'typoscript-lint'] as $tool) { + if ($this->usesCustomConfigFile($tool)) { + $tools[$tool] = $this->getCustomConfigFilePath($tool); + } + } + + return $tools; + } + + /** + * Get comprehensive debug information. + */ + public function getComprehensiveDebugInfo(): array + { + $debugInfo = [ + 'is_hierarchical' => $this->isHierarchicalConfiguration(), + 'project_root' => $this->getProjectRoot(), + 'configuration_sources' => $this->getConfigurationSources(), + 'has_conflicts' => $this->hasConfigurationConflicts(), + 'conflicts_count' => \count($this->conflicts), + 'merge_summary' => $this->getMergeSummary(), + 'tools_with_custom_configs' => $this->getToolsWithCustomConfigs(), + ]; + + if ($this->isHierarchicalConfiguration()) { + $debugInfo['hierarchy_info'] = $this->getHierarchyInfo(); + $debugInfo['discovery_info'] = $this->getDiscoveryInfo(); + } + + if ($this->hasConfigurationConflicts()) { + $debugInfo['conflicts'] = $this->getConfigurationConflicts(); + } + + return $debugInfo; + } + + /** + * Export configuration with full metadata for debugging. + */ + public function exportWithMetadata(): array + { + return [ + 'configuration' => $this->toArray(), + 'source_map' => $this->sourceMap, + 'conflicts' => $this->conflicts, + 'merge_summary' => $this->mergeSummary, + 'debug_info' => $this->getComprehensiveDebugInfo(), + ]; + } + + /** + * Create an enhanced configuration from a regular configuration. + */ + public static function fromConfiguration(Configuration $config): self + { + return new self( + data: $config->toArray(), + sourceMap: [], + conflicts: [], + mergeSummary: [], + hierarchy: null, + discovery: null, + projectRoot: $config->getProjectRoot(), + validator: null, + ); + } + + /** + * Check if a configuration value was overridden by a higher priority source. + */ + public function wasValueOverridden(string $keyPath): bool + { + return !empty($this->getConflictsForKey($keyPath)); + } + + /** + * Get the configuration chain for a specific key (all sources that provided values). + */ + public function getConfigurationChain(string $keyPath): array + { + $chain = []; + $conflicts = $this->getConflictsForKey($keyPath); + + foreach ($conflicts as $conflict) { + $chain[] = [ + 'source' => $conflict['existing_source'], + 'value' => $conflict['existing_value'], + 'overridden' => true, + ]; + } + + // Add final value + $finalSource = $this->getConfigurationSource($keyPath); + if ($finalSource !== null) { + $chain[] = [ + 'source' => $finalSource, + 'value' => $this->getValueByKeyPath($keyPath), + 'overridden' => false, + ]; + } + + return $chain; + } + + /** + * Get a configuration value by dot-notation key path. + */ + private function getValueByKeyPath(string $keyPath): mixed + { + $keys = explode('.', $keyPath); + $value = $this->toArray(); + + foreach ($keys as $key) { + if (!\is_array($value) || !\array_key_exists($key, $value)) { + return null; + } + $value = $value[$key]; + } + + return $value; + } +} diff --git a/src/Configuration/HierarchicalConfigurationLoader.php b/src/Configuration/HierarchicalConfigurationLoader.php new file mode 100644 index 0000000..5a6563c --- /dev/null +++ b/src/Configuration/HierarchicalConfigurationLoader.php @@ -0,0 +1,274 @@ +filesystemService, + $this->securityService, + $this->validator, + ); + $merger = new ConfigurationMerger(); + + // Discover all configuration sources + $configurations = $discovery->discoverConfigurations(); + + // Add command line overrides as highest priority + if (!empty($commandLineOverrides)) { + $configurations[] = [ + 'source' => 'command_line', + 'file_path' => null, + 'file_type' => 'array', + 'tool' => null, + 'data' => $commandLineOverrides, + 'precedence' => -1, // Highest priority + 'timestamp' => time(), + ]; + } + + // Merge all configurations + $mergeResult = $merger->mergeConfigurations($configurations); + + // Validate final merged configuration + $this->validateMergedConfiguration($mergeResult['data']); + + // Create enhanced configuration with full metadata + $enhanced = new EnhancedConfiguration( + data: $mergeResult['data'], + sourceMap: $mergeResult['source_map'], + conflicts: $mergeResult['conflicts'], + mergeSummary: $mergeResult['merge_summary'], + hierarchy: $hierarchy, + discovery: $discovery, + projectRoot: $projectRoot, + validator: $this->validator, + ); + + return $enhanced; + } + + /** + * Load configuration for a specific tool with tool-specific precedence. + */ + public function loadForTool(string $projectRoot, string $tool, array $commandLineOverrides = []): EnhancedConfiguration + { + $hierarchy = new ConfigurationHierarchy($projectRoot); + $discovery = new ConfigurationDiscovery( + $hierarchy, + $this->filesystemService, + $this->securityService, + $this->validator, + ); + + // Get configurations that affect this tool + $configurations = $discovery->getToolAffectingConfigurations($tool); + + // If tool has its own config file, it overrides unified configurations + if ($discovery->hasToolConfiguration($tool)) { + $toolConfigPath = $discovery->getToolConfigurationPath($tool); + if ($toolConfigPath !== null) { + // Mark that this tool uses a custom config file + $commandLineOverrides['quality-tools']['tools'][$tool]['config_file'] = $toolConfigPath; + $commandLineOverrides['quality-tools']['tools'][$tool]['use_custom_config'] = true; + } + } + + // Add command line overrides + if (!empty($commandLineOverrides)) { + $configurations[] = [ + 'source' => 'command_line', + 'file_path' => null, + 'file_type' => 'array', + 'tool' => $tool, + 'data' => $commandLineOverrides, + 'precedence' => -1, + 'timestamp' => time(), + ]; + } + + $merger = new ConfigurationMerger(); + $mergeResult = $merger->mergeConfigurations($configurations); + + // Validate final configuration + $this->validateMergedConfiguration($mergeResult['data']); + + return new EnhancedConfiguration( + data: $mergeResult['data'], + sourceMap: $mergeResult['source_map'], + conflicts: $mergeResult['conflicts'], + mergeSummary: $mergeResult['merge_summary'], + hierarchy: $hierarchy, + discovery: $discovery, + projectRoot: $projectRoot, + validator: $this->validator, + ); + } + + /** + * Validate the final merged configuration. + */ + private function validateMergedConfiguration(array $data): void + { + if (empty($data)) { + return; // Empty configuration is valid + } + + $validationResult = $this->validator->validateSafe($data); + if (!$validationResult->isValid()) { + $errors = implode("\n", $validationResult->getErrors()); + throw new ConfigurationLoadException("Invalid merged configuration:\n$errors", 'merged'); + } + } + + /** + * Create a simple loader for backward compatibility. + */ + public function createSimpleConfiguration(string $projectRoot): Configuration + { + $enhanced = $this->load($projectRoot); + + return new Configuration($enhanced->toArray(), $this->validator); + } + + /** + * Check if hierarchical configuration is available for a project. + */ + public function hasHierarchicalConfiguration(string $projectRoot): bool + { + $hierarchy = new ConfigurationHierarchy($projectRoot); + $existingFiles = $hierarchy->getExistingConfigurationFiles(); + + return !empty($existingFiles); + } + + /** + * Get configuration loading errors for diagnostic purposes. + * + * @return array Array of file paths mapped to error messages + */ + public function getConfigurationErrors(string $projectRoot): array + { + $hierarchy = new ConfigurationHierarchy($projectRoot); + $discovery = new ConfigurationDiscovery( + $hierarchy, + $this->filesystemService, + $this->securityService, + $this->validator, + ); + + // Trigger discovery to collect errors + $discovery->discoverConfigurations(); + + return $discovery->getConfigurationErrors(); + } + + /** + * Get debug information about configuration loading for a project. + */ + public function getConfigurationDebugInfo(string $projectRoot): array + { + $hierarchy = new ConfigurationHierarchy($projectRoot); + $discovery = new ConfigurationDiscovery( + $hierarchy, + $this->filesystemService, + $this->securityService, + $this->validator, + ); + + return [ + 'project_root' => $projectRoot, + 'hierarchy_info' => $hierarchy->getDebugInfo(), + 'discovery_info' => $discovery->getDiscoveryDebugInfo(), + 'has_hierarchical_config' => $this->hasHierarchicalConfiguration($projectRoot), + ]; + } + + /** + * Preview what the merged configuration would look like without loading. + */ + public function previewMergedConfiguration(string $projectRoot, array $commandLineOverrides = []): array + { + $hierarchy = new ConfigurationHierarchy($projectRoot); + $discovery = new ConfigurationDiscovery( + $hierarchy, + $this->filesystemService, + $this->securityService, + $this->validator, + ); + + $configurations = $discovery->discoverConfigurations(); + + if (!empty($commandLineOverrides)) { + $configurations[] = [ + 'source' => 'command_line', + 'file_path' => null, + 'file_type' => 'array', + 'tool' => null, + 'data' => $commandLineOverrides, + 'precedence' => -1, + 'timestamp' => time(), + ]; + } + + $merger = new ConfigurationMerger(); + + return $merger->mergeConfigurations($configurations); + } + + /** + * Get all configuration files that would be loaded for a project. + */ + public function getConfigurationSources(string $projectRoot): array + { + $hierarchy = new ConfigurationHierarchy($projectRoot); + $discovery = new ConfigurationDiscovery( + $hierarchy, + $this->filesystemService, + $this->securityService, + $this->validator, + ); + + $configurations = $discovery->discoverConfigurations(); + $sources = []; + + foreach ($configurations as $config) { + $sources[] = [ + 'source' => $config['source'], + 'file_path' => $config['file_path'], + 'file_type' => $config['file_type'], + 'tool' => $config['tool'], + 'precedence' => $config['precedence'], + 'exists' => $config['file_path'] !== null ? file_exists($config['file_path']) : true, + 'readable' => $config['file_path'] !== null ? is_readable($config['file_path']) : true, + ]; + } + + return $sources; + } +} diff --git a/src/Configuration/YamlConfigurationLoader.php b/src/Configuration/YamlConfigurationLoader.php index 2a3b5a2..1eca1cb 100644 --- a/src/Configuration/YamlConfigurationLoader.php +++ b/src/Configuration/YamlConfigurationLoader.php @@ -4,16 +4,17 @@ namespace Cpsit\QualityTools\Configuration; -use Cpsit\QualityTools\Exception\ConfigurationFileNotFoundException; -use Cpsit\QualityTools\Exception\ConfigurationFileNotReadableException; -use Cpsit\QualityTools\Exception\ConfigurationLoadException; -use Cpsit\QualityTools\Exception\FileSystemException; use Cpsit\QualityTools\Service\FilesystemService; use Cpsit\QualityTools\Service\SecurityService; -use Symfony\Component\Yaml\Yaml; +use Cpsit\QualityTools\Traits\ConfigurationFileReaderTrait; +use Cpsit\QualityTools\Traits\EnvironmentVariableInterpolationTrait; +use Cpsit\QualityTools\Traits\YamlFileLoaderTrait; final readonly class YamlConfigurationLoader { + use ConfigurationFileReaderTrait; + use EnvironmentVariableInterpolationTrait; + use YamlFileLoaderTrait; private const array CONFIG_FILES = [ '.quality-tools.yaml', 'quality-tools.yaml', @@ -86,79 +87,6 @@ private function loadProjectConfiguration(string $projectRoot): array return []; } - private function readConfigurationFile(string $path): string - { - try { - return $this->filesystemService->readFile($path); - } catch (FileSystemException $e) { - // Convert filesystem exceptions to configuration-specific exceptions - if ($e->getCode() === FileSystemException::ERROR_FILE_NOT_FOUND) { - throw new ConfigurationFileNotFoundException($path); - } - if ($e->getCode() === FileSystemException::ERROR_FILE_NOT_READABLE) { - throw new ConfigurationFileNotReadableException($path); - } - throw new ConfigurationLoadException('Failed to read configuration file: ' . $e->getMessage(), $path, $e); - } - } - - private function loadYamlFile(string $path): array - { - try { - $content = $this->readConfigurationFile($path); - - // Interpolate environment variables - $content = $this->interpolateEnvironmentVariables($content); - - // Parse YAML - $data = Yaml::parse($content); - if (!\is_array($data)) { - throw new ConfigurationLoadException('Configuration file must contain valid YAML data', $path); - } - - // Validate configuration - $validationResult = $this->validator->validateSafe($data); - if (!$validationResult->isValid()) { - $errors = implode("\n", $validationResult->getErrors()); - throw new ConfigurationLoadException("Invalid configuration:\n$errors", $path); - } - - return $data; - } catch (ConfigurationFileNotFoundException|ConfigurationFileNotReadableException|ConfigurationLoadException $e) { - // Re-throw configuration-specific exceptions as-is - throw $e; - } catch (\Exception $e) { - throw new ConfigurationLoadException('Failed to load configuration: ' . $e->getMessage(), $path, $e); - } - } - - private function interpolateEnvironmentVariables(string $content): string - { - return preg_replace_callback( - '/\$\{([A-Z_][A-Z0-9_]*):?([^}]*)\}/', - function (array $matches): string { - $envVar = $matches[1]; - $default = $matches[2]; - - // Handle syntax: ${VAR:-default} - if (str_starts_with($default, '-')) { - $default = substr($default, 1); - } - - // Use security service for safe environment variable access - try { - return $this->securityService->getEnvironmentVariable($envVar, $default); - } catch (\RuntimeException $e) { - if ($default !== '') { - return $default; - } - throw $e; - } - }, - $content, - ); - } - private function mergeConfigurations(array $configurations): array { $merged = []; @@ -176,9 +104,9 @@ private function deepMerge(array $array1, array $array2): array foreach ($array2 as $key => $value) { if (\is_array($value) && isset($merged[$key]) && \is_array($merged[$key])) { - // If both arrays are indexed (not associative), replace rather than merge + // If both arrays are indexed (not associative), merge and deduplicate if ($this->isIndexedArray($value) && $this->isIndexedArray($merged[$key])) { - $merged[$key] = $value; + $merged[$key] = array_values(array_unique(array_merge($merged[$key], $value))); } else { $merged[$key] = $this->deepMerge($merged[$key], $value); } diff --git a/src/Console/Command/BaseCommand.php b/src/Console/Command/BaseCommand.php index 58d8ec8..abcbfd2 100644 --- a/src/Console/Command/BaseCommand.php +++ b/src/Console/Command/BaseCommand.php @@ -6,6 +6,7 @@ use Cpsit\QualityTools\Configuration\Configuration; use Cpsit\QualityTools\Configuration\ConfigurationValidator; +use Cpsit\QualityTools\Configuration\HierarchicalConfigurationLoader; use Cpsit\QualityTools\Configuration\YamlConfigurationLoader; use Cpsit\QualityTools\Console\QualityToolsApplication; use Cpsit\QualityTools\DependencyInjection\ContainerAwareInterface; @@ -520,4 +521,18 @@ protected function getYamlConfigurationLoader(): YamlConfigurationLoader new FilesystemService(), ); } + + protected function getHierarchicalConfigurationLoader(): HierarchicalConfigurationLoader + { + if ($this->hasService(HierarchicalConfigurationLoader::class)) { + return $this->getService(HierarchicalConfigurationLoader::class); + } + + // Fallback for tests and scenarios without DI container + return new HierarchicalConfigurationLoader( + new ConfigurationValidator(), + new SecurityService(), + new FilesystemService(), + ); + } } diff --git a/src/Console/Command/ConfigShowCommand.php b/src/Console/Command/ConfigShowCommand.php index 87564f1..18cbd18 100644 --- a/src/Console/Command/ConfigShowCommand.php +++ b/src/Console/Command/ConfigShowCommand.php @@ -4,7 +4,7 @@ namespace Cpsit\QualityTools\Console\Command; -use Cpsit\QualityTools\Configuration\YamlConfigurationLoader; +use Cpsit\QualityTools\Configuration\HierarchicalConfigurationLoader; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -44,14 +44,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int } try { - $configuration = $this->getConfiguration($input); - $configData = $configuration->toArray(); + // For config:show command, we need to validate that critical configuration + // files can be loaded. If they can't, we should fail. + $this->validateCriticalConfigurationFiles($projectRoot); + + // Use hierarchical configuration loader specifically for config:show + $loader = $this->getHierarchicalConfigurationLoader(); + $enhancedConfiguration = $loader->load($projectRoot); + $configData = $enhancedConfiguration->toArray(); $io->title('Resolved Configuration'); // Show configuration file sources if verbose if ($output->isVerbose()) { - $loader = $this->getYamlConfigurationLoader(); $this->showConfigurationSources($io, $loader, $projectRoot); } @@ -79,32 +84,107 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - private function showConfigurationSources(SymfonyStyle $io, YamlConfigurationLoader $loader, string $projectRoot): void + private function showConfigurationSources(SymfonyStyle $io, HierarchicalConfigurationLoader $loader, string $projectRoot): void { $io->section('Configuration Sources'); $sources = []; - // Check for global configuration - $homeDir = getenv('HOME') ?: ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE'] ?? ''); - if (!empty($homeDir)) { - $globalConfig = $homeDir . '/.quality-tools.yaml'; - if (file_exists($globalConfig)) { - $sources[] = \sprintf('Global: %s', $globalConfig); + // Get all configuration sources from hierarchical loader + try { + $configSources = $loader->getConfigurationSources($projectRoot); + foreach ($configSources as $source) { + if ($source['file_path'] !== null) { + $label = match ($source['source']) { + 'project_root' => 'Project', + 'config_dir' => 'Config directory', + 'global' => 'Global', + 'package_config' => 'Package', + 'tool_specific' => 'Tool-specific', + 'tool_config_dir' => 'Tool config dir', + default => ucfirst((string) $source['source']) + }; + $sources[] = \sprintf('%s: %s', $label, $source['file_path']); + } elseif ($source['source'] === 'package_defaults') { + $sources[] = 'Package defaults (built-in)'; + } } + } catch (\Exception) { + $sources[] = 'Package defaults (built-in)'; } - // Check for project configuration - $projectConfig = $loader->findConfigurationFile($projectRoot); - if ($projectConfig !== null) { - $sources[] = \sprintf('Project: %s', $projectConfig); + $io->listing($sources); + + // Show configuration errors if any occurred + $configErrors = $loader->getConfigurationErrors($projectRoot); + if (!empty($configErrors)) { + $io->warning('Some configuration files could not be loaded:'); + foreach ($configErrors as $filePath => $error) { + $io->text(\sprintf('• %s: %s', $filePath, $error)); + } + $io->newLine(); } - // Show package defaults - $sources[] = 'Package defaults (built-in)'; + $io->newLine(); + } + + /** + * Validate that critical configuration files can be loaded. + * + * @throws \RuntimeException when critical configuration files fail to load + */ + private function validateCriticalConfigurationFiles(string $projectRoot): void + { + $hierarchy = new \Cpsit\QualityTools\Configuration\ConfigurationHierarchy($projectRoot); + $existingFiles = $hierarchy->getExistingConfigurationFiles(); - $io->listing($sources); + // Check project_root and config_dir configuration files + foreach (['project_root', 'config_dir'] as $criticalLevel) { + if (!isset($existingFiles[$criticalLevel])) { + continue; + } - $io->newLine(); + foreach ($existingFiles[$criticalLevel] as $fileInfo) { + try { + // Try to load the configuration file directly + $securityService = new \Cpsit\QualityTools\Service\SecurityService(); + $validator = new \Cpsit\QualityTools\Configuration\ConfigurationValidator(); + + // Load the file content + $content = file_get_contents($fileInfo['path']); + if ($content === false) { + throw new \RuntimeException("Cannot read configuration file: {$fileInfo['path']}"); + } + + // Parse YAML and interpolate environment variables + $data = Yaml::parse($content); + if (!\is_array($data)) { + throw new \RuntimeException('Configuration file must contain valid YAML data'); + } + + // Interpolate environment variables + $interpolatedContent = preg_replace_callback( + '/\$\{([A-Z_][A-Z0-9_]*):?([^}]*)\}/', + function (array $matches) use ($securityService): string { + $envVar = $matches[1]; + $default = $matches[2]; + + // Handle syntax: ${VAR:-default} + if (str_starts_with($default, '-')) { + $default = substr($default, 1); + } + + return $securityService->getEnvironmentVariable($envVar, $default); + }, + $content, + ); + + // Re-parse after interpolation + Yaml::parse($interpolatedContent); + } catch (\Exception $e) { + throw new \RuntimeException('Failed to load configuration: ' . $e->getMessage()); + } + } + } } } diff --git a/src/Console/QualityToolsApplication.php b/src/Console/QualityToolsApplication.php index 97701cc..1ca017c 100644 --- a/src/Console/QualityToolsApplication.php +++ b/src/Console/QualityToolsApplication.php @@ -144,7 +144,7 @@ private function registerCommands(): void } $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($commandDir, \RecursiveDirectoryIterator::SKIP_DOTS), + new \RecursiveDirectoryIterator($commandDir, \FilesystemIterator::SKIP_DOTS), ); foreach ($iterator as $file) { diff --git a/src/Exception/ConfigurationException.php b/src/Exception/ConfigurationException.php index fb2461b..70aac70 100644 --- a/src/Exception/ConfigurationException.php +++ b/src/Exception/ConfigurationException.php @@ -9,10 +9,10 @@ */ class ConfigurationException extends QualityToolsException { - public const ERROR_CONFIG_FILE_NOT_FOUND = 1001; - public const ERROR_CONFIG_FILE_INVALID = 1002; - public const ERROR_CONFIG_PATH_NOT_ACCESSIBLE = 1003; - public const ERROR_CONFIG_VALIDATION_FAILED = 1004; + public const int ERROR_CONFIG_FILE_NOT_FOUND = 1001; + public const int ERROR_CONFIG_FILE_INVALID = 1002; + public const int ERROR_CONFIG_PATH_NOT_ACCESSIBLE = 1003; + public const int ERROR_CONFIG_VALIDATION_FAILED = 1004; public function __construct( string $message = '', diff --git a/src/Exception/FileSystemException.php b/src/Exception/FileSystemException.php index 03eedee..baa0efc 100644 --- a/src/Exception/FileSystemException.php +++ b/src/Exception/FileSystemException.php @@ -9,12 +9,12 @@ */ class FileSystemException extends QualityToolsException { - public const ERROR_FILE_NOT_FOUND = 3001; - public const ERROR_DIRECTORY_NOT_FOUND = 3002; - public const ERROR_FILE_NOT_READABLE = 3003; - public const ERROR_FILE_NOT_WRITABLE = 3004; - public const ERROR_PERMISSION_DENIED = 3005; - public const ERROR_DISK_FULL = 3006; + public const int ERROR_FILE_NOT_FOUND = 3001; + public const int ERROR_DIRECTORY_NOT_FOUND = 3002; + public const int ERROR_FILE_NOT_READABLE = 3003; + public const int ERROR_FILE_NOT_WRITABLE = 3004; + public const int ERROR_PERMISSION_DENIED = 3005; + public const int ERROR_DISK_FULL = 3006; public function __construct( string $message = '', diff --git a/src/Exception/PathScannerException.php b/src/Exception/PathScannerException.php index 5702a5f..c616f93 100644 --- a/src/Exception/PathScannerException.php +++ b/src/Exception/PathScannerException.php @@ -9,9 +9,9 @@ */ class PathScannerException extends QualityToolsException { - public const ERROR_PATH_RESOLUTION_FAILED = 6001; - public const ERROR_PATH_VALIDATION_FAILED = 6002; - public const ERROR_PATTERN_INVALID = 6003; + public const int ERROR_PATH_RESOLUTION_FAILED = 6001; + public const int ERROR_PATH_VALIDATION_FAILED = 6002; + public const int ERROR_PATTERN_INVALID = 6003; public function __construct( string $message = '', diff --git a/src/Exception/ProcessException.php b/src/Exception/ProcessException.php index cfab479..b14c664 100644 --- a/src/Exception/ProcessException.php +++ b/src/Exception/ProcessException.php @@ -9,11 +9,11 @@ */ class ProcessException extends QualityToolsException { - public const ERROR_PROCESS_EXECUTION_FAILED = 2001; - public const ERROR_PROCESS_TIMEOUT = 2002; - public const ERROR_PROCESS_MEMORY_LIMIT = 2003; - public const ERROR_PROCESS_BINARY_NOT_FOUND = 2004; - public const ERROR_PROCESS_PERMISSION_DENIED = 2005; + public const int ERROR_PROCESS_EXECUTION_FAILED = 2001; + public const int ERROR_PROCESS_TIMEOUT = 2002; + public const int ERROR_PROCESS_MEMORY_LIMIT = 2003; + public const int ERROR_PROCESS_BINARY_NOT_FOUND = 2004; + public const int ERROR_PROCESS_PERMISSION_DENIED = 2005; public function __construct( string $message = '', diff --git a/src/Exception/TransientException.php b/src/Exception/TransientException.php index 9b5cc17..3f02352 100644 --- a/src/Exception/TransientException.php +++ b/src/Exception/TransientException.php @@ -9,11 +9,11 @@ */ class TransientException extends QualityToolsException { - public const ERROR_NETWORK_TIMEOUT = 4001; - public const ERROR_TEMPORARY_FILE_LOCK = 4002; - public const ERROR_MEMORY_PRESSURE = 4003; - public const ERROR_SERVICE_UNAVAILABLE = 4004; - public const ERROR_RATE_LIMIT_EXCEEDED = 4005; + public const int ERROR_NETWORK_TIMEOUT = 4001; + public const int ERROR_TEMPORARY_FILE_LOCK = 4002; + public const int ERROR_MEMORY_PRESSURE = 4003; + public const int ERROR_SERVICE_UNAVAILABLE = 4004; + public const int ERROR_RATE_LIMIT_EXCEEDED = 4005; public function __construct( string $message = '', diff --git a/src/Traits/ConfigurationFileReaderTrait.php b/src/Traits/ConfigurationFileReaderTrait.php new file mode 100644 index 0000000..4ccef26 --- /dev/null +++ b/src/Traits/ConfigurationFileReaderTrait.php @@ -0,0 +1,55 @@ +filesystemService->readFile($path); + } catch (FileSystemException $e) { + // Convert filesystem exceptions to configuration-specific exceptions + if ($e->getCode() === FileSystemException::ERROR_FILE_NOT_FOUND) { + throw new ConfigurationFileNotFoundException($path); + } + if ($e->getCode() === FileSystemException::ERROR_FILE_NOT_READABLE) { + throw new ConfigurationFileNotReadableException($path); + } + throw new ConfigurationLoadException('Failed to read configuration file: ' . $e->getMessage(), $path, $e); + } catch (\Exception $e) { + // Handle cases where filesystemService is not available or other errors + if (!file_exists($path)) { + throw new ConfigurationFileNotFoundException($path); + } + if (!is_readable($path)) { + throw new ConfigurationFileNotReadableException($path); + } + throw new ConfigurationLoadException('Failed to read configuration file: ' . $e->getMessage(), $path, $e); + } + } +} diff --git a/src/Traits/EnvironmentVariableInterpolationTrait.php b/src/Traits/EnvironmentVariableInterpolationTrait.php new file mode 100644 index 0000000..ca8a0e0 --- /dev/null +++ b/src/Traits/EnvironmentVariableInterpolationTrait.php @@ -0,0 +1,53 @@ +securityService->getEnvironmentVariable($envVar, $default); + } catch (\RuntimeException $e) { + if ($default !== '') { + return $default; + } + throw $e; + } + }, + $content, + ); + } +} diff --git a/src/Traits/YamlFileLoaderTrait.php b/src/Traits/YamlFileLoaderTrait.php new file mode 100644 index 0000000..639d488 --- /dev/null +++ b/src/Traits/YamlFileLoaderTrait.php @@ -0,0 +1,58 @@ +readConfigurationFile($path); + + // Apply environment variable interpolation + $content = $this->interpolateEnvironmentVariables($content); + + $data = Yaml::parse($content); + if (!\is_array($data)) { + throw new ConfigurationLoadException('Configuration file must contain valid YAML data', $path); + } + + // Validate configuration if validator is available + if (isset($this->validator)) { + $validationResult = $this->validator->validateSafe($data); + if (!$validationResult->isValid()) { + $errors = implode("\n", $validationResult->getErrors()); + throw new ConfigurationLoadException("Invalid configuration:\n$errors", $path); + } + } + + return $data; + } catch (ConfigurationFileNotFoundException|ConfigurationFileNotReadableException $e) { + throw $e; + } catch (\Exception $e) { + throw new ConfigurationLoadException('Failed to load YAML configuration: ' . $e->getMessage(), $path, $e); + } + } +} diff --git a/tests/Integration/Configuration/ConfigurationMergingTest.php b/tests/Integration/Configuration/ConfigurationMergingTest.php index cb9549d..aa3ed08 100644 --- a/tests/Integration/Configuration/ConfigurationMergingTest.php +++ b/tests/Integration/Configuration/ConfigurationMergingTest.php @@ -148,8 +148,8 @@ public function testThreeTierConfigurationMerging(): void self::assertTrue($config->isCacheEnabled()); // from global // Test paths configuration merging - self::assertSame(['packages/', 'src/', 'tests/'], $config->getScanPaths()); // from project - self::assertSame(['var/', 'vendor/', 'node_modules/'], $config->getExcludePaths()); // project override + self::assertSame(['packages/', 'config/system/', 'src/', 'tests/'], $config->getScanPaths()); // defaults + project + self::assertSame(['var/', 'vendor/', 'public/', '_assets/', 'fileadmin/', 'typo3/', 'Tests/', 'tests/', 'typo3conf/', 'build/', 'node_modules/'], $config->getExcludePaths()); // defaults + global + project } public function testComplexEnvironmentVariableInterpolation(): void @@ -231,11 +231,11 @@ public function testComplexEnvironmentVariableInterpolation(): void // Test interpolated paths self::assertSame( - ['custom-packages/', 'src/', 'integration-tests/'], + ['packages/', 'config/system/', 'custom-packages/', 'src/', 'integration-tests/'], $config->getScanPaths(), ); self::assertSame( - ['var/', 'vendor/', 'node_modules/'], + ['var/', 'vendor/', 'public/', '_assets/', 'fileadmin/', 'typo3/', 'Tests/', 'tests/', 'typo3conf/', 'node_modules/'], $config->getExcludePaths(), ); // all defaults @@ -395,7 +395,7 @@ public function testNestedConfigurationOverrides(): void self::assertSame('1G', $phpStanConfig['memory_limit']); // from global // Test PHPStan path overrides $phpStanPaths = $config->getToolPaths('phpstan'); - self::assertSame(['src/', 'packages/'], $phpStanPaths['scan'] ?? []); // project override + self::assertSame(['default-path/', 'src/', 'packages/'], $phpStanPaths['scan'] ?? []); // global + project merge // Test Fractor merging $fractorConfig = $config->getFractorConfig(); @@ -404,7 +404,7 @@ public function testNestedConfigurationOverrides(): void // Test Fractor path overrides $fractorPaths = $config->getToolPaths('fractor'); - self::assertSame(['project-skip.ts', 'another-skip.ts'], $fractorPaths['exclude'] ?? []); // project override + self::assertSame(['global-skip.ts', 'project-skip.ts', 'another-skip.ts'], $fractorPaths['exclude'] ?? []); // global + project merge // Test PHP CS Fixer merging $phpCsFixerConfig = $config->getPhpCsFixerConfig(); @@ -419,7 +419,7 @@ public function testNestedConfigurationOverrides(): void // Test TypoScript Lint path overrides $typoscriptLintPaths = $config->getToolPaths('typoscript-lint'); - self::assertSame(['project-ignore.ts'], $typoscriptLintPaths['exclude'] ?? []); // project override + self::assertSame(['global-ignore.ts', 'project-ignore.ts'], $typoscriptLintPaths['exclude'] ?? []); // global + project merge } public function testArrayMergingBehavior(): void @@ -469,15 +469,15 @@ public function testArrayMergingBehavior(): void fn (): Configuration => $this->loader->load($this->tempDir), ); - // Arrays should be replaced by project config, not merged - self::assertSame(['project-scan/'], $config->getScanPaths()); + // Arrays should be merged: defaults + global + project (with deduplication) + self::assertSame(['packages/', 'config/system/', 'global-scan1/', 'global-scan2/', 'project-scan/'], $config->getScanPaths()); self::assertSame( - ['project-exclude1/', 'project-exclude2/', 'project-exclude3/'], + ['var/', 'vendor/', 'public/', '_assets/', 'fileadmin/', 'typo3/', 'Tests/', 'tests/', 'typo3conf/', 'global-exclude1/', 'global-exclude2/', 'project-exclude1/', 'project-exclude2/', 'project-exclude3/'], $config->getExcludePaths(), ); $phpStanPaths = $config->getToolPaths('phpstan'); - self::assertSame(['project-phpstan/'], $phpStanPaths['scan'] ?? []); + self::assertSame(['global-phpstan1/', 'global-phpstan2/', 'project-phpstan/'], $phpStanPaths['scan'] ?? []); } public function testConfigurationWithoutGlobalFile(): void diff --git a/tests/Integration/Configuration/HierarchicalConfigurationTest.php b/tests/Integration/Configuration/HierarchicalConfigurationTest.php new file mode 100644 index 0000000..fc6cb52 --- /dev/null +++ b/tests/Integration/Configuration/HierarchicalConfigurationTest.php @@ -0,0 +1,494 @@ +projectRoot = TestHelper::createTempDirectory('hierarchical_config_test_'); + $this->loader = new HierarchicalConfigurationLoader( + new ConfigurationValidator(), + new SecurityService(), + new FilesystemService(), + ); + + // Create a temporary home directory for global config tests + $this->originalHome = $_SERVER['HOME'] ?? ''; + $tempHome = TestHelper::createTempDirectory('home_config_test_'); + $_SERVER['HOME'] = $tempHome; + putenv('HOME=' . $tempHome); // Set the environment variable so getenv() works + $this->globalConfigPath = $tempHome . '/.quality-tools.yaml'; + } + + protected function tearDown(): void + { + TestHelper::removeDirectory($this->projectRoot); + if (file_exists($this->globalConfigPath)) { + TestHelper::removeDirectory(\dirname($this->globalConfigPath)); + } + $_SERVER['HOME'] = $this->originalHome; + if ($this->originalHome !== '' && $this->originalHome !== '0') { + putenv('HOME=' . $this->originalHome); + } else { + putenv('HOME'); // Unset environment variable + } + } + + /** + * Test Scenario 1: Basic project-level configuration loading. + * + * This test verifies that a simple project configuration file + * is loaded correctly and parsed into the configuration object. + */ + public function testBasicConfigurationLoading(): void + { + $projectConfig = <<projectRoot . '/.quality-tools.yaml', $projectConfig); + + $config = $this->loader->load($this->projectRoot); + + self::assertSame('test-project', $config->getProjectName()); + self::assertSame('8.4', $config->getProjectPhpVersion()); + + $phpStanConfig = $config->getToolConfig('phpstan'); + self::assertSame(7, $phpStanConfig['level']); + self::assertTrue($phpStanConfig['enabled']); + } + + /** + * Test Scenario 2: Global configuration in home directory. + * + * This test verifies that the global configuration in ~/.quality-tools.yaml + * is loaded and provides default values for projects. + */ + public function testGlobalConfigurationLoading(): void + { + $globalConfig = <<globalConfigPath, $globalConfig); + + $config = $this->loader->load($this->projectRoot); + + self::assertSame('8.3', $config->getProjectPhpVersion()); + self::assertSame('13.4', $config->getProjectTypo3Version()); + + $phpStanConfig = $config->getToolConfig('phpstan'); + self::assertSame(6, $phpStanConfig['level']); + self::assertSame('2G', $phpStanConfig['memory_limit']); + self::assertTrue($phpStanConfig['enabled']); + } + + /** + * Test Scenario 3: Project configuration overrides global configuration. + * + * This test verifies the precedence hierarchy where project-level + * configuration overrides global defaults. + */ + public function testProjectOverridesGlobal(): void + { + $globalConfig = <<globalConfigPath, $globalConfig); + file_put_contents($this->projectRoot . '/.quality-tools.yaml', $projectConfig); + + $config = $this->loader->load($this->projectRoot); + + // Project values should override global + self::assertSame('override-project', $config->getProjectName()); + self::assertSame('8.4', $config->getProjectPhpVersion()); + + $phpStanConfig = $config->getToolConfig('phpstan'); + self::assertSame(8, $phpStanConfig['level']); // Overridden by a project + self::assertSame('2G', $phpStanConfig['memory_limit']); // From global + } + + /** + * Test Scenario 4: Project configuration overrides the config directory. + * + * This test verifies that the project root configuration has higher precedence + * than config/ directory configuration (per Feature 015 spec). + */ + public function testProjectOverridesConfigDirectory(): void + { + $projectConfig = <<projectRoot . '/.quality-tools.yaml', $projectConfig); + + // Create config directory and configuration + $configDir = $this->projectRoot . '/config'; + mkdir($configDir, 0o777, true); + file_put_contents($configDir . '/quality-tools.yaml', $configDirConfig); + + $config = $this->loader->load($this->projectRoot); + + $phpStanConfig = $config->getToolConfig('phpstan'); + self::assertSame(6, $phpStanConfig['level']); // Project root overrides config dir + self::assertTrue($phpStanConfig['enabled']); // From project + + $rectorConfig = $config->getToolConfig('rector'); + self::assertFalse($rectorConfig['enabled']); // Project root overrides config dir + self::assertSame('typo3-13', $rectorConfig['level']); // From config dir (not overridden by project) + } + + /** + * Test Scenario 5: Multiple tool configuration merging. + * + * This test verifies that different tools can be configured independently + * and that the configuration merging works correctly for multiple tools. + */ + public function testMultipleToolConfiguration(): void + { + $projectConfig = <<projectRoot . '/.quality-tools.yaml', $projectConfig); + + $config = $this->loader->load($this->projectRoot); + + // Verify each tool gets its own configuration + $phpStanConfig = $config->getToolConfig('phpstan'); + self::assertSame(6, $phpStanConfig['level']); + self::assertTrue($phpStanConfig['enabled']); + + $rectorConfig = $config->getToolConfig('rector'); + self::assertTrue($rectorConfig['enabled']); + self::assertSame('typo3-12', $rectorConfig['level']); + + $phpCsFixerConfig = $config->getToolConfig('php-cs-fixer'); + self::assertFalse($phpCsFixerConfig['enabled']); + self::assertSame('psr12', $phpCsFixerConfig['preset']); + } + + /** + * Test Scenario 6: Environment variable interpolation in configuration. + * + * This test verifies that environment variables are properly interpolated + * in configuration files with default values. + */ + public function testEnvironmentVariableInterpolation(): void + { + // Set some environment variables + TestHelper::withEnvironment([ + 'PROJECT_NAME' => 'env-test-project', + 'PHPSTAN_MEMORY' => '4G', + // Leave PHP_VERSION and PHPSTAN_LEVEL unset to test defaults + ], function (): void { + $configWithEnvVars = <<projectRoot . '/.quality-tools.yaml', $configWithEnvVars); + + $config = $this->loader->load($this->projectRoot); + + // Values from environment + self::assertSame('env-test-project', $config->getProjectName()); + + $phpStanConfig = $config->getToolConfig('phpstan'); + self::assertSame('4G', $phpStanConfig['memory_limit']); + + // Default values (env vars not set) + self::assertSame('8.4', $config->getProjectPhpVersion()); + self::assertSame(6, $phpStanConfig['level']); + + // Path interpolation + $scanPaths = $config->getScanPaths(); + self::assertContains('src/', $scanPaths); // Default value used + self::assertContains('packages/', $scanPaths); + }); + } + + /** + * Test Scenario 7: Array merging and deduplication. + * + * This test verifies that arrays from multiple configuration sources + * are properly merged and deduplicated, particularly for paths. + */ + public function testArrayMergingAndDeduplication(): void + { + $globalConfig = <<globalConfigPath, $globalConfig); + file_put_contents($this->projectRoot . '/.quality-tools.yaml', $projectConfig); + + $config = $this->loader->load($this->projectRoot); + + $scanPaths = $config->getScanPaths(); + $excludePaths = $config->getExcludePaths(); + + // Scan paths should be merged and deduplicated + self::assertContains('packages/', $scanPaths); + self::assertContains('src/', $scanPaths); + self::assertContains('app/packages/', $scanPaths); + self::assertContains('config/', $scanPaths); + + // Should not contain duplicates + self::assertSame(1, array_count_values($scanPaths)['packages/']); + + // Exclude paths should be merged and deduplicated + self::assertContains('var/', $excludePaths); + self::assertContains('.git/', $excludePaths); + self::assertContains('vendor/', $excludePaths); + + // Should not contain duplicates + self::assertSame(1, array_count_values($excludePaths)['var/']); + } + + /** + * Test Scenario 8: Package configuration discovery and merging. + * + * This test verifies that configuration files in package directories + * are discovered and merged with lower precedence than project config. + */ + public function testPackageConfigurationMerging(): void + { + $projectConfig = <<projectRoot . '/.quality-tools.yaml', $projectConfig); + + // Create package directory structure + $packageDir = $this->projectRoot . '/packages/test-package'; + mkdir($packageDir, 0o777, true); + file_put_contents($packageDir . '/quality-tools.yaml', $packageConfig); + + $config = $this->loader->load($this->projectRoot); + + // Project config should override package config + self::assertSame('main-project', $config->getProjectName()); // From project + + $phpStanConfig = $config->getToolConfig('phpstan'); + self::assertSame(7, $phpStanConfig['level']); // Overridden by a project + self::assertSame('1G', $phpStanConfig['memory_limit']); // From package + + // Package-only config should be preserved + $rectorConfig = $config->getToolConfig('rector'); + self::assertTrue($rectorConfig['enabled']); // From package + + self::assertSame('8.3', $config->getProjectPhpVersion()); // From package + } + + /** + * Test Scenario 9: Complete hierarchy testing with all configuration sources. + * + * This test verifies the complete precedence hierarchy with all possible + * configuration sources present. + */ + public function testCompleteConfigurationHierarchy(): void + { + // Global config (the lowest precedence for conflicts) + $globalConfig = <<globalConfigPath, $globalConfig); + + $packageDir = $this->projectRoot . '/packages/hierarchy-package'; + mkdir($packageDir, 0o777, true); + file_put_contents($packageDir . '/quality-tools.yaml', $packageConfig); + + file_put_contents($this->projectRoot . '/.quality-tools.yaml', $projectConfig); + + $configDir = $this->projectRoot . '/config'; + mkdir($configDir, 0o777, true); + file_put_contents($configDir . '/quality-tools.yaml', $configDirConfig); + + $config = $this->loader->load($this->projectRoot); + + // Verify precedence hierarchy + self::assertSame('hierarchy-test', $config->getProjectName()); // From project + self::assertSame('8.4', $config->getProjectPhpVersion()); // From project (overrides package and global) + self::assertSame('12.4', $config->getProjectTypo3Version()); // From global (only source) + + $phpStanConfig = $config->getToolConfig('phpstan'); + self::assertSame(6, $phpStanConfig['level']); // From project root (higher precedence than config dir) + self::assertSame('4G', $phpStanConfig['memory_limit']); // From config dir (not overridden by project) + self::assertTrue($phpStanConfig['enabled']); // From global (no override) + } +} diff --git a/tests/Integration/Configuration/YamlConfigurationWorkflowTest.php b/tests/Integration/Configuration/YamlConfigurationWorkflowTest.php index fbfd24a..9f12107 100644 --- a/tests/Integration/Configuration/YamlConfigurationWorkflowTest.php +++ b/tests/Integration/Configuration/YamlConfigurationWorkflowTest.php @@ -245,21 +245,28 @@ public function testWorkflowWithInvalidConfiguration(): void $configFile = $this->tempDir . '/.quality-tools.yaml'; file_put_contents($configFile, $invalidConfig); - // Validation should fail - $appTester->run(['command' => 'config:validate']); + try { + // Validation should fail + $appTester->run(['command' => 'config:validate']); - self::assertSame(Command::FAILURE, $appTester->getStatusCode()); + self::assertSame(Command::FAILURE, $appTester->getStatusCode()); - $output = $appTester->getDisplay(); - self::assertStringContainsString('Unexpected Error:', $output); + $output = $appTester->getDisplay(); + self::assertStringContainsString('Unexpected Error:', $output); - // Show command should also fail - $appTester->run(['command' => 'config:show']); + // Show command should also fail + $appTester->run(['command' => 'config:show']); - self::assertSame(Command::FAILURE, $appTester->getStatusCode()); + self::assertSame(Command::FAILURE, $appTester->getStatusCode()); - $output = $appTester->getDisplay(); - self::assertStringContainsString('Failed to load configuration', $output); + $output = $appTester->getDisplay(); + self::assertStringContainsString('Failed to load configuration', $output); + } finally { + // Clean up invalid config file to prevent it from affecting other tests + if (file_exists($configFile)) { + unlink($configFile); + } + } } public function testWorkflowWithForceOverwrite(): void diff --git a/tests/Integration/Console/Command/MultiPathScanningTest.php b/tests/Integration/Console/Command/MultiPathScanningTest.php index aa5ed7b..f3e6064 100644 --- a/tests/Integration/Console/Command/MultiPathScanningTest.php +++ b/tests/Integration/Console/Command/MultiPathScanningTest.php @@ -130,7 +130,7 @@ public function phpCsFixerScansAllConfiguredPaths(): void $resolvedPaths = $config->getResolvedPathsForTool('php-cs-fixer'); // Verify all paths are resolved correctly - $this->assertCount(3, $resolvedPaths, 'All configured paths should be resolved'); + $this->assertCount(4, $resolvedPaths, 'All configured paths should be resolved'); $this->assertContains(realpath($this->tempProjectRoot . '/src'), $resolvedPaths); $this->assertContains(realpath($this->tempProjectRoot . '/vendor/cpsit/package1'), $resolvedPaths); $this->assertContains(realpath($this->tempProjectRoot . '/vendor/fr/package2'), $resolvedPaths); diff --git a/tests/Unit/Configuration/ConfigurationHierarchyTest.php b/tests/Unit/Configuration/ConfigurationHierarchyTest.php new file mode 100644 index 0000000..4a1417a --- /dev/null +++ b/tests/Unit/Configuration/ConfigurationHierarchyTest.php @@ -0,0 +1,263 @@ +tempDir = sys_get_temp_dir() . '/quality-tools-test-' . uniqid(); + mkdir($this->tempDir, 0o777, true); + $this->hierarchy = new ConfigurationHierarchy($this->tempDir); + } + + protected function tearDown(): void + { + if (is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + } + + public function testPrecedenceLevelsAreCorrectlyOrdered(): void + { + $expectedOrder = [ + 'command_line', + 'project_root', + 'config_dir', + 'tool_specific', + 'tool_config_dir', + 'package_config', + 'global', + 'package_defaults', + ]; + + $this->assertEquals($expectedOrder, ConfigurationHierarchy::PRECEDENCE_LEVELS); + } + + public function testGetPrecedenceLevel(): void + { + $this->assertEquals(0, $this->hierarchy->getPrecedenceLevel('command_line')); + $this->assertEquals(1, $this->hierarchy->getPrecedenceLevel('project_root')); + $this->assertEquals(6, $this->hierarchy->getPrecedenceLevel('global')); + $this->assertEquals(7, $this->hierarchy->getPrecedenceLevel('package_defaults')); + $this->assertEquals(8, $this->hierarchy->getPrecedenceLevel('unknown_source')); + } + + public function testHasHigherPrecedence(): void + { + $this->assertTrue($this->hierarchy->hasHigherPrecedence('command_line', 'project_root')); + $this->assertTrue($this->hierarchy->hasHigherPrecedence('project_root', 'package_defaults')); + $this->assertFalse($this->hierarchy->hasHigherPrecedence('package_defaults', 'command_line')); + } + + public function testGetConfigurationFilePathsReturnsExpectedStructure(): void + { + $paths = $this->hierarchy->getConfigurationFilePaths(); + + $this->assertIsArray($paths); + $this->assertArrayHasKey('project_root', $paths); + $this->assertArrayHasKey('config_dir', $paths); + $this->assertArrayHasKey('tool_specific', $paths); + $this->assertArrayHasKey('tool_config_dir', $paths); + + // Check project root files + $this->assertContains($this->tempDir . '/quality-tools.yaml', $paths['project_root']); + $this->assertContains($this->tempDir . '/.quality-tools.yaml', $paths['project_root']); + $this->assertContains($this->tempDir . '/quality-tools.yml', $paths['project_root']); + + // Check config directory files + $this->assertContains($this->tempDir . '/config/quality-tools.yaml', $paths['config_dir']); + + // Check tool-specific files + $this->assertContains($this->tempDir . '/rector.php', $paths['tool_specific']); + $this->assertContains($this->tempDir . '/phpstan.neon', $paths['tool_specific']); + } + + public function testGetExistingConfigurationFilesWithNoFiles(): void + { + $existingFiles = $this->hierarchy->getExistingConfigurationFiles(); + $this->assertEmpty($existingFiles); + } + + public function testGetExistingConfigurationFilesWithProjectConfig(): void + { + // Create a project configuration file + file_put_contents($this->tempDir . '/quality-tools.yaml', 'quality-tools: {}'); + + $existingFiles = $this->hierarchy->getExistingConfigurationFiles(); + + $this->assertArrayHasKey('project_root', $existingFiles); + $this->assertCount(1, $existingFiles['project_root']); + + $fileInfo = $existingFiles['project_root'][0]; + $this->assertEquals($this->tempDir . '/quality-tools.yaml', $fileInfo['path']); + $this->assertEquals('yaml', $fileInfo['type']); + $this->assertNull($fileInfo['tool']); + } + + public function testGetExistingConfigurationFilesWithToolConfig(): void + { + // Create a tool-specific configuration file + file_put_contents($this->tempDir . '/rector.php', 'hierarchy->getExistingConfigurationFiles(); + + $this->assertArrayHasKey('tool_specific', $existingFiles); + $this->assertCount(1, $existingFiles['tool_specific']); + + $fileInfo = $existingFiles['tool_specific'][0]; + $this->assertEquals($this->tempDir . '/rector.php', $fileInfo['path']); + $this->assertEquals('php', $fileInfo['type']); + $this->assertEquals('rector', $fileInfo['tool']); + } + + public function testGetExistingConfigurationFilesWithMultipleConfigs(): void + { + // Create multiple configuration files + file_put_contents($this->tempDir . '/quality-tools.yaml', 'quality-tools: {}'); + mkdir($this->tempDir . '/config', 0o777, true); + file_put_contents($this->tempDir . '/config/quality-tools.yaml', 'quality-tools: {}'); + file_put_contents($this->tempDir . '/rector.php', 'tempDir . '/phpstan.neon', 'parameters: {}'); + + $existingFiles = $this->hierarchy->getExistingConfigurationFiles(); + + $this->assertArrayHasKey('project_root', $existingFiles); + $this->assertArrayHasKey('config_dir', $existingFiles); + $this->assertArrayHasKey('tool_specific', $existingFiles); + + $this->assertCount(1, $existingFiles['project_root']); + $this->assertCount(1, $existingFiles['config_dir']); + $this->assertCount(2, $existingFiles['tool_specific']); + } + + public function testHasToolConfigOverrideWithNoConfig(): void + { + $this->assertFalse($this->hierarchy->hasToolConfigOverride('rector')); + $this->assertFalse($this->hierarchy->hasToolConfigOverride('phpstan')); + } + + public function testHasToolConfigOverrideWithRectorConfig(): void + { + file_put_contents($this->tempDir . '/rector.php', 'assertTrue($this->hierarchy->hasToolConfigOverride('rector')); + $this->assertFalse($this->hierarchy->hasToolConfigOverride('phpstan')); + } + + public function testHasToolConfigOverrideWithConfigDirConfig(): void + { + mkdir($this->tempDir . '/config', 0o777, true); + file_put_contents($this->tempDir . '/config/phpstan.neon', 'parameters: {}'); + + $this->assertTrue($this->hierarchy->hasToolConfigOverride('phpstan')); + $this->assertFalse($this->hierarchy->hasToolConfigOverride('rector')); + } + + public function testGetDebugInfoReturnsComprehensiveInformation(): void + { + // Create some test files + file_put_contents($this->tempDir . '/quality-tools.yaml', 'quality-tools: {}'); + file_put_contents($this->tempDir . '/rector.php', 'hierarchy->getDebugInfo(); + + $this->assertIsArray($debugInfo); + $this->assertArrayHasKey('project_root', $debugInfo); + $this->assertArrayHasKey('package_root', $debugInfo); + $this->assertArrayHasKey('precedence_levels', $debugInfo); + $this->assertArrayHasKey('all_potential_files', $debugInfo); + $this->assertArrayHasKey('existing_files', $debugInfo); + $this->assertArrayHasKey('tool_overrides', $debugInfo); + + $this->assertEquals($this->tempDir, $debugInfo['project_root']); + $this->assertEquals(ConfigurationHierarchy::PRECEDENCE_LEVELS, $debugInfo['precedence_levels']); + + // Check tool overrides + $this->assertTrue($debugInfo['tool_overrides']['rector']); + $this->assertFalse($debugInfo['tool_overrides']['phpstan']); + } + + public function testToolConfigFileMappings(): void + { + $expectedMappings = [ + 'rector' => ['rector.php'], + 'phpstan' => ['phpstan.neon', 'phpstan.neon.dist'], + 'php-cs-fixer' => ['.php-cs-fixer.dist.php', '.php-cs-fixer.php'], + 'typoscript-lint' => ['typoscript-lint.yml'], + 'fractor' => ['fractor.php'], + ]; + + $this->assertEquals($expectedMappings, ConfigurationHierarchy::TOOL_CONFIG_FILES); + } + + public function testFilePatternDefinitions(): void + { + $patterns = ConfigurationHierarchy::FILE_PATTERNS; + + $this->assertArrayHasKey('project_root', $patterns); + $this->assertArrayHasKey('config_dir', $patterns); + $this->assertArrayHasKey('tool_specific', $patterns); + $this->assertArrayHasKey('tool_config_dir', $patterns); + $this->assertArrayHasKey('package_config', $patterns); + + // Check some specific patterns + $this->assertContains('quality-tools.yaml', $patterns['project_root']); + $this->assertContains('config/quality-tools.yaml', $patterns['config_dir']); + $this->assertContains('rector.php', $patterns['tool_specific']); + $this->assertContains('config/rector.php', $patterns['tool_config_dir']); + } + + public function testMergeStrategies(): void + { + $expectedStrategies = [ + 'arrays' => 'merge_unique', + 'objects' => 'deep_merge', + 'scalars' => 'override', + 'paths' => 'resolve_relative', + ]; + + $this->assertEquals($expectedStrategies, ConfigurationHierarchy::MERGE_STRATEGIES); + } + + public function testSpecialKeys(): void + { + $expectedSpecialKeys = [ + 'paths' => 'path_resolution', + 'exclude' => 'path_resolution', + 'scan' => 'path_resolution', + 'config_file' => 'tool_config_override', + ]; + + $this->assertEquals($expectedSpecialKeys, ConfigurationHierarchy::SPECIAL_KEYS); + } + + private function removeDirectory(string $dir): void + { + $files = array_diff(scandir($dir), ['.', '..']); + + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + + rmdir($dir); + } +} diff --git a/tests/Unit/Configuration/ConfigurationMergerTest.php b/tests/Unit/Configuration/ConfigurationMergerTest.php new file mode 100644 index 0000000..93e2d18 --- /dev/null +++ b/tests/Unit/Configuration/ConfigurationMergerTest.php @@ -0,0 +1,486 @@ +hierarchy = new ConfigurationHierarchy('/test/project'); + $this->merger = new ConfigurationMerger(); + } + + public function testMergeEmptyConfigurations(): void + { + $result = $this->merger->mergeConfigurations([]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('source_map', $result); + $this->assertArrayHasKey('conflicts', $result); + $this->assertArrayHasKey('merge_summary', $result); + + $this->assertEmpty($result['data']); + $this->assertEmpty($result['source_map']); + $this->assertEmpty($result['conflicts']); + } + + public function testMergeSingleConfiguration(): void + { + $configurations = [ + [ + 'source' => 'test', + 'precedence' => 1, + 'data' => [ + 'quality-tools' => [ + 'project' => ['name' => 'test-project'], + 'tools' => ['rector' => ['enabled' => true]], + ], + ], + ], + ]; + + $result = $this->merger->mergeConfigurations($configurations); + + $expected = [ + 'quality-tools' => [ + 'project' => ['name' => 'test-project'], + 'tools' => ['rector' => ['enabled' => true]], + ], + ]; + + $this->assertEquals($expected, $result['data']); + $this->assertEmpty($result['conflicts']); + } + + public function testMergeTwoConfigurationsWithoutConflicts(): void + { + $configurations = [ + [ + 'source' => 'base', + 'precedence' => 2, + 'data' => [ + 'quality-tools' => [ + 'project' => ['name' => 'test-project'], + 'tools' => ['rector' => ['enabled' => true]], + ], + ], + ], + [ + 'source' => 'override', + 'precedence' => 1, + 'data' => [ + 'quality-tools' => [ + 'tools' => ['phpstan' => ['level' => 6]], + ], + ], + ], + ]; + + $result = $this->merger->mergeConfigurations($configurations); + + $expected = [ + 'quality-tools' => [ + 'project' => ['name' => 'test-project'], + 'tools' => [ + 'rector' => ['enabled' => true], + 'phpstan' => ['level' => 6], + ], + ], + ]; + + $this->assertEquals($expected, $result['data']); + $this->assertEmpty($result['conflicts']); + } + + public function testMergeTwoConfigurationsWithConflicts(): void + { + $configurations = [ + [ + 'source' => 'base', + 'precedence' => 2, + 'data' => [ + 'quality-tools' => [ + 'project' => ['name' => 'base-project'], + 'tools' => ['rector' => ['enabled' => false]], + ], + ], + ], + [ + 'source' => 'override', + 'precedence' => 1, + 'data' => [ + 'quality-tools' => [ + 'project' => ['name' => 'override-project'], + 'tools' => ['rector' => ['enabled' => true]], + ], + ], + ], + ]; + + $result = $this->merger->mergeConfigurations($configurations); + + $expected = [ + 'quality-tools' => [ + 'project' => ['name' => 'override-project'], + 'tools' => ['rector' => ['enabled' => true]], + ], + ]; + + $this->assertEquals($expected, $result['data']); + $this->assertNotEmpty($result['conflicts']); + + // Check conflicts were recorded + $conflicts = $result['conflicts']; + $this->assertCount(2, $conflicts); + + $projectNameConflict = $conflicts[0]; + $this->assertEquals('quality-tools.project.name', $projectNameConflict['key_path']); + $this->assertEquals('base-project', $projectNameConflict['existing_value']); + $this->assertEquals('override-project', $projectNameConflict['new_value']); + $this->assertEquals('base', $projectNameConflict['existing_source']); + $this->assertEquals('override', $projectNameConflict['new_source']); + } + + public function testMergeArraysWithUniqueStrategy(): void + { + $configurations = [ + [ + 'source' => 'base', + 'precedence' => 2, + 'data' => [ + 'quality-tools' => [ + 'paths' => [ + 'scan' => ['packages/', 'src/'], + ], + ], + ], + ], + [ + 'source' => 'override', + 'precedence' => 1, + 'data' => [ + 'quality-tools' => [ + 'paths' => [ + 'scan' => ['lib/', 'packages/'], + ], + ], + ], + ], + ]; + + $result = $this->merger->mergeConfigurations($configurations); + + // The merge should properly combine the paths + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('quality-tools', $result['data']); + $this->assertArrayHasKey('paths', $result['data']['quality-tools']); + $this->assertArrayHasKey('scan', $result['data']['quality-tools']['paths']); + + $expectedScanPaths = ['packages/', 'src/', 'lib/']; + $actualScanPaths = $result['data']['quality-tools']['paths']['scan'] ?? []; + + // Order might differ, so sort both arrays for comparison + sort($expectedScanPaths); + sort($actualScanPaths); + + $this->assertEquals($expectedScanPaths, $actualScanPaths); + } + + public function testMergePathArraysRemovesDuplicates(): void + { + $configurations = [ + [ + 'source' => 'base', + 'precedence' => 2, + 'data' => [ + 'quality-tools' => [ + 'paths' => [ + 'exclude' => ['vendor/', 'var/'], + ], + ], + ], + ], + [ + 'source' => 'override', + 'precedence' => 1, + 'data' => [ + 'quality-tools' => [ + 'paths' => [ + 'exclude' => ['vendor/', 'public/'], + ], + ], + ], + ], + ]; + + $result = $this->merger->mergeConfigurations($configurations); + + $expectedExcludePaths = ['public/', 'var/', 'vendor/']; + $actualExcludePaths = $result['data']['quality-tools']['paths']['exclude'] ?? []; + + sort($actualExcludePaths); + + $this->assertEquals($expectedExcludePaths, $actualExcludePaths); + $this->assertCount(3, $actualExcludePaths); // No duplicates + } + + public function testMergeSummaryInformation(): void + { + $configurations = [ + [ + 'source' => 'base', + 'file_path' => '/test/base.yaml', + 'file_type' => 'yaml', + 'tool' => null, + 'precedence' => 2, + 'data' => ['test' => 'value1'], + ], + [ + 'source' => 'override', + 'file_path' => '/test/override.yaml', + 'file_type' => 'yaml', + 'tool' => null, + 'precedence' => 1, + 'data' => ['test' => 'value2'], + ], + ]; + + $result = $this->merger->mergeConfigurations($configurations); + + $summary = $result['merge_summary']; + + $this->assertEquals(2, $summary['total_configurations']); + $this->assertArrayHasKey('configurations_by_source', $summary); + $this->assertArrayHasKey('base', $summary['configurations_by_source']); + $this->assertArrayHasKey('override', $summary['configurations_by_source']); + + $this->assertEquals('/test/base.yaml', $summary['configurations_by_source']['base']['file_path']); + $this->assertEquals('/test/override.yaml', $summary['configurations_by_source']['override']['file_path']); + } + + #[DataProvider('mergeStrategyProvider')] + public function testMergeStrategies(array $base, array $override, array $expected): void + { + $configurations = [ + ['source' => 'base', 'precedence' => 2, 'data' => $base], + ['source' => 'override', 'precedence' => 1, 'data' => $override], + ]; + + $result = $this->merger->mergeConfigurations($configurations); + + $this->assertEquals($expected, $result['data']); + } + + public static function mergeStrategyProvider(): array + { + return [ + 'scalar override' => [ + ['key' => 'value1'], + ['key' => 'value2'], + ['key' => 'value2'], + ], + 'array replace (for simple lists)' => [ + ['list' => ['a', 'b']], + ['list' => ['c', 'd']], + ['list' => ['c', 'd']], + ], + 'object deep merge' => [ + ['object' => ['key1' => 'value1', 'key2' => 'value2']], + ['object' => ['key2' => 'new_value2', 'key3' => 'value3']], + ['object' => ['key1' => 'value1', 'key2' => 'new_value2', 'key3' => 'value3']], + ], + 'mixed types override' => [ + ['key' => ['array']], + ['key' => 'scalar'], + ['key' => 'scalar'], + ], + ]; + } + + public function testMergeTwoUtilityMethod(): void + { + $base = ['key1' => 'value1', 'shared' => 'base_value']; + $override = ['key2' => 'value2', 'shared' => 'override_value']; + + $result = ConfigurationMerger::mergeTwo($base, $override); + + $expected = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'shared' => 'override_value', + ]; + + $this->assertEquals($expected, $result); + } + + public function testGetConflictsAndHasConflicts(): void + { + $configurations = [ + [ + 'source' => 'base', + 'precedence' => 2, + 'data' => ['key' => 'value1'], + ], + [ + 'source' => 'override', + 'precedence' => 1, + 'data' => ['key' => 'value2'], + ], + ]; + + $this->merger->mergeConfigurations($configurations); + + $this->assertTrue($this->merger->hasConflicts()); + $this->assertNotEmpty($this->merger->getConflicts()); + + $conflicts = $this->merger->getConflicts(); + $this->assertCount(1, $conflicts); + + $conflict = $conflicts[0]; + $this->assertEquals('key', $conflict['key_path']); + $this->assertEquals('value1', $conflict['existing_value']); + $this->assertEquals('value2', $conflict['new_value']); + } + + public function testGetConflictsForSpecificKey(): void + { + $configurations = [ + [ + 'source' => 'base', + 'precedence' => 2, + 'data' => ['key1' => 'value1', 'key2' => 'value2'], + ], + [ + 'source' => 'override', + 'precedence' => 1, + 'data' => ['key1' => 'new_value1', 'key2' => 'new_value2'], + ], + ]; + + $this->merger->mergeConfigurations($configurations); + + $key1Conflicts = $this->merger->getConflictsForKey('key1'); + $this->assertCount(1, $key1Conflicts); + $this->assertEquals('key1', $key1Conflicts[0]['key_path']); + + $key2Conflicts = $this->merger->getConflictsForKey('key2'); + $this->assertCount(1, $key2Conflicts); + $this->assertEquals('key2', $key2Conflicts[0]['key_path']); + + $nonExistentKeyConflicts = $this->merger->getConflictsForKey('key3'); + $this->assertEmpty($nonExistentKeyConflicts); + } + + public function testCreateDebugMerger(): void + { + $debugMerger = ConfigurationMerger::createDebugMerger($this->hierarchy); + + $this->assertFalse($debugMerger->hasConflicts()); + $this->assertEmpty($debugMerger->getConflicts()); + } + + public function testComplexNestedMerging(): void + { + $configurations = [ + [ + 'source' => 'base', + 'precedence' => 3, + 'data' => [ + 'quality-tools' => [ + 'project' => ['name' => 'test'], + 'tools' => [ + 'rector' => ['enabled' => true, 'level' => 'typo3-12'], + 'phpstan' => ['enabled' => true, 'level' => 5], + ], + 'paths' => [ + 'scan' => ['packages/', 'src/'], + 'exclude' => ['vendor/', 'var/'], + ], + ], + ], + ], + [ + 'source' => 'project', + 'precedence' => 2, + 'data' => [ + 'quality-tools' => [ + 'tools' => [ + 'rector' => ['level' => 'typo3-13'], + 'fractor' => ['enabled' => true], + ], + 'paths' => [ + 'scan' => ['lib/'], + 'exclude' => ['public/'], + ], + ], + ], + ], + [ + 'source' => 'command_line', + 'precedence' => 1, + 'data' => [ + 'quality-tools' => [ + 'tools' => [ + 'phpstan' => ['level' => 6], + ], + ], + ], + ], + ]; + + $result = $this->merger->mergeConfigurations($configurations); + + $expected = [ + 'quality-tools' => [ + 'project' => ['name' => 'test'], + 'tools' => [ + 'rector' => ['enabled' => true, 'level' => 'typo3-13'], + 'phpstan' => ['enabled' => true, 'level' => 6], + 'fractor' => ['enabled' => true], + ], + 'paths' => [ + 'scan' => ['lib/', 'packages/', 'src/'], + 'exclude' => ['public/', 'var/', 'vendor/'], + ], + ], + ]; + + // Sort arrays for consistent comparison + sort($expected['quality-tools']['paths']['scan']); + sort($expected['quality-tools']['paths']['exclude']); + + $resultScanPaths = $result['data']['quality-tools']['paths']['scan'] ?? []; + $resultExcludePaths = $result['data']['quality-tools']['paths']['exclude'] ?? []; + + sort($resultScanPaths); + sort($resultExcludePaths); + + $result['data']['quality-tools']['paths']['scan'] = $resultScanPaths; + $result['data']['quality-tools']['paths']['exclude'] = $resultExcludePaths; + + $this->assertEquals($expected, $result['data']); + + // Verify conflicts were recorded for overridden values + $this->assertTrue($this->merger->hasConflicts()); + $conflicts = $this->merger->getConflicts(); + + // Should have conflicts for rector.level and phpstan.level + $this->assertGreaterThan(0, \count($conflicts)); + } +} diff --git a/tests/Unit/Configuration/YamlConfigurationLoaderTest.php b/tests/Unit/Configuration/YamlConfigurationLoaderTest.php index 3b34cf8..9ff01d1 100644 --- a/tests/Unit/Configuration/YamlConfigurationLoaderTest.php +++ b/tests/Unit/Configuration/YamlConfigurationLoaderTest.php @@ -130,7 +130,7 @@ public function testEnvironmentVariableInterpolation(): void self::assertSame('env-test-project', $config->getProjectName()); self::assertSame('8.4', $config->getProjectPhpVersion()); self::assertSame('2G', $config->getPhpStanConfig()['memory_limit']); - $expectedPaths = ['packages/']; + $expectedPaths = ['packages/', 'config/system/']; self::assertSame($expectedPaths, $config->getScanPaths()); } @@ -333,7 +333,7 @@ public function testComplexEnvironmentVariableInterpolation(): void self::assertSame('2G', $phpStanConfig['memory_limit']); self::assertSame(6, $phpStanConfig['level']); - self::assertSame(['packages/', 'custom/'], $config->getScanPaths()); + self::assertSame(['packages/', 'config/system/', 'custom/'], $config->getScanPaths()); } public function testEmptyYamlFile(): void diff --git a/tests/Unit/Console/Command/ConfigInitCommandTest.php b/tests/Unit/Console/Command/ConfigInitCommandTest.php index ebe5e4b..8018ee3 100644 --- a/tests/Unit/Console/Command/ConfigInitCommandTest.php +++ b/tests/Unit/Console/Command/ConfigInitCommandTest.php @@ -206,8 +206,7 @@ public function testExecuteWithExistingQualityToolsYaml(): void $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Configuration file already exists', $output); - // Use basename to avoid path format differences between /var/ and /private/var/ - self::assertStringContainsString(basename($existingConfig), $output); + self::assertStringContainsString('Use --force to overwrite', $output); // New file should not be created self::assertFileDoesNotExist($this->tempDir . '/.quality-tools.yaml');