From deec478b1a7394339c6721a170888243f2af6326 Mon Sep 17 00:00:00 2001 From: Daryll Doyle Date: Mon, 29 Jun 2026 09:28:43 +0100 Subject: [PATCH 1/9] Move class-loader cache to build-time and add loader debug page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class-loader cache (breaking — targets a major release): - Runtime is now read-only. ModuleInitialization reads a pre-built cache if present and discovers live otherwise; it never writes the cache, removing the stale-cache failure mode from #30. - Caching is opt-in, produced at build time via a shipped `vendor/bin/tenup-framework-generate-class-cache` command (no WordPress required) and a `composer generate-class-cache` alias in this repo. - Removed should_use_cache(), the production/staging gating, and VIP_GO_APP_ENVIRONMENT handling. TENUP_FRAMEWORK_DISABLE_CLASS_CACHE now forces live discovery. Cache filename bumped to class-loader-cache-v2.php so caches written by 1.x are ignored after upgrade rather than served stale. Loader debug page: - Hidden, admin-only page (admin.php?page=tenup-framework-loaders, manage_options) aggregating every loader cache across all framework copies via the tenup_framework_debug_loaders filter, with an on-demand live-vs-cache staleness check. Recording and the page are gated behind is_admin() so the front end pays nothing. Read-only; disable via the tenup_framework_enable_loader_debug filter or the TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG constant. Docs and tests: - New docs: Build-and-Deployment, Debugging, Upgrade-Guide. Rewrote the cache sections of Autoloading and Modules-and-Initialization. - Tests cover the read-only driver, generate/read paths, admin vs front-end dispatch, the debug registry/page, and the staleness diff. phpcs, phpstan (level 10) and phpunit all green. Refs #30 --- .gitignore | 3 + CHANGELOG.md | 11 + bin/tenup-framework-generate-class-cache | 76 +++ composer.json | 9 +- docs/Autoloading.md | 17 +- docs/Build-and-Deployment.md | 190 +++++++ docs/Debugging.md | 70 +++ docs/Modules-and-Initialization.md | 19 +- docs/README.md | 3 + docs/Upgrade-Guide.md | 119 ++++ phpcs.xml | 12 + src/Cache/ReadOnlyFileDiscoverCacheDriver.php | 65 +++ src/Debug/LoaderDebug.php | 519 ++++++++++++++++++ src/ModuleInitialization.php | 209 ++++++- .../ReadOnlyFileDiscoverCacheDriverTest.php | 118 ++++ tests/Debug/LoaderDebugTest.php | 309 +++++++++++ tests/FrameworkTestSetup.php | 29 + tests/ModuleInitializationTest.php | 231 ++++++-- 18 files changed, 1939 insertions(+), 70 deletions(-) create mode 100755 bin/tenup-framework-generate-class-cache create mode 100644 docs/Build-and-Deployment.md create mode 100644 docs/Debugging.md create mode 100644 docs/Upgrade-Guide.md create mode 100644 src/Cache/ReadOnlyFileDiscoverCacheDriver.php create mode 100644 src/Debug/LoaderDebug.php create mode 100644 tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php create mode 100644 tests/Debug/LoaderDebugTest.php diff --git a/.gitignore b/.gitignore index 106a68b..675fea7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ vendor/ coverage/ .phpunit.result.cache + +# Generated class-loader cache (a build artefact, not source) +class-loader-cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9040d..127735c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file, per [the Keep a Changelog standard](http://keepachangelog.com/) and will adhere to [Semantic Versioning](http://semver.org/). ## [Unreleased] - TBD +### Added +- Build-time class-cache generation: a `tenup-framework-generate-class-cache` command (installed to `vendor/bin/`) and a `composer generate-class-cache` alias that build the cache in CI without bootstrapping WordPress. See [Build and Deployment](docs/Build-and-Deployment.md) ([#30](https://github.com/10up/wp-framework/issues/30)). +- Hidden admin page (`admin.php?page=tenup-framework-loaders`, `manage_options`) that aggregates every class-loader cache on the site — across all framework copies — and shows each cache's path, status, loaded classes, and an on-demand live-vs-cache staleness check. Admin-only (no front-end overhead) and read-only. Disable with the `tenup_framework_enable_loader_debug` filter or the `TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG` constant. See [Debugging class loaders](docs/Debugging.md). + +### Changed +- The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)). +- Bumped the cache identifier so a cache written by an older version is ignored after upgrade rather than served stale. +- `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` now forces live discovery (ignores any shipped cache). + +### Removed +- Automatic runtime cache generation and its environment gating — `should_use_cache()`, the `production`/`staging` checks, and the `VIP_GO_APP_ENVIRONMENT` handling. Caching is now produced at build time instead. ## [1.2.0] - 2025-03-20 ### Changed diff --git a/bin/tenup-framework-generate-class-cache b/bin/tenup-framework-generate-class-cache new file mode 100755 index 0000000..23458fb --- /dev/null +++ b/bin/tenup-framework-generate-class-cache @@ -0,0 +1,76 @@ +#!/usr/bin/env php + [ ...] + * + * Pass the same directory (or directories) you pass to + * TenupFramework\ModuleInitialization::init_classes() — usually your plugin/theme `inc/`. + * + * @package TenupFramework + */ + +declare( strict_types = 1 ); + +namespace TenupFramework\Bin; + +use TenupFramework\ModuleInitialization; +use Throwable; + +// Locate the Composer autoloader whether this runs from within the package itself +// (development) or installed inside a consumer project's vendor directory. Composer 2.2+ +// exposes the path via this global from the generated bin proxy; otherwise fall back to +// the known relative locations. +$tenup_autoloader_loaded = false; + +if ( isset( $GLOBALS['_composer_autoload_path'] ) && file_exists( (string) $GLOBALS['_composer_autoload_path'] ) ) { + require $GLOBALS['_composer_autoload_path']; + $tenup_autoloader_loaded = true; +} else { + $tenup_autoload_candidates = [ + __DIR__ . '/../vendor/autoload.php', // Running from the package itself. + __DIR__ . '/../../../autoload.php', // Installed at vendor/10up/wp-framework/bin. + ]; + + foreach ( $tenup_autoload_candidates as $tenup_autoload_candidate ) { + if ( file_exists( $tenup_autoload_candidate ) ) { + require $tenup_autoload_candidate; + $tenup_autoloader_loaded = true; + break; + } + } +} + +if ( ! $tenup_autoloader_loaded ) { + fwrite( STDERR, "Could not locate the Composer autoloader. Run `composer install` first.\n" ); + exit( 1 ); +} + +// Target directories are the CLI arguments (everything after the script name). +$tenup_directories = array_slice( $argv, 1 ); + +if ( empty( $tenup_directories ) ) { + fwrite( STDERR, "Usage: tenup-framework-generate-class-cache [ ...]\n" ); + fwrite( STDERR, "Pass one or more directories (the same ones passed to ModuleInitialization::init_classes()).\n" ); + exit( 1 ); +} + +$tenup_exit_code = 0; + +foreach ( $tenup_directories as $tenup_directory ) { + try { + $tenup_classes = ModuleInitialization::instance()->generate_cache( $tenup_directory ); + fwrite( STDOUT, sprintf( "Cached %d class(es) for %s\n", count( $tenup_classes ), $tenup_directory ) ); + } catch ( Throwable $tenup_exception ) { + fwrite( STDERR, sprintf( "Failed to generate cache for %s: %s\n", $tenup_directory, $tenup_exception->getMessage() ) ); + $tenup_exit_code = 1; + } +} + +exit( $tenup_exit_code ); diff --git a/composer.json b/composer.json index a97cfc4..ee11579 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,9 @@ "role": "Developer" } ], + "bin": [ + "bin/tenup-framework-generate-class-cache" + ], "autoload": { "psr-4": { "TenupFramework\\": "src/" @@ -52,7 +55,11 @@ "static": [ "Composer\\Config::disableProcessTimeout", "phpstan --memory-limit=1G" - ] + ], + "generate-class-cache": "@php bin/tenup-framework-generate-class-cache" + }, + "scripts-descriptions": { + "generate-class-cache": "Generate the class-loader cache for one or more directories, e.g. `composer generate-class-cache -- inc/`." }, "config": { "allow-plugins": { diff --git a/docs/Autoloading.md b/docs/Autoloading.md index 0b7038d..40814a5 100644 --- a/docs/Autoloading.md +++ b/docs/Autoloading.md @@ -78,11 +78,17 @@ add_action( 'plugins_loaded', function () { } ); ``` -Environment caching: -- Discovery results are cached only in production and staging environments (per `wp_get_environment_type()`). -- Cache is stored under the directory you pass to `init_classes()`, in a "class-loader-cache" folder (e.g., `YOUR_PLUGIN_INC . 'class-loader-cache'`). -- To refresh: delete that folder; it will be rebuilt automatically. -- Caching is skipped entirely when the constant `VIP_GO_APP_ENVIRONMENT` is defined or when `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` is set to `true`. Use `define( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE', true )` in environments that don't support writable file systems. +Class caching (optional, build-time): +- Discovery is fast, but on large codebases you can cache the discovered class list. The cache is **opt-in and produced at build time**: the framework reads it at runtime but never writes it, so it can never go stale on a server. +- With no cache file present (the default), classes are discovered live on every request. This is correct and is the right default for small projects. +- To produce a cache, run the shipped command in your build/deploy pipeline and ship the result as a build artefact: + ```bash + vendor/bin/tenup-framework-generate-class-cache YOUR_PLUGIN_INC + # or, via the Composer alias (see Build and Deployment): + composer generate-class-cache -- inc/ + ``` +- Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` to ignore any shipped cache and always discover live (useful for debugging a suspected stale cache). +- See [Build and Deployment](Build-and-Deployment.md) for CI examples and the per-package caching model. ## Defining a Module ```php @@ -116,6 +122,7 @@ class YourModule implements ModuleInterface { ## See also - [Docs Home](README.md) - [Modules and Initialization](Modules-and-Initialization.md) +- [Build and Deployment](Build-and-Deployment.md) - [Post Types](Post-Types.md) - [Taxonomies](Taxonomies.md) - [Asset Loading](Asset-Loading.md) diff --git a/docs/Build-and-Deployment.md b/docs/Build-and-Deployment.md new file mode 100644 index 0000000..2f6c3e3 --- /dev/null +++ b/docs/Build-and-Deployment.md @@ -0,0 +1,190 @@ +# Build and Deployment + +This page covers the **build-time class cache** — how to generate it, how to wire it into +CI, and the per-package model the framework assumes. + +## Why caching is a build step + +Discovering Modules at runtime is fast, but on large codebases you may want to cache the +discovered class list. The framework's cache is deliberately **read-only at runtime**: it +reads a cache file if one is present and discovers live otherwise, but it never writes one. + +That single rule removes a whole class of "works locally, not on the server" bugs. A server +that can't write the cache can't hold a stale one, so the only cache that can exist is the +one your build produced for that exact deploy. There is no freshness check, no background +regeneration, and nothing to clear by hand. (For the history, see +[issue #30](https://github.com/10up/wp-framework/issues/30).) + +Caching is therefore **opt-in**: do nothing and your project runs uncached, which is correct +and is the right default for a project with a handful of classes. Add the generate step when +you have a performance reason to. + +## Generating the cache + +The framework ships a standalone command (installed to your project's `vendor/bin/`) that +runs **without bootstrapping WordPress**, so it is safe to run in CI: + +```bash +vendor/bin/tenup-framework-generate-class-cache [ ...] +``` + +Pass the same directory you pass to `ModuleInitialization::init_classes()` — usually your +plugin/theme `inc/`. It writes `class-loader-cache/class-loader-cache-v2.php` inside each +directory. Multiple directories may be passed in one call. + +### Composer alias + +For nicer ergonomics, add a one-line script alias to your project's `composer.json`: + +```json +{ + "scripts": { + "generate-class-cache": "tenup-framework-generate-class-cache inc/" + } +} +``` + +Then run it with: + +```bash +composer generate-class-cache +``` + +> Composer does not propagate a dependency's scripts into your project, so the alias lives in +> your own `composer.json`. The `vendor/bin` command is the portable entry point either way. + +### Bypassing the cache + +Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` (e.g. in `wp-config.php`) to ignore any +shipped cache and always discover live. Useful when debugging a suspected stale or incorrect +cache. + +## Gitignore the cache + +The cache is a build artefact, not source. Ignore it and regenerate it on every deploy so an +old copy can never linger: + +```gitignore +# Generated class-loader cache (built in CI, shipped with the deploy) +**/class-loader-cache/ +``` + +## Wiring it into CI + +The generate step always sits **after** `composer install` (it needs the framework and its +dependencies on disk) and **before** the deploy/packaging step (so the cache ships with the +build). `spatie/php-structure-discoverer` and this command are runtime dependencies, so +`composer install --no-dev` keeps them available. + +### GitHub Actions + +```yaml +name: Deploy +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + - name: Install PHP dependencies + run: composer install --no-dev --prefer-dist --no-progress + - name: Generate the class-loader cache + run: composer generate-class-cache + # or: vendor/bin/tenup-framework-generate-class-cache inc/ + - name: Deploy + run: ./bin/deploy.sh # your deploy ships inc/class-loader-cache/ with the build +``` + +### GitLab CI + +```yaml +stages: + - build + - deploy + +build: + stage: build + image: php:8.3 + script: + - composer install --no-dev --prefer-dist --no-progress + - vendor/bin/tenup-framework-generate-class-cache inc/ + artifacts: + paths: + - vendor/ + - inc/class-loader-cache/ + +deploy: + stage: deploy + script: + - ./bin/deploy.sh # ships the artifacts produced by the build stage +``` + +### CircleCI + +```yaml +version: 2.1 + +jobs: + build-and-deploy: + docker: + - image: cimg/php:8.3 + steps: + - checkout + - run: + name: Install PHP dependencies + command: composer install --no-dev --prefer-dist --no-progress + - run: + name: Generate the class-loader cache + command: vendor/bin/tenup-framework-generate-class-cache inc/ + - run: + name: Deploy + command: ./bin/deploy.sh # ships inc/class-loader-cache/ with the build + +workflows: + deploy: + jobs: + - build-and-deploy: + filters: + branches: + only: main +``` + +If the build can't run the generate step for some reason, the deploy still works — it just +runs uncached. A broken cache after a build means the build is the thing to fix, not the +server. + +## The per-package model + +The framework discovers classes from **one directory mapped to one namespace, per Composer +package**. Each plugin or theme that uses the framework is its own unit: its own namespace, +its own `inc/` (or `src/`) directory, its own cache, and it requires the framework via +Composer in its own `composer.json`. + +This is why caching is per package rather than per project. A single cache at the project +root could not tell which discovered classes belong to which plugin without scanning +everything — which is exactly the work the cache exists to avoid. So the cache is generated +per package, into the directory passed to that package's `init_classes()`, and shipped inside +that package. + +The trade-off is that each package carries its own `vendor/` (a duplicated framework install) +and its own CI generate step, in exchange for domain-focused units that decouple cleanly. If +your build produces several packages, generate each one's cache — either with a call per +package, or by passing every directory to a single invocation: + +```bash +vendor/bin/tenup-framework-generate-class-cache \ + wp-content/plugins/foo/inc \ + wp-content/plugins/bar/inc +``` + +## See also +- [Docs Home](README.md) +- [Autoloading and Modules](Autoloading.md) +- [Modules and Initialization](Modules-and-Initialization.md) diff --git a/docs/Debugging.md b/docs/Debugging.md new file mode 100644 index 0000000..bf219e4 --- /dev/null +++ b/docs/Debugging.md @@ -0,0 +1,70 @@ +# Debugging class loaders + +The framework ships a hidden admin page that shows the state of every class-loader cache active +on a site. It exists to answer one question quickly: **what is each loader actually loading, and +is any cache stale?** That matters most on production, where a failed build or a missed deploy can +leave an old cache file in place (see [Build and Deployment](Build-and-Deployment.md) and +[issue #30](https://github.com/10up/wp-framework/issues/30)). + +## Opening the page + +There is no menu item — the page is hidden. Visit it directly: + +``` +/wp-admin/admin.php?page=tenup-framework-loaders +``` + +It requires the `manage_options` capability. + +## What it shows + +A site can run **1..n** framework copies (one per plugin/theme that requires the package). The +page aggregates every loader recorded across all of them — even copies that are php-scoped +(prefixed) or on different versions — by collecting over the fixed-string +`tenup_framework_debug_loaders` filter. For each loader you get: + +- **Owner** — the plugin or theme the directory belongs to (derived from the path). +- **Directory** — the directory passed to `ModuleInitialization::init_classes()`. +- **Framework version** — version and git reference of the copy that recorded it, so a + version mismatch between plugins is visible. +- **Cache file** — its path, and the status: in use, present-but-not-used, discovering live + (no file), or disabled. When a file is present, its age and size. +- **Stale cache files** — a warning if the cache directory holds files other than the current + one (usually leftovers from an older framework version). +- **Classes loaded** — every class the loader resolved, with the file each one lives in. A class + that no longer resolves is flagged as a likely stale entry. + +## Staleness check + +Each loader has a **Check this cache for staleness** button. It re-runs discovery live against the +directory and diffs the result against what the cache loaded, listing: + +- classes **on disk but missing from the cache** (the cache is behind), and +- classes **in the cache but no longer on disk** (renamed/removed). + +The check runs only when clicked, so the page itself stays cheap. If it reports drift, the cache +is stale: regenerate it in your build (`composer generate-class-cache`) or remove the file and +redeploy. The page is **read-only** — it never deletes or rewrites a cache, consistent with the +read-only runtime. + +## Performance + +The recording and the page are **admin-only**. On front-end requests nothing is recorded, no hooks +are added, and the debug class is never even loaded. + +## Disabling it + +Enabled by default in the admin. Turn it off with either: + +```php +add_filter( 'tenup_framework_enable_loader_debug', '__return_false' ); +``` + +```php +define( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG', true ); +``` + +## See also +- [Docs Home](README.md) +- [Build and Deployment](Build-and-Deployment.md) +- [Modules and Initialization](Modules-and-Initialization.md) diff --git a/docs/Modules-and-Initialization.md b/docs/Modules-and-Initialization.md index e00a34d..6869783 100644 --- a/docs/Modules-and-Initialization.md +++ b/docs/Modules-and-Initialization.md @@ -23,8 +23,7 @@ ModuleInitialization::instance()->init_classes( YOUR_PLUGIN_INC ); ModuleInitialization performs the following steps: 1. Validate the directory exists; otherwise throw a RuntimeException. 2. Discover class names within the directory using spatie/structure-discoverer. - - In production and staging environments (wp_get_environment_type), results are cached for performance using a file-based cache. - - Caching is skipped entirely when the constant `VIP_GO_APP_ENVIRONMENT` is defined. + - If a pre-built class cache is present it is read; otherwise classes are discovered live on every request. The runtime never writes the cache — see [Class caching](#class-caching) below. 3. Reflect on each discovered class and skip any that: - are not instantiable, - do not implement `TenupFramework\ModuleInterface`. @@ -35,12 +34,16 @@ ModuleInitialization performs the following steps: 7. For each module, call `register()` only if `can_register()` returns true. 8. Store initialized modules for later retrieval. -Environment cache behavior -- Where cache lives: under the directory you pass to `init_classes()`, in a `class-loader-cache` folder (e.g., `YOUR_PLUGIN_INC . 'class-loader-cache'`). -- When it’s used: only in `production` and `staging` environment types (`wp_get_environment_type()`). -- How to clear: delete the `class-loader-cache` folder; it will be rebuilt on next discovery. -- How to disable in development: use `development` or `local` environment types, or define `VIP_GO_APP_ENVIRONMENT` to skip the cache. -- How to disable for hosts that don't support file-based caching: `define( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE', true );` to skip caching altogether. +## Class caching +Caching the discovered class list is **optional and produced at build time**. The runtime only ever reads the cache; it never writes one, so a server cannot end up serving a stale cache it generated itself (the failure mode this model replaced — see [issue #30](https://github.com/10up/wp-framework/issues/30)). + +- Default (no cache file): classes are discovered live on every request. Correct, and the right default for small codebases — caching is opt-in. +- With a cache file present: the framework reads it and skips discovery. +- Where it lives: a `class-loader-cache` folder inside the directory you pass to `init_classes()`, e.g. `YOUR_PLUGIN_INC . 'class-loader-cache'`. The filename is versioned (`class-loader-cache-v2.php`) so a cache written by an older framework version is ignored after an upgrade rather than served stale; the old file is harmless cruft a clean deploy clears. +- How to produce it: run `vendor/bin/tenup-framework-generate-class-cache ` (or `composer generate-class-cache -- `) in your build/deploy pipeline and ship the result as a build artefact. +- How to bypass: define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` to ignore any shipped cache and always discover live. + +Gitignore the `class-loader-cache` directory and regenerate it on every deploy. See [Build and Deployment](Build-and-Deployment.md) for CI examples and the per-package caching model, and [Debugging class loaders](Debugging.md) for the hidden admin page that shows what each cache is loading and flags stale ones. Hooks - Action: `tenup_framework_module_init__{slug}` — fires before each module’s `register()` runs. diff --git a/docs/README.md b/docs/README.md index 2cb283c..e262e3e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,8 +19,11 @@ WP Framework is a lightweight set of building blocks for structuring WordPress p 5) Load assets via the `GetAssetInfo` trait using dist/.asset.php sidecars. ## Table of Contents +- [Upgrade Guide](Upgrade-Guide.md) — breaking changes and how to migrate (start here when updating a major version) - [Autoloading and Modules](Autoloading.md) — how classes are discovered and initialized - [Modules and Initialization](Modules-and-Initialization.md) +- [Build and Deployment](Build-and-Deployment.md) — generating the class cache and wiring it into CI +- [Debugging class loaders](Debugging.md) — the hidden admin page for inspecting caches - [Post Types](Post-Types.md) — building custom and core post type integrations - [Taxonomies](Taxonomies.md) — registering and configuring taxonomies - [Asset Loading](Asset-Loading.md) — working with dist/.asset.php for dependencies and versioning diff --git a/docs/Upgrade-Guide.md b/docs/Upgrade-Guide.md new file mode 100644 index 0000000..d22fcbd --- /dev/null +++ b/docs/Upgrade-Guide.md @@ -0,0 +1,119 @@ +# Upgrade Guide + +## Upgrading to 2.0 + +2.0 is a **breaking release**. It changes how the class-loader cache works: the framework no +longer generates the cache automatically at runtime. This page covers what changed, who is +affected, and exactly what to do. + +For the background on *why*, see [issue #30](https://github.com/10up/wp-framework/issues/30) — +the automatic runtime cache could go stale on a server and could only be cleared by hand. + +## Who is affected + +You are affected if, on 1.x, you relied on the cache being **built automatically in production +or staging**. After upgrading, that no longer happens — the framework reads a pre-built cache if +one is present and otherwise **discovers classes live on every request**. + +- Small projects (a handful of Modules): running uncached is fine. No action needed beyond the + cleanup below. +- Large codebases that want the performance of a cache: you must now **produce the cache as a + build step** (see [Build and Deployment](Build-and-Deployment.md)). If you upgrade without + adding that step, the site keeps working but runs uncached — slower discovery on every request. + +> There is no runtime warning when running uncached. To confirm whether a cache is actually in +> use after upgrading, use the [loader debug page](Debugging.md) — see *Verify the upgrade* below. + +## What changed + +### Caching is now read-only at runtime and opt-in + +- **1.x:** the first request on a production/staging server discovered classes and **wrote** the + cache; later requests read it (and never re-checked it — the stale-cache bug). +- **2.0:** the runtime **only ever reads** a cache. It never writes one. A cache is produced at + build time with the shipped command and shipped as a build artefact. + +### Removed + +- `should_use_cache()` and its environment gating. +- The automatic `production` / `staging` caching behaviour (via `wp_get_environment_type()`). +- `VIP_GO_APP_ENVIRONMENT` handling. It no longer affects caching. VIP sites already ran uncached + in practice, so there is nothing to do unless you want to opt into the build step. + +### Changed + +- **`TENUP_FRAMEWORK_DISABLE_CLASS_CACHE`** still disables caching, but its meaning is now "ignore + any shipped cache and always discover live." If you set it on 1.x to avoid caching, it keeps + doing what you want — no change needed. +- The cache file name is versioned: `class-loader-cache/class-loader-cache-v2.php`. The runtime + looks only for this name, so a cache written by 1.x is ignored rather than served stale. + +## What you need to do + +### 1. Decide: uncached or build-time cache + +- **Run uncached (default):** do nothing. Discovery runs on every request. Correct, and fine for + most projects. +- **Keep a cache:** add the generate step to your build/deploy pipeline. Full instructions and + CI examples (GitHub Actions, GitLab CI, CircleCI) are in + [Build and Deployment](Build-and-Deployment.md). In short: + + ```bash + # Run after `composer install`, before deploy/packaging: + vendor/bin/tenup-framework-generate-class-cache inc/ + ``` + + Pass the same directory you pass to `ModuleInitialization::init_classes()`. If you have several + plugins/themes using the framework, generate each one's cache (the command accepts multiple + directories). + + > A `composer generate-class-cache` alias is convenient, but Composer does not expose a + > dependency's scripts to your project — add the alias to **your own** `composer.json` if you + > want it. The `vendor/bin` command is the portable entry point. See + > [Build and Deployment](Build-and-Deployment.md#composer-alias). + +### 2. Clean up old cache files + +The cache directory is a build artefact, not source. Add it to `.gitignore` and let a clean +deploy clear stale copies: + +```gitignore +# Generated class-loader cache (built in CI, shipped with the deploy) +**/class-loader-cache/ +``` + +Any old `discoverer-cache-*` file left by 1.x is harmless — the 2.0 runtime never reads it — but +you can delete the `class-loader-cache` directory to remove the clutter; it will be regenerated by +your build if you opted into caching. + +### 3. Verify the upgrade + +Because nothing in the runtime announces whether caching is on, confirm it explicitly with the +[loader debug page](Debugging.md): + +``` +/wp-admin/admin.php?page=tenup-framework-loaders +``` + +For each loader it shows a **Cache status** line: + +- *"discovering live on every request"* — no cache is in use (expected if you didn't add the build + step). +- *"Cache in use …"* — a pre-built cache is being read (expected after wiring in the build step). + +Use the per-loader **staleness check** to confirm a shipped cache matches what's on disk. + +## Reference + +| 1.x | 2.0 | +| --- | --- | +| Cache written automatically at runtime (production/staging) | Cache produced at build time only; runtime reads, never writes | +| `should_use_cache()` gates writing | Removed | +| `VIP_GO_APP_ENVIRONMENT` skips caching | No effect (removed) | +| `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` skips the auto-cache | Ignores any shipped cache, always discovers live | +| Cache at `class-loader-cache/discoverer-cache-TenupFramework` | Cache at `class-loader-cache/class-loader-cache-v2.php` | + +## See also +- [Build and Deployment](Build-and-Deployment.md) — generating the cache and CI examples +- [Debugging class loaders](Debugging.md) — verifying cache state on a running site +- [Modules and Initialization](Modules-and-Initialization.md) diff --git a/phpcs.xml b/phpcs.xml index dac99b7..59bfa01 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -17,6 +17,18 @@ + + + */tests/* + + + */tests/* + + diff --git a/src/Cache/ReadOnlyFileDiscoverCacheDriver.php b/src/Cache/ReadOnlyFileDiscoverCacheDriver.php new file mode 100644 index 0000000..8757406 --- /dev/null +++ b/src/Cache/ReadOnlyFileDiscoverCacheDriver.php @@ -0,0 +1,65 @@ +directory = rtrim( $directory, '/' ); + $this->serialize = $serialize; + $this->filename = $filename; + } + + /** + * No-op. Cache generation happens at build time only, never at runtime. + * + * @param string $id The cache identifier. + * @param array $discovered The discovered structures. + * + * @return void + */ + public function put( string $id, array $discovered ): void { + // Intentionally empty. + } + + /** + * No-op. The runtime never deletes the cache. + * + * @param string $id The cache identifier. + * + * @return void + */ + public function forget( string $id ): void { + // Intentionally empty. + } +} diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php new file mode 100644 index 0000000..44e6a5f --- /dev/null +++ b/src/Debug/LoaderDebug.php @@ -0,0 +1,519 @@ +> + */ + protected static $loaders = []; + + /** + * Whether this copy has wired its WordPress hooks yet. + * + * @var bool + */ + protected static $booted = false; + + /** + * Record a loader and ensure the admin hooks are wired. + * + * Called from ModuleInitialization::init_classes() (admin requests only). Returns early + * without storing anything or adding hooks when the tooling is disabled. + * + * @param array $record The loader record. + * + * @return void + */ + public static function record( array $record ) { + if ( ! self::is_enabled() ) { + return; + } + + self::$loaders[] = $record; + + self::boot(); + } + + /** + * The loader records collected for this framework copy (not the cross-copy aggregate — + * use the `tenup_framework_debug_loaders` filter for that). + * + * @return array> + */ + public static function get_loaders(): array { + return self::$loaders; + } + + /** + * Whether the debug tooling is enabled. + * + * Off when WordPress isn't loaded, when the disable constant is set, or when a filter turns + * it off. On by default (in the admin, which is the only place record() is called). + * + * @return bool + */ + public static function is_enabled(): bool { + if ( ! function_exists( 'add_action' ) ) { + return false; + } + + if ( defined( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG' ) && true === constant( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG' ) ) { + return false; + } + + return (bool) apply_filters( 'tenup_framework_enable_loader_debug', true ); + } + + /** + * Wire the WordPress hooks once per copy: contribute this copy's records to the shared + * filter, and register the admin page a single time across every loaded copy. + * + * @return void + */ + protected static function boot() { + if ( self::$booted ) { + return; + } + self::$booted = true; + + add_filter( + self::FILTER, + static function ( $loaders ) { + return array_merge( (array) $loaders, self::$loaders ); + } + ); + + // Register the page only once, even when several framework copies are loaded. + if ( empty( $GLOBALS['tenup_framework_debug_page_registered'] ) ) { + $GLOBALS['tenup_framework_debug_page_registered'] = true; + add_action( 'admin_menu', [ self::class, 'register_page' ] ); + } + } + + /** + * Register the hidden admin page. + * + * An empty parent slug keeps the page out of every menu while leaving it reachable at + * admin.php?page=tenup-framework-loaders. + * + * @return void + */ + public static function register_page() { + $title = __( 'WP Framework Loaders', 'tenup-framework' ); + + add_submenu_page( + '', + $title, + $title, + self::CAPABILITY, + self::PAGE_SLUG, + [ self::class, 'render_page' ] + ); + } + + /** + * Render the page. + * + * @return void + */ + public static function render_page() { + if ( ! current_user_can( self::CAPABILITY ) ) { + wp_die( esc_html__( 'You do not have permission to view this page.', 'tenup-framework' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only diagnostic; the value is nonce-verified below before use. + $requested_check = ( isset( $_GET['check'] ) && is_string( $_GET['check'] ) ) ? sanitize_text_field( wp_unslash( $_GET['check'] ) ) : ''; + $check = self::check_is_valid( $requested_check ) ? $requested_check : ''; + + $loaders = apply_filters( self::FILTER, [] ); + if ( ! is_array( $loaders ) ) { + $loaders = []; + } + + echo '
'; + echo '

