# Audit Verdict: wb-listora

**Branch:** 1.1.0 (pre-release, code at commit 3aa8620)
**Audited:** 2026-06-03
**Auditor:** AutoVAP full-lens pass

---

## Shippable? NO — VERSION SKEW BLOCKER

The plugin header and WB_LISTORA_VERSION constant read `1.0.5`. The CHANGELOG.md and readme.txt both say `1.1.0`. The smoke-pass JSON records `release_version: 1.0.5`. Tagging or distributing the current tree would ship a mislabeled product: auto-update clients would never see this as an upgrade from 1.0.5, the EDD license activation stores the wrong version, and `wb_listora_is_pro_active()` version-gating in Pro breaks. Fix the version bump first; then the product is otherwise ready.

## Sellable? YES WITH POLISH

The core feature set is complete, secure, and well-engineered. A paying customer would find a fully-functional directory plugin. Three minor polish issues (admin list truncation at 50 rows, small-button tap-target below 40px on the `--sm` variant, and the dashicon-migration mapping table surfacing in advisory output) would surface only in high-volume or accessibility-audit scenarios. None is a customer-facing regression.

---

## Findings (ranked: Blocker → Major → Minor → Polish)

| # | Severity | Lens | Finding | File:line | Suggested journey to fix |
|---|----------|------|---------|-----------|--------------------------|
| 1 | **Blocker** | Standards / Release | Version skew: `WB_LISTORA_VERSION` and plugin header say `1.0.5`; `CHANGELOG.md`, `readme.txt` Stable tag, and smoke-pass target say `1.1.0`. Distributing the current tree ships an incorrectly-versioned build — auto-updates silent, EDD license registration wrong, Pro version-gate misfires. | `wb-listora.php:6,22` | `fix` — bump `Version:` header and `WB_LISTORA_VERSION` constant to `1.1.0`; re-run `bin/build-release.sh`. |
| 2 | **Major** | Performance / Big-site | `wb_listora_prepare_card_data()` runs one `SELECT avg_rating, review_count FROM listora_search_index WHERE listing_id = %d` query per card inside the listing-grid foreach loop. At 20 listings/page this is 20 extra queries per page render in addition to `Meta_Handler::get_all_values()` per listing. No `update_meta_cache()` or batch-fetch is called before the loop in `blocks/listing-grid/render.php`. At 100k+ listings under load this is the dominant TTFB driver on archive pages. | `blocks/listing-grid/render.php:137–142`, `includes/class-template-helpers.php:461–466` | `improve` — extract a `Search_Indexer::get_index_rows_for_ids( array $ids ): array` batch query (single `IN(…)` call), prime a keyed map before the loop, then read from the map inside `wb_listora_prepare_card_data`. The REST `Listings_Controller` already does the correct prefetch pattern (line 177) — replicate it here. |
| 3 | **Major** | Performance / Big-site | Admin Reviews page (`listora-reviews`) and Claims page (`listora-claims`) both query with `LIMIT 50` and no OFFSET, no total COUNT, no page nav, no `?paged=` parameter. On a high-volume site with 5,000+ reviews an admin can never see items past position 50, and there is no indicator that rows are hidden. | `includes/admin/class-admin.php:1177, 1520` | `improve` — implement `WP_List_Table`-style pagination: add `?paged=N` URL param, compute `$offset = ($paged-1)*50`, emit `paginate_links()` below the table, add a matching `SELECT COUNT(*)` for the tab count display. |
| 4 | **Major** | Performance / Big-site | Calendar block (`blocks/listing-calendar/render.php`) fetches ALL recurring events of the selected type with no LIMIT: `SELECT … WHERE … AND pm_rec.meta_value IN ('daily','weekly','monthly') ORDER BY pm.meta_value ASC`. On an event directory with 10,000 recurring listings this pulls the full table into PHP on every calendar render. A monthly-view calendar only ever needs events whose next occurrence falls within the 42-day display window. | `blocks/listing-calendar/render.php:68–82` | `improve` — add a `LIMIT 500` safety cap (matching `Search_Engine::MAX_PHASE_1_CANDIDATES` pattern), log a warning when the cap is hit, and add a `_listora_start_date` + recurrence window pre-filter to exclude events whose range can never intersect the displayed month. |
| 5 | **Major** | Standards | Version constant `WB_LISTORA_VERSION = '1.0.5'` is used for EDD license registration (`item_id: 1662779`) and credits SDK `version:` registration. Mismatched version causes the EDD store to log license activations under the wrong version, potentially invalidating update entitlement checks on the customer's site. | `wb-listora.php:460, 620` | Resolved by fixing finding #1. |
| 6 | **Minor** | Security | `includes/class-cli-commands.php:98` interpolates `$prefix.$table` directly into a `SHOW TABLE STATUS LIKE '…'` query without `$wpdb->prepare()`. While `$prefix` is `$wpdb->prefix . 'listora_'` (not user-supplied) and `$table` is from a hardcoded array, the PHPCS suppress comment misidentifies this as `WordPress.DB.PreparedSQL.InterpolatedNotPrepared`; a future maintenance dev could inadvertently make `$table` user-sourced. It also leaves the query unguarded against a site with a malicious table prefix injected via a compromised `wp-config.php`. | `includes/class-cli-commands.php:98` | `fix` — use `$wpdb->prepare( "SHOW TABLE STATUS LIKE %s", $prefix . $table )`. |
| 7 | **Minor** | Performance | `wb_listora_settings` option is stored with `update_option( 'wb_listora_settings', … )` (no explicit `$autoload = false`). WordPress defaults to autoload for options without an explicit parameter, meaning the entire settings array (~40 keys including `google_maps_key`, `captcha_secret_key`, captcha site key, notification toggles, per-role limit arrays) is loaded on every single WordPress request, even pure REST or WP-CLI calls that never need it. The `wb_listora_get_setting()` function-static cache is a good read-cache, but the initial DB hit is unnecessary on non-Listora requests. | `includes/rest/class-settings-controller.php:401, 613`, `includes/admin/class-settings-page.php` (via `register_setting`) | `improve` — pass `'no_autoload' => true` (WP 6.6+) / `autoload = 'no'` to `register_setting` and `update_option` calls. On WP < 6.6 use `add_option( …, false )` for new installs and a one-time migration on upgrade. |
| 8 | **Minor** | Performance | `wb_listora_notification_log` option (`LOG_MAX_ENTRIES = 1000`) stores up to 1,000 email-log entries as an autoloaded PHP-serialized array. Each entry has 6 fields; at 1,000 entries this is typically 50–100 KB of autoloaded data. `get_option( LOG_OPTION_KEY )` is called from cron, REST, and admin code paths, and `update_option` is called with `false` autoload — but the option can still end up autoloaded if created on older WP without the explicit parameter. | `includes/workflow/class-notifications.php:22, 1217, 1228` | `improve` — ensure `LOG_OPTION_KEY` is always registered with explicit `autoload = 'no'`. Consider migrating this log to a dedicated custom table (or the existing `listora_analytics` table) to eliminate the option-table size pressure. |
| 9 | **Minor** | UX / Big-site | `listora-btn--sm` class sets `min-height: 32px` — below the WCAG 2.5.5 (Level AA) 44px tap target and also below the wppqa Rule 4 recommended floor of 40px. This is the same 2 new frontend tap-target warnings the 2026-05-24 wppqa baseline flagged in `assets/css/listora-components.css:309`. The `--sm` variant is used on the admin dashboard listing rows and service-action buttons. While the CLAUDE.md marks admin-context tap-target warnings as expected/non-blocking, the `--sm` variant is also used in customer-facing contexts (search filter buttons, submission form). | `assets/css/listora-components.css:311` | `improve` — raise `--sm` min-height from `32px` to `36px` (density-exception floor per ux-foundation) for customer-facing uses; keep admin tables at 32px by scoping the override to `.wb-listora-admin .listora-btn--sm`. |
| 10 | **Minor** | UX / Big-site | Seven distinct CSS breakpoints are in use: `480px`, `600px`, `640px`, `767px`, `768px`, `782px`, `1024px`. The `ux-audit` advisory flags `> 3 distinct breakpoints`. The `600px` and `768px` breakpoints appear only a few times (3 and 4 occurrences) and likely duplicate the 640px / 767px canonical values. Having 7 breakpoints also inflates per-block CSS file sizes and makes responsive debugging non-deterministic. | `blocks/*/style.css` (multiple) | `improve` — consolidate to exactly 3 breakpoints: `480px` (mobile), `640px` (tablet-portrait), `1024px` (tablet-landscape). Migrate the 3× `600px` and 4× `768px` occurrences; drop the WordPress admin `782px` to `admin.css` only. |
| 11 | **Minor** | Pro contract | `cross-plugin-coupling.json` was last computed `2026-05-08` and lists only 7 Free→Pro pairs, while CLAUDE.md refers to 29 pairs (from the 2026-05-21 manifest refresh). The derived cache is stale by ~25 days and 18 pairs. A stale coupling map means the Pro team cannot reliably know which Free hooks they can safely rename, and the `cleanup-boundary-check.sh` gates run against incomplete data. | `audit/derived/cross-plugin-coupling.json` | `fix` — run `php bin/cleanup-duplicate-detect.php && bash bin/cleanup-boundary-check.sh` on the current `1.1.0` tree and commit the refreshed `audit/derived/` files before tagging. |
| 12 | **Polish** | UX | `includes/core/class-listing-type-registry.php:331–340` contains a dashicon-to-Lucide migration map. The UX audit Rule 5 advisory fires on these 10 lines because they reference dashicon names as string literals. These are intentionally legacy values for migrating existing customer data — they are not new dashicon usages. The comment context makes this clear, but a brief inline `// phpcs:ignore — dashicon migration map only` and a code comment documenting that new icons must use Lucide names would suppress the advisory permanently and prevent future accidental additions. | `includes/core/class-listing-type-registry.php:331–340` | `improve` — add `@note: dashicon → Lucide migration map. Keys are legacy dashicon slugs for existing customer data; never add new dashicons here.` in the method PHPDoc. |
| 13 | **Polish** | Standards | `phpstan-baseline.neon` is 3,967 lines — 102 type-annotation gaps baselined as legacy debt per the 2026-05-08 CI restoration note. The baseline is not growing (no new suppressions in the 1.1.0 window), but it represents significant hidden type-safety debt. PHPStan level 7 with 102 silenced errors means the static analysis gate provides weaker guarantees than the level implies. | `phpstan-baseline.neon` | `improve` — schedule a dedicated "baseline burn-down" sprint: target reducing by 25 entries per release. Start with the `missingType.return` cluster (easiest: add return-type annotations). |

