From deec478b1a7394339c6721a170888243f2af6326 Mon Sep 17 00:00:00 2001 From: Daryll Doyle Date: Mon, 29 Jun 2026 09:28:43 +0100 Subject: [PATCH 1/2] 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/2] 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.
' . esc_html__( 'Class', 'tenup-framework' ) . '' . esc_html__( 'File', 'tenup-framework' ) . '