' . esc_html__( 'WP Framework Loaders', 'tenup-framework' ) . '

'; + echo '

' . esc_html__( 'Each block is a directory passed to ModuleInitialization::init_classes(), with the state of its class-loader cache. Caches are built at deploy time and read (never written) at runtime.', 'tenup-framework' ) . '

'; + + if ( empty( $loaders ) ) { + echo '

' . esc_html__( 'No class loaders were recorded for this request.', 'tenup-framework' ) . '

'; + echo '
'; + return; + } + + foreach ( $loaders as $loader ) { + if ( is_array( $loader ) ) { + self::render_loader( $loader, $check ); + } + } + + echo ''; + } + + /** + * Render a single loader block. + * + * @param array $loader The loader record. + * @param string $check The validated staleness-check token, if any. + * + * @return void + */ + protected static function render_loader( array $loader, string $check ) { + $directory = self::to_string( $loader['directory'] ?? '' ); + $cache_file = self::to_string( $loader['cache_file'] ?? '' ); + $classes = isset( $loader['classes'] ) && is_array( $loader['classes'] ) + ? array_values( array_map( [ self::class, 'to_string' ], $loader['classes'] ) ) + : []; + + echo '

' . esc_html( self::owner_label( $directory ) ) . '

'; + echo ''; + + self::render_row( __( 'Directory', 'tenup-framework' ), $directory ); + self::render_row( __( 'Framework version', 'tenup-framework' ), self::version_label( $loader ) ); + self::render_row( __( 'Cache file', 'tenup-framework' ), $cache_file ); + self::render_row( __( 'Cache status', 'tenup-framework' ), self::cache_status_label( $loader ) ); + self::render_row( __( 'Classes loaded', 'tenup-framework' ), (string) count( $classes ) ); + + $legacy = self::legacy_files( $cache_file ); + if ( ! empty( $legacy ) ) { + self::render_row( + __( 'Stale cache files', 'tenup-framework' ), + sprintf( + /* translators: %s: comma-separated list of unexpected filenames. */ + __( 'Unexpected files alongside the current cache (likely left by an older version): %s', 'tenup-framework' ), + implode( ', ', $legacy ) + ) + ); + } + + echo '
'; + + self::render_classes( $classes ); + self::render_staleness( $directory, $classes, $check ); + } + + /** + * Render a label/value row, escaping both. + * + * @param string $label The row label. + * @param string $value The row value. + * + * @return void + */ + protected static function render_row( string $label, string $value ) { + echo '' . esc_html( $label ) . '' . esc_html( $value ) . ''; + } + + /** + * Render the list of loaded classes with the file each resolves to. + * + * @param array $classes The class names. + * + * @return void + */ + protected static function render_classes( array $classes ) { + if ( empty( $classes ) ) { + return; + } + + echo ''; + echo ''; + + $module_init = ModuleInitialization::instance(); + + foreach ( $classes as $class ) { + $reflection = $module_init->get_fully_loadable_class( $class ); + $file = $reflection ? (string) $reflection->getFileName() : __( 'Does not resolve — likely a stale cache entry.', 'tenup-framework' ); + + echo ''; + } + + echo '
' . esc_html__( 'Class', 'tenup-framework' ) . '' . esc_html__( 'File', 'tenup-framework' ) . '
' . esc_html( $class ) . '' . esc_html( $file ) . '
'; + } + + /** + * Render the on-demand staleness check: a trigger link, and the diff when requested. + * + * @param string $directory The loader directory. + * @param array $classes The currently loaded class list. + * @param string $check The validated staleness-check token, if any. + * + * @return void + */ + protected static function render_staleness( string $directory, array $classes, string $check ) { + if ( '' === $directory ) { + return; + } + + if ( self::token_for( $directory ) !== $check ) { + $url = wp_nonce_url( + add_query_arg( + [ + 'page' => self::PAGE_SLUG, + 'check' => self::token_for( $directory ), + ], + admin_url( 'admin.php' ) + ), + self::CHECK_NONCE + ); + + echo '

