From d2698fd12b4d027a7392facfe22782f0e4b493aa Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Fri, 24 Jul 2026 19:22:40 +0530 Subject: [PATCH 1/5] feat(contracts): add AbstractJob for Action Scheduler-backed background jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts a background-job contract into wp-framework per rtCamp/wp-devtools#12. Action Scheduler is never a dependency of this package — every scheduling method guards on is_available() and no-ops when the library isn't loaded. register_hooks() always attaches a plain WordPress action, so handle() runs synchronously via do_action() even without Action Scheduler installed. PHPStan resolves the as_*() functions via a hand-written analysis-only stub (.stubs/action-scheduler.stub); tests exercise both branches deterministically against fakes in tests/Fixtures/ActionSchedulerFakes.php rather than the real library, since the real dependency belongs on the consuming plugin, not here. --- .stubs/action-scheduler.stub | 70 +++++++ inc/Contracts/Abstracts/AbstractJob.php | 196 ++++++++++++++++++ phpstan.neon.dist | 9 + tests/Contracts/Abstracts/AbstractJobTest.php | 194 +++++++++++++++++ tests/Fixtures/ActionSchedulerFakes.php | 173 ++++++++++++++++ tests/bootstrap.php | 4 + 6 files changed, 646 insertions(+) create mode 100644 .stubs/action-scheduler.stub create mode 100644 inc/Contracts/Abstracts/AbstractJob.php create mode 100644 tests/Contracts/Abstracts/AbstractJobTest.php create mode 100644 tests/Fixtures/ActionSchedulerFakes.php diff --git a/.stubs/action-scheduler.stub b/.stubs/action-scheduler.stub new file mode 100644 index 0000000..eaf8b64 --- /dev/null +++ b/.stubs/action-scheduler.stub @@ -0,0 +1,70 @@ + $args Arguments to pass when the hook triggers. + * @param string $group The group to assign this job to. + * @param bool $unique Whether the action should be unique. + * @param int $priority Lower values take precedence. + * + * @return int The action ID. Zero on error. + */ +function as_enqueue_async_action( string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int {} + +/** + * @param int $timestamp When the job will run. + * @param string $hook The hook to trigger. + * @param array $args Arguments to pass when the hook triggers. + * @param string $group The group to assign this job to. + * @param bool $unique Whether the action should be unique. + * @param int $priority Lower values take precedence. + * + * @return int The action ID. Zero on error. + */ +function as_schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int {} + +/** + * @param int $timestamp When the first instance will run. + * @param int $interval_in_seconds How long to wait between runs. + * @param string $hook The hook to trigger. + * @param array $args Arguments to pass when the hook triggers. + * @param string $group The group to assign this job to. + * @param bool $unique Whether the action should be unique. + * @param int $priority Lower values take precedence. + * + * @return int The action ID. Zero on error. + */ +function as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int {} + +/** + * @param string $hook Name of the hook to search for. + * @param array|null $args Arguments to match. + * @param string $group Group of the action to match. + */ +function as_has_scheduled_action( string $hook, ?array $args = null, string $group = '' ): bool {} + +/** + * @param string $hook The hook that the job will trigger. + * @param array $args Args that would have been passed to the job. + * @param string $group The group the job is assigned to. + * + * @return int|null The scheduled action ID if found, or null. + */ +function as_unschedule_action( string $hook, array $args = [], string $group = '' ): ?int {} diff --git a/inc/Contracts/Abstracts/AbstractJob.php b/inc/Contracts/Abstracts/AbstractJob.php new file mode 100644 index 0000000..4e5229c --- /dev/null +++ b/inc/Contracts/Abstracts/AbstractJob.php @@ -0,0 +1,196 @@ +handle( $args ); + }, + $this->get_priority(), + 1 + ); + } + + /** + * Whether Action Scheduler is loaded and its data store is ready. + * + * @return bool + */ + public static function is_available(): bool { + return class_exists( \ActionScheduler::class, false ) && \ActionScheduler::is_initialized(); + } + + /** + * Enqueues the job to run as soon as possible. + * + * @param array $args Arguments passed to {@see handle()}. + * + * @return int|null The action ID, or null when Action Scheduler is unavailable. + */ + public function schedule_async( array $args = [] ): ?int { + if ( ! static::is_available() ) { + return null; + } + + $id = as_enqueue_async_action( static::get_hook(), [ $args ], $this->get_group(), $this->is_unique() ); + + return $id > 0 ? $id : null; + } + + /** + * Schedules the job to run once, at a given time. + * + * @param int $timestamp Unix timestamp to run at. + * @param array $args Arguments passed to {@see handle()}. + * + * @return int|null The action ID, or null when Action Scheduler is unavailable. + */ + public function schedule_at( int $timestamp, array $args = [] ): ?int { + if ( ! static::is_available() ) { + return null; + } + + $id = as_schedule_single_action( $timestamp, static::get_hook(), [ $args ], $this->get_group(), $this->is_unique() ); + + return $id > 0 ? $id : null; + } + + /** + * Schedules the job to run repeatedly. + * + * @param int $timestamp When the first run happens. + * @param int $interval_in_seconds How long to wait between runs. + * @param array $args Arguments passed to {@see handle()}. + * + * @return int|null The action ID, or null when Action Scheduler is unavailable. + */ + public function schedule_recurring( int $timestamp, int $interval_in_seconds, array $args = [] ): ?int { + if ( ! static::is_available() ) { + return null; + } + + $id = as_schedule_recurring_action( $timestamp, $interval_in_seconds, static::get_hook(), [ $args ], $this->get_group(), $this->is_unique() ); + + return $id > 0 ? $id : null; + } + + /** + * Whether a matching pending or running action is already scheduled. + * + * @param array $args Arguments to match. + * + * @return bool + */ + public function is_scheduled( array $args = [] ): bool { + if ( ! static::is_available() ) { + return false; + } + + return as_has_scheduled_action( static::get_hook(), [ $args ], $this->get_group() ); + } + + /** + * Cancels the next matching pending occurrence, if any. + * + * @param array $args Arguments to match. + * + * @return int|null The cancelled action ID, or null when none matched or Action Scheduler is unavailable. + */ + public function unschedule( array $args = [] ): ?int { + if ( ! static::is_available() ) { + return null; + } + + return as_unschedule_action( static::get_hook(), [ $args ], $this->get_group() ); + } + + /** + * Return the WordPress action hook this job runs on. + * + * @return string e.g. "my-plugin/send-welcome-email". + */ + abstract public static function get_hook(): string; + + /** + * Do the actual work. + * + * @param array $args The arguments the job was scheduled with. + * + * @return void + */ + abstract protected function handle( array $args ): void; + + /** + * Return the Action Scheduler group this job's actions belong to. + * + * Empty by default — override to group related jobs for bulk + * management/inspection. + * + * @return string + */ + protected function get_group(): string { + return ''; + } + + /** + * Return the hook priority `register_hooks()` registers `handle()` at. + * + * @return int + */ + protected function get_priority(): int { + return 10; + } + + /** + * Whether a scheduled action should be unique. + * + * When true, Action Scheduler skips scheduling if a pending or running + * action already exists with the same hook and group. + * + * @return bool + */ + protected function is_unique(): bool { + return false; + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 08fc030..0971e9c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,15 @@ parameters: paths: - inc + # Action Scheduler is not a dependency of this package (see AbstractJob), so + # declare its symbols here to keep the guarded calls resolvable on every + # machine. scanFiles (not stubFiles) is required: these symbols exist nowhere + # PHPStan can reflect, and stubFiles only overrides types for already-known + # ones. This also avoids inline ignores that would go unmatched wherever the + # library *is* installed (reportUnmatchedIgnoredErrors is on). + scanFiles: + - .stubs/action-scheduler.stub + excludePaths: - vendor - node_modules (?) diff --git a/tests/Contracts/Abstracts/AbstractJobTest.php b/tests/Contracts/Abstracts/AbstractJobTest.php new file mode 100644 index 0000000..5afda48 --- /dev/null +++ b/tests/Contracts/Abstracts/AbstractJobTest.php @@ -0,0 +1,194 @@ +log?->append( $args ); + } + + protected function get_group(): string { + return $this->group; + } + + protected function get_priority(): int { + return $this->priority; + } + + protected function is_unique(): bool { + return $this->unique; + } + }; + } + + public function test_implements_registrable(): void { + $this->assertInstanceOf( Registrable::class, $this->make_job() ); + } + + public function test_register_hooks_adds_action_on_get_hook_with_declared_priority_and_one_accepted_arg(): void { + global $wp_filter; + + $job = $this->make_job( null, '', 20 ); + $job->register_hooks(); + + $hook = $job::get_hook(); + + $this->assertArrayHasKey( $hook, $wp_filter ); + $this->assertArrayHasKey( 20, $wp_filter[ $hook ]->callbacks ); + + $registered = array_values( $wp_filter[ $hook ]->callbacks[20] ); + $this->assertCount( 1, $registered ); + $this->assertSame( 1, $registered[0]['accepted_args'] ); + } + + public function test_firing_the_hook_invokes_handle_with_the_scheduled_args(): void { + $log = new \ArrayObject(); + $job = $this->make_job( $log ); + $job->register_hooks(); + + // A plain do_action() call — no Action Scheduler involved — proves handle() + // runs synchronously off the registered hook regardless of whether + // Action Scheduler is present at all. + do_action( $job::get_hook(), [ 'foo' => 'bar' ] ); + + $this->assertSame( [ [ 'foo' => 'bar' ] ], $log->getArrayCopy() ); + } + + public function test_is_available_true_by_default(): void { + $this->assertTrue( AbstractJob::is_available() ); + } + + public function test_is_available_reflects_action_scheduler_initialization_state(): void { + \ActionScheduler::$initialized = false; + + $this->assertFalse( AbstractJob::is_available() ); + } + + public function test_schedule_async_enqueues_and_returns_action_id(): void { + $job = $this->make_job(); + $id = $job->schedule_async( [ 'foo' => 'bar' ] ); + + $this->assertIsInt( $id ); + $this->assertGreaterThan( 0, $id ); + $this->assertTrue( $job->is_scheduled( [ 'foo' => 'bar' ] ) ); + } + + public function test_schedule_at_and_schedule_recurring_pass_timestamp_and_interval_through(): void { + $job = $this->make_job(); + $at_timestamp = time() + HOUR_IN_SECONDS; + $recurring_start = time() + 2 * HOUR_IN_SECONDS; + + $single_id = $job->schedule_at( $at_timestamp, [ 'x' => 1 ] ); + $this->assertIsInt( $single_id ); + $this->assertGreaterThan( 0, $single_id ); + $this->assertSame( $at_timestamp, as_next_scheduled_action( $job::get_hook(), [ [ 'x' => 1 ] ] ) ); + + $recurring_id = $job->schedule_recurring( $recurring_start, HOUR_IN_SECONDS, [ 'y' => 2 ] ); + $this->assertIsInt( $recurring_id ); + $this->assertGreaterThan( 0, $recurring_id ); + $this->assertSame( $recurring_start, as_next_scheduled_action( $job::get_hook(), [ [ 'y' => 2 ] ] ) ); + } + + public function test_is_scheduled_and_unschedule_round_trip(): void { + $job = $this->make_job(); + $this->assertFalse( $job->is_scheduled( [ 'z' => 3 ] ) ); + + $id = $job->schedule_at( time() + HOUR_IN_SECONDS, [ 'z' => 3 ] ); + $this->assertIsInt( $id ); + $this->assertTrue( $job->is_scheduled( [ 'z' => 3 ] ) ); + + $cancelled_id = $job->unschedule( [ 'z' => 3 ] ); + $this->assertSame( $id, $cancelled_id ); + $this->assertFalse( $job->is_scheduled( [ 'z' => 3 ] ) ); + } + + public function test_group_and_uniqueness_overrides_propagate(): void { + $job = $this->make_job( null, 'test-group', 10, true ); + + $first_id = $job->schedule_at( time() + HOUR_IN_SECONDS, [ 'w' => 1 ] ); + $this->assertIsInt( $first_id ); + $this->assertGreaterThan( 0, $first_id ); + + // is_unique() = true: a second schedule call for the same hook/group/args + // must not create a second pending action. + $job->schedule_at( time() + 2 * HOUR_IN_SECONDS, [ 'w' => 1 ] ); + + $ids = as_get_scheduled_actions( + [ + 'hook' => $job::get_hook(), + 'args' => [ [ 'w' => 1 ] ], + 'group' => 'test-group', + ], + 'ids' + ); + $this->assertCount( 1, $ids ); + + // The group scoped the lookup: querying a different group finds nothing. + $this->assertFalse( as_has_scheduled_action( $job::get_hook(), [ [ 'w' => 1 ] ], 'other-group' ) ); + } + + public function test_schedule_methods_return_null_when_action_scheduler_unavailable(): void { + \ActionScheduler::$initialized = false; + + $job = $this->make_job(); + + $this->assertNull( $job->schedule_async() ); + $this->assertNull( $job->schedule_at( time() + HOUR_IN_SECONDS ) ); + $this->assertNull( $job->schedule_recurring( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS ) ); + $this->assertFalse( $job->is_scheduled() ); + $this->assertNull( $job->unschedule() ); + } +} diff --git a/tests/Fixtures/ActionSchedulerFakes.php b/tests/Fixtures/ActionSchedulerFakes.php new file mode 100644 index 0000000..73f6005 --- /dev/null +++ b/tests/Fixtures/ActionSchedulerFakes.php @@ -0,0 +1,173 @@ +, group: string, timestamp: ?int}> */ + public static array $actions = []; + + private static int $next_id = 1; + + public static function reset(): void { + self::$actions = []; + self::$next_id = 1; + } + + /** + * @param array $args + */ + public static function find( string $hook, array $args, string $group ): ?int { + foreach ( self::$actions as $id => $action ) { + if ( $action['hook'] === $hook && $action['args'] === $args && $action['group'] === $group ) { + return $id; + } + } + + return null; + } + + /** + * @param array $args + */ + public static function add( string $hook, array $args, string $group, bool $unique, ?int $timestamp ): int { + if ( $unique ) { + $existing = self::find( $hook, $args, $group ); + if ( null !== $existing ) { + return $existing; + } + } + + $id = self::$next_id++; + + self::$actions[ $id ] = [ + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + 'timestamp' => $timestamp, + ]; + + return $id; + } + } +} + +if ( ! function_exists( 'as_enqueue_async_action' ) ) { + function as_enqueue_async_action( string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int { + return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, null ); + } +} + +if ( ! function_exists( 'as_schedule_single_action' ) ) { + function as_schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int { + return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, $timestamp ); + } +} + +if ( ! function_exists( 'as_schedule_recurring_action' ) ) { + function as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int { + return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, $timestamp ); + } +} + +if ( ! function_exists( 'as_has_scheduled_action' ) ) { + function as_has_scheduled_action( string $hook, ?array $args = null, string $group = '' ): bool { + foreach ( ActionSchedulerFakeStore::$actions as $action ) { + if ( $action['hook'] === $hook && $action['group'] === $group && ( null === $args || $action['args'] === $args ) ) { + return true; + } + } + + return false; + } +} + +if ( ! function_exists( 'as_next_scheduled_action' ) ) { + function as_next_scheduled_action( string $hook, ?array $args = null, string $group = '' ): int|bool { + foreach ( ActionSchedulerFakeStore::$actions as $action ) { + if ( $action['hook'] === $hook && $action['group'] === $group && ( null === $args || $action['args'] === $args ) ) { + return $action['timestamp'] ?? true; + } + } + + return false; + } +} + +if ( ! function_exists( 'as_unschedule_action' ) ) { + function as_unschedule_action( string $hook, array $args = [], string $group = '' ): ?int { + $id = ActionSchedulerFakeStore::find( $hook, $args, $group ); + if ( null === $id ) { + return null; + } + + unset( ActionSchedulerFakeStore::$actions[ $id ] ); + + return $id; + } +} + +if ( ! function_exists( 'as_get_scheduled_actions' ) ) { + /** + * @param array $args + * + * @return array + */ + function as_get_scheduled_actions( array $args = [], string $return_format = 'OBJECT' ): array { + $matches = []; + + foreach ( ActionSchedulerFakeStore::$actions as $id => $action ) { + if ( isset( $args['hook'] ) && $action['hook'] !== $args['hook'] ) { + continue; + } + if ( isset( $args['group'] ) && $action['group'] !== $args['group'] ) { + continue; + } + if ( isset( $args['args'] ) && $action['args'] !== $args['args'] ) { + continue; + } + + $matches[ $id ] = $action; + } + + return 'ids' === $return_format ? array_keys( $matches ) : $matches; + } +} + +if ( ! function_exists( 'as_fakes_reset' ) ) { + /** + * Reset both fakes to a clean, "available" state. Call from setUp(). + */ + function as_fakes_reset(): void { + ActionSchedulerFakeStore::reset(); + ActionScheduler::$initialized = true; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 17ecbcd..42adacb 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -62,5 +62,9 @@ // single-class files matching the class name). require_once __DIR__ . '/Fixtures/LoaderFixtures.php'; +// Action Scheduler is not a dependency of this package (see AbstractJob), so +// its API is faked here rather than installed for real. +require_once __DIR__ . '/Fixtures/ActionSchedulerFakes.php'; + // Start up the WP testing environment. require $_test_root . '/includes/bootstrap.php'; From 684431c75402bb234d8f4e72d44081b13b9f9363 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 4 Aug 2026 13:12:21 +0530 Subject: [PATCH 2/5] feat(contracts): add the platform-extensibility abstracts Four vendor-neutral seams that later packages fill in with concrete implementations (rtCamp/wp-devtools#12, item 3): AbstractPlatformLog reads recent host log entries and normalizes them to level/message/file:line, AbstractPlatformProfile carries the ruleset a readiness lens applies, AbstractLocalEnvironment sits behind wp-env and other local stacks, and AbstractVulnerabilityProvider fronts a CVE database. None implement Registrable: they register no WordPress hook and are queried on demand, so the Loader has nothing to drive. Every rule bucket and seam defaults to empty or permissive, which keeps a bare subclass useful and lets new buckets land later without breaking existing ones. The base classes own the two pieces of shared logic worth centralising: parse_error_log_line() handles both PHP error-log formats (FILE:LINE and "on line LINE") and returns null for stack frames, and affects_version() resolves a vulnerability's fixed_in against an installed version. --- .../Abstracts/AbstractLocalEnvironment.php | 82 ++++++++ .../Abstracts/AbstractPlatformLog.php | 117 +++++++++++ .../Abstracts/AbstractPlatformProfile.php | 106 ++++++++++ .../AbstractVulnerabilityProvider.php | 92 +++++++++ .../AbstractLocalEnvironmentTest.php | 101 ++++++++++ .../Abstracts/AbstractPlatformLogTest.php | 189 ++++++++++++++++++ .../Abstracts/AbstractPlatformProfileTest.php | 121 +++++++++++ .../AbstractVulnerabilityProviderTest.php | 126 ++++++++++++ 8 files changed, 934 insertions(+) create mode 100644 inc/Contracts/Abstracts/AbstractLocalEnvironment.php create mode 100644 inc/Contracts/Abstracts/AbstractPlatformLog.php create mode 100644 inc/Contracts/Abstracts/AbstractPlatformProfile.php create mode 100644 inc/Contracts/Abstracts/AbstractVulnerabilityProvider.php create mode 100644 tests/Contracts/Abstracts/AbstractLocalEnvironmentTest.php create mode 100644 tests/Contracts/Abstracts/AbstractPlatformLogTest.php create mode 100644 tests/Contracts/Abstracts/AbstractPlatformProfileTest.php create mode 100644 tests/Contracts/Abstracts/AbstractVulnerabilityProviderTest.php diff --git a/inc/Contracts/Abstracts/AbstractLocalEnvironment.php b/inc/Contracts/Abstracts/AbstractLocalEnvironment.php new file mode 100644 index 0000000..caf1bcd --- /dev/null +++ b/inc/Contracts/Abstracts/AbstractLocalEnvironment.php @@ -0,0 +1,82 @@ + Entries, newest first. + */ + abstract public function get_recent_entries( int $limit = 100 ): array; + + /** + * Whether this log source can be read right now. + * + * Defaults to true. Override to probe whatever the source depends on — a + * readable file, CLI credentials, a reachable endpoint. + * + * @return bool + */ + public function is_available(): bool { + return true; + } + + /** + * Parse one standard PHP error-log line into an entry. + * + * Handles both formats PHP emits — `[timestamp] PHP Level: message in + * FILE:LINE` and `... in FILE on line LINE` — and returns null for a line + * that is neither (a stack-trace frame, a blank line), so callers can + * `array_filter()` the result of mapping over a file. + * + * This is generic PHP log formatting rather than anything host-specific, + * so every file-backed subclass gets it for free. + * + * @param string $line One raw log line, without its trailing newline. + * + * @return array{timestamp: ?string, level: string, message: string, file: ?string, line: ?int, raw: string}|null Parsed entry, or null when the line is not a log entry. + */ + protected function parse_error_log_line( string $line ): ?array { + $rest = trim( $line ); + $timestamp = null; + $level = 'unknown'; + + if ( preg_match( '/^\[([^\]]+)\]\s*(.*)$/', $rest, $matches ) ) { + $timestamp = $matches[1]; + $rest = $matches[2]; + } + + if ( preg_match( '/^PHP\s+([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/', $rest, $matches ) ) { + $level = strtolower( $matches[1] ); + $rest = $matches[2]; + } + + // Neither marker present — a stack-trace frame or unrelated output. + if ( null === $timestamp && 'unknown' === $level ) { + return null; + } + + // Xdebug timestamps the trace it prints under a fatal, so those lines + // survive the check above. They belong to the entry before them. + if ( 'stack trace' === $level || preg_match( '/^(?:PHP\s+\d+\.|#\d+)\s/', $rest ) ) { + return null; + } + + $file = null; + $line_number = null; + + // Greedy leading group so the *last* " in " wins: a message may contain its own. + if ( preg_match( '/^(.*) in (.+) on line (\d+)$/', $rest, $matches ) ) { + $rest = $matches[1]; + $file = $matches[2]; + $line_number = (int) $matches[3]; + } elseif ( preg_match( '/^(.*) in (.+):(\d+)$/', $rest, $matches ) ) { + $rest = $matches[1]; + $file = $matches[2]; + $line_number = (int) $matches[3]; + } + + return [ + 'timestamp' => $timestamp, + 'level' => $level, + 'message' => trim( $rest ), + 'file' => $file, + 'line' => $line_number, + 'raw' => $line, + ]; + } +} diff --git a/inc/Contracts/Abstracts/AbstractPlatformProfile.php b/inc/Contracts/Abstracts/AbstractPlatformProfile.php new file mode 100644 index 0000000..3906e7e --- /dev/null +++ b/inc/Contracts/Abstracts/AbstractPlatformProfile.php @@ -0,0 +1,106 @@ + null, + 'backend' => null, + ]; + } + + /** + * Return the plugin slugs the platform is incompatible with. + * + * Defaults to none. Override to list them. + * + * @return string[] + */ + public function get_incompatible_plugins(): array { + return []; + } +} diff --git a/inc/Contracts/Abstracts/AbstractVulnerabilityProvider.php b/inc/Contracts/Abstracts/AbstractVulnerabilityProvider.php new file mode 100644 index 0000000..5de1dc1 --- /dev/null +++ b/inc/Contracts/Abstracts/AbstractVulnerabilityProvider.php @@ -0,0 +1,92 @@ + Vulnerabilities. + */ + abstract public function get_plugin_vulnerabilities( string $slug ): array; + + /** + * Return every known vulnerability for a theme. + * + * @param string $slug Theme slug, e.g. "twentytwentyfour". + * + * @return array Vulnerabilities. + */ + abstract public function get_theme_vulnerabilities( string $slug ): array; + + /** + * Return every known vulnerability for a WordPress core version. + * + * @param string $version Core version, e.g. "6.5.2". + * + * @return array Vulnerabilities. + */ + abstract public function get_core_vulnerabilities( string $version ): array; + + /** + * Whether this provider can be queried right now. + * + * Defaults to true. Override to probe whatever the source needs — an API + * token, network access, a rate-limit budget. + * + * @return bool + */ + public function is_available(): bool { + return true; + } + + /** + * Whether a vulnerability affects a given installed version. + * + * True when the vulnerability has no fix yet (`fixed_in` is null) or when + * the installed version predates the one that fixed it. + * + * @param array{fixed_in?: ?string} $vulnerability One entry from a lookup above. + * @param string $installed_version The version in use. + * + * @return bool + */ + public function affects_version( array $vulnerability, string $installed_version ): bool { + $fixed_in = $vulnerability['fixed_in'] ?? null; + + if ( null === $fixed_in || '' === $fixed_in ) { + return true; + } + + return version_compare( $installed_version, $fixed_in, '<' ); + } +} diff --git a/tests/Contracts/Abstracts/AbstractLocalEnvironmentTest.php b/tests/Contracts/Abstracts/AbstractLocalEnvironmentTest.php new file mode 100644 index 0000000..fcaee09 --- /dev/null +++ b/tests/Contracts/Abstracts/AbstractLocalEnvironmentTest.php @@ -0,0 +1,101 @@ +active; + } + }; + } + + /** + * Adapter that overrides every seam. + */ + private function make_custom_environment(): AbstractLocalEnvironment { + return new class() extends AbstractLocalEnvironment { + public function get_name(): string { + return 'custom-env'; + } + + public function is_active(): bool { + return true; + } + + public function get_debug_log_path(): string { + return '/srv/logs/custom.log'; + } + + public function get_available_services(): array { + return [ 'memcached', 'elasticsearch' ]; + } + + public function is_filesystem_writable(): bool { + return false; + } + }; + } + + public function test_identity_and_detection_come_from_the_subclass(): void { + $this->assertSame( 'bare-env', $this->make_environment()->get_name() ); + $this->assertTrue( $this->make_environment()->is_active() ); + $this->assertFalse( $this->make_environment( false )->is_active() ); + } + + public function test_debug_log_path_defaults_to_wp_content(): void { + $this->assertSame( + WP_CONTENT_DIR . '/debug.log', + $this->make_environment()->get_debug_log_path() + ); + } + + public function test_services_default_to_none(): void { + $this->assertSame( [], $this->make_environment()->get_available_services() ); + } + + public function test_filesystem_defaults_to_writable(): void { + $this->assertTrue( $this->make_environment()->is_filesystem_writable() ); + } + + public function test_overrides_propagate(): void { + $environment = $this->make_custom_environment(); + + $this->assertSame( '/srv/logs/custom.log', $environment->get_debug_log_path() ); + $this->assertSame( [ 'memcached', 'elasticsearch' ], $environment->get_available_services() ); + $this->assertFalse( $environment->is_filesystem_writable() ); + } +} diff --git a/tests/Contracts/Abstracts/AbstractPlatformLogTest.php b/tests/Contracts/Abstracts/AbstractPlatformLogTest.php new file mode 100644 index 0000000..039d158 --- /dev/null +++ b/tests/Contracts/Abstracts/AbstractPlatformLogTest.php @@ -0,0 +1,189 @@ +available ? parent::is_available() : $this->available; + } + + public function get_recent_entries( int $limit = 100 ): array { + if ( ! $this->is_available() ) { + return []; + } + + $parsed = array_map( + fn ( string $line ): ?array => $this->parse_error_log_line( $line ), + array_slice( $this->lines, -$limit ) + ); + + return array_reverse( array_values( array_filter( $parsed ) ) ); + } + + public function parse( string $line ): ?array { + return $this->parse_error_log_line( $line ); + } + }; + } + + public function test_is_available_defaults_to_true(): void { + $this->assertTrue( $this->make_log()->is_available() ); + } + + public function test_parses_fatal_error_with_colon_line_format(): void { + $line = '[04-Aug-2026 12:34:56 UTC] PHP Fatal error: Uncaught Error: Call to undefined function foo() in /var/www/html/wp-content/plugins/x/y.php:12'; + $entry = $this->make_log()->parse( $line ); + + $this->assertSame( '04-Aug-2026 12:34:56 UTC', $entry['timestamp'] ); + $this->assertSame( 'fatal error', $entry['level'] ); + $this->assertSame( 'Uncaught Error: Call to undefined function foo()', $entry['message'] ); + $this->assertSame( '/var/www/html/wp-content/plugins/x/y.php', $entry['file'] ); + $this->assertSame( 12, $entry['line'] ); + $this->assertSame( $line, $entry['raw'] ); + } + + public function test_parses_warning_with_on_line_format(): void { + $entry = $this->make_log()->parse( '[04-Aug-2026 12:34:56 UTC] PHP Warning: Undefined variable $x in /var/www/html/index.php on line 42' ); + + $this->assertSame( 'warning', $entry['level'] ); + $this->assertSame( 'Undefined variable $x', $entry['message'] ); + $this->assertSame( '/var/www/html/index.php', $entry['file'] ); + $this->assertSame( 42, $entry['line'] ); + } + + public function test_parses_deprecated_notice(): void { + $entry = $this->make_log()->parse( '[04-Aug-2026 12:34:56 UTC] PHP Deprecated: Function old_thing() is deprecated in /srv/app.php on line 7' ); + + $this->assertSame( 'deprecated', $entry['level'] ); + $this->assertSame( 7, $entry['line'] ); + } + + public function test_level_is_unknown_without_a_php_marker(): void { + $entry = $this->make_log()->parse( '[04-Aug-2026 12:34:56 UTC] Something a plugin logged directly' ); + + $this->assertSame( 'unknown', $entry['level'] ); + $this->assertSame( 'Something a plugin logged directly', $entry['message'] ); + $this->assertNull( $entry['file'] ); + $this->assertNull( $entry['line'] ); + } + + public function test_entry_without_a_location_leaves_file_and_line_null(): void { + $entry = $this->make_log()->parse( '[04-Aug-2026 12:34:56 UTC] PHP Notice: Something happened' ); + + $this->assertSame( 'Something happened', $entry['message'] ); + $this->assertNull( $entry['file'] ); + $this->assertNull( $entry['line'] ); + } + + public function test_last_in_wins_when_the_message_contains_its_own(): void { + $entry = $this->make_log()->parse( '[04-Aug-2026 12:34:56 UTC] PHP Warning: Trouble in paradise in /srv/app.php on line 3' ); + + $this->assertSame( 'Trouble in paradise', $entry['message'] ); + $this->assertSame( '/srv/app.php', $entry['file'] ); + $this->assertSame( 3, $entry['line'] ); + } + + /** + * @dataProvider data_non_entries + * + * @param string $line A line that is not a log entry. + */ + public function test_returns_null_for_non_entries( string $line ): void { + $this->assertNull( $this->make_log()->parse( $line ) ); + } + + /** + * @return array Lines that should not parse. + */ + public function data_non_entries(): array { + return [ + 'stack frame' => [ '#0 /var/www/html/wp-includes/plugin.php(205): my_callback()' ], + 'continuation' => [ ' thrown in /var/www/html/x.php on line 9' ], + 'blank' => [ '' ], + 'whitespace' => [ ' ' ], + // Xdebug timestamps these, so they reach the parser looking like entries. + 'timestamped trace header' => [ '[04-Aug-2026 12:34:56 UTC] PHP Stack trace:' ], + 'timestamped trace frame' => [ '[04-Aug-2026 12:34:56 UTC] PHP 1. {main}() /srv/e.php:0' ], + 'timestamped frame hash' => [ '[04-Aug-2026 12:34:56 UTC] #0 /srv/e.php(3): boom()' ], + ]; + } + + public function test_a_message_starting_with_a_number_is_still_an_entry(): void { + $entry = $this->make_log()->parse( '[04-Aug-2026 12:34:56 UTC] PHP Warning: 2 items failed in /srv/a.php on line 1' ); + + $this->assertSame( '2 items failed', $entry['message'] ); + $this->assertSame( 1, $entry['line'] ); + } + + public function test_get_recent_entries_returns_newest_first_and_drops_unparseable_lines(): void { + $entries = $this->make_log( + [ + '[04-Aug-2026 10:00:00 UTC] PHP Warning: First in /srv/a.php on line 1', + '#0 /srv/a.php(1): boom()', + '[04-Aug-2026 11:00:00 UTC] PHP Warning: Second in /srv/b.php on line 2', + ] + )->get_recent_entries(); + + $this->assertCount( 2, $entries ); + $this->assertSame( 'Second', $entries[0]['message'] ); + $this->assertSame( 'First', $entries[1]['message'] ); + } + + public function test_get_recent_entries_respects_the_limit(): void { + $entries = $this->make_log( + [ + '[04-Aug-2026 10:00:00 UTC] PHP Warning: First in /srv/a.php on line 1', + '[04-Aug-2026 11:00:00 UTC] PHP Warning: Second in /srv/b.php on line 2', + '[04-Aug-2026 12:00:00 UTC] PHP Warning: Third in /srv/c.php on line 3', + ] + )->get_recent_entries( 2 ); + + $this->assertCount( 2, $entries ); + $this->assertSame( 'Third', $entries[0]['message'] ); + $this->assertSame( 'Second', $entries[1]['message'] ); + } + + public function test_is_available_override_propagates(): void { + $log = $this->make_log( + [ '[04-Aug-2026 10:00:00 UTC] PHP Warning: First in /srv/a.php on line 1' ], + false + ); + + $this->assertFalse( $log->is_available() ); + $this->assertSame( [], $log->get_recent_entries() ); + } +} diff --git a/tests/Contracts/Abstracts/AbstractPlatformProfileTest.php b/tests/Contracts/Abstracts/AbstractPlatformProfileTest.php new file mode 100644 index 0000000..454cc02 --- /dev/null +++ b/tests/Contracts/Abstracts/AbstractPlatformProfileTest.php @@ -0,0 +1,121 @@ + 1048576, + 'backend' => 'memcached', + ]; + } + + public function get_incompatible_plugins(): array { + return [ 'some-plugin' ]; + } + }; + } + + public function test_identity_comes_from_the_subclass(): void { + $profile = $this->make_profile(); + + $this->assertSame( 'bare', $profile->get_slug() ); + $this->assertSame( 'Bare Platform', $profile->get_name() ); + } + + public function test_rule_buckets_default_to_empty(): void { + $profile = $this->make_profile(); + + $this->assertSame( [], $profile->get_restricted_functions() ); + $this->assertSame( [], $profile->get_writable_paths() ); + $this->assertSame( [], $profile->get_supported_php_versions() ); + $this->assertSame( [], $profile->get_incompatible_plugins() ); + } + + public function test_object_cache_constraints_default_to_unconstrained(): void { + $this->assertSame( + [ + 'max_object_bytes' => null, + 'backend' => null, + ], + $this->make_profile()->get_object_cache_constraints() + ); + } + + public function test_overrides_propagate(): void { + $profile = $this->make_constrained_profile(); + + $this->assertSame( [ 'exec', 'shell_exec' ], $profile->get_restricted_functions() ); + $this->assertSame( [ 'wp-content/uploads', '/tmp' ], $profile->get_writable_paths() ); + $this->assertSame( [ '8.2', '8.3' ], $profile->get_supported_php_versions() ); + $this->assertSame( [ 'some-plugin' ], $profile->get_incompatible_plugins() ); + $this->assertSame( + [ + 'max_object_bytes' => 1048576, + 'backend' => 'memcached', + ], + $profile->get_object_cache_constraints() + ); + } +} diff --git a/tests/Contracts/Abstracts/AbstractVulnerabilityProviderTest.php b/tests/Contracts/Abstracts/AbstractVulnerabilityProviderTest.php new file mode 100644 index 0000000..0b03ed6 --- /dev/null +++ b/tests/Contracts/Abstracts/AbstractVulnerabilityProviderTest.php @@ -0,0 +1,126 @@ + 'Stored XSS', + 'type' => 'xss', + 'fixed_in' => '4.1.0', + 'references' => [ + 'url' => [ 'https://example.test/advisory' ], + 'cve' => [ 'CVE-2026-0001' ], + ], + 'cvss' => [ + 'score' => 7.5, + 'vector' => 'CVSS:3.1/AV:N', + ], + ]; + + /** + * Minimal concrete provider returning canned rows. + * + * @param bool|null $available is_available() override; null keeps the default. + * @param array $plugin_vulnerabilities Rows returned for the "akismet" slug. + */ + private function make_provider( ?bool $available = null, array $plugin_vulnerabilities = [] ): AbstractVulnerabilityProvider { + return new class( $available, $plugin_vulnerabilities ) extends AbstractVulnerabilityProvider { + public function __construct( + private readonly ?bool $available, + private readonly array $plugin_vulnerabilities + ) {} + + public function is_available(): bool { + return null === $this->available ? parent::is_available() : $this->available; + } + + public function get_plugin_vulnerabilities( string $slug ): array { + return 'akismet' === $slug ? $this->plugin_vulnerabilities : []; + } + + public function get_theme_vulnerabilities( string $slug ): array { + return []; + } + + public function get_core_vulnerabilities( string $version ): array { + return []; + } + }; + } + + public function test_is_available_defaults_to_true(): void { + $this->assertTrue( $this->make_provider()->is_available() ); + } + + public function test_is_available_override_propagates(): void { + $this->assertFalse( $this->make_provider( false )->is_available() ); + } + + public function test_lookups_return_what_the_subclass_provides(): void { + $provider = $this->make_provider( null, [ self::VULNERABILITY ] ); + + $this->assertSame( [ self::VULNERABILITY ], $provider->get_plugin_vulnerabilities( 'akismet' ) ); + $this->assertSame( [], $provider->get_plugin_vulnerabilities( 'other' ) ); + $this->assertSame( [], $provider->get_theme_vulnerabilities( 'twentytwentyfour' ) ); + $this->assertSame( [], $provider->get_core_vulnerabilities( '6.5.2' ) ); + } + + /** + * @dataProvider data_affected_versions + * + * @param string|null $fixed_in The vulnerability's fixed_in value. + * @param string $installed The installed version. + * @param bool $expected Whether the installed version is affected. + */ + public function test_affects_version( ?string $fixed_in, string $installed, bool $expected ): void { + $vulnerability = self::VULNERABILITY; + $vulnerability['fixed_in'] = $fixed_in; + + $this->assertSame( + $expected, + $this->make_provider()->affects_version( $vulnerability, $installed ) + ); + } + + /** + * @return array fixed_in, installed, expected. + */ + public function data_affected_versions(): array { + return [ + 'unpatched (null fixed_in)' => [ null, '4.0.0', true ], + 'empty fixed_in' => [ '', '4.0.0', true ], + 'installed predates fix' => [ '4.1.0', '4.0.0', true ], + 'installed is the fix' => [ '4.1.0', '4.1.0', false ], + 'installed postdates fix' => [ '4.1.0', '4.2.0', false ], + 'point release below fix' => [ '4.1.2', '4.1.1', true ], + ]; + } + + public function test_affects_version_treats_a_missing_fixed_in_key_as_unpatched(): void { + $vulnerability = self::VULNERABILITY; + unset( $vulnerability['fixed_in'] ); + + $this->assertTrue( $this->make_provider()->affects_version( $vulnerability, '99.0.0' ) ); + } +} From 198ed44e72e8e75342e7f00e966d2fa65186fab7 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 4 Aug 2026 13:15:47 +0530 Subject: [PATCH 3/5] docs: document the job and platform abstracts; sync AI instruction files Abstracts cookbook gains a Background jobs section (Action Scheduler as an optional seam, the single-array args contract, the schedule_*() surface) and a Platform extensibility section covering the four queried-on-demand contracts, each with a consumer example. Hook table, README, and docs index updated; the count is now seventeen. The AI-instruction class lists pick up all five, rule 5 gains the as_*() scheduling functions, and AGENTS.md records that an optional third-party library is a guarded seam rather than a dependency. --- .github/instructions/php.instructions.md | 6 +- AGENTS.md | 2 +- README.md | 8 +- ai/framework-php.instructions.md | 4 +- docs/abstracts.md | 217 ++++++++++++++++++++++- docs/index.md | 14 +- 6 files changed, 233 insertions(+), 18 deletions(-) diff --git a/.github/instructions/php.instructions.md b/.github/instructions/php.instructions.md index a052500..acc5779 100644 --- a/.github/instructions/php.instructions.md +++ b/.github/instructions/php.instructions.md @@ -8,11 +8,11 @@ description: "Framework-development rules for rtcamp/wp-framework PHP." ## Layout & contracts - `inc/Contracts/Interfaces/`: `Registrable`, `ConditionallyRegistrable`, `Shareable`, `CLICommand`. -- `inc/Contracts/Abstracts/`: `AbstractModule`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`, `AbstractFeature`, `AbstractAbility`, `AbstractAbilityRegistrar`. +- `inc/Contracts/Abstracts/`: `AbstractModule`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`, `AbstractFeature`, `AbstractAbility`, `AbstractAbilityRegistrar`, `AbstractJob`, `AbstractPlatformLog`, `AbstractPlatformProfile`, `AbstractLocalEnvironment`, `AbstractVulnerabilityProvider`. - `inc/Contracts/Traits/`: `Loader`, `Singleton`. - `inc/` root: `Container`, `AssetLoader`, `ComponentLoader`, `TemplateLoader`; `inc/Utils/`: utilities (e.g. `Encryptor`). -Everything under `inc/Contracts/` is a **consumed contract**. New abstracts/interfaces must follow the existing shape (e.g. an `Abstract*` `implements Registrable` and exposes `abstract` methods for the bits that vary). +Everything under `inc/Contracts/` is a **consumed contract**. Most new abstracts follow the `Registrable` shape (`implements Registrable`, `abstract` methods for the bits that vary). The exception is a plain describable/queried-on-demand object with no WordPress hook of its own — the platform-extensibility abstracts (`AbstractPlatformLog`, `AbstractPlatformProfile`, `AbstractLocalEnvironment`, `AbstractVulnerabilityProvider`) are this shape on purpose. ## Mandatory @@ -28,4 +28,4 @@ Everything under `inc/Contracts/` is a **consumed contract**. New abstracts/inte 3. 🚩 A new dependency added to `composer.json` `require` (must stay `php`-only; dev tools go in `require-dev`). 4. 🚩 Missing `strict_types`/types/docblocks; PSR-4 mismatch; `self::` where `static::` is required. 5. 🚩 Missing escape/sanitize where the utility touches WordPress output/input; raw `$wpdb` without `prepare()`. -6. 🚩 New abstract/interface that doesn't follow the existing contract shape (e.g. an `Abstract*` not implementing `Registrable`, or duplicating a capability the `Loader`/`Container` already provides). +6. 🚩 New abstract/interface that doesn't follow the existing contract shape, or duplicates a capability the `Loader`/`Container` already provides. Not implementing `Registrable` is fine for a plain describable/queried-on-demand object (see the platform-extensibility precedent above) — only flag it if the class also fires WordPress hooks itself without going through `Registrable`. diff --git a/AGENTS.md b/AGENTS.md index 2e7469f..d4097e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Cod ## Key principles (full detail in the files above) - **`inc/Contracts/` is public API.** Interfaces, abstracts, and their method signatures are consumed by every plugin/theme: a signature change breaks all of them. Treat such changes as breaking. -- **Zero runtime deps**: `composer.json` `require` holds only `php`; everything else is `require-dev`. +- **Zero runtime deps**: `composer.json` `require` holds only `php`; everything else is `require-dev`. An optional third-party library is a guarded seam, never a dependency — `AbstractJob` reaches Action Scheduler only behind `is_available()`, with a `.stubs/` entry for PHPStan and fakes for tests. - **TDD**: failing PHPUnit test first (`tests/` mirrors `inc/`), then code. - **Tests run against real WordPress via wp-env** — no WP function mocking. `npm run wp-env start` then `npm run test:php` (a `pretest:php` hook runs `composer install` in the container first). WP-dependent tests extend `rtCamp\WPFramework\Tests\TestCase` (a `WP_UnitTestCase`); pure-logic tests can stay on `PHPUnit\Framework\TestCase`. CI runs a PHP × WP matrix (PHP 8.2+, WP 6.5+). - `declare( strict_types = 1 );`, full types, `@package`/`@since`, `static::` not `self::`, PSR-4 (`rtCamp\WPFramework\` → `inc/`). diff --git a/README.md b/README.md index fd9a1bd..4071683 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,14 @@ PSR-4 autoloading: `rtCamp\WPFramework\` → `inc/`. interfaces - the `Loader` trait (instantiate a list of classes, register their hooks, cache the shared ones) and the `Container` it stores instances in -- **Twelve `Abstract*` base classes** — one per WordPress registration chore, so - a consumer writes intent instead of boilerplate: `AbstractModule`, +- **Seventeen `Abstract*` base classes** — one per WordPress registration chore, + so a consumer writes intent instead of boilerplate: `AbstractModule`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`, `AbstractFeature`, `AbstractAbility`, - `AbstractAbilityRegistrar` + `AbstractAbilityRegistrar`, `AbstractJob`, `AbstractPlatformLog`, + `AbstractPlatformProfile`, `AbstractLocalEnvironment`, + `AbstractVulnerabilityProvider` - **Asset & render loaders** — `AssetLoader` (scripts/styles/modules + `*.asset.php` manifests), `ComponentLoader` and `TemplateLoader` (resolve components/templates across the child-theme → parent-theme → package hierarchy) diff --git a/ai/framework-php.instructions.md b/ai/framework-php.instructions.md index 3196f03..2623318 100644 --- a/ai/framework-php.instructions.md +++ b/ai/framework-php.instructions.md @@ -16,7 +16,7 @@ Decision order for a new class, **do NOT default to Singleton**: 2. **`Registrable` + `Shareable`**: only if another class must retrieve it via `get_shared()`. 3. **`Singleton`**: only the `Main` bootstrap. -Extend the framework abstracts; never hand-roll their job: `AbstractModule` and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole,Feature,Ability,AbilityRegistrar}`. +Extend the framework abstracts; never hand-roll their job: `AbstractModule` and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole,Feature,Ability,AbilityRegistrar,Job,PlatformLog,PlatformProfile,LocalEnvironment,VulnerabilityProvider}`. Flag genuine contract/security violations, not style. Allow any correct implementation. @@ -48,7 +48,7 @@ Flag genuine contract/security violations, not style. Allow any correct implemen 2. 🚩 `Singleton`/`::get_instance()` outside `Main` → `Loader`+`Registrable` (or `Shareable`+`get_shared()` if retrieval is genuinely needed). Never a service locator. 3. 🚩 `Shareable` with no real later-retrieval need → plain `Registrable`. 4. 🚩 WP-hooking class not implementing `Registrable` / not loaded via the `Loader`. -5. 🚩 A class calling `register_post_type`/`register_taxonomy`/`register_rest_route`/`add_menu_page`/`add_shortcode`/`register_block_type`/`wp_register_ability` directly instead of extending the matching `Abstract*` (`AbstractPostType`, `AbstractRESTController`, `AbstractAdminPage`, …). Name the abstract to extend. +5. 🚩 A class calling `register_post_type`/`register_taxonomy`/`register_rest_route`/`add_menu_page`/`add_shortcode`/`register_block_type`/`wp_register_ability`/`as_schedule_single_action`/`as_schedule_recurring_action`/`as_enqueue_async_action` directly instead of extending the matching `Abstract*` (`AbstractPostType`, `AbstractRESTController`, `AbstractAdminPage`, `AbstractJob`, …). Name the abstract to extend. 6. 🚩 Missing `strict_types`/types/docblocks; PSR-4 mismatch; `self::` for LSB. 7. 🚩 Missing escape/sanitize/nonce/capability; raw `$wpdb` without `prepare()`; REST without a real `permission_callback`; inline assets; wrong/absent text domain. 8. 🚩 Edit under `vendor/rtcamp/wp-framework` or WordPress core. diff --git a/docs/abstracts.md b/docs/abstracts.md index f29d4e9..6e80a77 100644 --- a/docs/abstracts.md +++ b/docs/abstracts.md @@ -1,14 +1,17 @@ # Abstracts — the base-class cookbook -The twelve `Abstract*` classes in +The seventeen `Abstract*` classes in [`inc/Contracts/Abstracts/`](../inc/Contracts/Abstracts/) are the part of the framework a service author touches most. Each one wraps a single WordPress registration chore so the subclass writes *what* it is, not *how* to register it. Every abstract here (except `AbstractModule`, which is structural; -`AbstractFeature`, which gates another service behind a flag; and +`AbstractFeature`, which gates another service behind a flag; `AbstractAbility`, which describes an ability that its paired -`AbstractAbilityRegistrar` registers) follows the same shape: +`AbstractAbilityRegistrar` registers; and the four platform-extensibility +abstracts — `AbstractPlatformLog`, `AbstractPlatformProfile`, +`AbstractLocalEnvironment`, `AbstractVulnerabilityProvider` — which are queried +on demand and register no hook at all) follows the same shape: - it `implements Registrable`, so the [`Loader`](architecture.md) drives it; - its `register_hooks()` attaches **one** WordPress hook; @@ -20,6 +23,10 @@ action on the right WordPress hook → when that hook fires, the actual registration runs. Read [architecture.md](architecture.md) if that split isn't familiar yet. +`AbstractJob` is `Registrable` too, but registers on **its own** hook (named +by the subclass) rather than a fixed WordPress one — see +[Background jobs](#background-jobs) below. + ## Which hook each one uses | Abstract | Registers on | You must implement | @@ -36,6 +43,11 @@ familiar yet. | `AbstractFeature` | — (gates the subclass's own hooks) | `get_slug()`, `get_feature_registry()`, plus the subclass's `register_hooks()` | | `AbstractAbility` | — (registered by its `AbstractAbilityRegistrar`) | `name()`, `label()`, `description()`, `category()`, `input_schema()`, `output_schema()`, `execute()` | | `AbstractAbilityRegistrar` | `wp_abilities_api_categories_init` + `wp_abilities_api_init` | `category_slug()`, `category_description()`, `abilities()` | +| `AbstractJob` | its own hook (via `get_hook()`) | `get_hook()`, `handle()` | +| `AbstractPlatformLog` | — (queried on demand) | `get_recent_entries()` | +| `AbstractPlatformProfile` | — (queried on demand) | `get_slug()`, `get_name()` | +| `AbstractLocalEnvironment` | — (queried on demand) | `get_name()`, `is_active()` | +| `AbstractVulnerabilityProvider` | — (queried on demand) | `get_plugin_vulnerabilities()`, `get_theme_vulnerabilities()`, `get_core_vulnerabilities()` | --- @@ -484,6 +496,205 @@ Load `Registrar` like any other `Registrable` (usually from a module's `get_classes()`); the ability is then retrievable via `wp_get_ability( 'my-plugin/site-summary' )` and executable by administrators. +## Background jobs + +### AbstractJob + +[`AbstractJob.php`](../inc/Contracts/Abstracts/AbstractJob.php) — a background +job that runs through [Action Scheduler](https://actionscheduler.org/), the de +facto WordPress queue/scheduling library. Action Scheduler is **never a +dependency of this package** — it's a seam, same as the platform-extensibility +abstracts below. Every scheduling method guards on `AbstractJob::is_available()` +and degrades to a silent no-op (`null`/`false`) when the library isn't loaded. + +**Must implement:** `get_hook()` (static — the WordPress action hook the job +runs on, e.g. `"my-plugin/send-welcome-email"`) and `handle( array $args )` +(the actual work). + +`register_hooks()` always attaches the listener, regardless of whether Action +Scheduler is present: `handle()` runs off a plain WordPress action, so +`do_action( MyJob::get_hook(), $args )` runs the job synchronously even +without Action Scheduler installed at all — scheduling is an enhancement, not +a requirement. + +Args are always a single associative array. Every `schedule_*()` method wraps +the caller's `$args` as Action Scheduler's one positional argument, and +`register_hooks()` registers the listener with a fixed `accepted_args = 1` to +match — so `handle()` always receives one plain array. Scheduling through the +raw `as_*()` functions directly, instead of this class's own `schedule_*()` +methods, breaks that contract. + +Overridable seams: `get_group()` (defaults to `''`), `get_priority()` +(defaults to `10`), `is_unique()` (defaults to `false` — when `true`, Action +Scheduler skips scheduling a duplicate of an already-pending/running action +with the same hook, group, and args). + +```php +final class SendWelcomeEmailJob extends AbstractJob { + public static function get_hook(): string { return 'my-plugin/send-welcome-email'; } + + protected function handle( array $args ): void { + wp_mail( $args['email'], 'Welcome!', 'Thanks for signing up.' ); + } +} + +$job = new SendWelcomeEmailJob(); +$job->register_hooks(); // load-time, e.g. from a module +$job->schedule_async( [ 'email' => $user->user_email ] ); // imperative call site +``` + +`schedule_async()`, `schedule_at( $timestamp, $args )`, and +`schedule_recurring( $timestamp, $interval_in_seconds, $args )` each return +the Action Scheduler action ID, or `null` when Action Scheduler is +unavailable. `is_scheduled( $args )` and `unschedule( $args )` round out the +seam for checking and cancelling. + +## Platform extensibility + +Four contracts with **no platform or vendor specifics of their own** — pure +seams a later package (VIP support, a CVE scanner, a readiness lens) fills +in with a concrete implementation. None register a WordPress hook; each is +constructed and queried on demand by whatever consumes it. + +### AbstractPlatformLog + +[`AbstractPlatformLog.php`](../inc/Contracts/Abstracts/AbstractPlatformLog.php) +— reads recent host-level log entries (PHP errors/fatals) and normalizes each +to a message, level, timestamp, and — where the source line permits it — a +file:line location. + +**Must implement:** `get_recent_entries( int $limit = 100 )`, returning +entries newest-first as +`array{timestamp: ?string, level: string, message: string, file: ?string, line: ?int, raw: string}`. + +Overridable: `is_available()` (defaults to `true`) — a capability-check seam +for whatever the source depends on (a readable file, CLI credentials, …). +`get_recent_entries()` should still return `[]` rather than throw even if a +caller skips that check. + +A protected `parse_error_log_line( string $line )` helper is included: it +parses a standard PHP error-log line (`[timestamp] PHP Level: message in +FILE:LINE` or `... in FILE on line LINE`) into the same shape. This is +generic PHP log formatting, not specific to any host, and is the natural +building block for a local `debug.log`-backed implementation: + +```php +final class DebugLogReader extends AbstractPlatformLog { + public function is_available(): bool { + return defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG && is_readable( WP_CONTENT_DIR . '/debug.log' ); + } + + public function get_recent_entries( int $limit = 100 ): array { + if ( ! $this->is_available() ) { + return []; + } + + $lines = array_slice( file( WP_CONTENT_DIR . '/debug.log' ) ?: [], -$limit ); + $entries = array_filter( array_map( fn ( string $line ) => $this->parse_error_log_line( trim( $line ) ), $lines ) ); + + return array_reverse( array_values( $entries ) ); + } +} +``` + +### AbstractPlatformProfile + +[`AbstractPlatformProfile.php`](../inc/Contracts/Abstracts/AbstractPlatformProfile.php) +— the ruleset a readiness lens applies: named rule buckets a hosting platform +constrains. Every bucket defaults to empty/permissive, so a profile that +declares nothing imposes no constraints, and new buckets can be added later +without breaking existing subclasses. + +**Must implement:** `get_slug()`, `get_name()`. + +Overridable rule buckets, all empty/permissive by default: +`get_restricted_functions()`, `get_writable_paths()`, +`get_supported_php_versions()`, `get_object_cache_constraints()` (returns +`array{max_object_bytes: ?int, backend: ?string}`), `get_incompatible_plugins()`. + +```php +final class VipPlatformProfile extends AbstractPlatformProfile { + public function get_slug(): string { return 'vip'; } + public function get_name(): string { return 'WordPress VIP'; } + + public function get_restricted_functions(): array { + return [ 'eval', 'exec', 'shell_exec', 'system' ]; + } + + public function get_writable_paths(): array { + return [ 'wp-content/uploads', '/tmp' ]; + } +} +``` + +### AbstractLocalEnvironment + +[`AbstractLocalEnvironment.php`](../inc/Contracts/Abstracts/AbstractLocalEnvironment.php) +— the seam behind local WordPress development environments: `wp-env`, VIP's +`vip dev-env`, or any other Docker-based local stack. Each concrete adapter +implements its own detection. + +**Must implement:** `get_name()`, `is_active()` (the detection seam — e.g. a +constant or env var unique to that environment). + +Overridable: `get_debug_log_path()` (defaults to `WP_CONTENT_DIR . '/debug.log'`), +`get_available_services()` (defaults to `[]`, e.g. `['memcached', 'elasticsearch']`), +`is_filesystem_writable()` (defaults to `true`). + +```php +final class VipDevEnvAdapter extends AbstractLocalEnvironment { + public function get_name(): string { return 'vip-dev-env'; } + public function is_active(): bool { return defined( 'VIP_GO_APP_ENVIRONMENT' ); } + public function get_available_services(): array { return [ 'memcached', 'elasticsearch' ]; } + public function is_filesystem_writable(): bool { return false; } +} +``` + +### AbstractVulnerabilityProvider + +[`AbstractVulnerabilityProvider.php`](../inc/Contracts/Abstracts/AbstractVulnerabilityProvider.php) +— the seam for a WordPress-ecosystem CVE/vulnerability database, WPScan +first, so a second provider is a new class extending this one. Lookups +return every known vulnerability for a slug/version unfiltered (matching how +WPScan's own API responds). + +**Must implement:** `get_plugin_vulnerabilities( string $slug )`, +`get_theme_vulnerabilities( string $slug )`, `get_core_vulnerabilities( string $version )` +— each returning a list of +`array{title: string, type: string, fixed_in: ?string, references: array{url: string[], cve: string[]}, cvss: ?array{score: float, vector: string}}`. + +Overridable: `is_available()` (defaults to `true`) — a capability-check seam +(an API token, network access, …). + +A concrete `affects_version( array $vulnerability, string $installed_version ): bool` +helper is provided: `true` when `fixed_in` is `null` (still unpatched) or the +installed version predates it, `false` otherwise — the shared filtering logic +every provider gets for free: + +```php +final class WPScanProvider extends AbstractVulnerabilityProvider { + public function __construct( private readonly string $api_token = '' ) {} + + public function is_available(): bool { + return '' !== $this->api_token; + } + + public function get_plugin_vulnerabilities( string $slug ): array { + // wp_remote_get( "https://wpscan.com/api/v3/plugins/{$slug}" ), mapped to the shape above. + } + + public function get_theme_vulnerabilities( string $slug ): array { /* … */ } + public function get_core_vulnerabilities( string $version ): array { /* … */ } +} + +$provider = new WPScanProvider( getenv( 'WPSCAN_API_TOKEN' ) ?: '' ); +foreach ( $provider->get_plugin_vulnerabilities( 'akismet' ) as $vulnerability ) { + if ( $provider->affects_version( $vulnerability, '4.0.0' ) ) { + // flag it + } +} +``` + --- Next: [loaders.md](loaders.md) for the asset and template machinery, or back to diff --git a/docs/index.md b/docs/index.md index 8452aa5..28fa6ad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,11 +18,11 @@ Two rules define the whole package: 1. **A registration system.** A predictable way to turn a list of classes into live WordPress hooks — `Registrable`, the `Loader` trait, and the `Container`. This is the spine; read [architecture.md](architecture.md) first. -2. **A library of base classes.** Twelve `Abstract*` classes — most wrap one +2. **A library of base classes.** Seventeen `Abstract*` classes — most wrap one WordPress registration chore (a post type, a taxonomy, a block, a settings page, an ability, …); two are structural: `AbstractModule` groups services - and `AbstractFeature` gates one behind a flag. See - [abstracts.md](abstracts.md). + and `AbstractFeature` gates one behind a flag; four more are queried on + demand rather than hook-registered — see [abstracts.md](abstracts.md). 3. **Asset & render plumbing.** `AssetLoader`, `ComponentLoader`, and `TemplateLoader` — enqueue built assets and resolve component/template files across the child-theme → parent-theme → package hierarchy. See @@ -37,7 +37,7 @@ Two rules define the whole package: |---|---| | [architecture.md](architecture.md) | The mental model: how a class becomes a live hook. The `Registrable` → `Loader` → `Container` flow and where `Module` fits. Start here. | | [contracts.md](contracts.md) | Reference for the interfaces and traits: `Registrable`, `ConditionallyRegistrable`, `Shareable`, `CLICommand`, `Loader`, `Singleton`. | -| [abstracts.md](abstracts.md) | Cookbook for the twelve `Abstract*` base classes — what each is for, the methods to implement, the hook it wires, a minimal subclass. | +| [abstracts.md](abstracts.md) | Cookbook for the seventeen `Abstract*` base classes — what each is for, the methods to implement, the hook it wires, a minimal subclass. | | [loaders.md](loaders.md) | `AssetLoader`, `ComponentLoader`, `TemplateLoader` — the asset/render subsystem and the theme-override hierarchy they share. | | [utilities.md](utilities.md) | `Encryptor`, `Cache`, `FeatureSelector`, `FeatureSelectorSettingsPage`, and `Container`. | | [ai-review-system.md](ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. | @@ -49,7 +49,9 @@ names — usually a list of `Module`s. Each `Module` is itself a `Loader` that holds a list of services. Loading walks the list: every class is instantiated, anything that is `Registrable` gets its `register_hooks()` called (so it wires its own `add_action`/`add_filter`), and anything marked `Shareable` is cached in -a `Container` so it can be fetched later. The `Abstract*` classes are all +a `Container` so it can be fetched later. Most `Abstract*` classes are `Registrable` — they exist so the service author writes "this is a post type called *foo*" instead of hand-writing the `register_post_type()` call and the -`init` hook. That's the entire framework in one breath; the rest is detail. +`init` hook. A few are plain describable objects queried on demand instead +(no hook of their own); [abstracts.md](abstracts.md) calls out which. That's +the entire framework in one breath; the rest is detail. From 75bdc4c21b473cc2e42a1acb0c8416db1a5b7eec Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 5 Aug 2026 14:28:43 +0530 Subject: [PATCH 4/5] fix(contracts): split job priorities into hook and queue seams; forward queue priority to as_* calls --- composer.json | 6 ++- docs/abstracts.md | 11 +++-- inc/Contracts/Abstracts/AbstractJob.php | 27 +++++++++--- tests/Contracts/Abstracts/AbstractJobTest.php | 43 ++++++++++++++----- tests/Fixtures/ActionSchedulerFakes.php | 21 ++++++--- 5 files changed, 81 insertions(+), 27 deletions(-) diff --git a/composer.json b/composer.json index 279e9c6..8cd7312 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,11 @@ "autoload-dev": { "psr-4": { "rtCamp\\WPFramework\\Tests\\": "tests/" - } + }, + "exclude-from-classmap": [ + "tests/Fixtures/ActionSchedulerFakes.php", + "tests/Fixtures/LoaderFixtures.php" + ] }, "config": { "allow-plugins": { diff --git a/docs/abstracts.md b/docs/abstracts.md index 6e80a77..3ca9f41 100644 --- a/docs/abstracts.md +++ b/docs/abstracts.md @@ -524,10 +524,13 @@ match — so `handle()` always receives one plain array. Scheduling through the raw `as_*()` functions directly, instead of this class's own `schedule_*()` methods, breaks that contract. -Overridable seams: `get_group()` (defaults to `''`), `get_priority()` -(defaults to `10`), `is_unique()` (defaults to `false` — when `true`, Action -Scheduler skips scheduling a duplicate of an already-pending/running action -with the same hook, group, and args). +Overridable seams: `get_group()` (defaults to `''`), `is_unique()` (defaults to +`false` — when `true`, Action Scheduler skips scheduling a duplicate of an +already-pending/running action with the same hook, group, and args), plus two +distinct priorities, both defaulting to `10`: `get_hook_priority()` is the +WordPress hook priority `register_hooks()` attaches `handle()` at, while +`get_queue_priority()` is passed to every `schedule_*()` call to order this job +against other queued actions (Action Scheduler clamps it to `0`-`255`). ```php final class SendWelcomeEmailJob extends AbstractJob { diff --git a/inc/Contracts/Abstracts/AbstractJob.php b/inc/Contracts/Abstracts/AbstractJob.php index 4e5229c..6b87105 100644 --- a/inc/Contracts/Abstracts/AbstractJob.php +++ b/inc/Contracts/Abstracts/AbstractJob.php @@ -47,7 +47,7 @@ public function register_hooks(): void { function ( array $args = [] ): void { $this->handle( $args ); }, - $this->get_priority(), + $this->get_hook_priority(), 1 ); } @@ -73,7 +73,7 @@ public function schedule_async( array $args = [] ): ?int { return null; } - $id = as_enqueue_async_action( static::get_hook(), [ $args ], $this->get_group(), $this->is_unique() ); + $id = as_enqueue_async_action( static::get_hook(), [ $args ], $this->get_group(), $this->is_unique(), $this->get_queue_priority() ); return $id > 0 ? $id : null; } @@ -91,7 +91,7 @@ public function schedule_at( int $timestamp, array $args = [] ): ?int { return null; } - $id = as_schedule_single_action( $timestamp, static::get_hook(), [ $args ], $this->get_group(), $this->is_unique() ); + $id = as_schedule_single_action( $timestamp, static::get_hook(), [ $args ], $this->get_group(), $this->is_unique(), $this->get_queue_priority() ); return $id > 0 ? $id : null; } @@ -110,7 +110,7 @@ public function schedule_recurring( int $timestamp, int $interval_in_seconds, ar return null; } - $id = as_schedule_recurring_action( $timestamp, $interval_in_seconds, static::get_hook(), [ $args ], $this->get_group(), $this->is_unique() ); + $id = as_schedule_recurring_action( $timestamp, $interval_in_seconds, static::get_hook(), [ $args ], $this->get_group(), $this->is_unique(), $this->get_queue_priority() ); return $id > 0 ? $id : null; } @@ -174,11 +174,26 @@ protected function get_group(): string { } /** - * Return the hook priority `register_hooks()` registers `handle()` at. + * Return the WordPress hook priority `register_hooks()` registers `handle()` at. + * + * Orders this listener against other callbacks on the same hook — unrelated + * to {@see get_queue_priority()}. + * + * @return int + */ + protected function get_hook_priority(): int { + return 10; + } + + /** + * Return the Action Scheduler queue priority the `schedule_*()` methods pass. + * + * Orders this job against other queued actions — lower runs first. Action + * Scheduler clamps it to 0-255, unlike a WordPress hook priority. * * @return int */ - protected function get_priority(): int { + protected function get_queue_priority(): int { return 10; } diff --git a/tests/Contracts/Abstracts/AbstractJobTest.php b/tests/Contracts/Abstracts/AbstractJobTest.php index 5afda48..111cf20 100644 --- a/tests/Contracts/Abstracts/AbstractJobTest.php +++ b/tests/Contracts/Abstracts/AbstractJobTest.php @@ -32,24 +32,30 @@ final class AbstractJobTest extends TestCase { protected function setUp(): void { parent::setUp(); + if ( ! AS_FAKES_ACTIVE ) { + $this->markTestSkipped( 'The real Action Scheduler is loaded, so the fakes these tests drive are not.' ); + } + as_fakes_reset(); } /** * Minimal concrete AbstractJob with injectable behavior/config seams. * - * @param \ArrayObject|null $log Appended to with every handle() call, if given. - * @param string $group get_group() override. - * @param int $priority get_priority() override. - * @param bool $unique is_unique() override. + * @param \ArrayObject|null $log Appended to with every handle() call, if given. + * @param string $group get_group() override. + * @param int $hook_priority get_hook_priority() override. + * @param bool $unique is_unique() override. + * @param int $queue_priority get_queue_priority() override. */ - private function make_job( ?\ArrayObject $log = null, string $group = '', int $priority = 10, bool $unique = false ): AbstractJob { - return new class( $log, $group, $priority, $unique ) extends AbstractJob { + private function make_job( ?\ArrayObject $log = null, string $group = '', int $hook_priority = 10, bool $unique = false, int $queue_priority = 10 ): AbstractJob { + return new class( $log, $group, $hook_priority, $unique, $queue_priority ) extends AbstractJob { public function __construct( private readonly ?\ArrayObject $log, private readonly string $group, - private readonly int $priority, - private readonly bool $unique + private readonly int $hook_priority, + private readonly bool $unique, + private readonly int $queue_priority ) {} public static function get_hook(): string { @@ -64,8 +70,12 @@ protected function get_group(): string { return $this->group; } - protected function get_priority(): int { - return $this->priority; + protected function get_hook_priority(): int { + return $this->hook_priority; + } + + protected function get_queue_priority(): int { + return $this->queue_priority; } protected function is_unique(): bool { @@ -78,7 +88,7 @@ public function test_implements_registrable(): void { $this->assertInstanceOf( Registrable::class, $this->make_job() ); } - public function test_register_hooks_adds_action_on_get_hook_with_declared_priority_and_one_accepted_arg(): void { + public function test_register_hooks_adds_action_on_get_hook_with_declared_hook_priority_and_one_accepted_arg(): void { global $wp_filter; $job = $this->make_job( null, '', 20 ); @@ -180,6 +190,17 @@ public function test_group_and_uniqueness_overrides_propagate(): void { $this->assertFalse( as_has_scheduled_action( $job::get_hook(), [ [ 'w' => 1 ] ], 'other-group' ) ); } + public function test_queue_priority_reaches_every_scheduling_call_and_ignores_hook_priority(): void { + $job = $this->make_job( null, '', PHP_INT_MAX, false, 30 ); + + $job->schedule_async( [ 'a' => 1 ] ); + $job->schedule_at( time() + HOUR_IN_SECONDS, [ 'b' => 2 ] ); + $job->schedule_recurring( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, [ 'c' => 3 ] ); + + // The out-of-range hook priority stays on the hook; only get_queue_priority() reaches the queue. + $this->assertSame( [ 30, 30, 30 ], array_column( \ActionSchedulerFakeStore::$actions, 'priority' ) ); + } + public function test_schedule_methods_return_null_when_action_scheduler_unavailable(): void { \ActionScheduler::$initialized = false; diff --git a/tests/Fixtures/ActionSchedulerFakes.php b/tests/Fixtures/ActionSchedulerFakes.php index 73f6005..6b446a5 100644 --- a/tests/Fixtures/ActionSchedulerFakes.php +++ b/tests/Fixtures/ActionSchedulerFakes.php @@ -14,6 +14,11 @@ declare( strict_types = 1 ); +// False when the real library is loaded — nothing below is declared, so AbstractJob's tests skip. +if ( ! defined( 'AS_FAKES_ACTIVE' ) ) { + define( 'AS_FAKES_ACTIVE', ! class_exists( 'ActionScheduler', false ) && ! function_exists( 'as_enqueue_async_action' ) ); +} + if ( ! class_exists( 'ActionScheduler', false ) ) { final class ActionScheduler { /** @@ -32,7 +37,7 @@ public static function is_initialized( ?string $function_name = null ): bool { * In-memory scheduled-action store backing the as_*() fakes below. */ final class ActionSchedulerFakeStore { - /** @var array, group: string, timestamp: ?int}> */ + /** @var array, group: string, timestamp: ?int, priority: int}> */ public static array $actions = []; private static int $next_id = 1; @@ -58,7 +63,7 @@ public static function find( string $hook, array $args, string $group ): ?int { /** * @param array $args */ - public static function add( string $hook, array $args, string $group, bool $unique, ?int $timestamp ): int { + public static function add( string $hook, array $args, string $group, bool $unique, ?int $timestamp, int $priority ): int { if ( $unique ) { $existing = self::find( $hook, $args, $group ); if ( null !== $existing ) { @@ -73,6 +78,7 @@ public static function add( string $hook, array $args, string $group, bool $uniq 'args' => $args, 'group' => $group, 'timestamp' => $timestamp, + 'priority' => max( 0, min( 255, $priority ) ), ]; return $id; @@ -82,19 +88,19 @@ public static function add( string $hook, array $args, string $group, bool $uniq if ( ! function_exists( 'as_enqueue_async_action' ) ) { function as_enqueue_async_action( string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int { - return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, null ); + return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, null, $priority ); } } if ( ! function_exists( 'as_schedule_single_action' ) ) { function as_schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int { - return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, $timestamp ); + return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, $timestamp, $priority ); } } if ( ! function_exists( 'as_schedule_recurring_action' ) ) { function as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int { - return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, $timestamp ); + return ActionSchedulerFakeStore::add( $hook, $args, $group, $unique, $timestamp, $priority ); } } @@ -111,6 +117,7 @@ function as_has_scheduled_action( string $hook, ?array $args = null, string $gro } if ( ! function_exists( 'as_next_scheduled_action' ) ) { + // int timestamp, or true for an undated (async) action, matching the real API. function as_next_scheduled_action( string $hook, ?array $args = null, string $group = '' ): int|bool { foreach ( ActionSchedulerFakeStore::$actions as $action ) { if ( $action['hook'] === $hook && $action['group'] === $group && ( null === $args || $action['args'] === $args ) ) { @@ -167,6 +174,10 @@ function as_get_scheduled_actions( array $args = [], string $return_format = 'OB * Reset both fakes to a clean, "available" state. Call from setUp(). */ function as_fakes_reset(): void { + if ( ! AS_FAKES_ACTIVE ) { + return; + } + ActionSchedulerFakeStore::reset(); ActionScheduler::$initialized = true; } From 80d4a8e0562e758f8515ebaa6c98971cee76a662 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 5 Aug 2026 15:03:34 +0530 Subject: [PATCH 5/5] fix(tests): gate Action Scheduler fakes behind AS_FAKES_ACTIVE and load after WP bootstrap --- tests/Fixtures/ActionSchedulerFakes.php | 10 +++++----- tests/bootstrap.php | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/Fixtures/ActionSchedulerFakes.php b/tests/Fixtures/ActionSchedulerFakes.php index 6b446a5..8ede932 100644 --- a/tests/Fixtures/ActionSchedulerFakes.php +++ b/tests/Fixtures/ActionSchedulerFakes.php @@ -14,11 +14,15 @@ declare( strict_types = 1 ); -// False when the real library is loaded — nothing below is declared, so AbstractJob's tests skip. +// False when the real library is loaded — nothing below is declared and AbstractJob's tests skip. if ( ! defined( 'AS_FAKES_ACTIVE' ) ) { define( 'AS_FAKES_ACTIVE', ! class_exists( 'ActionScheduler', false ) && ! function_exists( 'as_enqueue_async_action' ) ); } +if ( ! AS_FAKES_ACTIVE ) { + return; +} + if ( ! class_exists( 'ActionScheduler', false ) ) { final class ActionScheduler { /** @@ -174,10 +178,6 @@ function as_get_scheduled_actions( array $args = [], string $return_format = 'OB * Reset both fakes to a clean, "available" state. Call from setUp(). */ function as_fakes_reset(): void { - if ( ! AS_FAKES_ACTIVE ) { - return; - } - ActionSchedulerFakeStore::reset(); ActionScheduler::$initialized = true; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 42adacb..a3e59cb 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -62,9 +62,9 @@ // single-class files matching the class name). require_once __DIR__ . '/Fixtures/LoaderFixtures.php'; -// Action Scheduler is not a dependency of this package (see AbstractJob), so -// its API is faked here rather than installed for real. -require_once __DIR__ . '/Fixtures/ActionSchedulerFakes.php'; - // Start up the WP testing environment. require $_test_root . '/includes/bootstrap.php'; + +// Action Scheduler is not a dependency of this package (see AbstractJob), so its +// API is faked here. After WordPress, so a real one would win instead. +require_once __DIR__ . '/Fixtures/ActionSchedulerFakes.php';