From 0cf8e954c2518c26296dd3da0210bc3c7e3e8eab Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Mon, 20 Apr 2026 22:06:20 +0200 Subject: [PATCH 1/9] Unified Snippets: implement Tier 1 generic scanners (functions.php, Additional CSS, .htaccess, wp-config.php, mu-plugins) with a shared WP_Filesystem reader and PHPUnit coverage --- src/php/Plugin.php | 11 + src/php/UnifiedSnippets/Filesystem_Reader.php | 110 +++++++ .../Scanners/Additional_CSS_Scanner.php | 69 +++++ .../Scanners/Functions_Php_Scanner.php | 291 ++++++++++++++++++ .../Scanners/Htaccess_Scanner.php | 282 +++++++++++++++++ .../Scanners/Mu_Plugins_Scanner.php | 140 +++++++++ .../Scanners/Wp_Config_Scanner.php | 220 +++++++++++++ tests/phpunit/test-unified-scanners.php | 284 +++++++++++++++++ 8 files changed, 1407 insertions(+) create mode 100644 src/php/UnifiedSnippets/Filesystem_Reader.php create mode 100644 src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php create mode 100644 src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php create mode 100644 src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php create mode 100644 src/php/UnifiedSnippets/Scanners/Mu_Plugins_Scanner.php create mode 100644 src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php create mode 100644 tests/phpunit/test-unified-scanners.php diff --git a/src/php/Plugin.php b/src/php/Plugin.php index 79056cc2..35fce33a 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -21,6 +21,11 @@ use Code_Snippets\UnifiedSnippets\Scanner_Registry; use Code_Snippets\UnifiedSnippets\Scan_Results_Store; use Code_Snippets\UnifiedSnippets\REST\Scan_REST_Controller; +use Code_Snippets\UnifiedSnippets\Scanners\Additional_CSS_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Functions_Php_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Htaccess_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Mu_Plugins_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Wp_Config_Scanner; /** * The main plugin class @@ -169,6 +174,12 @@ private function init_unified_snippets(): void { $this->unified_snippets = new Scanner_Registry(); $this->unified_snippets_store = new Scan_Results_Store(); + $this->unified_snippets->register( new Functions_Php_Scanner() ); + $this->unified_snippets->register( new Additional_CSS_Scanner() ); + $this->unified_snippets->register( new Htaccess_Scanner() ); + $this->unified_snippets->register( new Wp_Config_Scanner() ); + $this->unified_snippets->register( new Mu_Plugins_Scanner() ); + new Scan_REST_Controller( $this->unified_snippets, $this->unified_snippets_store ); } diff --git a/src/php/UnifiedSnippets/Filesystem_Reader.php b/src/php/UnifiedSnippets/Filesystem_Reader.php new file mode 100644 index 00000000..0f10abad --- /dev/null +++ b/src/php/UnifiedSnippets/Filesystem_Reader.php @@ -0,0 +1,110 @@ +exists( $path ) ) { + $contents = $fs->get_contents( $path ); + return false === $contents ? null : $contents; + } + + if ( ! is_readable( $path ) ) { + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $path ); + return false === $contents ? null : $contents; + } + + /** + * Determine whether a path is readable through the filesystem API. + * + * @param string $path Absolute path. + * + * @return bool + */ + public static function is_readable( string $path ): bool { + $fs = self::get_fs(); + + if ( $fs ) { + return $fs->is_readable( $path ); + } + + return is_readable( $path ); + } + + /** + * Lazily initialise and retrieve the shared WP_Filesystem instance. + * + * @return WP_Filesystem_Base|null + */ + private static function get_fs(): ?WP_Filesystem_Base { + if ( self::$initialised ) { + return self::$fs; + } + + self::$initialised = true; + + if ( ! defined( 'ABSPATH' ) ) { + return null; + } + + if ( ! function_exists( 'WP_Filesystem' ) ) { + $includes = ABSPATH . 'wp-admin/includes/file.php'; + if ( ! is_readable( $includes ) ) { + return null; + } + require_once $includes; + } + + if ( ! WP_Filesystem() ) { + return null; + } + + global $wp_filesystem; + + if ( $wp_filesystem instanceof WP_Filesystem_Base ) { + self::$fs = $wp_filesystem; + } + + return self::$fs; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php b/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php new file mode 100644 index 00000000..68c045f6 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php @@ -0,0 +1,69 @@ +post_content ) ) { + return []; + } + + $stylesheet = get_stylesheet(); + $theme_name = function_exists( 'wp_get_theme' ) ? wp_get_theme()->get( 'Name' ) : $stylesheet; + + return [ + $this->build_snippet( + [ + 'name' => sprintf( + /* translators: %s: theme name */ + __( 'Additional CSS (%s)', 'code-snippets' ), + $theme_name + ), + 'code' => $post->post_content, + 'type' => 'css', + 'source_type' => 'customizer', + 'source_name' => $theme_name, + 'source_path' => 'customizer://custom_css/' . $stylesheet, + 'line_start' => 0, + 'line_end' => 0, + 'is_active' => true, + ] + ), + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php new file mode 100644 index 00000000..6de1174e --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php @@ -0,0 +1,291 @@ + + */ + private array $path_overrides; + + /** + * Class constructor. + * + * @param array $path_overrides Optional path overrides for testing. + */ + public function __construct( array $path_overrides = [] ) { + $this->path_overrides = $path_overrides; + } + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'functions-php'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( 'Theme functions.php', 'code-snippets' ); + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + foreach ( $this->resolve_targets() as $target ) { + if ( Filesystem_Reader::is_readable( $target['path'] ) ) { + return true; + } + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function get_risk_level(): string { + return 'medium'; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + $snippets = []; + + foreach ( $this->resolve_targets() as $source_type => $target ) { + if ( ! Filesystem_Reader::is_readable( $target['path'] ) ) { + continue; + } + + $snippets = array_merge( + $snippets, + $this->scan_file( $target['path'], $source_type, $target['name'] ) + ); + } + + return $snippets; + } + + /** + * Resolve parent and child theme functions.php targets. + * + * @return array + */ + private function resolve_targets(): array { + if ( $this->path_overrides ) { + return $this->path_overrides; + } + + $targets = []; + + if ( function_exists( 'get_template_directory' ) ) { + $parent_path = wp_normalize_path( get_template_directory() . '/functions.php' ); + $parent_name = function_exists( 'wp_get_theme' ) ? wp_get_theme( get_template() )->get( 'Name' ) : 'Parent theme'; + + $targets['theme'] = [ + 'path' => $parent_path, + 'name' => $parent_name, + ]; + } + + if ( function_exists( 'get_stylesheet_directory' ) && get_stylesheet() !== get_template() ) { + $child_path = wp_normalize_path( get_stylesheet_directory() . '/functions.php' ); + $child_name = wp_get_theme()->get( 'Name' ); + + $targets['child-theme'] = [ + 'path' => $child_path, + 'name' => $child_name, + ]; + } + + return $targets; + } + + /** + * Scan a single functions.php file, extracting top-level symbols. + * + * @param string $path Absolute file path. + * @param string $source_type 'theme' or 'child-theme'. + * @param string $source_name Human-readable theme name. + * + * @return Discovered_Snippet[] + */ + private function scan_file( string $path, string $source_type, string $source_name ): array { + $code = Filesystem_Reader::get_contents( $path ); + + if ( null === $code || '' === $code ) { + return []; + } + + try { + $tokens = token_get_all( $code, TOKEN_PARSE ); + } catch ( ParseError $e ) { + return []; + } + + $lines = explode( "\n", $code ); + $snippets = []; + $depth = 0; + + for ( $i = 0, $n = count( $tokens ); $i < $n; $i++ ) { + $token = $tokens[ $i ]; + + if ( is_string( $token ) ) { + if ( '{' === $token ) { + ++$depth; + } elseif ( '}' === $token ) { + $depth = max( 0, $depth - 1 ); + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( ! in_array( $token[0], [ T_FUNCTION, T_CLASS, T_TRAIT, T_INTERFACE ], true ) ) { + continue; + } + + $symbol = $this->extract_symbol( $tokens, $i, $lines ); + + if ( null === $symbol ) { + continue; + } + + $snippets[] = $this->build_snippet( + [ + 'name' => $symbol['name'], + 'code' => $symbol['code'], + 'type' => 'php', + 'source_type' => $source_type, + 'source_name' => $source_name, + 'source_path' => $path, + 'line_start' => $symbol['line_start'], + 'line_end' => $symbol['line_end'], + 'is_active' => true, + ] + ); + } + + return $snippets; + } + + /** + * Extract a single top-level symbol starting at the given token index. + * + * Advances $i past the symbol's closing brace (or semicolon). + * + * @param array $tokens Full token stream. + * @param int $i Current index (by reference). + * @param string[] $lines Original source split by newline. + * + * @return array{name: string, code: string, line_start: int, line_end: int}|null + */ + private function extract_symbol( array $tokens, int &$i, array $lines ): ?array { + $start_token = $tokens[ $i ]; + $line_start = $start_token[2]; + $type_id = $start_token[0]; + $n = count( $tokens ); + $name = ''; + + for ( $j = $i + 1; $j < $n; $j++ ) { + $inner = $tokens[ $j ]; + + if ( is_string( $inner ) ) { + // Anonymous function definitions never reach here at top level without a T_STRING. + if ( '(' === $inner || ';' === $inner || '{' === $inner ) { + break; + } + continue; + } + + if ( T_STRING === $inner[0] ) { + $name = $inner[1]; + break; + } + } + + if ( '' === $name ) { + return null; + } + + $depth = 0; + $seen_brace = false; + $line_end = $line_start; + + for ( $j = $i + 1; $j < $n; $j++ ) { + $inner = $tokens[ $j ]; + + if ( is_string( $inner ) ) { + if ( '{' === $inner ) { + ++$depth; + $seen_brace = true; + } elseif ( '}' === $inner ) { + --$depth; + if ( 0 === $depth && $seen_brace ) { + $line_end = $this->token_line( $tokens, $j ); + $i = $j; + break; + } + } elseif ( ';' === $inner && ! $seen_brace && T_FUNCTION !== $type_id ) { + $line_end = $this->token_line( $tokens, $j ); + $i = $j; + break; + } + } + } + + $snippet_lines = array_slice( $lines, $line_start - 1, ( $line_end - $line_start ) + 1 ); + + return [ + 'name' => $name, + 'code' => implode( "\n", $snippet_lines ), + 'line_start' => $line_start, + 'line_end' => $line_end, + ]; + } + + /** + * Get the source line for a token index. For string tokens (which carry no line + * metadata), walk backwards to the nearest array token and add any newlines in + * its text so the result reflects the string token's actual source line. + * + * @param array $tokens Full token stream. + * @param int $index Token index. + * + * @return int + */ + private function token_line( array $tokens, int $index ): int { + if ( is_array( $tokens[ $index ] ) ) { + return $tokens[ $index ][2]; + } + + for ( $k = $index - 1; $k >= 0; $k-- ) { + if ( is_array( $tokens[ $k ] ) ) { + return $tokens[ $k ][2] + substr_count( $tokens[ $k ][1], "\n" ); + } + } + + return 1; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php b/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php new file mode 100644 index 00000000..4a283ec9 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php @@ -0,0 +1,282 @@ +path = $path ?? ( defined( 'ABSPATH' ) ? ABSPATH . '.htaccess' : '.htaccess' ); + } + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'htaccess'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( '.htaccess', 'code-snippets' ); + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + return Filesystem_Reader::is_readable( $this->path ); + } + + /** + * {@inheritDoc} + */ + public function supports_import(): bool { + return false; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + $contents = Filesystem_Reader::get_contents( $this->path ); + + if ( null === $contents || '' === $contents ) { + return []; + } + + $lines = preg_split( "/\r\n|\n|\r/", $contents ); + $sections = $this->split_into_sections( $lines ); + + $snippets = []; + + foreach ( $sections as $section ) { + $snippets[] = $this->build_section_snippet( $section ); + } + + return $snippets; + } + + /** + * Split the file lines into labeled BEGIN/END sections plus contiguous custom groups. + * + * @param string[] $lines The file lines (without trailing newlines). + * + * @return array + */ + private function split_into_sections( array $lines ): array { + $sections = []; + $n = count( $lines ); + $i = 0; + $custom_start = null; + $custom_has_body = false; + $custom_buffer = []; + + $flush_custom = static function () use ( &$sections, &$custom_start, &$custom_buffer, &$custom_has_body ) { + if ( null !== $custom_start && $custom_has_body ) { + $sections[] = [ + 'marker' => '', + 'line_start' => $custom_start, + 'line_end' => $custom_start + count( $custom_buffer ) - 1, + 'code' => implode( "\n", $custom_buffer ), + ]; + } + $custom_start = null; + $custom_buffer = []; + $custom_has_body = false; + }; + + while ( $i < $n ) { + $line = $lines[ $i ]; + + if ( preg_match( '/^# BEGIN (.+)$/', $line, $matches ) ) { + $flush_custom(); + + $marker = trim( $matches[1] ); + $start_idx = $i; + $end_idx = $i; + + for ( $j = $i + 1; $j < $n; $j++ ) { + if ( preg_match( '/^# END (.+)$/', $lines[ $j ], $em ) && trim( $em[1] ) === $marker ) { + $end_idx = $j; + break; + } + $end_idx = $j; + } + + $sections[] = [ + 'marker' => $marker, + 'line_start' => $start_idx + 1, + 'line_end' => $end_idx + 1, + 'code' => implode( "\n", array_slice( $lines, $start_idx, ( $end_idx - $start_idx ) + 1 ) ), + ]; + + $i = $end_idx + 1; + continue; + } + + if ( null === $custom_start ) { + $custom_start = $i + 1; + } + + $custom_buffer[] = $line; + + if ( '' !== trim( $line ) ) { + $custom_has_body = true; + } + + ++$i; + } + + $flush_custom(); + + return $sections; + } + + /** + * Build a Discovered_Snippet for a single parsed section. + * + * @param array{marker: string, line_start: int, line_end: int, code: string} $section Section data. + * + * @return Discovered_Snippet + */ + private function build_section_snippet( array $section ): Discovered_Snippet { + $marker = $section['marker']; + $classification = $this->classify_section( $marker, $section['code'] ); + $default_name = '' === $marker ? __( 'Custom', 'code-snippets' ) : $marker; + + return $this->build_snippet( + [ + 'name' => $default_name, + 'code' => $section['code'], + 'type' => 'config', + 'source_type' => 'server', + 'source_name' => $default_name, + 'source_path' => $this->path, + 'line_start' => $section['line_start'], + 'line_end' => $section['line_end'], + 'is_active' => true, + 'is_importable' => $classification['importable'], + 'risk_level' => $classification['risk'], + 'import_notes' => '[' . $classification['category'] . '] ' . $classification['note'], + ] + ); + } + + /** + * Classify a section by marker and directive content. + * + * @param string $marker Section marker name (empty for custom/unattributed). + * @param string $body Section body text. + * + * @return array{category: string, risk: string, importable: bool, note: string} + */ + private function classify_section( string $marker, string $body ): array { + if ( 'WordPress' === $marker ) { + return [ + 'category' => 'core', + 'risk' => 'high', + 'importable' => false, + 'note' => __( 'WordPress core rewrite block. Never edit manually.', 'code-snippets' ), + ]; + } + + foreach ( self::SERVER_ONLY_HIGH as $needle ) { + if ( false !== stripos( $body, $needle ) ) { + return [ + 'category' => 'server-only', + 'risk' => 'high', + 'importable' => false, + 'note' => __( 'Server-level directive with no PHP equivalent.', 'code-snippets' ), + ]; + } + } + + foreach ( self::SERVER_ONLY_MEDIUM as $needle ) { + if ( false !== stripos( $body, $needle ) ) { + return [ + 'category' => 'server-only', + 'risk' => 'medium', + 'importable' => false, + 'note' => __( 'Server-level performance directive with no PHP equivalent.', 'code-snippets' ), + ]; + } + } + + foreach ( self::CONVERTIBLE_DIRECTIVES as $needle ) { + if ( false !== stripos( $body, $needle ) ) { + return [ + 'category' => 'convertible', + 'risk' => 'low', + 'importable' => true, + 'note' => __( 'Can be converted to a PHP snippet via WordPress hooks.', 'code-snippets' ), + ]; + } + } + + return [ + 'category' => 'convertible', + 'risk' => 'medium', + 'importable' => false, + 'note' => __( 'Unrecognised directive; review manually before importing.', 'code-snippets' ), + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Mu_Plugins_Scanner.php b/src/php/UnifiedSnippets/Scanners/Mu_Plugins_Scanner.php new file mode 100644 index 00000000..8a65c6c8 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Mu_Plugins_Scanner.php @@ -0,0 +1,140 @@ +directory = $directory ?? $default; + } + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'mu-plugins'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( 'Must-Use Plugins', 'code-snippets' ); + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + return '' !== $this->directory && is_dir( $this->directory ); + } + + /** + * {@inheritDoc} + */ + public function get_risk_level(): string { + return 'medium'; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + if ( ! $this->is_available() ) { + return []; + } + + $mu_plugins = $this->collect_mu_plugins(); + $snippets = []; + + foreach ( $mu_plugins as $file => $data ) { + $path = trailingslashit( $this->directory ) . $file; + + if ( ! Filesystem_Reader::is_readable( $path ) ) { + continue; + } + + $code = Filesystem_Reader::get_contents( $path ); + + if ( null === $code ) { + continue; + } + + $line_end = max( 1, substr_count( $code, "\n" ) + 1 ); + $name = ! empty( $data['Name'] ) ? $data['Name'] : $file; + + $snippets[] = $this->build_snippet( + [ + 'name' => $name, + 'code' => $code, + 'type' => 'php', + 'source_type' => 'mu-plugin', + 'source_name' => $name, + 'source_path' => $path, + 'line_start' => 1, + 'line_end' => $line_end, + 'is_active' => true, + ] + ); + } + + return $snippets; + } + + /** + * Collect mu-plugin metadata from the configured directory. + * + * @return array> + */ + private function collect_mu_plugins(): array { + if ( function_exists( 'get_mu_plugins' ) && defined( 'WPMU_PLUGIN_DIR' ) && WPMU_PLUGIN_DIR === $this->directory ) { + return get_mu_plugins(); + } + + if ( defined( 'ABSPATH' ) && ! function_exists( 'get_plugin_data' ) ) { + $plugin_includes = ABSPATH . 'wp-admin/includes/plugin.php'; + if ( is_readable( $plugin_includes ) ) { + require_once $plugin_includes; + } + } + + $results = []; + + foreach ( (array) glob( trailingslashit( $this->directory ) . '*.php' ) as $path ) { + if ( ! is_file( $path ) ) { + continue; + } + + $file = basename( $path ); + $data = function_exists( 'get_plugin_data' ) + ? get_plugin_data( $path, false, false ) + : [ 'Name' => $file ]; + + $results[ $file ] = $data; + } + + return $results; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php b/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php new file mode 100644 index 00000000..cec86dbb --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php @@ -0,0 +1,220 @@ +config_path = $config_path ?? $abspath . 'wp-config.php'; + $this->sample_path = $sample_path ?? $abspath . 'wp-config-sample.php'; + } + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'wp-config'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( 'wp-config.php', 'code-snippets' ); + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + return Filesystem_Reader::is_readable( $this->config_path ) + && Filesystem_Reader::is_readable( $this->sample_path ); + } + + /** + * {@inheritDoc} + */ + public function get_risk_level(): string { + return 'high'; + } + + /** + * {@inheritDoc} + */ + public function supports_import(): bool { + return false; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + $config = Filesystem_Reader::get_contents( $this->config_path ); + $sample = Filesystem_Reader::get_contents( $this->sample_path ); + + if ( null === $config || null === $sample ) { + return []; + } + + $sample_lines = $this->build_sample_set( $sample ); + $config_lines = preg_split( "/\r\n|\n|\r/", $config ); + + $blocks = []; + $buffer = []; + $start = 0; + + $flush = static function () use ( &$blocks, &$buffer, &$start ) { + if ( ! empty( $buffer ) ) { + $blocks[] = [ + 'line_start' => $start, + 'line_end' => $start + count( $buffer ) - 1, + 'code' => implode( "\n", $buffer ), + ]; + $buffer = []; + $start = 0; + } + }; + + foreach ( $config_lines as $idx => $line ) { + if ( $this->is_user_addition( $line, $sample_lines ) ) { + if ( empty( $buffer ) ) { + $start = $idx + 1; + } + $buffer[] = $line; + } else { + $flush(); + } + } + + $flush(); + + $snippets = []; + $note = __( 'wp-config constants run before WordPress loads and cannot be replaced by a snippet.', 'code-snippets' ); + + foreach ( $blocks as $index => $block ) { + $snippets[] = $this->build_snippet( + [ + 'name' => sprintf( + /* translators: %d: block index */ + __( 'wp-config.php addition #%d', 'code-snippets' ), + $index + 1 + ), + 'code' => $block['code'], + 'type' => 'config', + 'source_type' => 'core', + 'source_name' => 'wp-config.php', + 'source_path' => $this->config_path, + 'line_start' => $block['line_start'], + 'line_end' => $block['line_end'], + 'is_active' => true, + 'is_importable' => false, + 'risk_level' => 'high', + 'import_notes' => $note, + ] + ); + } + + return $snippets; + } + + /** + * Build a set of trimmed sample lines for quick membership checks. + * + * @param string $sample Contents of wp-config-sample.php. + * + * @return array Lookup keyed by trimmed line. + */ + private function build_sample_set( string $sample ): array { + $set = []; + + foreach ( preg_split( "/\r\n|\n|\r/", $sample ) as $line ) { + $trimmed = trim( $line ); + if ( '' !== $trimmed ) { + $set[ $trimmed ] = true; + } + } + + return $set; + } + + /** + * Determine whether a wp-config line represents a user addition worth reporting. + * + * @param string $line Raw line from wp-config.php. + * @param array $sample_lines Lookup of sample lines. + * + * @return bool + */ + private function is_user_addition( string $line, array $sample_lines ): bool { + $trimmed = trim( $line ); + + if ( '' === $trimmed ) { + return false; + } + + if ( '' === $trimmed ) { + return false; + } + + if ( 0 === strpos( $trimmed, '//' ) || 0 === strpos( $trimmed, '#' ) || 0 === strpos( $trimmed, '*' ) || 0 === strpos( $trimmed, '/*' ) ) { + return false; + } + + if ( isset( $sample_lines[ $trimmed ] ) ) { + return false; + } + + foreach ( self::NOISE_PATTERNS as $pattern ) { + if ( preg_match( $pattern, $trimmed ) ) { + return false; + } + } + + return true; + } +} diff --git a/tests/phpunit/test-unified-scanners.php b/tests/phpunit/test-unified-scanners.php new file mode 100644 index 00000000..80d0f9dd --- /dev/null +++ b/tests/phpunit/test-unified-scanners.php @@ -0,0 +1,284 @@ +tmp_dir = sys_get_temp_dir() . '/cs-scanner-' . wp_generate_uuid4(); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $this->tmp_dir, 0777, true ); + } + + /** + * Remove the fixture directory after each test. + */ + public function tear_down() { + $this->rrmdir( $this->tmp_dir ); + parent::tear_down(); + } + + /** + * Recursively delete a directory. + * + * @param string $dir Directory path. + */ + private function rrmdir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + foreach ( (array) glob( $dir . '/*' ) as $path ) { + if ( is_dir( $path ) ) { + $this->rrmdir( $path ); + } else { + wp_delete_file( $path ); + } + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + rmdir( $dir ); + } + + /** + * Write a fixture file through the filesystem directly. + * + * @param string $path Absolute file path. + * @param string $contents File contents. + */ + private function write_fixture( string $path, string $contents ): void { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $path, $contents ); + } + + /** + * The functions.php scanner extracts top-level functions and classes. + */ + public function test_functions_php_scanner_extracts_top_level_symbols() { + $code = <<<'PHP' +tmp_dir . '/functions.php'; + $this->write_fixture( $path, $code ); + + $scanner = new Functions_Php_Scanner( + [ + 'theme' => [ + 'path' => $path, + 'name' => 'Fixture Theme', + ], + ] + ); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 2, $results ); + + $names = array_map( static fn( $s ) => $s->name, $results ); + $this->assertContains( 'my_custom_function', $names ); + $this->assertContains( 'My_Plugin_Helper', $names ); + + foreach ( $results as $snippet ) { + $this->assertSame( 'php', $snippet->type ); + $this->assertSame( 'theme', $snippet->source_type ); + $this->assertSame( 'Fixture Theme', $snippet->source_name ); + $this->assertGreaterThan( 0, $snippet->line_start ); + $this->assertGreaterThanOrEqual( $snippet->line_start, $snippet->line_end ); + $this->assertTrue( $snippet->is_active ); + } + } + + /** + * The Additional CSS scanner reads the active theme's custom CSS post. + */ + public function test_additional_css_scanner_returns_customizer_css() { + wp_update_custom_css_post( 'body { color: red; }' ); + + $scanner = new Additional_CSS_Scanner(); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 1, $results ); + $snippet = $results[0]; + + $this->assertSame( 'css', $snippet->type ); + $this->assertSame( 'customizer', $snippet->source_type ); + $this->assertSame( 'body { color: red; }', $snippet->code ); + $this->assertStringStartsWith( 'customizer://custom_css/', $snippet->source_path ); + + wp_update_custom_css_post( '' ); + } + + /** + * The Additional CSS scanner returns nothing when there is no custom CSS. + */ + public function test_additional_css_scanner_returns_empty_when_no_css() { + wp_update_custom_css_post( '' ); + + $scanner = new Additional_CSS_Scanner(); + $results = $scanner->scan(); + + $this->assertSame( [], $results ); + } + + /** + * The .htaccess scanner splits sections and classifies them correctly. + */ + public function test_htaccess_scanner_classifies_sections() { + $htaccess = <<<'TXT' +# BEGIN WordPress +RewriteEngine On +RewriteBase / +RewriteRule ^index\.php$ - [L] +# END WordPress + +# BEGIN Redirects +Redirect 301 /old /new +# END Redirects + + + Require all denied + +TXT; + + $path = $this->tmp_dir . '/.htaccess'; + $this->write_fixture( $path, $htaccess ); + + $scanner = new Htaccess_Scanner( $path ); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 3, $results ); + + $by_name = []; + foreach ( $results as $snippet ) { + $by_name[ $snippet->name ] = $snippet; + } + + $this->assertArrayHasKey( 'WordPress', $by_name ); + $this->assertSame( 'high', $by_name['WordPress']->risk_level ); + $this->assertFalse( $by_name['WordPress']->is_importable ); + $this->assertStringStartsWith( '[core]', $by_name['WordPress']->import_notes ); + + $this->assertArrayHasKey( 'Redirects', $by_name ); + $this->assertTrue( $by_name['Redirects']->is_importable ); + $this->assertStringStartsWith( '[convertible]', $by_name['Redirects']->import_notes ); + + $this->assertArrayHasKey( 'Custom', $by_name ); + $this->assertFalse( $by_name['Custom']->is_importable ); + $this->assertStringStartsWith( '[server-only]', $by_name['Custom']->import_notes ); + } + + /** + * The wp-config scanner reports only user-added lines grouped by contiguous blocks. + */ + public function test_wp_config_scanner_detects_user_additions() { + $sample = <<<'PHP' +tmp_dir . '/wp-config-sample.php'; + $config_path = $this->tmp_dir . '/wp-config.php'; + $this->write_fixture( $sample_path, $sample ); + $this->write_fixture( $config_path, $config ); + + $scanner = new Wp_Config_Scanner( $config_path, $sample_path ); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 1, $results ); + $snippet = $results[0]; + + $this->assertSame( 'config', $snippet->type ); + $this->assertSame( 'core', $snippet->source_type ); + $this->assertFalse( $snippet->is_importable ); + $this->assertSame( 'high', $snippet->risk_level ); + $this->assertStringContainsString( "define( 'WP_DEBUG', true );", $snippet->code ); + $this->assertStringContainsString( "define( 'DISALLOW_FILE_EDIT', true );", $snippet->code ); + $this->assertSame( 4, $snippet->line_start ); + $this->assertSame( 5, $snippet->line_end ); + } + + /** + * The mu-plugins scanner emits one snippet per file in the configured directory. + */ + public function test_mu_plugins_scanner_reads_files() { + $file = $this->tmp_dir . '/site-helper.php'; + $this->write_fixture( + $file, + "tmp_dir ); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 1, $results ); + $snippet = $results[0]; + + $this->assertSame( 'php', $snippet->type ); + $this->assertSame( 'mu-plugin', $snippet->source_type ); + $this->assertSame( $file, $snippet->source_path ); + $this->assertStringContainsString( 'return true;', $snippet->code ); + $this->assertSame( 1, $snippet->line_start ); + $this->assertGreaterThan( 1, $snippet->line_end ); + } +} From d56945b3f2415660d258e2130c498e69bf5ba18b Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Mon, 20 Apr 2026 22:21:19 +0200 Subject: [PATCH 2/9] unified snippets scanner updates and tests hardening --- .../Scanners/Additional_CSS_Scanner.php | 9 +++ .../Scanners/Functions_Php_Scanner.php | 7 +- .../Scanners/Htaccess_Scanner.php | 40 ++++++++-- tests/phpunit/test-unified-scanners.php | 77 +++++++++++++++++-- 4 files changed, 120 insertions(+), 13 deletions(-) diff --git a/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php b/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php index 68c045f6..5e75caed 100644 --- a/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php @@ -33,6 +33,15 @@ public function is_available(): bool { return function_exists( 'wp_get_custom_css_post' ); } + /** + * {@inheritDoc} + * + * Customizer CSS is always low-risk regardless of the base default. + */ + public function get_risk_level(): string { + return 'low'; + } + /** * {@inheritDoc} */ diff --git a/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php index 6de1174e..2c96c719 100644 --- a/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php @@ -137,7 +137,12 @@ private function scan_file( string $path, string $source_type, string $source_na } try { - $tokens = token_get_all( $code, TOKEN_PARSE ); + // TOKEN_PARSE was added in PHP 8.0. It makes token_get_all() throw on parse errors + // instead of silently returning a partial stream; on 7.4 we fall back to a plain call + // and accept that broken files may yield partial results rather than skipping cleanly. + $tokens = PHP_VERSION_ID >= 80000 + ? token_get_all( $code, TOKEN_PARSE ) + : token_get_all( $code ); } catch ( ParseError $e ) { return []; } diff --git a/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php b/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php index 4a283ec9..fe682ff1 100644 --- a/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php @@ -120,7 +120,7 @@ public function scan(): array { * * @param string[] $lines The file lines (without trailing newlines). * - * @return array + * @return array */ private function split_into_sections( array $lines ): array { $sections = []; @@ -134,6 +134,7 @@ private function split_into_sections( array $lines ): array { if ( null !== $custom_start && $custom_has_body ) { $sections[] = [ 'marker' => '', + 'unmatched' => false, 'line_start' => $custom_start, 'line_end' => $custom_start + count( $custom_buffer ) - 1, 'code' => implode( "\n", $custom_buffer ), @@ -153,17 +154,28 @@ private function split_into_sections( array $lines ): array { $marker = trim( $matches[1] ); $start_idx = $i; $end_idx = $i; + $matched = false; for ( $j = $i + 1; $j < $n; $j++ ) { if ( preg_match( '/^# END (.+)$/', $lines[ $j ], $em ) && trim( $em[1] ) === $marker ) { $end_idx = $j; + $matched = true; break; } + + // If another BEGIN appears before our END, stop the search at the line before it + // so the orphan section ends cleanly instead of swallowing the next block. + if ( preg_match( '/^# BEGIN /', $lines[ $j ] ) ) { + $end_idx = $j - 1; + break; + } + $end_idx = $j; } $sections[] = [ 'marker' => $marker, + 'unmatched' => ! $matched, 'line_start' => $start_idx + 1, 'line_end' => $end_idx + 1, 'code' => implode( "\n", array_slice( $lines, $start_idx, ( $end_idx - $start_idx ) + 1 ) ), @@ -194,13 +206,13 @@ private function split_into_sections( array $lines ): array { /** * Build a Discovered_Snippet for a single parsed section. * - * @param array{marker: string, line_start: int, line_end: int, code: string} $section Section data. + * @param array{marker: string, unmatched: bool, line_start: int, line_end: int, code: string} $section Section data. * * @return Discovered_Snippet */ private function build_section_snippet( array $section ): Discovered_Snippet { $marker = $section['marker']; - $classification = $this->classify_section( $marker, $section['code'] ); + $classification = $this->classify_section( $marker, $section['code'], ! empty( $section['unmatched'] ) ); $default_name = '' === $marker ? __( 'Custom', 'code-snippets' ) : $marker; return $this->build_snippet( @@ -224,12 +236,26 @@ private function build_section_snippet( array $section ): Discovered_Snippet { /** * Classify a section by marker and directive content. * - * @param string $marker Section marker name (empty for custom/unattributed). - * @param string $body Section body text. + * @param string $marker Section marker name (empty for custom/unattributed). + * @param string $body Section body text. + * @param bool $unmatched Whether the BEGIN marker was not closed by a matching END. * * @return array{category: string, risk: string, importable: bool, note: string} */ - private function classify_section( string $marker, string $body ): array { + private function classify_section( string $marker, string $body, bool $unmatched = false ): array { + if ( $unmatched ) { + return [ + 'category' => 'server-only', + 'risk' => 'high', + 'importable' => false, + 'note' => sprintf( + /* translators: %s: section marker name */ + __( 'Unclosed BEGIN marker for "%s"; review .htaccess manually.', 'code-snippets' ), + $marker + ), + ]; + } + if ( 'WordPress' === $marker ) { return [ 'category' => 'core', @@ -276,7 +302,7 @@ private function classify_section( string $marker, string $body ): array { 'category' => 'convertible', 'risk' => 'medium', 'importable' => false, - 'note' => __( 'Unrecognised directive; review manually before importing.', 'code-snippets' ), + 'note' => __( 'Unrecognized directive; review manually before importing.', 'code-snippets' ), ]; } } diff --git a/tests/phpunit/test-unified-scanners.php b/tests/phpunit/test-unified-scanners.php index 80d0f9dd..1d5ec0c1 100644 --- a/tests/phpunit/test-unified-scanners.php +++ b/tests/phpunit/test-unified-scanners.php @@ -110,16 +110,35 @@ public function run() { $this->assertCount( 2, $results ); - $names = array_map( static fn( $s ) => $s->name, $results ); - $this->assertContains( 'my_custom_function', $names ); - $this->assertContains( 'My_Plugin_Helper', $names ); + $by_name = []; + foreach ( $results as $snippet ) { + $by_name[ $snippet->name ] = $snippet; + } + + $this->assertArrayHasKey( 'my_custom_function', $by_name ); + $this->assertArrayHasKey( 'My_Plugin_Helper', $by_name ); + + // Fixture layout: + // L1: assertSame( 3, $by_name['my_custom_function']->line_start ); + $this->assertSame( 5, $by_name['my_custom_function']->line_end ); + $this->assertSame( 7, $by_name['My_Plugin_Helper']->line_start ); + $this->assertSame( 11, $by_name['My_Plugin_Helper']->line_end ); foreach ( $results as $snippet ) { $this->assertSame( 'php', $snippet->type ); $this->assertSame( 'theme', $snippet->source_type ); $this->assertSame( 'Fixture Theme', $snippet->source_name ); - $this->assertGreaterThan( 0, $snippet->line_start ); - $this->assertGreaterThanOrEqual( $snippet->line_start, $snippet->line_end ); $this->assertTrue( $snippet->is_active ); } } @@ -277,8 +296,56 @@ public function test_mu_plugins_scanner_reads_files() { $this->assertSame( 'php', $snippet->type ); $this->assertSame( 'mu-plugin', $snippet->source_type ); $this->assertSame( $file, $snippet->source_path ); + $this->assertSame( 'Site Helper', $snippet->name ); $this->assertStringContainsString( 'return true;', $snippet->code ); $this->assertSame( 1, $snippet->line_start ); $this->assertGreaterThan( 1, $snippet->line_end ); } + + /** + * The .htaccess classifier uses first-match-wins: a section containing both a + * high-risk server-only directive and a convertible one is marked server-only. + */ + public function test_htaccess_scanner_first_match_wins() { + $htaccess = <<<'TXT' +# BEGIN Mixed +Redirect 301 /old /new +php_value upload_max_filesize 64M +# END Mixed +TXT; + + $path = $this->tmp_dir . '/.htaccess'; + $this->write_fixture( $path, $htaccess ); + + $results = ( new Htaccess_Scanner( $path ) )->scan(); + + $this->assertCount( 1, $results ); + $this->assertSame( 'Mixed', $results[0]->name ); + $this->assertSame( 'high', $results[0]->risk_level ); + $this->assertFalse( $results[0]->is_importable ); + $this->assertStringStartsWith( '[server-only]', $results[0]->import_notes ); + } + + /** + * An unmatched BEGIN marker is reported as a high-risk server-only section + * instead of silently swallowing the rest of the file. + */ + public function test_htaccess_scanner_reports_unmatched_begin() { + $htaccess = <<<'TXT' +# BEGIN Orphan +RewriteEngine On +RewriteRule ^foo$ /bar [L] +TXT; + + $path = $this->tmp_dir . '/.htaccess'; + $this->write_fixture( $path, $htaccess ); + + $results = ( new Htaccess_Scanner( $path ) )->scan(); + + $this->assertCount( 1, $results ); + $this->assertSame( 'Orphan', $results[0]->name ); + $this->assertSame( 'high', $results[0]->risk_level ); + $this->assertFalse( $results[0]->is_importable ); + $this->assertStringContainsString( 'Unclosed BEGIN marker', $results[0]->import_notes ); + } } From f93f8f21fcb7e81f6bd2ba106456ef8354587d34 Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Mon, 20 Apr 2026 22:27:59 +0200 Subject: [PATCH 3/9] refactor Functions_Php_Scanner for better readability --- .../Scanners/Functions_Php_Scanner.php | 224 +++++++++++------- 1 file changed, 135 insertions(+), 89 deletions(-) diff --git a/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php index 2c96c719..e23fe6ad 100644 --- a/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php @@ -120,6 +120,8 @@ private function resolve_targets(): array { return $targets; } + private const DEFINITION_TOKENS = [ T_FUNCTION, T_CLASS, T_TRAIT, T_INTERFACE ]; + /** * Scan a single functions.php file, extracting top-level symbols. * @@ -136,51 +138,20 @@ private function scan_file( string $path, string $source_type, string $source_na return []; } - try { - // TOKEN_PARSE was added in PHP 8.0. It makes token_get_all() throw on parse errors - // instead of silently returning a partial stream; on 7.4 we fall back to a plain call - // and accept that broken files may yield partial results rather than skipping cleanly. - $tokens = PHP_VERSION_ID >= 80000 - ? token_get_all( $code, TOKEN_PARSE ) - : token_get_all( $code ); - } catch ( ParseError $e ) { + $tokens = $this->tokenize( $code ); + + if ( null === $tokens ) { return []; } $lines = explode( "\n", $code ); $snippets = []; - $depth = 0; - - for ( $i = 0, $n = count( $tokens ); $i < $n; $i++ ) { - $token = $tokens[ $i ]; - - if ( is_string( $token ) ) { - if ( '{' === $token ) { - ++$depth; - } elseif ( '}' === $token ) { - $depth = max( 0, $depth - 1 ); - } - continue; - } - - if ( 0 !== $depth ) { - continue; - } - - if ( ! in_array( $token[0], [ T_FUNCTION, T_CLASS, T_TRAIT, T_INTERFACE ], true ) ) { - continue; - } - - $symbol = $this->extract_symbol( $tokens, $i, $lines ); - - if ( null === $symbol ) { - continue; - } + foreach ( $this->find_top_level_symbols( $tokens ) as $symbol ) { $snippets[] = $this->build_snippet( [ 'name' => $symbol['name'], - 'code' => $symbol['code'], + 'code' => $this->slice_source( $lines, $symbol['line_start'], $symbol['line_end'] ), 'type' => 'php', 'source_type' => $source_type, 'source_name' => $source_name, @@ -196,78 +167,153 @@ private function scan_file( string $path, string $source_type, string $source_na } /** - * Extract a single top-level symbol starting at the given token index. + * Tokenize PHP source, returning null if the file cannot be parsed. * - * Advances $i past the symbol's closing brace (or semicolon). + * TOKEN_PARSE (PHP 8.0+) makes token_get_all() throw ParseError on invalid source + * instead of silently returning a partial stream. On 7.4 we accept partial results. * - * @param array $tokens Full token stream. - * @param int $i Current index (by reference). - * @param string[] $lines Original source split by newline. + * @param string $code Source code. * - * @return array{name: string, code: string, line_start: int, line_end: int}|null + * @return array|null */ - private function extract_symbol( array $tokens, int &$i, array $lines ): ?array { - $start_token = $tokens[ $i ]; - $line_start = $start_token[2]; - $type_id = $start_token[0]; - $n = count( $tokens ); - $name = ''; - - for ( $j = $i + 1; $j < $n; $j++ ) { - $inner = $tokens[ $j ]; - - if ( is_string( $inner ) ) { - // Anonymous function definitions never reach here at top level without a T_STRING. - if ( '(' === $inner || ';' === $inner || '{' === $inner ) { - break; - } + private function tokenize( string $code ): ?array { + try { + return PHP_VERSION_ID >= 80000 + ? token_get_all( $code, TOKEN_PARSE ) + : token_get_all( $code ); + } catch ( ParseError $e ) { + return null; + } + } + + /** + * Yield each top-level function/class/trait/interface definition as {name, line_start, line_end}. + * + * @param array $tokens Token stream. + * + * @return \Generator + */ + private function find_top_level_symbols( array $tokens ): \Generator { + $depth = 0; + $count = count( $tokens ); + + for ( $i = 0; $i < $count; $i++ ) { + $token = $tokens[ $i ]; + + if ( '{' === $token ) { + ++$depth; continue; } - if ( T_STRING === $inner[0] ) { - $name = $inner[1]; - break; + if ( '}' === $token ) { + $depth = max( 0, $depth - 1 ); + continue; + } + + if ( 0 !== $depth || ! is_array( $token ) ) { + continue; } + + if ( ! in_array( $token[0], self::DEFINITION_TOKENS, true ) ) { + continue; + } + + $name = $this->read_symbol_name( $tokens, $i ); + + if ( '' === $name ) { + continue; + } + + $end_index = $this->find_symbol_end( $tokens, $i, $token[0] ); + + yield [ + 'name' => $name, + 'line_start' => $token[2], + 'line_end' => $this->token_line( $tokens, $end_index ), + ]; + + $i = $end_index; } + } - if ( '' === $name ) { - return null; + /** + * Read the T_STRING name that follows a definition token (function foo, class Bar, etc.). + * + * @param array $tokens Token stream. + * @param int $start Index of the definition keyword. + * + * @return string Empty string if no name is present (e.g. an anonymous class). + */ + private function read_symbol_name( array $tokens, int $start ): string { + for ( $i = $start + 1, $n = count( $tokens ); $i < $n; $i++ ) { + $token = $tokens[ $i ]; + + if ( is_array( $token ) && T_STRING === $token[0] ) { + return $token[1]; + } + + // Reached the parameter list or body without seeing a name: it's anonymous. + if ( '(' === $token || '{' === $token || ';' === $token ) { + return ''; + } } + return ''; + } + + /** + * Find the index of the token that closes a symbol's body (the matching `}` or, for + * interface/abstract declarations, the terminating `;`). + * + * @param array $tokens Token stream. + * @param int $start Index of the definition keyword. + * @param int $type_id The definition token id (T_FUNCTION etc). + * + * @return int Index of the closing token. Falls back to the last token if unmatched. + */ + private function find_symbol_end( array $tokens, int $start, int $type_id ): int { $depth = 0; $seen_brace = false; - $line_end = $line_start; - - for ( $j = $i + 1; $j < $n; $j++ ) { - $inner = $tokens[ $j ]; - - if ( is_string( $inner ) ) { - if ( '{' === $inner ) { - ++$depth; - $seen_brace = true; - } elseif ( '}' === $inner ) { - --$depth; - if ( 0 === $depth && $seen_brace ) { - $line_end = $this->token_line( $tokens, $j ); - $i = $j; - break; - } - } elseif ( ';' === $inner && ! $seen_brace && T_FUNCTION !== $type_id ) { - $line_end = $this->token_line( $tokens, $j ); - $i = $j; - break; + $count = count( $tokens ); + + for ( $i = $start + 1; $i < $count; $i++ ) { + $token = $tokens[ $i ]; + + if ( '{' === $token ) { + ++$depth; + $seen_brace = true; + continue; + } + + if ( '}' === $token ) { + --$depth; + if ( $seen_brace && 0 === $depth ) { + return $i; } + continue; + } + + // A `;` before any body closes declarations that have no body (e.g. abstract method). + // Functions always have a body in a functions.php top-level context, so ignore `;` for them. + if ( ';' === $token && ! $seen_brace && T_FUNCTION !== $type_id ) { + return $i; } } - $snippet_lines = array_slice( $lines, $line_start - 1, ( $line_end - $line_start ) + 1 ); + return $count - 1; + } - return [ - 'name' => $name, - 'code' => implode( "\n", $snippet_lines ), - 'line_start' => $line_start, - 'line_end' => $line_end, - ]; + /** + * Join a 1-indexed inclusive line range from an array of source lines. + * + * @param string[] $lines Source lines. + * @param int $line_start First line (1-indexed). + * @param int $line_end Last line (1-indexed, inclusive). + * + * @return string + */ + private function slice_source( array $lines, int $line_start, int $line_end ): string { + return implode( "\n", array_slice( $lines, $line_start - 1, ( $line_end - $line_start ) + 1 ) ); } /** From 8a5320291e642924c563f6e61137eea66cb18220 Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Mon, 20 Apr 2026 22:35:35 +0200 Subject: [PATCH 4/9] refactor scanners for improved readability --- .../Scanners/Functions_Php_Scanner.php | 4 +- .../Scanners/Htaccess_Scanner.php | 334 ++++++++++++------ .../Scanners/Wp_Config_Scanner.php | 155 +++++--- 3 files changed, 327 insertions(+), 166 deletions(-) diff --git a/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php index e23fe6ad..7b515c5a 100644 --- a/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php @@ -22,6 +22,8 @@ class Functions_Php_Scanner extends Scanner_Base { */ private array $path_overrides; + private const DEFINITION_TOKENS = [ T_FUNCTION, T_CLASS, T_TRAIT, T_INTERFACE ]; + /** * Class constructor. * @@ -120,8 +122,6 @@ private function resolve_targets(): array { return $targets; } - private const DEFINITION_TOKENS = [ T_FUNCTION, T_CLASS, T_TRAIT, T_INTERFACE ]; - /** * Scan a single functions.php file, extracting top-level symbols. * diff --git a/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php b/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php index fe682ff1..78cfc16d 100644 --- a/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php @@ -20,6 +20,10 @@ */ class Htaccess_Scanner extends Scanner_Base { + private const BEGIN_PATTERN = '/^# BEGIN (.+)$/'; + private const END_PATTERN = '/^# END (.+)$/'; + private const BEGIN_CUTOFF_PATTERN = '/^# BEGIN /'; + private const SERVER_ONLY_HIGH = [ 'php_value', 'php_flag', @@ -118,89 +122,153 @@ public function scan(): array { /** * Split the file lines into labeled BEGIN/END sections plus contiguous custom groups. * + * Walks the file once. Each iteration is either: + * - a BEGIN marker → consume through matching END (or orphan cutoff) and emit a labeled section + * - anything else → buffer as part of a pending custom section + * + * A custom section is only emitted if it contains at least one non-empty line. + * * @param string[] $lines The file lines (without trailing newlines). * * @return array */ private function split_into_sections( array $lines ): array { - $sections = []; - $n = count( $lines ); - $i = 0; - $custom_start = null; - $custom_has_body = false; - $custom_buffer = []; - - $flush_custom = static function () use ( &$sections, &$custom_start, &$custom_buffer, &$custom_has_body ) { - if ( null !== $custom_start && $custom_has_body ) { - $sections[] = [ - 'marker' => '', - 'unmatched' => false, - 'line_start' => $custom_start, - 'line_end' => $custom_start + count( $custom_buffer ) - 1, - 'code' => implode( "\n", $custom_buffer ), - ]; - } - $custom_start = null; - $custom_buffer = []; - $custom_has_body = false; - }; - - while ( $i < $n ) { - $line = $lines[ $i ]; - - if ( preg_match( '/^# BEGIN (.+)$/', $line, $matches ) ) { - $flush_custom(); - - $marker = trim( $matches[1] ); - $start_idx = $i; - $end_idx = $i; - $matched = false; - - for ( $j = $i + 1; $j < $n; $j++ ) { - if ( preg_match( '/^# END (.+)$/', $lines[ $j ], $em ) && trim( $em[1] ) === $marker ) { - $end_idx = $j; - $matched = true; - break; - } - - // If another BEGIN appears before our END, stop the search at the line before it - // so the orphan section ends cleanly instead of swallowing the next block. - if ( preg_match( '/^# BEGIN /', $lines[ $j ] ) ) { - $end_idx = $j - 1; - break; - } - - $end_idx = $j; - } - - $sections[] = [ - 'marker' => $marker, - 'unmatched' => ! $matched, - 'line_start' => $start_idx + 1, - 'line_end' => $end_idx + 1, - 'code' => implode( "\n", array_slice( $lines, $start_idx, ( $end_idx - $start_idx ) + 1 ) ), - ]; - - $i = $end_idx + 1; + $sections = []; + $custom = $this->new_custom_buffer(); + $total = count( $lines ); + + for ( $i = 0; $i < $total; ) { + if ( preg_match( self::BEGIN_PATTERN, $lines[ $i ], $matches ) ) { + $this->flush_custom( $sections, $custom ); + + $labeled = $this->consume_labeled_section( $lines, $i, trim( $matches[1] ) ); + $sections[] = $labeled['section']; + $i = $labeled['next_index']; continue; } - if ( null === $custom_start ) { - $custom_start = $i + 1; - } + $this->append_to_custom( $custom, $lines[ $i ], $i ); + ++$i; + } + + $this->flush_custom( $sections, $custom ); + + return $sections; + } + + /** + * Consume a labeled BEGIN/END section starting at $start_idx. + * + * @param string[] $lines All file lines. + * @param int $start_idx Index of the BEGIN line. + * @param string $marker Marker name from the BEGIN line. + * + * @return array{ + * section: array{marker: string, unmatched: bool, line_start: int, line_end: int, code: string}, + * next_index: int + * } + */ + private function consume_labeled_section( array $lines, int $start_idx, string $marker ): array { + [ $end_idx, $matched ] = $this->find_section_end( $lines, $start_idx, $marker ); + + $section = [ + 'marker' => $marker, + 'unmatched' => ! $matched, + 'line_start' => $start_idx + 1, + 'line_end' => $end_idx + 1, + 'code' => implode( "\n", array_slice( $lines, $start_idx, ( $end_idx - $start_idx ) + 1 ) ), + ]; + + return [ + 'section' => $section, + 'next_index' => $end_idx + 1, + ]; + } - $custom_buffer[] = $line; + /** + * Locate the closing line for a BEGIN marker. + * + * A section ends at its matching `# END $marker`. If a different `# BEGIN` appears + * first, the section is treated as unmatched and ends at the line before that BEGIN + * so the orphan does not swallow the next block. If neither is found, it runs to EOF. + * + * @param string[] $lines All file lines. + * @param int $start_idx Index of the BEGIN line. + * @param string $marker Marker name to match. + * + * @return array{0: int, 1: bool} [end index, matched?] + */ + private function find_section_end( array $lines, int $start_idx, string $marker ): array { + $total = count( $lines ); + $end_idx = $start_idx; - if ( '' !== trim( $line ) ) { - $custom_has_body = true; + for ( $j = $start_idx + 1; $j < $total; $j++ ) { + if ( preg_match( self::END_PATTERN, $lines[ $j ], $em ) && trim( $em[1] ) === $marker ) { + return [ $j, true ]; } - ++$i; + if ( preg_match( self::BEGIN_CUTOFF_PATTERN, $lines[ $j ] ) ) { + return [ $j - 1, false ]; + } + + $end_idx = $j; } - $flush_custom(); + return [ $end_idx, false ]; + } - return $sections; + /** + * Initial state for a pending custom (unlabeled) section. + * + * @return array{start: int|null, has_body: bool, buffer: string[]} + */ + private function new_custom_buffer(): array { + return [ + 'start' => null, + 'has_body' => false, + 'buffer' => [], + ]; + } + + /** + * Append a line to the in-progress custom buffer. + * + * @param array{start: int|null, has_body: bool, buffer: string[]} $custom Buffer state (by reference). + * @param string $line Raw line content. + * @param int $idx 0-indexed line number. + */ + private function append_to_custom( array &$custom, string $line, int $idx ): void { + if ( null === $custom['start'] ) { + $custom['start'] = $idx + 1; + } + + $custom['buffer'][] = $line; + + if ( '' !== trim( $line ) ) { + $custom['has_body'] = true; + } + } + + /** + * Emit any pending custom section and reset the buffer. + * + * Custom sections with no non-empty lines are discarded. + * + * @param array $sections Accumulated sections (by reference). + * @param array{start: int|null, has_body: bool, buffer: string[]} $custom Buffer state (by reference). + */ + private function flush_custom( array &$sections, array &$custom ): void { + if ( null !== $custom['start'] && $custom['has_body'] ) { + $sections[] = [ + 'marker' => '', + 'unmatched' => false, + 'line_start' => $custom['start'], + 'line_end' => $custom['start'] + count( $custom['buffer'] ) - 1, + 'code' => implode( "\n", $custom['buffer'] ), + ]; + } + + $custom = $this->new_custom_buffer(); } /** @@ -211,9 +279,9 @@ private function split_into_sections( array $lines ): array { * @return Discovered_Snippet */ private function build_section_snippet( array $section ): Discovered_Snippet { - $marker = $section['marker']; - $classification = $this->classify_section( $marker, $section['code'], ! empty( $section['unmatched'] ) ); - $default_name = '' === $marker ? __( 'Custom', 'code-snippets' ) : $marker; + $marker = $section['marker']; + $classification = $this->classify_section( $marker, $section['code'], $section['unmatched'] ); + $default_name = '' === $marker ? __( 'Custom', 'code-snippets' ) : $marker; return $this->build_snippet( [ @@ -234,7 +302,12 @@ private function build_section_snippet( array $section ): Discovered_Snippet { } /** - * Classify a section by marker and directive content. + * Classify a section. First match wins, in this order: + * 1. unmatched BEGIN marker + * 2. WordPress core rewrite block + * 3. server-only directives (high risk, then medium risk) + * 4. convertible directives + * 5. fallthrough: unrecognized custom directive * * @param string $marker Section marker name (empty for custom/unattributed). * @param string $body Section body text. @@ -242,67 +315,98 @@ private function build_section_snippet( array $section ): Discovered_Snippet { * * @return array{category: string, risk: string, importable: bool, note: string} */ - private function classify_section( string $marker, string $body, bool $unmatched = false ): array { + private function classify_section( string $marker, string $body, bool $unmatched ): array { if ( $unmatched ) { - return [ - 'category' => 'server-only', - 'risk' => 'high', - 'importable' => false, - 'note' => sprintf( + return $this->classification( + 'server-only', + 'high', + false, + sprintf( /* translators: %s: section marker name */ __( 'Unclosed BEGIN marker for "%s"; review .htaccess manually.', 'code-snippets' ), $marker - ), - ]; + ) + ); } if ( 'WordPress' === $marker ) { - return [ - 'category' => 'core', - 'risk' => 'high', - 'importable' => false, - 'note' => __( 'WordPress core rewrite block. Never edit manually.', 'code-snippets' ), - ]; + return $this->classification( + 'core', + 'high', + false, + __( 'WordPress core rewrite block. Never edit manually.', 'code-snippets' ) + ); } - foreach ( self::SERVER_ONLY_HIGH as $needle ) { - if ( false !== stripos( $body, $needle ) ) { - return [ - 'category' => 'server-only', - 'risk' => 'high', - 'importable' => false, - 'note' => __( 'Server-level directive with no PHP equivalent.', 'code-snippets' ), - ]; - } + if ( $this->body_contains_any( $body, self::SERVER_ONLY_HIGH ) ) { + return $this->classification( + 'server-only', + 'high', + false, + __( 'Server-level directive with no PHP equivalent.', 'code-snippets' ) + ); } - foreach ( self::SERVER_ONLY_MEDIUM as $needle ) { - if ( false !== stripos( $body, $needle ) ) { - return [ - 'category' => 'server-only', - 'risk' => 'medium', - 'importable' => false, - 'note' => __( 'Server-level performance directive with no PHP equivalent.', 'code-snippets' ), - ]; - } + if ( $this->body_contains_any( $body, self::SERVER_ONLY_MEDIUM ) ) { + return $this->classification( + 'server-only', + 'medium', + false, + __( 'Server-level performance directive with no PHP equivalent.', 'code-snippets' ) + ); } - foreach ( self::CONVERTIBLE_DIRECTIVES as $needle ) { + if ( $this->body_contains_any( $body, self::CONVERTIBLE_DIRECTIVES ) ) { + return $this->classification( + 'convertible', + 'low', + true, + __( 'Can be converted to a PHP snippet via WordPress hooks.', 'code-snippets' ) + ); + } + + return $this->classification( + 'convertible', + 'medium', + false, + __( 'Unrecognized directive; review manually before importing.', 'code-snippets' ) + ); + } + + /** + * Whether any of the given needles appear (case-insensitively) in the section body. + * + * @param string $body Section body text. + * @param string[] $needles Needles to search for. + * + * @return bool + */ + private function body_contains_any( string $body, array $needles ): bool { + foreach ( $needles as $needle ) { if ( false !== stripos( $body, $needle ) ) { - return [ - 'category' => 'convertible', - 'risk' => 'low', - 'importable' => true, - 'note' => __( 'Can be converted to a PHP snippet via WordPress hooks.', 'code-snippets' ), - ]; + return true; } } + return false; + } + + /** + * Build a classification result. + * + * @param string $category Category key. + * @param string $risk Risk level. + * @param bool $importable Whether the section can be imported. + * @param string $note Human-readable note. + * + * @return array{category: string, risk: string, importable: bool, note: string} + */ + private function classification( string $category, string $risk, bool $importable, string $note ): array { return [ - 'category' => 'convertible', - 'risk' => 'medium', - 'importable' => false, - 'note' => __( 'Unrecognized directive; review manually before importing.', 'code-snippets' ), + 'category' => $category, + 'risk' => $risk, + 'importable' => $importable, + 'note' => $note, ]; } } diff --git a/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php b/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php index cec86dbb..95f50622 100644 --- a/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php @@ -28,6 +28,11 @@ class Wp_Config_Scanner extends Scanner_Base { "/^require_once\s+ABSPATH\s*\.\s*['\"]wp-settings\.php['\"]/i", ]; + /** + * Lines that are part of the PHP envelope / comments and should never appear as additions. + */ + private const COMMENT_PREFIX_PATTERN = '#^(?://|\#|\*|/\*)#'; + /** * Absolute path to wp-config.php. * @@ -103,63 +108,97 @@ public function scan(): array { $sample_lines = $this->build_sample_set( $sample ); $config_lines = preg_split( "/\r\n|\n|\r/", $config ); + $blocks = $this->find_addition_blocks( $config_lines, $sample_lines ); + + $snippets = []; + + foreach ( $blocks as $index => $block ) { + $snippets[] = $this->build_addition_snippet( $block, $index + 1 ); + } + return $snippets; + } + + /** + * Group contiguous user-added lines into blocks. + * + * @param string[] $config_lines Lines of wp-config.php. + * @param array $sample_lines Lookup of lines present in wp-config-sample.php. + * + * @return array + */ + private function find_addition_blocks( array $config_lines, array $sample_lines ): array { $blocks = []; $buffer = []; $start = 0; - $flush = static function () use ( &$blocks, &$buffer, &$start ) { - if ( ! empty( $buffer ) ) { - $blocks[] = [ - 'line_start' => $start, - 'line_end' => $start + count( $buffer ) - 1, - 'code' => implode( "\n", $buffer ), - ]; - $buffer = []; - $start = 0; - } - }; - foreach ( $config_lines as $idx => $line ) { if ( $this->is_user_addition( $line, $sample_lines ) ) { if ( empty( $buffer ) ) { $start = $idx + 1; } $buffer[] = $line; - } else { - $flush(); + continue; + } + + if ( ! empty( $buffer ) ) { + $blocks[] = $this->make_block( $buffer, $start ); + $buffer = []; } } - $flush(); + if ( ! empty( $buffer ) ) { + $blocks[] = $this->make_block( $buffer, $start ); + } - $snippets = []; - $note = __( 'wp-config constants run before WordPress loads and cannot be replaced by a snippet.', 'code-snippets' ); + return $blocks; + } - foreach ( $blocks as $index => $block ) { - $snippets[] = $this->build_snippet( - [ - 'name' => sprintf( - /* translators: %d: block index */ - __( 'wp-config.php addition #%d', 'code-snippets' ), - $index + 1 - ), - 'code' => $block['code'], - 'type' => 'config', - 'source_type' => 'core', - 'source_name' => 'wp-config.php', - 'source_path' => $this->config_path, - 'line_start' => $block['line_start'], - 'line_end' => $block['line_end'], - 'is_active' => true, - 'is_importable' => false, - 'risk_level' => 'high', - 'import_notes' => $note, - ] - ); - } + /** + * Build a block record from a buffer of contiguous addition lines. + * + * @param string[] $buffer Lines making up the block. + * @param int $start 1-indexed line number of the first buffered line. + * + * @return array{line_start: int, line_end: int, code: string} + */ + private function make_block( array $buffer, int $start ): array { + return [ + 'line_start' => $start, + 'line_end' => $start + count( $buffer ) - 1, + 'code' => implode( "\n", $buffer ), + ]; + } - return $snippets; + /** + * Build a Discovered_Snippet for a single addition block. + * + * @param array{line_start: int, line_end: int, code: string} $block Block data. + * @param int $display_index 1-indexed block number used in the snippet name. + * + * @return \Code_Snippets\UnifiedSnippets\Model\Discovered_Snippet + */ + private function build_addition_snippet( array $block, int $display_index ) { + return $this->build_snippet( + [ + 'name' => sprintf( + /* translators: %d: block index */ + __( 'wp-config.php addition #%d', 'code-snippets' ), + $display_index + ), + 'code' => $block['code'], + 'type' => 'config', + 'source_type' => 'core', + 'source_name' => 'wp-config.php', + 'source_path' => $this->config_path, + 'line_start' => $block['line_start'], + 'line_end' => $block['line_end'], + 'is_active' => true, + 'is_importable' => false, + 'risk_level' => 'high', + 'import_notes' => __( 'wp-config constants run before WordPress loads and cannot be replaced by a snippet.', 'code-snippets' ), + ] + ); } /** @@ -193,28 +232,46 @@ private function build_sample_set( string $sample ): array { private function is_user_addition( string $line, array $sample_lines ): bool { $trimmed = trim( $line ); - if ( '' === $trimmed ) { + if ( '' === $trimmed || $this->is_envelope_line( $trimmed ) ) { return false; } - if ( '' === $trimmed ) { + if ( isset( $sample_lines[ $trimmed ] ) ) { return false; } - if ( 0 === strpos( $trimmed, '//' ) || 0 === strpos( $trimmed, '#' ) || 0 === strpos( $trimmed, '*' ) || 0 === strpos( $trimmed, '/*' ) ) { - return false; - } + return ! $this->is_noise_line( $trimmed ); + } - if ( isset( $sample_lines[ $trimmed ] ) ) { - return false; + /** + * Whether a trimmed line is part of the PHP envelope (opening/closing tag or comment). + * + * @param string $trimmed Trimmed line. + * + * @return bool + */ + private function is_envelope_line( string $trimmed ): bool { + if ( '' === $trimmed ) { + return true; } + return 1 === preg_match( self::COMMENT_PREFIX_PATTERN, $trimmed ); + } + + /** + * Whether a trimmed line matches a known noise pattern (DB creds, salts, bootstrap require). + * + * @param string $trimmed Trimmed line. + * + * @return bool + */ + private function is_noise_line( string $trimmed ): bool { foreach ( self::NOISE_PATTERNS as $pattern ) { if ( preg_match( $pattern, $trimmed ) ) { - return false; + return true; } } - return true; + return false; } } From f7268bf49fd3039f48173ac01946b1422e92577a Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Mon, 20 Apr 2026 22:55:47 +0200 Subject: [PATCH 5/9] unified snippets - fix phpunit test --- tests/phpunit/test-unified-scanners.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/test-unified-scanners.php b/tests/phpunit/test-unified-scanners.php index 1d5ec0c1..929e8036 100644 --- a/tests/phpunit/test-unified-scanners.php +++ b/tests/phpunit/test-unified-scanners.php @@ -51,7 +51,15 @@ private function rrmdir( string $dir ): void { return; } - foreach ( (array) glob( $dir . '/*' ) as $path ) { + // scandir() returns dotfiles (e.g. .htaccess fixtures); glob('*') would skip them + // and leave the directory non-empty so rmdir() fails. + foreach ( (array) scandir( $dir ) as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + + $path = $dir . '/' . $entry; + if ( is_dir( $path ) ) { $this->rrmdir( $path ); } else { From b86c409e367214fdb9490841fcea0780f3d4402e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:02:06 +0000 Subject: [PATCH 6/9] Fix CI: use unlink() instead of wp_delete_file() in rrmdir test helper to fix htaccess teardown Agent-Logs-Url: https://github.com/codesnippetspro/code-snippets/sessions/b0440861-2957-4a55-9d99-953f7a8d4032 Co-authored-by: louiswol94 <20996103+louiswol94@users.noreply.github.com> --- tests/phpunit/test-unified-scanners.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/test-unified-scanners.php b/tests/phpunit/test-unified-scanners.php index 929e8036..ce820ebb 100644 --- a/tests/phpunit/test-unified-scanners.php +++ b/tests/phpunit/test-unified-scanners.php @@ -63,7 +63,8 @@ private function rrmdir( string $dir ): void { if ( is_dir( $path ) ) { $this->rrmdir( $path ); } else { - wp_delete_file( $path ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $path ); } } From 816ccb4292381a9e425dcfb3bdc19f5c2c7a0136 Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Wed, 22 Apr 2026 21:41:50 +0200 Subject: [PATCH 7/9] Unified Snippets: implement Tier 2 DB scanners --- src/php/Plugin.php | 6 + .../Adapters/DB_Scanner_Adapter.php | 147 +++++++++++++++ .../Header_Footer_Code_Manager_Scanner.php | 73 ++++++++ .../Insert_Headers_And_Footers_Scanner.php | 81 ++++++++ .../Insert_PHP_Code_Snippet_Scanner.php | 82 +++++++++ tests/phpunit/fakes/Fake_Hfcm_Importer.php | 36 ++++ tests/phpunit/fakes/Fake_Ihaf_Importer.php | 38 ++++ tests/phpunit/fakes/Fake_Ipcs_Importer.php | 36 ++++ tests/phpunit/test-db-scanners.php | 173 ++++++++++++++++++ 9 files changed, 672 insertions(+) create mode 100644 src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php create mode 100644 src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php create mode 100644 src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php create mode 100644 src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php create mode 100644 tests/phpunit/fakes/Fake_Hfcm_Importer.php create mode 100644 tests/phpunit/fakes/Fake_Ihaf_Importer.php create mode 100644 tests/phpunit/fakes/Fake_Ipcs_Importer.php create mode 100644 tests/phpunit/test-db-scanners.php diff --git a/src/php/Plugin.php b/src/php/Plugin.php index 35fce33a..b8fd5ac5 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -23,7 +23,10 @@ use Code_Snippets\UnifiedSnippets\REST\Scan_REST_Controller; use Code_Snippets\UnifiedSnippets\Scanners\Additional_CSS_Scanner; use Code_Snippets\UnifiedSnippets\Scanners\Functions_Php_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Header_Footer_Code_Manager_Scanner; use Code_Snippets\UnifiedSnippets\Scanners\Htaccess_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Insert_Headers_And_Footers_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Insert_PHP_Code_Snippet_Scanner; use Code_Snippets\UnifiedSnippets\Scanners\Mu_Plugins_Scanner; use Code_Snippets\UnifiedSnippets\Scanners\Wp_Config_Scanner; @@ -179,6 +182,9 @@ private function init_unified_snippets(): void { $this->unified_snippets->register( new Htaccess_Scanner() ); $this->unified_snippets->register( new Wp_Config_Scanner() ); $this->unified_snippets->register( new Mu_Plugins_Scanner() ); + $this->unified_snippets->register( new Insert_Headers_And_Footers_Scanner() ); + $this->unified_snippets->register( new Header_Footer_Code_Manager_Scanner() ); + $this->unified_snippets->register( new Insert_PHP_Code_Snippet_Scanner() ); new Scan_REST_Controller( $this->unified_snippets, $this->unified_snippets_store ); } diff --git a/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php b/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php new file mode 100644 index 00000000..dc7c51c3 --- /dev/null +++ b/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php @@ -0,0 +1,147 @@ +importer = $importer ?? $this->create_importer(); + } + + /** + * Create the default importer instance for this adapter. + * + * @return Plugin_Importer + */ + abstract protected function create_importer(): Plugin_Importer; + + /** + * Name of the source plugin's database table, used to build synthetic URIs. + * + * @return string e.g. 'hfcm_scripts'. + */ + abstract protected function get_table_name(): string; + + /** + * Map a single raw row from the importer into Discovered_Snippet field overrides. + * + * Return null to skip the row (unsupported type, missing code, etc.). + * + * @param array $row Raw row cast to associative array. + * + * @return array|null + */ + abstract protected function map_row( array $row ): ?array; + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + return (bool) call_user_func( [ get_class( $this->importer ), 'is_active' ] ); + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + if ( ! $this->is_available() ) { + return []; + } + + $snippets = []; + + foreach ( $this->importer->get_data() as $row ) { + $row_array = is_array( $row ) ? $row : (array) $row; + $fields = $this->map_row( $row_array ); + + if ( null === $fields ) { + continue; + } + + $snippets[] = $this->build_snippet( $this->with_defaults( $fields ) ); + } + + return $snippets; + } + + /** + * Fill in common Discovered_Snippet fields that are shared by all DB-backed adapters. + * + * @param array $fields Adapter-supplied field overrides. + * + * @return array + */ + private function with_defaults( array $fields ): array { + return array_merge( + [ + 'source_type' => 'plugin', + 'source_name' => $this->get_label(), + 'line_start' => 0, + 'line_end' => 0, + ], + $fields + ); + } + + /** + * Build a synthetic URI identifying a row in the source plugin's table. + * + * @param int|string $id Row identifier. + * + * @return string e.g. 'db://hfcm_scripts/42'. + */ + protected function build_source_path( $id ): string { + return 'db://' . $this->get_table_name() . '/' . $id; + } + + /** + * Derive a {@see Discovered_Snippet} `type` from the source plugin's code-type value. + * + * Unknown or empty types fall back to 'php' to satisfy the model's allow-list. + * + * @param string $code_type Source-plugin code type, e.g. 'html', 'universal'. + * + * @return string + */ + protected function derive_type( string $code_type ): string { + switch ( strtolower( $code_type ) ) { + case 'css': + return 'css'; + case 'js': + case 'javascript': + return 'js'; + case 'html': + case 'universal': + return 'html'; + case 'php': + case '': + return 'php'; + default: + return 'mixed'; + } + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php b/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php new file mode 100644 index 00000000..1ff1427f --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php @@ -0,0 +1,73 @@ + $row Row returned by the importer. + * + * @return array|null Null when the row should be skipped. + */ + protected function map_row( array $row ): ?array { + $code = (string) ( $row['snippet'] ?? '' ); + + if ( '' === trim( $code ) ) { + return null; + } + + $id = (int) ( $row['script_id'] ?? 0 ); + $title = (string) ( $row['name'] ?? '' ); + + return [ + 'name' => '' !== $title ? $title : sprintf( 'HFCM #%d', $id ), + 'code' => $code, + 'type' => $this->derive_type( (string) ( $row['snippet_type'] ?? '' ) ), + 'source_path' => $this->build_source_path( $id ), + 'is_active' => 'active' === ( $row['status'] ?? '' ), + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php b/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php new file mode 100644 index 00000000..bb1aef16 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php @@ -0,0 +1,81 @@ + $row Row returned by the importer. + * + * @return array|null Null when the row should be skipped. + */ + protected function map_row( array $row ): ?array { + $code_type = (string) ( $row['code_type'] ?? '' ); + + if ( ! in_array( $code_type, self::SUPPORTED_CODE_TYPES, true ) ) { + return null; + } + + $code = (string) ( $row['code'] ?? '' ); + + if ( '' === trim( $code ) ) { + return null; + } + + $id = $row['table_data']['id'] ?? ( $row['id'] ?? 0 ); + $title = (string) ( $row['table_data']['title'] ?? ( $row['title'] ?? '' ) ); + + return [ + 'name' => '' !== $title ? $title : sprintf( 'WPCode #%d', (int) $id ), + 'code' => $code, + 'type' => $this->derive_type( $code_type ), + 'source_path' => $this->build_source_path( (int) $id ), + 'is_active' => true, + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php b/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php new file mode 100644 index 00000000..296be719 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php @@ -0,0 +1,82 @@ + $row Row returned by the importer. + * + * @return array|null Null when the row should be skipped. + */ + protected function map_row( array $row ): ?array { + $code = (string) ( $row['content'] ?? '' ); + + if ( '' === trim( $code ) ) { + return null; + } + + $id = (int) ( $row['id'] ?? 0 ); + $title = (string) ( $row['title'] ?? '' ); + + return [ + 'name' => '' !== $title ? $title : sprintf( 'Insert PHP #%d', $id ), + 'code' => $code, + 'type' => 'php', + 'source_path' => $this->build_source_path( $id ), + 'is_active' => 1 === (int) ( $row['status'] ?? 0 ), + ]; + } +} diff --git a/tests/phpunit/fakes/Fake_Hfcm_Importer.php b/tests/phpunit/fakes/Fake_Hfcm_Importer.php new file mode 100644 index 00000000..d22ea3d7 --- /dev/null +++ b/tests/phpunit/fakes/Fake_Hfcm_Importer.php @@ -0,0 +1,36 @@ +> + */ + public array $rows = []; + + /** + * {@inheritDoc} + */ + public static function is_active(): bool { + return true; + } + + /** + * Return the canned rows regardless of the requested IDs. + * + * @param array $ids_to_import Unused. + * + * @return array> + */ + public function get_data( array $ids_to_import = [] ): array { + return $this->rows; + } +} diff --git a/tests/phpunit/fakes/Fake_Ihaf_Importer.php b/tests/phpunit/fakes/Fake_Ihaf_Importer.php new file mode 100644 index 00000000..d9d1b599 --- /dev/null +++ b/tests/phpunit/fakes/Fake_Ihaf_Importer.php @@ -0,0 +1,38 @@ +> + */ + public array $rows = []; + + /** + * {@inheritDoc} + */ + public static function is_active(): bool { + return true; + } + + /** + * Return the canned rows regardless of the requested IDs. + * + * @param array $ids_to_import Unused. + * + * @return array> + */ + public function get_data( array $ids_to_import = [] ): array { + return $this->rows; + } +} diff --git a/tests/phpunit/fakes/Fake_Ipcs_Importer.php b/tests/phpunit/fakes/Fake_Ipcs_Importer.php new file mode 100644 index 00000000..354eebba --- /dev/null +++ b/tests/phpunit/fakes/Fake_Ipcs_Importer.php @@ -0,0 +1,36 @@ + + */ + public array $rows = []; + + /** + * {@inheritDoc} + */ + public static function is_active(): bool { + return true; + } + + /** + * Return the canned rows regardless of the requested IDs. + * + * @param array $ids_to_import Unused. + * + * @return array + */ + public function get_data( array $ids_to_import = [] ): array { + return $this->rows; + } +} diff --git a/tests/phpunit/test-db-scanners.php b/tests/phpunit/test-db-scanners.php new file mode 100644 index 00000000..03ff2e5a --- /dev/null +++ b/tests/phpunit/test-db-scanners.php @@ -0,0 +1,173 @@ +rows = [ + [ + 'code' => "console.log('hi');", + 'code_type' => 'js', + 'table_data' => [ + 'id' => 7, + 'title' => 'Hello JS', + ], + ], + [ + 'code' => '// plain php', + 'code_type' => 'php', + 'table_data' => [ + 'id' => 8, + 'title' => 'PHP Helper', + ], + ], + [ + 'code' => '', + 'code_type' => 'text', + 'table_data' => [ + 'id' => 9, + 'title' => 'Unsupported', + ], + ], + [ + 'code' => ' ', + 'code_type' => 'php', + 'table_data' => [ + 'id' => 10, + 'title' => 'Empty', + ], + ], + ]; + + $scanner = new Insert_Headers_And_Footers_Scanner( $importer ); + + $this->assertTrue( $scanner->is_available() ); + $this->assertSame( 'wpcode', $scanner->get_id() ); + + $results = $scanner->scan(); + $this->assertCount( 2, $results ); + + $first = $results[0]; + $this->assertSame( 'Hello JS', $first->name ); + $this->assertSame( 'js', $first->type ); + $this->assertSame( 'plugin', $first->source_type ); + $this->assertSame( 'db://wpcode_snippets/7', $first->source_path ); + $this->assertTrue( $first->is_active ); + $this->assertSame( 'wpcode', $first->scanner_id ); + + $this->assertSame( 'php', $results[1]->type ); + } + + /** + * HFCM adapter reads `snippet`/`script_id`/`status` and builds a proper URI. + */ + public function test_hfcm_adapter_maps_rows_and_active_flag() { + $importer = new Fake_Hfcm_Importer(); + $importer->rows = [ + [ + 'script_id' => 42, + 'name' => 'Analytics Header', + 'snippet' => '', + 'snippet_type' => 'html', + 'status' => 'active', + ], + [ + 'script_id' => 43, + 'name' => 'Disabled Pixel', + 'snippet' => '', + 'snippet_type' => 'html', + 'status' => 'inactive', + ], + [ + 'script_id' => 44, + 'name' => 'Empty', + 'snippet' => '', + 'snippet_type' => 'html', + 'status' => 'active', + ], + ]; + + $scanner = new Header_Footer_Code_Manager_Scanner( $importer ); + $results = $scanner->scan(); + + $this->assertCount( 2, $results ); + + $active = $results[0]; + $this->assertSame( 'Analytics Header', $active->name ); + $this->assertSame( 'html', $active->type ); + $this->assertSame( 'db://hfcm_scripts/42', $active->source_path ); + $this->assertTrue( $active->is_active ); + $this->assertSame( 'hfcm', $active->scanner_id ); + + $this->assertFalse( $results[1]->is_active ); + } + + /** + * IPCS adapter treats status=1 as active and always emits PHP snippets. + */ + public function test_ipcs_adapter_maps_rows_and_risk() { + $importer = new Fake_Ipcs_Importer(); + $importer->rows = [ + (object) [ + 'id' => 5, + 'title' => 'Custom Hook', + 'content' => " 1, + ], + (object) [ + 'id' => 6, + 'title' => '', + 'content' => ' 0, + ], + ]; + + $scanner = new Insert_PHP_Code_Snippet_Scanner( $importer ); + + $this->assertSame( 'high', $scanner->get_risk_level() ); + + $results = $scanner->scan(); + $this->assertCount( 2, $results ); + + $this->assertSame( 'Custom Hook', $results[0]->name ); + $this->assertSame( 'php', $results[0]->type ); + $this->assertSame( 'db://xyz_ips_short_code/5', $results[0]->source_path ); + $this->assertTrue( $results[0]->is_active ); + $this->assertSame( 'high', $results[0]->risk_level ); + + $this->assertSame( 'Insert PHP #6', $results[1]->name ); + $this->assertFalse( $results[1]->is_active ); + } + + /** + * Adapters short-circuit when the source plugin is inactive. + */ + public function test_adapter_returns_empty_when_unavailable() { + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $hfcm = new Header_Footer_Code_Manager_Scanner(); + + $this->assertFalse( $hfcm->is_available() ); + $this->assertSame( [], $hfcm->scan() ); + } +} From 68372bf971371f102dfac95fa835a0c06b6b169b Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Wed, 22 Apr 2026 21:54:46 +0200 Subject: [PATCH 8/9] Unified Snippets: implement Tier 2 DB scanners --- .../Adapters/DB_Scanner_Adapter.php | 20 --- .../Header_Footer_Code_Manager_Scanner.php | 19 --- .../Insert_Headers_And_Footers_Scanner.php | 19 --- .../Insert_PHP_Code_Snippet_Scanner.php | 24 ---- tests/phpunit/test-db-scanners.php | 133 ++++++------------ 5 files changed, 44 insertions(+), 171 deletions(-) diff --git a/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php b/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php index dc7c51c3..3f4b7160 100644 --- a/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php +++ b/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php @@ -23,11 +23,6 @@ abstract class DB_Scanner_Adapter extends Scanner_Base { */ protected Plugin_Importer $importer; - /** - * Class constructor. - * - * @param Plugin_Importer|null $importer Optional importer override, useful for tests. - */ public function __construct( ?Plugin_Importer $importer = null ) { $this->importer = $importer ?? $this->create_importer(); } @@ -57,16 +52,10 @@ abstract protected function get_table_name(): string; */ abstract protected function map_row( array $row ): ?array; - /** - * {@inheritDoc} - */ public function is_available(): bool { return (bool) call_user_func( [ get_class( $this->importer ), 'is_active' ] ); } - /** - * {@inheritDoc} - */ public function scan(): array { if ( ! $this->is_available() ) { return []; @@ -88,13 +77,6 @@ public function scan(): array { return $snippets; } - /** - * Fill in common Discovered_Snippet fields that are shared by all DB-backed adapters. - * - * @param array $fields Adapter-supplied field overrides. - * - * @return array - */ private function with_defaults( array $fields ): array { return array_merge( [ @@ -121,8 +103,6 @@ protected function build_source_path( $id ): string { /** * Derive a {@see Discovered_Snippet} `type` from the source plugin's code-type value. * - * Unknown or empty types fall back to 'php' to satisfy the model's allow-list. - * * @param string $code_type Source-plugin code type, e.g. 'html', 'universal'. * * @return string diff --git a/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php b/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php index 1ff1427f..d42b9998 100644 --- a/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php @@ -17,41 +17,22 @@ */ class Header_Footer_Code_Manager_Scanner extends DB_Scanner_Adapter { - /** - * {@inheritDoc} - */ public function get_id(): string { return 'hfcm'; } - /** - * {@inheritDoc} - */ public function get_label(): string { return __( 'Header Footer Code Manager', 'code-snippets' ); } - /** - * {@inheritDoc} - */ protected function create_importer(): Plugin_Importer { return new Header_Footer_Code_Manager_Plugin_Importer(); } - /** - * {@inheritDoc} - */ protected function get_table_name(): string { return 'hfcm_scripts'; } - /** - * Map an HFCM row into Discovered_Snippet field overrides. - * - * @param array $row Row returned by the importer. - * - * @return array|null Null when the row should be skipped. - */ protected function map_row( array $row ): ?array { $code = (string) ( $row['snippet'] ?? '' ); diff --git a/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php b/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php index bb1aef16..652abf4e 100644 --- a/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php @@ -19,41 +19,22 @@ class Insert_Headers_And_Footers_Scanner extends DB_Scanner_Adapter { private const SUPPORTED_CODE_TYPES = [ 'php', 'css', 'js', 'html', 'universal' ]; - /** - * {@inheritDoc} - */ public function get_id(): string { return 'wpcode'; } - /** - * {@inheritDoc} - */ public function get_label(): string { return __( 'WPCode (Insert Headers and Footers)', 'code-snippets' ); } - /** - * {@inheritDoc} - */ protected function create_importer(): Plugin_Importer { return new Insert_Headers_And_Footers_Plugin_Importer(); } - /** - * {@inheritDoc} - */ protected function get_table_name(): string { return 'wpcode_snippets'; } - /** - * Map a WPCode row into Discovered_Snippet field overrides. - * - * @param array $row Row returned by the importer. - * - * @return array|null Null when the row should be skipped. - */ protected function map_row( array $row ): ?array { $code_type = (string) ( $row['code_type'] ?? '' ); diff --git a/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php b/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php index 296be719..f47a2d90 100644 --- a/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php +++ b/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php @@ -17,50 +17,26 @@ */ class Insert_PHP_Code_Snippet_Scanner extends DB_Scanner_Adapter { - /** - * {@inheritDoc} - */ public function get_id(): string { return 'insert-php-code-snippet'; } - /** - * {@inheritDoc} - */ public function get_label(): string { return __( 'Insert PHP Code Snippet', 'code-snippets' ); } - /** - * Snippets from this source execute PHP, so they carry a higher risk than CSS/HTML sources. - * - * @return string - */ public function get_risk_level(): string { return 'high'; } - /** - * {@inheritDoc} - */ protected function create_importer(): Plugin_Importer { return new Insert_PHP_Code_Snippet_Plugin_Importer(); } - /** - * {@inheritDoc} - */ protected function get_table_name(): string { return 'xyz_ips_short_code'; } - /** - * Map an Insert PHP Code Snippet row into Discovered_Snippet field overrides. - * - * @param array $row Row returned by the importer. - * - * @return array|null Null when the row should be skipped. - */ protected function map_row( array $row ): ?array { $code = (string) ( $row['content'] ?? '' ); diff --git a/tests/phpunit/test-db-scanners.php b/tests/phpunit/test-db-scanners.php index 03ff2e5a..8bee1c43 100644 --- a/tests/phpunit/test-db-scanners.php +++ b/tests/phpunit/test-db-scanners.php @@ -10,156 +10,111 @@ require_once __DIR__ . '/fakes/Fake_Hfcm_Importer.php'; require_once __DIR__ . '/fakes/Fake_Ipcs_Importer.php'; -/** - * Tests for the DB-aware scanner adapters. - * - * @group unified-snippets - */ class DB_Scanners_Test extends TestCase { - /** - * WPCode adapter maps supported code types and skips unsupported ones. - */ - public function test_wpcode_adapter_maps_supported_types() { + public function test_wpcode_filters_unsupported_and_empty() { $importer = new Fake_Ihaf_Importer(); $importer->rows = [ [ - 'code' => "console.log('hi');", - 'code_type' => 'js', + 'code' => "console.log('hi');", + 'code_type' => 'js', 'table_data' => [ - 'id' => 7, - 'title' => 'Hello JS', + 'id' => 1, + 'title' => 'JS', ], ], [ - 'code' => '// plain php', - 'code_type' => 'php', + 'code' => '// php', + 'code_type' => 'php', 'table_data' => [ - 'id' => 8, - 'title' => 'PHP Helper', + 'id' => 2, + 'title' => 'PHP', ], ], [ - 'code' => '', - 'code_type' => 'text', + 'code' => '', + 'code_type' => 'text', 'table_data' => [ - 'id' => 9, + 'id' => 3, 'title' => 'Unsupported', ], ], [ - 'code' => ' ', - 'code_type' => 'php', + 'code' => ' ', + 'code_type' => 'php', 'table_data' => [ - 'id' => 10, - 'title' => 'Empty', + 'id' => 4, + 'title' => 'Blank', ], ], ]; - $scanner = new Insert_Headers_And_Footers_Scanner( $importer ); + $results = ( new Insert_Headers_And_Footers_Scanner( $importer ) )->scan(); - $this->assertTrue( $scanner->is_available() ); - $this->assertSame( 'wpcode', $scanner->get_id() ); - - $results = $scanner->scan(); $this->assertCount( 2, $results ); - - $first = $results[0]; - $this->assertSame( 'Hello JS', $first->name ); - $this->assertSame( 'js', $first->type ); - $this->assertSame( 'plugin', $first->source_type ); - $this->assertSame( 'db://wpcode_snippets/7', $first->source_path ); - $this->assertTrue( $first->is_active ); - $this->assertSame( 'wpcode', $first->scanner_id ); - + $this->assertSame( 'js', $results[0]->type ); $this->assertSame( 'php', $results[1]->type ); } - /** - * HFCM adapter reads `snippet`/`script_id`/`status` and builds a proper URI. - */ - public function test_hfcm_adapter_maps_rows_and_active_flag() { + public function test_hfcm_active_flag_and_empty_skip() { $importer = new Fake_Hfcm_Importer(); $importer->rows = [ [ - 'script_id' => 42, - 'name' => 'Analytics Header', - 'snippet' => '', + 'script_id' => 1, + 'name' => 'On', + 'snippet' => '1', 'snippet_type' => 'html', - 'status' => 'active', + 'status' => 'active', ], [ - 'script_id' => 43, - 'name' => 'Disabled Pixel', - 'snippet' => '', + 'script_id' => 2, + 'name' => 'Off', + 'snippet' => '2', 'snippet_type' => 'html', - 'status' => 'inactive', + 'status' => 'inactive', ], [ - 'script_id' => 44, - 'name' => 'Empty', - 'snippet' => '', + 'script_id' => 3, + 'name' => 'Blank', + 'snippet' => '', 'snippet_type' => 'html', - 'status' => 'active', + 'status' => 'active', ], ]; - $scanner = new Header_Footer_Code_Manager_Scanner( $importer ); - $results = $scanner->scan(); + $results = ( new Header_Footer_Code_Manager_Scanner( $importer ) )->scan(); $this->assertCount( 2, $results ); - - $active = $results[0]; - $this->assertSame( 'Analytics Header', $active->name ); - $this->assertSame( 'html', $active->type ); - $this->assertSame( 'db://hfcm_scripts/42', $active->source_path ); - $this->assertTrue( $active->is_active ); - $this->assertSame( 'hfcm', $active->scanner_id ); - + $this->assertTrue( $results[0]->is_active ); $this->assertFalse( $results[1]->is_active ); } - /** - * IPCS adapter treats status=1 as active and always emits PHP snippets. - */ - public function test_ipcs_adapter_maps_rows_and_risk() { + public function test_ipcs_active_flag_name_fallback_and_risk() { $importer = new Fake_Ipcs_Importer(); $importer->rows = [ (object) [ - 'id' => 5, - 'title' => 'Custom Hook', - 'content' => " 1, + 'id' => 5, + 'title' => 'Custom Hook', + 'content' => ' 1, ], (object) [ - 'id' => 6, - 'title' => '', - 'content' => ' 0, + 'id' => 6, + 'title' => '', + 'content' => ' 0, ], ]; $scanner = new Insert_PHP_Code_Snippet_Scanner( $importer ); - - $this->assertSame( 'high', $scanner->get_risk_level() ); - $results = $scanner->scan(); - $this->assertCount( 2, $results ); - $this->assertSame( 'Custom Hook', $results[0]->name ); - $this->assertSame( 'php', $results[0]->type ); - $this->assertSame( 'db://xyz_ips_short_code/5', $results[0]->source_path ); + $this->assertSame( 'high', $scanner->get_risk_level() ); $this->assertTrue( $results[0]->is_active ); - $this->assertSame( 'high', $results[0]->risk_level ); - - $this->assertSame( 'Insert PHP #6', $results[1]->name ); $this->assertFalse( $results[1]->is_active ); + $this->assertSame( 'Insert PHP #6', $results[1]->name ); } - /** - * Adapters short-circuit when the source plugin is inactive. - */ public function test_adapter_returns_empty_when_unavailable() { if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; From af3823afa87248b2e0825165cc8afb3de621a03d Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Wed, 22 Apr 2026 21:57:57 +0200 Subject: [PATCH 9/9] Unified Snippets: implement Tier 2 DB scanners --- .../Adapters/DB_Scanner_Adapter.php | 18 ----------------- tests/phpunit/fakes/Fake_Hfcm_Importer.php | 20 ------------------- tests/phpunit/fakes/Fake_Ihaf_Importer.php | 20 ------------------- tests/phpunit/fakes/Fake_Ipcs_Importer.php | 18 ----------------- 4 files changed, 76 deletions(-) diff --git a/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php b/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php index 3f4b7160..c429c30e 100644 --- a/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php +++ b/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php @@ -9,36 +9,18 @@ /** * Adapts an existing {@see Plugin_Importer} into a Unified Snippets scanner. * - * Concrete adapters supply the backing importer and a per-row field mapping; - * this base handles availability, iteration, and Discovered_Snippet assembly. - * * @package Code_Snippets */ abstract class DB_Scanner_Adapter extends Scanner_Base { - /** - * Backing plugin importer used to read raw snippet rows. - * - * @var Plugin_Importer - */ protected Plugin_Importer $importer; public function __construct( ?Plugin_Importer $importer = null ) { $this->importer = $importer ?? $this->create_importer(); } - /** - * Create the default importer instance for this adapter. - * - * @return Plugin_Importer - */ abstract protected function create_importer(): Plugin_Importer; - /** - * Name of the source plugin's database table, used to build synthetic URIs. - * - * @return string e.g. 'hfcm_scripts'. - */ abstract protected function get_table_name(): string; /** diff --git a/tests/phpunit/fakes/Fake_Hfcm_Importer.php b/tests/phpunit/fakes/Fake_Hfcm_Importer.php index d22ea3d7..ce71c374 100644 --- a/tests/phpunit/fakes/Fake_Hfcm_Importer.php +++ b/tests/phpunit/fakes/Fake_Hfcm_Importer.php @@ -4,32 +4,12 @@ use Code_Snippets\REST_API\Import\Plugins\Header_Footer_Code_Manager_Plugin_Importer; -/** - * Fake importer for Header Footer Code Manager used in DB-scanner adapter tests. - */ class Fake_Hfcm_Importer extends Header_Footer_Code_Manager_Plugin_Importer { - /** - * Canned data returned by {@see self::get_data()}. - * - * @var array> - */ - public array $rows = []; - - /** - * {@inheritDoc} - */ public static function is_active(): bool { return true; } - /** - * Return the canned rows regardless of the requested IDs. - * - * @param array $ids_to_import Unused. - * - * @return array> - */ public function get_data( array $ids_to_import = [] ): array { return $this->rows; } diff --git a/tests/phpunit/fakes/Fake_Ihaf_Importer.php b/tests/phpunit/fakes/Fake_Ihaf_Importer.php index d9d1b599..f17e709e 100644 --- a/tests/phpunit/fakes/Fake_Ihaf_Importer.php +++ b/tests/phpunit/fakes/Fake_Ihaf_Importer.php @@ -4,34 +4,14 @@ use Code_Snippets\REST_API\Import\Plugins\Insert_Headers_And_Footers_Plugin_Importer; -/** - * Fake importer for WPCode (Insert Headers and Footers) used in DB-scanner adapter tests. - * - * Overrides the two entry points the scanner uses so no plugin/database is required. - */ class Fake_Ihaf_Importer extends Insert_Headers_And_Footers_Plugin_Importer { - /** - * Canned data returned by {@see self::get_data()}. - * - * @var array> - */ public array $rows = []; - /** - * {@inheritDoc} - */ public static function is_active(): bool { return true; } - /** - * Return the canned rows regardless of the requested IDs. - * - * @param array $ids_to_import Unused. - * - * @return array> - */ public function get_data( array $ids_to_import = [] ): array { return $this->rows; } diff --git a/tests/phpunit/fakes/Fake_Ipcs_Importer.php b/tests/phpunit/fakes/Fake_Ipcs_Importer.php index 354eebba..7d999c22 100644 --- a/tests/phpunit/fakes/Fake_Ipcs_Importer.php +++ b/tests/phpunit/fakes/Fake_Ipcs_Importer.php @@ -4,32 +4,14 @@ use Code_Snippets\REST_API\Import\Plugins\Insert_PHP_Code_Snippet_Plugin_Importer; -/** - * Fake importer for Insert PHP Code Snippet used in DB-scanner adapter tests. - */ class Fake_Ipcs_Importer extends Insert_PHP_Code_Snippet_Plugin_Importer { - /** - * Canned data returned by {@see self::get_data()}. - * - * @var array - */ public array $rows = []; - /** - * {@inheritDoc} - */ public static function is_active(): bool { return true; } - /** - * Return the canned rows regardless of the requested IDs. - * - * @param array $ids_to_import Unused. - * - * @return array - */ public function get_data( array $ids_to_import = [] ): array { return $this->rows; }