Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ name: PHP Checks
on:
push:
branches: ["trunk", "develop"]
# Deliberately unfiltered. A `branches` list here matches the PR's *base* branch, so
# restricting it to trunk/develop silently skipped every PR opened against a release branch
# such as feature/2.0.0 - the checks tab simply reported nothing for the commit. Pushes stay
# limited to the long-lived branches so PR commits do not run twice.
pull_request:
branches: ["trunk", "develop"]
workflow_dispatch:

permissions:
contents: read
Expand Down Expand Up @@ -55,9 +59,36 @@ jobs:
run: composer run static

test:
name: Unit Tests
name: Tests
runs-on: ubuntu-latest

# The integration suite runs against a real WordPress, which Mantle installs itself into a
# temporary directory. It still needs a database to install into.
services:
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: wp_framework_tests
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s
--health-timeout=5s
--health-retries=5

env:
# Mantle reads these via getenv(); tests/bootstrap.php only supplies defaults for whatever
# is unset, so these win. WP_CORE_DIR is kept per-package so a shared runner can never
# hand us another project's cached wp-tests-config.php, which would point the destructive
# installer at the wrong database.
WP_DB_NAME: wp_framework_tests
WP_DB_USER: root
WP_DB_PASSWORD: ""
WP_DB_HOST: 127.0.0.1
WP_CORE_DIR: ${{ github.workspace }}/.wordpress-tests

steps:
- uses: actions/checkout@v3

Expand All @@ -66,9 +97,10 @@ jobs:
with:
php-version: ${{ env.PHP_VERSION }}
tools: composer:v2
extensions: mysqli

- name: Install dependencies
run: composer install --no-progress --no-suggest

- name: Run PHPUnit
- name: Run Pest
run: composer run test
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@ vendor/
coverage/
.phpunit.result.cache

# WordPress installed for the integration suite (CI points WP_CORE_DIR here; locally it
# defaults to the system temp directory).
.wordpress-tests/

