From 1ae6803d4b41430430f405808d9bc0e5636e23f4 Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Wed, 8 Jul 2026 14:26:24 -0300 Subject: [PATCH 01/10] refactor(logging)!: rename ErrorLogLogger to PhpErrorLogLogger The PSR-3 logger writes to PHP's built-in error_log(); the new name states its destination explicitly. No proxy/alias is kept. BREAKING CHANGE: the @api class Middag\WordPress\Logging\ErrorLogLogger is renamed to Middag\WordPress\Logging\PhpErrorLogLogger with no BC alias. Consumers instantiating the old class must update the reference. --- CLAUDE.md | 2 +- .../{ErrorLogLogger.php => PhpErrorLogLogger.php} | 2 +- ...orLogLoggerTest.php => PhpErrorLogLoggerTest.php} | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) rename src/Logging/{ErrorLogLogger.php => PhpErrorLogLogger.php} (97%) rename tests/Logging/{ErrorLogLoggerTest.php => PhpErrorLogLoggerTest.php} (79%) diff --git a/CLAUDE.md b/CLAUDE.md index 66912f3..614fb59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Some WordPress host surfaces are **intentionally not targeted** by this adapter: | `src/Hook/` | HookRegistrar + `Contract/HookInterface` (requires an explicit, existing hook directory) | | `src/Http/` | `Client/{HttpClient,HttpResponse}` (wp_remote_* with optional mTLS — see below), `Contract/RestControllerInterface`, `Controller/BaseController`, `Response/RestResponse`, `Routing/{Router,RouteRegistrar}`, `Inertia/{InertiaAdapter,PageContractNormalizer}`, `Middleware/AuthMiddleware` (JWT host auth), `Security/CsrfGuard` | | `src/Kernel/` | WpBootstrap (BootstrapInterface), WpComponentContext (HostComponentContextInterface), WpMaintenanceGate, PluginLifecycle, Loader/WpHookfileLoader | -| `src/Logging/` | ErrorLogLogger (PSR-3 → error_log) | +| `src/Logging/` | PhpErrorLogLogger (PSR-3 → error_log) | | `src/Mail/` | WpMailer (framework MailerInterface → wp_mail), EmailSender, EmailTemplate | | `src/Persistence/` | QueryBuilder (WP_Query/wp_posts) | | `src/Privacy/` | PrivacyRegistrar + `Contract/PersonalDataProviderInterface` (WordPress personal-data export/erasure glue) | diff --git a/src/Logging/ErrorLogLogger.php b/src/Logging/PhpErrorLogLogger.php similarity index 97% rename from src/Logging/ErrorLogLogger.php rename to src/Logging/PhpErrorLogLogger.php index 1f41630..b619e0a 100644 --- a/src/Logging/ErrorLogLogger.php +++ b/src/Logging/PhpErrorLogLogger.php @@ -27,7 +27,7 @@ * * @api */ -final class ErrorLogLogger extends AbstractLogger +final class PhpErrorLogLogger extends AbstractLogger { public function __construct( private readonly string $channel = 'middag', diff --git a/tests/Logging/ErrorLogLoggerTest.php b/tests/Logging/PhpErrorLogLoggerTest.php similarity index 79% rename from tests/Logging/ErrorLogLoggerTest.php rename to tests/Logging/PhpErrorLogLoggerTest.php index ccf612f..0b4fda6 100644 --- a/tests/Logging/ErrorLogLoggerTest.php +++ b/tests/Logging/PhpErrorLogLoggerTest.php @@ -12,7 +12,7 @@ namespace Middag\WordPress\Tests\Logging; -use Middag\WordPress\Logging\ErrorLogLogger; +use Middag\WordPress\Logging\PhpErrorLogLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -20,8 +20,8 @@ /** * @internal */ -#[CoversClass(ErrorLogLogger::class)] -final class ErrorLogLoggerTest extends TestCase +#[CoversClass(PhpErrorLogLogger::class)] +final class PhpErrorLogLoggerTest extends TestCase { private string $logFile; @@ -42,7 +42,7 @@ protected function tearDown(): void #[Test] public function interpolatesContextAndAppendsLeftoverAsJson(): void { - (new ErrorLogLogger('adapter'))->warning('user {id} failed', ['id' => 42, 'ip' => '127.0.0.1']); + (new PhpErrorLogLogger('adapter'))->warning('user {id} failed', ['id' => 42, 'ip' => '127.0.0.1']); $line = (string) file_get_contents($this->logFile); self::assertStringContainsString('[adapter.warning] user 42 failed {"ip":"127.0.0.1"}', $line); @@ -51,7 +51,7 @@ public function interpolatesContextAndAppendsLeftoverAsJson(): void #[Test] public function defaultChannelAndLevelPrefixTheLine(): void { - (new ErrorLogLogger())->error('boom'); + (new PhpErrorLogLogger())->error('boom'); self::assertStringContainsString('[middag.error] boom', (string) file_get_contents($this->logFile)); } @@ -59,7 +59,7 @@ public function defaultChannelAndLevelPrefixTheLine(): void #[Test] public function nonScalarInterpolationFallsBackToJson(): void { - (new ErrorLogLogger())->info('payload {data}', ['data' => ['a' => 1]]); + (new PhpErrorLogLogger())->info('payload {data}', ['data' => ['a' => 1]]); self::assertStringContainsString('payload {"a":1}', (string) file_get_contents($this->logFile)); } From 6d7e2250d2650f6821ceec33c91de6a1c932d3cd Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Wed, 8 Jul 2026 14:31:48 -0300 Subject: [PATCH 02/10] refactor(settings): move FieldType into Settings\Enum namespace Relocate the FieldType enum to Middag\WordPress\Settings\Enum, matching the existing Enum-subfolder convention (cf. Framework Database\Enum\Capability). Update the DSL (Field), renderer (FieldRenderer) and all Settings tests to import from the new namespace. No BC alias for the moved symbol (no-alias phase). --- src/Settings/{ => Enum}/FieldType.php | 3 ++- src/Settings/Field.php | 2 ++ src/Settings/FieldRenderer.php | 1 + tests/Settings/FieldRendererTest.php | 2 +- tests/Settings/FieldTest.php | 2 +- tests/Settings/FieldTypeTest.php | 2 +- tests/Settings/SectionTest.php | 2 +- tests/Settings/SettingsPageRegistrarTest.php | 2 +- 8 files changed, 10 insertions(+), 6 deletions(-) rename src/Settings/{ => Enum}/FieldType.php (96%) diff --git a/src/Settings/FieldType.php b/src/Settings/Enum/FieldType.php similarity index 96% rename from src/Settings/FieldType.php rename to src/Settings/Enum/FieldType.php index 226bd94..a0bea3c 100644 --- a/src/Settings/FieldType.php +++ b/src/Settings/Enum/FieldType.php @@ -10,8 +10,9 @@ * @license Apache-2.0 */ -namespace Middag\WordPress\Settings; +namespace Middag\WordPress\Settings\Enum; +use Middag\WordPress\Settings\Field; use Middag\WordPress\Support\SanitizeSupport; /** diff --git a/src/Settings/Field.php b/src/Settings/Field.php index 8acff2e..bd2c32c 100644 --- a/src/Settings/Field.php +++ b/src/Settings/Field.php @@ -12,6 +12,8 @@ namespace Middag\WordPress\Settings; +use Middag\WordPress\Settings\Enum\FieldType; + /** * Declarative description of one admin settings field: the persisted option * name, the control to render and how to sanitize it on save. diff --git a/src/Settings/FieldRenderer.php b/src/Settings/FieldRenderer.php index e9d5f34..a15f785 100644 --- a/src/Settings/FieldRenderer.php +++ b/src/Settings/FieldRenderer.php @@ -13,6 +13,7 @@ namespace Middag\WordPress\Settings; use Middag\WordPress\Exception\SettingsRenderException; +use Middag\WordPress\Settings\Enum\FieldType; use Middag\WordPress\Support\EscapeSupport; use Middag\WordPress\Support\OptionSupport; diff --git a/tests/Settings/FieldRendererTest.php b/tests/Settings/FieldRendererTest.php index bc18036..426b9db 100644 --- a/tests/Settings/FieldRendererTest.php +++ b/tests/Settings/FieldRendererTest.php @@ -13,9 +13,9 @@ namespace Middag\WordPress\Tests\Settings; use InvalidArgumentException; +use Middag\WordPress\Settings\Enum\FieldType; use Middag\WordPress\Settings\Field; use Middag\WordPress\Settings\FieldRenderer; -use Middag\WordPress\Settings\FieldType; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/tests/Settings/FieldTest.php b/tests/Settings/FieldTest.php index 66d7da3..e673578 100644 --- a/tests/Settings/FieldTest.php +++ b/tests/Settings/FieldTest.php @@ -12,8 +12,8 @@ namespace Middag\WordPress\Tests\Settings; +use Middag\WordPress\Settings\Enum\FieldType; use Middag\WordPress\Settings\Field; -use Middag\WordPress\Settings\FieldType; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/tests/Settings/FieldTypeTest.php b/tests/Settings/FieldTypeTest.php index 7a38095..623577a 100644 --- a/tests/Settings/FieldTypeTest.php +++ b/tests/Settings/FieldTypeTest.php @@ -12,7 +12,7 @@ namespace Middag\WordPress\Tests\Settings; -use Middag\WordPress\Settings\FieldType; +use Middag\WordPress\Settings\Enum\FieldType; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/tests/Settings/SectionTest.php b/tests/Settings/SectionTest.php index b5c0867..aaae87d 100644 --- a/tests/Settings/SectionTest.php +++ b/tests/Settings/SectionTest.php @@ -12,8 +12,8 @@ namespace Middag\WordPress\Tests\Settings; +use Middag\WordPress\Settings\Enum\FieldType; use Middag\WordPress\Settings\Field; -use Middag\WordPress\Settings\FieldType; use Middag\WordPress\Settings\Section; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Settings/SettingsPageRegistrarTest.php b/tests/Settings/SettingsPageRegistrarTest.php index 655237c..737031a 100644 --- a/tests/Settings/SettingsPageRegistrarTest.php +++ b/tests/Settings/SettingsPageRegistrarTest.php @@ -12,8 +12,8 @@ namespace Middag\WordPress\Tests\Settings; +use Middag\WordPress\Settings\Enum\FieldType; use Middag\WordPress\Settings\Field; -use Middag\WordPress\Settings\FieldType; use Middag\WordPress\Settings\Section; use Middag\WordPress\Settings\SettingsPageRegistrar; use Middag\WordPress\Settings\SettingsRegistrar; From c7cc37c8009d4c42719aca6cfee8fa201c482881 Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Wed, 8 Jul 2026 14:36:34 -0300 Subject: [PATCH 03/10] refactor(inertia)!: drop PageContractNormalizer, accept only canonical contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the PageContractNormalizer class and the automatic normalization call in InertiaAdapter::resolveProps(). The adapter now forwards the `contract` prop verbatim and accepts ONLY the canonical @middag-io/react PageContract; legacy contract shapes are no longer silently migrated to the new schema. The normalization is not moved to a helper — the intent is to eliminate silent acceptance of the old schema. A legacy contract is forwarded unchanged so the frontend rejects it, instead of being rewritten on the server. Tests: delete PageContractNormalizerTest; update the coverage test to assert canonical passthrough and add a case proving a legacy contract is NOT silently normalized (admin/badge/currentPage/emptyMessage survive verbatim). BREAKING CHANGE: Http\Inertia\PageContractNormalizer is removed and InertiaAdapter no longer normalizes the `contract` prop. Callers must pass the canonical PageContract shape directly. --- CLAUDE.md | 2 +- src/Http/Inertia/InertiaAdapter.php | 9 +- src/Http/Inertia/PageContractNormalizer.php | 173 -------- .../Inertia/InertiaAdapterCoverageTest.php | 46 +- .../Inertia/PageContractNormalizerTest.php | 418 ------------------ 5 files changed, 48 insertions(+), 600 deletions(-) delete mode 100644 src/Http/Inertia/PageContractNormalizer.php delete mode 100644 tests/Http/Inertia/PageContractNormalizerTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 614fb59..5fbc760 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Some WordPress host surfaces are **intentionally not targeted** by this adapter: | `src/Exception/` | Adapter-specific exception hierarchy for hooks, settings rendering, database failures | | `src/Filesystem/` | WpUploadsFilesystem (framework FilesystemInterface → uploads dir, via LocalFilesystem) | | `src/Hook/` | HookRegistrar + `Contract/HookInterface` (requires an explicit, existing hook directory) | -| `src/Http/` | `Client/{HttpClient,HttpResponse}` (wp_remote_* with optional mTLS — see below), `Contract/RestControllerInterface`, `Controller/BaseController`, `Response/RestResponse`, `Routing/{Router,RouteRegistrar}`, `Inertia/{InertiaAdapter,PageContractNormalizer}`, `Middleware/AuthMiddleware` (JWT host auth), `Security/CsrfGuard` | +| `src/Http/` | `Client/{HttpClient,HttpResponse}` (wp_remote_* with optional mTLS — see below), `Contract/RestControllerInterface`, `Controller/BaseController`, `Response/RestResponse`, `Routing/{Router,RouteRegistrar}`, `Inertia/InertiaAdapter`, `Middleware/AuthMiddleware` (JWT host auth), `Security/CsrfGuard` | | `src/Kernel/` | WpBootstrap (BootstrapInterface), WpComponentContext (HostComponentContextInterface), WpMaintenanceGate, PluginLifecycle, Loader/WpHookfileLoader | | `src/Logging/` | PhpErrorLogLogger (PSR-3 → error_log) | | `src/Mail/` | WpMailer (framework MailerInterface → wp_mail), EmailSender, EmailTemplate | diff --git a/src/Http/Inertia/InertiaAdapter.php b/src/Http/Inertia/InertiaAdapter.php index 0ff2127..b1a6ead 100644 --- a/src/Http/Inertia/InertiaAdapter.php +++ b/src/Http/Inertia/InertiaAdapter.php @@ -116,11 +116,10 @@ private static function resolveProps(array $props): array $resolved[$key] = $value instanceof Closure ? $value() : $value; } - // Auto-normalize PageContract to @middag-io/react v0.15+ schema - if (isset($resolved['contract']) && is_array($resolved['contract'])) { - $resolved['contract'] = PageContractNormalizer::normalize($resolved['contract']); - } - + // Props (including any `contract`) pass through verbatim. The adapter + // accepts ONLY the canonical @middag-io/react PageContract and performs + // no schema migration: a legacy contract is forwarded unchanged so the + // frontend rejects it, rather than being silently rewritten here. return $resolved; } diff --git a/src/Http/Inertia/PageContractNormalizer.php b/src/Http/Inertia/PageContractNormalizer.php deleted file mode 100644 index e33e841..0000000 --- a/src/Http/Inertia/PageContractNormalizer.php +++ /dev/null @@ -1,173 +0,0 @@ - - * @copyright 2026 MIDDAG (https://middag.io) - * @license Apache-2.0 - */ - -namespace Middag\WordPress\Http\Inertia; - -/** - * Normalizes legacy PageContract arrays to the @middag-io/react v0.15+ schema. - * - * Converts the old PHP controller format to the canonical PageContract shape - * expected by ContractPage on the frontend. - * - * Usage in controllers: - * $contract = PageContractNormalizer::normalize($legacyContract); - * InertiaAdapter::render('Page', ['contract' => $contract]); - * - * Schema migrations applied: - * - shell: "admin" -> "product" - * - column type: "badge" -> variant: "status" (with statusMap from badgeVariants) - * - column type: "date"/"currency" -> variant: "timestamp"/"text" - * - pagination: currentPage -> page, pages -> lastPage - * - emptyMessage -> emptyState { title, description } - * - searchable/searchPlaceholder preserved in table data - * - * @api - */ -final class PageContractNormalizer -{ - /** - * Normalize a legacy contract array to canonical schema. - * - * @param array $contract Legacy PageContract array - * - * @return array Normalized PageContract array - */ - public static function normalize(array $contract): array - { - // Shell normalization - if (($contract['shell'] ?? '') === 'admin') { - $contract['shell'] = 'product'; - } - - // Layout regions — normalize blocks - if (isset($contract['layout']['regions'])) { - foreach ($contract['layout']['regions'] as &$blocks) { - if (!is_array($blocks)) { - continue; - } - foreach ($blocks as &$block) { - $block = self::normalizeBlock($block); - } - } - unset($blocks, $block); - } - - return $contract; - } - - /** - * Normalize a single block descriptor. - * - * @param array $block - * - * @return array - */ - private static function normalizeBlock(array $block): array - { - if (($block['type'] ?? '') === 'dense_table' && isset($block['data']) && is_array($block['data'])) { - $block['data'] = self::normalizeDenseTable($block['data']); - } - - return $block; - } - - /** - * Normalize dense_table block data. - * - * @param array $data - * - * @return array - */ - private static function normalizeDenseTable(array $data): array - { - // Columns: type+badgeVariants -> variant+statusMap - if (isset($data['columns']) && is_array($data['columns'])) { - foreach ($data['columns'] as &$col) { - if (is_array($col)) { - $col = self::normalizeColumn($col); - } - } - unset($col); - } - - // Pagination: currentPage->page, pages->lastPage - if (isset($data['pagination']) && is_array($data['pagination'])) { - $pag = &$data['pagination']; - if (isset($pag['currentPage']) && !isset($pag['page'])) { - $pag['page'] = $pag['currentPage']; - unset($pag['currentPage']); - } - if (isset($pag['pages']) && !isset($pag['lastPage'])) { - $pag['lastPage'] = $pag['pages']; - unset($pag['pages']); - } - unset($pag); - } - - // emptyMessage -> emptyState - if (isset($data['emptyMessage']) && !isset($data['emptyState'])) { - $data['emptyState'] = [ - 'title' => $data['emptyMessage'], - 'description' => $data['emptyMessage'], - ]; - unset($data['emptyMessage']); - } - - // Ensure filters structure - if (!isset($data['filters'])) { - $data['filters'] = ['available' => [], 'applied' => (object) []]; - } - - // Ensure sort structure - if (!isset($data['sort'])) { - $data['sort'] = ['column' => null, 'direction' => null]; - } - - return $data; - } - - /** - * Normalize a column definition. - * - * type: "badge" + badgeVariants -> variant: "status" + statusMap - * type: "date" -> variant: "timestamp" - * type: "currency" -> variant: "text" (formatted server-side) - * - * @param array $col - * - * @return array - */ - private static function normalizeColumn(array $col): array - { - $type = $col['type'] ?? null; - - if ($type === 'badge') { - $col['variant'] = 'status'; - if (isset($col['badgeVariants'])) { - $col['statusMap'] = $col['badgeVariants']; - unset($col['badgeVariants']); - } - unset($col['type']); - } elseif ($type === 'date') { - $col['variant'] = 'timestamp'; - unset($col['type']); - } elseif ($type === 'currency') { - $col['variant'] = 'text'; - unset($col['type']); - } elseif ($type !== null && !isset($col['variant'])) { - $col['variant'] = $type; - unset($col['type']); - } - - return $col; - } -} diff --git a/tests/Http/Inertia/InertiaAdapterCoverageTest.php b/tests/Http/Inertia/InertiaAdapterCoverageTest.php index 9fe354b..d1a2690 100644 --- a/tests/Http/Inertia/InertiaAdapterCoverageTest.php +++ b/tests/Http/Inertia/InertiaAdapterCoverageTest.php @@ -63,22 +63,62 @@ public function renderEmitsTheMountNodeForANonInertiaRequest(): void } #[Test] - public function renderResolvesClosurePropsAndNormalizesContract(): void + public function renderResolvesClosurePropsAndForwardsCanonicalContractVerbatim(): void { InertiaAdapter::share('shared', 'S'); ob_start(); InertiaAdapter::render('Page', [ 'lazy' => static fn (): string => 'lazy-value', - 'contract' => ['shell' => 'admin'], + 'contract' => ['shell' => 'product', 'layout' => ['regions' => []]], ]); $html = (string) ob_get_clean(); self::assertStringContainsString('lazy-value', $html); - // The 'admin' shell is normalized to 'product' by PageContractNormalizer. + // The canonical contract is emitted verbatim: no schema rewriting. self::assertStringContainsString('product', $html); } + #[Test] + public function renderDoesNotSilentlyNormalizeALegacyContract(): void + { + ob_start(); + InertiaAdapter::render('Page', [ + 'contract' => [ + 'shell' => 'admin', + 'layout' => [ + 'regions' => [ + 'main' => [ + [ + 'type' => 'dense_table', + 'data' => [ + 'columns' => [ + ['key' => 'state', 'type' => 'badge', 'badgeVariants' => ['on' => 'success']], + ], + 'pagination' => ['currentPage' => 1, 'pages' => 4], + 'emptyMessage' => 'Empty', + ], + ], + ], + ], + ], + ], + ]); + $html = (string) ob_get_clean(); + + // The legacy contract is forwarded unchanged: the old auto-migration + // (shell admin->product, badge->status/statusMap, currentPage->page, + // emptyMessage->emptyState) no longer runs. The frontend rejects the + // legacy shape instead of it being silently accepted here. + self::assertStringContainsString('admin', $html); + self::assertStringContainsString('badgeVariants', $html); + self::assertStringContainsString('currentPage', $html); + self::assertStringContainsString('emptyMessage', $html); + self::assertStringNotContainsString('product', $html); + self::assertStringNotContainsString('statusMap', $html); + self::assertStringNotContainsString('emptyState', $html); + } + #[Test] public function isInertiaRequestDetectsTheHeaderFlag(): void { diff --git a/tests/Http/Inertia/PageContractNormalizerTest.php b/tests/Http/Inertia/PageContractNormalizerTest.php deleted file mode 100644 index 0a12362..0000000 --- a/tests/Http/Inertia/PageContractNormalizerTest.php +++ /dev/null @@ -1,418 +0,0 @@ - - * @copyright 2026 MIDDAG (https://middag.io) - * @license Apache-2.0 - */ - -namespace Middag\WordPress\Tests\Http\Inertia; - -use Middag\WordPress\Http\Inertia\PageContractNormalizer; -use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -use stdClass; - -/** - * Branch coverage for {@see PageContractNormalizer::normalize()} and its private - * helpers (block / dense-table / column normalization). The target is pure array - * transformation with no WordPress runtime dependency, so no stubs are exercised. - * - * @internal - */ -#[CoversClass(PageContractNormalizer::class)] -final class PageContractNormalizerTest extends TestCase -{ - // ─── Shell normalization ──────────────────────────────────────────────── - - #[Test] - public function adminShellIsRewrittenToProduct(): void - { - $result = PageContractNormalizer::normalize(['shell' => 'admin']); - - self::assertSame('product', $result['shell']); - } - - #[Test] - public function nonAdminShellIsLeftUntouched(): void - { - $result = PageContractNormalizer::normalize(['shell' => 'product']); - - self::assertSame('product', $result['shell']); - } - - #[Test] - public function missingShellIsNotAdded(): void - { - $result = PageContractNormalizer::normalize(['title' => 'x']); - - self::assertArrayNotHasKey('shell', $result); - } - - // ─── Layout / region traversal ────────────────────────────────────────── - - #[Test] - public function contractWithoutLayoutRegionsIsReturnedAsIs(): void - { - $contract = ['shell' => 'admin', 'layout' => ['title' => 'no regions']]; - - $result = PageContractNormalizer::normalize($contract); - - self::assertSame('product', $result['shell']); - self::assertSame(['title' => 'no regions'], $result['layout']); - } - - #[Test] - public function nonArrayRegionEntriesAreSkipped(): void - { - $contract = [ - 'layout' => [ - 'regions' => [ - 'sidebar' => 'not-an-array', - 'footer' => 42, - ], - ], - ]; - - $result = PageContractNormalizer::normalize($contract); - - self::assertSame('not-an-array', $result['layout']['regions']['sidebar']); - self::assertSame(42, $result['layout']['regions']['footer']); - } - - // ─── Block normalization ──────────────────────────────────────────────── - - #[Test] - public function nonDenseTableBlockIsLeftUnchanged(): void - { - $block = ['type' => 'hero', 'data' => ['heading' => 'Hi']]; - - self::assertSame($block, $this->firstBlock($this->normalizeBlock($block))); - } - - #[Test] - public function denseTableBlockWithoutDataIsLeftUnchanged(): void - { - $block = ['type' => 'dense_table']; - - self::assertSame($block, $this->firstBlock($this->normalizeBlock($block))); - } - - #[Test] - public function denseTableBlockWithNonArrayDataIsLeftUnchanged(): void - { - $block = ['type' => 'dense_table', 'data' => 'oops']; - - self::assertSame($block, $this->firstBlock($this->normalizeBlock($block))); - } - - #[Test] - public function blockWithoutTypeKeyIsLeftUnchanged(): void - { - $block = ['data' => ['columns' => []]]; - - self::assertSame($block, $this->firstBlock($this->normalizeBlock($block))); - } - - // ─── Column normalization ─────────────────────────────────────────────── - - #[Test] - public function badgeColumnWithVariantsBecomesStatusWithStatusMap(): void - { - $data = $this->denseTableData([ - 'columns' => [ - ['key' => 'state', 'type' => 'badge', 'badgeVariants' => ['on' => 'success', 'off' => 'muted']], - ], - ]); - - $col = $this->normalizeData($data)['columns'][0]; - - self::assertSame('status', $col['variant']); - self::assertSame(['on' => 'success', 'off' => 'muted'], $col['statusMap']); - self::assertArrayNotHasKey('type', $col); - self::assertArrayNotHasKey('badgeVariants', $col); - } - - #[Test] - public function badgeColumnWithoutVariantsBecomesStatusWithoutStatusMap(): void - { - $data = $this->denseTableData(['columns' => [['type' => 'badge']]]); - - $col = $this->normalizeData($data)['columns'][0]; - - self::assertSame('status', $col['variant']); - self::assertArrayNotHasKey('statusMap', $col); - self::assertArrayNotHasKey('type', $col); - } - - #[Test] - public function dateColumnBecomesTimestampVariant(): void - { - $data = $this->denseTableData(['columns' => [['type' => 'date']]]); - - $col = $this->normalizeData($data)['columns'][0]; - - self::assertSame('timestamp', $col['variant']); - self::assertArrayNotHasKey('type', $col); - } - - #[Test] - public function currencyColumnBecomesTextVariant(): void - { - $data = $this->denseTableData(['columns' => [['type' => 'currency']]]); - - $col = $this->normalizeData($data)['columns'][0]; - - self::assertSame('text', $col['variant']); - self::assertArrayNotHasKey('type', $col); - } - - #[Test] - public function unknownTypeWithoutVariantIsPromotedToVariant(): void - { - $data = $this->denseTableData(['columns' => [['type' => 'number']]]); - - $col = $this->normalizeData($data)['columns'][0]; - - self::assertSame('number', $col['variant']); - self::assertArrayNotHasKey('type', $col); - } - - #[Test] - public function unknownTypeWithExistingVariantIsLeftUntouched(): void - { - $data = $this->denseTableData(['columns' => [['type' => 'number', 'variant' => 'custom']]]); - - $col = $this->normalizeData($data)['columns'][0]; - - // Neither branch fires: type is kept and the pre-set variant is preserved. - self::assertSame('number', $col['type']); - self::assertSame('custom', $col['variant']); - } - - #[Test] - public function columnWithoutTypeIsLeftUntouched(): void - { - $data = $this->denseTableData(['columns' => [['key' => 'label', 'header' => 'Label']]]); - - $col = $this->normalizeData($data)['columns'][0]; - - self::assertSame(['key' => 'label', 'header' => 'Label'], $col); - } - - #[Test] - public function nonArrayColumnEntriesAreSkipped(): void - { - $data = $this->denseTableData(['columns' => ['scalar-col', ['type' => 'date']]]); - - $columns = $this->normalizeData($data)['columns']; - - self::assertSame('scalar-col', $columns[0]); - self::assertSame('timestamp', $columns[1]['variant']); - } - - #[Test] - public function nonArrayColumnsContainerIsSkipped(): void - { - $data = $this->denseTableData(['columns' => 'not-an-array']); - - self::assertSame('not-an-array', $this->normalizeData($data)['columns']); - } - - // ─── Pagination normalization ─────────────────────────────────────────── - - #[Test] - public function paginationCurrentPageAndPagesAreMigrated(): void - { - $data = $this->denseTableData(['pagination' => ['currentPage' => 3, 'pages' => 9, 'total' => 90]]); - - $pag = $this->normalizeData($data)['pagination']; - - self::assertSame(3, $pag['page']); - self::assertSame(9, $pag['lastPage']); - self::assertSame(90, $pag['total']); - self::assertArrayNotHasKey('currentPage', $pag); - self::assertArrayNotHasKey('pages', $pag); - } - - #[Test] - public function paginationAlreadyCanonicalIsPreserved(): void - { - $data = $this->denseTableData([ - 'pagination' => ['currentPage' => 3, 'page' => 1, 'pages' => 9, 'lastPage' => 2], - ]); - - $pag = $this->normalizeData($data)['pagination']; - - // Canonical keys already present -> legacy keys are NOT migrated over them. - self::assertSame(1, $pag['page']); - self::assertSame(2, $pag['lastPage']); - self::assertSame(3, $pag['currentPage']); - self::assertSame(9, $pag['pages']); - } - - #[Test] - public function nonArrayPaginationIsSkipped(): void - { - $data = $this->denseTableData(['pagination' => 'nope']); - - self::assertSame('nope', $this->normalizeData($data)['pagination']); - } - - // ─── emptyMessage → emptyState ────────────────────────────────────────── - - #[Test] - public function emptyMessageIsExpandedIntoEmptyState(): void - { - $data = $this->denseTableData(['emptyMessage' => 'Nothing found']); - - $normalized = $this->normalizeData($data); - - self::assertSame( - ['title' => 'Nothing found', 'description' => 'Nothing found'], - $normalized['emptyState'], - ); - self::assertArrayNotHasKey('emptyMessage', $normalized); - } - - #[Test] - public function emptyMessageIsPreservedWhenEmptyStateAlreadyPresent(): void - { - $data = $this->denseTableData([ - 'emptyMessage' => 'legacy', - 'emptyState' => ['title' => 'Kept', 'description' => 'Kept desc'], - ]); - - $normalized = $this->normalizeData($data); - - self::assertSame(['title' => 'Kept', 'description' => 'Kept desc'], $normalized['emptyState']); - self::assertSame('legacy', $normalized['emptyMessage']); - } - - // ─── filters / sort defaults ──────────────────────────────────────────── - - #[Test] - public function filtersAndSortDefaultsAreAddedWhenMissing(): void - { - $normalized = $this->normalizeData($this->denseTableData([])); - - self::assertSame([], $normalized['filters']['available']); - self::assertInstanceOf(stdClass::class, $normalized['filters']['applied']); - self::assertEquals(new stdClass(), $normalized['filters']['applied']); - self::assertSame(['column' => null, 'direction' => null], $normalized['sort']); - } - - #[Test] - public function existingFiltersAndSortAreNotOverwritten(): void - { - $data = $this->denseTableData([ - 'filters' => ['available' => ['status'], 'applied' => ['status' => 'on']], - 'sort' => ['column' => 'name', 'direction' => 'asc'], - ]); - - $normalized = $this->normalizeData($data); - - self::assertSame(['available' => ['status'], 'applied' => ['status' => 'on']], $normalized['filters']); - self::assertSame(['column' => 'name', 'direction' => 'asc'], $normalized['sort']); - } - - // ─── Integration: full contract through the public entry point ─────────── - - #[Test] - public function fullContractIsNormalizedEndToEnd(): void - { - $contract = [ - 'shell' => 'admin', - 'layout' => [ - 'regions' => [ - 'main' => [ - [ - 'type' => 'dense_table', - 'data' => [ - 'columns' => [ - ['type' => 'badge', 'badgeVariants' => ['a' => 'success']], - ['type' => 'date'], - ], - 'pagination' => ['currentPage' => 1, 'pages' => 4], - 'emptyMessage' => 'Empty', - ], - ], - ], - 'aside' => 'skipme', - ], - ], - ]; - - $result = PageContractNormalizer::normalize($contract); - - self::assertSame('product', $result['shell']); - self::assertSame('skipme', $result['layout']['regions']['aside']); - - $data = $result['layout']['regions']['main'][0]['data']; - self::assertSame('status', $data['columns'][0]['variant']); - self::assertSame('timestamp', $data['columns'][1]['variant']); - self::assertSame(1, $data['pagination']['page']); - self::assertSame(4, $data['pagination']['lastPage']); - self::assertSame('Empty', $data['emptyState']['title']); - self::assertArrayHasKey('filters', $data); - self::assertArrayHasKey('sort', $data); - } - - // ─── Helpers ───────────────────────────────────────────────────────────── - - /** - * Wrap a single block in a minimal contract, normalize it, and return the - * whole normalized contract (region traversal exercises normalizeBlock()). - * - * @param array $block - * - * @return array - */ - private function normalizeBlock(array $block): array - { - return PageContractNormalizer::normalize([ - 'layout' => ['regions' => ['main' => [$block]]], - ]); - } - - /** - * Extract the first (only) block from a normalized single-block contract. - * - * @param array $normalized - * - * @return array - */ - private function firstBlock(array $normalized): array - { - return $normalized['layout']['regions']['main'][0]; - } - - /** - * Build a dense_table block wrapping the given table data. - * - * @param array $data - * - * @return array - */ - private function denseTableData(array $data): array - { - return ['type' => 'dense_table', 'data' => $data]; - } - - /** - * Normalize a dense_table block and return its (normalized) `data` payload. - * - * @param array $block - * - * @return array - */ - private function normalizeData(array $block): array - { - return $this->firstBlock($this->normalizeBlock($block))['data']; - } -} From c5f23e4cc87fc1f5cebef35ea6910f94f36dff97 Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Wed, 8 Jul 2026 16:15:06 -0300 Subject: [PATCH 04/10] chore(deps): drop v prefix from framework constraint --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c3f4899..d85d765 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "php": "^8.2", "ext-json": "*", "firebase/php-jwt": "^7.0", - "middag-io/framework": "^v1.2.1", + "middag-io/framework": "^1.2.1", "psr/container": "^2.0", "psr/log": "^3.0", "symfony/dependency-injection": "^7.0" From d33332d656acb82231446d56c7504e76df09547d Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Wed, 8 Jul 2026 16:15:17 -0300 Subject: [PATCH 05/10] docs: retire blocked-work backlog The blocked items (coverage guards, exit seams, R1 roadmap) are tracked in the workspace tracker now; git history keeps the full details. --- BACKLOG.md | 65 ------------------------------------------------------ 1 file changed, 65 deletions(-) delete mode 100644 BACKLOG.md diff --git a/BACKLOG.md b/BACKLOG.md deleted file mode 100644 index 85b56e2..0000000 --- a/BACKLOG.md +++ /dev/null @@ -1,65 +0,0 @@ -# BACKLOG — middag-io/wordpress - -**Blocked work only.** This file holds items that **cannot be done in the -normal development flow** — they need infrastructure or a decision that does not -exist yet. Anything doable in the flow is done, not parked here. Relocated from -the monorepo quality-gate tracker so the tracker can be retired. - -Suite is green (676 tests) at **~92% line coverage**. Every remaining sub-80% -class is blocked for one of the two reasons below. - ---- - -## Blocked: coverage of the `function_exists` no-op guards (needs new test infra) - -The thin static seams over WordPress functions each have one uncovered branch: -the `\function_exists('wp_*') → return …;` no-op guard (the path taken when the -host WordPress function is absent). The suite loads the WP function stubs -**globally** (`tests/stubs/wp-stubs.php`), so the function-absent branch can -never be reached in-process. - -Covering it is **blocked on test infrastructure we don't have**: a per-function -stub-skip under `#[RunInSeparateProcess]`, or a `runkit`/`uopz` extension to -undefine the stub. It is a harness capability, not a missing assertion. - -Affected: `Support/{PostTypeSupport, ShortcodeSupport, CronSupport, -LifecycleSupport, MetaSupport, OptionSupport, PathSupport, PrivacySupport, -SettingsSupport, AssetSupport, UserSupport, SecuritySupport, CacheSupport, -HookSupport}`. - -## Blocked: coverage of the terminating `exit` paths (needs a testable seam) - -Two request-terminating paths end in `exit`, so PHPUnit cannot drive them and -assert afterwards (even under process isolation the `exit` ends the child before -the result is recorded). The source docblocks already flag these as the -intentionally-untested exit paths: - -- `Http/Inertia/InertiaAdapter::sendJson()` and `::location()` (headers + `echo` - + `exit`; plus the private `isPartialReload()`/`getPartialData()` reachable - only through `sendJson()`). -- `Http/Security/CsrfGuard::reject()` (403 envelope + `exit`). - -Unblocking would require refactoring the terminate step behind an injectable -seam (e.g. a `Terminator` callable) — a source change, out of scope for a -coverage pass. - -## Blocked on a consumer: R1 feature roadmap (was QG-WP-05) - -These WordPress capabilities have **no consumer and therefore no code**. They -cannot be built in this flow — each needs a real host-plugin requirement/spec -first, which does not exist yet: - -- `WooCommerce/` integration -- OAuth2 + JWT in `Http/Client` -- Inbound webhook handling -- `Database/Schema` `dbDelta` migrations -- Generic EAV -- `Admin/` screens -- `Output/` + rate limiting + host event bridge -- Rewrite rules, Blocks (Gutenberg), Site Health, Media library, Multisite -- `AssetSupport` register/localize/inline enqueue helpers -- `WP_Query` auditing -- PSR-16 transients cache - -Non-target-by-design surfaces (widgets / `admin-ajax.php`) are a scope decision, -documented in `CLAUDE.md` — not backlog. From 49e630b508f1a1636f9652842f7b133142311e41 Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Wed, 8 Jul 2026 17:25:10 -0300 Subject: [PATCH 06/10] docs(logging): position channel factory as canonical path, add channel test PhpErrorLogLogger is the zero-dependency error_log fallback, not the canonical logging path. The canonical WooCommerce-like channel path is the framework LoggerFactory::forChannel(module, channel) primed once at boot via LogSupport::primeFromContainer(). Docblocks on both sides now say so, and a new test proves a custom (module, channel) tuple flows from the factory to the rotating file handler's on-disk destination. No code path removed (LB-WP-STRUCT-01 rescope). --- src/Logging/PhpErrorLogLogger.php | 13 +++++++-- src/Support/LogSupport.php | 8 ++++++ tests/Support/LogSupportTest.php | 46 +++++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/Logging/PhpErrorLogLogger.php b/src/Logging/PhpErrorLogLogger.php index b619e0a..8342aa5 100644 --- a/src/Logging/PhpErrorLogLogger.php +++ b/src/Logging/PhpErrorLogLogger.php @@ -17,11 +17,18 @@ use Stringable; /** - * PSR-3 logger that writes to the PHP error log — the lowest common - * denominator available on every WordPress host (surfaces in `debug.log` when - * `WP_DEBUG_LOG` is on). Bind it to {@see LogSupport} + * Zero-dependency PSR-3 fallback that writes to the PHP error log — the + * lowest common denominator available on every WordPress host (surfaces in + * `debug.log` when `WP_DEBUG_LOG` is on). Bind it to {@see LogSupport} * or the container when no richer logger (Monolog, WC_Logger) is wired. * + * This is NOT the canonical logging path. The canonical, WooCommerce-like + * channel path is the framework `LoggerFactory::forChannel(module, channel)` + * (Monolog + rotating file handler), primed once at boot via + * {@see LogSupport::primeFromContainer()}. Host adapters extend that same + * base by choosing their `(module, channel)` destinations; this class only + * covers hosts where no factory is wired. + * * Context values are interpolated into `{placeholders}` per PSR-3; leftover * context is appended as JSON so no data is silently dropped. * diff --git a/src/Support/LogSupport.php b/src/Support/LogSupport.php index c96c1ec..53e2ba3 100644 --- a/src/Support/LogSupport.php +++ b/src/Support/LogSupport.php @@ -13,6 +13,7 @@ namespace Middag\WordPress\Support; use Middag\Framework\Logging\LoggerFactory; +use Middag\WordPress\Logging\PhpErrorLogLogger; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Stringable; @@ -70,6 +71,13 @@ public static function getLogger(): ?LoggerInterface * No-op when already primed or when the container exposes no LoggerFactory * (the `error_log()` fallback then stays active). Returns whether a logger is * wired afterwards. + * + * This is the canonical, WooCommerce-like channel path: the `(module, + * channel)` tuple selects the on-disk destination of the framework's + * rotating file handler, and consumers/adapters pick their own tuple over + * the same base (a zero-dep `error_log` fallback such as + * {@see PhpErrorLogLogger} exists only for hosts + * where no factory is wired). */ public static function primeFromContainer(ContainerInterface $container, string $module = 'wordpress', string $channel = 'adapter'): bool { diff --git a/tests/Support/LogSupportTest.php b/tests/Support/LogSupportTest.php index ba51a98..908eb49 100644 --- a/tests/Support/LogSupportTest.php +++ b/tests/Support/LogSupportTest.php @@ -192,6 +192,41 @@ public function primeFromContainerIsIdempotentAndKeepsAnAlreadyWiredLogger(): vo self::assertSame($spy, LogSupport::getLogger()); } + #[Test] + public function primeFromContainerFlowsACustomChannelToTheFileHandler(): void + { + self::assertNull(LogSupport::getLogger()); + + $base = sys_get_temp_dir() . '/middag-logsupport-channel-' . uniqid(); + $factory = $this->loggerFactory(basePath: $base, enabled: true); + + $primed = LogSupport::primeFromContainer( + $this->container([LoggerFactory::class => $factory]), + module: 'clientx', + channel: 'payments', + ); + + self::assertTrue($primed); + + LogSupport::error('channel-routing-proof'); + + // The (module, channel) tuple drives the on-disk path of the framework + // RotatingStreamHandler: {base}/{module}/{channel}/*.log. + $files = glob($base . '/clientx/payments/*.log') ?: []; + self::assertNotSame([], $files, 'custom channel did not reach the file handler'); + + $written = implode('', array_map(static fn (string $file): string => (string) file_get_contents($file), $files)); + self::assertStringContainsString('channel-routing-proof', $written); + + foreach ($files as $file) { + @unlink($file); + } + + @rmdir($base . '/clientx/payments'); + @rmdir($base . '/clientx'); + @rmdir($base); + } + /** * @param array $services */ @@ -216,11 +251,12 @@ public function get(string $id): object } /** - * A real, disabled LoggerFactory (forChannel() yields a NullLogger) wired - * with trivial actor/origin resolvers — enough to prove the resolution path - * without writing log files. + * A real LoggerFactory wired with trivial actor/origin resolvers. Disabled + * by default (forChannel() yields a NullLogger — enough to prove the + * resolution path without writing log files); enable it to exercise the + * real file handler. */ - private function loggerFactory(): LoggerFactory + private function loggerFactory(?string $basePath = null, bool $enabled = false): LoggerFactory { $resolver = new class implements ActorResolverInterface, OriginResolverInterface { public function resolve(): string @@ -229,7 +265,7 @@ public function resolve(): string } }; - return new LoggerFactory(sys_get_temp_dir(), $resolver, $resolver, false); + return new LoggerFactory($basePath ?? sys_get_temp_dir(), $resolver, $resolver, $enabled); } /** From ab937598d6581e529e7df801d2ebefb89e4541fb Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Wed, 8 Jul 2026 18:08:38 -0300 Subject: [PATCH 07/10] chore(deps): raise framework floor to ^1.5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d85d765..97ba610 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "php": "^8.2", "ext-json": "*", "firebase/php-jwt": "^7.0", - "middag-io/framework": "^1.2.1", + "middag-io/framework": "^1.5", "psr/container": "^2.0", "psr/log": "^3.0", "symfony/dependency-injection": "^7.0" From f02321ed59ed94803121bc95e7a27fa7d4ca824b Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Wed, 8 Jul 2026 23:19:41 -0300 Subject: [PATCH 08/10] feat!: adopt PascalCase enum cases from middag-io/framework + guard test Apply D-ENUM-CASING (2026-07-08): this lib's own enum (FieldType) already conformed; the change updates the two call-sites of the renamed framework Database Capability cases (TRANSACTIONS -> Transactions, ...) and adds tests/Architecture/EnumCasePascalCaseTest.php guarding every enum under src/ (PascalCase + at-least-one-lowercase, rejects ALLCAPS). BREAKING CHANGE: requires middag-io/framework with PascalCase enum cases. --- src/Database/WpdbConnectionAdapter.php | 14 +-- tests/Architecture/EnumCasePascalCaseTest.php | 96 +++++++++++++++++++ tests/Database/WpdbConnectionAdapterTest.php | 14 +-- 3 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 tests/Architecture/EnumCasePascalCaseTest.php diff --git a/src/Database/WpdbConnectionAdapter.php b/src/Database/WpdbConnectionAdapter.php index 7d31696..c7f69a4 100644 --- a/src/Database/WpdbConnectionAdapter.php +++ b/src/Database/WpdbConnectionAdapter.php @@ -54,19 +54,19 @@ public function supports(Capability $feature): bool return match ($feature) { // $wpdb has START TRANSACTION / COMMIT / ROLLBACK; only meaningful on // InnoDB. We report true and leave engine choice to the schema layer. - Capability::TRANSACTIONS => true, + Capability::Transactions => true, // No native unbuffered cursor; fetchAll buffers the whole result set. - Capability::STREAMING => false, + Capability::Streaming => false, // MySQL 5.7+/8.0 supports JSON predicates; modern WP baselines do. - Capability::JSON_WHERE => true, + Capability::JsonWhere => true, // MySQL has no RETURNING. - Capability::RETURNING => false, + Capability::Returning => false, // INSERT ... ON DUPLICATE KEY UPDATE. - Capability::UPSERT => true, + Capability::Upsert => true, // WordPress owns its own schema lifecycle via dbDelta(); no diffing. - Capability::SCHEMA_DIFF => false, + Capability::SchemaDiff => false, // InnoDB supports SELECT ... FOR UPDATE / FOR SHARE. - Capability::ROW_LOCK => true, + Capability::RowLock => true, }; } diff --git a/tests/Architecture/EnumCasePascalCaseTest.php b/tests/Architecture/EnumCasePascalCaseTest.php new file mode 100644 index 0000000..5082524 --- /dev/null +++ b/tests/Architecture/EnumCasePascalCaseTest.php @@ -0,0 +1,96 @@ + + * @copyright 2026 MIDDAG (https://middag.io) + * @license Apache-2.0 + */ + +namespace Middag\WordPress\Tests\Architecture; + +use FilesystemIterator; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use ReflectionEnum; + +/** + * Architecture guard (LB-ENUM-01, D-ENUM-CASING): every enum case in src/ + * MUST be strict PascalCase (PER-CS 2.0) — starts uppercase, alphanumeric + * only, and contains at least one lowercase letter (rejects ALLCAPS such as + * `TEXT`/`NA` as well as snake_case and lowercase names). Backed values are + * free-form; only the case NAME is constrained. + * + * @internal + */ +#[CoversNothing] +final class EnumCasePascalCaseTest extends TestCase +{ + private const PASCAL_CASE = '/^[A-Z][A-Za-z0-9]*$/'; + + #[Test] + #[DataProvider('enumProvider')] + public function enumCasesArePascalCase(string $enumClass): void + { + foreach ((new ReflectionEnum($enumClass))->getCases() as $case) { + $name = $case->getName(); + + $this->assertMatchesRegularExpression( + self::PASCAL_CASE, + $name, + sprintf('%s::%s must be PascalCase (no underscores, starts uppercase).', $enumClass, $name) + ); + $this->assertMatchesRegularExpression( + '/[a-z]/', + $name, + sprintf('%s::%s must contain a lowercase letter (ALLCAPS is not PascalCase).', $enumClass, $name) + ); + } + } + + /** + * Every enum declared under src/. Files are pre-filtered textually so the + * provider never autoloads host-only classes that fatal outside the host. + * + * @return array + */ + public static function enumProvider(): array + { + $src = dirname(__DIR__, 2) . '/src'; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), + ); + + $enums = []; + + foreach ($iterator as $file) { + if (!$file->isFile()) { + continue; + } + if ($file->getExtension() !== 'php') { + continue; + } + $contents = (string) file_get_contents($file->getPathname()); + if (preg_match('/^\s*enum\s+\w+/m', $contents) !== 1) { + continue; + } + $relative = substr($file->getPathname(), \strlen($src) + 1, -\strlen('.php')); + $fqcn = 'Middag\WordPress\\' . str_replace('/', '\\', $relative); + + if (enum_exists($fqcn)) { + $enums[$fqcn] = [$fqcn]; + } + } + + ksort($enums); + + return $enums; + } +} diff --git a/tests/Database/WpdbConnectionAdapterTest.php b/tests/Database/WpdbConnectionAdapterTest.php index aa29f42..7359cc5 100644 --- a/tests/Database/WpdbConnectionAdapterTest.php +++ b/tests/Database/WpdbConnectionAdapterTest.php @@ -40,13 +40,13 @@ protected function setUp(): void #[Test] public function supportsReportsTheMysqlCapabilityMatrix(): void { - self::assertTrue($this->adapter->supports(Capability::TRANSACTIONS)); - self::assertTrue($this->adapter->supports(Capability::JSON_WHERE)); - self::assertTrue($this->adapter->supports(Capability::UPSERT)); - self::assertTrue($this->adapter->supports(Capability::ROW_LOCK)); - self::assertFalse($this->adapter->supports(Capability::STREAMING)); - self::assertFalse($this->adapter->supports(Capability::RETURNING)); - self::assertFalse($this->adapter->supports(Capability::SCHEMA_DIFF)); + self::assertTrue($this->adapter->supports(Capability::Transactions)); + self::assertTrue($this->adapter->supports(Capability::JsonWhere)); + self::assertTrue($this->adapter->supports(Capability::Upsert)); + self::assertTrue($this->adapter->supports(Capability::RowLock)); + self::assertFalse($this->adapter->supports(Capability::Streaming)); + self::assertFalse($this->adapter->supports(Capability::Returning)); + self::assertFalse($this->adapter->supports(Capability::SchemaDiff)); } #[Test] From 481ef1e7928821ace6c199904fb767719eddd740 Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Thu, 9 Jul 2026 01:33:51 -0300 Subject: [PATCH 09/10] chore(deps): raise framework floor to ^1.6 The adapter references the PascalCase enum cases (D-ENUM-CASING) that shipped in framework 1.6.0; older minors miss the renamed cases and fail at constant resolution. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 97ba610..7e7acd1 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "php": "^8.2", "ext-json": "*", "firebase/php-jwt": "^7.0", - "middag-io/framework": "^1.5", + "middag-io/framework": "^1.6", "psr/container": "^2.0", "psr/log": "^3.0", "symfony/dependency-injection": "^7.0" From 4e0e0216003444beb95e23ad8c3b22f6b347f1de Mon Sep 17 00:00:00 2001 From: Michael Meneses Date: Thu, 9 Jul 2026 01:33:52 -0300 Subject: [PATCH 10/10] chore: release 1.3.0 Release-As: 1.3.0