e33ae4e626
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 9s
Tests / Unit Tests (push) Successful in 9s
Tests / Integration Tests (push) Successful in 31s
Tests / PHP Lint (push) Successful in 8s
Tests / PHPCS (push) Successful in 20s
Tests / PHPStan (push) Successful in 24s
CoreBoundaryTest 靜態掃描核心 126 個生產檔,斷言每一處對 AddOn 類別的 static 呼叫都在同一個函式內有 class_exists() 守衛。上一個 commit 修的 TMDO_Listing_Stats fatal 就是這類缺陷,這個測試讓它不會再回來。 它當場又抓到 3 處同類違規(都是實際會 fatal 的路徑),一併修掉: - admin render_hpct_import():改印 admin notice 並 return - wp tmdo import-hpct:改 WP_CLI::error 明示需要 hivepress-addon - cli-post cleanup-hp-transients 其實早有守衛,是測試的行距啟發式太窄; 判斷範圍改成「同一個函式內」而非固定 12 行 負向驗證:暫時注入一處無守衛呼叫 → 測試如預期失敗;還原後回綠。 同時補完計畫階段 7 PR-I 列的兩個缺漏測試: - 核心 tests/unit/StandardPostInterceptorTest.php(10 tests) - HP AddOn tests/unit/ListingStatsTest.php(9 tests)——AddOn 的 unit bootstrap 先前刻意不載入真實 TMDO_Listing_Stats,改以 TMDO_TEST_SKIP_LISTING_STATS_STUB 常數讓它跳過核心的 stub - 核心 unit bootstrap 補 add_post_meta() stub(flush 路徑用得到) 核心 unit 451 → 587、HP AddOn 145 → 154。
153 lines
4.6 KiB
PHP
153 lines
4.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Architectural guard: core must never hard-depend on an AddOn's classes.
|
|
*
|
|
* The 11 AddOns are optional. A site can run this plugin alone, so every
|
|
* reference core makes to an AddOn-owned class has to sit behind a
|
|
* class_exists() check — otherwise that code path fatals.
|
|
*
|
|
* This caught a real defect: the admin Dashboard tab and two public REST
|
|
* endpoints called TMDO_Listing_Stats (which ships in the HivePress AddOn)
|
|
* unconditionally, so they died with "Class not found" on any plain install.
|
|
*
|
|
* Static analysis on purpose — the alternative, booting core once per AddOn
|
|
* combination, is far more machinery for a weaker signal.
|
|
*/
|
|
class CoreBoundaryTest extends TestCase {
|
|
|
|
/**
|
|
* Classes that are declared by an AddOn, never by core.
|
|
*
|
|
* Kept as prefixes so a newly added TMDO_HivePress_* adapter is covered
|
|
* without touching this list.
|
|
*
|
|
* @var string[]
|
|
*/
|
|
private const ADDON_CLASS_PREFIXES = array(
|
|
'TMDO_HivePress',
|
|
'TMDO_Hivepress',
|
|
'TMDO_HP_',
|
|
'TMDO_HPCT_',
|
|
'TMDO_Listing_',
|
|
'TMDO_Favorites_',
|
|
'TMDO_Messages_',
|
|
'TMDO_Memberships_',
|
|
'TMDO_Requests_',
|
|
'TMDO_Reviews_',
|
|
'TMDO_Statistics_',
|
|
'TMDO_WooCommerce',
|
|
'TMDO_Woocommerce',
|
|
'TMDO_WC_',
|
|
'TMDO_LatePoint',
|
|
'TMDO_Latepoint',
|
|
'TMDO_Infocards',
|
|
'TMDO_Bookings',
|
|
'TMDO_Collab',
|
|
'TMDO_Playlist',
|
|
'TMDO_Quotation',
|
|
'TMDO_Mobile_Bridge',
|
|
'TMDO_Hub_',
|
|
'TMDO_Spoke_',
|
|
'TMDO_Admin_HivePress',
|
|
'TMDO_Admin_WC',
|
|
'TMDO_CLI_HivePress',
|
|
);
|
|
|
|
/**
|
|
* Every core production file, excluding vendor and the test suite itself.
|
|
*
|
|
* @return array<string, array{string}>
|
|
*/
|
|
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
|
|
);
|
|
}
|
|
}
|