# Generated class-loader cache (a build artefact, not source)
class-loader-cache/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file, per [the Ke
- 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
- **Test suite migrated to [Pest 4](https://pestphp.com) + [Mantle Testkit](https://mantle.alley.com/testing/testkit/), replacing PHPUnit 9 and Brain Monkey.** Tests now run against a real WordPress installation and MySQL database instead of mocked WordPress functions, split into `tests/Unit/` (no WordPress), `tests/Integration/` (real WordPress) and `tests/Arch/` (Pest architecture rules). `AbstractPostType` and `AbstractTaxonomy` gain real coverage as a result: mocked `register_post_type()` calls could only prove the function was invoked, so those two classes previously had one test each. Development-only; nothing about the shipped package changes. Contributors need a MySQL database available — see the Testing section of `CLAUDE.md`.
- **Breaking: the minimum required PHP version is now 8.3** (raised from 8.2). Full native type coverage relies on [typed class constants](https://www.php.net/manual/en/language.oop5.constants.php), which are a PHP 8.3 feature and are a parse error on 8.2. This reverses the lowering to 8.2 made in 1.2.0 ([#8](https://github.com/10up/wp-framework/pull/8)). See the [Upgrade Guide](docs/Upgrade-Guide.md).
- **Breaking (static analysis only): `AbstractPostType::get_name()` and `AbstractTaxonomy::get_name()` now document a `lowercase-string&non-empty-string` return type**, stating what WordPress already requires of a post type or taxonomy key. The native `: string` signature is unchanged, so no subclass breaks and no runtime behaviour differs, but a project running PHPStan may need to narrow how it builds a dynamic key. See the [Upgrade Guide](docs/Upgrade-Guide.md).
- Full native type coverage across `src/`: every return type, parameter, property and class constant now declares a native type, and the generic types of every `array` are documented. Enforced by `tomasvotruba/type-coverage` through `composer run static`, which also no longer ignores `missingType.iterableValue`.
Expand Down
91 changes: 91 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

`10up/wp-framework` is a **Composer library** (not a runnable plugin/theme) that other 10up WordPress projects `composer require` and extend. It centralizes shared functionality — a module auto-loading system plus abstract base classes for post types, taxonomies, and asset metadata. Code here ships to many downstream projects, so treat the public API (class/method signatures, the `tenup_framework_module_init__{slug}` action, the `Module` trait contract) as stable; breaking changes require deliberate versioning.

- PHP **8.3+** (typed class constants are used throughout, so 8.2 is a parse error). PSR-4: `TenupFramework\` → `src/`. Test namespaces: `TenupFrameworkTests\` → `tests/`, `TenupFrameworkTestClasses\` → `fixtures/classes/`.
- Tests run **against a real WordPress install with a MySQL database**, via Pest 4 + Mantle Testkit. Brain Monkey is gone. See *Testing* below.

## Commands

```bash
composer test # Pest (no coverage driver required)
composer test-coverage # Pest with coverage; needs Xdebug or PCOV
composer lint # PHPCS against ./phpcs.xml (10up-Default standard)
composer lint-fix # PHPCBF auto-fix
composer static # PHPStan, level 10, 1G memory limit

# Run a subset:
./vendor/bin/pest tests/Unit # one suite
./vendor/bin/pest tests/Integration/PostTypes # one directory
./vendor/bin/pest --filter "registers the post type" # one test by name
```

CI (`.github/workflows/php.yml`) runs lint, static analysis, and tests on PHP 8.3 for pushes/PRs to `trunk` and `develop`. `develop` is the default/working branch. The test job provisions a MySQL service.

## Architecture

### Module system (the core abstraction)

A "Module" is any class implementing `TenupFramework\ModuleInterface` (and usually `use`ing the `TenupFramework\Module` trait for a default `load_order()` of 10). The interface contract is three methods:

- `load_order(): int` — lower runs first. **No relation to WP hook priority** — it only orders registration among modules. Taxonomies default to `9` so they exist before post types (default `10`) associate with them.
- `can_register(): bool` — gate registration by context (admin-only, frontend-only, feature flag).
- `register(): void` — attach hooks/filters here. Keep constructors lightweight.

`ModuleInitialization` (a singleton) drives discovery and registration. Downstream projects bootstrap with:

```php
ModuleInitialization::instance()->init_classes( YOUR_PLUGIN_INC );
```

The discovery/init flow in `src/ModuleInitialization.php` is the heart of the library:

1. Scans the given directory with **`spatie/php-structure-discoverer`**.
2. `withoutChains()` is called deliberately — discovery does **not** resolve inheritance chains. This was an intentional change (see git history "Disable chains"); don't re-enable it without understanding the perf/behavior tradeoff.
3. Each class is reflected and **skipped** unless it is instantiable AND implements `ModuleInterface`. (This is why abstract bases and plain classes like the `Standalone` fixture are never registered.)
4. Fires `do_action( 'tenup_framework_module_init__{slug}', $instance )` before each module registers — the extension/observability hook. `{slug}` = FQN with `\` → `-`, passed through `sanitize_title`.
5. Sorts by `load_order()`, then calls `register()` only when `can_register()` is true. Registered instances are retrievable via `ModuleInitialization::get_module( $fqn )`.

**Class cache (read-only at runtime, build-time generated):** Optional and opt-in. At runtime `get_classes()` reads `{dir}/class-loader-cache/class-loader-cache-v2.php` if it exists (via `ReadOnlyFileDiscoverCacheDriver`, whose `put()`/`forget()` are no-ops and whose constructor does not `mkdir`), and discovers live otherwise — it **never writes**. This is the fix for the stale-cache bug (issue #30): a server can't hold a cache it never wrote. The cache is produced at build time by `ModuleInitialization::generate_cache()`, exposed via `bin/tenup-framework-generate-class-cache <dir>` (composer `bin`) and the `composer generate-class-cache` alias; both run without WordPress. Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE = true` to ignore any shipped cache and always discover live. The `CACHE_FILENAME` constant (`...-v2.php`) is the invalidation lever — bumping it makes the runtime ignore caches from older versions. See `docs/Build-and-Deployment.md`.

**Loader debug page (`Debug\LoaderDebug`):** A hidden admin page (`admin.php?page=tenup-framework-loaders`, `manage_options`) that shows the state of every class-loader cache on the site and offers an on-demand live-vs-cache staleness diff. It's **admin-only** — `init_classes()` dispatches a loader record to `LoaderDebug::record()` only behind an `is_admin()` check placed *before* any reference to the class, so `LoaderDebug` never autoloads on the front end. Because a site can run 1..n framework copies (per-package installs, possibly php-scoped), aggregation happens over the fixed-string `tenup_framework_debug_loaders` filter rather than class references — every copy contributes its records, and the page registers once via the `$GLOBALS['tenup_framework_debug_page_registered']` guard. Read-only (never writes); disable via the `tenup_framework_enable_loader_debug` filter or `TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG`. See `docs/Debugging.md`.

### Abstract base classes

- `PostTypes\AbstractPostType` — implements `ModuleInterface` via the `Module` trait. Subclasses define `get_name()`, `get_singular_label()`, `get_plural_label()`, `get_menu_icon()`; `register()` calls `register_post_type()` + `register_taxonomies()` + `after_register()`. Override `get_options()`/`get_editor_supports()`/`get_supported_taxonomies()` to customize.
- `PostTypes\AbstractCorePostType` — for WP-builtin types (post/page). Labels/icon are no-ops; `register()` only wires taxonomies (the type already exists), and `can_register()` returns true.
- `Taxonomies\AbstractTaxonomy` — `load_order()` of `9`. Subclasses define name + labels; `get_post_types()` returns `[]` by default because **post types declare their own taxonomies**, not the reverse.
- `Assets\GetAssetInfo` (trait) — reads `*.asset.php` sidecar files (version + dependencies) emitted by the build. Call `setup_asset_vars( $dist_path, $fallback_version )` first or `get_asset_info()` throws `RuntimeException`. Looks under `dist/js/`, `dist/css/`, then `dist/blocks/`; falls back to the provided version + empty deps when no sidecar exists.

## Testing

**Pest 4 on PHPUnit 12, with Mantle Testkit booting a real WordPress.** `tests/bootstrap.php` calls `\Mantle\Testing\install()`, which downloads and installs WordPress into a temp directory on first run. No WordPress checkout or shell script is needed, but **a MySQL database is required**.

Three suites, bound to base classes in `tests/Pest.php`:

| Directory | Base class | Use for |
| --- | --- | --- |
| `tests/Unit/` | `Mantle\Testkit\Unit_Test_Case` | Code that calls no WordPress functions — filesystem, the class cache, subprocess runs |
| `tests/Integration/` | `Mantle\Testkit\Integration_Test_Case` | Anything touching WordPress. Each test runs in a DB transaction that rolls back |
| `tests/Arch/` | none | Pest architecture expectations (`arch()`) |

Gotchas worth knowing:

- **Database defaults live in `tests/bootstrap.php`** and are only applied when unset, so CI can override each. It defaults to a database named `wp_framework_tests` and a package-specific `WP_CORE_DIR`, deliberately *not* Mantle's `/tmp/wordpress` + `wordpress_unit_tests` defaults: the installer reuses any `wp-tests-config.php` it finds and then drops/recreates tables, so a shared temp dir will destroy another project's test database. The bootstrap refuses to run if the resolved config does not name the expected database.
- **Process state is not rolled back.** The DB transaction does not reset the `ModuleInitialization` singleton, `LoaderDebug`'s static records, `BlockRegistrar`'s static registries, registered post types/taxonomies, or the block registry. Use `tenup_reset_framework_state()` / `tenup_reset_block_registrar()` in `beforeEach`, and unregister types in `afterEach`.
- **`define()` needs a subprocess.** Testkit's `Unit_Test_Case` carries `#[RunTestsInSeparateProcesses]`, but that attribute does **not** survive Pest's generated test classes, so a constant defined in a test leaks into every later test. Tests that must define one shell out to `tests/scripts/`, e.g. `disable-class-cache.php`.
- **Admin context** comes from Mantle's `Admin_Screen` trait (`uses( Admin_Screen::class )`), which makes `is_admin()` true. `set_current_screen()` is unavailable, since Mantle skips the WP core test suite.
- **`_doing_it_wrong()` fails the test** unless declared with `$this->setExpectedIncorrectUsage( '...' )`.
- Shared helpers live in `tests/Helpers.php`, loaded via composer `autoload-dev.files`.
- `fixtures/classes/` holds sample modules used to exercise discovery — e.g. `Standalone` (no interface, must be skipped), `Loadable/InvalidChildClass` (un-loadable, excluded from PHPStan in `phpstan.neon`). When changing discovery logic, update these fixtures and the assertions in `tests/Integration/ModuleInitializationTest.php`.
- Coverage is measured against `./src/` only. `phpunit.xml.dist` deliberately declares no `<coverage><report>` block — doing so makes PHPUnit enable coverage on every run and hard-fail without Xdebug/PCOV.

## Conventions

- `declare( strict_types = 1 );` on every file (PHPCS enforces it, but not required on the first line).
- All linted code lives in `src/`, `tests/`, `fixtures/` (per `phpcs.xml`).
- The `tenup-plugin` text domain in base-class labels is a placeholder inherited by downstream projects.
19 changes: 12 additions & 7 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,30 @@
"psr-4": {
"TenupFrameworkTests\\": "tests/",
"TenupFrameworkTestClasses\\": "fixtures/classes/"
}
},
"files": [
"tests/Helpers.php"
]
},
"require": {
"php": ">=8.3",
"spatie/php-structure-discoverer": "^2.2"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"yoast/phpunit-polyfills": "^2.0",
"brain/monkey": "^2.6",
"pestphp/pest": "^4.7",
"mantle-framework/testkit": "^1.22",
"alleyinteractive/pest-plugin-wordpress": "^1.0",
"szepeviktor/phpstan-wordpress": "^2.0",
"php-stubs/wp-cli-stubs": "^2.11",
"phpstan/phpstan-deprecation-rules": "^2.0",
"10up/phpcs-composer": "^3.0",
"phpunit/php-code-coverage": "^9.2",
"slevomat/coding-standard": "^8.15",
"rector/rector": "^2.0",
"tomasvotruba/type-coverage": "^2.0"
},
"scripts": {
"test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit",
"test": "./vendor/bin/pest",
"test-coverage": "XDEBUG_MODE=coverage ./vendor/bin/pest --coverage",
"lint": "./vendor/bin/phpcs --standard=./phpcs.xml",
"lint-fix": "./vendor/bin/phpcbf --standard=./phpcs.xml",
"static": [
Expand All @@ -64,11 +67,13 @@
"generate-class-cache": "@php bin/tenup-framework-generate-class-cache"
},
"scripts-descriptions": {
"test-coverage": "Run the test suite with coverage. Requires Xdebug or PCOV; plain `composer test` does not.",
"generate-class-cache": "Generate the class-loader cache for one or more directories, e.g. `composer generate-class-cache -- inc/`."
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
"dealerdirect/phpcodesniffer-composer-installer": true,
"pestphp/pest-plugin": true
}
}
}
Loading
Loading