---

## Scores

| Lens | Grade | Notes |
|------|-------|-------|
| **Security** | A- | Zero critical XSS/SQLi/CSRF/auth-bypass findings. Full nonce coverage on write operations. Rate limiter is production-quality. REST permission callbacks consistent. One minor: CLI SHOW TABLE STATUS interpolation (#6). Public endpoints (`__return_true`) are intentional read-only surfaces (search, map config, public types). Captcha + anti-spam + honeypot all present. |
| **Performance** | B | Search engine correctly uses transient cache + MAX_PHASE_1_CANDIDATES cap. REST endpoints prefetch meta/term caches correctly. Action Scheduler migration complete. Autoloadable setting option, N+1 in listing-grid SSR loop, and unbounded calendar recurring query are the three real regression risks at scale. |
| **UX / Design system** | A- | Zero block-severity violations from ux-audit script. Token system is mature (107 v2 tokens, compiled CSS). Dark mode is token-root-only (correct). RTL via logical properties throughout. 7 breakpoints vs canonical 3 is the only structural drift. `--sm` button tap-target 4px short of 40px floor. All icon-only buttons have aria-labels. |
| **QA** | B+ | Smoke pass at 1.0.5 (2026-05-25) clean (0 failures, 0 debug log issues). 23 commits since last smoke pass — all 9 confirmed 1.1.0 bugs have commits claiming to fix them; a re-smoke at 1.1.0 is required before tagging. 10 integration + 2 unit tests present. Journey system (19 journeys) well-maintained. |
| **Standards** | B+ | WPCS clean (pre-push hook active). PHPStan level 7 with 3,967-line baseline (102 real debt items). Composer CI pipeline in place. The version-skew (#1) is a release-discipline issue, not a code-quality issue. |

---

## Pro Extension Contract Health

Free→Pro surface is structurally sound: 226 fired hooks (120 actions + 106 filters), Contracts namespace, `wb_listora_service()` locator, INV-3 boundary check at 0 violations. The coupling cache is stale (7 pairs cached vs 29 documented) — this is a tooling gap, not a breakage. The three known duplicate-consolidation backlog items (`set_taxonomy_terms`, `html_to_text`, migrator context arg) are documented and carry consolidation plans; none breaks the Pro contract today.

The default settings whitelist (`wb_listora_get_default_settings()`) correctly declares `pagination_type` so Pro's `load_more`/`infinite_scroll` writes are not silently dropped. `wb_listora_is_pro_active()` guard uses both `defined('WB_LISTORA_PRO_VERSION')` and `did_action('wb_listora_pro_loaded')` — correct upscale-model pattern.

---

## Release Checklist (before tagging 1.1.0)

1. Bump `Version:` in `wb-listora.php` to `1.1.0` AND update `WB_LISTORA_VERSION` constant to `'1.1.0'`. (Blocker #1)
2. Refresh `audit/derived/cross-plugin-coupling.json` by running `php bin/cleanup-duplicate-detect.php`. (Minor #11)
3. Run `/wp-plugin-smoke combo` at 1.1.0. Smoke must show `release_version: 1.1.0`, `failures: []`, `debug_log_issues: []`. The 23 post-smoke commits include 9 bug fixes that have not been browser-verified against the current tree.
4. Run `bash bin/architecture-checks.sh` — all 12/12 invariants must pass.
5. Run `bin/build-release.sh` — it re-validates the smoke report as defense-in-depth.

