wp-audit-standards · git:20261001.f11409c · 2026-10-01 · sha256 0e4ceadedc3ec5ab
wp-audit-standards git:20261001.f11409cA
Immutable. This exact content never changes and is served at /api/v1/blob/0e4ceadedc3ec5ab.
---
name: wp-audit-standards
description: Audit criteria, severity definitions, report JSON schema, and quality thresholds for wp-audit agents
user-invocable: false
---
# WP Audit Standards
This skill defines the audit criteria, severity levels, report format, and quality thresholds used by all wp-audit agents.
---
## Severity Levels
| Severity | Meaning | Action |
|----------|---------|--------|
| **CRITICAL** | Security vulnerability, complete accessibility failure, broken core functionality | Fix immediately |
| **WARNING** | Best-practice violation, degraded UX, performance issue | Fix before delivery |
| **INFO** | Optimization opportunity, minor improvement | Fix when convenient |
---
## Report JSON Schema
All audit agents MUST output findings in this format:
```json
{
"category": "security|seo|a11y|performance|best-practices|geo|usability",
"tier": 1,
"issues": [
{
"severity": "critical|warning|info",
"code": "SEC-001",
"message": "Human-readable description",
"file": "path/to/file.php",
"line": 42,
"auto_fixable": true,
"fix_method": "Description of how to fix"
}
],
"summary": {
"total": 0,
"critical": 0,
"warning": 0,
"info": 0,
"auto_fixable": 0
}
}
```
### Field Requirements
- `category` — one of: `security`, `seo`, `a11y`, `performance`, `best-practices`, `geo`, `usability`
- `tier` — integer 1–3 indicating which audit tier produced the finding
- `issues` — array of issue objects (may be empty)
- `severity` — one of: `critical`, `warning`, `info`
- `code` — prefixed issue code (see Issue Code Prefixes below)
- `message` — human-readable description of the problem
- `file` — relative path to the affected file
- `line` — line number (0 if not applicable)
- `auto_fixable` — boolean indicating whether the agent can safely fix this
- `fix_method` — description of the fix approach
- `summary` — counts aggregated from the issues array
---
## Issue Code Prefixes
| Prefix | Domain |
|--------|--------|
| `SEC-xxx` | Security |
| `SEO-xxx` | SEO |
| `A11Y-xxx` | Accessibility |
| `PERF-xxx` | Performance |
| `WP-xxx` | Best Practices |
| `GEO-Dnn`, `GEO-Axx`, `GEO-Uxx`, `GEO-Pxx` | GEO / AI-agent readiness, by ORA layer |
| `UX-xxx` | Usability |
| `A11Y-AXE-*`, `PERF-LH-*` | Evidence rows from the browser suite — measurements of an existing criterion, never criteria of their own |
---
## Site type and local clones
Two properties of the project change which checks apply. `/wp-audit` Step 2.3 reads them
once and every agent honours the result; this is the methodology behind that step.
### Gate by site type, do not delete
A store (WooCommerce active) has surfaces a generic site does not: a cart, a checkout, an
account area, priced-per-currency markup, protected paid files. Checks written for those
surfaces are **`N/A` on a non-commerce site**, reported with the reason and excluded from
the denominator — never silently dropped, and never counted as failures. The inverse also
holds: a commerce check must not fire on a site with no WooCommerce, or the score punishes a
site for lacking a feature it never claimed. Adding commerce depth therefore leaves a
generic site's score unchanged, because every commerce check reads `N/A` on it.
### A local clone is audited for production's posture
A project restored from a backup to run locally (`.wp-create.json` `project.source:
"restore"`, a `wordpress.url_origin`, or a non-public `wordpress.url` — see `/wp-audit`
Step 2.3 for the exact field paths and the host list) has been deliberately altered to work
in isolation. Those alterations — a dev host in the database, deactivated payment/cache/mail
plugins, `DISABLE_WP_CRON`, absent object-cache drop-ins, debug logging on, media uploaded
after the file backup was taken — are the price of the copy, not defects of the site. On a
clone they are **`N/A (local clone)`**, out of the denominator, and not printed as findings.
The catalog and the exact rule live in `/wp-audit` Step 2.3. The one test that keeps this
honest: *would this also be true on production?* If yes, it is a finding; if it exists only
because this is a copy, suppress it.
### Link and page sweeps against a site
Any check that requests many URLs of the audited site — broken links, a rendered-head
snapshot, a page walk — follows these limits. A local site shares one database and one web
server with every other project on the machine, and an uncached WordPress page is
expensive: an improvised crawler with 25 concurrent `curl -L` workers over a store's term
archives once held MariaDB at ~18 cores and load 18, slowing every site on the box.
- **At most 4 requests in flight.** Never a thread pool sized to the machine. This protects
a local machine. A production host is paced by "Production sits behind a WAF" below.
- **Status only, so no body.** `HEAD`, or `curl -r 0-0` when a server refuses `HEAD`. A
full render is paid only when a check reads the page itself.
- **Resolve internal targets through WP-CLI or the database first.** Whether a post or
term exists and is published is a query, not a page render.
`skills/wp-cli-patterns/scripts/resolve-link-targets.php` does it: a link counts as
resolved only when the object is published and its own canonical URL has the link's
path, so it may send a good link to HTTP but never marks a broken one resolved. Only
what it cannot answer — drafts, query strings, redirects, external links, rewrite rules
a plugin owns — needs a real request.
- **Sample term archives: 20 per taxonomy**, unless the operator asked for a full sweep.
Hundreds of author or category archives are one template; twenty of them say whether it
works. The resolver tags each link it passes on with its taxonomy, and the sweep samples
by that tag (falling back to the first path segment for untagged links).
- **Use `bin/link-sweep.mjs`** rather than writing a crawler. It applies every limit above,
classifies each link as internal, clone-origin (the `wordpress.url_origin` host, never
requested from a clone unless that host was confirmed this run) or external, reports a
CDN bot challenge as `UNMEASURED` instead of broken, and stops at a wall-clock budget:
```bash
# links.txt: one href per line, TAB, the page it was found on
$WP eval-file ${CLAUDE_PLUGIN_ROOT}/skills/wp-cli-patterns/scripts/resolve-link-targets.php \
links.txt resolved.json > http.txt
node ${CLAUDE_PLUGIN_ROOT}/bin/link-sweep.mjs --site "$SITE_URL" --urls http.txt \
--clone-origin "$URL_ORIGIN" --budget 120 > sweep.json
```
`resolved.json` lists what the database answered, with the pages carrying each link;
those are resolved, not unmeasured.
Links it did not request (sampled out, over budget, challenged, clone-origin) come back
`unmeasured` with the reason. Report them as `UNMEASURED` with the count, never as a pass.
### Live checks target production, and the URL is confirmed
Response headers and paid-file reachability can only be judged against the running
production site. A local server answers them differently — it reads `.htaccess` a production
nginx ignores — so a live check run against the clone is a false result, not a lenient one.
These checks use `--host`, or ask the user for the production URL (defaulting to
`wordpress.url_origin`) and fire no external request until it is confirmed; with no public
URL they are `UNMEASURED`, never `PASS`.
### Production sits behind a WAF: same measurements, one request at a time
Production servers run fail2ban, CrowdSec and ModSecurity. An audit must not trip them
unless the operator asks for it explicitly. One audit got its machine's IP banned in five
minutes:
- Seven agents were dispatched in parallel against the production host, each allowed 4
requests in flight, so up to 28 ran at once, plus the Lighthouse and Playwright loads.
- The security agent probed `readme.html`, `?author=1` and `/wp-json/wp/v2/users` in the
same burst.
Leaky-bucket rules count requests per window, so the burst filled them, not the paths
themselves. Every live check of that run was lost, and one agent's retries lengthened the
ban.
The fix changes **the pace, never the measurement**. Every check still runs the same command
against the same host, with the same headers and user agents. That covers Lighthouse,
Playwright, the suite, the GEO probes with AI user agents and `Accept: text/markdown`, and
the security probes. Against a public host:
- **Every request goes through `bin/prod-gate.sh`.** It holds a per-host lock shared by all
the agents of the run, so production sees one command at a time. It waits 2 s between
commands, and use `--delay 10` before reconnaissance-shaped paths: readme, license,
`?author=`, the users REST route, xmlrpc and login. A Lighthouse run or a Playwright launch
is one gated command. Browsers load a page's assets in parallel, as any visitor's would.
- **Sweeps run at `--concurrency 1 --delay-ms 1000 --stop-on-block`, with a budget sized to
match.** Exit `4` from the sweep means it stopped at a block: mark the host blocked. Use
`--budget` of at least one second per link plus the timeout, so the slower pace does not
turn links into `budget exhausted`. The suite runs with one Playwright worker:
`bin/audit-suite.sh` does this itself for a public URL.
- **The first block ends all production traffic, with no retry.** `prod-gate.sh` marks the
host blocked on curl's refused or reset codes. The caller marks it blocked with
`--mark-blocked` on any of these:
- a `429`
- a `403` carrying a WAF signature (`mod_security`, `crowdsec`, `cf-mitigated`, a captcha)
- `ERR_CONNECTION_REFUSED` in a browser
After that, every gated call exits `4` without sending anything. Exit `5` only means
another agent held the host past `--wait`. Nothing was sent, so call again. Every live check not yet
answered is `UNMEASURED: production blocked the audit`. A retry against a ban extends the
ban.
**A local site keeps its own limits, unchanged.** `prod-gate.sh` passes a development host
straight through, and nothing above applies to it. The local rule is "Link and page sweeps
against a site", above.
---
## Audit Tiers
### Tier 1 — Code-only (always available)
File scanning via Read, Grep, Glob. No runtime environment required.
### Tier 2 — + WP-CLI runtime (when `.wp-create.json` exists)
Plugin management, option reading, database queries. Requires a working WordPress installation with WP-CLI access.
### Tier 3 — + Browser measurement (when a browser automation tool is available)
Lighthouse-style browser audits: Core Web Vitals, rendered-page checks, performance traces.
Requires a browser automation tool — Playwright MCP, Chrome DevTools MCP or Claude in
Chrome. It adds measurement, never criteria: every threshold Tier 3 measures against is
recorded in this skill and in the audit agents, and a check a file scan can answer runs at
Tier 1 whether or not a browser is present.
---
## Performance Budgets
| Resource | Budget |
|----------|--------|
| CSS compressed | <100KB |
| JS compressed | <300KB |
| Total page weight | <1.5MB |
| Images above-fold | <500KB |
| Fonts total | <100KB |
---
## Accessibility Thresholds
| Requirement | Minimum |
|-------------|---------|
| Normal text contrast | 4.5:1 |
| Large text contrast (>=18px or >=14px bold) | 3:1 |
| UI component contrast | 3:1 |
| Touch target size | 24x24 CSS pixels fails (WCAG 2.2 AA 2.5.8), measured at desktop and mobile; 44x44 is AAA advice (INFO) |
| Minimum gray on white passing 4.5:1 | `#767676` |
---
## Core Web Vitals Targets
| Metric | Good | Needs Work | Poor |
|--------|------|-----------|------|
| LCP | ≤2.5s | 2.5s–4s | >4s |
| INP | ≤200ms | 200ms–500ms | >500ms |
| CLS | ≤0.1 | 0.1–0.25 | >0.25 |
---
## Performance — Hard-won Lessons (Tier 3 / Lighthouse)
Interpretation traps and fixes that actually move production bytes. Consult before filing or fixing a `PERF-xxx` finding from a Lighthouse run.
### Read *observed* metrics before chasing a bad *simulated* score
Lighthouse's default (`throttlingMethod: "simulate"`) reports **Lantern-simulated** LCP/FCP/TTI on a modeled slow-4G + 4× CPU. On a **dev server** this is dominated by local TTFB + the throttle model and can read 6–7 s while the page is actually instant. Before treating a high LCP as real, open the full JSON and compare:
- `audits.metrics.details.items[0].largestContentfulPaint` (simulated) **vs** `...observedLargestContentfulPaint` (real paint).
- If observed is ~200–900 ms and simulated is multi-second, the number is a **simulation/dev-server artifact** — the score barely moves regardless of theme changes, and production (with page cache + real CDN/TTFB) differs. Say so in the finding instead of burning effort chasing it. **Real byte reductions (WebP, right-sizing) still help production** and still shrink the *LCP resource*, so do those — just set score expectations.
- `image-delivery-insight`, `render-blocking-insight`, `lcp-discovery-insight` etc. are **weight-0** in the Performance score (informative). Only the 5 metric audits (FCP/LCP/TBT/CLS/SI) carry weight. Fixing a weight-0 insight is a production win, not a score win — label it accordingly.
### A Lighthouse run needs an idle machine, and a contended one is not a slow page
A Lighthouse score is a measurement of the machine as much as of the page. The same page, same
URL and same flags, measured on a busy laptop and then on an idle one, read **performance 62
with LCP 10,170 ms** and **performance 94 with LCP 1,580 ms**. Nothing in the theme changed
between the two runs. The first number is the kind that gets a morning spent on a
non-existent regression, and it is indistinguishable from a real one by inspection.
- **Never run Lighthouse next to anything else** — not a second Lighthouse, not the DOM/axe
suite, not a watch build, not a video call. Run it alone, and run the categories serially.
- **A page that "hangs" under contention is usually not hanging.** An internal search spec
that looked stuck, and was left out of the suite for it, completed in 4–7 s once Lighthouse
stopped competing with it; the whole suite went from 4.3 minutes to 1.0 minute.
- **Re-measure before filing a metric regression, on the idle machine, twice.** A single run
is not evidence. Where the two runs disagree by more than a few points, say so in the
finding rather than reporting the worse one.
- The same applies to a *before/after* pair for a fix: both halves must be measured under the
same conditions, or the fix's number is the machine's number.
### Inline critical CSS: measured on a real site, and rejected
Inlining the critical CSS is the standard advice for a render-blocking stylesheet, and on a
real build it made the metric it was meant to fix **worse**. Recorded here so the experiment is
not repeated blind:
| Variant | FCP | LCP | CLS |
|---|---|---|---|
| Baseline | 990 ms | 2950 ms | baseline |
| 43KB critical CSS inlined | **570 ms** | **3150 ms** | improved |
| 13KB (above-the-fold only) | improved | — | **0.139** |
| Preloading jQuery + carousel + page JS | **1340 ms** | — | — |
The inline block sits *ahead of the hero image on the same connection*, so the paint that
counts starts later even though the first paint starts sooner. The trimmed variant is smaller
than the styles the first viewport actually needs, which is what moved CLS to 0.139. All of it
was reverted with zero pixels of difference.
**The lesson is not "never inline".** It is that FCP and LCP move in opposite directions here,
so a change justified by FCP alone is unmeasured, and the LCP number decides. On that site the
real cause was elsewhere and only the Lighthouse breakdown showed it: 276 ms of *element
render delay*, because the carousel re-built the first slide inside its own track and the
second paint was the one being measured. A background the theme already painted without
JavaScript was not the problem.
### WebP: `image_editor_output_format` only covers NEW, attachment-pipeline images
`add_filter('image_editor_output_format', ...)` converts uploads to WebP **only on new uploads**, and **only images that flow through WP's attachment functions** (`wp_get_attachment_image`, `the_post_thumbnail`). Themes that print **raw SCF/ACF field URLs** (`echo $field['url']`) or CSS `background-image: url(<field>)` bypass it entirely, so existing hero/about/neighborhood/banner images stay JPEG/PNG.
To serve WebP for **existing + SCF-driven** images without touching every template:
1. Pre-generate `.webp` siblings for existing uploads (one-time): `find uploads -iname '*.jpg' -o -iname '*.png'` → `magick "$f" -quality 82 "${f%.*}.webp"` (hero/LCP images can go lower, ~q68, since they sit behind scrims or are video-replaced).
2. Add a front-end output-buffer that rewrites finished HTML — covers `src`, `srcset`, and inline `background-image` in one pass:
```php
add_action( 'template_redirect', function () {
if ( is_admin() || is_feed() || is_robots() ) return;
$u = wp_get_upload_dir(); $base = $u['baseurl']; $dir = $u['basedir'];
ob_start( function ( $html ) use ( $base, $dir ) {
$pat = '#' . preg_quote( $base, '#' ) . '/[^"\'\)\s]+?\.(?:jpe?g|png)#i';
return preg_replace_callback( $pat, function ( $m ) use ( $base, $dir ) {
$webp = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $m[0] );
return file_exists( $dir . substr( $webp, strlen( $base ) ) ) ? $webp : $m[0];
}, $html );
} );
} );
```
`template_redirect` is front-end-only, and with a page cache (WP Super Cache) the buffer runs once per cache build. WebP is universally supported by target browsers — matching the theme's existing unconditional `image_editor_output_format` policy — so no `Accept`-header branching is needed.
### Right-size — never print `$field['url']` for a fixed slot
Lighthouse "responsive-size" waste = serving a 2200px original in a 400px slot. Raw SCF field URLs (`$image['url']`) always emit the **full original**. Use the attachment ID so the browser gets a `srcset`:
```php
echo wp_get_attachment_image( (int) $image['id'], 'large', false, array(
'class' => 'about__bg', 'alt' => $heading, 'loading' => 'lazy',
'sizes' => '(max-width: 899px) 100vw, 136vw', // match the real CSS display width
) );
```
Pick `sizes` from the element's actual rendered width (full-bleed → `100vw`; a 136%-wide bg → `136vw`; a 136px logo → `136px`). This composes with the WebP buffer above (the srcset `.jpg` URLs are rewritten to `.webp`).
### Hero LCP pattern (poster-first, video deferred)
Make the LCP element a **small poster image**, not the video: `<img fetchpriority="high" width/height>` + a `<link rel="preload" as="image">` for it in `wp_head`; lazy-load the background `<video>` via JS on **desktop only** (`min-width:1024px`), after the poster paints (`requestIdleCallback`), and **never** on mobile / `navigator.connection.saveData`. Preload the poster's WebP so the preload and the rendered `src` match (else the preload is wasted).
### Render-blocking CSS
Dequeue the near-empty theme `style.css` on the front end (it usually carries only the WP header comment; runtime styles live in the compiled bundle). Async-load below-the-fold plugin CSS (e.g. Contact Form 7) via `style_loader_tag` → `media='print' onload="this.media='all'"`.
### AIOS × Lighthouse gotcha (Tier 2 ↔ Tier 3 interaction)
If the security agent sets AIOS `aiowps_disallow_unauthorized_rest_requests = 1`, it returns **403** on CF7's REST endpoints (`/wp-json/contact-form-7/v1/...`). Lighthouse then **hangs up to 45 s** on those pending requests and logs console errors → Best-Practices drops (often 100 → 96) and metrics inflate. If a CF7 form is present, leave that AIOS setting off (or whitelist the CF7 REST namespace). Symptom in the JSON: `errors-in-console` / pending `Fetch` requests to `contact-form-7` with `statusCode: 403`.
### Auto-fixable
Agent can safely fix without risk of breaking the site:
- Add missing attributes (e.g., `alt`, `aria-label`, `loading`)
- Append CSS rules
- Set wp-config constants
- Install/configure plugins via WP-CLI
- Add missing escaping wrappers
- Add missing nonce checks to simple forms
### Manual-only
Requires human judgment:
- Restructuring templates
- Changing application logic
- Removing code
- Design decisions
- Complex refactoring
---
## Deduplication Rules
When multiple agents find the same issue:
- **Security agent** owns vulnerability-class checks (XSS, SQLi, CSRF)
- **Practices agent** owns coding-standards checks (escaping for theme review compliance)
- **SEO agent** owns heading hierarchy for search ranking context
- **A11y agent** owns heading hierarchy for screen reader navigation context
- **Command** deduplicates identical file:line findings, keeping the highest severity
---
## Agent Interaction Model
All plugin interaction via WP-CLI options/meta, never PHP APIs:
| Operation | Command |
|-----------|---------|
| Read plugin config | `$WP option get <option_name>` |
| Write plugin config | `$WP option update/patch` |
| Seed data | `$WP post meta update` |
| Complex operations | `$WP eval "php code"` |
| Install plugins | `$WP plugin install --activate` |
| Theme modifications | Direct file Edit/Write |
| wp-config changes | `$WP config set` |