' . esc_html__( 'Check this cache for staleness', 'tenup-framework' ) . '

'; + return; + } + + $live = ModuleInitialization::instance()->discover_live( $directory ); + $loaded = array_values( $classes ); + $removed = array_diff( $loaded, $live ); // In cache but no longer on disk. + $added = array_diff( $live, $loaded ); // On disk but missing from the cache. + + if ( empty( $removed ) && empty( $added ) ) { + echo '

' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . '

'; + return; + } + + echo '

' . esc_html__( 'Stale — the cache differs from a live scan:', 'tenup-framework' ) . '

'; + + if ( ! empty( $added ) ) { + echo '

' . esc_html__( 'On disk but missing from the cache:', 'tenup-framework' ) . '

    '; + foreach ( $added as $class ) { + echo '
  • ' . esc_html( $class ) . '
  • '; + } + echo '
'; + } + + if ( ! empty( $removed ) ) { + echo '

' . esc_html__( 'In the cache but no longer on disk:', 'tenup-framework' ) . '

    '; + foreach ( $removed as $class ) { + echo '
  • ' . esc_html( $class ) . '
  • '; + } + echo '
'; + } + + echo '

' . esc_html__( 'Regenerate the cache in your build (composer generate-class-cache) or remove the file and redeploy.', 'tenup-framework' ) . '

'; + } + + /** + * Coerce a mixed value (loader records arrive through a filter as mixed) to a string, + * returning an empty string for anything non-scalar. + * + * @param mixed $value The value to coerce. + * + * @return string + */ + protected static function to_string( $value ): string { + return is_scalar( $value ) ? (string) $value : ''; + } + + /** + * A stable, opaque token identifying a loader directory in the check link. + * + * @param string $directory The loader directory. + * + * @return string + */ + protected static function token_for( string $directory ): string { + return md5( $directory ); + } + + /** + * Whether a requested check token is well-formed and the request is nonce-verified. + * + * @param string $token The requested token. + * + * @return bool + */ + protected static function check_is_valid( string $token ): bool { + if ( '' === $token ) { + return false; + } + + $nonce = ( isset( $_GET['_wpnonce'] ) && is_string( $_GET['_wpnonce'] ) ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; + + return (bool) wp_verify_nonce( $nonce, self::CHECK_NONCE ); + } + + /** + * A human-friendly owner label for a directory (plugin/theme name where derivable). + * + * @param string $directory The loader directory. + * + * @return string + */ + protected static function owner_label( string $directory ): string { + if ( '' === $directory ) { + return __( 'Unknown loader', 'tenup-framework' ); + } + + $roots = []; + if ( defined( 'WP_PLUGIN_DIR' ) ) { + $roots[] = [ self::to_string( constant( 'WP_PLUGIN_DIR' ) ), __( 'Plugin', 'tenup-framework' ) ]; + } + if ( defined( 'WPMU_PLUGIN_DIR' ) ) { + $roots[] = [ self::to_string( constant( 'WPMU_PLUGIN_DIR' ) ), __( 'Must-use plugin', 'tenup-framework' ) ]; + } + if ( function_exists( 'get_theme_root' ) ) { + $roots[] = [ self::to_string( get_theme_root() ), __( 'Theme', 'tenup-framework' ) ]; + } + + foreach ( $roots as $candidate ) { + $root = rtrim( $candidate[0], '/' ); + $type = $candidate[1]; + if ( '' !== $root && str_starts_with( $directory, $root . '/' ) ) { + $relative = ltrim( substr( $directory, strlen( $root ) ), '/' ); + $segment = explode( '/', $relative )[0]; + + /* translators: 1: owner type (Plugin/Theme), 2: plugin or theme folder name. */ + return sprintf( __( '%1$s: %2$s', 'tenup-framework' ), $type, $segment ); + } + } + + return $directory; + } + + /** + * A label describing the framework version that recorded a loader. + * + * @param array $loader The loader record. + * + * @return string + */ + protected static function version_label( array $loader ): string { + $version = self::to_string( $loader['version'] ?? '' ); + $reference = self::to_string( $loader['reference'] ?? '' ); + + if ( '' === $version ) { + $version = __( 'unknown', 'tenup-framework' ); + } + + if ( '' !== $reference ) { + $version .= ' (' . substr( $reference, 0, 8 ) . ')'; + } + + return $version; + } + + /** + * A label describing the cache status, including mtime, size and whether it is in use. + * + * @param array $loader The loader record. + * + * @return string + */ + protected static function cache_status_label( array $loader ): string { + if ( ! empty( $loader['cache_disabled'] ) ) { + return __( 'Disabled — discovering live (TENUP_FRAMEWORK_DISABLE_CLASS_CACHE).', 'tenup-framework' ); + } + + if ( empty( $loader['cache_exists'] ) ) { + return __( 'No cache file — discovering live on every request.', 'tenup-framework' ); + } + + $cache_file = self::to_string( $loader['cache_file'] ?? '' ); + $mtime = file_exists( $cache_file ) ? (int) filemtime( $cache_file ) : 0; + $size = file_exists( $cache_file ) ? (int) filesize( $cache_file ) : 0; + + $used = empty( $loader['cache_used'] ) + ? __( 'present but not used', 'tenup-framework' ) + : __( 'in use', 'tenup-framework' ); + + return sprintf( + /* translators: 1: in-use status, 2: relative age, 3: file size. */ + __( 'Cache %1$s — built %2$s ago, %3$s.', 'tenup-framework' ), + $used, + $mtime ? human_time_diff( $mtime ) : __( 'unknown time', 'tenup-framework' ), + size_format( $size ) + ); + } + + /** + * Find files in the cache directory that are not the current cache file — usually stale + * leftovers from an older framework version. + * + * @param string $cache_file The current cache file path. + * + * @return array The unexpected filenames. + */ + protected static function legacy_files( string $cache_file ): array { + if ( '' === $cache_file ) { + return []; + } + + $dir = dirname( $cache_file ); + if ( ! is_dir( $dir ) ) { + return []; + } + + $expected = basename( $cache_file ); + $found = []; + + foreach ( new \DirectoryIterator( $dir ) as $item ) { + if ( $item->isDot() || $item->isDir() ) { + continue; + } + $name = $item->getFilename(); + if ( $name !== $expected ) { + $found[] = $name; + } + } + + return $found; + } +} diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index 9f9a8e3..9f00ed0 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -9,10 +9,13 @@ namespace TenupFramework; +use Composer\InstalledVersions; use ReflectionClass; use Spatie\StructureDiscoverer\Cache\FileDiscoverCacheDriver; use Spatie\StructureDiscoverer\Data\DiscoveredStructure; use Spatie\StructureDiscoverer\Discover; +use TenupFramework\Cache\ReadOnlyFileDiscoverCacheDriver; +use TenupFramework\Debug\LoaderDebug; /** * ModuleInitialization class. @@ -21,6 +24,32 @@ */ class ModuleInitialization { + /** + * The directory name, within the discovery directory, that holds the class cache. + * + * @var string + */ + public const CACHE_DIR_NAME = 'class-loader-cache'; + + /** + * The class cache filename. + * + * Bumping this value invalidates caches written by older framework versions: the + * runtime looks for a filename the previous build never produced, so a stale file + * is simply ignored until a fresh build regenerates it. The old file is harmless + * cruft that a clean deploy clears. + * + * @var string + */ + public const CACHE_FILENAME = 'class-loader-cache-v2.php'; + + /** + * The Spatie cache identifier. + * + * @var string + */ + public const CACHE_ID = 'TenupFramework'; + /** * The class instance. * @@ -64,18 +93,23 @@ private function __construct() { public function get_classes( $dir ) { $this->directory_check( $dir ); - // Get all classes from this directory and its subdirectories. - $class_finder = Discover::in( $dir ); - // Only fetch classes. - $class_finder->classes(); - // Disable inheritance chain resolution - $class_finder->withoutChains(); + $class_finder = $this->build_discoverer( $dir ); - // If we are in production or staging, cache the class loader to improve performance. - if ( $this->should_use_cache() ) { + // The runtime only ever reads a pre-built cache; it never writes one. Caching is + // therefore opt-in: with no cache file present we discover live on every request, + // which is the correct default. A cache is produced at build time via the + // `tenup-framework-generate-class-cache` command and shipped as a build artefact. + // + // Define TENUP_FRAMEWORK_DISABLE_CLASS_CACHE to ignore any shipped cache and always + // discover live (useful for debugging). + if ( ! $this->cache_disabled() ) { $class_finder->withCache( - __NAMESPACE__, - new FileDiscoverCacheDriver( $dir . '/class-loader-cache' ) + self::CACHE_ID, + new ReadOnlyFileDiscoverCacheDriver( + $this->get_cache_directory( $dir ), + false, + self::CACHE_FILENAME + ) ); } @@ -86,24 +120,155 @@ public function get_classes( $dir ) { } /** - * Should we set up and use the class cache? + * Generate the class cache for a directory and write it to disk. + * + * This is the build-time counterpart to get_classes(): it is the only place the + * framework writes the cache, and it deliberately makes no WordPress calls so it can + * run from a plain CLI script during CI without bootstrapping WordPress. The resulting + * file is then deployed as a build artefact and read (never rewritten) at runtime. + * + * @param string $dir The directory to search for classes. + * + * @return array The discovered class names that were cached. + */ + public function generate_cache( $dir = '' ) { + $this->directory_check( $dir ); + + $class_finder = $this->build_discoverer( $dir ); + + $class_finder->withCache( + self::CACHE_ID, + new FileDiscoverCacheDriver( + $this->get_cache_directory( $dir ), + false, + self::CACHE_FILENAME + ) + ); + + // cache() forces a fresh discovery and overwrites any existing cache file, so a + // regenerate always reflects the current code rather than a previous build. + $classes = $class_finder->cache(); + + return array_filter( $classes, fn( $cl ) => is_string( $cl ) ); + } + + /** + * Build a discoverer configured the same way for both reading and generating, so the + * two paths can never drift apart. + * + * @param string $dir The directory to search for classes. + * + * @return Discover + */ + protected function build_discoverer( $dir ): Discover { + // Get all classes from this directory and its subdirectories. + $class_finder = Discover::in( $dir ); + // Only fetch classes. + $class_finder->classes(); + // Disable inheritance chain resolution. + $class_finder->withoutChains(); + + return $class_finder; + } + + /** + * Get the absolute path to the cache directory for a discovery directory. + * + * @param string $dir The directory to search for classes. + * + * @return string + */ + protected function get_cache_directory( $dir ): string { + return rtrim( $dir, '/' ) . '/' . self::CACHE_DIR_NAME; + } + + /** + * Whether class caching has been explicitly disabled. + * + * When true, the runtime ignores any shipped cache and discovers classes live on every + * request. Useful for debugging a suspected stale or incorrect cache. * * @return bool */ - protected function should_use_cache(): bool { - if ( defined( 'VIP_GO_APP_ENVIRONMENT' ) ) { - return false; + protected function cache_disabled(): bool { + return defined( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE' ) && true === TENUP_FRAMEWORK_DISABLE_CLASS_CACHE; + } + + /** + * Discover the classes in a directory live, ignoring any cache. + * + * Used by the admin-only debug page's on-demand staleness check to compare what is actually + * on disk against what the cache loaded. + * + * @param string $dir The directory to search for classes. + * + * @return array + */ + public function discover_live( $dir ) { + $this->directory_check( $dir ); + + return array_values( array_filter( $this->build_discoverer( $dir )->get(), fn( $cl ) => is_string( $cl ) ) ); + } + + /** + * Hand loader metadata to the admin-only debug tooling. + * + * Front-end requests do nothing here: the data is only viewable in the admin, so it is only + * gathered there. The is_admin() check happens before LoaderDebug is referenced, so that + * class never autoloads on the front end. + * + * @param string $dir The directory that was discovered. + * @param array $classes The discovered class names. + * + * @return void + */ + protected function record_loader_debug( $dir, array $classes ) { + if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { + return; } - if ( ! in_array( wp_get_environment_type(), [ 'production', 'staging' ], true ) ) { - return false; + $cache_file = $this->get_cache_directory( $dir ) . '/' . self::CACHE_FILENAME; + $cache_exists = file_exists( $cache_file ); + $disabled = $this->cache_disabled(); + + LoaderDebug::record( + [ + 'directory' => $dir, + 'cache_file' => $cache_file, + 'cache_exists' => $cache_exists, + 'cache_used' => $cache_exists && ! $disabled, + 'cache_disabled' => $disabled, + 'classes' => $classes, + 'version' => $this->framework_version(), + 'reference' => $this->framework_reference(), + ] + ); + } + + /** + * The installed framework version, or an empty string when it cannot be determined. + * + * @return string + */ + protected function framework_version(): string { + if ( class_exists( InstalledVersions::class ) && InstalledVersions::isInstalled( '10up/wp-framework' ) ) { + return (string) InstalledVersions::getPrettyVersion( '10up/wp-framework' ); } - if ( defined( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE' ) && true === TENUP_FRAMEWORK_DISABLE_CLASS_CACHE ) { - return false; + return ''; + } + + /** + * The installed framework reference (git hash), or an empty string when unavailable. + * + * @return string + */ + protected function framework_reference(): string { + if ( class_exists( InstalledVersions::class ) && InstalledVersions::isInstalled( '10up/wp-framework' ) ) { + return (string) InstalledVersions::getReference( '10up/wp-framework' ); } - return true; + return ''; } /** @@ -138,8 +303,12 @@ protected function directory_check( $dir ): bool { public function init_classes( $dir = '' ) { $this->directory_check( $dir ); + $classes = $this->get_classes( $dir ); + + $this->record_loader_debug( $dir, $classes ); + $load_class_order = []; - foreach ( $this->get_classes( $dir ) as $class ) { + foreach ( $classes as $class ) { // Create a slug for the class name. $slug = $this->slugify_class_name( $class ); diff --git a/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php b/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php new file mode 100644 index 0000000..23fd831 --- /dev/null +++ b/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php @@ -0,0 +1,118 @@ +dir = sys_get_temp_dir() . '/tenup_ro_driver_' . uniqid( '', true ); + mkdir( $this->dir ); + + return $this->dir; + } + + /** + * Remove the temporary directory after a test. + * + * @return void + */ + protected function tearDown(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + if ( '' !== $this->dir && is_dir( $this->dir ) ) { + $files = glob( $this->dir . '/*' ); + if ( false !== $files ) { + array_map( 'unlink', $files ); + } + rmdir( $this->dir ); + } + $this->dir = ''; + + parent::tearDown(); + } + + /** + * The constructor must not create the cache directory: the runtime never writes. + * + * @return void + */ + public function test_constructor_does_not_create_directory() { + $missing = sys_get_temp_dir() . '/tenup_ro_missing_' . uniqid( '', true ); + + new ReadOnlyFileDiscoverCacheDriver( $missing, false, 'cache.php' ); + + $this->assertDirectoryDoesNotExist( $missing ); + } + + /** + * put() is a no-op: nothing is written to disk. + * + * @return void + */ + public function test_put_writes_nothing() { + $dir = $this->make_dir(); + $driver = new ReadOnlyFileDiscoverCacheDriver( $dir, false, 'cache.php' ); + + $driver->put( 'id', [ 'Foo\\Bar' ] ); + + $this->assertFalse( $driver->has( 'id' ) ); + $this->assertFileDoesNotExist( $dir . '/cache.php' ); + } + + /** + * forget() is a no-op: an existing cache file is left untouched. + * + * @return void + */ + public function test_forget_deletes_nothing() { + $dir = $this->make_dir(); + file_put_contents( $dir . '/cache.php', 'forget( 'id' ); + + $this->assertFileExists( $dir . '/cache.php' ); + } + + /** + * has()/get() read an existing cache file produced with the same settings the + * build-time generator uses (serialize = false, an explicit filename). + * + * @return void + */ + public function test_has_and_get_read_an_existing_file() { + $dir = $this->make_dir(); + file_put_contents( $dir . '/cache.php', "assertTrue( $driver->has( 'id' ) ); + $this->assertSame( [ 'Foo\\Bar' ], $driver->get( 'id' ) ); + } +} diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php new file mode 100644 index 0000000..d315a66 --- /dev/null +++ b/tests/Debug/LoaderDebugTest.php @@ -0,0 +1,309 @@ + + */ + private function sample_record( string $directory = '/srv/site/wp-content/plugins/demo/inc' ): array { + return [ + 'directory' => $directory, + 'cache_file' => $directory . '/class-loader-cache/class-loader-cache-v2.php', + 'cache_exists' => false, + 'cache_used' => false, + 'cache_disabled' => false, + 'classes' => [ 'TenupTmp\\Widget' ], + 'version' => '1.3.0', + 'reference' => 'abcdef1234567890', + ]; + } + + /** + * Stub the functions record() needs, with the tooling enabled. + * + * @return void + */ + private function stub_enabled() { + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + } + + /** + * is_enabled() is false when the enable filter returns false. + * + * @return void + */ + public function test_is_enabled_false_when_filter_disables() { + when( 'add_action' )->justReturn( true ); + when( 'apply_filters' )->justReturn( false ); + + $this->assertFalse( LoaderDebug::is_enabled() ); + } + + /** + * is_enabled() is false when the disable constant is set. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_is_enabled_false_when_constant_defined() { + when( 'add_action' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + define( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG', true ); + + $this->assertFalse( LoaderDebug::is_enabled() ); + } + + /** + * record() stores the record when enabled. + * + * @return void + */ + public function test_record_stores_when_enabled() { + $this->stub_enabled(); + + LoaderDebug::record( $this->sample_record() ); + + $this->assertCount( 1, LoaderDebug::get_loaders() ); + } + + /** + * record() accumulates multiple records. + * + * @return void + */ + public function test_records_accumulate() { + $this->stub_enabled(); + + LoaderDebug::record( $this->sample_record( '/a/inc' ) ); + LoaderDebug::record( $this->sample_record( '/b/inc' ) ); + + $this->assertCount( 2, LoaderDebug::get_loaders() ); + } + + /** + * record() stores nothing when disabled. + * + * @return void + */ + public function test_record_skips_when_disabled() { + when( 'add_action' )->justReturn( true ); + when( 'apply_filters' )->justReturn( false ); + + LoaderDebug::record( $this->sample_record() ); + + $this->assertSame( [], LoaderDebug::get_loaders() ); + } + + /** + * The callback registered on the aggregation filter merges this copy's records into + * whatever other copies have already contributed. + * + * @return void + */ + public function test_aggregation_filter_merges_records() { + $captured = null; + + when( 'add_action' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + when( 'add_filter' )->alias( + static function ( $hook, $callback ) use ( &$captured ) { + if ( LoaderDebug::FILTER === $hook ) { + $captured = $callback; + } + return true; + } + ); + + LoaderDebug::record( $this->sample_record( '/b/inc' ) ); + + $this->assertIsCallable( $captured ); + + // A record contributed by another copy should be preserved alongside ours. + $existing = [ [ 'directory' => '/a/inc' ] ]; + $merged = $captured( $existing ); + + $this->assertCount( 2, $merged ); + $this->assertSame( '/a/inc', $merged[0]['directory'] ); + $this->assertSame( '/b/inc', $merged[1]['directory'] ); + } + + /** + * render_page() lists each loader and the classes it loaded. + * + * @return void + */ + public function test_render_page_lists_loaders_and_classes() { + $this->stub_render_environment(); + + LoaderDebug::record( $this->sample_record() ); + + $output = $this->capture_render(); + + $this->assertStringContainsString( 'WP Framework Loaders', $output ); + $this->assertStringContainsString( '/srv/site/wp-content/plugins/demo/inc', $output ); + $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); + $this->assertStringContainsString( 'Check this cache for staleness', $output ); + } + + /** + * owner_label() derives a plugin name when the directory sits under the plugins root. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_owner_label_derives_plugin_name() { + define( 'WP_PLUGIN_DIR', '/srv/site/wp-content/plugins' ); + + $method = ( new \ReflectionClass( LoaderDebug::class ) )->getMethod( 'owner_label' ); + $method->setAccessible( true ); + + $this->assertSame( + 'Plugin: demo', + $method->invoke( null, '/srv/site/wp-content/plugins/demo/inc' ) + ); + } + + /** + * render_page() reports drift when a staleness check is requested with a valid nonce. + * + * @return void + */ + public function test_render_page_reports_staleness_drift() { + $this->stub_render_environment(); + when( 'wp_verify_nonce' )->justReturn( true ); + + $dir = $this->make_temp_class_dir(); + + // The loaded list (in the record) is deliberately out of date versus what is on disk. + $record = $this->sample_record( $dir ); + $record['classes'] = [ 'TenupTmp\\Old' ]; + LoaderDebug::record( $record ); + + $_GET['check'] = md5( $dir ); + $_GET['_wpnonce'] = 'test'; + + $output = $this->capture_render(); + + unset( $_GET['check'], $_GET['_wpnonce'] ); + $this->remove_temp_dir( $dir ); + + $this->assertStringContainsString( 'Stale', $output ); + $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); // On disk, missing from cache. + $this->assertStringContainsString( 'TenupTmp\\Old', $output ); // In cache, gone from disk. + } + + /** + * Stub everything render_page() touches, with the tooling enabled and the current user + * capable. apply_filters returns this copy's records for the aggregation filter. + * + * @return void + */ + private function stub_render_environment() { + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'current_user_can' )->justReturn( true ); + when( 'sanitize_text_field' )->returnArg( 1 ); + when( 'wp_unslash' )->returnArg( 1 ); + when( 'admin_url' )->alias( + static function ( $path = '' ) { + return 'http://example.test/wp-admin/' . $path; + } + ); + when( 'add_query_arg' )->alias( + static function ( $args, $url ) { + return $url . '?' . http_build_query( (array) $args ); + } + ); + when( 'wp_nonce_url' )->returnArg( 1 ); + when( 'apply_filters' )->alias( + static function ( $hook, $value = null ) { + if ( LoaderDebug::FILTER === $hook ) { + return LoaderDebug::get_loaders(); + } + return $value; + } + ); + } + + /** + * Capture the output of render_page(). + * + * @return string + */ + private function capture_render(): string { + ob_start(); + LoaderDebug::render_page(); + return (string) ob_get_clean(); + } + + /** + * Create a temporary directory containing a single discoverable class. + * + * @return string The created directory path. + */ + private function make_temp_class_dir(): string { + $dir = sys_get_temp_dir() . '/tenup_loader_debug_' . uniqid( '', true ); + mkdir( $dir ); + file_put_contents( $dir . '/Widget.php', "isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } + + rmdir( $dir ); + } +} diff --git a/tests/FrameworkTestSetup.php b/tests/FrameworkTestSetup.php index 587344b..14a7cae 100644 --- a/tests/FrameworkTestSetup.php +++ b/tests/FrameworkTestSetup.php @@ -53,6 +53,9 @@ protected function setUp(): void { // phpcs:ignore WordPress.NamingConventions.V stubs( [ 'wp_get_environment_type' => 'local', + // Default to the front end so existing tests don't trigger admin-only debug + // recording; admin tests override this with their own stub. + 'is_admin' => false, 'sanitize_title' => function ( $title ) { return str_replace( ' ', '-', strtolower( $title ) ); }, @@ -68,6 +71,32 @@ protected function setUp(): void { // phpcs:ignore WordPress.NamingConventions.V stubEscapeFunctions(); stubTranslationFunctions(); + + $this->reset_loader_debug(); + } + + /** + * Reset the static state of the LoaderDebug registry so each test starts clean, + * independent of test execution order or process isolation. + * + * @return void + */ + protected function reset_loader_debug(): void { + if ( ! class_exists( \TenupFramework\Debug\LoaderDebug::class ) ) { + return; + } + + $reflection = new \ReflectionClass( \TenupFramework\Debug\LoaderDebug::class ); + + $loaders = $reflection->getProperty( 'loaders' ); + $loaders->setAccessible( true ); + $loaders->setValue( null, [] ); + + $booted = $reflection->getProperty( 'booted' ); + $booted->setAccessible( true ); + $booted->setValue( null, false ); + + unset( $GLOBALS['tenup_framework_debug_page_registered'] ); } /** diff --git a/tests/ModuleInitializationTest.php b/tests/ModuleInitializationTest.php index 2984b67..6a5e683 100644 --- a/tests/ModuleInitializationTest.php +++ b/tests/ModuleInitializationTest.php @@ -10,7 +10,7 @@ namespace TenupFrameworkTests; use PHPUnit\Framework\TestCase; -use function Brain\Monkey\Functions\stubs; +use function Brain\Monkey\Functions\when; /** * Test Class @@ -151,72 +151,231 @@ public function testIsClassFullyLoadable() { /** - * Ensure it returns false if VIP_GO_APP_ENVIRONMENT is defined. + * generate_cache() writes a readable cache file and returns the discovered classes. * * @return void */ - public function test_should_use_cache_returns_false_when_vip_env_is_defined() { - define( 'VIP_GO_APP_ENVIRONMENT', true ); + public function test_generate_cache_writes_a_readable_cache_file() { + $dir = $this->make_temp_class_dir(); + $module_init = \TenupFramework\ModuleInitialization::instance(); - $reflection = new \ReflectionClass( $module_init ); - $method = $reflection->getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $cached = $module_init->generate_cache( $dir ); + + $this->assertFileExists( $this->cache_file_path( $dir ) ); + $this->assertContains( 'TenupTmp\\Widget', $cached ); - $this->assertFalse( $method->invoke( $module_init ) ); + $this->remove_temp_dir( $dir ); } /** - * Ensure it returns false in non-production or staging environments. + * The runtime read path uses the cache file when one is present. * * @return void */ - public function test_should_use_cache_returns_false_in_non_production_or_staging_env() { - stubs( - [ - 'wp_get_environment_type' => 'development', - ] - ); + public function test_get_classes_reads_the_cache_file_when_present() { + $dir = $this->make_temp_class_dir(); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $module_init->generate_cache( $dir ); + + // Tamper with the cache so we can prove the read path uses it rather than re-discovering. + $this->write_file( $this->cache_file_path( $dir ), "get_classes( $dir ); + + $this->assertSame( [ 'TenupTmp\\Sentinel' ], array_values( $read ) ); + + $this->remove_temp_dir( $dir ); + } + + /** + * With no cache present the runtime discovers live and writes nothing. + * + * @return void + */ + public function test_get_classes_creates_no_cache_when_none_exists() { + $dir = $this->make_temp_class_dir(); $module_init = \TenupFramework\ModuleInitialization::instance(); - $reflection = new \ReflectionClass( $module_init ); - $method = $reflection->getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $classes = $module_init->get_classes( $dir ); + + $this->assertContains( 'TenupTmp\\Widget', $classes ); + $this->assertDirectoryDoesNotExist( $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME ); - $this->assertFalse( $method->invoke( $module_init ) ); + $this->remove_temp_dir( $dir ); } /** - * Ensure it returns false when TENUP_FRAMEWORK_DISABLE_CLASS_CACHE is defined. + * A cache written by an older framework version (a different filename) is ignored, + * so an upgraded site never serves a stale cache it cannot rewrite. * * @return void */ - public function test_should_use_cache_returns_false_when_disable_class_cache_is_defined() { + public function test_get_classes_ignores_legacy_cache_file() { + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME; + mkdir( $cache_dir ); + + // The previous version wrote `discoverer-cache-{id}` as a serialized file. + $this->write_file( $cache_dir . '/discoverer-cache-TenupFramework', serialize( [ 'TenupTmp\\Legacy' ] ) ); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $read = $module_init->get_classes( $dir ); + + $this->assertNotContains( 'TenupTmp\\Legacy', $read ); + $this->assertContains( 'TenupTmp\\Widget', $read ); + + $this->remove_temp_dir( $dir ); + } + + /** + * Defining TENUP_FRAMEWORK_DISABLE_CLASS_CACHE forces live discovery even when a + * cache file is present. + * + * @return void + */ + public function test_disable_constant_forces_live_discovery() { + $dir = $this->make_temp_class_dir(); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $module_init->generate_cache( $dir ); + + // Tamper with the cache; with caching disabled this sentinel must not be read. + $this->write_file( $this->cache_file_path( $dir ), "get_classes( $dir ); + + $this->assertNotContains( 'TenupTmp\\Sentinel', $read ); + $this->assertContains( 'TenupTmp\\Widget', $read ); + + $this->remove_temp_dir( $dir ); + } + + /** + * In the admin, init_classes() hands a loader record to the debug registry. + * + * @return void + */ + public function test_init_classes_records_a_loader_in_admin() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + + \TenupFramework\ModuleInitialization::instance()->init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertCount( 1, $loaders ); + $this->assertSame( $dir, $loaders[0]['directory'] ); + $this->assertContains( 'TenupTmp\\Widget', $loaders[0]['classes'] ); + + $this->remove_temp_dir( $dir ); + } + + /** + * On the front end, init_classes() records nothing (the data is only viewable in the admin). + * + * @return void + */ + public function test_init_classes_records_nothing_on_the_front_end() { + when( 'is_admin' )->justReturn( false ); + + $dir = $this->make_temp_class_dir(); + + \TenupFramework\ModuleInitialization::instance()->init_classes( $dir ); + + $this->assertSame( [], \TenupFramework\Debug\LoaderDebug::get_loaders() ); + + $this->remove_temp_dir( $dir ); + } + + /** + * discover_live() ignores any cache file and returns the real on-disk classes. + * + * @return void + */ + public function test_discover_live_ignores_the_cache() { + $dir = $this->make_temp_class_dir(); $module_init = \TenupFramework\ModuleInitialization::instance(); - $reflection = new \ReflectionClass( $module_init ); - $method = $reflection->getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $module_init->generate_cache( $dir ); + + // Tamper with the cache; discover_live() must not read it. + $this->write_file( $this->cache_file_path( $dir ), "discover_live( $dir ); + + $this->assertContains( 'TenupTmp\\Widget', $live ); + $this->assertNotContains( 'TenupTmp\\Sentinel', $live ); + + $this->remove_temp_dir( $dir ); + } + + /** + * Build the absolute path to the cache file for a discovery directory. + * + * @param string $dir The discovery directory. + * + * @return string + */ + private function cache_file_path( string $dir ): string { + return $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME + . '/' . \TenupFramework\ModuleInitialization::CACHE_FILENAME; + } + + /** + * Create a temporary directory containing a single discoverable class. + * + * @return string The created directory path. + */ + private function make_temp_class_dir(): string { + $dir = sys_get_temp_dir() . '/tenup_framework_test_' . uniqid( '', true ); + mkdir( $dir ); + $this->write_file( $dir . '/Widget.php', "assertFalse( $method->invoke( $module_init ) ); + return $dir; } /** - * Ensure it returns true under default conditions. + * Write a file, asserting the write succeeded. + * + * @param string $path The file path. + * @param string $contents The contents to write. + * + * @return void + */ + private function write_file( string $path, string $contents ): void { + $this->assertNotFalse( file_put_contents( $path, $contents ) ); + } + + /** + * Recursively remove a temporary directory. + * + * @param string $dir The directory to remove. * * @return void */ - public function test_should_use_cache_returns_true_under_default_conditions() { - stubs( - [ - 'wp_get_environment_type' => 'production', - ] + private function remove_temp_dir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST ); - $module_init = \TenupFramework\ModuleInitialization::instance(); - $reflection = new \ReflectionClass( $module_init ); - $method = $reflection->getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + foreach ( $items as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } - $this->assertTrue( $method->invoke( $module_init ) ); + rmdir( $dir ); } } From 0235e11841a4bad963ff5e18e805cf78943a072f Mon Sep 17 00:00:00 2001 From: Daryll Doyle Date: Mon, 29 Jun 2026 09:55:56 +0100 Subject: [PATCH 2/9] Polish the loader debug page UI - Card per loader with a colour-coded status badge (in use / uncached / disabled / present-but-unused) and an explanatory notice. - Move the loaded class list into a collapsible
accordion so a long list no longer makes the page huge. - Surface uncached as a noticeable amber state (valid default, not an error); reserve red for genuine problems (present-but-unused, stale, legacy files). - Self-contained scoped styles using the WP admin palette. --- src/Debug/LoaderDebug.php | 149 ++++++++++++++++++++++++++++++-------- 1 file changed, 118 insertions(+), 31 deletions(-) diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 44e6a5f..1edaa02 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -189,12 +189,13 @@ public static function render_page() { $loaders = []; } - echo '
'; + echo '
'; + self::render_styles(); echo '

' . esc_html__( 'WP Framework Loaders', 'tenup-framework' ) . '

'; - echo '

' . esc_html__( 'Each block is a directory passed to ModuleInitialization::init_classes(), with the state of its class-loader cache. Caches are built at deploy time and read (never written) at runtime.', 'tenup-framework' ) . '

'; + echo '

' . esc_html__( 'Each card is a directory passed to ModuleInitialization::init_classes(), with the state of its class-loader cache. Caches are built at deploy time and read — never written — at runtime.', 'tenup-framework' ) . '

'; if ( empty( $loaders ) ) { - echo '

' . esc_html__( 'No class loaders were recorded for this request.', 'tenup-framework' ) . '

'; + echo '
' . esc_html__( 'No class loaders were recorded for this request.', 'tenup-framework' ) . '
'; echo '
'; return; } @@ -223,31 +224,51 @@ protected static function render_loader( array $loader, string $check ) { ? array_values( array_map( [ self::class, 'to_string' ], $loader['classes'] ) ) : []; + $state = self::cache_state( $loader ); + $legacy = self::legacy_files( $cache_file ); + + echo '
'; + + echo '
'; echo '

' . esc_html( self::owner_label( $directory ) ) . '

'; - echo ''; + echo '' . esc_html( $state['badge'] ) . ''; + echo ''; - self::render_row( __( 'Directory', 'tenup-framework' ), $directory ); - self::render_row( __( 'Framework version', 'tenup-framework' ), self::version_label( $loader ) ); - self::render_row( __( 'Cache file', 'tenup-framework' ), $cache_file ); - self::render_row( __( 'Cache status', 'tenup-framework' ), self::cache_status_label( $loader ) ); - self::render_row( __( 'Classes loaded', 'tenup-framework' ), (string) count( $classes ) ); + if ( '' !== $state['note'] ) { + echo '
' . esc_html( $state['note'] ) . '
'; + } - $legacy = self::legacy_files( $cache_file ); if ( ! empty( $legacy ) ) { - self::render_row( - __( 'Stale cache files', 'tenup-framework' ), + echo '
' . esc_html( sprintf( /* translators: %s: comma-separated list of unexpected filenames. */ - __( 'Unexpected files alongside the current cache (likely left by an older version): %s', 'tenup-framework' ), + __( 'Unexpected files in the cache directory, likely left by an older version: %s. Delete them or redeploy.', 'tenup-framework' ), implode( ', ', $legacy ) ) - ); + ) . '
'; } + echo '
'; + self::render_row( __( 'Directory', 'tenup-framework' ), $directory ); + self::render_row( __( 'Framework version', 'tenup-framework' ), self::version_label( $loader ) ); + self::render_row( __( 'Cache file', 'tenup-framework' ), '' !== $cache_file ? $cache_file : '—' ); + self::render_row( __( 'Cache detail', 'tenup-framework' ), self::cache_detail( $loader ) ); echo '
'; + echo '
'; + echo '' . esc_html( + sprintf( + /* translators: %d: number of classes. */ + _n( '%d class loaded', '%d classes loaded', count( $classes ), 'tenup-framework' ), + count( $classes ) + ) + ) . ''; self::render_classes( $classes ); + echo '
'; + self::render_staleness( $directory, $classes, $check ); + + echo '
'; } /** @@ -274,7 +295,7 @@ protected static function render_classes( array $classes ) { return; } - echo ''; + echo '
'; echo ''; $module_init = ModuleInitialization::instance(); @@ -315,7 +336,7 @@ protected static function render_staleness( string $directory, array $classes, s self::CHECK_NONCE ); - echo '

' . esc_html__( 'Check this cache for staleness', 'tenup-framework' ) . '

'; + echo '

' . esc_html__( 'Check this cache for staleness', 'tenup-framework' ) . '

'; return; } @@ -325,11 +346,12 @@ protected static function render_staleness( string $directory, array $classes, s $added = array_diff( $live, $loaded ); // On disk but missing from the cache. if ( empty( $removed ) && empty( $added ) ) { - echo '

' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . '

'; + echo '
' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . '
'; return; } - echo '

' . esc_html__( 'Stale — the cache differs from a live scan:', 'tenup-framework' ) . '

'; + echo '
'; + echo '' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ''; if ( ! empty( $added ) ) { echo '

' . esc_html__( 'On disk but missing from the cache:', 'tenup-framework' ) . '

    '; @@ -348,6 +370,7 @@ protected static function render_staleness( string $directory, array $classes, s } echo '

    ' . esc_html__( 'Regenerate the cache in your build (composer generate-class-cache) or remove the file and redeploy.', 'tenup-framework' ) . '

    '; + echo '
'; } /** @@ -451,38 +474,102 @@ protected static function version_label( array $loader ): string { } /** - * A label describing the cache status, including mtime, size and whether it is in use. + * Resolve the headline cache state for a loader: a severity, a short badge label, and an + * optional explanatory note. + * + * Severity maps to the badge/notice colour. Note that running uncached is a valid default + * (caching is opt-in), so it is surfaced as a warning to be noticeable, not as an error. * * @param array $loader The loader record. * - * @return string + * @return array{severity: string, badge: string, note: string} */ - protected static function cache_status_label( array $loader ): string { + protected static function cache_state( array $loader ): array { if ( ! empty( $loader['cache_disabled'] ) ) { - return __( 'Disabled — discovering live (TENUP_FRAMEWORK_DISABLE_CLASS_CACHE).', 'tenup-framework' ); + return [ + 'severity' => 'warn', + 'badge' => __( 'Caching disabled', 'tenup-framework' ), + 'note' => __( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE is set, so any shipped cache is ignored and classes are discovered live on every request.', 'tenup-framework' ), + ]; } if ( empty( $loader['cache_exists'] ) ) { - return __( 'No cache file — discovering live on every request.', 'tenup-framework' ); + return [ + 'severity' => 'warn', + 'badge' => __( 'Uncached — live discovery', 'tenup-framework' ), + 'note' => __( 'No cache file is present, so classes are discovered live on every request. That is the correct default for small projects; for large codebases, build a cache in your pipeline (see Build and Deployment).', 'tenup-framework' ), + ]; } + if ( empty( $loader['cache_used'] ) ) { + return [ + 'severity' => 'error', + 'badge' => __( 'Cache present but not used', 'tenup-framework' ), + 'note' => __( 'A cache file exists but is not being used. This is unexpected — check TENUP_FRAMEWORK_DISABLE_CLASS_CACHE.', 'tenup-framework' ), + ]; + } + + return [ + 'severity' => 'ok', + 'badge' => __( 'Cache in use', 'tenup-framework' ), + 'note' => '', + ]; + } + + /** + * A short description of the cache file on disk (age and size), or a placeholder when none. + * + * @param array $loader The loader record. + * + * @return string + */ + protected static function cache_detail( array $loader ): string { $cache_file = self::to_string( $loader['cache_file'] ?? '' ); - $mtime = file_exists( $cache_file ) ? (int) filemtime( $cache_file ) : 0; - $size = file_exists( $cache_file ) ? (int) filesize( $cache_file ) : 0; - $used = empty( $loader['cache_used'] ) - ? __( 'present but not used', 'tenup-framework' ) - : __( 'in use', 'tenup-framework' ); + if ( '' === $cache_file || ! file_exists( $cache_file ) ) { + return __( 'No cache file on disk.', 'tenup-framework' ); + } + + $mtime = (int) filemtime( $cache_file ); + $size = (int) filesize( $cache_file ); return sprintf( - /* translators: 1: in-use status, 2: relative age, 3: file size. */ - __( 'Cache %1$s — built %2$s ago, %3$s.', 'tenup-framework' ), - $used, + /* translators: 1: relative age, 2: file size. */ + __( 'Built %1$s ago · %2$s', 'tenup-framework' ), $mtime ? human_time_diff( $mtime ) : __( 'unknown time', 'tenup-framework' ), size_format( $size ) ); } + /** + * Output the page's scoped styles once. + * + * @return void + */ + protected static function render_styles() { + echo ''; + } + /** * Find files in the cache directory that are not the current cache file — usually stale * leftovers from an older framework version. From 0696419e8e88fcc829dba8aa1d00f7cb91388d6a Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Tue, 7 Jul 2026 12:20:16 -0400 Subject: [PATCH 3/9] test: cover the build command and debug diagnostics; add loader timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing: - Add tests/Bin/GenerateClassCacheTest: shells out to the actual tenup-framework-generate-class-cache script (as CI would) and covers the generate path, no-args usage/exit-1, a missing directory failing while valid ones still cache, and multi-directory runs. Backed by small, salient example loader directories under tests/examples/ (a real ModuleInterface module, a plain support class, and a second directory). - Cover the previously untested LoaderDebug branches: legacy_files() detection, every cache_state() variant, and the staleness "up to date" path. - Replace the vacuous assertGreaterThanOrEqual(0, ...) in test_it_can_find_classes_to_register with an assertion that the registered set is non-empty and contains only ModuleInterface implementations. Loader timing: - ModuleInitialization::init_classes() now times discovery (cache read or live scan) and class lookup (reflection/instantiation/registration) separately and records both on the loader debug record. - The debug page shows both timings per loader, and the staleness check reports how long its live discovery ran — so the cache's saving on a given site is measurable. New format_duration() helper picks a sensible unit. phpcs, phpstan (level 10) and phpunit all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + docs/Debugging.md | 9 +- src/Debug/LoaderDebug.php | 48 +++- src/ModuleInitialization.php | 39 ++- tests/Bin/GenerateClassCacheTest.php | 232 ++++++++++++++++++ tests/Debug/LoaderDebugTest.php | 187 +++++++++++++- tests/ModuleInitializationTest.php | 36 ++- tests/examples/README.md | 20 ++ .../plugin-inc/Modules/GreetingModule.php | 40 +++ .../examples/plugin-inc/Support/Formatter.php | 28 +++ tests/examples/second-inc/Widgets/Card.php | 26 ++ 11 files changed, 638 insertions(+), 28 deletions(-) create mode 100644 tests/Bin/GenerateClassCacheTest.php create mode 100644 tests/examples/README.md create mode 100644 tests/examples/plugin-inc/Modules/GreetingModule.php create mode 100644 tests/examples/plugin-inc/Support/Formatter.php create mode 100644 tests/examples/second-inc/Widgets/Card.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 127735c..f4820f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file, per [the Ke ### Added - Build-time class-cache generation: a `tenup-framework-generate-class-cache` command (installed to `vendor/bin/`) and a `composer generate-class-cache` alias that build the cache in CI without bootstrapping WordPress. See [Build and Deployment](docs/Build-and-Deployment.md) ([#30](https://github.com/10up/wp-framework/issues/30)). - Hidden admin page (`admin.php?page=tenup-framework-loaders`, `manage_options`) that aggregates every class-loader cache on the site — across all framework copies — and shows each cache's path, status, loaded classes, and an on-demand live-vs-cache staleness check. Admin-only (no front-end overhead) and read-only. Disable with the `tenup_framework_enable_loader_debug` filter or the `TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG` constant. See [Debugging class loaders](docs/Debugging.md). +- The loader debug page reports per-loader timing: how long class **discovery** took (a cache read when cached, a live filesystem scan otherwise) and how long **class lookup** (reflection, instantiation and registration) took. The staleness check also reports how long its live discovery ran, so the cache's saving on a given site is measurable. ### Changed - The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)). diff --git a/docs/Debugging.md b/docs/Debugging.md index bf219e4..a1f3c71 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -33,6 +33,11 @@ page aggregates every loader recorded across all of them — even copies that ar one (usually leftovers from an older framework version). - **Classes loaded** — every class the loader resolved, with the file each one lives in. A class that no longer resolves is flagged as a likely stale entry. +- **Discovery time** — how long this request spent obtaining the class list. With a cache present + this is the cost of reading it; uncached it is the cost of a live filesystem scan, so the two + states can be compared directly. +- **Class lookup time** — how long reflecting, instantiating and registering the discovered + classes took. ## Staleness check @@ -42,7 +47,9 @@ directory and diffs the result against what the cache loaded, listing: - classes **on disk but missing from the cache** (the cache is behind), and - classes **in the cache but no longer on disk** (renamed/removed). -The check runs only when clicked, so the page itself stays cheap. If it reports drift, the cache +It also reports **how long the live discovery took**, which — compared against the cached +**Discovery time** above — shows what the cache is actually saving on this site. The check runs +only when clicked, so the page itself stays cheap. If it reports drift, the cache is stale: regenerate it in your build (`composer generate-class-cache`) or remove the file and redeploy. The page is **read-only** — it never deletes or rewrites a cache, consistent with the read-only runtime. diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 1edaa02..c898307 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -253,6 +253,8 @@ protected static function render_loader( array $loader, string $check ) { self::render_row( __( 'Framework version', 'tenup-framework' ), self::version_label( $loader ) ); self::render_row( __( 'Cache file', 'tenup-framework' ), '' !== $cache_file ? $cache_file : '—' ); self::render_row( __( 'Cache detail', 'tenup-framework' ), self::cache_detail( $loader ) ); + self::render_row( __( 'Discovery time', 'tenup-framework' ), self::format_duration( $loader['discovery_seconds'] ?? null ) ); + self::render_row( __( 'Class lookup time', 'tenup-framework' ), self::format_duration( $loader['lookup_seconds'] ?? null ) ); echo '
' . esc_html__( 'Class', 'tenup-framework' ) . '' . esc_html__( 'File', 'tenup-framework' ) . '
'; echo '
'; @@ -340,18 +342,27 @@ protected static function render_staleness( string $directory, array $classes, s return; } - $live = ModuleInitialization::instance()->discover_live( $directory ); + $live_start = microtime( true ); + $live = ModuleInitialization::instance()->discover_live( $directory ); + $live_seconds = microtime( true ) - $live_start; + $loaded = array_values( $classes ); $removed = array_diff( $loaded, $live ); // In cache but no longer on disk. $added = array_diff( $live, $loaded ); // On disk but missing from the cache. + $timing = sprintf( + /* translators: %s: formatted duration. */ + __( 'Live discovery took %s.', 'tenup-framework' ), + self::format_duration( $live_seconds ) + ); + if ( empty( $removed ) && empty( $added ) ) { - echo '
' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . '
'; + echo '
' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ) . '
'; return; } echo '
'; - echo '' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ''; + echo '' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ); if ( ! empty( $added ) ) { echo '

' . esc_html__( 'On disk but missing from the cache:', 'tenup-framework' ) . '

    '; @@ -385,6 +396,37 @@ protected static function to_string( $value ): string { return is_scalar( $value ) ? (string) $value : ''; } + /** + * Format a duration in seconds for display, choosing a sensible unit. Values arrive through + * a filter as mixed, so anything non-numeric or non-positive renders as a placeholder. + * + * @param mixed $seconds The duration in seconds. + * + * @return string + */ + protected static function format_duration( $seconds ): string { + $seconds = is_numeric( $seconds ) ? (float) $seconds : 0.0; + + if ( $seconds <= 0.0 ) { + return '—'; + } + + $milliseconds = $seconds * 1000; + + if ( $milliseconds < 1 ) { + /* translators: %s: duration in milliseconds. */ + return sprintf( __( '%s ms', 'tenup-framework' ), number_format( $milliseconds, 3 ) ); + } + + if ( $milliseconds < 1000 ) { + /* translators: %s: duration in milliseconds. */ + return sprintf( __( '%s ms', 'tenup-framework' ), number_format( $milliseconds, 2 ) ); + } + + /* translators: %s: duration in seconds. */ + return sprintf( __( '%s s', 'tenup-framework' ), number_format( $seconds, 2 ) ); + } + /** * A stable, opaque token identifying a loader directory in the check link. * diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index 9f00ed0..f009401 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -217,12 +217,14 @@ public function discover_live( $dir ) { * gathered there. The is_admin() check happens before LoaderDebug is referenced, so that * class never autoloads on the front end. * - * @param string $dir The directory that was discovered. - * @param array $classes The discovered class names. + * @param string $dir The directory that was discovered. + * @param array $classes The discovered class names. + * @param float $discovery_seconds Seconds spent obtaining the class list (cache read or live scan). + * @param float $lookup_seconds Seconds spent reflecting, instantiating and registering the classes. * * @return void */ - protected function record_loader_debug( $dir, array $classes ) { + protected function record_loader_debug( $dir, array $classes, float $discovery_seconds = 0.0, float $lookup_seconds = 0.0 ) { if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { return; } @@ -233,14 +235,16 @@ protected function record_loader_debug( $dir, array $classes ) { LoaderDebug::record( [ - 'directory' => $dir, - 'cache_file' => $cache_file, - 'cache_exists' => $cache_exists, - 'cache_used' => $cache_exists && ! $disabled, - 'cache_disabled' => $disabled, - 'classes' => $classes, - 'version' => $this->framework_version(), - 'reference' => $this->framework_reference(), + 'directory' => $dir, + 'cache_file' => $cache_file, + 'cache_exists' => $cache_exists, + 'cache_used' => $cache_exists && ! $disabled, + 'cache_disabled' => $disabled, + 'classes' => $classes, + 'version' => $this->framework_version(), + 'reference' => $this->framework_reference(), + 'discovery_seconds' => $discovery_seconds, + 'lookup_seconds' => $lookup_seconds, ] ); } @@ -303,9 +307,14 @@ protected function directory_check( $dir ): bool { public function init_classes( $dir = '' ) { $this->directory_check( $dir ); - $classes = $this->get_classes( $dir ); + // Time discovery (a cache read when a cache is present, a live filesystem scan + // otherwise) separately from the reflection/instantiation work below, so the debug + // page can show where the request's time actually goes. + $discovery_start = microtime( true ); + $classes = $this->get_classes( $dir ); + $discovery_seconds = microtime( true ) - $discovery_start; - $this->record_loader_debug( $dir, $classes ); + $lookup_start = microtime( true ); $load_class_order = []; foreach ( $classes as $class ) { @@ -366,6 +375,10 @@ public function init_classes( $dir = '' ) { } } } + + $lookup_seconds = microtime( true ) - $lookup_start; + + $this->record_loader_debug( $dir, $classes, $discovery_seconds, $lookup_seconds ); } /** diff --git a/tests/Bin/GenerateClassCacheTest.php b/tests/Bin/GenerateClassCacheTest.php new file mode 100644 index 0000000..81d10db --- /dev/null +++ b/tests/Bin/GenerateClassCacheTest.php @@ -0,0 +1,232 @@ + + */ + private $temp_dirs = []; + + /** + * Remove any temporary directories created during the test. + * + * @return void + */ + protected function tearDown(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + foreach ( $this->temp_dirs as $dir ) { + $this->remove_dir( $dir ); + } + $this->temp_dirs = []; + + parent::tearDown(); + } + + /** + * Running the command against a directory writes a readable cache of its classes. + * + * @return void + */ + public function test_generates_a_cache_for_a_directory() { + $dir = $this->example_copy( 'plugin-inc' ); + + $result = $this->run_bin( [ $dir ] ); + + $this->assertSame( 0, $result['exit'], $result['stderr'] ); + $this->assertStringContainsString( 'Cached', $result['stdout'] ); + + $cache_file = $this->cache_file_path( $dir ); + $this->assertFileExists( $cache_file ); + + $cached = require $cache_file; + $this->assertContains( 'TenupFrameworkExamples\\Modules\\GreetingModule', $cached ); + $this->assertContains( 'TenupFrameworkExamples\\Support\\Formatter', $cached ); + } + + /** + * With no arguments the command prints usage to stderr and exits non-zero. + * + * @return void + */ + public function test_reports_usage_and_fails_without_arguments() { + $result = $this->run_bin( [] ); + + $this->assertSame( 1, $result['exit'] ); + $this->assertStringContainsString( 'Usage:', $result['stderr'] ); + } + + /** + * A missing directory fails that directory (non-zero exit, error on stderr) but the command + * still processes the directories that are valid. + * + * @return void + */ + public function test_missing_directory_fails_but_valid_directories_still_cache() { + $good = $this->example_copy( 'plugin-inc' ); + $missing = sys_get_temp_dir() . '/tenup_bin_missing_' . uniqid( '', true ); + + $result = $this->run_bin( [ $missing, $good ] ); + + $this->assertSame( 1, $result['exit'] ); + $this->assertStringContainsString( $missing, $result['stderr'] ); + $this->assertStringContainsString( 'Failed to generate cache', $result['stderr'] ); + + // The valid directory was still cached despite the earlier failure. + $this->assertFileExists( $this->cache_file_path( $good ) ); + } + + /** + * Several directories can be cached in a single invocation. + * + * @return void + */ + public function test_caches_multiple_directories() { + $first = $this->example_copy( 'plugin-inc' ); + $second = $this->example_copy( 'second-inc' ); + + $result = $this->run_bin( [ $first, $second ] ); + + $this->assertSame( 0, $result['exit'], $result['stderr'] ); + $this->assertFileExists( $this->cache_file_path( $first ) ); + $this->assertFileExists( $this->cache_file_path( $second ) ); + + $cached = require $this->cache_file_path( $second ); + $this->assertContains( 'TenupFrameworkExamples\\Widgets\\Card', $cached ); + } + + /** + * Run the bin script with the given arguments, returning its stdout, stderr and exit code. + * + * @param array $args The arguments to pass after the script name. + * + * @return array{stdout: string, stderr: string, exit: int} + */ + private function run_bin( array $args ): array { + $script = dirname( __DIR__, 2 ) . '/bin/tenup-framework-generate-class-cache'; + $command = array_map( 'escapeshellarg', array_merge( [ PHP_BINARY, $script ], $args ) ); + + $descriptors = [ + 1 => [ 'pipe', 'w' ], + 2 => [ 'pipe', 'w' ], + ]; + + $process = proc_open( implode( ' ', $command ), $descriptors, $pipes ); + $this->assertIsResource( $process ); + + $stdout = (string) stream_get_contents( $pipes[1] ); + $stderr = (string) stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit = proc_close( $process ); + + return [ + 'stdout' => $stdout, + 'stderr' => $stderr, + 'exit' => $exit, + ]; + } + + /** + * Copy an example directory into a fresh temp directory so the command can write a cache + * into it without touching the committed examples. + * + * @param string $name The example directory name under tests/examples. + * + * @return string The path to the temp copy. + */ + private function example_copy( string $name ): string { + $source = __DIR__ . '/../examples/' . $name; + $target = sys_get_temp_dir() . '/tenup_bin_' . $name . '_' . uniqid( '', true ); + + $this->copy_dir( $source, $target ); + $this->temp_dirs[] = $target; + + return $target; + } + + /** + * The absolute path to the cache file the command writes for a directory. + * + * @param string $dir The discovery directory. + * + * @return string + */ + private function cache_file_path( string $dir ): string { + return $dir . '/' . ModuleInitialization::CACHE_DIR_NAME . '/' . ModuleInitialization::CACHE_FILENAME; + } + + /** + * Recursively copy a directory. + * + * @param string $source The source directory. + * @param string $target The target directory. + * + * @return void + */ + private function copy_dir( string $source, string $target ): void { + mkdir( $target, 0777, true ); + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $source, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $items as $item ) { + $destination = $target . '/' . $items->getSubPathname(); + if ( $item->isDir() ) { + mkdir( $destination, 0777, true ); + } else { + copy( $item->getPathname(), $destination ); + } + } + } + + /** + * Recursively remove a directory. + * + * @param string $dir The directory to remove. + * + * @return void + */ + private function remove_dir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $items as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } + + rmdir( $dir ); + } +} diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index d315a66..8d8f5fe 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -30,14 +30,16 @@ class LoaderDebugTest extends TestCase { */ private function sample_record( string $directory = '/srv/site/wp-content/plugins/demo/inc' ): array { return [ - 'directory' => $directory, - 'cache_file' => $directory . '/class-loader-cache/class-loader-cache-v2.php', - 'cache_exists' => false, - 'cache_used' => false, - 'cache_disabled' => false, - 'classes' => [ 'TenupTmp\\Widget' ], - 'version' => '1.3.0', - 'reference' => 'abcdef1234567890', + 'directory' => $directory, + 'cache_file' => $directory . '/class-loader-cache/class-loader-cache-v2.php', + 'cache_exists' => false, + 'cache_used' => false, + 'cache_disabled' => false, + 'classes' => [ 'TenupTmp\\Widget' ], + 'version' => '1.3.0', + 'reference' => 'abcdef1234567890', + 'discovery_seconds' => 0.0123, + 'lookup_seconds' => 0.0456, ]; } @@ -171,6 +173,12 @@ public function test_render_page_lists_loaders_and_classes() { $this->assertStringContainsString( '/srv/site/wp-content/plugins/demo/inc', $output ); $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); $this->assertStringContainsString( 'Check this cache for staleness', $output ); + + // The recorded discovery/lookup timings are surfaced on the page. + $this->assertStringContainsString( 'Discovery time', $output ); + $this->assertStringContainsString( 'Class lookup time', $output ); + $this->assertStringContainsString( '12.30 ms', $output ); // 0.0123s discovery. + $this->assertStringContainsString( '45.60 ms', $output ); // 0.0456s lookup. } /** @@ -222,6 +230,169 @@ public function test_render_page_reports_staleness_drift() { $this->assertStringContainsString( 'TenupTmp\\Old', $output ); // In cache, gone from disk. } + /** + * render_page() confirms an up-to-date cache and reports how long the live scan took when a + * staleness check is requested and the loaded list matches disk. + * + * @return void + */ + public function test_render_page_reports_up_to_date_and_timing() { + $this->stub_render_environment(); + when( 'wp_verify_nonce' )->justReturn( true ); + + $dir = $this->make_temp_class_dir(); + + // The loaded list matches what is actually on disk (the single Widget class). + $record = $this->sample_record( $dir ); + $record['classes'] = [ 'TenupTmp\\Widget' ]; + LoaderDebug::record( $record ); + + $_GET['check'] = md5( $dir ); + $_GET['_wpnonce'] = 'test'; + + $output = $this->capture_render(); + + unset( $_GET['check'], $_GET['_wpnonce'] ); + $this->remove_temp_dir( $dir ); + + $this->assertStringContainsString( 'Up to date', $output ); + $this->assertStringContainsString( 'Live discovery took', $output ); + } + + /** + * cache_state() maps each combination of the record flags to the expected severity and badge. + * + * @dataProvider cache_state_provider + * + * @param array $flags The cache_* flags to set on the record. + * @param string $expected_sev The expected severity. + * @param string $expected_snippet A substring expected in the badge. + * + * @return void + */ + public function test_cache_state_resolves_expected_states( array $flags, string $expected_sev, string $expected_snippet ) { + $state = $this->invoke_protected( 'cache_state', [ array_merge( $this->sample_record(), $flags ) ] ); + + $this->assertSame( $expected_sev, $state['severity'] ); + $this->assertStringContainsString( $expected_snippet, $state['badge'] ); + } + + /** + * Data for test_cache_state_resolves_expected_states. + * + * @return array, 1: string, 2: string}> + */ + public function cache_state_provider(): array { + return [ + 'disabled' => [ [ 'cache_disabled' => true ], 'warn', 'disabled' ], + 'uncached' => [ + [ + 'cache_exists' => false, + 'cache_used' => false, + ], + 'warn', + 'Uncached', + ], + 'present but unused' => [ + [ + 'cache_exists' => true, + 'cache_used' => false, + ], + 'error', + 'not used', + ], + 'in use' => [ + [ + 'cache_exists' => true, + 'cache_used' => true, + ], + 'ok', + 'in use', + ], + ]; + } + + /** + * legacy_files() reports files in the cache directory that are not the current cache file, + * and nothing when the directory is clean or absent. + * + * @return void + */ + public function test_legacy_files_detects_unexpected_files() { + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/class-loader-cache'; + mkdir( $cache_dir ); + + $current = $cache_dir . '/class-loader-cache-v2.php'; + file_put_contents( $current, 'assertSame( [], $this->invoke_protected( 'legacy_files', [ $current ] ) ); + + // A leftover file from an older version is reported. + file_put_contents( $cache_dir . '/discoverer-cache-TenupFramework', 'x' ); + $found = $this->invoke_protected( 'legacy_files', [ $current ] ); + $this->assertContains( 'discoverer-cache-TenupFramework', $found ); + $this->assertNotContains( 'class-loader-cache-v2.php', $found ); + + $this->remove_temp_dir( $dir ); + } + + /** + * legacy_files() is empty when the cache directory does not exist. + * + * @return void + */ + public function test_legacy_files_empty_when_directory_absent() { + $missing = sys_get_temp_dir() . '/tenup_missing_' . uniqid( '', true ) . '/class-loader-cache-v2.php'; + + $this->assertSame( [], $this->invoke_protected( 'legacy_files', [ $missing ] ) ); + } + + /** + * format_duration() picks a sensible unit and renders a placeholder for non-positive input. + * + * @dataProvider duration_provider + * + * @param mixed $seconds The duration in seconds. + * @param string $expected The expected rendered string. + * + * @return void + */ + public function test_format_duration( $seconds, string $expected ) { + $this->assertSame( $expected, $this->invoke_protected( 'format_duration', [ $seconds ] ) ); + } + + /** + * Data for test_format_duration. + * + * @return array + */ + public function duration_provider(): array { + return [ + 'zero' => [ 0.0, '—' ], + 'non-numeric' => [ 'nope', '—' ], + 'sub-milli' => [ 0.0004, '0.400 ms' ], + 'milliseconds' => [ 0.0123, '12.30 ms' ], + 'seconds' => [ 1.5, '1.50 s' ], + ]; + } + + /** + * Invoke a protected static method on LoaderDebug via reflection. + * + * @param string $method The method name. + * @param array $args The arguments. + * + * @return mixed + */ + private function invoke_protected( string $method, array $args ) { + $reflection = ( new \ReflectionClass( LoaderDebug::class ) )->getMethod( $method ); + $reflection->setAccessible( true ); + + return $reflection->invokeArgs( null, $args ); + } + /** * Stub everything render_page() touches, with the tooling enabled and the current user * capable. apply_filters returns this copy's records for the aggregation filter. diff --git a/tests/ModuleInitializationTest.php b/tests/ModuleInitializationTest.php index 6a5e683..9b349f9 100644 --- a/tests/ModuleInitializationTest.php +++ b/tests/ModuleInitializationTest.php @@ -42,11 +42,14 @@ public function test_it_can_find_classes() { */ public function test_it_can_find_classes_to_register() { $class = \TenupFramework\ModuleInitialization::instance(); - $class->init_classes( dirname( __DIR__, 1 ) . '/src/' ); + $class->init_classes( dirname( __DIR__, 1 ) . '/fixtures/classes' ); $classes = $class->get_all_classes(); - // Check that we have only classes that extend Module and more than 0. - $this->assertGreaterThanOrEqual( 0, count( $classes ) ); + // The registered set is non-empty and contains only ModuleInterface implementations. + $this->assertNotEmpty( $classes ); + foreach ( $classes as $registered ) { + $this->assertInstanceOf( \TenupFramework\ModuleInterface::class, $registered ); + } } /** @@ -276,6 +279,33 @@ public function test_init_classes_records_a_loader_in_admin() { $this->remove_temp_dir( $dir ); } + /** + * In the admin, init_classes() records how long discovery and class lookup took. + * + * @return void + */ + public function test_init_classes_records_timing_in_admin() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + + \TenupFramework\ModuleInitialization::instance()->init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertCount( 1, $loaders ); + $this->assertArrayHasKey( 'discovery_seconds', $loaders[0] ); + $this->assertArrayHasKey( 'lookup_seconds', $loaders[0] ); + $this->assertIsFloat( $loaders[0]['discovery_seconds'] ); + $this->assertIsFloat( $loaders[0]['lookup_seconds'] ); + $this->assertGreaterThanOrEqual( 0.0, $loaders[0]['discovery_seconds'] ); + $this->assertGreaterThanOrEqual( 0.0, $loaders[0]['lookup_seconds'] ); + + $this->remove_temp_dir( $dir ); + } + /** * On the front end, init_classes() records nothing (the data is only viewable in the admin). * diff --git a/tests/examples/README.md b/tests/examples/README.md new file mode 100644 index 0000000..ff67582 --- /dev/null +++ b/tests/examples/README.md @@ -0,0 +1,20 @@ +# Example loader directories + +Small, self-contained directories that mirror what a real plugin/theme passes to +`ModuleInitialization::init_classes()` — the same directories you would point the +`tenup-framework-generate-class-cache` build command at. + +They exist so the class-cache tooling can be exercised end-to-end against realistic +input rather than throwaway inline strings: + +- `plugin-inc/` — a typical plugin `inc/` directory: one `ModuleInterface` module + (`Modules\GreetingModule`) plus a plain support class (`Support\Formatter`) that is + discovered but never registered. +- `second-inc/` — a second directory, used to prove the build command caches several + directories in a single run (as a multi-package project would). + +The classes are intentionally tiny. Discovery reads them with a tokenizer and never +loads them, so they do not need to be autoloadable to be cached. + +Generated `class-loader-cache/` directories are git-ignored build artefacts; the tests +create them in a temporary copy and clean them up. diff --git a/tests/examples/plugin-inc/Modules/GreetingModule.php b/tests/examples/plugin-inc/Modules/GreetingModule.php new file mode 100644 index 0000000..808e678 --- /dev/null +++ b/tests/examples/plugin-inc/Modules/GreetingModule.php @@ -0,0 +1,40 @@ + Date: Tue, 7 Jul 2026 14:32:46 -0400 Subject: [PATCH 4/9] test: address code-review feedback on timing and diagnostics - format_duration(): reject non-finite input (INF/NAN) alongside non-positive, so a misbehaving contributor to the tenup_framework_debug_loaders filter can no longer render "inf s" / "nan s". Makes the "untrusted mixed input" contract in the docblock actually hold. Cover with negative, NAN and INF data cases. - FrameworkTestSetup: reset the ModuleInitialization singleton in setUp(). The trait-level @runTestsInSeparateProcesses annotation does not take effect (PHPUnit ignores it on a used trait; confirmed by suite wall-time and the author's explicit method-level @runInSeparateProcess on the two define() tests), so the singleton's accumulated $classes previously leaked between tests. Prevents order-dependent flakiness as more tests are added. phpcs, phpstan (level 10) and phpunit all green (57 tests / 148 assertions). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Debug/LoaderDebug.php | 4 +++- tests/Debug/LoaderDebugTest.php | 3 +++ tests/FrameworkTestSetup.php | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index c898307..8254f83 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -407,7 +407,9 @@ protected static function to_string( $value ): string { protected static function format_duration( $seconds ): string { $seconds = is_numeric( $seconds ) ? (float) $seconds : 0.0; - if ( $seconds <= 0.0 ) { + // Values arrive through the cross-copy filter as mixed, so reject non-positive and + // non-finite (INF/NAN) input rather than rendering "inf s" / "nan s". + if ( $seconds <= 0.0 || ! is_finite( $seconds ) ) { return '—'; } diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index 8d8f5fe..097078d 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -371,7 +371,10 @@ public function test_format_duration( $seconds, string $expected ) { public function duration_provider(): array { return [ 'zero' => [ 0.0, '—' ], + 'negative' => [ -0.005, '—' ], 'non-numeric' => [ 'nope', '—' ], + 'not-a-number' => [ NAN, '—' ], + 'infinite' => [ INF, '—' ], 'sub-milli' => [ 0.0004, '0.400 ms' ], 'milliseconds' => [ 0.0123, '12.30 ms' ], 'seconds' => [ 1.5, '1.50 s' ], diff --git a/tests/FrameworkTestSetup.php b/tests/FrameworkTestSetup.php index 14a7cae..d879f85 100644 --- a/tests/FrameworkTestSetup.php +++ b/tests/FrameworkTestSetup.php @@ -72,9 +72,28 @@ protected function setUp(): void { // phpcs:ignore WordPress.NamingConventions.V stubEscapeFunctions(); stubTranslationFunctions(); + $this->reset_module_initialization(); $this->reset_loader_debug(); } + /** + * Reset the ModuleInitialization singleton so its accumulated `$classes` do not leak between + * tests. The suite is not process-isolated (the trait-level annotation does not take effect), + * so without this a class registered in one test would be seen as "already initialized" in a + * later one. + * + * @return void + */ + protected function reset_module_initialization(): void { + if ( ! class_exists( \TenupFramework\ModuleInitialization::class ) ) { + return; + } + + $instance = ( new \ReflectionClass( \TenupFramework\ModuleInitialization::class ) )->getProperty( 'instance' ); + $instance->setAccessible( true ); + $instance->setValue( null, null ); + } + /** * Reset the static state of the LoaderDebug registry so each test starts clean, * independent of test execution order or process isolation. From 3fcfa846b7328cc119da2d97b3224369f565ff1c Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Fri, 10 Jul 2026 13:00:47 -0400 Subject: [PATCH 5/9] harden cache resilience, fix timing correctness/tests, document mono-repo & opcache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a Fable re-review of the build-time-cache PR plus the timing addition, targeting mono-repo flexibility, genuine live-timing measurement, and security/perf. Resilience (security/perf): - get_classes() now catches a corrupt/truncated shipped cache (the cache is executable PHP loaded via `require`) and falls back to a live discovery instead of fataling every request until redeploy. Same spirit as #30: a bad cache must never take the site down. Covered by a new test. - record_loader_debug() bails on wp_doing_ajax(): is_admin() is also true on admin-ajax.php, and the debug page re-records on its own GET, so ajax recording was pure waste (often front-end triggered). Timing correctness: - Switch discovery/lookup timing from microtime(true) to the monotonic hrtime(true), so an NTP adjustment mid-request cannot skew a delta. - Rewrite the timing tests that were vacuous (assertGreaterThanOrEqual(0.0) passed the never-wired 0.0 default; the staleness string also matched the "took —." failure rendering). Now: the no-cache case asserts cache_used===false and strictly-positive live discovery time; a new cached case asserts cache_used===true and positive cache-read time; the staleness checks assert a real duration via regex. Mono-repo dedupe: - LoaderDebug::record() keeps one record per directory, so a repeated init_classes() for the same directory refreshes rather than duplicating a card. Docs: - Build-and-Deployment: opcache in-place-deploy staleness caveat, corrupt-cache fallback behaviour, and mixed-framework-version generation guidance for mono-repos. - Debugging: known limitations (per-request visibility, oldest-UI renders on mixed versions). CHANGELOG: fallback note; "identifier" -> "filename" wording fix. phpcs, phpstan (level 10) and phpunit all green (59 tests / 153 assertions). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +- docs/Build-and-Deployment.md | 22 +++++++++- docs/Debugging.md | 9 ++++ src/Debug/LoaderDebug.php | 12 ++++++ src/ModuleInitialization.php | 32 ++++++++++---- tests/Debug/LoaderDebugTest.php | 6 ++- tests/ModuleInitializationTest.php | 67 +++++++++++++++++++++++++++--- 7 files changed, 134 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4820f4..3772021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ All notable changes to this project will be documented in this file, per [the Ke ### Changed - The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)). -- Bumped the cache identifier so a cache written by an older version is ignored after upgrade rather than served stale. +- A corrupt or truncated shipped cache is caught at runtime and the request falls back to a live scan instead of fataling, so a bad cache degrades performance rather than taking the site down. +- Bumped the cache filename so a cache written by an older version is ignored after upgrade rather than served stale. - `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` now forces live discovery (ignores any shipped cache). ### Removed diff --git a/docs/Build-and-Deployment.md b/docs/Build-and-Deployment.md index 2f6c3e3..ef0bd6a 100644 --- a/docs/Build-and-Deployment.md +++ b/docs/Build-and-Deployment.md @@ -158,7 +158,19 @@ workflows: If the build can't run the generate step for some reason, the deploy still works — it just runs uncached. A broken cache after a build means the build is the thing to fix, not the -server. +server. A cache file that is corrupt or truncated (a half-finished rsync, an interrupted +build) is caught at runtime and the request falls back to a live scan, so a bad cache slows +the site rather than taking it down. + +### Opcache and in-place deploys + +The cache is a PHP file loaded with `require`, so PHP's opcache caches it like any other +source file. On hosts with `opcache.validate_timestamps=0` (common on the managed hosts where +issue #30 was reported), overwriting `class-loader-cache-v2.php` **in place** keeps serving the +previously compiled array until opcache is reset — which would reintroduce the very staleness +this design removes. Either deploy to a fresh path (atomic symlink swap, the default on most +zero-downtime deployers) or reset opcache as part of the deploy. The loader debug page's +staleness check live-scans and will flag this if it happens. ## The per-package model @@ -184,6 +196,14 @@ vendor/bin/tenup-framework-generate-class-cache \ wp-content/plugins/bar/inc ``` +One caveat for a mono-repo where packages pin **different** framework versions: the single +invocation above uses one package's `vendor/bin` copy to write every directory's cache. That +copy determines the cache filename and the Spatie discoverer version used. Today the payload is +a plain array of class-name strings and the filename is identical across versions, so this is +safe — but a future cache-format or filename bump would silently mismatch. When packages are on +different framework versions, run **each package's own** `vendor/bin/tenup-framework-generate-class-cache` +against its own directory so the writer and the reader are always the same version. + ## See also - [Docs Home](README.md) - [Autoloading and Modules](Autoloading.md) diff --git a/docs/Debugging.md b/docs/Debugging.md index a1f3c71..0f29a1c 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -59,6 +59,15 @@ read-only runtime. The recording and the page are **admin-only**. On front-end requests nothing is recorded, no hooks are added, and the debug class is never even loaded. +## Known limitations + +- **Per request** — the page shows loaders recorded on the current admin request. A plugin whose + `init_classes()` did not run on this request will not appear. +- **Mixed framework versions render with the oldest UI** — when a mono-repo runs several framework + copies on different versions, the first copy to record a loader registers and renders the page, + so newer per-loader fields degrade to blank rather than showing. Aligning framework versions + across packages avoids this; the data itself is still aggregated correctly across all copies. + ## Disabling it Enabled by default in the admin. Turn it off with either: diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 8254f83..4ea4334 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -88,6 +88,18 @@ public static function record( array $record ) { return; } + // Keep one record per directory: if init_classes() runs more than once for the same + // directory in a request, the latest call (with fresh timing) replaces the earlier one + // rather than producing a duplicate card. + $directory = isset( $record['directory'] ) && is_string( $record['directory'] ) ? $record['directory'] : ''; + foreach ( self::$loaders as $index => $existing ) { + if ( ( $existing['directory'] ?? null ) === $directory ) { + self::$loaders[ $index ] = $record; + self::boot(); + return; + } + } + self::$loaders[] = $record; self::boot(); diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index f009401..e69c8e1 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -113,10 +113,18 @@ public function get_classes( $dir ) { ); } - $classes = array_filter( $class_finder->get(), fn( $cl ) => is_string( $cl ) ); + try { + $discovered = $class_finder->get(); + } catch ( \Throwable $e ) { + // A shipped cache file that is corrupt or truncated — a partial deploy, an + // interrupted build, a half-written rsync — would otherwise fatal on every request + // (the cache is executable PHP loaded with `require`). Fall back to a fresh live + // discovery so the site keeps working, uncached, until the cache is rebuilt. This + // is the same spirit as issue #30: a bad cache must never take the site down. + $discovered = $this->build_discoverer( $dir )->get(); + } - // Return the classes - return $classes; + return array_filter( $discovered, fn( $cl ) => is_string( $cl ) ); } /** @@ -229,6 +237,13 @@ protected function record_loader_debug( $dir, array $classes, float $discovery_s return; } + // is_admin() is also true for admin-ajax.php. The debug page is a normal admin GET that + // re-runs discovery and records afresh, so recording on ajax requests is pure waste + // (often triggered from the front end). Skip them. + if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { + return; + } + $cache_file = $this->get_cache_directory( $dir ) . '/' . self::CACHE_FILENAME; $cache_exists = file_exists( $cache_file ); $disabled = $this->cache_disabled(); @@ -309,12 +324,13 @@ public function init_classes( $dir = '' ) { // Time discovery (a cache read when a cache is present, a live filesystem scan // otherwise) separately from the reflection/instantiation work below, so the debug - // page can show where the request's time actually goes. - $discovery_start = microtime( true ); + // page can show where the request's time actually goes. hrtime() is monotonic, so an + // NTP adjustment mid-request cannot produce a negative or wildly wrong delta. + $discovery_start = hrtime( true ); $classes = $this->get_classes( $dir ); - $discovery_seconds = microtime( true ) - $discovery_start; + $discovery_seconds = ( hrtime( true ) - $discovery_start ) / 1e9; - $lookup_start = microtime( true ); + $lookup_start = hrtime( true ); $load_class_order = []; foreach ( $classes as $class ) { @@ -376,7 +392,7 @@ public function init_classes( $dir = '' ) { } } - $lookup_seconds = microtime( true ) - $lookup_start; + $lookup_seconds = ( hrtime( true ) - $lookup_start ) / 1e9; $this->record_loader_debug( $dir, $classes, $discovery_seconds, $lookup_seconds ); } diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index 097078d..fcf7a84 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -228,6 +228,8 @@ public function test_render_page_reports_staleness_drift() { $this->assertStringContainsString( 'Stale', $output ); $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); // On disk, missing from cache. $this->assertStringContainsString( 'TenupTmp\\Old', $output ); // In cache, gone from disk. + // The drift notice also reports a real, positive live-discovery duration. + $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output ); } /** @@ -256,7 +258,9 @@ public function test_render_page_reports_up_to_date_and_timing() { $this->remove_temp_dir( $dir ); $this->assertStringContainsString( 'Up to date', $output ); - $this->assertStringContainsString( 'Live discovery took', $output ); + // Require a real, positive duration — this must NOT match the "Live discovery took —." + // placeholder that format_duration() emits for a non-positive/absent value. + $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output ); } /** diff --git a/tests/ModuleInitializationTest.php b/tests/ModuleInitializationTest.php index 9b349f9..1fb5644 100644 --- a/tests/ModuleInitializationTest.php +++ b/tests/ModuleInitializationTest.php @@ -231,6 +231,33 @@ public function test_get_classes_ignores_legacy_cache_file() { $this->remove_temp_dir( $dir ); } + /** + * A corrupt or truncated cache file does not fatal the request: get_classes() catches the + * error and falls back to a live discovery, so a bad cache degrades to uncached rather than + * taking the site down. + * + * @return void + */ + public function test_get_classes_falls_back_to_live_when_cache_is_corrupt() { + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME; + mkdir( $cache_dir ); + + // A truncated / syntactically broken cache file — `require` on this throws a ParseError. + $this->write_file( + $cache_dir . '/' . \TenupFramework\ModuleInitialization::CACHE_FILENAME, + "get_classes( $dir ); + + // Fell back to a live scan and still found the real class on disk. + $this->assertContains( 'TenupTmp\\Widget', $read ); + + $this->remove_temp_dir( $dir ); + } + /** * Defining TENUP_FRAMEWORK_DISABLE_CLASS_CACHE forces live discovery even when a * cache file is present. @@ -280,11 +307,12 @@ public function test_init_classes_records_a_loader_in_admin() { } /** - * In the admin, init_classes() records how long discovery and class lookup took. + * With no cache present, init_classes() records the time of a genuine live (uncached) + * discovery — a real filesystem scan, so the recorded duration is strictly positive. * * @return void */ - public function test_init_classes_records_timing_in_admin() { + public function test_init_classes_records_live_discovery_timing_in_admin() { when( 'is_admin' )->justReturn( true ); when( 'add_action' )->justReturn( true ); when( 'add_filter' )->justReturn( true ); @@ -296,12 +324,39 @@ public function test_init_classes_records_timing_in_admin() { $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); $this->assertCount( 1, $loaders ); - $this->assertArrayHasKey( 'discovery_seconds', $loaders[0] ); - $this->assertArrayHasKey( 'lookup_seconds', $loaders[0] ); + $this->assertFalse( $loaders[0]['cache_used'], 'No cache exists, so discovery must be live.' ); $this->assertIsFloat( $loaders[0]['discovery_seconds'] ); $this->assertIsFloat( $loaders[0]['lookup_seconds'] ); - $this->assertGreaterThanOrEqual( 0.0, $loaders[0]['discovery_seconds'] ); - $this->assertGreaterThanOrEqual( 0.0, $loaders[0]['lookup_seconds'] ); + // A live filesystem scan and the reflection loop both take measurable time; the never-wired + // default is 0.0, so asserting strictly-positive proves the instrumentation actually ran. + $this->assertGreaterThan( 0.0, $loaders[0]['discovery_seconds'] ); + $this->assertGreaterThan( 0.0, $loaders[0]['lookup_seconds'] ); + + $this->remove_temp_dir( $dir ); + } + + /** + * With a pre-built cache present, init_classes() reads it (cache_used) and still records a + * positive discovery duration — the cache-read cost rather than a live scan. + * + * @return void + */ + public function test_init_classes_records_cache_read_timing_in_admin() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + $module = \TenupFramework\ModuleInitialization::instance(); + $module->generate_cache( $dir ); + + $module->init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertCount( 1, $loaders ); + $this->assertTrue( $loaders[0]['cache_used'], 'A cache file exists, so it should be used.' ); + $this->assertGreaterThan( 0.0, $loaders[0]['discovery_seconds'] ); $this->remove_temp_dir( $dir ); } From 0fac09cb8be8baa21866167a7546fc203fbffb3c Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Fri, 10 Jul 2026 17:43:31 -0400 Subject: [PATCH 6/9] show absolute UTC build time in the loader cache detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache detail now reads "Built ago · · ", using gmdate() so the build timestamp is unambiguous regardless of site or server timezone. Covered by a cache_detail() test asserting the size and trailing UTC segment. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Debug/LoaderDebug.php | 20 +++++++++++++++----- tests/Debug/LoaderDebugTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 4ea4334..f675a40 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -573,7 +573,8 @@ protected static function cache_state( array $loader ): array { } /** - * A short description of the cache file on disk (age and size), or a placeholder when none. + * A short description of the cache file on disk — relative age, size, and the absolute build + * time in UTC — or a placeholder when none. Format: "Built ago · ()". * * @param array $loader The loader record. * @@ -589,11 +590,20 @@ protected static function cache_detail( array $loader ): string { $mtime = (int) filemtime( $cache_file ); $size = (int) filesize( $cache_file ); + if ( ! $mtime ) { + return sprintf( + /* translators: %s: file size. */ + __( 'Built at an unknown time · %s', 'tenup-framework' ), + size_format( $size ) + ); + } + return sprintf( - /* translators: 1: relative age, 2: file size. */ - __( 'Built %1$s ago · %2$s', 'tenup-framework' ), - $mtime ? human_time_diff( $mtime ) : __( 'unknown time', 'tenup-framework' ), - size_format( $size ) + /* translators: 1: relative age (e.g. "5 minutes"); 2: file size; 3: absolute build time in UTC. */ + __( 'Built %1$s ago · %2$s · %3$s', 'tenup-framework' ), + human_time_diff( $mtime ), + size_format( $size ), + gmdate( 'Y-m-d H:i:s', $mtime ) . ' UTC' ); } diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index fcf7a84..da25199 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -385,6 +385,32 @@ public function duration_provider(): array { ]; } + /** + * cache_detail() renders "Built ago · · " with the build time in UTC. + * + * @return void + */ + public function test_cache_detail_shows_size_and_utc_build_time() { + when( 'human_time_diff' )->justReturn( '5 minutes' ); + when( 'size_format' )->alias( static fn( $bytes ) => $bytes . ' B' ); + + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/class-loader-cache'; + mkdir( $cache_dir ); + $cache_file = $cache_dir . '/class-loader-cache-v2.php'; + file_put_contents( $cache_file, 'invoke_protected( 'cache_detail', [ [ 'cache_file' => $cache_file ] ] ); + + $this->assertStringContainsString( 'Built 5 minutes ago', $detail ); + // The absolute build time is the file mtime rendered in UTC as the trailing segment. + $expected_utc = gmdate( 'Y-m-d H:i:s', (int) filemtime( $cache_file ) ) . ' UTC'; + $this->assertStringContainsString( '· ' . $expected_utc, $detail ); + $this->assertMatchesRegularExpression( '/·\s*\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/', $detail ); + + $this->remove_temp_dir( $dir ); + } + /** * Invoke a protected static method on LoaderDebug via reflection. * From 1c5ed6f925605c3b505c5254ffb0ebefa6562948 Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Fri, 10 Jul 2026 17:56:35 -0400 Subject: [PATCH 7/9] grow the loader card to fit an expanded class list The class table's long file paths pushed it past the card's right border. The card now sizes to its content (min 60em, capped at the admin content width) so it widens when a class list is expanded, and long paths in the meta/class tables wrap (overflow-wrap: anywhere) so nothing spills once the width cap is reached. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Debug/LoaderDebug.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index f675a40..20bb500 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -614,7 +614,7 @@ protected static function cache_detail( array $loader ): string { */ protected static function render_styles() { echo ''; } From 14befa74926a5c208ba15caa196c33e4c2f01bc0 Mon Sep 17 00:00:00 2001 From: Ryan Leeson Date: Fri, 10 Jul 2026 18:01:27 -0400 Subject: [PATCH 8/9] Remove unused namespace reference --- src/ModuleInitialization.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index e69c8e1..2f021cf 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -12,7 +12,6 @@ use Composer\InstalledVersions; use ReflectionClass; use Spatie\StructureDiscoverer\Cache\FileDiscoverCacheDriver; -use Spatie\StructureDiscoverer\Data\DiscoveredStructure; use Spatie\StructureDiscoverer\Discover; use TenupFramework\Cache\ReadOnlyFileDiscoverCacheDriver; use TenupFramework\Debug\LoaderDebug; From 0d45ee7d6abb5432835b96443ec9f1b7c24fa2e0 Mon Sep 17 00:00:00 2001 From: Daryll Doyle Date: Thu, 30 Jul 2026 15:30:43 +0100 Subject: [PATCH 9/9] Surface corrupt-cache fallback on the debug page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ryan's #36 made a corrupt or truncated cache fall back to a live scan instead of fataling, but the loader record still derived cache_used from file existence alone — so the debug page badged a failed cache green "Cache in use", the staleness check read "up to date", and nothing was logged. The one screen meant to diagnose cache health hid the failure the fallback recovers from. - get_classes() records whether it fell back (a corrupt read) and fires a `tenup_framework_cache_load_failed` action so projects can log or alert. The array_filter now sits inside the try, so a cache that parses but returns a non-array also falls back rather than fataling on the filter. - The loader record carries `cache_failed`; cache_used is now false when the read failed. LoaderDebug badges that loader red, "Cache failed to load — running live". - Staleness timing switched from wall-clock microtime() to monotonic hrtime(), matching the request path. - Tests: corrupt cache falls back without fataling; the failed state is recorded and rendered. Docs + changelog note the action hook and the red state. Refs #30 --- CHANGELOG.md | 2 +- docs/Debugging.md | 19 +++++++++++- src/Debug/LoaderDebug.php | 12 ++++++-- src/ModuleInitialization.php | 39 ++++++++++++++++++++---- tests/Debug/LoaderDebugTest.php | 9 ++++++ tests/ModuleInitializationTest.php | 48 ++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3772021..23dc70e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file, per [the Ke ### Changed - The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)). -- A corrupt or truncated shipped cache is caught at runtime and the request falls back to a live scan instead of fataling, so a bad cache degrades performance rather than taking the site down. +- A corrupt or truncated shipped cache is caught at runtime and the request falls back to a live scan instead of fataling, so a bad cache degrades performance rather than taking the site down. The fallback fires a `tenup_framework_cache_load_failed` action (for logging or alerting) and the loader debug page flags that loader red as "Cache failed to load — running live" instead of reporting it as in use. - Bumped the cache filename so a cache written by an older version is ignored after upgrade rather than served stale. - `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` now forces live discovery (ignores any shipped cache). diff --git a/docs/Debugging.md b/docs/Debugging.md index 0f29a1c..18de671 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -28,7 +28,8 @@ page aggregates every loader recorded across all of them — even copies that ar - **Framework version** — version and git reference of the copy that recorded it, so a version mismatch between plugins is visible. - **Cache file** — its path, and the status: in use, present-but-not-used, discovering live - (no file), or disabled. When a file is present, its age and size. + (no file), disabled, or **failed to load** (a present cache that was corrupt or truncated, so + the runtime fell back to a live scan — badged red). When a file is present, its age and size. - **Stale cache files** — a warning if the cache directory holds files other than the current one (usually leftovers from an older framework version). - **Classes loaded** — every class the loader resolved, with the file each one lives in. A class @@ -59,6 +60,22 @@ read-only runtime. The recording and the page are **admin-only**. On front-end requests nothing is recorded, no hooks are added, and the debug class is never even loaded. +## Corrupt cache + +If a shipped cache file is corrupt or truncated (a partial deploy or half-written rsync), the +runtime catches it and falls back to a live scan rather than fataling the request. The debug page +flags that loader red as **"Cache failed to load — running live"** so the degraded state is +visible rather than looking healthy. To alert or log on it outside the admin, hook the action that +fires on every fallback: + +```php +add_action( 'tenup_framework_cache_load_failed', function ( $dir, $error ) { + error_log( "WP Framework: class cache for {$dir} failed to load: " . $error->getMessage() ); +}, 10, 2 ); +``` + +The fix is to rebuild the cache in your pipeline and redeploy. + ## Known limitations - **Per request** — the page shows loaders recorded on the current admin request. A plugin whose diff --git a/src/Debug/LoaderDebug.php b/src/Debug/LoaderDebug.php index 20bb500..0bf7a97 100644 --- a/src/Debug/LoaderDebug.php +++ b/src/Debug/LoaderDebug.php @@ -354,9 +354,9 @@ protected static function render_staleness( string $directory, array $classes, s return; } - $live_start = microtime( true ); + $live_start = hrtime( true ); $live = ModuleInitialization::instance()->discover_live( $directory ); - $live_seconds = microtime( true ) - $live_start; + $live_seconds = ( hrtime( true ) - $live_start ) / 1e9; $loaded = array_values( $classes ); $removed = array_diff( $loaded, $live ); // In cache but no longer on disk. @@ -549,6 +549,14 @@ protected static function cache_state( array $loader ): array { ]; } + if ( ! empty( $loader['cache_failed'] ) ) { + return [ + 'severity' => 'error', + 'badge' => __( 'Cache failed to load — running live', 'tenup-framework' ), + 'note' => __( 'A cache file is present but could not be read (corrupt or truncated), so the framework fell back to a live scan on every request. Rebuild the cache in your pipeline and redeploy.', 'tenup-framework' ), + ]; + } + if ( empty( $loader['cache_exists'] ) ) { return [ 'severity' => 'warn', diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index 2f021cf..a6ee4b1 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -82,6 +82,15 @@ private function __construct() { */ protected $classes = []; + /** + * Whether the most recent get_classes() call fell back to a live scan because a shipped + * cache file failed to load (corrupt or truncated). Read by record_loader_debug() so the + * debug page flags the degraded state instead of reporting the cache as healthy. + * + * @var bool + */ + protected $cache_read_failed = false; + /** * Get all the TenupFramework plugin classes. * @@ -112,18 +121,34 @@ public function get_classes( $dir ) { ); } + $this->cache_read_failed = false; + try { - $discovered = $class_finder->get(); + // array_filter is inside the try so that a cache which parses but returns a + // non-array (not only a truncated one) also falls back rather than fataling here. + return array_filter( $class_finder->get(), fn( $cl ) => is_string( $cl ) ); } catch ( \Throwable $e ) { // A shipped cache file that is corrupt or truncated — a partial deploy, an // interrupted build, a half-written rsync — would otherwise fatal on every request // (the cache is executable PHP loaded with `require`). Fall back to a fresh live // discovery so the site keeps working, uncached, until the cache is rebuilt. This // is the same spirit as issue #30: a bad cache must never take the site down. - $discovered = $this->build_discoverer( $dir )->get(); - } + $this->cache_read_failed = true; + + if ( function_exists( 'do_action' ) ) { + /** + * Fires when a shipped class cache could not be read and the runtime fell back + * to a live scan. Lets a project log or alert on a degraded (uncached) deploy; + * the loader debug page flags the same state. + * + * @param string $dir The directory whose cache failed to load. + * @param \Throwable $e The error raised while reading the cache. + */ + do_action( 'tenup_framework_cache_load_failed', $dir, $e ); + } - return array_filter( $discovered, fn( $cl ) => is_string( $cl ) ); + return array_filter( $this->build_discoverer( $dir )->get(), fn( $cl ) => is_string( $cl ) ); + } } /** @@ -246,14 +271,18 @@ protected function record_loader_debug( $dir, array $classes, float $discovery_s $cache_file = $this->get_cache_directory( $dir ) . '/' . self::CACHE_FILENAME; $cache_exists = file_exists( $cache_file ); $disabled = $this->cache_disabled(); + $failed = $this->cache_read_failed; LoaderDebug::record( [ 'directory' => $dir, 'cache_file' => $cache_file, 'cache_exists' => $cache_exists, - 'cache_used' => $cache_exists && ! $disabled, + // A present cache that failed to load was not actually used — the runtime fell + // back to a live scan — so report it as such rather than "in use". + 'cache_used' => $cache_exists && ! $disabled && ! $failed, 'cache_disabled' => $disabled, + 'cache_failed' => $failed, 'classes' => $classes, 'version' => $this->framework_version(), 'reference' => $this->framework_reference(), diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php index da25199..6eab4fd 100644 --- a/tests/Debug/LoaderDebugTest.php +++ b/tests/Debug/LoaderDebugTest.php @@ -305,6 +305,15 @@ public function cache_state_provider(): array { 'error', 'not used', ], + 'failed to load' => [ + [ + 'cache_exists' => true, + 'cache_used' => false, + 'cache_failed' => true, + ], + 'error', + 'failed to load', + ], 'in use' => [ [ 'cache_exists' => true, diff --git a/tests/ModuleInitializationTest.php b/tests/ModuleInitializationTest.php index 1fb5644..5f054ff 100644 --- a/tests/ModuleInitializationTest.php +++ b/tests/ModuleInitializationTest.php @@ -399,6 +399,54 @@ public function test_discover_live_ignores_the_cache() { $this->remove_temp_dir( $dir ); } + /** + * A corrupt or truncated cache file falls back to a live scan instead of fataling. + * + * @return void + */ + public function test_get_classes_falls_back_when_the_cache_is_corrupt() { + $dir = $this->make_temp_class_dir(); + $module_init = \TenupFramework\ModuleInitialization::instance(); + $module_init->generate_cache( $dir ); + + // Truncated PHP: require() raises a ParseError, which the read path must catch. + $this->write_file( $this->cache_file_path( $dir ), 'get_classes( $dir ); + + $this->assertContains( 'TenupTmp\\Widget', $classes ); + + $this->remove_temp_dir( $dir ); + } + + /** + * When the read path falls back, the loader record flags the failure so the debug page can + * show it rather than reporting the cache as in use. + * + * @return void + */ + public function test_corrupt_cache_records_a_failed_state() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + $module_init = \TenupFramework\ModuleInitialization::instance(); + $module_init->generate_cache( $dir ); + $this->write_file( $this->cache_file_path( $dir ), 'init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertNotEmpty( $loaders ); + $this->assertTrue( $loaders[0]['cache_failed'] ); + $this->assertFalse( $loaders[0]['cache_used'] ); + $this->assertContains( 'TenupTmp\\Widget', $loaders[0]['classes'] ); + + $this->remove_temp_dir( $dir ); + } + /** * Build the absolute path to the cache file for a discovery directory. *