# WordPress Content Sync Foundation Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the loadable WordPress plugin foundation for WP Content Sync with tooling, bootstrap, settings, logging, and an admin dashboard shell. **Architecture:** The foundation uses a small bootstrap file at the plugin root and focused PHP classes under `src/`. A service container wires settings, logging, activation hooks, and admin screens while keeping WordPress calls at clear boundaries so unit tests can cover the domain behavior without a full WordPress runtime. **Tech Stack:** PHP 7.4+, WordPress 5.6+, Composer, PHPUnit, PHPStan level 6+, PHP_CodeSniffer with the project `phpcs.xml`, WordPress Coding Standards. --- ## File Structure - Create: `wp-content-sync.php` as the WordPress plugin entrypoint. - Create: `composer.json` for autoloading, dev dependencies, and scripts. - Create: `phpcs.xml` for WordPress-oriented coding standards. - Create: `phpstan.neon` for level 6 static analysis. - Create: `phpunit.xml.dist` for unit test configuration. - Create: `src/Plugin.php` as the lifecycle coordinator. - Create: `src/Container.php` as the lightweight service registry. - Create: `src/Activator.php` to install default options on activation. - Create: `src/Deactivator.php` to perform safe deactivation cleanup. - Create: `src/Settings/Settings.php` as a typed settings value object. - Create: `src/Settings/SettingsRepository.php` as the WordPress option boundary. - Create: `src/Logging/LoggerInterface.php` as the logging boundary. - Create: `src/Logging/OptionLogger.php` as the initial bounded option-backed logger. - Create: `src/Admin/AdminPage.php` as the admin dashboard controller. - Create: `templates/admin/dashboard.php` as the escaped dashboard view. - Create: `tests/bootstrap.php` for PHPUnit bootstrap and WordPress function stubs. - Create: `tests/Unit/SettingsTest.php` for settings defaults and sanitization behavior. - Create: `tests/Unit/ContainerTest.php` for service registration behavior. - Create: `tests/Unit/OptionLoggerTest.php` for log redaction and retention behavior. ## Task 1: Add Composer and Quality Tooling **Files:** - Create: `composer.json` - Create: `phpcs.xml` - Create: `phpstan.neon` - Create: `phpunit.xml.dist` - [ ] **Step 1: Create Composer metadata and scripts** Create `composer.json`: ```json { "name": "ksolo/wp-content-sync", "description": "Bidirectional WordPress content synchronization plugin.", "type": "wordpress-plugin", "license": "GPL-2.0-or-later", "require": { "php": ">=7.4" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.6", "squizlabs/php_codesniffer": "^3.9", "wp-coding-standards/wpcs": "^3.0" }, "autoload": { "psr-4": { "WPContentSync\\": "src/" } }, "autoload-dev": { "psr-4": { "WPContentSync\\Tests\\": "tests/" } }, "scripts": { "validate": "composer validate --strict", "lint": "phpcs", "lint:fix": "phpcbf", "stan": "phpstan analyse", "test": "phpunit" }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true }, "sort-packages": true } } ``` - [ ] **Step 2: Create PHPCS rules** Create `phpcs.xml`: ```xml Custom coding standards for WP Content Sync. wp-content-sync.php src tests vendor/* ``` - [ ] **Step 3: Create PHPStan configuration** Create `phpstan.neon`: ```neon parameters: level: 6 paths: - src - wp-content-sync.php scanFiles: - tests/bootstrap.php bootstrapFiles: - tests/bootstrap.php ``` - [ ] **Step 4: Create PHPUnit configuration** Create `phpunit.xml.dist`: ```xml tests/Unit ``` - [ ] **Step 5: Install dependencies** Run: `composer install` Expected: Composer installs dev dependencies and generates `vendor/autoload.php`. - [ ] **Step 6: Validate Composer metadata** Run: `composer validate --strict` Expected: PASS with no schema or lock warnings after `composer install`. - [ ] **Step 7: Commit** ```bash git add composer.json composer.lock phpcs.xml phpstan.neon phpunit.xml.dist git commit -m "chore: add php quality tooling" ``` ## Task 2: Add PHPUnit Bootstrap and Core Test Scaffolding **Files:** - Create: `tests/bootstrap.php` - Create: `tests/Unit/ContainerTest.php` - Create: `src/Container.php` - [ ] **Step 1: Write the failing container tests** Create `tests/Unit/ContainerTest.php`: ```php set( 'example', $service ); self::assertSame( $service, $container->get( 'example' ) ); } public function test_it_reuses_factory_result(): void { $container = new Container(); $calls = 0; $container->factory( 'example', static function () use ( &$calls ): \stdClass { ++$calls; return new \stdClass(); } ); $first = $container->get( 'example' ); $second = $container->get( 'example' ); self::assertSame( $first, $second ); self::assertSame( 1, $calls ); } public function test_it_throws_for_unknown_service(): void { $container = new Container(); $this->expectException( \InvalidArgumentException::class ); $this->expectExceptionMessage( 'Service "missing" is not registered.' ); $container->get( 'missing' ); } } ``` - [ ] **Step 2: Add test bootstrap with WordPress stubs** Create `tests/bootstrap.php`: ```php */ private array $services = array(); /** * @var array */ private array $factories = array(); public function set( string $id, $service ): void { $this->services[ $id ] = $service; } /** * @param callable(): mixed $factory */ public function factory( string $id, callable $factory ): void { $this->factories[ $id ] = $factory; } public function get( string $id ) { if ( array_key_exists( $id, $this->services ) ) { return $this->services[ $id ]; } if ( array_key_exists( $id, $this->factories ) ) { $this->services[ $id ] = $this->factories[ $id ](); return $this->services[ $id ]; } throw new \InvalidArgumentException( sprintf( 'Service "%s" is not registered.', $id ) ); } } ``` - [ ] **Step 5: Run test to verify it passes** Run: `composer test -- --filter ContainerTest` Expected: PASS with 3 tests. - [ ] **Step 6: Commit** ```bash git add src/Container.php tests/bootstrap.php tests/Unit/ContainerTest.php git commit -m "test: add service container coverage" ``` ## Task 3: Add Settings Value Object and Repository **Files:** - Create: `src/Settings/Settings.php` - Create: `src/Settings/SettingsRepository.php` - Create: `tests/Unit/SettingsTest.php` - [ ] **Step 1: Write failing settings tests** Create `tests/Unit/SettingsTest.php`: ```php syncPairs() ); self::assertSame( 'warning', $settings->loggingLevel() ); self::assertTrue( $settings->automaticUrlReplacementEnabled() ); self::assertSame( 'last_write_wins', $settings->conflictStrategy() ); } public function test_it_sanitizes_scalar_settings(): void { $settings = Settings::fromArray( array( 'logging_level' => 'debug', 'conflict_strategy' => "manual_review\n", 'automatic_url_replacement' => false, ) ); self::assertSame( 'debug', $settings->loggingLevel() ); self::assertSame( 'manual_review', $settings->conflictStrategy() ); self::assertFalse( $settings->automaticUrlReplacementEnabled() ); } public function test_it_rejects_unknown_logging_level(): void { $settings = Settings::fromArray( array( 'logging_level' => 'verbose', ) ); self::assertSame( 'warning', $settings->loggingLevel() ); } public function test_it_serializes_to_array(): void { $settings = Settings::fromArray( array( 'sync_pairs' => array( array( 'name' => 'Staging', 'source_url' => 'https://example.test', 'destination_url' => 'https://staging.example.test', ), ), ) ); self::assertSame( array( 'sync_pairs' => array( array( 'name' => 'Staging', 'source_url' => 'https://example.test', 'destination_url' => 'https://staging.example.test', ), ), 'logging_level' => 'warning', 'automatic_url_replacement' => true, 'conflict_strategy' => 'last_write_wins', ), $settings->toArray() ); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `composer test -- --filter SettingsTest` Expected: FAIL because class `WPContentSync\Settings\Settings` does not exist. - [ ] **Step 3: Implement settings value object** Create `src/Settings/Settings.php`: ```php */ private array $sync_pairs; private string $logging_level; private bool $automatic_url_replacement; private string $conflict_strategy; /** * @param array $sync_pairs */ private function __construct( array $sync_pairs, string $logging_level, bool $automatic_url_replacement, string $conflict_strategy ) { $this->sync_pairs = $sync_pairs; $this->logging_level = $logging_level; $this->automatic_url_replacement = $automatic_url_replacement; $this->conflict_strategy = $conflict_strategy; } /** * @param array $data */ public static function fromArray( array $data ): self { $logging_level = self::sanitizeChoice( $data['logging_level'] ?? 'warning', self::LOGGING_LEVELS, 'warning' ); $conflict_strategy = self::sanitizeChoice( $data['conflict_strategy'] ?? 'last_write_wins', self::CONFLICT_STRATEGIES, 'last_write_wins' ); $automatic_url_replacement = array_key_exists( 'automatic_url_replacement', $data ) ? (bool) $data['automatic_url_replacement'] : true; return new self( self::sanitizeSyncPairs( $data['sync_pairs'] ?? array() ), $logging_level, $automatic_url_replacement, $conflict_strategy ); } /** * @return array */ public function syncPairs(): array { return $this->sync_pairs; } public function loggingLevel(): string { return $this->logging_level; } public function automaticUrlReplacementEnabled(): bool { return $this->automatic_url_replacement; } public function conflictStrategy(): string { return $this->conflict_strategy; } /** * @return array */ public function toArray(): array { return array( 'sync_pairs' => $this->sync_pairs, 'logging_level' => $this->logging_level, 'automatic_url_replacement' => $this->automatic_url_replacement, 'conflict_strategy' => $this->conflict_strategy, ); } /** * @param mixed $value * @param array $allowed */ private static function sanitizeChoice( $value, array $allowed, string $fallback ): string { $sanitized = sanitize_text_field( (string) $value ); return in_array( $sanitized, $allowed, true ) ? $sanitized : $fallback; } /** * @param mixed $pairs * @return array */ private static function sanitizeSyncPairs( $pairs ): array { if ( ! is_array( $pairs ) ) { return array(); } $sanitized = array(); foreach ( $pairs as $pair ) { if ( ! is_array( $pair ) ) { continue; } $name = sanitize_text_field( (string) ( $pair['name'] ?? '' ) ); $source_url = esc_url( (string) ( $pair['source_url'] ?? '' ) ); $destination_url = esc_url( (string) ( $pair['destination_url'] ?? '' ) ); if ( '' === $name || '' === $source_url || '' === $destination_url ) { continue; } $sanitized[] = array( 'name' => $name, 'source_url' => $source_url, 'destination_url' => $destination_url, ); } return $sanitized; } } ``` - [ ] **Step 4: Implement settings repository** Create `src/Settings/SettingsRepository.php`: ```php toArray(), false ); } /** * @param mixed $value * @return array */ public function sanitizeOption( $value ): array { return Settings::fromArray( is_array( $value ) ? $value : array() )->toArray(); } } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `composer test -- --filter SettingsTest` Expected: PASS with 4 tests. - [ ] **Step 6: Commit** ```bash git add src/Settings/Settings.php src/Settings/SettingsRepository.php tests/Unit/SettingsTest.php git commit -m "feat: add typed plugin settings" ``` ## Task 4: Add Option-Backed Logger **Files:** - Create: `src/Logging/LoggerInterface.php` - Create: `src/Logging/OptionLogger.php` - Create: `tests/Unit/OptionLoggerTest.php` - [ ] **Step 1: Write failing logger tests** Create `tests/Unit/OptionLoggerTest.php`: ```php warning( 'Connection failed.', array( 'url' => 'https://example.test' ) ); $entries = get_option( OptionLogger::OPTION_NAME, array() ); self::assertCount( 1, $entries ); self::assertSame( 'warning', $entries[0]['level'] ); self::assertSame( 'Connection failed.', $entries[0]['message'] ); self::assertSame( 'https://example.test', $entries[0]['context']['url'] ); } public function test_it_redacts_sensitive_context_values(): void { $logger = new OptionLogger( 10 ); $logger->error( 'Authentication failed.', array( 'application_password' => 'secret-value', 'token' => 'token-value', 'username' => 'admin', ) ); $entries = get_option( OptionLogger::OPTION_NAME, array() ); self::assertSame( '[redacted]', $entries[0]['context']['application_password'] ); self::assertSame( '[redacted]', $entries[0]['context']['token'] ); self::assertSame( 'admin', $entries[0]['context']['username'] ); } public function test_it_limits_retained_entries(): void { $logger = new OptionLogger( 2 ); $logger->info( 'First' ); $logger->info( 'Second' ); $logger->info( 'Third' ); $entries = get_option( OptionLogger::OPTION_NAME, array() ); self::assertCount( 2, $entries ); self::assertSame( 'Second', $entries[0]['message'] ); self::assertSame( 'Third', $entries[1]['message'] ); } } ``` - [ ] **Step 2: Add option stubs to test bootstrap** Modify `tests/bootstrap.php` by appending: ```php if ( ! function_exists( 'get_option' ) ) { function get_option( $name, $default = false ) { return $GLOBALS['wpcs_test_options'][ $name ] ?? $default; } } if ( ! function_exists( 'update_option' ) ) { function update_option( $name, $value, $autoload = null ) { $GLOBALS['wpcs_test_options'][ $name ] = $value; return true; } } ``` - [ ] **Step 3: Run test to verify it fails** Run: `composer test -- --filter OptionLoggerTest` Expected: FAIL because class `WPContentSync\Logging\OptionLogger` does not exist. - [ ] **Step 4: Implement logger interface** Create `src/Logging/LoggerInterface.php`: ```php $context */ public function error( string $message, array $context = array() ): void; /** * @param array $context */ public function warning( string $message, array $context = array() ): void; /** * @param array $context */ public function info( string $message, array $context = array() ): void; /** * @param array $context */ public function debug( string $message, array $context = array() ): void; } ``` - [ ] **Step 5: Implement option logger** Create `src/Logging/OptionLogger.php`: ```php max_entries = max( 1, $max_entries ); } public function error( string $message, array $context = array() ): void { $this->log( 'error', $message, $context ); } public function warning( string $message, array $context = array() ): void { $this->log( 'warning', $message, $context ); } public function info( string $message, array $context = array() ): void { $this->log( 'info', $message, $context ); } public function debug( string $message, array $context = array() ): void { $this->log( 'debug', $message, $context ); } /** * @param array $context */ private function log( string $level, string $message, array $context ): void { $entries = get_option( self::OPTION_NAME, array() ); $entries = is_array( $entries ) ? $entries : array(); $entries[] = array( 'timestamp' => gmdate( 'c' ), 'level' => $level, 'message' => sanitize_text_field( $message ), 'context' => $this->redactContext( $context ), ); if ( count( $entries ) > $this->max_entries ) { $entries = array_slice( $entries, -1 * $this->max_entries ); } update_option( self::OPTION_NAME, $entries, false ); } /** * @param array $context * @return array */ private function redactContext( array $context ): array { $redacted = array(); foreach ( $context as $key => $value ) { $normalized_key = strtolower( (string) $key ); $redacted[ $key ] = in_array( $normalized_key, self::SENSITIVE_KEYS, true ) ? '[redacted]' : $value; } return $redacted; } } ``` - [ ] **Step 6: Run tests to verify they pass** Run: `composer test -- --filter OptionLoggerTest` Expected: PASS with 3 tests. - [ ] **Step 7: Commit** ```bash git add src/Logging/LoggerInterface.php src/Logging/OptionLogger.php tests/Unit/OptionLoggerTest.php tests/bootstrap.php git commit -m "feat: add bounded option logger" ``` ## Task 5: Add Plugin Bootstrap and Lifecycle Hooks **Files:** - Create: `wp-content-sync.php` - Create: `src/Plugin.php` - Create: `src/Activator.php` - Create: `src/Deactivator.php` - [ ] **Step 1: Create the plugin entrypoint** Create `wp-content-sync.php`: ```php register(); } ); ``` - [ ] **Step 2: Implement activator** Create `src/Activator.php`: ```php toArray(), false ); } } } ``` - [ ] **Step 3: Implement deactivator** Create `src/Deactivator.php`: ```php container = $container; } public static function create(): self { $container = new Container(); $container->factory( SettingsRepository::class, static function (): SettingsRepository { return new SettingsRepository(); } ); $container->factory( LoggerInterface::class, static function (): LoggerInterface { return new OptionLogger(); } ); $container->factory( AdminPage::class, static function () use ( $container ): AdminPage { return new AdminPage( $container->get( SettingsRepository::class ), $container->get( LoggerInterface::class ) ); } ); return new self( $container ); } public function register(): void { $this->container->get( AdminPage::class )->register(); } } ``` - [ ] **Step 5: Add missing transient stub to test bootstrap** Modify `tests/bootstrap.php` by appending: ```php if ( ! function_exists( 'delete_transient' ) ) { function delete_transient( $name ) { unset( $GLOBALS['wpcs_test_transients'][ $name ] ); return true; } } ``` - [ ] **Step 6: Run static checks for lifecycle files** Run: `composer stan` Expected: PASS with no PHPStan errors for `src` and `wp-content-sync.php`. - [ ] **Step 7: Commit** ```bash git add wp-content-sync.php src/Plugin.php src/Activator.php src/Deactivator.php tests/bootstrap.php git commit -m "feat: add plugin bootstrap lifecycle" ``` ## Task 6: Add Admin Dashboard Shell **Files:** - Create: `src/Admin/AdminPage.php` - Create: `templates/admin/dashboard.php` - Modify: `src/Plugin.php` - Modify: `tests/bootstrap.php` - [ ] **Step 1: Implement admin page controller** Create `src/Admin/AdminPage.php`: ```php settings_repository = $settings_repository; $this->logger = $logger; } public function register(): void { add_action( 'admin_menu', array( $this, 'registerMenu' ) ); add_action( 'admin_init', array( $this, 'registerSettings' ) ); } public function registerMenu(): void { add_management_page( __( 'WP Content Sync', 'wp-content-sync' ), __( 'Content Sync', 'wp-content-sync' ), 'manage_options', 'wp-content-sync', array( $this, 'render' ) ); } public function registerSettings(): void { register_setting( 'wpcs_settings', SettingsRepository::OPTION_NAME, array( 'type' => 'array', 'sanitize_callback' => array( $this->settings_repository, 'sanitizeOption' ), 'default' => $this->settings_repository->get()->toArray(), ) ); } public function render(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access WP Content Sync.', 'wp-content-sync' ) ); } $settings = $this->settings_repository->get(); $this->logger->debug( 'Admin dashboard viewed.' ); include WPCS_PLUGIN_DIR . 'templates/admin/dashboard.php'; } } ``` - [ ] **Step 2: Create escaped dashboard template** Create `templates/admin/dashboard.php`: ```php syncPairs() ) ); ?> loggingLevel() ); ?> automaticUrlReplacementEnabled() ? __( 'Enabled', 'wp-content-sync' ) : __( 'Disabled', 'wp-content-sync' ) ); ?> conflictStrategy() ); ?> ``` - [ ] **Step 3: Add admin WordPress stubs for static/unit runs** Modify `tests/bootstrap.php` by appending: ```php if ( ! function_exists( '__' ) ) { function __( $text, $domain = 'default' ) { return $text; } } if ( ! function_exists( 'esc_html__' ) ) { function esc_html__( $text, $domain = 'default' ) { return esc_html( $text ); } } if ( ! function_exists( 'add_action' ) ) { function add_action( $hook_name, $callback ) { $GLOBALS['wpcs_test_actions'][ $hook_name ][] = $callback; return true; } } if ( ! function_exists( 'add_management_page' ) ) { function add_management_page( $page_title, $menu_title, $capability, $menu_slug, $callback ) { $GLOBALS['wpcs_test_admin_pages'][ $menu_slug ] = compact( 'page_title', 'menu_title', 'capability', 'callback' ); return $menu_slug; } } if ( ! function_exists( 'register_setting' ) ) { function register_setting( $option_group, $option_name, $args = array() ) { $GLOBALS['wpcs_test_registered_settings'][ $option_name ] = compact( 'option_group', 'args' ); return true; } } if ( ! function_exists( 'current_user_can' ) ) { function current_user_can( $capability ) { return 'manage_options' === $capability; } } if ( ! function_exists( 'wp_die' ) ) { function wp_die( $message ) { throw new \RuntimeException( (string) $message ); } } ``` - [ ] **Step 4: Run lint and static analysis** Run: `composer lint` Expected: PASS with no PHPCS violations. Run: `composer stan` Expected: PASS with no PHPStan errors. - [ ] **Step 5: Commit** ```bash git add src/Admin/AdminPage.php templates/admin/dashboard.php tests/bootstrap.php git commit -m "feat: add admin dashboard shell" ``` ## Task 7: Run Full Foundation Verification **Files:** - Verify all files created in Tasks 1-6. - [ ] **Step 1: Run Composer validation** Run: `composer validate --strict` Expected: PASS. - [ ] **Step 2: Run PHPCS** Run: `composer lint` Expected: PASS. - [ ] **Step 3: Run PHPStan** Run: `composer stan` Expected: PASS. - [ ] **Step 4: Run PHPUnit** Run: `composer test` Expected: PASS with all unit tests. - [ ] **Step 5: Perform manual WordPress smoke test** Install the plugin directory into a WordPress 5.6+ site and activate WP Content Sync from the Plugins screen. Expected: - Activation completes without fatal errors. - A `Content Sync` item appears under `Tools`. - Opening `Tools > Content Sync` shows the dashboard shell. - The dashboard shows `Configured Sync Pairs` as `0`, `Logging Level` as `warning`, `URL Replacement` as `Enabled`, and `Conflict Strategy` as `last_write_wins`. - [ ] **Step 6: Commit final verification note if code changed during fixes** ```bash git status --short git add . git commit -m "chore: verify plugin foundation" ``` ## Self-Review **Spec coverage in this foundation plan:** - Covers initial admin interface shell, settings persistence, secure defaults, logging boundary, plugin lifecycle, and project tooling. - Defers sync engine, content handlers, URL transformation, REST transport, file transport, conflict resolution, and background progress to later roadmap phases. - Security requirements begin here through capability checks, escaped admin output, sanitized settings, and log redaction. **Placeholder scan:** This plan intentionally avoids placeholder implementation steps. Deferred product areas are listed in the roadmap as later plans rather than vague steps inside this plan. **Type consistency:** `SettingsRepository`, `Settings`, `LoggerInterface`, `OptionLogger`, `AdminPage`, `Container`, `Activator`, `Deactivator`, and `Plugin` names are consistent across tasks.