Skip to content

chore: PHPStan level 5 in CI + dead-code cleanup (sprint 1)#22

Merged
oxyc merged 3 commits into
masterfrom
fix/v0.5.1-foundation
May 29, 2026
Merged

chore: PHPStan level 5 in CI + dead-code cleanup (sprint 1)#22
oxyc merged 3 commits into
masterfrom
fix/v0.5.1-foundation

Conversation

@oxyc

@oxyc oxyc commented May 29, 2026

Copy link
Copy Markdown
Member

Sprint 1 of the post-audit cleanup, mirroring gds-assistant#33. Foundation pass: type-checker in CI, dead-code removal, real bugs surfaced. No user-visible behaviour change.

Started from 97 PHPStan errors at level 5 and went to zero.

PHPStan level 5 in CI

`phpstan/phpstan ^2.2` + `szepeviktor/phpstan-wordpress ^2.0` + `php-stubs/wp-cli-stubs ^2.12`. `composer stan` script + wired into the lint job.

Most of the noise (~70 errors) was third-party functions / classes only reached behind `function_exists` / `class_exists` guards (Polylang, Polylang Pro, ACF, Stream, Safe Redirect Manager, Redirection, WooCommerce, Sage CacheTags). One regex per integration in `phpstan.neon`'s `ignoreErrors` covers each.

Real fixes

One pattern, 14 errors

`RestDelegation` and the four ability classes that duplicated it all read `$typeObject->rest_namespace ?? 'wp/v2'`, but the property is typed `bool|string` (never null). Switched to `?:` so the fallback covers the empty-string / false case the `??` was trying to handle.

Unused property removal

  • `PostTypeAbility::$postType` and `TaxonomyAbility::$taxonomy` were unused promoted constructor args — removed, including the matching `new self(...)` call sites in `registerAll()`.

Other

  • `Plugin::getInstance()` now returns `self` instead of unsafe `new static()`.
  • `GetBlockAbility`: `$blockType->styles ?? []` is dead; `styles` is always an array.
  • `PatchBlockAbility`: removed the stale `! empty($blockType->render)` half of the dynamic-block guard (`render` doesn't exist; the modern API is `render_callback`); switched the guard itself to `is_callable($blockType->render_callback)`.
  • `TranslationAuditAbility`: dropped redundant `count > 0` after `empty()` on the same array.
  • `RestoreSnapshot::deleteTerm()` and `bulk()` had `WP_Error` in their return types but never returned one — tightened to `array`.

Pairs with

gds-assistant PR — same sprint, same shape, also lands PHPStan level 5 in CI.

oxyc and others added 3 commits May 29, 2026 10:42
Sprint 1 of the post-audit cleanup, mirroring gds-assistant#33. Started
from 97 PHPStan errors at level 5 and went to zero.

PHPStan stack (level 5, runs over `src/`):
- phpstan/phpstan ^2.2
- szepeviktor/phpstan-wordpress ^2.0
- php-stubs/wp-cli-stubs ^2.12

The bulk of the noise was third-party functions / classes only reached
behind `function_exists` / `class_exists` guards (Polylang, Polylang
Pro, ACF, Stream, Safe Redirect Manager, Redirection, WooCommerce,
Sage CacheTags). All of those moved into `phpstan.neon`'s
`ignoreErrors` block with one regex per integration so the analyser
doesn't demand stubs for runtime-gated code.

Real fixes:

- `RestDelegation` + the 4 ability classes that duplicated it all read
  `$typeObject->rest_namespace ?? 'wp/v2'`, but the property is typed
  `bool|string` (never null) — switched to `?:` so the fallback covers
  the empty string / false case the `??` was trying to.
- `WP_Post_Type::$rest_namespace ?? ''` patterns folded down the same
  way across `GenericPostTypeAbility`, `GenericTaxonomyAbility`,
  `PostTypeAbility`, `TaxonomyAbility` — 14 errors gone with one
  conceptual fix.
- `PostTypeAbility::$postType` and `TaxonomyAbility::$taxonomy` were
  unused promoted constructor args — removed (plus the matching `new
  self(...)` call sites in `registerAll()`).
- `Plugin::getInstance()` now returns `self` instead of unsafe
  `new static()` on a non-final class.
- `GetBlockAbility`: `$blockType->styles ?? []` was dead; `styles` is
  always an array.
- `PatchBlockAbility`: removed the stale `! empty($blockType->render)`
  half of the dynamic-block guard (the property doesn't exist; the
  modern API is `render_callback`). Switched the guard itself to
  `is_callable($blockType->render_callback)` which matches the
  runtime-honest check WP_Block_Type's stub doesn't let PHPStan see.
- `TranslationAuditAbility`: dropped a redundant `count > 0` after an
  `empty()` check on the same array.
- `RestoreSnapshot::deleteTerm()` and `bulk()` had `WP_Error` in their
  return types but never returned one — tightened to `array` so the
  type matches reality.

The handful of remaining stub imprecisions (PHPStan can't see past
WP's `render_callback: callable`, treating it as always-callable and
the unreachable branches that follow as dead code) sit in
`phpstan.neon` `ignoreErrors` with paths scoped to the affected file +
a comment explaining the stub gap, so they don't mask other findings.

CI:
- New `composer stan` script.
- `lint` job in `.github/workflows/test.yml` now also runs
  `composer stan` so PHPStan lands alongside Pint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprint 2 of the post-audit cleanup. Two targeted security fixes.

## XPath DoS guards (WebFetchAbility::applyXPath)

The XPath fetch path accepts an LLM-supplied expression and runs it
against the fetched DOM. The result-iteration was already bounded
(500 nodes, 1 MB), but the `$xpath->query()` call itself is
unbounded — pathological expressions (stacked descendant axes like
`//.//.//*//.//.//*`) can lock libxml's evaluator for a very long
time on small pages.

Added two pre-flight checks before invoking the evaluator:

- Hard cap on expression length (1000 chars). Real selectors are
  short — this just deflects the cheap "run a huge expression" form
  of the attack.
- Reject expressions matching `#//\.//.*//\.//#` (two-or-more stacked
  descendant axes), which is the cheapest known form of XPath
  amplification.

Returns clear WP_Error codes so the model can rewrite the selector.

## Media temp file leak (MediaUploadAbility::sideloadAndRespond)

`download_url()` / `wp_tempnam()` create temp files in the URL and
base64 upload paths. The previous flow `@unlink`'d on the error
branch only, trusting `media_handle_sideload` to move it on success.
Any unhandled exception during sideload, metadata write, or the
reversible-snapshot wrap-up would leak the temp file.

Wrapped the body in try-finally so the unlink runs on every exit
path. The `file_exists` check skips the `@`-error noise for the
common success path (where sideload already moved the file).

PHPStan + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprint 1 removed the unused $postType arg from PostTypeAbility/
TaxonomyAbility but the tests still passed it. PHP silently dropped the
trailing 4th arg and shifted everything: the route became 'post' /
'page' / 'category' instead of '/wp/v2/posts' etc. — so executeRead /
executeCreate / executeList all hit rest_no_route 404 in CI.

Locally everything passed because the previously-cached ability
construction was never re-run; CI starts fresh and exposes the breakage.

  16 PHPUnit failures → 0 after this fix (verified via wp-env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oxyc oxyc merged commit 305e351 into master May 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant