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..23dc70e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ 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). +- 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)). +- 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). + +### 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..ef0bd6a --- /dev/null +++ b/docs/Build-and-Deployment.md @@ -0,0 +1,210 @@ +# 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. 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 + +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 +``` + +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) +- [Modules and Initialization](Modules-and-Initialization.md) diff --git a/docs/Debugging.md b/docs/Debugging.md new file mode 100644 index 0000000..18de671 --- /dev/null +++ b/docs/Debugging.md @@ -0,0 +1,103 @@ +# 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), 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 + 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 + +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). + +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. + +## 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. + +## 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 + `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: + +```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..0bf7a97 --- /dev/null +++ b/src/Debug/LoaderDebug.php @@ -0,0 +1,681 @@ +> + */ + 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; + } + + // 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(); + } + + /** + * 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 '
'; + self::render_styles(); + echo '

' . esc_html__( 'WP Framework Loaders', '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 '
'; + 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'] ) ) + : []; + + $state = self::cache_state( $loader ); + $legacy = self::legacy_files( $cache_file ); + + echo '
'; + + echo '
'; + echo '

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

'; + echo '' . esc_html( $state['badge'] ) . ''; + echo '
'; + + if ( '' !== $state['note'] ) { + echo '
' . esc_html( $state['note'] ) . '
'; + } + + if ( ! empty( $legacy ) ) { + echo '
' . esc_html( + sprintf( + /* translators: %s: comma-separated list of unexpected filenames. */ + __( '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 ) ); + 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 '
'; + + 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 '
'; + } + + /** + * 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_start = hrtime( true ); + $live = ModuleInitialization::instance()->discover_live( $directory ); + $live_seconds = ( hrtime( true ) - $live_start ) / 1e9; + + $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' ) . ' ' . esc_html( $timing ) . '
'; + return; + } + + echo '
'; + 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' ) . '

    '; + 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' ) . '

