Repository navigation
Conversation
Contributor
|
Test on Playground |
Contributor
🔍 WordPress Plugin Check Report
📊 Report
|
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
103 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
📁 classes/suggested-tasks/providers/class-content-review.php (4 warnings)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
232 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
377 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
381 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
388 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
📁 classes/activities/class-query.php (2 warnings)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
71 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $table_name used in $wpdb->query()\n$table_name assigned unsafely at line 58. |
163 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $where_args used in $wpdb->get_results()\n$where_args assigned unsafely at line 153. |
📁 classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
111 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $query used in $wpdb->get_row()\n$query assigned unsafely at line 98. |
📁 classes/suggested-tasks/data-collector/class-terms-without-description.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
108 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 106. |
📁 classes/suggested-tasks/data-collector/class-terms-without-posts.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
120 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 118. |
🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check
Contributor
✅ Code Coverage Report
🎉 Great job maintaining/improving code coverage! 📊 File-level Coverage Changes (16 files)🆕 New Files
📈 Coverage Improved
📉 Coverage Decreased
ℹ️ About this report
|
ilicfilip
force-pushed
the
filip/abilities-api
branch
from
September 18, 2026 10:51
bc258a9 to
19d91e4
Compare
Registers two read-only abilities with the core Abilities API so an AI agent can read what this plugin knows: - progress-planner/get-site-score - progress-planner/list-recommendations Registration is plain wp_register_ability(), guarded by function_exists(). The plugin stays unaware of any MCP bridge: a bridge that discovers WordPress abilities picks these up on its own, so no per-bridge integration is needed on either side. Nothing downstream re-checks a third-party ability's risk classification, so the permission callbacks here are the only gate that runs. Both require edit_others_posts, matching the capability the admin pages are gated on, and list-recommendations additionally drops any task whose provider is unavailable to the current user. The score payload is deliberately narrower than the SaaS status endpoint: the active-plugin inventory, site URL and branding ID that endpoint reports are telemetry for progressplanner.com, not something an agent needs. The monthly-score history is shared with System_Status rather than copied, so the two cannot drift. Task evaluation runs on admin_init, so these abilities report stored state rather than triggering it -- an agent request is not an admin request, and evaluating here would mean a read silently writes. Registration is idempotent: the constructor hooks the init actions, so a second instance would otherwise re-register and trip core's incorrect-usage notice. is_registered() is the notice-free probe for that. PHPStan cannot see the Abilities API functions because the pinned WordPress stubs predate them; ignored the same way the optional Yoast symbols are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ilicfilip
force-pushed
the
filip/abilities-api
branch
from
September 18, 2026 11:00
19d91e4 to
0849e44
Compare
Registers two read-only abilities with the core Abilities API so an AI agent can read what the plugin knows: - progress-planner/get-site-score - progress-planner/list-recommendations Registration is plain wp_register_ability(), guarded by function_exists(). The plugin stays unaware of any MCP bridge: a bridge that discovers WordPress abilities picks these up on its own, so no per-bridge integration is needed on either side. Nothing downstream re-checks a third-party ability's risk classification, so the permission callbacks here are the only gate that runs. Both require edit_others_posts, matching the capability the admin pages are gated on, and list-recommendations additionally drops any task whose provider is unavailable to the current user. The score payload is deliberately narrower than the SaaS status endpoint: the active-plugin inventory, site URL and branding ID that endpoint reports are telemetry for progressplanner.com, not something an agent needs. Task evaluation runs on admin_init, so these abilities report stored state rather than triggering it -- an agent request is not an admin request, and evaluating here would mean a read silently writes. PHPStan cannot see the Abilities API functions because the pinned WordPress stubs predate them; ignored the same way the optional Yoast symbols are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the permission gate, the payload shapes both abilities declare, the status-to-post-status mapping and the provider filter. Two of these are regression guards rather than shape checks. The checklist test asserts stable keys and that no_pending_updates flips to true when updates are mocked away: get_checklist_results() keys its results by translated label, so reading them positionally silently returned false for every flag. The telemetry test asserts the score payload does not grow the plugin inventory, site URL or branding ID that the stats endpoint reports. Registration itself is only asserted when wp_register_ability() exists, since CI runs against a WordPress release that predates the Abilities API. The rest of the logic is exercised directly and does not depend on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Abilities API shipped in WordPress 6.9, so it is present in CI. The plugin has therefore already registered by the time the suite runs, and firing wp_abilities_api_categories_init / wp_abilities_api_init again re-registered every ability and category, which core reports as incorrect usage. The registry is now read as-is instead. For the same reason the guard test cannot be exercised where the API exists: calling the register methods outside their hooks is itself incorrect usage. It now skips unless the functions are absent, which is the only situation the guard protects against. Adds a check that both abilities are registered under the plugin's own category. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The constructor hooks the registration actions, so every instance added another listener and the next fire re-registered everything, which core reports as incorrect usage. The test setUp() built a fresh instance per test and tripped exactly that. Registration now asks the registry first, via is_registered(). That is the notice-free probe: wp_get_ability() and wp_get_ability_category() report a miss as incorrect usage in their own right, so using them as a guard trades one notice for another. The tests now use the plugin's own instance from the service locator rather than constructing one, and the guard test became an idempotency test: the WordPress test harness fails any test that leaves a doing_it_wrong notice behind, so re-registering being a no-op is what it now asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a site with no recommendations the loop body never ran, so the test passed without asserting anything. CI hid this because it runs PHPUnit with --dont-report-useless-tests; it shows up as a risky test locally, and on multisite there are no recommendations at all. The test now creates a recommendation and asserts the list is non-empty before inspecting its fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lets an agent apply a recommendation that consists of one site setting, either by provider ID or, with no argument, the highest-priority one that needs no value. The fixable set is an explicit list of six providers, not everything extending Tasks_Interactive. That inference was wrong: the same base class covers sending a test email (a diagnostic, not a fix), deleting terms (irreversible), and rewriting permalinks (flushes rewrite rules and changes every URL). Each entry in Recommendation_Fixes was read individually and is there because it writes one known option and nothing else. The option name always comes from that table and never from caller input, so no ability argument can reach an arbitrary option. Recommendations outside the list are returned with status "manual" and an admin URL rather than half-applied. Completion is observed, not asserted: after writing the setting the provider decides whether the site now satisfies the task. Claiming otherwise would award points for work that did not happen. Because evaluation runs on admin_init, a just-fixed task is still pending in the database, so next-mode skips providers that already report themselves satisfied instead of picking the same one twice. There is no nonce, deliberately. An authenticated agent call is not a forged cross-origin form post, so manage_options and the fixed settings list are what bound this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Yoast and All in One SEO recommendations that consist of flipping one setting: author, date and format archives, media pages, and the emoji, author-feed and comment-feed crawl settings. Seventeen providers are now applicable, up from six. Yoast's tasks had no server-side save path at all. They predate the AIOSEO integration and only ever got a UI affordance: the popover deep-links into Yoast's own settings screen and highlights the field. That is useful to a person and meaningless to an agent, which never loads the page -- the underlying setting is a plain boolean, so it is set through WPSEO_Options::set(). AIOSEO settings are a nested object walked from a declared path. The paths were read from each provider's own submit handler rather than inferred: media-pages writes redirectAttachmentUrls under dynamicOptions, not a boolean under options, which guessing would have got wrong. Settings and values still come only from the table, never from caller input. Each SEO entry checks its plugin is active before writing, so an entry reached on a site without that plugin returns an error instead of fatalling. aioseo-crawl-settings-feed-comments is deliberately left out: it writes two settings, which the single-path model does not express. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Abilities class had grown to 813 lines doing three unrelated things: registering abilities, building the score payload, and reading and applying recommendations. Split into Abilities (registration and permissions only), Site_Score, Recommendations and Schemas, none over 300 lines. Removes two pieces of duplication: - get_monthly_scores() was a verbatim copy of the chart configuration in System_Status, twenty lines defining what a monthly score is. That definition now lives once in System_Status::get_monthly_scores() and both callers use it, so the score an agent reads cannot drift from the score the SaaS endpoint reports. - Three near-identical registration blocks each repeated the category, permission callback and annotations. They now share ability_args(), the same shape Yoast uses, so a new ability cannot quietly differ in its permission model or its annotations. No behaviour change: same 436 tests, same assertions, and the live site returns the same payloads before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs PHPCS at default severity, which surfaces two things the pre-commit hook (severity 6) hides: - 16 array double-arrow alignment warnings in class-recommendation-fixes.php (auto-fixed with phpcbf). - `$object` used as a variable name in the abilities test — reserved in PHP 8; renamed to `$instance`. No behaviour change. Full suite and PHPStan still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Email delivery is the one check a site cannot verify for itself: WordPress knows wp_mail() returned true, not that anything arrived. The existing token solves that by living only in the delivered message, so presenting it back proves delivery. The token is delivered as the href of a "Click here" link, which works for a person who clicks and not for a person who wants to relay it. Reading it off the screen is impossible; copying it means right-clicking and pasting a 128-character URL. That matters now this question gets asked through an AI assistant rather than the dashboard. An assistant with mailbox access can read the token and needs nothing from the user. An assistant without one has to ask, and "tell it the code from the email" only works if the code is short enough to say out loud. So the email now carries both: the link, unchanged, and a four-character code shown as text. The code is deliberately weaker than the token, and the docblock on generate_task_confirmation_code() records why that is acceptable -- single use, 24-hour expiry, scoped to one user and one task, manage_options required, and rate limiting around it. Anyone who could satisfy all of that can complete the task from wp-admin anyway. The alphabet omits characters that are misread when spoken or retyped (0/O, 1/I/L, 5/S, 8/B), and comparison ignores case and surrounding whitespace, because a code that survives being read aloud is the entire point. A test asserts the alphabet holds across repeated generation. The email body was built in two places; it is now built once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds hello-world and sample-page, bringing the applicable set to 19. These are the first entries that remove something rather than change a setting, and they are treated differently for it: - Annotated destructive. The annotation describes what the ability can do, not what a given call does, so complete-recommendation as a whole is now destructive and a client prompts before any of it runs. - Confirm-only, so next-mode skips them. A daily unattended run must never be the thing that deleted something; they can only be applied by naming the provider. - Trashed, not force-deleted. The dashboard's JavaScript passes force=true and removes the post outright. An agent should leave a way back, and the completion check passes either way because it looks for a published post. What makes them defensible at all is that the target is not ambiguous: the data collector resolves the specific post WordPress ships, by slug with a title fallback, and no ID is ever taken from the caller. The success message says the content was trashed and can be restored, rather than reusing the settings wording. The new tests create and trash posts, which writes to the activities table. That table is custom, so WP_UnitTestCase does not roll it back, and post IDs are reused across tests -- a leftover row was being seen by an unrelated test that asserts a fresh post has no activity. The class now clears the table in tearDown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the applicable set to 22. These recommendations do not create a page. They ask whether the site has one, and record the answer plus which page it is. The interesting part is who decides. The plugin deliberately does not search for the page. Searching a real site for "about" returns everything whose content mentions the word: on the demo site that is eleven pages, including one called "Job opening", with the actual About page ranked ninth and titled "About Emilia" at /about-us/. Deciding which of those is the About page means weighing title, slug, hierarchy and navigation in whatever language the site is written in. A caller that can read the site's pages does that well; a title match in PHP would get it wrong quietly, and worse on non-English sites. So the division of labour is: the caller identifies the page, and this verifies the ID before writing. A missing ID, an ID that does not exist, a draft, or a post that is not a page are each a distinct error rather than a silent no-op, because "we recorded your About page" is worth being true. The page type comes from the table and never from the caller, and the allowed post types are derived from the hierarchical public ones rather than hardcoded to 'page', so a site serving these roles from a custom post type still works. They need a value, so next-mode skips them: there is no correct page to choose on the owner's behalf. One caveat is recorded in is_satisfied(). These providers do not override is_task_completed(), so satisfaction now falls back to should_add_task(). That can still read pre-write state within the same request, because page-type lookups are memoised in a static cache with no invalidation -- which is why the status distinguishes "applied" from "satisfied" instead of assuming they are the same. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Let an agent apply recommendations that are a single setting
The MCP adapter's default server lists and runs only abilities whose meta sets mcp.public. Without it, discovery returns none of ours and executing one fails with "not exposed via MCP", so an agent connected through AI Connector could not see or call any of them. Found by driving wp.test through that server as an agent. The flag is set once in ability_args(), so every ability gets it, and a test asserts it for each registered ability. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Completion is two post statuses, not one: Task::is_completed() counts both trash and pending. A completed task is pending until the dashboard celebrates it, and only then trash. The status map returned only trash for `completed`, so a recommendation finished outside wp-admin -- through the email link, for one -- fell out of every list until someone opened the dashboard. A caller could complete a recommendation, be told it worked, then list to confirm and find it gone from pending, completed and snoozed alike, with no way to tell a completion from a deletion. The existing test asserted the mapping returned 'trash' for completed, which was true and was the bug: it encoded one of the two statuses as though it were the only one. Originally 10865f54 on the prototype branch, dropped before it was pushed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_posts_by_type() memoised its results in a function-static variable nothing could clear. set_page_values() reads it before writing, so the check that follows in the same request saw the page types from before the write: setting the About page through complete-recommendation reported applied_not_yet_complete although the recommendation was satisfied. The cache is now a property, dropped whenever term relationships change (set_object_terms, deleted_term_relationships) -- a page's type is a term, and set_page_values() writes it both through Page_Types and through wp_remove_object_terms() directly, so invalidating at the source covers every writer. The comment in is_satisfied() that documented the stale read goes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
complete-recommendation reported `completed` but recorded nothing: the task stayed published, with no activity, until the next wp-admin page load evaluated it. Until then it was still listed as pending and earned no points. It now records the completion itself, through a new Suggested_Tasks::mark_completed() that the admin_init path uses too, so there is one way a task gets completed: celebrate(), flush the task cache, insert the activity. The timezone and date-format providers never completed through the ability at all. Neither has state to observe -- any timezone or date format is valid -- so each counts a recorded completion, which the popover writes on submit. Their fixes are marked completes_on_apply in Recommendation_Fixes, making the exception explicit instead of relaxing "completion is observed, never asserted" for every fix. The abilities test cleared its activities with TRUNCATE, which commits the open transaction and let posts leak into later tests. That was invisible while complete() never changed a task; it is DELETE now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Registers two read-only abilities with the core Abilities API, so an AI agent can read what this plugin knows about a site.
progress-planner/get-site-scoreprogress-planner/list-recommendationsThe write ability (
complete-recommendation) was split into a separate PR stacked on this one, so this can be reviewed as a self-contained read-only surface.Why this shape
Registration is plain
wp_register_ability(), guarded byfunction_exists(). The plugin stays unaware of any MCP bridge — a bridge that discovers WordPress abilities picks these up on its own, so there is no per-plugin integration to write on either side. That is how AI Connector already works, and it is why this needs no changes there.It also means nothing downstream re-checks these abilities. A bridge serving a third-party ability runs that ability's own permission callback and nothing else, so the callbacks here are the only gate. Both require
edit_others_posts— the same capability the admin pages are gated on, so an ability can never surface data the user could not open in wp-admin.list-recommendationsadditionally drops any task whose provider is unavailable to the current user.For reference, Yoast SEO already ships
yoast-seo/get-seo-scores; this plugin currently registers nothing.Notes
System_Status::get_system_status(). The active-plugin inventory, site URL and branding ID that endpoint reports are telemetry for progressplanner.com, not something an agent needs.System_Status, not copied, so the score an agent reads cannot drift from the score the SaaS endpoint reports.admin_init; an agent request is not an admin request, and evaluating here would mean a read silently writes.is_registered()is the notice-free probe for that.Verified
Against a live WordPress 7.1 install, over a real MCP connection, and in CI:
tools/listreturnsprogress-planner-get-site-scoreandprogress-planner-list-recommendationsalongside its own tools, andtools/callreturns live data. No integration code on either side.check_permissions()andexecute()(ability_invalid_permissions) — no data leak.statusvalues work,providerfiltering works, and an invalid enum is rejected withability_invalid_input.check-csclean.Two of the tests are regression guards rather than shape checks.
get_checklist_results()keys its results by translated label, so reading them positionally returnedfalsefor every flag — the runtime output looked plausible because all-falsewas correct on the test site. The other asserts the score payload does not regrow the telemetry fields.🤖 Generated with Claude Code