mcp-ai-wpoos-wporg-submission
Operational guide for WordPress.org submission readiness of NV oOS standalone plugins (nvoos-docs-hub live; nvoos-design-system next in the pipeline — issue #6811). Covers the 18 wp.org guidelines, th
SKILL.md.agents/skills/mcp-ai-wpoos-wporg-submission/SKILL.md
GitHubNV oOS WordPress.org Submission — Readiness Playbook
Playbook distilled from five executed passes: the Docs Hub 0.4.3 submission-readiness pass (PR #6403), the base-plugin gate repair, the first real reviewer-reply pass (0.4.3 → 0.4.4, PR #6606 — all findings fixed plus a related-issue sweep), the second reviewer-reply pass (0.4.4 → 0.4.5 — remote-call service framing + the initial-dir symlink residual), the third reviewer-reply pass (0.4.6 → 0.4.7 — readme-only "Tested up to" + cron-on-init fix), the docs-hub 0.5.2 checkout-integration pre-submission pass (PR #6963 — commerce disclosures, five-screenshot listing set, capture-environment pitfalls), and the docs-hub 0.5.3 rebuild-reliability pre-submission pass (PRs #6967/#6968 — PCP 0 blocking errors, 0.5.3 live on the directory). Covers everything between "this plugin should ship to wp.org" and "the reviewer approves it".
When to use this skill
- "Prepare <plugin> for wp.org submission" / "run the 18-point checklist"
- A reviewer reply email arrives (the submission is pended) — fix the findings and upload a new version (see "The reviewer reply pass" below)
- The
plugin-checkCI job fails (any workflow) or a release gate is red - "Capture the wp.org listing screenshots" / "refresh the .wordpress-org assets"
- Triaging
wp plugin checkfindings (ERROR vs WARNING, false positives) - Adding/fixing the packaging exclusions for a standalone ZIP
- Reviewing readme.txt against wp.org standards
The 18 official wp.org guidelines (with repo mapping)
The authoritative list is
https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/
(18 numbered points, last updated 2026-03-11). Some third-party "20-point"
checklists add security and listing-assets items — those live in separate
wp.org handbooks and are covered by this skill's other sections (PCP gate,
.wordpress-org assets). Work the 18 below top-down for any submission;
"where verified" tells you which artifact proves compliance for this repo.
| # | Guideline (condensed) | Where verified in this repo |
|---|---|---|
| 1 | GPL-compatible licensing (GPLv2+ recommended); all bundled code/data/images licensed compatibly | License: GPLv3 or later header, LICENSE copied into ZIPs by release.yml/build-nvdigital-oos-wporg.yml, PCP readme license check |
| 2 | Developer is responsible for all contents and actions; verify third-party licenses before SVN upload | SUBMISSION.md manifest; wp-org-compliance-auditor agent scope |
| 3 | A stable version must always be available from the directory page | SVN trunk + tags/<ver> discipline (.wordpress-org/README.md runbook) |
| 4 | Code must be (mostly) human readable — no obfuscation/packer/mangled names; ship source or link build tooling | Audit row R-Q-06 (every .min.js has a sibling source/map); PCP code-quality checks |
| 5 | No trialware — no locked/paywalled features inside the plugin | Pro features ship in the separate Pro addon; base ZIP has no locked functionality (SUBMISSION.md) |
| 6 | SaaS is permitted — the service must provide real functionality and be documented with ToS links | AI providers are services; readme.txt == External Services == (ToS + privacy URLs per host); the docs-hub OffloadedContent PCP allowlist rationale |
| 7 | No tracking without explicit, informed consent; no phone-home; document data collection | All remote fetches are server-side and only after admin config (readme wording); auditor agent flags new wp_remote_* without disclosure |
| 8 | No executable code via third-party systems; no CDN-loaded JS/CSS (fonts excepted); no remote update/install | Auditor agent: "no remote JS/CSS" rule; release.yml BASE_ONLY_EXCLUDES strips the langchain/transformers/web-worker CDN enqueue files from the wp.org ZIP |
| 9 | Nothing illegal, dishonest, or morally offensive; no fake reviews/black-hat SEO | Manual audit scope (wp-org-compliance-auditor) |
| 10 | No embedded links/credits on public site without explicit opt-in; "Powered by" must default OFF | Auditor agent success criteria: no unsolicited attribution |
| 11 | No admin-dashboard hijacking — dismissible, contextual notices only; resolve-and-remove errors | Auditor agent success criteria; docs-hub settings page limits notices to its own page |
| 12 | Readmes must not spam — ≤ 5 tags, no keyword stuffing, no affiliate links, written for people | Docs-hub readme trimmed to 3 standard tags; PCP flags tag/keyword issues |
| 13 | Use WordPress' bundled libraries (jQuery, SimplePie, PHPMailer…) — don't ship your own copies | PCP library_core_files finding (present in the base-plugin report); bundle only non-WP deps in vendor/ |
| 14 | Avoid frequent SVN commits; descriptive commit messages; releases only | Process note — commit per release, not per change (.wordpress-org/README.md) |
| 15 | Version must increment each release; trunk readme must reflect current version | Version-bump commits per release; PCP readme checks (Stable tag == header Version, Tested up to fresh) |
| 16 | A complete plugin must exist at submission — no slug reservations/placeholders | PCP no_plugin_readme ERROR (readme.txt must be at the ZIP root); no empty-dir submissions |
| 17 | Respect trademarks — no other product's term as the slug's initial term | PCP trademarked_term (base plugin: wp-mcp-ai contains "wp" → warning; the chosen slug nvdigital-open-operator-system-oos avoids it) |
| 18 | The directory team reserves maintenance rights (updates to rules, removals, exceptions) | Informational — factor review feedback loops into the submission timeline |
Checklist source documents (read before executing)
SUBMISSION.md(repo root) — base-plugin submission state + per-finding reviewer-reply table.github/agents/wp-org-compliance-auditor.agent.md— PR-level auditor (set_time_limit, attribution, do_shortcode wrap, CLI write paths, External Services disclosure, CDN ban)docs/operations/compliance/WORDPRESS_ORG_SUBMISSION_CHECKLIST.md— pre-submission requirements + SVN process (account → review → SVN)docs/project/audits/2026-04/wp-org-submission-checklist.md— executed audit with per-row status + IDs (R-D-01, R-Q-01…, R-S-01…, R-T-04…)
Running the official Plugin Check locally (Docker)
The QA stack (oos-wp, http://localhost:8000) has plugin-check active, but
PCP's runtime checks crash on the heavy plugin stack. Use a fresh WP install
(one-off container on the oos-wp_default network, scratch DB in oos-wp-db):
# 1. Scratch DB + grants (the MYSQL_DATABASE env does this in CI; locally the
# wordpress user needs explicit grants on any DB beyond `wordpress`).
docker exec oos-wp-db sh -c 'mysql -uroot -pwordpress -e "CREATE DATABASE IF NOT EXISTS wordpress_pluginchk CHARACTER SET utf8mb4; GRANT ALL PRIVILEGES ON wordpress_pluginchk.* TO '\''wordpress'\''@'\''%'\''; FLUSH PRIVILEGES;"'
# 2. One-off WP install + PCP run. Mount the plugin source and a scratch dir.
# --user 33:33 matters when writing into the wp_core volume (uploads are
# owned by uid 33; the cli image runs as uid 82).
MSYS_NO_PATHCONV=1 docker run --rm --user 33:33 --network oos-wp_default \
-v oos-wp_wp_core:/var/www/html \
-v F:/GITHUB/worktrees/mcp-ai-wpoos/<worktree>/mcp-ai-wpoos:/var/www/html/wp-content/plugins/mcp-ai-wpoos \
-e WORDPRESS_DB_HOST=oos-wp-db -e WORDPRESS_DB_NAME=wordpress \
-e WORDPRESS_DB_USER=wordpress -e WORDPRESS_DB_PASSWORD=wordpress \
wordpress:cli-php8.2 sh -c \
'php -d memory_limit=1G /usr/local/bin/wp plugin check <slug> --format=json --severity=5 --path=/var/www/html > /tmp/report.json 2>/dev/null; echo EXIT:$?'
Hard rules learned the expensive way:
- WordPress cannot boot without a DB.
wp plugin installandwp plugin checkload WP — a DB-less bootstrap dies with "Error establishing a database connection". Always provision MySQL (service container in CI; scratch DB locally). - Activate plugin-check; never
--requireit.--require=.../ plugin-check/plugin.phploads PCP before WP boots and fatal-errors withCall to undefined function plugin_dir_path().wp plugin install plugin-check --activateis the only working invocation. wordpress:cli-php8.2runs as uid 82 (the Apache image's www-data is uid 33). Files in the sharedwp_corevolume are owned by 33 — pass--user 33:33for any run that writes uploads/cache.- The CLI image's
wpshim ignoresWP_CLI_PHP_ARGS. Memory errors (128M cap while loading WooCommerce et al) → prefix every call withphp -d memory_limit=512M /usr/local/bin/wp. - Windows bind-mount readdir truncation. After heavy writes (e.g. tar
extraction) through a Windows bind mount, Docker Desktop can serve a
truncated directory listing to containers (host
findsees 9365 files, container sees 4583). Symptom: PCP report is absurdly small andno_plugin_readmefires even though readme.txt exists. For full-tree PCP sims use Linux-native docker volumes (docker volume create,chown -R 82:82first) and verify the file count INSIDE the run container before trusting the report. Small plugin trees (docs-hub scale,cp -rin-container) are unaffected. - PCP CLI caching. Results can look stale across re-runs; bump the
plugin's
Version:header in the sim tree to force fresh results when a report doesn't change after fixing files. - Exit code + report shape.
wp plugin check --format=jsonemits oneFILE: <path>header + JSON array per file with findings (chunked stream, not one array). Gate with the slurp form:jq -s '[.[][] | select(.type=="ERROR" and .code!="<allowlist>")] | length'. The report is NOT a single valid JSON document —json_decode()of the whole file returns null, and a run with zero findings produces an EMPTY report. Inbin/run-docs-hub-plugin-check.shthe gate is grep-based ("type":"ERROR"— no space after the colon in PCP's output) so no jq is needed. - The
--helptrap:wp plugin check --helpfatal-errors (PCP's mu-plugin hook runs without a plugin arg). Don't use it; read the source underwp-content/plugins/plugin-check/includes/. - The report is ephemeral.
/tmp/pcp-report.jsonlives in the one-off container's filesystem and is gone when the run exits — a seconddocker runto inspect it finds nothing. Compute the gate (and copy the report to a mounted host dir) INSIDE the samesh -cthat ran the check. - No
jqinwordpress:cli-php8.2(current image), and host-shell quoting mangles inline gates.sh: jq: not foundaborts the documented one-liner, and nested$(php -r '…$f["type"]…')through the Windows host shell dies withsyntax error near unexpected token. Both were hit on the 0.4.7 pass. Usebin/run-docs-hub-plugin-check.sh— it writes the runner as a heredoc (LF endings, no quoting trap), copies it into the container before executing (the bind mount is read-only, so in-placesed -i 's/\r$//'on a Windows-host script fails with "Resource busy"), and counts blocking errors with grep instead of jq.
For small plugin trees (docs-hub scale) a fully isolated variant avoids the
QA volume entirely — build the stage tree on the host with the CI's exact
tar --exclude list, mount it read-only, download WP core into the
container, and cp -r the plugin in.
Preferred: bin/run-docs-hub-plugin-check.sh (repo root) does the
staging + scratch DB + one-off PCP run + grep-based gate in one shot,
handling pitfalls 9 and 10. The raw commands below are the fallback when
that script needs adapting:
# Host: stage the ZIP-shaped tree (mirrors build-spa-addons.yml EXCLUDES +
# docs-hub's `--exclude='*.md'`).
mkdir -p /f/GITHUB/tmp-dh-pcp/nvoos-docs-hub
tar -C addons/docs-hub -cf - --exclude='./node_modules' --exclude='./src' \
--exclude='./tests' --exclude='./vendor' --exclude='./docs' \
--exclude='./.wordpress-org' --exclude='*.md' \
--exclude='./composer.json' --exclude='./composer.lock' \
--exclude='./package.json' --exclude='./package-lock.json' \
--exclude='./tsconfig.json' --exclude='./esbuild.config.js' \
--exclude='./eslint.config.js' --exclude='./vitest.config.ts' \
--exclude='./.gitignore' --exclude='./.distignore' . | \
tar -C /f/GITHUB/tmp-dh-pcp/nvoos-docs-hub -xf -
# Container: bootstrap → activate PCP → cp plugin → check → gate, all in one
# run (pitfall 9). No --user needed (nothing written to the wp_core volume).
MSYS_NO_PATHCONV=1 docker run --rm --network oos-wp_default \
-v F:/GITHUB/tmp-dh-pcp:/plugin-src \
-e WORDPRESS_DB_HOST=oos-wp-db -e WORDPRESS_DB_NAME=wordpress_pluginchk \
-e WORDPRESS_DB_USER=wordpress -e WORDPRESS_DB_PASSWORD=wordpress \
wordpress:cli-php8.2 sh -c 'set -e; \
php -d memory_limit=512M /usr/local/bin/wp core download --version=latest --skip-content --path=/tmp/wp --quiet; \
php -d memory_limit=512M /usr/local/bin/wp config create --dbname=wordpress_pluginchk --dbuser=wordpress --dbpass=wordpress --dbhost=oos-wp-db --path=/tmp/wp --quiet; \
php -d memory_limit=512M /usr/local/bin/wp core install --url=http://127.0.0.1 --title=PCP --admin_user=admin --admin_password=admin --admin_email=a@a.com --path=/tmp/wp --quiet 2>/dev/null || true; \
php -d memory_limit=512M /usr/local/bin/wp plugin install plugin-check --activate --path=/tmp/wp --quiet; \
cp -r /plugin-src/nvoos-docs-hub /tmp/wp/wp-content/plugins/nvoos-docs-hub; \
php -d memory_limit=1G /usr/local/bin/wp plugin check nvoos-docs-hub --path=/tmp/wp --format=json --severity=5 > /tmp/pcp-report.json 2>/tmp/pcp-stderr.log; \
echo PCP_EXIT:$?; \
ERRORS=$(cat /tmp/pcp-report.json | jq -s "[.[][] | select(.type==\"ERROR\" and .code!=\"PluginCheck.CodeAnalysis.Offloading.OffloadedContent\")] | length"); \
echo BLOCKING_ERRORS:$ERRORS; cat /tmp/pcp-report.json; \
cp /tmp/pcp-report.json /plugin-src/pcp-report.json'
Notes: wp core install prints a sendmail warning (no MTA in the container)
— harmless, and || true covers the idempotent re-run case. Expected healthy
output: PCP_EXIT:0, BLOCKING_ERRORS:0, with only the two allowlisted
OffloadedContent rows and the NonPrefixedTraitFound warning in the
report.
The CI gate
Two workflows carry the gate:
.github/workflows/build-spa-addons.yml→plugin-checkjob (docs-hub ZIP; active). Runs on PRs touchingaddons/docs-hub/**, gates onERROR-severity findings minus the allowlistedOffloadedContent, and documents the one non-blockingNonPrefixedTraitFoundwarning..github/workflows/release.yml→plugin-checkjob (LEGACYv*tag path, slugwp-mcp-ai). Fixed mechanically (DB + activate + no--require+ slurped jq) but the active release path isbuild-nvdigital-oos-wporg.yml(nvdigital-oos-v*tags, slugnvdigital-open-operator-system-oos, text-domain transform at packaging time). The legacy gate will report the TextDomainMismatch backlog below — that's the point of the gate; see R-T-04.
Job anatomy (copy this shape, not the old DB-less one):
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress_pluginchk
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
ports: ["3306:3306"]
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s --health-timeout=5s --health-retries=5
# steps: wp core download → wp config create (real creds) → wp core install
# → wp plugin install plugin-check --activate → unzip target ZIP →
# wp plugin check <slug> --format=json --severity=5 → jq -s gate
The base-plugin current PCP state (measured on WP 7.1 + PCP 2.1.0 against a
ZIP-shaped tree, Linux volume): ~31k ERRORs, ~3.9k WARNINGs, dominated by
WordPress.WP.I18n.TextDomainMismatch (~30.9k — repo domain mcp-ai-wpoos
vs expected wp-mcp-ai/wp.org slug). This is a known backlog for the
submission track, NOT something to allowlist. The docs-hub ZIP is clean
(0.5.2 checkout pass, PR #6963, and 0.5.3 rebuild-reliability pass, PR #6968:
0 blocking errors, 1 documented warning — the NonPrefixedTraitFound trait;
0.5.3 is live on the directory).
Known PCP findings triage
| Code | Verdict | Handling |
|---|---|---|
PluginCheck.CodeAnalysis.Offloading.OffloadedContent |
False positive | Allowlist — flags literal raw.githubusercontent.com hosts in the SSRF-hardened, host-allowlisted remote fetcher (disclosed in readme.txt == External Services ==). On docs-hub 0.5.2/0.5.3 the rows come from blueprints/seed-content.php (the Playground demo seed) — the PCP staging includes blueprints/, the real ZIP excludes it, so those rows never ship |
WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedTraitFound |
Intentional (docs-hub) | Warning only — the WP_MCP_AI_Inline_Async_Tick_Trait stub must carry the BASE plugin's trait name so the real trait replaces it when NV oOS is active |
WordPress.WP.I18n.TextDomainMismatch |
Real blocker (base) | Text domain must equal the wp.org slug/dir name; the repo domain is mcp-ai-wpoos. Needs the slug migration track — do not allowlist |
no_plugin_readme |
Real or mount artifact | readme.txt must sit at the ZIP root. If it does and PCP still fires, suspect the bind-mount truncation (pitfall 5) |
CodeAnalysis — core function newer than the declared Requires at least |
Real | PCP flags any WP function whose @since is newer than the plugin's minimum (e.g. wp_generate_uuid4() is WP 6.7+ under Requires at least: 6.0). Fix: a function_exists()-guarded polyfill (keeps the floor) or bump Requires at least. Add the same check for any WP API used by new standalone plugins |
Second plugin in the pipeline (nvoos-design-system, issue #6811)
The addon is functionally standalone (all base-plugin touchpoints gated:
tools behind wp_mcp_ai_register_tools + interface_exists, Paper Store
behind class_exists, AI keys behind function_exists, WooCommerce behind
class_exists) but still needs the docs-hub packaging set before PCP:
readme.txt (Stable tag == header Version), LICENSE, CHANGELOG.md,
.distignore tri-synced with bin/build-addon-zips.sh (docs-hub ships only
readme.txt, no *.md), languages/<slug>.pot, .wordpress-org/ assets,
the wp_generate_uuid4() polyfill above, and a plugin-check CI job — plus
owner-side steps: standalone repo, subtree-sync workflow + deploy token
(mirror sync-nvoos-docs-hub.yml), wp.org slug request, SVN submission.
Third standalone plugin: nvoos-content-graph (live + re-submission loop)
Content Graph is LIVE on the directory (first approved as 1.0.3; 1.0.7
signoff uploaded; 1.0.8 released; 1.0.9 pre-upload pass PR #6959; 1.0.10
released — the excluded-sources prune fix, PR #6973: pruneExcludedSources()
- chunked
pruneNodesByTypes(), changelog + readme entries in-window; operational note — run Rebuild Graph once after deploying 1.0.10 so already-indexed excluded CCTs/CPTs get pruned). It carries its own review-artifact convention next to the docs-hub one:
plugins/nvoos-content-graph/WPORG-REVIEW-{FINDINGS,REPLY,18POINT-SIGNOFF,DETAILED-REPORT,COMMERCE-NOTES}.md— excluded from the ZIP via.distignore(WPORG-REVIEW-*.md); the signoff holds per-version re-verification sections (1.0.7 pass + 1.0.9 pre-upload pass) — append one per re-submission.- Commerce surface discipline: every new remote link/flow in the
purchase modal needs a matching
== External services ==disclosure in the same PR. The 1.0.9 free base-version download link (browser-side GitHub link, no data sent) is the latest example; keepWPORG-REVIEW-COMMERCE-NOTES.mdcurrent for the review team. - Reviewer categories recur across plugins — sweep siblings: the 0.4.7
"cron scheduled before init" category also existed in content-graph
(boot-path
syncRebuildSchedule()onplugins_loaded, only when no rebuild event existed — the WC translated-interval notice on WP 6.7+). Fixed in 1.0.9 by hookinginit; the activation-hook scheduling was already safe (post-init). The 0.4.6 "Tested up to in the PHP header" sweep also applies (content-graph header is clean — readme-only). - Tri-sync: fix ALL THREE legs. #6662 fixed the workflow rsync list,
but
bin/build-nvoos-content-graph.shstill lackedblueprints/until the 1.0.9 pass — when one leg drifts, diff all three before closing. - PCP profile (1.0.9, WP latest): 0 ERRORs, ~161 WARNINGs — all known non-blocking categories (DirectDB on constant-derived table names, dynamic-hook indirection from prefixed constants, slow meta-query pattern on the graph tables). No allowlist required; a new category = stop and triage.
- POT regeneration gotchas:
wp i18n make-potvia a one-offwordpress:clicontainer with the plugin dir mounted (no DB needed). Do NOT put the version in--package-name— wp-cli appends the plugin-header version itself, so "Name 1.0.9" produces "Project-Id-Version: Name 1.0.9 1.0.9". Verify with a msgid set-diff:comm -23 <(git show HEAD:<pot> | grep '^msgid "' | sort) <(grep '^msgid "' <pot> | sort)must be empty (1.0.9: +3 msgids, 0 lost).
Fourth pass: docs-hub 0.5.2 — NV oOS Complete checkout integration (PR #6963)
Docs Hub added the ported commerce stack (upsell card after the settings
Save button, /payments/* admin-only routes, purchase modal). Pre-submission
sweep results that extend the earlier passes:
- Commerce surface discipline now applies to docs-hub too. The new
checkout block in
readme.txt == External Services ==lists the vendor checkout server (nvdigitalsolutions.com, with the data sent: product, site URL, intent id, buyer email/country, consent timestamp),js.stripe.com(browser-side, on-demand), and the ZIP-download host.WPORG-REVIEW-COMMERCE-NOTES.mdat the addon root is excluded from the ZIP by the existing*.mddistignore rule — the notes pre-answer "why does a free plugin install another plugin" (post-payment only, manual download is primary, off-directory distribution). - The port recipe + test conventions live in
.agents/skills/mcp-ai-wpoos-checkout-integration/SKILL.md— use it for the next plugin that wants the flow. - PCP profile (0.5.2): 0 blocking errors. The 5
OffloadedContentrows are inblueprints/seed-content.php, which the PCP staging tree includes but the real ZIP excludes — a superset scan, not a regression. - phpcs on the whole addon reports 22 pre-existing ERRORs in
blueprints/seed-content.php(heredoc style, dev-only, excluded from the ZIP) and warnings inlink-fixer/remote-repo/scanner/rebuild-job; every SHIPPED file is clean — scope phpcs to the shipped surface before concluding a submission blocker. - readme changelog headings can be swallowed by edits — the 0.5.2 edit
initially consumed the
= 0.5.1 =heading and merged its bullets into 0.5.2; re-verify the whole== Changelog ==structure (and== Upgrade Notice ==) after every readme edit. - Listing screenshots grew to five — screenshot-5 (Save button + upsell
card) required updating BOTH
readme.txt == Screenshots ==and the.wordpress-org/README.mdasset table (see capture pitfalls below).
Fifth pass: docs-hub 0.5.3 — rebuild reliability (PRs #6967/#6968)
0.5.3 fixed the index wipe bug (settings saves / plugin updates cleared the
live cache and relied on the async chunked rebuild — standalone installs with
DISABLE_WP_CRON stalled silently) and passed the full 18-guideline review:
- No-clear rebuild model — the pipeline builds into a staging namespace
and promotes it atomically;
on_settings_changed()/maybe_rebuild_after_version_change()no longer clear the live cache (they cancel in-flight rebuilds first); update/activation handlers callenqueue_async()directly (clear_and_enqueue()is a deprecated no-clear wrapper);maybe_schedule_shutdown_tick()keeps cron-less installs progressing on each admin visit/save. Readers keep serving the previous index during a rebuild. - PCP profile (0.5.3):
PCP_EXIT:0,BLOCKING_ERRORS:0— only the 5 allowlistedOffloadedContentrows (dev-only blueprint seed, excluded from the real ZIP) + the documentedNonPrefixedTraitFoundwarning. The== External Services ==automatic-rebuild-trigger list still matches the code exactly; no new hosts, notices, remote JS/CSS, translatable strings, or packaging-exclusion changes (tri-sync verified);WPORG-REVIEW- COMMERCE-NOTES.mdrecords that 0.5.3 carries no commerce-surface changes. - Version sweep discipline (seven locations) — plugin header,
NVOOS_DOCS_HUB_VERSION, readme.txtStable tag+== Changelog ==+== Upgrade Notice ==entries, POTProject-Id-Version, addonCHANGELOG.md,.wordpress-org/README.mdSVN runbook examples — all 0.5.3 (the 0.5.2 heading-swallow lesson applies again here). - Release wiring lesson (#6970) — the tag-push flow that should have
built + released 0.5.3 was broken two ways (the
on.pushtags+pathsAND combination never fired, and the dispatchifcompared a string'true'to booleantrue); 0.5.3 shipped via the dispatch path and the fixedrelease-spa-addons.ymlnow handles futuredocs-hub-vX.Y.Ztags (build + PCP gate + GitHub release — no publish-back onto the tag). - 0.5.3 is LIVE on the wp.org directory (2026-10-09) — the docs-hub SVN loop now tracks 0.5.3; future passes diff against it.
The reviewer reply pass (0.4.3 → 0.4.4, PR #6606)
The first real wp.org review pended the docs-hub submission. Below is the findings taxonomy (reviewer + AI flags), the fix applied, and the related-issue sweep that found two more leaks. Reuse this map whenever the next reviewer email lands — the same categories recur across plugins.
Findings taxonomy → fixes applied
| Review finding | Root cause | Fix pattern (docs-hub) |
|---|---|---|
| "Add yourself to Contributors" — the listed name must be a wp.org username, not the GitHub org | Contributors: nvdigitalsolutions but the account is vsamtani |
Contributors: vsamtani — only real wp.org usernames; drop org names with no account |
Minified assets/dist/docs-hub.js has no bundled source and no repo link (Guideline 4) |
src/ is excluded from the ZIP; no readme disclosure |
Add == Source Code == readme section (public repo URL + npm install && npm run build steps + where dev files live), AND make the bundle self-describing: esbuild banner: { js: …, css: … } with /*!-style source/build/license header. Do both JS and CSS banners — the CSS bundle is generated too |
permission_callback — /search is public but leaks source: "context" excerpts to non-admins |
Named public permission callback is fine; the data was the leak — get_manifest() and get_page() filtered context but search() did not |
In every public endpoint, filter source === 'context' for ! current_user_can('manage_options'). Verify all context surfaces: manifest, pages, search, and the sitemap (docs-hub's sitemap provider was exposing context slugs — caught by the sweep, not the review) |
load_plugin_textdomain() unnecessary (WP ≥ 4.6) |
Redundant since plugin requires 6.0; wp.org loads translations automatically | Remove the call; keep the Text Domain/Domain Path header. (Keep only if the plugin supports WP < 4.6.) |
| "Plugins should not hijack the admin dashboard" (Guideline 11) | Site-wide admin_notices warning rendered on every admin page |
Scope notices to the plugin's own screen via get_current_screen() (docs-hub: settings_page_nvoos-docs-hub). Action-result notices (rebuild success/fail) that fire once after a form POST on the settings page are contextual and fine |
Staging transients: set_transient on page write (L168) + live transient read (L149) break staging isolation |
get_manifest/get_search_index honoured the staging toggle; get_page/set_page did not |
Mirror the manifest pattern: skip transient read when $this->staging, only set transient when ! $this->staging |
uninstall.php recursive delete follows symlinks |
rm_rf recursed via is_dir() without link checks or containment |
Check is_link() before is_dir() (delete the link, never the target), and verify realpath() stays inside the cache root before recursing. Apply to every recursive delete in the codebase — Cache::rm_rf() had the same bug |
Second reviewer pass (0.4.4 → 0.4.5, review P0TDX367696HGN)
The 0.4.4 fixes passed except two residuals — one a category the reviewer wanted re-framed, one an AI-flagged corner of the previous symlink fix:
| Review finding | Root cause | Fix pattern (docs-hub) |
|---|---|---|
"Calling files remotely" — flagged raw.githubusercontent.com in class-nvoos-docs-hub-remote-repo.php (ALLOWED_HOSTS + raw URL build) |
The remote fetcher IS the plugin's service (like Akismet/oEmbed) — server-side only, host-allowlisted, admin-triggered — but the readme didn't say so in reviewer terms | Do not rip out the service. Rewrite == External Services == to state it is a documentation-import service: what it does, the servers called (api.github.com, raw.githubusercontent.com), that no account is required (optional token only raises rate limits), that fetches are server-side/cached, and that rendered pages may contain content-authored links/images to github.com domains loaded by the browser, not the server. Reply explaining the service exception (Guideline 6) |
| "The initial cache directory can itself be a symlink: realpath() treats its external target as the containment root" (uninstall.php:112) | The 0.4.4 containment guard resolves the root from the path being deleted — when the top-level cache dir is a symlink, target == root and the guard passes trivially | is_link()-check the top-level cache dir before recursing and delete only the link. Same class of bug in Cache::get_live_dir(): a symlinked cache dir would receive .htaccess/index.php/pages/ writes and make clear()'s glob() deletes touch external files — unlink the link and recreate a real dir there. Keep is_link()-first guards at every recursion entry. Regression tests: symlink the cache dir → Cache::clear() and require uninstall.php must leave the external target's sentinel file intact |
Third reviewer pass (0.4.6 → 0.4.7, review P0TDX367696HGN)
The 0.4.5 fixes passed but the automated gate flagged two new items — one packaging rule and one WP 6.7+ timing bug:
| Review finding | Root cause | Fix pattern (docs-hub) |
|---|---|---|
| "Please declare 'Tested up to' only in your readme file" — the value is also in the main PHP file's plugin headers | wp.org's readme header list is the ONLY supported place for Tested up to; declaring it in the PHP header risks the wrong compatibility version being shown |
Remove the * Tested up to: line from the main PHP file; keep it in readme.txt only. Requires at least/Requires PHP/Text Domain/Domain Path ARE valid PHP headers — only Tested up to is readme-only. Sweep: the base plugin mcp-ai-wpoos.php and several addons also carry the header — fix each before ITS submission |
| "The plugin has problems when it is activated… early WooCommerce translation-loading notice before init" | Rebuild_Job::schedule() ran wp_schedule_event() on plugins_loaded. wp_schedule_event() consults wp_get_schedules(), which applies the cron_schedules filter — WooCommerce registers a TRANSLATED interval there (__( 'Once monthly', 'woocommerce' )), and on WP 6.7+ translations load on init, so the call triggers the "translation loading triggered too early" notice on the first dashboard load after activation |
Move cron scheduling to init (never call wp_schedule_event() or translation functions before init). Related sweep: (a) add register_deactivation_hook() → unschedule() clearing the daily cron AND pending pipeline tick events (ghost-event cleanup), (b) on self-deactivation clear the cache but do NOT re-enqueue an async rebuild — the tick callbacks no longer exist once inactive, (c) grep the whole plugin for other pre-init scheduling/translation — none remained. Regression tests: schedule/unschedule round-trip + self-vs-related deactivation behavior |
Related-issue sweep checklist (always run after fixing the flagged items)
Each review item is a category — grep the whole plugin for siblings:
- Context/sensitive-data leaks — every surface that serves indexed content to guests: REST endpoints, sitemap providers, feeds, shortcode data attributes. docs-hub sweep caught the sitemap leaking context slugs (fixed alongside search).
- Transient staleness around staging — after promotion, md5-keyed page
transients can still serve stale (or deleted) pages until TTL. Fix:
promote_staging()deletes page transients for every swapped slug (+ best-effort orphan slugs via--→/reversal — lossy but a wrong-key delete is a harmless miss), andclear()/uninstall.phpwildcard-clean_transient_<prefix>p_*rows with$wpdb->prepare+esc_like. - Symlink traversal in deletion — grep
rmdir|unlink|RecursiveDirectoryIterator| rm_rf. Every recursive delete needs the is_link + realpath-containment guard (see fixes above). Also guard the containment root itself: when the root being deleted IS the containment root (uninstall's cache dir),is_link()-check it first or the external target becomes the root (the 0.4.4 → 0.4.5 finding). Sweepglob(+wp_delete_filecleanup andwp_mkdir_p+guard-file writes too — those follow a symlinked cache dir into external targets as well (get_live_dir()fix). - Secrets in localized/admin output — grep
wp_localize_scriptand form renders for settings blobs. docs-hub localized the FULL settings (including GitHub PATs inremote_repos[].token) into the page DOM; the JS only stripped tokens at export time. Fix: strip server-side before localizing (settings_without_tokens()). The PHP form was already safe (password input, value never echoed — "(saved — enter new value to change)" placeholder). - Remote host disclosure (Guidelines 6/7) — every
wp_remote_*/curl call must match the== External Services ==readme section exactly. docs-hub: allowlist is exactlyapi.github.com+raw.githubusercontent.com— matches the readme. - Frontend data paths — verify the SPA only hits the permission-checked
endpoints (docs-hub:
manifest+pages/{slug}+searchonly; FlexSearch fallback indexes the already-filtered manifest). - Other notice/admin-surface scans — grep
admin_noticesand confirm each render is either on-page (settings screen) or a one-shot action-result; grep$_GET/$_POST/$_REQUESTand confirm cap + nonce + sanitize on every handler.
Reviewer reply email (template)
Keep it brief, factual, no filler (the review explicitly asks for this). Reply to the thread, list each finding as resolved, state the new version, and confirm validation. State the permalink only if changing it.
Hi,
Thanks for the detailed review. All reported issues are fixed and version
0.4.7 has been uploaded:
- Removed the "Tested up to" header from nvoos-docs-hub.php — it is now
declared only in readme.txt.
- The daily rebuild cron is now scheduled on init instead of plugins_loaded,
so wp_schedule_event() no longer runs before translations load (the cause
of the "translation loading triggered too early" notice with WooCommerce
active).
- Related sweep: rebuild cron events are cleared on plugin deactivation, and
deactivating the plugin no longer enqueues a rebuild that cannot run.
Validated on a clean WordPress install with WP_DEBUG on: activation is
clean, wp plugin check reports 0 errors (only the documented allowlisted
items), and the full PHPUnit suite passes on WordPress 6.9 and 7.1.
Keeping the permalink nvoos-docs-hub.
Thanks,
[Your name]
Process notes from the pass:
- Version bump is mandatory per release (Guideline 15): header
Version,NVOOS_DOCS_HUB_VERSION, readmeStable tag, changelog, upgrade notice, POTProject-Id-Version,.wordpress-org/README.mdexample versions. - One review round = one commit cluster; PR against
alpha-working(fix/docs-hub-wporg-review-044→ PR #6606). CI runs the docs-hub plugin-check job on PRs touchingaddons/docs-hub/**; taggingdocs-hub-vX.Y.Zafter merge builds the ZIP;sync-nvoos-docs-hub.ymlmirrors the addon to the standalone public repo. - Unrelated working-tree changes (e.g. stale
vendor/composer/*from acomposer install) stay unstaged — commit only the addon paths. - After merge: upload the ZIP, then reply to the same email thread.
Running the docs-hub PHPUnit tests
Docs-hub tests are NOT in the root phpunit.xml.dist testsuite — passing the
addons/docs-hub/tests directory to phpunit runs nothing. Pass the files
explicitly:
vendor/bin/phpunit -c phpunit.xml.dist --no-coverage \
addons/docs-hub/tests/test-cache-staging.php \
addons/docs-hub/tests/test-rest-manifest.php \
addons/docs-hub/tests/test-rebuild-chunked.php \
addons/docs-hub/tests/test-rebuild-job.php \
addons/docs-hub/tests/test-rebuild-pipeline-inline-kick.php \
# … (all files except test-fnmatch-polyfill.php)
test-fnmatch-polyfill.phpis a standalone script, not PHPUnit — passing it errors with "Class test-fnmatch-polyfill cannot be found".- Run them through the Docker runner, not the host vendor — see
.agents/skills/mcp-ai-wpoos-test-suite/SKILL.md→ "Cross-worktree runs". The hostvendor/is frequently a pruned copy whose classmap has zero PHPUnit entries (Class "PHPUnit\\TextUI\\Application" not foundon BOTH host and container); the Linux vendor volume is the reliable path. The 0.4.7 pass validated the full docs-hub set on WP 6.9 and WP 7.1 with an isolated DB (wordpress_test_moth*) — 107 tests, 0 failures each. - Known pre-existing Windows-only failure:
Test_Docs_Hub_Scanner::test_path_traversal_prevented(realpath behavior differs on Windows; passes on Linux CI). Don't chase it in local runs. - The suite boots the full base plugin, so expect
wp_is_block_themenotices and Pro module warnings in the output — cosmetic.
Packaging exclusions (tri-sync)
Standalone plugin ZIPs keep dev files out via three lists that must stay in
sync: the addon's .distignore, the docs-hub step of
bin/build-addon-zips.sh, and the rsync/tar excludes in the CI workflow
(build-spa-addons.yml assemble step). Current sets exclude: node_modules/,
src/, tests/, vendor/, composer.json|lock, package*.json,
esbuild/eslint/tsconfig/vitest configs, docs/, .wordpress-org/,
.gitignore, .distignore, .DS_Store. Any new dev-only path must be added
to all three.
The same trap bit the content-graph workflow (build-nvoos-content-graph.yml)
in PR #6662: its inline rsync list drifted from .distignore, a dev
blueprints/ folder shipped in the ZIP, and PCP failed on the leaked PHP
file. Playground-demo dev folders and their exclusion checklist live in the
mcp-ai-wpoos-playground-demos skill.
Windows local builds — when the msys rsync/zip binaries are broken
(missing DLLs — hit on the docs-hub 0.5.1 trunk build), stage with GNU
tar using the same exclude set and zip with Python zipfile (normalize
entry names to forward slashes, verify the listing, and check readme.txt
sits at the ZIP root) before uploading.
.wordpress-org assets
Layout mirrors plugins/nvoos-content-graph/.wordpress-org/:
.wordpress-org/
├── README.md # asset checklist + SVN upload runbook
├── assets/ # icon-128x128/256x256.png, banner-772x250/1544x500.png,
│ # screenshot-{1..N}.png (committed, excluded from ZIP)
└── source/ # artwork masters (1024x1024 icon, 1344x768 banner)
.wordpress-org/maps to the plugin's SVNassets/dir — it never ships inside the ZIP.- PNGs are gitignored locally via
.git/info/exclude(*.png) — stage withgit add -f(content-graph precedent). - Live Preview (
assets/blueprints/blueprint.json) — enables the Playground preview button on the plugin page (docs: developer.wordpress.org → Previews and Blueprints). Committers see a "Test Preview" button as soon as the file is committed; it only becomes public when a committer marks the preview public in the plugin's Advanced view. The preview loader pre-installs the plugin without activating it, and it injects its own installPlugin step before the blueprint's steps — runPHP steps must self-activate or avoid plugin classes entirely (patterns + the docs-hub case study in themcp-ai-wpoos-playground-demosskill). Assets-only SVN commits do NOT change plugin visibility — the plugin goes public on the firsttrunk/code commit.
Screenshot capture (Playwright)
Templates in bin/: capture-nvoos-content-graph-screenshots.js (origin),
capture-nvoos-docs-hub-screenshots.js (newer, docs-hub specific). Workflow:
- QA stack up (
docker compose up -d), plugin active, data seeded (docs-hub: remote repo configured +wp nvoos-docs rebuild --sync, published[nvoos_docs]page). - Login as admin (
admin/password) for admin shots; guest context for frontend (public access). Set generous timeouts — the QA site serves pages in 20–60s (setDefaultTimeout(240000)). - Element captures for settings widgets; viewport/fullPage for the frontend.
- Verify with a pixel-level QA pass (sub-agent can decode text/layout without vision) before committing.
Capture pitfalls (all hit in practice):
- fullPage texture limit — Chromium fails
page.screenshoton very tall pages ("Unable to capture screenshot"); use viewport captures for shots with overlay UI (search dropdowns). - Theme content width — default themes constrain content to ~620px,
dropping the docs app below its sidebar/TOC breakpoints. Inject
:root { --wp--style--global--content-size: 1240px !important; --wp--style--global--wide-size: 1400px !important; }for the capture. - Wait for real content — the SPA shell paints long before the REST page
fetch lands; wait for
.dh-contenttextContent length (not just the container) or you capture a "Loading…" screen. - Duplicate roots — the shortcode emits an empty container div AND the
rendered app root; select the live one via
[data-theme]. locator.screenshot({clip})is unreliable — usepage.screenshotwith viewport-relativeclipfromlocator.boundingBox().- Red error rows in listing shots — clip admin captures to exclude broken-link tables (they read negatively at thumbnail size).
- Icon/banner artwork is manual — screenshots are scripted; icons and
banners still need design work (documented in the
.wordpress-org/README.md). - The QA site's plugin dir can be a SYMLINK into the mounted worktree.
On the QA stack,
wp-content/plugins/nvoos-docs-hubsymlinks tomcp-ai-wpoos/addons/docs-hubof the bind-mounted worktree —docker cpandtar -Cfollow the symlink and write into the OTHER worktree's tracked files (andrm -rf …/node_modulesthere deletes it). To stage new code on the QA site:rmthe symlink, extract into a real dir, capture, then restore the symlink (ln -s …). If you already polluted the other worktree:git restore <addon>+ delete only the files you introduced, and note the lostnode_modules/(regenerable vianpm ci). - Rebuilding the QA index via wp-cli without the base plugin mount breaks
it — the manifest records
base_version: ""; the settings-page version guard then sees a mismatch on every admin visit and clear+re-enqueue rebuilds forever (live cache keeps flashing tobuilt_at: 0, the SPA shows "No documents indexed yet."). Rebuild with the base-plugin path bind-mounted into the one-off cli container (or through the web UI), and verify the manifest'sbase_versionequalsWP_MCP_AI_VERSIONbefore capturing. - Clear cron + pipeline ticks before a long capture. The QA root cron
loop hits
/wp-cron.phpevery 60 s; a due daily rebuild (or a leftover chunked tick) clears the live cache mid-capture.wp eval "wp_clear_scheduled_hook( 'nvoos_docs_hub_rebuild_cron' ); NV_oOS_Docs_Hub_Rebuild_Job::unschedule();"first (the daily event re-schedules itself on the next page load — harmless, not due again). - wp-cli against the QA volume needs the env recipe: one-off
wordpress:cli-php8.2on the QA network with-e WORDPRESS_DB_HOST/NAME/USER/PASSWORD(the wp-config reads env with wrong defaults otherwise),--user 33:33(uploads owned by www-data),php -d memory_limit=512M /usr/local/bin/wp …(the 128 M cap fatals on the heavy plugin stack), and--allow-root. Root-owned uploads subdirs (created by root-tooling) block www-data writes —chown -R www-databefore rebuilding. - Verify clip geometry without vision. When a new screenshot clips a
composed region (e.g. Save button + upsell card), prove the clip with a
Playwright probe that re-computes both
boundingBox()es and asserts they fall inside the captured rect (a vision-less model can verify geometry, text, and textContent, just not pixels). Adding a screenshot requires updatingreadme.txt == Screenshots ==AND the.wordpress-org/README.mdasset table in the same commit.
readme.txt standards
Contributors:is a case-sensitive, comma-separated list of wp.org usernames — not GitHub orgs (the review flaggednvdigitalsolutionsbecause the account isvsamtani). Only list accounts that exist; drop org names with no wp.org account.== External Services ==section: every third-party host contacted, server-side-only wording, data-sent statement, Terms + Privacy URLs. The host list must match the code's allowlist exactly (docs-hub:api.github.comraw.githubusercontent.com).
== Source Code ==section (Guideline 4): public repo URL, where the frontend source lives (src/), the exact build steps (npm install && npm run build), and where dev-only files live. The bundled dist files should be self-describing too: esbuildbanner: { js, css }with a/*!-style header (source URL + build command + license).== Screenshots ==section with1. Alt textlines matchingscreenshot-{N}.pngin SVN assets; add when the PNGs land.Stable tagmust equal the plugin headerVersion;Tested up tolatest WP.Tested up tomust be major.minor only (7.1) — when the value matches the latest stable, a patch-level value (7.1.1) is a PCP ERRORinvalid_tested_upto_minor("The version number should only include major versions"). A major.minor7.1against latest7.1.xis NOT stale (PCP compares major.minor). Validate the suite against the patch release anyway — declare major.minor, back it with the patch run.- Trim tags to directory-standard tags (PCP flags exotic tags).
- Text Domain header must equal the slug for the ZIP dir name on wp.org.
Skill bookkeeping (when adding skills like this one)
Adding a coding-time skill requires count updates in: AGENTS.md §1
(inventory table row + coding-time-vs-runtime paragraph),
.github/copilot-instructions.md (multi-agent-awareness bullet + repo-tree
comment), and the README.md repo-map row. Executed-release lines are left
untouched; the next docs catch-up (.agents/skills/mcp-ai-wpoos-updates)
folds the new skill into the next release notes.
References
- Docker one-off runner patterns:
.agents/skills/mcp-ai-wpoos-test-suite/SKILL.md(cross-worktree runs, shared-DB etiquette) - Plugin operational guide:
.agents/skills/mcp-ai-wpoos-plugin/SKILL.md - Maintenance tracks:
.agents/skills/mcp-ai-wpoos-updates/SKILL.md - Checkout/commerce flow integration:
.agents/skills/mcp-ai-wpoos-checkout-integration/SKILL.md - Capture scripts:
bin/capture-nvoos-docs-hub-screenshots.js,bin/capture-nvoos-content-graph-screenshots.js,bin/README-SCREENSHOT-TOOLS.md