'; + echo '
'; + } + + /** + * 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 : ''; + } + + /** + * 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; + + // 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 '—'; + } + + $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. + * + * @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; + } + + /** + * 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 array{severity: string, badge: string, note: string} + */ + protected static function cache_state( array $loader ): array { + if ( ! empty( $loader['cache_disabled'] ) ) { + 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_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', + '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 — relative age, size, and the absolute build + * time in UTC — or a placeholder when none. Format: "Built ago · ()". + * + * @param array $loader The loader record. + * + * @return string + */ + protected static function cache_detail( array $loader ): string { + $cache_file = self::to_string( $loader['cache_file'] ?? '' ); + + 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 ); + + 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 (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' + ); + } + + /** + * 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. + * + * @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..a6ee4b1 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -9,10 +9,12 @@ 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 +23,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. * @@ -54,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. * @@ -64,46 +101,221 @@ private function __construct() { public function get_classes( $dir ) { $this->directory_check( $dir ); + $class_finder = $this->build_discoverer( $dir ); + + // 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( + self::CACHE_ID, + new ReadOnlyFileDiscoverCacheDriver( + $this->get_cache_directory( $dir ), + false, + self::CACHE_FILENAME + ) + ); + } + + $this->cache_read_failed = false; + + try { + // 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. + $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( $this->build_discoverer( $dir )->get(), fn( $cl ) => is_string( $cl ) ); + } + } + + /** + * 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 + // Disable inheritance chain resolution. $class_finder->withoutChains(); - // If we are in production or staging, cache the class loader to improve performance. - if ( $this->should_use_cache() ) { - $class_finder->withCache( - __NAMESPACE__, - new FileDiscoverCacheDriver( $dir . '/class-loader-cache' ) - ); - } - - $classes = array_filter( $class_finder->get(), fn( $cl ) => is_string( $cl ) ); + return $class_finder; + } - // Return the classes - return $classes; + /** + * 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; } /** - * Should we set up and use the class cache? + * 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. + * @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, float $discovery_seconds = 0.0, float $lookup_seconds = 0.0 ) { + if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { + return; } - if ( ! in_array( wp_get_environment_type(), [ 'production', 'staging' ], true ) ) { - return false; + // 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; } - if ( defined( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE' ) && true === TENUP_FRAMEWORK_DISABLE_CLASS_CACHE ) { - return false; + $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, + // 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(), + 'discovery_seconds' => $discovery_seconds, + 'lookup_seconds' => $lookup_seconds, + ] + ); + } + + /** + * 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' ); } - return true; + 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 ''; } /** @@ -138,8 +350,18 @@ protected function directory_check( $dir ): bool { public function init_classes( $dir = '' ) { $this->directory_check( $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. 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 = ( hrtime( true ) - $discovery_start ) / 1e9; + + $lookup_start = hrtime( true ); + $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 ); @@ -197,6 +419,10 @@ public function init_classes( $dir = '' ) { } } } + + $lookup_seconds = ( hrtime( true ) - $lookup_start ) / 1e9; + + $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/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..6eab4fd --- /dev/null +++ b/tests/Debug/LoaderDebugTest.php @@ -0,0 +1,522 @@ + + */ + 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', + 'discovery_seconds' => 0.0123, + 'lookup_seconds' => 0.0456, + ]; + } + + /** + * 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 ); + + // 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. + } + + /** + * 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. + // The drift notice also reports a real, positive live-discovery duration. + $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output ); + } + + /** + * 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 ); + // 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 ); + } + + /** + * 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', + ], + 'failed to load' => [ + [ + 'cache_exists' => true, + 'cache_used' => false, + 'cache_failed' => true, + ], + 'error', + 'failed to load', + ], + '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, '—' ], + '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' ], + ]; + } + + /** + * 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. + * + * @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. + * + * @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..d879f85 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,51 @@ 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. + * + * @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..5f054ff 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 @@ -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 ); + } } /** @@ -151,72 +154,361 @@ 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_generate_cache_writes_a_readable_cache_file() { + $dir = $this->make_temp_class_dir(); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $cached = $module_init->generate_cache( $dir ); + + $this->assertFileExists( $this->cache_file_path( $dir ) ); + $this->assertContains( 'TenupTmp\\Widget', $cached ); + + $this->remove_temp_dir( $dir ); + } + + /** + * The runtime read path uses the cache file when one is present. * * @return void */ - public function test_should_use_cache_returns_false_when_vip_env_is_defined() { - define( 'VIP_GO_APP_ENVIRONMENT', true ); + public function test_get_classes_reads_the_cache_file_when_present() { + $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 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->assertFalse( $method->invoke( $module_init ) ); + $this->assertSame( [ 'TenupTmp\\Sentinel' ], array_values( $read ) ); + + $this->remove_temp_dir( $dir ); } /** - * Ensure it returns false in non-production or staging environments. + * With no cache present the runtime discovers live and writes nothing. * * @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_creates_no_cache_when_none_exists() { + $dir = $this->make_temp_class_dir(); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $classes = $module_init->get_classes( $dir ); + + $this->assertContains( 'TenupTmp\\Widget', $classes ); + $this->assertDirectoryDoesNotExist( $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME ); + + $this->remove_temp_dir( $dir ); + } + + /** + * 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_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 ); + } + + /** + * 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, + "getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $read = $module_init->get_classes( $dir ); + + // Fell back to a live scan and still found the real class on disk. + $this->assertContains( 'TenupTmp\\Widget', $read ); - $this->assertFalse( $method->invoke( $module_init ) ); + $this->remove_temp_dir( $dir ); } /** - * Ensure it returns false when TENUP_FRAMEWORK_DISABLE_CLASS_CACHE is defined. + * Defining TENUP_FRAMEWORK_DISABLE_CLASS_CACHE forces live discovery even when a + * cache file is present. * * @return void */ - public function test_should_use_cache_returns_false_when_disable_class_cache_is_defined() { + 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 ); + } + + /** + * 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_live_discovery_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->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'] ); + // 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 ); + } + + /** + * 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 ), "assertFalse( $method->invoke( $module_init ) ); + $live = $module_init->discover_live( $dir ); + + $this->assertContains( 'TenupTmp\\Widget', $live ); + $this->assertNotContains( 'TenupTmp\\Sentinel', $live ); + + $this->remove_temp_dir( $dir ); } /** - * Ensure it returns true under default conditions. + * A corrupt or truncated cache file falls back to a live scan instead of fataling. * * @return void */ - public function test_should_use_cache_returns_true_under_default_conditions() { - stubs( - [ - 'wp_get_environment_type' => 'production', - ] - ); + 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(); - $reflection = new \ReflectionClass( $module_init ); - $method = $reflection->getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $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. + * + * @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', "assertNotFalse( file_put_contents( $path, $contents ) ); + } + + /** + * Recursively remove a temporary directory. + * + * @param string $dir The directory to remove. + * + * @return void + */ + 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 + ); + + foreach ( $items as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } - $this->assertTrue( $method->invoke( $module_init ) ); + rmdir( $dir ); } } 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 @@ +