Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 60 additions & 2 deletions src/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 98 additions & 0 deletions src/src/BreakingChanges/Commands/DiffCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace QIT_CLI\BreakingChanges\Commands;

use QIT_CLI\BreakingChanges\Diff\HookDiffer;
use QIT_CLI\BreakingChanges\Diff\SymbolDiffer;
use QIT_CLI\BreakingChanges\Extraction\DirectoryExtractor;
use QIT_CLI\BreakingChanges\Models\DiffResult;
use QIT_CLI\BreakingChanges\PluginSourceResolver;
use QIT_CLI\BreakingChanges\Renderers\DiffRenderer;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class DiffCommand extends Command {
protected static $defaultName = 'breaking-changes:diff'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase

private PluginSourceResolver $resolver;
private DirectoryExtractor $extractor;
private SymbolDiffer $symbol_differ;
private HookDiffer $hook_differ;
private DiffRenderer $renderer;

public function __construct(
PluginSourceResolver $resolver,
DirectoryExtractor $extractor,
SymbolDiffer $symbol_differ,
HookDiffer $hook_differ,
DiffRenderer $renderer
) {
$this->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( '<error>The --old option is required.</error>' );
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 );
}
}
143 changes: 143 additions & 0 deletions src/src/BreakingChanges/Commands/IndexCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace QIT_CLI\BreakingChanges\Commands;

use QIT_CLI\BreakingChanges\Extraction\DirectoryExtractor;
use QIT_CLI\BreakingChanges\Models\ExtractedSymbols;
use QIT_CLI\BreakingChanges\PluginSourceResolver;
use QIT_CLI\RequestBuilder;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use function QIT_CLI\get_manager_url;

class IndexCommand extends Command {
protected static $defaultName = 'breaking-changes:index'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase

private PluginSourceResolver $resolver;
private DirectoryExtractor $extractor;

public function __construct(
PluginSourceResolver $resolver,
DirectoryExtractor $extractor
) {
$this->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( '<error>Failed to resolve plugin: %s</error>', $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( '<error>Upload failed: %s</error>', $e->getMessage() ) );
return Command::FAILURE;
}

$output->writeln( '<info>Hook index updated successfully.</info>' );

return Command::SUCCESS;
}

/**
* Build hook definitions payload from extracted symbols.
*
* @return array<array<string, mixed>>
*/
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<array<string, mixed>>
*/
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 string $slug Plugin slug.
* @param string $version Plugin version.
* @param array<array<string, mixed>> $definitions Hook definitions.
* @param array<array<string, mixed>> $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';

$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.' );
}
}
}
Loading
Loading