*/ public static function coreFileProvider(): array { $root = dirname( __DIR__, 2 ); $files = array(); foreach ( array( 'includes', 'admin', 'cli', 'modules' ) as $dir ) { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root . '/' . $dir, FilesystemIterator::SKIP_DOTS ) ); foreach ( $iterator as $file ) { if ( 'php' === $file->getExtension() ) { $rel = substr( $file->getPathname(), strlen( $root ) + 1 ); $files[ $rel ] = array( $file->getPathname() ); } } } ksort( $files ); return $files; } /** * @dataProvider coreFileProvider * * @param string $path Absolute path to a core production file. */ public function test_addon_classes_are_only_used_behind_a_guard( string $path ): void { $source = file_get_contents( $path ); $this->assertIsString( $source, "Unreadable: {$path}" ); $lines = explode( "\n", $source ); $offences = array(); foreach ( $lines as $i => $line ) { $code = $this->strip_comments_and_strings( $line ); foreach ( self::ADDON_CLASS_PREFIXES as $prefix ) { // Only static calls / constant reads bind at runtime; a bare // mention (e.g. inside an array of names to probe) does not. if ( ! preg_match( '/\b(' . preg_quote( $prefix, '/' ) . '\w*)::/', $code, $m ) ) { continue; } if ( $this->is_guarded( $lines, $i, $m[1] ) ) { continue; } $offences[] = sprintf( '%s:%d — %s', basename( $path ), $i + 1, trim( $line ) ); } } $this->assertSame( array(), $offences, "Core calls an AddOn class without a class_exists() guard:\n" . implode( "\n", $offences ) ); } /** * Remove line comments and string literals so mentions inside them are ignored. */ private function strip_comments_and_strings( string $line ): string { $line = preg_replace( '#(//|\*|\#).*$#', '', $line ) ?? $line; $line = preg_replace( "/'[^']*'/", "''", $line ) ?? $line; return preg_replace( '/"[^"]*"/', '""', $line ) ?? $line; } /** * A guard counts if class_exists() for the same class appears earlier in * the same function (or, for top-level code, earlier in the file). * * Function scope rather than a fixed line window: an early-return guard at * the top of a 60-line CLI command legitimately protects every call below * it, and a window tight enough to be meaningful would reject that. * * @param string[] $lines Whole file, split on newlines. * @param int $index Zero-based line index of the call site. * @param string $class Class name being called. */ private function is_guarded( array $lines, int $index, string $class ): bool { $from = 0; for ( $i = $index; $i >= 0; $i-- ) { if ( preg_match( '/^\t{1,2}(?:(?:public|private|protected|static|final|abstract)\s+)*function\s/', $lines[ $i ] ) ) { $from = $i; break; } } $slice = implode( "\n", array_slice( $lines, $from, $index - $from + 1 ) ); return (bool) preg_match( '/class_exists\(\s*[\'"]' . preg_quote( $class, '/' ) . '[\'"]/', $slice ); } }