From 073cd8b1d3d4ab75d9cc10d3763cc5f89d9b02dc Mon Sep 17 00:00:00 2001 From: Greg Date: Tue, 17 Mar 2026 11:43:08 +0800 Subject: [PATCH 1/2] Add breaking change detection: extraction, diffing, scanning, and CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full breaking change detection library for WooCommerce ecosystem plugins. Extracts public PHP symbols and hooks via php-parser AST analysis, diffs between plugin versions to find removals, and scans dependent plugins for references to removed symbols. New commands: - breaking-changes:diff — compare two plugin versions - breaking-changes:scan — check if a plugin uses removed symbols - breaking-changes:index — extract and upload hooks to Manager index Co-Authored-By: Claude Opus 4.6 --- src/composer.json | 3 +- src/composer.lock | 62 +++- .../BreakingChanges/Commands/DiffCommand.php | 98 ++++++ .../BreakingChanges/Commands/IndexCommand.php | 142 +++++++++ .../BreakingChanges/Commands/ScanCommand.php | 218 +++++++++++++ src/src/BreakingChanges/Diff/HookDiffer.php | 39 +++ src/src/BreakingChanges/Diff/SymbolDiffer.php | 38 +++ .../Extraction/DirectoryExtractor.php | 101 ++++++ .../BreakingChanges/Extraction/FileParser.php | 50 +++ src/src/BreakingChanges/HookIndexClient.php | 78 +++++ src/src/BreakingChanges/Models/DiffResult.php | 17 + .../Models/ExtractedSymbols.php | 63 ++++ .../BreakingChanges/Models/FoundReference.php | 34 ++ .../BreakingChanges/Models/HookDiffResult.php | 24 ++ src/src/BreakingChanges/Models/HookInfo.php | 39 +++ src/src/BreakingChanges/Models/ScanResult.php | 29 ++ .../Models/SymbolDiffResult.php | 24 ++ src/src/BreakingChanges/Models/SymbolInfo.php | 50 +++ .../BreakingChanges/PluginSourceResolver.php | 94 ++++++ .../Renderers/DiffRenderer.php | 239 ++++++++++++++ .../Renderers/ScanRenderer.php | 175 +++++++++++ .../Scanner/ReferenceScanner.php | 114 +++++++ .../Scanner/ReferenceVisitor.php | 293 ++++++++++++++++++ .../BreakingChanges/Visitors/HookVisitor.php | 93 ++++++ .../Visitors/SymbolVisitor.php | 238 ++++++++++++++ .../BreakingChanges/WooDevelopedFetcher.php | 49 +++ src/src/CachedDownloader.php | 33 +- src/src/bootstrap.php | 5 + .../Commands/DiffCommandTest.php | 121 ++++++++ .../Commands/ScanCommandCheckAgainstTest.php | 123 ++++++++ .../Commands/ScanCommandTest.php | 148 +++++++++ .../BreakingChanges/Diff/HookDifferTest.php | 129 ++++++++ .../BreakingChanges/Diff/SymbolDifferTest.php | 159 ++++++++++ .../Extraction/DirectoryExtractorTest.php | 141 +++++++++ .../Extraction/FileParserTest.php | 65 ++++ .../PluginSourceResolverTest.php | 115 +++++++ .../Renderers/DiffRendererTest.php | 124 ++++++++ .../Scanner/ReferenceScannerTest.php | 106 +++++++ .../Scanner/ReferenceVisitorTest.php | 184 +++++++++++ .../Visitors/HookVisitorTest.php | 127 ++++++++ .../Visitors/SymbolVisitorTest.php | 149 +++++++++ .../includes/class-sample-helper.php | 13 + .../includes/class-sample-manager.php | 27 ++ .../sample-plugin-v1/includes/functions.php | 15 + .../includes/interface-sample-contract.php | 8 + .../sample-plugin-v1/sample-plugin.php | 10 + .../includes/class-sample-helper.php | 15 + .../includes/class-sample-manager.php | 25 ++ .../includes/class-sample-registry.php | 13 + .../sample-plugin-v2/includes/functions.php | 17 + .../sample-plugin-v2/sample-plugin.php | 11 + .../target-plugin/includes/integration.php | 36 +++ .../fixtures/target-plugin/target-plugin.php | 9 + 53 files changed, 4322 insertions(+), 10 deletions(-) create mode 100644 src/src/BreakingChanges/Commands/DiffCommand.php create mode 100644 src/src/BreakingChanges/Commands/IndexCommand.php create mode 100644 src/src/BreakingChanges/Commands/ScanCommand.php create mode 100644 src/src/BreakingChanges/Diff/HookDiffer.php create mode 100644 src/src/BreakingChanges/Diff/SymbolDiffer.php create mode 100644 src/src/BreakingChanges/Extraction/DirectoryExtractor.php create mode 100644 src/src/BreakingChanges/Extraction/FileParser.php create mode 100644 src/src/BreakingChanges/HookIndexClient.php create mode 100644 src/src/BreakingChanges/Models/DiffResult.php create mode 100644 src/src/BreakingChanges/Models/ExtractedSymbols.php create mode 100644 src/src/BreakingChanges/Models/FoundReference.php create mode 100644 src/src/BreakingChanges/Models/HookDiffResult.php create mode 100644 src/src/BreakingChanges/Models/HookInfo.php create mode 100644 src/src/BreakingChanges/Models/ScanResult.php create mode 100644 src/src/BreakingChanges/Models/SymbolDiffResult.php create mode 100644 src/src/BreakingChanges/Models/SymbolInfo.php create mode 100644 src/src/BreakingChanges/PluginSourceResolver.php create mode 100644 src/src/BreakingChanges/Renderers/DiffRenderer.php create mode 100644 src/src/BreakingChanges/Renderers/ScanRenderer.php create mode 100644 src/src/BreakingChanges/Scanner/ReferenceScanner.php create mode 100644 src/src/BreakingChanges/Scanner/ReferenceVisitor.php create mode 100644 src/src/BreakingChanges/Visitors/HookVisitor.php create mode 100644 src/src/BreakingChanges/Visitors/SymbolVisitor.php create mode 100644 src/src/BreakingChanges/WooDevelopedFetcher.php create mode 100644 src/tests/unit/BreakingChanges/Commands/DiffCommandTest.php create mode 100644 src/tests/unit/BreakingChanges/Commands/ScanCommandCheckAgainstTest.php create mode 100644 src/tests/unit/BreakingChanges/Commands/ScanCommandTest.php create mode 100644 src/tests/unit/BreakingChanges/Diff/HookDifferTest.php create mode 100644 src/tests/unit/BreakingChanges/Diff/SymbolDifferTest.php create mode 100644 src/tests/unit/BreakingChanges/Extraction/DirectoryExtractorTest.php create mode 100644 src/tests/unit/BreakingChanges/Extraction/FileParserTest.php create mode 100644 src/tests/unit/BreakingChanges/PluginSourceResolverTest.php create mode 100644 src/tests/unit/BreakingChanges/Renderers/DiffRendererTest.php create mode 100644 src/tests/unit/BreakingChanges/Scanner/ReferenceScannerTest.php create mode 100644 src/tests/unit/BreakingChanges/Scanner/ReferenceVisitorTest.php create mode 100644 src/tests/unit/BreakingChanges/Visitors/HookVisitorTest.php create mode 100644 src/tests/unit/BreakingChanges/Visitors/SymbolVisitorTest.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v1/includes/class-sample-helper.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v1/includes/class-sample-manager.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v1/includes/functions.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v1/includes/interface-sample-contract.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v1/sample-plugin.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v2/includes/class-sample-helper.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v2/includes/class-sample-manager.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v2/includes/class-sample-registry.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v2/includes/functions.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/sample-plugin-v2/sample-plugin.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/target-plugin/includes/integration.php create mode 100644 src/tests/unit/BreakingChanges/fixtures/target-plugin/target-plugin.php diff --git a/src/composer.json b/src/composer.json index 8eed5e615..4d735e048 100644 --- a/src/composer.json +++ b/src/composer.json @@ -33,7 +33,8 @@ "symfony/serializer": "^5", "symfony/yaml": "^5", "vlucas/phpdotenv": "^5", - "opis/json-schema": "^2.4" + "opis/json-schema": "^2.4", + "nikic/php-parser": "^5" }, "require-dev": { "phpunit/phpunit": "^8", diff --git a/src/composer.lock b/src/composer.lock index 1306f34ec..a3454682c 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f4d9d98d15677b380ecf56af9ea7e703", + "content-hash": "3ca9703532f2e2ee1640f405b24affbb", "packages": [ { "name": "composer/ca-bundle", @@ -189,6 +189,64 @@ }, "time": "2025-04-01T17:10:31+00:00" }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, { "name": "opis/json-schema", "version": "2.4.1", @@ -4912,5 +4970,5 @@ "platform-overrides": { "php": "7.4" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/src/BreakingChanges/Commands/DiffCommand.php b/src/src/BreakingChanges/Commands/DiffCommand.php new file mode 100644 index 000000000..98c66569e --- /dev/null +++ b/src/src/BreakingChanges/Commands/DiffCommand.php @@ -0,0 +1,98 @@ +resolver = $resolver; + $this->extractor = $extractor; + $this->symbol_differ = $symbol_differ; + $this->hook_differ = $hook_differ; + $this->renderer = $renderer; + + parent::__construct(); + } + + protected function configure(): void { + $this + ->setDescription( 'Diff two versions of a plugin to detect breaking changes.' ) + ->setHelp( 'Compares the public API surface (classes, functions, hooks, constants) between two plugin versions.' ) + ->addArgument( 'slug', InputArgument::REQUIRED, 'Plugin slug (WPORG) or local path' ) + ->addOption( 'old', null, InputOption::VALUE_REQUIRED, 'Old version number or path' ) + ->addOption( 'new', null, InputOption::VALUE_REQUIRED, 'New version number or path (default: latest)' ) + ->addOption( 'format', null, InputOption::VALUE_REQUIRED, 'Output format: table, json, github', 'table' ); + } + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $slug = $input->getArgument( 'slug' ); + $old_version = $input->getOption( 'old' ); + $new_version = $input->getOption( 'new' ); + $format = $input->getOption( 'format' ); + + if ( empty( $old_version ) ) { + $output->writeln( 'The --old option is required.' ); + return Command::FAILURE; + } + + $output->writeln( sprintf( 'Resolving old version (%s)...', $old_version ), OutputInterface::VERBOSITY_VERBOSE ); + $old_path = $this->resolve_source( $slug, $old_version ); + + $output->writeln( sprintf( 'Resolving new version (%s)...', $new_version ?? 'latest' ), OutputInterface::VERBOSITY_VERBOSE ); + $new_path = $this->resolve_source( $slug, $new_version ); + + $output->writeln( 'Extracting symbols from old version...', OutputInterface::VERBOSITY_VERBOSE ); + $old_symbols = $this->extractor->extract( $old_path ); + + $output->writeln( 'Extracting symbols from new version...', OutputInterface::VERBOSITY_VERBOSE ); + $new_symbols = $this->extractor->extract( $new_path ); + + $output->writeln( 'Diffing...', OutputInterface::VERBOSITY_VERBOSE ); + $symbol_diff = $this->symbol_differ->diff( $old_symbols, $new_symbols ); + $hook_diff = $this->hook_differ->diff( $old_symbols, $new_symbols ); + + $result = new DiffResult( $symbol_diff, $hook_diff ); + + $this->renderer->render( $result, $output, $format ); + + return $result->has_removals() ? Command::FAILURE : Command::SUCCESS; + } + + /** + * Resolve a version string or local path to a plugin directory. + * If the value is a local directory or zip, use it directly. + * Otherwise, treat it as a version and resolve via slug. + */ + private function resolve_source( string $slug, ?string $version_or_path ): string { + if ( $version_or_path !== null && ( is_dir( $version_or_path ) || is_file( $version_or_path ) ) ) { + return $this->resolver->resolve( $version_or_path ); + } + + return $this->resolver->resolve( $slug, $version_or_path ); + } +} diff --git a/src/src/BreakingChanges/Commands/IndexCommand.php b/src/src/BreakingChanges/Commands/IndexCommand.php new file mode 100644 index 000000000..04032f46b --- /dev/null +++ b/src/src/BreakingChanges/Commands/IndexCommand.php @@ -0,0 +1,142 @@ +resolver = $resolver; + $this->extractor = $extractor; + + parent::__construct(); + } + + protected function configure(): void { + $this + ->setDescription( 'Extract hooks from a plugin and upload to the hook index.' ) + ->setHelp( 'Resolves a plugin, extracts hook definitions and references, and POSTs them to the Manager hook index endpoint.' ) + ->addArgument( 'slug', InputArgument::REQUIRED, 'Plugin slug (WPORG) or local path' ) + ->addOption( 'version', null, InputOption::VALUE_REQUIRED, 'Plugin version (default: latest)' ); + } + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $slug = $input->getArgument( 'slug' ); + $version = $input->getOption( 'version' ); + + $output->writeln( sprintf( 'Resolving %s%s...', $slug, $version ? "@{$version}" : '' ) ); + + try { + $plugin_path = $this->resolver->resolve( $slug, $version ); + } catch ( \Exception $e ) { + $output->writeln( sprintf( 'Failed to resolve plugin: %s', $e->getMessage() ) ); + return Command::FAILURE; + } + + $output->writeln( 'Extracting symbols...' ); + $symbols = $this->extractor->extract( $plugin_path ); + + $definitions = $this->build_definitions_payload( $symbols ); + $references = $this->build_references_payload( $symbols ); + + $resolved_version = $version ?? 'latest'; + + $output->writeln( sprintf( + 'Found %d hook definitions, %d hook references. Uploading...', + count( $definitions ), + count( $references ) + ) ); + + try { + $this->upload_to_index( $slug, $resolved_version, $definitions, $references ); + } catch ( \Exception $e ) { + $output->writeln( sprintf( 'Upload failed: %s', $e->getMessage() ) ); + return Command::FAILURE; + } + + $output->writeln( 'Hook index updated successfully.' ); + + return Command::SUCCESS; + } + + /** + * Build hook definitions payload from extracted symbols. + * + * @return array> + */ + private function build_definitions_payload( ExtractedSymbols $symbols ): array { + $payload = []; + + foreach ( $symbols->hooks as $hook ) { + $payload[] = [ + 'hook_name' => $hook->name, + 'hook_type' => $hook->type, + 'file_path' => $hook->file, + 'line_number' => $hook->line, + 'arg_count' => $hook->arg_count, + 'is_dynamic' => $hook->is_dynamic ? 1 : 0, + ]; + } + + return $payload; + } + + /** + * Build hook references payload. + * For now, this scans for add_action/add_filter/remove_action/remove_filter calls + * that reference hooks defined in other plugins. + * + * @return array> + */ + private function build_references_payload( ExtractedSymbols $symbols ): array { + // Hook references are extracted by the HookVisitor as part of the same + // extraction pass. For the index, we include all hooks as both definitions + // (do_action/apply_filters) and potential references (the hooks this plugin + // uses from other plugins are captured separately by ReferenceScanner). + // For simplicity, we return an empty array here — the ReferenceScanner + // workflow will populate references via a separate pass. + return []; + } + + /** + * @param array> $definitions + * @param array> $references + */ + private function upload_to_index( string $slug, string $version, array $definitions, array $references ): void { + $url = get_manager_url() . '/wp-json/cd/v1/hook-index'; + + $response = ( new RequestBuilder( $url ) ) + ->with_method( 'POST' ) + ->with_post_body( [ + 'plugin_slug' => $slug, + 'plugin_version' => $version, + 'definitions' => $definitions, + 'references' => $references, + ] ) + ->request(); + + $data = json_decode( $response, true ); + + if ( ! is_array( $data ) || empty( $data['success'] ) ) { + throw new \RuntimeException( 'Hook index ingest returned unexpected response.' ); + } + } +} diff --git a/src/src/BreakingChanges/Commands/ScanCommand.php b/src/src/BreakingChanges/Commands/ScanCommand.php new file mode 100644 index 000000000..966c8ca3a --- /dev/null +++ b/src/src/BreakingChanges/Commands/ScanCommand.php @@ -0,0 +1,218 @@ +resolver = $resolver; + $this->extractor = $extractor; + $this->symbol_differ = $symbol_differ; + $this->hook_differ = $hook_differ; + $this->scanner = $scanner; + $this->renderer = $renderer; + $this->woo_developed_fetcher = $woo_developed_fetcher; + + parent::__construct(); + } + + protected function configure(): void { + $this + ->setDescription( 'Scan a plugin for references to breaking changes in a dependency.' ) + ->setHelp( 'Diffs a dependency between two versions, then scans the target plugin for references to removed symbols and hooks.' ) + ->addArgument( 'target', InputArgument::OPTIONAL, 'Plugin slug or path to scan for breaking references' ) + ->addOption( 'dependency', null, InputOption::VALUE_REQUIRED, 'Dependency plugin slug or path' ) + ->addOption( 'old', null, InputOption::VALUE_REQUIRED, 'Old version of the dependency' ) + ->addOption( 'new', null, InputOption::VALUE_REQUIRED, 'New version of the dependency (default: latest)' ) + ->addOption( 'check-against', null, InputOption::VALUE_REQUIRED, 'Scan multiple plugins: "woo-developed" or comma-separated slugs' ) + ->addOption( 'format', null, InputOption::VALUE_REQUIRED, 'Output format: table, json, github', 'table' ); + } + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $target = $input->getArgument( 'target' ); + $dependency = $input->getOption( 'dependency' ); + $old_version = $input->getOption( 'old' ); + $new_version = $input->getOption( 'new' ); + $check_against = $input->getOption( 'check-against' ); + $format = $input->getOption( 'format' ); + + if ( empty( $dependency ) ) { + $output->writeln( 'The --dependency option is required.' ); + return Command::FAILURE; + } + + if ( empty( $old_version ) ) { + $output->writeln( 'The --old option is required.' ); + return Command::FAILURE; + } + + if ( empty( $target ) && empty( $check_against ) ) { + $output->writeln( 'Either a target argument or --check-against option is required.' ); + return Command::FAILURE; + } + + // Step 1: Resolve and diff the dependency. + $output->writeln( 'Resolving dependency versions...', OutputInterface::VERBOSITY_VERBOSE ); + $old_dep_path = $this->resolve_source( $dependency, $old_version ); + $new_dep_path = $this->resolve_source( $dependency, $new_version ); + + $output->writeln( 'Extracting symbols from dependency...', OutputInterface::VERBOSITY_VERBOSE ); + $old_symbols = $this->extractor->extract( $old_dep_path ); + $new_symbols = $this->extractor->extract( $new_dep_path ); + + $symbol_diff = $this->symbol_differ->diff( $old_symbols, $new_symbols ); + $hook_diff = $this->hook_differ->diff( $old_symbols, $new_symbols ); + + if ( ! $symbol_diff->has_removals() && ! $hook_diff->has_removals() ) { + $output->writeln( 'No breaking changes in dependency. Nothing to scan.' ); + return Command::SUCCESS; + } + + $output->writeln( sprintf( + 'Found %d removed symbol(s) and %d removed hook(s) in dependency.', + count( $symbol_diff->removed ), + count( $hook_diff->removed ) + ), OutputInterface::VERBOSITY_VERBOSE ); + + // Step 2: Determine which plugins to scan. + if ( ! empty( $check_against ) ) { + try { + return $this->scan_multiple( $check_against, $symbol_diff, $hook_diff, $dependency, $output, $format ); + } catch ( \RuntimeException $e ) { + $output->writeln( sprintf( '%s', $e->getMessage() ) ); + return Command::FAILURE; + } + } + + // Single target scan. + $output->writeln( 'Scanning target plugin...', OutputInterface::VERBOSITY_VERBOSE ); + $target_path = $this->resolve_source( $target, null ); + + $result = $this->scanner->scan( $target_path, $symbol_diff, $hook_diff, $this->get_slug( $target ) ); + + $this->renderer->render( $result, $output, $format ); + + return $result->has_breaking_references() ? Command::FAILURE : Command::SUCCESS; + } + + /** + * Scan multiple plugins against the diff result. + */ + private function scan_multiple( + string $check_against, + $symbol_diff, + $hook_diff, + string $dependency, + OutputInterface $output, + string $format + ): int { + $slugs = $this->resolve_check_against( $check_against, $dependency ); + + if ( empty( $slugs ) ) { + $output->writeln( 'No plugins to scan.' ); + return Command::SUCCESS; + } + + if ( $format !== 'json' ) { + $output->writeln( sprintf( 'Scanning %d plugin(s)...', count( $slugs ) ) ); + } + + $results = []; + $has_failure = false; + + foreach ( $slugs as $slug ) { + $output->writeln( sprintf( ' Scanning %s...', $slug ), OutputInterface::VERBOSITY_VERBOSE ); + + try { + $target_path = $this->resolver->resolve( $slug ); + $result = $this->scanner->scan( $target_path, $symbol_diff, $hook_diff, $slug ); + $results[] = $result; + + if ( $result->has_breaking_references() ) { + $has_failure = true; + } + } catch ( \Exception $e ) { + $output->writeln( sprintf( ' Skipping %s: %s', $slug, $e->getMessage() ) ); + $results[] = new ScanResult( $slug, [], [ $e->getMessage() ] ); + } + } + + $this->renderer->render_multi( $results, $output, $format ); + + return $has_failure ? Command::FAILURE : Command::SUCCESS; + } + + /** + * Resolve --check-against value to a list of plugin slugs. + * + * @return string[] + */ + private function resolve_check_against( string $value, string $dependency ): array { + if ( strtolower( $value ) === 'woo-developed' ) { + if ( $this->woo_developed_fetcher === null ) { + throw new \RuntimeException( 'Cannot use --check-against=woo-developed: not connected to QIT backend.' ); + } + + $slugs = $this->woo_developed_fetcher->fetch(); + + // Exclude the dependency itself from the scan list. + $dep_slug = $this->get_slug( $dependency ); + return array_values( array_filter( $slugs, function ( string $slug ) use ( $dep_slug ) { + return $slug !== $dep_slug; + } ) ); + } + + // Comma-separated slugs. + $slugs = array_map( 'trim', explode( ',', $value ) ); + return array_filter( $slugs, function ( string $slug ) { + return $slug !== ''; + } ); + } + + private function resolve_source( string $slug, ?string $version_or_path ): string { + if ( $version_or_path !== null && ( is_dir( $version_or_path ) || is_file( $version_or_path ) ) ) { + return $this->resolver->resolve( $version_or_path ); + } + + return $this->resolver->resolve( $slug, $version_or_path ); + } + + private function get_slug( string $slug_or_path ): string { + if ( is_dir( $slug_or_path ) || is_file( $slug_or_path ) ) { + return basename( $slug_or_path ); + } + + return $slug_or_path; + } +} diff --git a/src/src/BreakingChanges/Diff/HookDiffer.php b/src/src/BreakingChanges/Diff/HookDiffer.php new file mode 100644 index 000000000..f826cbd98 --- /dev/null +++ b/src/src/BreakingChanges/Diff/HookDiffer.php @@ -0,0 +1,39 @@ +hooks as $name => $hook ) { + if ( $hook->is_dynamic ) { + continue; + } + if ( ! isset( $new->hooks[ $name ] ) ) { + $removed[] = $hook; + } + } + + // Find added hooks (in new but not in old). + foreach ( $new->hooks as $name => $hook ) { + if ( $hook->is_dynamic ) { + continue; + } + if ( ! isset( $old->hooks[ $name ] ) ) { + $added[] = $hook; + } + } + + return new HookDiffResult( $removed, $added ); + } +} diff --git a/src/src/BreakingChanges/Diff/SymbolDiffer.php b/src/src/BreakingChanges/Diff/SymbolDiffer.php new file mode 100644 index 000000000..e2724a2f2 --- /dev/null +++ b/src/src/BreakingChanges/Diff/SymbolDiffer.php @@ -0,0 +1,38 @@ +$category; + $new_symbols = $new->$category; + + // Find removed symbols (in old but not in new). + foreach ( $old_symbols as $key => $symbol ) { + if ( ! isset( $new_symbols[ $key ] ) ) { + $removed[] = $symbol; + } + } + + // Find added symbols (in new but not in old). + foreach ( $new_symbols as $key => $symbol ) { + if ( ! isset( $old_symbols[ $key ] ) ) { + $added[] = $symbol; + } + } + } + + return new SymbolDiffResult( $removed, $added ); + } +} diff --git a/src/src/BreakingChanges/Extraction/DirectoryExtractor.php b/src/src/BreakingChanges/Extraction/DirectoryExtractor.php new file mode 100644 index 000000000..9759fe9c2 --- /dev/null +++ b/src/src/BreakingChanges/Extraction/DirectoryExtractor.php @@ -0,0 +1,101 @@ +parser = $parser; + } + + /** + * Extract all symbols and hooks from PHP files in a directory. + */ + public function extract( string $directory ): ExtractedSymbols { + $result = new ExtractedSymbols(); + $php_files = $this->find_php_files( $directory ); + $base_path = rtrim( $directory, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR; + + foreach ( $php_files as $file ) { + $relative_path = str_replace( $base_path, '', $file ); + $ast = $this->parser->parse( $file ); + + if ( $ast === null ) { + $result->add_warning( "Failed to parse: {$relative_path}" ); + continue; + } + + $file_symbols = new ExtractedSymbols(); + + $symbol_visitor = new SymbolVisitor( $file_symbols, $relative_path ); + $hook_visitor = new HookVisitor( $file_symbols, $relative_path ); + + $traverser = new NodeTraverser(); + $traverser->addVisitor( new NameResolver() ); + $traverser->addVisitor( $symbol_visitor ); + $traverser->addVisitor( $hook_visitor ); + $traverser->traverse( $ast ); + + $result->merge( $file_symbols ); + } + + return $result; + } + + /** + * Recursively find all .php files, skipping excluded directories. + * + * @return string[] + */ + private function find_php_files( string $directory ): array { + $files = []; + $directory = rtrim( $directory, DIRECTORY_SEPARATOR ); + + if ( ! is_dir( $directory ) ) { + return $files; + } + + $iterator = new \RecursiveDirectoryIterator( + $directory, + \RecursiveDirectoryIterator::SKIP_DOTS + ); + + $filter = new \RecursiveCallbackFilterIterator( + $iterator, + function ( \SplFileInfo $current, string $key, \RecursiveDirectoryIterator $iterator ): bool { + if ( $current->isDir() ) { + return ! in_array( $current->getFilename(), self::SKIP_DIRS, true ); + } + + return $current->getExtension() === 'php'; + } + ); + + $flat_iterator = new \RecursiveIteratorIterator( $filter ); + + foreach ( $flat_iterator as $file ) { + /** @var \SplFileInfo $file */ + $files[] = $file->getPathname(); + } + + sort( $files ); + + return $files; + } +} diff --git a/src/src/BreakingChanges/Extraction/FileParser.php b/src/src/BreakingChanges/Extraction/FileParser.php new file mode 100644 index 000000000..b663bd7b7 --- /dev/null +++ b/src/src/BreakingChanges/Extraction/FileParser.php @@ -0,0 +1,50 @@ +parser = ( new ParserFactory() )->createForHostVersion(); + } + + /** + * Parse a PHP file and return its AST. + * + * @return Stmt[]|null AST nodes or null on parse error. + */ + public function parse( string $file_path ): ?array { + if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) { + return null; + } + + $code = file_get_contents( $file_path ); + if ( $code === false ) { + return null; + } + + try { + return $this->parser->parse( $code ); + } catch ( \PhpParser\Error $e ) { + return null; + } + } + + /** + * Parse PHP code string and return its AST. + * + * @return Stmt[]|null AST nodes or null on parse error. + */ + public function parse_code( string $code ): ?array { + try { + return $this->parser->parse( $code ); + } catch ( \PhpParser\Error $e ) { + return null; + } + } +} diff --git a/src/src/BreakingChanges/HookIndexClient.php b/src/src/BreakingChanges/HookIndexClient.php new file mode 100644 index 000000000..852b98205 --- /dev/null +++ b/src/src/BreakingChanges/HookIndexClient.php @@ -0,0 +1,78 @@ +cache = $cache; + } + + /** + * Query the hook index for plugins referencing the given hook names. + * + * @param string[] $hook_names + * @return array>> Grouped by hook name. + */ + public function query_references( array $hook_names ): array { + if ( empty( $hook_names ) ) { + return []; + } + + $url = get_manager_url() . '/wp-json/cd/v1/hook-references'; + $response = ( new RequestBuilder( $url ) ) + ->with_method( 'POST' ) + ->with_post_body( [ 'hook_names' => $hook_names ] ) + ->request(); + + $data = json_decode( $response, true ); + + if ( ! is_array( $data ) || ! isset( $data['references'] ) ) { + return []; + } + + return $data['references']; + } + + /** + * Get the hook index status. + * + * @return array{indexed_plugins: int, total_definitions: int, total_references: int, last_updated: ?string} + */ + public function get_status(): array { + $url = get_manager_url() . '/wp-json/cd/v1/hook-index-status'; + $response = ( new RequestBuilder( $url ) ) + ->with_method( 'GET' ) + ->request(); + + $data = json_decode( $response, true ); + + if ( ! is_array( $data ) ) { + return [ + 'indexed_plugins' => 0, + 'total_definitions' => 0, + 'total_references' => 0, + 'last_updated' => null, + ]; + } + + return $data; + } + + /** + * Check if the hook index is available and populated. + */ + public function is_available(): bool { + try { + $status = $this->get_status(); + return $status['total_definitions'] > 0; + } catch ( \Exception $e ) { + return false; + } + } +} diff --git a/src/src/BreakingChanges/Models/DiffResult.php b/src/src/BreakingChanges/Models/DiffResult.php new file mode 100644 index 000000000..e720eb4ec --- /dev/null +++ b/src/src/BreakingChanges/Models/DiffResult.php @@ -0,0 +1,17 @@ +symbols = $symbols; + $this->hooks = $hooks; + } + + public function has_removals(): bool { + return $this->symbols->has_removals() || $this->hooks->has_removals(); + } +} diff --git a/src/src/BreakingChanges/Models/ExtractedSymbols.php b/src/src/BreakingChanges/Models/ExtractedSymbols.php new file mode 100644 index 000000000..f98d9ebdf --- /dev/null +++ b/src/src/BreakingChanges/Models/ExtractedSymbols.php @@ -0,0 +1,63 @@ + Keyed by FQN */ + public array $classes = []; + + /** @var array Keyed by Class::method */ + public array $methods = []; + + /** @var array Keyed by FQN */ + public array $functions = []; + + /** @var array Keyed by FQN */ + public array $constants = []; + + /** @var array Keyed by hook name */ + public array $hooks = []; + + /** @var string[] Parse or extraction warnings */ + public array $warnings = []; + + /** @var int Count of dynamic hooks encountered */ + public int $dynamic_hook_count = 0; + + public function add_class( SymbolInfo $symbol ): void { + $this->classes[ $symbol->get_key() ] = $symbol; + } + + public function add_method( SymbolInfo $symbol ): void { + $this->methods[ $symbol->get_key() ] = $symbol; + } + + public function add_function( SymbolInfo $symbol ): void { + $this->functions[ $symbol->get_key() ] = $symbol; + } + + public function add_constant( SymbolInfo $symbol ): void { + $this->constants[ $symbol->get_key() ] = $symbol; + } + + public function add_hook( HookInfo $hook ): void { + $this->hooks[ $hook->name ] = $hook; + } + + public function add_warning( string $warning ): void { + $this->warnings[] = $warning; + } + + /** + * Merge another ExtractedSymbols into this one. + */ + public function merge( ExtractedSymbols $other ): void { + $this->classes = array_merge( $this->classes, $other->classes ); + $this->methods = array_merge( $this->methods, $other->methods ); + $this->functions = array_merge( $this->functions, $other->functions ); + $this->constants = array_merge( $this->constants, $other->constants ); + $this->hooks = array_merge( $this->hooks, $other->hooks ); + $this->warnings = array_merge( $this->warnings, $other->warnings ); + $this->dynamic_hook_count += $other->dynamic_hook_count; + } +} diff --git a/src/src/BreakingChanges/Models/FoundReference.php b/src/src/BreakingChanges/Models/FoundReference.php new file mode 100644 index 000000000..14bc59e55 --- /dev/null +++ b/src/src/BreakingChanges/Models/FoundReference.php @@ -0,0 +1,34 @@ +name = $name; + $this->type = $type; + $this->file = $file; + $this->line = $line; + $this->context = $context; + } +} diff --git a/src/src/BreakingChanges/Models/HookDiffResult.php b/src/src/BreakingChanges/Models/HookDiffResult.php new file mode 100644 index 000000000..0197ed059 --- /dev/null +++ b/src/src/BreakingChanges/Models/HookDiffResult.php @@ -0,0 +1,24 @@ +removed = $removed; + $this->added = $added; + } + + public function has_removals(): bool { + return count( $this->removed ) > 0; + } +} diff --git a/src/src/BreakingChanges/Models/HookInfo.php b/src/src/BreakingChanges/Models/HookInfo.php new file mode 100644 index 000000000..f27281f1c --- /dev/null +++ b/src/src/BreakingChanges/Models/HookInfo.php @@ -0,0 +1,39 @@ +name = $name; + $this->type = $type; + $this->file = $file; + $this->line = $line; + $this->is_dynamic = $is_dynamic; + $this->arg_count = $arg_count; + } +} diff --git a/src/src/BreakingChanges/Models/ScanResult.php b/src/src/BreakingChanges/Models/ScanResult.php new file mode 100644 index 000000000..f7b52b872 --- /dev/null +++ b/src/src/BreakingChanges/Models/ScanResult.php @@ -0,0 +1,29 @@ +plugin_slug = $plugin_slug; + $this->references = $references; + $this->warnings = $warnings; + } + + public function has_breaking_references(): bool { + return count( $this->references ) > 0; + } +} diff --git a/src/src/BreakingChanges/Models/SymbolDiffResult.php b/src/src/BreakingChanges/Models/SymbolDiffResult.php new file mode 100644 index 000000000..2e025c2fb --- /dev/null +++ b/src/src/BreakingChanges/Models/SymbolDiffResult.php @@ -0,0 +1,24 @@ +removed = $removed; + $this->added = $added; + } + + public function has_removals(): bool { + return count( $this->removed ) > 0; + } +} diff --git a/src/src/BreakingChanges/Models/SymbolInfo.php b/src/src/BreakingChanges/Models/SymbolInfo.php new file mode 100644 index 000000000..076969595 --- /dev/null +++ b/src/src/BreakingChanges/Models/SymbolInfo.php @@ -0,0 +1,50 @@ +name = $name; + $this->type = $type; + $this->file = $file; + $this->line = $line; + $this->visibility = $visibility; + $this->parent_class = $parent_class; + } + + /** + * Get a unique key for deduplication and comparison. + */ + public function get_key(): string { + if ( $this->type === 'method' && $this->parent_class !== null ) { + return $this->parent_class . '::' . $this->name; + } + + return $this->name; + } +} diff --git a/src/src/BreakingChanges/PluginSourceResolver.php b/src/src/BreakingChanges/PluginSourceResolver.php new file mode 100644 index 000000000..61a8869f9 --- /dev/null +++ b/src/src/BreakingChanges/PluginSourceResolver.php @@ -0,0 +1,94 @@ +downloader = $downloader; + $this->zipper = $zipper; + } + + /** + * Resolve a plugin slug or path to a local directory for analysis. + * + * @param string $slug_or_path Plugin slug, local directory path, or local zip path. + * @param string|null $version Optional version for WPORG downloads. + * @return string Path to the extracted plugin directory. + */ + public function resolve( string $slug_or_path, ?string $version = null ): string { + // Local directory — use directly. + if ( is_dir( $slug_or_path ) ) { + return rtrim( $slug_or_path, DIRECTORY_SEPARATOR ); + } + + // Local zip file — extract to temp directory. + if ( is_file( $slug_or_path ) && $this->is_zip_file( $slug_or_path ) ) { + return $this->extract_zip( $slug_or_path ); + } + + // WPORG slug — download and extract. + return $this->download_wporg( $slug_or_path, $version ); + } + + private function is_zip_file( string $path ): bool { + return strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ) === 'zip'; + } + + private function extract_zip( string $zip_path ): string { + $extract_dir = sys_get_temp_dir() . '/qit-breaking-changes/' . basename( $zip_path, '.zip' ) . '-' . substr( md5( $zip_path ), 0, 8 ); + + if ( is_dir( $extract_dir ) ) { + return $this->find_plugin_root( $extract_dir ); + } + + mkdir( $extract_dir, 0755, true ); + + $zip = new \ZipArchive(); + if ( $zip->open( $zip_path ) !== true ) { + throw new \RuntimeException( "Failed to open zip file: {$zip_path}" ); + } + + $zip->extractTo( $extract_dir ); + $zip->close(); + + return $this->find_plugin_root( $extract_dir ); + } + + private function download_wporg( string $slug, ?string $version ): string { + $cache_dir = sys_get_temp_dir() . '/qit-breaking-changes/cache'; + $options = []; + + if ( $version !== null ) { + $options['version'] = $version; + } + + $result = $this->downloader->download( 'wporg_plugin', $slug, $cache_dir, $options ); + $zip_path = $result['path']; + + return $this->extract_zip( $zip_path ); + } + + /** + * Find the actual plugin root directory inside an extracted zip. + * Most WordPress plugin zips contain a single top-level directory. + */ + private function find_plugin_root( string $extract_dir ): string { + $entries = array_diff( scandir( $extract_dir ), [ '.', '..' ] ); + + // If there's exactly one directory, that's the plugin root. + if ( count( $entries ) === 1 ) { + $single = $extract_dir . '/' . reset( $entries ); + if ( is_dir( $single ) ) { + return $single; + } + } + + return $extract_dir; + } +} diff --git a/src/src/BreakingChanges/Renderers/DiffRenderer.php b/src/src/BreakingChanges/Renderers/DiffRenderer.php new file mode 100644 index 000000000..a559bc416 --- /dev/null +++ b/src/src/BreakingChanges/Renderers/DiffRenderer.php @@ -0,0 +1,239 @@ +render_json( $result, $output ); + break; + case 'github': + $this->render_github( $result, $output ); + break; + case 'table': + default: + $this->render_table( $result, $output ); + break; + } + } + + private function render_table( DiffResult $result, OutputInterface $output ): void { + $this->render_symbol_table( $result, $output ); + $output->writeln( '' ); + $this->render_hook_table( $result, $output ); + $output->writeln( '' ); + $this->render_summary( $result, $output ); + } + + private function render_symbol_table( DiffResult $result, OutputInterface $output ): void { + if ( empty( $result->symbols->removed ) && empty( $result->symbols->added ) ) { + $output->writeln( 'No symbol changes detected.' ); + return; + } + + if ( ! empty( $result->symbols->removed ) ) { + $output->writeln( 'Removed Symbols' ); + $table = new Table( $output ); + $table->setHeaders( [ 'Type', 'Name', 'File', 'Line' ] ); + + foreach ( $result->symbols->removed as $symbol ) { + $table->addRow( [ + $symbol->type, + $symbol->get_key(), + $symbol->file, + $symbol->line, + ] ); + } + + $table->render(); + $output->writeln( '' ); + } + + if ( ! empty( $result->symbols->added ) ) { + $output->writeln( 'Added Symbols' ); + $table = new Table( $output ); + $table->setHeaders( [ 'Type', 'Name', 'File', 'Line' ] ); + + foreach ( $result->symbols->added as $symbol ) { + $table->addRow( [ + $symbol->type, + $symbol->get_key(), + $symbol->file, + $symbol->line, + ] ); + } + + $table->render(); + } + } + + private function render_hook_table( DiffResult $result, OutputInterface $output ): void { + if ( empty( $result->hooks->removed ) && empty( $result->hooks->added ) ) { + $output->writeln( 'No hook changes detected.' ); + return; + } + + if ( ! empty( $result->hooks->removed ) ) { + $output->writeln( 'Removed Hooks' ); + $table = new Table( $output ); + $table->setHeaders( [ 'Type', 'Name', 'File', 'Line' ] ); + + foreach ( $result->hooks->removed as $hook ) { + $table->addRow( [ + $hook->type, + $hook->name, + $hook->file, + $hook->line, + ] ); + } + + $table->render(); + $output->writeln( '' ); + } + + if ( ! empty( $result->hooks->added ) ) { + $output->writeln( 'Added Hooks' ); + $table = new Table( $output ); + $table->setHeaders( [ 'Type', 'Name', 'File', 'Line' ] ); + + foreach ( $result->hooks->added as $hook ) { + $table->addRow( [ + $hook->type, + $hook->name, + $hook->file, + $hook->line, + ] ); + } + + $table->render(); + } + } + + private function render_summary( DiffResult $result, OutputInterface $output ): void { + $removed_symbols = count( $result->symbols->removed ); + $added_symbols = count( $result->symbols->added ); + $removed_hooks = count( $result->hooks->removed ); + $added_hooks = count( $result->hooks->added ); + + $output->writeln( sprintf( + 'Symbols: %d removed, %d added', + $removed_symbols, + $added_symbols + ) ); + $output->writeln( sprintf( + 'Hooks: %d removed, %d added', + $removed_hooks, + $added_hooks + ) ); + + if ( $result->has_removals() ) { + $output->writeln( '' ); + $output->writeln( 'Breaking changes detected!' ); + } else { + $output->writeln( '' ); + $output->writeln( 'No breaking changes detected.' ); + } + } + + private function render_json( DiffResult $result, OutputInterface $output ): void { + $data = [ + 'symbols' => [ + 'removed' => array_map( [ $this, 'symbol_to_array' ], $result->symbols->removed ), + 'added' => array_map( [ $this, 'symbol_to_array' ], $result->symbols->added ), + ], + 'hooks' => [ + 'removed' => array_map( [ $this, 'hook_to_array' ], $result->hooks->removed ), + 'added' => array_map( [ $this, 'hook_to_array' ], $result->hooks->added ), + ], + 'summary' => [ + 'has_breaking_changes' => $result->has_removals(), + 'removed_symbols' => count( $result->symbols->removed ), + 'added_symbols' => count( $result->symbols->added ), + 'removed_hooks' => count( $result->hooks->removed ), + 'added_hooks' => count( $result->hooks->added ), + ], + ]; + + $output->writeln( json_encode( $data, JSON_PRETTY_PRINT ) ); + } + + private function render_github( DiffResult $result, OutputInterface $output ): void { + foreach ( $result->symbols->removed as $symbol ) { + $output->writeln( sprintf( + '::error file=%s,line=%d::Removed %s: %s', + $symbol->file, + $symbol->line, + $symbol->type, + $symbol->get_key() + ) ); + } + + foreach ( $result->hooks->removed as $hook ) { + $output->writeln( sprintf( + '::error file=%s,line=%d::Removed %s hook: %s', + $hook->file, + $hook->line, + $hook->type, + $hook->name + ) ); + } + + foreach ( $result->symbols->added as $symbol ) { + $output->writeln( sprintf( + '::notice file=%s,line=%d::Added %s: %s', + $symbol->file, + $symbol->line, + $symbol->type, + $symbol->get_key() + ) ); + } + + foreach ( $result->hooks->added as $hook ) { + $output->writeln( sprintf( + '::notice file=%s,line=%d::Added %s hook: %s', + $hook->file, + $hook->line, + $hook->type, + $hook->name + ) ); + } + } + + /** + * @return array + */ + private function symbol_to_array( SymbolInfo $symbol ): array { + return [ + 'name' => $symbol->get_key(), + 'type' => $symbol->type, + 'file' => $symbol->file, + 'line' => $symbol->line, + 'visibility' => $symbol->visibility, + 'parent_class' => $symbol->parent_class, + ]; + } + + /** + * @return array + */ + private function hook_to_array( HookInfo $hook ): array { + return [ + 'name' => $hook->name, + 'type' => $hook->type, + 'file' => $hook->file, + 'line' => $hook->line, + 'is_dynamic' => $hook->is_dynamic, + 'arg_count' => $hook->arg_count, + ]; + } +} diff --git a/src/src/BreakingChanges/Renderers/ScanRenderer.php b/src/src/BreakingChanges/Renderers/ScanRenderer.php new file mode 100644 index 000000000..1fe523bcc --- /dev/null +++ b/src/src/BreakingChanges/Renderers/ScanRenderer.php @@ -0,0 +1,175 @@ +render_json( $result, $output ); + break; + case 'github': + $this->render_github( $result, $output ); + break; + case 'table': + default: + $this->render_table( $result, $output ); + break; + } + } + + /** + * Render multiple scan results. + * + * @param ScanResult[] $results + */ + public function render_multi( array $results, OutputInterface $output, string $format = 'table' ): void { + switch ( $format ) { + case 'json': + $this->render_multi_json( $results, $output ); + break; + case 'github': + foreach ( $results as $result ) { + $this->render_github( $result, $output ); + } + break; + case 'table': + default: + foreach ( $results as $result ) { + $this->render_table( $result, $output ); + $output->writeln( '' ); + } + $this->render_multi_summary( $results, $output ); + break; + } + } + + private function render_table( ScanResult $result, OutputInterface $output ): void { + $output->writeln( sprintf( 'Plugin: %s', $result->plugin_slug ) ); + + if ( ! $result->has_breaking_references() ) { + $output->writeln( 'No references to removed symbols or hooks found.' ); + return; + } + + // Group references by file. + $grouped = $this->group_by_file( $result->references ); + + foreach ( $grouped as $file => $refs ) { + $output->writeln( sprintf( ' %s', $file ) ); + + $table = new Table( $output ); + $table->setHeaders( [ 'Line', 'Type', 'Symbol', 'Context' ] ); + + foreach ( $refs as $ref ) { + $table->addRow( [ + $ref->line, + $ref->type, + $ref->name, + $ref->context, + ] ); + } + + $table->render(); + } + + $output->writeln( sprintf( + 'Found %d reference(s) to removed symbols/hooks.', + count( $result->references ) + ) ); + } + + private function render_json( ScanResult $result, OutputInterface $output ): void { + $data = $this->result_to_array( $result ); + $output->writeln( json_encode( $data, JSON_PRETTY_PRINT ) ); + } + + /** + * @param ScanResult[] $results + */ + private function render_multi_json( array $results, OutputInterface $output ): void { + $data = [ + 'plugins' => array_map( [ $this, 'result_to_array' ], $results ), + 'summary' => [ + 'total_plugins' => count( $results ), + 'affected_plugins' => count( array_filter( $results, function ( ScanResult $r ) { + return $r->has_breaking_references(); + } ) ), + ], + ]; + + $output->writeln( json_encode( $data, JSON_PRETTY_PRINT ) ); + } + + private function render_github( ScanResult $result, OutputInterface $output ): void { + foreach ( $result->references as $ref ) { + $output->writeln( sprintf( + '::error file=%s,line=%d::[%s] References removed %s: %s', + $ref->file, + $ref->line, + $result->plugin_slug, + $ref->type, + $ref->name + ) ); + } + } + + /** + * @param ScanResult[] $results + */ + private function render_multi_summary( array $results, OutputInterface $output ): void { + $affected = array_filter( $results, function ( ScanResult $r ) { + return $r->has_breaking_references(); + } ); + + $output->writeln( sprintf( + 'Scanned %d plugin(s): %d affected, %d clean', + count( $results ), + count( $affected ), + count( $results ) - count( $affected ) + ) ); + } + + /** + * @return array + */ + private function result_to_array( ScanResult $result ): array { + return [ + 'plugin_slug' => $result->plugin_slug, + 'has_breaking_references' => $result->has_breaking_references(), + 'reference_count' => count( $result->references ), + 'references' => array_map( function ( FoundReference $ref ) { + return [ + 'name' => $ref->name, + 'type' => $ref->type, + 'file' => $ref->file, + 'line' => $ref->line, + 'context' => $ref->context, + ]; + }, $result->references ), + 'warnings' => $result->warnings, + ]; + } + + /** + * Group references by file path. + * + * @param FoundReference[] $references + * @return array + */ + private function group_by_file( array $references ): array { + $grouped = []; + foreach ( $references as $ref ) { + $grouped[ $ref->file ][] = $ref; + } + return $grouped; + } +} diff --git a/src/src/BreakingChanges/Scanner/ReferenceScanner.php b/src/src/BreakingChanges/Scanner/ReferenceScanner.php new file mode 100644 index 000000000..180b30598 --- /dev/null +++ b/src/src/BreakingChanges/Scanner/ReferenceScanner.php @@ -0,0 +1,114 @@ +parser = $parser; + } + + /** + * Scan a directory for references to removed symbols/hooks. + */ + public function scan( + string $directory, + SymbolDiffResult $symbol_diff, + HookDiffResult $hook_diff, + string $plugin_slug = '' + ): ScanResult { + $all_references = []; + $warnings = []; + $base_path = rtrim( $directory, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR; + + if ( empty( $plugin_slug ) ) { + $plugin_slug = basename( $directory ); + } + + // No removals means nothing to scan for. + if ( ! $symbol_diff->has_removals() && ! $hook_diff->has_removals() ) { + return new ScanResult( $plugin_slug ); + } + + $php_files = $this->find_php_files( $directory ); + + foreach ( $php_files as $file ) { + $relative_path = str_replace( $base_path, '', $file ); + $ast = $this->parser->parse( $file ); + + if ( $ast === null ) { + $warnings[] = "Failed to parse: {$relative_path}"; + continue; + } + + $visitor = new ReferenceVisitor( $symbol_diff, $hook_diff, $relative_path ); + $traverser = new NodeTraverser(); + $traverser->addVisitor( new NameResolver() ); + $traverser->addVisitor( $visitor ); + $traverser->traverse( $ast ); + + $all_references = array_merge( $all_references, $visitor->get_references() ); + } + + return new ScanResult( $plugin_slug, $all_references, $warnings ); + } + + /** + * Recursively find all .php files, skipping excluded directories. + * + * @return string[] + */ + private function find_php_files( string $directory ): array { + $files = []; + $directory = rtrim( $directory, DIRECTORY_SEPARATOR ); + + if ( ! is_dir( $directory ) ) { + return $files; + } + + $iterator = new \RecursiveDirectoryIterator( + $directory, + \RecursiveDirectoryIterator::SKIP_DOTS + ); + + $filter = new \RecursiveCallbackFilterIterator( + $iterator, + function ( \SplFileInfo $current, string $key, \RecursiveDirectoryIterator $iterator ): bool { + if ( $current->isDir() ) { + return ! in_array( $current->getFilename(), self::SKIP_DIRS, true ); + } + + return $current->getExtension() === 'php'; + } + ); + + $flat_iterator = new \RecursiveIteratorIterator( $filter ); + + foreach ( $flat_iterator as $file ) { + /** @var \SplFileInfo $file */ + $files[] = $file->getPathname(); + } + + sort( $files ); + + return $files; + } +} diff --git a/src/src/BreakingChanges/Scanner/ReferenceVisitor.php b/src/src/BreakingChanges/Scanner/ReferenceVisitor.php new file mode 100644 index 000000000..99c75d714 --- /dev/null +++ b/src/src/BreakingChanges/Scanner/ReferenceVisitor.php @@ -0,0 +1,293 @@ + Removed class FQNs */ + private array $removed_classes = []; + + /** @var array Removed method keys (Class::method) */ + private array $removed_methods = []; + + /** @var array Removed function FQNs */ + private array $removed_functions = []; + + /** @var array Removed constant names */ + private array $removed_constants = []; + + /** @var array Removed hook names */ + private array $removed_hooks = []; + + /** @var FoundReference[] */ + private array $references = []; + + private string $file; + + /** @var array Hook registration functions */ + private static array $hook_functions = [ + 'add_action' => true, + 'add_filter' => true, + 'remove_action' => true, + 'remove_filter' => true, + 'has_action' => true, + 'has_filter' => true, + ]; + + public function __construct( + SymbolDiffResult $symbol_diff, + HookDiffResult $hook_diff, + string $file + ) { + $this->file = $file; + + foreach ( $symbol_diff->removed as $symbol ) { + switch ( $symbol->type ) { + case 'class': + $this->removed_classes[ $symbol->get_key() ] = true; + break; + case 'method': + $this->removed_methods[ $symbol->get_key() ] = true; + break; + case 'function': + $this->removed_functions[ $symbol->get_key() ] = true; + break; + case 'constant': + $this->removed_constants[ $symbol->get_key() ] = true; + break; + } + } + + foreach ( $hook_diff->removed as $hook ) { + $this->removed_hooks[ $hook->name ] = true; + } + } + + /** + * @return FoundReference[] + */ + public function get_references(): array { + return $this->references; + } + + /** + * @return int|null + */ + public function enterNode( Node $node ) { + if ( $node instanceof Expr\New_ ) { + $this->check_class_instantiation( $node ); + } elseif ( $node instanceof Expr\StaticCall ) { + $this->check_static_call( $node ); + } elseif ( $node instanceof Expr\StaticPropertyFetch ) { + $this->check_static_property( $node ); + } elseif ( $node instanceof Expr\ClassConstFetch ) { + $this->check_class_const( $node ); + } elseif ( $node instanceof Expr\FuncCall ) { + $this->check_function_call( $node ); + } elseif ( $node instanceof Expr\ConstFetch ) { + $this->check_constant_access( $node ); + } elseif ( $node instanceof Node\Stmt\Class_ ) { + $this->check_class_extends( $node ); + } elseif ( $node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Enum_ ) { + $this->check_implements( $node ); + } + + return null; + } + + private function check_class_instantiation( Expr\New_ $node ): void { + if ( ! $node->class instanceof Name ) { + return; + } + + $class_name = $node->class->toString(); + if ( isset( $this->removed_classes[ $class_name ] ) ) { + $this->references[] = new FoundReference( + $class_name, + 'class_usage', + $this->file, + $node->getStartLine(), + "new {$class_name}(...)" + ); + } + } + + private function check_static_call( Expr\StaticCall $node ): void { + if ( ! $node->class instanceof Name ) { + return; + } + + $class_name = $node->class->toString(); + + // Check if the class itself is removed. + if ( isset( $this->removed_classes[ $class_name ] ) ) { + $this->references[] = new FoundReference( + $class_name, + 'class_usage', + $this->file, + $node->getStartLine(), + "{$class_name}::..." + ); + return; + } + + // Check if the specific method is removed. + if ( $node->name instanceof Node\Identifier ) { + $method_key = $class_name . '::' . $node->name->toString(); + if ( isset( $this->removed_methods[ $method_key ] ) ) { + $this->references[] = new FoundReference( + $method_key, + 'static_call', + $this->file, + $node->getStartLine(), + "{$method_key}(...)" + ); + } + } + } + + private function check_static_property( Expr\StaticPropertyFetch $node ): void { + if ( ! $node->class instanceof Name ) { + return; + } + + $class_name = $node->class->toString(); + if ( isset( $this->removed_classes[ $class_name ] ) ) { + $this->references[] = new FoundReference( + $class_name, + 'class_usage', + $this->file, + $node->getStartLine(), + "{$class_name}::\$..." + ); + } + } + + private function check_class_const( Expr\ClassConstFetch $node ): void { + if ( ! $node->class instanceof Name ) { + return; + } + + $class_name = $node->class->toString(); + if ( isset( $this->removed_classes[ $class_name ] ) ) { + $this->references[] = new FoundReference( + $class_name, + 'class_usage', + $this->file, + $node->getStartLine(), + "{$class_name}::CONST" + ); + } + } + + private function check_function_call( Expr\FuncCall $node ): void { + if ( ! $node->name instanceof Name ) { + return; + } + + $func_name = $node->name->toString(); + + // Check if it's a removed function. + if ( isset( $this->removed_functions[ $func_name ] ) ) { + $this->references[] = new FoundReference( + $func_name, + 'function_call', + $this->file, + $node->getStartLine(), + "{$func_name}(...)" + ); + return; + } + + // Check if it's a hook registration referencing a removed hook. + $func_lower = $node->name->toLowerString(); + if ( isset( self::$hook_functions[ $func_lower ] ) ) { + $this->check_hook_reference( $node ); + } + } + + private function check_hook_reference( Expr\FuncCall $node ): void { + if ( count( $node->args ) < 1 ) { + return; + } + + $first_arg = $node->args[0]; + if ( ! $first_arg instanceof Arg ) { + return; + } + + if ( ! $first_arg->value instanceof String_ ) { + return; + } + + $hook_name = $first_arg->value->value; + if ( isset( $this->removed_hooks[ $hook_name ] ) ) { + $func_name = $node->name instanceof Name ? $node->name->toString() : 'hook_call'; + $this->references[] = new FoundReference( + $hook_name, + 'hook_registration', + $this->file, + $node->getStartLine(), + "{$func_name}( '{$hook_name}', ... )" + ); + } + } + + private function check_constant_access( Expr\ConstFetch $node ): void { + $const_name = $node->name->toString(); + if ( isset( $this->removed_constants[ $const_name ] ) ) { + $this->references[] = new FoundReference( + $const_name, + 'constant_access', + $this->file, + $node->getStartLine(), + $const_name + ); + } + } + + private function check_class_extends( Node\Stmt\Class_ $node ): void { + if ( $node->extends === null ) { + return; + } + + $parent = $node->extends->toString(); + if ( isset( $this->removed_classes[ $parent ] ) ) { + $this->references[] = new FoundReference( + $parent, + 'class_usage', + $this->file, + $node->getStartLine(), + "extends {$parent}" + ); + } + } + + /** + * @param Node\Stmt\Class_|Node\Stmt\Enum_ $node + */ + private function check_implements( Node $node ): void { + $implements = $node->implements ?? []; + foreach ( $implements as $interface ) { + $name = $interface->toString(); + if ( isset( $this->removed_classes[ $name ] ) ) { + $this->references[] = new FoundReference( + $name, + 'class_usage', + $this->file, + $node->getStartLine(), + "implements {$name}" + ); + } + } + } +} diff --git a/src/src/BreakingChanges/Visitors/HookVisitor.php b/src/src/BreakingChanges/Visitors/HookVisitor.php new file mode 100644 index 000000000..958c212cb --- /dev/null +++ b/src/src/BreakingChanges/Visitors/HookVisitor.php @@ -0,0 +1,93 @@ + */ + private static array $action_functions = [ + 'do_action' => true, + 'do_action_ref_array' => true, + 'do_action_deprecated' => true, + ]; + + /** @var array */ + private static array $filter_functions = [ + 'apply_filters' => true, + 'apply_filters_ref_array' => true, + 'apply_filters_deprecated' => true, + ]; + + private ExtractedSymbols $symbols; + private string $file; + + public function __construct( ExtractedSymbols $symbols, string $file ) { + $this->symbols = $symbols; + $this->file = $file; + } + + public function get_symbols(): ExtractedSymbols { + return $this->symbols; + } + + /** + * @return int|null + */ + public function enterNode( Node $node ) { + if ( ! $node instanceof FuncCall ) { + return null; + } + + if ( ! $node->name instanceof Node\Name ) { + return null; + } + + $func_name = $node->name->toLowerString(); + + $is_action = isset( self::$action_functions[ $func_name ] ); + $is_filter = isset( self::$filter_functions[ $func_name ] ); + + if ( ! $is_action && ! $is_filter ) { + return null; + } + + if ( count( $node->args ) < 1 ) { + return null; + } + + $first_arg = $node->args[0]; + if ( ! $first_arg instanceof Arg ) { + return null; + } + + $hook_type = $is_action ? 'action' : 'filter'; + + // Count additional arguments (excluding the hook name itself). + $arg_count = count( $node->args ) - 1; + + if ( $first_arg->value instanceof String_ ) { + $hook_name = $first_arg->value->value; + + $this->symbols->add_hook( new HookInfo( + $hook_name, + $hook_type, + $this->file, + $node->getStartLine(), + false, + $arg_count + ) ); + } else { + // Dynamic hook name — can't determine statically. + $this->symbols->dynamic_hook_count++; + } + + return null; + } +} diff --git a/src/src/BreakingChanges/Visitors/SymbolVisitor.php b/src/src/BreakingChanges/Visitors/SymbolVisitor.php new file mode 100644 index 000000000..82657cdbb --- /dev/null +++ b/src/src/BreakingChanges/Visitors/SymbolVisitor.php @@ -0,0 +1,238 @@ +symbols = $symbols; + $this->file = $file; + } + + public function get_symbols(): ExtractedSymbols { + return $this->symbols; + } + + /** + * @return int|null + */ + public function enterNode( Node $node ) { + if ( $node instanceof Stmt\Class_ ) { + $this->visit_class( $node ); + } elseif ( $node instanceof Stmt\Interface_ ) { + $this->visit_interface( $node ); + } elseif ( $node instanceof Stmt\Trait_ ) { + $this->visit_trait( $node ); + } elseif ( $node instanceof Stmt\Enum_ ) { + $this->visit_enum( $node ); + } elseif ( $node instanceof Stmt\ClassMethod ) { + $this->visit_method( $node ); + } elseif ( $node instanceof Stmt\Function_ ) { + $this->visit_function( $node ); + } elseif ( $node instanceof Stmt\Const_ ) { + $this->visit_const_statement( $node ); + } elseif ( $node instanceof FuncCall ) { + $this->visit_define_call( $node ); + } + + return null; + } + + /** + * @return int|Node|null + */ + public function leaveNode( Node $node ) { + if ( + $node instanceof Stmt\Class_ + || $node instanceof Stmt\Interface_ + || $node instanceof Stmt\Trait_ + || $node instanceof Stmt\Enum_ + ) { + $this->current_class = null; + } + + return null; + } + + private function visit_class( Stmt\Class_ $node ): void { + if ( $node->name === null ) { + return; // Anonymous class. + } + + $fqn = $this->get_fqn( $node ); + + $this->current_class = $fqn; + + $this->symbols->add_class( new SymbolInfo( + $fqn, + 'class', + $this->file, + $node->getStartLine(), + 'public' + ) ); + } + + private function visit_interface( Stmt\Interface_ $node ): void { + $fqn = $this->get_fqn( $node ); + + $this->current_class = $fqn; + + $this->symbols->add_class( new SymbolInfo( + $fqn, + 'class', + $this->file, + $node->getStartLine(), + 'public' + ) ); + } + + private function visit_trait( Stmt\Trait_ $node ): void { + $fqn = $this->get_fqn( $node ); + + $this->current_class = $fqn; + + $this->symbols->add_class( new SymbolInfo( + $fqn, + 'class', + $this->file, + $node->getStartLine(), + 'public' + ) ); + } + + private function visit_enum( Stmt\Enum_ $node ): void { + $fqn = $this->get_fqn( $node ); + + $this->current_class = $fqn; + + $this->symbols->add_class( new SymbolInfo( + $fqn, + 'class', + $this->file, + $node->getStartLine(), + 'public' + ) ); + } + + private function visit_method( Stmt\ClassMethod $node ): void { + if ( $this->current_class === null ) { + return; + } + + // Only collect public methods. + if ( ! $node->isPublic() ) { + return; + } + + $method_name = $node->name->toString(); + + $this->symbols->add_method( new SymbolInfo( + $method_name, + 'method', + $this->file, + $node->getStartLine(), + 'public', + $this->current_class + ) ); + } + + private function visit_function( Stmt\Function_ $node ): void { + $fqn = $this->get_fqn( $node ); + + $this->symbols->add_function( new SymbolInfo( + $fqn, + 'function', + $this->file, + $node->getStartLine(), + 'public' + ) ); + } + + private function visit_const_statement( Stmt\Const_ $node ): void { + foreach ( $node->consts as $const ) { + $fqn = $this->get_fqn_from_const( $const ); + + $this->symbols->add_constant( new SymbolInfo( + $fqn, + 'constant', + $this->file, + $const->getStartLine(), + 'public' + ) ); + } + } + + private function visit_define_call( FuncCall $node ): void { + if ( ! $node->name instanceof Node\Name ) { + return; + } + + $func_name = $node->name->toLowerString(); + if ( $func_name !== 'define' ) { + return; + } + + if ( count( $node->args ) < 2 ) { + return; + } + + $first_arg = $node->args[0]; + if ( ! $first_arg instanceof Node\Arg ) { + return; + } + + if ( ! $first_arg->value instanceof String_ ) { + return; + } + + $const_name = $first_arg->value->value; + + $this->symbols->add_constant( new SymbolInfo( + $const_name, + 'constant', + $this->file, + $node->getStartLine(), + 'public' + ) ); + } + + /** + * Get fully qualified name from a named node. + * + * @param Stmt\Class_|Stmt\Interface_|Stmt\Trait_|Stmt\Enum_|Stmt\Function_ $node + */ + private function get_fqn( Node $node ): string { + // NameResolver sets the 'namespacedName' attribute. + if ( $node->namespacedName !== null ) { + return $node->namespacedName->toString(); + } + + if ( isset( $node->name ) && $node->name !== null ) { + return $node->name->toString(); + } + + return ''; + } + + private function get_fqn_from_const( Const_ $node ): string { + if ( $node->namespacedName !== null ) { + return $node->namespacedName->toString(); + } + + return $node->name->toString(); + } +} diff --git a/src/src/BreakingChanges/WooDevelopedFetcher.php b/src/src/BreakingChanges/WooDevelopedFetcher.php new file mode 100644 index 000000000..17a372973 --- /dev/null +++ b/src/src/BreakingChanges/WooDevelopedFetcher.php @@ -0,0 +1,49 @@ +cache = $cache; + } + + /** + * Fetch the list of Woo-developed plugin slugs. + * + * @return string[] Array of plugin slugs. + */ + public function fetch(): array { + $cached = $this->cache->get( self::CACHE_KEY ); + if ( is_array( $cached ) ) { + return $cached; + } + + $url = get_manager_url() . '/wp-json/cd/v1/cli/woo-developed-extensions'; + $response = ( new RequestBuilder( $url ) ) + ->with_method( 'GET' ) + ->request(); + + $data = json_decode( $response, true ); + + if ( ! is_array( $data ) ) { + throw new \RuntimeException( 'Failed to fetch Woo-developed extensions list.' ); + } + + $slugs = array_map( function ( array $ext ) { + return $ext['slug']; + }, $data ); + + $this->cache->set( self::CACHE_KEY, $slugs, self::CACHE_TTL ); + + return $slugs; + } +} diff --git a/src/src/CachedDownloader.php b/src/src/CachedDownloader.php index 740dc1486..28826da91 100644 --- a/src/src/CachedDownloader.php +++ b/src/src/CachedDownloader.php @@ -110,7 +110,8 @@ public function download( */ private function fetch_remote_metadata( string $type, string $identifier, array $options ): array { // Check short-lived metadata cache first (30 seconds) - $metadata_cache_key = "remote_metadata_{$type}_{$identifier}"; + $version_suffix = ! empty( $options['version'] ) ? '_' . $options['version'] : ''; + $metadata_cache_key = "remote_metadata_{$type}_{$identifier}{$version_suffix}"; $cached_metadata = $this->cache->get( $metadata_cache_key ); if ( is_array( $cached_metadata ) ) { @@ -123,7 +124,7 @@ private function fetch_remote_metadata( string $type, string $identifier, array $metadata = $this->fetch_test_package_metadata( $identifier, $options ); break; case 'wporg_plugin': - $metadata = $this->fetch_wporg_plugin_metadata( $identifier ); + $metadata = $this->fetch_wporg_plugin_metadata( $identifier, $options ); break; case 'wporg_theme': $metadata = $this->fetch_wporg_theme_metadata( $identifier ); @@ -178,11 +179,19 @@ private function fetch_test_package_metadata( string $identifier, array $options /** * Fetch WordPress.org plugin metadata. * - * @param string $slug + * @param string $slug + * @param array $options Optional. Supports 'version' key for specific version downloads. * @return array */ - private function fetch_wporg_plugin_metadata( string $slug ): array { + private function fetch_wporg_plugin_metadata( string $slug, array $options = [] ): array { + $requested_version = $options['version'] ?? null; + $needs_versions = $requested_version !== null + && ! in_array( strtolower( $requested_version ), [ 'stable', 'latest' ], true ); + $url = "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request[slug]={$slug}"; + if ( $needs_versions ) { + $url .= '&request[fields][versions]=1'; + } $response = ( new RequestBuilder( $url ) ) ->with_method( 'GET' ) @@ -194,11 +203,21 @@ private function fetch_wporg_plugin_metadata( string $slug ): array { throw new \RuntimeException( "Failed to fetch metadata for WPORG plugin: $slug" ); } + $download_url = $data['download_link']; + $version = $data['version'] ?? 'unknown'; + + // If a specific version was requested, look it up in the versions list. + if ( $needs_versions && ! empty( $data['versions'][ $requested_version ] ) ) { + $download_url = $data['versions'][ $requested_version ]; + $version = $requested_version; + } elseif ( $needs_versions ) { + throw new \RuntimeException( "Version {$requested_version} not found for WPORG plugin: {$slug}" ); + } + return [ - 'url' => $data['download_link'], - 'version' => $data['version'] ?? 'unknown', + 'url' => $download_url, + 'version' => $version, 'last_updated' => $data['last_updated'] ?? null, - // WPORG doesn't provide checksums in API ]; } diff --git a/src/src/bootstrap.php b/src/src/bootstrap.php index 874933014..647e3253a 100644 --- a/src/src/bootstrap.php +++ b/src/src/bootstrap.php @@ -205,6 +205,8 @@ public function getDefaultCommands() { $application->add( $container->make( WooValidateZipCommand::class ) ); $application->add( $container->make( TunnelSetupCommand::class ) ); $application->add( $container->make( TunnelSetDefaultCommand::class ) ); +$application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\DiffCommand::class ) ); +$application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\ScanCommand::class ) ); // Environment commands. try { @@ -292,6 +294,9 @@ public function getDefaultCommands() { $application->add( $container->make( AIInstallAgentsCommand::class ) ); } + // Breaking Changes (requires backend for index upload). + $application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\IndexCommand::class ) ); + // Group Commands. $application->add( $container->make( RunGroupCommand::class ) ); $application->add( $container->make( GroupFetchCommand::class ) ); diff --git a/src/tests/unit/BreakingChanges/Commands/DiffCommandTest.php b/src/tests/unit/BreakingChanges/Commands/DiffCommandTest.php new file mode 100644 index 000000000..3aeb9e3ba --- /dev/null +++ b/src/tests/unit/BreakingChanges/Commands/DiffCommandTest.php @@ -0,0 +1,121 @@ +fixtures_dir = dirname( __DIR__ ) . '/fixtures'; + } + + private function make_command_tester(): CommandTester { + $downloader = $this->createMock( CachedDownloader::class ); + $zipper = $this->createMock( Zipper::class ); + $resolver = new PluginSourceResolver( $downloader, $zipper ); + $extractor = new DirectoryExtractor( new FileParser() ); + + $command = new DiffCommand( + $resolver, + $extractor, + new SymbolDiffer(), + new HookDiffer(), + new DiffRenderer() + ); + + $application = new Application(); + $application->add( $command ); + + return new CommandTester( $application->find( 'breaking-changes:diff' ) ); + } + + public function test_detects_breaking_changes_between_fixtures(): void { + $tester = $this->make_command_tester(); + + $exit_code = $tester->execute( [ + 'slug' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + ] ); + + // Should exit 1 because there are removals. + $this->assertEquals( 1, $exit_code ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString( 'Breaking changes detected', $output ); + } + + public function test_no_changes_returns_success(): void { + $tester = $this->make_command_tester(); + + $exit_code = $tester->execute( [ + 'slug' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v1', + ] ); + + $this->assertEquals( 0, $exit_code ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString( 'No breaking changes detected', $output ); + } + + public function test_json_output_format(): void { + $tester = $this->make_command_tester(); + + $tester->execute( [ + 'slug' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + '--format' => 'json', + ] ); + + $output = $tester->getDisplay(); + $data = json_decode( $output, true ); + + $this->assertIsArray( $data ); + $this->assertTrue( $data['summary']['has_breaking_changes'] ); + $this->assertGreaterThan( 0, $data['summary']['removed_symbols'] ); + $this->assertGreaterThan( 0, $data['summary']['removed_hooks'] ); + } + + public function test_github_output_format(): void { + $tester = $this->make_command_tester(); + + $tester->execute( [ + 'slug' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + '--format' => 'github', + ] ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString( '::error file=', $output ); + $this->assertStringContainsString( '::notice file=', $output ); + } + + public function test_requires_old_option(): void { + $tester = $this->make_command_tester(); + + $exit_code = $tester->execute( [ + 'slug' => 'some-plugin', + ] ); + + $this->assertEquals( 1, $exit_code ); + $this->assertStringContainsString( '--old option is required', $tester->getDisplay() ); + } +} diff --git a/src/tests/unit/BreakingChanges/Commands/ScanCommandCheckAgainstTest.php b/src/tests/unit/BreakingChanges/Commands/ScanCommandCheckAgainstTest.php new file mode 100644 index 000000000..7fe98ecd3 --- /dev/null +++ b/src/tests/unit/BreakingChanges/Commands/ScanCommandCheckAgainstTest.php @@ -0,0 +1,123 @@ +fixtures_dir = dirname( __DIR__ ) . '/fixtures'; + } + + private function make_command_tester( ?WooDevelopedFetcher $fetcher = null ): CommandTester { + $downloader = $this->createMock( CachedDownloader::class ); + $zipper = $this->createMock( Zipper::class ); + $parser = new FileParser(); + $resolver = new PluginSourceResolver( $downloader, $zipper ); + + $command = new ScanCommand( + $resolver, + new DirectoryExtractor( $parser ), + new SymbolDiffer(), + new HookDiffer(), + new ReferenceScanner( $parser ), + new ScanRenderer(), + $fetcher + ); + + $application = new Application(); + $application->add( $command ); + + return new CommandTester( $application->find( 'breaking-changes:scan' ) ); + } + + public function test_check_against_comma_separated_local_paths(): void { + $tester = $this->make_command_tester(); + + $exit_code = $tester->execute( [ + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + '--check-against' => $this->fixtures_dir . '/target-plugin,' . $this->fixtures_dir . '/sample-plugin-v2', + ] ); + + $output = $tester->getDisplay(); + // target-plugin has breaking references, sample-plugin-v2 does not. + $this->assertStringContainsString( 'target-plugin', $output ); + } + + public function test_check_against_no_target_needed(): void { + $tester = $this->make_command_tester(); + + // Should work without 'target' argument when --check-against is provided. + $exit_code = $tester->execute( [ + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + '--check-against' => $this->fixtures_dir . '/target-plugin', + ] ); + + // Should find references in target-plugin. + $this->assertEquals( 1, $exit_code ); + } + + public function test_check_against_json_format(): void { + $tester = $this->make_command_tester(); + + $tester->execute( [ + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + '--check-against' => $this->fixtures_dir . '/target-plugin,' . $this->fixtures_dir . '/sample-plugin-v2', + '--format' => 'json', + ] ); + + $data = json_decode( $tester->getDisplay(), true ); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'plugins', $data ); + $this->assertArrayHasKey( 'summary', $data ); + $this->assertCount( 2, $data['plugins'] ); + } + + public function test_check_against_woo_developed_requires_fetcher(): void { + $tester = $this->make_command_tester( null ); + + $exit_code = $tester->execute( [ + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + '--check-against' => 'woo-developed', + ] ); + + $this->assertEquals( 1, $exit_code ); + $this->assertStringContainsString( 'not connected to QIT backend', $tester->getDisplay() ); + } + + public function test_requires_target_or_check_against(): void { + $tester = $this->make_command_tester(); + + $exit_code = $tester->execute( [ + '--dependency' => 'woocommerce', + '--old' => '9.4.0', + ] ); + + $this->assertEquals( 1, $exit_code ); + $this->assertStringContainsString( 'Either a target argument or --check-against', $tester->getDisplay() ); + } +} diff --git a/src/tests/unit/BreakingChanges/Commands/ScanCommandTest.php b/src/tests/unit/BreakingChanges/Commands/ScanCommandTest.php new file mode 100644 index 000000000..9c26e94d3 --- /dev/null +++ b/src/tests/unit/BreakingChanges/Commands/ScanCommandTest.php @@ -0,0 +1,148 @@ +fixtures_dir = dirname( __DIR__ ) . '/fixtures'; + } + + private function make_command_tester(): CommandTester { + $downloader = $this->createMock( CachedDownloader::class ); + $zipper = $this->createMock( Zipper::class ); + $parser = new FileParser(); + $resolver = new PluginSourceResolver( $downloader, $zipper ); + + $command = new ScanCommand( + $resolver, + new DirectoryExtractor( $parser ), + new SymbolDiffer(), + new HookDiffer(), + new ReferenceScanner( $parser ), + new ScanRenderer() + ); + + $application = new Application(); + $application->add( $command ); + + return new CommandTester( $application->find( 'breaking-changes:scan' ) ); + } + + public function test_finds_breaking_references(): void { + $tester = $this->make_command_tester(); + + $exit_code = $tester->execute( [ + 'target' => $this->fixtures_dir . '/target-plugin', + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + ] ); + + $this->assertEquals( 1, $exit_code ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString( 'reference(s) to removed symbols/hooks', $output ); + } + + public function test_returns_success_when_no_breaking_references(): void { + $tester = $this->make_command_tester(); + + // Scan v2 against v1→v2 changes (v2 doesn't reference its own removed symbols). + $exit_code = $tester->execute( [ + 'target' => $this->fixtures_dir . '/sample-plugin-v2', + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + ] ); + + $this->assertEquals( 0, $exit_code ); + } + + public function test_json_output_format(): void { + $tester = $this->make_command_tester(); + + $tester->execute( [ + 'target' => $this->fixtures_dir . '/target-plugin', + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + '--format' => 'json', + ] ); + + $data = json_decode( $tester->getDisplay(), true ); + + $this->assertIsArray( $data ); + $this->assertTrue( $data['has_breaking_references'] ); + $this->assertGreaterThan( 0, $data['reference_count'] ); + } + + public function test_github_output_format(): void { + $tester = $this->make_command_tester(); + + $tester->execute( [ + 'target' => $this->fixtures_dir . '/target-plugin', + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v2', + '--format' => 'github', + ] ); + + $output = $tester->getDisplay(); + $this->assertStringContainsString( '::error file=', $output ); + } + + public function test_requires_dependency_option(): void { + $tester = $this->make_command_tester(); + + $exit_code = $tester->execute( [ + 'target' => 'some-plugin', + ] ); + + $this->assertEquals( 1, $exit_code ); + $this->assertStringContainsString( '--dependency option is required', $tester->getDisplay() ); + } + + public function test_requires_old_option(): void { + $tester = $this->make_command_tester(); + + $exit_code = $tester->execute( [ + 'target' => 'some-plugin', + '--dependency' => 'woocommerce', + ] ); + + $this->assertEquals( 1, $exit_code ); + $this->assertStringContainsString( '--old option is required', $tester->getDisplay() ); + } + + public function test_no_breaking_changes_in_dependency(): void { + $tester = $this->make_command_tester(); + + // Same version for old and new — no changes. + $exit_code = $tester->execute( [ + 'target' => $this->fixtures_dir . '/target-plugin', + '--dependency' => $this->fixtures_dir . '/sample-plugin-v1', + '--old' => $this->fixtures_dir . '/sample-plugin-v1', + '--new' => $this->fixtures_dir . '/sample-plugin-v1', + ] ); + + $this->assertEquals( 0, $exit_code ); + $this->assertStringContainsString( 'No breaking changes in dependency', $tester->getDisplay() ); + } +} diff --git a/src/tests/unit/BreakingChanges/Diff/HookDifferTest.php b/src/tests/unit/BreakingChanges/Diff/HookDifferTest.php new file mode 100644 index 000000000..a63bf0f27 --- /dev/null +++ b/src/tests/unit/BreakingChanges/Diff/HookDifferTest.php @@ -0,0 +1,129 @@ +differ = new HookDiffer(); + } + + public function test_detects_removed_hook(): void { + $old = new ExtractedSymbols(); + $old->add_hook( new HookInfo( 'old_hook', 'action', 'file.php', 10 ) ); + $old->add_hook( new HookInfo( 'kept_hook', 'filter', 'file.php', 20 ) ); + + $new = new ExtractedSymbols(); + $new->add_hook( new HookInfo( 'kept_hook', 'filter', 'file.php', 20 ) ); + + $result = $this->differ->diff( $old, $new ); + + $this->assertTrue( $result->has_removals() ); + $this->assertCount( 1, $result->removed ); + $this->assertEquals( 'old_hook', $result->removed[0]->name ); + } + + public function test_detects_added_hook(): void { + $old = new ExtractedSymbols(); + $old->add_hook( new HookInfo( 'existing_hook', 'action', 'file.php', 10 ) ); + + $new = new ExtractedSymbols(); + $new->add_hook( new HookInfo( 'existing_hook', 'action', 'file.php', 10 ) ); + $new->add_hook( new HookInfo( 'new_hook', 'filter', 'file.php', 20 ) ); + + $result = $this->differ->diff( $old, $new ); + + $this->assertFalse( $result->has_removals() ); + $this->assertCount( 1, $result->added ); + $this->assertEquals( 'new_hook', $result->added[0]->name ); + } + + public function test_skips_dynamic_hooks(): void { + $old = new ExtractedSymbols(); + $old->add_hook( new HookInfo( 'dynamic_hook', 'action', 'file.php', 10, true ) ); + + $new = new ExtractedSymbols(); + + $result = $this->differ->diff( $old, $new ); + + // Dynamic hook should be skipped, so no removals detected. + $this->assertFalse( $result->has_removals() ); + $this->assertEmpty( $result->removed ); + } + + public function test_no_changes(): void { + $old = new ExtractedSymbols(); + $old->add_hook( new HookInfo( 'hook_a', 'action', 'file.php', 10 ) ); + $old->add_hook( new HookInfo( 'hook_b', 'filter', 'file.php', 20 ) ); + + $new = new ExtractedSymbols(); + $new->add_hook( new HookInfo( 'hook_a', 'action', 'file.php', 10 ) ); + $new->add_hook( new HookInfo( 'hook_b', 'filter', 'file.php', 20 ) ); + + $result = $this->differ->diff( $old, $new ); + + $this->assertFalse( $result->has_removals() ); + $this->assertEmpty( $result->removed ); + $this->assertEmpty( $result->added ); + } + + public function test_diff_with_fixture_plugins(): void { + $extractor = new DirectoryExtractor( new FileParser() ); + + $old = $extractor->extract( __DIR__ . '/../fixtures/sample-plugin-v1' ); + $new = $extractor->extract( __DIR__ . '/../fixtures/sample-plugin-v2' ); + + $result = $this->differ->diff( $old, $new ); + + $this->assertTrue( $result->has_removals() ); + + $removed_names = array_map( + function ( HookInfo $h ) { + return $h->name; + }, + $result->removed + ); + + // Removed hooks: sample_plugin_init, sample_plugin_before_process, sample_plugin_after_process. + $this->assertContains( 'sample_plugin_init', $removed_names ); + $this->assertContains( 'sample_plugin_before_process', $removed_names ); + $this->assertContains( 'sample_plugin_after_process', $removed_names ); + + $added_names = array_map( + function ( HookInfo $h ) { + return $h->name; + }, + $result->added + ); + + // Added hooks: sample_plugin_initialized, sample_plugin_before_batch, sample_plugin_after_batch, + // sample_plugin_sanitize_output, sample_plugin_registered, sample_plugin_registry_get, sample_plugin_utility_result. + $this->assertContains( 'sample_plugin_initialized', $added_names ); + $this->assertContains( 'sample_plugin_before_batch', $added_names ); + $this->assertContains( 'sample_plugin_after_batch', $added_names ); + $this->assertContains( 'sample_plugin_sanitize_output', $added_names ); + $this->assertContains( 'sample_plugin_registered', $added_names ); + $this->assertContains( 'sample_plugin_registry_get', $added_names ); + $this->assertContains( 'sample_plugin_utility_result', $added_names ); + } + + public function test_empty_inputs(): void { + $old = new ExtractedSymbols(); + $new = new ExtractedSymbols(); + + $result = $this->differ->diff( $old, $new ); + + $this->assertFalse( $result->has_removals() ); + $this->assertEmpty( $result->removed ); + $this->assertEmpty( $result->added ); + } +} diff --git a/src/tests/unit/BreakingChanges/Diff/SymbolDifferTest.php b/src/tests/unit/BreakingChanges/Diff/SymbolDifferTest.php new file mode 100644 index 000000000..e4efd0db2 --- /dev/null +++ b/src/tests/unit/BreakingChanges/Diff/SymbolDifferTest.php @@ -0,0 +1,159 @@ +differ = new SymbolDiffer(); + } + + public function test_detects_removed_class(): void { + $old = new ExtractedSymbols(); + $old->add_class( new SymbolInfo( 'Foo\Bar', 'class', 'bar.php', 1 ) ); + $old->add_class( new SymbolInfo( 'Foo\Baz', 'class', 'baz.php', 1 ) ); + + $new = new ExtractedSymbols(); + $new->add_class( new SymbolInfo( 'Foo\Bar', 'class', 'bar.php', 1 ) ); + + $result = $this->differ->diff( $old, $new ); + + $this->assertTrue( $result->has_removals() ); + $this->assertCount( 1, $result->removed ); + $this->assertEquals( 'Foo\Baz', $result->removed[0]->name ); + } + + public function test_detects_added_class(): void { + $old = new ExtractedSymbols(); + $old->add_class( new SymbolInfo( 'Foo\Bar', 'class', 'bar.php', 1 ) ); + + $new = new ExtractedSymbols(); + $new->add_class( new SymbolInfo( 'Foo\Bar', 'class', 'bar.php', 1 ) ); + $new->add_class( new SymbolInfo( 'Foo\Baz', 'class', 'baz.php', 1 ) ); + + $result = $this->differ->diff( $old, $new ); + + $this->assertFalse( $result->has_removals() ); + $this->assertCount( 1, $result->added ); + $this->assertEquals( 'Foo\Baz', $result->added[0]->name ); + } + + public function test_detects_removed_method(): void { + $old = new ExtractedSymbols(); + $old->add_method( new SymbolInfo( 'foo', 'method', 'bar.php', 5, 'public', 'Foo\Bar' ) ); + $old->add_method( new SymbolInfo( 'baz', 'method', 'bar.php', 10, 'public', 'Foo\Bar' ) ); + + $new = new ExtractedSymbols(); + $new->add_method( new SymbolInfo( 'foo', 'method', 'bar.php', 5, 'public', 'Foo\Bar' ) ); + + $result = $this->differ->diff( $old, $new ); + + $this->assertTrue( $result->has_removals() ); + $this->assertCount( 1, $result->removed ); + $this->assertEquals( 'baz', $result->removed[0]->name ); + $this->assertEquals( 'Foo\Bar', $result->removed[0]->parent_class ); + } + + public function test_detects_removed_function(): void { + $old = new ExtractedSymbols(); + $old->add_function( new SymbolInfo( 'old_func', 'function', 'funcs.php', 1 ) ); + + $new = new ExtractedSymbols(); + + $result = $this->differ->diff( $old, $new ); + + $this->assertTrue( $result->has_removals() ); + $this->assertCount( 1, $result->removed ); + $this->assertEquals( 'old_func', $result->removed[0]->name ); + } + + public function test_detects_removed_constant(): void { + $old = new ExtractedSymbols(); + $old->add_constant( new SymbolInfo( 'MY_CONST', 'constant', 'file.php', 1 ) ); + + $new = new ExtractedSymbols(); + + $result = $this->differ->diff( $old, $new ); + + $this->assertTrue( $result->has_removals() ); + $this->assertCount( 1, $result->removed ); + $this->assertEquals( 'MY_CONST', $result->removed[0]->name ); + } + + public function test_no_changes(): void { + $old = new ExtractedSymbols(); + $old->add_class( new SymbolInfo( 'Foo\Bar', 'class', 'bar.php', 1 ) ); + $old->add_function( new SymbolInfo( 'my_func', 'function', 'funcs.php', 1 ) ); + + $new = new ExtractedSymbols(); + $new->add_class( new SymbolInfo( 'Foo\Bar', 'class', 'bar.php', 1 ) ); + $new->add_function( new SymbolInfo( 'my_func', 'function', 'funcs.php', 1 ) ); + + $result = $this->differ->diff( $old, $new ); + + $this->assertFalse( $result->has_removals() ); + $this->assertEmpty( $result->removed ); + $this->assertEmpty( $result->added ); + } + + public function test_diff_with_fixture_plugins(): void { + $extractor = new DirectoryExtractor( new FileParser() ); + + $old = $extractor->extract( __DIR__ . '/../fixtures/sample-plugin-v1' ); + $new = $extractor->extract( __DIR__ . '/../fixtures/sample-plugin-v2' ); + + $result = $this->differ->diff( $old, $new ); + + // Removed symbols: SampleContract (interface), process_item (method), + // deprecated_method (method), sample_plugin_deprecated_function (function), + // SAMPLE_PLUGIN_DIR (constant), + // SampleContract::execute (method), SampleContract::get_name (method). + $this->assertTrue( $result->has_removals() ); + + $removed_names = array_map( + function ( SymbolInfo $s ) { + return $s->get_key(); + }, + $result->removed + ); + + $this->assertContains( 'SamplePlugin\SampleContract', $removed_names ); + $this->assertContains( 'SamplePlugin\SampleManager::process_item', $removed_names ); + $this->assertContains( 'SamplePlugin\SampleHelper::deprecated_method', $removed_names ); + $this->assertContains( 'SamplePlugin\sample_plugin_deprecated_function', $removed_names ); + $this->assertContains( 'SAMPLE_PLUGIN_DIR', $removed_names ); + + // Added symbols should include new classes/methods/functions/constants. + $added_names = array_map( + function ( SymbolInfo $s ) { + return $s->get_key(); + }, + $result->added + ); + + $this->assertContains( 'SamplePlugin\SampleRegistry', $added_names ); + $this->assertContains( 'SamplePlugin\SampleHelper::sanitize_output', $added_names ); + $this->assertContains( 'SamplePlugin\sample_plugin_new_utility', $added_names ); + $this->assertContains( 'SAMPLE_PLUGIN_MIN_PHP', $added_names ); + } + + public function test_empty_inputs(): void { + $old = new ExtractedSymbols(); + $new = new ExtractedSymbols(); + + $result = $this->differ->diff( $old, $new ); + + $this->assertFalse( $result->has_removals() ); + $this->assertEmpty( $result->removed ); + $this->assertEmpty( $result->added ); + } +} diff --git a/src/tests/unit/BreakingChanges/Extraction/DirectoryExtractorTest.php b/src/tests/unit/BreakingChanges/Extraction/DirectoryExtractorTest.php new file mode 100644 index 000000000..8827651ac --- /dev/null +++ b/src/tests/unit/BreakingChanges/Extraction/DirectoryExtractorTest.php @@ -0,0 +1,141 @@ +extractor = new DirectoryExtractor( new FileParser() ); + } + + public function test_extracts_symbols_from_v1_plugin(): void { + $dir = __DIR__ . '/../fixtures/sample-plugin-v1'; + $symbols = $this->extractor->extract( $dir ); + + // Classes. + $this->assertArrayHasKey( 'SamplePlugin\SampleManager', $symbols->classes ); + $this->assertArrayHasKey( 'SamplePlugin\SampleHelper', $symbols->classes ); + $this->assertArrayHasKey( 'SamplePlugin\SampleContract', $symbols->classes ); + + // Public methods. + $this->assertArrayHasKey( 'SamplePlugin\SampleManager::initialize', $symbols->methods ); + $this->assertArrayHasKey( 'SamplePlugin\SampleManager::get_items', $symbols->methods ); + $this->assertArrayHasKey( 'SamplePlugin\SampleManager::process_item', $symbols->methods ); + $this->assertArrayHasKey( 'SamplePlugin\SampleHelper::format_output', $symbols->methods ); + $this->assertArrayHasKey( 'SamplePlugin\SampleHelper::deprecated_method', $symbols->methods ); + + // Protected/private methods should NOT be present. + $this->assertArrayNotHasKey( 'SamplePlugin\SampleManager::internal_helper', $symbols->methods ); + $this->assertArrayNotHasKey( 'SamplePlugin\SampleManager::private_method', $symbols->methods ); + + // Functions. + $this->assertArrayHasKey( 'SamplePlugin\sample_plugin_get_version', $symbols->functions ); + $this->assertArrayHasKey( 'SamplePlugin\sample_plugin_deprecated_function', $symbols->functions ); + $this->assertArrayHasKey( 'SamplePlugin\sample_plugin_helper', $symbols->functions ); + + // Constants. + $this->assertArrayHasKey( 'SAMPLE_PLUGIN_VERSION', $symbols->constants ); + $this->assertArrayHasKey( 'SAMPLE_PLUGIN_DIR', $symbols->constants ); + $this->assertArrayHasKey( 'SAMPLE_PLUGIN_SLUG', $symbols->constants ); + + // Hooks. + $this->assertArrayHasKey( 'sample_plugin_init', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_items', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_before_process', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_after_process', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_format_output', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_helper_result', $symbols->hooks ); + } + + public function test_extracts_symbols_from_v2_plugin(): void { + $dir = __DIR__ . '/../fixtures/sample-plugin-v2'; + $symbols = $this->extractor->extract( $dir ); + + // New class in v2. + $this->assertArrayHasKey( 'SamplePlugin\SampleRegistry', $symbols->classes ); + + // Removed method should not be in v2. + $this->assertArrayNotHasKey( 'SamplePlugin\SampleHelper::deprecated_method', $symbols->methods ); + + // New method in v2. + $this->assertArrayHasKey( 'SamplePlugin\SampleHelper::sanitize_output', $symbols->methods ); + + // Removed function should not be in v2. + $this->assertArrayNotHasKey( 'SamplePlugin\sample_plugin_deprecated_function', $symbols->functions ); + + // New function in v2. + $this->assertArrayHasKey( 'SamplePlugin\sample_plugin_new_utility', $symbols->functions ); + + // Removed constant should not be in v2. + $this->assertArrayNotHasKey( 'SAMPLE_PLUGIN_DIR', $symbols->constants ); + + // New constant in v2. + $this->assertArrayHasKey( 'SAMPLE_PLUGIN_MIN_PHP', $symbols->constants ); + + // Removed hooks should not be in v2. + $this->assertArrayNotHasKey( 'sample_plugin_init', $symbols->hooks ); + $this->assertArrayNotHasKey( 'sample_plugin_before_process', $symbols->hooks ); + $this->assertArrayNotHasKey( 'sample_plugin_after_process', $symbols->hooks ); + + // New hooks in v2. + $this->assertArrayHasKey( 'sample_plugin_initialized', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_before_batch', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_after_batch', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_sanitize_output', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_registered', $symbols->hooks ); + $this->assertArrayHasKey( 'sample_plugin_registry_get', $symbols->hooks ); + } + + public function test_returns_empty_for_nonexistent_directory(): void { + $symbols = $this->extractor->extract( '/nonexistent/dir' ); + + $this->assertEmpty( $symbols->classes ); + $this->assertEmpty( $symbols->methods ); + $this->assertEmpty( $symbols->functions ); + $this->assertEmpty( $symbols->constants ); + $this->assertEmpty( $symbols->hooks ); + } + + public function test_no_warnings_on_valid_fixtures(): void { + $dir = __DIR__ . '/../fixtures/sample-plugin-v1'; + $symbols = $this->extractor->extract( $dir ); + + $this->assertEmpty( $symbols->warnings ); + } + + public function test_skips_vendor_directory(): void { + $tmp = sys_get_temp_dir() . '/extractor-test-' . uniqid(); + mkdir( $tmp ); + mkdir( $tmp . '/vendor', 0777, true ); + + file_put_contents( $tmp . '/main.php', 'extractor->extract( $tmp ); + + $this->assertArrayHasKey( 'Main', $symbols->classes ); + $this->assertArrayNotHasKey( 'VendorDep', $symbols->classes ); + } finally { + unlink( $tmp . '/main.php' ); + unlink( $tmp . '/vendor/dep.php' ); + rmdir( $tmp . '/vendor' ); + rmdir( $tmp ); + } + } + + public function test_relative_paths_in_symbols(): void { + $dir = __DIR__ . '/../fixtures/sample-plugin-v1'; + $symbols = $this->extractor->extract( $dir ); + + $class = $symbols->classes['SamplePlugin\SampleManager']; + $this->assertStringNotContainsString( $dir, $class->file ); + $this->assertStringContainsString( 'class-sample-manager.php', $class->file ); + } +} diff --git a/src/tests/unit/BreakingChanges/Extraction/FileParserTest.php b/src/tests/unit/BreakingChanges/Extraction/FileParserTest.php new file mode 100644 index 000000000..077ea3437 --- /dev/null +++ b/src/tests/unit/BreakingChanges/Extraction/FileParserTest.php @@ -0,0 +1,65 @@ +parser = new FileParser(); + } + + public function test_parses_valid_php_file(): void { + $fixture = __DIR__ . '/../fixtures/sample-plugin-v1/sample-plugin.php'; + $ast = $this->parser->parse( $fixture ); + + $this->assertNotNull( $ast ); + $this->assertIsArray( $ast ); + $this->assertNotEmpty( $ast ); + } + + public function test_returns_null_for_nonexistent_file(): void { + $ast = $this->parser->parse( '/nonexistent/file.php' ); + + $this->assertNull( $ast ); + } + + public function test_returns_null_for_invalid_php(): void { + $tmp = tempnam( sys_get_temp_dir(), 'php_test' ); + file_put_contents( $tmp, 'parser->parse( $tmp ); + $this->assertNull( $ast ); + } finally { + unlink( $tmp ); + } + } + + public function test_parse_code_with_valid_php(): void { + $code = 'parser->parse_code( $code ); + + $this->assertNotNull( $ast ); + $this->assertIsArray( $ast ); + } + + public function test_parse_code_with_invalid_php(): void { + $code = 'parser->parse_code( $code ); + + $this->assertNull( $ast ); + } + + public function test_parses_class_with_namespace(): void { + $fixture = __DIR__ . '/../fixtures/sample-plugin-v1/includes/class-sample-manager.php'; + $ast = $this->parser->parse( $fixture ); + + $this->assertNotNull( $ast ); + $this->assertNotEmpty( $ast ); + } +} diff --git a/src/tests/unit/BreakingChanges/PluginSourceResolverTest.php b/src/tests/unit/BreakingChanges/PluginSourceResolverTest.php new file mode 100644 index 000000000..1952c0fd8 --- /dev/null +++ b/src/tests/unit/BreakingChanges/PluginSourceResolverTest.php @@ -0,0 +1,115 @@ +createMock( CachedDownloader::class ); + $zipper = $this->createMock( Zipper::class ); + $this->resolver = new PluginSourceResolver( $downloader, $zipper ); + } + + public function test_resolves_local_directory(): void { + $dir = __DIR__ . '/fixtures/sample-plugin-v1'; + + $result = $this->resolver->resolve( $dir ); + + $this->assertEquals( $dir, $result ); + } + + public function test_resolves_local_directory_strips_trailing_slash(): void { + $dir = __DIR__ . '/fixtures/sample-plugin-v1/'; + + $result = $this->resolver->resolve( $dir ); + + $this->assertEquals( rtrim( $dir, '/' ), $result ); + } + + public function test_resolves_local_zip(): void { + // Create a temporary zip with a plugin directory inside. + $tmp_dir = sys_get_temp_dir() . '/qit-test-zip-' . uniqid(); + mkdir( $tmp_dir, 0755, true ); + + $zip_path = $tmp_dir . '/test-plugin.zip'; + $zip = new \ZipArchive(); + $zip->open( $zip_path, \ZipArchive::CREATE ); + $zip->addFromString( 'test-plugin/test-plugin.php', 'close(); + + try { + $result = $this->resolver->resolve( $zip_path ); + + $this->assertDirectoryExists( $result ); + $this->assertFileExists( $result . '/test-plugin.php' ); + } finally { + // Cleanup. + $this->recursive_rmdir( $tmp_dir ); + // Clean up extracted dir. + $extract_base = sys_get_temp_dir() . '/qit-breaking-changes/'; + if ( is_dir( $extract_base ) ) { + $this->recursive_rmdir( $extract_base ); + } + } + } + + public function test_download_wporg_plugin(): void { + $zip_path = $this->create_mock_plugin_zip( 'my-plugin' ); + + $downloader = $this->createMock( CachedDownloader::class ); + $downloader->expects( $this->once() ) + ->method( 'download' ) + ->with( 'wporg_plugin', 'my-plugin', $this->anything(), [ 'version' => '1.0.0' ] ) + ->willReturn( [ + 'path' => $zip_path, + 'metadata' => [ 'version' => '1.0.0' ], + 'cached' => false, + ] ); + + $zipper = $this->createMock( Zipper::class ); + $resolver = new PluginSourceResolver( $downloader, $zipper ); + + try { + $result = $resolver->resolve( 'my-plugin', '1.0.0' ); + + $this->assertDirectoryExists( $result ); + } finally { + unlink( $zip_path ); + $extract_base = sys_get_temp_dir() . '/qit-breaking-changes/'; + if ( is_dir( $extract_base ) ) { + $this->recursive_rmdir( $extract_base ); + } + } + } + + private function create_mock_plugin_zip( string $slug ): string { + $tmp = tempnam( sys_get_temp_dir(), 'zip' ); + $zip = new \ZipArchive(); + $zip->open( $tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ); + $zip->addFromString( "{$slug}/{$slug}.php", "close(); + + return $tmp; + } + + private function recursive_rmdir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $files = array_diff( scandir( $dir ), [ '.', '..' ] ); + foreach ( $files as $file ) { + $path = $dir . '/' . $file; + is_dir( $path ) ? $this->recursive_rmdir( $path ) : unlink( $path ); + } + rmdir( $dir ); + } +} diff --git a/src/tests/unit/BreakingChanges/Renderers/DiffRendererTest.php b/src/tests/unit/BreakingChanges/Renderers/DiffRendererTest.php new file mode 100644 index 000000000..821372711 --- /dev/null +++ b/src/tests/unit/BreakingChanges/Renderers/DiffRendererTest.php @@ -0,0 +1,124 @@ +renderer = new DiffRenderer(); + } + + private function make_sample_result(): DiffResult { + $symbols = new SymbolDiffResult( + [ new SymbolInfo( 'OldClass', 'class', 'old.php', 10 ) ], + [ new SymbolInfo( 'NewClass', 'class', 'new.php', 5 ) ] + ); + + $hooks = new HookDiffResult( + [ new HookInfo( 'old_hook', 'action', 'hooks.php', 20 ) ], + [ new HookInfo( 'new_hook', 'filter', 'hooks.php', 30 ) ] + ); + + return new DiffResult( $symbols, $hooks ); + } + + public function test_render_table_format(): void { + $output = new BufferedOutput(); + $result = $this->make_sample_result(); + + $this->renderer->render( $result, $output, 'table' ); + + $text = $output->fetch(); + $this->assertStringContainsString( 'OldClass', $text ); + $this->assertStringContainsString( 'NewClass', $text ); + $this->assertStringContainsString( 'old_hook', $text ); + $this->assertStringContainsString( 'new_hook', $text ); + $this->assertStringContainsString( 'Breaking changes detected', $text ); + } + + public function test_render_json_format(): void { + $output = new BufferedOutput(); + $result = $this->make_sample_result(); + + $this->renderer->render( $result, $output, 'json' ); + + $text = $output->fetch(); + $data = json_decode( $text, true ); + + $this->assertIsArray( $data ); + $this->assertCount( 1, $data['symbols']['removed'] ); + $this->assertCount( 1, $data['symbols']['added'] ); + $this->assertCount( 1, $data['hooks']['removed'] ); + $this->assertCount( 1, $data['hooks']['added'] ); + $this->assertTrue( $data['summary']['has_breaking_changes'] ); + $this->assertEquals( 'OldClass', $data['symbols']['removed'][0]['name'] ); + } + + public function test_render_github_format(): void { + $output = new BufferedOutput(); + $result = $this->make_sample_result(); + + $this->renderer->render( $result, $output, 'github' ); + + $text = $output->fetch(); + $this->assertStringContainsString( '::error file=old.php,line=10::Removed class: OldClass', $text ); + $this->assertStringContainsString( '::error file=hooks.php,line=20::Removed action hook: old_hook', $text ); + $this->assertStringContainsString( '::notice file=new.php,line=5::Added class: NewClass', $text ); + $this->assertStringContainsString( '::notice file=hooks.php,line=30::Added filter hook: new_hook', $text ); + } + + public function test_render_no_changes(): void { + $output = new BufferedOutput(); + $result = new DiffResult( + new SymbolDiffResult(), + new HookDiffResult() + ); + + $this->renderer->render( $result, $output, 'table' ); + + $text = $output->fetch(); + $this->assertStringContainsString( 'No symbol changes detected', $text ); + $this->assertStringContainsString( 'No hook changes detected', $text ); + $this->assertStringContainsString( 'No breaking changes detected', $text ); + } + + public function test_render_json_no_changes(): void { + $output = new BufferedOutput(); + $result = new DiffResult( + new SymbolDiffResult(), + new HookDiffResult() + ); + + $this->renderer->render( $result, $output, 'json' ); + + $data = json_decode( $output->fetch(), true ); + + $this->assertFalse( $data['summary']['has_breaking_changes'] ); + $this->assertEquals( 0, $data['summary']['removed_symbols'] ); + } + + public function test_render_method_with_parent_class(): void { + $output = new BufferedOutput(); + $symbols = new SymbolDiffResult( + [ new SymbolInfo( 'doStuff', 'method', 'class.php', 15, 'public', 'App\MyClass' ) ], + [] + ); + + $result = new DiffResult( $symbols, new HookDiffResult() ); + $this->renderer->render( $result, $output, 'json' ); + + $data = json_decode( $output->fetch(), true ); + $this->assertEquals( 'App\MyClass::doStuff', $data['symbols']['removed'][0]['name'] ); + } +} diff --git a/src/tests/unit/BreakingChanges/Scanner/ReferenceScannerTest.php b/src/tests/unit/BreakingChanges/Scanner/ReferenceScannerTest.php new file mode 100644 index 000000000..a5856bf08 --- /dev/null +++ b/src/tests/unit/BreakingChanges/Scanner/ReferenceScannerTest.php @@ -0,0 +1,106 @@ +scanner = new ReferenceScanner( $parser ); + $this->extractor = new DirectoryExtractor( $parser ); + $this->fixtures_dir = dirname( __DIR__ ) . '/fixtures'; + } + + public function test_finds_references_to_removed_symbols(): void { + $old = $this->extractor->extract( $this->fixtures_dir . '/sample-plugin-v1' ); + $new = $this->extractor->extract( $this->fixtures_dir . '/sample-plugin-v2' ); + + $symbol_diff = ( new SymbolDiffer() )->diff( $old, $new ); + $hook_diff = ( new HookDiffer() )->diff( $old, $new ); + + $result = $this->scanner->scan( + $this->fixtures_dir . '/target-plugin', + $symbol_diff, + $hook_diff, + 'target-plugin' + ); + + $this->assertTrue( $result->has_breaking_references() ); + $this->assertEquals( 'target-plugin', $result->plugin_slug ); + + $names = array_map( function ( $ref ) { + return $ref->name; + }, $result->references ); + + // Should find references to: + // - SAMPLE_PLUGIN_DIR (removed constant) + // - sample_plugin_init (removed hook) + // - sample_plugin_before_process (removed hook) + // - sample_plugin_deprecated_function (removed function - namespaced) + $this->assertContains( 'SAMPLE_PLUGIN_DIR', $names ); + $this->assertContains( 'sample_plugin_init', $names ); + $this->assertContains( 'sample_plugin_before_process', $names ); + + // Should NOT find reference to sample_plugin_items (still exists in v2). + $this->assertNotContains( 'sample_plugin_items', $names ); + } + + public function test_returns_empty_when_no_removals(): void { + $old = $this->extractor->extract( $this->fixtures_dir . '/sample-plugin-v1' ); + + $symbol_diff = ( new SymbolDiffer() )->diff( $old, $old ); + $hook_diff = ( new HookDiffer() )->diff( $old, $old ); + + $result = $this->scanner->scan( + $this->fixtures_dir . '/target-plugin', + $symbol_diff, + $hook_diff + ); + + $this->assertFalse( $result->has_breaking_references() ); + $this->assertEmpty( $result->references ); + } + + public function test_handles_nonexistent_directory(): void { + $old = $this->extractor->extract( $this->fixtures_dir . '/sample-plugin-v1' ); + $new = $this->extractor->extract( $this->fixtures_dir . '/sample-plugin-v2' ); + + $symbol_diff = ( new SymbolDiffer() )->diff( $old, $new ); + $hook_diff = ( new HookDiffer() )->diff( $old, $new ); + + $result = $this->scanner->scan( + '/nonexistent/directory', + $symbol_diff, + $hook_diff + ); + + $this->assertFalse( $result->has_breaking_references() ); + } + + public function test_no_warnings_on_valid_fixtures(): void { + $old = $this->extractor->extract( $this->fixtures_dir . '/sample-plugin-v1' ); + $new = $this->extractor->extract( $this->fixtures_dir . '/sample-plugin-v2' ); + + $symbol_diff = ( new SymbolDiffer() )->diff( $old, $new ); + $hook_diff = ( new HookDiffer() )->diff( $old, $new ); + + $result = $this->scanner->scan( + $this->fixtures_dir . '/target-plugin', + $symbol_diff, + $hook_diff + ); + + $this->assertEmpty( $result->warnings ); + } +} diff --git a/src/tests/unit/BreakingChanges/Scanner/ReferenceVisitorTest.php b/src/tests/unit/BreakingChanges/Scanner/ReferenceVisitorTest.php new file mode 100644 index 000000000..79391041d --- /dev/null +++ b/src/tests/unit/BreakingChanges/Scanner/ReferenceVisitorTest.php @@ -0,0 +1,184 @@ +createForHostVersion(); + $ast = $parser->parse( $code ); + $visitor = new ReferenceVisitor( $symbol_diff, $hook_diff, 'test.php' ); + + $traverser = new NodeTraverser(); + $traverser->addVisitor( new NameResolver() ); + $traverser->addVisitor( $visitor ); + $traverser->traverse( $ast ); + + return $visitor->get_references(); + } + + public function test_detects_removed_class_instantiation(): void { + $symbol_diff = new SymbolDiffResult( + [ new SymbolInfo( 'App\OldClass', 'class', 'old.php', 1 ) ] + ); + + $refs = $this->scan_code( + 'assertCount( 1, $refs ); + $this->assertEquals( 'App\OldClass', $refs[0]->name ); + $this->assertEquals( 'class_usage', $refs[0]->type ); + } + + public function test_detects_removed_static_call(): void { + $symbol_diff = new SymbolDiffResult( + [ new SymbolInfo( 'doStuff', 'method', 'cls.php', 5, 'public', 'App\Helper' ) ] + ); + + $refs = $this->scan_code( + 'assertCount( 1, $refs ); + $this->assertEquals( 'App\Helper::doStuff', $refs[0]->name ); + $this->assertEquals( 'static_call', $refs[0]->type ); + } + + public function test_detects_removed_function_call(): void { + $symbol_diff = new SymbolDiffResult( + [ new SymbolInfo( 'old_function', 'function', 'funcs.php', 1 ) ] + ); + + $refs = $this->scan_code( + 'assertCount( 1, $refs ); + $this->assertEquals( 'old_function', $refs[0]->name ); + $this->assertEquals( 'function_call', $refs[0]->type ); + } + + public function test_detects_removed_constant(): void { + $symbol_diff = new SymbolDiffResult( + [ new SymbolInfo( 'OLD_CONST', 'constant', 'const.php', 1 ) ] + ); + + $refs = $this->scan_code( + 'assertCount( 1, $refs ); + $this->assertEquals( 'OLD_CONST', $refs[0]->name ); + $this->assertEquals( 'constant_access', $refs[0]->type ); + } + + public function test_detects_removed_hook_registration(): void { + $hook_diff = new HookDiffResult( + [ new HookInfo( 'old_hook', 'action', 'hooks.php', 10 ) ] + ); + + $refs = $this->scan_code( + "assertCount( 1, $refs ); + $this->assertEquals( 'old_hook', $refs[0]->name ); + $this->assertEquals( 'hook_registration', $refs[0]->type ); + } + + public function test_detects_remove_filter_reference(): void { + $hook_diff = new HookDiffResult( + [ new HookInfo( 'old_filter', 'filter', 'hooks.php', 10 ) ] + ); + + $refs = $this->scan_code( + "assertCount( 1, $refs ); + $this->assertEquals( 'old_filter', $refs[0]->name ); + } + + public function test_ignores_existing_symbols(): void { + $symbol_diff = new SymbolDiffResult( + [ new SymbolInfo( 'App\Removed', 'class', 'old.php', 1 ) ] + ); + + // ExistingClass is NOT in the removed list. + $refs = $this->scan_code( + 'assertEmpty( $refs ); + } + + public function test_ignores_existing_hooks(): void { + $hook_diff = new HookDiffResult( + [ new HookInfo( 'removed_hook', 'action', 'hooks.php', 10 ) ] + ); + + // existing_hook is NOT in the removed list. + $refs = $this->scan_code( + "assertEmpty( $refs ); + } + + public function test_detects_class_extends_removed(): void { + $symbol_diff = new SymbolDiffResult( + [ new SymbolInfo( 'App\BaseClass', 'class', 'base.php', 1 ) ] + ); + + $refs = $this->scan_code( + 'assertGreaterThanOrEqual( 1, count( $refs ) ); + $names = array_column( $refs, 'name' ); + $this->assertContains( 'App\BaseClass', $names ); + } + + public function test_has_filter_reference(): void { + $hook_diff = new HookDiffResult( + [ new HookInfo( 'old_filter', 'filter', 'hooks.php', 10 ) ] + ); + + $refs = $this->scan_code( + "assertCount( 1, $refs ); + $this->assertEquals( 'old_filter', $refs[0]->name ); + } +} diff --git a/src/tests/unit/BreakingChanges/Visitors/HookVisitorTest.php b/src/tests/unit/BreakingChanges/Visitors/HookVisitorTest.php new file mode 100644 index 000000000..2eedf3fa1 --- /dev/null +++ b/src/tests/unit/BreakingChanges/Visitors/HookVisitorTest.php @@ -0,0 +1,127 @@ +createForHostVersion(); + $ast = $parser->parse( $code ); + $symbols = new ExtractedSymbols(); + $visitor = new HookVisitor( $symbols, 'test.php' ); + + $traverser = new NodeTraverser(); + $traverser->addVisitor( new NameResolver() ); + $traverser->addVisitor( $visitor ); + $traverser->traverse( $ast ); + + return $symbols; + } + + public function test_extracts_do_action(): void { + $code = "extract_from_code( $code ); + + $this->assertArrayHasKey( 'my_hook', $symbols->hooks ); + $this->assertEquals( 'action', $symbols->hooks['my_hook']->type ); + $this->assertFalse( $symbols->hooks['my_hook']->is_dynamic ); + } + + public function test_extracts_apply_filters(): void { + $code = "extract_from_code( $code ); + + $this->assertArrayHasKey( 'my_filter', $symbols->hooks ); + $this->assertEquals( 'filter', $symbols->hooks['my_filter']->type ); + } + + public function test_extracts_do_action_ref_array(): void { + $code = "extract_from_code( $code ); + + $this->assertArrayHasKey( 'ref_hook', $symbols->hooks ); + $this->assertEquals( 'action', $symbols->hooks['ref_hook']->type ); + } + + public function test_extracts_apply_filters_ref_array(): void { + $code = "extract_from_code( $code ); + + $this->assertArrayHasKey( 'ref_filter', $symbols->hooks ); + $this->assertEquals( 'filter', $symbols->hooks['ref_filter']->type ); + } + + public function test_extracts_deprecated_hooks(): void { + $code = "extract_from_code( $code ); + + $this->assertArrayHasKey( 'old_hook', $symbols->hooks ); + $this->assertEquals( 'action', $symbols->hooks['old_hook']->type ); + } + + public function test_counts_dynamic_hooks(): void { + $code = 'extract_from_code( $code ); + + $this->assertEmpty( $symbols->hooks ); + $this->assertEquals( 1, $symbols->dynamic_hook_count ); + } + + public function test_counts_arg_count(): void { + $code = "extract_from_code( $code ); + + $this->assertEquals( 3, $symbols->hooks['my_hook']->arg_count ); + } + + public function test_ignores_non_hook_functions(): void { + $code = "extract_from_code( $code ); + + $this->assertEmpty( $symbols->hooks ); + $this->assertEquals( 0, $symbols->dynamic_hook_count ); + } + + public function test_extracts_multiple_hooks(): void { + $code = "extract_from_code( $code ); + + $this->assertCount( 3, $symbols->hooks ); + $this->assertArrayHasKey( 'hook_one', $symbols->hooks ); + $this->assertArrayHasKey( 'filter_one', $symbols->hooks ); + $this->assertArrayHasKey( 'hook_two', $symbols->hooks ); + } + + public function test_file_is_set_on_hooks(): void { + $code = "extract_from_code( $code ); + + $this->assertEquals( 'test.php', $symbols->hooks['my_hook']->file ); + } + + public function test_dynamic_concat_hook(): void { + $code = "extract_from_code( $code ); + + $this->assertEmpty( $symbols->hooks ); + $this->assertEquals( 1, $symbols->dynamic_hook_count ); + } + + public function test_apply_filters_deprecated(): void { + $code = "extract_from_code( $code ); + + $this->assertArrayHasKey( 'old_filter', $symbols->hooks ); + $this->assertEquals( 'filter', $symbols->hooks['old_filter']->type ); + } +} diff --git a/src/tests/unit/BreakingChanges/Visitors/SymbolVisitorTest.php b/src/tests/unit/BreakingChanges/Visitors/SymbolVisitorTest.php new file mode 100644 index 000000000..d2f430e4a --- /dev/null +++ b/src/tests/unit/BreakingChanges/Visitors/SymbolVisitorTest.php @@ -0,0 +1,149 @@ +createForHostVersion(); + $ast = $parser->parse( $code ); + $symbols = new ExtractedSymbols(); + $visitor = new SymbolVisitor( $symbols, 'test.php' ); + + $traverser = new NodeTraverser(); + $traverser->addVisitor( new NameResolver() ); + $traverser->addVisitor( $visitor ); + $traverser->traverse( $ast ); + + return $symbols; + } + + public function test_extracts_class(): void { + $code = 'extract_from_code( $code ); + + $this->assertArrayHasKey( 'Foo\Bar', $symbols->classes ); + $this->assertEquals( 'class', $symbols->classes['Foo\Bar']->type ); + } + + public function test_extracts_interface(): void { + $code = 'extract_from_code( $code ); + + $this->assertArrayHasKey( 'Foo\Baz', $symbols->classes ); + $this->assertArrayHasKey( 'Foo\Baz::run', $symbols->methods ); + } + + public function test_extracts_trait(): void { + $code = 'extract_from_code( $code ); + + $this->assertArrayHasKey( 'Foo\MyTrait', $symbols->classes ); + $this->assertArrayHasKey( 'Foo\MyTrait::helper', $symbols->methods ); + } + + public function test_extracts_public_methods_only(): void { + $code = 'extract_from_code( $code ); + + $this->assertArrayHasKey( 'Foo\Bar::pub', $symbols->methods ); + $this->assertArrayNotHasKey( 'Foo\Bar::prot', $symbols->methods ); + $this->assertArrayNotHasKey( 'Foo\Bar::priv', $symbols->methods ); + } + + public function test_extracts_function(): void { + $code = 'extract_from_code( $code ); + + $this->assertArrayHasKey( 'Foo\my_func', $symbols->functions ); + $this->assertEquals( 'function', $symbols->functions['Foo\my_func']->type ); + } + + public function test_extracts_global_function(): void { + $code = 'extract_from_code( $code ); + + $this->assertArrayHasKey( 'global_func', $symbols->functions ); + } + + public function test_extracts_const_statement(): void { + $code = 'extract_from_code( $code ); + + $this->assertArrayHasKey( 'Foo\MY_CONST', $symbols->constants ); + $this->assertEquals( 'constant', $symbols->constants['Foo\MY_CONST']->type ); + } + + public function test_extracts_define_call(): void { + $code = "extract_from_code( $code ); + + $this->assertArrayHasKey( 'MY_PLUGIN_VERSION', $symbols->constants ); + } + + public function test_ignores_define_with_non_string_name(): void { + $code = 'extract_from_code( $code ); + + $this->assertEmpty( $symbols->constants ); + } + + public function test_ignores_anonymous_class(): void { + $code = 'extract_from_code( $code ); + + $this->assertEmpty( $symbols->classes ); + } + + public function test_extracts_enum(): void { + $code = 'extract_from_code( $code ); + + $this->assertArrayHasKey( 'Foo\Status', $symbols->classes ); + } + + public function test_method_has_parent_class(): void { + $code = 'extract_from_code( $code ); + + $method = $symbols->methods['Foo\Bar::baz']; + $this->assertEquals( 'Foo\Bar', $method->parent_class ); + $this->assertEquals( 'baz', $method->name ); + } + + public function test_file_is_set_on_symbols(): void { + $code = 'extract_from_code( $code ); + + $this->assertEquals( 'test.php', $symbols->classes['Foo']->file ); + } + + public function test_multiple_classes_in_one_file(): void { + $code = 'extract_from_code( $code ); + + $this->assertCount( 2, $symbols->classes ); + $this->assertArrayHasKey( 'App\First', $symbols->classes ); + $this->assertArrayHasKey( 'App\Second', $symbols->classes ); + $this->assertArrayHasKey( 'App\First::a', $symbols->methods ); + $this->assertArrayHasKey( 'App\Second::b', $symbols->methods ); + } +} diff --git a/src/tests/unit/BreakingChanges/fixtures/sample-plugin-v1/includes/class-sample-helper.php b/src/tests/unit/BreakingChanges/fixtures/sample-plugin-v1/includes/class-sample-helper.php new file mode 100644 index 000000000..0beb4d7be --- /dev/null +++ b/src/tests/unit/BreakingChanges/fixtures/sample-plugin-v1/includes/class-sample-helper.php @@ -0,0 +1,13 @@ +process_item( 'test' ); + } + + public function get_name(): string { + return 'my-implementation'; + } +} + +// Calls a removed function. +$version = \SamplePlugin\sample_plugin_deprecated_function(); + +// Registers on removed hooks. +add_action( 'sample_plugin_init', function () { + // This hook was renamed to sample_plugin_initialized in v2. +} ); + +add_action( 'sample_plugin_before_process', function ( $item ) { + // This hook was removed in v2. +} ); + +add_filter( 'sample_plugin_items', function ( $items ) { + // This hook still exists in v2 — should NOT be flagged. + return $items; +} ); diff --git a/src/tests/unit/BreakingChanges/fixtures/target-plugin/target-plugin.php b/src/tests/unit/BreakingChanges/fixtures/target-plugin/target-plugin.php new file mode 100644 index 000000000..c94c856f5 --- /dev/null +++ b/src/tests/unit/BreakingChanges/fixtures/target-plugin/target-plugin.php @@ -0,0 +1,9 @@ + Date: Sat, 21 Mar 2026 17:30:16 +0800 Subject: [PATCH 2/2] PHPcs and unit tests fixes --- src/src/BreakingChanges/Commands/DiffCommand.php | 4 ++-- src/src/BreakingChanges/Commands/IndexCommand.php | 7 ++++--- src/src/BreakingChanges/Commands/ScanCommand.php | 6 ++++-- src/src/BreakingChanges/HookIndexClient.php | 6 +----- .../BreakingChanges/Models/ExtractedSymbols.php | 12 ++++++------ src/src/BreakingChanges/PluginSourceResolver.php | 10 +++------- .../BreakingChanges/Renderers/ScanRenderer.php | 10 +++++----- .../BreakingChanges/Scanner/ReferenceScanner.php | 1 - .../BreakingChanges/Scanner/ReferenceVisitor.php | 15 ++++++++------- src/src/BreakingChanges/Visitors/HookVisitor.php | 2 +- .../BreakingChanges/Visitors/SymbolVisitor.php | 6 +++++- src/src/BreakingChanges/WooDevelopedFetcher.php | 4 ++-- src/src/Utils/LocalTestRunNotifier.php | 2 +- src/src/bootstrap.php | 13 +++++++++---- .../BreakingChanges/Commands/DiffCommandTest.php | 4 +--- .../Commands/ScanCommandCheckAgainstTest.php | 4 +--- .../BreakingChanges/Commands/ScanCommandTest.php | 4 +--- .../BreakingChanges/PluginSourceResolverTest.php | 7 ++----- 18 files changed, 56 insertions(+), 61 deletions(-) diff --git a/src/src/BreakingChanges/Commands/DiffCommand.php b/src/src/BreakingChanges/Commands/DiffCommand.php index 98c66569e..f6e6c43f8 100644 --- a/src/src/BreakingChanges/Commands/DiffCommand.php +++ b/src/src/BreakingChanges/Commands/DiffCommand.php @@ -50,10 +50,10 @@ protected function configure(): void { } protected function execute( InputInterface $input, OutputInterface $output ): int { - $slug = $input->getArgument( 'slug' ); + $slug = $input->getArgument( 'slug' ); $old_version = $input->getOption( 'old' ); $new_version = $input->getOption( 'new' ); - $format = $input->getOption( 'format' ); + $format = $input->getOption( 'format' ); if ( empty( $old_version ) ) { $output->writeln( 'The --old option is required.' ); diff --git a/src/src/BreakingChanges/Commands/IndexCommand.php b/src/src/BreakingChanges/Commands/IndexCommand.php index 04032f46b..fc4beb443 100644 --- a/src/src/BreakingChanges/Commands/IndexCommand.php +++ b/src/src/BreakingChanges/Commands/IndexCommand.php @@ -4,7 +4,6 @@ use QIT_CLI\BreakingChanges\Extraction\DirectoryExtractor; use QIT_CLI\BreakingChanges\Models\ExtractedSymbols; -use QIT_CLI\BreakingChanges\Models\HookInfo; use QIT_CLI\BreakingChanges\PluginSourceResolver; use QIT_CLI\RequestBuilder; use Symfony\Component\Console\Command\Command; @@ -117,8 +116,10 @@ private function build_references_payload( ExtractedSymbols $symbols ): array { } /** - * @param array> $definitions - * @param array> $references + * @param string $slug Plugin slug. + * @param string $version Plugin version. + * @param array> $definitions Hook definitions. + * @param array> $references Hook references. */ private function upload_to_index( string $slug, string $version, array $definitions, array $references ): void { $url = get_manager_url() . '/wp-json/cd/v1/hook-index'; diff --git a/src/src/BreakingChanges/Commands/ScanCommand.php b/src/src/BreakingChanges/Commands/ScanCommand.php index 966c8ca3a..d7d7bdaf5 100644 --- a/src/src/BreakingChanges/Commands/ScanCommand.php +++ b/src/src/BreakingChanges/Commands/ScanCommand.php @@ -5,7 +5,9 @@ use QIT_CLI\BreakingChanges\Diff\HookDiffer; use QIT_CLI\BreakingChanges\Diff\SymbolDiffer; use QIT_CLI\BreakingChanges\Extraction\DirectoryExtractor; +use QIT_CLI\BreakingChanges\Models\HookDiffResult; use QIT_CLI\BreakingChanges\Models\ScanResult; +use QIT_CLI\BreakingChanges\Models\SymbolDiffResult; use QIT_CLI\BreakingChanges\PluginSourceResolver; use QIT_CLI\BreakingChanges\Renderers\ScanRenderer; use QIT_CLI\BreakingChanges\Scanner\ReferenceScanner; @@ -131,8 +133,8 @@ protected function execute( InputInterface $input, OutputInterface $output ): in */ private function scan_multiple( string $check_against, - $symbol_diff, - $hook_diff, + SymbolDiffResult $symbol_diff, + HookDiffResult $hook_diff, string $dependency, OutputInterface $output, string $format diff --git a/src/src/BreakingChanges/HookIndexClient.php b/src/src/BreakingChanges/HookIndexClient.php index 852b98205..d9fafea20 100644 --- a/src/src/BreakingChanges/HookIndexClient.php +++ b/src/src/BreakingChanges/HookIndexClient.php @@ -2,15 +2,11 @@ namespace QIT_CLI\BreakingChanges; -use QIT_CLI\Cache; use QIT_CLI\RequestBuilder; use function QIT_CLI\get_manager_url; class HookIndexClient { - private Cache $cache; - - public function __construct( Cache $cache ) { - $this->cache = $cache; + public function __construct() { } /** diff --git a/src/src/BreakingChanges/Models/ExtractedSymbols.php b/src/src/BreakingChanges/Models/ExtractedSymbols.php index f98d9ebdf..80296506e 100644 --- a/src/src/BreakingChanges/Models/ExtractedSymbols.php +++ b/src/src/BreakingChanges/Models/ExtractedSymbols.php @@ -52,12 +52,12 @@ public function add_warning( string $warning ): void { * Merge another ExtractedSymbols into this one. */ public function merge( ExtractedSymbols $other ): void { - $this->classes = array_merge( $this->classes, $other->classes ); - $this->methods = array_merge( $this->methods, $other->methods ); - $this->functions = array_merge( $this->functions, $other->functions ); - $this->constants = array_merge( $this->constants, $other->constants ); - $this->hooks = array_merge( $this->hooks, $other->hooks ); - $this->warnings = array_merge( $this->warnings, $other->warnings ); + $this->classes = array_merge( $this->classes, $other->classes ); + $this->methods = array_merge( $this->methods, $other->methods ); + $this->functions = array_merge( $this->functions, $other->functions ); + $this->constants = array_merge( $this->constants, $other->constants ); + $this->hooks = array_merge( $this->hooks, $other->hooks ); + $this->warnings = array_merge( $this->warnings, $other->warnings ); $this->dynamic_hook_count += $other->dynamic_hook_count; } } diff --git a/src/src/BreakingChanges/PluginSourceResolver.php b/src/src/BreakingChanges/PluginSourceResolver.php index 61a8869f9..87694058a 100644 --- a/src/src/BreakingChanges/PluginSourceResolver.php +++ b/src/src/BreakingChanges/PluginSourceResolver.php @@ -3,15 +3,11 @@ namespace QIT_CLI\BreakingChanges; use QIT_CLI\CachedDownloader; -use QIT_CLI\Zipper; class PluginSourceResolver { private CachedDownloader $downloader; - private Zipper $zipper; - - public function __construct( CachedDownloader $downloader, Zipper $zipper ) { + public function __construct( CachedDownloader $downloader ) { $this->downloader = $downloader; - $this->zipper = $zipper; } /** @@ -68,8 +64,8 @@ private function download_wporg( string $slug, ?string $version ): string { $options['version'] = $version; } - $result = $this->downloader->download( 'wporg_plugin', $slug, $cache_dir, $options ); - $zip_path = $result['path']; + $result = $this->downloader->download( 'wporg_plugin', $slug, $cache_dir, $options ); + $zip_path = $result['path']; return $this->extract_zip( $zip_path ); } diff --git a/src/src/BreakingChanges/Renderers/ScanRenderer.php b/src/src/BreakingChanges/Renderers/ScanRenderer.php index 1fe523bcc..f9705ab0d 100644 --- a/src/src/BreakingChanges/Renderers/ScanRenderer.php +++ b/src/src/BreakingChanges/Renderers/ScanRenderer.php @@ -99,7 +99,7 @@ private function render_multi_json( array $results, OutputInterface $output ): v $data = [ 'plugins' => array_map( [ $this, 'result_to_array' ], $results ), 'summary' => [ - 'total_plugins' => count( $results ), + 'total_plugins' => count( $results ), 'affected_plugins' => count( array_filter( $results, function ( ScanResult $r ) { return $r->has_breaking_references(); } ) ), @@ -143,10 +143,10 @@ private function render_multi_summary( array $results, OutputInterface $output ) */ private function result_to_array( ScanResult $result ): array { return [ - 'plugin_slug' => $result->plugin_slug, + 'plugin_slug' => $result->plugin_slug, 'has_breaking_references' => $result->has_breaking_references(), - 'reference_count' => count( $result->references ), - 'references' => array_map( function ( FoundReference $ref ) { + 'reference_count' => count( $result->references ), + 'references' => array_map( function ( FoundReference $ref ) { return [ 'name' => $ref->name, 'type' => $ref->type, @@ -155,7 +155,7 @@ private function result_to_array( ScanResult $result ): array { 'context' => $ref->context, ]; }, $result->references ), - 'warnings' => $result->warnings, + 'warnings' => $result->warnings, ]; } diff --git a/src/src/BreakingChanges/Scanner/ReferenceScanner.php b/src/src/BreakingChanges/Scanner/ReferenceScanner.php index 180b30598..fd5bf8d96 100644 --- a/src/src/BreakingChanges/Scanner/ReferenceScanner.php +++ b/src/src/BreakingChanges/Scanner/ReferenceScanner.php @@ -5,7 +5,6 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use QIT_CLI\BreakingChanges\Extraction\FileParser; -use QIT_CLI\BreakingChanges\Models\FoundReference; use QIT_CLI\BreakingChanges\Models\HookDiffResult; use QIT_CLI\BreakingChanges\Models\ScanResult; use QIT_CLI\BreakingChanges\Models\SymbolDiffResult; diff --git a/src/src/BreakingChanges/Scanner/ReferenceVisitor.php b/src/src/BreakingChanges/Scanner/ReferenceVisitor.php index 99c75d714..94f1417e3 100644 --- a/src/src/BreakingChanges/Scanner/ReferenceVisitor.php +++ b/src/src/BreakingChanges/Scanner/ReferenceVisitor.php @@ -35,12 +35,12 @@ class ReferenceVisitor extends NodeVisitorAbstract { /** @var array Hook registration functions */ private static array $hook_functions = [ - 'add_action' => true, - 'add_filter' => true, - 'remove_action' => true, - 'remove_filter' => true, - 'has_action' => true, - 'has_filter' => true, + 'add_action' => true, + 'add_filter' => true, + 'remove_action' => true, + 'remove_filter' => true, + 'has_action' => true, + 'has_filter' => true, ]; public function __construct( @@ -97,7 +97,8 @@ public function enterNode( Node $node ) { $this->check_constant_access( $node ); } elseif ( $node instanceof Node\Stmt\Class_ ) { $this->check_class_extends( $node ); - } elseif ( $node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Enum_ ) { + $this->check_implements( $node ); + } elseif ( $node instanceof Node\Stmt\Enum_ ) { $this->check_implements( $node ); } diff --git a/src/src/BreakingChanges/Visitors/HookVisitor.php b/src/src/BreakingChanges/Visitors/HookVisitor.php index 958c212cb..b8afb3aca 100644 --- a/src/src/BreakingChanges/Visitors/HookVisitor.php +++ b/src/src/BreakingChanges/Visitors/HookVisitor.php @@ -85,7 +85,7 @@ public function enterNode( Node $node ) { ) ); } else { // Dynamic hook name — can't determine statically. - $this->symbols->dynamic_hook_count++; + ++$this->symbols->dynamic_hook_count; } return null; diff --git a/src/src/BreakingChanges/Visitors/SymbolVisitor.php b/src/src/BreakingChanges/Visitors/SymbolVisitor.php index 82657cdbb..532174a76 100644 --- a/src/src/BreakingChanges/Visitors/SymbolVisitor.php +++ b/src/src/BreakingChanges/Visitors/SymbolVisitor.php @@ -216,8 +216,10 @@ private function visit_define_call( FuncCall $node ): void { * @param Stmt\Class_|Stmt\Interface_|Stmt\Trait_|Stmt\Enum_|Stmt\Function_ $node */ private function get_fqn( Node $node ): string { - // NameResolver sets the 'namespacedName' attribute. + // NameResolver sets the 'namespacedName' attribute (PHP-Parser property). + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase if ( $node->namespacedName !== null ) { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase return $node->namespacedName->toString(); } @@ -229,7 +231,9 @@ private function get_fqn( Node $node ): string { } private function get_fqn_from_const( Const_ $node ): string { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase if ( $node->namespacedName !== null ) { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase return $node->namespacedName->toString(); } diff --git a/src/src/BreakingChanges/WooDevelopedFetcher.php b/src/src/BreakingChanges/WooDevelopedFetcher.php index 17a372973..7703c6014 100644 --- a/src/src/BreakingChanges/WooDevelopedFetcher.php +++ b/src/src/BreakingChanges/WooDevelopedFetcher.php @@ -9,8 +9,8 @@ class WooDevelopedFetcher { private Cache $cache; - private const CACHE_KEY = 'woo_developed_extensions'; - private const CACHE_TTL = DAY_IN_SECONDS; + private const CACHE_KEY = 'woo_developed_extensions'; + private const CACHE_TTL = DAY_IN_SECONDS; public function __construct( Cache $cache ) { $this->cache = $cache; diff --git a/src/src/Utils/LocalTestRunNotifier.php b/src/src/Utils/LocalTestRunNotifier.php index 552ef248b..664a7d50c 100644 --- a/src/src/Utils/LocalTestRunNotifier.php +++ b/src/src/Utils/LocalTestRunNotifier.php @@ -359,7 +359,7 @@ public function notify_test_finished( $test_result, $orchestrator = null ): arra $debug_log_compressed = ''; if ( ! empty( $debug_log_json ) ) { - $compressed = gzcompress( $debug_log_json ); + $compressed = gzcompress( $debug_log_json ); $debug_log_compressed = ( $compressed !== false ) ? base64_encode( $compressed ) : $debug_log_json; // Fallback to uncompressed. diff --git a/src/src/bootstrap.php b/src/src/bootstrap.php index 647e3253a..9336e9b24 100644 --- a/src/src/bootstrap.php +++ b/src/src/bootstrap.php @@ -205,8 +205,11 @@ public function getDefaultCommands() { $application->add( $container->make( WooValidateZipCommand::class ) ); $application->add( $container->make( TunnelSetupCommand::class ) ); $application->add( $container->make( TunnelSetDefaultCommand::class ) ); -$application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\DiffCommand::class ) ); -$application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\ScanCommand::class ) ); +// Breaking changes commands require nikic/php-parser which needs PHP 8.1+. +if ( class_exists( \PhpParser\ParserFactory::class ) ) { + $application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\DiffCommand::class ) ); + $application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\ScanCommand::class ) ); +} // Environment commands. try { @@ -294,8 +297,10 @@ public function getDefaultCommands() { $application->add( $container->make( AIInstallAgentsCommand::class ) ); } - // Breaking Changes (requires backend for index upload). - $application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\IndexCommand::class ) ); + // Breaking Changes (requires backend for index upload and nikic/php-parser). + if ( class_exists( \PhpParser\ParserFactory::class ) ) { + $application->add( $container->make( \QIT_CLI\BreakingChanges\Commands\IndexCommand::class ) ); + } // Group Commands. $application->add( $container->make( RunGroupCommand::class ) ); diff --git a/src/tests/unit/BreakingChanges/Commands/DiffCommandTest.php b/src/tests/unit/BreakingChanges/Commands/DiffCommandTest.php index 3aeb9e3ba..52522c8ec 100644 --- a/src/tests/unit/BreakingChanges/Commands/DiffCommandTest.php +++ b/src/tests/unit/BreakingChanges/Commands/DiffCommandTest.php @@ -11,7 +11,6 @@ use QIT_CLI\BreakingChanges\PluginSourceResolver; use QIT_CLI\BreakingChanges\Renderers\DiffRenderer; use QIT_CLI\CachedDownloader; -use QIT_CLI\Zipper; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -25,8 +24,7 @@ protected function setUp(): void { private function make_command_tester(): CommandTester { $downloader = $this->createMock( CachedDownloader::class ); - $zipper = $this->createMock( Zipper::class ); - $resolver = new PluginSourceResolver( $downloader, $zipper ); + $resolver = new PluginSourceResolver( $downloader ); $extractor = new DirectoryExtractor( new FileParser() ); $command = new DiffCommand( diff --git a/src/tests/unit/BreakingChanges/Commands/ScanCommandCheckAgainstTest.php b/src/tests/unit/BreakingChanges/Commands/ScanCommandCheckAgainstTest.php index 7fe98ecd3..b80f8f2d2 100644 --- a/src/tests/unit/BreakingChanges/Commands/ScanCommandCheckAgainstTest.php +++ b/src/tests/unit/BreakingChanges/Commands/ScanCommandCheckAgainstTest.php @@ -13,7 +13,6 @@ use QIT_CLI\BreakingChanges\Scanner\ReferenceScanner; use QIT_CLI\BreakingChanges\WooDevelopedFetcher; use QIT_CLI\CachedDownloader; -use QIT_CLI\Zipper; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -27,9 +26,8 @@ protected function setUp(): void { private function make_command_tester( ?WooDevelopedFetcher $fetcher = null ): CommandTester { $downloader = $this->createMock( CachedDownloader::class ); - $zipper = $this->createMock( Zipper::class ); $parser = new FileParser(); - $resolver = new PluginSourceResolver( $downloader, $zipper ); + $resolver = new PluginSourceResolver( $downloader ); $command = new ScanCommand( $resolver, diff --git a/src/tests/unit/BreakingChanges/Commands/ScanCommandTest.php b/src/tests/unit/BreakingChanges/Commands/ScanCommandTest.php index 9c26e94d3..31d84247c 100644 --- a/src/tests/unit/BreakingChanges/Commands/ScanCommandTest.php +++ b/src/tests/unit/BreakingChanges/Commands/ScanCommandTest.php @@ -12,7 +12,6 @@ use QIT_CLI\BreakingChanges\Renderers\ScanRenderer; use QIT_CLI\BreakingChanges\Scanner\ReferenceScanner; use QIT_CLI\CachedDownloader; -use QIT_CLI\Zipper; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -26,9 +25,8 @@ protected function setUp(): void { private function make_command_tester(): CommandTester { $downloader = $this->createMock( CachedDownloader::class ); - $zipper = $this->createMock( Zipper::class ); $parser = new FileParser(); - $resolver = new PluginSourceResolver( $downloader, $zipper ); + $resolver = new PluginSourceResolver( $downloader ); $command = new ScanCommand( $resolver, diff --git a/src/tests/unit/BreakingChanges/PluginSourceResolverTest.php b/src/tests/unit/BreakingChanges/PluginSourceResolverTest.php index 1952c0fd8..dd9daed57 100644 --- a/src/tests/unit/BreakingChanges/PluginSourceResolverTest.php +++ b/src/tests/unit/BreakingChanges/PluginSourceResolverTest.php @@ -5,7 +5,6 @@ use PHPUnit\Framework\TestCase; use QIT_CLI\BreakingChanges\PluginSourceResolver; use QIT_CLI\CachedDownloader; -use QIT_CLI\Zipper; class PluginSourceResolverTest extends TestCase { private PluginSourceResolver $resolver; @@ -14,8 +13,7 @@ protected function setUp(): void { parent::setUp(); $downloader = $this->createMock( CachedDownloader::class ); - $zipper = $this->createMock( Zipper::class ); - $this->resolver = new PluginSourceResolver( $downloader, $zipper ); + $this->resolver = new PluginSourceResolver( $downloader ); } public function test_resolves_local_directory(): void { @@ -74,8 +72,7 @@ public function test_download_wporg_plugin(): void { 'cached' => false, ] ); - $zipper = $this->createMock( Zipper::class ); - $resolver = new PluginSourceResolver( $downloader, $zipper ); + $resolver = new PluginSourceResolver( $downloader ); try { $result = $resolver->resolve( 'my-plugin', '1.0.0' );